SproutCMS

This is the code documentation for the SproutCMS project

source of /sprout/Helpers/Itemlist.php

  1. <?php
  2. /*
  3.  * Copyright (C) 2017 Karmabunny Pty Ltd.
  4.  *
  5.  * This file is a part of SproutCMS.
  6.  *
  7.  * SproutCMS is free software: you can redistribute it and/or modify it under the terms
  8.  * of the GNU General Public License as published by the Free Software Foundation, either
  9.  * version 2 of the License, or (at your option) any later version.
  10.  *
  11.  * For more information, visit <http://getsproutcms.com>.
  12.  */
  13.  
  14. namespace Sprout\Helpers;
  15.  
  16. use InvalidArgumentException;
  17. use PDOStatement;
  18. use Closure;
  19.  
  20.  
  21. /**
  22.  * Used to generate HTML for a table of database records.
  23.  * This is usually used for the admin/contents/* route which provides the main
  24.  * UI to operators for a given {@see ManagedAdminController}
  25.  */
  26. class Itemlist
  27. {
  28. public $items;
  29. public $main_columns;
  30. public $aggregate = [];
  31. public $checkboxes;
  32. public $ordering;
  33. public $table_class = 'main-list';
  34.  
  35. private $row_classes_func = null;
  36.  
  37. private $actions = array();
  38. private $actions_func = null;
  39. private $actions_classes = 'actions--link';
  40.  
  41.  
  42. public function __toString()
  43. {
  44. return (string) $this->render();
  45. }
  46.  
  47. public function render()
  48. {
  49. if (empty($this->main_columns)) {
  50. throw new InvalidArgumentException('No main columns defined');
  51. }
  52.  
  53. if ($this->items instanceof PDOStatement) {
  54. if ($this->items->rowCount() == 0) return;
  55. } else {
  56. if (count($this->items) == 0) return;
  57. }
  58.  
  59. if (isset($this->actions['edit'])) {
  60. $edit_action = $this->actions['edit'];
  61. unset($this->actions['edit']);
  62. } else {
  63. $edit_action = null;
  64. }
  65.  
  66. if ($this->ordering) {
  67. $base_url = Url::withoutArgs('order', 'dir', 'page');
  68. }
  69.  
  70. // All the (raw) values for aggregate columns are stored so the aggregation can process them
  71. $aggregate_vals = [];
  72. foreach ($this->aggregate as $title => $agg_defn) {
  73. $aggregate_vals[$title] = [];
  74. }
  75.  
  76. $val = "<table class=\"" . Enc::html($this->table_class) . "\">\n";
  77.  
  78. $val .= "<thead>\n";
  79. $val .= "<tr>";
  80.  
  81. if ($this->checkboxes) {
  82. $val .= '<th class="selection-all">';
  83. $val .= '<div class="field-element field-element--white field-element--checkbox field-element--checkbox--no-label">';
  84. $val .= '<div class="field-element__input-set">';
  85. $val .= '<div class="fieldset-input">';
  86. $val .= '<input id="itemList-select-all" type="checkbox">';
  87. $val .= '<label for="itemList-select-all"><span class="-vis-hidden">Select all</span></label>';
  88. $val .= '</div>';
  89. $val .= '</div>';
  90. $val .= '</div>';
  91. $val .= '</th>';
  92. }
  93.  
  94. foreach ($this->main_columns as $title => $col_name) {
  95. if (
  96. $this->ordering
  97. and
  98. (
  99. is_string($col_name)
  100. or
  101. (is_array($col_name) and $col_name[0] instanceof SortedColModifier)
  102. )
  103. ) {
  104. $val .= "<th class=\"table-sort-th\">";
  105.  
  106. $field_name = is_array($col_name) ? $col_name[1] : $col_name;
  107.  
  108. if ($_GET['order'] == $field_name and $_GET['dir'] == 'asc') {
  109. $val .= "<a class=\"icon-after icon-keyboard_arrow_up table-sort\" href=\"{$base_url}order={$field_name}&dir=desc\" title=\"Data is currently sorted by this column\">";
  110. $val .= $title;
  111. $val .= "</a>";
  112.  
  113. } else if ($_GET['order'] == $field_name and $_GET['dir'] == 'desc') {
  114. $val .= "<a class=\"icon-after icon-keyboard_arrow_down table-sort\" href=\"{$base_url}order={$field_name}&dir=asc\" title=\"Data is currently sorted by this column (backwards)\">";
  115. $val .= $title;
  116. $val .= "</a>";
  117.  
  118. } else {
  119. $val .= "<a class=\"table-sort\" href=\"{$base_url}order={$field_name}\" title=\"Click to sort by this column\">";
  120. $val .= $title;
  121. $val .= "</a>";
  122. }
  123.  
  124. $val .= "</th>";
  125.  
  126. } else {
  127. $val .= "<th>";
  128. $val .= $title;
  129. $val .= "</th>";
  130. }
  131. }
  132.  
  133. if (count($this->actions) or $this->actions_func) $val .= "<th>&nbsp;</th>";
  134.  
  135. $val .= "</tr>\n";
  136. $val .= "</thead>\n";
  137.  
  138. $val .= "<tbody>\n";
  139. foreach ($this->items as $item) {
  140. $classes = '';
  141. if (isset($this->row_classes_func)) {
  142. $func = $this->row_classes_func;
  143. $classes = $func($item);
  144. }
  145.  
  146. // Fetch aggregate values from row ($item) and load into the temporary array
  147. // This is done on the raw values, rather than processed values, so callback columns won't work
  148. foreach ($this->aggregate as $title => $agg_defn) {
  149. $col_defn = $this->main_columns[$title];
  150. if (is_string($col_defn)) {
  151. $aggregate_vals[$title][] = $item[$col_defn];
  152. } elseif (is_array($col_defn)) {
  153. $aggregate_vals[$title][] = $item[$col_defn[1]];
  154. }
  155. }
  156.  
  157. if ($classes) {
  158. $val .= '<tr class="' . Enc::html($classes) . '">';
  159. } else {
  160. $val .= "<tr>";
  161. }
  162.  
  163. if ($this->checkboxes) {
  164. $val .= "<td class=\"selection\">";
  165.  
  166. $val .= '<div class="field-element field-element--white field-element--checkbox field-element--checkbox--no-label">';
  167. $val .= '<div class="field-element__input-set">';
  168. $val .= '<div class="fieldset-input">';
  169. $val .= "<input type=\"checkbox\" id=\"itemList-checkbox-{$item['id']}\" name=\"ids[]\" value=\"{$item['id']}\">";
  170. $val .= "<label for=\"itemList-checkbox-{$item['id']}\"><span class=\"-vis-hidden\">Select row</span></label>";
  171. $val .= '</div>';
  172. $val .= '</div>';
  173. $val .= '</div>';
  174. $val .= "</td>";
  175. }
  176.  
  177. $i = 0;
  178. foreach ($this->main_columns as $title => $defn) {
  179. if (is_array($defn) and !is_string($defn[1])) {
  180. throw new InvalidArgumentException('Main column must either be a string, or an array with 0: ColModifier, 1: string');
  181. }
  182. $value = self::renderItem($defn, $item);
  183.  
  184. if ($i++ == 0 and $edit_action) {
  185. $url = $this->urlReplace($edit_action['url'], $item);
  186.  
  187. $url = Enc::html($url);
  188. $val .= "<td><a href=\"{$url}\">{$value}</a></td>";
  189. continue;
  190. }
  191.  
  192. $val .= "<td>{$value}</td>";
  193. }
  194.  
  195. if (count($this->actions) or $this->actions_func) {
  196. $val .= "<td class=\"actions\">";
  197.  
  198. foreach ($this->actions as $name => $details) {
  199. $show = $details['show_func'];
  200. if ($show and is_callable($show)) {
  201. $result = $show($item);
  202. if ($result == false) continue;
  203. }
  204.  
  205. $url = $this->urlReplace($details['url'], $item);
  206. $name = ucfirst($name);
  207.  
  208. $name = Enc::html($name);
  209. $url = Enc::html($url);
  210. $class = Enc::html(trim($this->actions_classes . ' ' . $details['classes']));
  211. $val .= "<a href=\"{$url}\" class=\"{$class}\">{$name}</a> ";
  212. }
  213.  
  214. if ($this->actions_func) {
  215. $func = $this->actions_func;
  216. $val .= $func($item);
  217. }
  218.  
  219. $val .= "</td>";
  220. }
  221.  
  222. $val .= "</tr>\n";
  223. }
  224.  
  225. if (!empty($this->aggregate)) {
  226. $val .= "<tr class=\"main-list--aggregate\">\n";
  227.  
  228. if ($this->checkboxes) {
  229. $val .= '<td></td>';
  230. }
  231.  
  232. foreach ($this->main_columns as $title => $col_defn) {
  233. if (empty($this->aggregate[$title])) {
  234. $value = '';
  235. } else {
  236. $agg_defn = $this->aggregate[$title];
  237.  
  238. if (isset($agg_defn['value'])) {
  239. $value = $agg_defn['value'];
  240. } else {
  241. $value = self::calculateAggregateColumn($agg_defn['operation'], $aggregate_vals[$title]);
  242. }
  243.  
  244. if (!empty($agg_defn['modifier'])) {
  245. $value = $agg_defn['modifier']->modify($value, null);
  246. }
  247.  
  248. // Escape value, except if it was processed by an UnescapedColModifier
  249. if (empty($agg_defn['modifier']) or !($agg_defn['modifier'] instanceof UnescapedColModifier)) {
  250. $value = Enc::html($value);
  251. }
  252. }
  253.  
  254. $val .= "<td>{$value}</td>";
  255. }
  256.  
  257. if (count($this->actions) or $this->actions_func) {
  258. $val .= '<td></td>';
  259. }
  260.  
  261. $val .= "</tr>\n";
  262. }
  263.  
  264. $val .= "</tbody>\n";
  265. $val .= "</table>\n";
  266.  
  267. return $val;
  268. }
  269.  
  270.  
  271. /**
  272.   * Set a function which should return the classes to use on the row.
  273.   *
  274.   * string function mycallable(array $row)
  275.   *
  276.   * The return value should be a string of class names
  277.   *
  278.   * @example
  279.   * $itemlist->setRowClassesFunc(function($row){
  280.   * if ($row['id'] == 42) return 'ultimate';
  281.   * return '';
  282.   * });
  283.   *
  284.   * @param callable $func
  285.   **/
  286. public function setRowClassesFunc($func)
  287. {
  288. $this->row_classes_func = $func;
  289. }
  290.  
  291.  
  292. /**
  293.   * Adds an action to this itemlist.
  294.   *
  295.   * The special action 'edit' is used when the row is clicked.
  296.   *
  297.   * @param string $name Link label
  298.   * @param string $url Link URL. This URL is processed by {@see Itemlist::urlReplace} during rendering
  299.   * @param string $classes Additional classes for the A element
  300.   * @param callable $show_func Function called for each row to show or hide this action for that row
  301.   **/
  302. public function addAction($name, $url, $classes = '', callable $show_func = null)
  303. {
  304. $this->actions[$name] = ['url' => $url, 'classes' => $classes, 'show_func' => $show_func];
  305. }
  306.  
  307.  
  308. /**
  309.   * Set link classes common for all actions
  310.   * The default is "actions--link".
  311.   *
  312.   * @example
  313.   * $itemlist->setActionsClasses('button')
  314.   *
  315.   * @param string $classes Classes for the A element
  316.   */
  317. public function setActionsClasses($classes)
  318. {
  319. $this->actions_classes = $classes;
  320. }
  321.  
  322.  
  323. /**
  324.   * Set a function which should return content for the actions column
  325.   * The func should have this signature:
  326.   *
  327.   * string function mycallable(array $row)
  328.   *
  329.   * The return value should be HTML with the links
  330.   **/
  331. public function setActionsFunc($func)
  332. {
  333. $this->actions_func = $func;
  334. }
  335.  
  336.  
  337. /**
  338.   * Add an aggregate which operates on the values of a column
  339.   *
  340.   * @throws InvalidArgumentException Unknown operation
  341.   * @param string $title Column to aggregate values of
  342.   * @param string $operation Aggregation operation, 'sum', 'count', 'avg'
  343.   * @param ColModifier $modifier Column modifier applied after aggregation to format the result
  344.   */
  345. public function addAggregateColumn($title, $operation, ColModifier $modifier = null)
  346. {
  347. static $ops = ['sum', 'count', 'avg'];
  348. if (in_array($operation, $ops)) {
  349. $this->aggregate[$title] = [
  350. 'operation' => $operation,
  351. 'modifier' => $modifier,
  352. ];
  353. } else {
  354. throw new InvalidArgumentException("Unknown operation '{$operation}'");
  355. }
  356. }
  357.  
  358.  
  359. /**
  360.   * Add an aggregate which is just a single pre-computed value
  361.   *
  362.   * @throws InvalidArgumentException Unknown operation
  363.   * @param string $title Column to aggregate values of
  364.   * @param string $Value Value to output for this column; this will be HTML-encoded on output
  365.   */
  366. public function addAggregateValue($title, $value)
  367. {
  368. $this->aggregate[$title] = [
  369. 'value' => $value,
  370. ];
  371. }
  372.  
  373.  
  374. /**
  375.   * Calculate the result of an aggregation
  376.   *
  377.   * @param string $operation Aggregation operation, 'sum', 'count', 'avg'
  378.   * @param array $values Raw values, direct from the database
  379.   * @return mixed Aggregation result; typically an integer or a float
  380.   */
  381. protected static function calculateAggregateColumn($operation, array $values)
  382. {
  383. switch ($operation) {
  384. case 'sum':
  385. return array_sum($values);
  386. case 'count':
  387. return count($values);
  388. case 'avg':
  389. return array_sum($values) / count($values);
  390. }
  391. }
  392.  
  393.  
  394. /**
  395.   * Does this itemlist support checkboxes?
  396.   **/
  397. public function setCheckboxes($checkboxes)
  398. {
  399. $this->checkboxes = $checkboxes;
  400. }
  401.  
  402.  
  403. /**
  404.   * Does this itemlist support ordering?
  405.   **/
  406. public function setOrdering($ordering)
  407. {
  408. $this->ordering = $ordering;
  409. }
  410.  
  411.  
  412. /**
  413.   * Does the parameter replacements on an action url
  414.   *
  415.   * Replaces %% with the id of the record.
  416.   **/
  417. private function urlReplace($url, $item)
  418. {
  419. $url = str_replace('%%', Enc::url($item['id']), $url);
  420. $url = str_replace('%ne%', $item['id'], $url);
  421.  
  422. return $url;
  423. }
  424.  
  425.  
  426. /**
  427.   * Renders an itemlist definition
  428.   *
  429.   * Definition can be one of:
  430.   * - A field name
  431.   * - An array with two indexes, 0 => ColModifier, 1 => field name
  432.   * - A Closure, which will receive one argument of the entire row as an array,
  433.   * and must return a string of HTML
  434.   *
  435.   * The Closure result supports a subset of HTML, {@see Text::limitedSubsetHtml} for more details
  436.   *
  437.   * @param mixed $defn
  438.   * @param array|object $item_data Result row
  439.   * @return string
  440.   **/
  441. protected static function renderItem($defn, $item_data)
  442. {
  443. if (is_array($defn)) {
  444. if ($defn[0] instanceof UnescapedColModifier) {
  445. return $defn[0]->modify($item_data[$defn[1]], $defn[1]);
  446. } else if ($defn[0] instanceof ColModifier) {
  447. return str_replace("\n", '<br>', Enc::html($defn[0]->modify($item_data[$defn[1]], $defn[1])));
  448. }
  449.  
  450. } elseif ($defn instanceof Closure) {
  451. return Text::limitedSubsetHtml($defn($item_data));
  452.  
  453. } else {
  454. return Enc::html($item_data[$defn]);
  455. }
  456. }
  457.  
  458. }
  459.