1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui;
6:
7: use Atk4\Core\Factory;
8: use Atk4\Data\Field;
9: use Atk4\Data\Model;
10: use Atk4\Ui\Js\Jquery;
11: use Atk4\Ui\Js\JsExpression;
12: use Atk4\Ui\Js\JsExpressionable;
13:
14: /**
15: * @phpstan-type JsCallbackSetClosure \Closure(Jquery, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed): (JsExpressionable|View|string|void)
16: */
17: class Table extends Lister
18: {
19: public $ui = 'table';
20:
21: public $defaultTemplate = 'table.html';
22:
23: /**
24: * If table is part of Grid or Crud, we want to reload that instead of table.
25: * Usually a Grid or Crud that contains the table.
26: *
27: * @var View|null
28: */
29: public $reload;
30:
31: /** @var array<int|string, Table\Column|array<int, Table\Column>> Contains list of declared columns. Value will always be a column object. */
32: public $columns = [];
33:
34: /**
35: * Allows you to inject HTML into table using getHtmlTags hook and column callbacks.
36: * Switch this feature off to increase performance at expense of some row-specific HTML.
37: *
38: * @var bool
39: */
40: public $useHtmlTags = true;
41:
42: /**
43: * Determines a strategy on how totals will be calculated. Do not touch those fields
44: * directly, instead use addTotals().
45: *
46: * @var array<string, string|array{ string|\Closure(mixed, string, $this): (int|float) }>|false
47: */
48: public $totalsPlan = false;
49:
50: /** @var bool Setting this to false will hide header row. */
51: public $header = true;
52:
53: /** @var array Contains list of totals accumulated during the render process. */
54: public $totals = [];
55:
56: /** @var HtmlTemplate|null Contain the template for the "Head" type row. */
57: public $tHead;
58:
59: /** @var HtmlTemplate */
60: public $tRowMaster;
61:
62: /** @var HtmlTemplate Contain the template for the "Body" type row. */
63: public $tRow;
64:
65: /** @var HtmlTemplate Contain the template for the "Foot" type row. */
66: public $tTotals;
67:
68: /**
69: * Set this if you want table to appear as sortable. This does not add any
70: * mechanic of actual sorting - either implement manually or use Grid.
71: *
72: * @var bool|null
73: */
74: public $sortable;
75:
76: /**
77: * When $sortable is true, you can specify which column will appear to have
78: * active sorting on it.
79: *
80: * @var string
81: */
82: public $sortBy;
83:
84: /**
85: * When $sortable is true, and $sortBy is set, you can set order direction.
86: *
87: * @var 'asc'|'desc'|null
88: */
89: public $sortDirection;
90:
91: /**
92: * Make action columns in table use
93: * the collapsing CSS class.
94: * An action cell that is collapsing will
95: * only uses as much space as required.
96: *
97: * @var bool
98: */
99: public $hasCollapsingCssActionColumn = true;
100:
101: #[\Override]
102: protected function initChunks(): void
103: {
104: // create one column object that will be used to render all columns in the table
105: if (!$this->tHead) {
106: $this->tHead = $this->template->cloneRegion('Head');
107: $this->tRowMaster = $this->template->cloneRegion('Row');
108: $this->tTotals = $this->template->cloneRegion('Totals');
109: $this->tEmpty = $this->template->cloneRegion('Empty');
110:
111: $this->template->del('Head');
112: $this->template->del('Body');
113: $this->template->del('Foot');
114: }
115: }
116:
117: /**
118: * Defines a new column for this field. You need two objects for field to
119: * work.
120: *
121: * First is being Model field. If your Table is already associated with
122: * the model, it will automatically pick one by looking up element
123: * corresponding to the $name or add it as per your definition inside $field.
124: *
125: * The other object is a Column Decorator. This object know how to produce HTML for
126: * cells and will handle other things, like alignment. If you do not specify
127: * column, then it will be selected dynamically based on field type.
128: *
129: * If you don't want table column to be associated with model field, then
130: * pass $name parameter as null.
131: *
132: * @param string|null $name Data model field name
133: * @param array|Table\Column $columnDecorator
134: * @param ($name is null ? array{} : array|Field) $field
135: *
136: * @return Table\Column
137: */
138: public function addColumn(?string $name, $columnDecorator = [], $field = [])
139: {
140: $this->assertIsInitialized();
141:
142: if ($name !== null && isset($this->columns[$name])) {
143: throw (new Exception('Table column already exists'))
144: ->addMoreInfo('name', $name);
145: }
146:
147: if (!$this->model) {
148: $this->model = new \Atk4\Ui\Misc\ProxyModel();
149: }
150: $this->model->assertIsModel();
151:
152: // should be vaguely consistent with Form\AbstractLayout::addControl()
153:
154: if ($name === null) {
155: $field = null;
156: } elseif (!$this->model->hasField($name)) {
157: $field = $this->model->addField($name, $field);
158: $field->neverPersist = true;
159: } else {
160: $field = $this->model->getField($name)
161: ->setDefaults($field);
162: }
163:
164: if ($field === null) {
165: // column is not associated with any model field
166: // TODO simplify to single $this->decoratorFactory call
167: $columnDecorator = $this->_addUnchecked(Table\Column::fromSeed($columnDecorator, ['table' => $this]));
168: } else {
169: $columnDecorator = $this->decoratorFactory($field, Factory::mergeSeeds($columnDecorator, ['columnData' => $name]));
170: }
171:
172: if ($name === null) {
173: $this->columns[] = $columnDecorator;
174: } else {
175: $this->columns[$name] = $columnDecorator;
176: }
177:
178: return $columnDecorator;
179: }
180:
181: // TODO do not use elements/add(), elements are only for View based objects
182: private function _addUnchecked(Table\Column $column): Table\Column
183: {
184: return \Closure::bind(function () use ($column) {
185: return $this->_add($column);
186: }, $this, AbstractView::class)();
187: }
188:
189: /**
190: * Set Popup action for columns filtering.
191: *
192: * @param array $cols an array with columns name that need filtering
193: */
194: public function setFilterColumn($cols = null): void
195: {
196: if (!$this->model) {
197: throw new Exception('Model need to be defined in order to use column filtering');
198: }
199:
200: // set filter to all column when null
201: if (!$cols) {
202: foreach ($this->model->getFields() as $key => $field) {
203: if (isset($this->columns[$key])) {
204: $cols[] = $field->shortName;
205: }
206: }
207: }
208:
209: // create column popup
210: foreach ($cols as $colName) {
211: $col = $this->getColumn($colName);
212:
213: $pop = $col->addPopup(new Table\Column\FilterPopup(['field' => $this->model->getField($colName), 'reload' => $this->reload, 'colTrigger' => '#' . $col->name . '_ac']));
214: if ($pop->isFilterOn()) {
215: $col->setHeaderPopupIcon('table-filter-on');
216: }
217: // apply condition according to popup form
218: $this->model = $pop->setFilterCondition($this->model);
219: }
220: }
221:
222: /**
223: * Add column Decorator.
224: *
225: * @param array|Table\Column $seed
226: *
227: * @return Table\Column
228: */
229: public function addDecorator(string $name, $seed)
230: {
231: if (!isset($this->columns[$name])) {
232: throw (new Exception('Table column does not exist'))
233: ->addMoreInfo('name', $name);
234: }
235:
236: $decorator = $this->_addUnchecked(Table\Column::fromSeed($seed, ['table' => $this]));
237:
238: if (!is_array($this->columns[$name])) {
239: $this->columns[$name] = [$this->columns[$name]];
240: }
241: $this->columns[$name][] = $decorator;
242:
243: return $decorator;
244: }
245:
246: /**
247: * Return array of column decorators for particular column.
248: */
249: public function getColumnDecorators(string $name): array
250: {
251: $dec = $this->columns[$name];
252:
253: return is_array($dec) ? $dec : [$dec];
254: }
255:
256: /**
257: * Return column instance or first instance if using decorator.
258: *
259: * @return Table\Column
260: */
261: protected function getColumn(string $name)
262: {
263: // NOTE: It is not guaranteed that we will have only one element here. When adding decorators, the key will not
264: // contain the column instance anymore but an array with column instance set at 0 indexes and the rest as decorators.
265: // This is enough for fixing this issue right now. We can work on unifying decorator API in a separate PR.
266: return is_array($this->columns[$name]) ? $this->columns[$name][0] : $this->columns[$name];
267: }
268:
269: /**
270: * @var array<string, array>
271: */
272: protected array $typeToDecorator = [
273: 'atk4_money' => [Table\Column\Money::class],
274: 'text' => [Table\Column\Text::class],
275: 'boolean' => [Table\Column\Status::class, ['positive' => [true], 'negative' => [false]]],
276: ];
277:
278: /**
279: * Will come up with a column object based on the field object supplied.
280: * By default will use default column.
281: *
282: * @param array|Table\Column $seed
283: *
284: * @return Table\Column
285: */
286: public function decoratorFactory(Field $field, $seed = [])
287: {
288: $seed = Factory::mergeSeeds(
289: $seed,
290: $field->ui['table'] ?? null,
291: $this->typeToDecorator[$field->type] ?? null,
292: [Table\Column::class]
293: );
294:
295: return $this->_addUnchecked(Table\Column::fromSeed($seed, ['table' => $this]));
296: }
297:
298: /**
299: * Make columns resizable by dragging column header.
300: *
301: * The callback function will receive two parameter, a Jquery chain object and a array containing all table columns
302: * name and size.
303: *
304: * @param \Closure(Jquery, mixed): (JsExpressionable|View|string|void) $fx a callback function with columns widths as parameter
305: * @param array<int, int> $widths ex: [100, 200, 300, 100]
306: * @param array $resizerOptions column-resizer module options, see https://www.npmjs.com/package/column-resizer
307: *
308: * @return $this
309: */
310: public function resizableColumn($fx = null, $widths = null, $resizerOptions = [])
311: {
312: $options = [];
313: if ($fx !== null) {
314: $cb = JsCallback::addTo($this);
315: $cb->set(function (Jquery $chain, string $data) use ($fx) {
316: return $fx($chain, $this->getApp()->decodeJson($data));
317: }, ['widths' => 'widths']);
318: $options['url'] = $cb->getJsUrl();
319: }
320:
321: if ($widths !== null) {
322: $options['widths'] = $widths;
323: }
324:
325: $options = array_merge($options, $resizerOptions);
326:
327: $this->js(true, $this->js()->atkColumnResizer($options));
328:
329: return $this;
330: }
331:
332: #[\Override]
333: public function addJsPaginator($ipp, $options = [], $container = null, $scrollRegion = 'Body')
334: {
335: $options = array_merge($options, ['appendTo' => 'tbody']);
336:
337: return parent::addJsPaginator($ipp, $options, $container, $scrollRegion);
338: }
339:
340: /**
341: * Override works like this:.
342: * [
343: * 'name' => 'Totals for {$num} rows:',
344: * 'price' => '--',
345: * 'total' => ['sum']
346: * ].
347: *
348: * @param array<string, string|array{ string|\Closure(mixed, string, $this): (int|float) }> $plan
349: */
350: public function addTotals($plan = []): void
351: {
352: $this->totalsPlan = $plan;
353: }
354:
355: /**
356: * @param array<int, string>|null $fields if null, then all "editable" fields will be added
357: */
358: #[\Override]
359: public function setModel(Model $model, array $fields = null): void
360: {
361: $model->assertIsModel();
362:
363: parent::setModel($model);
364:
365: if ($fields === null) {
366: $fields = array_keys($model->getFields('visible'));
367: }
368:
369: foreach ($fields as $field) {
370: $this->addColumn($field);
371: }
372: }
373:
374: #[\Override]
375: protected function renderView(): void
376: {
377: if (!$this->columns) {
378: throw (new Exception('Table does not have any columns defined'))
379: ->addMoreInfo('columns', $this->columns);
380: }
381:
382: if ($this->sortable) {
383: $this->addClass('sortable');
384: }
385:
386: // generate Header Row
387: if ($this->header) {
388: $this->tHead->dangerouslySetHtml('cells', $this->getHeaderRowHtml());
389: $this->template->dangerouslySetHtml('Head', $this->tHead->renderToHtml());
390: }
391:
392: // generate template for data row
393: $this->tRowMaster->dangerouslySetHtml('cells', $this->getDataRowHtml());
394: $this->tRowMaster->set('dataId', '{$dataId}');
395: $this->tRow = new HtmlTemplate($this->tRowMaster->renderToHtml());
396: $this->tRow->setApp($this->getApp());
397:
398: // iterate data rows
399: $this->_renderedRowsCount = 0;
400:
401: // TODO we should not iterate using $this->model variable,
402: // then also backup/tryfinally would be not needed
403: // the same in Lister class
404: $modelBackup = $this->model;
405: $tRowBackup = $this->tRow;
406: try {
407: foreach ($this->model as $this->model) {
408: $this->currentRow = $this->model;
409: $this->tRow = clone $tRowBackup;
410: if ($this->hook(self::HOOK_BEFORE_ROW) === false) {
411: continue;
412: }
413:
414: if ($this->totalsPlan) {
415: $this->updateTotals();
416: }
417:
418: $this->renderRow();
419:
420: ++$this->_renderedRowsCount;
421:
422: if ($this->hook(self::HOOK_AFTER_ROW) === false) {
423: continue;
424: }
425: }
426: } finally {
427: $this->model = $modelBackup;
428: $this->tRow = $tRowBackup;
429: }
430:
431: // add totals rows or empty message
432: if ($this->_renderedRowsCount === 0) {
433: if (!$this->jsPaginator || !$this->jsPaginator->getPage()) {
434: $this->template->dangerouslyAppendHtml('Body', $this->tEmpty->renderToHtml());
435: }
436: } elseif ($this->totalsPlan) {
437: $this->tTotals->dangerouslySetHtml('cells', $this->getTotalsRowHtml());
438: $this->template->dangerouslyAppendHtml('Foot', $this->tTotals->renderToHtml());
439: }
440:
441: // stop JsPaginator if there are no more records to fetch
442: if ($this->jsPaginator && ($this->_renderedRowsCount < $this->ipp)) {
443: $this->jsPaginator->jsIdle();
444: }
445:
446: View::renderView();
447: }
448:
449: #[\Override]
450: public function renderRow(): void
451: {
452: $this->tRow->set($this->model);
453:
454: if ($this->useHtmlTags) {
455: // prepare row-specific HTML tags
456: $htmlTags = [];
457:
458: foreach ($this->hook(Table\Column::HOOK_GET_HTML_TAGS, [$this->model]) as $ret) {
459: if (is_array($ret)) {
460: $htmlTags = array_merge($htmlTags, $ret);
461: }
462: }
463:
464: foreach ($this->columns as $name => $columns) {
465: if (!is_array($columns)) {
466: $columns = [$columns];
467: }
468: $field = is_int($name) ? null : $this->model->getField($name);
469: foreach ($columns as $column) {
470: $htmlTags = array_merge($column->getHtmlTags($this->model, $field), $htmlTags);
471: }
472: }
473:
474: // render row and add to body
475: $this->tRow->dangerouslySetHtml($htmlTags);
476: $this->tRow->set('dataId', (string) $this->model->getId());
477: $this->template->dangerouslyAppendHtml('Body', $this->tRow->renderToHtml());
478: $this->tRow->del(array_keys($htmlTags));
479: } else {
480: $this->template->dangerouslyAppendHtml('Body', $this->tRow->renderToHtml());
481: }
482: }
483:
484: /**
485: * Same as on('click', 'tr', $action), but will also make sure you can't
486: * click outside of the body. Additionally when you move cursor over the
487: * rows, pointer will be used and rows will be highlighted as you hover.
488: *
489: * @param JsExpressionable|JsCallbackSetClosure $action Code to execute
490: */
491: public function onRowClick($action): void
492: {
493: $this->addClass('selectable');
494: $this->js(true)->find('tbody')->css('cursor', 'pointer');
495:
496: // do not bubble row click event if click stems from row content like checkboxes
497: // TODO one ->on() call would be better, but we need a method to convert Closure $action into JsExpression first
498: $preventBubblingJs = new JsExpression(<<<'EOF'
499: let elem = event.target;
500: while (elem !== null && elem !== event.currentTarget) {
501: if (elem.tagName === 'A' || elem.classList.contains('atk4-norowclick')
502: || (elem.classList.contains('ui') && ['button', 'input', 'checkbox', 'dropdown'].some(v => elem.classList.contains(v)))) {
503: event.stopImmediatePropagation();
504: }
505: elem = elem.parentElement;
506: }
507: EOF);
508: $this->on('click', 'tbody > tr', $preventBubblingJs, ['preventDefault' => false]);
509:
510: $this->on('click', 'tbody > tr', $action);
511: }
512:
513: /**
514: * Use this to quickly access the <tr> and wrap in Jquery.
515: *
516: * $this->jsRow()->data('id');
517: *
518: * @return Jquery
519: */
520: public function jsRow(): JsExpressionable
521: {
522: return (new Jquery())->closest('tr');
523: }
524:
525: /**
526: * Remove a row in table using javascript using a model ID.
527: *
528: * @param string $id the model ID where row need to be removed
529: * @param string $transition the transition effect
530: *
531: * @return Jquery
532: */
533: public function jsRemoveRow($id, $transition = 'fade left'): JsExpressionable
534: {
535: return $this->js()->find('tr[data-id=' . $id . ']')->transition($transition);
536: }
537:
538: /**
539: * Executed for each row if "totals" are enabled to add up values.
540: */
541: public function updateTotals(): void
542: {
543: foreach ($this->totalsPlan as $key => $val) {
544: // if value is array, then we treat it as built-in or closure aggregate method
545: if (is_array($val)) {
546: $f = $val[0];
547:
548: // initial value is always 0
549: if (!isset($this->totals[$key])) {
550: $this->totals[$key] = 0;
551: }
552:
553: if ($f instanceof \Closure) {
554: $this->totals[$key] += $f($this->model->get($key), $key, $this);
555: } elseif (is_string($f)) {
556: switch ($f) {
557: case 'sum':
558: $this->totals[$key] += $this->model->get($key);
559:
560: break;
561: case 'count':
562: ++$this->totals[$key];
563:
564: break;
565: case 'min':
566: if ($this->model->get($key) < $this->totals[$key]) {
567: $this->totals[$key] = $this->model->get($key);
568: }
569:
570: break;
571: case 'max':
572: if ($this->model->get($key) > $this->totals[$key]) {
573: $this->totals[$key] = $this->model->get($key);
574: }
575:
576: break;
577: default:
578: throw (new Exception('Unsupported table aggregate function'))
579: ->addMoreInfo('name', $f);
580: }
581: }
582: }
583: }
584: }
585:
586: /**
587: * Responds with the HTML to be inserted in the header row that would
588: * contain captions of all columns.
589: */
590: public function getHeaderRowHtml(): string
591: {
592: $output = [];
593: foreach ($this->columns as $name => $column) {
594: // if multiple formatters are defined, use the first for the header cell
595: if (is_array($column)) {
596: $column = $column[0];
597: }
598:
599: if (!is_int($name)) {
600: $field = $this->model->getField($name);
601:
602: $output[] = $column->getHeaderCellHtml($field);
603: } else {
604: $output[] = $column->getHeaderCellHtml();
605: }
606: }
607:
608: return implode('', $output);
609: }
610:
611: /**
612: * Responds with HTML to be inserted in the footer row that would
613: * contain totals for all columns.
614: */
615: public function getTotalsRowHtml(): string
616: {
617: $output = [];
618: foreach ($this->columns as $name => $column) {
619: // if no totals plan, then show dash, but keep column formatting
620: if (!isset($this->totalsPlan[$name])) {
621: $output[] = $column->getTag('foot', '-');
622:
623: continue;
624: }
625:
626: // if totals plan is set as array, then show formatted value
627: if (is_array($this->totalsPlan[$name])) {
628: $field = $this->model->getField($name);
629: $output[] = $column->getTotalsCellHtml($field, $this->totals[$name]);
630:
631: continue;
632: }
633:
634: // otherwise just show it, for example, "Totals:" cell
635: $output[] = $column->getTag('foot', $this->totalsPlan[$name]);
636: }
637:
638: return implode('', $output);
639: }
640:
641: /**
642: * Collects cell templates from all the columns and combine them into row template.
643: */
644: public function getDataRowHtml(): string
645: {
646: $output = [];
647: foreach ($this->columns as $name => $column) {
648: // if multiple formatters are defined, use the first for the header cell
649: $field = !is_int($name) ? $this->model->getField($name) : null;
650:
651: if (!is_array($column)) {
652: $column = [$column];
653: }
654:
655: // we need to smartly wrap things up
656: $cell = null;
657: $tdAttr = [];
658: foreach ($column as $cKey => $c) {
659: if ($cKey !== array_key_last($column)) {
660: $html = $c->getDataCellTemplate($field);
661: $tdAttr = $c->getTagAttributes('body', $tdAttr);
662: } else {
663: // last formatter, ask it to give us whole rendering
664: $html = $c->getDataCellHtml($field, $tdAttr);
665: }
666:
667: if ($cell) {
668: if ($name) {
669: // if name is set, we can wrap things
670: $cell = str_replace('{$' . $name . '}', $cell, $html);
671: } else {
672: $cell .= ' ' . $html;
673: }
674: } else {
675: $cell = $html;
676: }
677: }
678:
679: $output[] = $cell;
680: }
681:
682: return implode('', $output);
683: }
684: }
685: