Extending Smoc - Check Box Groups, Search and Pagination

In the prior version we just had the basics, automatic listings of all entries in a table, update/inster forms with text fields, text areas and drop downs through a single Doctrine relation (hasOne). Now we add check box groups which will manage multiple relations (hasMany) through an intermediary relation table, search and pagination of the search results.


The challenge here is taking stuff we’ve used in the check box group tutorial and the pagination tutorial, and implementing it so that it is automated and general, ie it should work out of the box with any database, using very little configuration.

We’re still using the emailer as an example application. Download the source for this extended version it contains both the Smoc source and the sample emailer application.

Most changes have been made to AdminCtrl and Ctrl. Let’s start with the problem of fetching check boxes for multiple relations in the insert form, to that effect the assignRelations method has been greatly expanded:

function assignRelations($hide = true, $hide_with = array()){
    $hide_with = empty($hide_with) ? $this->hidden : $hide_with;
    foreach($this->t->getRelations() as $key => $value){
        if($value instanceof Doctrine_Relation_Association){
            $relations[ $key ]['foreign'] = true;
            if($hide){
                if(!in_array($key, $hide_with))
                    $this->columns += array($key => array());
            }else
                $this->columns += array($key => array());
        }else if($value instanceof Doctrine_Relation_LocalKey)
            $key = $value->getLocal();
        
        $relations[ $key ]['label'] = $this->getCtrl($value->getClass())->label;
        $relations[ $key ]['Mdl']   = $value->getClass();
    }
    $this->assign('relations', $relations);
    $columns = $hide ? array_diff_key($this->t->getColumns(), $hide_with) : $this->t->getColumns();
    $this->assign('columns', $columns + $this->columns);
}

The arguments $hide and $hide_with will through configuration control what is displayed or not, you might not for instance want to show all people you can tag when you insert a new tag, however you will probably want to assign one or more tags to a person when you create a new one. That is the basic functionality of those two variables, $hide_with can overrule $this->hidden, more on that later.

So we start looping through the relations for the model in question, if we have an instance of Doctrine_Relation_Association we know it’s a multi to multi (hasMany) and act accordingly, if not then it’s just a single (hasOne). If it’s multi we set a flag foreign to true. If we are to hide at all we test with the array of fields we want to hide, otherwise we don’t test at all of course.

Note the different ways of fetching the keys in question if we have a single relation or a multi relation. We need the label that has been assigned (now in each controller, earlier it was in the model but PHP Doctrine works differently in the new version 1.0 so we had to change).

Lastly we assign the columns, they will be used for the field names in the table header and need to reflect which fields we have allowed or not.

The ui.tpl Smarty template that goes with this looks like this:

<form {$form_head} > 
<table> 
    {foreach from=$columns key=c item=info} 
    <tr> 
        <td>{$c}: </td> 
        <td> 
            {if !empty($relations.$c)}
                {if $relations.$c.foreign eq true}
                    {obj->checkBoxes lbl=$relations.$c.label mdl=$relations.$c.Mdl idname=$c}
                {else}
                    {obj->selBox lbl=$relations.$c.label mdl=$relations.$c.Mdl idname=$c}
                {/if} 
            {elseif $info.type eq "boolean"} 
                {obj->radioBox idname=$c}
            {elseif $info.length > 500} 
                {obj->textArea idname=$c}
        {elseif $c eq "password"}
        {obj->textBox idname=$c pwd=true}
            {else} 
                {obj->textBox idname=$c} 
            {/if} 
        </td> 
    </tr> 
    {/foreach} 
</table> 
<input type="submit" value="Submit"> 
</form>

Notice the first section, that is how we utilize the relations we have assigned. Notice how we also use the Doctrine boolean type to create a radiogroup. A convention here is that if a field is called password the input field will be a password field. The logic that processes this field has not been changed since we’ve already got what it takes to handle check box groups.

The above logic is used in the generic search form (it’s search_form.tpl, very similar to ui.tpl):

