⬆️ ⬇️

Screwing the Gantt chart

When developing a workflow system, it became necessary to display data in the form of a Gantt chart . After a brief search, a suitable free component was found that needed to be screwed to the “engine” easla.com .



My experience of screwing JS-components to the engine on Yii with a description, code and examples under the cut.



First of all, I would say that given the specifics of the workflow system being developed, the first thought was to develop the components from scratch on its own. But, having reduced all requirements to a long list, having estimated the amount of work, the amount of code in PHP and in JavaScript, it has cooled down a bit. And, like many others realized that it is wiser to search for ready-made components with the necessary functionality, even if they turn out to be paid.



Component requirements were as follows:



Given that the Gantt chart is a very popular method of displaying information, the component on the Internet turned out to be very much. I looked through and selected them probably the whole day. Of course, at first, I abandoned the simplest ones, which represent exclusively basic functionality, and gradually formed a short list of the most powerful and advanced components.

I carefully studied only a couple of components, one of which was DHTMLX Gantt . On it and stopped.



Task



The requirements for integration of the components were as follows:



The first point raised a number of questions. The component is 100% client, i.e. everything is written in javascript, but it is necessary that it be initialized with PHP and take many input parameters. Fortunately, DHTMLX Gantt is written very well and with the help of input parameters it can be configured exactly as it should.

')

Page by page is the next headache. At the developer’s forum there were questions about “page-by-page”, but in response only bewilderment like: “Why is this necessary? This also violates the ideology of the Gantt chart! ”However, in my case, there is no“ page-by-page ”in any way, therefore the implementation scheme was also found before implementation.



Filtering and sorting is the same difficult question as paging. Sorting in the component has its own, but it can only be used when displaying all the data in the table at once. In other words, in paging, inline sorting will not work. The filter works in a similar way. I had to spend a couple of days studying the implementation of the render in the component in order to understand whether it would be possible to draw the cap in its own way. Like that:





Fortunately, I didn’t see any problems with the feedback. The component is full of events, and there is also a dataProcessor, through which you can update data. By the way, with the component they offer a whole crowd of classes for integration, but they were not useful to me.



PHP implementation



First of all, the question arose which class to inherit in order to create its own component. I really wanted to inherit the CGridView class, but after a couple of attempts it became clear that it is redundant. At the same time, it became obvious that the inherited class should have a basic set of methods for render and pagination. Ultimately, I stopped at CBaseListView .

The created class AlxdDhtmlxGantt really wanted to make it look like a CGridView, so as not to “reinvent the wheel” and not create difficulties, so I started copying all the necessary properties directly from CGridView along with comments to the new class. Currently, the following properties have been added to AlxdDhtmlxGantt from unique properties:

public $onTaskSelected; public $onTaskOpened; public $onTaskClosed; public $onTaskDragStart; public $onTaskDrag; public $onBeforeTaskDrag; public $onBeforeTaskChanged; public $onAfterTaskDrag; public $itemsTag = 'div'; public $dataProcessorUrl; public $itemsStyle='height:500px;'; public $taskAttributes = array(); public $scales; public $tree = false; 


As is obvious from the names, all on * are event handlers, used primarily for additional checkboxes.



taskAttributes is one of the important properties that should contain a list of attributes of the displayed model that will be used by the diagram for the name, the start and end dates of the task. The format is as follows:

 public $taskAttributes = array( 'text'=>'description', 'start_date'=>'plan_start_date', 'end_date'=>'plan_end_date' ); 


Instead of end_date, you can specify duration . The main thing that must be specified or attribute the end of the task, or its duration. Read more in the documentation .



The scales are another important property that should describe the timeline of the Gantt chart. In my opinion, the developers of the components are a little wise with the settings of the timeline, highlighting the parameters of the main scale in scale_unit and date_scale , and the parameters are add. scales in subscales . But, I hope, they knew better there. I combined the scale setting into one class property, which should take an array of all timelines. One scale is necessary - it means there will be only one scale in the array. Two are necessary - it means two, etc. The format is as follows:

 Public $scales = array( array('unit'=>'year', 'step'=>1, 'date'=>'%Y') array('unit'=>'month', 'step'=>1, 'date'=>'%F, %Y') ); 


In my opinion it is easier.



Similar to the CGridView in AlxdDhtmlxGantt, you need to initialize the columns. Their initialization is one-to-one as in a CGridView. Frankly, the method is simply copied and slightly corrected.

