1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui;
6:
7: use Atk4\Data\Model;
8: use Atk4\Data\Persistence;
9: use Atk4\Ui\Js\Jquery;
10: use Atk4\Ui\Js\JsBlock;
11: use Atk4\Ui\Js\JsChain;
12: use Atk4\Ui\Js\JsExpression;
13: use Atk4\Ui\Js\JsExpressionable;
14: use Atk4\Ui\Js\JsFunction;
15: use Atk4\Ui\Js\JsReload;
16: use Atk4\Ui\Js\JsVueService;
17: use Atk4\Ui\UserAction\ExecutorFactory;
18:
19: /**
20: * Base view of all UI components.
21: *
22: * @phpstan-type JsCallbackSetClosure \Closure(Jquery, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed): (JsExpressionable|View|string|void)
23: */
24: class View extends AbstractView
25: {
26: /**
27: * When you call render() this will be populated with JavaScript chains.
28: *
29: * @var array<1|string, array<int, JsExpressionable>>
30: *
31: * @internal
32: */
33: protected array $_jsActions = [];
34:
35: public ?Model $model = null;
36:
37: /**
38: * Name of the region in the parent's template where this object will output itself.
39: */
40: public ?string $region = null;
41:
42: /**
43: * Enables UI keyword for Fomantic-UI indicating that this is a
44: * UI element. If you set this variable value to string, it will
45: * be appended at the end of the element class.
46: *
47: * @var bool|string
48: */
49: public $ui = false;
50:
51: /** @var array<int, string> List of element CSS classes. */
52: public array $class = [];
53:
54: /** @var array<string, string> Map of element CSS styles. */
55: public array $style = [];
56:
57: /** @var array<string, string|int> Map of element attributes. */
58: public array $attr = [];
59:
60: /**
61: * Template object, that, for most Views will be rendered to
62: * produce HTML output. If you leave this object as "null" then
63: * a new Template will be generated during init() based on the
64: * value of $defaultTemplate.
65: *
66: * @var HtmlTemplate|null
67: */
68: public $template;
69:
70: /**
71: * Specifies how to initialize $template.
72: *
73: * If you specify a string, then it will be considered a filename
74: * from which to load the $template.
75: *
76: * @var string|null
77: */
78: public $defaultTemplate = 'element.html';
79:
80: /** @var string|null Set static contents of this view. */
81: public $content;
82:
83: /** Change this if you want to substitute default "div" for something else. */
84: public string $element = 'div';
85:
86: /** @var ExecutorFactory|null */
87: protected $executorFactory;
88:
89: // {{{ Setting Things up
90:
91: /**
92: * @param array<0|string, mixed>|string $label
93: */
94: public function __construct($label = [])
95: {
96: $defaults = is_array($label) ? $label : [$label];
97:
98: if (array_key_exists(0, $defaults)) {
99: $defaults['content'] = $defaults[0];
100: unset($defaults[0]);
101: }
102:
103: $this->setDefaults($defaults);
104: }
105:
106: /**
107: * Associate this view with a model. Do not place any logic in this class, instead take it
108: * to renderView().
109: *
110: * Do not try to create your own "Model" implementation, instead you must be looking for
111: * your own "Persistence" implementation.
112: *
113: * @phpstan-assert !null $this->model
114: */
115: public function setModel(Model $model): void
116: {
117: if ($this->model !== null && $this->model !== $model) {
118: throw new Exception('Different model is already set');
119: }
120:
121: $this->model = $model;
122: }
123:
124: /**
125: * Sets source of the View.
126: *
127: * @param array $fields Limit model to particular fields
128: *
129: * @phpstan-assert !null $this->model
130: */
131: public function setSource(array $data, $fields = null): Model
132: {
133: // ID with zero value is not supported (at least in MySQL replaces it with next AI value)
134: if (isset($data[0])) {
135: if (array_is_list($data)) {
136: $oldData = $data;
137: $data = [];
138: foreach ($oldData as $k => $row) {
139: $data[$k + 1_000_000_000] = $row; // large offset to prevent accessing wrong data by old key
140: }
141: } else {
142: throw new Exception('Source data contains unsupported zero key');
143: }
144: }
145:
146: $this->setModel(new Model(new Persistence\Static_($data)), $fields); // @phpstan-ignore-line
147: $this->model->getField($this->model->idField)->type = 'string'; // TODO probably unwanted
148:
149: return $this->model;
150: }
151:
152: #[\Override]
153: protected function setMissingProperty(string $propertyName, $value): void
154: {
155: if (is_bool($value) && str_starts_with($propertyName, 'class.')) {
156: $class = substr($propertyName, strlen('class.'));
157: if ($value) {
158: $this->addClass($class);
159: } else {
160: $this->removeClass($class);
161: }
162:
163: return;
164: }
165:
166: parent::setMissingProperty($propertyName, $value);
167: }
168:
169: /**
170: * @param string $element
171: *
172: * @return $this
173: */
174: public function setElement($element)
175: {
176: $this->element = $element;
177:
178: return $this;
179: }
180:
181: /**
182: * Makes view into a "<a>" element with a link.
183: *
184: * @param string|array<0|string, string|int|false> $url
185: *
186: * @return $this
187: */
188: public function link($url, string $target = null)
189: {
190: $this->setElement('a');
191:
192: if (is_string($url)) {
193: $this->setAttr('href', $url);
194: } else {
195: $this->setAttr('href', $this->url($url));
196: }
197:
198: if ($target !== null) {
199: $this->setAttr('target', $target);
200: }
201:
202: return $this;
203: }
204:
205: // }}}
206:
207: // {{{ Default init() method and add() logic
208:
209: /**
210: * Called when view becomes part of render tree. You can override it but avoid
211: * placing any "heavy processing" here.
212: */
213: #[\Override]
214: protected function init(): void
215: {
216: // almost every View needs an App to load a template, so assert App is set upfront
217: // TODO consider lazy loading the template
218: $app = $this->getApp();
219:
220: $addLater = $this->_addLater;
221: $this->_addLater = null;
222:
223: parent::init();
224:
225: if ($this->region === null) {
226: $this->region = 'Content';
227: }
228:
229: if ($this->template === null) {
230: if ($this->defaultTemplate !== null) {
231: $this->template = $app->loadTemplate($this->defaultTemplate);
232: } else {
233: if ($this->region !== 'Content' && $this->issetOwner() && $this->getOwner()->template) {
234: $this->template = $this->getOwner()->template->cloneRegion($this->region);
235: $this->getOwner()->template->del($this->region);
236: }
237: }
238: }
239:
240: if ($this->template !== null && (!$this->template->issetApp() || $this->template->getApp() !== $app)) {
241: $this->template->setApp($app);
242: }
243:
244: foreach ($addLater as [$object, $region]) {
245: $this->add($object, $region);
246: }
247:
248: // allow for injecting the model with a seed
249: if ($this->model !== null) {
250: $this->setModel($this->model);
251: }
252: }
253:
254: public function getExecutorFactory(): ExecutorFactory
255: {
256: return $this->executorFactory ?? $this->getApp()->getExecutorFactory();
257: }
258:
259: /**
260: * In addition to adding a child object, sets up it's template
261: * and associate it's output with the region in our template.
262: *
263: * @param AbstractView $object
264: * @param string|array|null $region
265: */
266: #[\Override]
267: public function add($object, $region = null): AbstractView
268: {
269: if (!is_object($object)) { // @phpstan-ignore-line
270: // for BC do not throw
271: // later consider to accept strictly objects only
272: $object = AbstractView::fromSeed($object);
273: }
274:
275: if (!$this->issetApp()) {
276: $this->_addLater[] = [$object, $region];
277:
278: return $object;
279: }
280:
281: if (is_array($region)) {
282: $args = $region;
283: $region = $args['region'] ?? null;
284: unset($args['region']);
285: } else {
286: $args = [];
287: }
288:
289: // set region
290: if ($region !== null) {
291: $object->setDefaults(['region' => $region]);
292: }
293:
294: // will call init() of the object
295: parent::add($object, $args);
296:
297: return $object;
298: }
299:
300: /**
301: * Get closest owner which is instance of particular class.
302: *
303: * @template T of View
304: *
305: * @param class-string<T> $class
306: *
307: * @return T|null
308: */
309: public function getClosestOwner(string $class): ?self
310: {
311: if (!$this->issetOwner()) {
312: return null;
313: }
314:
315: if ($this->getOwner() instanceof $class) {
316: return $this->getOwner();
317: }
318:
319: return $this->getOwner()->getClosestOwner($class);
320: }
321:
322: // }}}
323:
324: // {{{ Manipulating classes and view properties
325:
326: /**
327: * TODO this method is hard to override, drop it from View.
328: *
329: * @param string $content
330: *
331: * @return $this
332: */
333: public function set($content)
334: {
335: if (!is_string($content) && $content !== null) { // @phpstan-ignore-line
336: throw (new Exception('Not sure what to do with argument'))
337: ->addMoreInfo('this', $this)
338: ->addMoreInfo('arg', $content);
339: }
340:
341: $this->content = $content;
342:
343: return $this;
344: }
345:
346: /**
347: * Add CSS class to element. Previously added classes are not affected.
348: * Multiple CSS classes can also be added if passed as space separated
349: * string or array of class names.
350: *
351: * @param string|array<int, string> $class
352: *
353: * @return $this
354: */
355: public function addClass($class)
356: {
357: if ($class !== []) {
358: $classArr = explode(' ', is_array($class) ? implode(' ', $class) : $class);
359: $this->class = array_merge($this->class, $classArr);
360: }
361:
362: return $this;
363: }
364:
365: /**
366: * Remove one or several CSS classes from the element.
367: *
368: * @param string|array<int, string> $class
369: *
370: * @return $this
371: */
372: public function removeClass($class)
373: {
374: $classArr = explode(' ', is_array($class) ? implode(' ', $class) : $class);
375: $this->class = array_diff($this->class, $classArr);
376:
377: return $this;
378: }
379:
380: /**
381: * Add inline CSS style to element.
382: * Multiple CSS styles can also be set if passed as array.
383: *
384: * @param string|array<string, string> $property
385: * @param ($property is array ? never : string) $value
386: *
387: * @return $this
388: */
389: public function setStyle($property, string $value = null)
390: {
391: if (is_array($property)) {
392: foreach ($property as $k => $v) {
393: $this->setStyle($k, $v);
394: }
395: } else {
396: $this->style[$property] = $value;
397: }
398:
399: return $this;
400: }
401:
402: /**
403: * Remove inline CSS style from element.
404: *
405: * @param string $property
406: *
407: * @return $this
408: */
409: public function removeStyle($property)
410: {
411: unset($this->style[$property]);
412:
413: return $this;
414: }
415:
416: /**
417: * Set attribute.
418: *
419: * @param string|int|array<string, string|int> $name
420: * @param ($name is array ? never : string|int) $value
421: *
422: * @return $this
423: */
424: public function setAttr($name, $value = null)
425: {
426: if (is_array($name)) {
427: foreach ($name as $k => $v) {
428: $this->setAttr($k, $v);
429: }
430: } else {
431: $this->attr[$name] = $value;
432: }
433:
434: return $this;
435: }
436:
437: /**
438: * Remove attribute.
439: *
440: * @param string|array<int, string> $name
441: *
442: * @return $this
443: */
444: public function removeAttr($name)
445: {
446: if (is_array($name)) {
447: foreach ($name as $v) {
448: $this->removeAttr($v);
449: }
450: } else {
451: unset($this->attr[$name]);
452: }
453:
454: return $this;
455: }
456:
457: // }}}
458:
459: // {{{ Sticky URLs
460:
461: /** @var array<string, string> stickyGet arguments */
462: public $stickyArgs = [];
463:
464: /**
465: * Build an URL which this view can use for callbacks.
466: *
467: * @param string|array<0|string, string|int|false> $page URL as string or array with page path as first element and other GET arguments
468: */
469: public function url($page = []): string
470: {
471: return $this->getApp()->url($page, $this->_getStickyArgs());
472: }
473:
474: /**
475: * Build an URL which this view can use for JS callbacks.
476: *
477: * @param string|array<0|string, string|int|false> $page URL as string or array with page path as first element and other GET arguments
478: */
479: public function jsUrl($page = []): string
480: {
481: return $this->getApp()->jsUrl($page, $this->_getStickyArgs());
482: }
483:
484: /**
485: * Get sticky arguments defined by the view and parents (including API).
486: */
487: protected function _getStickyArgs(): array
488: {
489: if ($this->issetOwner()) {
490: $stickyArgs = array_merge($this->getOwner()->_getStickyArgs(), $this->stickyArgs);
491: } else {
492: $stickyArgs = $this->stickyArgs;
493: }
494:
495: return $stickyArgs;
496: }
497:
498: /**
499: * Mark GET argument as sticky. Calling url() on this view or any
500: * sub-views will embed the value of this GET argument.
501: *
502: * If GET argument is empty or false, it won't make into URL.
503: *
504: * If GET argument is not presently set you can specify a 2nd argument
505: * to forge-set the GET argument for current view and it's sub-views.
506: */
507: public function stickyGet(string $name, string $newValue = null): ?string
508: {
509: $this->stickyArgs[$name] = $newValue ?? $this->stickyArgs[$name] ?? $this->getApp()->tryGetRequestQueryParam($name);
510:
511: return $this->stickyArgs[$name];
512: }
513:
514: // }}}
515:
516: // {{{ Rendering
517:
518: /**
519: * View-specific rendering stuff. Feel free to replace this method with
520: * your own. View::renderView contains some logic that integrates with
521: * Fomantic-UI.
522: */
523: protected function renderView(): void
524: {
525: if ($this->element !== 'div') {
526: $this->template->set('_element', $this->element);
527: } else {
528: $this->template->trySet('_element', $this->element);
529: }
530:
531: $app = $this->getApp();
532: if (!$app->isVoidTag($this->element)) {
533: $this->template->tryDangerouslySetHtml('_element_end', '</' . $this->element . '>');
534: }
535:
536: $attrsHtml = [];
537:
538: if ($this->name) {
539: $attrsHtml[] = 'id="' . $app->encodeHtml($this->name) . '"';
540:
541: // TODO hack for template/tabs.html
542: if ($this->template->hasTag('Tabs')) {
543: array_pop($attrsHtml);
544: }
545:
546: // TODO hack for template/form/control/upload.html
547: if ($this->template->hasTag('AfterBeforeInput') && str_contains($this->template->renderToHtml(), ' type="file"')) {
548: array_pop($attrsHtml);
549: }
550:
551: // needed for templates like '<input id="{$_id}_input">'
552: $this->template->trySet('_id', $this->name);
553: }
554:
555: $class = null;
556: if ($this->class !== []) {
557: $class = implode(' ', $this->class);
558:
559: // needed for templates like template/form/layout/generic-input.html
560: $this->template->tryAppend('class', implode(' ', $this->class));
561: }
562: if ($this->ui !== false) {
563: $class = 'ui ' . $this->ui . ($class !== null ? ' ' . $class : '');
564: }
565: if ($class !== null) {
566: $attrsHtml[] = 'class="' . $app->encodeHtml($class) . '"';
567: }
568:
569: if ($this->style !== []) {
570: $styles = [];
571: foreach ($this->style as $k => $v) {
572: $styles[] = $k . ': ' . $app->encodeHtml($v) . ';';
573: }
574: $attrsHtml[] = 'style="' . implode(' ', $styles) . '"';
575:
576: // needed for template/html.html
577: $this->template->tryDangerouslyAppendHtml('style', implode(' ', $styles));
578: }
579:
580: foreach ($this->attr as $k => $v) {
581: $attrsHtml[] = $k . '="' . $app->encodeHtml((string) $v) . '"';
582: }
583:
584: if ($attrsHtml !== []) {
585: try {
586: $this->template->dangerouslySetHtml('attributes', implode(' ', $attrsHtml));
587: } catch (Exception $e) {
588: // TODO hack to ignore missing '{$attributes}' mostly in layout templates
589: if (count($attrsHtml) === 1 ? !str_starts_with(reset($attrsHtml), 'id=') : !$this instanceof Lister) {
590: throw $e;
591: }
592: }
593: }
594: }
595:
596: /**
597: * Recursively render all children, placing their output in our template.
598: */
599: protected function recursiveRender(): void
600: {
601: foreach ($this->elements as $view) {
602: if (!$view instanceof self) {
603: continue;
604: }
605:
606: $this->template->dangerouslyAppendHtml($view->region, $view->getHtml());
607:
608: // collect JS from everywhere
609: foreach ($view->_jsActions as $when => $actions) {
610: foreach ($actions as $action) {
611: $this->_jsActions[$when][] = $action;
612: }
613: }
614: }
615:
616: if ($this->content !== null) {
617: $this->template->append('Content', $this->content);
618: }
619: }
620:
621: /**
622: * Render everything recursively, render ourselves but don't return anything just yet.
623: */
624: public function renderAll(): void
625: {
626: if (!$this->isInitialized()) {
627: $this->invokeInit();
628: }
629:
630: if (!$this->_rendered) {
631: $this->renderView();
632:
633: $this->recursiveRender();
634: $this->_rendered = true;
635: }
636: }
637:
638: /**
639: * For Form::renderTemplateToHtml() only.
640: */
641: protected function renderTemplateToHtml(): string
642: {
643: return $this->template->renderToHtml();
644: }
645:
646: /**
647: * This method is for those cases when developer want to simply render his
648: * view and grab HTML himself.
649: */
650: public function render(): string
651: {
652: $this->renderAll();
653:
654: $js = $this->getJs();
655:
656: return ($js !== '' ? $this->getApp()->getTag('script', [], '$(function () {' . $js . ';});') : '')
657: . $this->renderTemplateToHtml();
658: }
659:
660: /**
661: * This method is to render view to place inside a Fomantic-UI Tab.
662: */
663: public function renderToTab(): array
664: {
665: $this->renderAll();
666:
667: return [
668: 'atkjs' => $this->getJsRenderActions(),
669: 'html' => $this->renderTemplateToHtml(),
670: ];
671: }
672:
673: /**
674: * Render View using JSON format.
675: */
676: public function renderToJsonArr(): array
677: {
678: $this->renderAll();
679:
680: return [
681: 'success' => true,
682: 'atkjs' => $this->getJs(),
683: 'html' => $this->renderTemplateToHtml(),
684: 'id' => $this->name,
685: ];
686: }
687:
688: /**
689: * Created for recursive rendering or when you want to only get HTML of
690: * this object (not javascript).
691: */
692: public function getHtml(): string
693: {
694: if ($this->getApp()->hasRequestQueryParam('__atk_reload') && $this->getApp()->getRequestQueryParam('__atk_reload') === $this->name) {
695: $this->getApp()->terminateJson($this);
696: }
697:
698: $this->renderAll();
699:
700: return $this->renderTemplateToHtml();
701: }
702:
703: // }}}
704:
705: // {{{ JavaScript integration
706:
707: /**
708: * Views in Agile UI can assign javascript actions to themselves. This
709: * is done by calling $view->js() method which returns instance of JsChain
710: * object that is initialized to the object itself. Normally this chain
711: * will map into $('#object_id') and calling additional methods will map
712: * into additional calls.
713: *
714: * Action can represent javascript event, such as "click" or "mouseenter".
715: * If you specify action = true, then the event will ALWAYS be executed on
716: * documentReady. It will also be executed if respective view is being reloaded
717: * by js()->reload()
718: *
719: * (Do not make mistake by specifying "true" instead of true)
720: *
721: * action = false will still return JsChain but will not bind it.
722: * You can bind it by passing object into on() method.
723: *
724: * 1. Calling with arguments:
725: * $view->js(); // technically does nothing
726: * $a = $view->js()->hide(); // creates chain for hiding $view but does not bind to event yet
727: *
728: * 2. Binding existing chains
729: * $img->on('mouseenter', $a); // binds previously defined chain to event on event of $img
730: *
731: * Produced code: $('#img_id').on('mouseenter', function (event) {
732: * event.preventDefault();
733: * event.stopPropagation();
734: * $('#view1').hide();
735: * });
736: *
737: * 3. $button->on('click', $form->js()->submit()); // clicking button will result in form submit
738: *
739: * 4. $view->js(true)->find('.current')->text($text);
740: *
741: * Will convert calls to jQuery chain into JavaScript string:
742: * $('#view').find('.current').text('abc'); // the text will be JSON encoded to avoid JS injection
743: *
744: * @param bool|string $when Event when chain will be executed
745: * @param ($when is false ? null : JsExpressionable|null) $action JavaScript action
746: * @param string|self|null $selector If you wish to override jQuery($selector)
747: *
748: * @return ($action is null ? Jquery : null)
749: */
750: public function js($when = false, $action = null, $selector = null): ?JsExpressionable
751: {
752: // binding on a specific event
753: // TODO allow only boolean $when, otherwise user should use self::on() method
754: if (!is_bool($when)) {
755: return $this->on($when, $selector, $action);
756: }
757:
758: if ($action !== null) {
759: $res = null;
760: } else {
761: $action = new Jquery($this);
762: if ($selector) {
763: $action->find($selector);
764: }
765: $res = $action;
766: }
767:
768: if ($when === true) {
769: $this->_jsActions[$when][] = $action;
770: }
771:
772: return $res;
773: }
774:
775: /**
776: * Create Vue.js instance.
777: * Vue.js instance can be created from Atk4\Ui\View.
778: *
779: * Component managed and defined by atk does not need componentDefinition variable name
780: * because these are already loaded within the atk js namespace.
781: * When creating your own component externally, you must supply the variable name holding
782: * your Vue component definition. This definition must be also accessible within the window javascript
783: * object. This way, you do not need to load Vue js file since it has already being include within
784: * atkjs-ui.js build.
785: *
786: * If the external component use other components, it is possible to register them using
787: * vueService getVue() method. This method return the current Vue object.
788: * ex: atk.vueService.getVue().component('external_component', externalComponent). This is the same
789: * as Vue.component() method.
790: *
791: * @param string $component The component name
792: * @param array $initData The component properties passed as the initData prop.
793: * This is the initial data pass to your main component via the initData bind property
794: * of the vue component instance created via the vueService.
795: * @param JsExpressionable|null $componentDefinition component definition object
796: * @param string|self|null $selector the selector for creating the base root object in Vue
797: *
798: * @return $this
799: */
800: public function vue($component, $initData = [], $componentDefinition = null, $selector = null)
801: {
802: if (!$selector) {
803: $selector = '#' . $this->getHtmlId();
804: }
805:
806: if ($componentDefinition) {
807: $chain = (new JsVueService())->createVue($selector, $component, $componentDefinition, $initData);
808: } else {
809: $chain = (new JsVueService())->createAtkVue($selector, $component, $initData);
810: }
811:
812: $this->js(true, $chain);
813:
814: return $this;
815: }
816:
817: /**
818: * Emit an event on atkEvent bus.
819: *
820: * example of adding a listener on for an emit event.
821: *
822: * atk.eventBus.on('eventName', (data) => {
823: * console.log(data)
824: * });
825: *
826: * Note: In order to make sure your event is unique within atk, you can
827: * use the view name in it.
828: * $this->jsEmitEvent($this->name . '-my-event', $data)
829: */
830: public function jsEmitEvent(string $eventName, array $eventData = []): JsChain
831: {
832: return (new JsChain('atk.eventBus'))->emit($eventName, $eventData);
833: }
834:
835: /**
836: * Get Local and Session web storage associated with this view.
837: * Web storage can be retrieved using a $view->jsReload() request.
838: */
839: public function jsGetStoreData(): array
840: {
841: $data = [];
842: $data['local'] = $this->getApp()->decodeJson(
843: $this->getApp()->tryGetRequestQueryParam($this->name . '_local_store') ?? $this->getApp()->tryGetRequestPostParam($this->name . '_local_store') ?? 'null'
844: );
845: $data['session'] = $this->getApp()->decodeJson(
846: $this->getApp()->tryGetRequestQueryParam($this->name . '_session_store') ?? $this->getApp()->tryGetRequestPostParam($this->name . '_session_store') ?? 'null'
847: );
848:
849: return $data;
850: }
851:
852: /**
853: * Clear Web storage data associated with this view.
854: */
855: public function jsClearStoreData(bool $useSession = false): JsExpressionable
856: {
857: $type = $useSession ? 'session' : 'local';
858:
859: $name = $this->name;
860: if (!$name) {
861: throw new Exception('View property name needs to be set');
862: }
863:
864: return (new JsChain('atk.dataService'))->clearData($name, $type);
865: }
866:
867: /**
868: * Add Web storage for this specific view.
869: * Data will be store as json value where key name
870: * will be the name of this view.
871: *
872: * Data added to web storage is merge against previous value.
873: * $v->jsAddStoreData(['args' => ['path' => '.']]);
874: * $v->jsAddStoreData(['args' => ['path' => '/'], 'fields' => ['name' => 'test']]]);
875: *
876: * Final store value will be: ['args' => ['path' => '/'], 'fields' => ['name' => 'test']];
877: */
878: public function jsAddStoreData(array $data, bool $useSession = false): JsExpressionable
879: {
880: $type = $useSession ? 'session' : 'local';
881:
882: $name = $this->name;
883: if (!$name) {
884: throw new Exception('View property name needs to be set');
885: }
886:
887: return (new JsChain('atk.dataService'))->addJsonData($name, $this->getApp()->encodeJson($data), $type);
888: }
889:
890: /**
891: * Returns JS for reloading View.
892: *
893: * @param array $args
894: * @param JsExpressionable|null $afterSuccess
895: * @param array $apiConfig
896: *
897: * @return JsReload
898: */
899: public function jsReload($args = [], $afterSuccess = null, $apiConfig = []): JsExpressionable
900: {
901: return new JsReload($this, $args, $afterSuccess, $apiConfig);
902: }
903:
904: /**
905: * Views in Agile Toolkit can assign javascript actions to themselves. This
906: * is done by calling $view->js() or $view->on().
907: *
908: * on() method is similar to jQuery on(event, [selector, ] action) method.
909: *
910: * When no $action is passed, the on() method returns a chain corresponding to the affected element.
911: *
912: * Here are some ways to use on():
913: *
914: * // clicking on button will make the $view disappear
915: * $button->on('click', $view->js()->hide());
916: *
917: * // clicking on <a class="clickable"> will make it's parent disappear
918: * $view->on('click', 'a[data=clickable]')->parent()->hide();
919: *
920: * Finally, it's also possible to use PHP closure as an action:
921: *
922: * $view->on('click', 'a', function (Jquery $js, $data) {
923: * if (!$data['clickable']) {
924: * return new JsExpression('alert([])', ['This record is not clickable'])
925: * }
926: * return $js->parent()->hide();
927: * });
928: *
929: * @param string $event JavaScript event
930: * @param ($action is object ? string : ($action is null ? string : never)|JsExpressionable|JsCallback|JsCallbackSetClosure|array{JsCallbackSetClosure}|UserAction\ExecutorInterface|Model\UserAction) $selector Optional jQuery-style selector
931: * @param ($selector is string|null ? JsExpressionable|JsCallback|JsCallbackSetClosure|array{JsCallbackSetClosure}|UserAction\ExecutorInterface|Model\UserAction : array) $action code to execute
932: *
933: * @return ($selector is string|null ? ($action is null ? Jquery : null) : ($action is array|null ? Jquery : null))
934: */
935: public function on(string $event, $selector = null, $action = null, array $defaults = [])
936: {
937: // second argument may be omitted
938: if ($selector !== null && !is_string($selector) && ($action === null || is_array($action)) && $defaults === []) {
939: $defaults = $action ?? [];
940: $action = $selector;
941: $selector = null;
942: }
943:
944: // check for arguments
945: $arguments = $defaults['args'] ?? [];
946: unset($defaults['args']);
947:
948: // all values with int keys of defaults are arguments
949: foreach ($defaults as $key => $value) {
950: if (is_int($key)) {
951: $arguments[] = $value;
952: unset($defaults[$key]);
953: }
954: }
955:
956: if ($action !== null) {
957: $res = null;
958: } else {
959: $action = new Jquery();
960: $res = $action;
961: }
962:
963: // set preventDefault and stopPropagation by default
964: $eventStatements = [];
965: $eventStatements['preventDefault'] = $defaults['preventDefault'] ?? true;
966: $eventStatements['stopPropagation'] = $defaults['stopPropagation'] ?? true;
967:
968: $lazyJsRenderFx = function (\Closure $fx): JsExpressionable {
969: return new class($fx) implements JsExpressionable {
970: public \Closure $fx;
971:
972: /**
973: * @param \Closure(JsExpressionable): JsExpressionable $fx
974: */
975: public function __construct(\Closure $fx)
976: {
977: $this->fx = $fx;
978: }
979:
980: #[\Override]
981: public function jsRender(): string
982: {
983: return ($this->fx)()->jsRender();
984: }
985: };
986: };
987:
988: // dealing with callback action
989: if ($action instanceof \Closure || (is_array($action) && ($action[0] ?? null) instanceof \Closure)) {
990: $actions = [];
991: if (is_array($action)) {
992: $urlData = $action;
993: unset($urlData[0]);
994: foreach ($urlData as $a) {
995: $actions[] = $a;
996: }
997: $action = $action[0];
998: }
999:
1000: // create callback, that will include event as part of the full name
1001: $cb = JsCallback::addTo($this, [], [['desired_name' => $event]]);
1002: if ($defaults['apiConfig'] ?? null) {
1003: $cb->apiConfig = $defaults['apiConfig'];
1004: }
1005:
1006: $cb->set(static function (Jquery $chain, ...$args) use ($action) {
1007: return $action($chain, ...$args);
1008: }, $arguments);
1009:
1010: $actions[] = $lazyJsRenderFx(static fn () => $cb->jsExecute());
1011: } elseif ($action instanceof UserAction\ExecutorInterface || $action instanceof UserAction\SharedExecutor || $action instanceof Model\UserAction) {
1012: $ex = $action instanceof Model\UserAction ? $this->getExecutorFactory()->createExecutor($action, $this) : $action;
1013:
1014: $setupNonSharedExecutorFx = static function (UserAction\ExecutorInterface $ex) use (&$defaults, &$arguments): void {
1015: /** @var AbstractView&UserAction\ExecutorInterface $ex https://github.com/phpstan/phpstan/issues/3770 */
1016: $ex = $ex;
1017:
1018: if (isset($arguments['id'])) {
1019: $arguments[$ex->name] = $arguments['id'];
1020: unset($arguments['id']);
1021: } elseif (isset($arguments[0])) {
1022: // if "id" is not specified we assume arguments[0] is the model ID
1023: $arguments[$ex->name] = $arguments[0];
1024: unset($arguments[0]);
1025: }
1026:
1027: if ($ex instanceof UserAction\JsCallbackExecutor) {
1028: $confirmation = $ex->getAction()->getConfirmation();
1029: if ($confirmation) {
1030: $defaults['confirm'] = $confirmation;
1031: }
1032: if ($defaults['apiConfig'] ?? null) {
1033: $ex->apiConfig = $defaults['apiConfig'];
1034: }
1035: }
1036: };
1037:
1038: if ($ex instanceof UserAction\SharedExecutor) {
1039: $setupNonSharedExecutorFx($ex->getExecutor());
1040: $actions = [$ex->getExecutor() instanceof UserAction\JsCallbackExecutor
1041: ? $lazyJsRenderFx(static fn () => $ex->jsExecute($arguments))
1042: : $ex->jsExecute($arguments)];
1043: } elseif ($ex instanceof UserAction\JsExecutorInterface && $ex instanceof self) {
1044: $setupNonSharedExecutorFx($ex);
1045: $ex->executeModelAction();
1046: $actions = [$ex->jsExecute($arguments)];
1047: } elseif ($ex instanceof UserAction\JsCallbackExecutor) {
1048: $setupNonSharedExecutorFx($ex);
1049: $ex->executeModelAction();
1050: $actions = [$lazyJsRenderFx(static fn () => $ex->jsExecute($arguments))];
1051: } else {
1052: throw new Exception('Executor must be of type UserAction\JsCallbackExecutor or UserAction\JsExecutorInterface');
1053: }
1054: } elseif ($action instanceof JsCallback) {
1055: $actions = [$lazyJsRenderFx(static fn () => $action->jsExecute())];
1056: } else {
1057: $actions = [$action];
1058: }
1059:
1060: if ($defaults['confirm'] ?? null) {
1061: array_unshift($eventStatements, new JsExpression('$.atkConfirm({ message: [confirm], onApprove: [action], options: { button: { ok: [ok], cancel: [cancel] } }, context: this })', [
1062: 'confirm' => $defaults['confirm'],
1063: 'action' => new JsFunction([], $actions),
1064: 'ok' => $defaults['ok'] ?? 'Ok',
1065: 'cancel' => $defaults['cancel'] ?? 'Cancel',
1066: ]));
1067: } else {
1068: $eventStatements = array_merge($eventStatements, $actions);
1069: }
1070:
1071: $eventFunction = new JsFunction([], $eventStatements);
1072: $eventChain = new Jquery($this);
1073: if ($selector) {
1074: $eventChain->on($event, $selector, $eventFunction);
1075: } else {
1076: $eventChain->on($event, $eventFunction);
1077: }
1078:
1079: $this->_jsActions[$event][] = $eventChain;
1080:
1081: return $res;
1082: }
1083:
1084: public function getHtmlId(): string
1085: {
1086: $this->assertIsInitialized();
1087:
1088: return $this->name;
1089: }
1090:
1091: /**
1092: * Return rendered js actions as a string.
1093: */
1094: public function getJsRenderActions(): string
1095: {
1096: $actions = [];
1097: foreach ($this->_jsActions as $eventActions) {
1098: foreach ($eventActions as $action) {
1099: $actions[] = $action;
1100: }
1101: }
1102:
1103: return (new JsBlock($actions))->jsRender();
1104: }
1105:
1106: /**
1107: * Get JavaScript objects from this render tree.
1108: *
1109: * TODO dedup with getJsRenderActions()
1110: *
1111: * @return string
1112: */
1113: public function getJs()
1114: {
1115: $actions = [];
1116: foreach ($this->_jsActions as $eventActions) {
1117: foreach ($eventActions as $action) {
1118: $actions[] = $action;
1119: }
1120: }
1121:
1122: if (count($actions) === 0) {
1123: return '';
1124: }
1125:
1126: return (new JsExpression('[]()', [new JsFunction([], $actions)]))->jsRender();
1127: }
1128:
1129: // }}}
1130: }
1131: