From 49a2c7375a61a282a8eb0092b32d089b97e2ceeb Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Fri, 28 Feb 2025 15:16:43 +0100 Subject: [PATCH 1/5] Redesign empty schedule view - Add `empty-notice` and `Add Rotation` button under `.day-header` - Hide clock --- library/Notifications/Widget/Timeline.php | 56 +++++++++++----- public/css/timeline.less | 81 ++++++++++++++++++++++- 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/library/Notifications/Widget/Timeline.php b/library/Notifications/Widget/Timeline.php index f185d3092..9335b4fc2 100644 --- a/library/Notifications/Widget/Timeline.php +++ b/library/Notifications/Widget/Timeline.php @@ -25,6 +25,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; @@ -313,21 +314,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( @@ -374,6 +360,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/timeline.less b/public/css/timeline.less index ff156a820..f2fa65e51 100644 --- a/public/css/timeline.less +++ b/public/css/timeline.less @@ -5,6 +5,20 @@ flex-direction: column; overflow: hidden; + .empty-state-notice { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1em; + margin-bottom: 2em; + + > span { + padding-left: 0.5em; + flex-grow: 1; + text-align: center; + } + } + .time-grid { --sidebarWidth: 12em; --stepRowHeight: 4em; @@ -44,6 +58,14 @@ } } + .btn-container { + display: flex; + justify-content: center; + align-items: center; + text-align: center; + height: var(--primaryRowHeight); + } + .overlay { .entry { margin-top: 1em; @@ -115,8 +137,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 { @@ -216,6 +238,16 @@ /* Design */ .timeline { + .empty-state-notice { + border: 1px solid @gray-light; + color: @text-color-light; + .rounded-corners(); + + .icon { + font-size: 1.75em; + } + } + .time-grid-header { background: @body-bg-color; } @@ -255,6 +287,24 @@ } } + .btn-container { + background-color: fade(@gray-light, 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; @@ -282,6 +332,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; @@ -290,6 +365,6 @@ 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; } From 20eae447c02babd44802de8e5219064e10cd84a0 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Thu, 6 Mar 2025 08:55:24 +0100 Subject: [PATCH 2/5] Redesign non-empty schedule view - Add button to the .overlay element - Overlap sidebar entry (.placeholder) with the button row --- .../Widget/TimeGrid/BaseGrid.php | 43 +++++++++++++++++-- library/Notifications/Widget/Timeline.php | 5 ++- public/css/calendar.less | 1 - public/css/timeline.less | 35 +++++++++++++-- 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/library/Notifications/Widget/TimeGrid/BaseGrid.php b/library/Notifications/Widget/TimeGrid/BaseGrid.php index 7fc94f4cc..3c9041979 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')) + ) + ); + + // occupy 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 9335b4fc2..b82a53f59 100644 --- a/library/Notifications/Widget/Timeline.php +++ b/library/Notifications/Widget/Timeline.php @@ -265,7 +265,7 @@ protected function getGrid() $this->grid = (new DynamicGrid($this, $this->getStyle(), $this->start))->setDays($this->days); } - if (! $this->minimalLayout) { + if (! $this->minimalLayout && ! empty($this->rotations)) { $rotations = $this->rotations; usort($rotations, function (Rotation $a, Rotation $b) { return $b->getPriority() <=> $a->getPriority(); @@ -277,6 +277,9 @@ protected function getGrid() $this->grid->addToSideBar($this->assembleSidebarEntry($rotation)); } } + + // placeholder for `.btn-container` to match the row count of grid. The result row is rendered below it + $this->grid->addToSideBar(new HtmlElement('div', new Attributes(['class' => 'placeholder']))); } } diff --git a/public/css/calendar.less b/public/css/calendar.less index bcef33df5..9c0cbf699 100644 --- a/public/css/calendar.less +++ b/public/css/calendar.less @@ -83,7 +83,6 @@ .grid, .overlay { display: grid; - overflow: hidden; 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/timeline.less b/public/css/timeline.less index f2fa65e51..404c58efe 100644 --- a/public/css/timeline.less +++ b/public/css/timeline.less @@ -64,9 +64,27 @@ align-items: center; text-align: center; height: var(--primaryRowHeight); + + .empty-notice { + margin-right: 1em; + } + + .button-link { + border: 1px solid; + } + + .hint { + margin-left: .5em; + } + } + + &:not(.empty) .btn-container { + margin-left: ~"calc(var(--sidebarWidth) * -1)"; // overlap sidebar's .placeholder } .overlay { + overflow: visible; // Required to extend the .btn-container to the sidebar. + .entry { margin-top: 1em; margin-bottom: 1em; @@ -288,21 +306,26 @@ } .btn-container { - background-color: fade(@gray-light, 25%); + pointer-events: all; // allow to click on the buttons + background-color: mix(@gray-light, @body-bg-color, 25%); .empty-notice { - margin-right: 1em; color: @text-color-light; } .button-link { - border: 1px solid; + color: @disabled-gray; + background: none; &:hover { color: @text-color-on-icinga-blue; background-color: @icinga-blue; } } + + .hint { + color: @text-color-light; + } } .entry .icon { @@ -368,3 +391,9 @@ #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 + } +}; From ba539006408a47439975fba7ca3393694edb67ca Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Thu, 6 Mar 2025 09:03:01 +0100 Subject: [PATCH 3/5] Remove now superfluous `Add Rotation` button from controls - Remove float for `cog` icon --- application/controllers/ScheduleController.php | 5 ----- public/css/schedule.less | 4 ---- 2 files changed, 9 deletions(-) diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 92cd3e7c4..90b84ac6c 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/public/css/schedule.less b/public/css/schedule.less index 5e0464dc9..393966e0f 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 { From 1aa04bc4c897ca9d80e07d4bd1d0fa1deddd28c7 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Mon, 31 Mar 2025 12:39:17 +0200 Subject: [PATCH 4/5] Highlight related sidebar row when result `.entry` is hovered --- public/css/schedule.less | 22 +++++++++++++++------- public/js/schedule.js | 14 ++++++++++---- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/public/css/schedule.less b/public/css/schedule.less index 393966e0f..c61fe4fc5 100644 --- a/public/css/schedule.less +++ b/public/css/schedule.less @@ -54,12 +54,20 @@ /* Design */ -.schedule-detail .entry.highlighted { - outline: 2px solid var(--entry-border-color); - outline-offset: 1px; -} +.schedule-detail { + .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/js/schedule.js b/public/js/schedule.js index 3119a3b03..cd0ac0212 100644 --- a/public/js/schedule.js +++ b/public/js/schedule.js @@ -79,12 +79,15 @@ 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 + '"]' @@ -101,12 +104,15 @@ 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 + '"]' From 7b9eed5a71bcfd06b7e844e367fcf0152bbdeb97 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Tue, 1 Apr 2025 11:07:56 +0200 Subject: [PATCH 5/5] schedule.js: Optimize duplicate code and add JSDoc --- public/js/schedule.js | 64 +++++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/public/js/schedule.js b/public/js/schedule.js index cd0ac0212..668b54fd0 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,33 +84,33 @@ 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; - const sideBar = grid.previousSibling; - - let relatedElements; - if ('rotationPosition' in entry.dataset) { - 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.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; @@ -119,9 +129,15 @@ ); } - relatedElements.forEach((relatedElement) => { - relatedElement.classList.remove('highlighted'); - }); + if (isHovered) { + relatedElements.forEach((relatedElement) => { + relatedElement.classList.add('highlighted'); + }); + } else { + relatedElements.forEach((relatedElement) => { + relatedElement.classList.remove('highlighted'); + }); + } } }