diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:30:08 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:30:08 +0000 |
commit | 4ce65d59ca91871cfd126497158200a818720bce (patch) | |
tree | e277def01fc7eba7dbc21c4a4ae5576e8aa2cf1f /vendor/ipl/web/src/FormElement | |
parent | Initial commit. (diff) | |
download | icinga-php-library-4ce65d59ca91871cfd126497158200a818720bce.tar.xz icinga-php-library-4ce65d59ca91871cfd126497158200a818720bce.zip |
Adding upstream version 0.13.1.upstream/0.13.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
14 files changed, 2598 insertions, 0 deletions
diff --git a/vendor/ipl/web/src/FormElement/ScheduleElement.php b/vendor/ipl/web/src/FormElement/ScheduleElement.php new file mode 100644 index 0000000..f872f49 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement.php @@ -0,0 +1,636 @@ +<?php + +namespace ipl\Web\FormElement; + +use DateTime; +use InvalidArgumentException; +use ipl\Html\Attributes; +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 */ + protected const CRON_EXPR = 'cron_expr'; + + /** @var string Configure the individual expression parts manually */ + protected const CUSTOM_EXPR = 'custom'; + + /** @var string Used to run a one-off task */ + protected const NO_REPEAT = 'none'; + + protected $defaultAttributes = ['class' => '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->getPopulatedValue('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 || ! $this->hasBeenValidated()) { + 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')); + } + + // Sync the start time and first recurrence of the rule + if (! $this->hasCronExpression() && $this->getFrequency() !== static::NO_REPEAT) { + $nextDue = $rule->getNextRecurrences($start)->current() ?? $start; + $rule->startAt($nextDue); + } + + 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); + + $autosubmit = ! $this->hasCronExpression() && $this->getFrequency() !== static::NO_REPEAT; + $this->addElement('localDateTime', 'start', [ + 'class' => $autosubmit ? 'autosubmit' : null, + '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' => ! $this->hasCronExpression() ? 'autosubmit' : null, + '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', [ + 'required' => true, + 'label' => $this->translate('Cron Expression'), + 'description' => $this->translate('Job cron Schedule'), + 'validators' => [ + new CallbackValidator(function ($value, CallbackValidator $validator) { + if ($value && ! Cron::isValid($value)) { + $validator->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'), + 'validate' => function (): array { + $isValid = $this->isValid(); + $reason = null; + if (! $isValid && $this->getFrequency() === static::CUSTOM_EXPR) { + if ( + $this->getCustomFrequency() !== RRule::YEARLY + && ! $this->getElement('interval')->isValid() + ) { + $reason = current($this->getElement('interval')->getMessages()); + } else { + $frequency = $this->getCustomFrequency(); + switch ($frequency) { + case RRule::WEEKLY: + $reason = current($this->weeklyField->getMessages()); + + break; + case RRule::MONTHLY: + $reason = current($this->monthlyFields->getMessages()); + + break; + default: // annually + $reason = current($this->annuallyFields->getMessages()); + + break; + } + } + } + + return [$isValid, $reason]; + }, + '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|month|day(\d+)?|[A-Z]{2})]$/'; + + $partUpdates = []; + if ( + $autoSubmittedBy + && ( + preg_match('/\[(start|end)]$/', $autoSubmittedBy[0], $matches) + || preg_match($pattern, $autoSubmittedBy[0]) + || preg_match('/\[interval]/', $autoSubmittedBy[0]) + ) + ) { + $this->ensureAssembled(); + + $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/vendor/ipl/web/src/FormElement/ScheduleElement/AnnuallyFields.php b/vendor/ipl/web/src/FormElement/ScheduleElement/AnnuallyFields.php new file mode 100644 index 0000000..857711a --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement/AnnuallyFields.php @@ -0,0 +1,133 @@ +<?php + +namespace ipl\Web\FormElement\ScheduleElement; + +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\FormattedString; +use ipl\Html\FormElement\FieldsetElement; +use ipl\Html\HtmlElement; +use ipl\Web\FormElement\ScheduleElement\Common\FieldsProtector; +use ipl\Web\FormElement\ScheduleElement\Common\FieldsUtils; +use ipl\Web\Widget\Icon; + +class AnnuallyFields extends FieldsetElement +{ + use FieldsUtils; + use FieldsProtector; + + /** @var array A list of valid months */ + protected $months = []; + + /** @var string A month to preselect by default */ + protected $default = 'JAN'; + + public function __construct($name, $attributes = null) + { + $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') + ]; + + parent::__construct($name, $attributes); + } + + protected function init(): void + { + parent::init(); + $this->initUtils(); + } + + /** + * 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); + + $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/vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsProtector.php b/vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsProtector.php new file mode 100644 index 0000000..affd519 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsProtector.php @@ -0,0 +1,41 @@ +<?php + +namespace ipl\Web\FormElement\ScheduleElement\Common; + +trait FieldsProtector +{ + /** @var callable */ + protected $protector; + + /** + * Set callback to protect ids with + * + * @param ?callable $protector + * + * @return $this + */ + public function setIdProtector(?callable $protector): self + { + $this->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/vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsUtils.php b/vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsUtils.php new file mode 100644 index 0000000..bf28255 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsUtils.php @@ -0,0 +1,243 @@ +<?php + +namespace ipl\Web\FormElement\ScheduleElement\Common; + +use DateInterval; +use DateTime; +use Exception; +use InvalidArgumentException; +use ipl\Html\Contract\FormElement; +use ipl\Scheduler\RRule; +use ipl\Web\FormElement\ScheduleElement\MonthlyFields; + +trait FieldsUtils +{ + // Non-standard frequency options + public static $everyDay = 'day'; + public static $everyWeekday = 'weekday'; + public static $everyWeekend = 'weekend'; + + // Enumerators for the monthly and annually schedule of a custom frequency + public static $first = 'first'; + public static $second = 'second'; + public static $third = 'third'; + public static $fourth = 'fourth'; + public static $fifth = 'fifth'; + public static $last = 'last'; + + private $regulars = []; + + protected function initUtils(): void + { + $this->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()))) { + $monthDays = $rule->getByMonthDay() ?? []; + foreach (range(1, $this->availableFields) as $value) { + $values["day$value"] = in_array((string) $value, $monthDays, true) ? 'y' : 'n'; + } + + $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/vendor/ipl/web/src/FormElement/ScheduleElement/FieldsRadio.php b/vendor/ipl/web/src/FormElement/ScheduleElement/FieldsRadio.php new file mode 100644 index 0000000..31b77c3 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement/FieldsRadio.php @@ -0,0 +1,58 @@ +<?php + +namespace ipl\Web\FormElement\ScheduleElement; + +use ipl\Html\Attributes; +use ipl\Html\FormElement\InputElement; +use ipl\Html\FormElement\RadioElement; +use ipl\Html\HtmlElement; +use ipl\Web\FormElement\ScheduleElement\Common\FieldsProtector; + +class FieldsRadio extends RadioElement +{ + use FieldsProtector; + + 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() + ->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/vendor/ipl/web/src/FormElement/ScheduleElement/MonthlyFields.php b/vendor/ipl/web/src/FormElement/ScheduleElement/MonthlyFields.php new file mode 100644 index 0000000..26329fc --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement/MonthlyFields.php @@ -0,0 +1,191 @@ +<?php + +namespace ipl\Web\FormElement\ScheduleElement; + +use ipl\Html\Attributes; +use ipl\Html\FormElement\FieldsetElement; +use ipl\Html\HtmlElement; +use ipl\Validator\CallbackValidator; +use ipl\Validator\InArrayValidator; +use ipl\Validator\ValidatorChain; +use ipl\Web\FormElement\ScheduleElement\Common\FieldsProtector; +use ipl\Web\FormElement\ScheduleElement\Common\FieldsUtils; + +class MonthlyFields extends FieldsetElement +{ + use FieldsUtils; + use FieldsProtector; + + /** @var string Used as radio option to run each selected days/months */ + public const RUNS_EACH = 'each'; + + /** @var string Used as radio option to build complex job schedules */ + public const RUNS_ONTHE = 'onthe'; + + /** @var int Number of days in a week */ + public const WEEK_DAYS = 7; + + /** @var int Day of the month to preselect by default */ + protected $default = 1; + + /** @var int Number of fields to render */ + protected $availableFields; + + protected function init(): void + { + parent::init(); + $this->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'); + } + + foreach (range(1, $this->availableFields) as $day) { + $checkbox = $this->createElement('checkbox', "day$day", [ + 'class' => ['autosubmit', 'sr-only'], + 'value' => $day === $this->default && $runsOn === static::RUNS_EACH + ]); + $this->registerElement($checkbox); + + $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); + } + + $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']); + } + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add( + new CallbackValidator(function ($_, CallbackValidator $validator): bool { + if ($this->getValue('runsOn', static::RUNS_EACH) !== static::RUNS_EACH) { + return true; + } + + $valid = false; + foreach (range(1, $this->availableFields) as $day) { + if ($this->getValue("day$day") === 'y') { + $valid = true; + + break; + } + } + + if (! $valid) { + $validator->addMessage($this->translate('You must select at least one of these days')); + } + + return $valid; + }) + ); + } +} diff --git a/vendor/ipl/web/src/FormElement/ScheduleElement/Recurrence.php b/vendor/ipl/web/src/FormElement/ScheduleElement/Recurrence.php new file mode 100644 index 0000000..8693b20 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement/Recurrence.php @@ -0,0 +1,89 @@ +<?php + +namespace ipl\Web\FormElement\ScheduleElement; + +use DateTime; +use ipl\Html\Attributes; +use ipl\Html\FormElement\BaseFormElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Scheduler\Contract\Frequency; +use ipl\Scheduler\RRule; + +class Recurrence extends BaseFormElement +{ + use Translation; + + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'schedule-recurrences']; + + /** @var callable A callable that generates a frequency instance */ + protected $frequencyCallback; + + /** @var callable A validation callback for the schedule element */ + protected $validateCallback; + + /** + * Set a validation callback that will be called when assembling this element + * + * @param callable $callback + * + * @return $this + */ + public function setValid(callable $callback): self + { + $this->validateCallback = $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() + { + list($isValid, $reason) = ($this->validateCallback)(); + if (! $isValid) { + // Render why we can't generate the recurrences + $this->addHtml(Text::create($reason)); + + 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('validate', null, [$this, 'setValid']); + } +} diff --git a/vendor/ipl/web/src/FormElement/ScheduleElement/WeeklyFields.php b/vendor/ipl/web/src/FormElement/ScheduleElement/WeeklyFields.php new file mode 100644 index 0000000..01933ca --- /dev/null +++ b/vendor/ipl/web/src/FormElement/ScheduleElement/WeeklyFields.php @@ -0,0 +1,151 @@ +<?php + +namespace ipl\Web\FormElement\ScheduleElement; + +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\FormElement\FieldsetElement; +use ipl\Html\HtmlElement; +use ipl\Validator\CallbackValidator; +use ipl\Validator\ValidatorChain; +use ipl\Web\FormElement\ScheduleElement\Common\FieldsProtector; + +class WeeklyFields extends FieldsetElement +{ + use FieldsProtector; + + /** @var array A list of valid week days */ + protected $weekdays = []; + + /** @var string A valid weekday to be selected by default */ + protected $default = 'MO'; + + public function __construct($name, $attributes = null) + { + $this->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 $weekdays + * + * @return array + */ + public function loadWeekDays(array $weekdays): array + { + $values = []; + foreach ($this->weekdays as $weekday => $_) { + $values[$weekday] = in_array($weekday, $weekdays, true) ? 'y' : 'n'; + } + + 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']]); + + foreach ($this->weekdays as $day => $value) { + $checkbox = $this->createElement('checkbox', $day, [ + 'class' => ['autosubmit', 'sr-only'], + 'value' => $day === $this->default + ]); + $this->registerElement($checkbox); + + $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); + } + + $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']); + } + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add( + new CallbackValidator(function ($_, CallbackValidator $validator): bool { + $valid = false; + foreach ($this->weekdays as $weekday => $_) { + if ($this->getValue($weekday) === 'y') { + $valid = true; + + break; + } + } + + if (! $valid) { + $validator->addMessage($this->translate('You must select at least one of these weekdays')); + } + + return $valid; + }) + ); + } +} diff --git a/vendor/ipl/web/src/FormElement/TermInput.php b/vendor/ipl/web/src/FormElement/TermInput.php new file mode 100644 index 0000000..352cce4 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/TermInput.php @@ -0,0 +1,450 @@ +<?php + +namespace ipl\Web\FormElement; + +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\FormElement\FieldsetElement; +use ipl\Html\FormElement\HiddenElement; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Stdlib\Events; +use ipl\Web\FormElement\TermInput\RegisteredTerm; +use ipl\Web\FormElement\TermInput\TermContainer; +use ipl\Web\FormElement\TermInput\ValidatedTerm; +use ipl\Web\Url; +use Psr\Http\Message\ServerRequestInterface; + +class TermInput extends FieldsetElement +{ + use Events; + + /** @var string Emitted in case the user added new terms */ + const ON_ADD = 'on_add'; + + /** @var string Emitted in case the user inserted new terms */ + const ON_PASTE = 'on_paste'; + + /** @var string Emitted in case the user changed existing terms */ + const ON_SAVE = 'on_save'; + + /** @var string Emitted in case the user removed terms */ + const ON_REMOVE = 'on_remove'; + + /** @var string Emitted in case terms need to be enriched */ + const ON_ENRICH = 'on_enrich'; + + /** @var Url The suggestion url */ + protected $suggestionUrl; + + /** @var bool Whether term direction is vertical */ + protected $verticalTermDirection = false; + + /** @var array Changes to transmit to the client */ + protected $changes = []; + + /** @var RegisteredTerm[] The terms */ + protected $terms = []; + + /** @var bool Whether this input has been automatically submitted */ + private $hasBeenAutoSubmitted = false; + + /** @var bool Whether the term input value has been pasted */ + private $valueHasBeenPasted; + + /** @var TermContainer The term container */ + protected $termContainer; + + /** + * Set the suggestion url + * + * @param Url $url + * + * @return $this + */ + public function setSuggestionUrl(Url $url): self + { + $this->suggestionUrl = $url; + + return $this; + } + + /** + * Get the suggestion url + * + * @return ?Url + */ + public function getSuggestionUrl(): ?Url + { + return $this->suggestionUrl; + } + + /** + * Set whether term direction should be vertical + * + * @param bool $state + * + * @return $this + */ + public function setVerticalTermDirection(bool $state = true): self + { + $this->verticalTermDirection = $state; + + return $this; + } + + /** + * Get the desired term direction + * + * @return ?string + */ + public function getTermDirection(): ?string + { + return $this->verticalTermDirection ? 'vertical' : null; + } + + /** + * Set terms + * + * @param RegisteredTerm ...$terms + * + * @return $this + */ + public function setTerms(RegisteredTerm ...$terms): self + { + $this->terms = $terms; + + return $this; + } + + /** + * Get the terms + * + * @return RegisteredTerm[] + */ + public function getTerms(): array + { + return $this->terms; + } + + public function getElements() + { + // TODO: Only a quick-fix. Remove once fieldsets are properly partially validated + $this->ensureAssembled(); + + return parent::getElements(); + } + + public function getValue($name = null, $default = null) + { + if ($name !== null) { + return parent::getValue($name, $default); + } + + $terms = []; + foreach ($this->getTerms() as $term) { + $terms[] = $term->render(','); + } + + return implode(',', $terms); + } + + public function setValue($value) + { + $recipients = $value; + if (is_array($value)) { + $recipients = $value['value'] ?? ''; + parent::setValue($value); + } + + $terms = []; + foreach ($this->parseValue($recipients) as $term) { + $terms[] = new RegisteredTerm($term); + } + + return $this->setTerms(...$terms); + } + + /** + * Parse the given separated string of terms + * + * @param string $value + * + * @return string[] + */ + public function parseValue(string $value): array + { + $terms = []; + + $term = ''; + $ignoreSeparator = false; + for ($i = 0; $i <= strlen($value); $i++) { + if (! isset($value[$i])) { + if (! empty($term)) { + $terms[] = rawurldecode($term); + } + + break; + } + + $c = $value[$i]; + if ($c === '"') { + $ignoreSeparator = ! $ignoreSeparator; + } elseif (! $ignoreSeparator && $c === ',') { + $terms[] = rawurldecode($term); + $term = ''; + } else { + $term .= $c; + } + } + + return $terms; + } + + /** + * Prepare updates to transmit for this input during multipart responses + * + * @param ServerRequestInterface $request + * + * @return array + */ + public function prepareMultipartUpdate(ServerRequestInterface $request): array + { + $updates = []; + if ($this->valueHasBeenPasted()) { + $updates[] = $this->termContainer(); + $updates[] = [ + HtmlString::create(json_encode(['#' . $this->getName() . '-search-input', []])), + 'Behavior:InputEnrichment' + ]; + } elseif (! empty($this->changes)) { + $updates[] = [ + HtmlString::create(json_encode(['#' . $this->getName() . '-search-input', $this->changes])), + 'Behavior:InputEnrichment' + ]; + } + + if (empty($updates) && $this->hasBeenAutoSubmitted()) { + $updates[] = $updates[] = [ + HtmlString::create(json_encode(['#' . $this->getName() . '-search-input', 'bogus'])), + 'Behavior:InputEnrichment' + ]; + } + + return $updates; + } + + /** + * Get whether this input has been automatically submitted + * + * @return bool + */ + private function hasBeenAutoSubmitted(): bool + { + return $this->hasBeenAutoSubmitted; + } + + /** + * Get whether the term input value has been pasted + * + * @return bool + */ + private function valueHasBeenPasted(): bool + { + if ($this->valueHasBeenPasted === null) { + $this->valueHasBeenPasted = ($this->getElement('data')->getValue()['type'] ?? null) === 'paste'; + } + + return $this->valueHasBeenPasted; + } + + public function onRegistered(Form $form) + { + $termContainerId = $this->getName() . '-terms'; + $mainInputId = $this->getName() . '-search-input'; + $autoSubmittedBy = $form->getRequest()->getHeader('X-Icinga-Autosubmittedby'); + + $this->hasBeenAutoSubmitted = in_array($mainInputId, $autoSubmittedBy, true) + || in_array($termContainerId, $autoSubmittedBy, true); + + parent::onRegistered($form); + } + + /** + * Validate the given terms + * + * @param string $type The type of change to validate + * @param array $terms The terms affected by the change + * @param array $changes Potential changes made by validators + * + * @return bool + */ + private function validateTerms(string $type, array $terms, array &$changes): bool + { + $validatedTerms = []; + foreach ($terms as $index => $data) { + $validatedTerms[$index] = ValidatedTerm::fromTermData($data); + } + + switch ($type) { + case 'submit': + case 'exchange': + $type = self::ON_ADD; + + break; + case 'paste': + $type = self::ON_PASTE; + + break; + case 'save': + $type = self::ON_SAVE; + + break; + case 'remove': + default: + return true; + } + + $this->emit($type, [$validatedTerms]); + + $invalid = false; + foreach ($validatedTerms as $index => $term) { + if (! $term->isValid()) { + $invalid = true; + } + + if (! $term->isValid() || $term->hasBeenChanged()) { + $changes[$index] = $term->toTermData(); + } + } + + return $invalid; + } + + /** + * Get the term container + * + * @return TermContainer + */ + protected function termContainer(): TermContainer + { + if ($this->termContainer === null) { + $this->termContainer = (new TermContainer($this)) + ->setAttribute('id', $this->getName() . '-terms'); + } + + return $this->termContainer; + } + + protected function assemble() + { + $myName = $this->getName(); + + $termInputId = $myName . '-term-input'; + $dataInputId = $myName . '-data-input'; + $searchInputId = $myName . '-search-input'; + $suggestionsId = $myName . '-suggestions'; + + $termContainer = $this->termContainer(); + + $suggestions = (new HtmlElement('div')) + ->setAttribute('id', $suggestionsId) + ->setAttribute('class', 'search-suggestions'); + + $termInput = $this->createElement('hidden', 'value', [ + 'id' => $termInputId, + 'disabled' => true + ]); + + $dataInput = new class ('data', [ + 'ignore' => true, + 'id' => $dataInputId, + 'validators' => ['callback' => function ($data) use ($termContainer) { + $changes = []; + $invalid = $this->validateTerms($data['type'], $data['terms'] ?? [], $changes); + $this->changes = $changes; + + $terms = $this->getTerms(); + foreach ($changes as $index => $termData) { + $terms[$index]->applyTermData($termData); + } + + return ! $invalid; + }] + ]) extends HiddenElement { + /** @var TermInput */ + private $parent; + + public function setParent(TermInput $parent): void + { + $this->parent = $parent; + } + + public function setValue($value) + { + $data = json_decode($value, true); + if (($data['type'] ?? null) === 'paste') { + array_push($data['terms'], ...array_map(function ($t) { + return ['search' => $t]; + }, $this->parent->parseValue($data['input']))); + } + + return parent::setValue($data); + } + + public function getValueAttribute() + { + return null; + } + }; + $dataInput->setParent($this); + + $label = $this->getLabel(); + $this->setLabel(null); + + // TODO: Separator customization + $mainInput = $this->createElement('text', 'value', [ + 'id' => $searchInputId, + 'label' => $label, + 'required' => $this->isRequired(), + 'placeholder' => $this->translate('Type to search. Separate multiple terms by comma.'), + 'class' => 'term-input', + 'autocomplete' => 'off', + 'data-term-separator' => ',', + 'data-enrichment-type' => 'terms', + 'data-with-multi-completion' => true, + 'data-no-auto-submit-on-remove' => true, + 'data-term-direction' => $this->getTermDirection(), + 'data-data-input' => '#' . $dataInputId, + 'data-term-input' => '#' . $termInputId, + 'data-term-container' => '#' . $termContainer->getAttribute('id')->getValue(), + 'data-term-suggestions' => '#' . $suggestionsId + ]); + $mainInput->getAttributes() + ->registerAttributeCallback('value', function () { + return null; + }); + if ($this->getSuggestionUrl() !== null) { + $mainInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () { + return (string) $this->getSuggestionUrl(); + }); + } + + $this->addElement($termInput); + $this->addElement($dataInput); + $this->addElement($mainInput); + + $mainInput->prependWrapper((new HtmlElement( + 'div', + Attributes::create(['class' => ['term-input-area', $this->getTermDirection()]]), + $termContainer, + new HtmlElement('label', null, $mainInput) + ))); + + $this->addHtml($suggestions); + + if (! $this->hasBeenAutoSubmitted()) { + $this->emit(self::ON_ENRICH, [$this->getTerms()]); + } + } +} diff --git a/vendor/ipl/web/src/FormElement/TermInput/RegisteredTerm.php b/vendor/ipl/web/src/FormElement/TermInput/RegisteredTerm.php new file mode 100644 index 0000000..dd79dd1 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/TermInput/RegisteredTerm.php @@ -0,0 +1,144 @@ +<?php + +namespace ipl\Web\FormElement\TermInput; + +class RegisteredTerm implements Term +{ + /** @var string The search value */ + protected $value; + + /** @var ?string The label */ + protected $label; + + /** @var ?string The CSS class */ + protected $class; + + /** @var string The failure message */ + protected $message; + + /** @var string The validation constraint */ + protected $pattern; + + /** + * Create a new RegisteredTerm + * + * @param string $value The search value + */ + public function __construct(string $value) + { + $this->setSearchValue($value); + } + + public function setSearchValue(string $value): self + { + $this->value = $value; + + return $this; + } + + public function getSearchValue(): string + { + return $this->value; + } + + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setClass(string $class): self + { + $this->class = $class; + + return $this; + } + + public function getClass(): ?string + { + return $this->class; + } + + public function setMessage(string $message): self + { + $this->message = $message; + + return $this; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setPattern(string $pattern): self + { + $this->pattern = $pattern; + + return $this; + } + + public function getPattern(): ?string + { + if ($this->message === null) { + return null; + } + + return $this->pattern ?? sprintf(Term::DEFAULT_CONSTRAINT, $this->getLabel() ?? $this->getSearchValue()); + } + + /** + * Render this term as a string + * + * Pass the separator being used to separate multiple terms. If the term's value contains it, + * the result will be automatically quoted. + * + * @param string $separator + * + * @return string + */ + public function render(string $separator): string + { + if (strpos($this->value, $separator) !== false) { + return '"' . $this->value . '"'; + } + + return $this->value; + } + + /** + * Apply the given term data to this term + * + * @param array $termData + * + * @return void + */ + public function applyTermData(array $termData): void + { + if (isset($termData['search'])) { + $this->value = $termData['search']; + } + + if (isset($termData['label'])) { + $this->setLabel($termData['label']); + } + + if (isset($termData['class'])) { + $this->setClass($termData['class']); + } + + if (isset($termData['invalidMsg'])) { + $this->setMessage($termData['invalidMsg']); + } + + if (isset($termData['pattern'])) { + $this->setPattern($termData['pattern']); + } + } +} diff --git a/vendor/ipl/web/src/FormElement/TermInput/Term.php b/vendor/ipl/web/src/FormElement/TermInput/Term.php new file mode 100644 index 0000000..be08e8a --- /dev/null +++ b/vendor/ipl/web/src/FormElement/TermInput/Term.php @@ -0,0 +1,89 @@ +<?php + +namespace ipl\Web\FormElement\TermInput; + +interface Term +{ + /** @var string The default validation constraint */ + public const DEFAULT_CONSTRAINT = '^\s*(?!%s\b).*\s*$'; + + /** + * Set the search value + * + * @param string $value + * + * @return $this + */ + public function setSearchValue(string $value); + + /** + * Get the search value + * + * @return string + */ + public function getSearchValue(): string; + + /** + * Set the label + * + * @param string $label + * + * @return $this + */ + public function setLabel(string $label); + + /** + * Get the label + * + * @return ?string + */ + public function getLabel(): ?string; + + /** + * Set the CSS class + * + * @param string $class + * + * @return $this + */ + public function setClass(string $class); + + /** + * Get the CSS class + * + * @return ?string + */ + public function getClass(): ?string; + + /** + * Set the failure message + * + * @param string $message + * + * @return $this + */ + public function setMessage(string $message); + + /** + * Get the failure message + * + * @return ?string + */ + public function getMessage(): ?string; + + /** + * Set the validation constraint + * + * @param string $pattern + * + * @return $this + */ + public function setPattern(string $pattern); + + /** + * Get the validation constraint + * + * @return ?string + */ + public function getPattern(): ?string; +} diff --git a/vendor/ipl/web/src/FormElement/TermInput/TermContainer.php b/vendor/ipl/web/src/FormElement/TermInput/TermContainer.php new file mode 100644 index 0000000..c5a614c --- /dev/null +++ b/vendor/ipl/web/src/FormElement/TermInput/TermContainer.php @@ -0,0 +1,54 @@ +<?php + +namespace ipl\Web\FormElement\TermInput; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Web\FormElement\TermInput; + +class TermContainer extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'terms']; + + /** @var TermInput */ + protected $input; + + /** + * Create a new TermContainer + * + * @param TermInput $input + */ + public function __construct(TermInput $input) + { + $this->input = $input; + } + + protected function assemble() + { + foreach ($this->input->getTerms() as $i => $term) { + $label = $term->getLabel() ?: $term->getSearchValue(); + + $this->addHtml(new HtmlElement( + 'label', + Attributes::create([ + 'class' => $term->getClass(), + 'data-search' => $term->getSearchValue(), + 'data-label' => $label, + 'data-index' => $i + ]), + new HtmlElement( + 'input', + Attributes::create([ + 'type' => 'text', + 'value' => $label, + 'pattern' => $term->getPattern(), + 'data-invalid-msg' => $term->getMessage() + ]) + ) + )); + } + } +} diff --git a/vendor/ipl/web/src/FormElement/TermInput/TermSuggestions.php b/vendor/ipl/web/src/FormElement/TermInput/TermSuggestions.php new file mode 100644 index 0000000..26b00ea --- /dev/null +++ b/vendor/ipl/web/src/FormElement/TermInput/TermSuggestions.php @@ -0,0 +1,281 @@ +<?php + +namespace ipl\Web\FormElement\TermInput; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\I18n\Translation; +use Psr\Http\Message\ServerRequestInterface; +use Traversable; + +use function ipl\Stdlib\yield_groups; + +class TermSuggestions extends BaseHtmlElement +{ + use Translation; + + protected $tag = 'ul'; + + /** @var Traversable */ + protected $provider; + + /** @var ?callable */ + protected $groupingCallback; + + /** @var ?string */ + protected $searchTerm; + + /** @var ?string */ + protected $searchPattern; + + /** @var ?string */ + protected $originalValue; + + /** @var string[] */ + protected $excludeTerms = []; + + /** + * Create new TermSuggestions + * + * The provider must deliver terms in form of arrays with the following keys: + * * (required) search: The search value + * * label: A human-readable label + * * class: A CSS class + * * title: A message shown upon hover on the term + * + * Any excess key is also transferred to the client, but currently unused. + * + * @param Traversable $provider + */ + public function __construct(Traversable $provider) + { + $this->provider = $provider; + } + + /** + * Set a callback to identify groups for terms delivered by the provider + * + * The callback must return a string which is used as label for the group. + * Its interface is: `function (array $data): string` + * + * @param callable $callback + * + * @return $this + */ + public function setGroupingCallback(callable $callback): self + { + $this->groupingCallback = $callback; + + return $this; + } + + /** + * Get the callback used to identify groups for terms delivered by the provider + * + * @return ?callable + */ + public function getGroupingCallback(): ?callable + { + return $this->groupingCallback; + } + + /** + * Set the search term (can contain `*` wildcards) + * + * @param string $term + * + * @return $this + */ + public function setSearchTerm(string $term): self + { + $this->searchTerm = $term; + $this->setSearchPattern( + '/' . str_replace( + '\\000', + '.*', + preg_quote( + str_replace( + '*', + "\0", + $term + ), + '/' + ) + ) . '/i' + ); + + return $this; + } + + /** + * Get the search term + * + * @return ?string + */ + public function getSearchTerm(): ?string + { + return $this->searchTerm; + } + + /** + * Set the search pattern used by {@see matchSearch} + * + * @param string $pattern + * + * @return $this + */ + protected function setSearchPattern(string $pattern): self + { + $this->searchPattern = $pattern; + + return $this; + } + + /** + * Set the original search value + * + * The one without automatically added wildcards. + * + * @param string $term + * + * @return $this + */ + public function setOriginalSearchValue(string $term): self + { + $this->originalValue = $term; + + return $this; + } + + /** + * Get the original search value + * + * @return ?string + */ + public function getOriginalSearchValue(): ?string + { + return $this->originalValue; + } + + /** + * Set the terms to exclude in the suggestion list + * + * @param string[] $terms + * + * @return $this + */ + public function setExcludeTerms(array $terms): self + { + $this->excludeTerms = $terms; + + return $this; + } + + /** + * Get the terms to exclude in the suggestion list + * + * @return string[] + */ + public function getExcludeTerms(): array + { + return $this->excludeTerms; + } + + /** + * Match the given search term against the users search + * + * @param string $term + * + * @return bool Whether the search matches or not + */ + public function matchSearch(string $term): bool + { + if (! $this->searchPattern || $this->searchPattern === '.*') { + return true; + } + + return (bool) preg_match($this->searchPattern, $term); + } + + /** + * Load suggestions as requested by the client + * + * @param ServerRequestInterface $request + * + * @return $this + */ + public function forRequest(ServerRequestInterface $request): self + { + if ($request->getMethod() !== 'POST') { + return $this; + } + + /** @var array<string, array<int|string, string>> $requestData */ + $requestData = json_decode($request->getBody()->read(8192), true); + if (empty($requestData)) { + return $this; + } + + $this->setSearchTerm($requestData['term']['label']); + $this->setOriginalSearchValue($requestData['term']['search']); + $this->setExcludeTerms($requestData['exclude'] ?? []); + + return $this; + } + + protected function assemble() + { + $groupingCallback = $this->getGroupingCallback(); + if ($groupingCallback) { + $provider = yield_groups($this->provider, $groupingCallback); + } else { + $provider = [null => $this->provider]; + } + + /** @var iterable<?string, array<array<string, string>>> $provider */ + foreach ($provider as $group => $suggestions) { + if ($group) { + $this->addHtml( + new HtmlElement( + 'li', + Attributes::create([ + 'class' => 'suggestion-title' + ]), + Text::create($group) + ) + ); + } + + foreach ($suggestions as $data) { + $attributes = [ + 'type' => 'button', + 'value' => $data['label'] ?? $data['search'] + ]; + foreach ($data as $name => $value) { + $attributes["data-$name"] = $value; + } + + $this->addHtml( + new HtmlElement( + 'li', + null, + new HtmlElement( + 'input', + Attributes::create($attributes) + ) + ) + ); + } + } + + if ($this->isEmpty()) { + $this->addHtml(new HtmlElement( + 'li', + Attributes::create(['class' => 'nothing-to-suggest']), + new HtmlElement('em', null, Text::create($this->translate('Nothing to suggest'))) + )); + } + } +} diff --git a/vendor/ipl/web/src/FormElement/TermInput/ValidatedTerm.php b/vendor/ipl/web/src/FormElement/TermInput/ValidatedTerm.php new file mode 100644 index 0000000..e91c203 --- /dev/null +++ b/vendor/ipl/web/src/FormElement/TermInput/ValidatedTerm.php @@ -0,0 +1,38 @@ +<?php + +namespace ipl\Web\FormElement\TermInput; + +use BadMethodCallException; + +class ValidatedTerm extends \ipl\Web\Control\SearchBar\ValidatedTerm implements Term +{ + const DEFAULT_PATTERN = Term::DEFAULT_CONSTRAINT; + + /** @var ?string The CSS class */ + protected $class; + + public function setClass(string $class): Term + { + $this->class = $class; + + return $this; + } + + public function getClass(): ?string + { + return $this->class; + } + + public function toTermData() + { + $data = parent::toTermData(); + $data['class'] = $this->getClass(); + + return $data; + } + + public function toMetaData() + { + throw new BadMethodCallException(self::class . '::toTermData() not implemented yet'); + } +} |