1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui\Table;
6:
7: use Atk4\Data\Field;
8: use Atk4\Data\Model;
9: use Atk4\Ui\Js\Jquery;
10: use Atk4\Ui\Js\JsExpression;
11: use Atk4\Ui\Js\JsExpressionable;
12: use Atk4\Ui\JsCallback;
13: use Atk4\Ui\Popup;
14: use Atk4\Ui\Table;
15: use Atk4\Ui\View;
16:
17: /**
18: * Implements Column helper for table.
19: *
20: * @method Table getOwner()
21: */
22: class Column
23: {
24: use \Atk4\Core\AppScopeTrait;
25: use \Atk4\Core\DiContainerTrait;
26: use \Atk4\Core\InitializerTrait;
27: use \Atk4\Core\NameTrait;
28: use \Atk4\Core\TrackableTrait;
29:
30: public const HOOK_GET_HTML_TAGS = self::class . '@getHtmlTags';
31: public const HOOK_GET_HEADER_CELL_HTML = self::class . '@getHeaderCellHtml';
32:
33: /** @var Table Link back to the table, where column is used. */
34: public $table;
35:
36: /** Contains any custom attributes that may be applied on head, body or foot. */
37: public array $attr = [];
38:
39: /** @var string|null If set, will override column header value. */
40: public $caption;
41:
42: /** @var bool Is column sortable? */
43: public $sortable = true;
44:
45: /** @var string|null The data-column attribute value for Table th tag. */
46: public $columnData;
47:
48: /** @var bool Include header action tag in rendering or not. */
49: public $hasHeaderAction = false;
50:
51: /** @var array|null The tag value required for getTag when using an header action. */
52: public $headerActionTag;
53:
54: public function __construct(array $defaults = [])
55: {
56: $this->setDefaults($defaults);
57: }
58:
59: /**
60: * Add popup to header.
61: * Use ColumnName for better popup positioning.
62: *
63: * @param string $icon CSS class for filter icon
64: *
65: * @return mixed
66: */
67: public function addPopup(Popup $popup = null, $icon = 'table-filter-off')
68: {
69: $id = $this->name . '_ac';
70:
71: $popup = $this->table->getOwner()->add($popup ?? [Popup::class])->setHoverable();
72:
73: $this->setHeaderPopup($icon, $id);
74:
75: $popup->triggerBy = '#' . $id;
76: $popup->popOptions = array_merge(
77: $popup->popOptions,
78: [
79: 'on' => 'click',
80: 'position' => 'bottom left',
81: 'movePopup' => $this->columnData ? true : false,
82: 'target' => $this->columnData ? 'th[data-column=' . $this->columnData . ']' : false,
83: 'distanceAway' => -12,
84: ]
85: );
86: $popup->stopClickEvent = true;
87:
88: return $popup;
89: }
90:
91: /**
92: * Setup popup header action.
93: *
94: * @param string $class the CSS class for filter icon
95: * @param string $id
96: */
97: public function setHeaderPopup($class, $id): void
98: {
99: $this->hasHeaderAction = true;
100:
101: $this->headerActionTag = ['div', ['class' => 'atk-table-dropdown'],
102: [
103: ['i', ['id' => $id, 'class' => $class . ' icon'], ''],
104: ],
105: ];
106: }
107:
108: /**
109: * Set header popup icon.
110: *
111: * @param string $icon
112: */
113: public function setHeaderPopupIcon($icon): void
114: {
115: $this->headerActionTag = ['div', ['class' => 'atk-table-dropdown'],
116: [
117: ['i', ['id' => $this->name . '_ac', 'class' => $icon . ' icon'], ''],
118: ],
119: ];
120: }
121:
122: /**
123: * Add a dropdown header menu.
124: *
125: * @param \Closure(string, string): (JsExpressionable|View|string|void) $fx
126: * @param string $icon
127: * @param string|null $menuId the menu name
128: */
129: public function addDropdown(array $items, \Closure $fx, $icon = 'caret square down', $menuId = null): void
130: {
131: $menuItems = [];
132: foreach ($items as $key => $item) {
133: $menuItems[] = ['name' => is_int($key) ? $item : $key, 'value' => $item];
134: }
135:
136: $cb = $this->setHeaderDropdown($menuItems, $icon, $menuId);
137:
138: $cb->onSelectItem(static function (string $menu, string $item) use ($fx) {
139: return $fx($item, $menu);
140: });
141: }
142:
143: /**
144: * Setup dropdown header action.
145: * This method return a callback where you can detect
146: * menu item change via $cb->onMenuItem($item) function.
147: *
148: * @param array<int, array> $items
149: *
150: * @return Column\JsHeaderDropdownCallback
151: */
152: public function setHeaderDropdown($items, string $icon = 'caret square down', string $menuId = null): JsCallback
153: {
154: $this->hasHeaderAction = true;
155: $id = $this->name . '_ac';
156: $this->headerActionTag = ['div', ['class' => 'atk-table-dropdown'], [
157: [
158: 'div', ['id' => $id, 'class' => 'ui top left pointing dropdown', 'data-menu-id' => $menuId],
159: [['i', ['class' => $icon . ' icon'], '']],
160: ],
161: ]];
162:
163: $cb = Column\JsHeaderDropdownCallback::addTo($this->table);
164:
165: $function = new JsExpression('function (value, text, item) {
166: if (value === undefined || value === \'\' || value === null) {
167: return;
168: }
169: $(this).api({
170: on: \'now\',
171: url: \'' . $cb->getJsUrl() . '\',
172: data: { item: value, id: $(this).data(\'menu-id\') }
173: });
174: }');
175:
176: $chain = new Jquery('#' . $id);
177: $chain->dropdown([
178: 'action' => 'hide',
179: 'values' => $items,
180: 'onChange' => $function,
181: ]);
182:
183: // will stop grid column from being sorted
184: $chain->on('click', new JsExpression('function (e) { e.stopPropagation(); }'));
185:
186: $this->table->js(true, $chain);
187:
188: return $cb;
189: }
190:
191: /**
192: * Adds a new class to the cells of this column. The optional second argument may be "head",
193: * "body" or "foot". If position is not defined, then class will be applied on all cells.
194: *
195: * @param string $class
196: * @param string $position
197: *
198: * @return $this
199: */
200: public function addClass($class, $position = 'body')
201: {
202: $this->attr[$position]['class'][] = $class;
203:
204: return $this;
205: }
206:
207: /**
208: * Adds a new attribute to the cells of this column. The optional second argument may be "head",
209: * "body" or "foot". If position is not defined, then attribute will be applied on all cells.
210: *
211: * You can also use the "{$name}" value if you wish to specific row value:
212: *
213: * $table->column['name']->setAttr('data', '{$id}');
214: *
215: * @param string $attr
216: * @param string $value
217: * @param string $position
218: *
219: * @return $this
220: */
221: public function setAttr($attr, $value, $position = 'body')
222: {
223: $this->attr[$position][$attr] = $value;
224:
225: return $this;
226: }
227:
228: public function getTagAttributes(string $position, array $attr = []): array
229: {
230: // "all" applies on all positions
231: // $position is for specific position classes
232: foreach (['all', $position] as $key) {
233: if (isset($this->attr[$key])) {
234: $attr = array_merge_recursive($attr, $this->attr[$key]);
235: }
236: }
237:
238: return $attr;
239: }
240:
241: /**
242: * Returns a suitable cell tag with the supplied value. Applies modifiers
243: * added through addClass and setAttr.
244: *
245: * @param string $position 'head', 'body' or 'tail'
246: * @param string|array<int, array{0: string, 1?: array<0|string, string|bool>, 2?: string|array|null}|string>|null $value either HTML or array defining HTML structure, see App::getTag help
247: * @param array<string, string|bool|array> $attr extra attributes to apply on the tag
248: */
249: public function getTag(string $position, $value, array $attr = []): string
250: {
251: $attr = $this->getTagAttributes($position, $attr);
252:
253: if (isset($attr['class'])) {
254: $attr['class'] = implode(' ', $attr['class']);
255: }
256:
257: return $this->getApp()->getTag($position === 'body' ? 'td' : 'th', $attr, $value);
258: }
259:
260: /**
261: * Provided with a field definition (from a model) will return a header
262: * cell, fully formatted to be included in a Table. (<th>).
263: *
264: * @param mixed $value
265: */
266: public function getHeaderCellHtml(Field $field = null, $value = null): string
267: {
268: $tags = $this->table->hook(self::HOOK_GET_HEADER_CELL_HTML, [$this, $field, $value]);
269: if ($tags) {
270: return reset($tags);
271: }
272:
273: if ($field === null) {
274: return $this->getTag('head', $this->caption ?? '', $this->table->sortable ? ['class' => ['disabled']] : []);
275: }
276:
277: // if $this->caption is empty, header caption will be overridden by linked field definition
278: $caption = $this->caption ?? $field->getCaption();
279:
280: $attr = [
281: 'data-column' => $this->columnData,
282: ];
283:
284: $class = 'atk-table-column-header';
285:
286: if ($this->hasHeaderAction) {
287: $attr['id'] = $this->name . '_th';
288:
289: // add the action tag to the caption
290: $caption = [$this->headerActionTag, $this->getApp()->encodeHtml($caption)];
291: }
292:
293: if ($this->table->sortable) {
294: $attr['data-sort'] = $field->shortName;
295:
296: if ($this->sortable) {
297: $attr['class'] = ['sortable'];
298: }
299:
300: // if table is being sorted by THIS column, set the proper class
301: if ($this->table->sortBy === $field->shortName) {
302: $class .= ' sorted ' . ['asc' => 'ascending', 'desc' => 'descending'][$this->table->sortDirection];
303:
304: if ($this->table->sortDirection === 'asc') {
305: $attr['data-sort'] = '-' . $attr['data-sort'];
306: } elseif ($this->table->sortDirection === 'desc') {
307: $attr['data-sort'] = '';
308: }
309: }
310: }
311:
312: return $this->getTag('head', [['div', ['class' => $class], $caption]], $attr);
313: }
314:
315: /**
316: * Return HTML for a total value of a specific field.
317: *
318: * @param mixed $value
319: */
320: public function getTotalsCellHtml(Field $field, $value): string
321: {
322: return $this->getTag('foot', $this->getApp()->uiPersistence->typecastSaveField($field, $value));
323: }
324:
325: /**
326: * Provided with a field definition will return a string containing a "Template"
327: * that would produce <td> cell when rendered. Example output:.
328: *
329: * <td><b>{$name}</b></td>
330: *
331: * The must correspond to the name of the field, although you can also use multiple tags. The tag
332: * will also be formatted before inserting, see UI Persistence formatting in the documentation.
333: *
334: * This method will be executed only once per table rendering, if you need to format data manually,
335: * you should use $this->table->onHook('beforeRow' or 'afterRow', ...);
336: */
337: public function getDataCellHtml(Field $field = null, array $attr = []): string
338: {
339: return $this->getTag('body', [$this->getDataCellTemplate($field)], $attr);
340: }
341:
342: /**
343: * Provided with a field definition will return a string containing a "Template"
344: * that would produce CONTENTS OF <td> cell when rendered. Example output:.
345: *
346: * <b>{$name}</b>
347: *
348: * The tag that corresponds to the name of the field (e.g. {$name}) may be substituted
349: * by another template returned by getDataCellTemplate when multiple formatters are
350: * applied to the same column. The first one to be applied is executed first, then
351: * a subsequent ones are executed.
352: */
353: public function getDataCellTemplate(Field $field = null): string
354: {
355: if ($field) {
356: return '{$' . $field->shortName . '}';
357: }
358:
359: return '{_$' . $this->shortName . '}';
360: }
361:
362: /**
363: * Return associative array of tags to be filled with pre-rendered HTML on
364: * a column-basis. Will not be invoked if HTML output is turned off for the table.
365: *
366: * @return array<string, string>
367: */
368: public function getHtmlTags(Model $row, ?Field $field): array
369: {
370: return [];
371: }
372: }
373: