Introduce TicketLinkObjectList and CommentRenderer

TicketLinkObjectList: This class creates object list with ticket links using TicketLinks trait
CommentRenderer: Defines the rendering rules for Comment object
Cleanup css and unused classes
Adjust comment-popup.less
This commit is contained in:
Sukhwinder Dhillon 2025-03-21 08:52:50 +01:00 committed by Johannes Meyer
parent bfe1681859
commit c55f1dceb8
16 changed files with 254 additions and 307 deletions

View file

@ -10,7 +10,7 @@ use Icinga\Module\Icingadb\Common\Links;
use Icinga\Module\Icingadb\Model\Comment;
use Icinga\Module\Icingadb\Web\Controller;
use Icinga\Module\Icingadb\Widget\Detail\CommentDetail;
use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
use Icinga\Module\Icingadb\Widget\Detail\ObjectHeader;
use ipl\Stdlib\Filter;
use ipl\Web\Url;
@ -49,13 +49,9 @@ class CommentController extends Controller
public function indexAction()
{
$this->addControl((new CommentList([$this->comment]))
->setViewMode('minimal')
->setDetailActionsDisabled()
->setCaptionDisabled()
->setNoSubjectLink());
$this->addControl(new ObjectHeader($this->comment));
$this->addContent((new CommentDetail($this->comment))->setTicketLinkEnabled());
$this->addContent(new CommentDetail($this->comment));
$this->setAutorefreshInterval(10);
}

View file

