CButtonColumn 'click' live event attached multiple times when entire grid widget is replaced through ajax.

This example is for Issue 3084.

The example contains a controller and a view - the sample data is include (this example shoud work ‘as is’.

To demonstrate the bug, select ‘Entity B’ and then ‘Entity A’ again. This reloads the grid.

A click on ‘update’ or ‘delete’ will then open the alert window two times.

The files are also attached - the controller should go in ‘controllers/’ and the view in ‘views/example/’.

ExampleController.php


<?php

class ExampleController extends CController {


    public function getSampleData() {

        return array(

        1 => array(

            	'displayname'=>'Entity A',

                'id' => 1,

                'tags'=>array(

                            array('id'=>1,'tag'=>'tag a'),

                            array('id'=>2,'tag'=>'tag b'),

                            array('id'=>3,'tag'=>'tag c'),

                            array('id'=>4,'tag'=>'tag d'),

                            array('id'=>5,'tag'=>'tag e'),

                        ),

        ),

        4 => array(

            	'displayname'=>'Entity B',

            	'id'=>4,

                'tags'=>array(

                            array('tag'=>'tag f'),

                            array('tag'=>'tag g'),

                            array('tag'=>'tag h'),

                            array('tag'=>'tag i'),

                            array('tag'=>'tag g'),

                        ),

        ),

        );

    }


    public function actionGrid() {

        $view='grid';

        $id = Yii::app()->request->getParam(id,1);

        $data = null;

        if(is_numeric($id)) {

            $alldata = $this->getSampleData();

            $data=$alldata[$id];

        }

        if($data===undefined) {

            echo 'Bad id';

        } else {

            $params = array('data'=>$data);

            if(Yii::app()->request->isAjaxRequest) {

                $this->renderPartial($view,$params, false, true);

                Yii::app()->end();

            } else {

                $this->render($view,$params);

            }

        }

    }

}

View grid.php


<?php

$id = $data['id'];

$gridid = 'grid-'.$id;  // Id of the grid

$gridajaxkey = 'grid';  // Id for updating the grid


$isAjaxRequest = Yii::app()->request->isAjaxRequest;

$ajaxParam = Yii::app()->request->getParam('ajax');

$renderGrid = !$isAjaxRequest||$ajaxParam===$gridajaxkey;

$renderGridContent = !$isAjaxRequest||($ajaxParam===$gridid)||$renderGrid;


if(!$isAjaxRequest) {

        echo CHtml::dropDownList('eid',

                    $id,

                    array(1=>'Entity A',4=>'Entity B'),

                    array('encode'=>false,

    	                  'onchange'=>CHtml::encode('jQuery("body").trigger("id_change",[this.value]);')

                )

        );


        if(true||Yii::app()->request->enableCsrfValidation)

		{

			$csrfTokenName = Yii::app()->request->csrfTokenName;

			$csrfToken = Yii::app()->request->csrfToken;

			$csrf = "\r\ndata:{ '$csrfTokenName':'$csrfToken' },";

		} else {

			$csrf = '';

		}


        $href=$this->createUrl('',array('ajax'=>$gridajaxkey));

        // Javascript to listen to entity id update event and update the grid view.

        $js=<<<EOD

        jQuery('body').bind('id_change',

function (event,id) {

    jQuery.ajax(

        {'type':'POST',

         'url':"$href&id="+id,

          $csrf

         'success':function(data){

               jQuery("#entity-detail").replaceWith(data);

          },

         'error':function(html){alert('Issue fetching entity data from server.');}

    });

   });

EOD;

        Yii::app()->getClientScript()->registerScript('trackinfoeidlistener', $js,CClientScript::POS_READY);


}

if($renderGrid) {

    echo '<div id="entity-detail">';


    echo '<div id="entity-detail-title" style="font-size:40px">';

    echo Yii::t('app','Detail for {displayname}',array('{displayname}'=>$data['displayname']));

    echo '</div>';

    echo '<div id="entity-detail-content">';

    //$dataprovider = new CActiveDataProvider('Tracker');

}


if($renderGridContent) {

    $dataprovider = new CArrayDataProvider($data['tags'],

    array(

        'id'=>'id',

         'pagination'=>array(

            'pageSize'=>2,

    ),

    ));

    $grid=$this->widget('zii.widgets.grid.CGridView', array(

            'dataProvider'=>$dataprovider,

            'id'=>$gridid,

            'template'=>"{items}\n{pager}", /* Default: '{summary}\n{items}\n{pager}'*/

            'ajaxUrl'=>$this->createUrl('',array('id'=>$id)),

        	'columns'=>array(

                'id',

                'tag',

                array_replace_recursive(

                    array('class'   =>'CButtonColumn',), //Yii::app()->params['defaultCButtonColumnConfig'],

                    array(

                            'template' =>'{update} {delete}',

                        	'buttons'=>array(

                            	'update'=>array(

                            		'click'=>'js:function(){alert("update clicked");return false;}',

                    ),

                            	'delete'=>array(

                            		'url'=>'Yii::app()->controller->createUrl("deletealert",array("id"=>$data->primaryKey))',

                            		'click'=>'js:function(){alert("delete clicked");return false;}',

                    ),

                    ),

                    )

                )

            )

        )

    );

}


if($renderGrid) {

    echo '</div>';

}

?>

My fix is the following code in CButtonColumn. The previous handler is removed (if any) before adding the new one. This solution also avoids (‘live’) which is no longer recommended in jQuery [otherwise ‘die’ must be used in stead of ‘off’].

CButtonColumn.php - registerClientScript


	protected function registerClientScript()

	{

		$js=array();

		foreach($this->buttons as $id=>$button)

		{

			if(isset($button['click']))

			{

				$function=CJavaScript::encode($button['click']);

				$class=preg_replace('/\s+/','.',$button['options']['class']);[indent]                $js[]="if(typeof(_gridf)==='undefined'){_gridf={};}"

                ."if(typeof(_gridf['on-{$this->grid->id}-{$class}'])!=='undefined') {jQuery(document).off('click','#{$this->grid->id} a.{$class}',_gridf['on-{$this->grid->id}-{$class}']);}"

                ."_gridf['on-{$this->grid->id}-{$class}']=$function;"

                ."jQuery(document).on('click','#{$this->grid->id} a.{$class}',_gridf['on-{$this->grid->id}-{$class}']);";[/indent]


			}

		}


		if($js!==array())

			Yii::app()->getClientScript()->registerScript(__CLASS__.'#'.$this->id, implode("\n",$js));

	}



There are different solutions to this… but adding off()/undelegate()/die() is not optimal at all… why remove a handler just so that you can add the same handler again.

A solution would be to not process the output when using renderpartial but that sometime is not possible as you need some script processed…

In the your code there is one more problem if you noticed… on every ajax call jquery.js, jquery-bbq.js and jquery.yiigridview.js are loaded again and again.

For this a solution would be to check if it’s an ajax call and if it is then to prevent loading of those files with the scriptmap.

The best solution when using the grid is to use his update() method - $.fn.yiiGridView.update()

As you want to update some other parts of the page not only the grid, then you need to set the ajaxUpdate - http://www.yiiframework.com/doc/api/1.1/CGridView#ajaxUpdate-detail

On the ajax call you just need to return all the containers that are needed to be replaced and all other will do the update method.

So by this solution… your grid.php code would be like this:




<?php


$id = $data['id'];

$gridid = 'grid-' . $id;  // Id of the grid

$gridajaxkey = 'grid';  // Id for updating the grid


if(!Yii::app()->request->isAjaxRequest)

{

	echo CHtml::dropDownList('eid', $id, 

		array(

			1 => 'Entity A',

			4 => 'Entity B'

		),

		array(

			'encode' => false,

			'onchange' => "$.fn.yiiGridView.update('grid-test',{

					data: {'id':this.value}

				});"

		)

	);

}

	echo '<div id="entity-detail">';


	echo '<div id="entity-detail-title" style="font-size:40px">';

	echo Yii::t('app','Detail for {displayname}',array('{displayname}' => $data['displayname']));

	echo '</div>';

	echo '<div id="entity-detail-content">';

	//$dataprovider = new CActiveDataProvider('Tracker');


	$dataprovider = new CArrayDataProvider($data['tags'],

			array(

				'id' => 'id',

				'pagination' => array(

					'pageSize' => 2,

				),

		));

	$grid = $this->widget('zii.widgets.grid.CGridView',array(

		'dataProvider' => $dataprovider,

		'id' => 'grid-test',

		'ajaxUpdate'=>'entity-detail-title',

		'template' => "{items}\n{pager}",/* Default: '{summary}\n{items}\n{pager}' */

		'ajaxUrl' => $this->createUrl(''),

		'columns' => array(

			'id',

			'tag',

			array_replace_recursive(

				array('class' => 'CButtonColumn',),//Yii::app()->params['defaultCButtonColumnConfig'],

				array(

				'template' => '{U} {D}',

				'buttons' => array(

					'U' => array(

						'click' => 'js:function(){alert("update clicked");return false;}',

					),

					'D' => array(

						'url' => 'Yii::app()->controller->createUrl("deletealert",array("id"=>$data["id"]))',

						'click' => 'js:function(){alert("delete clicked");return false;}',

					),

				),

				)

			)

		)

		)

	);

	echo '</div>';

?>



And here is the controller with some warnings/bugs fixed (the second element of the sampleData does not have the IDs)




<?php


class ExampleController extends Controller

{


	public function getSampleData()

	{

		return array(

			1 => array(

				'displayname' => 'Entity A',

				'id' => 1,

				'tags' => array(

					array('id' => 1,'tag' => 'tag a'),

					array('id' => 2,'tag' => 'tag b'),

					array('id' => 3,'tag' => 'tag c'),

					array('id' => 4,'tag' => 'tag d'),

					array('id' => 5,'tag' => 'tag e'),

				),

			),

			4 => array(

				'displayname' => 'Entity B',

				'id' => 4,

				'tags' => array(

					array('id' => 6,'tag' => 'tag f'),

					array('id' => 7,'tag' => 'tag g'),

					array('id' => 8,'tag' => 'tag h'),

					array('id' => 9,'tag' => 'tag i'),

					array('id' => 10,'tag' => 'tag j'),

				),

			),

		);

	}


	public function actionGrid()

	{

		$view = 'grid';

		$id = Yii::app()->request->getParam('id',1);

		$data = null;

		if(is_numeric($id))

		{

			$alldata = $this->getSampleData();

			$data = $alldata[$id];

		}


		if($data === 'undefined')

		{

			echo 'Bad id';

		}

		else

		{

			$params = array('data' => $data);

			if(Yii::app()->request->isAjaxRequest)

			{

				$this->renderPartial($view,$params,false,true);

				Yii::app()->end();

			}

			else

			{

				$this->render($view,$params);

			}

		}

	}


}



