1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui\UserAction;
6:
7: use Atk4\Core\Factory;
8: use Atk4\Data\Model;
9: use Atk4\Data\Model\UserAction;
10: use Atk4\Ui\Button;
11: use Atk4\Ui\Form;
12: use Atk4\Ui\Js\JsBlock;
13: use Atk4\Ui\Js\JsExpressionable;
14: use Atk4\Ui\Js\JsFunction;
15: use Atk4\Ui\Loader;
16: use Atk4\Ui\View;
17:
18: trait StepExecutorTrait
19: {
20: /** @var array<int, string> The steps need to complete the action. */
21: protected array $steps;
22:
23: /** @var string current step. */
24: protected $step;
25:
26: /** @var Loader The Loader that will execute all action step. */
27: protected $loader;
28:
29: /** @var string */
30: public $loaderUi = 'basic segment';
31:
32: /** @var array */
33: public $loaderShim = [];
34:
35: /** @var Button The action step previous button. */
36: protected $previousStepButton;
37:
38: /** @var Button The action next step button. */
39: protected $nextStepButton;
40:
41: /** @var Button The execute action button. */
42: protected $executeActionButton;
43:
44: /** @var View */
45: protected $buttonsView;
46:
47: /** @var UserAction The action to execute. */
48: public $action;
49:
50: /** @var array will collect data while doing action step. */
51: private $actionData = [];
52:
53: /** @var bool */
54: protected $actionInitialized = false;
55:
56: /** @var JsExpressionable|\Closure JS expression to return if action was successful, e.g "new JsToast('Thank you')" */
57: public $jsSuccess;
58:
59: /** @var array A seed for creating form in order to edit arguments/fields user entry. */
60: public $formSeed = [Form::class];
61:
62: /** @var string can be "console", "text", or "html". Determine how preview step will display information. */
63: public $previewType = 'html';
64:
65: /** @var array<string, array<mixed>> View seed for displaying title for each step. */
66: protected $stepTitle = ['args' => [], 'fields' => [], 'preview' => []];
67:
68: /** @var string */
69: public $finalMsg = 'Complete!';
70:
71: /**
72: * Utility for setting Title for each step.
73: */
74: protected function addStepTitle(View $view, string $step): void
75: {
76: $seed = $this->stepTitle[$step] ?? null;
77: if ($seed) {
78: $view->add(Factory::factory($seed));
79: }
80: }
81:
82: /**
83: * Utility for setting form in each step.
84: */
85: protected function addFormTo(View $view): Form
86: {
87: /** @var Form $f */
88: $f = $view->add(Factory::factory($this->formSeed));
89: $f->buttonSave->destroy();
90:
91: return $f;
92: }
93:
94: /**
95: * Will add field into form based on $fields array.
96: */
97: protected function setFormField(Form $form, array $fields, string $step): Form
98: {
99: foreach ($fields as $k => $val) {
100: $form->getControl($k)->set($val);
101: }
102:
103: $this->hook(self::HOOK_STEP, [$step, $form]);
104:
105: return $form;
106: }
107:
108: protected function runSteps(): void
109: {
110: $this->loader->set(function (Loader $p) {
111: switch ($this->step) {
112: case 'args':
113: $this->doArgs($p);
114:
115: break;
116: case 'fields':
117: $this->doFields($p);
118:
119: break;
120: case 'preview':
121: $this->doPreview($p);
122:
123: break;
124: case 'final':
125: $this->doFinal($p);
126:
127: break;
128: }
129: });
130: }
131:
132: protected function doArgs(View $page): void
133: {
134: $this->addStepTitle($page, $this->step);
135:
136: $form = $this->addFormTo($page);
137: foreach ($this->action->args as $key => $val) {
138: if ($val instanceof Model) {
139: $val = ['model' => $val];
140: }
141:
142: if (isset($val['model'])) {
143: $val['model'] = Factory::factory($val['model']);
144: $form->addControl($key, [Form\Control\Lookup::class])->setModel($val['model']);
145: } else {
146: $form->addControl($key, [], $val);
147: }
148: }
149:
150: // set args value if available
151: $this->setFormField($form, $this->getActionData('args'), $this->step);
152:
153: // setup execute, next and previous button handler for this step
154: $this->jsSetSubmitButton($page, $form, $this->step);
155: $this->jsSetPreviousHandler($page, $this->step);
156:
157: $form->onSubmit(function (Form $form) {
158: // collect arguments
159: $this->setActionDataFromModel('args', $form->model, array_keys($form->model->getFields()));
160:
161: return $this->jsStepSubmit($this->step);
162: });
163: }
164:
165: protected function doFields(View $page): void
166: {
167: $this->addStepTitle($page, $this->step);
168: $form = $this->addFormTo($page);
169:
170: $form->setModel($this->action->getEntity(), $this->action->fields);
171: // set Fields value if set from another step
172: $this->setFormField($form, $this->getActionData('fields'), $this->step);
173:
174: // setup execute, next and previous button handler for this step
175: $this->jsSetSubmitButton($page, $form, $this->step);
176: $this->jsSetPreviousHandler($page, $this->step);
177:
178: $form->onSubmit(function (Form $form) {
179: // collect fields defined in Model\UserAction
180: $this->setActionDataFromModel('fields', $form->model, $this->action->fields);
181:
182: return $this->jsStepSubmit($this->step);
183: });
184: }
185:
186: protected function doPreview(View $page): void
187: {
188: $this->addStepTitle($page, $this->step);
189:
190: $fields = $this->getActionData('fields');
191: if ($fields) {
192: $this->action->getEntity()->setMulti($fields);
193: }
194:
195: if (!$this->isFirstStep($this->step)) {
196: $chain = $this->loader->jsLoad(
197: [
198: 'step' => $this->getPreviousStep($this->step),
199: $this->name => $this->action->getEntity()->getId(),
200: ],
201: ['method' => 'POST'],
202: $this->loader->name
203: );
204:
205: $page->js(true, $this->previousStepButton->js()->on('click', new JsFunction([], [$chain])));
206: }
207:
208: // setup executor button to perform action
209: $page->js(
210: true,
211: $this->executeActionButton->js()->on('click', new JsFunction([], [
212: $this->loader->jsLoad(
213: [
214: 'step' => 'final',
215: $this->name => $this->action->getEntity()->getId(),
216: ],
217: ['method' => 'POST'],
218: $this->loader->name
219: ),
220: ]))
221: );
222:
223: $text = $this->getActionPreview();
224:
225: switch ($this->previewType) {
226: case 'console':
227: $preview = View::addTo($page, ['ui' => 'inverted black segment', 'element' => 'pre']);
228: $preview->set($text);
229:
230: break;
231: case 'text':
232: $preview = View::addTo($page, ['ui' => 'basic segment']);
233: $preview->set($text);
234:
235: break;
236: case 'html':
237: $preview = View::addTo($page, ['ui' => 'basic segment']);
238: $preview->template->dangerouslySetHtml('Content', $text);
239:
240: break;
241: }
242: }
243:
244: protected function doFinal(View $page): void
245: {
246: View::addTo($page, ['content' => $this->finalMsg]);
247: foreach ($this->getActionData('fields') as $field => $value) {
248: $this->action->getEntity()->set($field, $value);
249: }
250:
251: $return = $this->action->execute(...$this->getActionArgs($this->getActionData('args')));
252:
253: $page->js(true, $this->jsGetExecute($return, $this->action->getEntity()->getId()));
254: }
255:
256: /**
257: * Get how many steps is required for this action.
258: */
259: protected function getSteps(): array
260: {
261: $steps = [];
262: if ($this->action->args) {
263: $steps[] = 'args';
264: }
265: if ($this->action->fields) {
266: $steps[] = 'fields';
267: }
268: if ($this->action->preview) {
269: $steps[] = 'preview';
270: }
271:
272: return $steps;
273: }
274:
275: protected function isFirstStep(string $step): bool
276: {
277: return $this->steps[array_key_first($this->steps)] === $step;
278: }
279:
280: protected function isLastStep(string $step): bool
281: {
282: return $this->steps[array_key_last($this->steps)] === $step;
283: }
284:
285: protected function getPreviousStep(string $step): string
286: {
287: $steps = array_values($this->steps);
288:
289: return $steps[array_search($step, $steps, true) - 1];
290: }
291:
292: protected function getNextStep(string $step): string
293: {
294: $steps = array_values($this->steps);
295:
296: return $steps[array_search($step, $steps, true) + 1];
297: }
298:
299: protected function getStep(): string
300: {
301: return $this->step;
302: }
303:
304: protected function createButtonBar(): View
305: {
306: $this->buttonsView = (new View())->setStyle(['min-height' => '24px']);
307: $this->previousStepButton = Button::addTo($this->buttonsView, ['Previous'])->setStyle(['float' => 'left !important']);
308: $this->nextStepButton = Button::addTo($this->buttonsView, ['Next', 'class.blue' => true]);
309: $this->executeActionButton = $this->getExecutorFactory()->createTrigger($this->action, ExecutorFactory::MODAL_BUTTON);
310: $this->buttonsView->add($this->executeActionButton);
311:
312: return $this->buttonsView;
313: }
314:
315: /**
316: * Generate JS for setting Buttons state based on current step.
317: */
318: protected function jsSetButtonsState(View $view, string $step): void
319: {
320: if (count($this->steps) === 1) {
321: $view->js(true, $this->previousStepButton->js()->hide());
322: $view->js(true, $this->nextStepButton->js()->hide());
323: } else {
324: $view->js(true, $this->jsSetPreviousState($step));
325: $view->js(true, $this->jsSetNextState($step));
326: $view->js(true, $this->jsSetExecuteState($step));
327: }
328:
329: // reset button handler
330: $view->js(true, $this->executeActionButton->js()->off());
331: $view->js(true, $this->nextStepButton->js()->off());
332: $view->js(true, $this->previousStepButton->js()->off());
333: $view->js(true, $this->nextStepButton->js()->removeClass('disabled'));
334: $view->js(true, $this->executeActionButton->js()->removeClass('disabled'));
335: }
336:
337: /**
338: * Generate JS for Next button state.
339: */
340: protected function jsSetNextState(string $step): JsExpressionable
341: {
342: if ($this->isLastStep($step)) {
343: return $this->nextStepButton->js()->hide();
344: }
345:
346: return $this->nextStepButton->js()->show();
347: }
348:
349: /**
350: * Generated JS for Previous button state.
351: */
352: protected function jsSetPreviousState(string $step): JsExpressionable
353: {
354: if ($this->isFirstStep($step)) {
355: return $this->previousStepButton->js()->hide();
356: }
357:
358: return $this->previousStepButton->js()->show();
359: }
360:
361: /**
362: * Generate JS for Execute button state.
363: */
364: protected function jsSetExecuteState(string $step): JsExpressionable
365: {
366: if ($this->isLastStep($step)) {
367: return $this->executeActionButton->js()->show();
368: }
369:
370: return $this->executeActionButton->js()->hide();
371: }
372:
373: /**
374: * Generate JS function for Previous button.
375: */
376: protected function jsSetPreviousHandler(View $view, string $step): void
377: {
378: if (!$this->isFirstStep($step)) {
379: $chain = $this->loader->jsLoad(
380: [
381: 'step' => $this->getPreviousStep($step),
382: $this->name => $this->action->getEntity()->getId(),
383: ],
384: ['method' => 'POST'],
385: $this->loader->name
386: );
387:
388: $view->js(true, $this->previousStepButton->js()->on('click', new JsFunction([], [$chain])));
389: }
390: }
391:
392: /**
393: * Determine which button is responsible for submitting form on a specific step.
394: */
395: protected function jsSetSubmitButton(View $view, Form $form, string $step): void
396: {
397: $button = $this->isLastStep($step)
398: ? $this->executeActionButton
399: : $this->nextStepButton; // submit on next
400:
401: $view->js(true, $button->js()->on('click', new JsFunction([], [$form->js()->form('submit')])));
402: }
403:
404: /**
405: * Get proper JS after submitting a form in a step.
406: */
407: protected function jsStepSubmit(string $step): JsBlock
408: {
409: if (count($this->steps) === 1) {
410: // collect argument and execute action
411: $return = $this->action->execute(...$this->getActionArgs($this->getActionData('args')));
412: $js = $this->jsGetExecute($return, $this->action->getEntity()->getId());
413: } else {
414: // store data and setup reload
415: $js = new JsBlock([
416: $this->loader->jsAddStoreData($this->actionData, true),
417: $this->loader->jsLoad(
418: [
419: 'step' => $this->isLastStep($step) ? 'final' : $this->getNextStep($step),
420: $this->name => $this->action->getEntity()->getId(),
421: ],
422: ['method' => 'POST'],
423: $this->loader->name
424: ),
425: ]);
426: }
427:
428: return $js;
429: }
430:
431: protected function getActionData(string $step): array
432: {
433: return $this->actionData[$step] ?? [];
434: }
435:
436: /**
437: * @param array<string> $fields
438: */
439: private function setActionDataFromModel(string $step, Model $model, array $fields): void
440: {
441: $data = [];
442: foreach ($fields as $k) {
443: $data[$k] = $model->get($k);
444: }
445: $this->actionData[$step] = $data;
446: }
447:
448: /**
449: * Get action preview based on it's argument.
450: *
451: * @return mixed
452: */
453: protected function getActionPreview()
454: {
455: $args = [];
456: foreach ($this->action->args as $key => $val) {
457: $args[] = $this->getActionData('args')[$key];
458: }
459:
460: return $this->action->preview(...$args);
461: }
462:
463: /**
464: * Utility for retrieving Argument.
465: */
466: protected function getActionArgs(array $data): array
467: {
468: $args = [];
469: foreach ($this->action->args as $key => $val) {
470: $args[] = $data[$key];
471: }
472:
473: return $args;
474: }
475: }
476: