src/View.php
<?php
declare(strict_types=1);
namespace Atk4\Ui;
use Atk4\Data\Field;
use Atk4\Data\Model;
use Atk4\Data\Persistence;
use Atk4\Ui\Js\Jquery;
use Atk4\Ui\Js\JsBlock;
use Atk4\Ui\Js\JsCallbackLoadableValue;
use Atk4\Ui\Js\JsChain;
use Atk4\Ui\Js\JsExpression;
use Atk4\Ui\Js\JsExpressionable;
use Atk4\Ui\Js\JsFunction;
use Atk4\Ui\Js\JsReload;
use Atk4\Ui\Js\JsVueService;
use Atk4\Ui\UserAction\ExecutorFactory;
/**
* Base view of all UI components.
*
* @phpstan-type JsCallbackSetClosure \Closure(Jquery, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed): (JsExpressionable|View|string|void)
*/
class View extends AbstractView
{
/**
* When you call renderAll() this will be populated with JavaScript chains.
*
* @var array<1|string, list<JsExpressionable>>
*
* @internal
*/
protected array $_jsActions = [];
public ?Model $model = null;
public ?Model $entity = null;
/**
* Name of the region in the parent's template where this object will output itself.
*/
public ?string $region = null;
/**
* Enables UI keyword for Fomantic-UI indicating that this is a
* UI element. If you set this variable value to string, it will
* be appended at the end of the element class.
*
* @var bool|string
*/
public $ui = false;
/** @var list<string> List of element CSS classes. */
public array $class = [];
/** @var array<string, string> Map of element CSS styles. */
public array $style = [];
/** @var array<string, string|int> Map of element attributes. */
public array $attr = [];
/**
* Template object, that, for most Views will be rendered to
* produce HTML output. If you leave this object as "null" then
* a new Template will be generated during init() based on the
* value of $defaultTemplate.
*
* @var HtmlTemplate|null
*/
public $template;
/**
* Specifies how to initialize $template.
*
* If you specify a string, then it will be considered a filename
* from which to load the $template.
*
* @var string|null
*/
public $defaultTemplate = 'element.html';
/** @var string|null Set static contents of this view. */
public $content;
/** Change this if you want to substitute default "div" for something else. */
public string $element = 'div';
/** @var ExecutorFactory|null */
protected $executorFactory;
// {{{ Setting Things up
/**
* @param array<0|string, mixed>|string $label
*/
public function __construct($label = [])
{
$defaults = is_array($label) ? $label : [$label];
if (array_key_exists(0, $defaults)) {
$defaults['content'] = $defaults[0];
unset($defaults[0]);
}
$this->setDefaults($defaults);
}
#[\Override]
public function &__get(string $name)
{
// TODO remove in atk4/ui 6.0
if ($name === 'model' && !(new \ReflectionProperty(self::class, 'model'))->isInitialized($this) && $this->entity !== null) {
throw new Exception('Use View::$entity property instead for entity access');
}
return parent::__get($name);
}
/**
* Associate this view with a model. Do not place any logic in this class, instead take it
* to renderView().
*
* Do not try to create your own "Model" implementation, instead you must be looking for
* your own "Persistence" implementation.
*/
public function setModel(Model $model): void
{
if (((new \ReflectionProperty(self::class, 'model'))->isInitialized($this) ? $this->model : $this->entity) !== null) {
if (((new \ReflectionProperty(self::class, 'model'))->isInitialized($this) ? $this->model : $this->entity) === $model) {
return;
}
throw new Exception('Different model is already set');
}
if ($model->isEntity()) {
unset($this->{'model'});
$this->entity = $model;
} else {
unset($this->{'entity'});
$this->model = $model;
}
}
/**
* Sets source of the View.
*
* @param array<int|string, mixed> $data
* @param list<string> $fields Limit model to particular fields
*
* @phpstan-assert !null $this->model
*/
public function setSource(array $data, $fields = null): Model
{
// ID with zero value is not supported (at least in MySQL replaces it with next AI value)
if (isset($data[0])) {
if (array_is_list($data)) {
$oldData = $data;
$data = [];
foreach ($oldData as $k => $row) {
$data[$k + 1_000_000_000] = $row; // large offset to prevent accessing wrong data by old key
}
} else {
throw new Exception('Source data contains unsupported zero key');
}
}
$this->setModel(new Model(new Persistence\Static_($data)), $fields); // @phpstan-ignore arguments.count
$this->model->getIdField()->type = 'string'; // TODO probably unwanted
return $this->model;
}
#[\Override]
protected function setMissingProperty(string $propertyName, $value): void
{
if (is_bool($value) && str_starts_with($propertyName, 'class.')) {
$class = substr($propertyName, strlen('class.'));
if ($value) {
$this->addClass($class);
} else {
$this->removeClass($class);
}
return;
}
parent::setMissingProperty($propertyName, $value);
}
/**
* @param string $element
*
* @return $this
*/
public function setElement($element)
{
$this->element = $element;
return $this;
}
/**
* Makes view into a "<a>" element with a link.
*
* @param string|array<0|string, string|int|false> $url
*
* @return $this
*/
public function link($url, ?string $target = null)
{
$this->setElement('a');
if (is_string($url)) {
$this->setAttr('href', $url);
} else {
$this->setAttr('href', $this->url($url));
}
if ($target !== null) {
$this->setAttr('target', $target);
}
return $this;
}
// }}}
// {{{ Default init() method and add() logic
/**
* Called when view becomes part of render tree. You can override it but avoid
* placing any "heavy processing" here.
*/
#[\Override]
protected function init(): void
{
// almost every View needs an App to load a template, so assert App is set upfront
// TODO consider lazy loading the template
$app = $this->getApp();
$addLater = $this->_addLater;
$this->_addLater = null;
parent::init();
if ($this->region === null) {
$this->region = 'Content';
}
if ($this->template === null) {
if ($this->defaultTemplate !== null) {
$this->template = $app->loadTemplate($this->defaultTemplate);
} else {
if ($this->region !== 'Content' && $this->issetOwner() && $this->getOwner()->template) {
$this->template = $this->getOwner()->template->cloneRegion($this->region);
$this->getOwner()->template->del($this->region);
}
}
}
foreach ($addLater as [$object, $region]) {
$this->add($object, $region);
}
// allow for injecting the model with a seed
if ($this->model !== null) {
$this->setModel($this->model);
}
}
public function getExecutorFactory(): ExecutorFactory
{
return $this->executorFactory ?? $this->getApp()->getExecutorFactory();
}
/**
* In addition to adding a child object, sets up it's template
* and associate it's output with the region in our template.
*
* @param AbstractView $object
* @param string|array<mixed>|null $region
*/
#[\Override]
public function add($object, $region = null): AbstractView
{
if (!is_object($object)) { // @phpstan-ignore function.alreadyNarrowedType
// for BC do not throw
// later consider to accept strictly objects only
$object = AbstractView::fromSeed($object);
}
if (!$this->issetApp()) {
$this->_addLater[] = [$object, $region];
return $object;
}
if (is_array($region)) {
$args = $region;
$region = $args['region'] ?? null;
unset($args['region']);
} else {
$args = [];
}
// set region
if ($region !== null) {
$object->setDefaults(['region' => $region]);
}
// will call init() of the object
parent::add($object, $args);
return $object;
}
public function getHtmlId(): string
{
$this->assertIsInitialized();
return $this->name;
}
/**
* Get closest owner which is instance of particular class.
*
* @template T of View
*
* @param class-string<T> $class
*
* @return T|null
*/
public function getClosestOwner(string $class): ?self
{
if (!$this->issetOwner()) {
return null;
}
if ($this->getOwner() instanceof $class) {
return $this->getOwner();
}
return $this->getOwner()->getClosestOwner($class);
}
// }}}
// {{{ Manipulating classes and view properties
/**
* TODO this method is hard to override, drop it from View.
*
* @param string $content
*
* @return $this
*/
public function set($content)
{
if (!is_string($content) && $content !== null) { // @phpstan-ignore function.alreadyNarrowedType, notIdentical.alwaysTrue, booleanAnd.alwaysFalse
throw (new Exception('Not sure what to do with argument'))
->addMoreInfo('this', $this)
->addMoreInfo('arg', $content);
}
$this->content = $content;
return $this;
}
/**
* Add CSS class to element. Previously added classes are not affected.
* Multiple CSS classes can also be added if passed as space separated
* string or array of class names.
*
* @param string|list<string> $class
*
* @return $this
*/
public function addClass($class)
{
if ($class !== []) {
$classArr = explode(' ', is_array($class) ? implode(' ', $class) : $class);
$this->class = array_merge($this->class, $classArr);
}
return $this;
}
/**
* Remove one or several CSS classes from the element.
*
* @param string|list<string> $class
*
* @return $this
*/
public function removeClass($class)
{
$classArr = explode(' ', is_array($class) ? implode(' ', $class) : $class);
$this->class = array_values(array_diff($this->class, $classArr));
return $this;
}
/**
* Add inline CSS style to element.
* Multiple CSS styles can also be set if passed as array.
*
* @param string|array<string, string> $property
* @param ($property is array ? never : string) $value
*
* @return $this
*/
public function setStyle($property, ?string $value = null)
{
if (is_array($property)) {
foreach ($property as $k => $v) {
$this->setStyle($k, $v);
}
} else {
$this->style[$property] = $value;
}
return $this;
}
/**
* Remove inline CSS style from element.
*
* @param string $property
*
* @return $this
*/
public function removeStyle($property)
{
unset($this->style[$property]);
return $this;
}
/**
* Set attribute.
*
* @param string|int|array<string, string|int> $name
* @param ($name is array ? never : string|int) $value
*
* @return $this
*/
public function setAttr($name, $value = null)
{
if (is_array($name)) {
foreach ($name as $k => $v) {
$this->setAttr($k, $v);
}
} else {
$this->attr[$name] = $value;
}
return $this;
}
/**
* Remove attribute.
*
* @param string|list<string> $name
*
* @return $this
*/
public function removeAttr($name)
{
if (is_array($name)) {
foreach ($name as $v) {
$this->removeAttr($v);
}
} else {
unset($this->attr[$name]);
}
return $this;
}
// }}}
// {{{ Sticky URLs
/** @var array<string, string|null> stickyGet arguments */
public $stickyArgs = [];
/**
* Build an URL which this view can use for callbacks.
*
* @param string|array<0|string, string|int|false> $page URL as string or array with page path as first element and other GET arguments
*/
public function url($page = []): string
{
return $this->getApp()->url($page, $this->_getStickyArgs());
}
/**
* Build an URL which this view can use for JS callbacks.
*
* @param string|array<0|string, string|int|false> $page URL as string or array with page path as first element and other GET arguments
*/
public function jsUrl($page = []): string
{
return $this->getApp()->jsUrl($page, $this->_getStickyArgs());
}
/**
* Get sticky arguments defined by the view and parents (including API).
*
* @return array<string, string|null>
*/
protected function _getStickyArgs(): array
{
if ($this->issetOwner()) {
$stickyArgs = array_merge($this->getOwner()->_getStickyArgs(), $this->stickyArgs);
} else {
$stickyArgs = $this->stickyArgs;
}
return $stickyArgs;
}
/**
* Mark GET argument as sticky. Calling url() on this view or any
* sub-views will embed the value of this GET argument.
*
* If GET argument is empty or false, it won't make into URL.
*
* If GET argument is not presently set you can specify a 2nd argument
* to forge-set the GET argument for current view and it's sub-views.
*/
public function stickyGet(string $name, ?string $newValue = null): ?string
{
$this->stickyArgs[$name] = $newValue ?? $this->stickyArgs[$name] ?? $this->getApp()->tryGetRequestQueryParam($name);
return $this->stickyArgs[$name];
}
// }}}
// {{{ Rendering
/**
* View-specific rendering stuff. Feel free to replace this method with
* your own. View::renderView contains some logic that integrates with
* Fomantic-UI.
*/
protected function renderView(): void
{
if ($this->element !== 'div') {
$this->template->set('_element', $this->element);
} else {
$this->template->trySet('_element', $this->element);
}
$app = $this->getApp();
if (!$app->isVoidTag($this->element)) {
$this->template->tryDangerouslySetHtml('_element_end', '</' . $this->element . '>');
}
$attrsHtml = [
'id="' . $app->encodeHtml($this->name) . '"',
];
// TODO hack for template/tabs.html
if ($this->template->hasTag('Tabs')) {
array_pop($attrsHtml);
}
// TODO hack for template/form/control/upload.html
if ($this->template->hasTag('AfterBeforeInput') && str_contains($this->template->renderToHtml(), ' type="file"')) {
array_pop($attrsHtml);
}
// needed for templates like '<input id="{$_id}_input">'
$this->template->trySet('_id', $this->name);
$class = null;
if ($this->class !== []) {
$class = implode(' ', $this->class);
// needed for templates like template/form/layout/generic-input.html
$this->template->tryAppend('class', implode(' ', $this->class));
}
if ($this->ui !== false) {
$class = 'ui ' . $this->ui . ($class !== null ? ' ' . $class : '');
}
if ($class !== null) {
$attrsHtml[] = 'class="' . $app->encodeHtml($class) . '"';
}
if ($this->style !== []) {
$styles = [];
foreach ($this->style as $k => $v) {
$styles[] = $k . ': ' . $app->encodeHtml($v) . ';';
}
$attrsHtml[] = 'style="' . implode(' ', $styles) . '"';
// needed for template/html.html
$this->template->tryDangerouslyAppendHtml('style', implode(' ', $styles));
}
foreach ($this->attr as $k => $v) {
$attrsHtml[] = $k . '="' . $app->encodeHtml((string) $v) . '"';
}
if ($attrsHtml !== []) {
try {
$this->template->dangerouslySetHtml('attributes', implode(' ', $attrsHtml));
} catch (Exception $e) {
// TODO hack to ignore missing '{$attributes}' mostly in layout templates
if (count($attrsHtml) === 1 ? !str_starts_with(reset($attrsHtml), 'id=') : !$this instanceof Lister) {
throw $e;
}
}
}
}
/**
* Recursively render all children, placing their output in our template.
*/
protected function recursiveRender(): void
{
foreach ($this->elements as $view) {
if (!$view instanceof self) {
continue;
}
$this->template->dangerouslyAppendHtml($view->region, $view->getHtml());
// collect JS from everywhere
foreach ($view->_jsActions as $when => $actions) {
foreach ($actions as $action) {
$this->_jsActions[$when][] = $action;
}
}
}
if ($this->content !== null) {
$this->template->append('Content', $this->content);
}
}
/**
* Render everything recursively, render ourselves but don't return anything just yet.
*/
public function renderAll(): void
{
if (!$this->isInitialized()) {
$this->invokeInit();
}
if (!$this->_rendered) {
$this->renderView();
$this->recursiveRender();
$this->_rendered = true;
}
}
/**
* For Form::renderTemplateToHtml() only.
*/
protected function renderTemplateToHtml(): string
{
return $this->template->renderToHtml();
}
/**
* This method is for those cases when developer want to simply render his
* view and grab HTML himself.
*/
public function renderToHtml(): string
{
$this->renderAll();
$js = $this->getJs()->jsRender();
return ($js !== '' ? $this->getApp()->getTag('script', [], '$(function () {' . $js . ';});') : '')
. $this->renderTemplateToHtml();
}
/**
* Render View using JSON format.
*
* @return array<string, mixed>
*/
public function renderToJsonArr(): array
{
$this->renderAll();
return [
'success' => true,
'atkjs' => $this->getJs()->jsRender(),
'html' => $this->renderTemplateToHtml(),
'id' => $this->name,
];
}
/**
* Created for recursive rendering or when you want to only get HTML of
* this object (not javascript).
*/
public function getHtml(): string
{
if ($this->getApp()->hasRequestQueryParam('__atk_reload') && $this->getApp()->getRequestQueryParam('__atk_reload') === ($this->name ?? null)) {
$this->getApp()->terminateJson($this);
}
$this->renderAll();
return $this->renderTemplateToHtml();
}
// }}}
// {{{ JavaScript integration
/**
* Views in Agile UI can assign javascript actions to themselves. This
* is done by calling $view->js() method which returns instance of JsChain
* object that is initialized to the object itself. Normally this chain
* will map into $('#object_id') and calling additional methods will map
* into additional calls.
*
* Action can represent javascript event, such as "click" or "mouseenter".
* If you specify action = true, then the event will ALWAYS be executed on
* documentReady. It will also be executed if respective view is being reloaded
* by js()->reload()
*
* (Do not make mistake by specifying "true" instead of true)
*
* action = false will still return JsChain but will not bind it.
* You can bind it by passing object into on() method.
*
* 1. Calling with arguments:
* $view->js(); // technically does nothing
* $a = $view->js()->hide(); // creates chain for hiding $view but does not bind to event yet
*
* 2. Binding existing chains
* $img->on('mouseenter', $a); // binds previously defined chain to event on event of $img
*
* Produced code: $('#img_id').on('mouseenter', function (event) {
* event.preventDefault();
* event.stopPropagation();
* $('#view1').hide();
* });
*
* 3. $button->on('click', $form->js()->submit()); // clicking button will result in form submit
*
* 4. $view->js(true)->find('.current')->text($text);
*
* Will convert calls to jQuery chain into JavaScript string:
* $('#view').find('.current').text('abc'); // the text will be JSON encoded to avoid JS injection
*
* @param bool|string $when Event when chain will be executed
* @param ($when is false ? null : JsExpressionable|null) $action JavaScript action
* @param string|self|null $selector If you wish to override jQuery($selector)
*
* @return ($action is null ? Jquery : null)
*/
public function js($when = false, $action = null, $selector = null): ?JsExpressionable
{
// binding on a specific event
// TODO allow only boolean $when, otherwise user should use self::on() method
if (!is_bool($when)) {
return $this->on($when, $selector, $action);
}
if ($action !== null) {
$res = null;
} else {
$action = new Jquery($this);
if ($selector) {
$action->find($selector);
}
$res = $action;
}
if ($when === true) {
$this->_jsActions[$when][] = $action;
}
return $res;
}
/**
* Create Vue.js instance.
*
* Component managed and defined by atk does not need componentDefinition variable name
* because these are already loaded within the atk js namespace.
* When creating your own component externally, you must supply the variable name holding
* your Vue component definition. This definition must be also accessible within the window javascript
* object. This way, you do not need to load Vue js file since it has already being include within
* atkjs-ui.js build.
*
* If the external component use other components, it is possible to register them using
* vueService getVue() method. This method return the current Vue object.
* ex: atk.vueService.getVue().component('external_component', externalComponent). This is the same
* as Vue.component() method.
*
* @param string $component The component name
* @param array<string, mixed> $initData The component properties passed as the initData prop.
* This is the initial data pass to your main component via the initData bind property
* of the vue component instance created via the vueService.
* @param JsExpressionable|null $componentDefinition component definition object
* @param string|self|null $selector the selector for creating the base root object in Vue
*
* @return $this
*/
public function vue($component, $initData = [], $componentDefinition = null, $selector = null)
{
if (!$selector) {
$selector = '#' . $this->getHtmlId();
}
if ($componentDefinition) {
$chain = (new JsVueService())->createVue($selector, $component, $componentDefinition, $initData);
} else {
$chain = (new JsVueService())->createAtkVue($selector, $component, $initData);
}
$this->js(true, $chain);
return $this;
}
/**
* Emit an event on atkEvent bus.
*
* Example of adding a listener on for an emit event:
*
* atk.eventBus.on('eventName', (data) => {
* console.log(data)
* });
*
* Note: In order to make sure your event is unique within atk, you can
* use the view name in it.
* $this->jsEmitEvent($this->name . '-my-event', $data)
*
* @param array<string, mixed> $eventData
*/
public function jsEmitEvent(string $eventName, array $eventData = []): JsChain
{
return (new JsChain('atk.eventBus'))->emit($eventName, $eventData);
}
/**
* Get Local and Session web storage associated with this view.
* Web storage can be retrieved using a $view->jsReload() request.
*
* @return array{local: mixed, session: mixed}
*/
public function jsGetStoreData(): array
{
$data = [
'local' => $this->getApp()->decodeJson(
$this->getApp()->tryGetRequestQueryParam($this->name . '_local_store') ?? $this->getApp()->tryGetRequestPostParam($this->name . '_local_store') ?? 'null'
),
'session' => $this->getApp()->decodeJson(
$this->getApp()->tryGetRequestQueryParam($this->name . '_session_store') ?? $this->getApp()->tryGetRequestPostParam($this->name . '_session_store') ?? 'null'
),
];
return $data;
}
/**
* Clear Web storage data associated with this view.
*/
public function jsClearStoreData(bool $useSession = false): JsExpressionable
{
$type = $useSession ? 'session' : 'local';
return (new JsChain('atk.dataService'))->clearData($this->name, $type);
}
/**
* Add Web storage for this specific view.
* Data will be store as json value where key name
* will be the name of this view.
*
* Data added to web storage is merge against previous value.
* $v->jsAddStoreData(['args' => ['path' => '.']]);
* $v->jsAddStoreData(['args' => ['path' => '/'], 'fields' => ['name' => 'test']]]);
*
* Final store value will be: ['args' => ['path' => '/'], 'fields' => ['name' => 'test']];
*
* @param array<string, mixed> $data
*/
public function jsAddStoreData(array $data, bool $useSession = false): JsExpressionable
{
$type = $useSession ? 'session' : 'local';
return (new JsChain('atk.dataService'))->addJsonData($this->name, $this->getApp()->encodeJson($data), $type);
}
/**
* Returns JS for reloading View.
*
* @param array<string, string|int|JsExpressionable> $args
* @param JsExpressionable|null $afterSuccess
* @param array<string, mixed> $apiConfig
*
* @return JsReload
*/
public function jsReload($args = [], $afterSuccess = null, array $apiConfig = []): JsExpressionable
{
return new JsReload($this, $args, $afterSuccess, $apiConfig);
}
/**
* Views in Agile Toolkit can assign javascript actions to themselves. This
* is done by calling $view->js() or $view->on().
*
* on() method is similar to jQuery on(event, [selector, ] action) method.
*
* When no $action is passed, the on() method returns a chain corresponding to the affected element.
*
* Here are some ways to use on():
*
* // clicking on button will make the $view disappear
* $button->on('click', $view->js()->hide());
*
* // clicking on <a class="clickable"> will make it's parent disappear
* $view->on('click', 'a[data=clickable]')->parent()->hide();
*
* Finally, it's also possible to use PHP closure as an action:
*
* $view->on('click', 'a', function (Jquery $js, $data) {
* if (!$data['clickable']) {
* return new JsExpression('alert([])', ['This record is not clickable'])
* }
*
* return $js->parent()->hide();
* });
*
* @param string $event JavaScript event
* @param ($action is object ? string : ($action is null ? string : never)|JsExpressionable|JsCallback|JsCallbackSetClosure|array{JsCallbackSetClosure}|UserAction\ExecutorInterface|Model\UserAction) $selector Optional jQuery-style selector
* @param ($selector is string|null ? JsExpressionable|JsCallback|JsCallbackSetClosure|array{JsCallbackSetClosure}|UserAction\ExecutorInterface|Model\UserAction : array<int|string, mixed>) $action code to execute
* @param array<int|string, mixed> $defaults
*
* @return ($selector is string|null ? ($action is null ? Jquery : null) : ($action is array|null ? Jquery : null))
*/
public function on(string $event, $selector = null, $action = null, array $defaults = [])
{
// second argument may be omitted
if ($selector !== null && !is_string($selector) && ($action === null || is_array($action)) && $defaults === []) {
$defaults = $action ?? [];
$action = $selector;
$selector = null;
}
// check for arguments
$arguments = $defaults['args'] ?? [];
unset($defaults['args']);
// all values with int keys of defaults are arguments
foreach ($defaults as $key => $value) {
if (is_int($key)) {
$arguments[] = $value;
unset($defaults[$key]);
}
}
if ($action !== null) {
$res = null;
} else {
$action = new Jquery();
$res = $action;
}
// set preventDefault and stopPropagation by default
$eventStatements = [];
$eventStatements['preventDefault'] = $defaults['preventDefault'] ?? true;
$eventStatements['stopPropagation'] = $defaults['stopPropagation'] ?? true;
$lazyJsRenderFx = function (\Closure $fx): JsExpressionable {
return new class($fx) implements JsExpressionable {
public \Closure $fx;
/**
* @param \Closure(JsExpressionable): JsExpressionable $fx
*/
public function __construct(\Closure $fx)
{
$this->fx = $fx;
}
#[\Override]
public function jsRender(): string
{
return ($this->fx)()->jsRender();
}
};
};
// dealing with callback action
if ($action instanceof \Closure || (is_array($action) && ($action[0] ?? null) instanceof \Closure)) {
$actions = [];
if (is_array($action)) {
$urlData = $action;
unset($urlData[0]);
foreach ($urlData as $a) {
$actions[] = $a;
}
$action = $action[0];
}
// create callback, that will include event as part of the full name
$cb = JsCallback::addTo($this, [], [['desired_name' => $event]]);
if ($defaults['apiConfig'] ?? null) {
$cb->apiConfig = $defaults['apiConfig'];
}
$cb->set(static function (Jquery $chain, ...$args) use ($action) {
return $action($chain, ...$args);
}, $arguments);
$actions[] = $lazyJsRenderFx(static fn () => $cb->jsExecute());
} elseif ($action instanceof UserAction\ExecutorInterface || $action instanceof UserAction\SharedExecutor || $action instanceof Model\UserAction) {
$ex = $action instanceof Model\UserAction ? $this->getExecutorFactory()->createExecutor($action, $this) : $action;
$setupNonSharedExecutorFx = function (UserAction\ExecutorInterface $ex) use (&$defaults, &$arguments): void {
/** @var AbstractView&UserAction\ExecutorInterface $ex https://github.com/phpstan/phpstan/issues/3770 */
$ex = $ex;
if (isset($arguments['id'])) {
$arguments[$ex->name] = $arguments['id'];
unset($arguments['id']);
} elseif (isset($arguments[0])) {
// if "id" is not specified we assume arguments[0] is the model ID
$arguments[$ex->name] = $arguments[0];
unset($arguments[0]);
}
// implicitly typecast all user action arguments
foreach ($arguments as $k => $v) {
if (!$v instanceof JsExpressionable) {
if ($k !== $ex->name && !isset($ex->getAction()->args[$k])) {
continue;
}
$actionArgType = $k === $ex->name
? $ex->getAction()->getModel()->getIdField()->type
: $ex->getAction()->args[$k]['type'];
$arguments[$k] = new JsCallbackLoadableValue(
new JsExpression('[]', [$this->getApp()->uiPersistence->typecastAttributeSaveField(new Field(['type' => $actionArgType]), $v)]),
function ($v) use ($actionArgType) {
return $this->getApp()->uiPersistence->typecastAttributeLoadField(new Field(['type' => $actionArgType]), $v);
}
);
}
}
if ($ex instanceof UserAction\JsCallbackExecutor) {
$confirmation = $ex->getAction()->getConfirmation();
if ($confirmation) {
$defaults['confirm'] = $confirmation;
}
if ($defaults['apiConfig'] ?? null) {
$ex->apiConfig = $defaults['apiConfig'];
}
}
};
if ($ex instanceof UserAction\SharedExecutor) {
$setupNonSharedExecutorFx($ex->getExecutor());
$actions = [$ex->getExecutor() instanceof UserAction\JsCallbackExecutor
? $lazyJsRenderFx(static fn () => $ex->jsExecute($arguments))
: $ex->jsExecute($arguments)];
} elseif ($ex instanceof UserAction\JsExecutorInterface && $ex instanceof self) {
$setupNonSharedExecutorFx($ex);
$ex->executeModelAction();
$actions = [$ex->jsExecute($arguments)];
} elseif ($ex instanceof UserAction\JsCallbackExecutor) {
$setupNonSharedExecutorFx($ex);
$ex->executeModelAction($arguments);
$actions = [$lazyJsRenderFx(static fn () => $ex->jsExecute($arguments))];
} else {
throw new Exception('Executor must be of type UserAction\JsCallbackExecutor or UserAction\JsExecutorInterface');
}
} elseif ($action instanceof JsCallback) {
$actions = [$lazyJsRenderFx(static fn () => $action->jsExecute())];
} else {
$actions = [$action];
}
if ($defaults['confirm'] ?? null) {
array_unshift($eventStatements, new JsExpression('$.atkConfirm({ message: [confirm], onApprove: [action], options: { button: { ok: [ok], cancel: [cancel] } }, context: this })', [
'confirm' => $defaults['confirm'],
'action' => new JsFunction([], $actions),
'ok' => $defaults['ok'] ?? 'Ok',
'cancel' => $defaults['cancel'] ?? 'Cancel',
]));
} else {
$eventStatements = array_merge($eventStatements, $actions);
}
$eventFunction = new JsFunction([], $eventStatements);
$eventChain = new Jquery($this);
if ($selector) {
$eventChain->on($event, $selector, $eventFunction);
} else {
$eventChain->on($event, $eventFunction);
}
$this->_jsActions[$event][] = $eventChain;
return $res;
}
/**
* Get JavaScript objects from this render tree.
*/
public function getJs(): JsBlock
{
$actions = [];
foreach ($this->_jsActions as $eventActions) {
foreach ($eventActions as $action) {
$actions[] = $action;
}
}
return new JsBlock($actions);
}
// }}}
}