<form method="post" action="{$base_dir}paginate/start/1">
    <table> 
    {foreach from=$columns key=c item=info}
        {if $c neq "password"}
        <tr>
            <td>{$c}: </td> 
            <td> 
                {if !empty($relations.$c)} 
                    {if $relations.$c.foreign eq true}
                        {obj->checkBoxes lbl=$relations.$c.label mdl=$relations.$c.Mdl idname=$c}
                    {else}
                        {obj->selBox lbl=$relations.$c.label mdl=$relations.$c.Mdl idname=$c}
                    {/if}
                {elseif $info.type eq "boolean"} 
                    {obj->radioBox idname=$c}
                {else} 
                    {obj->textBox idname=$c} 
                {/if} 
            </td> 
        </tr>
        {/if}
    {/foreach}
    </table> 
    <input type="submit" value="Search">    
</form>

As you can see we call paginate which is now extended by AdminCtrl:

function paginate(){
    $this->assignUpdateDelete(false);
    return parent::paginate('arrWithRelations', array(true));
}

function assignUpdateDelete($hide = true, $hide_with = array()){
    $this->assign('update', $this->update_list); 
    $this->assign('delete', $this->delete_list);
    $this->assignRelations($hide, $hide_with);
}

So we will hide and we will hide with $this->hidden which we’ll get to later. As you can see paginate() now has two arguments, in this case arrWithRelations and an array with true in its first slot:

function paginate($func = false, $params = array(), $tpl = '../search_result.tpl'){
...
    $items = !$func ? $pager->execute()->toArray() : call_user_func_array(array(&$this, $func), array_merge(array($pager->execute()), $params));
...
}

So we call arrWithRelations in AdminCtrl with the resultant collection ($pager->execute()) and true:

In fact, search() has also been changed to be able to handle check box groups:

