From e5e8c2011ea11923c7fe98a2dddd4d2214d37470 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Tue, 6 Dec 2022 16:18:45 +0100 Subject: [PATCH 01/43] Aways render `control-label-group` wrapper --- src/FormDecorator/IcingaFormDecorator.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/FormDecorator/IcingaFormDecorator.php b/src/FormDecorator/IcingaFormDecorator.php index e1e3e7f3..c6bd8205 100644 --- a/src/FormDecorator/IcingaFormDecorator.php +++ b/src/FormDecorator/IcingaFormDecorator.php @@ -10,6 +10,7 @@ use ipl\Html\FormElement\FieldsetElement; use ipl\Html\HtmlDocument; use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; use ipl\Html\Text; use ipl\Web\Widget\Icon; @@ -72,8 +73,16 @@ protected function createCheckbox(CheckboxElement $checkbox) protected function assembleLabel() { $label = parent::assembleLabel(); - if ($label !== null && ! $this->formElement instanceof FieldsetElement) { - $label->addWrapper(new HtmlElement('div', Attributes::create(['class' => 'control-label-group']))); + if (! $this->formElement instanceof FieldsetElement) { + if ($label !== null) { + $label->addWrapper(new HtmlElement('div', Attributes::create(['class' => 'control-label-group']))); + } else { + $label = new HtmlElement( + 'div', + Attributes::create(['class' => 'control-label-group']), + HtmlString::create(' ') + ); + } } return $label; From 8e687f3b4b05607e0f111833c0fec86bcd379805 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Thu, 8 Dec 2022 15:15:34 +0100 Subject: [PATCH 02/43] Introduce trait `ScheduleUtils` --- src/Common/ScheduleFieldsUtils.php | 241 +++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 src/Common/ScheduleFieldsUtils.php diff --git a/src/Common/ScheduleFieldsUtils.php b/src/Common/ScheduleFieldsUtils.php new file mode 100644 index 00000000..9ccbc211 --- /dev/null +++ b/src/Common/ScheduleFieldsUtils.php @@ -0,0 +1,241 @@ +regulars = [ + 'MO' => $this->translate('Monday'), + 'TU' => $this->translate('Tuesday'), + 'WE' => $this->translate('Wednesday'), + 'TH' => $this->translate('Thursday'), + 'FR' => $this->translate('Friday'), + 'SA' => $this->translate('Saturday'), + 'SU' => $this->translate('Sunday') + ]; + } + + protected function createOrdinalElement(): FormElement + { + return $this->createElement('select', 'ordinal', [ + 'class' => 'autosubmit', + 'value' => $this->getPopulatedValue('ordinal', static::$first), + 'options' => [ + static::$first => $this->translate('First'), + static::$second => $this->translate('Second'), + static::$third => $this->translate('Third'), + static::$fourth => $this->translate('Fourth'), + static::$fifth => $this->translate('Fifth'), + static::$last => $this->translate('Last') + ] + ]); + } + + protected function createOrdinalSelectableDays(): FormElement + { + return $this->createElement('select', 'day', [ + 'class' => 'autosubmit', + 'value' => $this->getPopulatedValue('day', static::$everyDay), + 'options' => [ + 'Regular' => $this->regulars, + 'Non Standards' => [ + static::$everyDay => $this->translate('Day'), + static::$everyWeekday => $this->translate('Weekday (Mon - Fri)'), + static::$everyWeekend => $this->translate('WeekEnd (Sat or Sun)') + ] + ] + ]); + } + + /** + * Load the given RRule instance into a list of key=>value pairs + * + * @param RRule $rule + * + * @return array + */ + public function loadRRule(RRule $rule): array + { + $values = []; + $isMonthly = $rule->getFrequency() === RRule::MONTHLY; + if ($isMonthly && (! empty($rule->getByMonthDay()) || empty($rule->getByDay()))) { + foreach ($rule->getByMonthDay() ?? [] as $value) { + $values["day$value"] = 'y'; + } + + $values['runsOn'] = MonthlyFields::RUNS_EACH; + } else { + $position = $rule->getBySetPosition(); + $byDay = $rule->getByDay() ?? []; + + if ($isMonthly) { + $values['runsOn'] = MonthlyFields::RUNS_ONTHE; + } else { + $months = $rule->getByMonth(); + if (empty($months) && $rule->getStartDate()) { + $months[] = $rule->getStartDate()->format('m'); + } elseif (empty($months)) { + $months[] = date('m'); + } + + $values['month'] = strtoupper($this->getMonthByNumber((int)$months[0])); + $values['runsOnThe'] = ! empty($byDay) ? 'y' : 'n'; + } + + if (count($byDay) == 1 && preg_match('/^(-?\d)+(\S.*)$/', $byDay[0], $matches)) { + $values['ordinal'] = $this->getOrdinalString($matches[1]); + $values['day'] = $this->getWeekdayName($matches[2]); + } elseif (! empty($byDay)) { + $values['ordinal'] = $this->getOrdinalString(current($position)); + switch (count($byDay)) { + case MonthlyFields::WEEK_DAYS: + $values['day'] = static::$everyDay; + + break; + case MonthlyFields::WEEK_DAYS - 2: + $values['day'] = static::$everyWeekday; + + break; + case 1: + $values['day'] = current($byDay); + + break; + case 2: + $byDay = array_flip($byDay); + if (isset($byDay['SA']) && isset($byDay['SU'])) { + $values['day'] = static::$everyWeekend; + } + } + } + } + + return $values; + } + + /** + * Transform the given expression part into a valid week day string representation + * + * @param string $day + * + * @return string + */ + public function getWeekdayName(string $day): string + { + // Not transformation is needed when the given day is part of the valid weekdays + if (isset($this->regulars[strtoupper($day)])) { + return $day; + } + + try { + // Try to figure it out using date time before raising an error + $datetime = new DateTime('Sunday'); + $datetime->add(new DateInterval("P$day" . 'D')); + + return $datetime->format('D'); + } catch (Exception $_) { + throw new InvalidArgumentException(sprintf('Invalid weekday provided: %s', $day)); + } + } + + /** + * Transform the given integer enums into something like first,second... + * + * @param string $ordinal + * + * @return string + */ + public function getOrdinalString(string $ordinal): string + { + switch ($ordinal) { + case '1': + return static::$first; + case '2': + return static::$second; + case '3': + return static::$third; + case '4': + return static::$fourth; + case '5': + return static::$fifth; + case '-1': + return static::$last; + default: + throw new InvalidArgumentException( + sprintf('Invalid ordinal string representation provided: %s', $ordinal) + ); + } + } + + /** + * Get the string representation of the given ordinal to an integer + * + * This transforms the given ordinal such as (first, second...) into its respective + * integral representation. At the moment only (1..5 + the non-standard "last") options + * are supported. So if this method returns the character "-1", is meant the last option. + * + * @param string $ordinal + * + * @return int + */ + public function getOrdinalAsInteger(string $ordinal): int + { + switch ($ordinal) { + case static::$first: + return 1; + case static::$second: + return 2; + case static::$third: + return 3; + case static::$fourth: + return 4; + case static::$fifth: + return 5; + case static::$last: + return -1; + default: + throw new InvalidArgumentException(sprintf('Invalid enumerator provided: %s', $ordinal)); + } + } + + /** + * Get a short textual representation of the given month + * + * @param int $month + * + * @return string + */ + public function getMonthByNumber(int $month): string + { + $time = DateTime::createFromFormat('!m', $month); + if ($time) { + return $time->format('M'); + } + + throw new InvalidArgumentException(sprintf('Invalid month number provided: %d', $month)); + } +} From e31b6f374860842a1551cd41fc6543d149ba7eaa Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Fri, 27 Jan 2023 17:22:00 +0100 Subject: [PATCH 03/43] Introduce `FieldsProtector` trait --- src/Common/FieldsProtector.php | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/Common/FieldsProtector.php diff --git a/src/Common/FieldsProtector.php b/src/Common/FieldsProtector.php new file mode 100644 index 00000000..e51d4e5f --- /dev/null +++ b/src/Common/FieldsProtector.php @@ -0,0 +1,41 @@ +protector = $protector; + + return $this; + } + + /** + * Protect the given html id + * + * The provided id is returned as is, if no protector is specified + * + * @param mixed $id + * + * @return mixed + */ + public function protectId($id) + { + if (is_callable($this->protector)) { + return call_user_func($this->protector, $id); + } + + return $id; + } +} From e7fb02240c8fce5dabe86823ce49ca87dd5bb06b Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Tue, 6 Dec 2022 16:23:05 +0100 Subject: [PATCH 04/43] Introduce class `ScheduleFieldsRadio` --- src/FormElement/ScheduleFieldsRadio.php | 81 +++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/FormElement/ScheduleFieldsRadio.php diff --git a/src/FormElement/ScheduleFieldsRadio.php b/src/FormElement/ScheduleFieldsRadio.php new file mode 100644 index 00000000..6d3b73be --- /dev/null +++ b/src/FormElement/ScheduleFieldsRadio.php @@ -0,0 +1,81 @@ +disable = $value; + + return $this; + } + + protected function assemble() + { + $listItems = HtmlElement::create('ul', ['class' => ['schedule-element-fields', 'single-fields']]); + foreach ($this->options as $option) { + $radio = (new InputElement($this->getValueOfNameAttribute())) + ->setValue($option->getValue()) + ->setType($this->type); + + $radio->setAttributes(clone $this->getAttributes()); + + $htmlId = $this->protectId($option->getValue()); + $radio->getAttributes() + ->registerAttributeCallback('id', function () use ($htmlId) { + return $htmlId; + }) + ->registerAttributeCallback('checked', function () use ($option) { + return (string) $this->getValue() === (string) $option->getValue(); + }) + ->registerAttributeCallback('required', function () { + return $this->getRequiredAttribute(); + }) + ->registerAttributeCallback('disabled', function () use ($option) { + return $option->isDisabled(); + }) + ->registerAttributeCallback('class', function () use ($option) { + return Attributes::create(['class', $option->getLabelCssClass()])->get('class'); + }); + + $listItem = HtmlElement::create('li'); + $radio->prependWrapper($listItem); + + $listItem->addHtml($radio, HtmlElement::create('label', ['for' => $htmlId], $option->getLabel())); + $listItems->addHtml($radio); + } + + if ($this->disable) { + $listItems->getAttributes()->add('class', 'disabled'); + } + + $this->addHtml($listItems); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes + ->registerAttributeCallback('protector', null, [$this, 'setIdProtector']); + } +} From 27fd169c3036afb36a4f70082706fd059c44ba91 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Fri, 27 Jan 2023 17:24:00 +0100 Subject: [PATCH 05/43] Introduce `MonthlyFields` class --- src/FormElement/MonthlyFields.php | 175 ++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 src/FormElement/MonthlyFields.php diff --git a/src/FormElement/MonthlyFields.php b/src/FormElement/MonthlyFields.php new file mode 100644 index 00000000..4438a821 --- /dev/null +++ b/src/FormElement/MonthlyFields.php @@ -0,0 +1,175 @@ +initUtils(); + + $this->availableFields = (int) date('t'); + } + + /** + * Set the available fields/days of the month to be rendered + * + * @param int $fields + * + * @return $this + */ + public function setAvailableFields(int $fields): self + { + $this->availableFields = $fields; + + return $this; + } + + /** + * Set the default field/day to be selected by default + * + * @param int $default + * + * @return $this + */ + public function setDefault(int $default): self + { + $this->default = $default; + + return $this; + } + + /** + * Get all the selected weekdays + * + * @return array + */ + public function getSelectedDays(): array + { + $selectedDays = []; + foreach (range(1, $this->availableFields) as $day) { + if ($this->getValue("day$day", 'n') === 'y') { + $selectedDays[] = $day; + } + } + + if (empty($selectedDays)) { + $selectedDays[] = $this->default; + } + + return $selectedDays; + } + + protected function assemble() + { + $this->getAttributes()->set('id', $this->protectId('monthly-fields')); + + $runsOn = $this->getPopulatedValue('runsOn', static::RUNS_EACH); + $this->addElement('radio', 'runsOn', [ + 'required' => true, + 'class' => 'autosubmit', + 'value' => $runsOn, + 'options' => [static::RUNS_EACH => $this->translate('Each')], + ]); + + $listItems = HtmlElement::create('ul', ['class' => ['schedule-element-fields', 'multiple-fields']]); + if ($runsOn === static::RUNS_ONTHE) { + $listItems->getAttributes()->add('class', 'disabled'); + } + + $foundCheckedDay = false; + foreach (range(1, $this->availableFields) as $day) { + $checkbox = $this->createElement('checkbox', "day$day", [ + 'class' => 'sr-only autosubmit', + 'value' => $this->getPopulatedValue("day$day", 'n') + ]); + $this->registerElement($checkbox); + + $foundCheckedDay = $foundCheckedDay || $checkbox->isChecked(); + $htmlId = $this->protectId("day$day"); + $checkbox->getAttributes()->set('id', $htmlId); + + $listItem = HtmlElement::create('li'); + $checkbox->prependWrapper($listItem); + + $listItem->addHtml($checkbox, HtmlElement::create('label', ['for' => $htmlId], $day)); + $listItems->addHtml($checkbox); + } + + if (! $foundCheckedDay) { + $this->getElement("day{$this->default}")->setChecked(true); + } + + $monthlyWrapper = HtmlElement::create('div', ['class' => 'monthly']); + $runsEach = $this->getElement('runsOn'); + $runsEach->prependWrapper($monthlyWrapper); + $monthlyWrapper->addHtml($runsEach, $listItems); + + $this->addElement('radio', 'runsOn', [ + 'required' => $runsOn !== static::RUNS_EACH, + 'class' => 'autosubmit', + 'options' => [static::RUNS_ONTHE => $this->translate('On the')] + ]); + + $runsOnThe = $this->getElement('runsOn'); + $runsOnValidators = $runsOnThe->getValidators(); + $runsOnValidators + ->clearValidators() + ->add( + new DeferredInArrayValidator(function (): array { + return [static::RUNS_EACH, static::RUNS_ONTHE]; + }), + true + ); + + $ordinalWrapper = HtmlElement::create('div', ['class' => 'ordinal']); + $runsOnThe->prependWrapper($ordinalWrapper); + $ordinalWrapper->addHtml($runsOnThe); + + $enumerations = $this->createOrdinalElement(); + $enumerations->getAttributes()->set('disabled', $runsOn === static::RUNS_EACH); + $this->registerElement($enumerations); + + $selectableDays = $this->createOrdinalSelectableDays(); + $selectableDays->getAttributes()->set('disabled', $runsOn === static::RUNS_EACH); + $this->registerElement($selectableDays); + + $ordinalWrapper->addHtml($enumerations, $selectableDays); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes + ->registerAttributeCallback('default', null, [$this, 'setDefault']) + ->registerAttributeCallback('availableFields', null, [$this, 'setAvailableFields']) + ->registerAttributeCallback('protector', null, [$this, 'setIdProtector']); + } +} From 89d46accdba53e5a6399879c9f908dc89b2dca57 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Fri, 27 Jan 2023 17:25:00 +0100 Subject: [PATCH 06/43] Introduce `AnnuallyFields` class --- src/FormElement/AnnuallyFields.php | 133 +++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/FormElement/AnnuallyFields.php diff --git a/src/FormElement/AnnuallyFields.php b/src/FormElement/AnnuallyFields.php new file mode 100644 index 00000000..7892ecdd --- /dev/null +++ b/src/FormElement/AnnuallyFields.php @@ -0,0 +1,133 @@ +initUtils(); + + $this->months = [ + 'JAN' => $this->translate('Jan'), + 'FEB' => $this->translate('Feb'), + 'MAR' => $this->translate('Mar'), + 'APR' => $this->translate('Apr'), + 'MAY' => $this->translate('May'), + 'JUN' => $this->translate('Jun'), + 'JUL' => $this->translate('Jul'), + 'AUG' => $this->translate('Aug'), + 'SEP' => $this->translate('Sep'), + 'OCT' => $this->translate('Oct'), + 'NOV' => $this->translate('Nov'), + 'DEC' => $this->translate('Dec') + ]; + } + + public function onRegistered(Form $form) + { + $form->on(Form::ON_SENT, function ($form) { + $this->isAutoSubmitted = ! $form->hasBeenSubmitted(); + }); + } + + /** + * Set the default month to be activated + * + * @param string $default + * + * @return $this + */ + public function setDefault(string $default): self + { + // Attributes are registered far before the initialization of this element! + if (! empty($this->months) && ! isset($this->months[strtoupper($this->default)])) { + throw new InvalidArgumentException(sprintf('Invalid month provided: %s', $default)); + } + + $this->default = strtoupper($default); + + return $this; + } + + protected function assemble() + { + $this->getAttributes()->set('id', $this->protectId('annually-fields')); + + $fieldsSelector = new ScheduleFieldsRadio('month', [ + 'class' => 'autosubmit sr-only', + 'value' => $this->default, + 'options' => $this->months, + 'protector' => function ($value) { + return $this->protectId($value); + } + ]); + $this->registerElement($fieldsSelector); + + $runsOnThe = $this->getPopulatedValue('runsOnThe', 'n'); + $this->addElement('checkbox', 'runsOnThe', [ + 'class' => 'autosubmit', + 'value' => $runsOnThe + ]); + + $checkboxControls = HtmlElement::create('div', ['class' => 'toggle-slider-controls']); + $checkbox = $this->getElement('runsOnThe'); + $checkbox->prependWrapper($checkboxControls); + $checkboxControls->addHtml($checkbox, HtmlElement::create('span', null, $this->translate('On the'))); + + $annuallyWrapper = HtmlElement::create('div', ['class' => 'annually']); + $checkboxControls->prependWrapper($annuallyWrapper); + $annuallyWrapper->addHtml($fieldsSelector); + + if ($runsOnThe === 'n' && $this->isAutoSubmitted) { + $this->clearPopulatedValue('ordinal'); + $this->clearPopulatedValue('day'); + } + + $enumerations = $this->createOrdinalElement(); + $enumerations->getAttributes()->set('disabled', $runsOnThe === 'n'); + $this->registerElement($enumerations); + + $selectableDays = $this->createOrdinalSelectableDays(); + $selectableDays->getAttributes()->set('disabled', $runsOnThe === 'n'); + $this->registerElement($selectableDays); + + $ordinalWrapper = HtmlElement::create('div', ['class' => ['ordinal', 'annually']]); + $this + ->decorate($enumerations) + ->addHtml($enumerations); + + $enumerations->prependWrapper($ordinalWrapper); + $ordinalWrapper->addHtml($enumerations, $selectableDays); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes + ->registerAttributeCallback('default', null, [$this, 'setDefault']) + ->registerAttributeCallback('protector', null, [$this, 'setIdProtector']); + } +} From 936bd0e7c791c6a2d9938ac31a938de57a3c8cff Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Fri, 27 Jan 2023 17:32:00 +0100 Subject: [PATCH 07/43] Introduce `WeeklyFields` class --- src/FormElement/WeeklyFields.php | 141 +++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/FormElement/WeeklyFields.php diff --git a/src/FormElement/WeeklyFields.php b/src/FormElement/WeeklyFields.php new file mode 100644 index 00000000..e05b5fed --- /dev/null +++ b/src/FormElement/WeeklyFields.php @@ -0,0 +1,141 @@ +weekdays = [ + 'MO' => $this->translate('Mon'), + 'TU' => $this->translate('Tue'), + 'WE' => $this->translate('Wed'), + 'TH' => $this->translate('Thu'), + 'FR' => $this->translate('Fri'), + 'SA' => $this->translate('Sat'), + 'SU' => $this->translate('Sun') + ]; + } + + /** + * Set the default weekday to be preselected + * + * @param string $default + * + * @return $this + */ + public function setDefault(string $default): self + { + $weekday = strlen($default) > 2 ? substr($default, 0, -1) : $default; + // Attributes are registered far before the initialization of this element! + if (! empty($this->weekdays) && ! isset($this->weekdays[strtoupper($weekday)])) { + throw new InvalidArgumentException(sprintf('Invalid weekday provided: %s', $default)); + } + + $this->default = strtoupper($weekday); + + return $this; + } + + /** + * Get all the selected weekdays + * + * @return array + */ + public function getSelectedWeekDays(): array + { + $selectedDays = []; + foreach ($this->weekdays as $day => $_) { + if ($this->getValue($day, 'n') === 'y') { + $selectedDays[] = $day; + } + } + + if (empty($selectedDays)) { + $selectedDays[] = $this->default; + } + + return $selectedDays; + } + + /** + * Transform the given weekdays into key=>value array that can be populated + * + * @param array $days + * + * @return array + */ + public function loadWeekDays(array $days): array + { + $values = []; + foreach ($days as $day) { + $weekDays = strtoupper($day); + if (! isset($this->weekdays[$weekDays])) { + throw new InvalidArgumentException(sprintf('Invalid weekday provided: %s', $day)); + } + + $values[$weekDays] = 'y'; + } + + return $values; + } + + protected function assemble() + { + $this->getAttributes()->set('id', $this->protectId('weekly-fields')); + + $fieldsWrapper = HtmlElement::create('div', ['class' => 'weekly']); + $listItems = HtmlElement::create('ul', ['class' => ['schedule-element-fields', 'multiple-fields']]); + + $foundCheckedDay = false; + foreach ($this->weekdays as $day => $value) { + $checkbox = $this->createElement('checkbox', $day, [ + 'class' => 'sr-only autosubmit', + 'value' => $this->getPopulatedValue($day, 'n') + ]); + $this->registerElement($checkbox); + + $foundCheckedDay = $foundCheckedDay || $checkbox->isChecked(); + $htmlId = $this->protectId("weekday-$day"); + $checkbox->getAttributes()->set('id', $htmlId); + + $listItem = HtmlElement::create('li'); + $checkbox->prependWrapper($listItem); + + $listItem->addHtml($checkbox, HtmlElement::create('label', ['for' => $htmlId], $value)); + $listItems->addHtml($checkbox); + } + + if (! $foundCheckedDay) { + $this->getElement($this->default)->setChecked(true); + } + + $listItems->prependWrapper($fieldsWrapper); + $this->addHtml($listItems); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes + ->registerAttributeCallback('default', null, [$this, 'setDefault']) + ->registerAttributeCallback('protector', null, [$this, 'setIdProtector']); + } +} From 815a8165b2966f8118b8eab194af6cabb2113051 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Thu, 8 Dec 2022 15:15:58 +0100 Subject: [PATCH 08/43] Introduce `ScheduleRecurrence` form element --- src/FormElement/ScheduleRecurrence.php | 96 ++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/FormElement/ScheduleRecurrence.php diff --git a/src/FormElement/ScheduleRecurrence.php b/src/FormElement/ScheduleRecurrence.php new file mode 100644 index 00000000..031fcc2e --- /dev/null +++ b/src/FormElement/ScheduleRecurrence.php @@ -0,0 +1,96 @@ + 'schedule-occurrence']; + + /** @var callable A callable that generates a frequency instance */ + protected $frequencyCallback; + + /** @var callable A validation callback for the schedule element */ + protected $isValidCallback; + + /** + * Set a validation callback that will be called when assembling this element + * + * @param callable $callback + * + * @return $this + */ + public function setValid(callable $callback): self + { + $this->isValidCallback = $callback; + + return $this; + } + + /** + * Set a callback that generates an {@see Frequency} instance + * + * @param callable $callback + * + * @return $this + */ + public function setFrequency(callable $callback): self + { + $this->frequencyCallback = $callback; + + return $this; + } + + protected function assemble() + { + $isValid = ($this->isValidCallback)(); + if (! $isValid) { + return; + } + + /** @var RRule $frequency */ + $frequency = ($this->frequencyCallback)(); + $recurrences = $frequency->getNextRecurrences(new DateTime(), 3); + if (! $recurrences->valid()) { + // Such a situation can be caused by setting an invalid end time + $this->addHtml(HtmlString::create($this->translate('Recurrences cannot be generated'))); + + return; + } + + foreach ($recurrences as $recurrence) { + $this->addHtml( + HtmlElement::create( + 'p', + null, + sprintf( + '%s, %s', + $recurrence->format('D'), + $recurrence->format('Y/m/d, H:i:s') + ) + ) + ); + } + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes + ->registerAttributeCallback('frequency', null, [$this, 'setFrequency']) + ->registerAttributeCallback('valid', null, [$this, 'setValid']); + } +} From b83ab7792d2dce866d820dee187cee63e067dc84 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Tue, 6 Dec 2022 16:23:33 +0100 Subject: [PATCH 09/43] Introduce class `ScheduleElement` --- src/FormElement/ScheduleElement.php | 311 ++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 src/FormElement/ScheduleElement.php diff --git a/src/FormElement/ScheduleElement.php b/src/FormElement/ScheduleElement.php new file mode 100644 index 00000000..87981f8e --- /dev/null +++ b/src/FormElement/ScheduleElement.php @@ -0,0 +1,311 @@ + 'schedule-element']; + + /** @var array A list of allowed frequencies used to configure custom expressions */ + protected $frequencies = []; + + /** @var string Schedule frequency of this element */ + protected $frequency = RRule::DAILY; + + /** @var DateTime */ + protected $start; + + /** @var WeeklyFields Weekly parts of this schedule element */ + protected $weeklyField; + + /** @var MonthlyFields Monthly parts of this schedule element */ + protected $monthlyFields; + + /** @var AnnuallyFields Annually parts of this schedule element */ + protected $annuallyFields; + + protected function init(): void + { + $this->start = new DateTime(); + $this->weeklyField = new WeeklyFields('weekly-fields', [ + 'default' => $this->start->format('D'), + 'protector' => function (string $day) { + return $this->protectId($day); + }, + ]); + + $this->monthlyFields = new MonthlyFields('monthly-fields', [ + 'default' => $this->start->format('j'), + 'availableFields' => (int) $this->start->format('t'), + 'protector' => function ($day) { + return $this->protectId($day); + } + ]); + + $this->annuallyFields = new AnnuallyFields('annually-fields', [ + 'default' => $this->start->format('M'), + 'protector' => function ($month) { + return $this->protectId($month); + } + ]); + + $this->frequencies = [ + RRule::DAILY => $this->translate('Daily'), + RRule::WEEKLY => $this->translate('Weekly'), + RRule::MONTHLY => $this->translate('Monthly'), + RRule::YEARLY => $this->translate('Annually') + ]; + } + + public function setDefaultElementDecorator($decorator) + { + parent::setDefaultElementDecorator($decorator); + + $this->weeklyField->setDefaultElementDecorator($this->getDefaultElementDecorator()); + $this->monthlyFields->setDefaultElementDecorator($this->getDefaultElementDecorator()); + $this->annuallyFields->setDefaultElementDecorator($this->getDefaultElementDecorator()); + } + + /** + * Get the frequency of this element + * + * @return string + */ + public function getFrequency(): string + { + return $this->getValue('custom_frequency', $this->frequency); + } + + /** + * Set the custom frequency of this cron + * + * @param string $frequency + * + * @return $this + */ + public function setFrequency(string $frequency): self + { + if (! isset($this->frequencies[$frequency])) { + throw new InvalidArgumentException(sprintf('Invalid frequency provided: %s', $frequency)); + } + + $this->frequency = $frequency; + + return $this; + } + + /** + * Set start time of the parsed expressions + * + * @param DateTime $start + * + * @return $this + */ + public function setStart(DateTime $start): self + { + $this->start = $start; + + // Forward the start time update to the sub elements as well! + $this->weeklyField->setDefault($start->format('D')); + $this->annuallyFields->setDefault($start->format('M')); + $this->monthlyFields + ->setDefault((int) $start->format('j')) + ->setAvailableFields((int) $start->format('t')); + + return $this; + } + + /** + * Get the monthly fields of this element + * + * Is only used when using multipart updates + * + * @return MonthlyFields + */ + public function getMonthlyFields(): MonthlyFields + { + return $this->monthlyFields; + } + + /** + * Parse this schedule element and derive a {@see RRule} instance from it + * + * @return RRule + */ + public function getRRule(): RRule + { + $repeat = $this->getFrequency(); + $interval = $this->getValue('interval', 1); + switch ($repeat) { + case RRule::DAILY: + if ($interval === '*') { + $interval = 1; + } + + return new RRule("FREQ=DAILY;INTERVAL=$interval"); + case RRule::WEEKLY: + $byDay = implode(',', $this->weeklyField->getSelectedWeekDays()); + + return new RRule("FREQ=WEEKLY;INTERVAL=$interval;BYDAY=$byDay"); + /** @noinspection PhpMissingBreakStatementInspection */ + case RRule::MONTHLY: + $runsOn = $this->monthlyFields->getValue('runsOn', MonthlyFields::RUNS_EACH); + if ($runsOn === MonthlyFields::RUNS_EACH) { + $byMonth = implode(',', $this->monthlyFields->getSelectedDays()); + + return new RRule("FREQ=MONTHLY;INTERVAL=$interval;BYMONTHDAY=$byMonth"); + } + // Fall-through to the next switch case + case RRule::YEARLY: + $rule = "FREQ=MONTHLY;INTERVAL=$interval;"; + if ($repeat === RRule::YEARLY) { + $runsOn = $this->annuallyFields->getValue('runsOnThe', 'n'); + $month = $this->annuallyFields->getValue('month', (int) $this->start->format('m')); + if (is_string($month)) { + $datetime = DateTime::createFromFormat('!M', $month); + if (! $datetime) { + throw new InvalidArgumentException(sprintf('Invalid month provided: %s', $month)); + } + + $month = (int) $datetime->format('m'); + } + + $rule = "FREQ=YEARLY;INTERVAL=1;BYMONTH=$month;"; + if ($runsOn === 'n') { + return new RRule($rule); + } + } + + $element = $this->monthlyFields; + if ($repeat === RRule::YEARLY) { + $element = $this->annuallyFields; + } + + $runDay = $element->getValue('day', $element::$everyDay); + $ordinal = $element->getValue('ordinal', $element::$first); + $position = $element->getOrdinalAsInteger($ordinal); + + if ($runDay === $element::$everyDay) { + $rule .= "BYDAY=MO,TU,WE,TH,FR,SA,SU;BYSETPOS=$position"; + } elseif ($runDay === $element::$everyWeekday) { + $rule .= "BYDAY=MO,TU,WE,TH,FR;BYSETPOS=$position"; + } elseif ($runDay === $element::$everyWeekend) { + $rule .= "BYDAY=SA,SU;BYSETPOS=$position"; + } else { + $rule .= sprintf('BYDAY=%d%s', $position, $runDay); + } + + return new RRule($rule); + } + // Oops!! + } + + /** + * Load the given RRule instance into a list of key=>value pairs + * + * @param RRule $rule + * + * @return array + */ + public function loadRRule(RRule $rule): array + { + $values = [ + 'interval' => $rule->getInterval(), + 'custom_frequency' => $rule->getFrequency() + ]; + switch ($rule->getFrequency()) { + case RRule::WEEKLY: + $values['weekly-fields'] = $this->weeklyField->loadWeekDays($rule->getByDay()); + + break; + case RRule::MONTHLY: + $values['monthly-fields'] = $this->monthlyFields->loadRRule($rule); + + break; + case RRule::YEARLY: + $values['annually-fields'] = $this->annuallyFields->loadRRule($rule); + } + + return $values; + } + + protected function assemble() + { + $this->addElement('select', 'custom_frequency', [ + 'required' => false, + 'class' => 'autosubmit', + 'value' => $this->getFrequency(), + 'options' => $this->frequencies, + 'label' => $this->translate('Custom Frequency'), + 'description' => $this->translate('Specifies how often this job run should be recurring') + ]); + + switch ($this->getFrequency()) { + case RRule::DAILY: + $this->assembleCommonElements(); + + break; + case RRule::WEEKLY: + $this->assembleCommonElements(); + $this->addElement($this->weeklyField); + + break; + case RRule::MONTHLY: + $this->assembleCommonElements(); + $this + ->registerElement($this->monthlyFields) + ->addHtml($this->monthlyFields); + + break; + case RRule::YEARLY: + $this + ->registerElement($this->annuallyFields) + ->addHtml($this->annuallyFields); + } + } + + /** + * Assemble common parts for all the frequencies + */ + private function assembleCommonElements(): void + { + $repeat = $this->getFrequency(); + if ($repeat === RRule::WEEKLY) { + $text = $this->translate('week(s) on'); + $max = 53; + } elseif ($repeat === RRule::MONTHLY) { + $text = $this->translate('month(s)'); + $max = 12; + } else { + $text = $this->translate('day(s)'); + $max = 31; + } + + $options = ['min' => 1, 'max' => $max]; + $this->addElement('number', 'interval', [ + 'class' => 'autosubmit', + 'value' => 1, + 'min' => 1, + 'max' => $max, + 'validators' => [new BetweenValidator($options)] + ]); + + $numberSpecifier = HtmlElement::create('div', ['class' => 'number-specifier']); + $element = $this->getElement('interval'); + $element->prependWrapper($numberSpecifier); + + $numberSpecifier->prependHtml(HtmlElement::create('span', null, $this->translate('Every'))); + $numberSpecifier->addHtml($element); + $numberSpecifier->addHtml(HtmlElement::create('span', null, $text)); + } +} From 652fba3480fe3cd563f9c0ad4539130d43ee9387 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Tue, 6 Dec 2022 16:23:57 +0100 Subject: [PATCH 10/43] CSS: Add `schedule-element` rules --- asset/css/compat.less | 17 +++++ asset/css/schedule-element.less | 128 ++++++++++++++++++++++++++++++++ asset/css/variables.less | 23 +++++- 3 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 asset/css/compat.less create mode 100644 asset/css/schedule-element.less diff --git a/asset/css/compat.less b/asset/css/compat.less new file mode 100644 index 00000000..8048fabf --- /dev/null +++ b/asset/css/compat.less @@ -0,0 +1,17 @@ +.icinga-form > .schedule-element, +.icinga-form > .schedule-element > fieldset { + margin-top: 1em; + + > .control-group:first-child { + margin-top: 0; + } +} + +.icinga-form .schedule-element { + .control-group > fieldset > .weekly, + .control-group > .ordinal, + .control-group > .monthly, + .control-group > .annually { + flex: 1 1 auto; + } +} diff --git a/asset/css/schedule-element.less b/asset/css/schedule-element.less new file mode 100644 index 00000000..97363479 --- /dev/null +++ b/asset/css/schedule-element.less @@ -0,0 +1,128 @@ +// Schedule form element + +.schedule-element { + + fieldset[name="schedule-element[weekly-fields]"] { + display: flex; + } + + .ordinal { + display: flex; + flex-wrap: wrap; + + .radio-label { + flex: 1 1 auto; + } + + select { + flex: 1 1 auto; + + &:first-of-type { + margin-right: 1em; + } + } + } + + .radio-label { + width: 100%; + margin-bottom: .5em; + display: flex; + align-items: center; // To center the radio element on safari + } + + .number-specifier > input[type="number"] { + width: 5em; + margin: 0 1em; + } + + .monthly, .annually, .ordinal { + padding: .4em; + border: .1em solid @schedule-element-fields-border-color; + .rounded-corners(.6em); + + li { + margin-bottom: 1px; + } + } + + .schedule-element-fields { + margin: 0; + padding: 0; + + &.disabled { // When the "On the" radio button is checked + pointer-events: none; + + label { + color: inherit; + background-color: @schedule-element-fields-disabled-bg; + } + } + + li { + list-style: none; + display: inline-flex; + width: calc(100% / 7); + + label { + width: 100%; + cursor: pointer; + text-align: center; + padding: .8em 0 .8em; + border-right: .1em solid @schedule-element-fields-border-color; + background: @schedule-element-fields-bg; + color: @schedule-element-text-color; + + &:hover { + background-color: @schedule-element-fields-hover-bg; + } + } + + input[type="radio"]:focus + label, + input[type="checkbox"]:focus + label { + background-color: @schedule-element-fields-hover-bg; + color: @schedule-element-text-color; + } + + input:checked:is(:focus) + label { + background-color: @schedule-element-fields-focus-bg; + border-color: @schedule-element-fields-focus-bg; + } + + input:checked + label { + background-color: @schedule-element-fields-selected-bg; + color: @schedule-element-default-text-color; + + &:hover { + background-color: @schedule-element-fields-focus-bg; + border-color: @schedule-element-fields-focus-bg; + } + } + } + } + + .weekly-fields .schedule-element-fields, .monthly .schedule-element-fields { + li:nth-child(7n + 1) label, li:first-of-type label { + border-radius: .4em 0 0 .4em; + } + + li:nth-child(7n) label, li:last-of-type label { + border-right: none; + border-radius: 0 .4em .4em 0; + } + } + + .annually .schedule-element-fields { + li:nth-child(4n + 1) label { + border-radius: .4em 0 0 .4em; + } + + li:nth-child(4n) label { + border-right: none; + border-radius: 0 .4em .4em 0; + } + } + + .annually li { + width: 25%; // 100% / 4 elements + } +} diff --git a/asset/css/variables.less b/asset/css/variables.less index 1d6c8951..a7ceada4 100644 --- a/asset/css/variables.less +++ b/asset/css/variables.less @@ -33,10 +33,12 @@ @base-primary-color: #00C3ED; @base-primary-bg: #00C3ED; +@base-primary-dark: #0081a6; @default-text-color: #fff; @default-text-color-light: fade(@default-text-color, 75%); @default-text-color-inverted: @default-bg; +@default-input-bg: #404d72; @state-ok: #44bb77; @state-up: @state-ok; @@ -48,7 +50,7 @@ @primary-button-color: @default-text-color-inverted; @primary-button-bg: @base-primary-bg; -@primary-button-hover-bg: #0081a6; +@primary-button-hover-bg: @base-primary-dark; @search-term-bg: @base-gray; @search-term-color: @default-text-color-inverted; @@ -66,7 +68,7 @@ @search-logical-operator-bg: @base-gray-light; @search-logical-operator-color: @default-text-color-light; -@searchbar-bg: #404d72; +@searchbar-bg: @default-input-bg; @searchbar-scrollbar-bg: @base-gray-light; @search-editor-control-color: @base-gray-light; @@ -100,6 +102,15 @@ @card-border-color: @base-gray-light; +@schedule-element-fields-bg: @default-input-bg; +@schedule-element-default-text-color: @default-bg; +@schedule-element-text-color: @base-primary-color; +@schedule-element-fields-selected-bg: @base-primary-color; +@schedule-element-fields-hover-bg: fade(@schedule-element-text-color, 5%); +@schedule-element-fields-border-color: @base-gray-light; +@schedule-element-fields-disabled-bg: @base-gray-lighter; +@schedule-element-fields-focus-bg: @base-primary-dark; + @iplWebLightRules: { :root { --base-gray: #819398; @@ -112,12 +123,13 @@ --default-text-color: #535353; --default-text-color-light: fade(#535353, 75%); // --default-text-color --default-text-color-inverted: #F5F9FA; + --default-input-bg: #DEECF1; --primary-button-color: var(--default-text-color-inverted); --primary-button-bg: @primary-button-bg; --primary-button-hover-bg: @primary-button-hover-bg; - --searchbar-bg: #DEECF1; + --searchbar-bg: var(--default-input-bg); --searchbar-scrollbar-bg: var(--base-gray-light); --search-term-bg: var(--base-gray-light); @@ -162,5 +174,10 @@ --suggestions-relation-path-focus-bg: var(--base-gray); --card-border-color: var(--base-gray-light); + + --schedule-element-fields-bg: var(--default-input-bg); + --schedule-element-default-text-color: #F5F9FA; + --schedule-element-fields-border-color: var(--base-gray-light); + --schedule-element-fields-disabled-bg: var(--base-gray-lighter); } }; From 6a39161a0c962af1e6411e02f058f9c0f24ca62e Mon Sep 17 00:00:00 2001 From: Florian Strohmaier Date: Wed, 7 Dec 2022 13:40:34 +0100 Subject: [PATCH 11/43] CSS: Consolidate `border-radius` of `.schedule-element`s --- asset/css/compat.less | 7 +++++++ asset/css/schedule-element.less | 26 ++++++++++++++++++-------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/asset/css/compat.less b/asset/css/compat.less index 8048fabf..98c55d58 100644 --- a/asset/css/compat.less +++ b/asset/css/compat.less @@ -15,3 +15,10 @@ flex: 1 1 auto; } } + +form.icinga-form .control-group { + > .monthly, + > .ordinal { + margin-right: 2em; + } +} diff --git a/asset/css/schedule-element.less b/asset/css/schedule-element.less index 97363479..f23b0b17 100644 --- a/asset/css/schedule-element.less +++ b/asset/css/schedule-element.less @@ -1,6 +1,7 @@ // Schedule form element .schedule-element { + @input-border-radius: .25em; fieldset[name="schedule-element[weekly-fields]"] { display: flex; @@ -36,15 +37,24 @@ } .monthly, .annually, .ordinal { - padding: .4em; - border: .1em solid @schedule-element-fields-border-color; - .rounded-corners(.6em); + padding: .5em; + border: 1px solid @schedule-element-fields-border-color; + .rounded-corners(.75em); li { margin-bottom: 1px; } } + .monthly, .ordinal { + margin-left: -.5em; + } + + .annually { + margin-left: 13.5em; /* 14em - .5em */ + margin-right: 1em; + } + .schedule-element-fields { margin: 0; padding: 0; @@ -68,7 +78,7 @@ cursor: pointer; text-align: center; padding: .8em 0 .8em; - border-right: .1em solid @schedule-element-fields-border-color; + border-right: 1px solid @schedule-element-fields-border-color; background: @schedule-element-fields-bg; color: @schedule-element-text-color; @@ -102,23 +112,23 @@ .weekly-fields .schedule-element-fields, .monthly .schedule-element-fields { li:nth-child(7n + 1) label, li:first-of-type label { - border-radius: .4em 0 0 .4em; + border-radius: @input-border-radius 0 0 @input-border-radius; } li:nth-child(7n) label, li:last-of-type label { border-right: none; - border-radius: 0 .4em .4em 0; + border-radius: 0 @input-border-radius @input-border-radius 0; } } .annually .schedule-element-fields { li:nth-child(4n + 1) label { - border-radius: .4em 0 0 .4em; + border-radius: @input-border-radius 0 0 @input-border-radius; } li:nth-child(4n) label { border-right: none; - border-radius: 0 .4em .4em 0; + border-radius: 0 @input-border-radius @input-border-radius 0; } } From 8c86cc8b4ae50a815477452dfd32a1b3519e2054 Mon Sep 17 00:00:00 2001 From: Florian Strohmaier Date: Wed, 7 Dec 2022 14:30:46 +0100 Subject: [PATCH 12/43] CSS: Visually represent multiselect behavior of `.schedule-element`s --- asset/css/schedule-element.less | 53 ++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/asset/css/schedule-element.less b/asset/css/schedule-element.less index f23b0b17..de1b1ac2 100644 --- a/asset/css/schedule-element.less +++ b/asset/css/schedule-element.less @@ -40,10 +40,6 @@ padding: .5em; border: 1px solid @schedule-element-fields-border-color; .rounded-corners(.75em); - - li { - margin-bottom: 1px; - } } .monthly, .ordinal { @@ -58,6 +54,8 @@ .schedule-element-fields { margin: 0; padding: 0; + .rounded-corners(.25em); + overflow: hidden; &.disabled { // When the "On the" radio button is checked pointer-events: none; @@ -78,7 +76,6 @@ cursor: pointer; text-align: center; padding: .8em 0 .8em; - border-right: 1px solid @schedule-element-fields-border-color; background: @schedule-element-fields-bg; color: @schedule-element-text-color; @@ -87,6 +84,10 @@ } } + &:not(:last-child) label { + border-right: 1px solid @schedule-element-fields-border-color; + } + input[type="radio"]:focus + label, input[type="checkbox"]:focus + label { background-color: @schedule-element-fields-hover-bg; @@ -110,29 +111,45 @@ } } - .weekly-fields .schedule-element-fields, .monthly .schedule-element-fields { - li:nth-child(7n + 1) label, li:first-of-type label { - border-radius: @input-border-radius 0 0 @input-border-radius; + .monthly { + li label { + border-top: 1px solid @schedule-element-fields-border-color; } - li:nth-child(7n) label, li:last-of-type label { + li:first-child, + li:nth-child(2), + li:nth-child(3), + li:nth-child(4), + li:nth-child(5), + li:nth-child(6), + li:nth-child(7) { + label { + border-top: none; + } + } + + li:nth-child(7n) label { border-right: none; - border-radius: 0 @input-border-radius @input-border-radius 0; } } - .annually .schedule-element-fields { - li:nth-child(4n + 1) label { - border-radius: @input-border-radius 0 0 @input-border-radius; + .annually { + li { + width: 25%; // 100% / 4 elements } - li:nth-child(4n) label { + li label { + .rounded-corners(.25em); + margin-right: 1px; + margin-bottom: 1px; + } + + .schedule-element-fields li label { border-right: none; - border-radius: 0 @input-border-radius @input-border-radius 0; } - } - .annually li { - width: 25%; // 100% / 4 elements + li:nth-child(4n) label { + margin-right: 0; + } } } From 5646289b84ef5405fad57d9ad203090d2dc65873 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Thu, 8 Dec 2022 15:17:44 +0100 Subject: [PATCH 13/43] CSS: Adjust annually rules & add `toggle-slider` style sheet --- asset/css/schedule-element.less | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/asset/css/schedule-element.less b/asset/css/schedule-element.less index de1b1ac2..74abb6e8 100644 --- a/asset/css/schedule-element.less +++ b/asset/css/schedule-element.less @@ -36,21 +36,16 @@ margin: 0 1em; } - .monthly, .annually, .ordinal { + .monthly, .ordinal:not(.ordinal-annually) { padding: .5em; border: 1px solid @schedule-element-fields-border-color; .rounded-corners(.75em); } - .monthly, .ordinal { + .monthly, .ordinal:not(.annually) { margin-left: -.5em; } - .annually { - margin-left: 13.5em; /* 14em - .5em */ - margin-right: 1em; - } - .schedule-element-fields { margin: 0; padding: 0; @@ -151,5 +146,13 @@ li:nth-child(4n) label { margin-right: 0; } + + .toggle-slider-controls { + display: flex; + column-gap: 1em; + align-items: center; + margin-top: 1em; + margin-bottom: -.6em; + } } } From a3318ffb187f1c31b02246c1aca43b02be72b53f Mon Sep 17 00:00:00 2001 From: Florian Strohmaier Date: Tue, 13 Dec 2022 10:31:46 +0100 Subject: [PATCH 14/43] CSS: Optimize schedule-element hover/focus/disabled appearance --- asset/css/variables.less | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/asset/css/variables.less b/asset/css/variables.less index a7ceada4..e4eb22f8 100644 --- a/asset/css/variables.less +++ b/asset/css/variables.less @@ -105,11 +105,16 @@ @schedule-element-fields-bg: @default-input-bg; @schedule-element-default-text-color: @default-bg; @schedule-element-text-color: @base-primary-color; + @schedule-element-fields-selected-bg: @base-primary-color; -@schedule-element-fields-hover-bg: fade(@schedule-element-text-color, 5%); +@schedule-element-fields-hover-bg: #406B90; +@schedule-element-fields-selected-hover-bg: #00A5CF; + @schedule-element-fields-border-color: @base-gray-light; + +@schedule-element-fields-disabled-color: @base-gray; @schedule-element-fields-disabled-bg: @base-gray-lighter; -@schedule-element-fields-focus-bg: @base-primary-dark; +@schedule-element-fields-disabled-selected-bg: @base-gray-light; @iplWebLightRules: { :root { @@ -177,6 +182,10 @@ --schedule-element-fields-bg: var(--default-input-bg); --schedule-element-default-text-color: #F5F9FA; + + --schedule-element-fields-hover-bg: #C7E8F0; + --schedule-element-fields-selected-hover-bg: #6FD7EF; + --schedule-element-fields-border-color: var(--base-gray-light); --schedule-element-fields-disabled-bg: var(--base-gray-lighter); } From 3f81f1f843316ede83a3cf7ef40208a6ada79d10 Mon Sep 17 00:00:00 2001 From: Florian Strohmaier Date: Tue, 13 Dec 2022 11:49:27 +0100 Subject: [PATCH 15/43] CSS: Fix focus states for monthly scheduler --- asset/css/schedule-element.less | 47 ++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/asset/css/schedule-element.less b/asset/css/schedule-element.less index 74abb6e8..faa3eb9a 100644 --- a/asset/css/schedule-element.less +++ b/asset/css/schedule-element.less @@ -21,6 +21,10 @@ &:first-of-type { margin-right: 1em; } + + &:disabled { + color: @schedule-element-fields-disabled-color; + } } } @@ -59,6 +63,11 @@ color: inherit; background-color: @schedule-element-fields-disabled-bg; } + + input:checked + label { + background: @schedule-element-fields-disabled-selected-bg; + color: @schedule-element-fields-disabled-color; + } } li { @@ -83,25 +92,9 @@ border-right: 1px solid @schedule-element-fields-border-color; } - input[type="radio"]:focus + label, - input[type="checkbox"]:focus + label { - background-color: @schedule-element-fields-hover-bg; - color: @schedule-element-text-color; - } - - input:checked:is(:focus) + label { - background-color: @schedule-element-fields-focus-bg; - border-color: @schedule-element-fields-focus-bg; - } - input:checked + label { background-color: @schedule-element-fields-selected-bg; color: @schedule-element-default-text-color; - - &:hover { - background-color: @schedule-element-fields-focus-bg; - border-color: @schedule-element-fields-focus-bg; - } } } } @@ -126,6 +119,23 @@ li:nth-child(7n) label { border-right: none; } + + input:checked + label { + &:hover { + background-color: @schedule-element-fields-selected-hover-bg; + border-color: @schedule-element-fields-selected-hover-bg; + } + } + + input:focus + label { + //border: 3px solid lime; + box-shadow: inset 0 0 0 3px rgba(0, 195, 237, 0.5); + } + + input:checked:focus + label { + //border: 3px solid lime; + box-shadow: inset 0 0 0 3px rgba(255,255,255,.5); + } } .annually { @@ -143,6 +153,11 @@ border-right: none; } + .schedule-element-fields:focus-within { + outline: 3px solid rgba(0, 195, 237, 0.5); + outline-offset: 2px; + } + li:nth-child(4n) label { margin-right: 0; } From 1f6d98f125d5a07fc00c11e33840108dcb77defe Mon Sep 17 00:00:00 2001 From: Florian Strohmaier Date: Tue, 13 Dec 2022 12:46:23 +0100 Subject: [PATCH 16/43] CSS: Optimizes `.ordinal-annually` spacing --- asset/css/compat.less | 4 ++++ asset/css/schedule-element.less | 7 ++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/asset/css/compat.less b/asset/css/compat.less index 98c55d58..8233bb84 100644 --- a/asset/css/compat.less +++ b/asset/css/compat.less @@ -21,4 +21,8 @@ form.icinga-form .control-group { > .ordinal { margin-right: 2em; } + + > .ordinal.annually { + margin-right: 1em; + } } diff --git a/asset/css/schedule-element.less b/asset/css/schedule-element.less index faa3eb9a..f8a1a385 100644 --- a/asset/css/schedule-element.less +++ b/asset/css/schedule-element.less @@ -40,16 +40,13 @@ margin: 0 1em; } - .monthly, .ordinal:not(.ordinal-annually) { + .monthly, .ordinal:not(.annually) { padding: .5em; + margin-left: -.5em; border: 1px solid @schedule-element-fields-border-color; .rounded-corners(.75em); } - .monthly, .ordinal:not(.annually) { - margin-left: -.5em; - } - .schedule-element-fields { margin: 0; padding: 0; From 35a3a87261d136df65b27e9adc6fb9741374063f Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Thu, 12 Jan 2023 12:25:16 +0100 Subject: [PATCH 17/43] CSS: Fix hover & focus colors for weekly fields --- asset/css/schedule-element.less | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/asset/css/schedule-element.less b/asset/css/schedule-element.less index f8a1a385..ae5bb649 100644 --- a/asset/css/schedule-element.less +++ b/asset/css/schedule-element.less @@ -93,6 +93,21 @@ background-color: @schedule-element-fields-selected-bg; color: @schedule-element-default-text-color; } + + input:checked + label:hover { + background-color: @schedule-element-fields-selected-hover-bg; + border-color: @schedule-element-fields-selected-hover-bg; + } + } + + &.multiple-fields { + input:focus + label { + box-shadow: inset 0 0 0 3px rgba(0, 195, 237, 0.5); + } + + input:checked:focus + label { + box-shadow: inset 0 0 0 3px rgba(255, 255, 255, .5); + } } } @@ -116,23 +131,6 @@ li:nth-child(7n) label { border-right: none; } - - input:checked + label { - &:hover { - background-color: @schedule-element-fields-selected-hover-bg; - border-color: @schedule-element-fields-selected-hover-bg; - } - } - - input:focus + label { - //border: 3px solid lime; - box-shadow: inset 0 0 0 3px rgba(0, 195, 237, 0.5); - } - - input:checked:focus + label { - //border: 3px solid lime; - box-shadow: inset 0 0 0 3px rgba(255,255,255,.5); - } } .annually { From 5784f63499c766b5964ce1e9fe2d872c8303a859 Mon Sep 17 00:00:00 2001 From: Florian Strohmaier Date: Mon, 16 Jan 2023 17:10:59 +0100 Subject: [PATCH 18/43] CSS: Separate styles for single and multiple selective fields --- asset/css/schedule-element.less | 69 +++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/asset/css/schedule-element.less b/asset/css/schedule-element.less index ae5bb649..3db9d8b9 100644 --- a/asset/css/schedule-element.less +++ b/asset/css/schedule-element.less @@ -48,16 +48,19 @@ } .schedule-element-fields { + list-style-type: none; margin: 0; padding: 0; .rounded-corners(.25em); overflow: hidden; + display:flex; + flex-wrap: wrap; &.disabled { // When the "On the" radio button is checked pointer-events: none; label { - color: inherit; + color: @schedule-element-fields-disabled-color; background-color: @schedule-element-fields-disabled-bg; } @@ -68,15 +71,14 @@ } li { - list-style: none; - display: inline-flex; - width: calc(100% / 7); + width: calc(100% / 7); /* default for week based cols makes sense */ label { + display: block; width: 100%; cursor: pointer; text-align: center; - padding: .8em 0 .8em; + padding: .75em 0; background: @schedule-element-fields-bg; color: @schedule-element-text-color; @@ -85,10 +87,6 @@ } } - &:not(:last-child) label { - border-right: 1px solid @schedule-element-fields-border-color; - } - input:checked + label { background-color: @schedule-element-fields-selected-bg; color: @schedule-element-default-text-color; @@ -101,6 +99,10 @@ } &.multiple-fields { + li:not(:last-child) label { + border-right: 1px solid @schedule-element-fields-border-color; + } + input:focus + label { box-shadow: inset 0 0 0 3px rgba(0, 195, 237, 0.5); } @@ -109,8 +111,37 @@ box-shadow: inset 0 0 0 3px rgba(255, 255, 255, .5); } } + + &.single-fields { + li { + padding-right: 1px; + } + + li label { + .rounded-corners(.25em); + margin-right: 1px; + margin-bottom: 1px; + } + + li label { + border-right: none; + } + + &:focus-within { + outline: 3px solid fade(@base-primary-bg, 50); + outline-offset: 2px; + } + + input:checked + label:hover { + background-color: @schedule-element-fields-selected-bg; + } + } } + /* .weekly */ + .weekly { } + + /* .monthly styles */ .monthly { li label { border-top: 1px solid @schedule-element-fields-border-color; @@ -128,31 +159,19 @@ } } - li:nth-child(7n) label { + /* last of row should not have a border */ + .schedule-element-fields li:nth-child(7n) label { border-right: none; } } + + /* .annually styles */ .annually { li { width: 25%; // 100% / 4 elements } - li label { - .rounded-corners(.25em); - margin-right: 1px; - margin-bottom: 1px; - } - - .schedule-element-fields li label { - border-right: none; - } - - .schedule-element-fields:focus-within { - outline: 3px solid rgba(0, 195, 237, 0.5); - outline-offset: 2px; - } - li:nth-child(4n) label { margin-right: 0; } From 44b8d38bcfe91c28bca1068080c07c263306be7c Mon Sep 17 00:00:00 2001 From: Florian Strohmaier Date: Tue, 17 Jan 2023 15:19:33 +0100 Subject: [PATCH 19/43] variables.less: Avoid absolute color values --- asset/css/variables.less | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/asset/css/variables.less b/asset/css/variables.less index e4eb22f8..ec3e18c5 100644 --- a/asset/css/variables.less +++ b/asset/css/variables.less @@ -34,6 +34,7 @@ @base-primary-color: #00C3ED; @base-primary-bg: #00C3ED; @base-primary-dark: #0081a6; +@base-primary-light: fade(@icinga-blue, 35%); @default-text-color: #fff; @default-text-color-light: fade(@default-text-color, 75%); @@ -107,7 +108,8 @@ @schedule-element-text-color: @base-primary-color; @schedule-element-fields-selected-bg: @base-primary-color; -@schedule-element-fields-hover-bg: #406B90; +@schedule-element-fields-hover-bg: fade(@base-primary-dark, 30); + @schedule-element-fields-selected-hover-bg: #00A5CF; @schedule-element-fields-border-color: @base-gray-light; @@ -181,10 +183,9 @@ --card-border-color: var(--base-gray-light); --schedule-element-fields-bg: var(--default-input-bg); - --schedule-element-default-text-color: #F5F9FA; + --schedule-element-default-text-color: var(--base-primary-color); - --schedule-element-fields-hover-bg: #C7E8F0; - --schedule-element-fields-selected-hover-bg: #6FD7EF; + --schedule-element-fields-hover-bg: @base-primary-light; --schedule-element-fields-border-color: var(--base-gray-light); --schedule-element-fields-disabled-bg: var(--base-gray-lighter); From b1c21445818b402e5554b5fddc40dc4abc505dbd Mon Sep 17 00:00:00 2001 From: Florian Strohmaier Date: Fri, 27 Jan 2023 17:26:00 +0100 Subject: [PATCH 20/43] AnnuallyFields: Add keyboard navigation notes --- src/FormElement/AnnuallyFields.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/FormElement/AnnuallyFields.php b/src/FormElement/AnnuallyFields.php index 7892ecdd..eb797505 100644 --- a/src/FormElement/AnnuallyFields.php +++ b/src/FormElement/AnnuallyFields.php @@ -5,10 +5,13 @@ use InvalidArgumentException; use ipl\Html\Attributes; use ipl\Html\Form; +use ipl\Html\FormattedString; use ipl\Html\FormElement\FieldsetElement; use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; use ipl\Web\Common\FieldsProtector; use ipl\Web\Common\ScheduleFieldsUtils; +use ipl\Web\Widget\Icon; class AnnuallyFields extends FieldsetElement { @@ -100,6 +103,16 @@ protected function assemble() $checkboxControls->prependWrapper($annuallyWrapper); $annuallyWrapper->addHtml($fieldsSelector); + $notes = HtmlElement::create('div', ['class' => 'note']); + $notes->addHtml( + FormattedString::create( + $this->translate('Use %s / %s keys to choose a month by keyboard.'), + new Icon('arrow-left'), + new Icon('arrow-right') + ) + ); + $annuallyWrapper->addHtml($notes); + if ($runsOnThe === 'n' && $this->isAutoSubmitted) { $this->clearPopulatedValue('ordinal'); $this->clearPopulatedValue('day'); From f71e549ccf0b79b3fa709a25c23b82a05d31fd39 Mon Sep 17 00:00:00 2001 From: Florian Strohmaier Date: Mon, 23 Jan 2023 13:21:50 +0100 Subject: [PATCH 21/43] CSS: Style keyboard navigation note --- asset/css/schedule-element.less | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/asset/css/schedule-element.less b/asset/css/schedule-element.less index 3db9d8b9..84f9abe6 100644 --- a/asset/css/schedule-element.less +++ b/asset/css/schedule-element.less @@ -132,12 +132,26 @@ outline-offset: 2px; } + &:focus-within + .note { + display: block; + } + input:checked + label:hover { background-color: @schedule-element-fields-selected-bg; } } } + .note { + display: none; + padding: .5em; + background: @base-gray-light; + .rounded-corners(.25em); + text-align: center; + margin-top: 1em; + line-height: 1.25; + } + /* .weekly */ .weekly { } From 692da07887dc2f8f1ec13fe7faab2f47fafccb14 Mon Sep 17 00:00:00 2001 From: Florian Strohmaier Date: Thu, 2 Feb 2023 14:42:09 +0100 Subject: [PATCH 22/43] CSS: Fixes --- asset/css/variables.less | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/asset/css/variables.less b/asset/css/variables.less index ec3e18c5..31e17716 100644 --- a/asset/css/variables.less +++ b/asset/css/variables.less @@ -51,7 +51,7 @@ @primary-button-color: @default-text-color-inverted; @primary-button-bg: @base-primary-bg; -@primary-button-hover-bg: @base-primary-dark; +@primary-button-hover-bg: #0081a6; @search-term-bg: @base-gray; @search-term-color: @default-text-color-inverted; @@ -104,13 +104,13 @@ @card-border-color: @base-gray-light; @schedule-element-fields-bg: @default-input-bg; -@schedule-element-default-text-color: @default-bg; +@schedule-element-default-text-color: @default-text-color-inverted; @schedule-element-text-color: @base-primary-color; -@schedule-element-fields-selected-bg: @base-primary-color; -@schedule-element-fields-hover-bg: fade(@base-primary-dark, 30); +@schedule-element-fields-selected-bg: @primary-button-bg; +@schedule-element-fields-hover-bg: @base-primary-light; -@schedule-element-fields-selected-hover-bg: #00A5CF; +@schedule-element-fields-selected-hover-bg: @primary-button-hover-bg; @schedule-element-fields-border-color: @base-gray-light; @@ -127,6 +127,9 @@ --base-remove-bg: @state-critical; + --base-primary-dark: fade(@primary-button-bg, 35%); + --base-primary-light: fade(@primary-button-bg, 35%); + --default-text-color: #535353; --default-text-color-light: fade(#535353, 75%); // --default-text-color --default-text-color-inverted: #F5F9FA; @@ -183,11 +186,18 @@ --card-border-color: var(--base-gray-light); --schedule-element-fields-bg: var(--default-input-bg); - --schedule-element-default-text-color: var(--base-primary-color); + --schedule-element-default-text-color: var(--default-text-color-inverted); + --schedule-element-text-color: var(--base-primary-color); + + --schedule-element-fields-selected-bg: var(--base-primary-bg); + --schedule-element-fields-hover-bg: var(--base-primary-light); - --schedule-element-fields-hover-bg: @base-primary-light; + --schedule-element-fields-selected-hover-bg: var(--base-primary-light); --schedule-element-fields-border-color: var(--base-gray-light); + + --schedule-element-fields-disabled-color: var(--base-gray); --schedule-element-fields-disabled-bg: var(--base-gray-lighter); + --schedule-element-fields-disabled-selected-bg: var(--base-gray-light); } }; From c394531e6fd9f776114b5475c2748218a7443703 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Thu, 2 Feb 2023 12:51:28 +0100 Subject: [PATCH 23/43] Move all newly added classes to a new namespace --- src/FormElement/ScheduleElement.php | 5 ++++- .../{ => ScheduleElement}/AnnuallyFields.php | 10 +++++----- .../ScheduleElement}/Common/FieldsProtector.php | 2 +- .../ScheduleElement/Common/FieldsUtils.php} | 6 +++--- .../FieldsRadio.php} | 6 +++--- .../{ => ScheduleElement}/MonthlyFields.php | 8 ++++---- .../Recurrence.php} | 4 ++-- src/FormElement/{ => ScheduleElement}/WeeklyFields.php | 4 ++-- 8 files changed, 24 insertions(+), 21 deletions(-) rename src/FormElement/{ => ScheduleElement}/AnnuallyFields.php (95%) rename src/{ => FormElement/ScheduleElement}/Common/FieldsProtector.php (92%) rename src/{Common/ScheduleFieldsUtils.php => FormElement/ScheduleElement/Common/FieldsUtils.php} (98%) rename src/FormElement/{ScheduleFieldsRadio.php => ScheduleElement/FieldsRadio.php} (94%) rename src/FormElement/{ => ScheduleElement}/MonthlyFields.php (96%) rename src/FormElement/{ScheduleRecurrence.php => ScheduleElement/Recurrence.php} (96%) rename src/FormElement/{ => ScheduleElement}/WeeklyFields.php (97%) diff --git a/src/FormElement/ScheduleElement.php b/src/FormElement/ScheduleElement.php index 87981f8e..72f121c7 100644 --- a/src/FormElement/ScheduleElement.php +++ b/src/FormElement/ScheduleElement.php @@ -8,7 +8,10 @@ use ipl\Html\HtmlElement; use ipl\Scheduler\RRule; use ipl\Validator\BetweenValidator; -use ipl\Web\Common\FieldsProtector; +use ipl\Web\FormElement\ScheduleElement\AnnuallyFields; +use ipl\Web\FormElement\ScheduleElement\Common\FieldsProtector; +use ipl\Web\FormElement\ScheduleElement\MonthlyFields; +use ipl\Web\FormElement\ScheduleElement\WeeklyFields; class ScheduleElement extends FieldsetElement { diff --git a/src/FormElement/AnnuallyFields.php b/src/FormElement/ScheduleElement/AnnuallyFields.php similarity index 95% rename from src/FormElement/AnnuallyFields.php rename to src/FormElement/ScheduleElement/AnnuallyFields.php index eb797505..838178ec 100644 --- a/src/FormElement/AnnuallyFields.php +++ b/src/FormElement/ScheduleElement/AnnuallyFields.php @@ -1,6 +1,6 @@ getAttributes()->set('id', $this->protectId('annually-fields')); - $fieldsSelector = new ScheduleFieldsRadio('month', [ + $fieldsSelector = new FieldsRadio('month', [ 'class' => 'autosubmit sr-only', 'value' => $this->default, 'options' => $this->months, diff --git a/src/Common/FieldsProtector.php b/src/FormElement/ScheduleElement/Common/FieldsProtector.php similarity index 92% rename from src/Common/FieldsProtector.php rename to src/FormElement/ScheduleElement/Common/FieldsProtector.php index e51d4e5f..0ed31261 100644 --- a/src/Common/FieldsProtector.php +++ b/src/FormElement/ScheduleElement/Common/FieldsProtector.php @@ -1,6 +1,6 @@ Date: Thu, 2 Feb 2023 17:33:19 +0100 Subject: [PATCH 24/43] Clean up code --- .../ScheduleElement/AnnuallyFields.php | 19 ++++---- .../Common/FieldsProtector.php | 6 +-- .../ScheduleElement/FieldsRadio.php | 46 +++++-------------- .../ScheduleElement/WeeklyFields.php | 19 ++++---- 4 files changed, 33 insertions(+), 57 deletions(-) diff --git a/src/FormElement/ScheduleElement/AnnuallyFields.php b/src/FormElement/ScheduleElement/AnnuallyFields.php index 838178ec..27cebfb6 100644 --- a/src/FormElement/ScheduleElement/AnnuallyFields.php +++ b/src/FormElement/ScheduleElement/AnnuallyFields.php @@ -8,7 +8,6 @@ use ipl\Html\FormattedString; use ipl\Html\FormElement\FieldsetElement; use ipl\Html\HtmlElement; -use ipl\Html\HtmlString; use ipl\Web\FormElement\ScheduleElement\Common\FieldsProtector; use ipl\Web\FormElement\ScheduleElement\Common\FieldsUtils; use ipl\Web\Widget\Icon; @@ -27,11 +26,8 @@ class AnnuallyFields extends FieldsetElement /** @var string A month to preselect by default */ protected $default = 'JAN'; - protected function init(): void + public function __construct($name, $attributes = null) { - parent::init(); - $this->initUtils(); - $this->months = [ 'JAN' => $this->translate('Jan'), 'FEB' => $this->translate('Feb'), @@ -46,6 +42,14 @@ protected function init(): void 'NOV' => $this->translate('Nov'), 'DEC' => $this->translate('Dec') ]; + + parent::__construct($name, $attributes); + } + + protected function init(): void + { + parent::init(); + $this->initUtils(); } public function onRegistered(Form $form) @@ -64,8 +68,7 @@ public function onRegistered(Form $form) */ public function setDefault(string $default): self { - // Attributes are registered far before the initialization of this element! - if (! empty($this->months) && ! isset($this->months[strtoupper($this->default)])) { + if (! isset($this->months[strtoupper($this->default)])) { throw new InvalidArgumentException(sprintf('Invalid month provided: %s', $default)); } @@ -79,7 +82,7 @@ protected function assemble() $this->getAttributes()->set('id', $this->protectId('annually-fields')); $fieldsSelector = new FieldsRadio('month', [ - 'class' => 'autosubmit sr-only', + 'class' => ['autosubmit', 'sr-only'], 'value' => $this->default, 'options' => $this->months, 'protector' => function ($value) { diff --git a/src/FormElement/ScheduleElement/Common/FieldsProtector.php b/src/FormElement/ScheduleElement/Common/FieldsProtector.php index 0ed31261..affd5198 100644 --- a/src/FormElement/ScheduleElement/Common/FieldsProtector.php +++ b/src/FormElement/ScheduleElement/Common/FieldsProtector.php @@ -26,11 +26,11 @@ public function setIdProtector(?callable $protector): self * * The provided id is returned as is, if no protector is specified * - * @param mixed $id + * @param string $id * - * @return mixed + * @return string */ - public function protectId($id) + public function protectId(string $id): string { if (is_callable($this->protector)) { return call_user_func($this->protector, $id); diff --git a/src/FormElement/ScheduleElement/FieldsRadio.php b/src/FormElement/ScheduleElement/FieldsRadio.php index aac3f877..b3a01bf4 100644 --- a/src/FormElement/ScheduleElement/FieldsRadio.php +++ b/src/FormElement/ScheduleElement/FieldsRadio.php @@ -12,23 +12,6 @@ class FieldsRadio extends RadioElement { use FieldsProtector; - /** @var bool Whether to disable the "on the" radio options */ - protected $disable; - - /** - * En/Disable the "on the" radio options of this element - * - * @param bool $value - * - * @return $this - */ - public function disable(bool $value): self - { - $this->disable = $value; - - return $this; - } - protected function assemble() { $listItems = HtmlElement::create('ul', ['class' => ['schedule-element-fields', 'single-fields']]); @@ -41,31 +24,24 @@ protected function assemble() $htmlId = $this->protectId($option->getValue()); $radio->getAttributes() - ->registerAttributeCallback('id', function () use ($htmlId) { - return $htmlId; - }) + ->set('id', $htmlId) ->registerAttributeCallback('checked', function () use ($option) { return (string) $this->getValue() === (string) $option->getValue(); }) - ->registerAttributeCallback('required', function () { - return $this->getRequiredAttribute(); - }) + ->registerAttributeCallback('required', [$this, 'getRequiredAttribute']) ->registerAttributeCallback('disabled', function () use ($option) { - return $option->isDisabled(); - }) - ->registerAttributeCallback('class', function () use ($option) { - return Attributes::create(['class', $option->getLabelCssClass()])->get('class'); + return $this->getAttributes()->get('disabled')->getValue() || $option->isDisabled(); }); $listItem = HtmlElement::create('li'); - $radio->prependWrapper($listItem); - - $listItem->addHtml($radio, HtmlElement::create('label', ['for' => $htmlId], $option->getLabel())); - $listItems->addHtml($radio); - } - - if ($this->disable) { - $listItems->getAttributes()->add('class', 'disabled'); + $listItem->addHtml( + $radio, + HtmlElement::create('label', [ + 'for' => $htmlId, + 'class' => $option->getLabelCssClass() + ], $option->getLabel()) + ); + $listItems->addHtml($listItem); } $this->addHtml($listItems); diff --git a/src/FormElement/ScheduleElement/WeeklyFields.php b/src/FormElement/ScheduleElement/WeeklyFields.php index da8238cd..cab42cbc 100644 --- a/src/FormElement/ScheduleElement/WeeklyFields.php +++ b/src/FormElement/ScheduleElement/WeeklyFields.php @@ -18,10 +18,8 @@ class WeeklyFields extends FieldsetElement /** @var string A valid weekday to be selected by default */ protected $default = 'MO'; - protected function init(): void + public function __construct($name, $attributes = null) { - parent::init(); - $this->weekdays = [ 'MO' => $this->translate('Mon'), 'TU' => $this->translate('Tue'), @@ -31,6 +29,8 @@ protected function init(): void 'SA' => $this->translate('Sat'), 'SU' => $this->translate('Sun') ]; + + parent::__construct($name, $attributes); } /** @@ -43,8 +43,7 @@ protected function init(): void public function setDefault(string $default): self { $weekday = strlen($default) > 2 ? substr($default, 0, -1) : $default; - // Attributes are registered far before the initialization of this element! - if (! empty($this->weekdays) && ! isset($this->weekdays[strtoupper($weekday)])) { + if (! isset($this->weekdays[strtoupper($weekday)])) { throw new InvalidArgumentException(sprintf('Invalid weekday provided: %s', $default)); } @@ -106,7 +105,7 @@ protected function assemble() $foundCheckedDay = false; foreach ($this->weekdays as $day => $value) { $checkbox = $this->createElement('checkbox', $day, [ - 'class' => 'sr-only autosubmit', + 'class' => ['autosubmit', 'sr-only'], 'value' => $this->getPopulatedValue($day, 'n') ]); $this->registerElement($checkbox); @@ -116,18 +115,16 @@ protected function assemble() $checkbox->getAttributes()->set('id', $htmlId); $listItem = HtmlElement::create('li'); - $checkbox->prependWrapper($listItem); - $listItem->addHtml($checkbox, HtmlElement::create('label', ['for' => $htmlId], $value)); - $listItems->addHtml($checkbox); + $listItems->addHtml($listItem); } if (! $foundCheckedDay) { $this->getElement($this->default)->setChecked(true); } - $listItems->prependWrapper($fieldsWrapper); - $this->addHtml($listItems); + $fieldsWrapper->addHtml($listItems); + $this->addHtml($fieldsWrapper); } protected function registerAttributeCallbacks(Attributes $attributes) From d5fd839482aed48931ce6b74fd515bc1d8dd473d Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Fri, 3 Feb 2023 15:20:23 +0100 Subject: [PATCH 25/43] Recurrence: Use html text & format datetime properly --- src/FormElement/ScheduleElement/Recurrence.php | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/FormElement/ScheduleElement/Recurrence.php b/src/FormElement/ScheduleElement/Recurrence.php index 45a06bf5..d7a5eac1 100644 --- a/src/FormElement/ScheduleElement/Recurrence.php +++ b/src/FormElement/ScheduleElement/Recurrence.php @@ -6,7 +6,7 @@ use ipl\Html\Attributes; use ipl\Html\FormElement\BaseFormElement; use ipl\Html\HtmlElement; -use ipl\Html\HtmlString; +use ipl\Html\Text; use ipl\I18n\Translation; use ipl\Scheduler\Contract\Frequency; use ipl\Scheduler\RRule; @@ -65,23 +65,13 @@ protected function assemble() $recurrences = $frequency->getNextRecurrences(new DateTime(), 3); if (! $recurrences->valid()) { // Such a situation can be caused by setting an invalid end time - $this->addHtml(HtmlString::create($this->translate('Recurrences cannot be generated'))); + $this->addHtml(Text::create($this->translate('Recurrences cannot be generated'))); return; } foreach ($recurrences as $recurrence) { - $this->addHtml( - HtmlElement::create( - 'p', - null, - sprintf( - '%s, %s', - $recurrence->format('D'), - $recurrence->format('Y/m/d, H:i:s') - ) - ) - ); + $this->addHtml(HtmlElement::create('p', null, $recurrence->format($this->translate('D, Y/m/d, H:i:s')))); } } From 377271c78b542eb54edcc96cf610a1f9b133f5d4 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Fri, 3 Feb 2023 15:21:10 +0100 Subject: [PATCH 26/43] MonthlyFields: Avoid setting wrappers explicitly & use `InArrayValidator` --- .../ScheduleElement/MonthlyFields.php | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/src/FormElement/ScheduleElement/MonthlyFields.php b/src/FormElement/ScheduleElement/MonthlyFields.php index 0a9e557c..360e09de 100644 --- a/src/FormElement/ScheduleElement/MonthlyFields.php +++ b/src/FormElement/ScheduleElement/MonthlyFields.php @@ -5,7 +5,7 @@ use ipl\Html\Attributes; use ipl\Html\FormElement\FieldsetElement; use ipl\Html\HtmlElement; -use ipl\Validator\DeferredInArrayValidator; +use ipl\Validator\InArrayValidator; use ipl\Web\FormElement\ScheduleElement\Common\FieldsProtector; use ipl\Web\FormElement\ScheduleElement\Common\FieldsUtils; @@ -26,7 +26,7 @@ class MonthlyFields extends FieldsetElement /** @var int Day of the month to preselect by default */ protected $default = 1; - /** @var int Available fields to be rendered */ + /** @var int Number of fields to render */ protected $availableFields; protected function init(): void @@ -52,7 +52,7 @@ public function setAvailableFields(int $fields): self } /** - * Set the default field/day to be selected by default + * Set the default field/day to be selected * * @param int $default * @@ -106,7 +106,7 @@ protected function assemble() $foundCheckedDay = false; foreach (range(1, $this->availableFields) as $day) { $checkbox = $this->createElement('checkbox', "day$day", [ - 'class' => 'sr-only autosubmit', + 'class' => ['autosubmit', 'sr-only'], 'value' => $this->getPopulatedValue("day$day", 'n') ]); $this->registerElement($checkbox); @@ -116,10 +116,8 @@ protected function assemble() $checkbox->getAttributes()->set('id', $htmlId); $listItem = HtmlElement::create('li'); - $checkbox->prependWrapper($listItem); - $listItem->addHtml($checkbox, HtmlElement::create('label', ['for' => $htmlId], $day)); - $listItems->addHtml($checkbox); + $listItems->addHtml($listItem); } if (! $foundCheckedDay) { @@ -132,23 +130,19 @@ protected function assemble() $monthlyWrapper->addHtml($runsEach, $listItems); $this->addElement('radio', 'runsOn', [ - 'required' => $runsOn !== static::RUNS_EACH, - 'class' => 'autosubmit', - 'options' => [static::RUNS_ONTHE => $this->translate('On the')] + 'required' => $runsOn !== static::RUNS_EACH, + 'class' => 'autosubmit', + 'options' => [static::RUNS_ONTHE => $this->translate('On the')], + 'validators' => [ + new InArrayValidator([ + 'strict' => true, + 'haystack' => [static::RUNS_EACH, static::RUNS_ONTHE] + ]) + ] ]); - $runsOnThe = $this->getElement('runsOn'); - $runsOnValidators = $runsOnThe->getValidators(); - $runsOnValidators - ->clearValidators() - ->add( - new DeferredInArrayValidator(function (): array { - return [static::RUNS_EACH, static::RUNS_ONTHE]; - }), - true - ); - $ordinalWrapper = HtmlElement::create('div', ['class' => 'ordinal']); + $runsOnThe = $this->getElement('runsOn'); $runsOnThe->prependWrapper($ordinalWrapper); $ordinalWrapper->addHtml($runsOnThe); From 34e93cd7e1437c727313715c6e72912a89920341 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Fri, 3 Feb 2023 16:05:12 +0100 Subject: [PATCH 27/43] Add missing `Recurrence` css rules --- asset/css/schedule-element.less | 9 +++++++++ src/FormElement/ScheduleElement/Recurrence.php | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/asset/css/schedule-element.less b/asset/css/schedule-element.less index 84f9abe6..c028e22f 100644 --- a/asset/css/schedule-element.less +++ b/asset/css/schedule-element.less @@ -199,3 +199,12 @@ } } } + +.schedule-recurrences { + line-height: 1.1em; + padding-top: 0.5625em; + + p { + color: @schedule-element-fields-disabled-color; + } +} diff --git a/src/FormElement/ScheduleElement/Recurrence.php b/src/FormElement/ScheduleElement/Recurrence.php index d7a5eac1..3c2e0f6a 100644 --- a/src/FormElement/ScheduleElement/Recurrence.php +++ b/src/FormElement/ScheduleElement/Recurrence.php @@ -17,7 +17,7 @@ class Recurrence extends BaseFormElement protected $tag = 'div'; - protected $defaultAttributes = ['class' => 'schedule-occurrence']; + protected $defaultAttributes = ['class' => 'schedule-recurrences']; /** @var callable A callable that generates a frequency instance */ protected $frequencyCallback; From 4b2ab3051397eed3db0b087cb8a47500d87dd2c4 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Mon, 6 Feb 2023 10:04:02 +0100 Subject: [PATCH 28/43] FieldsUtils: Use line separator to render regular & non-regular options --- .../ScheduleElement/Common/FieldsUtils.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/FormElement/ScheduleElement/Common/FieldsUtils.php b/src/FormElement/ScheduleElement/Common/FieldsUtils.php index f078eb04..e0263da9 100644 --- a/src/FormElement/ScheduleElement/Common/FieldsUtils.php +++ b/src/FormElement/ScheduleElement/Common/FieldsUtils.php @@ -58,18 +58,19 @@ protected function createOrdinalElement(): FormElement protected function createOrdinalSelectableDays(): FormElement { - return $this->createElement('select', 'day', [ + $select = $this->createElement('select', 'day', [ 'class' => 'autosubmit', 'value' => $this->getPopulatedValue('day', static::$everyDay), - 'options' => [ - 'Regular' => $this->regulars, - 'Non Standards' => [ - static::$everyDay => $this->translate('Day'), - static::$everyWeekday => $this->translate('Weekday (Mon - Fri)'), - static::$everyWeekend => $this->translate('WeekEnd (Sat or Sun)') - ] + 'options' => $this->regulars + [ + 'separator' => '──────────────────────────', + static::$everyDay => $this->translate('Day'), + static::$everyWeekday => $this->translate('Weekday (Mon - Fri)'), + static::$everyWeekend => $this->translate('WeekEnd (Sat or Sun)') ] ]); + $select->getOption('separator')->getAttributes()->set('disabled', true); + + return $select; } /** From 70652a26cba7cf35b8dfbe891620da0d6f7d5a4f Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Mon, 6 Feb 2023 10:18:57 +0100 Subject: [PATCH 29/43] FieldsUtils: Optimize regex --- src/FormElement/ScheduleElement/Common/FieldsUtils.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FormElement/ScheduleElement/Common/FieldsUtils.php b/src/FormElement/ScheduleElement/Common/FieldsUtils.php index e0263da9..493099e0 100644 --- a/src/FormElement/ScheduleElement/Common/FieldsUtils.php +++ b/src/FormElement/ScheduleElement/Common/FieldsUtils.php @@ -108,7 +108,7 @@ public function loadRRule(RRule $rule): array $values['runsOnThe'] = ! empty($byDay) ? 'y' : 'n'; } - if (count($byDay) == 1 && preg_match('/^(-?\d)+(\S.*)$/', $byDay[0], $matches)) { + if (count($byDay) == 1 && preg_match('/^(-?\d)(\w.*)$/', $byDay[0], $matches)) { $values['ordinal'] = $this->getOrdinalString($matches[1]); $values['day'] = $this->getWeekdayName($matches[2]); } elseif (! empty($byDay)) { From 01c30b6b605f9a85d5d3917dc437776d9af64aae Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Mon, 6 Feb 2023 11:39:53 +0100 Subject: [PATCH 30/43] Fix flickering annually fields on chrome/firefox --- asset/css/schedule-element.less | 4 ++++ src/FormElement/ScheduleElement/FieldsRadio.php | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/asset/css/schedule-element.less b/asset/css/schedule-element.less index c028e22f..b8ec87ab 100644 --- a/asset/css/schedule-element.less +++ b/asset/css/schedule-element.less @@ -85,6 +85,10 @@ &:hover { background-color: @schedule-element-fields-hover-bg; } + + &:focus { + outline: none; + } } input:checked + label { diff --git a/src/FormElement/ScheduleElement/FieldsRadio.php b/src/FormElement/ScheduleElement/FieldsRadio.php index b3a01bf4..31b77c34 100644 --- a/src/FormElement/ScheduleElement/FieldsRadio.php +++ b/src/FormElement/ScheduleElement/FieldsRadio.php @@ -37,8 +37,9 @@ protected function assemble() $listItem->addHtml( $radio, HtmlElement::create('label', [ - 'for' => $htmlId, - 'class' => $option->getLabelCssClass() + 'for' => $htmlId, + 'class' => $option->getLabelCssClass(), + 'tabindex' => -1 ], $option->getLabel()) ); $listItems->addHtml($listItem); From 1314cba8cb4b4895fb7e3f7cd8394d17960dd346 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Mon, 6 Feb 2023 15:27:26 +0100 Subject: [PATCH 31/43] ScheduleElement: Support also non-advanced frequencies --- composer.json | 1 + src/FormElement/ScheduleElement.php | 458 +++++++++++++++++++++------- 2 files changed, 351 insertions(+), 108 deletions(-) diff --git a/composer.json b/composer.json index 90173f1f..bd2f6775 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "require": { "php": ">=7.2", "ext-json": "*", + "psr/http-message": "^1.0", "ipl/html": ">=0.6.0", "ipl/i18n": ">=0.2.0", "ipl/stdlib": ">=0.12.0", diff --git a/src/FormElement/ScheduleElement.php b/src/FormElement/ScheduleElement.php index 72f121c7..2cd3087a 100644 --- a/src/FormElement/ScheduleElement.php +++ b/src/FormElement/ScheduleElement.php @@ -6,28 +6,56 @@ use InvalidArgumentException; use ipl\Html\FormElement\FieldsetElement; use ipl\Html\HtmlElement; +use ipl\Scheduler\Contract\Frequency; +use ipl\Scheduler\Cron; +use ipl\Scheduler\OneOff; use ipl\Scheduler\RRule; use ipl\Validator\BetweenValidator; +use ipl\Validator\CallbackValidator; use ipl\Web\FormElement\ScheduleElement\AnnuallyFields; use ipl\Web\FormElement\ScheduleElement\Common\FieldsProtector; use ipl\Web\FormElement\ScheduleElement\MonthlyFields; +use ipl\Web\FormElement\ScheduleElement\Recurrence; use ipl\Web\FormElement\ScheduleElement\WeeklyFields; +use LogicException; +use Psr\Http\Message\RequestInterface; class ScheduleElement extends FieldsetElement { use FieldsProtector; + /** @var string Plain cron expressions */ + public const CRON_EXPR = 'cron_expr'; + + /** @var string Configure the individual expression parts manually */ + public const CUSTOM_EXPR = 'custom'; + + /** @var string Used to run a one-off task */ + public const NO_REPEAT = 'none'; + protected $defaultAttributes = ['class' => 'schedule-element']; /** @var array A list of allowed frequencies used to configure custom expressions */ - protected $frequencies = []; + protected $customFrequencies = []; + + /** @var array */ + protected $advanced = []; + + /** @var array */ + protected $regulars = []; /** @var string Schedule frequency of this element */ protected $frequency = RRule::DAILY; + /** @var string */ + protected $customFrequency; + /** @var DateTime */ protected $start; + /** @var DateTime */ + protected $end; + /** @var WeeklyFields Weekly parts of this schedule element */ protected $weeklyField; @@ -62,11 +90,23 @@ protected function init(): void } ]); - $this->frequencies = [ - RRule::DAILY => $this->translate('Daily'), - RRule::WEEKLY => $this->translate('Weekly'), - RRule::MONTHLY => $this->translate('Monthly'), - RRule::YEARLY => $this->translate('Annually') + + $this->regulars = [ + RRule::MINUTELY => $this->translate('Minutely'), + RRule::HOURLY => $this->translate('Hourly'), + RRule::DAILY => $this->translate('Daily'), + RRule::WEEKLY => $this->translate('Weekly'), + RRule::MONTHLY => $this->translate('Monthly'), + RRule::QUARTERLY => $this->translate('Quarterly'), + RRule::YEARLY => $this->translate('Annually'), + ]; + + $this->customFrequencies = array_slice($this->regulars, 2); + unset($this->customFrequencies[RRule::QUARTERLY]); + + $this->advanced = [ + static::CUSTOM_EXPR => $this->translate('Custom…'), + static::CRON_EXPR => $this->translate('Cron Expression…') ]; } @@ -86,11 +126,11 @@ public function setDefaultElementDecorator($decorator) */ public function getFrequency(): string { - return $this->getValue('custom_frequency', $this->frequency); + return $this->getValue('frequency', $this->frequency); } /** - * Set the custom frequency of this cron + * Set the custom frequency of this schedule element * * @param string $frequency * @@ -98,7 +138,11 @@ public function getFrequency(): string */ public function setFrequency(string $frequency): self { - if (! isset($this->frequencies[$frequency])) { + if ( + $frequency !== static::NO_REPEAT + && ! isset($this->regulars[$frequency]) + && ! isset($this->advanced[$frequency]) + ) { throw new InvalidArgumentException(sprintf('Invalid frequency provided: %s', $frequency)); } @@ -107,6 +151,34 @@ public function setFrequency(string $frequency): self return $this; } + /** + * Get custom frequency of this element + * + * @return ?string + */ + public function getCustomFrequency(): ?string + { + return $this->getValue('custom_frequency', $this->customFrequency); + } + + /** + * Set custom frequency of this element + * + * @param string $frequency + * + * @return $this + */ + public function setCustomFrequency(string $frequency): self + { + if (! isset($this->customFrequencies[$frequency])) { + throw new InvalidArgumentException(sprintf('Invalid custom frequency provided: %s', $frequency)); + } + + $this->customFrequency = $frequency; + + return $this; + } + /** * Set start time of the parsed expressions * @@ -129,114 +201,142 @@ public function setStart(DateTime $start): self } /** - * Get the monthly fields of this element + * Set the end time of this schedule element * - * Is only used when using multipart updates + * @param DateTime $end * - * @return MonthlyFields + * @return $this */ - public function getMonthlyFields(): MonthlyFields + public function setEnd(DateTime $end): self { - return $this->monthlyFields; + $this->end = $end; + + return $this; } /** - * Parse this schedule element and derive a {@see RRule} instance from it + * Parse this schedule element and derive a {@see Frequency} instance from it * - * @return RRule + * @return Frequency */ - public function getRRule(): RRule + public function getRRule(): Frequency { - $repeat = $this->getFrequency(); - $interval = $this->getValue('interval', 1); - switch ($repeat) { + $frequency = $this->getFrequency(); + switch ($frequency) { + case static::NO_REPEAT: + return new OneOff($this->getValue('start')); + case static::CRON_EXPR: + return new Cron($this->getValue('cron-expression')); + case RRule::MINUTELY: + case RRule::HOURLY: case RRule::DAILY: - if ($interval === '*') { - $interval = 1; - } - - return new RRule("FREQ=DAILY;INTERVAL=$interval"); case RRule::WEEKLY: - $byDay = implode(',', $this->weeklyField->getSelectedWeekDays()); - - return new RRule("FREQ=WEEKLY;INTERVAL=$interval;BYDAY=$byDay"); - /** @noinspection PhpMissingBreakStatementInspection */ case RRule::MONTHLY: - $runsOn = $this->monthlyFields->getValue('runsOn', MonthlyFields::RUNS_EACH); - if ($runsOn === MonthlyFields::RUNS_EACH) { - $byMonth = implode(',', $this->monthlyFields->getSelectedDays()); - - return new RRule("FREQ=MONTHLY;INTERVAL=$interval;BYMONTHDAY=$byMonth"); - } - // Fall-through to the next switch case + case RRule::QUARTERLY: case RRule::YEARLY: - $rule = "FREQ=MONTHLY;INTERVAL=$interval;"; - if ($repeat === RRule::YEARLY) { - $runsOn = $this->annuallyFields->getValue('runsOnThe', 'n'); - $month = $this->annuallyFields->getValue('month', (int) $this->start->format('m')); - if (is_string($month)) { - $datetime = DateTime::createFromFormat('!M', $month); - if (! $datetime) { - throw new InvalidArgumentException(sprintf('Invalid month provided: %s', $month)); + return RRule::fromFrequency($frequency); + default: // static::CUSTOM_EXPR + $interval = $this->getValue('interval', 1); + $customFrequency = $this->getValue('custom_frequency', RRule::DAILY); + switch ($customFrequency) { + case RRule::DAILY: + if ($interval === '*') { + $interval = 1; } - $month = (int) $datetime->format('m'); - } + return new RRule("FREQ=DAILY;INTERVAL=$interval"); + case RRule::WEEKLY: + $byDay = implode(',', $this->weeklyField->getSelectedWeekDays()); - $rule = "FREQ=YEARLY;INTERVAL=1;BYMONTH=$month;"; - if ($runsOn === 'n') { - return new RRule($rule); - } - } + return new RRule("FREQ=WEEKLY;INTERVAL=$interval;BYDAY=$byDay"); + /** @noinspection PhpMissingBreakStatementInspection */ + case RRule::MONTHLY: + $runsOn = $this->monthlyFields->getValue('runsOn', MonthlyFields::RUNS_EACH); + if ($runsOn === MonthlyFields::RUNS_EACH) { + $byMonth = implode(',', $this->monthlyFields->getSelectedDays()); - $element = $this->monthlyFields; - if ($repeat === RRule::YEARLY) { - $element = $this->annuallyFields; - } + return new RRule("FREQ=MONTHLY;INTERVAL=$interval;BYMONTHDAY=$byMonth"); + } + // Fall-through to the next switch case + case RRule::YEARLY: + $rule = "FREQ=MONTHLY;INTERVAL=$interval;"; + if ($customFrequency === RRule::YEARLY) { + $runsOn = $this->annuallyFields->getValue('runsOnThe', 'n'); + $month = $this->annuallyFields->getValue('month', (int) $this->start->format('m')); + if (is_string($month)) { + $datetime = DateTime::createFromFormat('!M', $month); + if (! $datetime) { + throw new InvalidArgumentException(sprintf('Invalid month provided: %s', $month)); + } + + $month = (int) $datetime->format('m'); + } + + $rule = "FREQ=YEARLY;INTERVAL=1;BYMONTH=$month;"; + if ($runsOn === 'n') { + return new RRule($rule); + } + } - $runDay = $element->getValue('day', $element::$everyDay); - $ordinal = $element->getValue('ordinal', $element::$first); - $position = $element->getOrdinalAsInteger($ordinal); - - if ($runDay === $element::$everyDay) { - $rule .= "BYDAY=MO,TU,WE,TH,FR,SA,SU;BYSETPOS=$position"; - } elseif ($runDay === $element::$everyWeekday) { - $rule .= "BYDAY=MO,TU,WE,TH,FR;BYSETPOS=$position"; - } elseif ($runDay === $element::$everyWeekend) { - $rule .= "BYDAY=SA,SU;BYSETPOS=$position"; - } else { - $rule .= sprintf('BYDAY=%d%s', $position, $runDay); - } + $element = $this->monthlyFields; + if ($customFrequency === RRule::YEARLY) { + $element = $this->annuallyFields; + } + + $runDay = $element->getValue('day', $element::$everyDay); + $ordinal = $element->getValue('ordinal', $element::$first); + $position = $element->getOrdinalAsInteger($ordinal); + + if ($runDay === $element::$everyDay) { + $rule .= "BYDAY=MO,TU,WE,TH,FR,SA,SU;BYSETPOS=$position"; + } elseif ($runDay === $element::$everyWeekday) { + $rule .= "BYDAY=MO,TU,WE,TH,FR;BYSETPOS=$position"; + } elseif ($runDay === $element::$everyWeekend) { + $rule .= "BYDAY=SA,SU;BYSETPOS=$position"; + } else { + $rule .= sprintf('BYDAY=%d%s', $position, $runDay); + } - return new RRule($rule); + return new RRule($rule); + default: + throw new LogicException(sprintf('Custom frequency %s is not supported!', $customFrequency)); + } } - // Oops!! } /** - * Load the given RRule instance into a list of key=>value pairs + * Load the given frequency instance into a list of key=>value pairs * - * @param RRule $rule + * @param Frequency $frequency * * @return array */ - public function loadRRule(RRule $rule): array + public function loadRRule(Frequency $frequency): array { $values = [ - 'interval' => $rule->getInterval(), - 'custom_frequency' => $rule->getFrequency() + 'frequency' => $this->getFrequency(), + 'custom_frequency' => $this->getCustomFrequency(), + 'start' => $this->start, + 'use-end-time' => $this->end instanceof DateTime, + 'end' => $this->end ]; - switch ($rule->getFrequency()) { - case RRule::WEEKLY: - $values['weekly-fields'] = $this->weeklyField->loadWeekDays($rule->getByDay()); - break; - case RRule::MONTHLY: - $values['monthly-fields'] = $this->monthlyFields->loadRRule($rule); - - break; - case RRule::YEARLY: - $values['annually-fields'] = $this->annuallyFields->loadRRule($rule); + if ($frequency instanceof Cron) { + $values['cron-expression'] = implode(' ', $frequency->getParts()); + } elseif ($frequency instanceof RRule) { + $values['interval'] = $frequency->getInterval(); + switch ($frequency->getFrequency()) { + case RRule::WEEKLY: + $values['weekly-fields'] = $this->weeklyField->loadWeekDays($frequency->getByDay()); + + break; + case RRule::MONTHLY: + $values['monthly-fields'] = $this->monthlyFields->loadRRule($frequency); + + break; + case RRule::YEARLY: + $values['annually-fields'] = $this->annuallyFields->loadRRule($frequency); + } } return $values; @@ -244,36 +344,141 @@ public function loadRRule(RRule $rule): array protected function assemble() { - $this->addElement('select', 'custom_frequency', [ + $start = $this->getPopulatedValue('start', $this->start); + if (! $start instanceof DateTime) { + $start = new DateTime($start); + } + $this->setStart($start); + + $this->addElement('localDateTime', 'start', [ + 'class' => 'autosubmit', + 'required' => true, + 'label' => $this->translate('Start'), + 'value' => $start, + 'description' => $this->translate('Start time of this schedule') + ]); + + $this->addElement('checkbox', 'use-end-time', [ + 'required' => false, + 'class' => 'autosubmit', + 'value' => $this->getPopulatedValue('use-end-time', 'n'), + 'label' => $this->translate('Use End Time') + ]); + + if ($this->getPopulatedValue('use-end-time', 'n') === 'y') { + $end = $this->getPopulatedValue('end', $this->end ?: new DateTime()); + if (! $end instanceof DateTime) { + $end = new DateTime($end); + } + + $this->addElement('localDateTime', 'end', [ + 'class' => 'autosubmit', + 'required' => true, + 'value' => $end, + 'label' => $this->translate('End'), + 'description' => $this->translate('End time of this schedule') + ]); + } + + $this->addElement('select', 'frequency', [ 'required' => false, 'class' => 'autosubmit', - 'value' => $this->getFrequency(), - 'options' => $this->frequencies, - 'label' => $this->translate('Custom Frequency'), - 'description' => $this->translate('Specifies how often this job run should be recurring') + 'options' => [ + static::NO_REPEAT => $this->translate('None'), + 'Regular' => $this->regulars, + 'Advanced' => $this->advanced + ], + 'label' => $this->translate('Frequency'), + 'description' => $this->translate('Specifies how often this job run should be recurring'), ]); - switch ($this->getFrequency()) { - case RRule::DAILY: - $this->assembleCommonElements(); + if ($this->getFrequency() === static::CUSTOM_EXPR) { + $this->addElement('select', 'custom_frequency', [ + 'required' => false, + 'class' => 'autosubmit', + 'value' => $this->getValue('custom_frequency'), + 'options' => $this->customFrequencies, + 'label' => $this->translate('Custom Frequency'), + 'description' => $this->translate('Specifies how often this job run should be recurring') + ]); + + switch ($this->getValue('custom_frequency', RRule::DAILY)) { + case RRule::DAILY: + $this->assembleCommonElements(); + + break; + case RRule::WEEKLY: + $this->assembleCommonElements(); + $this->addElement($this->weeklyField); + + break; + case RRule::MONTHLY: + $this->assembleCommonElements(); + $this + ->registerElement($this->monthlyFields) + ->addHtml($this->monthlyFields); + + break; + case RRule::YEARLY: + $this + ->registerElement($this->annuallyFields) + ->addHtml($this->annuallyFields); + } + } elseif ($this->getFrequency() === static::CRON_EXPR) { + $this->addElement('text', 'cron-expression', [ + 'label' => $this->translate('Cron Expression'), + 'description' => $this->translate('Job cron Schedule'), + 'validators' => [ + new CallbackValidator(function ($value) { + if ($value && ! Cron::isValid($value)) { + $this + ->getElement('cron-expression') + ->addMessage($this->translate('Invalid CRON expression')); + + return false; + } - break; - case RRule::WEEKLY: - $this->assembleCommonElements(); - $this->addElement($this->weeklyField); + return true; + }) + ] + ]); + } - break; - case RRule::MONTHLY: - $this->assembleCommonElements(); - $this - ->registerElement($this->monthlyFields) - ->addHtml($this->monthlyFields); + if ($this->getFrequency() !== static::NO_REPEAT && $this->getFrequency() !== static::CRON_EXPR) { + $this->addElement( + new Recurrence('schedule-recurrences', [ + 'id' => $this->protectId('schedule-recurrences'), + 'label' => $this->translate('Next occurrences'), + 'valid' => function (): bool { + return $this->isValid(); + }, + 'frequency' => function (): Frequency { + if ($this->getFrequency() === static::CUSTOM_EXPR) { + $rule = $this->getRRule(); + } else { + $rule = RRule::fromFrequency($this->getFrequency()); + } - break; - case RRule::YEARLY: - $this - ->registerElement($this->annuallyFields) - ->addHtml($this->annuallyFields); + $start = $this->getPopulatedValue('start', new DateTime()); + if (! $start instanceof DateTime) { + $start = new DateTime($start); + } + + $rule->startAt($start); + + if ($this->getPopulatedValue('use-end-time') === 'y') { + $end = $this->getPopulatedValue('end', new DateTime()); + if (! $end instanceof DateTime) { + $end = new DateTime($end); + } + + $rule->endAt($end); + } + + return $rule; + } + ]) + ); } } @@ -311,4 +516,41 @@ private function assembleCommonElements(): void $numberSpecifier->addHtml($element); $numberSpecifier->addHtml(HtmlElement::create('span', null, $text)); } + + /** + * Get prepared multipart updates + * + * @param RequestInterface $request + * + * @return array + */ + public function prepareMultipartUpdate(RequestInterface $request): array + { + $autoSubmittedBy = $request->getHeader('X-Icinga-AutoSubmittedBy'); + $pattern = '/^schedule-element\[(weekly-fields|monthly-fields|annually-fields)]' + . '\[(ordinal|interval|month|day(\d+)?|[A-Z]{2})]$/'; + + $partUpdates = []; + if ( + $autoSubmittedBy + && $this->getFrequency() !== static::CRON_EXPR + && ( + preg_match('/^schedule-element\[(start|end)]$/', $autoSubmittedBy[0], $matches) + || preg_match($pattern, $autoSubmittedBy[0]) + ) + ) { + $partUpdates[] = $this->getElement('schedule-recurrences'); + if ( + $this->getFrequency() === static::CUSTOM_EXPR + && $this->getCustomFrequency() === RRule::MONTHLY + && isset($matches[1]) + && $matches[1] === 'start' + ) { + // To update the available fields/days based on the provided start time + $partUpdates[] = $this->monthlyFields; + } + } + + return $partUpdates; + } } From 9693e518936de5093990faa4ca0bb079af17dafc Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Tue, 7 Feb 2023 09:08:54 +0100 Subject: [PATCH 32/43] Fix defaults for the regular & custom frequencies --- src/FormElement/ScheduleElement.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FormElement/ScheduleElement.php b/src/FormElement/ScheduleElement.php index 2cd3087a..08b4682e 100644 --- a/src/FormElement/ScheduleElement.php +++ b/src/FormElement/ScheduleElement.php @@ -45,10 +45,10 @@ class ScheduleElement extends FieldsetElement protected $regulars = []; /** @var string Schedule frequency of this element */ - protected $frequency = RRule::DAILY; + protected $frequency = self::NO_REPEAT; /** @var string */ - protected $customFrequency; + protected $customFrequency = RRule::DAILY; /** @var DateTime */ protected $start; From 740965c9d89db461ad54d9fd6c763a29b6a7b1e9 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Tue, 7 Feb 2023 12:10:14 +0100 Subject: [PATCH 33/43] Make constants protected & add monthly/annually fields properly --- src/FormElement/ScheduleElement.php | 43 ++++++++++++++--------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/src/FormElement/ScheduleElement.php b/src/FormElement/ScheduleElement.php index 08b4682e..b5e13057 100644 --- a/src/FormElement/ScheduleElement.php +++ b/src/FormElement/ScheduleElement.php @@ -25,13 +25,13 @@ class ScheduleElement extends FieldsetElement use FieldsProtector; /** @var string Plain cron expressions */ - public const CRON_EXPR = 'cron_expr'; + protected const CRON_EXPR = 'cron_expr'; /** @var string Configure the individual expression parts manually */ - public const CUSTOM_EXPR = 'custom'; + protected const CUSTOM_EXPR = 'custom'; /** @var string Used to run a one-off task */ - public const NO_REPEAT = 'none'; + protected const NO_REPEAT = 'none'; protected $defaultAttributes = ['class' => 'schedule-element']; @@ -110,13 +110,14 @@ protected function init(): void ]; } - public function setDefaultElementDecorator($decorator) + /** + * Get whether this element is rendering a cron expression + * + * @return bool + */ + public function hasCronExpression(): bool { - parent::setDefaultElementDecorator($decorator); - - $this->weeklyField->setDefaultElementDecorator($this->getDefaultElementDecorator()); - $this->monthlyFields->setDefaultElementDecorator($this->getDefaultElementDecorator()); - $this->annuallyFields->setDefaultElementDecorator($this->getDefaultElementDecorator()); + return $this->getFrequency() === static::CRON_EXPR; } /** @@ -383,13 +384,13 @@ protected function assemble() $this->addElement('select', 'frequency', [ 'required' => false, 'class' => 'autosubmit', - 'options' => [ - static::NO_REPEAT => $this->translate('None'), - 'Regular' => $this->regulars, - 'Advanced' => $this->advanced - ], 'label' => $this->translate('Frequency'), 'description' => $this->translate('Specifies how often this job run should be recurring'), + 'options' => [ + static::NO_REPEAT => $this->translate('None'), + $this->translate('Regular') => $this->regulars, + $this->translate('Advanced') => $this->advanced + ], ]); if ($this->getFrequency() === static::CUSTOM_EXPR) { @@ -414,17 +415,13 @@ protected function assemble() break; case RRule::MONTHLY: $this->assembleCommonElements(); - $this - ->registerElement($this->monthlyFields) - ->addHtml($this->monthlyFields); + $this->addElement($this->monthlyFields); break; case RRule::YEARLY: - $this - ->registerElement($this->annuallyFields) - ->addHtml($this->annuallyFields); + $this->addElement($this->annuallyFields); } - } elseif ($this->getFrequency() === static::CRON_EXPR) { + } elseif ($this->hasCronExpression()) { $this->addElement('text', 'cron-expression', [ 'label' => $this->translate('Cron Expression'), 'description' => $this->translate('Job cron Schedule'), @@ -444,7 +441,7 @@ protected function assemble() ]); } - if ($this->getFrequency() !== static::NO_REPEAT && $this->getFrequency() !== static::CRON_EXPR) { + if ($this->getFrequency() !== static::NO_REPEAT && ! $this->hasCronExpression()) { $this->addElement( new Recurrence('schedule-recurrences', [ 'id' => $this->protectId('schedule-recurrences'), @@ -533,7 +530,7 @@ public function prepareMultipartUpdate(RequestInterface $request): array $partUpdates = []; if ( $autoSubmittedBy - && $this->getFrequency() !== static::CRON_EXPR + && ! $this->hasCronExpression() && ( preg_match('/^schedule-element\[(start|end)]$/', $autoSubmittedBy[0], $matches) || preg_match($pattern, $autoSubmittedBy[0]) From 9efe32d03b387eb007ff83adc2b593f31aad57b7 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Tue, 7 Feb 2023 12:11:14 +0100 Subject: [PATCH 34/43] Simulate an empty control-label-group for weekly fields --- asset/css/compat.less | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/asset/css/compat.less b/asset/css/compat.less index 8233bb84..6fd00f8f 100644 --- a/asset/css/compat.less +++ b/asset/css/compat.less @@ -14,6 +14,13 @@ .control-group > .annually { flex: 1 1 auto; } + + // TODO: This effectively restricts the weekly fields to always be aligned to the right, + // regardless of the using an icinga-form or not. So this should be removed once we + // have re-implemented the decorators. + .control-group > fieldset > .weekly { + margin-left: 14em; + } } form.icinga-form .control-group { From 8cac60a979d4078b1e30f9b3a69dad6646e50ba6 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Tue, 7 Feb 2023 13:21:55 +0100 Subject: [PATCH 35/43] Don't always render `every x day(s)` for all custom frequencies --- src/FormElement/ScheduleElement.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FormElement/ScheduleElement.php b/src/FormElement/ScheduleElement.php index b5e13057..651bf816 100644 --- a/src/FormElement/ScheduleElement.php +++ b/src/FormElement/ScheduleElement.php @@ -484,7 +484,7 @@ protected function assemble() */ private function assembleCommonElements(): void { - $repeat = $this->getFrequency(); + $repeat = $this->getCustomFrequency(); if ($repeat === RRule::WEEKLY) { $text = $this->translate('week(s) on'); $max = 53; From c2c4f08f2c6313cabbc83b9d30a8aa59235a0f6b Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Tue, 7 Feb 2023 13:38:56 +0100 Subject: [PATCH 36/43] Recurrence: Render never next recurrence correctly --- src/FormElement/ScheduleElement/Recurrence.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FormElement/ScheduleElement/Recurrence.php b/src/FormElement/ScheduleElement/Recurrence.php index 3c2e0f6a..bbf147e6 100644 --- a/src/FormElement/ScheduleElement/Recurrence.php +++ b/src/FormElement/ScheduleElement/Recurrence.php @@ -65,7 +65,7 @@ protected function assemble() $recurrences = $frequency->getNextRecurrences(new DateTime(), 3); if (! $recurrences->valid()) { // Such a situation can be caused by setting an invalid end time - $this->addHtml(Text::create($this->translate('Recurrences cannot be generated'))); + $this->addHtml(HtmlElement::create('p', null, Text::create($this->translate('Never')))); return; } From 988c8a06dd53733463f0aa0065dc85f87dee1082 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Tue, 7 Feb 2023 16:00:13 +0100 Subject: [PATCH 37/43] ScheduleElement: Remove `load/getRRule()` & use `get/setValue()` instead --- src/FormElement/ScheduleElement.php | 222 ++++++++++++++++++---------- 1 file changed, 143 insertions(+), 79 deletions(-) diff --git a/src/FormElement/ScheduleElement.php b/src/FormElement/ScheduleElement.php index 651bf816..35c6d5f1 100644 --- a/src/FormElement/ScheduleElement.php +++ b/src/FormElement/ScheduleElement.php @@ -8,7 +8,6 @@ use ipl\Html\HtmlElement; use ipl\Scheduler\Contract\Frequency; use ipl\Scheduler\Cron; -use ipl\Scheduler\OneOff; use ipl\Scheduler\RRule; use ipl\Validator\BetweenValidator; use ipl\Validator\CallbackValidator; @@ -48,14 +47,11 @@ class ScheduleElement extends FieldsetElement protected $frequency = self::NO_REPEAT; /** @var string */ - protected $customFrequency = RRule::DAILY; + protected $customFrequency; /** @var DateTime */ protected $start; - /** @var DateTime */ - protected $end; - /** @var WeeklyFields Weekly parts of this schedule element */ protected $weeklyField; @@ -159,7 +155,7 @@ public function setFrequency(string $frequency): self */ public function getCustomFrequency(): ?string { - return $this->getValue('custom_frequency', $this->customFrequency); + return $this->getValue('custom-frequency', $this->customFrequency); } /** @@ -201,33 +197,23 @@ public function setStart(DateTime $start): self return $this; } - /** - * Set the end time of this schedule element - * - * @param DateTime $end - * - * @return $this - */ - public function setEnd(DateTime $end): self + public function getValue($name = null, $default = null) { - $this->end = $end; - - return $this; - } + if ($name !== null) { + return parent::getValue($name, $default); + } - /** - * Parse this schedule element and derive a {@see Frequency} instance from it - * - * @return Frequency - */ - public function getRRule(): Frequency - { $frequency = $this->getFrequency(); + $start = parent::getValue('start'); switch ($frequency) { case static::NO_REPEAT: - return new OneOff($this->getValue('start')); + $rule = static::NO_REPEAT; + + break; case static::CRON_EXPR: - return new Cron($this->getValue('cron-expression')); + $rule = parent::getValue('cron-expression'); + + break; case RRule::MINUTELY: case RRule::HOURLY: case RRule::DAILY: @@ -235,28 +221,36 @@ public function getRRule(): Frequency case RRule::MONTHLY: case RRule::QUARTERLY: case RRule::YEARLY: - return RRule::fromFrequency($frequency); + $rule = RRule::fromFrequency($frequency); + + break; default: // static::CUSTOM_EXPR - $interval = $this->getValue('interval', 1); - $customFrequency = $this->getValue('custom_frequency', RRule::DAILY); + $interval = parent::getValue('interval', 1); + $customFrequency = parent::getValue('custom-frequency', RRule::DAILY); switch ($customFrequency) { case RRule::DAILY: if ($interval === '*') { $interval = 1; } - return new RRule("FREQ=DAILY;INTERVAL=$interval"); + $rule = new RRule("FREQ=DAILY;INTERVAL=$interval"); + + break; case RRule::WEEKLY: $byDay = implode(',', $this->weeklyField->getSelectedWeekDays()); - return new RRule("FREQ=WEEKLY;INTERVAL=$interval;BYDAY=$byDay"); + $rule = new RRule("FREQ=WEEKLY;INTERVAL=$interval;BYDAY=$byDay"); + + break; /** @noinspection PhpMissingBreakStatementInspection */ case RRule::MONTHLY: $runsOn = $this->monthlyFields->getValue('runsOn', MonthlyFields::RUNS_EACH); if ($runsOn === MonthlyFields::RUNS_EACH) { $byMonth = implode(',', $this->monthlyFields->getSelectedDays()); - return new RRule("FREQ=MONTHLY;INTERVAL=$interval;BYMONTHDAY=$byMonth"); + $rule = new RRule("FREQ=MONTHLY;INTERVAL=$interval;BYMONTHDAY=$byMonth"); + + break; } // Fall-through to the next switch case case RRule::YEARLY: @@ -275,7 +269,9 @@ public function getRRule(): Frequency $rule = "FREQ=YEARLY;INTERVAL=1;BYMONTH=$month;"; if ($runsOn === 'n') { - return new RRule($rule); + $rule = new RRule($rule); + + break; } } @@ -298,49 +294,107 @@ public function getRRule(): Frequency $rule .= sprintf('BYDAY=%d%s', $position, $runDay); } - return new RRule($rule); + $rule = new RRule($rule); + + break; default: throw new LogicException(sprintf('Custom frequency %s is not supported!', $customFrequency)); } } - } - /** - * Load the given frequency instance into a list of key=>value pairs - * - * @param Frequency $frequency - * - * @return array - */ - public function loadRRule(Frequency $frequency): array - { + if ($rule instanceof RRule) { + $rule = $rule->jsonSerialize(); + } + $values = [ - 'frequency' => $this->getFrequency(), - 'custom_frequency' => $this->getCustomFrequency(), - 'start' => $this->start, - 'use-end-time' => $this->end instanceof DateTime, - 'end' => $this->end + 'frequency' => $rule, + 'start' => $start, + 'end' => null ]; - if ($frequency instanceof Cron) { - $values['cron-expression'] = implode(' ', $frequency->getParts()); - } elseif ($frequency instanceof RRule) { - $values['interval'] = $frequency->getInterval(); - switch ($frequency->getFrequency()) { - case RRule::WEEKLY: - $values['weekly-fields'] = $this->weeklyField->loadWeekDays($frequency->getByDay()); + if (parent::getValue('use-end-time', 'n') === 'y') { + $values['end'] = parent::getValue('end'); + } - break; - case RRule::MONTHLY: - $values['monthly-fields'] = $this->monthlyFields->loadRRule($frequency); + return $values; + } - break; - case RRule::YEARLY: - $values['annually-fields'] = $this->annuallyFields->loadRRule($frequency); + public function setValue($value) + { + if (isset($value['start']) && $value['start'] instanceof DateTime) { + $this->setStart($value['start']); + } + + if (isset($value['end']) && ! isset($value['use-end-time'])) { + $value['use-end-time'] = 'y'; + } + + $frequency = $value['frequency'] ?? null; + if ($frequency && $frequency !== static::CUSTOM_EXPR && $frequency !== static::CRON_EXPR) { + $rule = $frequency; + if (! $rule instanceof Frequency) { + $rule = $this->frequencyFromConfig($value); + } + + if ($rule instanceof Cron) { + $value['cron-expression'] = $frequency; + $value['frequency'] = static::CRON_EXPR; + + $this->setFrequency(static::CRON_EXPR); + } elseif ($rule instanceof RRule) { + $value['interval'] = $rule->getInterval(); + switch ($rule->getFrequency()) { + case RRule::DAILY: + if ($rule->getInterval() <= 1 && strpos($rule->jsonSerialize(), 'INTERVAL=') === false) { + $this->setFrequency(RRule::DAILY); + } else { + $this + ->setFrequency(static::CUSTOM_EXPR) + ->setCustomFrequency(RRule::DAILY); + } + + break; + case RRule::WEEKLY: + if (! $rule->getByDay() || empty($rule->getByDay())) { + $this->setFrequency(RRule::WEEKLY); + } else { + $value['weekly-fields'] = $this->weeklyField->loadWeekDays($rule->getByDay()); + $this + ->setFrequency(static::CUSTOM_EXPR) + ->setCustomFrequency(RRule::WEEKLY); + } + + break; + case RRule::MONTHLY: + case RRule::YEARLY: + $isMonthly = $rule->getFrequency() === RRule::MONTHLY; + if ($rule->getByDay() || $rule->getByMonthDay() || $rule->getByMonth()) { + $this->setFrequency(static::CUSTOM_EXPR); + + if ($isMonthly) { + $value['monthly-fields'] = $this->monthlyFields->loadRRule($rule); + $this->setCustomFrequency(RRule::MONTHLY); + } else { + $value['annually-fields'] = $this->annuallyFields->loadRRule($rule); + $this->setCustomFrequency(RRule::YEARLY); + } + } elseif ($isMonthly && $rule->getInterval() === 3) { + $this->setFrequency(RRule::QUARTERLY); + } else { + $this->setFrequency($rule->getFrequency()); + } + + break; + default: + $this->setFrequency($rule->getFrequency()); + } + + $value['frequency'] = $this->getFrequency(); + $value['custom-frequency'] = $this->getCustomFrequency(); } } - return $values; + return parent::setValue($value); } protected function assemble() @@ -362,12 +416,13 @@ protected function assemble() $this->addElement('checkbox', 'use-end-time', [ 'required' => false, 'class' => 'autosubmit', + 'disabled' => $this->getPopulatedValue('frequency', static::NO_REPEAT) === static::NO_REPEAT ?: null, 'value' => $this->getPopulatedValue('use-end-time', 'n'), 'label' => $this->translate('Use End Time') ]); if ($this->getPopulatedValue('use-end-time', 'n') === 'y') { - $end = $this->getPopulatedValue('end', $this->end ?: new DateTime()); + $end = $this->getPopulatedValue('end', new DateTime()); if (! $end instanceof DateTime) { $end = new DateTime($end); } @@ -394,16 +449,16 @@ protected function assemble() ]); if ($this->getFrequency() === static::CUSTOM_EXPR) { - $this->addElement('select', 'custom_frequency', [ + $this->addElement('select', 'custom-frequency', [ 'required' => false, 'class' => 'autosubmit', - 'value' => $this->getValue('custom_frequency'), + 'value' => parent::getValue('custom-frequency'), 'options' => $this->customFrequencies, 'label' => $this->translate('Custom Frequency'), 'description' => $this->translate('Specifies how often this job run should be recurring') ]); - switch ($this->getValue('custom_frequency', RRule::DAILY)) { + switch (parent::getValue('custom-frequency', RRule::DAILY)) { case RRule::DAILY: $this->assembleCommonElements(); @@ -451,24 +506,33 @@ protected function assemble() }, 'frequency' => function (): Frequency { if ($this->getFrequency() === static::CUSTOM_EXPR) { - $rule = $this->getRRule(); + list($rule, $start, $end) = array_values($this->getValue()); + $rule = new RRule($rule); } else { $rule = RRule::fromFrequency($this->getFrequency()); - } + $start = $this->getPopulatedValue('start', new DateTime()); + if (! $start instanceof DateTime) { + $start = new DateTime($start); + } - $start = $this->getPopulatedValue('start', new DateTime()); - if (! $start instanceof DateTime) { - $start = new DateTime($start); + $end = null; + if ($this->getPopulatedValue('use-end-time') === 'y') { + $end = $this->getPopulatedValue('end', new DateTime()); + if (! $end instanceof DateTime) { + $end = new DateTime($end); + } + } } - $rule->startAt($start); + $now = new DateTime(); + if ($start < $now) { + $now->setTime($start->format('H'), $start->format('i'), $start->format('s')); - if ($this->getPopulatedValue('use-end-time') === 'y') { - $end = $this->getPopulatedValue('end', new DateTime()); - if (! $end instanceof DateTime) { - $end = new DateTime($end); - } + $start = $now; + } + $rule->startAt($start); + if ($end instanceof DateTime) { $rule->endAt($end); } From 82eb173288ce5c60613e6ccd570e5135c00e4594 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Tue, 14 Feb 2023 13:05:09 +0100 Subject: [PATCH 38/43] Adjust `set/getValue()` of the `ScheduleElement` --- src/FormElement/ScheduleElement.php | 89 +++++++------------ .../ScheduleElement/Common/FieldsUtils.php | 4 +- 2 files changed, 35 insertions(+), 58 deletions(-) diff --git a/src/FormElement/ScheduleElement.php b/src/FormElement/ScheduleElement.php index 35c6d5f1..2458b559 100644 --- a/src/FormElement/ScheduleElement.php +++ b/src/FormElement/ScheduleElement.php @@ -8,6 +8,7 @@ use ipl\Html\HtmlElement; use ipl\Scheduler\Contract\Frequency; use ipl\Scheduler\Cron; +use ipl\Scheduler\OneOff; use ipl\Scheduler\RRule; use ipl\Validator\BetweenValidator; use ipl\Validator\CallbackValidator; @@ -207,11 +208,9 @@ public function getValue($name = null, $default = null) $start = parent::getValue('start'); switch ($frequency) { case static::NO_REPEAT: - $rule = static::NO_REPEAT; - - break; + return new OneOff($start); case static::CRON_EXPR: - $rule = parent::getValue('cron-expression'); + $rule = new Cron(parent::getValue('cron-expression')); break; case RRule::MINUTELY: @@ -302,50 +301,41 @@ public function getValue($name = null, $default = null) } } - if ($rule instanceof RRule) { - $rule = $rule->jsonSerialize(); - } - - $values = [ - 'frequency' => $rule, - 'start' => $start, - 'end' => null - ]; - + $rule->startAt($start); if (parent::getValue('use-end-time', 'n') === 'y') { - $values['end'] = parent::getValue('end'); + $rule->endAt(parent::getValue('end')); } - return $values; + return $rule; } public function setValue($value) { - if (isset($value['start']) && $value['start'] instanceof DateTime) { - $this->setStart($value['start']); - } - - if (isset($value['end']) && ! isset($value['use-end-time'])) { - $value['use-end-time'] = 'y'; - } + $values = $value; + $rule = $value; + if ($rule instanceof Frequency) { + if ($rule->getStart()) { + $this->setStart($rule->getStart()); + } - $frequency = $value['frequency'] ?? null; - if ($frequency && $frequency !== static::CUSTOM_EXPR && $frequency !== static::CRON_EXPR) { - $rule = $frequency; - if (! $rule instanceof Frequency) { - $rule = $this->frequencyFromConfig($value); + $values = []; + if ($rule->getEnd() && ! $rule instanceof OneOff) { + $values['use-end-time'] = 'y'; + $values['end'] = $rule->getEnd(); } - if ($rule instanceof Cron) { - $value['cron-expression'] = $frequency; - $value['frequency'] = static::CRON_EXPR; + if ($rule instanceof OneOff) { + $values['frequency'] = static::NO_REPEAT; + } elseif ($rule instanceof Cron) { + $values['cron-expression'] = $rule->getExpression(); + $values['frequency'] = static::CRON_EXPR; $this->setFrequency(static::CRON_EXPR); } elseif ($rule instanceof RRule) { - $value['interval'] = $rule->getInterval(); + $values['interval'] = $rule->getInterval(); switch ($rule->getFrequency()) { case RRule::DAILY: - if ($rule->getInterval() <= 1 && strpos($rule->jsonSerialize(), 'INTERVAL=') === false) { + if ($rule->getInterval() <= 1 && strpos($rule->getString(), 'INTERVAL=') === false) { $this->setFrequency(RRule::DAILY); } else { $this @@ -358,7 +348,7 @@ public function setValue($value) if (! $rule->getByDay() || empty($rule->getByDay())) { $this->setFrequency(RRule::WEEKLY); } else { - $value['weekly-fields'] = $this->weeklyField->loadWeekDays($rule->getByDay()); + $values['weekly-fields'] = $this->weeklyField->loadWeekDays($rule->getByDay()); $this ->setFrequency(static::CUSTOM_EXPR) ->setCustomFrequency(RRule::WEEKLY); @@ -372,10 +362,10 @@ public function setValue($value) $this->setFrequency(static::CUSTOM_EXPR); if ($isMonthly) { - $value['monthly-fields'] = $this->monthlyFields->loadRRule($rule); + $values['monthly-fields'] = $this->monthlyFields->loadRRule($rule); $this->setCustomFrequency(RRule::MONTHLY); } else { - $value['annually-fields'] = $this->annuallyFields->loadRRule($rule); + $values['annually-fields'] = $this->annuallyFields->loadRRule($rule); $this->setCustomFrequency(RRule::YEARLY); } } elseif ($isMonthly && $rule->getInterval() === 3) { @@ -389,12 +379,12 @@ public function setValue($value) $this->setFrequency($rule->getFrequency()); } - $value['frequency'] = $this->getFrequency(); - $value['custom-frequency'] = $this->getCustomFrequency(); + $values['frequency'] = $this->getFrequency(); + $values['custom-frequency'] = $this->getCustomFrequency(); } } - return parent::setValue($value); + return parent::setValue($values); } protected function assemble() @@ -506,34 +496,21 @@ protected function assemble() }, 'frequency' => function (): Frequency { if ($this->getFrequency() === static::CUSTOM_EXPR) { - list($rule, $start, $end) = array_values($this->getValue()); - $rule = new RRule($rule); + $rule = $this->getValue(); } else { $rule = RRule::fromFrequency($this->getFrequency()); - $start = $this->getPopulatedValue('start', new DateTime()); - if (! $start instanceof DateTime) { - $start = new DateTime($start); - } - - $end = null; - if ($this->getPopulatedValue('use-end-time') === 'y') { - $end = $this->getPopulatedValue('end', new DateTime()); - if (! $end instanceof DateTime) { - $end = new DateTime($end); - } - } } $now = new DateTime(); + $start = $this->getValue('start'); if ($start < $now) { $now->setTime($start->format('H'), $start->format('i'), $start->format('s')); - $start = $now; } $rule->startAt($start); - if ($end instanceof DateTime) { - $rule->endAt($end); + if ($this->getPopulatedValue('use-end-time') === 'y') { + $rule->endAt($this->getValue('end')); } return $rule; diff --git a/src/FormElement/ScheduleElement/Common/FieldsUtils.php b/src/FormElement/ScheduleElement/Common/FieldsUtils.php index 493099e0..79f818a4 100644 --- a/src/FormElement/ScheduleElement/Common/FieldsUtils.php +++ b/src/FormElement/ScheduleElement/Common/FieldsUtils.php @@ -98,8 +98,8 @@ public function loadRRule(RRule $rule): array $values['runsOn'] = MonthlyFields::RUNS_ONTHE; } else { $months = $rule->getByMonth(); - if (empty($months) && $rule->getStartDate()) { - $months[] = $rule->getStartDate()->format('m'); + if (empty($months) && $rule->getStart()) { + $months[] = $rule->getStart()->format('m'); } elseif (empty($months)) { $months[] = date('m'); } From 0786a523d71baa26f977dd7f01cd5a1fd8b953ec Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 9 Feb 2023 17:42:22 +0100 Subject: [PATCH 39/43] tests: Introduce `ScheduleElementTest` --- composer.json | 3 +- tests/ScheduleElementTest.php | 592 ++++++++++++++++++++++++++++++++++ 2 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 tests/ScheduleElementTest.php diff --git a/composer.json b/composer.json index bd2f6775..ad3c2c79 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ "php": ">=7.2", "ext-json": "*", "psr/http-message": "^1.0", - "ipl/html": ">=0.6.0", + "ipl/html": "dev-master", + "ipl/validator": "@dev", "ipl/i18n": ">=0.2.0", "ipl/stdlib": ">=0.12.0", "fortawesome/font-awesome": "^6" diff --git a/tests/ScheduleElementTest.php b/tests/ScheduleElementTest.php new file mode 100644 index 00000000..f6d48fd2 --- /dev/null +++ b/tests/ScheduleElementTest.php @@ -0,0 +1,592 @@ +ensureAssembled()->hasElement('schedule-recurrences')) { + $element->remove($element->getElement('schedule-recurrences')); + } + + $element->render(); // Forces also assembly of any content + + return $element; + } + + public function testOneOffFrequency() + { + $datetime = new DateTime('2023-02-07T15:17:07'); + $element = $this->assembleElement(['value' => new OneOff($datetime)]); + + $this->assertEquals($datetime, $element->getValue('start')); + $this->assertNull($element->getValue('end')); + $this->assertSame('none', $element->getValue('frequency')); + + $this->assertEquals(new OneOff($datetime), $element->getValue()); + } + + public function testMinutelyFrequency() + { + $start = new DateTime('2023-02-07T15:17:07'); + $frequency = RRule::fromFrequency(RRule::MINUTELY)->startAt($start); + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertNull($element->getValue('end')); + $this->assertSame(RRule::MINUTELY, $element->getValue('frequency')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testMinutelyFrequencyWithEnd() + { + $start = new DateTime('2023-02-07T15:17:07'); + $end = new DateTime('2023-02-10T18:00:00'); + $frequency = RRule::fromFrequency(RRule::MINUTELY) + ->startAt($start) + ->endAt($end); + + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertEquals($end, $element->getValue('end')); + $this->assertSame(RRule::MINUTELY, $element->getValue('frequency')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testHourlyFrequency() + { + $start = new DateTime('2023-02-07T15:17:08'); + $frequency = RRule::fromFrequency(RRule::HOURLY)->startAt($start); + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertNull($element->getValue('end')); + $this->assertSame(RRule::HOURLY, $element->getValue('frequency')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testHourlyFrequencyWithEnd() + { + $start = new DateTime('2023-02-07T15:17:07'); + $end = new DateTime('2023-02-10T18:00:00'); + $frequency = RRule::fromFrequency(RRule::HOURLY) + ->startAt($start) + ->endAt($end); + + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertEquals($end, $element->getValue('end')); + $this->assertSame(RRule::HOURLY, $element->getValue('frequency')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testDailyFrequency() + { + $start = new DateTime('2023-02-07T15:17:07'); + $frequency = RRule::fromFrequency(RRule::DAILY)->startAt($start); + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertNull($element->getValue('end')); + $this->assertSame(RRule::DAILY, $element->getValue('frequency')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testDailyFrequencyWithEnd() + { + $start = new DateTime('2023-02-07T15:17:07'); + $end = new DateTime('2023-02-10T18:00:00'); + $frequency = RRule::fromFrequency(RRule::DAILY) + ->startAt($start) + ->endAt($end); + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertEquals($end, $element->getValue('end')); + $this->assertSame(RRule::DAILY, $element->getValue('frequency')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testWeeklyFrequency() + { + $start = new DateTime('2023-02-07T15:17:07'); + $frequency = RRule::fromFrequency(RRule::WEEKLY)->startAt($start); + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertNull($element->getValue('end')); + $this->assertSame(RRule::WEEKLY, $element->getValue('frequency')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testWeeklyFrequencyWithEnd() + { + $start = new DateTime('2023-02-07T15:17:07'); + $end = new DateTime('2023-02-10T18:00:00'); + $frequency = RRule::fromFrequency(RRule::WEEKLY) + ->startAt($start) + ->endAt($end); + + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertEquals($end, $element->getValue('end')); + $this->assertSame(RRule::WEEKLY, $element->getValue('frequency')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testMonthlyFrequency() + { + $start = new DateTime('2023-02-07T15:17:07'); + $frequency = RRule::fromFrequency(RRule::MONTHLY)->startAt($start); + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertNull($element->getValue('end')); + $this->assertSame(RRule::MONTHLY, $element->getValue('frequency')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testMonthlyFrequencyWithEnd() + { + $start = new DateTime('2023-02-07T15:17:07'); + $end = new DateTime('2023-02-10T18:00:00'); + $frequency = RRule::fromFrequency(RRule::MONTHLY) + ->startAt($start) + ->endAt($end); + + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertEquals($end, $element->getValue('end')); + $this->assertSame(RRule::MONTHLY, $element->getValue('frequency')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testQuarterlyFrequency() + { + $start = new DateTime('2023-02-07T15:17:07'); + $frequency = RRule::fromFrequency(RRule::QUARTERLY)->startAt($start); + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertNull($element->getValue('end')); + $this->assertSame(RRule::QUARTERLY, $element->getValue('frequency')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testQuarterlyFrequencyWithEnd() + { + $start = new DateTime('2023-02-07T15:17:07'); + $end = new DateTime('2023-02-10T18:00:00'); + $frequency = RRule::fromFrequency(RRule::QUARTERLY) + ->startAt($start) + ->endAt($end); + + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertEquals($end, $element->getValue('end')); + $this->assertSame(RRule::QUARTERLY, $element->getValue('frequency')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testAnnuallyFrequency() + { + $start = new DateTime('2023-02-07T15:17:07'); + $frequency = RRule::fromFrequency(RRule::YEARLY)->startAt($start); + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertNull($element->getValue('end')); + $this->assertSame(RRule::YEARLY, $element->getValue('frequency')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testAnnuallyFrequencyWithEnd() + { + $start = new DateTime('2023-02-07T15:17:07'); + $end = new DateTime('2023-02-10T18:00:00'); + $frequency = RRule::fromFrequency(RRule::YEARLY) + ->startAt($start) + ->endAt($end); + + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertEquals($end, $element->getValue('end')); + $this->assertSame(RRule::YEARLY, $element->getValue('frequency')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testCronFrequency() + { + $start = new DateTime('2023-02-07T15:17:07'); + $cron = (new Cron('5 4 * * *'))->startAt($start); + $element = $this->assembleElement(['value' => $cron]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertNull($element->getValue('end')); + $this->assertSame('cron_expr', $element->getValue('frequency')); + $this->assertSame($cron->getExpression(), $element->getValue('cron-expression')); + + $this->assertEquals($cron, $element->getValue()); + } + + public function testCronFrequencyWithEnd() + { + $start = new DateTime('2023-02-07T15:17:07'); + $end = new DateTime('2023-02-10T18:00:00'); + $cron = (new Cron('0 22 * * 1-5')) + ->startAt($start) + ->endAt($end); + + $element = $this->assembleElement(['value' => $cron]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertEquals($end, $element->getValue('end')); + $this->assertSame('cron_expr', $element->getValue('frequency')); + $this->assertSame($cron->getExpression(), $element->getValue('cron-expression')); + + $this->assertEquals($cron, $element->getValue()); + } + + public function testCustomDailyFrequency() + { + $start = new DateTime('2023-02-07T15:17:07'); + $frequency = (new RRule('FREQ=DAILY;INTERVAL=1'))->startAt($start); + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertNull($element->getValue('end')); + $this->assertSame('custom', $element->getValue('frequency')); + $this->assertSame(RRule::DAILY, $element->getValue('custom-frequency')); + $this->assertSame(1, $element->getValue('interval')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testCustomDailyFrequencyWithEnd() + { + $start = new DateTime('2023-02-07T15:17:07'); + $end = new DateTime('2023-02-10T18:00:00'); + $frequency = (new RRule('FREQ=DAILY;INTERVAL=1')) + ->startAt($start) + ->endAt($end); + + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertEquals($end, $element->getValue('end')); + $this->assertSame('custom', $element->getValue('frequency')); + $this->assertSame(RRule::DAILY, $element->getValue('custom-frequency')); + $this->assertSame(1, $element->getValue('interval')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testCustomWeeklyFrequency() + { + $start = new DateTime('2023-02-07T15:17:07'); + $frequency = (new RRule('FREQ=WEEKLY;INTERVAL=4;BYDAY=WE'))->startAt($start); + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertNull($element->getValue('end')); + $this->assertSame('custom', $element->getValue('frequency')); + $this->assertSame(RRule::WEEKLY, $element->getValue('custom-frequency')); + $this->assertSame(4, $element->getValue('interval')); + $this->assertSame([ + 'MO' => 'n', + 'TU' => 'n', + 'WE' => 'y', + 'TH' => 'n', + 'FR' => 'n', + 'SA' => 'n', + 'SU' => 'n' + ], $element->getValue('weekly-fields')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testCustomWeeklyFrequencyWithEnd() + { + $start = new DateTime('2023-02-07T15:17:07'); + $end = new DateTime('2023-02-10T18:00:00'); + $frequency = (new RRule('FREQ=WEEKLY;INTERVAL=4;BYDAY=WE')) + ->startAt($start) + ->endAt($end); + + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertEquals($end, $element->getValue('end')); + $this->assertSame('custom', $element->getValue('frequency')); + $this->assertSame(RRule::WEEKLY, $element->getValue('custom-frequency')); + $this->assertSame(4, $element->getValue('interval')); + $this->assertSame([ + 'MO' => 'n', + 'TU' => 'n', + 'WE' => 'y', + 'TH' => 'n', + 'FR' => 'n', + 'SA' => 'n', + 'SU' => 'n' + ], $element->getValue('weekly-fields')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testCustomMonthlyFrequency() + { + $start = new DateTime('2023-02-07T15:17:07'); + $frequency = (new RRule('FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=8,17,18,27'))->startAt($start); + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertNull($element->getValue('end')); + $this->assertSame('custom', $element->getValue('frequency')); + $this->assertSame(RRule::MONTHLY, $element->getValue('custom-frequency')); + $this->assertSame(1, $element->getValue('interval')); + $this->assertSame([ + 'runsOn' => 'each', + 'day1' => 'n', + 'day2' => 'n', + 'day3' => 'n', + 'day4' => 'n', + 'day5' => 'n', + 'day6' => 'n', + 'day7' => 'n', + 'day8' => 'y', + 'day9' => 'n', + 'day10' => 'n', + 'day11' => 'n', + 'day12' => 'n', + 'day13' => 'n', + 'day14' => 'n', + 'day15' => 'n', + 'day16' => 'n', + 'day17' => 'y', + 'day18' => 'y', + 'day19' => 'n', + 'day20' => 'n', + 'day21' => 'n', + 'day22' => 'n', + 'day23' => 'n', + 'day24' => 'n', + 'day25' => 'n', + 'day26' => 'n', + 'day27' => 'y', + 'day28' => 'n', + 'ordinal' => 'first', // Not really of interest, as disabled, but it's returned anyway + 'day' => 'day' // Not really of interest, as disabled, but it's returned anyway + ], $element->getValue('monthly-fields')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testCustomMonthlyFrequencyWithEnd() + { + $start = new DateTime('2023-02-07T15:17:07'); + $end = new DateTime('2023-02-10T18:00:00'); + $frequency = (new RRule('FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=8,17,18,27')) + ->startAt($start) + ->endAt($end); + + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertEquals($end, $element->getValue('end')); + $this->assertSame('custom', $element->getValue('frequency')); + $this->assertSame(RRule::MONTHLY, $element->getValue('custom-frequency')); + $this->assertSame(1, $element->getValue('interval')); + $this->assertSame([ + 'runsOn' => 'each', + 'day1' => 'n', + 'day2' => 'n', + 'day3' => 'n', + 'day4' => 'n', + 'day5' => 'n', + 'day6' => 'n', + 'day7' => 'n', + 'day8' => 'y', + 'day9' => 'n', + 'day10' => 'n', + 'day11' => 'n', + 'day12' => 'n', + 'day13' => 'n', + 'day14' => 'n', + 'day15' => 'n', + 'day16' => 'n', + 'day17' => 'y', + 'day18' => 'y', + 'day19' => 'n', + 'day20' => 'n', + 'day21' => 'n', + 'day22' => 'n', + 'day23' => 'n', + 'day24' => 'n', + 'day25' => 'n', + 'day26' => 'n', + 'day27' => 'y', + 'day28' => 'n', + 'ordinal' => 'first', // Not really of interest, as disabled, but it's returned anyway + 'day' => 'day' // Not really of interest, as disabled, but it's returned anyway + ], $element->getValue('monthly-fields')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testCustomOnTheEachMonthFrequency() + { + $start = new DateTime('2023-02-07T15:17:07'); + $end = new DateTime('2023-02-10T18:00:00'); + $frequency = (new RRule('FREQ=MONTHLY;INTERVAL=1;BYDAY=2WE')) + ->startAt($start) + ->endAt($end); + + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertEquals($end, $element->getValue('end')); + $this->assertSame('custom', $element->getValue('frequency')); + $this->assertSame(RRule::MONTHLY, $element->getValue('custom-frequency')); + $this->assertSame(1, $element->getValue('interval')); + $this->assertSame([ + 'runsOn' => 'onthe', + 'day1' => 'n', + 'day2' => 'n', + 'day3' => 'n', + 'day4' => 'n', + 'day5' => 'n', + 'day6' => 'n', + 'day7' => 'n', + 'day8' => 'n', + 'day9' => 'n', + 'day10' => 'n', + 'day11' => 'n', + 'day12' => 'n', + 'day13' => 'n', + 'day14' => 'n', + 'day15' => 'n', + 'day16' => 'n', + 'day17' => 'n', + 'day18' => 'n', + 'day19' => 'n', + 'day20' => 'n', + 'day21' => 'n', + 'day22' => 'n', + 'day23' => 'n', + 'day24' => 'n', + 'day25' => 'n', + 'day26' => 'n', + 'day27' => 'n', + 'day28' => 'n', + 'ordinal' => 'second', + 'day' => 'WE' + ], $element->getValue('monthly-fields')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testCustomAnnuallyFrequency() + { + $start = new DateTime('2023-02-07T15:17:07'); + $frequency = (new RRule('FREQ=YEARLY;INTERVAL=1;BYMONTH=2'))->startAt($start); + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertNull($element->getValue('end')); + $this->assertSame('custom', $element->getValue('frequency')); + $this->assertSame(RRule::YEARLY, $element->getValue('custom-frequency')); + $this->assertSame([ + 'month' => 'FEB', + 'runsOnThe' => 'n', + 'ordinal' => 'first', // Not really of interest, as disabled, but it's returned anyway + 'day' => 'day' // Not really of interest, as disabled, but it's returned anyway + ], $element->getValue('annually-fields')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testCustomAnnuallyFrequencyWithEnd() + { + $start = new DateTime('2023-02-07T15:17:07'); + $end = new DateTime('2023-02-10T18:00:00'); + $frequency = (new RRule('FREQ=YEARLY;INTERVAL=1;BYMONTH=2')) + ->startAt($start) + ->endAt($end); + + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertEquals($end, $element->getValue('end')); + $this->assertSame('custom', $element->getValue('frequency')); + $this->assertSame(RRule::YEARLY, $element->getValue('custom-frequency')); + $this->assertSame([ + 'month' => 'FEB', + 'runsOnThe' => 'n', + 'ordinal' => 'first', // Not really of interest, as disabled, but it's returned anyway + 'day' => 'day' // Not really of interest, as disabled, but it's returned anyway + ], $element->getValue('annually-fields')); + + $this->assertEquals($frequency, $element->getValue()); + } + + public function testCustomOnTheEachYearFrequencyWithEnd() + { + $start = new DateTime('2023-02-07T15:17:07'); + $end = new DateTime('2023-02-10T18:00:00'); + $frequency = (new RRule('FREQ=YEARLY;INTERVAL=1;BYDAY=SA,SU;BYMONTH=2;BYSETPOS=3')) + ->startAt($start) + ->endAt($end); + + $element = $this->assembleElement(['value' => $frequency]); + + $this->assertEquals($start, $element->getValue('start')); + $this->assertEquals($end, $element->getValue('end')); + $this->assertSame('custom', $element->getValue('frequency')); + $this->assertSame(RRule::YEARLY, $element->getValue('custom-frequency')); + $this->assertSame([ + 'month' => 'FEB', + 'runsOnThe' => 'y', + 'ordinal' => 'third', + 'day' => 'weekend' + ], $element->getValue('annually-fields')); + + $this->assertEquals($frequency, $element->getValue()); + } +} From 2f9dd4ac183a3f5349d545994cc5807099c10503 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 9 Feb 2023 17:42:47 +0100 Subject: [PATCH 40/43] css: Cleanup variable usage --- asset/css/schedule-element.less | 12 +++++------ asset/css/variables.less | 37 ++++++++++++++------------------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/asset/css/schedule-element.less b/asset/css/schedule-element.less index b8ec87ab..56b21de3 100644 --- a/asset/css/schedule-element.less +++ b/asset/css/schedule-element.less @@ -80,7 +80,7 @@ text-align: center; padding: .75em 0; background: @schedule-element-fields-bg; - color: @schedule-element-text-color; + color: @schedule-element-fields-color; &:hover { background-color: @schedule-element-fields-hover-bg; @@ -93,7 +93,7 @@ input:checked + label { background-color: @schedule-element-fields-selected-bg; - color: @schedule-element-default-text-color; + color: @schedule-element-fields-selected-color; } input:checked + label:hover { @@ -108,11 +108,11 @@ } input:focus + label { - box-shadow: inset 0 0 0 3px rgba(0, 195, 237, 0.5); + box-shadow: inset 0 0 0 3px @schedule-element-fields-outline-color; } input:checked:focus + label { - box-shadow: inset 0 0 0 3px rgba(255, 255, 255, .5); + box-shadow: inset 0 0 0 3px @schedule-element-fields-selected-outline-color; } } @@ -132,7 +132,7 @@ } &:focus-within { - outline: 3px solid fade(@base-primary-bg, 50); + outline: 3px solid @schedule-element-fields-outline-color; outline-offset: 2px; } @@ -149,7 +149,7 @@ .note { display: none; padding: .5em; - background: @base-gray-light; + background: @schedule-element-keyboard-note-bg; .rounded-corners(.25em); text-align: center; margin-top: 1em; diff --git a/asset/css/variables.less b/asset/css/variables.less index 31e17716..bdc810d2 100644 --- a/asset/css/variables.less +++ b/asset/css/variables.less @@ -34,7 +34,7 @@ @base-primary-color: #00C3ED; @base-primary-bg: #00C3ED; @base-primary-dark: #0081a6; -@base-primary-light: fade(@icinga-blue, 35%); +@base-primary-light: fade(@base-primary-bg, 50%); @default-text-color: #fff; @default-text-color-light: fade(@default-text-color, 75%); @@ -51,7 +51,7 @@ @primary-button-color: @default-text-color-inverted; @primary-button-bg: @base-primary-bg; -@primary-button-hover-bg: #0081a6; +@primary-button-hover-bg: @base-primary-dark; @search-term-bg: @base-gray; @search-term-color: @default-text-color-inverted; @@ -104,19 +104,18 @@ @card-border-color: @base-gray-light; @schedule-element-fields-bg: @default-input-bg; -@schedule-element-default-text-color: @default-text-color-inverted; -@schedule-element-text-color: @base-primary-color; - +@schedule-element-fields-color: @base-primary-color; +@schedule-element-fields-border-color: @base-gray-light; @schedule-element-fields-selected-bg: @primary-button-bg; +@schedule-element-fields-selected-color: @default-text-color-inverted; @schedule-element-fields-hover-bg: @base-primary-light; - +@schedule-element-fields-outline-color: fade(@base-primary-bg, 50%); +@schedule-element-fields-selected-outline-color: fade(#fff, 50%); @schedule-element-fields-selected-hover-bg: @primary-button-hover-bg; - -@schedule-element-fields-border-color: @base-gray-light; - @schedule-element-fields-disabled-color: @base-gray; @schedule-element-fields-disabled-bg: @base-gray-lighter; @schedule-element-fields-disabled-selected-bg: @base-gray-light; +@schedule-element-keyboard-note-bg: @base-gray-light; @iplWebLightRules: { :root { @@ -127,9 +126,6 @@ --base-remove-bg: @state-critical; - --base-primary-dark: fade(@primary-button-bg, 35%); - --base-primary-light: fade(@primary-button-bg, 35%); - --default-text-color: #535353; --default-text-color-light: fade(#535353, 75%); // --default-text-color --default-text-color-inverted: #F5F9FA; @@ -186,18 +182,17 @@ --card-border-color: var(--base-gray-light); --schedule-element-fields-bg: var(--default-input-bg); - --schedule-element-default-text-color: var(--default-text-color-inverted); - --schedule-element-text-color: var(--base-primary-color); - - --schedule-element-fields-selected-bg: var(--base-primary-bg); - --schedule-element-fields-hover-bg: var(--base-primary-light); - - --schedule-element-fields-selected-hover-bg: var(--base-primary-light); - + --schedule-element-fields-color: var(--base-primary-color); --schedule-element-fields-border-color: var(--base-gray-light); - + --schedule-element-fields-selected-bg: var(--primary-button-bg); + --schedule-element-fields-selected-color: var(--default-text-color-inverted); + --schedule-element-fields-hover-bg: @base-primary-light; + --schedule-element-fields-outline-color: fade(@base-primary-bg, 50%); + --schedule-element-fields-selected-outline-color: fade(#fff, 50%); + --schedule-element-fields-selected-hover-bg: var(--primary-button-hover-bg); --schedule-element-fields-disabled-color: var(--base-gray); --schedule-element-fields-disabled-bg: var(--base-gray-lighter); --schedule-element-fields-disabled-selected-bg: var(--base-gray-light); + --schedule-element-keyboard-note-bg: var(--base-gray-light); } }; From 7912545b8a65be82d39bb466774f83f1f4d54caa Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Tue, 14 Feb 2023 13:06:43 +0100 Subject: [PATCH 41/43] Require `dev-main` of the ipl-scheduler --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index ad3c2c79..7d578115 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "ipl/html": "dev-master", "ipl/validator": "@dev", "ipl/i18n": ">=0.2.0", + "ipl/scheduler": "dev-main", "ipl/stdlib": ">=0.12.0", "fortawesome/font-awesome": "^6" } From 4c90f98b1e48b36428af52bbee4e7978f38e74a4 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Fri, 17 Feb 2023 13:16:30 +0100 Subject: [PATCH 42/43] Don't enforce element names to be used --- asset/css/schedule-element.less | 4 ---- src/FormElement/ScheduleElement.php | 5 ++--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/asset/css/schedule-element.less b/asset/css/schedule-element.less index 56b21de3..1905a056 100644 --- a/asset/css/schedule-element.less +++ b/asset/css/schedule-element.less @@ -3,10 +3,6 @@ .schedule-element { @input-border-radius: .25em; - fieldset[name="schedule-element[weekly-fields]"] { - display: flex; - } - .ordinal { display: flex; flex-wrap: wrap; diff --git a/src/FormElement/ScheduleElement.php b/src/FormElement/ScheduleElement.php index 2458b559..3403727d 100644 --- a/src/FormElement/ScheduleElement.php +++ b/src/FormElement/ScheduleElement.php @@ -565,15 +565,14 @@ private function assembleCommonElements(): void public function prepareMultipartUpdate(RequestInterface $request): array { $autoSubmittedBy = $request->getHeader('X-Icinga-AutoSubmittedBy'); - $pattern = '/^schedule-element\[(weekly-fields|monthly-fields|annually-fields)]' - . '\[(ordinal|interval|month|day(\d+)?|[A-Z]{2})]$/'; + $pattern = '/\[(weekly-fields|monthly-fields|annually-fields)]\[(ordinal|interval|month|day(\d+)?|[A-Z]{2})]$/'; $partUpdates = []; if ( $autoSubmittedBy && ! $this->hasCronExpression() && ( - preg_match('/^schedule-element\[(start|end)]$/', $autoSubmittedBy[0], $matches) + preg_match('/\[(start|end)]$/', $autoSubmittedBy[0], $matches) || preg_match($pattern, $autoSubmittedBy[0]) ) ) { From e0bd9ae4ce7a0a1024ce8ea39759807e5158a915 Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Fri, 17 Feb 2023 16:18:21 +0100 Subject: [PATCH 43/43] Register `protector` attribute callback --- src/FormElement/ScheduleElement.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/FormElement/ScheduleElement.php b/src/FormElement/ScheduleElement.php index 3403727d..c6712227 100644 --- a/src/FormElement/ScheduleElement.php +++ b/src/FormElement/ScheduleElement.php @@ -4,6 +4,7 @@ use DateTime; use InvalidArgumentException; +use ipl\Html\Attributes; use ipl\Html\FormElement\FieldsetElement; use ipl\Html\HtmlElement; use ipl\Scheduler\Contract\Frequency; @@ -590,4 +591,11 @@ public function prepareMultipartUpdate(RequestInterface $request): array return $partUpdates; } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback('protector', null, [$this, 'setIdProtector']); + } }