I know this is a lengthy wiki, but 90% of the stuff you have already working and I also used lots of spaces and comments between code. The actual code is not that long.
Many desktop programmers are used to having dynamic forms, where clicking on a record in a parent sub-form, updates another sub-form with the child records. While having many levels of nested sub-forms in a single view might not be such a good idea for a web application, I thought doing it one level deep might be interesting and useful. But, instead of sub-forms I used CGridViews.
In the database I have a classic many_many relation, which is broken/represented by a junction table. The two outer tables are called "cap_role" (PK: role_id) and "cap_permission" (PK: permission_id). The junction-table in the middle is called cap_role_permission (PK: rolepermission_id) (Unique: role_id, permission_id). (Tables are prefixed by "cap_".)
The idea is that you link permissions to roles (users will later be given roles). So obviously, a role could have many permissions, and a permission could also be assigned to many roles.
Models:
There is nothing special about the Role model, but the Permission model has, amongst others, the following MANY_MANY relation:
public function relations() { return array( 'relPermissionRoles'=>array(self::MANY_MANY, 'Role', 'cap_role_permission(role_id, permission_id)'), ); }
There is no need for a Role_permission model (for the cap_role_permission table) since I'm directly using the many_many relation in the Permission model.
(Yii calls the direct relation between the two outer tables - with the junction table between them - a many_many relation. There is no real many_many relation in the database and you always need the junction table in the middle. Yii's many_many relation is thus simply a direct relation between the two outer tables, working via the junction table in the middle. It saves you trouble, because you don’t have to work “through” the junction table (like in sql) to get to the other outer table, but in the background Yii still works through the junction table.
Note that the many_many relation in the above Permission model, links the Permission model to the Role model via the cap_role_permission junction table.)
(This application will work just as well with a has_many relation. Just amend the dataprovider in the getChildRecords function in the RoleController.)
Views
My views\role\admin.php view:
/*Parent Gridview in its own <div> using the default Role model received from the controller */ <div id="parentView"> <?php $this->widget('zii.widgets.grid.CGridView', array( 'id'=>'parent-grid', 'dataProvider'=>$parent_model->search(), 'filter'=>$parent_model, 'columns'=>array( 'role_id', 'role_desc', array( 'class'=>'CButtonColumn', ), ), )); </div> /*Use this paragraph to display the loading.gif icon above the Child Gridview, while waiting for the ajax response */ <p id="loadingPic"></br></p> /*The childView <div>, renders the _child form, which contains the Child Gridview. The ajax response will replace/update the whole <div> and not just the gridview. I have read posts where people complain about loosing the CSS if they only replace the gridview, but I have not investigated it further.*/ <div id="childView"> <?php $this->renderPartial('_child', array( 'childrecords' => $childrecords, )) </div> /*Load the javascript file that contains the ajax function*/ <?php $path = Yii::app()->baseUrl.'/js/customFunctions.js'; Yii::app()->clientScript->registerScriptFile($path, CClientScript::POS_END);
The _child form contains the Child CGridView. The gridview is not using a model, but receives a dataprovider from the controller.
Note that the buttons in this gridview have been customised to use the child records' controller (PermissionController) and not the default RoleController. If you think this will confuse the user, take them out. However, I think it could be a lovely feature if you have, for example, classes in the Parent gridview and students in the Child gridview. By selecting a class, you get a list of the students in that class and you can immediately update a student's details by clicking the update button in the Child gridview. Having said that, I would only have 'update' buttons in the Child gridview, otherwise it could get confusing.
$this->widget('zii.widgets.grid.CGridView', array( 'id'=>'childGrid', 'dataProvider'=>$childrecords, /*'filter'=>$child_model,*/ 'columns'=>array( 'permission_id', 'permission_desc', array( 'class'=>'CButtonColumn', 'viewButtonUrl' => 'array("permission/view", "id"=>$data->permission_id)', 'updateButtonUrl' => 'array("permission/update", "id"=>$data->permission_id)', 'deleteButtonUrl' => 'array("permission/delete", "id"=>$data->permission_id)', ), ), ));
The CSS for the paragraph displaying the loading.gif icon (this is done in the ajax function). You can find a loading.gif file in your local Yii installation:
.loadGIF { background: black url(/main/sub/css/images/general/loading.gif) left center no-repeat ; }
The RoleController:
public function actionAdmin(){ // Parent model for parent grid $parent_model = new Role('search'); $parent_model->unsetAttributes(); // Get first record in parent model $first_parent_instance=Role::model()->find(); if(isset($_GET['Role'])) $parent_model->attributes=$_GET['Role']; /* Render form, passing it the default model. Also pass it a dataprovider with the records for the child gridview. */ $this->render('admin',array( 'parent_model'=>$parent_model, 'childrecords'=>$this->getChildRecords($first_parent_instance->role_id), )); } /* This action is called by the ajax function */ public function actionUpdateChildGrid(){ if(isset($_POST['parentID'])){ $parentID = $_POST['parentID']; } $this->renderPartial('_child', array( 'childrecords' => $this->getChildRecords($parentID), )); } /* This function is used by the above two actions to create a dataprovider for the child gridview*/ private function getChildRecords($parentID){ $dataProvider = new CActiveDataProvider('Permission'/* related model: Permission */, array( 'criteria'=>array( 'with'=>array('relPermissionRoles' /* MANY_MANY relation-name in Permission model */ =>array( 'together'=>'true', 'alias'=>'tblPermissionRoles' /* name for the combined file*/)), 'condition'=>"tblPermissionRoles.role_id=$parentID", ), )); return $dataProvider; }
The ajax in the separately loaded javascript file
I first used the parent-grid's own selectionChanged event to update the child-gridview, but that caused problems when the user clicked the same row twice, since the second click deselected the same row that has just been selected by the first click. This deselecting of the row caused $.fn.yiiGridView.getSelection() not to be able to get the PK of the clicked row.
I then used jquery's $('#parent-grid table tbody tr').click(function(){. This function binds a click event handler on all data-rows in the parent grid. However, whenever the gridview was paged, these rows - and their assigned event handlers - no longer existed.
I then moved to using jquery's Event Delegation: It binds an "on()" event handler to the parentView div, which is the parent-gridview's parent.
This handler first watched for click events bubbling up when the user clicks one of the gridview's data rows. However, the rows also fired a click event if the user clicked one of the buttons in the row - which is not what I want.
So finally, I changed the script to check for clicking events coming from a row's columns (except the button-column). The first line thus binds an event handler on the outer parent - the parentView div. This handler looks for click-events bubbling up from any data-row (tbody) columns (td) that are not of class button-column.
$('#parentView').on("click", "table tbody td:not(td:.button-column)", function(event){ try{ /*Extract the Primary Key from the CGridView's clicked row. "this" is the CGridView's clicked column or <td>. Go up one parent - which is the row. Go down to child(1) which contains the row's PK. */ var gridRowPK = $(this).parent().children(':nth-child(1)').text(); /*Display the loading.gif file via jquery and CSS*/ $("#loadingPic").addClass("loadGIF"); /* Call the controller's UpdateChildGrid action via Ajax to get a new dataprovider for the Child CGridView */ var request = $.ajax({ url: "UpdateChildGrid", type: "POST", cache: false, data: {parentID : gridRowPK}, dataType: "html" }); request.done(function(response) { /*since you are updating innerHTML, make sure the received data does not contain any javascript - for security reasons*/ if (response.indexOf('<script') == -1){ /*update the view with the data received from the server*/ document.getElementById('childView').innerHTML = response; /*Remove the loading.gif file via jquery and CSS*/ $("#loadingPic").removeClass("loadGIF"); /*clear the ajax object after use*/ request = null; } else { throw new Error('Javascript in response - possible hacking!'); } }); request.fail(function(jqXHR, textStatus) { throw new Error('Request failed: ' + textStatus ); }); } catch (ex){ alert(ex.message); /*** Write this to a logging file when in production ***/ /*Remove the loading.gif file via jquery and CSS*/ $("#loadingPic").removeClass("loadGIF"); /*clear the ajax object after use*/ request = null; } });
You can either use CHtml::ajax or $.ajax (also called JQuery.ajax) (see http://api.jquery.com/jQuery.ajax/ for more examples).
CHtml::ajax and $.ajax do the same job. However CHtml::ajax is a Yii based PHP function. It must be stored in the view and not in a separate javascript file.
If you want to store your function in a separate javascript file, then use $.ajax.
"Both CHtml::ajax and $.ajax will take care of browser inconsistencies and will also help you with all the "readyState" and "ActiveXObject" (pure ajax) stuff. You don't have to worry about such things anymore, just focus on the interesting parts." (thank you for clearing that up Haensel).
Personally, I prefer putting my functions in separate javascript files, because it allows you to re-use your code. Haensel also mentioned that it makes debugging easier. Some experts also say that $.ajax gives you more control.
Possible Improvements:
A. The controller's Admin action finds the first record in the Role model and subsequently builds the child records' dataprovider using this data. An improvement would be to immediately have the Parent gridview's first row selected by default to remind the user that the first records displayed in the child gridview belong to the selected first row in the parent gridview.
B. I did not repeat this process of finding the first/top-most record after paging the parent gridview. This must also still be done.
Possible other use cases
A. I put the gridviews in the admin view, but I think what can also work well is the following scenario that will allow you to add/delete permissions to roles by adding/deleting records directly in the junction table.
This time, you will need a model (Role_permission) and a controller (Role_permissionController) for the junction table as well.
The user clicks on an update button of the parent gridview, which displays the update view for the role.
At the top of the update view, you have the role’s data, as normal.
Below that, you have a child gridview, displaying the permissions linked to the above role. This time you use a has_many relation between the Role model and the Role_permission model (the junction table) so that the records in the child gridview are the actual records in the junction table.
Give the child gridview delete buttons that use the Role_permissionController to delete the records from the junction table.
Below the child gridview, add a dropdownlist where the user can select new permissions (using the Permission model) to be linked to the role (new records are added to the junction table).
Just a thought: Be very careful when deleting junction table records. If your database is using cascading to ensure referential integrity, you might be deleting records from other tables without even knowing it. So rather just flag your records as being deleted or at least test to make sure the records have not been referenced in other tables.
Any suggestions for corrections and further improvements are welcome.
Thanx
Gerhard