1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui;
6:
7: use Atk4\Core\Factory;
8: use Atk4\Core\HookTrait;
9: use Atk4\Data\Field;
10: use Atk4\Data\Model;
11: use Atk4\Ui\Js\Jquery;
12: use Atk4\Ui\Js\JsExpressionable;
13: use Atk4\Ui\Js\JsReload;
14: use Atk4\Ui\UserAction\ConfirmationExecutor;
15: use Atk4\Ui\UserAction\ExecutorFactory;
16: use Atk4\Ui\UserAction\ExecutorInterface;
17:
18: /**
19: * @phpstan-type JsCallbackSetClosure \Closure(Jquery, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed): (JsExpressionable|View|string|void)
20: */
21: class Grid extends View
22: {
23: use HookTrait;
24:
25: /** @var Menu|array|false Will be initialized to Menu object, however you can set this to false to disable menu. */
26: public $menu;
27:
28: /** @var JsSearch|null */
29: public $quickSearch;
30:
31: /** @var array Field names to search for in Model. It will automatically add quicksearch component to grid if set. */
32: public $searchFieldNames = [];
33:
34: /**
35: * Paginator is automatically added below the table and will divide long tables into pages.
36: *
37: * You can provide your own Paginator object here to customize.
38: *
39: * @var Paginator|false
40: */
41: public $paginator;
42:
43: /** @var int Number of items per page to display. */
44: public $ipp = 50;
45:
46: /**
47: * Calling addActionButton will add a new column inside $table, and will be re-used
48: * for next addActionButton().
49: *
50: * @var Table\Column\ActionButtons|null
51: */
52: public $actionButtons;
53:
54: /**
55: * Calling addActionMenuItem will add a new column inside $table with dropdown menu,
56: * and will be re-used for next addActionMenuItem().
57: *
58: * @var Table\Column|null
59: */
60: public $actionMenu;
61:
62: /**
63: * Calling addSelection will add a new column inside $table, containing checkboxes.
64: * This column will be stored here, in case you want to access it.
65: *
66: * @var Table\Column\Checkbox
67: */
68: public $selection;
69:
70: /**
71: * Grid can be sorted by clicking on column headers. This will be automatically enabled
72: * if Model supports ordering. You may override by setting true/false.
73: *
74: * @var bool
75: */
76: public $sortable;
77:
78: /** @var string|null Set this if you want GET argument name to look beautifully for sorting. */
79: public $sortTrigger;
80:
81: /** @var Table|false Component that actually renders data rows / columns and possibly totals. */
82: public $table;
83:
84: /** @var View The container for table and paginator. */
85: public $container;
86:
87: public $defaultTemplate = 'grid.html';
88:
89: /** @var array Defines which Table Decorator to use for ActionButtons. */
90: protected $actionButtonsDecorator = [Table\Column\ActionButtons::class];
91:
92: /** @var array Defines which Table Decorator to use for ActionMenu. */
93: protected $actionMenuDecorator = [Table\Column\ActionMenu::class, 'label' => 'Actions...'];
94:
95: #[\Override]
96: protected function init(): void
97: {
98: parent::init();
99:
100: $this->container = View::addTo($this, ['template' => $this->template->cloneRegion('Container')]);
101: $this->template->del('Container');
102:
103: if (!$this->sortTrigger) {
104: $this->sortTrigger = $this->name . '_sort';
105: }
106:
107: if ($this->menu !== false && !is_object($this->menu)) {
108: $this->menu = $this->add(Factory::factory([Menu::class, 'activateOnClick' => false], $this->menu), 'Menu');
109: }
110:
111: $this->table = $this->initTable();
112:
113: if ($this->paginator !== false) {
114: $seg = View::addTo($this->container, [], ['Paginator'])->setStyle('text-align', 'center');
115: $this->paginator = $seg->add(Factory::factory([Paginator::class, 'reload' => $this->container], $this->paginator));
116: $this->stickyGet($this->paginator->name);
117: }
118:
119: // TODO dirty way to set stickyGet - add addQuickSearch to find the expected search input component ID and then remove it
120: if ($this->menu !== false) {
121: $appUniqueHashesBackup = $this->getApp()->uniqueNameHashes;
122: $menuElementNameCountsBackup = \Closure::bind(fn () => $this->_elementNameCounts, $this->menu, AbstractView::class)();
123: try {
124: $menuRight = $this->menu->addMenuRight(); // @phpstan-ignore-line
125: $menuItemView = View::addTo($menuRight->addItem()->setElement('div'));
126: $quickSearch = JsSearch::addTo($menuItemView);
127: $this->stickyGet($quickSearch->name . '_q');
128: $this->menu->removeElement($menuRight->shortName);
129: } finally {
130: $this->getApp()->uniqueNameHashes = $appUniqueHashesBackup;
131: \Closure::bind(fn () => $this->_elementNameCounts = $menuElementNameCountsBackup, $this->menu, AbstractView::class)();
132: }
133: }
134: }
135:
136: protected function initTable(): Table
137: {
138: /** @var Table */
139: $table = $this->container->add(Factory::factory([Table::class, 'class.very compact very basic striped single line' => true, 'reload' => $this->container], $this->table), 'Table');
140:
141: return $table;
142: }
143:
144: /**
145: * Add new column to grid. If column with this name already exists,
146: * an. Simply calls Table::addColumn(), so check that method out.
147: *
148: * @param string|null $name Data model field name
149: * @param array|Table\Column $columnDecorator
150: * @param ($name is null ? array{} : array|Field) $field
151: *
152: * @return Table\Column
153: */
154: public function addColumn(?string $name, $columnDecorator = [], $field = [])
155: {
156: return $this->table->addColumn($name, $columnDecorator, $field);
157: }
158:
159: /**
160: * Add additional decorator for existing column.
161: *
162: * @param array|Table\Column $seed
163: *
164: * @return Table\Column
165: */
166: public function addDecorator(string $name, $seed)
167: {
168: return $this->table->addDecorator($name, $seed);
169: }
170:
171: /**
172: * Add a new button to the Grid Menu with a given text.
173: *
174: * @param string $label
175: */
176: public function addButton($label): Button
177: {
178: if (!$this->menu) {
179: throw new Exception('Unable to add Button without Menu');
180: }
181:
182: return Button::addTo($this->menu->addItem(), [$label]);
183: }
184:
185: /**
186: * Set item per page value.
187: *
188: * If an array is passed, it will also add an ItemPerPageSelector to paginator.
189: *
190: * @param int|list<int> $ipp
191: * @param string $label
192: */
193: public function setIpp($ipp, $label = 'Items per page:'): void
194: {
195: if (is_array($ipp)) {
196: $this->addItemsPerPageSelector($ipp, $label);
197: } else {
198: $this->ipp = $ipp;
199: }
200: }
201:
202: /**
203: * Add ItemsPerPageSelector View in grid menu or paginator in order to dynamically setup number of item per page.
204: *
205: * @param list<int> $items an array of item's per page value
206: * @param string $label the memu item label
207: *
208: * @return $this
209: */
210: public function addItemsPerPageSelector(array $items = [10, 100, 1000], $label = 'Items per page:')
211: {
212: $ipp = (int) $this->container->stickyGet('ipp');
213: if ($ipp) {
214: $this->ipp = $ipp;
215: } else {
216: $this->ipp = $items[0];
217: }
218:
219: $pageLength = ItemsPerPageSelector::addTo($this->paginator, ['pageLengthItems' => $items, 'label' => $label, 'currentIpp' => $this->ipp], ['afterPaginator']);
220: $this->paginator->template->trySet('PaginatorType', 'ui grid');
221:
222: $sortBy = $this->getSortBy();
223: if ($sortBy) {
224: $pageLength->stickyGet($this->sortTrigger, $sortBy);
225: }
226:
227: $pageLength->onPageLengthSelect(function (int $ipp) {
228: $this->ipp = $ipp;
229: $this->setModelLimitFromPaginator();
230: // add ipp to quicksearch
231: if ($this->quickSearch instanceof JsSearch) {
232: $this->container->js(true, $this->quickSearch->js()->atkJsSearch('setUrlArgs', ['ipp', $this->ipp]));
233: }
234: $this->applySort();
235:
236: // return the view to reload
237: return $this->container;
238: });
239:
240: return $this;
241: }
242:
243: /**
244: * Add dynamic scrolling paginator.
245: *
246: * @param int $ipp number of item per page to start with
247: * @param array $options an array with JS Scroll plugin options
248: * @param View $container the container holding the lister for scrolling purpose
249: * @param string $scrollRegion A specific template region to render. Render output is append to container HTML element.
250: *
251: * @return $this
252: */
253: public function addJsPaginator($ipp, $options = [], $container = null, $scrollRegion = 'Body')
254: {
255: if ($this->paginator) {
256: $this->paginator->destroy();
257: // prevent action(count) to be output twice
258: $this->paginator = null;
259: }
260:
261: $sortBy = $this->getSortBy();
262: if ($sortBy) {
263: $this->stickyGet($this->sortTrigger, $sortBy);
264: }
265: $this->applySort();
266:
267: $this->table->addJsPaginator($ipp, $options, $container, $scrollRegion);
268:
269: return $this;
270: }
271:
272: /**
273: * Add dynamic scrolling paginator in container.
274: * Use this to make table headers fixed.
275: *
276: * @param int $ipp number of item per page to start with
277: * @param int $containerHeight number of pixel the table container should be
278: * @param array $options an array with JS Scroll plugin options
279: * @param View $container the container holding the lister for scrolling purpose
280: * @param string $scrollRegion A specific template region to render. Render output is append to container HTML element.
281: *
282: * @return $this
283: */
284: public function addJsPaginatorInContainer($ipp, $containerHeight, $options = [], $container = null, $scrollRegion = 'Body')
285: {
286: $this->table->hasCollapsingCssActionColumn = false;
287: $options = array_merge($options, [
288: 'hasFixTableHeader' => true,
289: 'tableContainerHeight' => $containerHeight,
290: ]);
291: // adding a state context to JS scroll plugin
292: $options = array_merge(['stateContext' => $this->container], $options);
293:
294: return $this->addJsPaginator($ipp, $options, $container, $scrollRegion);
295: }
296:
297: /**
298: * Add Search input field using JS action.
299: * By default, will query server when using Enter key on input search field.
300: * You can change it to query server on each keystroke by passing $autoQuery true,.
301: *
302: * @param array $fields the list of fields to search for
303: * @param bool $hasAutoQuery will query server on each key pressed
304: */
305: public function addQuickSearch($fields = [], $hasAutoQuery = false): void
306: {
307: if (!$this->model) {
308: throw new Exception('Call setModel() before addQuickSearch()');
309: }
310:
311: if (!$fields) {
312: $fields = [$this->model->titleField];
313: }
314:
315: if (!$this->menu) {
316: throw new Exception('Unable to add QuickSearch without Menu');
317: }
318:
319: $view = View::addTo($this->menu->addMenuRight()->addItem()->setElement('div'));
320:
321: $this->quickSearch = JsSearch::addTo($view, ['reload' => $this->container, 'autoQuery' => $hasAutoQuery]);
322: $q = $this->stickyGet($this->quickSearch->name . '_q') ?? '';
323: $qWords = preg_split('~\s+~', $q, -1, \PREG_SPLIT_NO_EMPTY);
324: if (count($qWords) > 0) {
325: $andScope = Model\Scope::createAnd();
326: foreach ($qWords as $v) {
327: $orScope = Model\Scope::createOr();
328: foreach ($fields as $field) {
329: $orScope->addCondition($field, 'like', '%' . $v . '%');
330: }
331: $andScope->addCondition($orScope);
332: }
333: $this->model->addCondition($andScope);
334: }
335: $this->quickSearch->initValue = $q;
336: }
337:
338: #[\Override]
339: public function jsReload($args = [], $afterSuccess = null, $apiConfig = []): JsExpressionable
340: {
341: return new JsReload($this->container, $args, $afterSuccess, $apiConfig);
342: }
343:
344: /**
345: * Adds a new button into the action column on the right. For Crud this
346: * column will already contain "delete" and "edit" buttons.
347: *
348: * @param string|array|View $button Label text, object or seed for the Button
349: * @param JsExpressionable|JsCallbackSetClosure $action
350: * @param bool $isDisabled
351: *
352: * @return View
353: */
354: public function addActionButton($button, $action = null, string $confirmMsg = '', $isDisabled = false)
355: {
356: return $this->getActionButtons()->addButton($button, $action, $confirmMsg, $isDisabled);
357: }
358:
359: /**
360: * Add a button for executing a model action via an action executor.
361: *
362: * @return View
363: */
364: public function addExecutorButton(UserAction\ExecutorInterface $executor, Button $button = null)
365: {
366: if ($button !== null) {
367: $this->add($button);
368: } else {
369: $button = $this->getExecutorFactory()->createTrigger($executor->getAction(), ExecutorFactory::TABLE_BUTTON);
370: }
371:
372: $confirmation = $executor->getAction()->getConfirmation();
373: if (!$confirmation) {
374: $confirmation = '';
375: }
376: $disabled = is_bool($executor->getAction()->enabled) ? !$executor->getAction()->enabled : $executor->getAction()->enabled;
377:
378: return $this->getActionButtons()->addButton($button, $executor, $confirmation, $disabled);
379: }
380:
381: private function getActionButtons(): Table\Column\ActionButtons
382: {
383: if ($this->actionButtons === null) {
384: $this->actionButtons = $this->table->addColumn(null, $this->actionButtonsDecorator);
385: }
386:
387: return $this->actionButtons; // @phpstan-ignore-line
388: }
389:
390: /**
391: * Similar to addActionButton. Will add Button that when click will display
392: * a Dropdown menu.
393: *
394: * @param View|string $view
395: * @param JsExpressionable|JsCallbackSetClosure $action
396: *
397: * @return View
398: */
399: public function addActionMenuItem($view, $action = null, string $confirmMsg = '', bool $isDisabled = false)
400: {
401: return $this->getActionMenu()->addActionMenuItem($view, $action, $confirmMsg, $isDisabled);
402: }
403:
404: /**
405: * @return View
406: */
407: public function addExecutorMenuItem(ExecutorInterface $executor)
408: {
409: $item = $this->getExecutorFactory()->createTrigger($executor->getAction(), ExecutorFactory::TABLE_MENU_ITEM);
410: // ConfirmationExecutor take care of showing the user confirmation, thus make it empty
411: $confirmation = !$executor instanceof ConfirmationExecutor ? $executor->getAction()->getConfirmation() : '';
412: if (!$confirmation) {
413: $confirmation = '';
414: }
415: $disabled = is_bool($executor->getAction()->enabled) ? !$executor->getAction()->enabled : $executor->getAction()->enabled;
416:
417: return $this->getActionMenu()->addActionMenuItem($item, $executor, $confirmation, $disabled);
418: }
419:
420: /**
421: * @return Table\Column\ActionMenu
422: */
423: private function getActionMenu()
424: {
425: if (!$this->actionMenu) {
426: $this->actionMenu = $this->table->addColumn(null, $this->actionMenuDecorator);
427: }
428:
429: return $this->actionMenu; // @phpstan-ignore-line
430: }
431:
432: /**
433: * Add action menu items using Model.
434: * You may specify the scope of actions to be added.
435: *
436: * @param string|null $appliesTo the scope of model action
437: */
438: public function addActionMenuFromModel(string $appliesTo = null): void
439: {
440: foreach ($this->model->getUserActions($appliesTo) as $action) {
441: $this->addActionMenuItem($action);
442: }
443: }
444:
445: /**
446: * An array of column name where filter is needed.
447: * Leave empty to include all column in grid.
448: *
449: * @param array|null $names an array with the name of column
450: *
451: * @return $this
452: */
453: public function addFilterColumn($names = null)
454: {
455: if (!$this->menu) {
456: throw new Exception('Unable to add Filter Column without Menu');
457: }
458: $this->menu->addItem(['Clear Filters'], new JsReload($this->table->reload, ['atk_clear_filter' => 1]));
459: $this->table->setFilterColumn($names);
460:
461: return $this;
462: }
463:
464: /**
465: * Add a dropdown menu to header column.
466: *
467: * @param string $columnName the name of column where to add dropdown
468: * @param array $items the menu items to add
469: * @param \Closure(string): (JsExpressionable|View|string|void) $fx the callback function to execute when an item is selected
470: * @param string $icon the icon
471: * @param string $menuId the menu ID return by callback
472: */
473: public function addDropdown(string $columnName, $items, \Closure $fx, $icon = 'caret square down', $menuId = null): void
474: {
475: $column = $this->table->columns[$columnName];
476:
477: if (!$menuId) {
478: $menuId = $columnName;
479: }
480:
481: $column->addDropdown($items, static function (string $item) use ($fx) {
482: return $fx($item);
483: }, $icon, $menuId);
484: }
485:
486: /**
487: * Add a popup to header column.
488: *
489: * @param string $columnName the name of column where to add popup
490: * @param Popup $popup popup view
491: * @param string $icon the icon
492: *
493: * @return mixed
494: */
495: public function addPopup($columnName, $popup = null, $icon = 'caret square down')
496: {
497: $column = $this->table->columns[$columnName];
498:
499: return $column->addPopup($popup, $icon);
500: }
501:
502: /**
503: * Similar to addActionButton but when button is clicked, modal is displayed
504: * with the $title and $callback is executed.
505: *
506: * @param string|array|View $button
507: * @param string $title
508: * @param \Closure(View, string|null): void $callback
509: * @param array $args extra URL argument for callback
510: *
511: * @return View
512: */
513: public function addModalAction($button, $title, \Closure $callback, $args = [])
514: {
515: return $this->getActionButtons()->addModal($button, $title, $callback, $this, $args);
516: }
517:
518: /**
519: * @return list<string>
520: */
521: private function explodeSelectionValue(string $value): array
522: {
523: return $value === '' ? [] : explode(',', $value);
524: }
525:
526: /**
527: * Similar to addActionButton but apply to a multiple records selection and display in menu.
528: * When menu item is clicked, $callback is executed.
529: *
530: * @param string|array|MenuItem $item
531: * @param \Closure(Js\Jquery, list<string>): JsExpressionable $callback
532: * @param array $args extra URL argument for callback
533: *
534: * @return View
535: */
536: public function addBulkAction($item, \Closure $callback, $args = [])
537: {
538: $menuItem = $this->menu->addItem($item);
539: $menuItem->on('click', function (Js\Jquery $j, string $value) use ($callback) {
540: return $callback($j, $this->explodeSelectionValue($value));
541: }, [$this->selection->jsChecked()]);
542:
543: return $menuItem;
544: }
545:
546: /**
547: * Similar to addModalAction but apply to a multiple records selection and display in menu.
548: * When menu item is clicked, modal is displayed with the $title and $callback is executed.
549: *
550: * @param string|array|MenuItem $item
551: * @param string $title
552: * @param \Closure(View, list<string>): void $callback
553: * @param array $args extra URL argument for callback
554: *
555: * @return View
556: */
557: public function addModalBulkAction($item, $title, \Closure $callback, $args = [])
558: {
559: $modalDefaults = is_string($title) ? ['title' => $title] : []; // @phpstan-ignore-line
560:
561: $modal = Modal::addTo($this->getOwner(), $modalDefaults);
562: $modal->set(function (View $t) use ($callback) {
563: $callback($t, $this->explodeSelectionValue($t->stickyGet($this->name) ?? ''));
564: });
565:
566: $menuItem = $this->menu->addItem($item);
567: $menuItem->on('click', $modal->jsShow(array_merge([$this->name => $this->selection->jsChecked()], $args)));
568:
569: return $menuItem;
570: }
571:
572: /**
573: * Get sortBy value from URL parameter.
574: */
575: public function getSortBy(): ?string
576: {
577: return $this->getApp()->tryGetRequestQueryParam($this->sortTrigger);
578: }
579:
580: /**
581: * Apply ordering to the current model as per the sort parameters.
582: */
583: public function applySort(): void
584: {
585: if ($this->sortable === false) {
586: return;
587: }
588:
589: $sortBy = $this->getSortBy();
590:
591: if ($sortBy && $this->paginator) {
592: $this->paginator->addReloadArgs([$this->sortTrigger => $sortBy]);
593: }
594:
595: $isDesc = false;
596: if ($sortBy && substr($sortBy, 0, 1) === '-') {
597: $isDesc = true;
598: $sortBy = substr($sortBy, 1);
599: }
600:
601: $this->table->sortable = true;
602:
603: if ($sortBy && isset($this->table->columns[$sortBy]) && $this->model->hasField($sortBy)) {
604: $this->model->setOrder($sortBy, $isDesc ? 'desc' : 'asc');
605: $this->table->sortBy = $sortBy;
606: $this->table->sortDirection = $isDesc ? 'desc' : 'asc';
607: }
608:
609: $this->table->on(
610: 'click',
611: 'thead>tr>th.sortable',
612: new JsReload($this->container, [$this->sortTrigger => (new Jquery())->data('sort')])
613: );
614: }
615:
616: /**
617: * @param array<int, string>|null $fields if null, then all "editable" fields will be added
618: */
619: #[\Override]
620: public function setModel(Model $model, array $fields = null): void
621: {
622: $this->table->setModel($model, $fields);
623:
624: parent::setModel($model);
625:
626: if ($this->searchFieldNames) {
627: $this->addQuickSearch($this->searchFieldNames, true);
628: }
629: }
630:
631: /**
632: * Makes rows of this grid selectable by creating new column on the left with
633: * checkboxes.
634: *
635: * @return Table\Column\Checkbox
636: */
637: public function addSelection()
638: {
639: $this->selection = $this->table->addColumn(null, [Table\Column\Checkbox::class]);
640:
641: // move last column to the beginning in table column array
642: array_unshift($this->table->columns, array_pop($this->table->columns));
643:
644: return $this->selection;
645: }
646:
647: /**
648: * Add column with drag handler on each row.
649: * Drag handler allow to reorder table via drag and drop.
650: *
651: * @return Table\Column
652: */
653: public function addDragHandler()
654: {
655: $handler = $this->table->addColumn(null, [Table\Column\DragHandler::class]);
656:
657: // move last column to the beginning in table column array
658: array_unshift($this->table->columns, array_pop($this->table->columns));
659:
660: return $handler;
661: }
662:
663: private function setModelLimitFromPaginator(): void
664: {
665: $this->paginator->setTotal((int) ceil($this->model->executeCountQuery() / $this->ipp));
666: $this->model->setLimit($this->ipp, ($this->paginator->page - 1) * $this->ipp);
667: }
668:
669: #[\Override]
670: protected function renderView(): void
671: {
672: // take care of sorting
673: if (!$this->table->jsPaginator) {
674: $this->applySort();
675: }
676:
677: parent::renderView();
678: }
679:
680: #[\Override]
681: protected function recursiveRender(): void
682: {
683: // bind with paginator
684: if ($this->paginator) {
685: $this->setModelLimitFromPaginator();
686: }
687:
688: if ($this->quickSearch instanceof JsSearch) {
689: $sortBy = $this->getSortBy();
690: if ($sortBy) {
691: $this->container->js(true, $this->quickSearch->js()->atkJsSearch('setUrlArgs', [$this->sortTrigger, $sortBy]));
692: }
693: }
694:
695: parent::recursiveRender();
696: }
697:
698: /**
699: * Proxy function for Table::jsRow().
700: *
701: * @return Jquery
702: */
703: public function jsRow(): JsExpressionable
704: {
705: return $this->table->jsRow();
706: }
707: }
708: