diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 92cd3e7c..90b84ac6 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -42,11 +42,6 @@ public function indexAction(): void null, Links::scheduleSettings($id), 'cog' - ))->openInModal(), - (new ButtonLink( - $this->translate('Add Rotation'), - Links::rotationAdd($id), - 'plus' ))->openInModal() ); diff --git a/library/Notifications/Widget/TimeGrid/BaseGrid.php b/library/Notifications/Widget/TimeGrid/BaseGrid.php index 7fc94f4c..d262f3a2 100644 --- a/library/Notifications/Widget/TimeGrid/BaseGrid.php +++ b/library/Notifications/Widget/TimeGrid/BaseGrid.php @@ -7,11 +7,15 @@ use DateInterval; use DateTime; use Generator; +use Icinga\Module\Notifications\Common\Links; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; use ipl\Html\HtmlElement; +use ipl\Html\Text; use ipl\I18n\Translation; use ipl\Web\Style; +use ipl\Web\Url; +use ipl\Web\Widget\ButtonLink; use ipl\Web\Widget\Link; use LogicException; use SplObjectStorage; @@ -470,8 +474,8 @@ final protected function yieldFixedEntries(Traversable $entries): Generator yield $gridArea => $entry; } - $this->style->addFor($this, [ - '--primaryRows' => $lastRow === 1 ? 1 : $lastRow - $rowStartModifier + 1, + $this->style->addFor($this, [ // +1 to create extra row for the `add rotation` button + '--primaryRows' => $lastRow === 1 ? 1 : $lastRow - $rowStartModifier + 1 + 1, '--rowsPerStep' => 1 ]); } @@ -490,11 +494,44 @@ protected function assembleGridOverlay(BaseHtmlElement $overlay): void $generator = $this->yieldFixedEntries($entries); } + $addButtonCreated = false; foreach ($generator as $gridArea => $entry) { + [$rowStart, $colStart, $rowEnd, $colEnd] = $gridArea; + + if (! $addButtonCreated && $entry->getAttributes()->has('data-rotation-position')) { + $btn = new HtmlElement('div', new Attributes(['class' => 'btn-container'])); + + $btn->addHtml( + (new ButtonLink( + $this->translate('Add another Rotation'), + Links::rotationAdd(Url::fromRequest()->getParam('id')), + 'plus' + ))->openInModal(), + new HtmlElement( + 'span', + new Attributes(['class' => 'hint']), + new Text($this->translate('to override rotations above')) + ) + ); + + // take up the entire row + $this->style->addFor($btn, [ + 'grid-area' => sprintf('~"%d / %d / %d / %d"', $rowStart, 1, $rowEnd, -1) + ]); + + $overlay->addHtml($btn); + $addButtonCreated = true; + } + + if ($addButtonCreated) { // result row must be below + $rowStart++; + $rowEnd++; + } + $this->style->addFor($entry, [ '--entry-bg' => $entry->getColor(10), '--entry-border-color' => $entry->getColor(50), - 'grid-area' => sprintf('~"%d / %d / %d / %d"', ...$gridArea) + 'grid-area' => sprintf('~"%d / %d / %d / %d"', $rowStart, $colStart, $rowEnd, $colEnd) ]); $overlay->addHtml($entry); diff --git a/library/Notifications/Widget/Timeline.php b/library/Notifications/Widget/Timeline.php index f3cfb46a..e6475726 100644 --- a/library/Notifications/Widget/Timeline.php +++ b/library/Notifications/Widget/Timeline.php @@ -24,6 +24,7 @@ use ipl\I18n\Translation; use ipl\Web\Style; use ipl\Web\Url; +use ipl\Web\Widget\ButtonLink; use ipl\Web\Widget\Icon; use ipl\Web\Widget\Link; use Locale; @@ -254,7 +255,7 @@ protected function getGrid() $this->grid = (new DynamicGrid($this, $this->getStyle(), $this->start))->setDays($this->days); } - if (! $this->minimalLayout) { + if (! $this->minimalLayout && $this->rotations) { $rotations = $this->rotations; usort($rotations, function (Rotation $a, Rotation $b) { return $b->getPriority() <=> $a->getPriority(); @@ -266,6 +267,9 @@ protected function getGrid() $this->grid->addToSideBar($this->assembleSidebarEntry($rotation)); } } + + // placeholder for new add button row + $this->grid->addToSideBar(new HtmlElement('div', new Attributes(['class' => 'placeholder']))); } } @@ -303,21 +307,6 @@ protected function assembleSidebarEntry(Rotation $rotation): BaseHtmlElement protected function assemble() { - if (empty($this->rotations)) { - $emptyNotice = new HtmlElement( - 'div', - Attributes::create(['class' => 'empty-notice']), - Text::create($this->translate('No rotations configured')) - ); - - if ($this->minimalLayout) { - $this->getAttributes()->add(['class' => 'minimal-layout']); - $this->addHtml($emptyNotice); - } else { - $this->getGrid()->addToSideBar($emptyNotice); - } - } - if (! $this->minimalLayout) { $this->getGrid()->addToSideBar( new HtmlElement( @@ -364,6 +353,46 @@ protected function assemble() ->addHtml($clock); } + if (! $this->rotations) { + $emptyNotice = new HtmlElement( + 'div', + Attributes::create(['class' => 'empty-notice']), + Text::create($this->translate('No rotations configured, yet.')) + ); + + if ($this->minimalLayout) { + $this->getAttributes()->add(['class' => 'minimal-layout']); + $this->addHtml($emptyNotice); + } else { + $this->addHtml(new HtmlElement( + 'div', + new Attributes(['class' => 'empty-state-notice']), + new Icon('info-circle'), + new HtmlElement( + 'span', + null, + new Text($this->translate( + 'With schedules Contacts can rotate in recurring shifts. You can add' + . ' multiple rotation layers to a schedule.' + )) + ) + )); + + $this->getGrid() + ->addAttributes(['class' => 'empty']) + ->addHtml(new HtmlElement( + 'div', + new Attributes(['class' => 'btn-container']), + $emptyNotice, + (new ButtonLink( + $this->translate('Add your first Rotation'), + Links::rotationAdd(Url::fromRequest()->getParam('id')), + 'plus' + ))->openInModal() + )); + } + } + $this->addHtml( $this->getGrid(), $this->getStyle() diff --git a/public/css/calendar.less b/public/css/calendar.less index bcef33df..ed8b22df 100644 --- a/public/css/calendar.less +++ b/public/css/calendar.less @@ -83,7 +83,7 @@ .grid, .overlay { display: grid; - overflow: hidden; + // overflow: hidden; //TODO(sd): is this required? grid-template-rows: repeat(~"calc(var(--primaryRows) * var(--rowsPerStep))", var(--stepRowHeight)); grid-template-columns: repeat(~"calc(var(--primaryColumns) * var(--columnsPerStep))", minmax(var(--minimumStepColumnWidth), 1fr)); border-width: 1px 0 0 1px; diff --git a/public/css/schedule.less b/public/css/schedule.less index 5e0464dc..c45311bc 100644 --- a/public/css/schedule.less +++ b/public/css/schedule.less @@ -8,10 +8,6 @@ h2 { display: inline; } - - > a:last-of-type { - float: right; - } } .schedule-detail { @@ -58,12 +54,20 @@ /* Design */ -.schedule-detail .entry.highlighted { - outline: 2px solid var(--entry-border-color); - outline-offset: 1px; -} +.schedule { + .entry.highlighted { + outline: 2px solid var(--entry-border-color); + outline-offset: 1px; + } -.schedule-detail .step.highlighted { - background-color: @gray-lighter; - border-color: @gray-light; + .sidebar .row-title.highlighted, + .step.highlighted { + background-color: @gray-lighter; + border-color: @gray-light; + } + + .sidebar .row-title.highlighted { + margin-top: -1px; // cover the border-top area + padding-top: 1px; + } } diff --git a/public/css/timeline.less b/public/css/timeline.less index f64f54ea..a662a1bd 100644 --- a/public/css/timeline.less +++ b/public/css/timeline.less @@ -5,6 +5,27 @@ flex-direction: column; overflow: hidden; + .empty-state-notice { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1em; + margin-bottom: 2em; + border: 1px solid @gray-light; + .rounded-corners(); + color: @text-color-light; + + .icon { + font-size: 1.75em; + } + + > span { + padding-left: 0.5em; + flex-grow: 1; + text-align: center; + } + } + .time-grid { --sidebarWidth: 12em; --stepRowHeight: 4em; @@ -44,6 +65,38 @@ } } + .sidebar .placeholderXXX { + background-color: fade(@gray-light, 25%); + border-top: 1px solid @gray-lighter; + margin-top: -1px; // to cover the border-bottom area of the previous sibling + } + + .btn-container { + display: flex; + justify-content: center; + align-items: center; + text-align: center; + height: var(--primaryRowHeight); + } + + &:not(.empty) .btn-container { + margin-left: ~"calc(var(--sidebarWidth) * -1)"; // overlap sidebar's .placeholder + } + + .overlay .btn-container { + pointer-events: all; // allow to click on the buttons + + .button-link:not(:hover) { + color: @disabled-gray; + background: none; + } + + .hint { + margin-left: .5em; + color: @text-color-light; + } + } + .overlay .entry { margin-top: 1em; margin-bottom: 1em; @@ -68,8 +121,8 @@ position: absolute; right: 0; left: 0; - // -1 to exclude result row - top: ~"calc((var(--stepRowHeight) * calc(var(--primaryRows) - 1)) + var(--daysHeaderHeight))"; + // -1 to exclude result row, max to have minimum 1 row + top: ~"calc(var(--stepRowHeight) * max(calc(var(--primaryRows) - 1), 1) + var(--daysHeaderHeight))"; } .timescale { @@ -204,6 +257,24 @@ } } + .btn-container { + background-color: mix(@gray-light, @body-bg-color, 25%); + + .empty-notice { + margin-right: 1em; + color: @text-color-light; + } + + .button-link { + border: 1px solid; + + &:hover { + color: @text-color-on-icinga-blue; + background-color: @icinga-blue; + } + } + } + .entry .icon { font-size: .75em; opacity: .8; @@ -231,6 +302,31 @@ color: @text-color-light; } +.time-grid.empty { + .btn-container { + grid-area: ~"3 / 1 / 3 / 3"; + } + + // .empty-state is placed under the .days-header, Everything below .empty-state element must slide down one row + // so the grid-row-start and grid-row-end is increased by 1 + .sidebar { + grid-area: ~"4 / 1 / 5 / 2"; + } + + .grid, + .overlay { + grid-area: ~"4 / 2 / 5 / 3"; + } + + .timescale { + grid-area: ~"5 / 2 / 5 / 3"; + } + + .clock { + grid-area: ~"3 / 2 / 5 / 3"; + } +} + #layout.twocols:not(.wide-layout) .days-header .column-title { display: flex; flex-direction: column; @@ -239,6 +335,12 @@ padding-bottom: .25em; } -#layout.twocols .schedule-detail .timescale:has(:nth-child(n+62)) { // month view (--timestampsPerDay * --primaryColumns = 62) +#layout.twocols .schedule .timescale:has(:nth-child(n+62)) { // month view (--timestampsPerDay * --primaryColumns = 62) display: none; } + +@light-mode: { + .timeline .btn-container { + background-color: mix(#d0d3da, #F5F9FA, 25%); //@gray-light, @light-body-bg-color + } +}; diff --git a/public/js/schedule.js b/public/js/schedule.js index 3119a3b0..668b54fd 100644 --- a/public/js/schedule.js +++ b/public/js/schedule.js @@ -20,6 +20,11 @@ this.on('mouseleave', '#notifications-schedule .entry', this.onEntryLeave, this); } + /** + * Make the sidebar sortable and add drag&drop support. + * + * @param event The event object. + */ onRendered(event) { if (event.target !== event.currentTarget) { @@ -46,6 +51,11 @@ }); } + /** + * Handle drop event on the sidebar. + * + * @param event The event object. + */ onDrop(event) { event = event.originalEvent; @@ -74,48 +84,60 @@ form.requestSubmit(); } + /** + * Handle hover (`mouseenter`) event on schedule entries. + * + * @param event The mouse event object. + */ onEntryHover(event) { - const entry = event.currentTarget; - const overlay = entry.parentElement; - const grid = overlay.previousSibling; - - let relatedElements; - if ('rotationPosition' in entry.dataset) { - relatedElements = grid.querySelectorAll( - '[data-y-position="' + entry.dataset.rotationPosition + '"]' - ); - } else { - relatedElements = overlay.querySelectorAll( - '[data-rotation-position="' + entry.dataset.entryPosition + '"]' - ); - } - - relatedElements.forEach((relatedElement) => { - relatedElement.classList.add('highlighted'); - }); + event.data.self.handleEntryHover(event, true); } + /** + * Handle hover (`mouseleave`) event on schedule entries. + * + * @param event The mouse event object. + */ onEntryLeave(event) { + event.data.self.handleEntryHover(event); + } + + /** + * Handle hover (`mouseenter`|`mouseleave`) events on schedule entries. + * + * @param event The mouse event object. + * @param {boolean} isHovered Whether the entry is hovered. + */ + handleEntryHover(event, isHovered = false) { const entry = event.currentTarget; const overlay = entry.parentElement; const grid = overlay.previousSibling; + const sideBar = grid.previousSibling; let relatedElements; if ('rotationPosition' in entry.dataset) { - relatedElements = grid.querySelectorAll( - '[data-y-position="' + entry.dataset.rotationPosition + '"]' + relatedElements = Array.from( + grid.querySelectorAll('[data-y-position="' + entry.dataset.rotationPosition + '"]') ); + + relatedElements.push(sideBar.childNodes[Number(entry.dataset.rotationPosition)]); } else { relatedElements = overlay.querySelectorAll( '[data-rotation-position="' + entry.dataset.entryPosition + '"]' ); } - relatedElements.forEach((relatedElement) => { - relatedElement.classList.remove('highlighted'); - }); + if (isHovered) { + relatedElements.forEach((relatedElement) => { + relatedElement.classList.add('highlighted'); + }); + } else { + relatedElements.forEach((relatedElement) => { + relatedElement.classList.remove('highlighted'); + }); + } } }