summaryrefslogtreecommitdiffstats
path: root/vendor/ipl/web/src/FormElement
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/ipl/web/src/FormElement')
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement.php636
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement/AnnuallyFields.php133
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsProtector.php41
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement/Common/FieldsUtils.php243
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement/FieldsRadio.php58
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement/MonthlyFields.php191
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement/Recurrence.php89
-rw-r--r--vendor/ipl/web/src/FormElement/ScheduleElement/WeeklyFields.php151
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput.php450
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/RegisteredTerm.php144
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/Term.php89
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/TermContainer.php54
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/TermSuggestions.php281
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/ValidatedTerm.php38
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');
+ }
+}