src/CardDeck.php
<?php
declare(strict_types=1);
namespace Atk4\Ui;
use Atk4\Core\Factory;
use Atk4\Data\Model;
use Atk4\Ui\Js\JsBlock;
use Atk4\Ui\Js\JsExpressionable;
use Atk4\Ui\Js\JsToast;
use Atk4\Ui\UserAction\ExecutorFactory;
use Atk4\Ui\UserAction\ExecutorInterface;
use Atk4\Ui\UserAction\SharedExecutorsContainer;
/**
* A collection of Card set from a model.
*/
class CardDeck extends View
{
public $ui = 'basic segment atk-card-deck';
public $defaultTemplate = 'card-deck.html';
/** @var array<mixed> Seed of Card inside this deck. */
public $cardSeed = [Card::class];
/** @var bool Whether card should use table display or not. */
public $useTable = false;
/** @var bool Whether card should use label display or not. */
public $useLabel = false;
/** @var string|null If using extra field in Card, glue, join them using extra glue. */
public $extraGlue = ' - ';
/** @var bool If each card should use action or not. */
public $useAction = true;
/** @var SharedExecutorsContainer|null */
public $sharedExecutorsContainer = [SharedExecutorsContainer::class];
/** @var View|null The container view. The view that is reload when page or data changed. */
public $container = [View::class, 'ui' => 'vertical segment'];
/** @var View The view containing Cards. */
public $cardHolder = [View::class, 'ui' => 'cards'];
/** @var Paginator|false|null The paginator view. */
public $paginator = [Paginator::class];
/** @var int The number of cards to be displayed per page. */
public $ipp = 9;
/** @var Menu|array<mixed>|false Will be initialized to Menu object, however you can set this to false to disable menu. */
public $menu;
/** @var array<mixed>|VueComponent\ItemSearch|false */
public $search = [VueComponent\ItemSearch::class];
/** @var array<mixed> Default notifier to perform when model action is successful * */
public $notifyDefault = [JsToast::class];
/** @var list<string> Model single scope action to include in table action column. Will include all single scope actions if empty. */
public array $singleScopeActions = [];
/** @var list<string> Model no_record scope action to include in menu. Will include all no record scope actions if empty. */
public array $noRecordScopeActions = [];
/** @var string Message to display when record is add or edit successfully. */
public $saveMsg = 'Record has been saved!';
/** @var string Message to display when record is delete successfully. */
public $deleteMsg = 'Record has been deleted!';
/** @var string Generic display message for no record scope action where model is not loaded. */
public $defaultMsg = 'Done!';
/** @var array<mixed> seed to create View for displaying when search result is empty. */
public $noRecordDisplay = [
Message::class,
'content' => 'Result empty!',
'icon' => 'info circle',
'text' => 'Your search did not return any record or there is no record available.',
];
/** @var array<string, array{button: MenuItem, executor: ExecutorInterface}> A collection of menu button added in Menu. */
private $menuActions = [];
/** @var string|null The current search query string. */
private $query;
#[\Override]
protected function init(): void
{
parent::init();
$this->sharedExecutorsContainer = $this->add($this->sharedExecutorsContainer);
$this->container = $this->add($this->container);
if ($this->menu !== false && !is_object($this->menu)) {
$this->menu = $this->add(Factory::factory([Menu::class, 'activateOnClick' => false], $this->menu), 'Menu');
if ($this->search !== false) {
$this->addMenuBarSearch();
}
}
$this->cardHolder = $this->container->add($this->cardHolder);
if ($this->paginator !== false) {
$this->addPaginator();
$this->stickyGet($this->paginator->name);
}
}
protected function addMenuBarSearch(): void
{
$view = View::addTo($this->menu->addMenuRight()->addItem());
$this->search = $view->add(Factory::factory($this->search, ['context' => $this->container]));
$this->search->reload = $this->container;
$this->query = $this->stickyGet($this->search->queryArg);
}
protected function addPaginator(): void
{
$seg = View::addTo($this->container, ['ui' => 'basic segment'])->setStyle('text-align', 'center');
$this->paginator = $seg->add(Factory::factory($this->paginator, ['reload' => $this->container]));
}
/**
* @param list<string> $fields
* @param list<string> $extra
*/
#[\Override]
public function setModel(Model $model, ?array $fields = null, ?array $extra = null): void
{
parent::setModel($model);
if ($this->search !== false) {
$this->search->setModelCondition($this->model);
}
$count = $this->initPaginator();
if ($count) {
foreach ($this->model as $entity) {
/** @var Card */
$c = $this->cardHolder->add(Factory::factory($this->cardSeed, ['useLabel' => $this->useLabel, 'useTable' => $this->useTable]));
$c->setModel($entity, $fields);
if ($extra) {
$c->addExtraFields($entity, $extra, $this->extraGlue);
}
if ($this->useAction) {
foreach ($this->getModelActions(Model\UserAction::APPLIES_TO_SINGLE_RECORD) as $action) {
$c->addClickAction($action, null, $this->getReloadArgs());
}
}
}
} else {
$this->cardHolder->addClass('centered')->add(Factory::factory($this->noRecordDisplay));
}
// add no record scope action to menu
if ($this->useAction && $this->menu) {
foreach ($this->getModelActions(Model\UserAction::APPLIES_TO_NO_RECORDS) as $k => $action) {
$executor = $this->initActionExecutor($action);
$this->menuActions[$k]['button'] = $this->menu->addItem(
$this->getExecutorFactory()->createTrigger($action, ExecutorFactory::MENU_ITEM)
);
$this->menuActions[$k]['executor'] = $executor;
}
}
$this->setItemsAction();
}
/**
* Setup JS for firing menu action - copied from Crud - TODO deduplicate.
*/
protected function setItemsAction(): void
{
foreach ($this->menuActions as $item) {
// hack - render executor action via MenuItem::on() into container
$item['button']->on('click.atk_crud_item', $item['executor']);
$jsAction = array_pop($item['button']->_jsActions['click.atk_crud_item']);
$this->container->js(true, $jsAction);
}
}
/**
* Setup executor for an action.
* First determine what fields action needs,
* then setup executor based on action fields, args and/or preview.
*
* Single record scope action use jsSuccess instead of afterExecute hook
* because hook will keep adding for every cards, thus repeating jsExecute multiple time,
* i.e. once for each card, unless hook is break.
*/
protected function initActionExecutor(Model\UserAction $action): ExecutorInterface
{
$executor = $this->getExecutorFactory()->createExecutor($action, $this);
if ($action->appliesTo === Model\UserAction::APPLIES_TO_SINGLE_RECORD) {
$executor->jsSuccess = function (ExecutorInterface $ex, Model $m, $id, $return) use ($action) {
return $this->jsExecute($return, $action);
};
} else {
$executor->onHook(UserAction\BasicExecutor::HOOK_AFTER_EXECUTE, function (ExecutorInterface $ex, $return, $id) use ($action) {
return $this->jsExecute($return, $action);
});
}
return $executor;
}
/**
* Return proper JS statement for afterExecute hook on action executor
* depending on return type, model loaded and action scope.
*
* @param string|JsExpressionable|Model|null $return
*/
protected function jsExecute($return, Model\UserAction $action): JsBlock
{
$res = new JsBlock();
if ($return instanceof Model) {
$return = $return->isLoaded()
? $this->saveMsg
: ($action->appliesTo === Model\UserAction::APPLIES_TO_SINGLE_RECORD ? $this->deleteMsg : $this->defaultMsg);
}
if (is_string($return)) {
$msg = $this->jsCreateNotifier($action, $return);
} elseif ($return instanceof JsExpressionable) {
$msg = $return;
} else {
$msg = $this->jsCreateNotifier($action, $this->defaultMsg);
}
$res->addStatement($msg);
$res->addStatement($this->container->jsReload($this->getReloadArgs()));
return $res;
}
/**
* Override this method for setting notifier based on action or model value.
*/
protected function jsCreateNotifier(Model\UserAction $action, ?string $msg = null): JsBlock
{
$notifier = Factory::factory($this->notifyDefault);
if ($msg) {
$notifier->setMessage($msg);
}
return new JsBlock([$notifier]);
}
/**
* Return reload argument based on Deck condition.
*
* @return mixed
*/
private function getReloadArgs()
{
$args = [];
if ($this->paginator !== false) {
$args[$this->paginator->name] = $this->paginator->getCurrentPage();
}
if ($this->search !== false) {
$args[$this->search->queryArg] = $this->query;
}
return $args;
}
/**
* Return proper action need to setup menu or action column.
*
* @return array<string, Model\UserAction>
*/
private function getModelActions(string $appliesTo): array
{
if ($appliesTo === Model\UserAction::APPLIES_TO_SINGLE_RECORD && $this->singleScopeActions !== []) {
$actions = array_combine(
$this->singleScopeActions,
array_map(fn ($v) => $this->model->getUserAction($v), $this->singleScopeActions)
);
} elseif ($appliesTo === Model\UserAction::APPLIES_TO_NO_RECORDS && $this->noRecordScopeActions !== []) {
$actions = array_combine(
$this->noRecordScopeActions,
array_map(fn ($v) => $this->model->getUserAction($v), $this->noRecordScopeActions)
);
} else {
$actions = $this->model->getUserActions($appliesTo);
}
return $actions;
}
/**
* Will set model limit according to paginator value.
*/
protected function initPaginator(): int
{
$count = $this->model->executeCountQuery();
if ($this->paginator) {
if ($count > 0) {
$this->paginator->setTotal((int) ceil($count / $this->ipp));
$this->model->setLimit($this->ipp, ($this->paginator->page - 1) * $this->ipp);
} else {
$this->paginator->destroy();
}
}
return $count;
}
}