jqGridPHP - таблицы на ajax без головной боли

~WR~

Новичок
Постараюсь покороче.

В текущем проекте возникла необходимость очень быстро создавать админки и отчеты, а также без проблем их потом поддерживать. Сердце любого административного интерфейса - это таблицы (гриды). Всё остальное, так или иначе, танцует вокруг них.

Выбор пал на jqGrid, как наилучший грид на jQuery.
Но, вот беда, бесплатной серверной части для него не было. Сели писать сами.

Очень быстро стало понятно, что в этом деле есть большое количество подводных камней. Каждый человек в команде изобретал какие-то свои ноу-хау, чтобы с этим справляться. Все усугублялось огромным количеством функций jqGrid, а также необходимостью быстро вносить изменения. Поддержка стремительно превращалась в ад.

Появилась потребность в библиотеке, которая объединила бы в себе весь накопленный опыт и разгрузила разработчиков от лишней головной боли. Что и было сделано. На мой взгляд, получилось намного лучше, чем у платных аналогов.

Сейчас есть желание поделиться наработками, выложив код в свободном доступе как Open Source проект.

========================

От слов к делу.
jqGridPHP - основной сайт и документация
jqGridPHP - примеры
jqGridPHP - github

Также обязательно понадобятся следующие ссылки:
jqGrid - основной сайт
jqGrid - примеры
jqGrid - документация

========================

Судя по тому, как живо реагировали люди на Devconf 2011, jqGrid уже использует немало людей.
Если вы входите в их число, либо если у вас есть желание попробовать - пожалуйста, посмотрите примеры исходные коды. Что вы думаете по этому поводу?

Будет совсем здорово, если найдутся добровольцы, которые испытают библиотеку в живом проекте и напишут о своих впечатлениях. Обещаю всяческую поддержку в разумных пределах. Мой интерес - обратная связь, и только.

Спасибо.
 

shelestov

я тут часто
Мне кажется, если нужна такая динамика лучше использовать extjs.
Иначе проект обрастет сотней разнообразных UI плагинов, которые будет сложно поддерживать и обновлять.
 

~WR~

Новичок
Совершенно так же думали в начале. Первая админка была написана на Ext JS 3.
В итоге отказались от него, т.к. в реальной жизни он создавал слишком много проблем.

1. Тормозит. Очень тяжелое DOM-дерево. Грид состоит из огромного количества вложенных div'ов.
Когда на одно экране 2-3 грида по 100-500 элементов в каждом - обычный офисный комп уже начинал задумываться.

Таблица в jqGrid - это обычный table.
Насколько я знаю, в свежем Ext JS 4 тоже переделали грид на таблицы. Возможно, этот пункт уже неактуален.

2. Сложно реализовать функции, которых нет в стандартном наборе. Например, попробуйте покрасить первые три строки таблицы в красный цвет без геморроя?

С jqGrid к нашим услугам вся мощь и простота jQuery:
PHP:
gridComplete: function()
{
    $(this).find('TR.jqgrow:lt(3)').addClass('red');
}
- все, покрасилось. Проще некуда.

3. Очень геморройный debug. Из-за сложного многоэтажного JSON-like синтаксиса легко ошибиться и поставить лишнюю скобочку. Это приводит к мало информативным ошибкам вида:
PHP:
B is undefined in ext-core.js line 123
А там огромное ядро с десятками вложенных функций и объектов. Сиди-думай, где ты промахнулся.

4. Плохая обратная совместимость. При обновлении необходимо проводить полную ревизию кода и проверять, все ли работает. Интерфейсов около ста.

У jQuery, jQuery UI и jqGrid практически 100% обратная совместимость. Накатываем актуальные версии почти не глядя, при этом всего два раза было, чтобы что-то сломалось. И то причина была в вольностях при написании селекторов, которые были запрещены начиная с версии 1.4 (вольности, а не селекторы))

===============

Примерно такие мысли.
Но, да, Ext JS очень круто выглядит на картинках и в примерах.
 

shelestov

я тут часто
Сам реально не начинал разбираться с extJS.
В проекте используется jquery, за время работы накопилось более 20 плагинов, причем все используются.
Кстати, jqGrid так же используется.
 

~WR~

Новичок
Как и у любых плагинов - сохранить время и нервы разработчика при решении тривиальных типовых задач.
Чтобы оставалось больше времени на нетривиальные и интересные. :)

jqGrid умеет практически всё, что нужно от гридов на стороне клиента. Тот редкий случай, когда не умеет, реализуется самостоятельно на jQuery.
jqGridPHP - серверная часть для него. Смысл серверной части - уменьшить до минимума кол-во рутины.

Без неё нужно повторять много однообразных действий. Строить colModel и на стороне клиента, и на стороне сервера. Следить, чтобы они сходились.
Чтобы добавить новую колонку для поиска - добавь лишний if. И т.д., и т.д.

Серверная часть реализует стандартное поведение, которое подходит в 90% случаев. А если нужно что-то особенное, то мы перегружаем соответствующие методы и дописываем только новый код. Одно и то же по сто раз не переписываем.

Примерно так)
 

~WR~

Новичок
Ладно, лучше один раз увидеть. Вот пример из реальной жизни. Отчет сделали сегодня за полтора часа.
Работает постраничный вывод, сортировка и поиск по всем столбцам, редактирование части полей, полный экспорт в Excel.

(смотреть в полном размере)
img.png

PHP код:
PHP:
<?php

class jq_purchase2 extends jqGrid_Adapter_ReadRu
{
	protected $tblName = 'bnd_books_periodical';
	
	protected function init()
	{
		$this->delivery_types = array(
			1 => 'Доставка',
			2 => 'Самовывоз',
			3 => 'Комбинир.',
		);

		$this->contract_types = array(
			1 => 'Купля-продажа',
			2 => 'Реализация',
		);

		#Простые запросы? Ха! :)
		$this->query = "
			SELECT {fields}
			FROM lst_periodical p

			JOIN (
				SELECT n.book_id
					,n.number_supply_date
					,COALESCE(n.period_id, s.period_id) AS period_id
					,COALESCE(n.number, s.number_next) AS number
					,COALESCE(n.year, s.number_next_year) AS year

					,COALESCE(s.period_cnt, 0) AS req_period

					,CASE WHEN n.book_id IS NOT NULL THEN calc_present_book(n.book_id, 0) ELSE 0 END AS cnt
					,b.purchase_delivery_id

				FROM bnd_books_periodical n

				FULL JOIN (
					SELECT s.period_id, s.number_next, s.number_next_year, sum(s.quantity) AS period_cnt
					FROM period_tbl_subscribe s
						JOIN tbl_order o ON (s.order_id=o.id)
					WHERE o.status_bo != 6 AND s.numbers_gone < s.number_cnt
					GROUP BY s.period_id, s.number_next, s.number_next_year
				) s ON (n.period_id=s.period_id AND n.number=s.number_next AND n.year=s.number_next_year)

				LEFT JOIN tbl_books b ON (n.book_id=b.id)

				WHERE (n.book_id IS NULL OR n.number IS NOT NULL)
			) AS n ON (p.id=n.period_id)

			LEFT JOIN (
				SELECT book_id, sum(cnt_reserve) AS cnt_reserve
				FROM tbl_order_reserve
				GROUP BY book_id
			) r ON (n.book_id=r.book_id)

			LEFT JOIN (
				SELECT i.book_id, sum(CASE WHEN ps.id IS NOT NULL THEN i.quantity - i.quantity_return_total END) AS sel_period
					, sum(CASE WHEN ps.id IS NULL THEN i.quantity - i.quantity_return_total END) AS sel_retail
				FROM tbl_order_item i
					JOIN tbl_order o ON (i.order_id=o.id)
					JOIN bnd_books_periodical n ON (i.book_id=n.book_id)
					LEFT JOIN period_tbl_subscribe ps ON (ps.period_id=n.period_id AND o.id=ANY(ps.orders_created))
				WHERE o.status_bo NOT IN (6,19)
				GROUP BY i.book_id
			) i ON (n.book_id = i.book_id)

			LEFT JOIN (
				SELECT n.period_id, wr_max(purchase_delivery_id, n.id) AS purchase_delivery_id
				FROM tbl_books b
					JOIN bnd_books_periodical n ON (b.id=n.book_id)
				WHERE b.purchase_delivery_id IS NOT NULL
				GROUP BY n.period_id
			) pd ON (p.id=pd.period_id)

			LEFT JOIN (
				SELECT d.id, cl.short_name AS client_name, co.number AS contract_number, co.contract_type_id
					,COALESCE(d.payment_conditions, 0) AS payment_conditions, d.delivery_type_id
				FROM purchase_tbl_deliveries d
					JOIN purchase_tbl_contracts co ON (d.contract_id=co.id)
					JOIN purchase_tbl_clients cl ON (co.client_id=cl.id)
			) AS d ON (COALESCE(n.purchase_delivery_id, pd.purchase_delivery_id)=d.id)

			LEFT JOIN (
				SELECT n.book_id, string_agg(l.name, ',') AS pubhouse
				FROM bnd_bookspubhouse n
					JOIN lst_pubhouse l ON (n.pubhouse_id=l.id)
					JOIN lst_periodical p ON (p.book_id=n.book_id)
				GROUP BY n.book_id
			) AS ph ON (p.book_id=ph.book_id)

			WHERE {where}
		";
		
		$this->cols_default = array('align' => 'center', 'width' => 10);
		
		$this->cols = array(
		'id'			=>array('db'	=> "n.period_id || ':' || n.number || ':' || n.year",
								'hidden'=> true,
								),

		'period_id'		=>array('label' => 'ID',
								'db'	=> 'p.id',
								'width'	=> 5,
								),

		'period_name'	=>array('label'	=> 'Название',
								'db'	=> 'p.name',
								'width'	=> 30,
								'align' => 'left',
								),

		'number_next_str'=>array('label'=> 'Номер',
								'db'	=> "'№' || n.number || '/' || n.year",
								'width'	=> 15,
								'stype' => 'select',
								'searchoptions' => array('value' => new jqGrid_Data_Value($this->getHalfYears(), 'Все полугодия')),
								'search_op' => 'number',
								),

		'number_supply_date'
						=>array('label'	=> 'Дата поставки',
								'db'	=> 'n.number_supply_date',
								'width'	=> 15,
								'formatter' => 'date',
								'editable' => true,
								'editoptions' => array('dataInit' => new jqGrid_Data_Raw('function(el){$(el).datepicker({"onSelect": onDateSelect});}')),
								),

		'delivery_type_id'
						=>array('label'	=> 'Доставка',
								'db'	=> 'd.delivery_type_id',
								'replace' => $this->delivery_types,
								'stype'	=> 'select',
								'searchoptions' => array('value' => new jqGrid_Data_Value($this->delivery_types, 'Все')),
								),

		'numbers_half'	=>array('label'	=> 'В п/г',
								'db'	=> 'p.numbers::numeric / 2',
								),

		'numbers_full'	=>array('label'	=> 'В год',
								'db'	=> 'p.numbers',
								),

		'req_period'	=>array('label'	=> 'Подписка',
								'db'	=> 'n.req_period',
								),

		'sel_total'		=>array('label'	=> 'Итого',
								'db'	=> 'COALESCE(i.sel_period, 0) + COALESCE(i.sel_retail, 0)',
								),

		'sel_period'	=>array('label'	=> 'Подписка',
								'db'	=> 'COALESCE(i.sel_period, 0)',
								),

		'sel_retail'	=>array('label' => 'Розница',
								'db'	=> 'COALESCE(i.sel_retail, 0)',
								),

		'cnt'			=>array('label'	=> 'На складе',
								'db'	=> 'n.cnt',
								),

		'reserve'		=>array('label'	=> 'Резерв',
								'db'	=> 'r.cnt_reserve',
								),

		'pubhouse'		=>array('label'	=> 'Издательство',
								'db'	=> 'ph.pubhouse',
								'width'	=> 15,
								'align' => 'left',
								'search_op' => 'like',
								),

		'client_name'	=>array('label'	=> 'Поставщик',
								'db'	=> 'd.client_name',
								'width'	=> 15,
								'align' => 'left',
								),

		'contract_number'
						=>array('label' => 'Номер',
								'db'	=> 'd.contract_number',
								'align' => 'left',
								),

		'contract_type_id'
						=>array('label' => 'Вид',
								'db'	=> 'd.contract_type_id',
								'replace' => $this->delivery_types,
								'stype'	=> 'select',
								'searchoptions' => array('value' => new jqGrid_Data_Value($this->contract_types, 'Все')),
								),

		'payment_conditions'
						=>array('label'	=> 'Оплата',
								'db'	=> 'd.payment_conditions',
								),

		'book_id'		=>array('hidden' => true,
								'db'	 => 'n.book_id',
								),
		);
	}
	