Hi mdomba

Thanks for taking the time to reply.

Either you avoid adding it twice, either you remove any existing one first and then always add the handler. The handler would only be removed when it is not the first time this code is executed (because there is none).

I just think that Yii would be more robust with a solution for this.

Also, undelegating is not new, this is in the ‘jquery.yiigridview.js’ file:


			$('body').undelegate(inputSelector, 'change').delegate(inputSelector, 'change', function(){

				var data = $(inputSelector).serialize();

				if(settings.pageVar!==undefined)

					data += '&'+settings.pageVar+'=1';

				$.fn.yiiGridView.update(id, {data: data});			});

Yep, in the real case there are also some jQuery UI elements that do not render correctly if there is no postprocessing.

Yes that is another issue, but I fixed that using an extension that does not load the duplicates. I did not overload the example with the fix for that.

As the grid is contained in another div that gets updated under certain circumstances the idea is to update the entire div at once rather the several parts.

Sorry for forgetting to add the ‘id’ in the second part of the sample data.

Regarding your fixes:

  • as the grid is contained in another div, I prefer updating the entire div rather than cutting it in smaller pieces.

  • calling yiiGridView.update with a parameter does not seem to update the default settings ’ $.fn.yiiGridView.settings[id]; '. I do not know the possible side effects from that.

  • I trigger an ‘id_change’ event so that the UI element responsible for the change does not have to know what needs to change (which is handled in the listeners). I changed tje relevant part in my example code to this:


        $js=<<<EOD


        jQuery('body').bind('id_change',function (event,id) {jQuery.fn.yiiGridView.update('$gridid',{data: {'id':id}});})

EOD;

and I changed the $gridid to be fixed.

I the ‘id’ is encoded in the generated Urls so I did not see a side effect of not updating $.fn.yiiGridView.settings[id]

As I wrote already there is no point in undelegating just to again delegate the same equal method… why do the repetitive work… what is delegate then for?

The undelegate you see in the gridview.js file was a quick fix in previous versions… that is gone in the yii 1.1.9 version as now the new on() method is used.

If to update several parts or just one div… it’s up to the developer… you just need to set the ajaxUpdate with appropriate ID(s)… I don’t see any problem with any of those… updating several (two in your case) divs makes sense if they are on different parts on the page… you don’t get any much faster code if you update just one div instead of two…

I did not understant the problem about updating settings with the parameter… but maybe you mean the gridID… I noticed in your code that you are changing the grid ID on ajax calls… that is not good… a DOM element should always keep his ID, especialy if some events are binded or delegated to it.

The delegate is still useful for updates of the grid data itself.

The grid is initialized using a generated call like this:


