From 7390022aac07aab6723c0198812c5a9a05bee4a0 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 18 Apr 2024 13:59:08 +0200 Subject: [PATCH 01/36] BaseGrid: Don't try to use all available space if not required If a grid defines a lower maximum row span than what space is available, there is no need to fill all available space. --- .../Widget/Calendar/BaseGrid.php | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/library/Notifications/Widget/Calendar/BaseGrid.php b/library/Notifications/Widget/Calendar/BaseGrid.php index aeb06856..a75b6ea8 100644 --- a/library/Notifications/Widget/Calendar/BaseGrid.php +++ b/library/Notifications/Widget/Calendar/BaseGrid.php @@ -176,7 +176,11 @@ protected function assembleGridOverlay(BaseHtmlElement $overlay): void $overlay->addHtml($style); + $maxRowSpan = $this->getMaximumRowSpan(); $sectionsPerStep = $this->getSectionsPerStep(); + $rowStartModifier = $this->getRowStartModifier(); + // +1 because rows are 0-based here, but CSS grid rows are 1-based, hence the default modifier is 1 + $fillAvailableSpace = $maxRowSpan === ($sectionsPerStep - $rowStartModifier + 1); $gridStartsAt = $this->getGridStart(); $gridEndsAt = $this->getGridEnd(); @@ -222,8 +226,8 @@ protected function assembleGridOverlay(BaseHtmlElement $overlay): void continue; } - $rowStart = $row + $this->getRowStartModifier(); - $rowSpan = $this->getMaximumRowSpan(); + $rowStart = $row + $rowStartModifier; + $rowSpan = $maxRowSpan; $competingOccupiers = array_filter($occupiers, function ($id) use ($rowPlacements, $row) { return isset($rowPlacements[$id][$row]); @@ -235,11 +239,15 @@ protected function assembleGridOverlay(BaseHtmlElement $overlay): void foreach ($competingOccupiers as $otherId) { list($otherRowStart, $otherRowSpan) = $rowPlacements[$otherId][$row]; if ($otherRowStart === $rowStart) { - $otherRowSpan = (int) ceil($otherRowSpan / 2); - $rowStart += $otherRowSpan; - $rowSpan -= $otherRowSpan; - $rowPlacements[$otherId][$row] = [$otherRowStart, $otherRowSpan]; - } else { + if ($fillAvailableSpace) { + $otherRowSpan = (int) ceil($otherRowSpan / 2); + $rowStart += $otherRowSpan; + $rowSpan -= $otherRowSpan; + $rowPlacements[$otherId][$row] = [$otherRowStart, $otherRowSpan]; + } else { + $rowStart += $maxRowSpan; + } + } elseif ($fillAvailableSpace) { $rowSpan = $otherRowStart - $rowStart; break; // It occupies space now that was already reserved, so it should be safe to use } From 1b848f69d232abedd42952afeadb38178627c26f Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 19 Apr 2024 16:09:06 +0200 Subject: [PATCH 02/36] css: Describe concrete grids using variables This is the basis for dynamic grids that are defined by the server to be only as large as required. --- public/css/calendar.less | 154 +++++++++++++++------------------------ 1 file changed, 59 insertions(+), 95 deletions(-) diff --git a/public/css/calendar.less b/public/css/calendar.less index 3971c591..ffa68044 100644 --- a/public/css/calendar.less +++ b/public/css/calendar.less @@ -25,15 +25,29 @@ } } +/** + * Basic rules for a calendar grid + * + * Required variables: + * - --primaryColumns: Number of primary columns + * - --primaryRows: Number of primary rows + * - --columnsPerStep: Number of columns per step + * - --rowsPerStep: Number of rows per step + */ .calendar-grid { - @days: 7; + --sidebarWidth: 4em; + --headerHeight: 1.5em; + --minimumPrimaryColumnWidth: 2em; + --minimumStepColumnWidth: ~"calc(var(--minimumPrimaryColumnWidth) / var(--columnsPerStep))"; + --primaryRowHeight: 1fr; + --stepRowHeight: 1fr; .header { display: grid; grid-gap: 1px; border-left: 1px solid transparent; border-right: 1px solid transparent; - grid-template-columns: repeat(@days, minmax(2em, 1fr)); + grid-template-columns: repeat(var(--primaryColumns), minmax(var(--minimumPrimaryColumnWidth), 1fr)); .column-title { text-align: center; @@ -42,6 +56,8 @@ .sidebar { display: grid; + grid-gap: 1px; + grid-template-rows: repeat(var(--primaryRows), var(--primaryRowHeight)); .row-title { text-align: right; @@ -66,10 +82,20 @@ .grid, .overlay { + display: grid; + overflow: hidden; grid-gap: 1px; + 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: 1px solid transparent; } + .step { + position: relative; + grid-row-end: span var(--rowsPerStep); + grid-column-end: span var(--columnsPerStep); + } + .step, .entry { > a { @@ -150,125 +176,63 @@ } .calendar-grid.month { - @days: 7; - @weeks: 6; - @rowsPerDay: 5; - @columnsPerDay: 48; - - .sidebar { - grid-template-rows: repeat(@weeks, 1fr); - } - - .grid, - .overlay { - display: grid; - grid-template-rows: repeat(@weeks * @rowsPerDay, 1fr); - grid-template-columns: repeat(@days * @columnsPerDay, minmax(0, 1fr)); - overflow: hidden; - } - - .step { - grid-row-end: span @rowsPerDay; - grid-column-end: span @columnsPerDay; - position: relative; - - > a { - text-align: right; - &:first-of-type { - padding-right: .25em; - } + --primaryColumns: 7; // days + --primaryRows: 6; // weeks + --columnsPerStep: 48; // 24 hours + --rowsPerStep: 5; + + .step > a { + text-align: right; + &:first-of-type { + padding-right: .25em; } } } .calendar-grid.week { - @days: 7; - @hours: 24; - @rowsPerHour: 2; - @columnsPerDay: 4; - - .sidebar { - grid-template-rows: repeat(@hours, 1fr); - } - - .grid, - .overlay { - display: grid; - grid-template-rows: repeat(@hours * @rowsPerHour, 1fr); - grid-template-columns: repeat(@days * @columnsPerDay, minmax(2em, 1fr)); - } + --primaryColumns: 7; // days + --primaryRows: 24; // hours + --columnsPerStep: 4; + --rowsPerStep: 2; + --sidebarWidth: 6em; + --headerHeight: 2.5em; .grid, .overlay { border-left: none; } - - .step { - grid-column-end: span @columnsPerDay; - grid-row-end: span @rowsPerHour; - position: relative; - } } .calendar-grid.day { - @days: 1; - @hours: 24; - @rowsPerHour: 2; - @columnsPerDay: 28; - - .sidebar { - grid-template-rows: repeat(@hours, 1fr); - } - - .grid, - .overlay { - display: grid; - grid-template-rows: repeat(@hours * @rowsPerHour, 1fr); - grid-template-columns: repeat(@days * @columnsPerDay, minmax(2em, 1fr)); - } + --primaryColumns: 1; // days + --primaryRows: 24; // hours + --columnsPerStep: 28; + --rowsPerStep: 2; + --sidebarWidth: 6em; + --headerHeight: 2.5em; .grid, .overlay { border-left: none; } - - .step { - grid-column-end: span @columnsPerDay; - grid-row-end: span @rowsPerHour; - } } .calendar-grid { display: grid; + grid-template-columns: var(--sidebarWidth) minmax(0, 1fr); + grid-template-rows: var(--headerHeight) minmax(0, 1fr); - &.week, - &.day, - &.month { - .header { - grid-area: ~"1 / 2 / 2 / 3"; - } - - .sidebar { - grid-area: ~"2 / 1 / 3 / 2"; - } - - .grid, - .overlay { - grid-area: ~"2 / 2 / 3 / 3"; - } + .header { + grid-area: ~"1 / 2 / 2 / 3"; } - &.week { - grid-template-columns: 6em minmax(0, 1fr); - grid-template-rows: 2.5em minmax(0, 1fr); - } - &.month { - grid-template-columns: 4em minmax(0, 1fr); - grid-template-rows: 1.5em minmax(0, 1fr); + .sidebar { + grid-area: ~"2 / 1 / 3 / 2"; } - &.day { - grid-template-columns: 6em minmax(0, 1fr); - grid-template-rows: 2.5em minmax(0, 1fr); + + .grid, + .overlay { + grid-area: ~"2 / 2 / 3 / 3"; } .overlay { From 7d72996baa7f2ec7204deb2998b307e5b2e9464b Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 23 Apr 2024 17:14:53 +0200 Subject: [PATCH 03/36] BaseGrid: Automatically generate an entry's color based on its attendee name This allows us to finally get rid of the color setting of users and usergroups. --- .../Widget/Calendar/Attendee.php | 12 ----- .../Widget/Calendar/BaseGrid.php | 44 ++++++++++++++++++- library/Notifications/Widget/Schedule.php | 2 - public/css/calendar.less | 6 +-- 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/library/Notifications/Widget/Calendar/Attendee.php b/library/Notifications/Widget/Calendar/Attendee.php index c6d50804..249fabb3 100644 --- a/library/Notifications/Widget/Calendar/Attendee.php +++ b/library/Notifications/Widget/Calendar/Attendee.php @@ -50,16 +50,4 @@ public function getIcon(): ValidHtml return $icon; } - - public function setColor(string $color): self - { - $this->color = $color; - - return $this; - } - - public function getColor(): string - { - return $this->color; - } } diff --git a/library/Notifications/Widget/Calendar/BaseGrid.php b/library/Notifications/Widget/Calendar/BaseGrid.php index a75b6ea8..09f1e9fa 100644 --- a/library/Notifications/Widget/Calendar/BaseGrid.php +++ b/library/Notifications/Widget/Calendar/BaseGrid.php @@ -54,6 +54,9 @@ abstract class BaseGrid extends BaseHtmlElement /** @var array Extra counts stored as [date1 => count1, date2 => count2]*/ protected $extraEntriesCount = []; + /** @var array */ + protected $entryColors = []; + /** * Create a new calendar * @@ -317,9 +320,9 @@ protected function assembleGridOverlay(BaseHtmlElement $overlay): void } $style->add(".$entryClass", [ - '--entry-bg' => $entry->getAttendee()->getColor() . dechex((int) (256 * 0.1)), + '--entry-bg' => $this->getEntryColor($entry, 10), 'grid-area' => sprintf('~"%d / %d / %d / %d"', ...$gridArea), - 'border-color' => $entry->getAttendee()->getColor() . dechex((int) (256 * 0.5)) + 'border-color' => $this->getEntryColor($entry, 50) ]); $entryHtml = new HtmlElement( @@ -462,4 +465,41 @@ protected function roundToNearestThirtyMinute(DateTime $time): DateTime return $time; } + + /** + * Get the given attendee's color with the given transparency suitable for CSS + * + * @param Entry $entry + * @param int<0, 100> $transparency + * + * @return string + */ + protected function getEntryColor(Entry $entry, int $transparency): string + { + $attendeeName = $entry->getAttendee()->getName(); + if (! isset($this->entryColors[$attendeeName])) { + // Get a representation of the attendee's name suitable for conversion to a decimal + // TODO: There are how million colors in sRGB? Then why not use this as max value and ensure a good spread? + // Hashes always have a high number, so the reason why we use the remainder of the modulo operation + // below makes somehow sense, though it limits the variation to 360 colors which is not good enough. + // The saturation makes it more diverse, but only by a factor of 3. So essentially there are 360 * 3 + // colors. By far lower than the 16.7 million colors in sRGB. But of course, we need distinct colors + // so if 500 thousand colors of these 16.7 millions are so similar that we can't distinguish them, + // there's no need for such a high variance. Hence we'd still need to partition the colors in a way + // that they are distinct enough. + $hash = hexdec(hash('sha256', $attendeeName)); + // Limit the hue to a maximum of 360 as it's HSL's maximum of 360 degrees + $h = (int) fmod($hash, 359.0); // TODO: Check if 359 is really of advantage here, instead of 360 + // The hue is already at least 1 degree off to every other, using a limited set of saturation values + // further ensures that colors are distinct enough even if similar + $s = [35, 50, 65][$h % 3]; + + $this->entryColors[$attendeeName] = [$h, $s]; + } else { + [$h, $s] = $this->entryColors[$attendeeName]; + } + + // We use a fixed luminosity to ensure good and equal contrast in both dark and light mode + return sprintf('~"hsl(%d %d%% 50%% / %d%%)"', $h, $s, $transparency); + } } diff --git a/library/Notifications/Widget/Schedule.php b/library/Notifications/Widget/Schedule.php index abfdd3b1..6d971632 100644 --- a/library/Notifications/Widget/Schedule.php +++ b/library/Notifications/Widget/Schedule.php @@ -89,10 +89,8 @@ protected function assembleCalendar(Calendar $calendar): void foreach ($members as $member) { if ($member->contact_id !== null) { $attendee = new Attendee($member->contact->full_name); - $attendee->setColor($member->contact->color); } else { // $member->contactgroup_id !== null $attendee = new Attendee($member->contactgroup->name); - $attendee->setColor($member->contactgroup->color); $attendee->setIcon('users'); } diff --git a/public/css/calendar.less b/public/css/calendar.less index ffa68044..89bda69d 100644 --- a/public/css/calendar.less +++ b/public/css/calendar.less @@ -327,6 +327,7 @@ border-width: 1px; border-style: solid; background-color: var(--entry-bg); + mix-blend-mode: screen; .rounded-corners(); a:hover { @@ -372,9 +373,6 @@ @light-mode: { .calendar .entry { - .title, - .description { - mix-blend-mode: multiply; - } + mix-blend-mode: revert; } }; From 0495cbc62fb4f7ca2f4c4dbb9064a9300436cafb Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 24 Apr 2024 09:57:30 +0200 Subject: [PATCH 04/36] DayGrid: Fix incorrect step interval --- library/Notifications/Widget/Calendar/DayGrid.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Notifications/Widget/Calendar/DayGrid.php b/library/Notifications/Widget/Calendar/DayGrid.php index 82ddab32..bc8b2ed6 100644 --- a/library/Notifications/Widget/Calendar/DayGrid.php +++ b/library/Notifications/Widget/Calendar/DayGrid.php @@ -46,7 +46,7 @@ protected function getGridArea(int $rowStart, int $rowEnd, int $colStart, int $c protected function createGridSteps(): Traversable { - $interval = new DateInterval('P1D'); + $interval = new DateInterval('PT1H'); $hourStartsAt = clone $this->getGridStart(); for ($i = 0; $i < 24; $i++) { yield $hourStartsAt; From 0457c1fe1404a86b0274385bf292461621dcf2b7 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 24 Apr 2024 09:59:05 +0200 Subject: [PATCH 05/36] BaseGrid: Move extra entry assembly to the base grid Extra entries shouldn't be dependent on the type of grid. Their placement relies of course on it, but not their existence. So with the new type for grid steps, it's now native to the base implementation and concrete grids only need to describe their steps in more detail. --- library/Notifications/Widget/Calendar.php | 1 + .../Widget/Calendar/BaseGrid.php | 37 ++++----- .../Notifications/Widget/Calendar/DayGrid.php | 7 +- .../Widget/Calendar/GridStep.php | 81 +++++++++++++++++++ .../Widget/Calendar/MonthGrid.php | 25 +++--- .../Widget/Calendar/WeekGrid.php | 27 +++---- 6 files changed, 123 insertions(+), 55 deletions(-) create mode 100644 library/Notifications/Widget/Calendar/GridStep.php diff --git a/library/Notifications/Widget/Calendar.php b/library/Notifications/Widget/Calendar.php index 9de477f8..3237d56a 100644 --- a/library/Notifications/Widget/Calendar.php +++ b/library/Notifications/Widget/Calendar.php @@ -9,6 +9,7 @@ use Icinga\Module\Notifications\Widget\Calendar\Controls; use Icinga\Module\Notifications\Widget\Calendar\DayGrid; use Icinga\Module\Notifications\Widget\Calendar\Entry; +use Icinga\Module\Notifications\Widget\Calendar\GridStep; use Icinga\Module\Notifications\Widget\Calendar\MonthGrid; use Icinga\Module\Notifications\Widget\Calendar\Util; use Icinga\Module\Notifications\Widget\Calendar\WeekGrid; diff --git a/library/Notifications/Widget/Calendar/BaseGrid.php b/library/Notifications/Widget/Calendar/BaseGrid.php index 09f1e9fa..06a9141a 100644 --- a/library/Notifications/Widget/Calendar/BaseGrid.php +++ b/library/Notifications/Widget/Calendar/BaseGrid.php @@ -89,6 +89,11 @@ public function getGridEnd(): DateTime return $this->end; } + /** + * Create steps to show on the grid + * + * @return Traversable + */ abstract protected function createGridSteps(): Traversable; abstract protected function calculateGridEnd(): DateTime; @@ -124,32 +129,28 @@ protected function createGrid(): BaseHtmlElement protected function assembleGrid(BaseHtmlElement $grid): void { $url = $this->calendar->getAddEntryUrl(); - foreach ($this->createGridSteps() as $gridStep) { - $step = new HtmlElement( - 'div', - Attributes::create([ - 'class' => 'step', - 'data-start' => $gridStep->format(DateTimeInterface::ATOM) - ]) - ); - + foreach ($this->createGridSteps() as $step) { if ($url !== null) { - $content = new Link(null, $url->with('start', $gridStep->format('Y-m-d\TH:i:s'))); - $step->addHtml($content); - } else { - $content = $step; + $content = new Link(null, $url->with('start', $step->getStart()->format('Y-m-d\TH:i:s'))); + $content->addFrom($step); + $step->setHtmlContent($content); } - $this->assembleGridStep($content, $gridStep); + if ($step->getEnd()->format('H') === '00') { + $extraEntryUrl = $this->calendar->prepareDayViewUrl($step->getStart()); + if ($extraEntryUrl !== null) { + $step->addHtml( + (new ExtraEntryCount(null, $extraEntryUrl)) + ->setGrid($this) + ->setGridStep($step->getStart()) + ); + } + } $grid->addHtml($step); } } - protected function assembleGridStep(BaseHtmlElement $content, DateTime $step): void - { - } - protected function createGridOverlay(): BaseHtmlElement { $overlay = new HtmlElement('div', Attributes::create(['class' => 'overlay'])); diff --git a/library/Notifications/Widget/Calendar/DayGrid.php b/library/Notifications/Widget/Calendar/DayGrid.php index bc8b2ed6..24c25afb 100644 --- a/library/Notifications/Widget/Calendar/DayGrid.php +++ b/library/Notifications/Widget/Calendar/DayGrid.php @@ -49,9 +49,11 @@ protected function createGridSteps(): Traversable $interval = new DateInterval('PT1H'); $hourStartsAt = clone $this->getGridStart(); for ($i = 0; $i < 24; $i++) { - yield $hourStartsAt; + $nextHour = (clone $hourStartsAt)->add($interval); - $hourStartsAt->add($interval); + yield new GridStep($hourStartsAt, $nextHour, 0, $i); + + $hourStartsAt = $nextHour; } } @@ -69,7 +71,6 @@ protected function createHeader(): BaseHtmlElement ]; $currentDay = clone $this->getGridStart(); - $interval = new DateInterval('P1D'); $header->addHtml(new HtmlElement( 'div', Attributes::create(['class' => 'column-title']), diff --git a/library/Notifications/Widget/Calendar/GridStep.php b/library/Notifications/Widget/Calendar/GridStep.php new file mode 100644 index 00000000..73d452bb --- /dev/null +++ b/library/Notifications/Widget/Calendar/GridStep.php @@ -0,0 +1,81 @@ + 'step']; + + /** + * Create a new grid step + * + * @param DateTime $start The start time of the grid step + * @param DateTime $end The end time of the grid step + * @param int $x The x position of the step on the grid + * @param int $y The y position of the step on the grid + */ + public function __construct(DateTime $start, DateTime $end, int $x, int $y) + { + $this->start = $start; + $this->end = $end; + $this->coordinates = [$x, $y]; + } + + /** + * Get the start time of the grid step + * + * @return DateTime + */ + public function getStart(): DateTime + { + return $this->start; + } + + /** + * Get the end time of the grid step + * + * @return DateTime + */ + public function getEnd(): DateTime + { + return $this->end; + } + + /** + * Get the coordinates of the grid step + * + * @return array{int, int} The x and y position of the step on the grid + */ + public function getCoordinates(): array + { + return $this->coordinates; + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + $this->getAttributes()->registerAttributeCallback('data-start', function () { + return $this->getStart()->format(DateTimeInterface::ATOM); + }); + } +} diff --git a/library/Notifications/Widget/Calendar/MonthGrid.php b/library/Notifications/Widget/Calendar/MonthGrid.php index 65f9dea1..b34f16e6 100644 --- a/library/Notifications/Widget/Calendar/MonthGrid.php +++ b/library/Notifications/Widget/Calendar/MonthGrid.php @@ -33,20 +33,6 @@ protected function calculateGridEnd(): DateTime return (clone $this->getGridStart())->add(new DateInterval('P42D')); } - protected function assembleGridStep(BaseHtmlElement $content, DateTime $step): void - { - $content->addHtml(Text::create($step->format('j'))); - - $dayViewUrl = $this->calendar->prepareDayViewUrl($step); - if ($dayViewUrl !== null) { - $content->addHtml( - (new ExtraEntryCount(null, $dayViewUrl)) - ->setGrid($this) - ->setGridStep($step) - ); - } - } - protected function getRowStartModifier(): int { return 2; // The month grid needs the first row for other things @@ -72,9 +58,16 @@ protected function createGridSteps(): Traversable $interval = new DateInterval('P1D'); $currentDay = clone $this->getGridStart(); for ($i = 0; $i < 42; $i++) { - yield $currentDay; + $nextDay = (clone $currentDay)->add($interval); + + yield (new GridStep( + $currentDay, + $nextDay, + $i % 7, + (int) floor($i / 7) + ))->addHtml(Text::create($currentDay->format('j'))); - $currentDay->add($interval); + $currentDay = $nextDay; } } diff --git a/library/Notifications/Widget/Calendar/WeekGrid.php b/library/Notifications/Widget/Calendar/WeekGrid.php index f874173f..f696aad2 100644 --- a/library/Notifications/Widget/Calendar/WeekGrid.php +++ b/library/Notifications/Widget/Calendar/WeekGrid.php @@ -41,17 +41,22 @@ protected function getGridArea(int $rowStart, int $rowEnd, int $colStart, int $c protected function createGridSteps(): Traversable { - $interval = new DateInterval('P1D'); + $oneDay = new DateInterval('P1D'); + $oneHour = new DateInterval('PT1H'); $hourStartsAt = clone $this->getGridStart(); + $nextHour = (clone $hourStartsAt)->add($oneHour); for ($i = 0; $i < 7 * 24; $i++) { - if ($i > 0 && $i % 7 === 0) { + $x = $i % 7; + if ($i > 0 && $x === 0) { $hourStartsAt = clone $this->getGridStart(); $hourStartsAt->add(new DateInterval(sprintf('PT%dH', $i / 7))); + $nextHour = (clone $hourStartsAt)->add($oneHour); } - yield $hourStartsAt; + yield new GridStep($hourStartsAt, $nextHour, $x, (int) floor($i / 7)); - $hourStartsAt->add($interval); + $hourStartsAt = (clone $hourStartsAt)->add($oneDay); + $nextHour = (clone $nextHour)->add($oneDay); } } @@ -116,20 +121,6 @@ protected function createSidebar(): BaseHtmlElement return $sidebar; } - protected function assembleGridStep(BaseHtmlElement $content, DateTime $step): void - { - if ($step->format('H') === '23') { - $dayViewUrl = $this->calendar->prepareDayViewUrl($step); - if ($dayViewUrl !== null) { - $content->addHtml( - (new ExtraEntryCount(null, $dayViewUrl)) - ->setGrid($this) - ->setGridStep($step) - ); - } - } - } - protected function assemble() { $this->getAttributes()->add('class', 'week'); From b7a09368847353899782d0913ed7aef61b7d5f92 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 24 Apr 2024 16:00:22 +0200 Subject: [PATCH 06/36] Add interface `EntryProvider` and use it for the `Calendar` widget Allows me to use a less calendar like widget for the timeline. --- library/Notifications/Widget/Calendar.php | 15 +++++--- .../Widget/Calendar/BaseGrid.php | 22 +++++------ .../Widget/Calendar/EntryProvider.php | 38 +++++++++++++++++++ 3 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 library/Notifications/Widget/Calendar/EntryProvider.php diff --git a/library/Notifications/Widget/Calendar.php b/library/Notifications/Widget/Calendar.php index 3237d56a..fbd3cab5 100644 --- a/library/Notifications/Widget/Calendar.php +++ b/library/Notifications/Widget/Calendar.php @@ -9,6 +9,7 @@ use Icinga\Module\Notifications\Widget\Calendar\Controls; use Icinga\Module\Notifications\Widget\Calendar\DayGrid; use Icinga\Module\Notifications\Widget\Calendar\Entry; +use Icinga\Module\Notifications\Widget\Calendar\EntryProvider; use Icinga\Module\Notifications\Widget\Calendar\GridStep; use Icinga\Module\Notifications\Widget\Calendar\MonthGrid; use Icinga\Module\Notifications\Widget\Calendar\Util; @@ -25,7 +26,7 @@ use LogicException; use Traversable; -class Calendar extends BaseHtmlElement +class Calendar extends BaseHtmlElement implements EntryProvider { /** @var string Mode to show an entire month */ public const MODE_MONTH = 'month'; @@ -78,9 +79,13 @@ public function setAddEntryUrl(?Url $url): self return $this; } - public function getAddEntryUrl(): ?Url + public function getStepUrl(GridStep $step): ?Url { - return $this->addEntryUrl; + if ($this->addEntryUrl === null) { + return null; + } + + return $this->addEntryUrl->with('start', $step->getStart()->format('Y-m-d\TH:i:s')); } public function setUrl(?Url $url): self @@ -90,12 +95,12 @@ public function setUrl(?Url $url): self return $this; } - public function prepareDayViewUrl(DateTime $date): ?Url + public function getExtraEntryUrl(GridStep $step): ?Url { return $this->url ? (clone $this->url)->overwriteParams([ 'mode' => 'day', - 'day' => $date->format('Y-m-d') + 'day' => $step->getStart()->format('Y-m-d') ]) : null; } diff --git a/library/Notifications/Widget/Calendar/BaseGrid.php b/library/Notifications/Widget/Calendar/BaseGrid.php index 06a9141a..ec290117 100644 --- a/library/Notifications/Widget/Calendar/BaseGrid.php +++ b/library/Notifications/Widget/Calendar/BaseGrid.php @@ -7,7 +7,6 @@ use DateInterval; use DateTime; use DateTimeInterface; -use Icinga\Module\Notifications\Widget\Calendar; use Icinga\Util\Csp; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; @@ -42,8 +41,8 @@ abstract class BaseGrid extends BaseHtmlElement protected $defaultAttributes = ['class' => 'calendar-grid']; - /** @var Calendar */ - protected $calendar; + /** @var EntryProvider */ + protected $provider; /** @var DateTime */ protected $start; @@ -58,13 +57,14 @@ abstract class BaseGrid extends BaseHtmlElement protected $entryColors = []; /** - * Create a new calendar + * Create a new time grid * + * @param EntryProvider $provider The provider for the grid's entries * @param DateTime $start When the shown timespan should start */ - public function __construct(Calendar $calendar, DateTime $start) + public function __construct(EntryProvider $provider, DateTime $start) { - $this->calendar = $calendar; + $this->provider = $provider; $this->setGridStart($start); } @@ -128,16 +128,14 @@ protected function createGrid(): BaseHtmlElement protected function assembleGrid(BaseHtmlElement $grid): void { - $url = $this->calendar->getAddEntryUrl(); foreach ($this->createGridSteps() as $step) { + $url = $this->provider->getStepUrl($step); if ($url !== null) { - $content = new Link(null, $url->with('start', $step->getStart()->format('Y-m-d\TH:i:s'))); - $content->addFrom($step); - $step->setHtmlContent($content); + $step->setHtmlContent((new Link(null, $url))->addFrom($step)); } if ($step->getEnd()->format('H') === '00') { - $extraEntryUrl = $this->calendar->prepareDayViewUrl($step->getStart()); + $extraEntryUrl = $this->provider->getExtraEntryUrl($step); if ($extraEntryUrl !== null) { $step->addHtml( (new ExtraEntryCount(null, $extraEntryUrl)) @@ -194,7 +192,7 @@ protected function assembleGridOverlay(BaseHtmlElement $overlay): void $cellOccupiers = []; /** @var SplObjectStorage $occupiedCells */ $occupiedCells = new SplObjectStorage(); - foreach ($this->calendar->getEntries() as $entry) { + foreach ($this->provider->getEntries() as $entry) { $actualStart = $this->roundToNearestThirtyMinute($entry->getStart()); if ($actualStart < $gridStartsAt) { $entryStartPos = 0; diff --git a/library/Notifications/Widget/Calendar/EntryProvider.php b/library/Notifications/Widget/Calendar/EntryProvider.php new file mode 100644 index 00000000..df0e2d32 --- /dev/null +++ b/library/Notifications/Widget/Calendar/EntryProvider.php @@ -0,0 +1,38 @@ + + */ + public function getEntries(): Traversable; + + /** + * Get the URL to use for the given grid step + * + * @param GridStep $step A step, as calculated by the grid + * + * @return ?Url + */ + public function getStepUrl(GridStep $step): ?Url; + + /** + * Get the URL to show any extraneous entries which don't fit onto the given grid step + * + * This is called each time an entire day has passed on the grid and a step represents the end of the day, even + * if there are no extraneous entries to show. Depending on the structure of the grid, and the flow of steps, + * it might be necessary to conditionally return a URL here, to avoid showing the same URL multiple times. + * + * @param GridStep $step A step, as calculated by the grid + * + * @return ?Url + */ + public function getExtraEntryUrl(GridStep $step): ?Url; +} From c84fc0a4780f21bb400ae85a7fcf74284d1adb55 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 24 Apr 2024 17:09:32 +0200 Subject: [PATCH 07/36] Move style initialization to the schedule widget I'd like to move the calendar and grid implementation to ipl-web some day, and this is one obstacle less. --- library/Notifications/Widget/Calendar.php | 29 ++++++++++++++++--- .../Widget/Calendar/BaseGrid.php | 26 ++++++++--------- library/Notifications/Widget/Schedule.php | 9 +++++- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/library/Notifications/Widget/Calendar.php b/library/Notifications/Widget/Calendar.php index fbd3cab5..a043e6cb 100644 --- a/library/Notifications/Widget/Calendar.php +++ b/library/Notifications/Widget/Calendar.php @@ -22,6 +22,7 @@ use ipl\Html\Text; use ipl\I18n\StaticTranslator; use ipl\Scheduler\RRule; +use ipl\Web\Style; use ipl\Web\Url; use LogicException; use Traversable; @@ -44,6 +45,9 @@ class Calendar extends BaseHtmlElement implements EntryProvider /** @var Controls */ protected $controls; + /** @var Style */ + protected $style; + /** @var BaseGrid The grid implementation */ protected $grid; @@ -72,6 +76,22 @@ public function getControls(): Controls return $this->controls; } + public function setStyle(Style $style): self + { + $this->style = $style; + + return $this; + } + + public function getStyle(): Style + { + if ($this->style === null) { + $this->style = new Style(); + } + + return $this->style; + } + public function setAddEntryUrl(?Url $url): self { $this->addEntryUrl = $url; @@ -127,11 +147,11 @@ public function getGrid(): BaseGrid { if ($this->grid === null) { if ($this->getControls()->getViewMode() === self::MODE_MONTH) { - $this->grid = new MonthGrid($this, $this->getModeStart()); + $this->grid = new MonthGrid($this, $this->getStyle(), $this->getModeStart()); } elseif ($this->getControls()->getViewMode() === self::MODE_WEEK) { - $this->grid = new WeekGrid($this, $this->getModeStart()); + $this->grid = new WeekGrid($this, $this->getStyle(), $this->getModeStart()); } else { - $this->grid = new DayGrid($this, $this->getModeStart()); + $this->grid = new DayGrid($this, $this->getStyle(), $this->getModeStart()); } } @@ -207,7 +227,8 @@ protected function assemble() new HtmlElement('strong', null, Text::create($month)), $modeStart->format('Y') )), - $this->getGrid() + $this->getGrid(), + $this->getStyle() ); } } diff --git a/library/Notifications/Widget/Calendar/BaseGrid.php b/library/Notifications/Widget/Calendar/BaseGrid.php index ec290117..2cd01774 100644 --- a/library/Notifications/Widget/Calendar/BaseGrid.php +++ b/library/Notifications/Widget/Calendar/BaseGrid.php @@ -7,7 +7,6 @@ use DateInterval; use DateTime; use DateTimeInterface; -use Icinga\Util\Csp; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; use ipl\Html\HtmlElement; @@ -44,6 +43,9 @@ abstract class BaseGrid extends BaseHtmlElement /** @var EntryProvider */ protected $provider; + /** @var Style */ + protected $style; + /** @var DateTime */ protected $start; @@ -60,11 +62,13 @@ abstract class BaseGrid extends BaseHtmlElement * Create a new time grid * * @param EntryProvider $provider The provider for the grid's entries + * @param Style $style Required to place entries onto the grid's overlay * @param DateTime $start When the shown timespan should start */ - public function __construct(EntryProvider $provider, DateTime $start) + public function __construct(EntryProvider $provider, Style $style, DateTime $start) { $this->provider = $provider; + $this->style = $style; $this->setGridStart($start); } @@ -172,12 +176,6 @@ public function getExtraEntryCount(DateTime $date): int protected function assembleGridOverlay(BaseHtmlElement $overlay): void { - $style = (new Style())->setNonce(Csp::getStyleNonce()); - $style->setModule('notifications'); // TODO: Don't hardcode this! - $style->setSelector('.calendar-grid .overlay'); - - $overlay->addHtml($style); - $maxRowSpan = $this->getMaximumRowSpan(); $sectionsPerStep = $this->getSectionsPerStep(); $rowStartModifier = $this->getRowStartModifier(); @@ -318,12 +316,6 @@ protected function assembleGridOverlay(BaseHtmlElement $overlay): void $gradientClass = 'ending-gradient'; } - $style->add(".$entryClass", [ - '--entry-bg' => $this->getEntryColor($entry, 10), - 'grid-area' => sprintf('~"%d / %d / %d / %d"', ...$gridArea), - 'border-color' => $this->getEntryColor($entry, 50) - ]); - $entryHtml = new HtmlElement( 'div', Attributes::create([ @@ -336,6 +328,12 @@ protected function assembleGridOverlay(BaseHtmlElement $overlay): void ]) ); + $this->style->addFor($entryHtml, [ + '--entry-bg' => $this->getEntryColor($entry, 10), + 'grid-area' => sprintf('~"%d / %d / %d / %d"', ...$gridArea), + 'border-color' => $this->getEntryColor($entry, 50) + ]); + if ($fromPrevGrid) { $continuationType = $toNextGrid ? self::ACROSS_GRID : self::FROM_PREV_GRID; } elseif ($toNextGrid) { diff --git a/library/Notifications/Widget/Schedule.php b/library/Notifications/Widget/Schedule.php index 6d971632..a32aad7d 100644 --- a/library/Notifications/Widget/Schedule.php +++ b/library/Notifications/Widget/Schedule.php @@ -11,11 +11,13 @@ use Icinga\Module\Notifications\Widget\Calendar\Attendee; use Icinga\Module\Notifications\Widget\Calendar\Controls; use Icinga\Module\Notifications\Widget\Calendar\Entry; +use Icinga\Util\Csp; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; use ipl\Html\HtmlElement; use ipl\Stdlib\Filter; use ipl\Web\Common\BaseTarget; +use ipl\Web\Style; use ipl\Web\Url; use ipl\Web\Widget\Icon; use ipl\Web\Widget\Link; @@ -113,7 +115,12 @@ protected function assembleCalendar(Calendar $calendar): void public function assemble() { $calendar = (new Calendar()) - ->setControls($this->controls); + ->setControls($this->controls) + ->setStyle( + (new Style()) + ->setNonce(Csp::getStyleNonce()) + ->setModule('notifications') + ); $this->setBaseTarget('entry-form'); if ($this->controls->getBaseTarget() === null) { From 5c53b18b766e62e01203920334489b36feb95e13 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 25 Apr 2024 13:54:53 +0200 Subject: [PATCH 08/36] BaseGrid: Allow grids to have an infinite number of rows --- .../Widget/Calendar/BaseGrid.php | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/library/Notifications/Widget/Calendar/BaseGrid.php b/library/Notifications/Widget/Calendar/BaseGrid.php index 2cd01774..527e1c85 100644 --- a/library/Notifications/Widget/Calendar/BaseGrid.php +++ b/library/Notifications/Widget/Calendar/BaseGrid.php @@ -14,6 +14,7 @@ use ipl\I18n\Translation; use ipl\Web\Style; use ipl\Web\Widget\Link; +use LogicException; use SplObjectStorage; use Traversable; @@ -36,6 +37,9 @@ abstract class BaseGrid extends BaseHtmlElement /** @var string Continuation type of the entry row continuing across edges of the grid */ public const ACROSS_EDGES = 'across-edges'; + /** @var int Return this in {@see getSectionsPerStep} to signal an infinite number of sections */ + protected const INFINITE = 0; + protected $tag = 'div'; protected $defaultAttributes = ['class' => 'calendar-grid']; @@ -179,14 +183,27 @@ protected function assembleGridOverlay(BaseHtmlElement $overlay): void $maxRowSpan = $this->getMaximumRowSpan(); $sectionsPerStep = $this->getSectionsPerStep(); $rowStartModifier = $this->getRowStartModifier(); - // +1 because rows are 0-based here, but CSS grid rows are 1-based, hence the default modifier is 1 - $fillAvailableSpace = $maxRowSpan === ($sectionsPerStep - $rowStartModifier + 1); + + $infiniteSections = $sectionsPerStep === self::INFINITE; + if ($infiniteSections) { + $fillAvailableSpace = false; + } else { + // +1 because rows are 0-based here, but CSS grid rows are 1-based, hence the default modifier is 1 + $fillAvailableSpace = $maxRowSpan === ($sectionsPerStep - $rowStartModifier + 1); + } $gridStartsAt = $this->getGridStart(); $gridEndsAt = $this->getGridEnd(); $amountOfDays = $gridStartsAt->diff($gridEndsAt)->days; $gridBorderAt = $this->getNoOfVisuallyConnectedHours() * 2; + if ($infiniteSections && $amountOfDays !== $gridBorderAt / 48) { + throw new LogicException( + 'The number of days in the grid must match the number of visually' + . ' connected hours, when an infinite number of sections is used.' + ); + } + $cellOccupiers = []; /** @var SplObjectStorage $occupiedCells */ $occupiedCells = new SplObjectStorage(); @@ -272,7 +289,7 @@ protected function assembleGridOverlay(BaseHtmlElement $overlay): void $colEnd = max($hours); // Calculate number of entries that are not displayed in the grid for each date - if ($rowStart > $row + $sectionsPerStep) { + if (! $infiniteSections && $rowStart > $row + $sectionsPerStep) { $startOffset = (int) (($row / $sectionsPerStep) * ($gridBorderAt / 48) + $colStart / 48); $endOffset = (int) (($row / $sectionsPerStep) * ($gridBorderAt / 48) + $colEnd / 48); $startDate = (clone $this->getGridStart())->add(new DateInterval("P$startOffset" . 'D')); @@ -349,6 +366,19 @@ protected function assembleGridOverlay(BaseHtmlElement $overlay): void $remainingRows -= 1; } } + + if ($infiniteSections) { + $lastRow = array_reduce($rowPlacements, function ($carry, $placements) { + return array_reduce($placements, function ($carry, $placement) { + return max($placement[0] + $placement[1], $carry); + }, $carry); + }, 1); + + $this->style->addFor($this, [ + '--primaryRows' => $lastRow === 1 ? 1 : ($lastRow - $rowStartModifier) / $maxRowSpan, + '--rowsPerStep' => $maxRowSpan + ]); + } } /** From 52c92f1dfc75483bb11b64adac21d571af0c9170 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 25 Apr 2024 13:55:25 +0200 Subject: [PATCH 09/36] BaseGrid: Provide base implementation for `getGridArea()` --- library/Notifications/Widget/Calendar/BaseGrid.php | 5 ++++- library/Notifications/Widget/Calendar/MonthGrid.php | 5 ----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/library/Notifications/Widget/Calendar/BaseGrid.php b/library/Notifications/Widget/Calendar/BaseGrid.php index 527e1c85..1792bd0b 100644 --- a/library/Notifications/Widget/Calendar/BaseGrid.php +++ b/library/Notifications/Widget/Calendar/BaseGrid.php @@ -108,7 +108,10 @@ abstract protected function calculateGridEnd(): DateTime; abstract protected function getNoOfVisuallyConnectedHours(): int; - abstract protected function getGridArea(int $rowStart, int $rowEnd, int $colStart, int $colEnd): array; + protected function getGridArea(int $rowStart, int $rowEnd, int $colStart, int $colEnd): array + { + return [$rowStart, $colStart, $rowEnd, $colEnd]; + } protected function getSectionsPerStep(): int { diff --git a/library/Notifications/Widget/Calendar/MonthGrid.php b/library/Notifications/Widget/Calendar/MonthGrid.php index b34f16e6..2aeafe61 100644 --- a/library/Notifications/Widget/Calendar/MonthGrid.php +++ b/library/Notifications/Widget/Calendar/MonthGrid.php @@ -48,11 +48,6 @@ protected function getNoOfVisuallyConnectedHours(): int return 7 * 24; } - protected function getGridArea(int $rowStart, int $rowEnd, int $colStart, int $colEnd): array - { - return [$rowStart, $colStart, $rowEnd, $colEnd]; - } - protected function createGridSteps(): Traversable { $interval = new DateInterval('P1D'); From 38c68b26d2c0154c6d509db951117abc13631987 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 25 Apr 2024 17:01:22 +0200 Subject: [PATCH 10/36] Introduce new class `DynamicGrid` The class name is probably not final yet, neither is the path. --- .../Widget/Calendar/DynamicGrid.php | 202 ++++++++++++++++++ public/css/calendar.less | 3 +- 2 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 library/Notifications/Widget/Calendar/DynamicGrid.php diff --git a/library/Notifications/Widget/Calendar/DynamicGrid.php b/library/Notifications/Widget/Calendar/DynamicGrid.php new file mode 100644 index 00000000..c0005841 --- /dev/null +++ b/library/Notifications/Widget/Calendar/DynamicGrid.php @@ -0,0 +1,202 @@ +format('H:i:s') !== '00:00:00') { + throw new InvalidArgumentException('Start is not midnight'); + } + + return parent::setGridStart($start); + } + + /** + * Set the number of days to show + * + * @param int $days + * + * @return $this + */ + public function setDays(int $days): self + { + $this->days = $days; + + return $this; + } + + /** + * Add the given element as row to the sidebar + * + * @param BaseHtmlElement $row + * + * @return $this + */ + public function addToSideBar(BaseHtmlElement $row): self + { + $row->addAttributes(['class' => 'row-title']); + $this->sideBar()->addHtml($row); + + return $this; + } + + protected function calculateGridEnd(): DateTime + { + return (clone $this->getGridStart())->add(new DateInterval(sprintf('P%dD', $this->days))); + } + + protected function getNoOfVisuallyConnectedHours(): int + { + return $this->days * 24; + } + + protected function getSectionsPerStep(): int + { + return self::INFINITE; + } + + protected function getMaximumRowSpan(): int + { + return 1; + } + + /** + * Get this grid's sidebar + * + * @return BaseHtmlElement + */ + protected function sideBar(): BaseHtmlElement + { + if ($this->sideBar === null) { + $this->sideBar = new HtmlElement('div', Attributes::create(['class' => 'sidebar'])); + } + + return $this->sideBar; + } + + 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; + } + + protected function createGridSteps(): Traversable + { + $interval = new DateInterval('P1D'); + $dayStartsAt = clone $this->getGridStart(); + $primaryRows = count($this->sideBar()); + if ($primaryRows === 0) { + throw new LogicException('At least one row in the sidebar is required'); + } + + for ($y = 0; $y < $primaryRows; $y++) { + for ($x = 0; $x < $this->days; $x++) { + $nextDay = (clone $dayStartsAt)->add($interval); + + yield new GridStep($dayStartsAt, $nextDay, $x, $y); + + $dayStartsAt = $nextDay; + } + + $dayStartsAt = clone $this->getGridStart(); + } + } + + protected function assemble() + { + $this->getAttributes()->add('class', 'dynamic'); + + $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->createHeader(), + $this->sideBar(), + $this->createGrid(), + $overlay + ); + } +} diff --git a/public/css/calendar.less b/public/css/calendar.less index 89bda69d..7568f0fe 100644 --- a/public/css/calendar.less +++ b/public/css/calendar.less @@ -259,7 +259,8 @@ font-weight: normal; } - .calendar-grid.month { + .calendar-grid.month, + .calendar-grid.dynamic { .entry { &.two-way-gradient { border-radius: 0; From 6047ee9c75ecde3c31f29538de94fbb5f9ec33ab Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 26 Apr 2024 16:18:33 +0200 Subject: [PATCH 11/36] BaseGrid: Split up entry placement and assembly Allows to add a simpler implementation for fixed layouts. --- .../Widget/Calendar/BaseGrid.php | 138 ++++++++++++------ 1 file changed, 91 insertions(+), 47 deletions(-) diff --git a/library/Notifications/Widget/Calendar/BaseGrid.php b/library/Notifications/Widget/Calendar/BaseGrid.php index 1792bd0b..d5532ff9 100644 --- a/library/Notifications/Widget/Calendar/BaseGrid.php +++ b/library/Notifications/Widget/Calendar/BaseGrid.php @@ -7,6 +7,7 @@ use DateInterval; use DateTime; use DateTimeInterface; +use Generator; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; use ipl\Html\HtmlElement; @@ -19,23 +20,32 @@ use Traversable; /** - * @phpstan-type ContinuationType self::ACROSS_GRID | self::FROM_PREV_GRID | self::TO_NEXT_GRID | self::ACROSS_EDGES + * @phpstan-type GridArea array{0: int, 1: int, 2: int, 3: int} + * @phpstan-type GridContinuationType self::FROM_PREV_GRID | self::TO_NEXT_GRID | self::ACROSS_GRID + * @phpstan-type EdgeContinuationType self::ACROSS_LEFT_EDGE | self::ACROSS_RIGHT_EDGE | self::ACROSS_BOTH_EDGES + * @phpstan-type ContinuationType GridContinuationType | EdgeContinuationType */ abstract class BaseGrid extends BaseHtmlElement { use Translation; - /** @var string Continuation type of the entry row continuing from the previous grid */ - public const FROM_PREV_GRID = 'from-prev-grid'; + /** @var string Continuation of an entry that started on the previous grid */ + protected const FROM_PREV_GRID = 'from-prev-grid'; - /** @var string Continuation type of the entry row continuing to the next grid */ - public const TO_NEXT_GRID = 'to-next-grid'; + /** @var string Continuation of an entry that continues on the next grid */ + protected const TO_NEXT_GRID = 'to-next-grid'; - /** @var string Continuation type of the entry row continuing from the previous grid to the next grid */ - public const ACROSS_GRID = 'across-grid'; + /** @var string Continuation of an entry that started on the previous grid and continues on the next */ + protected const ACROSS_GRID = 'across-grid'; - /** @var string Continuation type of the entry row continuing across edges of the grid */ - public const ACROSS_EDGES = 'across-edges'; + /** @var string Continuation of an entry that started on a previous grid row */ + protected const ACROSS_LEFT_EDGE = 'across-left-edge'; + + /** @var string Continuation of an entry that continues on the next grid row */ + protected const ACROSS_RIGHT_EDGE = 'across-right-edge'; + + /** @var string Continuation of an entry that started on a previous grid row and continues on the next */ + protected const ACROSS_BOTH_EDGES = 'across-both-edges'; /** @var int Return this in {@see getSectionsPerStep} to signal an infinite number of sections */ protected const INFINITE = 0; @@ -108,6 +118,16 @@ abstract protected function calculateGridEnd(): DateTime; abstract protected function getNoOfVisuallyConnectedHours(): int; + /** + * Translate the given grid area positions suitable for the current grid + * + * @param int $rowStart + * @param int $rowEnd + * @param int $colStart + * @param int $colEnd + * + * @return GridArea + */ protected function getGridArea(int $rowStart, int $rowEnd, int $colStart, int $colEnd): array { return [$rowStart, $colStart, $rowEnd, $colEnd]; @@ -181,7 +201,17 @@ public function getExtraEntryCount(DateTime $date): int return $this->extraEntriesCount[$date->format('Y-m-d')] ?? 0; } - protected function assembleGridOverlay(BaseHtmlElement $overlay): void + /** + * Yield the entries to show on the grid and place them using a flowing layout + * + * Entry positions are automatically calculated and can span multiple rows. + * Collisions are prevented and the grid can have a limited number of sections. + * + * @param Traversable $entries + * + * @return Generator + */ + final protected function yieldFlowingEntries(Traversable $entries): Generator { $maxRowSpan = $this->getMaximumRowSpan(); $sectionsPerStep = $this->getSectionsPerStep(); @@ -210,7 +240,7 @@ protected function assembleGridOverlay(BaseHtmlElement $overlay): void $cellOccupiers = []; /** @var SplObjectStorage $occupiedCells */ $occupiedCells = new SplObjectStorage(); - foreach ($this->provider->getEntries() as $entry) { + foreach ($entries as $entry) { $actualStart = $this->roundToNearestThirtyMinute($entry->getStart()); if ($actualStart < $gridStartsAt) { $entryStartPos = 0; @@ -318,52 +348,28 @@ protected function assembleGridOverlay(BaseHtmlElement $overlay): void $colEnd + 2 ); - $entryClass = 'area-' . implode('-', $gridArea); - $lastRow = $remainingRows === 1; - - if ($lastRow) { + $isLastRow = $remainingRows === 1; + if ($isLastRow) { $toNextGrid = $gridEndsAt < $entry->getEnd(); } $backward = $continuationType || $fromPrevGrid; - $forward = ! $lastRow || $toNextGrid; - $gradientClass = null; - if ($forward && $backward) { - $gradientClass = 'two-way-gradient'; + $forward = ! $isLastRow || $toNextGrid; + if ($backward && $forward) { + $continuationType = self::ACROSS_BOTH_EDGES; } elseif ($backward) { - $gradientClass = 'opening-gradient'; + $continuationType = self::ACROSS_LEFT_EDGE; } elseif ($forward) { - $gradientClass = 'ending-gradient'; - } - - $entryHtml = new HtmlElement( - 'div', - Attributes::create([ - 'class' => ['entry', $gradientClass, $entryClass], - 'data-entry-id' => $entry->getId(), - 'data-row-start' => $gridArea[0], - 'data-col-start' => $gridArea[1], - 'data-row-end' => $gridArea[2], - 'data-col-end' => $gridArea[3] - ]) - ); - - $this->style->addFor($entryHtml, [ - '--entry-bg' => $this->getEntryColor($entry, 10), - 'grid-area' => sprintf('~"%d / %d / %d / %d"', ...$gridArea), - 'border-color' => $this->getEntryColor($entry, 50) - ]); - - if ($fromPrevGrid) { - $continuationType = $toNextGrid ? self::ACROSS_GRID : self::FROM_PREV_GRID; + $continuationType = self::ACROSS_RIGHT_EDGE; + } elseif ($fromPrevGrid && $toNextGrid) { + $continuationType = self::ACROSS_GRID; + } elseif ($fromPrevGrid) { + $continuationType = self::FROM_PREV_GRID; } elseif ($toNextGrid) { $continuationType = self::TO_NEXT_GRID; - } elseif ($forward) { - $continuationType = self::ACROSS_EDGES; } - $this->assembleEntry($entryHtml, $entry, $continuationType); - $overlay->addHtml($entryHtml); + yield [$gridArea, $continuationType] => $entry; $fromPrevGrid = false; $remainingRows -= 1; @@ -384,6 +390,44 @@ protected function assembleGridOverlay(BaseHtmlElement $overlay): void } } + protected function assembleGridOverlay(BaseHtmlElement $overlay): void + { + foreach ($this->yieldFlowingEntries($this->provider->getEntries()) as $data => $entry) { + [$gridArea, $continuationType] = $data; + + $gradientClass = null; + if ($continuationType === self::ACROSS_GRID || $continuationType === self::ACROSS_BOTH_EDGES) { + $gradientClass = 'two-way-gradient'; + } elseif ($continuationType === self::FROM_PREV_GRID || $continuationType === self::ACROSS_LEFT_EDGE) { + $gradientClass = 'opening-gradient'; + } elseif ($continuationType === self::TO_NEXT_GRID || $continuationType === self::ACROSS_RIGHT_EDGE) { + $gradientClass = 'ending-gradient'; + } + + $entryHtml = new HtmlElement( + 'div', + Attributes::create([ + 'class' => ['entry', $gradientClass, 'area-' . implode('-', $gridArea)], + 'data-entry-id' => $entry->getId(), + 'data-row-start' => $gridArea[0], + 'data-col-start' => $gridArea[1], + 'data-row-end' => $gridArea[2], + 'data-col-end' => $gridArea[3] + ]) + ); + + $this->style->addFor($entryHtml, [ + '--entry-bg' => $this->getEntryColor($entry, 10), + 'grid-area' => sprintf('~"%d / %d / %d / %d"', ...$gridArea), + 'border-color' => $this->getEntryColor($entry, 50) + ]); + + $this->assembleEntry($entryHtml, $entry, $continuationType); + + $overlay->addHtml($entryHtml); + } + } + /** * Assemble the entry in the grid * From 00a95d6dcfeac55e4244a128db339afe4acee9aa Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 26 Apr 2024 16:19:35 +0200 Subject: [PATCH 12/36] BaseGrid: Add support for a fixed layout Which allows an entry provider to use specific rows for its entries. --- .../Widget/Calendar/BaseGrid.php | 114 +++++++++++++++++- .../Notifications/Widget/Calendar/Entry.php | 27 +++++ 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/library/Notifications/Widget/Calendar/BaseGrid.php b/library/Notifications/Widget/Calendar/BaseGrid.php index d5532ff9..f2dc09c9 100644 --- a/library/Notifications/Widget/Calendar/BaseGrid.php +++ b/library/Notifications/Widget/Calendar/BaseGrid.php @@ -19,6 +19,8 @@ use SplObjectStorage; use Traversable; +use function ipl\Stdlib\iterable_value_first; + /** * @phpstan-type GridArea array{0: int, 1: int, 2: int, 3: int} * @phpstan-type GridContinuationType self::FROM_PREV_GRID | self::TO_NEXT_GRID | self::ACROSS_GRID @@ -390,9 +392,119 @@ final protected function yieldFlowingEntries(Traversable $entries): Generator } } + /** + * Yield the entries to show on the grid and place them using a fixed layout + * + * Entry positions are expected to be registered on each individual entry and cannot span multiple rows. + * Collisions won't be prevented and the grid is expected to allow for an infinite number of sections. + * + * @param Traversable $entries + * + * @return Generator + */ + final protected function yieldFixedEntries(Traversable $entries): Generator + { + if ($this->getMaximumRowSpan() !== 1) { + throw new LogicException('Fixed layouts require a maximum row span of 1'); + } + + if ($this->getSectionsPerStep() !== self::INFINITE) { + throw new LogicException('Fixed layouts currently only work with an infinite number of sections'); + } + + $rowStartModifier = $this->getRowStartModifier(); + $gridStartsAt = $this->getGridStart(); + $gridEndsAt = $this->getGridEnd(); + $amountOfDays = $gridStartsAt->diff($gridEndsAt)->days; + $gridBorderAt = $this->getNoOfVisuallyConnectedHours() * 2; + + if ($amountOfDays !== $gridBorderAt / 48) { + throw new LogicException( + 'The number of days in the grid must match the number' + . ' of visually connected hours, when a fixed layout is used.' + ); + } + + $lastRow = 1; + foreach ($entries as $entry) { + $position = $entry->getPosition(); + if ($position === null) { + throw new LogicException('All entries must have a position set when using a fixed layout'); + } + + $rowStart = $position + $rowStartModifier; + if ($rowStart > $lastRow) { + $lastRow = $rowStart; + } + + $actualStart = $this->roundToNearestThirtyMinute($entry->getStart()); + if ($actualStart < $gridStartsAt) { + $colStart = 0; + } else { + $colStart = Util::diffHours($gridStartsAt, $actualStart) * 2; + } + + $actualEnd = $this->roundToNearestThirtyMinute($entry->getEnd()); + if ($actualEnd > $gridEndsAt) { + $colEnd = $gridBorderAt; + } else { + $colEnd = Util::diffHours($gridStartsAt, $actualEnd) * 2; + } + + if ($colStart > $gridBorderAt) { + throw new LogicException(sprintf( + 'Invalid entry (%d) position: %s to %s. Grid dimension: %s to %s', + $entry->getId(), + $actualStart->format('Y-m-d H:i:s'), + $actualEnd->format('Y-m-d H:i:s'), + $gridStartsAt->format('Y-m-d'), + $gridEndsAt->format('Y-m-d') + )); + } + + $gridArea = $this->getGridArea( + $rowStart, + $rowStart + 1, + $colStart + 1, + $colEnd + 1 + ); + + $fromPrevGrid = $gridStartsAt > $entry->getStart(); + $toNextGrid = $gridEndsAt < $entry->getEnd(); + if ($fromPrevGrid && $toNextGrid) { + $continuationType = self::ACROSS_GRID; + } elseif ($fromPrevGrid) { + $continuationType = self::FROM_PREV_GRID; + } elseif ($toNextGrid) { + $continuationType = self::TO_NEXT_GRID; + } else { + $continuationType = null; + } + + yield [$gridArea, $continuationType] => $entry; + } + + $this->style->addFor($this, [ + '--primaryRows' => $lastRow === 1 ? 1 : $lastRow - $rowStartModifier + 1, + '--rowsPerStep' => 1 + ]); + } + protected function assembleGridOverlay(BaseHtmlElement $overlay): void { - foreach ($this->yieldFlowingEntries($this->provider->getEntries()) as $data => $entry) { + $entries = $this->provider->getEntries(); + $firstEntry = iterable_value_first($entries); + if ($firstEntry === null) { + return; + } + + if ($firstEntry->getPosition() === null) { + $generator = $this->yieldFlowingEntries($entries); + } else { + $generator = $this->yieldFixedEntries($entries); + } + + foreach ($generator as $data => $entry) { [$gridArea, $continuationType] = $data; $gradientClass = null; diff --git a/library/Notifications/Widget/Calendar/Entry.php b/library/Notifications/Widget/Calendar/Entry.php index 3c5fd7d9..807f472d 100644 --- a/library/Notifications/Widget/Calendar/Entry.php +++ b/library/Notifications/Widget/Calendar/Entry.php @@ -17,6 +17,9 @@ class Entry protected $end; + /** @var ?int The 0-based position of the row where to place this entry on the grid */ + protected $position; + protected $rrule; /** @var Url */ @@ -73,6 +76,30 @@ public function getEnd(): ?DateTime return $this->end; } + /** + * Set the position of the row where to place this entry on the grid + * + * @param ?int $position The 0-based position of the row + * + * @return $this + */ + public function setPosition(?int $position): self + { + $this->position = $position; + + return $this; + } + + /** + * Get the position of the row where to place this entry on the grid + * + * @return ?int The 0-based position of the row + */ + public function getPosition(): ?int + { + return $this->position; + } + public function setRecurrencyRule(?string $rrule): self { $this->rrule = $rrule; From efacb5635ea2ed3ea79c0cd660afd42d7b0a3d3b Mon Sep 17 00:00:00 2001 From: Florian Strohmaier Date: Wed, 15 May 2024 14:58:23 +0200 Subject: [PATCH 13/36] img: Add mode pictograms --- public/img/pictogram/24-7-colored.jpg | Bin 0 -> 17858 bytes public/img/pictogram/24-7-gray.jpg | Bin 0 -> 10139 bytes public/img/pictogram/multi-colored.jpg | Bin 0 -> 10893 bytes public/img/pictogram/multi-gray.jpg | Bin 0 -> 8065 bytes public/img/pictogram/partial-colored.jpg | Bin 0 -> 15903 bytes public/img/pictogram/partial-gray.jpg | Bin 0 -> 10045 bytes 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/img/pictogram/24-7-colored.jpg create mode 100644 public/img/pictogram/24-7-gray.jpg create mode 100644 public/img/pictogram/multi-colored.jpg create mode 100644 public/img/pictogram/multi-gray.jpg create mode 100644 public/img/pictogram/partial-colored.jpg create mode 100644 public/img/pictogram/partial-gray.jpg diff --git a/public/img/pictogram/24-7-colored.jpg b/public/img/pictogram/24-7-colored.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0b48c81a74902848de07e3ebc5744f3561bd4ba9 GIT binary patch literal 17858 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 ZO(!mvsM8g`lWUy6KePFN@Hgzk{{fm+^Dh7Z literal 0 HcmV?d00001 diff --git a/public/img/pictogram/multi-colored.jpg b/public/img/pictogram/multi-colored.jpg new file mode 100644 index 0000000000000000000000000000000000000000..34b8df1c5add4043e7a02560a13bbf82f8da1ac7 GIT binary patch literal 10893 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 &ny9W3Pi59@B zDy(D7N4qDHJ0GI9fSrO?PB&M^ zf$M8XT+pBOIP0vhW}~CDh2Kcij8B?pA!+h_;%E!msgTzqEOlVsoL!6qLqwl(2{UpM z!om9u=V5Zb=M0m}132+tsT014g!s@Qt8CLy;qaJ5?9RG4;HmRFwN4I`Rb^IVzw%VD zGGrRsQ+Z5~GPJ_n@g zdTRVvs{kz%sf$Y)=2Q{%WUbqvw9fSU1%gnh!dncF1hqfMa~(7ZBZCl|A+tP4fYwU_ zFn@>6+%nAY_&8Sbj>1#mNfoNFwese`jh+JcWSebYCAyT<>kA|KTGX-_+>pg6A}qzz9=}CH~HkR9_kUyD2pG}ycDL_2^Sn#bQE*l$Q+Ee)Z>ZkM7}K! z4nbAg7SvP3bJydc{fn%D0(2nS

a zy1peLzoO0IV^7Xc^_c_p`D+1}f7N+T)z^Hi`=U~Ld?QKY8_7uke_|weKzh|#|B1$p zbRwYd%B^7If;t@LSzqZ);YPe4EbRW$WR0_~QL~;sba2s)MIF5yH>L01r+n-D z-bHqj@^i|?Bj4)0(M`DEa$MK2*xd)2|Ka4LNc%NibK850#5X1in@1`tE;PMizhkr3 zTK%o6S07S4Z>pTkwkkbHvVAXWwt%jlEVfidSl`7YOg@_&TGVz>(rCxVHL4o-Y7#cT zF}a)kCiebX)6NKPRQ{v9hKVa*;c$l2-~$?VZ~1 zz)AJ`He!YE&B=WFQs&CqyKp58Hnl7E@jwIeS{o}I0C@+Vb)m&& zWenj!?{6yNWGV^pYvnIYgz?`NJ&wef3u8X5l@z#+7%2BBmr{gD=V5PYwKk!xS!xAh z{&jB}%uS*-P4{2(dU;r>s*!zVtBDYiAuDH_D@I#Xc1ZEyjdXS>DY>e!bgP;B3ZoS% z_Tfe&JsV@FN}UmLMtk)?b7{|5!UbAQ-evogJ+}!TylHUxFb* z)#}kD^Q85crpmqTN;E8gdy8(7w8F%cw=u2Lnn(H`-M@BV{*wwr>y5xpRwsNW|NbbG zjzqq&|x#QKf|pH z2hPwi{CFETdI`c_fX#v{)ysj#ZF(>mv*&N}*S+Sy{7dbcKbbrD(1~6^V?Usc?aPDm zl>7A0x6!t3rE){isj!m)TZ}!zCBSpd5B8A%*sA~UAHds5QmdM}3O$Y=Q!}{4U8Tqs zUZbFdLbpbPg27jk-(O@mFoHjRg3o2CdE#AOWM)dC?NdYE^-SJc4MN|@9|l6RBU0Sx z8e5Erz9+~NWeQeKEKARo7 zH4-Kl-95)&re|8YJU@G~_;b-|(kkHi9g#_VrlYdQei;WFB cA=NI%D%xt$q*dzn2K?zZKLY#D{f58)A988k0ssI2 literal 0 HcmV?d00001 diff --git a/public/img/pictogram/multi-gray.jpg b/public/img/pictogram/multi-gray.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f1f746102ab5648dc739fbe558c1135131f125da GIT binary patch literal 8065 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�~|1fTwaQ;0gR8 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 B=)M2| literal 0 HcmV?d00001 From ee9d008559968919590be9f13f6f2049eac4a1e2 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 2 May 2024 17:38:44 +0200 Subject: [PATCH 14/36] Introduce new form `RotationConfigForm` --- application/forms/RotationConfigForm.php | 696 +++++++++++++++++++++++ public/css/form.less | 78 +++ 2 files changed, 774 insertions(+) create mode 100644 application/forms/RotationConfigForm.php diff --git a/application/forms/RotationConfigForm.php b/application/forms/RotationConfigForm.php new file mode 100644 index 00000000..ca300c87 --- /dev/null +++ b/application/forms/RotationConfigForm.php @@ -0,0 +1,696 @@ +submitLabel = $label; + + return $this; + } + + /** + * Get the label for the submit button + * + * @return string + */ + public function getSubmitLabel(): string + { + return $this->submitLabel ?? $this->translate('Add Rotation'); + } + + /** + * Set whether to render the remove button + * + * @param bool $state + * + * @return $this + */ + public function setShowRemoveButton(bool $state = true): self + { + $this->showRemoveButton = $state; + + return $this; + } + + /** + * Set the URL to fetch member suggestions from + * + * @param Url $url + * + * @return void + */ + public function setSuggestionUrl(Url $url): self + { + $this->suggestionUrl = $url; + + return $this; + } + + /** + * Disable the mode selection + * + * @return void + */ + public function disableModeSelection(): self + { + $this->disableModeSelection = true; + + return $this; + } + + /** + * Get multipart updates provided by this form's elements + * + * @return array + */ + public function getPartUpdates(): array + { + $this->ensureAssembled(); + + return $this->getElement('members')->prepareMultipartUpdate($this->getRequest()); + } + + /** + * Get whether the remove button was pressed + * + * @return bool + */ + public function hasBeenRemoved(): bool + { + $btn = $this->getPressedSubmitElement(); + $csrf = $this->getElement('CSRFToken'); + + return $csrf !== null && $csrf->isValid() && $btn !== null && $btn->getName() === 'remove'; + } + + /** + * Get whether the remove_all button was pressed + * + * @return bool + */ + public function hasBeenWiped(): bool + { + $btn = $this->getPressedSubmitElement(); + $csrf = $this->getElement('CSRFToken'); + + return $csrf !== null && $csrf->isValid() && $btn !== null && $btn->getName() === 'remove_all'; + } + + /** + * Create a new RotationConfigForm + * + * @param int $scheduleId + */ + public function __construct(int $scheduleId) + { + $this->scheduleId = $scheduleId; + } + + protected function assembleModeSelection(): string + { + $value = $this->getPopulatedValue('mode'); + + $modes = [ + '24-7' => $this->translate('24/7'), + 'partial' => $this->translate('Partial Day'), + 'multi' => $this->translate('Multi Day') + ]; + + $modeList = new HtmlElement('ul'); + foreach ($modes as $mode => $label) { + $radio = $this->createElement('input', 'mode', [ + 'type' => 'radio', + 'value' => $mode, + 'disabled' => $this->disableModeSelection, + 'id' => 'rotation-mode-' . $mode, + 'class' => 'autosubmit' + ]); + if ($value === null || $mode === $value) { + $radio->getAttributes()->set('checked', true); + $this->registerElement($radio); + $value = $mode; + } + + $modeList->addHtml(new HtmlElement( + 'li', + null, + new HtmlElement( + 'label', + null, + $radio, + new HtmlElement('img', Attributes::create([ + 'src' => Url::fromPath(sprintf('img/notifications/pictogram/%s-gray.jpg', $mode)), + 'class' => 'unchecked' + ])), + new HtmlElement('img', Attributes::create([ + 'src' => Url::fromPath(sprintf('img/notifications/pictogram/%s-colored.jpg', $mode)), + 'class' => 'checked' + ])), + Text::create($label) + ) + )); + } + + $this->addHtml(new HtmlElement( + 'div', + Attributes::create(['class' => ['rotation-mode', $this->disableModeSelection ? 'disabled' : '']]), + new HtmlElement('h2', null, Text::create($this->translate('Mode'))), + $modeList + )); + + return $value; + } + + /** + * Assemble option elements for the 24/7 mode + * + * @param FieldsetElement $options + * + * @return DateTime The default first handoff + */ + protected function assembleTwentyFourSevenOptions(FieldsetElement $options): DateTime + { + $options->addElement('number', 'interval', [ + 'required' => true, + 'label' => $this->translate('Handoff every'), + 'step' => 1, + 'min' => 1, + 'value' => 1, + 'validators' => [new GreaterThanValidator()] + ]); + $interval = $options->getElement('interval'); + + $frequency = $options->createElement('select', 'frequency', [ + 'required' => true, + 'options' => [ + 'd' => $this->translate('Days'), + 'w' => $this->translate('Weeks') + ] + ]); + $options->registerElement($frequency); + + $at = $options->createElement('select', 'at', [ + 'class' => 'autosubmit', + 'required' => true, + 'options' => $this->getTimeOptions() + ]); + $options->registerElement($at); + + $interval->prependWrapper( + (new HtmlDocument())->addHtml( + $interval, + $frequency, + new HtmlElement( + 'span', + null, + Text::create($this->translate('at', 'handoff every at