diff --git a/asset/css/compat.less b/asset/css/compat.less new file mode 100644 index 00000000..6fd00f8f --- /dev/null +++ b/asset/css/compat.less @@ -0,0 +1,35 @@ +.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; + } + + // 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 { + > .monthly, + > .ordinal { + margin-right: 2em; + } + + > .ordinal.annually { + margin-right: 1em; + } +} diff --git a/asset/css/schedule-element.less b/asset/css/schedule-element.less new file mode 100644 index 00000000..1905a056 --- /dev/null +++ b/asset/css/schedule-element.less @@ -0,0 +1,210 @@ +// Schedule form element + +.schedule-element { + @input-border-radius: .25em; + + .ordinal { + display: flex; + flex-wrap: wrap; + + .radio-label { + flex: 1 1 auto; + } + + select { + flex: 1 1 auto; + + &:first-of-type { + margin-right: 1em; + } + + &:disabled { + color: @schedule-element-fields-disabled-color; + } + } + } + + .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, .ordinal:not(.annually) { + padding: .5em; + margin-left: -.5em; + border: 1px solid @schedule-element-fields-border-color; + .rounded-corners(.75em); + } + + .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: @schedule-element-fields-disabled-color; + background-color: @schedule-element-fields-disabled-bg; + } + + input:checked + label { + background: @schedule-element-fields-disabled-selected-bg; + color: @schedule-element-fields-disabled-color; + } + } + + li { + width: calc(100% / 7); /* default for week based cols makes sense */ + + label { + display: block; + width: 100%; + cursor: pointer; + text-align: center; + padding: .75em 0; + background: @schedule-element-fields-bg; + color: @schedule-element-fields-color; + + &:hover { + background-color: @schedule-element-fields-hover-bg; + } + + &:focus { + outline: none; + } + } + + input:checked + label { + background-color: @schedule-element-fields-selected-bg; + color: @schedule-element-fields-selected-color; + } + + input:checked + label:hover { + background-color: @schedule-element-fields-selected-hover-bg; + border-color: @schedule-element-fields-selected-hover-bg; + } + } + + &.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 @schedule-element-fields-outline-color; + } + + input:checked:focus + label { + box-shadow: inset 0 0 0 3px @schedule-element-fields-selected-outline-color; + } + } + + &.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 @schedule-element-fields-outline-color; + 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: @schedule-element-keyboard-note-bg; + .rounded-corners(.25em); + text-align: center; + margin-top: 1em; + line-height: 1.25; + } + + /* .weekly */ + .weekly { } + + /* .monthly styles */ + .monthly { + li label { + border-top: 1px solid @schedule-element-fields-border-color; + } + + 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; + } + } + + /* 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:nth-child(4n) label { + margin-right: 0; + } + + .toggle-slider-controls { + display: flex; + column-gap: 1em; + align-items: center; + margin-top: 1em; + margin-bottom: -.6em; + } + } +} + +.schedule-recurrences { + line-height: 1.1em; + padding-top: 0.5625em; + + p { + color: @schedule-element-fields-disabled-color; + } +} diff --git a/asset/css/variables.less b/asset/css/variables.less index 1d6c8951..bdc810d2 100644 --- a/asset/css/variables.less +++ b/asset/css/variables.less @@ -33,10 +33,13 @@ @base-primary-color: #00C3ED; @base-primary-bg: #00C3ED; +@base-primary-dark: #0081a6; +@base-primary-light: fade(@base-primary-bg, 50%); @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 +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; @@ -66,7 +69,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 +103,20 @@ @card-border-color: @base-gray-light; +@schedule-element-fields-bg: @default-input-bg; +@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-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 { --base-gray: #819398; @@ -112,12 +129,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 +180,19 @@ --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-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); } }; diff --git a/composer.json b/composer.json index 90173f1f..7d578115 100644 --- a/composer.json +++ b/composer.json @@ -18,8 +18,11 @@ "require": { "php": ">=7.2", "ext-json": "*", - "ipl/html": ">=0.6.0", + "psr/http-message": "^1.0", + "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" } 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; diff --git a/src/FormElement/ScheduleElement.php b/src/FormElement/ScheduleElement.php new file mode 100644 index 00000000..c6712227 --- /dev/null +++ b/src/FormElement/ScheduleElement.php @@ -0,0 +1,601 @@ + 'schedule-element']; + + /** @var array A list of allowed frequencies used to configure custom expressions */ + protected $customFrequencies = []; + + /** @var array */ + protected $advanced = []; + + /** @var array */ + protected $regulars = []; + + /** @var string Schedule frequency of this element */ + protected $frequency = self::NO_REPEAT; + + /** @var string */ + protected $customFrequency; + + /** @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->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…') + ]; + } + + /** + * Get whether this element is rendering a cron expression + * + * @return bool + */ + public function hasCronExpression(): bool + { + return $this->getFrequency() === static::CRON_EXPR; + } + + /** + * Get the frequency of this element + * + * @return string + */ + public function getFrequency(): string + { + return $this->getValue('frequency', $this->frequency); + } + + /** + * Set the custom frequency of this schedule element + * + * @param string $frequency + * + * @return $this + */ + public function setFrequency(string $frequency): self + { + if ( + $frequency !== static::NO_REPEAT + && ! isset($this->regulars[$frequency]) + && ! isset($this->advanced[$frequency]) + ) { + throw new InvalidArgumentException(sprintf('Invalid frequency provided: %s', $frequency)); + } + + $this->frequency = $frequency; + + 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 + * + * @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; + } + + public function getValue($name = null, $default = null) + { + if ($name !== null) { + return parent::getValue($name, $default); + } + + $frequency = $this->getFrequency(); + $start = parent::getValue('start'); + switch ($frequency) { + case static::NO_REPEAT: + return new OneOff($start); + case static::CRON_EXPR: + $rule = new Cron(parent::getValue('cron-expression')); + + break; + case RRule::MINUTELY: + case RRule::HOURLY: + case RRule::DAILY: + case RRule::WEEKLY: + case RRule::MONTHLY: + case RRule::QUARTERLY: + case RRule::YEARLY: + $rule = RRule::fromFrequency($frequency); + + break; + default: // static::CUSTOM_EXPR + $interval = parent::getValue('interval', 1); + $customFrequency = parent::getValue('custom-frequency', RRule::DAILY); + switch ($customFrequency) { + case RRule::DAILY: + if ($interval === '*') { + $interval = 1; + } + + $rule = new RRule("FREQ=DAILY;INTERVAL=$interval"); + + break; + case RRule::WEEKLY: + $byDay = implode(',', $this->weeklyField->getSelectedWeekDays()); + + $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()); + + $rule = new RRule("FREQ=MONTHLY;INTERVAL=$interval;BYMONTHDAY=$byMonth"); + + break; + } + // 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') { + $rule = new RRule($rule); + + break; + } + } + + $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); + } + + $rule = new RRule($rule); + + break; + default: + throw new LogicException(sprintf('Custom frequency %s is not supported!', $customFrequency)); + } + } + + $rule->startAt($start); + if (parent::getValue('use-end-time', 'n') === 'y') { + $rule->endAt(parent::getValue('end')); + } + + return $rule; + } + + public function setValue($value) + { + $values = $value; + $rule = $value; + if ($rule instanceof Frequency) { + if ($rule->getStart()) { + $this->setStart($rule->getStart()); + } + + $values = []; + if ($rule->getEnd() && ! $rule instanceof OneOff) { + $values['use-end-time'] = 'y'; + $values['end'] = $rule->getEnd(); + } + + 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) { + $values['interval'] = $rule->getInterval(); + switch ($rule->getFrequency()) { + case RRule::DAILY: + if ($rule->getInterval() <= 1 && strpos($rule->getString(), '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 { + $values['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) { + $values['monthly-fields'] = $this->monthlyFields->loadRRule($rule); + $this->setCustomFrequency(RRule::MONTHLY); + } else { + $values['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()); + } + + $values['frequency'] = $this->getFrequency(); + $values['custom-frequency'] = $this->getCustomFrequency(); + } + } + + return parent::setValue($values); + } + + protected function assemble() + { + $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', + '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', 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', + '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) { + $this->addElement('select', 'custom-frequency', [ + 'required' => false, + 'class' => 'autosubmit', + '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 (parent::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->addElement($this->monthlyFields); + + break; + case RRule::YEARLY: + $this->addElement($this->annuallyFields); + } + } elseif ($this->hasCronExpression()) { + $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; + } + + return true; + }) + ] + ]); + } + + if ($this->getFrequency() !== static::NO_REPEAT && ! $this->hasCronExpression()) { + $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->getValue(); + } else { + $rule = RRule::fromFrequency($this->getFrequency()); + } + + $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 ($this->getPopulatedValue('use-end-time') === 'y') { + $rule->endAt($this->getValue('end')); + } + + return $rule; + } + ]) + ); + } + } + + /** + * Assemble common parts for all the frequencies + */ + private function assembleCommonElements(): void + { + $repeat = $this->getCustomFrequency(); + 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)); + } + + /** + * Get prepared multipart updates + * + * @param RequestInterface $request + * + * @return array + */ + public function prepareMultipartUpdate(RequestInterface $request): array + { + $autoSubmittedBy = $request->getHeader('X-Icinga-AutoSubmittedBy'); + $pattern = '/\[(weekly-fields|monthly-fields|annually-fields)]\[(ordinal|interval|month|day(\d+)?|[A-Z]{2})]$/'; + + $partUpdates = []; + if ( + $autoSubmittedBy + && ! $this->hasCronExpression() + && ( + preg_match('/\[(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; + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback('protector', null, [$this, 'setIdProtector']); + } +} diff --git a/src/FormElement/ScheduleElement/AnnuallyFields.php b/src/FormElement/ScheduleElement/AnnuallyFields.php new file mode 100644 index 00000000..27cebfb6 --- /dev/null +++ b/src/FormElement/ScheduleElement/AnnuallyFields.php @@ -0,0 +1,149 @@ +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') + ]; + + parent::__construct($name, $attributes); + } + + protected function init(): void + { + parent::init(); + $this->initUtils(); + } + + 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 + { + if (! 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 FieldsRadio('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); + + $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'); + } + + $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']); + } +} diff --git a/src/FormElement/ScheduleElement/Common/FieldsProtector.php b/src/FormElement/ScheduleElement/Common/FieldsProtector.php new file mode 100644 index 00000000..affd5198 --- /dev/null +++ b/src/FormElement/ScheduleElement/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 string $id + * + * @return string + */ + public function protectId(string $id): string + { + if (is_callable($this->protector)) { + return call_user_func($this->protector, $id); + } + + return $id; + } +} diff --git a/src/FormElement/ScheduleElement/Common/FieldsUtils.php b/src/FormElement/ScheduleElement/Common/FieldsUtils.php new file mode 100644 index 00000000..79f818a4 --- /dev/null +++ b/src/FormElement/ScheduleElement/Common/FieldsUtils.php @@ -0,0 +1,242 @@ +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 + { + $select = $this->createElement('select', 'day', [ + 'class' => 'autosubmit', + 'value' => $this->getPopulatedValue('day', static::$everyDay), + '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; + } + + /** + * 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->getStart()) { + $months[] = $rule->getStart()->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)(\w.*)$/', $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)); + } +} diff --git a/src/FormElement/ScheduleElement/FieldsRadio.php b/src/FormElement/ScheduleElement/FieldsRadio.php new file mode 100644 index 00000000..31b77c34 --- /dev/null +++ b/src/FormElement/ScheduleElement/FieldsRadio.php @@ -0,0 +1,58 @@ + ['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() + ->set('id', $htmlId) + ->registerAttributeCallback('checked', function () use ($option) { + return (string) $this->getValue() === (string) $option->getValue(); + }) + ->registerAttributeCallback('required', [$this, 'getRequiredAttribute']) + ->registerAttributeCallback('disabled', function () use ($option) { + return $this->getAttributes()->get('disabled')->getValue() || $option->isDisabled(); + }); + + $listItem = HtmlElement::create('li'); + $listItem->addHtml( + $radio, + HtmlElement::create('label', [ + 'for' => $htmlId, + 'class' => $option->getLabelCssClass(), + 'tabindex' => -1 + ], $option->getLabel()) + ); + $listItems->addHtml($listItem); + } + + $this->addHtml($listItems); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes + ->registerAttributeCallback('protector', null, [$this, 'setIdProtector']); + } +} diff --git a/src/FormElement/ScheduleElement/MonthlyFields.php b/src/FormElement/ScheduleElement/MonthlyFields.php new file mode 100644 index 00000000..360e09de --- /dev/null +++ b/src/FormElement/ScheduleElement/MonthlyFields.php @@ -0,0 +1,169 @@ +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 + * + * @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' => ['autosubmit', 'sr-only'], + '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'); + $listItem->addHtml($checkbox, HtmlElement::create('label', ['for' => $htmlId], $day)); + $listItems->addHtml($listItem); + } + + 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')], + 'validators' => [ + new InArrayValidator([ + 'strict' => true, + 'haystack' => [static::RUNS_EACH, static::RUNS_ONTHE] + ]) + ] + ]); + + $ordinalWrapper = HtmlElement::create('div', ['class' => 'ordinal']); + $runsOnThe = $this->getElement('runsOn'); + $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']); + } +} diff --git a/src/FormElement/ScheduleElement/Recurrence.php b/src/FormElement/ScheduleElement/Recurrence.php new file mode 100644 index 00000000..bbf147e6 --- /dev/null +++ b/src/FormElement/ScheduleElement/Recurrence.php @@ -0,0 +1,86 @@ + 'schedule-recurrences']; + + /** @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(HtmlElement::create('p', null, Text::create($this->translate('Never')))); + + return; + } + + foreach ($recurrences as $recurrence) { + $this->addHtml(HtmlElement::create('p', null, $recurrence->format($this->translate('D, 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']); + } +} diff --git a/src/FormElement/ScheduleElement/WeeklyFields.php b/src/FormElement/ScheduleElement/WeeklyFields.php new file mode 100644 index 00000000..cab42cbc --- /dev/null +++ b/src/FormElement/ScheduleElement/WeeklyFields.php @@ -0,0 +1,138 @@ +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') + ]; + + parent::__construct($name, $attributes); + } + + /** + * 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; + if (! 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' => ['autosubmit', 'sr-only'], + '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'); + $listItem->addHtml($checkbox, HtmlElement::create('label', ['for' => $htmlId], $value)); + $listItems->addHtml($listItem); + } + + if (! $foundCheckedDay) { + $this->getElement($this->default)->setChecked(true); + } + + $fieldsWrapper->addHtml($listItems); + $this->addHtml($fieldsWrapper); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes + ->registerAttributeCallback('default', null, [$this, 'setDefault']) + ->registerAttributeCallback('protector', null, [$this, 'setIdProtector']); + } +} 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()); + } +}