diff --git a/library/Icingadb/Web/Controller.php b/library/Icingadb/Web/Controller.php index 83ad365d..1bf51044 100644 --- a/library/Icingadb/Web/Controller.php +++ b/library/Icingadb/Web/Controller.php @@ -22,6 +22,7 @@ use Icinga\Module\Icingadb\Common\SearchControls; use Icinga\Module\Icingadb\Data\CsvResultSet; use Icinga\Module\Icingadb\Data\JsonResultSet; use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher; +use Icinga\Module\Icingadb\Widget\ItemTable\BaseItemTable; use Icinga\Module\Pdfexport\PrintableHtmlDocument; use Icinga\Module\Pdfexport\ProvidedHook\Pdfexport; use Icinga\Security\SecurityException; @@ -494,6 +495,8 @@ class Controller extends CompatController { if ($content instanceof BaseItemList) { $this->content->getAttributes()->add('class', 'full-width'); + } elseif ($content instanceof BaseItemTable) { + $this->content->getAttributes()->add('class', 'full-height'); } return parent::addContent($content); diff --git a/library/Icingadb/Widget/ItemTable/BaseItemTable.php b/library/Icingadb/Widget/ItemTable/BaseItemTable.php new file mode 100644 index 00000000..d8fd85b6 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/BaseItemTable.php @@ -0,0 +1,198 @@ + 'item-table' + ]; + + /** @var array The columns to render */ + protected $columns; + + /** @var iterable The datasource */ + protected $data; + + /** @var string The sort rules */ + protected $sort; + + protected $tag = 'table'; + + /** + * Create a new item table + * + * @param iterable $data Datasource of the table + * @param array $columns The columns to render, keys are labels + */ + public function __construct(iterable $data, array $columns) + { + $this->data = $data; + $this->columns = array_flip($columns); + + $this->addAttributes($this->baseAttributes); + + $this->init(); + } + + /** + * Initialize the item table + * + * If you want to adjust the item table after construction, override this method. + */ + protected function init() + { + } + + /** + * Get the columns being rendered + * + * @return array + */ + public function getColumns(): array + { + return $this->columns; + } + + /** + * Set sort rules (as returned by {@see SortControl::getSort()}) + * + * @param ?string $sort + * + * @return $this + */ + public function setSort(?string $sort): self + { + $this->sort = $sort; + + return $this; + } + + abstract protected function getItemClass(): string; + + abstract protected function getVisualColumn(): string; + + abstract protected function getVisualLabel(); + + protected function assembleColumnHeader(BaseHtmlElement $header, string $name, $label): void + { + $sortRules = []; + if ($this->sort !== null) { + $sortRules = SortUtil::createOrderBy($this->sort); + } + + $active = false; + $sortDirection = null; + foreach ($sortRules as $rule) { + if ($rule[0] === $name) { + $sortDirection = $rule[1]; + $active = true; + break; + } + } + + if ($sortDirection === 'desc') { + $value = "$name asc"; + } else { + $value = "$name desc"; + } + + $icon = 'sort'; + if ($active) { + $icon = $sortDirection === 'desc' ? 'sort-up' : 'sort-down'; + } + + $form = new Form(); + $form->setAttribute('method', 'GET'); + + $button = $form->createElement('button', 'sort', [ + 'value' => $value, + 'type' => 'submit', + 'title' => is_string($label) ? $label : null, + 'class' => $active ? 'active' : null + ]); + $button->addHtml( + Html::tag( + 'span', + null, + // With   to have the height sized the same as the others + $label ?? HtmlString::create(' ') + ), + new Icon($icon) + ); + $form->addElement($button); + + $header->add($form); + } + + protected function assemble() + { + $itemClass = $this->getItemClass(); + + $headerRow = new HtmlElement('tr'); + + $visualCell = new HtmlElement('th', Attributes::create(['class' => 'has-visual'])); + $this->assembleColumnHeader($visualCell, $this->getVisualColumn(), $this->getVisualLabel()); + $headerRow->addHtml($visualCell); + + foreach ($this->columns as $name => $label) { + $headerCell = new HtmlElement('th'); + $this->assembleColumnHeader($headerCell, $name, is_int($label) ? $name : $label); + $headerRow->addHtml($headerCell); + } + + $this->addHtml(new HtmlElement('thead', null, $headerRow)); + + $body = new HtmlElement('tbody', Attributes::create(['data-base-target' => '_next'])); + foreach ($this->data as $item) { + $body->addHtml(new $itemClass($item, $this)); + } + + if ($body->isEmpty()) { + $body->addHtml(new HtmlElement( + 'tr', + null, + new HtmlElement( + 'td', + Attributes::create(['colspan' => count($this->columns)]), + new EmptyState(t('No items found.')) + ) + )); + } + + $this->addHtml($body); + } + + /** + * Enrich the given list of column names with appropriate labels + * + * @param Query $query + * @param array $columns + * + * @return array + */ + public static function applyColumnMetaData(Query $query, array $columns): array + { + $newColumns = []; + foreach ($columns as $columnPath) { + $label = $query->getResolver()->getColumnDefinition($columnPath)->getLabel(); + $newColumns[$label ?? $columnPath] = $columnPath; + } + + return $newColumns; + } +} diff --git a/library/Icingadb/Widget/ItemTable/BaseRowItem.php b/library/Icingadb/Widget/ItemTable/BaseRowItem.php new file mode 100644 index 00000000..01896199 --- /dev/null +++ b/library/Icingadb/Widget/ItemTable/BaseRowItem.php @@ -0,0 +1,106 @@ + 'row-item']; + + /** @var Model */ + protected $item; + + /** @var BaseItemTable */ + protected $list; + + protected $tag = 'tr'; + + /** + * Create a new row item + * + * @param Model $item + * @param BaseItemTable $list + */ + public function __construct(Model $item, BaseItemTable $list) + { + $this->item = $item; + $this->list = $list; + + $this->init(); + } + + /** + * Initialize the row item + * + * If you want to adjust the row item after construction, override this method. + */ + protected function init() + { + } + + abstract protected function assembleVisual(BaseHtmlElement $visual); + + abstract protected function assembleCell(BaseHtmlElement $cell, string $path, $value); + + protected function createVisual(): BaseHtmlElement + { + $visual = new HtmlElement('td', Attributes::create(['class' => 'visual'])); + + $this->assembleVisual($visual); + + return $visual; + } + + protected function assemble() + { + $this->addHtml($this->createVisual()); + + foreach ($this->list->getColumns() as $columnPath => $_) { + $steps = explode('.', $columnPath); + if ($steps[0] === $this->item->getTableName()) { + array_shift($steps); + $columnPath = implode('.', $steps); + } + + $column = null; + $subject = $this->item; + foreach ($steps as $i => $step) { + if (isset($subject->$step)) { + if ($subject->$step instanceof Model) { + $subject = $subject->$step; + } else { + $column = $step; + } + } else { + $columnCandidate = implode('.', array_slice($steps, $i)); + if (isset($subject->$columnCandidate)) { + $column = $columnCandidate; + } else { + break; + } + } + } + + $value = null; + if ($column !== null) { + $value = $subject->$column; + if (is_array($value)) { + $value = empty($value) ? null : implode(',', $value); + } + } + + $cell = new HtmlElement('td'); + if ($value !== null) { + $this->assembleCell($cell, $columnPath, $value); + } + + $this->addHtml($cell); + } + } +} diff --git a/public/css/common.less b/public/css/common.less index ef6c823d..b89a161b 100644 --- a/public/css/common.less +++ b/public/css/common.less @@ -14,6 +14,11 @@ } } +& > .content.full-height { + padding-top: 0; + padding-bottom: 0; +} + .plugin-output { .monospace(); word-break: break-word; @@ -79,6 +84,7 @@ div.show-more { .box-shadow(0, 0, 0, 1px, @gray-lighter); flex-shrink: 0; position: relative; // Required for the host meta info control + z-index: 1; // The content may clip, this ensures the separator is always visible > :not(:only-child) { margin-bottom: .5em; diff --git a/public/css/list/item-table.less b/public/css/list/item-table.less index 1058c57e..c3074160 100644 --- a/public/css/list/item-table.less +++ b/public/css/list/item-table.less @@ -3,15 +3,62 @@ .item-table { padding: 0; - > .empty-state { + thead { + th { + font-weight: normal; + + // Border styles start + form { + padding: 0 0 0 1px; + border-bottom: 1px solid @gray-light; + background: linear-gradient(to top, @gray-light, @body-bg-color); + + button { + background: @body-bg-color; + } + } + &:first-child form { + padding-left: 0; + } + // Border styles end + } + + button { + .appearance(none); + border: none; + background: none; + padding: .1em .5em; + + text-align: left; + color: @text-color-light; + + > .icon { + opacity: 0; + width: 0; + transition: opacity .25s linear, width .25s ease; + } + &:hover .icon, + &:focus .icon, + &.active .icon { + opacity: 1; + width: 1em; + } + + &.active { + font-weight: bold; + } + } + } + + > .empty-state, + > tbody > tr:first-child .empty-state { .rounded-corners(); background-color: @gray-lightest; } - .list-item { - &:not(:last-child) > *:not(.visual) { - border-bottom: 1px solid @gray-light; - } + .list-item:not(:last-child) > *:not(.visual), + .row-item:not(:last-child) { + border-bottom: 1px solid @gray-light; } } @@ -25,11 +72,46 @@ // Layout +table.item-table { + table-layout: fixed; +} + .item-table { display: table; width: 100%; margin: 0; + thead { + position: sticky; + top: 0; + + th { + // That's layout, yes, controls overflow when scrolling + padding: 1em 0 0 0; + background: @body-bg-color; + } + + th button { + width: 100%; + display: inline-flex; + align-items: baseline; + justify-content: space-between; + + span { + .text-ellipsis(); + } + } + } + + th.has-visual { + width: 3em; + } + + tbody td { + .text-ellipsis(); + vertical-align: top; + } + .list-item { display: table-row; } @@ -66,7 +148,8 @@ padding: .5em 1em 0 0; } - > .empty-state { + > .empty-state, + > tbody > tr:first-child .empty-state { margin: 0 1em; padding: 1em; text-align: center; @@ -81,3 +164,13 @@ width: 1em; } } + +#layout.twocols table.item-table { + > thead > tr > th, + > tbody > tr > td { + &:nth-child(n+6) { + display: none; + width: 0; + } + } +}