@ -10,8 +10,8 @@ use Icinga\Module\Icingadb\Forms\Command\Object\DeleteCommentForm;
use Icinga\Module\Icingadb\Model\Comment;
use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
use Icinga\Module\Icingadb\Web\Controller;
use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
use Icinga\Module\Icingadb\Widget\ItemList\ObjectList;
use Icinga\Module\Icingadb\Widget\ShowMore;
use ipl\Web\Control\LimitControl;
use ipl\Web\Control\SortControl;
@ -81,7 +81,12 @@ class CommentsController extends Controller
$results = $comments->execute();
$this->addContent((new CommentList($results))->setViewMode($viewModeSwitcher->getViewMode()));
$this->addContent(
(new ObjectList($results))
->setViewMode($viewModeSwitcher->getViewMode())
->setMultiselectUrl(Links::commentsDetails())
->setDetailUrl(Url::fromPath('icingadb/comment'))
);
if ($compact) {
$this->addContent(
@ -156,7 +161,7 @@ class CommentsController extends Controller
$rs = $comments->execute();
$this->addControl((new CommentList($rs))->setViewMode('minimal'));
$this->addControl((new ObjectList($rs))->setViewMode('minimal'));
$this->addControl(new ShowMore(
$rs,

View file

@ -5,11 +5,12 @@
namespace Icinga\Module\Icingadb\Common;
use Icinga\Application\Hook;
use Icinga\Application\Hook\TicketHook;
trait TicketLinks
{
/** @var bool */
protected $ticketLinkEnabled = false;
protected $ticketLinkEnabled = false; // TODO: Remove once all usages are removed
/**
* Set whether list items should render host and service links
@ -43,11 +44,9 @@ trait TicketLinks
public function createTicketLinks($text): string
{
if (Hook::has('ticket')) {
/** @var TicketHook $tickets */
$tickets = Hook::first('ticket');
}
if ($this->getTicketLinkEnabled() && isset($tickets)) {
/** @var \Icinga\Application\Hook\TicketHook $tickets */
return $tickets->createLinks($text);
}

View file

@ -13,7 +13,6 @@ use Icinga\Module\Icingadb\Util\PerfDataSet;
use Icinga\Module\Icingadb\Util\PluginOutput;
use Icinga\Module\Icingadb\Widget\CheckAttempt;
use Icinga\Module\Icingadb\Widget\IconImage;
use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
use Icinga\Module\Icingadb\Widget\StateChange;
use ipl\Html\Attributes;
@ -25,6 +24,7 @@ use ipl\Html\Text;
use ipl\Html\ValidHtml;
use ipl\I18n\Translation;
use ipl\Web\Common\ItemRenderer;
use ipl\Web\Layout\ItemLayout;
use ipl\Web\Widget\EmptyState;
use ipl\Web\Widget\Icon;
use ipl\Web\Widget\StateBall;
@ -187,16 +187,15 @@ abstract class BaseHostAndServiceRenderer implements ItemRenderer
$comment->host = $item;
}
$comment = (new CommentList([$comment]))
->setNoSubjectLink()
->setObjectLinkDisabled()
->setDetailActionsDisabled();
$statusIcons->addHtml(
new HtmlElement(
'div',
Attributes::create(['class' => 'comment-wrapper']),
new HtmlElement('div', Attributes::create(['class' => 'comment-popup']), $comment),
new HtmlElement(
'div',
Attributes::create(['class' => 'comment-popup']),
new ItemLayout($comment, (new CommentRenderer())->setIsDetailView())
),
(new Icon('comments', ['class' => 'comment-icon']))
)
);

View file

@ -0,0 +1,161 @@
<?php
/* Icinga DB Web | (c) 2025 Icinga GmbH | GPLv2 */
namespace Icinga\Module\Icingadb\View;
use Icinga\Module\Icingadb\Common\HostLink;
use Icinga\Module\Icingadb\Common\Icons;
use Icinga\Module\Icingadb\Common\Links;
use Icinga\Module\Icingadb\Common\ServiceLink;
use Icinga\Module\Icingadb\Common\TicketLinks;
use Icinga\Module\Icingadb\Model\Comment;
use Icinga\Module\Icingadb\Widget\MarkdownLine;
use ipl\Html\Attributes;
use ipl\Html\FormattedString;
use ipl\Html\HtmlDocument;
use ipl\Html\HtmlElement;
use ipl\Html\Text;
use ipl\I18n\Translation;
use ipl\Web\Common\ItemRenderer;
use ipl\Web\Widget\Icon;
use ipl\Web\Widget\Link;
use ipl\Web\Widget\TimeAgo;
use ipl\Web\Widget\TimeUntil;
/** @implements ItemRenderer<Comment> */
class CommentRenderer implements ItemRenderer
{
use Translation;
use TicketLinks;
use HostLink;
use ServiceLink;
/**
* @var bool Whether the item is being rendered in the detail view of the associated object (host/service)
*
* When true:
*
* - Creation of the link of the associated object (host/service) is omitted from the title
* - The ticket link will be created
*/
protected $isDetailView = false;
/**
* Set whether the item is being rendered in the detail view of the associated object (host/service)
*
* @param bool $state
*
* @return $this
*/
public function setIsDetailView(bool $state = true): self
{
$this->isDetailView = $state;
return $this;
}
public function assembleAttributes($item, Attributes $attributes, string $layout): void
{
$attributes->get('class')->addValue('comment');
}
public function assembleVisual($item, HtmlDocument $visual, string $layout): void
{
$visual->addHtml(new HtmlElement(
'div',
Attributes::create(['class' => 'user-ball']),
Text::create($item->author[0])
));
}
public function assembleTitle($item, HtmlDocument $title, string $layout): void
{
$isAck = $item->entry_type === 'ack';
$expires = $item->expire_time;
$subjectText = sprintf(
$isAck
? $this->translate('%s acknowledged', '<username>..')
: $this->translate('%s commented', '<username>..'),
$item->author
);
$headerParts = [
new Icon(Icons::USER),
$layout === 'header'
? new HtmlElement('span', Attributes::create(['class' => 'subject']), Text::create($subjectText))
: new Link($subjectText, Links::comment($item), ['class' => 'subject'])
];
if ($isAck) {
$label = [Text::create('ack')];
if ($item->is_persistent) {
array_unshift($label, new Icon(Icons::IS_PERSISTENT));
}
$headerParts[] = Text::create(' ');
$headerParts[] = new HtmlElement('span', Attributes::create(['class' => 'ack-badge badge']), ...$label);
}
if ($expires !== null) {
$headerParts[] = Text::create(' ');
$headerParts[] = new HtmlElement(
'span',
Attributes::create(['class' => 'ack-badge badge']),
Text::create($this->translate('EXPIRES'))
);
}
if ($this->isDetailView) {
// pass
} elseif ($item->object_type === 'host') {
$headerParts[] = $this->createHostLink($item->host, true);
} else {
$headerParts[] = $this->createServiceLink($item->service, $item->service->host, true);
}
$title->addHtml(...$headerParts);
}
public function assembleCaption($item, HtmlDocument $caption, string $layout): void
{
$markdownLine = new MarkdownLine($this->isDetailView ? $this->createTicketLinks($item->text) : $item->text);
$caption->getAttributes()->add($markdownLine->getAttributes());
$caption->addFrom($markdownLine);
}
public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void
{
if ($item->expire_time) {
$info->addHtml(new HtmlElement(
'span',
null,
FormattedString::create(
$this->translate("expires %s"),
new TimeUntil($item->expire_time->getTimestamp())
)
));
} else {
$info->addHtml(new HtmlElement(
'span',
null,
FormattedString::create(
$this->translate("created %s"),
new TimeAgo($item->entry_time->getTimestamp())
)
));
}
}
public function assembleFooter($item, HtmlDocument $footer, string $layout): void
{
}
public function assemble($item, string $name, HtmlDocument $element, string $layout): bool
{
return false; // no custom sections
}
}

View file

@ -29,6 +29,7 @@ use Icinga\Module\Icingadb\Model\UnreachableParent;
use Icinga\Module\Icingadb\Redis\VolatileStateResults;
use Icinga\Module\Icingadb\Web\Navigation\Action;
use Icinga\Module\Icingadb\Widget\ItemList\ObjectList;
use Icinga\Module\Icingadb\Widget\ItemList\TicketLinkObjectList;
use Icinga\Module\Icingadb\Widget\MarkdownText;
use Icinga\Module\Icingadb\Common\ServiceLinks;
use Icinga\Module\Icingadb\Forms\Command\Object\ToggleObjectFeaturesForm;
@ -42,11 +43,11 @@ use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList;
use Icinga\Module\Icingadb\Widget\ShowMore;
use ipl\Sql\Expression;
use ipl\Sql\Filter\Exists;
use ipl\Web\Url;
use ipl\Web\Widget\CopyToClipboard;
use ipl\Web\Widget\EmptyState;
use ipl\Web\Widget\EmptyStateBar;
use ipl\Web\Widget\HorizontalKeyValue;
use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
use Icinga\Module\Icingadb\Widget\TagList;
use Icinga\Module\Monitoring\Hook\DetailviewExtensionHook;
@ -231,7 +232,9 @@ class ObjectDetail extends BaseHtmlElement
$content = [Html::tag('h2', t('Comments'))];
if ($comments->hasResult()) {
$content[] = (new CommentList($comments))->setObjectLinkDisabled()->setTicketLinkEnabled();
$content[] = (new TicketLinkObjectList($comments))
->setMultiselectUrl(Links::commentsDetails())
->setDetailUrl(Url::fromPath('icingadb/comment'));
$content[] = (new ShowMore($comments, $link))->setBaseTarget('_next');
} else {
$content[] = new EmptyState(t('No comments created.'));

View file

@ -5,11 +5,13 @@
namespace Icinga\Module\Icingadb\Widget\Detail;
use Icinga\Exception\NotImplementedError;
use Icinga\Module\Icingadb\Model\Comment;
use Icinga\Module\Icingadb\Model\Host;
use Icinga\Module\Icingadb\Model\RedundancyGroup;
use Icinga\Module\Icingadb\Model\Service;
use Icinga\Module\Icingadb\Model\User;
use Icinga\Module\Icingadb\Model\Usergroup;
use Icinga\Module\Icingadb\View\CommentRenderer;
use Icinga\Module\Icingadb\View\HostRenderer;
use Icinga\Module\Icingadb\View\RedundancyGroupRenderer;
use Icinga\Module\Icingadb\View\ServiceRenderer;
@ -53,6 +55,10 @@ class ObjectHeader extends BaseHtmlElement
case $this->object instanceof User:
$renderer = new UserRenderer();
break;
case $this->object instanceof Comment:
$renderer = new CommentRenderer();
break;
default:
throw new NotImplementedError('Not implemented');

View file

@ -1,131 +0,0 @@
<?php
/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
namespace Icinga\Module\Icingadb\Widget\ItemList;
use Icinga\Module\Icingadb\Common\NoSubjectLink;
use Icinga\Module\Icingadb\Common\ObjectLinkDisabled;
use Icinga\Module\Icingadb\Common\TicketLinks;
use ipl\Html\Html;
use Icinga\Module\Icingadb\Common\HostLink;
use Icinga\Module\Icingadb\Common\Icons;
use Icinga\Module\Icingadb\Common\Links;
use Icinga\Module\Icingadb\Widget\MarkdownLine;
use Icinga\Module\Icingadb\Common\ServiceLink;
use Icinga\Module\Icingadb\Model\Comment;
use ipl\Html\FormattedString;
use ipl\Web\Common\BaseListItem;
use ipl\Web\Widget\TimeAgo;
use ipl\Html\Attributes;
use ipl\Html\BaseHtmlElement;
use ipl\Html\HtmlElement;
use ipl\Html\Text;
use ipl\Stdlib\Filter;
use ipl\Web\Widget\Icon;
use ipl\Web\Widget\Link;
use ipl\Web\Widget\TimeUntil;
/**
* Comment item of a comment list. Represents one database row.
*
* @property Comment $item
* @property CommentList $list
*/
abstract class BaseCommentListItem extends BaseListItem
{
use HostLink;
use ServiceLink;
use NoSubjectLink;
use ObjectLinkDisabled;
use TicketLinks;
protected function assembleCaption(BaseHtmlElement $caption): void
{
$markdownLine = new MarkdownLine($this->createTicketLinks($this->item->text));
$caption->getAttributes()->add($markdownLine->getAttributes());
$caption->addFrom($markdownLine);
}
protected function assembleTitle(BaseHtmlElement $title): void
{
$isAck = $this->item->entry_type === 'ack';
$expires = $this->item->expire_time;
$subjectText = sprintf(
$isAck ? t('%s acknowledged', '<username>..') : t('%s commented', '<username>..'),
$this->item->author
);
$headerParts = [
new Icon(Icons::USER),
$this->getNoSubjectLink()
? new HtmlElement('span', Attributes::create(['class' => 'subject']), Text::create($subjectText))
: new Link($subjectText, Links::comment($this->item), ['class' => 'subject'])
];
if ($isAck) {
$label = [Text::create('ack')];
if ($this->item->is_persistent) {
array_unshift($label, new Icon(Icons::IS_PERSISTENT));
}
$headerParts[] = Text::create(' ');
$headerParts[] = new HtmlElement('span', Attributes::create(['class' => 'ack-badge badge']), ...$label);
}
if ($expires !== null) {
$headerParts[] = Text::create(' ');
$headerParts[] = new HtmlElement(
'span',
Attributes::create(['class' => 'ack-badge badge']),
Text::create(t('EXPIRES'))
);
}
if ($this->getObjectLinkDisabled()) {
// pass
} elseif ($this->item->object_type === 'host') {
$headerParts[] = $this->createHostLink($this->item->host, true);
} else {
$headerParts[] = $this->createServiceLink($this->item->service, $this->item->service->host, true);
}
$title->addHtml(...$headerParts);
}
protected function assembleVisual(BaseHtmlElement $visual): void
{
$visual->addHtml(new HtmlElement(
'div',
Attributes::create(['class' => 'user-ball']),
Text::create($this->item->author[0])
));
}
protected function createTimestamp(): ?BaseHtmlElement
{
if ($this->item->expire_time) {
return Html::tag(
'span',
FormattedString::create(t("expires %s"), new TimeUntil($this->item->expire_time->getTimestamp()))
);
}
return Html::tag(
'span',
FormattedString::create(t("created %s"), new TimeAgo($this->item->entry_time->getTimestamp()))
);
}
protected function init(): void
{
$this->setTicketLinkEnabled($this->list->getTicketLinkEnabled());
$this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
$this->list->addMultiselectFilterAttribute($this, Filter::equal('name', $this->item->name));
$this->setObjectLinkDisabled($this->list->getObjectLinkDisabled());
$this->setNoSubjectLink($this->list->getNoSubjectLink());
}
}

View file

@ -1,49 +0,0 @@
<?php
/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
namespace Icinga\Module\Icingadb\Widget\ItemList;
use Icinga\Module\Icingadb\Common\CaptionDisabled;
use Icinga\Module\Icingadb\Common\DetailActions;
use Icinga\Module\Icingadb\Common\Links;
use Icinga\Module\Icingadb\Common\NoSubjectLink;
use Icinga\Module\Icingadb\Common\ObjectLinkDisabled;
use Icinga\Module\Icingadb\Common\TicketLinks;
use Icinga\Module\Icingadb\Common\ViewMode;
use ipl\Web\Common\BaseItemList;
use ipl\Web\Url;
class CommentList extends BaseItemList
{
use CaptionDisabled;
use NoSubjectLink;
use ObjectLinkDisabled;
use ViewMode;
use TicketLinks;
use DetailActions;
protected $defaultAttributes = ['class' => 'comment-list'];
protected function getItemClass(): string
{
$viewMode = $this->getViewMode();
$this->addAttributes(['class' => $viewMode]);
if ($viewMode === 'minimal') {
return CommentListItemMinimal::class;
} elseif ($viewMode === 'detailed') {
$this->removeAttribute('class', 'default-layout');
}
return CommentListItem::class;
}
protected function init(): void
{
$this->initializeDetailActions();
$this->setMultiselectUrl(Links::commentsDetails());
$this->setDetailUrl(Url::fromPath('icingadb/comment'));
}
}

View file

@ -1,12 +0,0 @@
<?php
/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
namespace Icinga\Module\Icingadb\Widget\ItemList;
use Icinga\Module\Icingadb\Common\ListItemCommonLayout;
class CommentListItem extends BaseCommentListItem
{
use ListItemCommonLayout;
}

View file

@ -1,21 +0,0 @@
<?php
/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
namespace Icinga\Module\Icingadb\Widget\ItemList;
use Icinga\Module\Icingadb\Common\ListItemMinimalLayout;
class CommentListItemMinimal extends BaseCommentListItem
{
use ListItemMinimalLayout;
protected function init(): void
{
parent::init();
if ($this->list->isCaptionDisabled()) {
$this->setCaptionDisabled();
}
}
}

View file

@ -6,6 +6,7 @@ namespace Icinga\Module\Icingadb\Widget\ItemList;
use Icinga\Exception\NotImplementedError;
use Icinga\Module\Icingadb\Common\DetailActions;
use Icinga\Module\Icingadb\Model\Comment;
use Icinga\Module\Icingadb\Model\DependencyNode;
use Icinga\Module\Icingadb\Model\Host;
use Icinga\Module\Icingadb\Model\RedundancyGroup;
@ -14,6 +15,7 @@ use Icinga\Module\Icingadb\Model\UnreachableParent;
use Icinga\Module\Icingadb\Model\User;
use Icinga\Module\Icingadb\Model\Usergroup;
use Icinga\Module\Icingadb\Redis\VolatileStateResults;
use Icinga\Module\Icingadb\View\CommentRenderer;
use Icinga\Module\Icingadb\View\HostRenderer;
use Icinga\Module\Icingadb\View\RedundancyGroupRenderer;
use Icinga\Module\Icingadb\View\ServiceRenderer;
@ -57,9 +59,11 @@ class ObjectList extends ItemList
return new UsergroupRenderer();
} elseif ($item instanceof User) {
return new UserRenderer();
} else {
throw new NotImplementedError('Not implemented');
} elseif ($item instanceof Comment) {
return new CommentRenderer();
}
throw new NotImplementedError('Not implemented');
});
}
@ -190,6 +194,11 @@ class ObjectList extends ItemList
case $object instanceof Usergroup || $data instanceof User:
$this->addDetailFilterAttribute($item, Filter::equal('name', $object->name));
break;
case $object instanceof Comment:
$this->addDetailFilterAttribute($item, Filter::equal('name', $object->name));
$this->addMultiSelectFilterAttribute($item, Filter::equal('name', $object->name));
break;
}