	protected function parseRow($r)
	{
		$r['period_name'] = $r['book_id'] ? "<a href='http://example.com/id/{$r['book_id']}/' target='_blank' class='tip' book_id='{$r['book_id']}'>{$r['period_name']}</a>" : $r['period_name'];

		$r['numbers_half'] = round($r['numbers_half'], 1);
		$r['payment_conditions'] = $r['payment_conditions'] ? ($r['payment_conditions'] . 'дн') : 'предоплата';

		$r['_class'] = array(
			'numbers_half' => 'lavender',
			'numbers_full' => 'lavender',
			'req_period' => 'mint',
			'sel_total' => 'seashell',
			'sel_period' => 'seashell',
			'sel_retail' => 'seashell',
			'cnt'		=> 'smoke',
			'reserve'	=> 'smoke',
		);
		
		return $r;
	}

	protected function opEdit($id, $upd)
	{
		list($period_id, $number, $year) = array_map('intval', explode(':', $id));

		#Row exists?
		$row = coreDB::dbLoadObj("SELECT * FROM bnd_books_periodical WHERE period_id='$period_id' AND number='$number' AND year='$year'");

		if($row)
		{
			coreDB::doUpdate('bnd_books_periodical', array('number_supply_date' => $upd['number_supply_date']), $row['id']);
		}
		else
		{
			$ins = array(
				'period_id' => $period_id,
				'number'	=> $number,
				'year'		=> $year,
				'number_supply_date' => $upd['number_supply_date'],
			);

			coreDB::doInsert('bnd_books_periodical', $ins);
		}
	}

	protected function buildOrderBy($sidx, $sord)
	{
		return "ORDER BY $sidx $sord, n.year ASC, n.number ASC";	
	}

	protected function getHalfYears()
	{
                 ........
	}

	protected function searchOpNumber($c, $val)
	{
		list($year, $half) = array_map('intval', explode('-', $val));

		$op = ($half == 1) ? '<=' : '>';

		return "(n.year = '$year' AND (n.number - p.n_shift) $op (p.numbers::numeric / 2))";
	}
}
Код шаблона:
PHP:
<script>
{$jq_loader->render('jq_purchase2')}
{literal}
    width: 1800,
	height: 560,
	rowNum: 50,
    caption: 'Подписки - Закупка',
	sortname: 'period_id',
	sortorder: 'desc',
	gridComplete: function()
	{
		$(this).jqGrid('extHighlight');
		
		gridTooltip(this);
		gridPreserveSelection(this);
	},
	onSelectRow: function(id)
	{
		$(this).find('TR.jqgrow[editable=1]').each(function()
		{
			$jq_purchase2.restoreRow($(this).attr('id'));
		});
	},
	ondblClickRow: function(id)
	{
		$(this).editRow(id);
	}
});

$grid.navGrid(pager,{add: false, edit:false, edittext:'Изменить', del: false, deltext:'Удалить', refresh:true, refreshtext: 'Обновить', search:false});

$grid.jqGrid('navButtonAdd',pager,{caption:"Excel",title:"Excel",buttonicon :'ui-icon-extlink', onClickButton:
function()
{
	$jq_purchase2.jqGrid('extExport', {'export' : 'ExcelHtml', 'rows': -1});
}});

$grid.addClass('grid-nice');
$grid.filterToolbar();
{/literal}
</script>
Сколько нужно кода и времени, чтобы реализовывать все то же самое без плагинов и библиотек?
 

alexvanstalkeR-

Новичок
~WR~
а вы не могли бы поподробнее рассказать про группировку колонок в шапке?
 

~WR~

Новичок
Это одна из многих дополнительных JS-фич, которые пока не включены в публичный релиз.

Принцип работы следующий:
1. Копирует ряд с заголовками, удаляет текст из <th>, вставляет перед оригинальным заголовком с height: 0px;
2. Динамически создает дополнительный ряд с группировкой колонок, используя colspan. Вставляет перед оригинальным заголовком.
3. Profit. :)

Без дополнительного скрытого ряда нельзя обойтись, т.к. в этом случае слетит ширина сгруппированных заголовков.
Если нужно - могу выложить при следующем обновлении и добавить пример.
 

alexvanstalkeR-

Новичок
Да, было бы интересно взглянуть на вашу реализацию, потому что именно с шириной в свое время очень сильно помучались!
 

~WR~

Новичок
Ок, будет завтра к вечеру. С кодом и живым примером.
 

FRIE

Новичок
интересная штука только не понятно как ею пользоваться =)
 

~WR~

Новичок
Основные разработчики jqGrid сами заинтересовались проблемой группировки заголовков.
Думаю, скоро появится родной вариант. Общими усилиями)
 

Redjik

Джедай-мастер
Совершенно так же думали в начале. Первая админка была написана на Ext JS 3.
В итоге отказались от него, т.к. в реальной жизни он создавал слишком много проблем.

1. Тормозит. Очень тяжелое DOM-дерево. Грид состоит из огромного количества вложенных div'ов.
Когда на одно экране 2-3 грида по 100-500 элементов в каждом - обычный офисный комп уже начинал задумываться.

Таблица в jqGrid - это обычный table.
Насколько я знаю, в свежем Ext JS 4 тоже переделали грид на таблицы. Возможно, этот пункт уже неактуален.

2. Сложно реализовать функции, которых нет в стандартном наборе. Например, попробуйте покрасить первые три строки таблицы в красный цвет без геморроя?