[font=Consolas,]jQuery[/font][font=Consolas,]([/font][font=Consolas,]'#grid-1'[/font][font=Consolas,]).[/font][font=Consolas,]yiiGridView[/font][font=Consolas,]({[/font][font=Consolas,]'ajaxUpdate'[/font][font=Consolas,]:[[/font][font=Consolas,]'grid-1'[/font][font=Consolas,]],[/font][font=Consolas,]'ajaxVar'[/font][font=Consolas,]:[/font][font=Consolas,]'ajax'[/font][font=Consolas,],[/font][font=Consolas,]'pagerClass'[/font][font=Consolas,]:[/font][font=Consolas,]'pager'[/font][font=Consolas,],[/font][font=Consolas,]'loadingClass'[/font][font=Consolas,]:[/font][font=Consolas,]'grid-view-loading'[/font][font=Consolas,],[/font][font=Consolas,]'filterClass'[/font][font=Consolas,]:[/font][font=Consolas,]'filters'[/font][font=Consolas,],[/font][font=Consolas,]'tableClass'[/font][font=Consolas,]:[/font][font=Consolas,]'items'[/font][font=Consolas,],[/font][font=Consolas,]'selectableRows'[/font][font=Consolas,]:[/font][font=Consolas,]1[/font][font=Consolas,],[/font][font=Consolas,]'url'[/font][font=Consolas,]:[/font][font=Consolas,]'/workspace/locbox/access/index.php?r=example/grid&id=1'[/font][font=Consolas,],[/font][font=Consolas,]'pageVar'[/font][font=Consolas,]:[/font][font=Consolas,]'id_page'[/font][font=Consolas,]});[/font]

This initialisation is stored in a yiiGridView.settings array and referenced in several locations. The ‘url’ may need to change as the ‘id’ changes.

The DOM Element (the grid) is in a div that sees its content replaced entirely. The purpose of having a specific id is to allow multiple grids on the same page.

When I asked what is delegate for I was thinking in pure jQuery terms… in jQuery delegating means binding a custom code to an event even for future updates of the DOM elelement… that was introduced so that a developer can delegate his code only one time for all the future changes of the grid… by undelegating and again delegating you are breaking the main idea of that method… we can talk abou this on and on… but it leads nowhere… and there are solutions for this without doing it this way.

If you take any DOM element and change its ID… that always the same DOM element not a new one.

In the above example you don’t have multiple grids… you have one grid to whom you want to change the ID - this are not multiple grids…

In the end… I made a working solution… it works without the need to change the grid ID… the pagination works too… so it’s up to you now if you want to build upon that solution or not…

In jQuery terms: the ‘on’ AND the ‘off’ method both exist.

I agree that when you change the id of a DOM element it is still the same DOM element. But when you replace the content of a ‘<div>’ the DOM elements that were in the ‘<div>’ no longer exist do they? So any event attached directly to those get lost. As the events created with ‘on’ are attached to the body these continue to exist even after the replacement.

So maybe attaching the ‘on’ events to the uppermost level of the grid may be a generic solution. When the grid disappears, the events should disappear too.

Attaching the events related to the grid to the grid itself rather than the body, that should be ok - maybe that is what is been done for 1.1.9.

I’m having an issue with this and using 1.1.9… if an ajax call returns all the code necessary for a GridView, and you click whatever button does this ajax call multiple times, you get a bunch of popup confirmations when you click the default delete button because of this code getting sent multiple times:


jQuery('#product-grid a.delete').live('click',function() { ... }); return false;

I’ve tried putting this in my code before the GridView gets placed:


jQuery('#product-grid a.delete').die('click');

… but it doesn’t work. Any ideas?

The Javascript code should be using ‘on’ in stead of ‘live’.

In the ‘CButtonColumn’ class, in ‘registerScripts’, I have put:


if(true) {

				$class=preg_replace('/\s+/','.',$button['options']['class']);

                $js[]="if(typeof(_gridf)==='undefined'){_gridf={};}"

                ."if(typeof(_gridf['on-{$this->grid->id}-{$class}'])!=='undefined') {jQuery(document).off('click','#{$this->grid->id} a.{$class}',_gridf['on-{$this->grid->id}-{$class}']);}"

                ."_gridf['on-{$this->grid->id}-{$class}']=$function;"

                ."jQuery(document).on('click','#{$this->grid->id} a.{$class}',_gridf['on-{$this->grid->id}-{$class}']);";

} else {

				$class=preg_replace('/\s+/','.',$button['options']['class']);

				$js[]="jQuery('#{$this->grid->id} a.{$class}').live('click',$function);";

}

I do not remember if I wrote the alternative code or if I got it from somewhere.

It does some accounting regarding ‘on’ events that are already registered, switches them off if they exist and then recreate the event handler.

The ‘else’ part is never executed (it is the original code).

I know that when I do not have this code, I have the same issues as you have.

Is there any official solution in sight? Loading stuff via AJAX still breaks a lot of things which can be tackled by some extensions but not fully solved.

For information, I just applied this change again after upgrading to version 1.12.