View file

@ -0,0 +1,32 @@
<?php
/* Icinga DB Web | (c) 2025 Icinga GmbH | GPLv2 */
namespace Icinga\Module\Icingadb\Widget\ItemList;
use Icinga\Exception\NotImplementedError;
use Icinga\Module\Icingadb\Model\Comment;
use Icinga\Module\Icingadb\View\CommentRenderer;
use ipl\Orm\Model;
use ipl\Web\Widget\ItemList;
/**
* TicketLinkObjectList
*
* Create a list of icingadb objects with ticket links
*
* @extends ObjectList //TODO: define object type
*/
class TicketLinkObjectList extends ObjectList
{
public function __construct($data)
{
ItemList::__construct($data, function (Model $item) {
if ($item instanceof Comment) {
return (new CommentRenderer())->setIsDetailView();
}
throw new NotImplementedError('Not implemented');
});
}
}

View file

@ -382,16 +382,6 @@ div.show-more {
}
}
.comment-popup {
.comment-list .main {
// This is necessary to limit the visible comment lines
// because the popup is shown in detailed list mode only
.caption {
height: 3em;
}
}
}
form[name="form_confirm_removal"] {
text-align: center;
}

View file

@ -1,50 +0,0 @@
// Style
// Layout
.comment-list:not(.detailed) .list-item {
.title > i:first-child {
margin-right: 0;
}
.title > .subject + .badge,
.title > .badge + .subject,
.title > .badge:last-of-type {
margin-left: 0;
}
.title a {
&:not(.subject) {
.text-ellipsis();
}
}
.title .subject:not(:last-child) {
margin-left: 0;
}
.title .subject:nth-child(3):last-child {
margin-left: 0;
}
}
.comment-list.minimal .list-item {
.user-ball {
font-size: .857em;
height: 1.75em;
line-height: 1.5em;
width: 1.75em;
}
}
.comment-list.detailed .list-item {
.title > .subject:nth-child(3),
.title > .badge + .subject:last-child {
margin-left: .3em;
}
.caption {
max-height: 4.5em;
white-space: normal;
}
}

View file

@ -35,20 +35,24 @@
}
}
ul.item-list li:last-child .comment-wrapper {
.comment-popup {
top: -7em;
}
ul.item-list li:nth-last-child(2):not(:first-child),
ul.item-list li:last-child:not(:first-child) {
.comment-wrapper {
.comment-popup {
top: -7em;
}
.comment-popup:before {
bottom: ~"calc(-0.75em - 1px)";
top: unset;
transform: rotate(225deg);
.comment-popup:before {
bottom: ~"calc(-0.75em - 1px)";
top: unset;
transform: rotate(225deg);
}
}
}
.comment-wrapper:hover .comment-popup {
display: block
display: flex;
padding: 0 1em;
}
#layout {
@ -72,3 +76,9 @@ ul.item-list li:last-child .comment-wrapper {
}
}
}
.comment-popup .main .caption {
// This is necessary to limit the visible comment lines
// because the popup is shown in detailed list mode only
height: 3em;
}