Tutorial: Web 2.0 dashboard for lazy…

therefore good programers ;)

In my first tutorial I am going to show how to use some of the best things that CakePHP and jQuery has to offer. Now CakePHP supports other javascript framework – prototype. But from version 1.3 cake’s core is going to cooperate with jQuery. I just can’t wait. This tutorial is based on version 1.3.0-beta because I wanted to give a first look at it while working on this text. I’m not going to use any jQuery Helpers though, so it should fit wit 1.2 version as well (after some polishing maybe). (I will gladly help if one is needed).
This is my first tutorial, so I appreciate some clues, comments and little bit of understanding.

There’s a demo page, when You can view results we are aiming to and there is source to download as well.

After this point I assume that You know CakePHP and javascript basics. This means that You have done famous blog tutorial , You know how to use bake console tool, and at least You know how to disappear and change colour of some DOM element with pure javascript only.

Let’s prepare our example application first. You need to bake an application with two models: Thing and Item (both have id field and text field `name`)

Next thing to do is to create dashboard view (method all() in ItemsController).

// /app/controllers/items_controller.php
class ItemsController extends AppController {
 //...
 function all() {}
}
// /app/views/items/all.ctp
<?php echo $this->requestAction(
            array(
               "controller"=> "items",
               "action"=> "index"),
            array("return")
      ); ?>
<?php echo $this->requestAction(
            array(
               "controller"=> "things",
               "action"=> "index"),
            array("return")
      ); ?>

No need to worry about requestAction performance. Usually it’s better to put requestAction results in cached views, but in our case we’ll dump it soon enough.

dashboard

first look

Now change it into tabs by modifying all.ctp view:

<script type="text/javascript">
   $(document).ready(function() {
      $("#tabs").tabs();
   });
</script>


<div id="tabs">
   <ul>
      <li><a href="#tabs-1">Items</a></li>
      <li><a href="#tabs-2">Things</a></li>
   </ul>
   <div id="tabs-1">
      <?php echo $this->requestAction(
                  array(
                     "controller"=> "items",
                     "action"=> "index"),
                  array("return")
            ); ?>
   </div>
   <div id="tabs-2">
      <?php echo $this->requestAction(
                  array(
                     "controller"=> "things",
                     "action"=> "index"),
                  array("return")
            ); ?>
   </div>
</div>

If You want contents to be surrounded by tabs boarders – You need to add some html code at the end of Your index.ctp views:

<div style="float: none; clear: both;">&nbsp;</div>
Tabbed dashboard

this is how tabs look like now

Add some elements, and change pagination options to see its behaviour…

insert into items(`name`) values ('Item 4'), ('Item 5'), ('Item 6'), ('Item 7')
class ItemsController extends AppController {
//...
	var $paginate = array(
	  'limit'=> 5
	);
//...

The pagination links are still pointing to items/index. It would be better if clicking those links would reload tabs contents only.

You could try some server side solution. Deal with all() method (action) parameters, change targets of pagination links in views. You can do lot more just to make Your code look ugly ;). But javascript and particularly jQuery power comes in handy. But before that – lets change out ordinary boring tabs into fancy Ajax ones. Edit all.ctp view:

// views/items/all.ctp
<script type="text/javascript">
   $(document).ready(function() {
      $("#tabs").tabs();
   });
</script>


<div id="tabs">
   <ul>
      <li>
         <?php echo $html->link(
                  "Items",
                  array(
                     "controller"=> "items",
                     "action"=> "index"
                  )
               );
         ?>
      </li>
      <li>
         <?php echo $html->link(
                  "Things",
                  array(
                     "controller"=> "things",
                     "action"=> "index"
                  )
               );
         ?>
      </li>
   </ul>
</div>

…easy, isn’t it? And we got rid of requestAction performance issue. Yet there is a minor problem – we have our layout repeated in tabs. It’s easy to fix it witch RequestHandler component in AppController:

// /app/app_controller.php
var $components = array("RequestHandler");
function beforeFilter(){
    if($this->RequestHandler->isAjax()){
      $this->layout = "ajax";
    }
}

Lets go back to pagination. Ones need to load pagination results inside our tab. Lets use jQuery power.
Add a simple code:

// views/items/all.ctp
   $('#tabs a[href*=/sort:],#tabs a[href*=/page:]').live('click', function(){
       $('#tabs>div:visible').load($(this).attr('href'));
          return false;
   });

In few words: we catch a elements with words sort: and page: in their href attributes, and we force them to load target site at which they’re pointing to, to load in a tab by ajax instead of reloading whole page.