С jqGrid к нашим услугам вся мощь и простота jQuery:
PHP:
gridComplete: function()
{
    $(this).find('TR.jqgrow:lt(3)').addClass('red');
}
- все, покрасилось. Проще некуда.

3. Очень геморройный debug. Из-за сложного многоэтажного JSON-like синтаксиса легко ошибиться и поставить лишнюю скобочку. Это приводит к мало информативным ошибкам вида:
PHP:
B is undefined in ext-core.js line 123
А там огромное ядро с десятками вложенных функций и объектов. Сиди-думай, где ты промахнулся.

4. Плохая обратная совместимость. При обновлении необходимо проводить полную ревизию кода и проверять, все ли работает. Интерфейсов около ста.

У jQuery, jQuery UI и jqGrid практически 100% обратная совместимость. Накатываем актуальные версии почти не глядя, при этом всего два раза было, чтобы что-то сломалось. И то причина была в вольностях при написании селекторов, которые были запрещены начиная с версии 1.4 (вольности, а не селекторы))

===============

Примерно такие мысли.
Но, да, Ext JS очень круто выглядит на картинках и в примерах.
1) В 4м - такого не замечал,
2)Покрасить первые три строки очень просто... настраивается вывод через renderer
3) Руками забиваете чтоли?
4) Можно в песочнице запускать, а вообще есть циклы статей и целые книги по переходу с 3го на 4ый...

Просто что бы вы не придумали по функционалу - в Ext это уже есть... Так что я потратил побольше времени - настроил выводы - все работает как часы...
 

~WR~

Новичок
Снова пять копеек про Ext:
http://habrahabr.ru/blogs/extjs/129080/

Вот люди хотят сделать что-то чуть нестандартное, и ... героически решают проблемы. Вместо собственно разработки - копаются в исходниках, что-то там мутят, строят костыли и патчи. В комментах - то же самое. И то же самое слышу от знакомых в реале, которые что-то делают на Ext. Не знаю.. как-то всё это >__<

Btw, группировка заголовков уже добавлена в ядро jqGrid и появится в следующей версии.
Благодаря гитхабу, любые новые фичи обсуждаются и добавляются в кратчайшие сроки. :D
 

Valey

Новичок
День добрый.
Попытались подружить jqGridPHP c MSSQL. Создали файл Mssql.php из Mysql.php. Если задать неверные данные для подключения к БД, то выдает ошибку, если верные то нет. Так что думаем что к БД он цепляется. Но вот данные из таблицы не выводит. В чем может быть проблема?

Mssql.php
PHP:
<?php
/**
 * Sample MySQL driver
 * It's just an example - use PDO if you can
 */

class jqGrid_DB_Mssql extends jqGrid_DB
{
	protected $db_type = 'mssql';

	public function link()
	{
		static $link = null;

		if(!$link)
		{
			$host = $this->loader->get('db_host');
			$user = $this->loader->get('db_user');
			$pass = $this->loader->get('db_pass');
			$name = $this->loader->get('db_name');

			$link = mssql_connect($host, $user, $pass);

			if(!$link)
			{
				$this->throwMssqlException();
			}

			if(!mssql_select_db($name, $link))
			{
				$this->throwMssqlException();
			}
		}

		return $link;
	}

	public function query($query)
	{
		$result = mssql_query($query, $this->link());
                    /*
		if(!$result)
		{
			$this->throwMysqlException();
		}               */

		return $result;
	}

	public function fetch($result)
	{
		return mssql_fetch_assoc($result);
	}
       //       аналог mysql_real_escape_string для сиквела

	function mssql_escape_string($string)
	{
      return str_replace("'", "''", $string);
	}


	public function quote($val)
	{
		if(is_null($val))
		{
			return null;
		}

		//return "'" . mssql_escape_string($val, $this->link()) . "'";
		return "'" . str_replace("'", "''", $val, $this->link()) . "'";
	}






	public function rowCount($result)
	{
		return mssql_rows_affected($this->link());
	}

	protected function throwMssqlException()
	{
		throw new jqGrid_Exception_DB(mssql_error(), null, mssql_errno());
	}
}
 

~WR~

Новичок
С mssql сам лично не работал, но знаю, что там есть одна большая проблема - нет конструкции LIMIT + OFFSET.
А именно она используется в конструкторе запросов jqGridPHP по умолчанию.

Если покажете, как должен выглядеть запрос по выборке X рядов, начиная с ряда Y - напишу адаптер.
Сам в интернете нашел только это: http://msdn.microsoft.com/en-us/library/ms979197

Но там какая-то нездоровая жуть. -__-
 
Сверху