Filtering component for your tables

March 1st, 2008 by Nik Chankov Leave a reply »

Well, probably you wont believe me, but I wrote whole 3 pages while I realized that it’s so long and probably this is not the right way /and definitely it’s not easy/, so I scratched everything and start over.

It’s been quite long time since I post an article for CakePHP so here it is – shiny brand new post :)

This post is more a method how to make filtering, rather a rocket science component, because it’s possible to be used even without extra classes /helpers and components/, but I will post a component which will tune the functionality.

So what is all about? Have you ever miss a filtering functionality in your index actions? Well I really miss it :)

What is the best technique to make filter?
In this example there is a controller Countries which handle the countries in the application. table contain 3 columns, ID, NAME and CODE. Code column is numeric in this example and it’s not following any real naming or country convention.

1. You need a form in your index.ctp, so here is the code /basically it’s a form taken from add.ctp file ;) /

<?php
echo $form->create('Country', array('action'=>'/index'));
?><fieldset><legend><?php __('Filter');?></legend><?
echo $form->input('name');
echo $form->input('code');
?></fieldset><?
echo $form->end('Search');
?>

You can use your imagination how many fields and the position of them in the form, the tricky part is that you need to define the action parameter in the form tag, otherwise this will point to add action which is obviously not very smart ;)

Ok, That’s it … probably you don’t believe me, but it’s true, that’s it. The only thing which you need to do in the controller is this:

$this->set('countries', $this->paginate(null, $this->data));

OR

$this->set('countries', $this->{Model_name}->findAll($this->data), ...and the rest parameters which you need...);

and you are done, voila ;)

Ok, this was the good part, let’s continue with the bad part…
there are 2 things which are missing in the ideal world
1. if you have paginator and the filter return more results than you can see in a page then by clicking on the next>> link the filter will be lost and user will be confused from the results. A consequence of this is that the form will loose the data and user need to type it again…
2. if user search by part of a string let’s say try to search for all countries starting with “Bu” the result will be empty table instead of 3 countries starting with “Bu”, because SQL generated will search like:

SELECT ... FROM ... WHERE FIELD = 'searched string'

Ok, explaining the simple solution and the bad consequences here is the ultimate helper for this – the filter component:

<?php

/**

 * Filter component
 * Benefits:
 * 1. Keep the filter criteria in the Session
 * 2. Give ability to customize the search wrapper of the field types

 **

 * @author  Nik Chankov

 * @website http://nik.chankov.net

 * @version 1.0.0

 *

 */



class FilterComponent extends Object {
    /**
     * fields which will replace the regular syntax in where i.e. field = 'value'
     */

    var $fieldFormatting    = array(
                    "string"=>"LIKE %s%%",
                    "date"=>"'%s'"
                    );
    /**
     * Function which will change controller->data array
     *
     * @param object $controller the class of the controller which call this component
     * @access public
     */

function process($controller){
        $this->_prepareFilter($controller);
        $ret = array();
        if(isset($controller->data)){
            //Loop for models
            foreach($controller->data as $key=>$value){
                if(isset($controller->{$key})){
                    $columns = $controller->{$key}->getColumnTypes();
                    foreach($value as $k=>$v){
                        if($v != ''){
                            //Check if there are some fieldFormatting set
                            if(isset($this->fieldFormatting[$columns[$k]])){
                                $ret[$key.'.'.$k .' LIKE '] = sprintf($this->fieldFormatting[$columns[$k]], $v);
                            } else {
                                $ret[$key.'.'.$k .' LIKE '] = $v;
                            }
                        }
                    }
                    //unsetting the empty forms
                    if(count($value) == 0){
                        unset($controller->data[$key]);
                    }
                }
            }
        }
        return $ret;
    }
   
    /**
     * function which will take care of the storing the filter data and loading after this from the Session
     */

    function _prepareFilter(&$controller){
        if(isset($controller->data)){
            foreach($controller->data as $model=>$fields){
                foreach($fields as $key=>$field){
                    if($field == ''){
                        unset($controller->data[$model][$key]);
                    }
                }
            }
            $controller->Session->write($controller->name.'.'.$controller->params['action'], $controller->data);
        }
        $filter = $controller->Session->read($controller->name.'.'.$controller->params['action']);
        $controller->data = $filter;
    }

}

?>

