src/Lister.php
<?php
declare(strict_types=1);
namespace Atk4\Ui;
use Atk4\Core\HookTrait;
use Atk4\Data\Model;
class Lister extends View
{
use HookTrait;
public const HOOK_BEFORE_ROW = self::class . '@beforeRow';
public const HOOK_AFTER_ROW = self::class . '@afterRow';
public $ui = 'list';
public $defaultTemplate;
/**
* Lister repeats part of it's template. This property will contain
* the repeating part. Clones from {row}. If your template does not
* have {row} tag, then entire template will be repeated.
*
* @var HtmlTemplate
*/
public $tRow;
/** @var HtmlTemplate|null Lister use this part of template in case there are no elements in it. */
public $tEmpty;
/** @var JsPaginator|null A dynamic paginator attach to window scroll event. */
public $jsPaginator;
/** @var int|null The number of item per page for JsPaginator. */
public $ipp;
/** Current row entity */
public ?Model $currentRow = null;
#[\Override]
protected function init(): void
{
parent::init();
$this->initChunks();
}
/**
* From the current template will extract {row} into $this->tRowMaster and {empty} into $this->tEmpty.
*/
protected function initChunks(): void
{
if (!$this->template) {
throw new Exception('Lister does not have default template. Either supply your own HTML or use "defaultTemplate" => "lister.html"');
}
// empty row template
if ($this->template->hasTag('empty')) {
$this->tEmpty = $this->template->cloneRegion('empty');
$this->template->del('empty');
}
// data row template
if ($this->template->hasTag('row')) {
$this->tRow = $this->template->cloneRegion('row');
$this->template->del('rows');
} else {
$this->tRow = clone $this->template;
$this->template->del('_top');
}
}
/**
* Add Dynamic paginator when scrolling content via Javascript.
* Will output x item in lister set per IPP until user scroll content to the end of page.
* When this happen, content will be reload x number of items.
*
* @param int $ipp Number of item per page
* @param array<string, mixed> $options an array with JS Scroll plugin options
* @param View $container the container holding the lister for scrolling purpose
* @param string $scrollRegion A specific template region to render. Render output is append to container HTML element.
*
* @return $this
*/
public function addJsPaginator($ipp, array $options = [], $container = null, $scrollRegion = null)
{
$this->ipp = $ipp;
$this->jsPaginator = JsPaginator::addTo($this, ['view' => $container, 'options' => $options]);
// set initial model limit. can be overwritten by onScroll
$this->model->setLimit($ipp);
// add onScroll callback
$this->jsPaginator->onScroll(function (int $p) use ($ipp, $scrollRegion) {
// set/overwrite model limit
$this->model->setLimit($ipp, ($p - 1) * $ipp);
// render this View (it will count rendered records !)
$jsonArr = $this->renderToJsonArr($scrollRegion);
// let client know that there are no more records
$jsonArr['noMoreScrollPages'] = $this->_renderedRowsCount < $ipp;
// return JSON response
$this->getApp()->terminateJson($jsonArr);
});
return $this;
}
/** @var int This will count how many rows are rendered. Needed for JsPaginator for example. */
protected $_renderedRowsCount = 0;
#[\Override]
protected function renderView(): void
{
if (!$this->template) {
throw new Exception('Lister requires you to specify template explicitly');
}
// if no model is set, don't show anything (even warning)
if ($this->model === null) {
parent::renderView();
return;
}
// iterate data rows
$this->_renderedRowsCount = 0;
$tRowBackup = $this->tRow;
try {
foreach ($this->model as $entity) {
$this->currentRow = $entity;
$this->tRow = clone $tRowBackup;
if ($this->hook(self::HOOK_BEFORE_ROW) === false) {
continue;
}
$this->renderRow();
++$this->_renderedRowsCount;
}
} finally {
$this->tRow = $tRowBackup;
$this->currentRow = null;
}
// empty message
if ($this->_renderedRowsCount === 0) {
if (!$this->jsPaginator || !$this->jsPaginator->getPage()) {
$empty = $this->tEmpty !== null ? $this->tEmpty->renderToHtml() : '';
if ($this->template->hasTag('rows')) {
$this->template->dangerouslyAppendHtml('rows', $empty);
} else {
$this->template->dangerouslyAppendHtml('_top', $empty);
}
}
}
// stop JsPaginator if there are no more records to fetch
if ($this->jsPaginator && ($this->_renderedRowsCount < $this->ipp)) {
$this->jsPaginator->jsIdle();
}
parent::renderView();
}
/**
* Render individual row. Override this method if you want to do more
* decoration.
*/
public function renderRow(): void
{
$this->tRow->trySet($this->getApp()->uiPersistence->typecastSaveRow($this->currentRow, $this->currentRow->get()));
if ($this->tRow->hasTag('_title')) {
$this->tRow->set('_title', $this->currentRow->getTitle());
}
$idStr = $this->getApp()->uiPersistence->typecastAttributeSaveField($this->currentRow->getIdField(), $this->currentRow->getId());
if ($this->tRow->hasTag('_href')) {
$this->tRow->set('_href', $this->url(['id' => $idStr]));
}
$this->tRow->trySet('_id', $this->name . '-' . $idStr);
$html = $this->tRow->renderToHtml();
if ($this->template->hasTag('rows')) {
$this->template->dangerouslyAppendHtml('rows', $html);
} else {
$this->template->dangerouslyAppendHtml('_top', $html);
}
}
/**
* Hack - override parent method with region render only support.
*
* TODO this hack/method must be removed as rendering HTML only partially but with all JS
* is wrong by design. Each table row should be probably rendered natively using cloned
* render tree (instead of cloned template).
*/
#[\Override]
public function renderToJsonArr(?string $region = null): array
{
$this->renderAll();
// https://github.com/atk4/ui/issues/1932
if ($region !== null) {
if (!isset($this->_jsActions['click'])) {
$this->_jsActions['click'] = [];
}
array_unshift($this->_jsActions['click'], $this->js()->off());
}
return [
'success' => true,
'atkjs' => $this->getJs()->jsRender(),
'html' => $this->template->renderToHtml($region),
'id' => $this->name,
];
}
}