diff --git a/library/Icingadb/Web/Controller.php b/library/Icingadb/Web/Controller.php index e72b0eca..8be56209 100644 --- a/library/Icingadb/Web/Controller.php +++ b/library/Icingadb/Web/Controller.php @@ -22,7 +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\Icingadb\Widget\ItemTable\StateItemTable; use Icinga\Module\Pdfexport\PrintableHtmlDocument; use Icinga\Module\Pdfexport\ProvidedHook\Pdfexport; use Icinga\Security\SecurityException; @@ -513,7 +513,7 @@ class Controller extends CompatController { if ($content instanceof BaseItemList) { $this->content->getAttributes()->add('class', 'full-width'); - } elseif ($content instanceof BaseItemTable) { + } elseif ($content instanceof StateItemTable) { $this->content->getAttributes()->add('class', 'full-height'); } diff --git a/library/Icingadb/Widget/ItemTable/BaseItemTable.php b/library/Icingadb/Widget/ItemTable/BaseItemTable.php deleted file mode 100644 index d8fd85b6..00000000 --- a/library/Icingadb/Widget/ItemTable/BaseItemTable.php +++ /dev/null @@ -1,198 +0,0 @@ - '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/BaseStateRowItem.php similarity index 89% rename from library/Icingadb/Widget/ItemTable/BaseRowItem.php rename to library/Icingadb/Widget/ItemTable/BaseStateRowItem.php index 01896199..642d6b33 100644 --- a/library/Icingadb/Widget/ItemTable/BaseRowItem.php +++ b/library/Icingadb/Widget/ItemTable/BaseStateRowItem.php @@ -9,14 +9,15 @@ use ipl\Html\BaseHtmlElement; use ipl\Html\HtmlElement; use ipl\Orm\Model; -abstract class BaseRowItem extends BaseHtmlElement +/** @todo Figure out what this might (should) have in common with the new BaseTableRowItem implementation */ +abstract class BaseStateRowItem extends BaseHtmlElement { protected $defaultAttributes = ['class' => 'row-item']; /** @var Model */ protected $item; - /** @var BaseItemTable */ + /** @var StateItemTable */ protected $list; protected $tag = 'tr'; @@ -25,9 +26,9 @@ abstract class BaseRowItem extends BaseHtmlElement * Create a new row item * * @param Model $item - * @param BaseItemTable $list + * @param StateItemTable $list */ - public function __construct(Model $item, BaseItemTable $list) + public function __construct(Model $item, StateItemTable $list) { $this->item = $item; $this->list = $list; diff --git a/library/Icingadb/Widget/ItemTable/StateItemTable.php b/library/Icingadb/Widget/ItemTable/StateItemTable.php index 5f9b38a5..980c8593 100644 --- a/library/Icingadb/Widget/ItemTable/StateItemTable.php +++ b/library/Icingadb/Widget/ItemTable/StateItemTable.php @@ -4,11 +4,89 @@ namespace Icinga\Module\Icingadb\Widget\ItemTable; +use Icinga\Module\Icingadb\Widget\EmptyState; +use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; +use ipl\Html\Form; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Orm\Common\SortUtil; +use ipl\Orm\Query; +use ipl\Web\Control\SortControl; use ipl\Web\Widget\Icon; -abstract class StateItemTable extends BaseItemTable +/** @todo Figure out what this might (should) have in common with the new BaseItemTable implementation */ +abstract class StateItemTable extends BaseHtmlElement { + protected $baseAttributes = [ + 'class' => 'state-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; + protected function getVisualLabel() { return new Icon('heartbeat', ['title' => t('Severity')]); @@ -16,7 +94,53 @@ abstract class StateItemTable extends BaseItemTable protected function assembleColumnHeader(BaseHtmlElement $header, string $name, $label): void { - parent::assembleColumnHeader($header, $name, $label); + $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); switch (true) { case substr($name, -7) === '.output': @@ -32,4 +156,61 @@ abstract class StateItemTable extends BaseItemTable break; } } + + 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/StateRowItem.php b/library/Icingadb/Widget/ItemTable/StateRowItem.php index 32632219..4bbd0f2c 100644 --- a/library/Icingadb/Widget/ItemTable/StateRowItem.php +++ b/library/Icingadb/Widget/ItemTable/StateRowItem.php @@ -24,7 +24,7 @@ use ipl\Web\Widget\StateBall; use ipl\Web\Widget\TimeSince; use ipl\Web\Widget\TimeUntil; -abstract class StateRowItem extends BaseRowItem +abstract class StateRowItem extends BaseStateRowItem { /** @var StateItemTable */ protected $list; diff --git a/public/css/list/item-table.less b/public/css/list/item-table.less deleted file mode 100644 index c3074160..00000000 --- a/public/css/list/item-table.less +++ /dev/null @@ -1,176 +0,0 @@ -// Style - -.item-table { - padding: 0; - - 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), - .row-item:not(:last-child) { - border-bottom: 1px solid @gray-light; - } -} - -@media print { - .list-item.page-break-follows { - &:not(:last-child) > *:not(.visual) { - border-bottom: none; - } - } -} - -// 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; - } - - .list-item > .col { - display: table-cell; - vertical-align: middle; - white-space: nowrap; - - &:not(:last-child) { - padding-right: 1em; - } - - &.title { - .text-ellipsis(); - width: 100%; - } - - > * { - display: inline-block; - - &:not(:last-child) { - margin-right: .5em; - } - } - } - - .list-item > *:not(.visual) { - padding: .5em 0; - } - - .list-item > .visual { - display: table-cell; - padding: .5em 1em 0 0; - } - - > .empty-state, - > tbody > tr:first-child .empty-state { - margin: 0 1em; - padding: 1em; - text-align: center; - } -} - -.content.full-width .item-table .list-item { - // The .list-item itself can't have padding because of `display:table-row` - &:before, &:after { - display: inline-block; - content: '\00a0'; - width: 1em; - } -} - -#layout.twocols table.item-table { - > thead > tr > th, - > tbody > tr > td { - &:nth-child(n+6) { - display: none; - width: 0; - } - } -} diff --git a/public/css/list/state-item-table.less b/public/css/list/state-item-table.less index aece1484..be9c8c89 100644 --- a/public/css/list/state-item-table.less +++ b/public/css/list/state-item-table.less @@ -1,5 +1,180 @@ +// Style + +.state-item-table { + padding: 0; + + 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), + .row-item:not(:last-child) { + border-bottom: 1px solid @gray-light; + } +} + +@media print { + .list-item.page-break-follows { + &:not(:last-child) > *:not(.visual) { + border-bottom: none; + } + } +} + // Layout +table.state-item-table { + table-layout: fixed; +} + +.state-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; + } + + .list-item > .col { + display: table-cell; + vertical-align: middle; + white-space: nowrap; + + &:not(:last-child) { + padding-right: 1em; + } + + &.title { + .text-ellipsis(); + width: 100%; + } + + > * { + display: inline-block; + + &:not(:last-child) { + margin-right: .5em; + } + } + } + + .list-item > *:not(.visual) { + padding: .5em 0; + } + + .list-item > .visual { + display: table-cell; + padding: .5em 1em 0 0; + } + + > .empty-state, + > tbody > tr:first-child .empty-state { + margin: 0 1em; + padding: 1em; + text-align: center; + } +} + +.content.full-width .state-item-table .list-item { + // The .list-item itself can't have padding because of `display:table-row` + &:before, &:after { + display: inline-block; + content: '\00a0'; + width: 1em; + } +} + +#layout.twocols table.state-item-table { + > thead > tr > th, + > tbody > tr > td { + &:nth-child(n+6) { + display: none; + width: 0; + } + } +} + #layout.wide-layout .item-table th.has-plugin-output { width: 50em; }