From 975b785731862d64a1c7a4938d4dedd1cb1873e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Wed, 21 Dec 2022 22:07:24 +0100 Subject: [PATCH] Unimplement JsExpressionable from JsCallback (#1955) --- demos/_unit-test/sse.php | 9 ++-- demos/form-control/multiline.php | 2 +- docs/callbacks.rst | 2 +- src/CardDeck.php | 9 ++-- src/Console.php | 9 +--- src/Crud.php | 2 + src/JsCallback.php | 6 +-- src/JsExpression.php | 17 +++---- src/JsFunction.php | 22 +++++---- src/JsSse.php | 4 +- src/UserAction/ConfirmationExecutor.php | 3 -- src/UserAction/JsExecutorInterface.php | 4 ++ src/UserAction/ModalExecutor.php | 3 -- src/View.php | 26 +++++++++-- tests/JsIntegrationTest.php | 60 +++++++++++++++++++++---- tests/JsTest.php | 10 +++++ 16 files changed, 124 insertions(+), 64 deletions(-) diff --git a/demos/_unit-test/sse.php b/demos/_unit-test/sse.php index 81dfad20ea..f8b3cfe849 100644 --- a/demos/_unit-test/sse.php +++ b/demos/_unit-test/sse.php @@ -19,11 +19,10 @@ $sse->setUrlTrigger('see_test'); $v->js(true, $sse->set(function () use ($sse) { - $sse->send(new JsExpression('console.log(\'test\')')); - $sse->send(new JsExpression('console.log(\'test\')')); - $sse->send(new JsExpression('console.log(\'test\')')); - $sse->send(new JsExpression('console.log(\'test\')')); + for ($i = 0; $i < 4; ++$i) { + $sse->send(new JsExpression('console.log([])', ['test ' . $i])); + } // non-SSE way return new JsToast('SSE sent, see browser console log'); -})); +})->jsExecute()); diff --git a/demos/form-control/multiline.php b/demos/form-control/multiline.php index 8bd6530a18..0f383bba22 100644 --- a/demos/form-control/multiline.php +++ b/demos/form-control/multiline.php @@ -18,7 +18,7 @@ $inventory = new MultilineItem($app->db); $inventory->getField($inventory->fieldName()->item)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]; $inventory->getField($inventory->fieldName()->inv_date)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]; -$inventory->getField($inventory->fieldName()->inv_date)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]; +$inventory->getField($inventory->fieldName()->inv_time)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]; $inventory->getField($inventory->fieldName()->country_id)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 3]]; $inventory->getField($inventory->fieldName()->qty)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]; $inventory->getField($inventory->fieldName()->box)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]; diff --git a/docs/callbacks.rst b/docs/callbacks.rst index 95b2ea4b54..c5fbbbac29 100644 --- a/docs/callbacks.rst +++ b/docs/callbacks.rst @@ -210,7 +210,7 @@ When you trigger callback, you'll see the output:: {"success": true, "message": "Success", "eval": "alert(\"ok\")"} This is how JsCallback renders actions and sends them back to the browser. In order to retrieve and execute actions, -you'll need a JavaScript routine. Luckily JsCallback also implements JsExpressionable, so it, in itself is an action. +you'll need a JavaScript routine. Luckily JsCallback can be passed to :php:meth:`View::on()` as a JS action. Let me try this again. JsCallback is an :ref:`js_action` which will execute request towards a callback-URL that will execute PHP method returning one or more :ref:`js_action` which will be received and executed by the original action. diff --git a/src/CardDeck.php b/src/CardDeck.php index 9d75df6509..737e0dd51f 100644 --- a/src/CardDeck.php +++ b/src/CardDeck.php @@ -218,9 +218,9 @@ protected function initActionExecutor(Model\UserAction $action): ExecutorInterfa * Return proper js statement for afterExecute hook on action executor * depending on return type, model loaded and action scope. * - * @param string|array|JsExpressionable|Model|null $return + * @param string|JsExpressionable|array|Model|null $return * - * @return array|object + * @return JsExpressionable|array */ protected function jsExecute($return, Model\UserAction $action) { @@ -242,10 +242,9 @@ protected function jsExecute($return, Model\UserAction $action) } /** - * Return jsNotifier object. * Override this method for setting notifier based on action or model value. */ - protected function getNotifier(Model\UserAction $action, string $msg = null): object + protected function getNotifier(Model\UserAction $action, string $msg = null): JsExpressionable { $notifier = Factory::factory($this->notifyDefault); if ($msg) { @@ -257,6 +256,8 @@ protected function getNotifier(Model\UserAction $action, string $msg = null): ob /** * Js expression return when action afterHook executor return a Model. + * + * @return array */ protected function jsModelReturn(Model\UserAction $action, string $msg = 'Done!'): array { diff --git a/src/Console.php b/src/Console.php index 88ae288cb5..fca23cf57e 100644 --- a/src/Console.php +++ b/src/Console.php @@ -131,14 +131,9 @@ public function set($fx = null, $event = null) return $this; } - /** - * Return JavaScript expression to execute console. - * - * @return JsExpressionable - */ - public function jsExecute() + public function jsExecute(): JsExpressionable { - return $this->sse; + return $this->sse->jsExecute(); } private function escapeOutputHtml(string $message): string diff --git a/src/Crud.php b/src/Crud.php index 732143ae30..44faf767e4 100644 --- a/src/Crud.php +++ b/src/Crud.php @@ -157,6 +157,8 @@ protected function initActionExecutor(Model\UserAction $action) * depending on return type, model loaded and action scope. * * @param string|null $return + * + * @return array */ protected function jsExecute($return, Model\UserAction $action): array { diff --git a/src/JsCallback.php b/src/JsCallback.php index 2f8ab03abb..f89340d39b 100644 --- a/src/JsCallback.php +++ b/src/JsCallback.php @@ -4,7 +4,7 @@ namespace Atk4\Ui; -class JsCallback extends Callback implements JsExpressionable +class JsCallback extends Callback { /** @var array Holds information about arguments passed in to the callback. */ public $args = []; @@ -47,7 +47,7 @@ protected function flattenArray(array $response): array return $res; } - public function jsRender(): string + public function jsExecute(): JsExpression { $this->getApp(); // assert has App @@ -57,7 +57,7 @@ public function jsRender(): string 'confirm' => $this->confirm, 'apiConfig' => $this->apiConfig, 'storeName' => $this->storeName, - ])->jsRender(); + ]); } /** diff --git a/src/JsExpression.php b/src/JsExpression.php index 788f394771..7786a70116 100644 --- a/src/JsExpression.php +++ b/src/JsExpression.php @@ -34,7 +34,7 @@ public function jsRender(): string $namelessCount = 0; $res = preg_replace_callback( '~\[[\w]*\]|{[\w]*}~', - function ($matches) use (&$namelessCount) { + function ($matches) use (&$namelessCount): string { $identifier = substr($matches[0], 1, -1); // Allow template to contain [] @@ -51,7 +51,7 @@ function ($matches) use (&$namelessCount) { $value = $this->args[$identifier]; // no escaping for "{}" - if ($matches[0][0] === '{') { + if ($matches[0][0] === '{' && is_string($value)) { return $value; } @@ -74,15 +74,10 @@ function ($matches) use (&$namelessCount) { */ protected function _jsEncode($arg): string { - if (is_object($arg)) { - if ($arg instanceof JsExpressionable) { - $result = $arg->jsRender(); + if (is_object($arg) && $arg instanceof JsExpressionable) { + $result = $arg->jsRender(); - return $result; - } - - throw (new Exception('Not sure how to represent this object in JSON')) - ->addMoreInfo('obj', $arg); + return $result; } elseif (is_array($arg)) { $array = []; $assoc = !array_is_list($arg); @@ -115,7 +110,7 @@ protected function _jsEncode($arg): string } elseif ($arg === null) { $string = 'null'; } else { - throw (new Exception('Unsupported argument type')) + throw (new Exception('Argument is not renderable to JS')) ->addMoreInfo('arg', $arg); } diff --git a/src/JsFunction.php b/src/JsFunction.php index 2c4ea9e66b..461c66c890 100644 --- a/src/JsFunction.php +++ b/src/JsFunction.php @@ -16,7 +16,7 @@ class JsFunction implements JsExpressionable /** @var array */ public $fxArgs; - /** @var array */ + /** @var array */ public $fxStatements = []; /** @var bool add preventDefault(event) to generated method */ @@ -28,12 +28,19 @@ class JsFunction implements JsExpressionable /** @var string Indent of target code (not one indent level) */ public $indent = ' '; + /** + * @param array|array $statements + */ public function __construct(array $args, array $statements) { $this->fxArgs = $args; foreach ($statements as $key => $value) { if (is_int($key)) { + if ($value === null) { // TODO this should be not needed + continue; + } + $this->fxStatements[] = $value; } else { $this->{$key} = $value; @@ -56,18 +63,9 @@ public function jsRender(): string $output = 'function (' . implode(', ', $this->fxArgs) . ') {' . $pre; foreach ($this->fxStatements as $statement) { - if ($statement === null) { // TODO this should be not needed - continue; - } - - if ($statement instanceof JsExpressionable) { - $statement = $statement->jsRender(); - } else { - throw (new Exception('Incorrect statement for JsFunction')) - ->addMoreInfo('statement', $statement); - } + $js = $statement->jsRender(); - $output .= "\n" . $this->indent . ' ' . $statement . (!preg_match('~[;}]\s*$~', $statement) ? ';' : ''); + $output .= "\n" . $this->indent . ' ' . $js . (!preg_match('~[;}]\s*$~', $js) ? ';' : ''); } $output .= "\n" . $this->indent . '}'; diff --git a/src/JsSse.php b/src/JsSse.php index add3e3d413..113b4ef2a1 100644 --- a/src/JsSse.php +++ b/src/JsSse.php @@ -41,7 +41,7 @@ protected function init(): void } } - public function jsRender(): string + public function jsExecute(): JsExpression { $this->getApp(); // assert has App @@ -53,7 +53,7 @@ public function jsRender(): string $options['closeBeforeUnload'] = $this->closeBeforeUnload; } - return (new Jquery($this->getOwner() /* TODO element and loader element should be passed explicitly */))->atkServerEvent($options)->jsRender(); + return (new Jquery($this->getOwner() /* TODO element and loader element should be passed explicitly */))->atkServerEvent($options); } /** diff --git a/src/UserAction/ConfirmationExecutor.php b/src/UserAction/ConfirmationExecutor.php index aceba2de30..9f91537f9b 100644 --- a/src/UserAction/ConfirmationExecutor.php +++ b/src/UserAction/ConfirmationExecutor.php @@ -85,9 +85,6 @@ private function jsShowAndLoad(array $urlArgs, array $apiConfig): array ]; } - /** - * Return js expression that will trigger action executor. - */ public function jsExecute(array $urlArgs = []): array { if (!$this->action) { diff --git a/src/UserAction/JsExecutorInterface.php b/src/UserAction/JsExecutorInterface.php index 2f5be07d7a..90dba81d1a 100644 --- a/src/UserAction/JsExecutorInterface.php +++ b/src/UserAction/JsExecutorInterface.php @@ -4,6 +4,8 @@ namespace Atk4\Ui\UserAction; +use Atk4\Ui\JsExpressionable; + /** * Add js trigger for executing an action. */ @@ -11,6 +13,8 @@ interface JsExecutorInterface extends ExecutorInterface { /** * Return js expression that will trigger action executor. + * + * @return array */ public function jsExecute(array $urlArgs): array; } diff --git a/src/UserAction/ModalExecutor.php b/src/UserAction/ModalExecutor.php index b738e70f83..ff85812b7c 100644 --- a/src/UserAction/ModalExecutor.php +++ b/src/UserAction/ModalExecutor.php @@ -136,9 +136,6 @@ public function assignTrigger(View $view, array $urlArgs = [], string $when = 'c return $this; } - /** - * Generate js for triggering action. - */ public function jsExecute(array $urlArgs = []): array { if (!$this->actionInitialized) { diff --git a/src/View.php b/src/View.php index 977648f06a..b29b33d45b 100644 --- a/src/View.php +++ b/src/View.php @@ -960,8 +960,8 @@ public function jsReload($args = [], $afterSuccess = null, $apiConfig = []) * @see http://agile-ui.readthedocs.io/en/latest/js.html * * @param string $event JavaScript event - * @param ($action is null|array ? string|JsExpressionable|\Closure|array|UserAction\ExecutorInterface|Model\UserAction : string|array) $selector Optional jQuery-style selector - * @param string|JsExpressionable|\Closure|array|UserAction\ExecutorInterface|Model\UserAction|null $action code to execute + * @param ($action is null|array ? string|JsExpressionable|JsCallback|\Closure|array|UserAction\ExecutorInterface|Model\UserAction : string|array) $selector Optional jQuery-style selector + * @param string|JsExpressionable|JsCallback|\Closure|array|UserAction\ExecutorInterface|Model\UserAction|null $action code to execute * * @return ($selector is null|string ? ($action is null ? Jquery : null) : ($action is null|array ? Jquery : null)) */ @@ -1000,6 +1000,22 @@ public function on(string $event, $selector = null, $action = null, array $defau $eventStatements['preventDefault'] = $defaults['preventDefault'] ?? true; $eventStatements['stopPropagation'] = $defaults['stopPropagation'] ?? true; + $lazyJsRenderFx = function (\Closure $fx): JsExpressionable { + return new class($fx) implements JsExpressionable { + public \Closure $fx; + + public function __construct(\Closure $fx) + { + $this->fx = $fx; + } + + public function jsRender(): string + { + return ($this->fx)()->jsRender(); + } + }; + }; + // Dealing with callback action. if ($action instanceof \Closure || (is_array($action) && ($action[0] ?? null) instanceof \Closure)) { if (is_array($action)) { @@ -1021,7 +1037,7 @@ public function on(string $event, $selector = null, $action = null, array $defau return $action($chain, ...$args); }, $arguments); - $actions[] = $cb; + $actions[] = $lazyJsRenderFx(fn () => $cb->jsExecute()); } elseif ($action instanceof UserAction\ExecutorInterface || $action instanceof Model\UserAction) { // Setup UserAction executor. $ex = $action instanceof Model\UserAction ? $this->getExecutorFactory()->create($action, $this) : $action; @@ -1044,11 +1060,13 @@ public function on(string $event, $selector = null, $action = null, array $defau if ($defaults['apiConfig'] ?? null) { $ex->apiConfig = $defaults['apiConfig']; } - $actions[] = $ex; + $actions[] = $lazyJsRenderFx(fn () => $ex->jsExecute()); $ex->executeModelAction($arguments); } else { throw new Exception('Executor must be of type UserAction\JsCallbackExecutor or extend View and implement UserAction\JsExecutorInterface'); } + } elseif ($action instanceof JsCallback) { + $actions[] = $lazyJsRenderFx(fn () => $action->jsExecute()); } elseif (is_array($action)) { $actions = array_merge($actions, $action); } else { diff --git a/tests/JsIntegrationTest.php b/tests/JsIntegrationTest.php index 46e5e80595..f687f80d3e 100644 --- a/tests/JsIntegrationTest.php +++ b/tests/JsIntegrationTest.php @@ -6,6 +6,9 @@ use Atk4\Core\Phpunit\TestCase; use Atk4\Ui\Button; +use Atk4\Ui\Exception; +use Atk4\Ui\JsCallback; +use Atk4\Ui\JsExpression; use Atk4\Ui\View; class JsIntegrationTest extends TestCase @@ -43,7 +46,7 @@ public function testChainTrue(): void { $v = new Button(['name' => 'b']); $j = $v->js(true)->hide(); - $v->getHtml(); + $v->renderAll(); static::assertSame('(function () { $(\'#b\').hide(); @@ -54,7 +57,7 @@ public function testChainClick(): void { $v = new Button(['name' => 'b']); $v->js('click')->hide(); - $v->getHtml(); + $v->renderAll(); static::assertSame('(function () { $(\'#b\').on(\'click\', function (event) { @@ -69,7 +72,7 @@ public function testChainClickEmpty(): void { $v = new Button(['name' => 'b']); $v->js('click', null); - $v->getHtml(); + $v->renderAll(); static::assertSame('(function () { $(\'#b\').on(\'click\', function (event) { @@ -84,9 +87,9 @@ public function testChainClickEmpty(): void public function testChainNested(): void { - $bb = new View(['ui' => 'buttons']); - $b1 = Button::addTo($bb, ['name' => 'b1']); - $b2 = Button::addTo($bb, ['name' => 'b2']); + $v = new View(['ui' => 'buttons']); + $b1 = Button::addTo($v, ['name' => 'b1']); + $b2 = Button::addTo($v, ['name' => 'b2']); $b1->on('click', [ 'preventDefault' => false, @@ -95,7 +98,7 @@ public function testChainNested(): void $b2->js()->hide(), ]); $b1->js(true)->data('x', 'y'); - $bb->getHtml(); + $v->renderAll(); static::assertSame('(function () { $(\'#b1\').on(\'click\', function (event) { @@ -105,7 +108,7 @@ public function testChainNested(): void $(\'#b2\').hide(); }); $(\'#b1\').data(\'x\', \'y\'); -})()', $bb->getJs()); +})()', $v->getJs()); } public function testChainNullReturn(): void @@ -117,4 +120,45 @@ public function testChainNullReturn(): void static::assertNull($v->js(true, $js)); // @phpstan-ignore-line static::assertNull($v->on('click', $js)); // @phpstan-ignore-line } + + public function testChainUnsupportedTypeException(): void + { + $v = new View(); + $v->invokeInit(); + + $js = $v->js(); + $js->data(['url' => JsCallback::addTo($v)]); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('not renderable to JS'); + $js->jsRender(); + } + + public function testChainJsCallbackLazyExecuteRender(): void + { + $v = new View(); + $v->invokeInit(); + $b = Button::addTo($v); + + $jsCallback = new class() extends JsCallback { + public int $counter = 0; + + public function jsExecute(): JsExpression + { + ++$this->counter; + + return parent::jsExecute(); + } + }; + $v->add($jsCallback); + + $b->on('click', $jsCallback); + static::assertSame(0, $jsCallback->counter); + + $v->renderAll(); + static::assertSame(0, $jsCallback->counter); + + $v->getJs(); + static::assertSame(1, $jsCallback->counter); + } } diff --git a/tests/JsTest.php b/tests/JsTest.php index 0447fd8b15..c8cdf19cc4 100644 --- a/tests/JsTest.php +++ b/tests/JsTest.php @@ -6,6 +6,7 @@ use Atk4\Core\Phpunit\TestCase; use Atk4\Ui\App; +use Atk4\Ui\Exception; use Atk4\Ui\Jquery; use Atk4\Ui\JsChain; use Atk4\Ui\JsExpression; @@ -130,4 +131,13 @@ public function testComplex1(): void }) EOF, $fx->jsRender()); } + + public function testUnsupportedTypeRenderException(): void + { + $js = new JsExpression('{}', [new \stdClass()]); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('not renderable to JS'); + $js->jsRender(); + } }