src/Table/Column.php
<?php
declare(strict_types=1);
namespace Atk4\Ui\Table;
use Atk4\Core\AppScopeTrait;
use Atk4\Core\DiContainerTrait;
use Atk4\Core\InitializerTrait;
use Atk4\Core\NameTrait;
use Atk4\Core\TrackableTrait;
use Atk4\Data\Field;
use Atk4\Data\Model;
use Atk4\Ui\Exception;
use Atk4\Ui\Js\Jquery;
use Atk4\Ui\Js\JsExpression;
use Atk4\Ui\Js\JsExpressionable;
use Atk4\Ui\JsCallback;
use Atk4\Ui\Popup;
use Atk4\Ui\Table;
use Atk4\Ui\View;
/**
* Implements Column helper for table.
*
* @method Table getOwner()
*/
class Column
{
use AppScopeTrait;
use DiContainerTrait;
use InitializerTrait;
use NameTrait;
use TrackableTrait;
public const HOOK_GET_HTML_TAGS = self::class . '@getHtmlTags';
public const HOOK_GET_HEADER_CELL_HTML = self::class . '@getHeaderCellHtml';
/** @var Table Link back to the table, where column is used. */
public $table;
/** @var array<'head'|'body'|'foot'|'all', array<string, string|list<string>>> Contains any custom attributes that may be applied on head, body or foot. */
public array $attr = [];
/** @var string|null If set, will override column header value. */
public $caption;
/** @var bool Is column sortable? */
public $sortable = true;
/** @var string|null The data-column attribute value for Table th tag. */
public $columnData;
/** @var bool Include header action tag in rendering or not. */
public $hasHeaderAction = false;
/** @var array<0|1|2, mixed>|null The tag value required for getTag when using an header action. */
public $headerActionTag;
private string $nameInTableCache;
/**
* @param array<string, mixed> $defaults
*/
public function __construct(array $defaults = [])
{
$this->setDefaults($defaults);
}
/**
* Cloning View is unwanted as some references might be not cloned/set as expected,
* remove this method once https://github.com/atk4/ui/issues/1365 is implemented.
*
* @internal
*/
protected function assertColumnViewNotInitialized(View $view): void
{
if ($view->isInitialized() || ($view->name ?? null) !== null) {
throw (new Exception('Unexpected initialized View instance'))
->addMoreInfo('view', $view);
}
}
/**
* @template T of View
*
* @param T $view
*
* @return T
*
* @internal
*/
protected function cloneColumnView(View $view, Model $row, string $nameSuffix): View
{
$this->assertColumnViewNotInitialized($view);
$cloneViewWithAddLaterFx = static function (View $view) use (&$cloneViewWithAddLaterFx) {
$view = clone $view;
\Closure::bind(static function () use ($view, $cloneViewWithAddLaterFx) {
foreach ($view->_addLater as $k => [$obj]) {
$view->_addLater[$k][0] = $cloneViewWithAddLaterFx($obj); // @phpstan-ignore argument.type
}
}, null, View::class)();
return $view;
};
if (!isset($this->nameInTableCache)) {
foreach ($this->table->columns as $n => $columns) {
foreach (is_array($columns) ? $columns : [$columns] as $k => $column) {
if ($this === $column) {
$this->nameInTableCache = $n . '_' . $k;
break;
}
}
}
}
$view = $cloneViewWithAddLaterFx($view);
$view->shortName = 'c' . $this->nameInTableCache . '_' . $nameSuffix . '_r'
. $this->getApp()->uiPersistence->typecastAttributeSaveField($row->getIdField(), $row->getId());
$view->name = \Closure::bind(static fn (Table $table) => $view->_shorten($table->name, $view->shortName, null), null, Table::class)($this->table);
return $view;
}
/**
* Add popup to header.
* Use ColumnName for better popup positioning.
*
* @param string $icon CSS class for filter icon
*
* @return mixed
*/
public function addPopup(?Popup $popup = null, $icon = 'table-filter-off')
{
$id = $this->name . '_ac';
$popup = $this->table->getOwner()->add($popup ?? [Popup::class])->setHoverable();
$this->setHeaderPopup($icon, $id);
$popup->triggerBy = '#' . $id;
$popup->popOptions = array_merge(
$popup->popOptions,
[
'on' => 'click',
'position' => 'bottom left',
'movePopup' => $this->columnData ? true : false,
'target' => $this->columnData ? 'th[data-column=' . $this->columnData . ']' : false,
'distanceAway' => -12,
]
);
$popup->stopClickEvent = true;
return $popup;
}
/**
* Setup popup header action.
*
* @param string $class the CSS class for filter icon
* @param string $id
*/
public function setHeaderPopup($class, $id): void
{
$this->hasHeaderAction = true;
$this->headerActionTag = ['div', ['class' => 'atk-table-dropdown'],
[
['i', ['id' => $id, 'class' => $class . ' icon'], ''],
],
];
}
/**
* Set header popup icon.
*
* @param string $icon
*/
public function setHeaderPopupIcon($icon): void
{
$this->headerActionTag = ['div', ['class' => 'atk-table-dropdown'],
[
['i', ['id' => $this->name . '_ac', 'class' => $icon . ' icon'], ''],
],
];
}
/**
* Add a dropdown header menu.
*
* @param array<int|string, string> $items
* @param \Closure(string, string): (JsExpressionable|View|string|void) $fx
* @param string $icon
* @param string|null $menuId the menu name
*/
public function addDropdown(array $items, \Closure $fx, $icon = 'caret square down', $menuId = null): void
{
$menuItems = [];
foreach ($items as $key => $item) {
$menuItems[] = ['name' => is_int($key) ? $item : $key, 'value' => $item];
}
$cb = $this->setHeaderDropdown($menuItems, $icon, $menuId);
$cb->onSelectItem(static function (string $menu, string $item) use ($fx) {
return $fx($item, $menu);
});
}
/**
* Setup dropdown header action.
* This method return a callback where you can detect
* menu item change via $cb->onMenuItem($item) function.
*
* @param list<array{name: string, value: string}> $items
*
* @return Column\JsHeaderDropdownCallback
*/
public function setHeaderDropdown(array $items, string $icon = 'caret square down', ?string $menuId = null): JsCallback
{
$this->hasHeaderAction = true;
$id = $this->name . '_ac';
$this->headerActionTag = ['div', ['class' => 'atk-table-dropdown'], [
[
'div', ['id' => $id, 'class' => 'ui top left pointing dropdown', 'data-menu-id' => $menuId],
[['i', ['class' => $icon . ' icon'], '']],
],
]];
$cb = Column\JsHeaderDropdownCallback::addTo($this->table);
$function = new JsExpression('function (value, text, item) {
if (value === undefined || value === \'\' || value === null) {
return;
}
$(this).api({
on: \'now\',
url: \'' . $cb->getJsUrl() . '\',
data: { item: value, id: $(this).data(\'menu-id\') }
});
}');
$chain = new Jquery('#' . $id);
$chain->dropdown([
'action' => 'hide',
'values' => $items,
'onChange' => $function,
]);
// will stop grid column from being sorted
$chain->on('click', new JsExpression('function (e) { e.stopPropagation(); }'));
$this->table->js(true, $chain);
return $cb;
}
/**
* Adds a new class to the cells of this column.
*
* @param string $class
* @param 'head'|'body'|'foot'|'all' $position
*
* @return $this
*/
public function addClass($class, $position = 'body')
{
$this->attr[$position]['class'][] = $class;
return $this;
}
/**
* Adds a new attribute to the cells of this column.
*
* You can also use the "{$name}" value if you wish to specific row value:
*
* $table->column['name']->setAttr('data', '{$id}');
*
* @param string $attr
* @param string $value
* @param 'head'|'body'|'foot'|'all' $position
*
* @return $this
*/
public function setAttr($attr, $value, $position = 'body')
{
$this->attr[$position][$attr] = $value;
return $this;
}
/**
* @param array<string, string|list<string>> ...$attributesArr
*
* @return array<string, string|list<string>>
*/
protected function mergeTagAttributes(array ...$attributesArr): array
{
$res = [];
foreach ($attributesArr as $attributes) {
foreach ($attributes as $k => $v) {
if (is_string($v)) {
$res[$k] = $v;
} else {
$res[$k] = array_merge($res[$k] ?? [], $v);
}
}
}
return $res;
}
/**
* @param 'head'|'body'|'foot' $position
* @param array<string, string|list<string>> $attr
*
* @return array<string, string|list<string>>
*/
public function getTagAttributes(string $position, array $attr = []): array
{
return $this->mergeTagAttributes(
$this->attr['all'] ?? [],
$this->attr[$position] ?? [],
$attr
);
}
/**
* Returns a suitable cell tag with the supplied value. Applies modifiers
* added through addClass and setAttr.
*
* @param 'head'|'body'|'foot' $position
* @param array<string, string|bool|list<string>> $attr
* @param string|list<array{0: string, 1?: array<0|string, string|bool>, 2?: string|list<mixed>|null}|string>|null $value
*/
public function getTag(string $position, $attr, $value): string
{
$attr = $this->getTagAttributes($position, $attr);
if (isset($attr['class'])) {
$attr['class'] = implode(' ', $attr['class']);
}
return $this->getApp()->getTag($position === 'body' ? 'td' : 'th', $attr, $value);
}
/**
* Provided with a field definition (from a model) will return a header
* cell, fully formatted to be included in a Table. (<th>).
*
* @param mixed $value
*/
public function getHeaderCellHtml(?Field $field = null, $value = null): string
{
$tags = $this->table->hook(self::HOOK_GET_HEADER_CELL_HTML, [$this, $field, $value]);
if ($tags) {
return reset($tags);
}
if ($field === null) {
return $this->getTag('head', $this->table->sortable ? ['class' => ['disabled']] : [], $this->caption ?? '');
}
// if $this->caption is empty, header caption will be overridden by linked field definition
$caption = $this->caption ?? $field->getCaption();
$attr = [
'data-column' => $this->columnData,
];
$class = 'atk-table-column-header';
if ($this->hasHeaderAction) {
$attr['id'] = $this->name . '_th';
// add the action tag to the caption
$caption = [$this->headerActionTag, $this->getApp()->encodeHtml($caption)];
}
if ($this->table->sortable) {
$attr['data-sort'] = $field->shortName;
if ($this->sortable) {
$attr['class'] = ['sortable'];
}
// if table is being sorted by THIS column, set the proper class
if ($this->table->sortBy === $field->shortName) {
$class .= ' sorted ' . ['asc' => 'ascending', 'desc' => 'descending'][$this->table->sortDirection];
if ($this->table->sortDirection === 'asc') {
$attr['data-sort'] = '-' . $attr['data-sort'];
} elseif ($this->table->sortDirection === 'desc') {
$attr['data-sort'] = '';
}
}
}
return $this->getTag('head', $attr, [['div', ['class' => $class], $caption]]);
}
/**
* Return HTML for a total value of a specific field.
*
* @param int|float $value
*/
public function getTotalsCellHtml(Field $field, $value): string
{
return $this->getTag('foot', [], $this->getApp()->uiPersistence->typecastSaveField($field, $value));
}
/**
* Provided with a field definition will return a string containing a "Template"
* that would produce <td> cell when rendered. Example output:.
*
* <td><b>{$name}</b></td>
*
* The must correspond to the name of the field, although you can also use multiple tags. The tag
* will also be formatted before inserting, see UI Persistence formatting in the documentation.
*
* If you need to format data manually, you can use $this->table->onHook(Lister::HOOK_BEFORE_ROW or Lister::HOOK_AFTER_ROW, ...);
*
* @param array<string, string|list<string>> $attr
*/
public function getDataCellHtml(?Field $field = null, array $attr = []): string
{
return $this->getTag('body', $attr, [$this->getDataCellTemplate($field)]);
}
/**
* Provided with a field definition will return a string containing a "Template"
* that would produce CONTENTS OF <td> cell when rendered. Example output:.
*
* <b>{$name}</b>
*
* The tag that corresponds to the name of the field (e.g. {$name}) may be substituted
* by another template returned by getDataCellTemplate when multiple formatters are
* applied to the same column. The first one to be applied is executed first, then
* a subsequent ones are executed.
*/
public function getDataCellTemplate(?Field $field = null): string
{
if ($field) {
return '{$' . $field->shortName . '}';
}
return '{_$' . $this->shortName . '}';
}
/**
* Return associative array of tags to be filled with pre-rendered HTML on
* a column-basis. Will not be invoked if HTML output is turned off for the table.
*
* @return array<string, string>
*/
public function getHtmlTags(Model $row, ?Field $field): array
{
return [];
}
}