1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui;
6:
7: use Atk4\Core\Factory;
8: use Atk4\Data\Model;
9: use Atk4\Ui\Js\JsBlock;
10: use Atk4\Ui\Js\JsExpressionable;
11: use Atk4\Ui\Js\JsToast;
12: use Atk4\Ui\UserAction\ExecutorFactory;
13: use Atk4\Ui\UserAction\ExecutorInterface;
14: use Atk4\Ui\UserAction\SharedExecutorsContainer;
15:
16: /**
17: * A collection of Card set from a model.
18: */
19: class CardDeck extends View
20: {
21: public $ui = 'basic segment atk-card-deck';
22:
23: public $defaultTemplate = 'card-deck.html';
24:
25: /** @var class-string<View> Card type inside this deck. */
26: public $card = Card::class;
27:
28: /** @var bool Whether card should use table display or not. */
29: public $useTable = false;
30:
31: /** @var bool Whether card should use label display or not. */
32: public $useLabel = false;
33:
34: /** @var string|null If using extra field in Card, glue, join them using extra glue. */
35: public $extraGlue = ' - ';
36:
37: /** @var bool If each card should use action or not. */
38: public $useAction = true;
39:
40: /** @var SharedExecutorsContainer|null */
41: public $sharedExecutorsContainer = [SharedExecutorsContainer::class];
42:
43: /** @var View|null The container view. The view that is reload when page or data changed. */
44: public $container = [View::class, 'ui' => 'vertical segment'];
45:
46: /** @var View The view containing Cards. */
47: public $cardHolder = [View::class, 'ui' => 'cards'];
48:
49: /** @var Paginator|false|null The paginator view. */
50: public $paginator = [Paginator::class];
51:
52: /** @var int The number of cards to be displayed per page. */
53: public $ipp = 9;
54:
55: /** @var Menu|array|false Will be initialized to Menu object, however you can set this to false to disable menu. */
56: public $menu;
57:
58: /** @var array|VueComponent\ItemSearch|false */
59: public $search = [VueComponent\ItemSearch::class];
60:
61: /** @var array Default notifier to perform when model action is successful * */
62: public $notifyDefault = [JsToast::class];
63:
64: /** Model single scope action to include in table action column. Will include all single scope actions if empty. */
65: public array $singleScopeActions = [];
66:
67: /** Model no_record scope action to include in menu. Will include all no record scope actions if empty. */
68: public array $noRecordScopeActions = [];
69:
70: /** @var string Message to display when record is add or edit successfully. */
71: public $saveMsg = 'Record has been saved!';
72:
73: /** @var string Message to display when record is delete successfully. */
74: public $deleteMsg = 'Record has been deleted!';
75:
76: /** @var string Generic display message for no record scope action where model is not loaded. */
77: public $defaultMsg = 'Done!';
78:
79: /** @var array seed to create View for displaying when search result is empty. */
80: public $noRecordDisplay = [
81: Message::class,
82: 'content' => 'Result empty!',
83: 'icon' => 'info circle',
84: 'text' => 'Your search did not return any record or there is no record available.',
85: ];
86:
87: /** @var array A collection of menu button added in Menu. */
88: private $menuActions = [];
89:
90: /** @var string|null The current search query string. */
91: private $query;
92:
93: #[\Override]
94: protected function init(): void
95: {
96: parent::init();
97:
98: $this->sharedExecutorsContainer = $this->add($this->sharedExecutorsContainer);
99:
100: $this->container = $this->add($this->container);
101:
102: if ($this->menu !== false && !is_object($this->menu)) {
103: $this->menu = $this->add(Factory::factory([Menu::class, 'activateOnClick' => false], $this->menu), 'Menu');
104:
105: if ($this->search !== false) {
106: $this->addMenuBarSearch();
107: }
108: }
109:
110: $this->cardHolder = $this->container->add($this->cardHolder);
111:
112: if ($this->paginator !== false) {
113: $this->addPaginator();
114: $this->stickyGet($this->paginator->name);
115: }
116: }
117:
118: protected function addMenuBarSearch(): void
119: {
120: $view = View::addTo($this->menu->addMenuRight()->addItem()->setElement('div'));
121:
122: $this->search = $view->add(Factory::factory($this->search, ['context' => $this->container]));
123: $this->search->reload = $this->container;
124: $this->query = $this->stickyGet($this->search->queryArg);
125: }
126:
127: protected function addPaginator(): void
128: {
129: $seg = View::addTo($this->container, ['ui' => 'basic segment'])->setStyle('text-align', 'center');
130: $this->paginator = $seg->add(Factory::factory($this->paginator, ['reload' => $this->container]));
131: }
132:
133: /**
134: * @param array<int, string>|null $fields
135: */
136: #[\Override]
137: public function setModel(Model $model, array $fields = null, array $extra = null): void
138: {
139: parent::setModel($model);
140:
141: if ($this->search !== false) {
142: $this->search->setModelCondition($this->model);
143: }
144:
145: $count = $this->initPaginator();
146: if ($count) {
147: foreach ($this->model as $m) {
148: /** @var Card */
149: $c = $this->cardHolder->add(Factory::factory([$this->card], ['useLabel' => $this->useLabel, 'useTable' => $this->useTable]));
150: $c->setModel($m, $fields);
151: if ($extra) {
152: $c->addExtraFields($m, $extra, $this->extraGlue);
153: }
154: if ($this->useAction) {
155: foreach ($this->getModelActions(Model\UserAction::APPLIES_TO_SINGLE_RECORD) as $action) {
156: $c->addClickAction($action, null, $this->getReloadArgs());
157: }
158: }
159: }
160: } else {
161: $this->cardHolder->addClass('centered')->add(Factory::factory($this->noRecordDisplay));
162: }
163:
164: // add no record scope action to menu
165: if ($this->useAction && $this->menu) {
166: foreach ($this->getModelActions(Model\UserAction::APPLIES_TO_NO_RECORDS) as $k => $action) {
167: $executor = $this->initActionExecutor($action);
168: $this->menuActions[$k]['button'] = $this->menu->addItem(
169: $this->getExecutorFactory()->createTrigger($action, ExecutorFactory::MENU_ITEM)
170: );
171: $this->menuActions[$k]['executor'] = $executor;
172: }
173: }
174:
175: $this->setItemsAction();
176: }
177:
178: /**
179: * Setup JS for firing menu action - copied from Crud - TODO deduplicate.
180: */
181: protected function setItemsAction(): void
182: {
183: foreach ($this->menuActions as $item) {
184: // hack - render executor action via MenuItem::on() into container
185: $item['button']->on('click.atk_crud_item', $item['executor']);
186: $jsAction = array_pop($item['button']->_jsActions['click.atk_crud_item']);
187: $this->container->js(true, $jsAction);
188: }
189: }
190:
191: /**
192: * Setup executor for an action.
193: * First determine what fields action needs,
194: * then setup executor based on action fields, args and/or preview.
195: *
196: * Single record scope action use jsSuccess instead of afterExecute hook
197: * because hook will keep adding for every cards, thus repeating jsExecute multiple time,
198: * i.e. once for each card, unless hook is break.
199: */
200: protected function initActionExecutor(Model\UserAction $action): ExecutorInterface
201: {
202: $executor = $this->getExecutorFactory()->createExecutor($action, $this);
203: if ($action->appliesTo === Model\UserAction::APPLIES_TO_SINGLE_RECORD) {
204: $executor->jsSuccess = function (ExecutorInterface $ex, Model $m, $id, $return) use ($action) {
205: return $this->jsExecute($return, $action);
206: };
207: } else {
208: $executor->onHook(UserAction\BasicExecutor::HOOK_AFTER_EXECUTE, function (ExecutorInterface $ex, $return, $id) use ($action) {
209: return $this->jsExecute($return, $action);
210: });
211: }
212:
213: return $executor;
214: }
215:
216: /**
217: * Return proper JS statement for afterExecute hook on action executor
218: * depending on return type, model loaded and action scope.
219: *
220: * @param string|JsExpressionable|Model|null $return
221: */
222: protected function jsExecute($return, Model\UserAction $action): JsBlock
223: {
224: $res = new JsBlock();
225:
226: if ($return instanceof Model) {
227: $return = $return->isLoaded()
228: ? $this->saveMsg
229: : ($action->appliesTo === Model\UserAction::APPLIES_TO_SINGLE_RECORD ? $this->deleteMsg : $this->defaultMsg);
230: }
231:
232: if (is_string($return)) {
233: $msg = $this->jsCreateNotifier($action, $return);
234: } elseif ($return instanceof JsExpressionable) {
235: $msg = $return;
236: } else {
237: $msg = $this->jsCreateNotifier($action, $this->defaultMsg);
238: }
239: $res->addStatement($msg);
240:
241: $res->addStatement($this->container->jsReload($this->getReloadArgs()));
242:
243: return $res;
244: }
245:
246: /**
247: * Override this method for setting notifier based on action or model value.
248: */
249: protected function jsCreateNotifier(Model\UserAction $action, string $msg = null): JsBlock
250: {
251: $notifier = Factory::factory($this->notifyDefault);
252: if ($msg) {
253: $notifier->setMessage($msg);
254: }
255:
256: return new JsBlock([$notifier]);
257: }
258:
259: /**
260: * Return reload argument based on Deck condition.
261: *
262: * @return mixed
263: */
264: private function getReloadArgs()
265: {
266: $args = [];
267: if ($this->paginator !== false) {
268: $args[$this->paginator->name] = $this->paginator->getCurrentPage();
269: }
270: if ($this->search !== false) {
271: $args[$this->search->queryArg] = $this->query;
272: }
273:
274: return $args;
275: }
276:
277: /**
278: * Return proper action need to setup menu or action column.
279: */
280: private function getModelActions(string $appliesTo): array
281: {
282: if ($appliesTo === Model\UserAction::APPLIES_TO_SINGLE_RECORD && $this->singleScopeActions !== []) {
283: $actions = array_map(fn ($v) => $this->model->getUserAction($v), $this->singleScopeActions);
284: } elseif ($appliesTo === Model\UserAction::APPLIES_TO_NO_RECORDS && $this->noRecordScopeActions !== []) {
285: $actions = array_map(fn ($v) => $this->model->getUserAction($v), $this->noRecordScopeActions);
286: } else {
287: $actions = $this->model->getUserActions($appliesTo);
288: }
289:
290: return $actions;
291: }
292:
293: /**
294: * Will set model limit according to paginator value.
295: */
296: protected function initPaginator(): int
297: {
298: $count = $this->model->executeCountQuery();
299: if ($this->paginator) {
300: if ($count > 0) {
301: $this->paginator->setTotal((int) ceil($count / $this->ipp));
302: $this->model->setLimit($this->ipp, ($this->paginator->page - 1) * $this->ipp);
303: } else {
304: $this->paginator->destroy();
305: }
306: }
307:
308: return $count;
309: }
310: }
311: