diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 8d16d7d68..79fc1969e 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -129,6 +129,7 @@ public function addRotationAction(): void }); $form->on(RotationConfigForm::ON_SUCCESS, function (RotationConfigForm $form) use ($scheduleId) { $form->addRotation(); + $this->sendExtraUpdates(['#col1']); $this->closeModalAndRefreshRelatedView(Links::schedule($scheduleId)); }); @@ -154,14 +155,17 @@ public function editRotationAction(): void $form->setSuggestionUrl(Url::fromPath('notifications/schedule/suggest-recipient')); $form->on(RotationConfigForm::ON_SUCCESS, function (RotationConfigForm $form) use ($id, $scheduleId) { $form->editRotation($id); + $this->sendExtraUpdates(['#col1']); $this->closeModalAndRefreshRelatedView(Links::schedule($scheduleId)); }); $form->on(RotationConfigForm::ON_SENT, function (RotationConfigForm $form) use ($id, $scheduleId) { if ($form->hasBeenRemoved()) { $form->removeRotation($id); + $this->sendExtraUpdates(['#col1']); $this->closeModalAndRefreshRelatedView(Links::schedule($scheduleId)); } elseif ($form->hasBeenWiped()) { $form->wipeRotation(); + $this->sendExtraUpdates(['#col1']); $this->closeModalAndRefreshRelatedView(Links::schedule($scheduleId)); } elseif (! $form->hasBeenSubmitted()) { foreach ($form->getPartUpdates() as $update) { @@ -188,6 +192,7 @@ public function moveRotationAction(): void $form = new MoveRotationForm(Database::get()); $form->on(MoveRotationForm::ON_SUCCESS, function (MoveRotationForm $form) { + $this->sendExtraUpdates(['#col1']); $this->redirectNow(Links::schedule($form->getScheduleId())); }); diff --git a/application/controllers/SchedulesController.php b/application/controllers/SchedulesController.php index a9af10dcd..53fc30337 100644 --- a/application/controllers/SchedulesController.php +++ b/application/controllers/SchedulesController.php @@ -4,11 +4,15 @@ namespace Icinga\Module\Notifications\Controllers; +use DateTime; use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Model\Schedule; use Icinga\Module\Notifications\Web\Control\SearchBar\ObjectSuggestions; use Icinga\Module\Notifications\Widget\ItemList\ScheduleList; +use Icinga\Module\Notifications\Widget\TimeGrid\DaysHeader; +use ipl\Html\Attributes; +use ipl\Html\HtmlElement; use ipl\Stdlib\Filter; use ipl\Web\Compat\CompatController; use ipl\Web\Compat\SearchControls; @@ -70,6 +74,14 @@ public function indexAction(): void ))->openInModal() ); + $this->addContent( + HtmlElement::create( + 'div', + Attributes::create(['class' => ['timeline', 'schedules-header']]), + new DaysHeader((new DateTime())->setTime(0, 0), 7) + ) + ); + $this->addContent(new ScheduleList($schedules)); if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { diff --git a/library/Notifications/Widget/ItemList/ScheduleListItem.php b/library/Notifications/Widget/ItemList/ScheduleListItem.php index 24e9f9d80..5a6ffab6b 100644 --- a/library/Notifications/Widget/ItemList/ScheduleListItem.php +++ b/library/Notifications/Widget/ItemList/ScheduleListItem.php @@ -4,10 +4,15 @@ namespace Icinga\Module\Notifications\Widget\ItemList; +use DateTime; use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Model\Schedule; +use Icinga\Module\Notifications\Widget\Timeline; +use Icinga\Module\Notifications\Widget\Timeline\Rotation; +use Icinga\Util\Csp; use ipl\Html\BaseHtmlElement; use ipl\Web\Common\BaseListItem; +use ipl\Web\Style; use ipl\Web\Widget\Link; /** @@ -41,8 +46,27 @@ protected function assembleHeader(BaseHtmlElement $header): void $header->addHtml($this->createTitle()); } + protected function assembleCaption(BaseHtmlElement $caption): void + { + // Number of days is set to 7, since default mode for schedule is week + // and the start day should be the current day + $timeline = (new Timeline((new DateTime())->setTime(0, 0), 7)) + ->minimalLayout() + ->setStyle( + (new Style()) + ->setNonce(Csp::getStyleNonce()) + ->setModule('notifications') + ); + + foreach ($this->item->rotation->with('timeperiod')->orderBy('first_handoff', SORT_DESC) as $rotation) { + $timeline->addRotation(new Rotation($rotation)); + } + + $caption->addHtml($timeline); + } + protected function assembleMain(BaseHtmlElement $main): void { - $main->addHtml($this->createHeader()); + $main->addHtml($this->createHeader(), $this->createCaption()); } } diff --git a/library/Notifications/Widget/TimeGrid/DaysHeader.php b/library/Notifications/Widget/TimeGrid/DaysHeader.php new file mode 100644 index 000000000..5a73da9e7 --- /dev/null +++ b/library/Notifications/Widget/TimeGrid/DaysHeader.php @@ -0,0 +1,90 @@ + 'header']; + + /** @var DateTime Starting day */ + protected $startDay; + + public function __construct(DateTime $startDay, int $days) + { + $this->startDay = $startDay; + $this->days = $days; + } + + public function assemble(): void + { + $dayNames = [ + $this->translate('Mon', 'monday'), + $this->translate('Tue', 'tuesday'), + $this->translate('Wed', 'wednesday'), + $this->translate('Thu', 'thursday'), + $this->translate('Fri', 'friday'), + $this->translate('Sat', 'saturday'), + $this->translate('Sun', 'sunday') + ]; + + $interval = new DateInterval('P1D'); + $today = (new DateTime())->setTime(0, 0); + $time = clone $this->startDay; + $dateFormatter = new IntlDateFormatter( + Locale::getDefault(), + IntlDateFormatter::MEDIUM, + IntlDateFormatter::NONE + ); + + for ($i = 0; $i < $this->days; $i++) { + if ($time == $today) { + $title = [new HtmlElement( + 'span', + Attributes::create(['class' => 'day-name']), + Text::create($this->translate('Today')) + )]; + } else { + $title = [ + new HtmlElement( + 'span', + Attributes::create(['class' => 'date']), + Text::create($time->format($this->translate('d/m', 'day-name, time'))) + ), + Text::create(' '), + new HtmlElement( + 'span', + Attributes::create(['class' => 'day-name']), + Text::create($dayNames[$time->format('N') - 1]) + ) + ]; + } + + $this->addHtml(new HtmlElement( + 'div', + Attributes::create(['class' => 'column-title', 'title' => $dateFormatter->format($time)]), + ...$title + )); + + $time->add($interval); + } + } +} diff --git a/library/Notifications/Widget/TimeGrid/DynamicGrid.php b/library/Notifications/Widget/TimeGrid/DynamicGrid.php index e42b7b13b..7825e1a7e 100644 --- a/library/Notifications/Widget/TimeGrid/DynamicGrid.php +++ b/library/Notifications/Widget/TimeGrid/DynamicGrid.php @@ -6,13 +6,10 @@ use DateInterval; use DateTime; -use IntlDateFormatter; use InvalidArgumentException; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; use ipl\Html\HtmlElement; -use ipl\Html\Text; -use Locale; use LogicException; use Traversable; @@ -98,59 +95,7 @@ protected function sideBar(): BaseHtmlElement protected function createHeader(): BaseHtmlElement { - $dayNames = [ - $this->translate('Mon', 'monday'), - $this->translate('Tue', 'tuesday'), - $this->translate('Wed', 'wednesday'), - $this->translate('Thu', 'thursday'), - $this->translate('Fri', 'friday'), - $this->translate('Sat', 'saturday'), - $this->translate('Sun', 'sunday') - ]; - - $interval = new DateInterval('P1D'); - $today = (new DateTime())->setTime(0, 0); - $time = clone $this->getGridStart(); - $dateFormatter = new IntlDateFormatter( - Locale::getDefault(), - IntlDateFormatter::MEDIUM, - IntlDateFormatter::NONE - ); - - $header = new HtmlElement('div', Attributes::create(['class' => 'header'])); - for ($i = 0; $i < $this->days; $i++) { - if ($time == $today) { - $title = [new HtmlElement( - 'span', - Attributes::create(['class' => 'day-name']), - Text::create($this->translate('Today')) - )]; - } else { - $title = [ - new HtmlElement( - 'span', - Attributes::create(['class' => 'date']), - Text::create($time->format($this->translate('d/m', 'day-name, time'))) - ), - Text::create(' '), - new HtmlElement( - 'span', - Attributes::create(['class' => 'day-name']), - Text::create($dayNames[$time->format('N') - 1]) - ) - ]; - } - - $header->addHtml(new HtmlElement( - 'div', - Attributes::create(['class' => 'column-title', 'title' => $dateFormatter->format($time)]), - ...$title - )); - - $time->add($interval); - } - - return $header; + return new DaysHeader($this->getGridStart(), $this->days); } protected function createGridSteps(): Traversable diff --git a/library/Notifications/Widget/TimeGrid/MinimalGrid.php b/library/Notifications/Widget/TimeGrid/MinimalGrid.php new file mode 100644 index 000000000..0e35b95e9 --- /dev/null +++ b/library/Notifications/Widget/TimeGrid/MinimalGrid.php @@ -0,0 +1,42 @@ + 'sidebar']), new HtmlString('')); + } + + protected function assemble(): void + { + $this->style->addFor($this, [ + '--primaryColumns' => $this->days, + '--columnsPerStep' => 48, + '--rowsPerStep' => 1 + ]); + + $overlay = $this->createGridOverlay(); + if ($overlay->isEmpty()) { + $this->style->addFor($this, [ + '--primaryRows' => count($this->sideBar()) + ]); + } + + $this->addHtml( + $this->createGrid(), + $overlay + ); + } +} diff --git a/library/Notifications/Widget/Timeline.php b/library/Notifications/Widget/Timeline.php index 6ca486df8..fc753d854 100644 --- a/library/Notifications/Widget/Timeline.php +++ b/library/Notifications/Widget/Timeline.php @@ -8,9 +8,11 @@ use DateTime; use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Forms\MoveRotationForm; +use Icinga\Module\Notifications\Widget\TimeGrid\DaysHeader; use Icinga\Module\Notifications\Widget\TimeGrid\DynamicGrid; use Icinga\Module\Notifications\Widget\TimeGrid\EntryProvider; use Icinga\Module\Notifications\Widget\TimeGrid\GridStep; +use Icinga\Module\Notifications\Widget\TimeGrid\MinimalGrid; use Icinga\Module\Notifications\Widget\Timeline\Entry; use Icinga\Module\Notifications\Widget\Timeline\Rotation; use ipl\Html\Attributes; @@ -47,6 +49,9 @@ class Timeline extends BaseHtmlElement implements EntryProvider /** @var ?DynamicGrid */ protected $grid; + /** @var bool Whether to create the Timeline only with the Result using MinimalGrid */ + protected $minimalLayout = false; + /** * Set the style object to register inline styles in * @@ -87,6 +92,18 @@ public function __construct(DateTime $start, int $days) $this->days = $days; } + /** + * Set whether to create the Timeline only with the Result + * + * @return $this + */ + public function minimalLayout(): self + { + $this->minimalLayout = true; + + return $this; + } + /** * Add a rotation to the timeline * @@ -140,18 +157,20 @@ public function getEntries(): Traversable return array_fill((int) $cellStart, (int) $numberOfRequiredCells, $e); }; - $maxPriority = array_reduce($rotations, function (int $carry, Rotation $rotation) { - return max($carry, $rotation->getPriority()); - }, 0); + $maxPriority = $this->calculateMaxPriority($rotations); $occupiedCells = []; + $resultPosition = $maxPriority + 1; + foreach ($rotations as $rotation) { $rotationPosition = $maxPriority - $rotation->getPriority(); foreach ($rotation->fetchTimeperiodEntries($this->start, $this->getGrid()->getGridEnd()) as $entry) { - $entry->setPosition($rotationPosition); + if (! $this->minimalLayout) { + $entry->setPosition($rotationPosition); - yield $entry; + yield $entry; + } $occupiedCells += $getDesiredCells($entry); } @@ -191,16 +210,7 @@ public function getEntries(): Traversable $end = $entry->getEnd(); } - $resultEntry = (new Entry($entry->getId())) - ->setStart($start) - ->setEnd($end) - ->setUrl($entry->getUrl()) - ->setPosition($resultPosition) - ->setMember($entry->getMember()); - $resultEntry->getAttributes() - ->add('data-rotation-position', $entry->getPosition()); - - yield $resultEntry; + yield $this->createResultEntry($entry, $start, $end, $resultPosition); $firstCell = $cell; } @@ -210,6 +220,56 @@ public function getEntries(): Traversable } } + /** + * Calculate max priority of the given rotations + * + * @param array $rotations + * + * @return int + */ + protected function calculateMaxPriority(array $rotations): int + { + if ($this->minimalLayout) { + return -1; + } + + usort($rotations, function (Rotation $a, Rotation $b) { + return $a->getPriority() <=> $b->getPriority(); + }); + + return array_reduce($rotations, function (int $carry, Rotation $rotation) { + return max($carry, $rotation->getPriority()); + }, 0); + } + + /** + * Create Entry for the result + * + * @param Entry $entry + * @param DateTime $start + * @param DateTime $end + * @param int $position + * + * @return Entry + */ + protected function createResultEntry(Entry $entry, DateTime $start, DateTime $end, int $position): Entry + { + $resultEntry = (new Entry($entry->getId())) + ->setStart($start) + ->setEnd($end) + ->setPosition($position) + ->setMember($entry->getMember()); + + $resultEntry->getAttributes() + ->add('data-rotation-position', $entry->getPosition()); + + if ($this->minimalLayout) { + return $resultEntry; + } + + return $resultEntry->setUrl($entry->getUrl()); + } + /** * Get the grid for this timeline * @@ -218,18 +278,25 @@ public function getEntries(): Traversable protected function getGrid(): DynamicGrid { if ($this->grid === null) { - $this->grid = new DynamicGrid($this, $this->getStyle(), $this->start); + if ($this->minimalLayout) { + $this->grid = new MinimalGrid($this, $this->getStyle(), $this->start); + } else { + $this->grid = new DynamicGrid($this, $this->getStyle(), $this->start); + } + $this->grid->setDays($this->days); - $rotations = $this->rotations; - usort($rotations, function (Rotation $a, Rotation $b) { - return $b->getPriority() <=> $a->getPriority(); - }); - $occupiedPriorities = []; - foreach ($rotations as $rotation) { - if (! isset($occupiedPriorities[$rotation->getPriority()])) { - $occupiedPriorities[$rotation->getPriority()] = true; - $this->grid->addToSideBar($this->assembleSidebarEntry($rotation)); + if (! $this->minimalLayout) { + $rotations = $this->rotations; + usort($rotations, function (Rotation $a, Rotation $b) { + return $b->getPriority() <=> $a->getPriority(); + }); + $occupiedPriorities = []; + foreach ($rotations as $rotation) { + if (! isset($occupiedPriorities[$rotation->getPriority()])) { + $occupiedPriorities[$rotation->getPriority()] = true; + $this->grid->addToSideBar($this->assembleSidebarEntry($rotation)); + } } } } @@ -258,6 +325,18 @@ protected function assembleSidebarEntry(Rotation $rotation): BaseHtmlElement } protected function assemble() + { + if (! $this->minimalLayout) { + $this->createResultSideBar(); + } + + $this->addHtml( + $this->getGrid(), + $this->getStyle() + ); + } + + protected function createResultSideBar(): void { if (empty($this->rotations)) { $this->getGrid()->addToSideBar( @@ -276,10 +355,5 @@ protected function assemble() Text::create($this->translate('Result')) ) ); - - $this->addHtml( - $this->getGrid(), - $this->getStyle() - ); } } diff --git a/public/css/common.less b/public/css/common.less index 10d6302c3..e2f23577d 100644 --- a/public/css/common.less +++ b/public/css/common.less @@ -12,6 +12,32 @@ } } +.schedules-header { + margin-left: 0.5em; + .header { + display: grid; + border-left: 1px solid transparent; + grid-template-columns: repeat(7, minmax(2em, 1fr)); + text-transform: uppercase; + + .column-title { + text-align: center; + border-right: 1px solid transparent; + .date { + font-size: 0.75em; + } + + .day-name { + color: @text-color-light; + } + } + } + + .column-title:not(:last-of-type) { + border-right: 1px solid @gray-lighter; + } +} + .controls { &.contactgroup-detail, &.event-detail, diff --git a/public/css/list/schedule-list.less b/public/css/list/schedule-list.less new file mode 100644 index 000000000..ac162178b --- /dev/null +++ b/public/css/list/schedule-list.less @@ -0,0 +1,33 @@ +.item-list.schedule-list { + .time-grid { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + } + + .time-grid:after { + border-top: unset; + } + + .list-item { + .main { + .caption { + height: auto; + } + } + + .timeline { + .grid { + border-top: unset; + + .step { + border-bottom: unset; + } + } + + .grid, + .overlay { + grid-area: ~"3 / 1 / 4 / 3"; + } + } + } +}