1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui;
6:
7: use Atk4\Ui\Exception\UnhandledCallbackExceptionError;
8:
9: /**
10: * Add this object to your render tree and it will expose a unique URL which, when
11: * executed directly will perform a PHP callback that you set().
12: *
13: * Callback function run when triggered, i.e. when it's urlTrigger param value is present in the $_GET request.
14: * The current callback will be set within the $_GET[Callback::URL_QUERY_TARGET] and will be set to urlTrigger as well.
15: *
16: * $button = Button::addTo($layout);
17: * $button->set('Click to do something')->link(
18: * Callback::addTo($button)
19: * ->set(function () {
20: * do_something();
21: * })
22: * ->getUrl()
23: * );
24: */
25: class Callback extends AbstractView
26: {
27: public const URL_QUERY_TRIGGER_PREFIX = '__atk_cb_';
28: public const URL_QUERY_TARGET = '__atk_cbtarget';
29:
30: /** @var string Specify a custom GET trigger. */
31: protected $urlTrigger;
32:
33: /** @var bool Allow this callback to trigger during a reload. */
34: public $triggerOnReload = true;
35:
36: #[\Override]
37: public function add(AbstractView $object, array $args = []): AbstractView
38: {
39: throw new Exception('Callback cannot contain children');
40: }
41:
42: #[\Override]
43: protected function init(): void
44: {
45: $this->getApp(); // assert has App
46:
47: parent::init();
48:
49: $this->setUrlTrigger($this->urlTrigger);
50: }
51:
52: public function setUrlTrigger(string $trigger = null): void
53: {
54: $this->urlTrigger = $trigger ?? $this->name;
55:
56: $this->getOwner()->stickyGet(self::URL_QUERY_TRIGGER_PREFIX . $this->urlTrigger);
57: }
58:
59: public function getUrlTrigger(): string
60: {
61: return $this->urlTrigger;
62: }
63:
64: /**
65: * Executes user-specified action when callback is triggered.
66: *
67: * @template T
68: *
69: * @param \Closure(mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed): T $fx
70: * @param array $fxArgs
71: *
72: * @return T|null
73: */
74: public function set($fx = null, $fxArgs = null)
75: {
76: if ($this->isTriggered() && $this->canTrigger()) {
77: try {
78: return $fx(...($fxArgs ?? []));
79: } catch (\Exception $e) {
80: // catch and wrap an exception using a custom Error class to prevent "Callback requested, but never reached"
81: // exception which is hard to understand/locate as thrown from the main app context
82: throw new UnhandledCallbackExceptionError('', 0, $e);
83: }
84: }
85:
86: return null;
87: }
88:
89: /**
90: * Terminate this callback by rendering the given view.
91: */
92: public function terminateJson(View $view): void
93: {
94: if ($this->canTerminate()) {
95: $this->getApp()->terminateJson($view);
96: }
97: }
98:
99: /**
100: * Return true if urlTrigger is part of the request.
101: */
102: public function isTriggered(): bool
103: {
104: return $this->getApp()->hasRequestQueryParam(self::URL_QUERY_TRIGGER_PREFIX . $this->urlTrigger);
105: }
106:
107: public function getTriggeredValue(): string
108: {
109: return $this->getApp()->tryGetRequestQueryParam(self::URL_QUERY_TRIGGER_PREFIX . $this->urlTrigger) ?? '';
110: }
111:
112: /**
113: * Only current callback can terminate.
114: */
115: public function canTerminate(): bool
116: {
117: return $this->getApp()->hasRequestQueryParam(self::URL_QUERY_TARGET) && $this->getApp()->getRequestQueryParam(self::URL_QUERY_TARGET) === $this->urlTrigger;
118: }
119:
120: /**
121: * Allow callback to be triggered or not.
122: */
123: public function canTrigger(): bool
124: {
125: return $this->triggerOnReload || !$this->getApp()->hasRequestQueryParam('__atk_reload');
126: }
127:
128: /**
129: * Return URL that will trigger action on this callback. If you intend to request
130: * the URL directly in your browser (as iframe, new tab, or document location), you
131: * should use getUrl instead.
132: */
133: public function getJsUrl(string $value = 'ajax'): string
134: {
135: return $this->getOwner()->jsUrl($this->getUrlArguments($value));
136: }
137:
138: /**
139: * Return URL that will trigger action on this callback. If you intend to request
140: * the URL loading from inside JavaScript, it's always advised to use getJsUrl instead.
141: */
142: public function getUrl(string $value = 'callback'): string
143: {
144: return $this->getOwner()->url($this->getUrlArguments($value));
145: }
146:
147: /**
148: * Return proper URL argument for this callback.
149: */
150: private function getUrlArguments(string $value = null): array
151: {
152: return [
153: self::URL_QUERY_TARGET => $this->urlTrigger,
154: self::URL_QUERY_TRIGGER_PREFIX . $this->urlTrigger => $value ?? ($this->isTriggered() ? $this->getTriggeredValue() : ''),
155: ];
156: }
157: }
158: