<?php
/*
* Copyright (C) 2017 Karmabunny Pty Ltd.
*
* This file is a part of SproutCMS.
*
* SproutCMS is free software: you can redistribute it and/or modify it under the terms
* of the GNU General Public License as published by the Free Software Foundation, either
* version 2 of the License, or (at your option) any later version.
*
* For more information, visit <http://getsproutcms.com>.
*/
namespace Sprout\Helpers;
use InvalidArgumentException;
use PDOStatement;
use Closure;
/**
* Used to generate HTML for a table of database records.
* This is usually used for the admin/contents/* route which provides the main
* UI to operators for a given {@see ManagedAdminController}
*/
class Itemlist
{
public $items;
public $main_columns;
public $aggregate = [];
public $checkboxes;
public $ordering;
public $table_class = 'main-list';
private $row_classes_func = null;
private $actions = array(); private $actions_func = null;
private $actions_classes = 'actions--link';
public function __toString()
{
return (string) $this->render();
}
public function render()
{
if (empty($this->main_columns)) { throw new InvalidArgumentException('No main columns defined');
}
if ($this->items instanceof PDOStatement) {
if ($this->items->rowCount() == 0) return;
} else {
if (count($this->items) == 0) return; }
if (isset($this->actions['edit'])) { $edit_action = $this->actions['edit'];
unset($this->actions['edit']); } else {
$edit_action = null;
}
if ($this->ordering) {
$base_url = Url::withoutArgs('order', 'dir', 'page');
}
// All the (raw) values for aggregate columns are stored so the aggregation can process them
$aggregate_vals = [];
foreach ($this->aggregate as $title => $agg_defn) { $aggregate_vals[$title] = [];
}
$val = "<table class=\"" . Enc::html($this->table_class) . "\">\n";
$val .= "<thead>\n";
$val .= "<tr>";
if ($this->checkboxes) {
$val .= '<th class="selection-all">';
$val .= '<div class="field-element field-element--white field-element--checkbox field-element--checkbox--no-label">';
$val .= '<div class="field-element__input-set">';
$val .= '<div class="fieldset-input">';
$val .= '<input id="itemList-select-all" type="checkbox">';
$val .= '<label for="itemList-select-all"><span class="-vis-hidden">Select all</span></label>';
$val .= '</div>';
$val .= '</div>';
$val .= '</div>';
$val .= '</th>';
}
foreach ($this->main_columns as $title => $col_name) {
if (
$this->ordering
and
(
or
(is_array($col_name) and
$col_name[0] instanceof SortedColModifier
) )
) {
$val .= "<th class=\"table-sort-th\">";
$field_name = is_array($col_name) ?
$col_name[1] : $col_name;
if ($_GET['order'] == $field_name and $_GET['dir'] == 'asc') {
$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\">";
$val .= $title;
$val .= "</a>";
} else if ($_GET['order'] == $field_name and $_GET['dir'] == 'desc') {
$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)\">";
$val .= $title;
$val .= "</a>";
} else {
$val .= "<a class=\"table-sort\" href=\"{$base_url}order={$field_name}\" title=\"Click to sort by this column\">";
$val .= $title;
$val .= "</a>";
}
$val .= "</th>";
} else {
$val .= "<th>";
$val .= $title;
$val .= "</th>";
}
}
if (count($this->actions) or
$this->actions_func) $val .= "<th> </th>";
$val .= "</tr>\n";
$val .= "</thead>\n";
$val .= "<tbody>\n";
foreach ($this->items as $item) {
$classes = '';
if (isset($this->row_classes_func)) { $func = $this->row_classes_func;
$classes = $func($item);
}
// Fetch aggregate values from row ($item) and load into the temporary array
// This is done on the raw values, rather than processed values, so callback columns won't work
foreach ($this->aggregate as $title => $agg_defn) { $col_defn = $this->main_columns[$title];
$aggregate_vals[$title][] = $item[$col_defn];
$aggregate_vals[$title][] = $item[$col_defn[1]];
}
}
if ($classes) {
$val .= '<tr class="' . Enc::html($classes) . '">';
} else {
$val .= "<tr>";
}
if ($this->checkboxes) {
$val .= "<td class=\"selection\">";
$val .= '<div class="field-element field-element--white field-element--checkbox field-element--checkbox--no-label">';
$val .= '<div class="field-element__input-set">';
$val .= '<div class="fieldset-input">';
$val .= "<input type=\"checkbox\" id=\"itemList-checkbox-{$item['id']}\" name=\"ids[]\" value=\"{$item['id']}\">";
$val .= "<label for=\"itemList-checkbox-{$item['id']}\"><span class=\"-vis-hidden\">Select row</span></label>";
$val .= '</div>';
$val .= '</div>';
$val .= '</div>';
$val .= "</td>";
}
$i = 0;
foreach ($this->main_columns as $title => $defn) {
throw new InvalidArgumentException('Main column must either be a string, or an array with 0: ColModifier, 1: string');
}
$value = self::renderItem($defn, $item);
if ($i++ == 0 and $edit_action) {
$url = $this->urlReplace($edit_action['url'], $item);
$url = Enc::html($url);
$val .= "<td><a href=\"{$url}\">{$value}</a></td>";
continue;
}
$val .= "<td>{$value}</td>";
}
if (count($this->actions) or
$this->actions_func) { $val .= "<td class=\"actions\">";
foreach ($this->actions as $name => $details) {
$show = $details['show_func'];
$result = $show($item);
if ($result == false) continue;
}
$url = $this->urlReplace($details['url'], $item);
$name = Enc::html($name);
$url = Enc::html($url);
$class = Enc
::html(trim($this->actions_classes . ' ' . $details['classes'])); $val .= "<a href=\"{$url}\" class=\"{$class}\">{$name}</a> ";
}
if ($this->actions_func) {
$func = $this->actions_func;
$val .= $func($item);
}
$val .= "</td>";
}
$val .= "</tr>\n";
}
$val .= "<tr class=\"main-list--aggregate\">\n";
if ($this->checkboxes) {
$val .= '<td></td>';
}
foreach ($this->main_columns as $title => $col_defn) {
$value = '';
} else {
if (isset($agg_defn['value'])) { $value = $agg_defn['value'];
} else {
$value = self::calculateAggregateColumn($agg_defn['operation'], $aggregate_vals[$title]);
}
if (!empty($agg_defn['modifier'])) { $value = $agg_defn['modifier']->modify($value, null);
}
// Escape value, except if it was processed by an UnescapedColModifier
if (empty($agg_defn['modifier']) or
!($agg_defn['modifier'] instanceof UnescapedColModifier
)) { $value = Enc::html($value);
}
}
$val .= "<td>{$value}</td>";
}
if (count($this->actions) or
$this->actions_func) { $val .= '<td></td>';
}
$val .= "</tr>\n";
}
$val .= "</tbody>\n";
$val .= "</table>\n";
return $val;
}
/**
* Set a function which should return the classes to use on the row.
*
* string function mycallable(array $row)
*
* The return value should be a string of class names
*
* @example
* $itemlist->setRowClassesFunc(function($row){
* if ($row['id'] == 42) return 'ultimate';
* return '';
* });
*
* @param callable $func
**/
public function setRowClassesFunc($func)
{
$this->row_classes_func = $func;
}
/**
* Adds an action to this itemlist.
*
* The special action 'edit' is used when the row is clicked.
*
* @param string $name Link label
* @param string $url Link URL. This URL is processed by {@see Itemlist::urlReplace} during rendering
* @param string $classes Additional classes for the A element
* @param callable $show_func Function called for each row to show or hide this action for that row
**/
public function addAction($name, $url, $classes = '', callable $show_func = null)
{
$this->actions[$name] = ['url' => $url, 'classes' => $classes, 'show_func' => $show_func];
}
/**
* Set link classes common for all actions
* The default is "actions--link".
*
* @example
* $itemlist->setActionsClasses('button')
*
* @param string $classes Classes for the A element
*/
public function setActionsClasses($classes)
{
$this->actions_classes = $classes;
}
/**
* Set a function which should return content for the actions column
* The func should have this signature:
*
* string function mycallable(array $row)
*
* The return value should be HTML with the links
**/
public function setActionsFunc($func)
{
$this->actions_func = $func;
}
/**
* Add an aggregate which operates on the values of a column
*
* @throws InvalidArgumentException Unknown operation
* @param string $title Column to aggregate values of
* @param string $operation Aggregation operation, 'sum', 'count', 'avg'
* @param ColModifier $modifier Column modifier applied after aggregation to format the result
*/
public function addAggregateColumn($title, $operation, ColModifier $modifier = null)
{
static $ops = ['sum', 'count', 'avg'];
'operation' => $operation,
'modifier' => $modifier,
];
} else {
throw new InvalidArgumentException("Unknown operation '{$operation}'");
}
}
/**
* Add an aggregate which is just a single pre-computed value
*
* @throws InvalidArgumentException Unknown operation
* @param string $title Column to aggregate values of
* @param string $Value Value to output for this column; this will be HTML-encoded on output
*/
public function addAggregateValue($title, $value)
{
'value' => $value,
];
}
/**
* Calculate the result of an aggregation
*
* @param string $operation Aggregation operation, 'sum', 'count', 'avg'
* @param array $values Raw values, direct from the database
* @return mixed Aggregation result; typically an integer or a float
*/
protected static
function calculateAggregateColumn
($operation, array $values) {
switch ($operation) {
case 'sum':
case 'count':
case 'avg':
}
}
/**
* Does this itemlist support checkboxes?
**/
public function setCheckboxes($checkboxes)
{
$this->checkboxes = $checkboxes;
}
/**
* Does this itemlist support ordering?
**/
public function setOrdering($ordering)
{
$this->ordering = $ordering;
}
/**
* Does the parameter replacements on an action url
*
* Replaces %% with the id of the record.
**/
private function urlReplace($url, $item)
{
return $url;
}
/**
* Renders an itemlist definition
*
* Definition can be one of:
* - A field name
* - An array with two indexes, 0 => ColModifier, 1 => field name
* - A Closure, which will receive one argument of the entire row as an array,
* and must return a string of HTML
*
* The Closure result supports a subset of HTML, {@see Text::limitedSubsetHtml} for more details
*
* @param mixed $defn
* @param array|object $item_data Result row
* @return string
**/
protected static function renderItem($defn, $item_data)
{
if ($defn[0] instanceof UnescapedColModifier) {
return $defn[0]->modify($item_data[$defn[1]], $defn[1]);
} else if ($defn[0] instanceof ColModifier) {
return str_replace("\n", '<br>', Enc
::html($defn[0]->modify($item_data[$defn[1]], $defn[1]))); }
} elseif ($defn instanceof Closure) {
return Text::limitedSubsetHtml($defn($item_data));
} else {
return Enc::html($item_data[$defn]);
}
}
}