1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui;
6:
7: use Atk4\Core\HookTrait;
8: use Atk4\Data\Model;
9:
10: class Lister extends View
11: {
12: use HookTrait;
13:
14: public const HOOK_BEFORE_ROW = self::class . '@beforeRow';
15: public const HOOK_AFTER_ROW = self::class . '@afterRow';
16:
17: public $ui = 'list';
18:
19: public $defaultTemplate;
20:
21: /**
22: * Lister repeats part of it's template. This property will contain
23: * the repeating part. Clones from {row}. If your template does not
24: * have {row} tag, then entire template will be repeated.
25: *
26: * @var HtmlTemplate
27: */
28: public $tRow;
29:
30: /** @var HtmlTemplate|null Lister use this part of template in case there are no elements in it. */
31: public $tEmpty;
32:
33: /** @var JsPaginator|null A dynamic paginator attach to window scroll event. */
34: public $jsPaginator;
35:
36: /** @var int|null The number of item per page for JsPaginator. */
37: public $ipp;
38:
39: /** @var Model Current row entity */
40: public $currentRow;
41:
42: #[\Override]
43: protected function init(): void
44: {
45: parent::init();
46:
47: $this->initChunks();
48: }
49:
50: /**
51: * From the current template will extract {row} into $this->tRowMaster and {empty} into $this->tEmpty.
52: */
53: protected function initChunks(): void
54: {
55: if (!$this->template) {
56: throw new Exception('Lister does not have default template. Either supply your own HTML or use "defaultTemplate" => "lister.html"');
57: }
58:
59: // empty row template
60: if ($this->template->hasTag('empty')) {
61: $this->tEmpty = $this->template->cloneRegion('empty');
62: $this->template->del('empty');
63: }
64:
65: // data row template
66: if ($this->template->hasTag('row')) {
67: $this->tRow = $this->template->cloneRegion('row');
68: $this->template->del('rows');
69: } else {
70: $this->tRow = clone $this->template;
71: $this->template->del('_top');
72: }
73: }
74:
75: /**
76: * Add Dynamic paginator when scrolling content via Javascript.
77: * Will output x item in lister set per IPP until user scroll content to the end of page.
78: * When this happen, content will be reload x number of items.
79: *
80: * @param int $ipp Number of item per page
81: * @param array $options an array with JS Scroll plugin options
82: * @param View $container the container holding the lister for scrolling purpose
83: * @param string $scrollRegion A specific template region to render. Render output is append to container HTML element.
84: *
85: * @return $this
86: */
87: public function addJsPaginator($ipp, $options = [], $container = null, $scrollRegion = null)
88: {
89: $this->ipp = $ipp;
90: $this->jsPaginator = JsPaginator::addTo($this, ['view' => $container, 'options' => $options]);
91:
92: // set initial model limit. can be overwritten by onScroll
93: $this->model->setLimit($ipp);
94:
95: // add onScroll callback
96: $this->jsPaginator->onScroll(function (int $p) use ($ipp, $scrollRegion) {
97: // set/overwrite model limit
98: $this->model->setLimit($ipp, ($p - 1) * $ipp);
99:
100: // render this View (it will count rendered records !)
101: $jsonArr = $this->renderToJsonArr($scrollRegion);
102:
103: // let client know that there are no more records
104: $jsonArr['noMoreScrollPages'] = $this->_renderedRowsCount < $ipp;
105:
106: // return JSON response
107: $this->getApp()->terminateJson($jsonArr);
108: });
109:
110: return $this;
111: }
112:
113: /** @var int This will count how many rows are rendered. Needed for JsPaginator for example. */
114: protected $_renderedRowsCount = 0;
115:
116: #[\Override]
117: protected function renderView(): void
118: {
119: if (!$this->template) {
120: throw new Exception('Lister requires you to specify template explicitly');
121: }
122:
123: // if no model is set, don't show anything (even warning)
124: if (!$this->model) {
125: parent::renderView();
126:
127: return;
128: }
129:
130: // iterate data rows
131: $this->_renderedRowsCount = 0;
132:
133: // TODO we should not iterate using $this->model variable,
134: // then also backup/tryfinally would be not needed
135: // the same in Table class
136: $modelBackup = $this->model;
137: $tRowBackup = $this->tRow;
138: try {
139: foreach ($this->model as $this->model) {
140: $this->currentRow = $this->model;
141: $this->tRow = clone $tRowBackup;
142: if ($this->hook(self::HOOK_BEFORE_ROW) === false) {
143: continue;
144: }
145:
146: $this->renderRow();
147:
148: ++$this->_renderedRowsCount;
149: }
150: } finally {
151: $this->model = $modelBackup;
152: $this->tRow = $tRowBackup;
153: }
154:
155: // empty message
156: if ($this->_renderedRowsCount === 0) {
157: if (!$this->jsPaginator || !$this->jsPaginator->getPage()) {
158: $empty = $this->tEmpty !== null ? $this->tEmpty->renderToHtml() : '';
159: if ($this->template->hasTag('rows')) {
160: $this->template->dangerouslyAppendHtml('rows', $empty);
161: } else {
162: $this->template->dangerouslyAppendHtml('_top', $empty);
163: }
164: }
165: }
166:
167: // stop JsPaginator if there are no more records to fetch
168: if ($this->jsPaginator && ($this->_renderedRowsCount < $this->ipp)) {
169: $this->jsPaginator->jsIdle();
170: }
171:
172: parent::renderView();
173: }
174:
175: /**
176: * Render individual row. Override this method if you want to do more
177: * decoration.
178: */
179: public function renderRow(): void
180: {
181: $this->tRow->trySet($this->currentRow);
182:
183: if ($this->tRow->hasTag('_title')) {
184: $this->tRow->set('_title', $this->model->getTitle());
185: }
186: if ($this->tRow->hasTag('_href')) {
187: $this->tRow->set('_href', $this->url(['id' => $this->currentRow->getId()]));
188: }
189: $this->tRow->trySet('_id', $this->name . '-' . $this->currentRow->getId());
190:
191: $html = $this->tRow->renderToHtml();
192: if ($this->template->hasTag('rows')) {
193: $this->template->dangerouslyAppendHtml('rows', $html);
194: } else {
195: $this->template->dangerouslyAppendHtml('_top', $html);
196: }
197: }
198:
199: /**
200: * Hack - override parent method with region render only support.
201: *
202: * TODO this hack/method must be removed as rendering HTML only partially but with all JS
203: * is wrong by design. Each table row should be probably rendered natively using cloned
204: * render tree (instead of cloned template).
205: */
206: #[\Override]
207: public function renderToJsonArr(string $region = null): array
208: {
209: $this->renderAll();
210:
211: // https://github.com/atk4/ui/issues/1932
212: if ($region !== null) {
213: if (!isset($this->_jsActions['click'])) {
214: $this->_jsActions['click'] = [];
215: }
216: array_unshift($this->_jsActions['click'], $this->js()->off());
217: }
218:
219: return [
220: 'success' => true,
221: 'atkjs' => $this->getJs(),
222: 'html' => $this->template->renderToHtml($region),
223: 'id' => $this->name,
224: ];
225: }
226: }
227: