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\Jquery;
10: use Atk4\Ui\Js\JsBlock;
11: use Atk4\Ui\Js\JsExpression;
12: use Atk4\Ui\Js\JsExpressionable;
13: use Atk4\Ui\Js\JsFunction;
14: use Atk4\Ui\Js\JsToast;
15: use Atk4\Ui\UserAction\ExecutorFactory;
16: use Atk4\Ui\UserAction\ExecutorInterface;
17:
18: class Crud extends Grid
19: {
20: /** @var array of fields to display in Grid */
21: public $displayFields;
22:
23: /** @var array|null of fields to edit in Form for Model edit action */
24: public $editFields;
25:
26: /** @var array|null of fields to edit in Form for Model add action */
27: public $addFields;
28:
29: /** @var array Default notifier to perform when adding or editing is successful * */
30: public $notifyDefault = [JsToast::class];
31:
32: /** @var bool|null should we use table column drop-down menu to display user actions? */
33: public $useMenuActions;
34:
35: /** @var array<string, array{item: MenuItem, executor: AbstractView&ExecutorInterface}> Collection of APPLIES_TO_NO_RECORDS Scope Model action menu item */
36: private array $menuItems = [];
37:
38: /** Model single scope action to include in table action column. Will include all single scope actions if empty. */
39: public array $singleScopeActions = [];
40:
41: /** Model no_record scope action to include in menu. Will include all no record scope actions if empty. */
42: public array $noRecordScopeActions = [];
43:
44: /** @var string Message to display when record is add or edit successfully. */
45: public $saveMsg = 'Record has been saved!';
46:
47: /** @var string Message to display when record is delete successfully. */
48: public $deleteMsg = 'Record has been deleted!';
49:
50: /** @var string Generic display message for no record scope action where model is not loaded. */
51: public $defaultMsg = 'Done!';
52:
53: /** @var array<int, array<string, \Closure(Form, UserAction\ModalExecutor): void>> Callback containers for model action. */
54: public $onActions = [];
55:
56: /** @var mixed recently deleted record ID. */
57: private $deletedId;
58:
59: #[\Override]
60: protected function init(): void
61: {
62: parent::init();
63:
64: $sortBy = $this->getSortBy();
65: if ($sortBy) {
66: $this->stickyGet($this->name . '_sort', $sortBy);
67: }
68: }
69:
70: #[\Override]
71: public function applySort(): void
72: {
73: parent::applySort();
74:
75: if ($this->getSortBy()) {
76: foreach ($this->menuItems as $item) {
77: // remove previous click handler and attach new one using sort argument
78: $this->container->js(true, $item['item']->js()->off('click.atk_crud_item'));
79: $ex = $item['executor'];
80: if ($ex instanceof UserAction\JsExecutorInterface) {
81: $ex->stickyGet($this->name . '_sort', $this->getSortBy());
82: $this->container->js(true, $item['item']->js()->on('click.atk_crud_item', new JsFunction([], $ex->jsExecute([]))));
83: }
84: }
85: }
86: }
87:
88: #[\Override]
89: public function setModel(Model $model, array $fields = null): void
90: {
91: $model->assertIsModel();
92:
93: if ($fields !== null) {
94: $this->displayFields = $fields;
95: }
96:
97: parent::setModel($model, $this->displayFields);
98:
99: // grab model ID when using delete
100: // must be set before delete action execute
101: $this->model->onHook(Model::HOOK_AFTER_DELETE, function (Model $model) {
102: $this->deletedId = $model->getId();
103: });
104:
105: if ($this->useMenuActions === null) {
106: $this->useMenuActions = count($model->getUserActions()) > 4;
107: }
108:
109: foreach ($this->_getModelActions(Model\UserAction::APPLIES_TO_SINGLE_RECORD) as $action) {
110: $executor = $this->initActionExecutor($action);
111: if ($this->useMenuActions) {
112: $this->addExecutorMenuItem($executor);
113: } else {
114: $this->addExecutorButton($executor);
115: }
116: }
117:
118: if ($this->menu) {
119: foreach ($this->_getModelActions(Model\UserAction::APPLIES_TO_NO_RECORDS) as $k => $action) {
120: if ($action->enabled) {
121: $executor = $this->initActionExecutor($action);
122: $this->menuItems[$k]['item'] = $this->menu->addItem(
123: $this->getExecutorFactory()->createTrigger($action, ExecutorFactory::MENU_ITEM)
124: );
125: $this->menuItems[$k]['executor'] = $executor;
126: }
127: }
128: $this->setItemsAction();
129: }
130: }
131:
132: /**
133: * Setup executor for an action.
134: * First determine what fields action needs,
135: * then setup executor based on action fields, args and/or preview.
136: *
137: * Add hook for onStep 'fields'" Hook can call a callback function
138: * for UserAction onStep field. Callback will receive executor form where you
139: * can setup Input field via javascript prior to display form or change form submit event
140: * handler.
141: *
142: * @return AbstractView&ExecutorInterface
143: */
144: protected function initActionExecutor(Model\UserAction $action)
145: {
146: $executor = $this->getExecutor($action);
147: $executor->onHook(UserAction\BasicExecutor::HOOK_AFTER_EXECUTE, function (ExecutorInterface $ex, $return, $id) use ($action) {
148: return $this->jsExecute($return, $action);
149: });
150:
151: if ($executor instanceof UserAction\ModalExecutor) {
152: foreach ($this->onActions as $onAction) {
153: $executor->onHook(UserAction\ModalExecutor::HOOK_STEP, static function (UserAction\ModalExecutor $ex, string $step, Form $form) use ($onAction, $action) {
154: $key = array_key_first($onAction);
155: if ($key === $action->shortName && $step === 'fields') {
156: $onAction[$key]($form, $ex);
157: }
158: });
159: }
160: }
161:
162: return $executor;
163: }
164:
165: /**
166: * Return proper JS statement for afterExecute hook on action executor
167: * depending on return type, model loaded and action scope.
168: *
169: * @param string|null $return
170: */
171: protected function jsExecute($return, Model\UserAction $action): JsBlock
172: {
173: $res = new JsBlock();
174: $jsAction = $this->getJsGridAction($action);
175: if ($jsAction) {
176: $res->addStatement($jsAction);
177: }
178:
179: // display msg return by action or depending on action modifier
180: if (is_string($return)) {
181: $res->addStatement($this->jsCreateNotifier($return));
182: } else {
183: if ($action->modifier === Model\UserAction::MODIFIER_CREATE || $action->modifier === Model\UserAction::MODIFIER_UPDATE) {
184: $res->addStatement($this->jsCreateNotifier($this->saveMsg));
185: } elseif ($action->modifier === Model\UserAction::MODIFIER_DELETE) {
186: $res->addStatement($this->jsCreateNotifier($this->deleteMsg));
187: } else {
188: $res->addStatement($this->jsCreateNotifier($this->defaultMsg));
189: }
190: }
191:
192: return $res;
193: }
194:
195: /**
196: * Return proper JS actions depending on action modifier type.
197: */
198: protected function getJsGridAction(Model\UserAction $action): ?JsExpressionable
199: {
200: switch ($action->modifier) {
201: case Model\UserAction::MODIFIER_UPDATE:
202: case Model\UserAction::MODIFIER_CREATE:
203: $js = $this->container->jsReload($this->_getReloadArgs());
204:
205: break;
206: case Model\UserAction::MODIFIER_DELETE:
207: // use deleted record ID to remove row, fallback to closest tr if ID is not available
208: $js = $this->deletedId
209: ? $this->js(false, null, 'tr[data-id="' . $this->deletedId . '"]')
210: : (new Jquery())->closest('tr');
211: $js = $js->transition('fade left', new JsFunction([], [new JsExpression('this.remove()')]));
212:
213: break;
214: default:
215: $js = null;
216: }
217:
218: return $js;
219: }
220:
221: /**
222: * Override this method for setting notifier based on action or model value.
223: */
224: protected function jsCreateNotifier(string $msg = null): JsExpressionable
225: {
226: $notifier = Factory::factory($this->notifyDefault);
227: if ($msg) {
228: $notifier->setMessage($msg);
229: }
230:
231: return $notifier;
232: }
233:
234: /**
235: * Setup JS for firing menu action.
236: */
237: protected function setItemsAction(): void
238: {
239: foreach ($this->menuItems as $item) {
240: // hack - render executor action via MenuItem::on() into container
241: $item['item']->on('click.atk_crud_item', $item['executor']);
242: $jsAction = array_pop($item['item']->_jsActions['click.atk_crud_item']);
243: $this->container->js(true, $jsAction);
244: }
245: }
246:
247: /**
248: * Return proper action executor base on model action.
249: *
250: * @return AbstractView&ExecutorInterface
251: */
252: protected function getExecutor(Model\UserAction $action)
253: {
254: // prioritize Crud addFields over action->fields for Model add action
255: if ($action->shortName === 'add' && $this->addFields) {
256: $action->fields = $this->addFields;
257: }
258:
259: // prioritize Crud editFields over action->fields for Model edit action
260: if ($action->shortName === 'edit' && $this->editFields) {
261: $action->fields = $this->editFields;
262: }
263:
264: return $this->getExecutorFactory()->createExecutor($action, $this);
265: }
266:
267: /**
268: * Return reload argument based on Crud condition.
269: *
270: * @return mixed
271: */
272: private function _getReloadArgs()
273: {
274: $args = [];
275: $args[$this->name . '_sort'] = $this->getSortBy();
276: if ($this->paginator) {
277: $args[$this->paginator->name] = $this->paginator->getCurrentPage();
278: }
279:
280: return $args;
281: }
282:
283: /**
284: * Return proper action need to setup menu or action column.
285: */
286: private function _getModelActions(string $appliesTo): array
287: {
288: if ($appliesTo === Model\UserAction::APPLIES_TO_SINGLE_RECORD && $this->singleScopeActions !== []) {
289: $actions = array_map(fn ($v) => $this->model->getUserAction($v), $this->singleScopeActions);
290: } elseif ($appliesTo === Model\UserAction::APPLIES_TO_NO_RECORDS && $this->noRecordScopeActions !== []) {
291: $actions = array_map(fn ($v) => $this->model->getUserAction($v), $this->noRecordScopeActions);
292: } else {
293: $actions = $this->model->getUserActions($appliesTo);
294: }
295:
296: return $actions;
297: }
298:
299: /**
300: * Set callback for add action in Crud.
301: * Callback function will receive the Add Form and Executor as param.
302: *
303: * @param \Closure(Form, UserAction\ModalExecutor): void $fx
304: */
305: public function onFormAdd(\Closure $fx): void
306: {
307: $this->setOnActions('add', $fx);
308: }
309:
310: /**
311: * Set callback for edit action in Crud.
312: * Callback function will receive the Edit Form and Executor as param.
313: *
314: * @param \Closure(Form, UserAction\ModalExecutor): void $fx
315: */
316: public function onFormEdit(\Closure $fx): void
317: {
318: $this->setOnActions('edit', $fx);
319: }
320:
321: /**
322: * Set callback for both edit and add action form.
323: * Callback function will receive Forms and Executor as param.
324: *
325: * @param \Closure(Form, UserAction\ModalExecutor): void $fx
326: */
327: public function onFormAddEdit(\Closure $fx): void
328: {
329: $this->onFormAdd($fx);
330: $this->onFormEdit($fx);
331: }
332:
333: /**
334: * Set onActions.
335: *
336: * @param \Closure(Form, UserAction\ModalExecutor): void $fx
337: */
338: public function setOnActions(string $actionName, \Closure $fx): void
339: {
340: $this->onActions[] = [$actionName => $fx];
341: }
342: }
343: