src/Table.php
<?php
declare(strict_types=1);
namespace Atk4\Ui;
use Atk4\Core\Factory;
use Atk4\Data\Field;
use Atk4\Data\Model;
use Atk4\Ui\Js\Jquery;
use Atk4\Ui\Js\JsExpression;
use Atk4\Ui\Js\JsExpressionable;
use Atk4\Ui\Misc\ProxyModel;
/**
* @phpstan-type JsCallbackSetClosure \Closure(Jquery, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed): (JsExpressionable|View|string|void)
*/
class Table extends Lister
{
public $ui = 'table';
public $defaultTemplate = 'table.html';
/**
* If table is part of Grid or Crud, we want to reload that instead of table.
* Usually a Grid or Crud that contains the table.
*
* @var View|null
*/
public $reload;
/** @var array<int|string, Table\Column|list<Table\Column>> Contains list of declared columns. Value will always be a column object. */
public $columns = [];
/**
* Allows you to inject HTML into table using getHtmlTags hook and column callbacks.
* Switch this feature off to increase performance at expense of some row-specific HTML.
*
* @var bool
*/
public $useHtmlTags = true;
/**
* Determines a strategy on how totals will be calculated. Do not touch those fields
* directly, instead use addTotals().
*
* @var array<string, string|array{ string|\Closure(mixed, string, $this): (int|float) }>|false
*/
public $totalsPlan = false;
/** @var bool Setting this to false will hide header row. */
public $header = true;
/** @var array<string, int|float> Contains list of totals accumulated during the render process. */
public $totals = [];
/** @var HtmlTemplate|null Contain the template for the "Head" type row. */
public $tHead;
/** @var HtmlTemplate */
public $tRowMaster;
/** @var HtmlTemplate Contain the template for the "Body" type row. */
public $tRow;
/** @var HtmlTemplate Contain the template for the "Foot" type row. */
public $tTotals;
/**
* Set this if you want table to appear as sortable. This does not add any
* mechanic of actual sorting - either implement manually or use Grid.
*
* @var bool|null
*/
public $sortable;
/**
* When $sortable is true, you can specify which column will appear to have
* active sorting on it.
*
* @var string
*/
public $sortBy;
/**
* When $sortable is true, and $sortBy is set, you can set order direction.
*
* @var 'asc'|'desc'|null
*/
public $sortDirection;
/**
* Make action columns in table use
* the collapsing CSS class.
* An action cell that is collapsing will
* only uses as much space as required.
*
* @var bool
*/
public $hasCollapsingCssActionColumn = true;
#[\Override]
protected function initChunks(): void
{
// create one column object that will be used to render all columns in the table
if (!$this->tHead) {
$this->tHead = $this->template->cloneRegion('Head');
$this->tRowMaster = $this->template->cloneRegion('Row');
$this->tTotals = $this->template->cloneRegion('Totals');
$this->tEmpty = $this->template->cloneRegion('Empty');
$this->template->del('Head');
$this->template->del('Body');
$this->template->del('Foot');
}
}
/**
* Defines a new column for this field. You need two objects for field to
* work.
*
* First is being Model field. If your Table is already associated with
* the model, it will automatically pick one by looking up element
* corresponding to the $name or add it as per your definition inside $field.
*
* The other object is a Column Decorator. This object know how to produce HTML for
* cells and will handle other things, like alignment. If you do not specify
* column, then it will be selected dynamically based on field type.
*
* If you don't want table column to be associated with model field, then
* pass $name parameter as null.
*
* @param string|null $name Data model field name
* @param array<mixed>|Table\Column $columnDecorator
* @param ($name is null ? array{} : array<mixed>|Field) $field
*
* @return Table\Column
*/
public function addColumn(?string $name, $columnDecorator = [], $field = [])
{
$this->assertIsInitialized();
if ($name !== null && isset($this->columns[$name])) {
throw (new Exception('Table column already exists'))
->addMoreInfo('name', $name);
}
if ($this->model === null) {
$this->setModel(new ProxyModel());
}
$this->model->assertIsModel();
// should be vaguely consistent with Form\AbstractLayout::addControl()
if ($name === null) {
$field = null;
} elseif (!$this->model->hasField($name)) {
$field = $this->model->addField($name, $field);
$field->neverPersist = true;
} else {
$field = $this->model->getField($name)
->setDefaults($field);
}
if ($field === null) {
// column is not associated with any model field
// TODO simplify to single $this->decoratorFactory call
$columnDecorator = $this->_addUnchecked(Table\Column::fromSeed($columnDecorator, ['table' => $this]));
} else {
$columnDecorator = $this->decoratorFactory($field, Factory::mergeSeeds($columnDecorator, ['columnData' => $name]));
}
if ($name === null) {
$this->columns[] = $columnDecorator;
} else {
$this->columns[$name] = $columnDecorator;
}
return $columnDecorator;
}
// TODO do not use elements/add(), elements are only for View based objects
private function _addUnchecked(Table\Column $column): Table\Column
{
return \Closure::bind(function () use ($column) {
return $this->_add($column);
}, $this, AbstractView::class)();
}
/**
* Set Popup action for columns filtering.
*
* @param list<string> $cols an array with columns name that need filtering
*/
public function setFilterColumn($cols = null): void
{
if ($this->model === null) {
throw new Exception('Model need to be defined in order to use column filtering');
}
// set filter to all column when null
if (!$cols) {
foreach ($this->model->getFields() as $key => $field) {
if (isset($this->columns[$key])) {
$cols[] = $field->shortName;
}
}
}
// create column popup
foreach ($cols as $colName) {
$col = $this->getColumn($colName);
$pop = $col->addPopup(new Table\Column\FilterPopup(['field' => $this->model->getField($colName), 'reload' => $this->reload, 'colTrigger' => '#' . $col->name . '_ac']));
if ($pop->isFilterOn()) {
$col->setHeaderPopupIcon('table-filter-on');
}
// apply condition according to popup form
$pop->setFilterCondition($this->model);
}
}
/**
* Add column Decorator.
*
* @param array<mixed>|Table\Column $seed
*
* @return Table\Column
*/
public function addDecorator(string $name, $seed)
{
if (!isset($this->columns[$name])) {
throw (new Exception('Table column does not exist'))
->addMoreInfo('name', $name);
}
$decorator = $this->_addUnchecked(Table\Column::fromSeed($seed, ['table' => $this]));
if (!is_array($this->columns[$name])) {
$this->columns[$name] = [$this->columns[$name]];
}
$this->columns[$name][] = $decorator;
return $decorator;
}
/**
* Return array of column decorators for particular column.
*
* @return list<Table\Column>
*/
public function getColumnDecorators(string $name): array
{
$decorator = $this->columns[$name];
return is_array($decorator) ? $decorator : [$decorator];
}
/**
* Return column instance or first instance if using decorator.
*
* @return Table\Column
*/
protected function getColumn(string $name)
{
// NOTE: It is not guaranteed that we will have only one element here. When adding decorators, the key will not
// contain the column instance anymore but an array with column instance set at 0 indexes and the rest as decorators.
// This is enough for fixing this issue right now. We can work on unifying decorator API in a separate PR.
return is_array($this->columns[$name]) ? $this->columns[$name][0] : $this->columns[$name];
}
/**
* @var array<string, array<mixed>>
*/
protected array $typeToDecorator = [
'atk4_money' => [Table\Column\Money::class],
'text' => [Table\Column\Text::class],
'boolean' => [Table\Column\Status::class, ['positive' => [true], 'negative' => [false]]],
];
/**
* Will come up with a column object based on the field object supplied.
* By default will use default column.
*
* @param array<mixed>|Table\Column $seed
*
* @return Table\Column
*/
public function decoratorFactory(Field $field, $seed = [])
{
$seed = Factory::mergeSeeds(
$seed,
$field->ui['table'] ?? null,
$this->typeToDecorator[$field->type] ?? null,
[Table\Column::class]
);
return $this->_addUnchecked(Table\Column::fromSeed($seed, ['table' => $this]));
}
/**
* Make columns resizable by dragging column header.
*
* The callback function will receive two parameter, a Jquery chain object and a array containing all table columns
* name and size.
*
* @param \Closure(Jquery, mixed): (JsExpressionable|View|string|void) $fx a callback function with columns widths as parameter
* @param list<int> $widths ex: [100, 200, 300, 100]
* @param array<string, mixed> $resizerOptions column-resizer module options, see https://www.npmjs.com/package/column-resizer
*
* @return $this
*/
public function resizableColumn($fx = null, $widths = null, array $resizerOptions = [])
{
$options = [];
if ($fx !== null) {
$cb = JsCallback::addTo($this);
$cb->set(function (Jquery $chain, string $data) use ($fx) {
return $fx($chain, $this->getApp()->decodeJson($data));
}, ['widths' => 'widths']);
$options['url'] = $cb->getJsUrl();
}
if ($widths !== null) {
$options['widths'] = $widths;
}
$options = array_merge($options, $resizerOptions);
$this->js(true, $this->js()->atkColumnResizer($options));
return $this;
}
#[\Override]
public function addJsPaginator($ipp, array $options = [], $container = null, $scrollRegion = 'Body')
{
$options = array_merge($options, ['appendTo' => 'tbody']);
return parent::addJsPaginator($ipp, $options, $container, $scrollRegion);
}
/**
* Override works like this:.
* [
* 'name' => 'Totals for {$num} rows:',
* 'price' => '--',
* 'total' => ['sum']
* ].
*
* @param array<string, string|array{ string|\Closure(mixed, string, $this): (int|float) }> $plan
*/
public function addTotals($plan = []): void
{
$this->totalsPlan = $plan;
}
/**
* @param list<string>|null $fields if null, then all "editable" fields will be added
*/
#[\Override]
public function setModel(Model $model, ?array $fields = null): void
{
$model->assertIsModel();
parent::setModel($model);
if ($fields === null) {
$fields = array_keys($model->getFields('visible'));
}
foreach ($fields as $field) {
$this->addColumn($field);
}
}
#[\Override]
protected function renderView(): void
{
if (!$this->columns) {
throw (new Exception('Table does not have any columns defined'))
->addMoreInfo('columns', $this->columns);
}
if ($this->sortable) {
$this->addClass('sortable');
}
// generate Header Row
if ($this->header) {
$this->tHead->dangerouslySetHtml('cells', $this->getHeaderRowHtml());
$this->template->dangerouslySetHtml('Head', $this->tHead->renderToHtml());
}
$this->_renderedRowsCount = 0;
try {
foreach ($this->model as $entity) {
$this->currentRow = $entity;
// generate template for data row
$this->tRowMaster->dangerouslySetHtml('cells', $this->getDataRowHtml());
$this->tRowMaster->set('dataId', '{$dataId}');
$this->tRow = new HtmlTemplate($this->tRowMaster->renderToHtml()); // TODO reparse should not be needed
if ($this->hook(self::HOOK_BEFORE_ROW) === false) {
continue;
}
if ($this->totalsPlan) {
$this->updateTotals();
}
$this->renderRow();
++$this->_renderedRowsCount;
if ($this->hook(self::HOOK_AFTER_ROW) === false) {
continue;
}
}
} finally {
$this->tRowMaster->set('cells', null);
$this->tRow = null; // @phpstan-ignore assign.propertyType
$this->currentRow = null;
}
// add totals rows or empty message
if ($this->_renderedRowsCount === 0) {
if (!$this->jsPaginator || !$this->jsPaginator->getPage()) {
$this->template->dangerouslyAppendHtml('Body', $this->tEmpty->renderToHtml());
}
} elseif ($this->totalsPlan) {
$this->tTotals->dangerouslySetHtml('cells', $this->getTotalsRowHtml());
$this->template->dangerouslyAppendHtml('Foot', $this->tTotals->renderToHtml());
}
// stop JsPaginator if there are no more records to fetch
if ($this->jsPaginator && ($this->_renderedRowsCount < $this->ipp)) {
$this->jsPaginator->jsIdle();
}
View::renderView();
}
#[\Override]
public function renderRow(): void
{
$this->tRow->trySet($this->getApp()->uiPersistence->typecastSaveRow($this->currentRow, $this->currentRow->get()));
if ($this->useHtmlTags) {
// prepare row-specific HTML tags
$htmlTags = [];
foreach ($this->hook(Table\Column::HOOK_GET_HTML_TAGS, [$this->currentRow]) as $ret) {
if (is_array($ret)) {
$htmlTags = array_merge($htmlTags, $ret);
}
}
foreach ($this->columns as $name => $columns) {
if (!is_array($columns)) {
$columns = [$columns];
}
$field = is_int($name) ? null : $this->model->getField($name);
foreach ($columns as $column) {
$htmlTags = array_merge($column->getHtmlTags($this->currentRow, $field), $htmlTags);
}
}
// render row and add to body
$this->tRow->dangerouslySetHtml($htmlTags);
$this->tRow->set('dataId', $this->getApp()->uiPersistence->typecastAttributeSaveField($this->model->getIdField(), $this->currentRow->getId()));
$this->template->dangerouslyAppendHtml('Body', $this->tRow->renderToHtml());
$this->tRow->del(array_keys($htmlTags));
} else {
$this->template->dangerouslyAppendHtml('Body', $this->tRow->renderToHtml());
}
}
/**
* Same as on('click', 'tr', $action), but will also make sure you can't
* click outside of the body. Additionally when you move cursor over the
* rows, pointer will be used and rows will be highlighted as you hover.
*
* @param JsExpressionable|JsCallbackSetClosure $action Code to execute
*/
public function onRowClick($action): void
{
$this->addClass('selectable');
$this->js(true)->find('tbody')->css('cursor', 'pointer');
// do not bubble row click event if click stems from row content like checkboxes
// TODO one ->on() call would be better, but we need a method to convert Closure $action into JsExpression first
$preventBubblingJs = new JsExpression(<<<'EOF'
let elem = event.target;
while (elem !== null && elem !== event.currentTarget) {
if (elem.tagName === 'A' || elem.classList.contains('atk4-norowclick')
|| (elem.classList.contains('ui') && ['button', 'input', 'checkbox', 'dropdown'].some(v => elem.classList.contains(v)))) {
event.stopImmediatePropagation();
}
elem = elem.parentElement;
}
EOF);
$this->on('click', 'tbody > tr', $preventBubblingJs, ['preventDefault' => false]);
$this->on('click', 'tbody > tr', $action);
}
/**
* Use this to quickly access the <tr> and wrap in Jquery.
*
* $this->jsRow()->data('id');
*
* @return Jquery
*/
public function jsRow(): JsExpressionable
{
return (new Jquery())->closest('tr');
}
/**
* Remove a row in table using javascript using a model ID.
*
* @param string $id the model ID where row need to be removed
* @param string $transition the transition effect
*
* @return Jquery
*/
public function jsRemoveRow($id, $transition = 'fade left'): JsExpressionable
{
return $this->js()->find('tr[data-id=' . $id . ']')->transition($transition);
}
/**
* Executed for each row if "totals" are enabled to add up values.
*/
public function updateTotals(): void
{
foreach ($this->totalsPlan as $key => $val) {
// if value is array, then we treat it as built-in or closure aggregate method
if (is_array($val)) {
$f = $val[0];
// initial value is always 0
if (!isset($this->totals[$key])) {
$this->totals[$key] = 0;
}
if ($f instanceof \Closure) {
$this->totals[$key] += $f($this->currentRow->get($key), $key, $this);
} elseif (is_string($f)) {
switch ($f) {
case 'sum':
$this->totals[$key] += $this->currentRow->get($key);
break;
case 'count':
++$this->totals[$key];
break;
case 'min':
if ($this->currentRow->get($key) < $this->totals[$key]) {
$this->totals[$key] = $this->currentRow->get($key);
}
break;
case 'max':
if ($this->currentRow->get($key) > $this->totals[$key]) {
$this->totals[$key] = $this->currentRow->get($key);
}
break;
default:
throw (new Exception('Unsupported table aggregate function'))
->addMoreInfo('name', $f);
}
}
}
}
}
/**
* Responds with the HTML to be inserted in the header row that would
* contain captions of all columns.
*/
public function getHeaderRowHtml(): string
{
$output = [];
foreach ($this->columns as $name => $column) {
// if multiple formatters are defined, use the first for the header cell
if (is_array($column)) {
$column = $column[0];
}
if (!is_int($name)) {
$field = $this->model->getField($name);
$output[] = $column->getHeaderCellHtml($field);
} else {
$output[] = $column->getHeaderCellHtml();
}
}
return implode('', $output);
}
/**
* Responds with HTML to be inserted in the footer row that would
* contain totals for all columns.
*/
public function getTotalsRowHtml(): string
{
$output = [];
foreach ($this->columns as $name => $column) {
// if no totals plan, then show dash, but keep column formatting
if (!isset($this->totalsPlan[$name])) {
$output[] = $column->getTag('foot', [], '-');
continue;
}
// if totals plan is set as array, then show formatted value
if (is_array($this->totalsPlan[$name])) {
$field = $this->model->getField($name);
$output[] = $column->getTotalsCellHtml($field, $this->totals[$name]);
continue;
}
// otherwise just show it, for example, "Totals:" cell
$output[] = $column->getTag('foot', [], $this->totalsPlan[$name]);
}
return implode('', $output);
}
/**
* Collects cell templates from all the columns and combine them into row template.
*/
public function getDataRowHtml(): string
{
$output = [];
foreach ($this->columns as $name => $column) {
// if multiple formatters are defined, use the first for the header cell
$field = !is_int($name) ? $this->model->getField($name) : null;
if (!is_array($column)) {
$column = [$column];
}
// we need to smartly wrap things up
$cellHtml = null;
$tdAttr = [];
foreach ($column as $cKey => $c) {
if ($cKey !== array_key_last($column)) {
$html = $c->getDataCellTemplate($field);
$tdAttr = $c->getTagAttributes('body', $tdAttr);
} else {
// last formatter, ask it to give us whole rendering
$html = $c->getDataCellHtml($field, $tdAttr);
}
$cellHtml = $cellHtml === null
? $html
: ($name
// if name is set, we can wrap things
? str_replace('{$' . $name . '}', $cellHtml, $html)
: $cellHtml . ' ' . $html);
}
$output[] = $cellHtml;
}
return implode('', $output);
}
}