Tags

, , ,

First, I can’t believe I’ve missed a whole month of posting…. damn 28 days :(

Anyway, a recent post on the bakery http://bakery.cakephp.org/articles/view/dynamic-select-boxes-with-ajax-jquery prompted me to show a slightly more accurate approach on working with the given scenario.

(I don’t mean to piggy-back on someone’s work, but I feel it deserves a little “touch-up”).

If you don’t feel like reading the other post, the basic idea is to build a dynamic select list using CakePHP + jQuery.

For this example we’ll first select a car make and then build a select list of available models using jQuery.

In order to accomplish this, first of all, the appropriate association should be established between the models:
Car hasMany CarModel

Based on that we can have two controllers:

  1. cars_controller.php
  2. car_models_controller.php

Next, of course, we’ll need some actions and views…

(The simple add/edit/etc… you could easily “bake”, so I’ll just focus on jQuery and relevant views at this point).

In CarsController we’ll add a list_models() method…

Now let’s take a look at the relevant view (list_models.ctp).
Again, here we are only focusing on the two drop-downs.

<?php $this->Html->script('views/cars/list_models.js', array('inline' => FALSE)); ?>

<?php
  echo $this->Form->input('Car.name', array('empty' => 'Select One', 'options' => $names, 'id' => 'car-name'));
?>

<div id=&quot;car-models&quot; style=&quot;display: none;&quot;>
  <?php echo $this->Form->input('CarModel.name', array('type' => 'select', 'id' => 'car-model-name')); ?>
</div>

First, we’ll load up the jQuery script, which is relevant to the view. Despite my previous conventions, I find it much easier to replicate the structure of your JS file placement exactly as you’d do for the views. With one obvious difference, that all JS goes under /webroot/js/views/some_controller/same_as_view_name.js

You’ll notice that I wrapped the second select input into a div, which is hidden by default.
This is just one approach, but you certainly could leave it visible in your UI and populate it with an:
‘empty’ => ‘Select Car First’ … just a matter of choice here, I guess.

Next, comes our cars_controller.php:
I’m only showing the “interesting” actions.

  public function list_models() {
    $this->set('names', $this->Car->find('list'));
  }

  public function get_models_ajax() {
   Configure::write('debug', 0);
   if($this->RequestHandler->isAjax()) {
     $this->set('carModels', $this->Car->CarModel->find('list',
                            array('conditions' =>
                                        array('CarModel.car_id' => $this->params['url']['carId']),
                                  'recursive' => -1)));
   }
 }

Let’s review the code a little… The list_models() method doesn’t really do anything special, it simply sets the car names to be used for the first select list in the view.

The get_models_ajax() will be called via jQuery in order to build our second select input. We are turning off debug here, so that any “extra” output does not mess with the returned data…

Yet, a side note… I am referring to SQL debug, officially produced by cake, or timestamp…
Keep the debug “on” and the resulting output (in case of errors) will be seen in the firebug console… and if you don’t have firebug, then I don’t know how to debug AJAX stuff.

(Update: had to strike that one out, since in 1.3+ this has been dramatically improved and is no longer relevant)

Also, note the $this->params['url']['carId']. This value will come from our first select list, which lists the car names with the corresponding ID’s from the database. That is because we’ve previously established a proper model association, therefore finding all the models for a given car (car_id) is no trouble at all now. (Oh, and please don’t forget to include RequestHandler in your list of required components, see the manual for more info).

Next, we still need a view for our get_models_ajax() action. The purpose of that view would be to return all the $carModels, which as you see we are setting in the controller.

Here it is, get_models_ajax.ctp:

<?php
  if(isset($carModels)) {
    echo $this->Js->object($carModels);
  }
?>

(Too much for such a simple task (view and all)?… well, respect MVC and it will not come back to bite you in the ass later.)

The view is not terribly interesting, but one thing to note is that $this->Js->object($carModels); will convert the array of data, which is returned by the find(‘list’) in the controller, into a JSON object.

Mental note… You certainly don’t have to work with JSON and any type of data can be returned back to the client, but for simple AJAX communication between the client and the server I find JSON to be most convenient format.

Alright, last, but not least let’s see the jQuery snippet that makes all the magic happen.

list_models.js

$(document).ready(function(){
  $('#car-name').live('change', function() {
    if($(this).val().length != 0) {
      $.getJSON('/cars/get_models_ajax',
                  {carId: $(this).val()},
                  function(carModels) {
                    if(carModels !== null) {
                      populateCarModelList(carModels);
                    }
        });
      }
    });
});

function populateCarModelList(carModels) {
  var options = '';

  $.each(carModels, function(index, carModel) {
    options += '<option value=&quot;' + index + '&quot;>' + carModel + '</option>';
  });
  $('#car-model-name').html(options);
  $('#car-models').show();

}

Unfortunately it would take a few more days to explain every line of code in detail, and there are quite a few jQuery tutorials our there that will do a better job of explaining it, so I hope a little googl’ing will answer any outstanding questions.
… but I do want to point out a few things.

First, we are using jQuery’s handy $.getJSON, which does a GET request to a given URL with some data and returns the results back to our client.
Remember this piece: $this->params['url']['carId']? Well, that’s exactly where the carId value is coming from… i.e. the select input value, as specified between the curly brackets. Of course, there is no point in sending empty values to the server, therefore we wrap the entire chunk of AJAX code into if($(this).val().length != 0)… this will prevent jQuery making the extra call to the server if the “empty” option is selected.

Next, we already know that the data returned from the server will be a JSON object. So, before attempting to do anything with the returned data we check for some valid/good/existing data with:
if(carModels !== null)
In this example carModels is our JSON object, which is returned by CakePHP back to jQuery.

When all said and done, we use yet another awesome tool $.each to traverse the JSON object (i.e. carModels) and build our options list.
Finally, we add the freshly built HTML options list to the contents of our second select input and display it to the user.

We are pretty much done now, but just for some more detailed Q&A you can read further, if interested.

Q. Why use .live(‘change’… instead of just .change?

A. .live is a great tool to use if you are manipulating the DOM in some way and need to work with freshly inserted element. Granted in this example it is not necessary, but I wanted to show it off anyway. Just keep in mind that this approach is available and could be a life-saver at times.

Q. Why create populateCarModelList() function?
A. I like to keep things separated as much as possible, and who knows this function might come in handy for other reasons in a more complex application.

Q. Shouldn’t the get_models_ajax() action go into the CarModels Controller ?
A. Truth be told… it should. For the sake of simplicity I kept it in the same controller as the other method, but it would be “more proper” to place it in the CarModels Controller.

Q. Why did I assign DOM ID’s to the drop down elements, doesn’t cake do that automagically?
A. It does indeed, but cake’s DOM ID’s look like SomeModelThenField. In the world of CSS it is almost an unwritten rule that ID’s most often represented as some-model-then-field… so that’s my basic goal there. Thanks to a tip from Mark Story I promise to show in an upcoming post how to override the default CamelCasedID’s with dash-separated-ones.