PageRenderTime 51ms CodeModel.GetById 15ms RepoModel.GetById 0ms app.codeStats 0ms

/lib/blocks/manage.php

https://github.com/Icybee/Icybee
PHP | 1515 lines | 880 code | 257 blank | 378 comment | 84 complexity | 182a27744f0a69900242071124f96b41 MD5 | raw file
Possible License(s): BSD-3-Clause
  1. <?php
  2. /*
  3. * This file is part of the Icybee package.
  4. *
  5. * (c) Olivier Laviale <olivier.laviale@gmail.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Icybee;
  11. use ICanBoogie\ActiveRecord\Model;
  12. use ICanBoogie\ActiveRecord\Query;
  13. use ICanBoogie\I18n;
  14. use ICanBoogie\Operation;
  15. use Brickrouge\Alert;
  16. use Brickrouge\Button;
  17. use Brickrouge\Element;
  18. use Brickrouge\Form;
  19. use Brickrouge\Ranger;
  20. use Brickrouge\Text;
  21. use Icybee\Element\ActionbarSearch;
  22. use Icybee\ManageBlock\Column;
  23. use Icybee\ManageBlock\Options;
  24. use Icybee\ManageBlock\Translator;
  25. use Icybee\Element\ActionbarContextual;
  26. /* @var $column \Icybee\ManageBlock\Column */
  27. /**
  28. * An element to manage the records of a module.
  29. *
  30. * @property-read \ICanBoogie\ActiveRecord\Model $model
  31. * @property-read string $primary_key The primary key of the records.
  32. * @property-read Options $options The display options.
  33. * @property-read bool $is_filtering `true` if records are filtered.
  34. * @property-read Translator $t The translator used by the element.
  35. *
  36. * @changes-20130622
  37. *
  38. * - All extend_column* methods are removed.
  39. * - alter_range_query() signature changed, $options is now an instance of Options an not an array.
  40. * - AlterColumnsEvent has been redesigned, `records` is removed.
  41. *
  42. * @TODO-20130626:
  43. *
  44. * - [filters][options] -> [filter_options]
  45. * - throw error when COLUMNS_ORDER use an undefined column.
  46. */
  47. class ManageBlock extends Element
  48. {
  49. const DISCREET_PLACEHOLDER = '<span class="lighter">―</span>';
  50. const T_BLOCK = '#manager-block';
  51. const T_COLUMNS_ORDER = '#manager-columns-order';
  52. const T_ORDER_BY = '#manager-order-by';
  53. #
  54. # sort constants
  55. #
  56. const ORDER_ASC = 'asc';
  57. const ORDER_DESC = 'desc';
  58. static protected function add_assets(\Brickrouge\Document $document)
  59. {
  60. parent::add_assets($document);
  61. $document->js->add('manage.js', -170);
  62. $document->css->add(\Icybee\ASSETS . 'css/manage.css', -170);
  63. $document->js->add('manage/operations.js', -170);
  64. }
  65. /**
  66. * Currently used module.
  67. *
  68. * @var \ICanBoogie\Module
  69. */
  70. public $module;
  71. /**
  72. * Currently used model.
  73. *
  74. * @var \ICanBoogie\ActiveRecord\Model
  75. */
  76. protected $model;
  77. /**
  78. * Returns the {@link $model} property.
  79. *
  80. * @return \ICanBoogie\ActiveRecord\Model
  81. */
  82. protected function get_model()
  83. {
  84. return $this->model;
  85. }
  86. /**
  87. * The columns of the element.
  88. *
  89. * @var array[string]Column
  90. */
  91. protected $columns;
  92. /**
  93. * The records to display.
  94. *
  95. * @var array[]ActiveRecord
  96. */
  97. protected $records;
  98. /**
  99. * The total number of records matching the filters.
  100. *
  101. * @var int
  102. */
  103. protected $count;
  104. /**
  105. * Returns the primary key of the records.
  106. *
  107. * @var string
  108. */
  109. protected function get_primary_key()
  110. {
  111. return $this->model->primary;
  112. }
  113. /**
  114. * Jobs that can be applied to the records.
  115. *
  116. * @var array[string]mixed
  117. */
  118. protected $jobs = array();
  119. protected $browse;
  120. /**
  121. * Proxis translator with the following scope: "manager.<module_flat_id>"
  122. *
  123. * @var \ICanBoogie\I18n\Translator\Proxi
  124. */
  125. protected $t;
  126. /**
  127. * Returns the {@link $t} property.
  128. *
  129. * @return \ICanBoogie\I18n\Translator\Proxi
  130. */
  131. protected function get_t()
  132. {
  133. return $this->t;
  134. }
  135. /**
  136. * Display options.
  137. *
  138. * @var Options
  139. */
  140. protected $options;
  141. /**
  142. * Returns the {@link $options} property.
  143. *
  144. * @return \Icybee\ManageBlock\Options
  145. */
  146. protected function get_options()
  147. {
  148. return $this->options;
  149. }
  150. public function __construct(Module $module, array $attributes)
  151. {
  152. ## 20130625: checking deprecated methods
  153. if (method_exists($this, 'get_query_conditions'))
  154. {
  155. throw new \Exception("The <q>get_query_conditions()</q> method is deprecated. Use <q>alter_query()</q> instead.");
  156. }
  157. if (method_exists($this, 'extend_column'))
  158. {
  159. throw new \Exception("The <q>extend_column()</q> method is deprecated. Define columns with classes.");
  160. }
  161. if (method_exists($this, 'extend_columns'))
  162. {
  163. throw new \Exception("The <q>extend_columns()</q> method is deprecated. Define columns with classes.");
  164. }
  165. if (method_exists($this, 'retrieve_options'))
  166. {
  167. throw new \Exception("The <q>retrieve_options()</q> method is deprecated. Use <q>resolve_options()</q>.");
  168. }
  169. if (method_exists($this, 'store_options'))
  170. {
  171. throw new \Exception("The <q>store_options()</q> method is deprecated. Use the Options instance.");
  172. }
  173. if (method_exists($this, 'alter_range_query'))
  174. {
  175. throw new \Exception("The <q>alter_range_query()</q> method is deprecated. Use columns and <q>alter_query_with_limit()</q>.");
  176. }
  177. if (method_exists($this, 'load_range'))
  178. {
  179. throw new \Exception("The <q>load_range()</q> method is deprecated. Use <q>fetch_records()</q>.");
  180. }
  181. if (method_exists($this, 'parseColumns'))
  182. {
  183. throw new \Exception("The <q>parseColumns()</q> method is deprecated. Use <q>resolve_columns()</q>.");
  184. }
  185. if (method_exists($this, 'columns'))
  186. {
  187. throw new \Exception("The <q>columns()</q> method is deprecated. Use <q>get_available_columns()</q>.");
  188. }
  189. if (method_exists($this, 'jobs'))
  190. {
  191. throw new \Exception("The <q>jobs()</q> method is deprecated. Use <q>get_available_jobs()</q>.");
  192. }
  193. if (method_exists($this, 'addJob'))
  194. {
  195. throw new \Exception("The <q>addJob()</q> method is deprecated. Use <q>resolve_jobs()</q>.");
  196. }
  197. if (method_exists($this, 'getJobs'))
  198. {
  199. throw new \Exception("The <q>getJobs()</q> method is deprecated. Use <q>render_jobs()</q>.");
  200. }
  201. if (method_exists($this, 'render_limiter'))
  202. {
  203. throw new \Exception("The <q>render_limiter()</q> method is deprecated. Use <q>render_controls()</q>.");
  204. }
  205. $class_reflection = new \ReflectionClass($this);
  206. foreach ($class_reflection->getMethods() as $method_reflection)
  207. {
  208. if (strpos($method_reflection->name, 'extend_column_') === 0)
  209. {
  210. throw new \Exception("The <q>{$method_reflection->name}</q> method is deprecated. Use a column class.");
  211. }
  212. if (strpos($method_reflection->name, 'render_column_') === 0)
  213. {
  214. throw new \Exception("The <q>{$method_reflection->name}</q> method is deprecated. Use a column class.");
  215. }
  216. if (strpos($method_reflection->name, 'render_cell_') === 0)
  217. {
  218. throw new \Exception("The <q>{$method_reflection->name}</q> method is deprecated. Use a column class.");
  219. }
  220. }
  221. ## /20130625
  222. parent::__construct('div', $attributes + array('class' => 'listview listview-interactive'));
  223. $this->module = $module;
  224. $this->model = $module->model;
  225. $this->t = new Translator($module);
  226. $this->columns = $this->get_columns();
  227. $this->jobs = $this->get_jobs();
  228. }
  229. /**
  230. * Returns the available columns.
  231. *
  232. * @return array[string]mixed
  233. */
  234. protected function get_available_columns()
  235. {
  236. $primary_key = $this->model->primary;
  237. if ($primary_key)
  238. {
  239. return array($primary_key => 'Icybee\ManageBlock\KeyColumn');
  240. }
  241. return array();
  242. }
  243. protected function get_columns()
  244. {
  245. $columns = $this->get_available_columns();
  246. new \Icybee\ManageBlock\RegisterColumnsEvent($this, $columns);
  247. $columns = $this->resolve_columns($columns);
  248. new \Icybee\ManageBlock\AlterColumnsEvent($this, $columns);
  249. foreach ($columns as $column_id => $column)
  250. {
  251. if ($column instanceof Column)
  252. {
  253. continue;
  254. }
  255. throw new \UnexpectedValueException(\ICanBoogie\format
  256. (
  257. 'Column %id must be an instance of Column. Given: %type. :data', array
  258. (
  259. '%id' => $column_id,
  260. '%type' => gettype($column),
  261. ':data' => $column
  262. )
  263. ));
  264. }
  265. return $columns;
  266. }
  267. protected function resolve_columns(array $columns)
  268. {
  269. $columns_order = $this[self::T_COLUMNS_ORDER];
  270. if ($columns_order)
  271. {
  272. $primary = $this->model->primary;
  273. if ($primary)
  274. {
  275. array_unshift($columns_order, $primary);
  276. }
  277. $columns_order = array_combine($columns_order, array_fill(0, count($columns_order), null));
  278. $columns = array_intersect_key($columns, $columns_order);
  279. $columns = array_merge($columns_order, $columns);
  280. }
  281. $resolved_columns = array();
  282. foreach ($columns as $id => $options)
  283. {
  284. if ($options === null)
  285. {
  286. throw new \Exception(\ICanBoogie\format("Column %id is not defined.", array('id' => $id)));
  287. }
  288. $construct = __CLASS__ . '\Column';
  289. if (is_string($options))
  290. {
  291. $construct = $options;
  292. $options = array();
  293. }
  294. $resolved_columns[$id] = new $construct($this, $id, $options);
  295. }
  296. return $resolved_columns;
  297. }
  298. /**
  299. * Returns the available jobs.
  300. *
  301. * @return array[string]mixed
  302. */
  303. protected function get_available_jobs()
  304. {
  305. return array();
  306. }
  307. /**
  308. * Returns the jobs.
  309. *
  310. * @return array[string]mixed
  311. */
  312. protected function get_jobs()
  313. {
  314. $jobs = $this->get_available_jobs();
  315. $jobs = $this->resolve_jobs($jobs);
  316. return $jobs;
  317. }
  318. /**
  319. * Resolves the available jobs.
  320. *
  321. * @param array $jobs
  322. *
  323. * @return array
  324. */
  325. protected function resolve_jobs(array $jobs)
  326. {
  327. if ($this->primary_key)
  328. {
  329. $jobs = array_merge(array(Module::OPERATION_DELETE => $this->t('delete.operation.short_title')), $jobs);
  330. }
  331. return $jobs;
  332. }
  333. /**
  334. * Update filters with the specified modifiers.
  335. *
  336. * The extended schema of the model is used to automatically handle booleans, integers,
  337. * dates (date, datetime and timestamp) and strings (char, varchar).
  338. *
  339. * @param array $filters
  340. * @param array $modifiers
  341. *
  342. * @return array Updated filters.
  343. */
  344. protected function update_filters(array $filters, array $modifiers)
  345. {
  346. static $as_strings = array('char', 'varchar', 'date', 'datetime', 'timestamp');
  347. $fields = $this->model->extended_schema['fields'];
  348. foreach ($modifiers as $identifier => $value)
  349. {
  350. if (empty($fields[$identifier]))
  351. {
  352. continue;
  353. }
  354. $type = $fields[$identifier]['type'];
  355. if ($type == 'boolean')
  356. {
  357. $value = $value === '' ? null : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
  358. }
  359. else if ($type == 'integer')
  360. {
  361. $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE);
  362. }
  363. else if (in_array($type, $as_strings))
  364. {
  365. if ($value === '')
  366. {
  367. $value = null;
  368. }
  369. }
  370. else continue;
  371. if ($value === null)
  372. {
  373. unset($filters[$identifier]);
  374. continue;
  375. }
  376. $filters[$identifier] = $value;
  377. }
  378. foreach ($this->columns as $id => $column)
  379. {
  380. $filters = $column->alter_filters($filters, $modifiers);
  381. }
  382. return $filters;
  383. }
  384. /**
  385. * Updates options with the provided modifiers.
  386. *
  387. * The method updates the `order`, `start`, `limit`, `search` and `filters` options.
  388. *
  389. * The `start` options is reset to 1 when the `order`, `search` or `filters` options change.
  390. *
  391. * @param array $options Previous options.
  392. * @param array $modifiers Options modifiers.
  393. *
  394. * @return array Updated options.
  395. */
  396. protected function update_options(Options $options, array $modifiers)
  397. {
  398. $modifiers['filters'] = $this->update_filters($options->filters, $modifiers);
  399. return $options->update($modifiers);
  400. }
  401. /**
  402. * Resolves the display order of the records according to the default options and the
  403. * available columns.
  404. *
  405. * If the column that should be used to order the records does not exists, the order is
  406. * reseted.
  407. *
  408. * If the order direction if not defined, the default direction of the column if used
  409. * instead.
  410. *
  411. * @param string|null $order_by The identifier of the column used to order the records.
  412. * @param string|int|null $order_direction The direction of the ordering. One of: "asc",
  413. * "desc", 1, -1 or `null`.
  414. *
  415. * @return array Returns an array made of the column identifier and the order direction.
  416. */
  417. protected function resolve_order($order_by, $order_direction)
  418. {
  419. $columns = $this->columns;
  420. $default_order = $this[self::T_ORDER_BY];
  421. if (!$order_by && $default_order)
  422. {
  423. list($order_by, $order_direction) = (array) $default_order + array(1 => 'desc');
  424. $order_direction = ($order_direction == 'desc') ? -1 : 1;
  425. }
  426. if ($order_by && empty($columns[$order_by]))
  427. {
  428. \ICanBoogie\log_error("Undefined column for order: !order.", array('order' => $order_by));
  429. $order_by = null;
  430. $order_direction = null;
  431. }
  432. if (!$order_direction && isset($columns[$order_by]))
  433. {
  434. $order_direction = $columns[$order_by]->default_order;
  435. }
  436. return array($order_by, $order_direction);
  437. }
  438. /**
  439. * Returns the options for the element.
  440. *
  441. * Options are restored from the storing backend and updated according to the supplied
  442. * modifiers.
  443. *
  444. * @param array $modifiers
  445. *
  446. * @return Options
  447. */
  448. protected function resolve_options($name, array $modifiers)
  449. {
  450. $options = new Options($name);
  451. $options->retrieve();
  452. $options = $this->update_options($options, $modifiers);
  453. list($order_by, $order_direction) = $this->resolve_order($options->order_by, $options->order_direction);
  454. $options->order_by = $order_by;
  455. $options->order_direction = $order_direction;
  456. $options->store();
  457. return $options;
  458. }
  459. /**
  460. * Renders the element.
  461. *
  462. * If an error occurd while creating the query or fecthing the records, the filters and the
  463. * order are reseted.
  464. */
  465. public function render()
  466. {
  467. global $core;
  468. $options = $this->options = $this->resolve_options($this->module->flat_id, $core->request->params);
  469. $order_by = $options->order_by;
  470. if ($order_by)
  471. {
  472. $order_column = $this->columns[$order_by];
  473. $order_column->order = $options->order_direction;
  474. }
  475. try
  476. {
  477. $query = $this->resolve_query($options);
  478. $records = $this->fetch_records($query);
  479. if ($records)
  480. {
  481. $records = $this->alter_records($records);
  482. $this->records = array_values($records);
  483. }
  484. }
  485. catch (\Exception $e)
  486. {
  487. $options->order_by = null;
  488. $options->order_direction = null;
  489. $options->filters = array();
  490. $options->store();
  491. $rendered_exception = \Brickrouge\render_exception($e);
  492. return <<<EOT
  493. <div class="alert alert-error alert-block undissmisable">
  494. <p>There was an error in the SQL statement, orders and filters have been reseted,
  495. please reload the page.</p>
  496. $rendered_exception
  497. </div>
  498. EOT;
  499. }
  500. $html = parent::render();
  501. $document = \Brickrouge\get_document();
  502. foreach ($this->columns as $column)
  503. {
  504. $column->add_assets($document);
  505. }
  506. return $html;
  507. }
  508. /**
  509. * Renders the object into a HTML string.
  510. */
  511. protected function render_inner_html()
  512. {
  513. global $core;
  514. $records = $this->records;
  515. $options = $this->options;
  516. if ($records || $options->filters)
  517. {
  518. if ($records)
  519. {
  520. $body = '<tbody>' . $this->render_body() . '</tbody>';
  521. }
  522. else
  523. {
  524. $body = '<tbody class="empty"><tr><td colspan="' . count($this->columns) . '">' . $this->render_empty_body() . '</td></tr></tbody>';
  525. }
  526. $head = $this->render_head();
  527. $foot = $this->render_foot();
  528. $content = <<<EOT
  529. <table>
  530. $head
  531. $foot
  532. $body
  533. </table>
  534. EOT;
  535. }
  536. else
  537. {
  538. $body = $this->render_empty_body();
  539. $foot = $this->render_foot();
  540. $columns_n = count($this->columns);
  541. $content = <<<EOT
  542. <table>
  543. <tbody class="empty" td colspan="$columns_n">$body</tbody>
  544. $foot
  545. </table>
  546. EOT;
  547. }
  548. #
  549. $search = $this->render_search();
  550. $core->events->attach(function(ActionbarSearch\AlterInnerHTMLEvent $event, ActionbarSearch $sender) use($search)
  551. {
  552. $event->html .= $search;
  553. });
  554. #
  555. $rendered_jobs = $this->render_jobs($this->jobs);
  556. $core->events->attach(function(ActionbarContextual\CollectItemsEvent $event, ActionbarContextual $target) use($rendered_jobs) {
  557. $event->items[] = $rendered_jobs;
  558. });
  559. #
  560. return $content;
  561. }
  562. /**
  563. * Wraps the listview in a `form` element.
  564. */
  565. protected function render_outer_html()
  566. {
  567. $html = parent::render_outer_html();
  568. $operation_name = Operation::DESTINATION;
  569. $operation_value = $this->module->id;
  570. $block_name = self::T_BLOCK;
  571. $block_value = $this[self::T_BLOCK] ?: 'manage';
  572. return <<<EOT
  573. <form id="manager" method="GET" action="">
  574. <input type="hidden" name="{$operation_name}" value="{$operation_value}" />
  575. <input type="hidden" name="{$block_name}" value="{$block_value}" />
  576. $html
  577. </form>
  578. EOT;
  579. }
  580. /**
  581. * Resolve ActiveRecord query according to the supplied options.
  582. *
  583. * Note: The method updates the {@link $count} property with the number of records matching
  584. * the query, before a range is applied.
  585. *
  586. * @param Options $options
  587. *
  588. * @return \ICanBoogie\ActiveRecord\Query
  589. */
  590. protected function resolve_query(Options $options)
  591. {
  592. $query = new Query($this->model);
  593. $query = $this->alter_query($query, $options->filters);
  594. #
  595. new ManageBlock\AlterQueryEvent($this, $query, $options);
  596. #
  597. $search = $options->search;
  598. if ($search)
  599. {
  600. $query = $this->alter_query_with_search($query, $search);
  601. }
  602. #
  603. # Adjust `start` so that it's never greater than `count`.
  604. #
  605. $start = $options->start;
  606. $count = $this->count = $query->count;
  607. if ($start > $count)
  608. {
  609. $options->start = 1;
  610. $options->store();
  611. }
  612. else if ($start < -$count)
  613. {
  614. $options->start = 1;
  615. $options->store();
  616. }
  617. else if ($start < 0)
  618. {
  619. $start = -(-($start - 1) % $count) + $count;
  620. $start = ceil($start / $options->limit) * $options->limit + 1;
  621. $options->start = $start;
  622. $options->store();
  623. }
  624. $order_by = $options->order_by;
  625. if ($order_by)
  626. {
  627. $query = $this->columns[$order_by]->alter_query_with_order($query, $options->order_direction);
  628. }
  629. return $this->alter_query_with_range($query, $options->start - 1, $options->limit);
  630. }
  631. /**
  632. * Alters the initial query with the specified filters.
  633. *
  634. * The `alter_query` method of each column is invoked in turn to alter the query.
  635. *
  636. * @param Query $query
  637. * @param array $filters
  638. *
  639. * @return Query The altered query.
  640. */
  641. protected function alter_query(Query $query, array $filters)
  642. {
  643. foreach ($this->columns as $id => $column)
  644. {
  645. if (!isset($filters[$id]))
  646. {
  647. continue;
  648. }
  649. $query = $column->alter_query_with_filter($query, $filters[$id]);
  650. }
  651. return $query;
  652. }
  653. /**
  654. * Alters the query according to a search string.
  655. *
  656. * @param Query $query
  657. * @param string $search
  658. *
  659. * @return Query
  660. */
  661. protected function alter_query_with_search(Query $query, $search)
  662. {
  663. static $supported_types = array('char', 'varchar', 'text');
  664. $words = explode(' ', $search);
  665. $words = array_map('trim', $words);
  666. $queries = array();
  667. $fields = $this->model->extended_schema['fields'];
  668. foreach ($words as $word)
  669. {
  670. $concats = '';
  671. foreach ($fields as $identifier => $definition)
  672. {
  673. $type = $definition['type'];
  674. if (!in_array($type, $supported_types))
  675. {
  676. continue;
  677. }
  678. if (!empty($definition['null']))
  679. {
  680. $identifier = "IFNULL(`$identifier`, \"\")";
  681. }
  682. $concats .= ', `' . $identifier . '`';
  683. }
  684. if (!$concats)
  685. {
  686. continue;
  687. }
  688. $query->where('CONCAT_WS(" ", ' . substr($concats, 2) . ') LIKE ?', "%{$word}%");
  689. }
  690. return $query;
  691. }
  692. /**
  693. * Alters query with range (offset and limit).
  694. *
  695. * @param Query $query
  696. * @param int $offset The offset of the record to return.
  697. * @param int $limit The maximum number of records to return.
  698. *
  699. * @return Query
  700. */
  701. protected function alter_query_with_range(Query $query, $offset, $limit)
  702. {
  703. return $query->limit($offset, $limit);
  704. }
  705. /**
  706. * Fetches the records matching the query.
  707. *
  708. * @param Query $query
  709. */
  710. protected function fetch_records(Query $query)
  711. {
  712. return $query->all;
  713. }
  714. /**
  715. * Alters records.
  716. *
  717. * The function return the records _as is_ but subclasses can implement the method to
  718. * load all the dependencies of the records in a single step.
  719. *
  720. * @param array $records
  721. *
  722. * @return array
  723. */
  724. protected function alter_records(array $records)
  725. {
  726. foreach ($this->columns as $column)
  727. {
  728. $records = $column->alter_records($records);
  729. }
  730. return $records;
  731. }
  732. /**
  733. * Renders the THEAD element.
  734. *
  735. * @return string The rendered THEAD element.
  736. */
  737. protected function render_head()
  738. {
  739. $cells = '';
  740. foreach ($this->columns as $id => $column)
  741. {
  742. $cells .= $this->render_column($column, $id);
  743. }
  744. return <<<EOT
  745. <thead>
  746. <tr>$cells</tr>
  747. </thead>
  748. EOT;
  749. }
  750. /**
  751. * Renders a column header.
  752. *
  753. * @param array $cell
  754. * @param string $id
  755. *
  756. * @return string The rendered THEAD cell.
  757. */
  758. protected function render_column(Column $column, $id)
  759. {
  760. $class = 'header--' . \Brickrouge\normalize($id) . ' ' . $column->class;
  761. if ($this->count > 1 || $this->options->filters || $this->options->search)
  762. {
  763. $orderable = $column->orderable;
  764. if ($orderable)
  765. {
  766. $class .= ' orderable';
  767. }
  768. $filtering = $column->is_filtering;
  769. if ($filtering)
  770. {
  771. $class .= ' filtering';
  772. }
  773. $filters = $column->filters;
  774. if ($filters)
  775. {
  776. $class .= ' filters';
  777. }
  778. }
  779. else
  780. {
  781. $orderable = false;
  782. $filtering = false;
  783. $filters = array();
  784. }
  785. $header_options = $column->render_options();
  786. if ($header_options)
  787. {
  788. $class .= ' has-options';
  789. }
  790. $header = $column->render_header();
  791. if (!$header)
  792. {
  793. $class .= ' has-no-label';
  794. }
  795. $class = trim($class);
  796. return <<<EOT
  797. <th class="$class"><div>{$header}{$header_options}</div></th>
  798. EOT;
  799. }
  800. /**
  801. * Renders the cells of the columns.
  802. *
  803. * The method returns an array with the following layout:
  804. *
  805. * [<column_id>][] => <cell_content>
  806. *
  807. * @param array $columns The columns to render.
  808. *
  809. * @return array[string]mixed
  810. */
  811. protected function render_columns_cells(array $columns)
  812. {
  813. $rendered_columns_cells = array();
  814. foreach ($columns as $id => $column)
  815. {
  816. foreach ($this->records as $record)
  817. {
  818. try
  819. {
  820. $content = (string) $column->render_cell($record);
  821. }
  822. catch (\Exception $e)
  823. {
  824. $content = \Brickrouge\render_exception($e);
  825. }
  826. $rendered_columns_cells[$id][] = $content;
  827. }
  828. }
  829. return $rendered_columns_cells;
  830. }
  831. /**
  832. * Replaces repeating values of a column with the discreet placeholder.
  833. *
  834. * @param array $rendered_columns_cells
  835. *
  836. * @return array[string]mixed
  837. */
  838. protected function apply_discreet_filter(array $rendered_columns_cells)
  839. {
  840. $discreet_column_cells = $rendered_columns_cells;
  841. $columns = $this->columns;
  842. foreach ($discreet_column_cells as $id => &$cells)
  843. {
  844. $column = $columns[$id];
  845. if (!$column->discreet)
  846. {
  847. continue;
  848. }
  849. $last_content = null;
  850. foreach ($cells as &$content)
  851. {
  852. if ($last_content !== $content || !$content)
  853. {
  854. $last_content = $content;
  855. continue;
  856. }
  857. $content = self::DISCREET_PLACEHOLDER;
  858. }
  859. }
  860. return $discreet_column_cells;
  861. }
  862. /**
  863. * Convert rendered columns cells to rows.
  864. *
  865. * @param array $rendered_columns_cells
  866. *
  867. * @return array[]array
  868. */
  869. protected function columns_to_rows(array $rendered_columns_cells)
  870. {
  871. $rows = array();
  872. foreach ($rendered_columns_cells as $column_id => $cells)
  873. {
  874. foreach ($cells as $i => $cell)
  875. {
  876. $rows[$i][$column_id] = $cell;
  877. }
  878. }
  879. return $rows;
  880. }
  881. /**
  882. * Renders the specified rows.
  883. *
  884. * The rows are rendered as an array of {@link Element} instances representing `TR` elements.
  885. *
  886. * @param array $rows
  887. *
  888. * @return array[]Element
  889. */
  890. protected function render_rows(array $rows)
  891. {
  892. global $core;
  893. $rendered_rows = array();
  894. $columns = $this->columns;
  895. $records = $this->records;
  896. $key = $this->primary_key;
  897. $module = $this->module;
  898. $user = $core->user;
  899. foreach ($rows as $i => $cells)
  900. {
  901. $html = '';
  902. foreach ($cells as $column_id => $cell)
  903. {
  904. $html .= '<td class="'
  905. . trim('cell--' . \Brickrouge\normalize($column_id) . ' ' . $columns[$column_id]->class)
  906. . '">' . ($cell ?: '&nbsp;') . '</td>';
  907. }
  908. $tr = new Element('tr', array(Element::INNER_HTML => $html));
  909. if ($key && !$user->has_ownership($module, $records[$i]))
  910. {
  911. $tr->add_class('no-ownership');
  912. }
  913. $rendered_rows[] = $tr;
  914. }
  915. return $rendered_rows;
  916. }
  917. /**
  918. * Renders table body.
  919. *
  920. * @return string
  921. */
  922. protected function render_body()
  923. {
  924. $rendered_cells = $this->render_columns_cells($this->columns);
  925. new ManageBlock\AlterRenderedCellsEvent($this, $rendered_cells, $this->records);
  926. $rendered_cells = $this->apply_discreet_filter($rendered_cells);
  927. $rows = $this->columns_to_rows($rendered_cells);
  928. $rendered_rows = $this->render_rows($rows);
  929. return implode(PHP_EOL, $rendered_rows);
  930. }
  931. /**
  932. * Renders an alternate body when there is no record to display.
  933. *
  934. * @return \Brickrouge\Alert
  935. */
  936. protected function render_empty_body()
  937. {
  938. $search = $this->options->search;
  939. $filters = $this->options->filters;
  940. $context = null;
  941. if ($search)
  942. {
  943. $message = $this->t('Your search <q><strong>!search</strong></q> did not match any record.<br /><br /><a href="?q=" rel="manager/search" data-action="reset" class="btn btn-warning">Reset search filter</a>', array('!search' => $search));
  944. }
  945. else if ($filters)
  946. {
  947. // TODO-20130629: column should implement a humanize_filter() method that would return a humanized filter expression.
  948. $filters = implode(', ', $filters);
  949. $message = $this->t('Your selection <q><strong>!selection</strong></q> dit not match any record.', array('!selection' => $filters));
  950. }
  951. else
  952. {
  953. $message = $this->t('create_first', array('!url' => \ICanBoogie\Routing\contextualize("/admin/{$this->module->id}/new")));
  954. $context = 'info';
  955. }
  956. return new Alert($message, array(Alert::CONTEXT => $context, 'class' => 'alert listview-alert'));
  957. }
  958. /**
  959. * Renders the "search" element to be injected in the document.
  960. *
  961. * @return \Brickrouge\Form
  962. */
  963. protected function render_search()
  964. {
  965. $search = $this->options->search;
  966. return new Element
  967. (
  968. 'div', array
  969. (
  970. Element::CHILDREN => array
  971. (
  972. 'q' => new Text
  973. (
  974. array
  975. (
  976. 'title' => $this->t('Search in the records'),
  977. 'value' => $search,
  978. 'size' => '16',
  979. 'class' => 'search',
  980. 'tabindex' => 0,
  981. 'data-placeholder' => $this->t('Search')
  982. )
  983. ),
  984. new Button
  985. (
  986. '', array
  987. (
  988. 'type' => 'button',
  989. 'class' => 'icon-remove'
  990. )
  991. )
  992. ),
  993. 'class' => 'listview-search'
  994. )
  995. );
  996. }
  997. /**
  998. * Renders listview controls.
  999. *
  1000. * @return string
  1001. */
  1002. protected function render_controls()
  1003. {
  1004. $count = $this->count;
  1005. $start = $this->options->start;
  1006. $limit = $this->options->limit;
  1007. if ($count <= 10)
  1008. {
  1009. $content = $this->t($this->is_filtering || $this->options->search ? "records_count_with_filters" : "records_count", array(':count' => $count));
  1010. return <<<EOT
  1011. <div class="listview-controls">
  1012. $content
  1013. </div>
  1014. EOT;
  1015. }
  1016. $ranger = new Ranger
  1017. (
  1018. 'div', array
  1019. (
  1020. Ranger::T_START => $start,
  1021. Ranger::T_LIMIT => $limit,
  1022. Ranger::T_COUNT => $count,
  1023. Ranger::T_EDITABLE => true,
  1024. Ranger::T_NO_ARROWS => true,
  1025. 'class' => 'listview-start'
  1026. )
  1027. );
  1028. $page_limit_selector = null;
  1029. if ($limit >= 20 || $count >= $limit)
  1030. {
  1031. $page_limit_selector = new Element
  1032. (
  1033. 'select', array
  1034. (
  1035. Element::OPTIONS => array(10 => 10, 20 => 20, 50 => 50, 100 => 100),
  1036. 'title' => $this->t('Number of item to display by page'),
  1037. 'name' => 'limit',
  1038. 'onchange' => 'this.form.submit()',
  1039. 'value' => $limit
  1040. )
  1041. );
  1042. $page_limit_selector = '<div class="listview-limit">' . $this->t(':page_limit_selector by page', array(':page_limit_selector' => (string) $page_limit_selector)) . '</div>';
  1043. }
  1044. $browse = null;
  1045. if ($count > $limit)
  1046. {
  1047. $browse = <<<EOT
  1048. <div class="listview-browse">
  1049. <a href="?start=previous" class="browse previous" rel="manager"><i class="icon-arrow-left"></i></a>
  1050. <a href="?start=next" class="browse next" rel="manager"><i class="icon-arrow-right"></i></a>
  1051. </div>
  1052. EOT;
  1053. }
  1054. $this->browse = $browse;
  1055. # the hidden select is a trick for vertical alignement with the operation select
  1056. return <<<EOT
  1057. <div class="listview-controls">
  1058. {$ranger}{$page_limit_selector}{$browse}
  1059. </div>
  1060. EOT;
  1061. }
  1062. /**
  1063. * Renders jobs as an HTML element.
  1064. *
  1065. * @param array $jobs
  1066. *
  1067. * @return \Brickrouge\Element\null
  1068. */
  1069. protected function render_jobs(array $jobs)
  1070. {
  1071. if (!$jobs)
  1072. {
  1073. return;
  1074. }
  1075. $children = array();
  1076. foreach ($jobs as $operation => $label)
  1077. {
  1078. $children[] = new Button($label, array('data-operation' => $operation, 'data-target' => 'manager'));
  1079. }
  1080. return new Element
  1081. (
  1082. 'div', array
  1083. (
  1084. Element::CHILDREN => array
  1085. (
  1086. '<i class="icon-warning-sign context-icon"></i>',
  1087. /*
  1088. new Element
  1089. (
  1090. 'label', array
  1091. (
  1092. Element::INNER_HTML => "Pour la sĂŠlection&nbsp;:",
  1093. 'class' => 'btn-group-label'
  1094. )
  1095. ),
  1096. */
  1097. new Element
  1098. (
  1099. 'div', array
  1100. (
  1101. Element::CHILDREN => $children,
  1102. 'class' => 'btn-group'
  1103. )
  1104. )
  1105. ),
  1106. 'data-actionbar-context' => 'operations',
  1107. 'class' => 'listview-operations inline'
  1108. )
  1109. );
  1110. }
  1111. /**
  1112. * Renders the element's footer.
  1113. *
  1114. * @return string
  1115. */
  1116. protected function render_foot()
  1117. {
  1118. $ncolumns = count($this->columns);
  1119. $key_column = $this->primary_key ? '<td class="key">&nbsp;</td>' : '';
  1120. $rendered_jobs = null;
  1121. $rendered_controls = $this->render_controls();
  1122. return <<<EOT
  1123. <tfoot>
  1124. <tr>
  1125. $key_column
  1126. <td colspan="{$ncolumns}">{$rendered_jobs}{$rendered_controls}</td>
  1127. </tr>
  1128. </tfoot>
  1129. EOT;
  1130. }
  1131. /**
  1132. * Checks if the view is filtered.
  1133. *
  1134. * @param string $column_id This optional parameter can be used to check if the filter
  1135. * is applied to a specific column.
  1136. *
  1137. * @return boolean
  1138. */
  1139. public function is_filtering($column_id=null)
  1140. {
  1141. return $this->options->is_filtering($column_id);
  1142. }
  1143. protected function get_is_filtering()
  1144. {
  1145. return $this->is_filtering();
  1146. }
  1147. }
  1148. /*
  1149. * Events
  1150. */
  1151. namespace Icybee\ManageBlock;
  1152. use ICanBoogie\ActiveRecord\Query;
  1153. use ICanBoogie\Event;
  1154. use Icybee\ManageBlock;
  1155. use Icybee\ManageBlock\Options;
  1156. /**
  1157. * Event class for the `Icybee\ManageBlock::register_columns` event.
  1158. */
  1159. class RegisterColumnsEvent extends Event
  1160. {
  1161. /**
  1162. * Reference to the columns of the element.
  1163. *
  1164. * @var array[string]array
  1165. */
  1166. public $columns;
  1167. /**
  1168. * The event is constructed with the type `register_columns`.
  1169. *
  1170. * @param \Icybee\ManageBlock $target
  1171. * @param array $columns Reference to the columns of the element.
  1172. */
  1173. public function __construct(ManageBlock $target, array &$columns)
  1174. {
  1175. $this->columns = &$columns;
  1176. parent::__construct($target, 'register_columns');
  1177. }
  1178. public function add(Column $column, $weight=null)
  1179. {
  1180. if ($weight)
  1181. {
  1182. list($position, $relative) = explode(':', $weight) + array('before');
  1183. $this->columns = \ICanBoogie\array_insert($this->columns, $relative, $column, $column->id, $position == 'after');
  1184. }
  1185. else
  1186. {
  1187. $this->columns[$column->id] = $column;
  1188. }
  1189. }
  1190. }
  1191. /**
  1192. * Event class for the `Icybee\ManageBlock::alter_columns` event.
  1193. */
  1194. class AlterColumnsEvent extends Event
  1195. {
  1196. /**
  1197. * Reference to the columns of the element.
  1198. *
  1199. * @var array[string]array
  1200. */
  1201. public $columns;
  1202. /**
  1203. * The event is constructed with the type `alter_columns`.
  1204. *
  1205. * @param \Icybee\ManageBlock $target
  1206. * @param array $columns Reference to the columns of the element.
  1207. */
  1208. public function __construct(ManageBlock $target, array &$columns)
  1209. {
  1210. $this->columns = &$columns;
  1211. parent::__construct($target, 'alter_columns');
  1212. }
  1213. public function add(Column $column, $weight=null)
  1214. {
  1215. if ($weight)
  1216. {
  1217. list($position, $relative) = explode(':', $weight) + array('before');
  1218. $this->columns = \ICanBoogie\array_insert($this->columns, $relative, $column, $column->id, $position == 'after');
  1219. }
  1220. else
  1221. {
  1222. $this->columns[$column->id] = $column;
  1223. }
  1224. }
  1225. }
  1226. class AlterRenderedCellsEvent extends Event
  1227. {
  1228. /**
  1229. * Reference to the rendered cells.
  1230. *
  1231. * @var array[string]string
  1232. */
  1233. public $rendered_cells;
  1234. /**
  1235. * The records used to render the cells.
  1236. *
  1237. * @var array[]\ICanBoogie\ActiveRecord
  1238. */
  1239. public $records;
  1240. public function __construct(ManageBlock $target, array &$rendered_cells, array $records)
  1241. {
  1242. $this->rendered_cells = &$rendered_cells;
  1243. $this->records = $records;
  1244. parent::__construct($target, 'alter_rendered_cells');
  1245. }
  1246. }
  1247. class AlterQueryEvent extends Event
  1248. {
  1249. public $query;
  1250. public $options;
  1251. public function __construct(ManageBlock $target, Query $query, Options $options)
  1252. {
  1253. $this->query = $query;
  1254. $this->options = $options;
  1255. parent::__construct($target, 'alter_query');
  1256. }
  1257. }