- PVSM.RU - https://www.pvsm.ru -
При разработке системы документооборота возникла необходимость отображать данные в виде диаграммы Гантта [1]. После непродолжительных поисков был найден подходящий бесплатный компонент, который нужно было прикрутить к «движку» easla.com [2].
Мой опыт прикручивания JS-компоненты к движку на Yii с описание, кодом и примерами под катом.
Прежде всего скажу, что учитывая специфику разрабатываемой системы документооборота первой мыслью была разработка компоненты «с нуля» своими силами. Но, сведя все требования в длинный список, прикинув объем работ, количество кода на PHP и на JavaScript, немного остыл. И, как и многие другие [3] понял, что разумнее поискать готовые компоненты с необходимым функционалом, даже если они окажутся платными.
Требования к компоненте были следующие:
Учитывая, что диаграмма Гантта весьма популярный метод отображения информации, компонент в Интернете оказалось очень много. Просматривал и отбирал их наверное целый день. Конечно, сперва, отказывался от самих простых, представляющих исключительно базовый функционал, и постепенно формировал короткий список наиболее мощных и продвинутых компонент.
Внимательно изучил только парочку компонент, одной из которых и была DHTMLX Gantt [4]. На ней и остановился.
Требования к интеграции компоненты были такие:
Первый же пункт вызвал ряд вопросов. Компонента на 100% клиентская, т.е. вся написана на JavaScript, а нужно, чтобы она инициализировалась с помощью PHP и принимала множество входных параметров. К счастью, DHTMLX Gantt [4] написана очень качественно и с помощью входных параметров ее можно настроить ровно так, как надо.
Постраничное отображение – следующая головная боль. На форуме разработчика звучали вопросы о «постраничности», но в ответ только недоумение типа: «Зачем это нужно? Это же нарушает идеологию диаграммы Гантта!» Однако в моем случае без «постраничности» никак, поэтому схема реализации тоже была найдена еще до реализации.
Фильтр и сортировка такой же непростой вопрос как и постраничное отображение. Сортировка в компоненте есть своя, но она может быть использована только при отображении всех данных в таблице сразу. Иначе говоря, при постраничном отображении встроенная сортировка работать не будет. Фильтр работает аналогично. Мне пришлось потратить пару дней на изучение реализации render'а в компоненте, чтобы понять, получится ли отрисовывать шапку по-своему. Примерно так:
К счастью, с обратной связью особых проблем не увидел. В компоненте полно event'ов, а также присутствует dataProcessor, посредством которого можно обновлять данные. Кстати, с компонентой предлагают целую толпу классов для интеграции, но мне они не пригодились.
Прежде всего, встал вопрос, какой класс унаследовать, чтобы создать свою компоненту. Очень хотелось унаследовать класс CGridView [5], но после пары попыток стало понятно, что она избыточна. Одновременно стало очевидно, что наследуемый класс должен обладать базовым набором методов для render'а и «пагинации». В конечном счете остановился на CBaseListView [6].
Создаваемый класс AlxdDhtmlxGantt очень хотелось сделать похожим на CGridView, чтобы не «изобретать велосипед» и не создавать себе трудностей, поэтому в новый класс начал копировать все нужные свойства прямо из CGridView вместе с комментариями. На текущий момент, в AlxdDhtmlxGantt из уникальных свойств добавлены следующие:
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;
Как очевидно из названий, все on* — это обработчики событий, используемые, преимущественно, для дополнительного render'а checkbox'ов.
taskAttributes – это одно из важных свойств, которое должно содержать список атрибутов отображаемой модели, которые будут использованы диаграммой для наименования, даты начала и окончания задачи. Формат следующий:
public $taskAttributes = array(
'text'=>'description',
'start_date'=>'plan_start_date',
'end_date'=>'plan_end_date'
);
Вместо end_date можно указать duration. Главное, что должен быть указан или атрибут окончания задачи, или ее продолжительности. Подробнее в документации [7].
scales – это еще одно важное свойство, которое должно описывать временную шкалу диаграммы Гантта. На мой взгляд, разработчики компоненты немного намудрили с настройками временной шкалы, выделив параметры основной шкалы в scale_unit [8] и date_scale [9], а параметры доп. шкалы в subscales [10]. Но, надеюсь, им там было виднее. Я объединил настройку шкалы в одно свойство класса, которое должно принимать массив всех временных шкал. Надо одну шкалу – значит в массиве будет только одна шкала. Надо две – значит две и т.д. Формат следующий:
Public $scales = array(
array('unit'=>'year', 'step'=>1, 'date'=>'%Y')
array('unit'=>'month', 'step'=>1, 'date'=>'%F, %Y')
);
По-моему так проще.
По аналогии с CGridView в AlxdDhtmlxGantt нужно инициализировать колонки. Их инициализация один в один как в CGridView. Не скрою, метод просто скопирован и немного поправлен.
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;
}
}
}
Определившись с входными параметрами, пришло время решить вопрос с отображением данных. Взвесив все «за» и «против» пришел к выводу, что первоначальное заполнение диаграммы данными удобнее делать с помощью js, а обратную связь обеспечить через ajax. Перекрыл метод renderItems. Он теперь почти ничего не render'ит:
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();
}
Формирование массива значений для компоненты осуществляется с помощью метода getData, который перебирает данные (все или для активной страницы) и передает их в виде массива.
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;
}
Печальным оказался тот факт, что public метод renderDataCell [11] отрисовывает значение вместе с тэгом td. Пришлось использовать protected метод renderDataCellContent [12], вызывая его с помощью ReflectionMethod [13]. Примерно так:
$r = new ReflectionMethod($column, 'getDataCellContent');
$r->setAccessible(true);
$value = $r->invoke($column, $row, $data);
$ret[$column->name] = $value;
Полная инициализации компоненты для отображения осуществляется в методе 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);
}
Мои первые попытки написать компоненту не создавая отдельного js модуля не увенчались успехом, что и к лучшему. Помучившись стало понятно, что надо написать полноценный js-модуль, который будет обрабатывать процесс инициализации DHTML Gantt, привязки событий и обработку переключения страниц и фильтра. Более того, как выяснилось позже, пришлось перекрыть пару методов для корректного render'а шапки и данных диаграммы. Выглядеть должно было как-то так (на картинке монтаж, чтобы показать и фильтр, и контекстное меню):
Порывшись в исходном коде компоненты, нашел два метода: _render_grid_header и _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;
};
Собственно, код alxd.dhtmlxgantt.js [14] частично заимствовал из jquery.yiigridview.js [15] опять же, чтобы соблюсти преемственность конечного класса.
Конечно, из-за нахального перекрытия и изменения кода render'а DHTML Gantt, пришлось немного поправить стили. Не приложил их к своему исходному коду только потому, что в проекте easla.com [2] они хранятся в отдельном less-файле. Изменения следующие:
@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;
}
}
Использовать получившуюся компоненту можно также, как CGridView, только надо указать taskAttributes. В моем случае код выглядит вот так:
$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);
В easla.com [2] в переменной $viewpub хранится класс, который формирует все необходимые параметры для отображения вида. Но в общем случае:
dataProvider может быть как CActiveDataProvider [16], так и CArrayDataProvider [17];
сolumns тот же самый columns [18], что и в обычной CGridView.
Showall – параметр, который обрабатывается на стороне провайдера и выставляет параметр pagination=false, таким образом исключая постраничное отображения и требуя отображения всех данных.
В настоящий момент компонента используется в easla.com [2], как один из способов отображения информации для процессов управления задачами. Более подробно про управление задачами было описано в моей статье [19].
Выглядит все это удовольствие вот так:
По сути получился Microsoft Project, только, как метко заметил GarbageIntegrator [20], без навязанных бизнес-процессов, с неограниченным количеством полей и статусов, а главное, без надоевших багов.
Текущую версию AlxdDhtmlxGantt кому нужно может найти на github [21]. Буду рад, если она кому-то пригодится.
Автор: easla.com
Источник [22]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/129273
Ссылки в тексте:
[1] диаграммы Гантта: https://ru.wikipedia.org/wiki/%D0%94%D0%B8%D0%B0%D0%B3%D1%80%D0%B0%D0%BC%D0%BC%D0%B0_%D0%93%D0%B0%D0%BD%D1%82%D0%B0
[2] easla.com: http://easla.com
[3] многие другие: https://habrahabr.ru/company/devexpress/blog/103836/
[4] DHTMLX Gantt: http://docs.dhtmlx.com/gantt/
[5] CGridView: http://www.yiiframework.com/doc/api/1.1/CGridView
[6] CBaseListView: http://www.yiiframework.com/doc/api/1.1/CBaseListView
[7] документации: http://docs.dhtmlx.com/gantt/desktop__loading.html#specifyingdataproperties
[8] scale_unit: http://docs.dhtmlx.com/gantt/api__gantt_scale_unit_config.html
[9] date_scale: http://docs.dhtmlx.com/gantt/api__gantt_date_scale_config.html
[10] subscales: http://docs.dhtmlx.com/gantt/api__gantt_subscales_config.html
[11] renderDataCell: http://www.yiiframework.com/doc/api/1.1/CGridColumn#renderDataCell-detail
[12] renderDataCellContent: http://www.y iiframework.com/doc/api/1.1/CGridColumn#renderDataCellContent-detail
[13] ReflectionMethod: http://php.net/manual/ru/class.reflectionmethod.php
[14] alxd.dhtmlxgantt.js: https://github.com/Alxdhere/AlxdDhtmlxGantt/blob/master/assets/alxd.dhtmlxgantt.js
[15] jquery.yiigridview.js: https://github.com/yiisoft/yii/blob/master/framework/zii/widgets/assets/gridview/jquery.yiigridview.js
[16] CActiveDataProvider: http://www.yiiframework.com/doc/api/1.1/CActiveDataProvider
[17] CArrayDataProvider: http://www.yiiframework.com/doc/api/1.1/CArrayDataProvider
[18] columns: http://www.yiiframework.com/doc/api/1.1/CGridView#columns-detail
[19] статье: https://habrahabr.ru/company/easla/blog/301944/
[20] GarbageIntegrator: https://habrahabr.ru/users/garbageintegrator/
[21] github: https://github.com/Alxdhere/AlxdDhtmlxGantt
[22] Источник: https://habrahabr.ru/post/302786/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best
Нажмите здесь для печати.