Now we’ll deal with delete action. If You’re not familiar witch jQuery selectors You’ll probably be tempted to add “onclick” event to <a> element with function that will handle calling it by ajax. It’s not bad approach, but when You get to know jQuery selectors, You’ll know that this solution isn’t sexy enough.

Lets do this right just with few lines of code. In index.ctp views (for items and things) find place where delete links are generated. Give them “tab-update” class for easy finding:

<td class="actions">
	<?php echo $this->Html->link(__('View', true), array('action' => 'view', $item['Item']['id'])); ?>
	<?php echo $this->Html->link(__('Edit', true), array('action' => 'edit', $item['Item']['id'])); ?>
	<?php echo $this->Html->link(
	    __('Delete', true),
	    array('action' => 'delete', $item['Item']['id']),
	    array("class"=> "tab-update"),
	    sprintf(__('Are you sure you want to delete # %s?', true),
		    $item['Item']['id'])
	); ?>
</td>

My solution is not going to work along with cakes confirmationMessage (4th parameter in Html::link() method), so I suggest to delete it. I don’t have any good enough idea how to make it work to put it in this tutorial (I think that in 1.3 version it could be fixed, because of cakePHP-1.3 and jQuery marriage, so why bother?)

Lets use the same trick which in pagination solution – it’s needed to add another selector of class “tab-update” in function we already have:

   jQuery('#tabs a[href*=/sort:],#tabs a[href*=/page:],.tab-update').live('click', function(){
       jQuery('#tabs>div:visible').load(jQuery(this).attr('href'));
          return false;
   });

redirect error

redirect error


But now we have problem with redirects. It’s because Dispatcher doesn’t know which controller is it, in order to fix it we just need to change one line in deletee action:

// /app/controllers/items_controller.php
$this->redirect(array('action'=>'index'));

into

// /app/controllers/items_controller.php
$this->redirect(array("controller"=> "items", 'action' => 'index'));

Instead of typing controller name in hard-way one could use $this->name instead of “items” string to get better looking code.

Careful beholder (Your client) will notice that he doesn’t see any feedback info any more. We’ll deal with it later.

Now we’ll fix the way “Add Item” button behave. Now it kick us to new location, and after saving – redirects to /items/index (uncool from the user-experience point of view). If we had only one tab it wouldn’t be much of a problem – changing redirect destination would do the work. But it’s getting complex when we click “Add …” button in tab other than first – how to pass info which tab was active and activate it again? It’s better to use Ajax again. We’ll introduce jQuery plugin “Boxy”

It’s the best time to get rid of repetitions in our baked views. It’s not fun to edit two almost identical views in the same time. You can see how I’m doing this please read my other post. Further I assume that this kind of refactoring has been done.

Lets make all links that we wish to display in dialog box.Add “dispay-in-box” class to <a> for edit and add actions (in index.ctp in both items and things), and add some javascript in /items/all.ctp:

// /app/views/items/all.ctp
$("a.display-in-box").live(
        "click",
        function(){

           Boxy.load(
                 this.href,
                 {
                    title: (this.title)? this.title: this.href,
                    modal: true,
                 }
           );
           return false;
        }
);

It will attach a certain behaviour to those links. After clicking one of those a dialog box will open and load contents (via Ajax of course) from url specified in links href attribute, set title attribute (or if missing – href attribute) as box title and hijacks the reloading behaviour after clicking a link.

Adding an element in dialog box

Adding an element in dialog box

It looks nice, work nice but after successful add it push us to an unexpected place.

Fist problem is that submitting form results in page reload (and as an effect – redirecting to where we don’t want to be redirected). Lets deal with form so it would send itself via Ajax.

jQuery("body").ajaxComplete(
  function( event, xhr, options) {
      if(Boxy.isModalVisible()){
	$(".boxy-wrapper form").live(
	    "submit",
	    function() {
	      var form = this;
	      jQuery.ajax({
		  type: "post",
		  url: this.action,
		  data: jQuery(this).serialize(),
		  success: function(data, status, xhr) {
		    if(data != ":ajaxRedirect:") {
			  Boxy.get(form).setContent(data);
		    }else{
			  Boxy.get(form).hideAndUnload();
		    }
		  },
	      });
	      return false;
	    }
	);
      }
  }
);

I think it requires some explanation (I don’t just want You to make it right, but to understand how it is working also).
In line 1 we attach our function (it starts in line 2) in ajaxComplete event (callback in the matter of fact), and that means that after every completion of ajax request our function will be fired up.
In line 3 we check if there’s any dialog box opened. If so, we catch (in line 4) all (in our case always one) forms that are IN the dialog box and hijacking its submit event (line 4 and 5). Now in submit attempt of our form our function (line 6) will be called first.
In this function we’re preparing ajax request with parameters:
request method (8), endpoint or target of our request (9), data to send (10) (we leave dirty work of serializing data to jQuery), and what we want to happen if our request is successful (11)