How to use it:
1. Add this code into filter.php file under your /app/controllers/components
2. Add in your controllers which require filtering:

<?php
class CountriesController extends AppController {

    var $name = 'Countries';
    ...
    var $components = array(...,'Filter');

    function index() {
        $this->Country->recursive = 0;
        $filter = $this->Filter->process($this);
        $this->set('countries', $this->paginate(null, $filter));
    }
}
?>

That’s it now your filter forms will keep the values until you delete them or set a specific JavaScript function which would be “clear all fields and submit” – I leave it to your imagination ;)

Note that in the component there is an array which store all wrappers for different type of data, i.e. strings will be transformed with …field LIKE ‘filter_string%’ etc. you can modify this in the component directly, or by setting the custom array before using the prepare function.

Well, that’s it. nothing special, but doing miracles ;)

I would appreciate if you share your approach and ways to filter data.

Advertisement

23 comments

  1. Drazen says:

    well, thank you again.

    little remark, for thouse which allready startet with filter story, and alread have a form.

    // needed an hour to find :-(

    in view
    > $form->create(‘Country’, array(‘action’=>’/index’));

    where ‘Country’ is a correct Model name you using.

    so FilterComponent can find needed Fieldstype.

  2. Nik Chankov says:

    I didn’t understand you completely, but I mention in the beginning – get the form from your Add action ;)

  3. wirtsi says:

    Sweeeet

    is there any way to store the sort and direction variable from $paginate as well? This seems to get lost after ie deleting or editing an entry.

    Thanks a lot mate

    wirtsi

  4. Nik Chankov says:

    Wirtsi, great idea and I think it’s easy to be implemented. I am going to work on it these days.

  5. Tarique Sani says:

    Nice concept – will be more automagical if you create a beforeFilter instead of process and set filter directly from your component into the controller. All that the user will need to do then would be $this->paginate(null, $this->filter)

  6. Nik Chankov says:

    Tarique Sani – Good idea but, it would be more complex, because in the beforeFilter function you need to determine the index submission, from insert or edit submit. Otherwise it’s true, if you set it in beforeFilter function in AppController it will be very elegant :)

  7. Tarique Sani says:

    Determining index submission is easy – $this->action has the name of the current action

  8. haledov says:

    with multiply select box did’t work =(

  9. Stefano Manfredini says:

    Hello, I’ve changed the returned array this way:

    if(isset($this->fieldFormatting[$columns[$k]])){
    $ret[$key.'.'.$k] = sprintf($this->fieldFormatting[$columns[$k]], $v);
    } else {
    $ret[$key.'.'.$k] = $v;
    }
    And

    var $fieldFormatting = array(
    “string”=>”LIKE %%%s%%”,
    “date”=>”‘%s’”
    );

    because I got an sql error in some case (colname ambiguos). Now the query is something like
    .. WHERE ‘ModelName.colname’ LIKE ‘%enteredstring%’
    instead of
    .. WHERE ‘colname’ LIKE ‘enteredstring%’
    I don’t know if it is always correct this way, but it works for me :) (single field filter, using latest core version -7008- from svn)

  10. Johny_Num_5 says:

    anyone getting:
    Warning: Call-time pass-by-reference has been deprecated – argument passed by value; If you would like to pass it by reference, modify the declaration of [runtime function name](). If you would like to enable call-time pass-by-reference, you can set allow_call_time_pass_reference to true in your INI file. However, future versions may not support this any longer. in /home/htdocs/app/controllers/components/filter.php on line 38

    in function process(&$controller) {
    line38: $this->_prepareFilter(&$controller);

    then it doesn’t work…

  11. Chris says:

    This is great. I’ve changed the code slightly, now it also supports associations with a different name:

    <pre>
        function process(&$controller){
            $this->_prepareFilter(&$controller);
            $controllerModelName = Inflector::singularize($controller->name);
            $controllerModel = $controller->{$controllerModelName};
            $associated = $controllerModel->getAssociated();
            $ret = array();
            if(isset($controller->data)){
                //Loop for models
                foreach($controller->data as $key=>$value){
                    $modelName = $key;
                    if(!isset($controllerModel->{$key}) && isset($associated[$key])){
                        $assoc = $associated[$key];
                        $modelName = $controllerModel->{$assoc}[$key]['className'];
                    }
                    if(isset($controllerModel->{$modelName})){
                        $columns = $controllerModel->{$modelName}->getColumnTypes();
                        foreach($value as $k=>$v){
                            if($v != ''){
                                //Check if there are some fieldFormatting set
                                if(isset($this->fieldFormatting[$columns[$k]])){
                                    $ret[$key.'.'.$k] = sprintf($this->fieldFormatting[$columns[$k]], $v);
                                } else {
                                    $ret[$key.'.'.$k] = $v;
                                }
                            }
                        }
                        //unsetting the empty forms
                        if(count($value) == 0){
                            unset($controller->data[$key]);
                        }
                    }
                }
            }
            return $ret;
        }
    </pre>
  12. Really great component! I have made some changes so I don’t have to put filter data into session. You can see my code at http://blog.uplevel.pl/index.php/2008/06/cakephp-12-filter-component/ . Thanks a lot!

  13. Nik Chankov says:

    Thanks all for your comments
    @Maciej – nice approach, but It’s a matter of taste. :) I personally prefer to have filter stored in a Session, butcause it’s “stored” until you leave the browser, or you logged out.
    I am glad that this helps :)

  14. hit kazan says:

    thanks youu.

  15. Stefano Manfredini says:

    Just a tiny change to let the component work with cake 1.2 RC – (move the operator to the “key” side)

        function process($controller){
            $this-&gt;_prepareFilter($controller);
            $ret = array();
            if(isset($controller-&gt;data)){
                //Loop for models
                foreach($controller-&gt;data as $key=&gt;$value){
                    if(isset($controller-&gt;{$key})){
                        $columns = $controller-&gt;{$key}-&gt;getColumnTypes();
                        foreach($value as $k=&gt;$v){
                            if($v != ''){
                                //Check if there are some fieldFormatting set
                                if(isset($this-&gt;fieldFormatting[$columns[$k]])){
                                    $ret[$key.'.'.$k .' LIKE '] = sprintf($this-&gt;fieldFormatting[$columns[$k]], $v);
                                } else {
                                    $ret[$key.'.'.$k .' LIKE '] = $v;
                                }
                            }
                        }
                        //unsetting the empty forms
                        if(count($value) == 0){
                            unset($controller-&gt;data[$key]);
                        }
                    }
                }
            }
            return $ret;
        }
  16. Nik Chankov says:

    @Stefano – thanks! I haven’t used this component with 1.2RC so far. I will change in the core right now. :)

  17. Brenton says:

    Not returning correct result. Due to `$fieldFormatting` has “LIKE” defined in it, then again in `process()`:

    $ret[$key.'.'.$k .' LIKE '] = sprintf($this->fieldFormatting[$columns[$k]], $v);

    So the resulting query is (in my example):
    WHERE `Photographer`.`first_name` LIKE ‘LIKE Bre%’

    Yeah, that’s wrong …

    Small, easy fix.

  18. Brenton says:

    @Chris,

    Except it doesn’t retain the current controller model’s fields … if the current controller is dealing with ModelA that has an association to ModelB … your code seems to setup ModelB’s stuff, but forgets about ModelA’s.

  19. Nik, You said this:

    ‘That’s it now your filter forms will keep the values until you delete them or set a specific JavaScript function which would be “clear all fields and submit” – I leave it to your imagination’

    BUT, my imagination is NOT very GOOD :D

    Can you (or someone) post some code that will reset the session variables (or another way) to remove the filter that can be assigned to a separate button.

  20. Nik Chankov says:

    Johnny,
    Clearing the filter is simply submit an empty form. If the form has values in the text fields you need to go and manually delete their values. Of course if you have a javascript function attached to onclick event to a button could reset them very easy.

    Here is a simple example with 2 text fields could be:

    function clean(form){
           document.getElementById('field1').value = '';
           document.getElementById('field2').value = '';
           form.submit();
    }

    after this you attach this onclick event like:

    <input type="submit" name="clear" value="Clear form" onclick="clean(this.form)" />

    Of course, it could be more elegant and common including reset of select etc, but this one is a quick and dirty example.

  21. Dooltaz says:

    I built a reporting system… It’s quite different. It does not use sessions, however I do need to rewrite and optimize it for the changes in RC3..

    https://sites.google.com/a/esninteractive.com/cakephp-documentation/extensions/cake-reporting-filtering-extension

    -d

Leave a Reply