initColumns
  protected function initColumns() { if($this->columns===array()) { if($this->dataProvider instanceof CActiveDataProvider) $this->columns=$this->dataProvider->model->attributeNames(); elseif($this->dataProvider instanceof IDataProvider) { // use the keys of the first row of data as the default columns $data=$this->dataProvider->getData(); if(isset($data[0]) && is_array($data[0])) $this->columns=array_keys($data[0]); } } $id=$this->getId(); foreach($this->columns as $i=>$column) { if(is_string($column)) $column=$this->createDataColumn($column); else { if(!isset($column['class'])) { $column['class'] = 'CDataColumn'; } $column=Yii::createComponent($column, $this); } if(!$column->visible) { unset($this->columns[$i]); continue; } if($column->id===null) $column->id=$id.'_c'.$i; $this->columns[$i]=$column; } $tree_initiated = false; foreach($this->columns as $column) { $column->init(); if ($column instanceof CDataColumn && $this->tree && !$tree_initiated) { $this->tree_column_name = $column->name; $tree_initiated = true; } } } 




Having decided on the input parameters, it is time to resolve the issue of displaying data. After weighing all the pros and cons, he came to the conclusion that it is more convenient to do the initial filling of the diagram with js and provide feedback via ajax. Overlapped by the renderItems method. He now almost nothing render'it:

renderItems
  public function renderItems() { if($this->dataProvider->getItemCount()>0 || $this->showTableOnEmpty) { echo CHtml::openTag($this->itemsTag, array('class'=>$this->itemsCssClass, 'style'=>$this->itemsStyle)); //render container only //content render in javascript echo CHtml::closeTag($this->itemsTag); } else $this->renderEmptyText(); } 




The array of values ​​for the component is formed using the getData method, which iterates over the data (all or for the active page) and transmits them as an array.

  public function getData() { $ret = array('data'=>array()); $data = $this->dataProvider->getData(); $n = count($data); if($n > 0) { for($row=0; $row < $n; ++$row) $ret['data'][] = $this->getDataRow($row); } return $ret; } 


The sad fact is that the public method renderDataCell draws the value along with the td tag. I had to use the protected method renderDataCellContent , calling it using ReflectionMethod . Like that:

 $r = new ReflectionMethod($column, 'getDataCellContent'); $r->setAccessible(true); $value = $r->invoke($column, $row, $data); $ret[$column->name] = $value; 


Full initialization of components for display is done in the registerClientScript method. It also loads all the necessary scripts and display styles, including the localization script.

registerClientScript
  public function registerClientScript() { $id = $this->getId(); if($this->ajaxUpdate===false) $ajaxUpdate=false; else $ajaxUpdate=array_unique(preg_split('/\s*,\s*/',$this->ajaxUpdate.','.$id,-1,PREG_SPLIT_NO_EMPTY)); $itemsSelector = $this->itemsTag; $itemsCssClass = explode(' ',$this->itemsCssClass,2); if (is_array($itemsCssClass)) { $itemsSelector .= '.'.$itemsCssClass[0]; } $options=array( 'ajaxUpdate'=>$ajaxUpdate, 'ajaxVar'=>$this->ajaxVar, 'pagerClass'=>$this->pagerCssClass, 'loadingClass'=>$this->loadingCssClass, 'filterClass'=>$this->filterCssClass, // 'tableClass'=>$this->itemsCssClass, // 'selectableRows'=>$this->selectableRows, 'enableHistory'=>$this->enableHistory, 'updateSelector'=>$this->updateSelector, 'filterSelector'=>$this->filterSelector, 'itemsSelector'=>$itemsSelector, ); if($this->ajaxUrl!==null) $options['url']=CHtml::normalizeUrl($this->ajaxUrl); if($this->ajaxType!==null) $options['ajaxType']=strtoupper($this->ajaxType); if($this->enablePagination) $options['pageVar']=$this->dataProvider->getPagination()->pageVar; foreach(array('beforeAjaxUpdate', 'afterAjaxUpdate', 'ajaxUpdateError', 'onTaskSelected', 'onTaskOpened', 'onTaskClosed', 'onTaskDragStart', 'onTaskDrag', 'onBeforeTaskDrag', 'onBeforeTaskChanged', 'onAfterTaskDrag', /*, 'selectionChanged'*/) as $event) { if($this->$event!==null) { if($this->$event instanceof CJavaScriptExpression) $options[$event]=$this->$event; else $options[$event]=new CJavaScriptExpression($this->$event); } } $options['config'] = array( //The default date format for JSON and XML data is "%d-%m-%Y" http://docs.dhtmlx.com/gantt/desktop__loading.html#loadingfromadatabase 'xml_date'=>'%Y-%m-%d', 'columns'=>array_map(function($column){ if ($column instanceof CCheckBoxColumn) { $ret = array('name'=>$column->name); } elseif ($column instanceof AlxdStatusrefColumn) { $ret = array('name'=>$column->name.($column->format ? '.'.$column->format : '')); } elseif ($column instanceof AlxdAttributerefColumn) { $ret = array('name'=>$column->name.($column->attribute ? '.'.$column->attribute : '')); } else { $ret = array('name'=>$column->name); } $r = new ReflectionMethod($column, 'renderHeaderCellContent'); $r->setAccessible(true); ob_start(); $r->invoke($column); $ret['label'] = ob_get_contents(); ob_end_clean(); if ($column instanceof CCheckBoxColumn) { $ret['width'] = 36; } else { $headerHtmlOptions = $column->headerHtmlOptions; if (isset($headerHtmlOptions['style'])) { $styles = explode(';', rtrim($headerHtmlOptions['style'], ';')); foreach ($styles as $style) { $pair = explode(':', $style, 2); if (count($pair) == 2 && strtolower(trim($pair[0])) == 'width') { $l = strlen($pair[1]); if (strtolower(substr($pair[1], $l-2, 2)) == 'px') { $ret['width'] = substr($pair[1], 0, $l - 2); } } } } if ($this->tree && $column->name == $this->tree_column_name) { $ret['tree'] = $this->tree; } } return $ret; }, $this->columns), 'filters'=>array_map(function($column){ $r = new ReflectionMethod($column, 'renderFilterCellContent'); $r->setAccessible(true); ob_start(); $r->invoke($column); $filter = ob_get_contents(); ob_end_clean(); return array( 'name'=>$column->name, 'control'=>$filter ); }, $this->columns), 'data'=>$this->getData(), ); $options['config']['scale_unit'] = $this->scales[0]['unit']; $options['config']['date_scale'] = $this->scales[0]['date']; if (count($this->scales) > 1) { $options['config']['subscales'] = array_slice($this->scales, 1); } if ($this->filter !== null) { $options['config']['scale_height_auto'] = true; $options['config']['filter'] = true; } if (isset($this->dataProcessorUrl)) { $options['dataProcessorUrl'] = $this->dataProcessorUrl; } $options=CJavaScript::encode($options); $cs=Yii::app()->getClientScript(); if ($this->_assets == null) { $path = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'assets'; $this->_assets = Yii::app()->assetManager->publish($path); } $cs->registerCoreScript('jquery'); $cs->registerCoreScript('bbq'); if($this->enableHistory) $cs->registerCoreScript('history'); $cs->registerCssFile($this->_assets . DIRECTORY_SEPARATOR . 'dhtmlxgantt.css'); $cs->registerScriptFile($this->_assets . DIRECTORY_SEPARATOR . 'dhtmlxgantt.js', CClientScript::POS_BEGIN); $cs->registerScriptFile($this->_assets . DIRECTORY_SEPARATOR . 'locale/locale_'.Yii::app()->language.'.js', CClientScript::POS_BEGIN); $cs->registerScriptFile($this->_assets . DIRECTORY_SEPARATOR . 'alxd.dhtmlxgantt.js', CClientScript::POS_BEGIN); $cs->registerScript(__CLASS__.'#'.$id,"jQuery('#$id').alxdDhtmlxGantt($options);", CClientScript::POS_READY); } 




JavaScript implementation



My first attempts to write a component without creating a separate js module were not successful, and for the better. Having suffered it became clear that you need to write a full-fledged js-module that will handle the DHTML Gantt initialization process, event binding and page switching and filter handling. Moreover, as it turned out later, I had to block a couple of methods for correctly rendering the header and chart data. It had to look something like this (montage on the picture to show both the filter and the context menu):





Having rummaged in the source code of the component, I found two methods: _render_grid_header and _render_grid_item . I tried to surgically block them, but nothing happened, and eventually they completely blocked them by copying the source code and making the necessary edits.

_render_grid_header and _render_grid_item
 gantt._render_grid_header = function () { var columns = this.getGridColumns(); var filters = this.config.filters; var title_cells = []; var filter_cells = []; var width = 0, labels = this.locale.labels; var lineHeigth = this.config.scale_height - 2; for (var i = 0; i < columns.length; i++) { var last = i == columns.length - 1; var col = columns[i]; var colWidth = col.width*1; if (last && this._get_grid_width() > width + colWidth) col.width = colWidth = this._get_grid_width() - width; width += colWidth; var sort = (this._sort && col.name == this._sort.name) ? ("<div class='gantt_sort gantt_" + this._sort.direction + "'></div>") : ""; var cssClass = ["gantt_grid_head_cell", ("gantt_grid_head_" + col.name), (last ? "gantt_last_cell" : ""), this.templates.grid_header_class(col.name, col)].join(" "); var style = "width:" + (colWidth - (last ? 1 : 0)) + "px;"; var label = (col.label || labels["column_" + col.name]); label = label || ""; var title_cell = "<div class='" + cssClass + "' style='" + style + "' column_id='" + col.name + "'>" + label + sort + "</div>"; title_cells.push(title_cell); if (filters.length >= i) { var filter = filters[i]; var filter_cell = "<div class='" + cssClass + "' style='" + style + "'>" + filter.control + "</div>"; filter_cells.push(filter_cell); } } this.$grid_scale.innerHTML = "<div class='gantt_grid_scale_row'>" + title_cells.join("") + "</div>" + (this.config.filter ? "<div class='gantt_grid_scale_row'>" + filter_cells.join("") + "</div>" : ""); this.$grid_scale.style.width = (width - 1) + "px"; if (this.config.scale_height_auto == true) { var $grid_scale = $(this.$grid_scale); $grid_scale.removeAttr("style"); this.config.scale_height = $grid_scale.height(); this.$grid_scale.style.height = (this.config.scale_height - 1) + "px"; this.$grid_scale.style.lineHeight = "1.42857143"; } else { this.$grid_scale.style.height = (this.config.scale_height - 1) + "px"; this.$grid_scale.style.lineHeight = lineHeigth + "px"; } }; gantt._render_grid_item = function (item) { var btn_cell_width = 20; if (!gantt._is_grid_visible()) return null; var columns = this.getGridColumns(); var cells = []; var width = 0; for (var i = 0; i < columns.length; i++) { var last = i == columns.length - 1; var col = columns[i]; var cell; var value; var actions = null; if (col.template) value = col.template(item); else value = item[col.name]; if (value.actions) { actions = value.actions; value = value.content; } if (value instanceof Date) value = this.templates.date_grid(value, item); value = "<div class='gantt_tree_content'>" + value + "</div>"; var css = "gantt_cell" + (last ? " gantt_last_cell" : ""); var tree = ""; if (col.tree) { for (var j = 0; j < item.$level; j++) tree += this.templates.grid_indent(item); var has_child = this._has_children(item.id); if (has_child) { tree += this.templates.grid_open(item); tree += this.templates.grid_folder(item); } else { tree += this.templates.grid_blank(item); tree += this.templates.grid_file(item); } } var style = "width:" + (col.width - (actions ? btn_cell_width : 0) - (last ? 1 : 0)) + "px;"; if (this.defined(col.align)) style += "text-align:" + col.align + ";"; cell = "<div class='" + css + "' style='" + style + "'>" + tree + value + "</div>"; cells.push(cell); if (actions) { cells.push(actions); } } var css = item.$index % 2 === 0 ? "" : " odd"; css += (item.$transparent) ? " gantt_transparent" : ""; css += (item.$dataprocessor_class ? " " + item.$dataprocessor_class : ""); if (this.templates.grid_row_class) { var css_template = this.templates.grid_row_class.call(this, item.start_date, item.end_date, item); if (css_template) css += " " + css_template; } if (this.getState().selected_task == item.id) { css += " gantt_selected"; } var el = document.createElement("div"); el.className = "gantt_row" + css; el.style.height = this.config.row_height + "px"; el.style.lineHeight = (gantt.config.row_height) + "px"; el.setAttribute(this.config.task_attribute, item.id); el.innerHTML = cells.join(""); return el; }; 




Actually, the alxd.dhtmlxgantt.js code was partially borrowed from jquery.yiigridview.js again, in order to maintain the continuity of the final class.



Styles



Of course, because of the cheeky overlap and changes to the DHTML Gantt render code, I had to adjust the styles a bit. I did not attach them to my source code just because they are stored in a separate less-file in the easla.com project. The changes are as follows:

 @btn-cell-width: 20px; .gantt-loading { .gantt_container { background: url('../images/loading.gif') no-repeat center center !important; > .gantt_grid, > .gantt_task { opacity: 0.5; } } } .gantt_grid_scale, .gantt_task_scale { font-size: inherit; background-color: @primary-color; } .gantt_grid_head_cell { padding: 8px; text-align: inherit; overflow: inherit; white-space: normal; } .gantt_row { .btn-group { vertical-align: inherit; } .btn-cell { width: @btn-cell-width; height: 100%; .btn { height: inherit; line-height: inherit; padding: 0px; border-radius: 0px; border: none; width: 100%; span.caret { display: none; } } i { font-size: 14px; } } } .alxdgrid { .gantt.table-footer { margin-top: -1px; } } 




Application



You can also use the resulting component as a CGridView , just need to specify the taskAttributes . In my case, the code looks like this:

 $cnt = $viewpub->provider->totalItemCount; $template = array(); $template[] = '{items}'; if ($cnt > 0) { if ($viewpub->getShowAll()) { $isShowAll = isset($_GET['showall']) && $_GET['showall'] == 1; $params = array_merge((array)'', $_GET); if ($isShowAll) { unset($params['showall']); } else { $params['showall'] = 1; } $templateShowAll = CHtml::link( $isShowAll ? '<i class="fa fa-files-o"></i>' : '<i class="fa fa-file-o"></i>', $params, array( 'id'=>'Viewpub_page_to_all', 'class'=>'btn btn-primary btn-outline pull-right show-all', 'title'=>$isShowAll ? Yii::t('Viewpub','Page-by-page') : Yii::t('Viewpub','All at once') ) ); $template[] = '<div class="gantt table-footer clearfix">' . $templateShowAll . '{pager}{summary}</div>'; } else { $template[] = '<div class="gantt table-footer clearfix">{pager}{summary}</div>'; } } $options = array( 'id' => 'viewpub_grid_' . $suffix, 'type' => BsHtml::GRID_TYPE_STRIPED, 'dataProcessorUrl'=>Yii::app()->createUrl('viewpub/ganttDataProcessor', array('viewpub_id'=>$viewpub->id, 'user_id'=>$user->id)), 'dataProvider' => $viewpub->provider, 'filter' => $viewpub->objectref, 'columns' => array_merge( $cntCommands ? array($checkBoxColumn) : array(), $viewpub->columns ), 'taskAttributes'=> $viewpub->getTaskAttributes(), 'itemsCssClass' => 'gantt-mono-primary', 'summaryCssClass'=>'hidden-xs table-summary', 'pagerCssClass'=>'table-pagination', 'loadingCssClass'=>'gantt-loading', 'enableSorting' => $viewpub->getSorting(), 'tree' => $viewpub->getTree(), 'scales' =>$viewpub->getScales(), 'template' => implode('', $template), 'pager' => array( 'class' => 'CLinkPager', 'maxButtonCount' => $isMobileClient ? 3 : 10, 'firstPageLabel' => ' <i class="fa fa-angle-double-left"></i> ', 'header' => '', 'hiddenPageCssClass' => 'disabled', 'lastPageLabel' => ' <i class="fa fa-angle-double-right"></i> ', 'nextPageLabel' => ' <i class="fa fa-angle-right"></i> ',//'>', 'selectedPageCssClass' => 'active', 'prevPageLabel' => ' <i class="fa fa-angle-left"></i> ',//'<', 'htmlOptions' => array('class' => 'pagination') ), 'updateSelector' => ($viewpub->getShowAll() ? '{page}, {sort}, a.show-all' : '{page},{sort}'), 'ajaxUpdateError'=>'function(request, textStatus, errorThrow, errorMessage){ EaslaAlert.add(request.status == 501 ? request.responseText : request.statusText+": "+extractExceptionText(request.responseText), {type: "danger"});}' ); if ($cntCommands) { $options['afterAjaxUpdate'] = 'function() { $(":checkbox").uniform();}'; $options['onTaskSelected'] = $options['onTaskOpened'] = $options['onTaskClosed'] = $options['onTaskDrag'] = 'function(id) { $(":checkbox").uniform();}'; } $renderViewpub = $this->widget('ext.AlxdDhtmlxGantt.AlxdDhtmlxGantt', $options, true); 


In easla.com , the $ viewpub variable stores a class that generates all the necessary parameters to display the view. But in general:

dataProvider can be either a CActiveDataProvider or a CArrayDataProvider ;

columns have the same columns as in a regular CGridView.

Showall is a parameter that is processed on the provider side and sets the pagination = false parameter, thus excluding pagination and requiring the display of all data.



Results



The component is currently used in easla.com as one of the ways to display information for task management processes. In more detail about task management was described in my article .

All this pleasure looks like this:



In fact, it turned out Microsoft Project, only, as GarbageIntegrator aptly remarked, without imposed business processes, with an unlimited number of fields and statuses, and most importantly, without annoying bugs.



The current version of AlxdDhtmlxGantt can be found on github . I would be glad if it is useful to someone.

Source: https://habr.com/ru/post/302786/



All Articles