Before I continue explaining any further we need a step back to theory. Ajax request is successful always, when the request was send, and there was reply from server other than error (ie. 404 code). But cakePHP uses redirection, so regardless of the result is successful writer or validation error – from Ajax/Javascript view the result is successful. But we want to close this box if write was successful, or view some validation tips otherwise.

I use a little hack to send appropriate message to client side. The message is “Save was ok, close the window”. This is my approach: overwrite Controller::redirect(…) method in AppController…

// /app/app_controller.php
function redirect($url, $status = null, $exit = true) {
    if(!$this->RequestHandler->isAjax()){
      parent::redirect($url, $status, $exit);
    }else{
      exit(":ajaxRedirect:");
    }
}

it works in normal way in all requests other than ajax. In the other case – it will return our fixed code-word.

Now it’s simpler to understand what’s happening in line 12 to 16. If returned data is not our code-word – we load response into our dialog box (13) (se the results by adding validation rules to Your model, and sending form that is not fulfilling those rules), and when code-word is ok – it closes the window (15)
Line 19 just don’t allow to send form by Your browser again.

Validation in a dialog box

Validation in a dialog box

Unfortunately, because of our hack – deleting elements loose some of its usability ;)

Delete broken

Broken deletion


we deal this later. Now we need to make add work ok – now we don’t see any change after successful insert.

Now find this part, where there are dialog boxes were attached to “display-in-box” class elements. Catch element which class is .tab-container (this is place where tabs are in) – the one where link was clicked. The same which opened a dialog box. Next we’ll make to refresh active tab after closing a dialog box. The code:

$("a.display-in-box").live(
        "click",
        function(){
           var tabContainer = $(this).parents(".ui-tabs");
           Boxy.load(
                 this.href,
                 {
                    title: (this.title)? this.title: this.href,
                    modal: true,
                    fixed: false,
                    afterHide: function() {
                        tabContainer.tabs(
                           "load",
                           tabContainer.tabs("option", "selected")
                        );
                     }
                 }
           );
           return false;
        }
);

Lets quick fix deletion. Remember the code where me made “delete” work by ajax (those with “tab-update” class)? Use the same mechanism as in “load validation in boxy” scenario.
We need to use low-level-interface becouse it give us full controll over ajax manipulation. First step is to make pagination to work, and delete “click” not to clear tab contents”

   jQuery('#tabs a[href*=/sort:],#tabs a[href*=/page:],.tab-update').live('click', function(){
	    jQuery.ajax({
	    	url: 	jQuery(this).attr('href'),
	    	success: function(response, status, xhr){
	    	   if(response != ":ajaxRedirect"){
	    		   jQuery('#tabs>div:visible').html(response);
	    	   }
	      }
	    });
       return false;
   });

And now simply after getting “:ajaxRedirect:” – refresh tab contens instead of loading response, add else statement:

jQuery('#tabs a[href*=/sort:],#tabs a[href*=/page:],.tab-update').live('click', function(){
  jQuery.ajax({
    url: 	jQuery(this).attr('href'),
    success: function(response, status, xhr){
      if(response != ":ajaxRedirect:"){
	jQuery('#tabs>div:visible').html(response);
      }else {
	$("#tabs").tabs(
	  "load",
	  $("#tabs").tabs("option", "selected")
	);
      }
    }
  });
  return false;
});

We’re almost done. Lets enhance some feedback and user experience. After certain actions we dont see any flashMessage. It’s even worse. Try to delete something and navigate to some other url – You’ll get late flashMessage “Thing deleted”. Late feedback is worse than no feedback because its confusing.
The easy way to make it better is to copy ajax template from /cake/libs/views/layouts/ajax.ctp to /app/views/layouts/ajax.ctp and upgrade it:

<?php echo $this->Session->flash(); ?>
<?php echo $content_for_layout; ?>

The cherry on the cake is to make it dissapear after some time. I encurage You to experiment, share Your ideas on Your blog (and tracking bak to me) and to leave a comment.

dzięki za czytanie!

thanks for reading!


If You think that I made a decend work and tought You something – please support my blog by subscribing to rss, linking to it and commenting. Even about language mistakes (I’m trying to improve it continuously)

Share Button

2 thoughts on “Tutorial: Web 2.0 dashboard for lazy…

Leave a Reply

Your email address will not be published. Required fields are marked *