Introduce new widget base class BaseItemTable

This commit is contained in:
Johannes Meyer 2022-05-23 13:34:48 +02:00
parent b4b1fffe18
commit eee4a81d2e
5 changed files with 412 additions and 6 deletions

View file

@ -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);

View file

@ -0,0 +1,198 @@
<?php
/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
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 BaseItemTable extends BaseHtmlElement
{
protected $baseAttributes = [
'class' => 'item-table'
];
/** @var array<string, string> 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<string, string> $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<string, string>
*/
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 &nbsp; to have the height sized the same as the others
$label ?? HtmlString::create('&nbsp;')
),
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;
}
}

View file

@ -0,0 +1,106 @@
<?php
/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
namespace Icinga\Module\Icingadb\Widget\ItemTable;
use ipl\Html\Attributes;
use ipl\Html\BaseHtmlElement;
use ipl\Html\HtmlElement;
use ipl\Orm\Model;
abstract class BaseRowItem extends BaseHtmlElement
{
protected $defaultAttributes = ['class' => '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);
}
}
}

View file

@ -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;

View file

@ -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;
}
}
}