function search($post = NULL, $as_query = false){
...
$type = is_array($value) ? 'array' : Arr::fluent($this->t->getDefinitionOf($key))->at('type');
switch($type){
	case 'array':
		$tmp = '.id IN (?)';
		$arr[] = implode(',', $value);
		break;
...

Using DQL it’s extremely easy to get this stuff to work. If we are dealing with an array (ie Tags[]), the default return from a check box group we assign array to the $type variable. Using implode the final result could look something like this.id IN (1,6,9).

function arrWithRelations($collection, $hide = true){
    $rarr = array();
    foreach($collection as $item){
        $rarr[$item->id] = $item->toArray();
        foreach($this->t->getRelations() as $key => $value){
            $ctrl = $this->getCtrl( $value->getClass() );
            if($value instanceof Doctrine_Relation_Association){
                if($hide){
                    if(!in_array($key, $this->list_hidden))
                        $rarr[$item->id][$key] = $item->fluent2d($key)->flatten( $ctrl->label )->join('<br/>')->c;
                }else
                    $rarr[$item->id][$key] = $item->fluent2d($key)->flatten( $ctrl->label )->join('<br/>')->c;
            }else if($value instanceof Doctrine_Relation_LocalKey)
                $rarr[$item->id][$value->getLocal()] = $item->fluent($key)->at($ctrl->label);
        }
    }
    return $rarr;
}

The main thing here is to do something with the relations that are a part of the result, for instance all the tags that each person has been tagged with. We start with looping through the collection, saving each item as an array with the id of the item as key in the resulting array.

The next action is familiar by now, we start looping through the relations, if we have a relation and it is not to be hidden we flatten using the label in question, one item on each line. In this case if we are tagging people and we get each tag name on its own line for the person in question. In this case we might have a Tagscollection in $key (hasMany(’Tag as Tags’ …)). It will be automatically converted to a fluent 2d array in the $item->fluent2d($key) call.

If we are not dealing with a multi relation but a single relation (Doctrine_Relation_LocalKey) we simply fetch the value under the label straight away. Finally we return the result and use search_result.tpl to draw it:

<table>
	<tr>
		{foreach from=$columns key=k item=col} 
            <th class="table_head"><strong>{$k}</strong></th> 
        {/foreach}
	</tr>
	{foreach from=$items item=i}
	<tr class="{cycle values="tr_light, tr_dark"}"> 
		{foreach from=$i key=c item=val} 
	        <td> 
	        	{$val} 
	        </td> 
	    {/foreach}
		{if $update}
			<td>
				<a href="{$base_dir}form/id/{$i.id}/retUrl/paginate+page+{$cur_page}">Edit</a>
			</td>
		{/if}
		{if $delete}
			<td>
				<a href="{$base_dir}delete/id/{$i.id}/retUrl/paginate+page+{$cur_page}">Delete</a>
			</td>
		{/if}
	</tr>
	{/foreach} 
</table>
<br/>
{$pagination}

Not much to explain here that hasn’t been explained already, it’s more of a listing for completeness.

We also need to be able to handle the check box groups in an update form (using ui.tpl), in AdminCtrl:

function createPop($hide = true){ 
  $record       = $this->find(); 
  $pop          = $record->toArray();
  foreach($this->t->getRelations() as $key => $value){
      if($value instanceof Doctrine_Relation_Association){
        if($hide){
            if(!in_array($key, $this->hidden))
                $pop[$key]  = $record->fluent2d($key)->flatten('id')->c; 
        }else
            $pop[$key]  = $record->fluent2d($key)->flatten('id')->c;
      }
  }
  return $pop; 
}

function uForm(){ 
  $this->checkLogin();
  $this->setS('pop', $this->createPop()); 
  return parent::uForm(); 
}

It’s starting to become really repetitive, we loop through the relations yet again. We flatten by ‘id’, in this case that means having a lot of items as arrays in another array and using the sub field with key id to create a one dimensional array containing just the ids. This array will be used to prepopulate the tag check boxes for instance.

As you noticed we now have Edit links in for instance paginated search results, when the update form is submitted we want to go back to the correct page, that is done through retUrl/paginate+page+{$cur_page} in Edit. The form method in AdminCtrl looks like this:

function form(){
    $this->setRetUrl();
    $this->checkLogin();
    $this->setS('pop', NULL);
    return parent::form();
}

Notice the use of setRetUrl() in Ctrl:

function setRetUrl(){ 
    if(!empty($this->g['retUrl']))
        $this->setS('ret_url', str_replace('+', '/', $this->g['retUrl']));
}

function goRetUrl(){
    if($this->getS('ret_url') != NULL)
        header("Location: http://".$_SERVER['HTTP_HOST'].$this->base_dir.$this->getS('ret_url'));
    $this->setS('ret_url', NULL);
}

Note the use of +, we can’t use / as that would screw up the whole logic, + is good, it will do. Smoc didn’t use that character for anything prior to adding these new features so it’s cool.

And since we’re using function onUpdate(){ return $this->listAll(); } in AdminCtrl we can simply do:

function listAll(){
    $this->goRetUrl();
    $this->checkLogin();
    $this->assignUpdateDelete(true, $this->list_hidden);
    $this->assign('items', $this->arrWithRelations($this->findAll()));
    return $this->fetch('../list.tpl');
}

And the call to goRetUrl() will fire first and reroute if need be.

Let’s finish off by going through the hiding part, we begin by taking a look at the new FolderCtrl (a folder here works like a tag, stupid naming since you can’t put something in several folders at once in real life but I was too lazy to rename when the new criteria came).

class FolderCtrl extends AdminCtrl{
	function init($name){
		parent::init($name);
		$this->createHidden(true, array('Recipients'));
		$this->label = 'name'; 
	}
}

So we’re calling createHidden() in AdminCtrl with true and array(’Recipients’), let’s go there:

function createHidden($default, $add = array()){
    $default            = $default ? array('id', 'created_at', 'updated_at') : array();
    $hidden             = array_merge($default, $add);
    $this->hidden       = array_combine($hidden, $hidden);
    $this->list_hidden  = array_combine($add, $add);
}

So in this case the final array will contain both id, created_at, updated_at and Recipients, $this->list_hidden will contain only Recipients, through the use of list_hidden and just hidden we can control if some things should show in ui.tpl but not in list.tpl or search_result.tpl, or vice versa.

I think that concludes the latest changes to Smoc, with these changes we now have insert, update, search and display + pagination of items with connections, using check box groups and simply imploding when listing.

Related Posts

Tags: , ,