summaryrefslogtreecommitdiffstats
path: root/vendor/ipl/web/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--vendor/ipl/web/src/Common/BaseItemList.php73
-rw-r--r--vendor/ipl/web/src/Common/BaseItemTable.php88
-rw-r--r--vendor/ipl/web/src/Common/BaseListItem.php145
-rw-r--r--vendor/ipl/web/src/Common/BaseOrderedItemList.php31
-rw-r--r--vendor/ipl/web/src/Common/BaseOrderedListItem.php42
-rw-r--r--vendor/ipl/web/src/Common/BaseTableRowItem.php119
-rw-r--r--vendor/ipl/web/src/Common/BaseTarget.php36
-rw-r--r--vendor/ipl/web/src/Common/Card.php59
-rw-r--r--vendor/ipl/web/src/Common/CsrfCounterMeasure.php48
-rw-r--r--vendor/ipl/web/src/Common/FormUid.php59
-rw-r--r--vendor/ipl/web/src/Common/RedirectOption.php41
-rw-r--r--vendor/ipl/web/src/Common/StateBadges.php194
-rw-r--r--vendor/ipl/web/src/Compat/CompatController.php512
-rw-r--r--vendor/ipl/web/src/Compat/CompatDecorator.php14
-rw-r--r--vendor/ipl/web/src/Compat/CompatForm.php100
-rw-r--r--vendor/ipl/web/src/Compat/Multipart.php33
-rw-r--r--vendor/ipl/web/src/Compat/SearchControls.php260
-rw-r--r--vendor/ipl/web/src/Compat/StyleWithNonce.php25
-rw-r--r--vendor/ipl/web/src/Compat/ViewRenderer.php60
-rw-r--r--vendor/ipl/web/src/Control/LimitControl.php123
-rw-r--r--vendor/ipl/web/src/Control/PaginationControl.php523
-rw-r--r--vendor/ipl/web/src/Control/SearchBar.php541
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/SearchException.php9
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/Suggestions.php451
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/Terms.php255
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php44
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php80
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php196
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php41
-rw-r--r--vendor/ipl/web/src/Control/SearchEditor.php615
-rw-r--r--vendor/ipl/web/src/Control/SortControl.php293
-rw-r--r--vendor/ipl/web/src/Filter/ParseException.php36
-rw-r--r--vendor/ipl/web/src/Filter/Parser.php568
-rw-r--r--vendor/ipl/web/src/Filter/QueryString.php94
-rw-r--r--vendor/ipl/web/src/Filter/Renderer.php186
-rw-r--r--vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php123
-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
-rw-r--r--vendor/ipl/web/src/Layout/Content.php17
-rw-r--r--vendor/ipl/web/src/Layout/Controls.php59
-rw-r--r--vendor/ipl/web/src/Layout/Footer.php17
-rw-r--r--vendor/ipl/web/src/LessRuleset.php177
-rw-r--r--vendor/ipl/web/src/Style.php123
-rw-r--r--vendor/ipl/web/src/Url.php71
-rw-r--r--vendor/ipl/web/src/Widget/ActionBar.php51
-rw-r--r--vendor/ipl/web/src/Widget/ActionLink.php31
-rw-r--r--vendor/ipl/web/src/Widget/ButtonLink.php14
-rw-r--r--vendor/ipl/web/src/Widget/ContinueWith.php72
-rw-r--r--vendor/ipl/web/src/Widget/CopyToClipboard.php64
-rw-r--r--vendor/ipl/web/src/Widget/Dropdown.php63
-rw-r--r--vendor/ipl/web/src/Widget/EmptyState.php30
-rw-r--r--vendor/ipl/web/src/Widget/EmptyStateBar.php30
-rw-r--r--vendor/ipl/web/src/Widget/HorizontalKeyValue.php31
-rw-r--r--vendor/ipl/web/src/Widget/IcingaIcon.php28
-rw-r--r--vendor/ipl/web/src/Widget/Icon.php67
-rw-r--r--vendor/ipl/web/src/Widget/Link.php97
-rw-r--r--vendor/ipl/web/src/Widget/StateBadge.php47
-rw-r--r--vendor/ipl/web/src/Widget/StateBall.php43
-rw-r--r--vendor/ipl/web/src/Widget/Tabs.php190
-rw-r--r--vendor/ipl/web/src/Widget/TimeAgo.php33
-rw-r--r--vendor/ipl/web/src/Widget/TimeSince.php33
-rw-r--r--vendor/ipl/web/src/Widget/TimeUntil.php34
-rw-r--r--vendor/ipl/web/src/Widget/VerticalKeyValue.php32
75 files changed, 10169 insertions, 0 deletions
diff --git a/vendor/ipl/web/src/Common/BaseItemList.php b/vendor/ipl/web/src/Common/BaseItemList.php
new file mode 100644
index 0000000..ce0946c
--- /dev/null
+++ b/vendor/ipl/web/src/Common/BaseItemList.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace ipl\Web\Common;
+
+use InvalidArgumentException;
+use ipl\Html\BaseHtmlElement;
+use ipl\Orm\ResultSet;
+use ipl\Stdlib\BaseFilter;
+use ipl\Web\Widget\EmptyStateBar;
+
+/**
+ * Base class for item lists
+ */
+abstract class BaseItemList extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ /** @var array<string, mixed> */
+ protected $baseAttributes = [
+ 'class' => ['item-list', 'default-layout'],
+ 'data-base-target' => '_next',
+ 'data-pdfexport-page-breaks-at' => '.list-item'
+ ];
+
+ /** @var ResultSet|iterable<object> */
+ protected $data;
+
+ protected $tag = 'ul';
+
+ /**
+ * Create a new item list
+ *
+ * @param ResultSet|iterable<object> $data Data source of the list
+ */
+ public function __construct($data)
+ {
+ if (! is_iterable($data)) {
+ throw new InvalidArgumentException('Data must be an array or an instance of Traversable');
+ }
+
+ $this->data = $data;
+
+ $this->addAttributes($this->baseAttributes);
+
+ $this->init();
+ }
+
+ abstract protected function getItemClass(): string;
+
+ /**
+ * Initialize the item list
+ *
+ * If you want to adjust the item list after construction, override this method.
+ */
+ protected function init(): void
+ {
+ }
+
+ protected function assemble(): void
+ {
+ $itemClass = $this->getItemClass();
+ foreach ($this->data as $data) {
+ /** @var BaseListItem|BaseTableRowItem $item */
+ $item = new $itemClass($data, $this);
+ $this->addHtml($item);
+ }
+
+ if ($this->isEmpty()) {
+ $this->setTag('div');
+ $this->addHtml(new EmptyStateBar(t('No items found.')));
+ }
+ }
+}
diff --git a/vendor/ipl/web/src/Common/BaseItemTable.php b/vendor/ipl/web/src/Common/BaseItemTable.php
new file mode 100644
index 0000000..f6ca212
--- /dev/null
+++ b/vendor/ipl/web/src/Common/BaseItemTable.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace ipl\Web\Common;
+
+use InvalidArgumentException;
+use ipl\Html\BaseHtmlElement;
+use ipl\Orm\ResultSet;
+use ipl\Stdlib\BaseFilter;
+use ipl\Web\Widget\EmptyStateBar;
+
+/**
+ * Base class for item tables
+ */
+abstract class BaseItemTable extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ /** @var string Defines the layout used by this item */
+ public const TABLE_LAYOUT = 'table-layout';
+
+ /** @var array<string, mixed> */
+ protected $baseAttributes = [
+ 'class' => 'item-table',
+ 'data-base-target' => '_next'
+ ];
+
+ /** @var ResultSet|iterable<object> */
+ protected $data;
+
+ protected $tag = 'ul';
+
+ /**
+ * Create a new item table
+ *
+ * @param ResultSet|iterable<object> $data Data source of the table
+ */
+ public function __construct($data)
+ {
+ if (! is_iterable($data)) {
+ throw new InvalidArgumentException('Data must be an array or an instance of Traversable');
+ }
+
+ $this->data = $data;
+
+ $this->addAttributes($this->baseAttributes);
+
+ $this->init();
+ }
+
+ /**
+ * Initialize the item table
+ *
+ * If you want to adjust the item table after construction, override this method.
+ */
+ protected function init(): void
+ {
+ }
+
+ /**
+ * Get the table layout to use
+ *
+ * @return string
+ */
+ protected function getLayout(): string
+ {
+ return static::TABLE_LAYOUT;
+ }
+
+ abstract protected function getItemClass(): string;
+
+ protected function assemble(): void
+ {
+ $this->addAttributes(['class' => $this->getLayout()]);
+
+ $itemClass = $this->getItemClass();
+ foreach ($this->data as $data) {
+ /** @var BaseTableRowItem $item */
+ $item = new $itemClass($data, $this);
+
+ $this->addHtml($item);
+ }
+
+ if ($this->isEmpty()) {
+ $this->setTag('div');
+ $this->addHtml(new EmptyStateBar(t('No items found.')));
+ }
+ }
+}
diff --git a/vendor/ipl/web/src/Common/BaseListItem.php b/vendor/ipl/web/src/Common/BaseListItem.php
new file mode 100644
index 0000000..cf143ee
--- /dev/null
+++ b/vendor/ipl/web/src/Common/BaseListItem.php
@@ -0,0 +1,145 @@
+<?php
+
+namespace ipl\Web\Common;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+
+/**
+ * Base class for list items
+ */
+abstract class BaseListItem extends BaseHtmlElement
+{
+ /** @var array<string, mixed> */
+ protected $baseAttributes = ['class' => 'list-item'];
+
+ /** @var object The associated list item */
+ protected $item;
+
+ /** @var BaseItemList The list where the item is part of */
+ protected $list;
+
+ protected $tag = 'li';
+
+ /**
+ * Create a new list item
+ *
+ * @param object $item
+ * @param BaseItemList $list
+ */
+ public function __construct($item, BaseItemList $list)
+ {
+ $this->item = $item;
+ $this->list = $list;
+
+ $this->addAttributes($this->baseAttributes);
+
+ $this->init();
+ }
+
+ abstract protected function assembleHeader(BaseHtmlElement $header): void;
+
+ abstract protected function assembleMain(BaseHtmlElement $main): void;
+
+ protected function assembleFooter(BaseHtmlElement $footer): void
+ {
+ }
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ }
+
+ protected function createCaption(): BaseHtmlElement
+ {
+ $caption = new HtmlElement('section', Attributes::create(['class' => 'caption']));
+
+ $this->assembleCaption($caption);
+
+ return $caption;
+ }
+
+ protected function createHeader(): BaseHtmlElement
+ {
+ $header = new HtmlElement('header');
+
+ $this->assembleHeader($header);
+
+ return $header;
+ }
+
+ protected function createMain(): BaseHtmlElement
+ {
+ $main = new HtmlElement('div', Attributes::create(['class' => 'main']));
+
+ $this->assembleMain($main);
+
+ return $main;
+ }
+
+ protected function createFooter(): ?BaseHtmlElement
+ {
+ $footer = new HtmlElement('footer');
+
+ $this->assembleFooter($footer);
+ if ($footer->isEmpty()) {
+ return null;
+ }
+
+ return $footer;
+ }
+
+ protected function createTimestamp(): ?BaseHtmlElement
+ {
+ return null;
+ }
+
+ protected function createTitle(): BaseHtmlElement
+ {
+ $title = new HtmlElement('div', Attributes::create(['class' => 'title']));
+
+ $this->assembleTitle($title);
+
+ return $title;
+ }
+
+ /**
+ * @return ?BaseHtmlElement
+ */
+ protected function createVisual(): ?BaseHtmlElement
+ {
+ $visual = new HtmlElement('div', Attributes::create(['class' => 'visual']));
+
+ $this->assembleVisual($visual);
+ if ($visual->isEmpty()) {
+ return null;
+ }
+
+ return $visual;
+ }
+
+ /**
+ * Initialize the list item
+ *
+ * If you want to adjust the list item after construction, override this method.
+ */
+ protected function init(): void
+ {
+ }
+
+ protected function assemble(): void
+ {
+ $this->add([
+ $this->createVisual(),
+ $this->createMain()
+ ]);
+ }
+}
diff --git a/vendor/ipl/web/src/Common/BaseOrderedItemList.php b/vendor/ipl/web/src/Common/BaseOrderedItemList.php
new file mode 100644
index 0000000..c141fc5
--- /dev/null
+++ b/vendor/ipl/web/src/Common/BaseOrderedItemList.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace ipl\Web\Common;
+
+use ipl\Web\Widget\EmptyStateBar;
+
+/**
+ * @method BaseOrderedListItem getItemClass()
+ */
+abstract class BaseOrderedItemList extends BaseItemList
+{
+ protected $tag = 'ol';
+
+ protected function assemble(): void
+ {
+ $itemClass = $this->getItemClass();
+
+ $i = 0;
+ foreach ($this->data as $data) {
+ $item = new $itemClass($data, $this);
+ $item->setOrder($i++);
+
+ $this->addHtml($item);
+ }
+
+ if ($this->isEmpty()) {
+ $this->setTag('div');
+ $this->addHtml(new EmptyStateBar(t('No items found.')));
+ }
+ }
+}
diff --git a/vendor/ipl/web/src/Common/BaseOrderedListItem.php b/vendor/ipl/web/src/Common/BaseOrderedListItem.php
new file mode 100644
index 0000000..03b387d
--- /dev/null
+++ b/vendor/ipl/web/src/Common/BaseOrderedListItem.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace ipl\Web\Common;
+
+use LogicException;
+
+abstract class BaseOrderedListItem extends BaseListItem
+{
+ /** @var ?int This element's position */
+ protected $order;
+
+ /**
+ * Set this element's position
+ *
+ * @param int $order
+ *
+ * @return $this
+ */
+ public function setOrder(int $order): self
+ {
+ $this->order = $order;
+
+ return $this;
+ }
+
+ /**
+ * Get this element's position
+ *
+ * @return int
+ * @throws LogicException When calling this method without setting the `order` property
+ */
+ public function getOrder(): int
+ {
+ if ($this->order === null) {
+ throw new LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->order;
+ }
+}
diff --git a/vendor/ipl/web/src/Common/BaseTableRowItem.php b/vendor/ipl/web/src/Common/BaseTableRowItem.php
new file mode 100644
index 0000000..bc61c8e
--- /dev/null
+++ b/vendor/ipl/web/src/Common/BaseTableRowItem.php
@@ -0,0 +1,119 @@
+<?php
+
+namespace ipl\Web\Common;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+
+abstract class BaseTableRowItem extends BaseHtmlElement
+{
+ /** @var array<string, mixed> */
+ protected $baseAttributes = ['class' => 'table-row'];
+
+ /** @var object The associated list item */
+ protected $item;
+
+ /** @var ?BaseItemTable The list where the item is part of */
+ protected $table;
+
+ protected $tag = 'li';
+
+ /**
+ * Create a new table row item
+ *
+ * @param object $item
+ * @param BaseItemTable|null $table
+ */
+ public function __construct($item, BaseItemTable $table = null)
+ {
+ $this->item = $item;
+ $this->table = $table;
+
+ if ($table === null) {
+ $this->setTag('div');
+ }
+
+ $this->addAttributes($this->baseAttributes);
+
+ $this->init();
+ }
+
+ abstract protected function assembleTitle(BaseHtmlElement $title): void;
+
+ protected function assembleColumns(HtmlDocument $columns): void
+ {
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ }
+
+ /**
+ * Create column
+ *
+ * @param mixed $content
+ *
+ * @return BaseHtmlElement
+ */
+ protected function createColumn($content = null): BaseHtmlElement
+ {
+ return new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'col']),
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'content']),
+ ...Html::wantHtmlList($content)
+ )
+ );
+ }
+
+ protected function createColumns(): HtmlDocument
+ {
+ $columns = new HtmlDocument();
+
+ $this->assembleColumns($columns);
+
+ return $columns;
+ }
+
+ protected function createTitle(): BaseHtmlElement
+ {
+ $title = $this->createColumn()->addAttributes(['class' => 'title']);
+
+ $this->assembleTitle($title->getFirst('div'));
+
+ $title->prepend($this->createVisual());
+
+ return $title;
+ }
+
+ protected function createVisual(): ?BaseHtmlElement
+ {
+ $visual = new HtmlElement('div', Attributes::create(['class' => 'visual']));
+
+ $this->assembleVisual($visual);
+
+ return $visual->isEmpty() ? null : $visual;
+ }
+
+ /**
+ * Initialize the list item
+ *
+ * If you want to adjust the list item after construction, override this method.
+ */
+ protected function init(): void
+ {
+ }
+
+ protected function assemble(): void
+ {
+ $this->addHtml(
+ $this->createTitle(),
+ $this->createColumns()
+ );
+ }
+}
diff --git a/vendor/ipl/web/src/Common/BaseTarget.php b/vendor/ipl/web/src/Common/BaseTarget.php
new file mode 100644
index 0000000..080f6c6
--- /dev/null
+++ b/vendor/ipl/web/src/Common/BaseTarget.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace ipl\Web\Common;
+
+/**
+ * @method \ipl\Html\Attributes getAttributes()
+ */
+trait BaseTarget
+{
+ /**
+ * Get the data-base-target attribute
+ *
+ * @return string|null
+ */
+ public function getBaseTarget(): ?string
+ {
+ /** @var ?string $baseTarget */
+ $baseTarget = $this->getAttributes()->get('data-base-target')->getValue();
+
+ return $baseTarget;
+ }
+
+ /**
+ * Set the data-base-target attribute
+ *
+ * @param string $target
+ *
+ * @return $this
+ */
+ public function setBaseTarget(string $target): self
+ {
+ $this->getAttributes()->set('data-base-target', $target);
+
+ return $this;
+ }
+}
diff --git a/vendor/ipl/web/src/Common/Card.php b/vendor/ipl/web/src/Common/Card.php
new file mode 100644
index 0000000..434132c
--- /dev/null
+++ b/vendor/ipl/web/src/Common/Card.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace ipl\Web\Common;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+abstract class Card extends BaseHtmlElement
+{
+ protected $tag = 'section';
+
+ abstract protected function assembleBody(BaseHtmlElement $body);
+
+ abstract protected function assembleHeader(BaseHtmlElement $header);
+
+ protected function assembleFooter(BaseHtmlElement $footer)
+ {
+ }
+
+ protected function createBody()
+ {
+ $body = Html::tag('div', ['class' => 'card-body']);
+
+ $this->assembleBody($body);
+
+ return $body;
+ }
+
+ protected function createFooter()
+ {
+ $footer = Html::tag('div', ['class' => 'card-footer']);
+
+ $this->assembleFooter($footer);
+
+ if (! $footer->isEmpty()) {
+ return $footer;
+ }
+ }
+
+ protected function createHeader()
+ {
+ $header = Html::tag('div', ['class' => 'card-header']);
+
+ $this->assembleHeader($header);
+
+ return $header;
+ }
+
+ protected function assemble()
+ {
+ $this->addAttributes(['class' => 'card']);
+
+ $this->add([
+ $this->createHeader(),
+ $this->createBody(),
+ $this->createFooter()
+ ]);
+ }
+}
diff --git a/vendor/ipl/web/src/Common/CsrfCounterMeasure.php b/vendor/ipl/web/src/Common/CsrfCounterMeasure.php
new file mode 100644
index 0000000..348c4ee
--- /dev/null
+++ b/vendor/ipl/web/src/Common/CsrfCounterMeasure.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace ipl\Web\Common;
+
+use ipl\Html\Contract\FormElement;
+use ipl\Html\Form;
+
+trait CsrfCounterMeasure
+{
+ /**
+ * Create a form element to counter measure CSRF attacks
+ *
+ * @param string $uniqueId A unique ID that persists through different requests
+ *
+ * @return FormElement
+ */
+ protected function createCsrfCounterMeasure($uniqueId)
+ {
+ $hashAlgo = in_array('sha3-256', hash_algos(), true) ? 'sha3-256' : 'sha256';
+
+ $seed = random_bytes(16);
+ $token = base64_encode($seed) . '|' . hash($hashAlgo, $uniqueId . $seed);
+
+ /** @var Form $this */
+ return $this->createElement(
+ 'hidden',
+ 'CSRFToken',
+ [
+ 'ignore' => true,
+ 'required' => true,
+ 'value' => $token,
+ 'validators' => ['Callback' => function ($token) use ($uniqueId, $hashAlgo) {
+ if (strpos($token, '|') === false) {
+ die('Invalid CSRF token provided');
+ }
+
+ list($seed, $hash) = explode('|', $token);
+
+ if ($hash !== hash($hashAlgo, $uniqueId . base64_decode($seed))) {
+ die('Invalid CSRF token provided');
+ }
+
+ return true;
+ }]
+ ]
+ );
+ }
+}
diff --git a/vendor/ipl/web/src/Common/FormUid.php b/vendor/ipl/web/src/Common/FormUid.php
new file mode 100644
index 0000000..05aac7b
--- /dev/null
+++ b/vendor/ipl/web/src/Common/FormUid.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace ipl\Web\Common;
+
+use ipl\Html\Form;
+use ipl\Html\Contract\FormElement;
+use LogicException;
+
+trait FormUid
+{
+ protected $uidElementName = 'uid';
+
+ /**
+ * Create a form element to make this form distinguishable from others
+ *
+ * You'll have to define a name for the form for this to work.
+ *
+ * @return FormElement
+ */
+ protected function createUidElement()
+ {
+ /** @var Form $this */
+ $element = $this->createElement('hidden', $this->uidElementName, ['ignore' => true]);
+ $element->getAttributes()->registerAttributeCallback('value', function () {
+ /** @var Form $this */
+ return $this->getAttributes()->get('name')->getValue();
+ });
+
+ return $element;
+ }
+
+ /**
+ * Get whether the form has been sent
+ *
+ * A form is considered sent if the request's method equals the form's method
+ * and the sent UID is the form's UID.
+ *
+ * @return bool
+ */
+ public function hasBeenSent()
+ {
+ if (! parent::hasBeenSent()) {
+ return false;
+ } elseif ($this->getMethod() === 'GET') {
+ // Get forms are unlikely to require a UID. If they do, change this.
+ return true;
+ }
+
+ /** @var Form $this */
+ $name = $this->getAttributes()->get('name')->getValue();
+ if (! $name) {
+ throw new LogicException('Form has no name');
+ }
+
+ $values = $this->getRequest()->getParsedBody();
+
+ return isset($values[$this->uidElementName]) && $values[$this->uidElementName] === $name;
+ }
+}
diff --git a/vendor/ipl/web/src/Common/RedirectOption.php b/vendor/ipl/web/src/Common/RedirectOption.php
new file mode 100644
index 0000000..0d73ef8
--- /dev/null
+++ b/vendor/ipl/web/src/Common/RedirectOption.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace ipl\Web\Common;
+
+use ipl\Html\Contract\FormElement;
+use ipl\Html\Form;
+use LogicException;
+
+trait RedirectOption
+{
+ /**
+ * Create a form element to retrieve the redirect target upon form submit
+ *
+ * @return FormElement
+ */
+ protected function createRedirectOption()
+ {
+ /** @var Form $this */
+ return $this->createElement('hidden', 'redirect');
+ }
+
+ /**
+ * @see Form::getRedirectUrl()
+ */
+ public function getRedirectUrl()
+ {
+ /** @var Form $this */
+ $redirectOption = $this->getValue('redirect');
+ if (! $redirectOption) {
+ return parent::getRedirectUrl();
+ }
+
+ if (! $this->hasElement('CSRFToken') || ! $this->getElement('CSRFToken')->isValid()) {
+ throw new LogicException(
+ 'It is not safe to accept redirect targets from submit values without CSRF protection'
+ );
+ }
+
+ return $redirectOption;
+ }
+}
diff --git a/vendor/ipl/web/src/Common/StateBadges.php b/vendor/ipl/web/src/Common/StateBadges.php
new file mode 100644
index 0000000..e6e9cfd
--- /dev/null
+++ b/vendor/ipl/web/src/Common/StateBadges.php
@@ -0,0 +1,194 @@
+<?php
+
+namespace ipl\Web\Common;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Stdlib\BaseFilter;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBadge;
+
+/**
+ * @deprecated Use {@see \Icinga\Module\Icingadb\Common\StateBadges} instead.
+ */
+abstract class StateBadges extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ /** @var object $item */
+ protected $item;
+
+ /** @var string */
+ protected $type;
+
+ /** @var string Prefix */
+ protected $prefix;
+
+ /** @var Url Badge link */
+ protected $url;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'state-badges'];
+
+ /**
+ * Create a new widget for state badges
+ *
+ * @param object $item
+ */
+ public function __construct($item)
+ {
+ $this->item = $item;
+ $this->type = $this->getType();
+ $this->prefix = $this->getPrefix();
+ $this->url = $this->getBaseUrl();
+ }
+
+ /**
+ * Get the badge base URL
+ *
+ * @return Url
+ */
+ abstract protected function getBaseUrl(): Url;
+
+ /**
+ * Get the type of the items
+ *
+ * @return string
+ */
+ abstract protected function getType(): string;
+
+ /**
+ * Get the prefix for accessing state information
+ *
+ * @return string
+ */
+ abstract protected function getPrefix(): string;
+
+ /**
+ * Get the integer of the given state text
+ *
+ * @param string $state
+ *
+ * @return int
+ */
+ abstract protected function getStateInt(string $state): int;
+
+ /**
+ * Get the badge URL
+ *
+ * @return Url
+ */
+ public function getUrl(): Url
+ {
+ return $this->url;
+ }
+
+ /**
+ * Set the badge URL
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setUrl(Url $url): self
+ {
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * Create a badge link
+ *
+ * @param mixed $content
+ * @param ?array $filter
+ *
+ * @return Link
+ */
+ public function createLink($content, array $filter = null): Link
+ {
+ $url = clone $this->getUrl();
+
+ $urlFilter = Filter::all();
+ if (! empty($filter)) {
+ foreach ($filter as $column => $value) {
+ $urlFilter->add(Filter::equal($column, $value));
+ }
+ }
+
+ if ($this->hasBaseFilter()) {
+ $urlFilter->add($this->getBaseFilter());
+ }
+
+ if (! $urlFilter->isEmpty()) {
+ $url->setFilter($urlFilter);
+ }
+
+ return new Link($content, $url);
+ }
+
+ /**
+ * Create a state bade
+ *
+ * @param string $state
+ *
+ * @return ?BaseHtmlElement
+ */
+ protected function createBadge(string $state)
+ {
+ $key = $this->prefix . "_{$state}";
+
+ if (isset($this->item->$key) && $this->item->$key) {
+ return Html::tag('li', $this->createLink(
+ new StateBadge($this->item->$key, $state),
+ [$this->type . '.state.soft_state' => $this->getStateInt($state)]
+ ));
+ }
+
+ return null;
+ }
+
+ /**
+ * Create a state group
+ *
+ * @param string $state
+ *
+ * @return ?BaseHtmlElement
+ */
+ protected function createGroup(string $state)
+ {
+ $content = [];
+ $handledKey = $this->prefix . "_{$state}_handled";
+ $unhandledKey = $this->prefix . "_{$state}_unhandled";
+
+ if (isset($this->item->$unhandledKey) && $this->item->$unhandledKey) {
+ $content[] = Html::tag('li', $this->createLink(
+ new StateBadge($this->item->$unhandledKey, $state),
+ [
+ $this->type . '.state.soft_state' => $this->getStateInt($state),
+ $this->type . '.state.is_handled' => 'n'
+ ]
+ ));
+ }
+
+ if (isset($this->item->$handledKey) && $this->item->$handledKey) {
+ $content[] = Html::tag('li', $this->createLink(
+ new StateBadge($this->item->$handledKey, $state, true),
+ [
+ $this->type . '.state.soft_state' => $this->getStateInt($state),
+ $this->type . '.state.is_handled' => 'y'
+ ]
+ ));
+ }
+
+ if (empty($content)) {
+ return null;
+ }
+
+ return Html::tag('li', Html::tag('ul', $content));
+ }
+}
diff --git a/vendor/ipl/web/src/Compat/CompatController.php b/vendor/ipl/web/src/Compat/CompatController.php
new file mode 100644
index 0000000..f4c2fb0
--- /dev/null
+++ b/vendor/ipl/web/src/Compat/CompatController.php
@@ -0,0 +1,512 @@
+<?php
+
+namespace ipl\Web\Compat;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Application\Version;
+use InvalidArgumentException;
+use Icinga\Web\Controller;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlString;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Query;
+use ipl\Stdlib\Contract\Paginatable;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\PaginationControl;
+use ipl\Web\Control\SearchBar;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Layout\Content;
+use ipl\Web\Layout\Controls;
+use ipl\Web\Layout\Footer;
+use ipl\Web\Url;
+use ipl\Web\Widget\Tabs;
+use LogicException;
+use Psr\Http\Message\ServerRequestInterface;
+
+class CompatController extends Controller
+{
+ /** @var Content */
+ protected $content;
+
+ /** @var Controls */
+ protected $controls;
+
+ /** @var HtmlDocument */
+ protected $document;
+
+ /** @var Footer */
+ protected $footer;
+
+ /** @var Tabs */
+ protected $tabs;
+
+ /** @var array */
+ protected $parts;
+
+ protected function prepareInit()
+ {
+ parent::prepareInit();
+
+ $this->params->shift('isIframe');
+ $this->params->shift('showFullscreen');
+ $this->params->shift('showCompact');
+ $this->params->shift('renderLayout');
+ $this->params->shift('_disableLayout');
+ $this->params->shift('_dev');
+ if ($this->params->get('view') === 'compact') {
+ $this->params->remove('view');
+ }
+
+ $this->document = new HtmlDocument();
+ $this->document->setSeparator("\n");
+ $this->controls = new Controls();
+ $this->controls->setAttribute('id', $this->getRequest()->protectId('controls'));
+ $this->content = new Content();
+ $this->content->setAttribute('id', $this->getRequest()->protectId('content'));
+ $this->footer = new Footer();
+ $this->footer->setAttribute('id', $this->getRequest()->protectId('footer'));
+ $this->tabs = new Tabs();
+ $this->tabs->setAttribute('id', $this->getRequest()->protectId('tabs'));
+ $this->parts = [];
+
+ $this->view->tabs = $this->tabs;
+ $this->controls->setTabs($this->tabs);
+
+ ViewRenderer::inject();
+
+ $this->view->document = $this->document;
+ }
+
+ /**
+ * Get the current server request
+ *
+ * @return ServerRequestInterface
+ */
+ public function getServerRequest()
+ {
+ return ServerRequest::fromGlobals();
+ }
+
+ /**
+ * Get the document
+ *
+ * @return HtmlDocument
+ */
+ public function getDocument()
+ {
+ return $this->document;
+ }
+
+ /**
+ * Get the tabs
+ *
+ * @return Tabs
+ */
+ public function getTabs()
+ {
+ return $this->tabs;
+ }
+
+ /**
+ * Add content
+ *
+ * @param ValidHtml $content
+ *
+ * @return $this
+ */
+ protected function addContent(ValidHtml $content)
+ {
+ $this->content->add($content);
+
+ return $this;
+ }
+
+ /**
+ * Add a control
+ *
+ * @param ValidHtml $control
+ *
+ * @return $this
+ */
+ protected function addControl(ValidHtml $control)
+ {
+ $this->controls->add($control);
+
+ if (
+ $control instanceof PaginationControl
+ || $control instanceof LimitControl
+ || $control instanceof SortControl
+ || $control instanceof SearchBar
+ ) {
+ $this->controls->getAttributes()
+ ->get('class')
+ ->removeValue('default-layout')
+ ->addValue('default-layout');
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add footer
+ *
+ * @param ValidHtml $footer
+ *
+ * @return $this
+ */
+ protected function addFooter(ValidHtml $footer)
+ {
+ $this->footer->add($footer);
+
+ return $this;
+ }
+
+ /**
+ * Add a part to be served as multipart-content
+ *
+ * If an id is passed the element is used as-is as the part's content.
+ * Otherwise (no id given) the element's content is used instead.
+ *
+ * @param ValidHtml $element
+ * @param string $id If not given, this is taken from $element
+ *
+ * @throws InvalidArgumentException If no id is given and the element also does not have one
+ *
+ * @return $this
+ */
+ protected function addPart(ValidHtml $element, $id = null)
+ {
+ $part = new Multipart();
+
+ if ($id === null) {
+ if (! $element instanceof BaseHtmlElement) {
+ throw new InvalidArgumentException('If no id is given, $element must be a BaseHtmlElement');
+ }
+
+ $id = $element->getAttributes()->get('id')->getValue();
+ if (! $id) {
+ throw new InvalidArgumentException('Element has no id');
+ }
+
+ $part->addFrom($element);
+ } else {
+ $part->add($element);
+ }
+
+ $this->parts[] = $part->setFor($id);
+
+ return $this;
+ }
+
+ /**
+ * Set the given title as the window's title
+ *
+ * @param string $title
+ * @param mixed ...$args
+ *
+ * @return $this
+ */
+ protected function setTitle($title, ...$args)
+ {
+ if (! empty($args)) {
+ $title = vsprintf($title, $args);
+ }
+
+ $this->view->title = $title;
+
+ return $this;
+ }
+
+ /**
+ * Add an active tab with the given title and set it as the window's title too
+ *
+ * @param string $title
+ * @param mixed ...$args
+ *
+ * @return $this
+ */
+ protected function addTitleTab($title, ...$args)
+ {
+ $this->setTitle($title, ...$args);
+
+ $tabName = uniqid();
+ $this->getTabs()->add($tabName, [
+ 'label' => $this->view->title,
+ 'url' => $this->getRequest()->getUrl()
+ ])->activate($tabName);
+
+ return $this;
+ }
+
+ /**
+ * Create and return the LimitControl
+ *
+ * This automatically shifts the limit URL parameter from {@link $params}.
+ *
+ * @return LimitControl
+ */
+ public function createLimitControl(): LimitControl
+ {
+ $limitControl = new LimitControl(Url::fromRequest());
+ $limitControl->setDefaultLimit($this->getPageSize(null));
+
+ $this->params->shift($limitControl->getLimitParam());
+
+ return $limitControl;
+ }
+
+ /**
+ * Create and return the PaginationControl
+ *
+ * This automatically shifts the pagination URL parameters from {@link $params}.
+ *
+ * @param Paginatable $paginatable
+ *
+ * @return PaginationControl
+ */
+ public function createPaginationControl(Paginatable $paginatable): PaginationControl
+ {
+ $paginationControl = new PaginationControl($paginatable, Url::fromRequest());
+ $paginationControl->setDefaultPageSize($this->getPageSize(null));
+ $paginationControl->setAttribute('id', $this->getRequest()->protectId('pagination-control'));
+
+ $this->params->shift($paginationControl->getPageParam());
+ $this->params->shift($paginationControl->getPageSizeParam());
+
+ return $paginationControl->apply();
+ }
+
+ /**
+ * Create and return the SortControl
+ *
+ * This automatically shifts the sort URL parameter from {@link $params}.
+ *
+ * @param Query $query
+ * @param array $columns Possible sort columns as sort string-label pairs
+ * @param ?array|string $defaultSort Optional default sort column
+ *
+ * @return SortControl
+ */
+ public function createSortControl(Query $query, array $columns): SortControl
+ {
+ $sortControl = SortControl::create($columns);
+
+ $this->params->shift($sortControl->getSortParam());
+
+ $sortControl->handleRequest($this->getServerRequest());
+
+ $defaultSort = null;
+
+ if (func_num_args() === 3) {
+ $defaultSort = func_get_args()[2];
+ }
+
+ return $sortControl->apply($query, $defaultSort);
+ }
+
+ /**
+ * Send a multipart update instead of a standard response
+ *
+ * As part of a multipart update, the tabs, content and footer as well as selected controls are
+ * transmitted in a way the client can render them exclusively instead of a full column reload.
+ *
+ * By default the only control included in the response is the pagination control, if added.
+ *
+ * @param BaseHtmlElement ...$additionalControls Additional controls to include
+ *
+ * @throws LogicException In case an additional control has not been added
+ */
+ public function sendMultipartUpdate(BaseHtmlElement ...$additionalControls)
+ {
+ $searchBar = null;
+ $pagination = null;
+ $redirectUrl = null;
+ foreach ($this->controls->getContent() as $control) {
+ if ($control instanceof PaginationControl) {
+ $pagination = $control;
+ } elseif ($control instanceof SearchBar) {
+ $searchBar = $control;
+ $redirectUrl = $control->getRedirectUrl(); /** @var Url $redirectUrl */
+ }
+ }
+
+ if ($searchBar !== null && ($changes = $searchBar->getChanges()) !== null) {
+ $this->addPart(HtmlString::create(json_encode($changes)), 'Behavior:InputEnrichment');
+ }
+
+ foreach ($additionalControls as $control) {
+ $this->addPart($control);
+ }
+
+ if ($searchBar !== null && $this->content->isEmpty() && ! $searchBar->isValid()) {
+ // No content and an invalid search bar? That's it then, further updates are not required
+ return;
+ }
+
+ if ($this->tabs->count() > 0) {
+ if ($redirectUrl !== null) {
+ $this->tabs->setRefreshUrl($redirectUrl);
+ $this->tabs->getActiveTab()->setUrl($redirectUrl);
+
+ // As long as we still depend on the legacy tab implementation
+ // there is no other way to influence what the tab extensions
+ // use as url. (https://github.com/Icinga/icingadb-web/issues/373)
+ $oldPathInfo = $this->getRequest()->getPathInfo();
+ $oldQuery = $_SERVER['QUERY_STRING'];
+ $this->getRequest()->setPathInfo('/' . $redirectUrl->getPath());
+ $_SERVER['QUERY_STRING'] = $redirectUrl->getParams()->toString();
+ $this->tabs->ensureAssembled();
+ $this->getRequest()->setPathInfo($oldPathInfo);
+ $_SERVER['QUERY_STRING'] = $oldQuery;
+ }
+
+ $this->addPart($this->tabs);
+ }
+
+ if ($pagination !== null) {
+ if ($redirectUrl !== null) {
+ $pagination->setUrl(clone $redirectUrl);
+ }
+
+ $this->addPart($pagination);
+ }
+
+ if (! $this->content->isEmpty()) {
+ $this->addPart($this->content);
+ }
+
+ if (! $this->footer->isEmpty()) {
+ $this->addPart($this->footer);
+ }
+
+ if ($redirectUrl !== null) {
+ $this->getResponse()->setHeader('X-Icinga-Location-Query', $redirectUrl->getQueryString());
+ }
+ }
+
+ /**
+ * Instruct the client to side-load additional updates
+ *
+ * If an item in the given array is indexed by an integer, its value will be used by the client to refresh
+ * the parent of the element identified by it. The value is expected to be a valid CSS selector such
+ * as `.foo`, `#foo`. If indexed by a string, the client will use this index to identify a container (by id) and
+ * will use the value (a URL) to load content into it. Since Icinga Web >= 2.12, the indices can be specified with
+ * or without the `#` indicator. If you require compatibility with older Icinga Web versions, you have to specify
+ * the indices (container ids) without the `#` char.
+ *
+ * @param array $updates
+ *
+ * @return void
+ */
+ public function sendExtraUpdates(array $updates)
+ {
+ if (empty($updates)) {
+ return;
+ }
+
+ $extraUpdates = [];
+ foreach ($updates as $key => $value) {
+ if (is_int($key)) {
+ $extraUpdates[] = $value;
+ } else {
+ $extraUpdates[] = sprintf(
+ '%s;%s',
+ $key,
+ $value instanceof Url ? $value->getAbsoluteUrl() : $value
+ );
+ }
+ }
+
+ $this->getResponse()->setHeader('X-Icinga-Extra-Updates', join(',', $extraUpdates));
+ }
+
+ /**
+ * Close the modal content and refresh the related view
+ *
+ * NOTE: If you use this with older Icinga Web versions (< 2.12), you will need to specify a valid redirect url,
+ * that will produce the same result as using the `__REFRESH__` redirect with the latest Icinga Web version.
+ *
+ * This is supposed to be used in combination with a modal view and closes only the modal,
+ * and refreshes the modal opener (regardless of whether it is col1 or col2).
+ *
+ * @param Url|string $url
+ * @param bool $refreshCol1 Whether to refresh col1 after the redirect. Is just for compatibility reasons and
+ * won't be used with latest Icinga Web versions.
+ *
+ * @return never
+ */
+ public function closeModalAndRefreshRelatedView($url, bool $refreshCol1 = false)
+ {
+ if (version_compare(Version::VERSION, '2.12.0', '<')) {
+ if (! $url) {
+ throw new InvalidArgumentException('No redirect url provided');
+ }
+
+ if ($refreshCol1) {
+ $this->sendExtraUpdates(['#col1']);
+ }
+
+ $this->redirectNow($url);
+ } else {
+ $this->redirectNow('__REFRESH__');
+ }
+ }
+
+ /**
+ * Close the modal content and refresh all the remaining views
+ *
+ * NOTE: If you use this with older Icinga Web versions (< 2.12), you will need to specify a valid redirect url,
+ * that will produce the same result as using the `__REFRESH__` redirect with the latest Icinga Web version.
+ *
+ * This is supposed to be used in combination with a modal view and closes only the modal content. It refreshes
+ * the modal opener (expects to be always col2) and forces a refresh of col1.
+ *
+ * @param Url|string $url
+ *
+ * @return never
+ */
+ public function closeModalAndRefreshRemainingViews($url)
+ {
+ $this->sendExtraUpdates(['#col1']);
+
+ $this->closeModalAndRefreshRelatedView($url);
+ }
+
+ /**
+ * Redirect using `__CLOSE__`
+ *
+ * Change to a single column layout and refresh col1
+ *
+ * @return never
+ */
+ public function switchToSingleColumnLayout()
+ {
+ $this->redirectNow('__CLOSE__');
+ }
+
+ public function postDispatch()
+ {
+ if (empty($this->parts)) {
+ if (! $this->content->isEmpty()) {
+ $this->document->prepend($this->content);
+
+ if (! $this->view->compact && ! $this->controls->isEmpty()) {
+ $this->document->prepend($this->controls);
+ }
+
+ if (! $this->footer->isEmpty()) {
+ $this->document->add($this->footer);
+ }
+ }
+ } else {
+ $partSeparator = base64_encode(random_bytes(16));
+ $this->getResponse()->setHeader('X-Icinga-Multipart-Content', $partSeparator);
+
+ $this->document->setSeparator("\n$partSeparator\n");
+ $this->document->add($this->parts);
+ }
+
+ parent::postDispatch();
+ }
+}
diff --git a/vendor/ipl/web/src/Compat/CompatDecorator.php b/vendor/ipl/web/src/Compat/CompatDecorator.php
new file mode 100644
index 0000000..856b758
--- /dev/null
+++ b/vendor/ipl/web/src/Compat/CompatDecorator.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace ipl\Web\Compat;
+
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+
+/**
+ * Compat form element decorator based on div elements
+ *
+ * @deprecated Use {@see \ipl\Web\FormDecorator\IcingaFormDecorator} instead
+ */
+class CompatDecorator extends IcingaFormDecorator
+{
+}
diff --git a/vendor/ipl/web/src/Compat/CompatForm.php b/vendor/ipl/web/src/Compat/CompatForm.php
new file mode 100644
index 0000000..97ad10c
--- /dev/null
+++ b/vendor/ipl/web/src/Compat/CompatForm.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace ipl\Web\Compat;
+
+use http\Exception\InvalidArgumentException;
+use ipl\Html\Contract\FormSubmitElement;
+use ipl\Html\Form;
+use ipl\Html\FormElement\SubmitButtonElement;
+use ipl\Html\FormElement\SubmitElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlString;
+use ipl\I18n\Translation;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+
+class CompatForm extends Form
+{
+ use Translation;
+
+ protected $defaultAttributes = ['class' => 'icinga-form icinga-controls'];
+
+ /**
+ * Render the content of the element to HTML
+ *
+ * A duplicate of the primary submit button is being prepended if there is more than one present
+ *
+ * @return string
+ */
+ public function renderContent(): string
+ {
+ if (count($this->submitElements) > 1) {
+ return (new HtmlDocument())
+ ->setHtmlContent(
+ $this->duplicateSubmitButton($this->submitButton),
+ new HtmlString(parent::renderContent())
+ )
+ ->render();
+ }
+
+ return parent::renderContent();
+ }
+
+ public function hasDefaultElementDecorator()
+ {
+ if (parent::hasDefaultElementDecorator()) {
+ return true;
+ }
+
+ $this->setDefaultElementDecorator(new IcingaFormDecorator());
+
+ return true;
+ }
+
+ protected function ensureDefaultElementLoaderRegistered()
+ {
+ if (! $this->defaultElementLoaderRegistered) {
+ $this->addPluginLoader(
+ 'element',
+ 'ipl\\Web\\FormElement',
+ 'Element'
+ );
+
+ parent::ensureDefaultElementLoaderRegistered();
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return a duplicate of the given submit button with the `class` attribute fixed to `primary-submit-btn-duplicate`
+ *
+ * @param FormSubmitElement $originalSubmitButton
+ *
+ * @return FormSubmitElement
+ */
+ public function duplicateSubmitButton(FormSubmitElement $originalSubmitButton): FormSubmitElement
+ {
+ $attributes = (clone $originalSubmitButton->getAttributes())
+ ->set('class', 'primary-submit-btn-duplicate');
+ $attributes->remove('id');
+ // Remove to avoid `type="submit submit"` in SubmitButtonElement
+ $attributes->remove('type');
+
+ if ($originalSubmitButton instanceof SubmitElement) {
+ $newSubmitButton = new SubmitElement($originalSubmitButton->getName(), $attributes);
+ $newSubmitButton->setLabel($originalSubmitButton->getButtonLabel());
+
+ return $newSubmitButton;
+ } elseif ($originalSubmitButton instanceof SubmitButtonElement) {
+ $newSubmitButton = new SubmitButtonElement($originalSubmitButton->getName(), $attributes);
+ $newSubmitButton->setSubmitValue($originalSubmitButton->getSubmitValue());
+
+ return $newSubmitButton;
+ }
+
+ throw new InvalidArgumentException(sprintf(
+ 'Cannot duplicate submit button of type "%s"',
+ get_class($originalSubmitButton)
+ ));
+ }
+}
diff --git a/vendor/ipl/web/src/Compat/Multipart.php b/vendor/ipl/web/src/Compat/Multipart.php
new file mode 100644
index 0000000..432f837
--- /dev/null
+++ b/vendor/ipl/web/src/Compat/Multipart.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace ipl\Web\Compat;
+
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlString;
+
+class Multipart extends HtmlDocument
+{
+ /** @var string */
+ protected $for;
+
+ protected $contentSeparator = "\n";
+
+ /**
+ * Set the container's id which this part is for
+ *
+ * @param string $id
+ *
+ * @return $this
+ */
+ public function setFor($id)
+ {
+ $this->for = $id;
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $this->prepend(HtmlString::create(sprintf('for=%s', $this->for)));
+ }
+}
diff --git a/vendor/ipl/web/src/Compat/SearchControls.php b/vendor/ipl/web/src/Compat/SearchControls.php
new file mode 100644
index 0000000..f6e74ab
--- /dev/null
+++ b/vendor/ipl/web/src/Compat/SearchControls.php
@@ -0,0 +1,260 @@
+<?php
+
+namespace ipl\Web\Compat;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use ipl\Html\Html;
+use ipl\Orm\Exception\InvalidRelationException;
+use ipl\Orm\Query;
+use ipl\Stdlib\Seq;
+use ipl\Web\Control\SearchBar;
+use ipl\Web\Control\SearchEditor;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Stdlib\Filter;
+
+trait SearchControls
+{
+ /**
+ * Fetch available filter columns for the given query
+ *
+ * @param Query $query
+ *
+ * @return array<string, string> Keys are column paths, values are labels
+ */
+ public function fetchFilterColumns(Query $query)
+ {
+ $columns = [];
+ foreach ($query->getResolver()->getColumnDefinitions($query->getModel()) as $name => $definition) {
+ $columns[$name] = $definition->getLabel();
+ }
+
+ return $columns;
+ }
+
+ /**
+ * Get whether {@see SearchControls::createSearchBar()} and {@see SearchControls::createSearchEditor()}
+ * should handle form submits.
+ *
+ * @return bool
+ */
+ private function callHandleRequest()
+ {
+ return true;
+ }
+
+ /**
+ * Create and return the SearchBar
+ *
+ * @param Query $query The query being filtered
+ * @param Url $redirectUrl Url to redirect to upon success
+ * @param array $preserveParams Query params to preserve when redirecting
+ *
+ * @return SearchBar
+ */
+ public function createSearchBar(Query $query, ...$params): SearchBar
+ {
+ $requestUrl = Url::fromRequest();
+ $preserveParams = array_pop($params) ?? [];
+ $redirectUrl = array_pop($params);
+
+ if ($redirectUrl !== null) {
+ $redirectUrl->addParams($requestUrl->onlyWith($preserveParams)->getParams()->toArray(false));
+ } else {
+ $redirectUrl = $requestUrl->onlyWith($preserveParams);
+ }
+
+ $filter = QueryString::fromString((string) $this->params)
+ ->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use ($query) {
+ $this->enrichFilterCondition($condition, $query);
+ })
+ ->parse();
+
+ $searchBar = new SearchBar();
+ $searchBar->setFilter($filter);
+ $searchBar->setRedirectUrl($redirectUrl);
+ $searchBar->setAction($redirectUrl->getAbsoluteUrl());
+ $searchBar->setIdProtector([$this->getRequest(), 'protectId']);
+ $searchBar->addWrapper(Html::tag('div', ['class' => 'search-controls']));
+
+ $moduleName = $this->getRequest()->getModuleName();
+ $controllerName = $this->getRequest()->getControllerName();
+
+ if (method_exists($this, 'completeAction')) {
+ $searchBar->setSuggestionUrl(Url::fromPath(
+ "$moduleName/$controllerName/complete",
+ ['_disableLayout' => true, 'showCompact' => true]
+ ));
+ }
+
+ if (method_exists($this, 'searchEditorAction')) {
+ $searchBar->setEditorUrl(Url::fromPath(
+ "$moduleName/$controllerName/search-editor"
+ )->setParams($redirectUrl->getParams()));
+ }
+
+ $filterColumns = $this->fetchFilterColumns($query);
+ $columnValidator = function (SearchBar\ValidatedColumn $column) use ($query, $filterColumns) {
+ $searchPath = $column->getSearchValue();
+ if (strpos($searchPath, '.') === false) {
+ $column->setSearchValue($query->getResolver()->qualifyPath(
+ $searchPath,
+ $query->getModel()->getTableAlias()
+ ));
+ }
+
+ try {
+ $definition = $query->getResolver()->getColumnDefinition($searchPath);
+ } catch (InvalidRelationException $_) {
+ list($columnPath, $columnLabel) = Seq::find($filterColumns, $searchPath, false);
+ if ($columnPath === null) {
+ $column->setMessage(t('Is not a valid column'));
+ $column->setSearchValue($searchPath); // Resets the qualification made above
+ } else {
+ $column->setSearchValue($columnPath);
+ $column->setLabel($columnLabel);
+ }
+ }
+
+ if (isset($definition)) {
+ $column->setLabel($definition->getLabel());
+ }
+ };
+
+ $searchBar->on(SearchBar::ON_ADD, $columnValidator)
+ ->on(SearchBar::ON_INSERT, $columnValidator)
+ ->on(SearchBar::ON_SAVE, $columnValidator)
+ ->on(SearchBar::ON_SENT, function (SearchBar $form) {
+ /** @var Url $redirectUrl */
+ $redirectUrl = $form->getRedirectUrl();
+ $redirectUrl->setFilter($form->getFilter());
+ $form->setRedirectUrl($redirectUrl);
+ })->on(SearchBar::ON_SUCCESS, function (SearchBar $form) {
+ $this->getResponse()->redirectAndExit($form->getRedirectUrl());
+ });
+
+ if ($this->callHandleRequest()) {
+ $searchBar->handleRequest(ServerRequest::fromGlobals());
+ }
+
+ return $searchBar;
+ }
+
+ /**
+ * Create and return the SearchEditor
+ *
+ * @param Query $query The query being filtered
+ * @param Url $redirectUrl Url to redirect to upon success
+ * @param array $preserveParams Query params to preserve when redirecting
+ *
+ * @return SearchEditor
+ */
+ public function createSearchEditor(Query $query, ...$params): SearchEditor
+ {
+ $requestUrl = Url::fromRequest();
+ $preserveParams = array_pop($params) ?? [];
+ $redirectUrl = array_pop($params);
+ $moduleName = $this->getRequest()->getModuleName();
+ $controllerName = $this->getRequest()->getControllerName();
+
+ if ($redirectUrl !== null) {
+ $redirectUrl->addParams($requestUrl->onlyWith($preserveParams)->getParams()->toArray(false));
+ } else {
+ $redirectUrl = Url::fromPath("$moduleName/$controllerName");
+ if (! empty($preserveParams)) {
+ $redirectUrl->setParams($requestUrl->onlyWith($preserveParams)->getParams());
+ }
+ }
+
+ $editor = new SearchEditor();
+ $editor->setRedirectUrl($redirectUrl);
+ $editor->setAction($requestUrl->getAbsoluteUrl());
+ $editor->setQueryString((string) $this->params->without($preserveParams));
+
+ if (method_exists($this, 'completeAction')) {
+ $editor->setSuggestionUrl(Url::fromPath(
+ "$moduleName/$controllerName/complete",
+ ['_disableLayout' => true, 'showCompact' => true]
+ ));
+ }
+
+ $editor->getParser()->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use ($query) {
+ if ($condition->getColumn()) {
+ $this->enrichFilterCondition($condition, $query);
+ }
+ });
+
+ $filterColumns = $this->fetchFilterColumns($query);
+ $editor->on(SearchEditor::ON_VALIDATE_COLUMN, function (
+ Filter\Condition $condition
+ ) use (
+ $query,
+ $filterColumns
+ ) {
+ $searchPath = $condition->getColumn();
+ if (strpos($searchPath, '.') === false) {
+ $condition->setColumn($query->getResolver()->qualifyPath(
+ $searchPath,
+ $query->getModel()->getTableAlias()
+ ));
+ }
+
+ try {
+ $query->getResolver()->getColumnDefinition($searchPath);
+ } catch (InvalidRelationException $_) {
+ $columnPath = Seq::findKey(
+ $filterColumns,
+ $condition->metaData()->get('columnLabel', $searchPath),
+ false
+ );
+ if ($columnPath === null) {
+ $condition->setColumn($searchPath);
+ throw new SearchBar\SearchException(t('Is not a valid column'));
+ } else {
+ $condition->setColumn($columnPath);
+ }
+ }
+ })->on(SearchEditor::ON_SUCCESS, function (SearchEditor $form) {
+ /** @var Url $redirectUrl */
+ $redirectUrl = $form->getRedirectUrl();
+ $redirectUrl->setFilter($form->getFilter());
+
+ $this->getResponse()
+ ->setHeader('X-Icinga-Container', '_self')
+ ->redirectAndExit($redirectUrl);
+ });
+
+ if ($this->callHandleRequest()) {
+ $editor->handleRequest(ServerRequest::fromGlobals());
+ }
+
+ return $editor;
+ }
+
+ /**
+ * Enrich the filter condition with meta data from the query
+ *
+ * @param Filter\Condition $condition
+ * @param Query $query
+ *
+ * @return void
+ */
+ protected function enrichFilterCondition(Filter\Condition $condition, Query $query)
+ {
+ $path = $condition->getColumn();
+ if (strpos($path, '.') === false) {
+ $path = $query->getResolver()->qualifyPath($path, $query->getModel()->getTableAlias());
+ $condition->setColumn($path);
+ }
+
+ try {
+ $label = $query->getResolver()->getColumnDefinition($path)->getLabel();
+ } catch (InvalidRelationException $_) {
+ $label = null;
+ }
+
+ if (isset($label)) {
+ $condition->metaData()->set('columnLabel', $label);
+ }
+ }
+}
diff --git a/vendor/ipl/web/src/Compat/StyleWithNonce.php b/vendor/ipl/web/src/Compat/StyleWithNonce.php
new file mode 100644
index 0000000..f4c7185
--- /dev/null
+++ b/vendor/ipl/web/src/Compat/StyleWithNonce.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace ipl\Web\Compat;
+
+use Icinga\Application\Version;
+use Icinga\Util\Csp;
+use ipl\Web\Style;
+
+/**
+ * Use this class to define inline style which is compatible
+ * with Icinga Web &lt; 2.12 and with CSP support in &gt;= 2.12
+ */
+class StyleWithNonce extends Style
+{
+ public function getNonce(): ?string
+ {
+ if ($this->nonce === null) {
+ $this->nonce = version_compare(Version::VERSION, '2.12.0', '>=')
+ ? Csp::getStyleNonce() ?? ''
+ : '';
+ }
+
+ return parent::getNonce();
+ }
+}
diff --git a/vendor/ipl/web/src/Compat/ViewRenderer.php b/vendor/ipl/web/src/Compat/ViewRenderer.php
new file mode 100644
index 0000000..48ddcc3
--- /dev/null
+++ b/vendor/ipl/web/src/Compat/ViewRenderer.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace ipl\Web\Compat;
+
+use Zend_Controller_Action_Helper_ViewRenderer as Zf1ViewRenderer;
+use Zend_Controller_Action_HelperBroker as Zf1HelperBroker;
+
+class ViewRenderer extends Zf1ViewRenderer
+{
+ /**
+ * Inject the view renderer
+ */
+ public static function inject()
+ {
+ /** @var \Zend_Controller_Action_Helper_ViewRenderer $viewRenderer */
+ $viewRenderer = Zf1HelperBroker::getStaticHelper('ViewRenderer');
+
+ $inject = new static();
+
+ foreach (get_object_vars($viewRenderer) as $property => $value) {
+ if ($property === '_inflector') {
+ continue;
+ }
+
+ $inject->$property = $value;
+ }
+
+ Zf1HelperBroker::removeHelper('ViewRenderer');
+ Zf1HelperBroker::addHelper($inject);
+ }
+
+ public function getName()
+ {
+ return 'ViewRenderer';
+ }
+
+ /**
+ * Render the view w/o using a view script
+ *
+ * {@inheritdoc}
+ */
+ public function render($action = null, $name = null, $noController = null)
+ {
+ $view = $this->view;
+
+ if ($view->document->isEmpty() || $this->getRequest()->getParam('error_handler') !== null) {
+ parent::render($action, $name, $noController);
+
+ return;
+ }
+
+ if ($name === null) {
+ $name = $this->getResponseSegment();
+ }
+
+ $this->getResponse()->appendBody($view->document->render(), $name);
+
+ $this->setNoRender();
+ }
+}
diff --git a/vendor/ipl/web/src/Control/LimitControl.php b/vendor/ipl/web/src/Control/LimitControl.php
new file mode 100644
index 0000000..b390a0a
--- /dev/null
+++ b/vendor/ipl/web/src/Control/LimitControl.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace ipl\Web\Control;
+
+use ipl\Web\Compat\CompatForm;
+use ipl\Web\Url;
+
+/**
+ * Allows to adjust the limit of the number of items to display
+ */
+class LimitControl extends CompatForm
+{
+ /** @var int Default limit */
+ const DEFAULT_LIMIT = 25;
+
+ /** @var string Default limit param */
+ const DEFAULT_LIMIT_PARAM = 'limit';
+
+ /** @var int[] Selectable default limits */
+ public static $limits = [
+ '25' => '25',
+ '50' => '50',
+ '100' => '100',
+ '500' => '500'
+ ];
+
+ /** @var string Name of the URL parameter which stores the limit */
+ protected $limitParam = self::DEFAULT_LIMIT_PARAM;
+
+ /** @var int */
+ protected $defaultLimit;
+
+ /** @var Url */
+ protected $url;
+
+ protected $method = 'GET';
+
+ public function __construct(Url $url)
+ {
+ $this->url = $url;
+ }
+
+ /**
+ * Get the name of the URL parameter which stores the limit
+ *
+ * @return string
+ */
+ public function getLimitParam()
+ {
+ return $this->limitParam;
+ }
+
+ /**
+ * Set the name of the URL parameter which stores the limit
+ *
+ * @param string $limitParam
+ *
+ * @return $this
+ */
+ public function setLimitParam($limitParam)
+ {
+ $this->limitParam = $limitParam;
+
+ return $this;
+ }
+
+ /**
+ * Get the default limit
+ *
+ * @return int
+ */
+ public function getDefaultLimit()
+ {
+ return $this->defaultLimit ?: static::DEFAULT_LIMIT;
+ }
+
+ /**
+ * Set the default limit
+ *
+ * @param int $limit
+ *
+ * @return $this
+ */
+ public function setDefaultLimit($limit)
+ {
+ $this->defaultLimit = $limit;
+
+ return $this;
+ }
+
+ /**
+ * Get the limit
+ *
+ * @return int
+ */
+ public function getLimit()
+ {
+ return $this->url->getParam($this->getLimitParam(), $this->getDefaultLimit());
+ }
+
+ protected function assemble()
+ {
+ $this->addAttributes(['class' => 'limit-control inline']);
+
+ $limits = static::$limits;
+ if ($this->defaultLimit && ! isset($limits[$this->defaultLimit])) {
+ $limits[$this->defaultLimit] = $this->defaultLimit;
+ }
+
+ $limit = $this->getLimit();
+ if (! isset($limits[$limit])) {
+ $limits[$limit] = $limit;
+ }
+
+ $this->addElement('select', $this->getLimitParam(), [
+ 'class' => 'autosubmit',
+ 'label' => '#',
+ 'options' => $limits,
+ 'title' => t('Change item count per page'),
+ 'value' => $limit
+ ]);
+ }
+}
diff --git a/vendor/ipl/web/src/Control/PaginationControl.php b/vendor/ipl/web/src/Control/PaginationControl.php
new file mode 100644
index 0000000..00f5c20
--- /dev/null
+++ b/vendor/ipl/web/src/Control/PaginationControl.php
@@ -0,0 +1,523 @@
+<?php
+
+namespace ipl\Web\Control;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Stdlib\Contract\Paginatable;
+use ipl\Web\Compat\CompatForm;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+
+/**
+ * The pagination control displays a list of links that point to different pages of the current view
+ *
+ * The default HTML markup (tag and attributes) for the paginator look like the following:
+ * <div class="pagination-control" role="navigation">...</div>
+ */
+class PaginationControl extends BaseHtmlElement
+{
+ /** @var int Default maximum number of items which should be shown per page */
+ protected $defaultPageSize;
+
+ /** @var string Name of the URL parameter which stores the current page number */
+ protected $pageParam = 'page';
+
+ /** @var string Name of the URL parameter which holds the page size. If given, overrides {@link $defaultPageSize} */
+ protected $pageSizeParam = 'limit';
+
+ /** @var string */
+ protected $pageSpacer = '…';
+
+ /** @var Paginatable The pagination adapter which handles the underlying data source */
+ protected $paginatable;
+
+ /** @var Url The URL to base off pagination URLs */
+ protected $url;
+
+ /** @var int Cache for the total number of items */
+ private $totalCount;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = [
+ 'class' => 'pagination-control',
+ 'role' => 'navigation'
+ ];
+
+ /**
+ * Create a pagination control
+ *
+ * @param Paginatable $paginatable The paginatable
+ * @param Url $url The URL to base off paging URLs
+ */
+ public function __construct(Paginatable $paginatable, Url $url)
+ {
+ $this->paginatable = $paginatable;
+ $this->url = $url;
+ }
+
+ /**
+ * Set the URL to base off paging URLs
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setUrl(Url $url)
+ {
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * Get the default page size
+ *
+ * @return int
+ */
+ public function getDefaultPageSize()
+ {
+ return $this->defaultPageSize ?: LimitControl::DEFAULT_LIMIT;
+ }
+
+ /**
+ * Set the default page size
+ *
+ * @param int $defaultPageSize
+ *
+ * @return $this
+ */
+ public function setDefaultPageSize($defaultPageSize)
+ {
+ $this->defaultPageSize = $defaultPageSize;
+
+ return $this;
+ }
+
+ /**
+ * Get the name of the URL parameter which stores the current page number
+ *
+ * @return string
+ */
+ public function getPageParam()
+ {
+ return $this->pageParam;
+ }
+
+ /**
+ * Set the name of the URL parameter which stores the current page number
+ *
+ * @param string $pageParam
+ *
+ * @return $this
+ */
+ public function setPageParam($pageParam)
+ {
+ $this->pageParam = $pageParam;
+
+ return $this;
+ }
+
+ /**
+ * Get the name of the URL parameter which stores the page size
+ *
+ * @return string
+ */
+ public function getPageSizeParam()
+ {
+ return $this->pageSizeParam;
+ }
+ /**
+ * Set the name of the URL parameter which stores the page size
+ *
+ * @param string $pageSizeParam
+ *
+ * @return $this
+ */
+ public function setPageSizeParam($pageSizeParam)
+ {
+ $this->pageSizeParam = $pageSizeParam;
+
+ return $this;
+ }
+
+ /**
+ * Get the total number of items
+ *
+ * @return int
+ */
+ public function getTotalCount()
+ {
+ if ($this->totalCount === null) {
+ $this->totalCount = $this->paginatable->count();
+ }
+
+ return $this->totalCount;
+ }
+
+ /**
+ * Get the current page number
+ *
+ * @return int
+ */
+ public function getCurrentPageNumber()
+ {
+ return (int) $this->url->getParam($this->pageParam, 1);
+ }
+
+ /**
+ * Get the configured page size
+ *
+ * @return int
+ */
+ public function getPageSize()
+ {
+ return (int) $this->url->getParam($this->pageSizeParam, $this->getDefaultPageSize());
+ }
+
+ /**
+ * Get the total page count
+ *
+ * @return int
+ */
+ public function getPageCount()
+ {
+ $pageSize = $this->getPageSize();
+
+ if ($pageSize === 0) {
+ return 0;
+ }
+
+ if ($pageSize < 0) {
+ return 1;
+ }
+
+ return (int) ceil($this->getTotalCount() / $pageSize);
+ }
+
+ /**
+ * Get the limit
+ *
+ * Use this method to set the LIMIT part of a query for fetching the current page.
+ *
+ * @return int If the page size is infinite, -1 will be returned
+ */
+ public function getLimit()
+ {
+ $pageSize = $this->getPageSize();
+
+ return $pageSize < 0 ? -1 : $pageSize;
+ }
+
+ /**
+ * Get the offset
+ *
+ * Use this method to set the OFFSET part of a query for fetching the current page.
+ *
+ * @return int
+ */
+ public function getOffset()
+ {
+ $currentPageNumber = $this->getCurrentPageNumber();
+ $pageSize = $this->getPageSize();
+
+ return $currentPageNumber <= 1 ? 0 : ($currentPageNumber - 1) * $pageSize;
+ }
+
+ /**
+ * Apply limit and offset on the paginatable
+ *
+ * @return $this
+ */
+ public function apply()
+ {
+ $this->paginatable->limit($this->getLimit());
+ $this->paginatable->offset($this->getOffset());
+
+ return $this;
+ }
+
+ /**
+ * Create a URL for paging from the given page number
+ *
+ * @param int $pageNumber The page number
+ * @param int $pageSize The number of items per page. If you want to stick to the defaults,
+ * don't set this parameter
+ *
+ * @return Url
+ */
+ public function createUrl($pageNumber, $pageSize = null)
+ {
+ $params = [$this->getPageParam() => $pageNumber];
+
+ if ($pageSize !== null) {
+ $params[$this->getPageSizeParam()] = $pageSize;
+ }
+
+ return $this->url->with($params);
+ }
+
+ /**
+ * Get the first item number of the given page
+ *
+ * @param int $pageNumber
+ *
+ * @return int
+ */
+ protected function getFirstItemNumberOfPage($pageNumber)
+ {
+ return ($pageNumber - 1) * $this->getPageSize() + 1;
+ }
+
+ /**
+ * Get the last item number of the given page
+ *
+ * @param int $pageNumber
+ *
+ * @return int
+ */
+ protected function getLastItemNumberOfPage($pageNumber)
+ {
+ return min($pageNumber * $this->getPageSize(), $this->getTotalCount());
+ }
+
+ /**
+ * Create the label for the given page number
+ *
+ * @param int $pageNumber
+ *
+ * @return string
+ */
+ protected function createLabel($pageNumber)
+ {
+ return sprintf(
+ $this->translate('Show items %u to %u of %u'),
+ $this->getFirstItemNumberOfPage($pageNumber),
+ $this->getLastItemNumberOfPage($pageNumber),
+ $this->getTotalCount()
+ );
+ }
+
+ /**
+ * Create and return the previous page item
+ *
+ * @return BaseHtmlElement
+ */
+ protected function createPreviousPageItem()
+ {
+ $prevIcon = new Icon('angle-left');
+
+ $currentPageNumber = $this->getCurrentPageNumber();
+
+ if ($currentPageNumber > 1) {
+ $prevItem = Html::tag('li', ['class' => 'nav-item']);
+
+ $prevItem->add(Html::tag(
+ 'a',
+ [
+ 'class' => 'previous-page',
+ 'href' => $this->createUrl($currentPageNumber - 1),
+ 'title' => $this->createLabel($currentPageNumber - 1)
+ ],
+ $prevIcon
+ ));
+ } else {
+ $prevItem = Html::tag(
+ 'li',
+ [
+ 'aria-hidden' => true,
+ 'class' => 'nav-item disabled'
+ ]
+ );
+
+ $prevItem->add(Html::tag('span', ['class' => 'previous-page'], $prevIcon));
+ }
+
+ return $prevItem;
+ }
+
+ /**
+ * Create and return the next page item
+ *
+ * @return BaseHtmlElement
+ */
+ protected function createNextPageItem()
+ {
+ $nextIcon = new Icon('angle-right');
+
+ $currentPageNumber = $this->getCurrentPageNumber();
+
+ if ($currentPageNumber < $this->getPageCount()) {
+ $nextItem = Html::tag('li', ['class' => 'nav-item']);
+
+ $nextItem->add(Html::tag(
+ 'a',
+ [
+ 'class' => 'next-page',
+ 'href' => $this->createUrl($currentPageNumber + 1),
+ 'title' => $this->createLabel($currentPageNumber + 1)
+ ],
+ $nextIcon
+ ));
+ } else {
+ $nextItem = Html::tag(
+ 'li',
+ [
+ 'aria-hidden' => true,
+ 'class' => 'nav-item disabled'
+ ]
+ );
+
+ $nextItem->add(Html::tag('span', ['class' => 'next-page'], $nextIcon));
+ }
+
+ return $nextItem;
+ }
+
+ /** @TODO(el): Use ipl-translation when it's ready instead */
+ private function translate($message)
+ {
+ return $message;
+ }
+
+ /**
+ * Create and return the first page item
+ *
+ * @return BaseHtmlElement
+ */
+ protected function createFirstPageItem()
+ {
+ $currentPageNumber = $this->getCurrentPageNumber();
+
+ $url = clone $this->url;
+
+ $firstItem = Html::tag('li', ['class' => 'nav-item']);
+
+ if ($currentPageNumber === 1) {
+ $firstItem->addAttributes(['class' => 'disabled']);
+ $firstItem->add(Html::tag(
+ 'span',
+ ['class' => 'first-page'],
+ $this->getFirstItemNumberOfPage(1)
+ ));
+ } else {
+ $firstItem->add(Html::tag(
+ 'a',
+ [
+ 'class' => 'first-page',
+ 'href' => $url->remove(['page'])->getAbsoluteUrl(),
+ 'title' => $this->createLabel(1)
+ ],
+ $this->getFirstItemNumberOfPage(1)
+ ));
+ }
+
+ return $firstItem;
+ }
+
+ /**
+ * Create and return the last page item
+ *
+ * @return BaseHtmlElement
+ */
+ protected function createLastPageItem()
+ {
+ $currentPageNumber = $this->getCurrentPageNumber();
+ $lastItem = Html::tag('li', ['class' => 'nav-item']);
+
+ if ($currentPageNumber === $this->getPageCount()) {
+ $lastItem->addAttributes(['class' => 'disabled']);
+ $lastItem->add(Html::tag(
+ 'span',
+ ['class' => 'last-page'],
+ $this->getPageCount()
+ ));
+ } else {
+ $lastItem->add(Html::tag(
+ 'a',
+ [
+ 'class' => 'last-page',
+ 'href' => $this->url->setParam('page', $this->getPageCount()),
+ 'title' => $this->createLabel($this->getPageCount())
+ ],
+ $this->getPageCount()
+ ));
+ }
+
+ return $lastItem;
+ }
+
+ /**
+ * Create and return the page selector item
+ *
+ * @return BaseHtmlElement
+ */
+ protected function createPageSelectorItem()
+ {
+ $currentPageNumber = $this->getCurrentPageNumber();
+
+ $form = new CompatForm($this->url);
+ $form->addAttributes(['class' => 'inline']);
+ $form->setMethod('GET');
+
+ $select = Html::tag('select', [
+ 'name' => $this->getPageParam(),
+ 'class' => 'autosubmit',
+ 'title' => t('Go to page …')
+ ]);
+
+ if (isset($currentPageNumber)) {
+ if ($currentPageNumber === 1 || $currentPageNumber === $this->getPageCount()) {
+ $select->add(Html::tag('option', ['disabled' => '', 'selected' => ''], '…'));
+ }
+ }
+
+ foreach (range(2, $this->getPageCount() - 1) as $page) {
+ $option = Html::tag('option', [
+ 'value' => $page
+ ], $page);
+
+ if ($page == $currentPageNumber) {
+ $option->addAttributes(['selected' => '']);
+ }
+
+ $select->add($option);
+ }
+
+ $form->add($select);
+
+ $pageSelectorItem = Html::tag('li', $form);
+
+ return $pageSelectorItem;
+ }
+
+ protected function assemble()
+ {
+ if ($this->getPageCount() < 2) {
+ return;
+ }
+
+ // Accessibility info
+ $this->add(Html::tag(
+ 'h2',
+ [
+ 'class' => 'sr-only',
+ 'tabindex' => '-1'
+ ],
+ $this->translate('Pagination')
+ ));
+
+ $paginator = Html::tag('ul', ['class' => 'tab-nav nav']);
+
+ $paginator->add([
+ $this->createFirstPageItem(),
+ $this->createPreviousPageItem(),
+ $this->createPageSelectorItem(),
+ $this->createNextPageItem(),
+ $this->createLastPageItem()
+ ]);
+
+ $this->add($paginator);
+ }
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar.php b/vendor/ipl/web/src/Control/SearchBar.php
new file mode 100644
index 0000000..ab935ef
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar.php
@@ -0,0 +1,541 @@
+<?php
+
+namespace ipl\Web\Control;
+
+use ipl\Html\Attributes;
+use ipl\Html\Form;
+use ipl\Html\FormElement\HiddenElement;
+use ipl\Html\FormElement\InputElement;
+use ipl\Html\FormElement\SubmitElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Stdlib\Filter;
+use ipl\Validator\CallbackValidator;
+use ipl\Web\Common\FormUid;
+use ipl\Web\Control\SearchBar\Terms;
+use ipl\Web\Control\SearchBar\ValidatedColumn;
+use ipl\Web\Control\SearchBar\ValidatedOperator;
+use ipl\Web\Control\SearchBar\ValidatedValue;
+use ipl\Web\Filter\ParseException;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+
+class SearchBar extends Form
+{
+ use FormUid;
+
+ /** @var string Emitted in case the user added a new condition */
+ const ON_ADD = 'on_add';
+
+ /** @var string Emitted in case the user inserted a new condition */
+ const ON_INSERT = 'on_insert';
+
+ /** @var string Emitted in case the user changed an existing condition */
+ const ON_SAVE = 'on_save';
+
+ /** @var string Emitted in case the user removed a condition */
+ const ON_REMOVE = 'on_remove';
+
+ protected $defaultAttributes = [
+ 'data-enrichment-type' => 'search-bar',
+ 'class' => 'search-bar',
+ 'name' => 'search-bar',
+ 'role' => 'search'
+ ];
+
+ /** @var Url */
+ protected $editorUrl;
+
+ /** @var Filter\Rule */
+ protected $filter;
+
+ /** @var string */
+ protected $searchParameter;
+
+ /** @var Url */
+ protected $suggestionUrl;
+
+ /** @var string */
+ protected $submitLabel;
+
+ /** @var callable */
+ protected $protector;
+
+ /** @var array */
+ protected $changes;
+
+ /**
+ * Set the url from which to load the editor
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setEditorUrl(Url $url)
+ {
+ $this->editorUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * Get the url from which to load the editor
+ *
+ * @return Url
+ */
+ public function getEditorUrl()
+ {
+ return $this->editorUrl;
+ }
+
+ /**
+ * Set the filter to use
+ *
+ * @param Filter\Rule $filter
+ * @return $this
+ */
+ public function setFilter(Filter\Rule $filter)
+ {
+ $this->filter = $filter;
+
+ return $this;
+ }
+
+ /**
+ * Get the filter in use
+ *
+ * @return Filter\Rule
+ */
+ public function getFilter()
+ {
+ return $this->filter;
+ }
+
+ /**
+ * Set the search parameter to use
+ *
+ * @param string $name
+ * @return $this
+ */
+ public function setSearchParameter($name)
+ {
+ $this->searchParameter = $name;
+
+ return $this;
+ }
+
+ /**
+ * Get the search parameter in use
+ *
+ * @return string
+ */
+ public function getSearchParameter()
+ {
+ return $this->searchParameter ?: 'q';
+ }
+
+ /**
+ * Set the suggestion url
+ *
+ * @param Url $url
+ * @return $this
+ */
+ public function setSuggestionUrl(Url $url)
+ {
+ $this->suggestionUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * Get the suggestion url
+ *
+ * @return Url
+ */
+ public function getSuggestionUrl()
+ {
+ return $this->suggestionUrl;
+ }
+
+ /**
+ * Set the submit label
+ *
+ * @param string $label
+ * @return $this
+ */
+ public function setSubmitLabel($label)
+ {
+ $this->submitLabel = $label;
+
+ return $this;
+ }
+
+ /**
+ * Get the submit label
+ *
+ * @return string
+ */
+ public function getSubmitLabel()
+ {
+ return $this->submitLabel;
+ }
+
+ /**
+ * Set callback to protect ids with
+ *
+ * @param callable $protector
+ *
+ * @return $this
+ */
+ public function setIdProtector($protector)
+ {
+ $this->protector = $protector;
+
+ return $this;
+ }
+
+ /**
+ * Get changes to be applied on the client
+ *
+ * @return array
+ */
+ public function getChanges()
+ {
+ return $this->changes;
+ }
+
+ private function protectId($id)
+ {
+ if (is_callable($this->protector)) {
+ return call_user_func($this->protector, $id);
+ }
+
+ return $id;
+ }
+
+ public function populate($values)
+ {
+ if (array_key_exists($this->getSearchParameter(), (array) $values)) {
+ // If a filter is set, it must be reset in case new data arrives. The new data controls the filter,
+ // though if no data is sent, (populate() is only called if the form is sent) then the filter must
+ // be reset explicitly here to not keep the outdated filter.
+ $this->filter = Filter::all();
+ }
+
+ parent::populate($values);
+ }
+
+ public function isValidEvent($event)
+ {
+ switch ($event) {
+ case self::ON_ADD:
+ case self::ON_SAVE:
+ case self::ON_INSERT:
+ case self::ON_REMOVE:
+ return true;
+ default:
+ return parent::isValidEvent($event);
+ }
+ }
+
+ private function validateCondition($eventType, $indices, $termsData, &$changes)
+ {
+ // TODO: In case of the query string validation, all three are guaranteed to be set.
+ // The Parser also provides defaults, why shouldn't we here?
+ $column = ValidatedColumn::fromTermData($termsData[0]);
+ $operator = isset($termsData[1])
+ ? ValidatedOperator::fromTermData($termsData[1])
+ : null;
+ $value = isset($termsData[2])
+ ? ValidatedValue::fromTermData($termsData[2])
+ : null;
+
+ $this->emit($eventType, [$column, $operator, $value]);
+
+ if ($eventType !== self::ON_REMOVE) {
+ if (! $column->isValid() || $column->hasBeenChanged()) {
+ $changes[$indices[0]] = array_merge($termsData[0], $column->toTermData());
+ }
+
+ if ($operator && ! $operator->isValid()) {
+ $changes[$indices[1]] = array_merge($termsData[1], $operator->toTermData());
+ }
+
+ if ($value && (! $value->isValid() || $value->hasBeenChanged())) {
+ $changes[$indices[2]] = array_merge($termsData[2], $value->toTermData());
+ }
+ }
+
+ return $column->isValid() && (! $operator || $operator->isValid()) && (! $value || $value->isValid());
+ }
+
+
+ protected function assemble()
+ {
+ $termContainerId = $this->protectId('terms');
+ $termInputId = $this->protectId('term-input');
+ $dataInputId = $this->protectId('data-input');
+ $searchInputId = $this->protectId('search-input');
+ $suggestionsId = $this->protectId('suggestions');
+
+ $termContainer = (new Terms())->setAttribute('id', $termContainerId);
+ $termInput = new HiddenElement($this->getSearchParameter(), [
+ 'id' => $termInputId,
+ 'disabled' => true
+ ]);
+
+ if (! $this->getRequest()->getHeaderLine('X-Icinga-Autorefresh')) {
+ $termContainer->setFilter(function () {
+ return $this->getFilter();
+ });
+ $termInput->getAttributes()->registerAttributeCallback('value', function () {
+ return QueryString::render($this->getFilter());
+ });
+ }
+
+ $dataInput = new HiddenElement('data', [
+ 'id' => $dataInputId,
+ 'validators' => [
+ new CallbackValidator(function ($data, CallbackValidator $_) use ($termContainer, $searchInputId) {
+ $data = $data ? json_decode($data, true) : null;
+ if (empty($data)) {
+ return true;
+ }
+
+ switch ($data['type']) {
+ case 'add':
+ case 'exchange':
+ $type = self::ON_ADD;
+
+ break;
+ case 'insert':
+ $type = self::ON_INSERT;
+
+ break;
+ case 'save':
+ $type = self::ON_SAVE;
+
+ break;
+ case 'remove':
+ $type = self::ON_REMOVE;
+
+ break;
+ default:
+ return true;
+ }
+
+ $changes = [];
+ $invalid = false;
+ $indices = [null, null, null];
+ $termsData = [null, null, null];
+ foreach (isset($data['terms']) ? $data['terms'] : [] as $termIndex => $termData) {
+ switch ($termData['type']) {
+ case 'column':
+ $indices[0] = $termIndex;
+ $termsData[0] = $termData;
+
+ break;
+ case 'operator':
+ $indices[1] = $termIndex;
+ $termsData[1] = $termData;
+
+ break;
+ case 'value':
+ $indices[2] = $termIndex;
+ $termsData[2] = $termData;
+
+ break;
+ default:
+ if ($termsData[0] !== null) {
+ if (! $this->validateCondition($type, $indices, $termsData, $changes)) {
+ $invalid = true;
+ }
+ }
+
+ $indices = $termsData = [null, null, null];
+ }
+ }
+
+ if ($termsData[0] !== null) {
+ if (! $this->validateCondition($type, $indices, $termsData, $changes)) {
+ $invalid = true;
+ }
+ }
+
+ if (! empty($changes)) {
+ $this->changes = ['#' . $searchInputId, $changes];
+ $termContainer->applyChanges($changes);
+ }
+
+ return ! $invalid;
+ })
+ ]
+ ]);
+ $this->registerElement($dataInput);
+
+ $filterInput = new InputElement($this->getSearchParameter(), [
+ 'type' => 'text',
+ 'placeholder' => 'Type to search. Use * as wildcard.',
+ 'class' => 'filter-input',
+ 'id' => $searchInputId,
+ 'autocomplete' => 'off',
+ 'data-enrichment-type' => 'filter',
+ 'data-data-input' => '#' . $dataInputId,
+ 'data-term-input' => '#' . $termInputId,
+ 'data-term-container' => '#' . $termContainerId,
+ 'data-term-suggestions' => '#' . $suggestionsId,
+ 'data-missing-log-op' => t('Please add a logical operator on the left.'),
+ 'data-incomplete-group' => t('Please close or remove this group.'),
+ 'data-choose-template' => t('Please type one of: %s', '..<comma separated list>'),
+ 'data-choose-column' => t('Please enter a valid column.'),
+ 'validators' => [
+ new CallbackValidator(function ($q, CallbackValidator $validator) use ($searchInputId) {
+ $submitted = $this->hasBeenSubmitted();
+ $invalid = false;
+ $changes = [];
+
+ $parser = QueryString::fromString($q);
+ $parser->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use (
+ &$invalid,
+ &$changes,
+ $submitted
+ ) {
+ $columnIndex = $condition->metaData()->get('columnIndex');
+ if (isset($this->changes[1][$columnIndex])) {
+ $change = $this->changes[1][$columnIndex];
+ $condition->setColumn($change['search']);
+ } elseif (empty($this->changes)) {
+ $column = ValidatedColumn::fromFilterCondition($condition);
+ $operator = ValidatedOperator::fromFilterCondition($condition);
+ $value = ValidatedValue::fromFilterCondition($condition);
+ $this->emit(self::ON_ADD, [$column, $operator, $value]);
+
+ $condition->setColumn($column->getSearchValue());
+ $condition->setValue($value->getSearchValue());
+
+ if (! $column->isValid()) {
+ $invalid = true;
+
+ if ($submitted) {
+ $condition->metaData()->merge($column->toMetaData());
+ } else {
+ $changes[$columnIndex] = $column->toTermData();
+ }
+ }
+
+ if (! $operator->isValid()) {
+ $invalid = true;
+
+ if ($submitted) {
+ $condition->metaData()->merge($operator->toMetaData());
+ } else {
+ $changes[$condition->metaData()->get('operatorIndex')] = $operator->toTermData();
+ }
+ }
+
+ if (! $value->isValid()) {
+ $invalid = true;
+
+ if ($submitted) {
+ $condition->metaData()->merge($value->toMetaData());
+ } else {
+ $changes[$condition->metaData()->get('valueIndex')] = $value->toTermData();
+ }
+ }
+ }
+ });
+
+ try {
+ $filter = $parser->parse();
+ } catch (ParseException $e) {
+ $charAt = $e->getCharPos() - 1;
+ $char = $e->getChar();
+
+ $this->getElement($this->getSearchParameter())
+ ->addAttributes([
+ 'title' => sprintf(t('Unexpected %s at start of input'), $char),
+ 'pattern' => sprintf('^(?!%s).*', $char === ')' ? '\)' : $char),
+ 'data-has-syntax-error' => true
+ ])
+ ->getAttributes()
+ ->registerAttributeCallback('value', function () use ($q, $charAt) {
+ return substr($q, $charAt);
+ });
+
+ $probablyValidQueryString = substr($q, 0, $charAt);
+ $this->setFilter(QueryString::parse($probablyValidQueryString));
+ return false;
+ }
+
+ $this->getElement($this->getSearchParameter())
+ ->getAttributes()
+ ->registerAttributeCallback('value', function () {
+ return '';
+ });
+ $this->setFilter($filter);
+
+ if (! empty($changes)) {
+ $this->changes = ['#' . $searchInputId, $changes];
+ }
+
+ return ! $invalid;
+ })
+ ]
+ ]);
+ if ($this->getSuggestionUrl() !== null) {
+ $filterInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () {
+ return (string) $this->getSuggestionUrl();
+ });
+ }
+
+ $this->registerElement($filterInput);
+
+ $submitButton = new SubmitElement('submit', ['label' => $this->getSubmitLabel() ?: 'hidden']);
+ $this->registerElement($submitButton);
+
+ $editorOpener = null;
+ if ($this->getEditorUrl() !== null) {
+ $editorOpener = new HtmlElement(
+ 'button',
+ Attributes::create([
+ 'type' => 'button',
+ 'class' => 'search-editor-opener control-button',
+ 'title' => t('Adjust Filter')
+ ])->registerAttributeCallback('data-search-editor-url', function () {
+ return (string) $this->getEditorUrl();
+ }),
+ new Icon('cog')
+ );
+ }
+
+ $this->addHtml(
+ new HtmlElement(
+ 'button',
+ Attributes::create(['type' => 'button', 'class' => 'search-options']),
+ new Icon('search')
+ ),
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'filter-input-area']),
+ $termContainer,
+ new HtmlElement('label', Attributes::create(['data-label' => '']), $filterInput)
+ ),
+ $dataInput,
+ $termInput,
+ $submitButton,
+ $this->createUidElement(),
+ new HtmlElement('div', Attributes::create([
+ 'id' => $suggestionsId,
+ 'class' => 'search-suggestions',
+ 'data-base-target' => $suggestionsId
+ ]))
+ );
+
+ // Render the editor container outside of this form. It will contain a form as well later on
+ // loaded by XHR and HTML prohibits nested forms. It's style-wise also better...
+ $doc = new HtmlDocument();
+ $this->prependWrapper($doc);
+ $doc->addHtml($this, ...($editorOpener ? [$editorOpener] : []));
+ }
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar/SearchException.php b/vendor/ipl/web/src/Control/SearchBar/SearchException.php
new file mode 100644
index 0000000..a89c6ce
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar/SearchException.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace ipl\Web\Control\SearchBar;
+
+use Exception;
+
+class SearchException extends Exception
+{
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar/Suggestions.php b/vendor/ipl/web/src/Control/SearchBar/Suggestions.php
new file mode 100644
index 0000000..fe4a2db
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar/Suggestions.php
@@ -0,0 +1,451 @@
+<?php
+
+namespace ipl\Web\Control\SearchBar;
+
+use Countable;
+use Generator;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\FormattedString;
+use ipl\Html\FormElement\ButtonElement;
+use ipl\Html\FormElement\InputElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Contract\Paginatable;
+use ipl\Stdlib\Filter;
+use ipl\Web\Control\SearchEditor;
+use ipl\Web\Filter\QueryString;
+use IteratorIterator;
+use LimitIterator;
+use OuterIterator;
+use Psr\Http\Message\ServerRequestInterface;
+use Traversable;
+
+use function ipl\I18n\t;
+
+abstract class Suggestions extends BaseHtmlElement
+{
+ const DEFAULT_LIMIT = 50;
+ const SUGGESTION_TITLE_CLASS = 'suggestion-title';
+
+ protected $tag = 'ul';
+
+ /** @var string */
+ protected $searchTerm;
+
+ /** @var Traversable */
+ protected $data;
+
+ /** @var array */
+ protected $default;
+
+ /** @var string */
+ protected $type;
+
+ /** @var string */
+ protected $failureMessage;
+
+ public function setSearchTerm($term)
+ {
+ $this->searchTerm = $term;
+
+ return $this;
+ }
+
+ public function setData($data)
+ {
+ $this->data = $data;
+
+ return $this;
+ }
+
+ public function setDefault($default)
+ {
+ $this->default = $default;
+
+ return $this;
+ }
+
+ public function setType($type)
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ public function setFailureMessage($message)
+ {
+ $this->failureMessage = $message;
+
+ return $this;
+ }
+
+ /**
+ * Return whether the relation should be shown for the given column
+ *
+ * @param string $column
+ *
+ * @return bool
+ */
+ protected function shouldShowRelationFor(string $column): bool
+ {
+ return false;
+ }
+
+ /**
+ * Create a filter to provide as default for column suggestions
+ *
+ * @param string $searchTerm
+ *
+ * @return Filter\Rule
+ */
+ abstract protected function createQuickSearchFilter($searchTerm);
+
+ /**
+ * Fetch value suggestions for a particular column
+ *
+ * @param string $column
+ * @param string $searchTerm
+ * @param Filter\Chain $searchFilter
+ *
+ * @return Traversable
+ */
+ abstract protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $searchFilter);
+
+ /**
+ * Fetch column suggestions
+ *
+ * @param string $searchTerm
+ *
+ * @return Traversable
+ */
+ abstract protected function fetchColumnSuggestions($searchTerm);
+
+ protected function filterToTerms(Filter\Chain $filter)
+ {
+ $logicalSep = [
+ 'label' => QueryString::getRuleSymbol($filter),
+ 'search' => QueryString::getRuleSymbol($filter),
+ 'class' => 'logical_operator',
+ 'type' => 'logical_operator'
+ ];
+
+ $terms = [];
+ foreach ($filter as $child) {
+ if ($child instanceof Filter\Chain) {
+ $terms[] = [
+ 'search' => '(',
+ 'label' => '(',
+ 'type' => 'grouping_operator',
+ 'class' => 'grouping_operator_open'
+ ];
+ $terms = array_merge($terms, $this->filterToTerms($child));
+ $terms[] = [
+ 'search' => ')',
+ 'label' => ')',
+ 'type' => 'grouping_operator',
+ 'class' => 'grouping_operator_close'
+ ];
+ } else {
+ /** @var Filter\Condition $child */
+
+ $terms[] = [
+ 'search' => $child->getColumn(),
+ 'label' => $child->metaData()->get('columnLabel') ?? $child->getColumn(),
+ 'type' => 'column'
+ ];
+ $terms[] = [
+ 'search' => QueryString::getRuleSymbol($child),
+ 'label' => QueryString::getRuleSymbol($child),
+ 'type' => 'operator'
+ ];
+ $terms[] = [
+ 'search' => $child->getValue(),
+ 'label' => $child->getValue(),
+ 'type' => 'value'
+ ];
+ }
+
+ $terms[] = $logicalSep;
+ }
+
+ array_pop($terms);
+ return $terms;
+ }
+
+ protected function assembleDefault()
+ {
+ if ($this->default === null) {
+ return;
+ }
+
+ $attributes = [
+ 'type' => 'button',
+ 'tabindex' => -1,
+ 'data-label' => $this->default['search'],
+ 'value' => $this->default['search']
+ ];
+ if (isset($this->default['type'])) {
+ $attributes['data-type'] = $this->default['type'];
+ } elseif ($this->type !== null) {
+ $attributes['data-type'] = $this->type;
+ }
+
+ $button = new ButtonElement(null, $attributes);
+ if (isset($this->default['type']) && $this->default['type'] === 'terms') {
+ $terms = $this->filterToTerms($this->default['terms']);
+ $list = new HtmlElement('ul', Attributes::create(['class' => 'comma-separated']));
+ foreach ($terms as $data) {
+ if ($data['type'] === 'column') {
+ $list->addHtml(new HtmlElement(
+ 'li',
+ null,
+ new HtmlElement('em', null, Text::create($data['label']))
+ ));
+ }
+ }
+
+ $button->setAttribute('data-terms', json_encode($terms));
+ $button->addHtml(FormattedString::create(
+ t('Search for %s in: %s'),
+ new HtmlElement('em', null, Text::create($this->default['search'])),
+ $list
+ ));
+ } else {
+ $button->addHtml(FormattedString::create(
+ t('Search for %s'),
+ new HtmlElement('em', null, Text::create($this->default['search']))
+ ));
+ }
+
+ $this->prependHtml(new HtmlElement('li', Attributes::create(['class' => 'default']), $button));
+ }
+
+ protected function assemble()
+ {
+ if ($this->failureMessage !== null) {
+ $this->addHtml(new HtmlElement(
+ 'li',
+ Attributes::create(['class' => 'failure-message']),
+ new HtmlElement('em', null, Text::create(t('Can\'t search:'))),
+ Text::create($this->failureMessage)
+ ));
+ return;
+ }
+
+ if ($this->data === null) {
+ $data = [];
+ } elseif ($this->data instanceof Paginatable) {
+ $this->data->limit(self::DEFAULT_LIMIT);
+ $data = $this->data;
+ } else {
+ $data = new LimitIterator(new IteratorIterator($this->data), 0, self::DEFAULT_LIMIT);
+ }
+
+ foreach ($data as $term => $meta) {
+ if (is_int($term)) {
+ $term = $meta;
+ }
+
+ $attributes = [
+ 'type' => 'button',
+ 'tabindex' => -1,
+ 'data-search' => $term,
+ 'data-title' => $term
+ ];
+ if ($this->type !== null) {
+ $attributes['data-type'] = $this->type;
+ }
+
+ if (is_array($meta)) {
+ foreach ($meta as $key => $value) {
+ if ($key === 'label') {
+ $label = $value;
+ }
+
+ $attributes['data-' . $key] = $value;
+ }
+ } else {
+ $label = $meta;
+ $attributes['data-label'] = $meta;
+ }
+
+ $button = (new ButtonElement(null, $attributes))
+ ->setAttribute('value', $label)
+ ->addHtml(Text::create($label));
+ if ($this->type === 'column' && $this->shouldShowRelationFor($term)) {
+ $relationPath = substr($term, 0, strrpos($term, '.'));
+ $button->getAttributes()->add('class', 'has-details');
+ $button->addHtml(new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'relation-path']),
+ Text::create($relationPath)
+ ));
+ }
+
+ $this->addHtml(new HtmlElement('li', null, $button));
+ }
+
+ if ($this->hasMore($data, self::DEFAULT_LIMIT)) {
+ $this->getAttributes()->add('class', 'has-more');
+ }
+
+ $showDefault = true;
+ if ($this->searchTerm && $this->count() === 1) {
+ // The default option is only shown if the user's input does not result in an exact match
+ $input = $this->getFirst('li')->getFirst('button');
+ $showDefault = $input->getContent() != $this->searchTerm
+ && $input->getAttributes()->get('data-search')->getValue() != $this->searchTerm;
+ }
+
+ if ($this->type === 'column' && ! $this->isEmpty() && ! $this->getFirst('li')->getAttributes()->has('class')) {
+ // The column title is only added if there are any suggestions and the first item is not a title already
+ $this->prependHtml(new HtmlElement(
+ 'li',
+ Attributes::create(['class' => static::SUGGESTION_TITLE_CLASS]),
+ Text::create(t('Columns'))
+ ));
+ }
+
+ if ($showDefault) {
+ $this->assembleDefault();
+ }
+
+ if (! $this->searchTerm && $this->isEmpty()) {
+ $this->addHtml(new HtmlElement(
+ 'li',
+ Attributes::create(['class' => 'nothing-to-suggest']),
+ new HtmlElement('em', null, Text::create(t('Nothing to suggest')))
+ ));
+ }
+ }
+
+ /**
+ * Load suggestions as requested by the client
+ *
+ * @param ServerRequestInterface $request
+ *
+ * @return $this
+ */
+ public function forRequest(ServerRequestInterface $request)
+ {
+ if ($request->getMethod() !== 'POST') {
+ return $this;
+ }
+
+ $requestData = json_decode($request->getBody()->read(8192), true);
+ if (empty($requestData)) {
+ return $this;
+ }
+
+ $search = $requestData['term']['search'];
+ $label = $requestData['term']['label'];
+ $type = $requestData['term']['type'];
+
+ $this->setSearchTerm($search);
+ $this->setType($type);
+
+ switch ($type) {
+ case 'value':
+ if (! $requestData['column'] || $requestData['column'] === SearchEditor::FAKE_COLUMN) {
+ $this->setFailureMessage(t('Missing column name'));
+ break;
+ }
+
+ $searchFilter = QueryString::parse(
+ isset($requestData['searchFilter'])
+ ? $requestData['searchFilter']
+ : ''
+ );
+ if ($searchFilter instanceof Filter\Condition) {
+ $searchFilter = Filter::all($searchFilter);
+ }
+
+ try {
+ $this->setData($this->fetchValueSuggestions($requestData['column'], $label, $searchFilter));
+ } catch (SearchException $e) {
+ $this->setFailureMessage($e->getMessage());
+ }
+
+ if ($search) {
+ $this->setDefault([
+ 'search' => $requestData['operator'] === '~' || $requestData['operator'] === '!~'
+ ? $label
+ : $search
+ ]);
+ }
+
+ break;
+ case 'column':
+ $this->setData($this->filterColumnSuggestions($this->fetchColumnSuggestions($label), $label));
+
+ if ($search && isset($requestData['showQuickSearch']) && $requestData['showQuickSearch']) {
+ $quickFilter = $this->createQuickSearchFilter($label);
+ if (! $quickFilter instanceof Filter\Chain || ! $quickFilter->isEmpty()) {
+ $this->setDefault([
+ 'search' => $label,
+ 'type' => 'terms',
+ 'terms' => $quickFilter
+ ]);
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ protected function hasMore($data, $than)
+ {
+ if (is_array($data)) {
+ return count($data) > $than;
+ } elseif ($data instanceof Countable) {
+ return $data->count() > $than;
+ } elseif ($data instanceof OuterIterator) {
+ return $this->hasMore($data->getInnerIterator(), $than);
+ }
+
+ return false;
+ }
+
+ /**
+ * Filter the given suggestions by the client's input
+ *
+ * @param Traversable $data
+ * @param string $searchTerm
+ *
+ * @return Generator
+ */
+ protected function filterColumnSuggestions($data, $searchTerm)
+ {
+ foreach ($data as $key => $value) {
+ if ($this->matchSuggestion($key, $value, $searchTerm)) {
+ yield $key => $value;
+ }
+ }
+ }
+
+ /**
+ * Get whether the given suggestion should be provided to the client
+ *
+ * @param string $path
+ * @param string $label
+ * @param string $searchTerm
+ *
+ * @return bool
+ */
+ protected function matchSuggestion($path, $label, $searchTerm)
+ {
+ return fnmatch($searchTerm, $label, FNM_CASEFOLD) || fnmatch($searchTerm, $path, FNM_CASEFOLD);
+ }
+
+ public function renderUnwrapped()
+ {
+ $this->ensureAssembled();
+
+ if ($this->isEmpty()) {
+ return '';
+ }
+
+ return parent::renderUnwrapped();
+ }
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar/Terms.php b/vendor/ipl/web/src/Control/SearchBar/Terms.php
new file mode 100644
index 0000000..c81e336
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar/Terms.php
@@ -0,0 +1,255 @@
+<?php
+
+namespace ipl\Web\Control\SearchBar;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\Icon;
+
+class Terms extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'terms'];
+
+ /** @var callable|Filter\Rule */
+ protected $filter;
+
+ /** @var array */
+ protected $changes;
+
+ /** @var int */
+ private $changeIndexCorrection = 0;
+
+ /** @var int */
+ private $currentIndex = 0;
+
+ public function setFilter($filter)
+ {
+ $this->filter = $filter;
+
+ return $this;
+ }
+
+ /**
+ * Apply term changes
+ *
+ * @param array $changes
+ *
+ * @return $this
+ */
+ public function applyChanges(array $changes)
+ {
+ $this->changes = $changes;
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $filter = $this->filter;
+ if (is_callable($filter)) {
+ $filter = $filter();
+ }
+
+ if ($filter === null) {
+ return;
+ }
+
+ if ($filter instanceof Filter\Chain) {
+ if ($filter->isEmpty()) {
+ return;
+ }
+
+ if ($filter instanceof Filter\None) {
+ $this->assembleChain($filter, $this, $filter->count() > 1);
+ } else {
+ $this->assembleConditions($filter, $this);
+ }
+ } else {
+ /** @var Filter\Condition $filter */
+ $this->assembleCondition($filter, $this);
+ }
+ }
+
+ protected function assembleConditions(Filter\Chain $filters, BaseHtmlElement $where)
+ {
+ foreach ($filters as $i => $filter) {
+ if ($i > 0) {
+ $logicalOperator = QueryString::getRuleSymbol($filters);
+ $this->assembleTerm([
+ 'class' => 'logical_operator',
+ 'type' => 'logical_operator',
+ 'search' => $logicalOperator,
+ 'label' => $logicalOperator
+ ], $where);
+ }
+
+ if ($filter instanceof Filter\Chain) {
+ $this->assembleChain($filter, $where, $filter->count() > 1);
+ } else {
+ /** @var Filter\Condition $filter */
+ $this->assembleCondition($filter, $where);
+ }
+ }
+ }
+
+ protected function assembleChain(Filter\Chain $chain, BaseHtmlElement $where, $wrap = false)
+ {
+ if ($wrap) {
+ $group = new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'filter-chain', 'data-group-type' => 'chain'])
+ );
+ } else {
+ $group = $where;
+ }
+
+ if ($chain instanceof Filter\None) {
+ $this->assembleTerm([
+ 'class' => 'logical_operator',
+ 'type' => 'negation_operator',
+ 'search' => '!',
+ 'label' => '!'
+ ], $where);
+ }
+
+ if ($wrap) {
+ $opening = $this->assembleTerm([
+ 'class' => 'grouping_operator_open',
+ 'type' => 'grouping_operator',
+ 'search' => '(',
+ 'label' => '('
+ ], $group);
+ }
+
+ $this->assembleConditions($chain, $group);
+
+ if ($wrap) {
+ $closing = $this->assembleTerm([
+ 'class' => 'grouping_operator_close',
+ 'type' => 'grouping_operator',
+ 'search' => ')',
+ 'label' => ')'
+ ], $group);
+
+ $opening->addAttributes([
+ 'data-counterpart' => $closing->getAttributes()->get('data-index')->getValue()
+ ]);
+ $closing->addAttributes([
+ 'data-counterpart' => $opening->getAttributes()->get('data-index')->getValue()
+ ]);
+
+ $where->addHtml($group);
+ }
+ }
+
+ protected function assembleCondition(Filter\Condition $filter, BaseHtmlElement $where)
+ {
+ $column = $filter->getColumn();
+ $operator = QueryString::getRuleSymbol($filter);
+ $value = $filter->getValue();
+ $columnLabel = $filter->metaData()->get('columnLabel', $column);
+
+ $group = new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'filter-condition', 'data-group-type' => 'condition']),
+ new HtmlElement('button', Attributes::create(['type' => 'button']), new Icon('trash'))
+ );
+
+ $columnData = [
+ 'class' => 'column',
+ 'type' => 'column',
+ 'search' => rawurlencode($column),
+ 'label' => $columnLabel,
+ 'title' => $column
+ ];
+ if ($filter->metaData()->has('invalidColumnPattern')) {
+ $columnData['pattern'] = $filter->metaData()->get('invalidColumnPattern');
+ if ($filter->metaData()->has('invalidColumnMessage')) {
+ $columnData['invalidMsg'] = $filter->metaData()->get('invalidColumnMessage');
+ }
+ }
+
+ $this->assembleTerm($columnData, $group);
+
+ if ($value !== true) {
+ $operatorData = [
+ 'class' => 'operator',
+ 'type' => 'operator',
+ 'search' => $operator,
+ 'label' => $operator
+ ];
+ if ($filter->metaData()->has('invalidOperatorPattern')) {
+ $operatorData['pattern'] = $filter->metaData()->get('invalidOperatorPattern');
+ if ($filter->metaData()->has('invalidOperatorMessage')) {
+ $operatorData['invalidMsg'] = $filter->metaData()->get('invalidOperatorMessage');
+ }
+ }
+
+ $this->assembleTerm($operatorData, $group);
+
+ if (! empty($value) || ! is_string($value) || ctype_digit($value)) {
+ $valueData = [
+ 'class' => 'value',
+ 'type' => 'value',
+ 'search' => rawurlencode($value),
+ 'label' => $value
+ ];
+ if ($filter->metaData()->has('invalidValuePattern')) {
+ $valueData['pattern'] = $filter->metaData()->get('invalidValuePattern');
+ if ($filter->metaData()->has('invalidValueMessage')) {
+ $valueData['invalidMsg'] = $filter->metaData()->get('invalidValueMessage');
+ }
+ }
+
+ $this->assembleTerm($valueData, $group);
+ }
+ }
+
+ $where->addHtml($group);
+ }
+
+ protected function assembleTerm(array $data, BaseHtmlElement $where)
+ {
+ if (isset($this->changes[$this->currentIndex - $this->changeIndexCorrection])) {
+ $change = $this->changes[$this->currentIndex - $this->changeIndexCorrection];
+ if ($change['type'] !== $data['type']) {
+ // This can happen because the user didn't insert parentheses but the parser did
+ $this->changeIndexCorrection++;
+ } else {
+ $data = array_merge($data, $change);
+ }
+ }
+
+ $term = new HtmlElement('label', Attributes::create([
+ 'class' => $data['class'],
+ 'data-index' => $this->currentIndex++,
+ 'data-type' => $data['type'],
+ 'data-search' => $data['search'],
+ 'data-label' => $data['label']
+ ]), new HtmlElement('input', Attributes::create([
+ 'type' => 'text',
+ 'value' => $data['label']
+ ])));
+
+ if (isset($data['title'])) {
+ $term->setAttribute('title', $data['title']);
+ }
+
+ if (isset($data['pattern'])) {
+ $term->getFirst('input')->setAttribute('pattern', $data['pattern']);
+
+ if (isset($data['invalidMsg'])) {
+ $term->getFirst('input')->setAttribute('data-invalid-msg', $data['invalidMsg']);
+ }
+ }
+
+ $where->addHtml($term);
+
+ return $term;
+ }
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php
new file mode 100644
index 0000000..5825790
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace ipl\Web\Control\SearchBar;
+
+use ipl\Stdlib\Data;
+use ipl\Stdlib\Filter\Condition;
+
+class ValidatedColumn extends ValidatedTerm
+{
+ /**
+ * Create a new ValidatedColumn from the given filter condition
+ *
+ * @param Condition $condition
+ *
+ * @return static
+ */
+ public static function fromFilterCondition(Condition $condition)
+ {
+ return new static($condition->getColumn(), $condition->metaData()->get('columnLabel'));
+ }
+
+ public function toTermData()
+ {
+ $termData = parent::toTermData();
+ $termData['type'] = 'column';
+
+ return $termData;
+ }
+
+ public function toMetaData()
+ {
+ $data = new Data();
+ if (($label = $this->getLabel()) !== null) {
+ $data->set('columnLabel', $label);
+ }
+
+ if (! $this->isValid()) {
+ $data->set('invalidColumnMessage', $this->getMessage())
+ ->set('invalidColumnPattern', $this->getPattern());
+ }
+
+ return $data;
+ }
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php
new file mode 100644
index 0000000..67fdbf0
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace ipl\Web\Control\SearchBar;
+
+use InvalidArgumentException;
+use ipl\Stdlib\Data;
+use ipl\Stdlib\Filter;
+use LogicException;
+
+class ValidatedOperator extends ValidatedTerm
+{
+ /**
+ * Create a new ValidatedColumn from the given filter condition
+ *
+ * @param Filter\Condition $condition
+ *
+ * @return static
+ *
+ * @throws InvalidArgumentException In case the condition type is unknown
+ */
+ public static function fromFilterCondition(Filter\Condition $condition)
+ {
+ switch (true) {
+ case $condition instanceof Filter\Unlike:
+ case $condition instanceof Filter\Unequal:
+ $operator = '!=';
+ break;
+ case $condition instanceof Filter\Like:
+ case $condition instanceof Filter\Equal:
+ $operator = '=';
+ break;
+ case $condition instanceof Filter\GreaterThan:
+ $operator = '>';
+ break;
+ case $condition instanceof Filter\LessThan:
+ $operator = '<';
+ break;
+ case $condition instanceof Filter\GreaterThanOrEqual:
+ $operator = '>=';
+ break;
+ case $condition instanceof Filter\LessThanOrEqual:
+ $operator = '<=';
+ break;
+ default:
+ throw new InvalidArgumentException('Unknown condition type');
+ }
+
+ return new static($operator);
+ }
+
+ public function toTermData()
+ {
+ $termData = parent::toTermData();
+ $termData['type'] = 'operator';
+
+ return $termData;
+ }
+
+ public function toMetaData()
+ {
+ $data = new Data();
+
+ if (! $this->isValid()) {
+ $data->set('invalidOperatorMessage', $this->getMessage())
+ ->set('invalidOperatorPattern', $this->getPattern());
+ }
+
+ return $data;
+ }
+
+ public function setSearchValue(string $searchValue): ValidatedTerm
+ {
+ throw new LogicException('Operators cannot be changed');
+ }
+
+ public function setLabel(?string $label): ValidatedTerm
+ {
+ throw new LogicException('Operators cannot be changed');
+ }
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php
new file mode 100644
index 0000000..f616880
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php
@@ -0,0 +1,196 @@
+<?php
+
+namespace ipl\Web\Control\SearchBar;
+
+use ipl\Stdlib\Data;
+
+abstract class ValidatedTerm
+{
+ /** @var string The default validation constraint */
+ const DEFAULT_PATTERN = '^\s*(?!%s\b).*\s*$';
+
+ /** @var string The search value */
+ protected $searchValue;
+
+ /** @var ?string The label */
+ protected $label;
+
+ /** @var ?string The validation message */
+ protected $message;
+
+ /** @var ?string The validation constraint */
+ protected $pattern;
+
+ /** @var bool Whether the term has been adjusted */
+ protected $changed = false;
+
+ /**
+ * Create a new ValidatedTerm
+ *
+ * @param string $searchValue The search value
+ * @param ?string $label The label
+ */
+ public function __construct(string $searchValue, ?string $label = null)
+ {
+ $this->searchValue = $searchValue;
+ $this->label = $label;
+ }
+
+ /**
+ * Create a new ValidatedTerm from the given data
+ *
+ * @param array $data
+ *
+ * @return static
+ */
+ public static function fromTermData(array $data)
+ {
+ return new static($data['search'], isset($data['label']) ? $data['label'] : null);
+ }
+
+ /**
+ * Check whether the term is valid
+ *
+ * @return bool
+ */
+ public function isValid()
+ {
+ return $this->message === null;
+ }
+
+ /**
+ * Check whether the term has been adjusted
+ *
+ * @return bool
+ */
+ public function hasBeenChanged()
+ {
+ return $this->changed;
+ }
+
+ /**
+ * Get the search value
+ *
+ * @return string
+ */
+ public function getSearchValue(): string
+ {
+ return $this->searchValue;
+ }
+
+ /**
+ * Set the search value
+ *
+ * @param string $searchValue
+ *
+ * @return $this
+ */
+ public function setSearchValue(string $searchValue): self
+ {
+ $this->searchValue = $searchValue;
+ $this->changed = true;
+
+ return $this;
+ }
+
+ /**
+ * Get the label
+ *
+ * @return string
+ */
+ public function getLabel(): ?string
+ {
+ return $this->label;
+ }
+
+ /**
+ * Set the label
+ *
+ * @param ?string $label
+ *
+ * @return $this
+ */
+ public function setLabel(?string $label): self
+ {
+ $this->label = $label;
+ $this->changed = true;
+
+ return $this;
+ }
+
+ /**
+ * Get the validation message
+ *
+ * @return ?string
+ */
+ public function getMessage(): ?string
+ {
+ return $this->message;
+ }
+
+ /**
+ * Set the validation message
+ *
+ * @param string $message
+ *
+ * @return $this
+ */
+ public function setMessage(string $message): self
+ {
+ $this->message = $message;
+
+ return $this;
+ }
+
+ /**
+ * Get the validation constraint
+ *
+ * Returns the default constraint if none is set.
+ *
+ * @return string
+ */
+ public function getPattern(): ?string
+ {
+ if ($this->message === null) {
+ return null;
+ }
+
+ return $this->pattern ?? sprintf(self::DEFAULT_PATTERN, $this->getLabel() ?: $this->getSearchValue());
+ }
+
+ /**
+ * Set the validation constraint
+ *
+ * @param string $pattern
+ *
+ * @return $this
+ */
+ public function setPattern(string $pattern): self
+ {
+ $this->pattern = $pattern;
+
+ return $this;
+ }
+
+ /**
+ * Get this term's data
+ *
+ * @return array
+ */
+ public function toTermData()
+ {
+ return [
+ 'search' => $this->getSearchValue(),
+ 'label' => $this->getLabel() ?: $this->getSearchValue(),
+ 'invalidMsg' => $this->getMessage(),
+ 'pattern' => $this->getPattern()
+ ];
+ }
+
+ /**
+ * Get this term's metadata
+ *
+ * @return Data
+ */
+ abstract public function toMetaData();
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php
new file mode 100644
index 0000000..423102d
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace ipl\Web\Control\SearchBar;
+
+use ipl\Stdlib\Data;
+use ipl\Stdlib\Filter\Condition;
+
+class ValidatedValue extends ValidatedTerm
+{
+ /**
+ * Create a new ValidatedColumn from the given filter condition
+ *
+ * @param Condition $condition
+ *
+ * @return static
+ */
+ public static function fromFilterCondition(Condition $condition)
+ {
+ return new static($condition->getValue());
+ }
+
+ public function toTermData()
+ {
+ $termData = parent::toTermData();
+ $termData['type'] = 'value';
+
+ return $termData;
+ }
+
+ public function toMetaData()
+ {
+ $data = new Data();
+
+ if (! $this->isValid()) {
+ $data->set('invalidValueMessage', $this->getMessage())
+ ->set('invalidValuePattern', $this->getPattern());
+ }
+
+ return $data;
+ }
+}
diff --git a/vendor/ipl/web/src/Control/SearchEditor.php b/vendor/ipl/web/src/Control/SearchEditor.php
new file mode 100644
index 0000000..f975471
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchEditor.php
@@ -0,0 +1,615 @@
+<?php
+
+namespace ipl\Web\Control;
+
+use ipl\Html\Attributes;
+use ipl\Html\Form;
+use ipl\Html\FormDecorator\CallbackDecorator;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Events;
+use ipl\Stdlib\Filter;
+use ipl\Web\Control\SearchBar\SearchException;
+use ipl\Web\Filter\Parser;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Filter\Renderer;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+
+class SearchEditor extends Form
+{
+ use Events;
+
+ /** @var string Emitted for every validated column */
+ const ON_VALIDATE_COLUMN = 'validate-column';
+
+ /** @var string The column name used for empty conditions */
+ const FAKE_COLUMN = '_fake_';
+
+ protected $defaultAttributes = [
+ 'data-enrichment-type' => 'search-editor',
+ 'class' => 'search-editor'
+ ];
+
+ /** @var string */
+ protected $queryString;
+
+ /** @var Url */
+ protected $suggestionUrl;
+
+ /** @var Parser */
+ protected $parser;
+
+ /** @var Filter\Rule */
+ protected $filter;
+
+ /** @var bool */
+ protected $cleared = false;
+
+ /**
+ * Set the filter query string to populate the form with
+ *
+ * Use {@see SearchEditor::getParser()} to subscribe to parser events.
+ *
+ * @param string $query
+ *
+ * @return $this
+ */
+ public function setQueryString($query)
+ {
+ $this->queryString = $query;
+
+ return $this;
+ }
+
+ /**
+ * Get the suggestion url
+ *
+ * @return ?Url
+ */
+ public function getSuggestionUrl(): ?Url
+ {
+ return $this->suggestionUrl;
+ }
+
+ /**
+ * Set the suggestion url
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setSuggestionUrl(Url $url)
+ {
+ $this->suggestionUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * Get the query string parser being used
+ *
+ * @return Parser
+ */
+ public function getParser()
+ {
+ if ($this->parser === null) {
+ $this->parser = new Parser();
+ }
+
+ return $this->parser;
+ }
+
+ /**
+ * Get the current filter
+ *
+ * @return Filter\Rule
+ */
+ public function getFilter()
+ {
+ if ($this->filter === null) {
+ $this->filter = $this->getParser()
+ ->setQueryString($this->queryString)
+ ->parse();
+ }
+
+ return $this->filter;
+ }
+
+ public function populate($values)
+ {
+ // applyChanges() is basically this form's own populate implementation, hence
+ // why it changes $values and needs to run before actually populating the form
+ $filter = (new Parser(isset($values['filter']) ? $values['filter'] : $this->queryString))
+ ->setStrict()
+ ->parse();
+ $filter = $this->applyChanges($filter, $values);
+
+ parent::populate($values);
+
+ $this->filter = $this->applyStructuralChange($filter);
+ if ($this->filter !== null && ($this->filter instanceof Filter\Condition || ! $this->filter->isEmpty())) {
+ $this->queryString = (new Renderer($this->filter))->setStrict()->render();
+ } else {
+ $this->queryString = '';
+ }
+
+ return $this;
+ }
+
+ public function hasBeenSubmitted()
+ {
+ if (parent::hasBeenSubmitted()) {
+ return true;
+ }
+
+ return $this->cleared;
+ }
+
+ public function validate()
+ {
+ if ($this->cleared) {
+ $this->isValid = true;
+ } else {
+ parent::validate();
+ }
+
+ return $this;
+ }
+
+ protected function applyChanges(Filter\Rule $rule, array &$values, array $path = [0])
+ {
+ $identifier = 'rule-' . join('-', $path);
+
+ if ($rule instanceof Filter\Condition) {
+ $newColumn = $this->popKey($values, $identifier . '-column-search');
+ if ($newColumn === null) {
+ $newColumn = $this->popKey($values, $identifier . '-column');
+ } else {
+ // Make sure we don't forget to present the column labels again
+ $rule->metaData()->set('columnLabel', $this->popKey($values, $identifier . '-column'));
+ }
+
+ if ($newColumn !== null && $rule->getColumn() !== $newColumn) {
+ $rule->setColumn($newColumn ?: static::FAKE_COLUMN);
+ // TODO: Clear meta data?
+ }
+
+ $newValue = $this->popKey($values, $identifier . '-value');
+ $oldValue = $rule->getValue();
+ if ($newValue !== null && $oldValue !== $newValue) {
+ $rule->setValue($newValue);
+ }
+
+ $newOperator = $this->popKey($values, $identifier . '-operator');
+ if ($newOperator !== null && QueryString::getRuleSymbol($rule) !== $newOperator) {
+ $value = $rule->getValue();
+ $column = $rule->getColumn();
+ switch ($newOperator) {
+ case '~':
+ return Filter::like($column, $value);
+ case '!~':
+ return Filter::unlike($column, $value);
+ case '=':
+ return Filter::equal($column, $value);
+ case '!=':
+ return Filter::unequal($column, $value);
+ case '>':
+ return Filter::greaterThan($column, $value);
+ case '>=':
+ return Filter::greaterThanOrEqual($column, $value);
+ case '<':
+ return Filter::lessThan($column, $value);
+ case '<=':
+ return Filter::lessThanOrEqual($column, $value);
+ }
+ }
+
+ $value = $rule->getValue();
+ if ($oldValue !== $value && is_string($value) && strpos($value, '*') !== false) {
+ if (QueryString::getRuleSymbol($rule) === '=') {
+ return Filter::like($rule->getColumn(), $value);
+ } elseif (QueryString::getRuleSymbol($rule) === '!=') {
+ return Filter::unlike($rule->getColumn(), $value);
+ }
+ }
+ } else {
+ /** @var Filter\Chain $rule */
+ $newGroupOperator = $this->popKey($values, $identifier);
+ $oldGroupOperator = $rule instanceof Filter\None ? '!' : QueryString::getRuleSymbol($rule);
+ if ($newGroupOperator !== null && $oldGroupOperator !== $newGroupOperator) {
+ switch ($newGroupOperator) {
+ case '&':
+ $rule = Filter::all(...$rule);
+ break;
+ case '|':
+ $rule = Filter::any(...$rule);
+ break;
+ case '!':
+ $rule = Filter::none(...$rule);
+ break;
+ }
+ }
+
+ $i = 0;
+ foreach ($rule as $child) {
+ $childPath = $path;
+ $childPath[] = $i++;
+ $newChild = $this->applyChanges($child, $values, $childPath);
+ if ($child !== $newChild) {
+ $rule->replace($child, $newChild);
+ }
+ }
+ }
+
+ return $rule;
+ }
+
+ protected function applyStructuralChange(Filter\Rule $rule)
+ {
+ $structuralChange = $this->getPopulatedValue('structural-change');
+ if (empty($structuralChange)) {
+ return $rule;
+ } elseif (is_array($structuralChange)) {
+ ksort($structuralChange);
+ }
+
+ list($type, $where) = explode(':', is_array($structuralChange)
+ ? array_shift($structuralChange)
+ : $structuralChange);
+ $targetPath = explode('-', substr($where, 5));
+
+ $targetFinder = function ($path) use ($rule) {
+ $parent = null;
+ $target = null;
+ $children = [$rule];
+ foreach ($path as $targetPos) {
+ if ($target !== null) {
+ $parent = $target;
+ $children = $parent instanceof Filter\Chain
+ ? iterator_to_array($parent)
+ : [];
+ }
+
+ if (! isset($children[$targetPos])) {
+ return [null, null];
+ }
+
+ $target = $children[$targetPos];
+ }
+
+ return [$parent, $target];
+ };
+
+ list($parent, $target) = $targetFinder($targetPath);
+ if ($target === null) {
+ return $rule;
+ }
+
+ $emptyEqual = Filter::equal(static::FAKE_COLUMN, '');
+ switch ($type) {
+ case 'move-rule':
+ if (! is_array($structuralChange) || empty($structuralChange)) {
+ return $rule;
+ }
+
+ list($placement, $moveToPath) = explode(':', array_shift($structuralChange));
+ list($moveToParent, $moveToTarget) = $targetFinder(explode('-', substr($moveToPath, 5)));
+
+ $parent->remove($target);
+ if ($placement === 'to') {
+ $moveToTarget->add($target);
+ } elseif ($placement === 'before') {
+ $moveToParent->insertBefore($target, $moveToTarget);
+ } else {
+ $moveToParent->insertAfter($target, $moveToTarget);
+ }
+
+ break;
+ case 'add-condition':
+ $target->add($emptyEqual);
+
+ break;
+ case 'add-group':
+ $target->add(Filter::all($emptyEqual));
+
+ break;
+ case 'wrap-rule':
+ if ($parent !== null) {
+ $parent->replace($target, Filter::all($target));
+ } else {
+ $rule = Filter::all($target);
+ }
+
+ break;
+ case 'drop-rule':
+ if ($parent !== null) {
+ $parent->remove($target);
+ } else {
+ $rule = $emptyEqual;
+ }
+
+ break;
+ case 'clear':
+ $this->cleared = true;
+ $rule = null;
+ }
+
+ return $rule;
+ }
+
+ protected function createTree(Filter\Rule $rule, array $path = [0])
+ {
+ $identifier = 'rule-' . join('-', $path);
+
+ if ($rule instanceof Filter\Condition) {
+ $parts = [$this->createCondition($rule, $identifier), $this->createButtons($rule, $identifier)];
+
+ if (count($path) === 1) {
+ $item = new HtmlElement('ol', null, new HtmlElement(
+ 'li',
+ Attributes::create(['id' => $identifier]),
+ ...$parts
+ ));
+ } else {
+ array_splice($parts, 1, 0, [
+ new Icon('bars', ['class' => 'drag-initiator'])
+ ]);
+
+ $item = (new HtmlDocument())->addHtml(...$parts);
+ }
+ } else {
+ /** @var Filter\Chain $rule */
+ $item = new HtmlElement('ul');
+
+ $groupOperatorInput = $this->createElement('select', $identifier, [
+ 'options' => [
+ '&' => 'ALL',
+ '|' => 'ANY',
+ '!' => 'NONE'
+ ],
+ 'value' => $rule instanceof Filter\None ? '!' : QueryString::getRuleSymbol($rule)
+ ]);
+ $this->registerElement($groupOperatorInput);
+ $item->addHtml(HtmlElement::create('li', ['id' => $identifier], [
+ $groupOperatorInput,
+ count($path) > 1
+ ? new Icon('bars', ['class' => 'drag-initiator'])
+ : null,
+ $this->createButtons($rule, $identifier)
+ ]));
+
+ $children = new HtmlElement('ol');
+ $item->addHtml(new HtmlElement('li', null, $children));
+
+ $i = 0;
+ foreach ($rule as $child) {
+ $childPath = $path;
+ $childPath[] = $i++;
+ $children->addHtml(new HtmlElement(
+ 'li',
+ Attributes::create([
+ 'id' => 'rule-' . join('-', $childPath),
+ 'class' => $child instanceof Filter\Condition
+ ? 'filter-condition'
+ : 'filter-chain'
+ ]),
+ $this->createTree($child, $childPath)
+ ));
+ }
+ }
+
+ return $item;
+ }
+
+ protected function createButtons(Filter\Rule $for, $identifier)
+ {
+ $buttons = [];
+
+ if ($for instanceof Filter\Chain) {
+ $buttons[] = $this->createElement('submitButton', 'structural-change', [
+ 'value' => 'add-condition:' . $identifier,
+ 'label' => t('Add Condition', 'to a group of filter conditions'),
+ 'formnovalidate' => true
+ ]);
+ $buttons[] = $this->createElement('submitButton', 'structural-change', [
+ 'value' => 'add-group:' . $identifier,
+ 'label' => t('Add Group', 'of filter conditions'),
+ 'formnovalidate' => true
+ ]);
+ }
+
+ $buttons[] = $this->createElement('submitButton', 'structural-change', [
+ 'value' => 'wrap-rule:' . $identifier,
+ 'label' => t('Wrap in Group', 'a filter rule'),
+ 'formnovalidate' => true
+ ]);
+ $buttons[] = $this->createElement('submitButton', 'structural-change', [
+ 'value' => 'drop-rule:' . $identifier,
+ 'label' => t('Delete', 'a filter rule'),
+ 'formnovalidate' => true
+ ]);
+
+ $ul = new HtmlElement('ul');
+ foreach ($buttons as $button) {
+ $ul->addHtml(new HtmlElement('li', null, $button));
+ }
+
+ return new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'buttons']),
+ $ul,
+ new Icon('ellipsis-h')
+ );
+ }
+
+ protected function createCondition(Filter\Condition $condition, $identifier)
+ {
+ $columnInput = $this->createElement('text', $identifier . '-column', [
+ 'value' => $condition->metaData()->get(
+ 'columnLabel',
+ $condition->getColumn() !== static::FAKE_COLUMN
+ ? $condition->getColumn()
+ : null
+ ),
+ 'title' => $condition->getColumn() !== static::FAKE_COLUMN
+ ? $condition->getColumn()
+ : null,
+ 'required' => true,
+ 'autocomplete' => 'off',
+ 'data-type' => 'column',
+ 'data-enrichment-type' => 'completion',
+ 'data-term-suggestions' => '#search-editor-suggestions'
+ ]);
+ $columnInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () {
+ return (string) $this->getSuggestionUrl();
+ });
+ (new CallbackDecorator(function ($element) {
+ $errors = new HtmlElement('ul', Attributes::create(['class' => 'search-errors']));
+
+ foreach ($element->getMessages() as $message) {
+ $errors->addHtml(new HtmlElement('li', null, Text::create($message)));
+ }
+
+ if (! $errors->isEmpty()) {
+ if (trim($element->getValue())) {
+ $element->getAttributes()->add(
+ 'pattern',
+ sprintf(
+ '^\s*(?!%s\b).*\s*$',
+ $element->getValue()
+ )
+ );
+ }
+
+ $element->prependWrapper(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'search-error']),
+ $element,
+ $errors
+ ));
+ }
+ }))->decorate($columnInput);
+
+ $columnFakeInput = $this->createElement('hidden', $identifier . '-column-search', [
+ 'value' => static::FAKE_COLUMN
+ ]);
+ $columnSearchInput = $this->createElement('hidden', $identifier . '-column-search', [
+ 'value' => $condition->getColumn() !== static::FAKE_COLUMN
+ ? $condition->getColumn()
+ : null,
+ 'validators' => ['Callback' => function ($value) use ($condition, $columnInput, &$columnSearchInput) {
+ if (! $this->hasBeenSubmitted()) {
+ return true;
+ }
+
+ try {
+ $this->emit(static::ON_VALIDATE_COLUMN, [$condition]);
+ } catch (SearchException $e) {
+ $columnInput->addMessage($e->getMessage());
+ return false;
+ }
+
+ $columnSearchInput->setValue($condition->getColumn());
+ $columnInput->setValue($condition->metaData()->get('columnLabel', $condition->getColumn()));
+
+ return true;
+ }]
+ ]);
+
+ $operatorInput = $this->createElement('select', $identifier . '-operator', [
+ 'options' => [
+ '~' => '~',
+ '!~' => '!~',
+ '=' => '=',
+ '!=' => '!=',
+ '>' => '>',
+ '<' => '<',
+ '>=' => '>=',
+ '<=' => '<='
+ ],
+ 'value' => QueryString::getRuleSymbol($condition)
+ ]);
+
+ $valueInput = $this->createElement('text', $identifier . '-value', [
+ 'value' => $condition->getValue(),
+ 'autocomplete' => 'off',
+ 'data-type' => 'value',
+ 'data-enrichment-type' => 'completion',
+ 'data-term-suggestions' => '#search-editor-suggestions'
+ ]);
+ $valueInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () {
+ return (string) $this->getSuggestionUrl();
+ });
+
+ $this->registerElement($columnInput);
+ $this->registerElement($columnSearchInput);
+ $this->registerElement($operatorInput);
+ $this->registerElement($valueInput);
+
+ return new HtmlElement(
+ 'fieldset',
+ Attributes::create(['name' => $identifier . '-']),
+ $columnInput,
+ $columnFakeInput,
+ $columnSearchInput,
+ $operatorInput,
+ $valueInput
+ );
+ }
+
+ protected function assemble()
+ {
+ $filterInput = $this->createElement('hidden', 'filter');
+ $filterInput->getAttributes()->registerAttributeCallback(
+ 'value',
+ function () {
+ return $this->queryString ?: static::FAKE_COLUMN;
+ },
+ [$this, 'setQueryString']
+ );
+ $this->addElement($filterInput);
+
+ $filter = $this->getFilter();
+ if ($filter instanceof Filter\Chain && $filter->isEmpty()) {
+ $filter = Filter::equal('', '');
+ }
+
+ $this->addHtml($this->createTree($filter));
+ $this->addHtml(new HtmlElement('div', Attributes::create([
+ 'id' => 'search-editor-suggestions',
+ 'class' => 'search-suggestions'
+ ])));
+
+ if ($this->queryString) {
+ $this->addHtml($this->createElement('submitButton', 'structural-change', [
+ 'value' => 'clear:rule-0',
+ 'class' => 'cancel-button',
+ 'label' => t('Clear Filter'),
+ 'formnovalidate' => true
+ ]));
+ }
+
+ $this->addElement('submit', 'btn_submit', [
+ 'label' => t('Apply')
+ ]);
+
+ // Add submit button also as first element to make Web 2 submit
+ // the form instead of using a structural change to submit if
+ // the user just presses Enter.
+ $this->prepend($this->getElement('btn_submit'));
+ }
+
+ private function popKey(array &$from, $key, $default = null)
+ {
+ if (isset($from[$key])) {
+ $value = $from[$key];
+ unset($from[$key]);
+
+ return $value;
+ }
+
+ return $default;
+ }
+}
diff --git a/vendor/ipl/web/src/Control/SortControl.php b/vendor/ipl/web/src/Control/SortControl.php
new file mode 100644
index 0000000..65c2c3d
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SortControl.php
@@ -0,0 +1,293 @@
+<?php
+
+namespace ipl\Web\Control;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use ipl\Html\Form;
+use ipl\Html\FormDecorator\DivDecorator;
+use ipl\Html\FormElement\ButtonElement;
+use ipl\Html\HtmlElement;
+use ipl\Orm\Common\SortUtil;
+use ipl\Orm\Query;
+use ipl\Stdlib\Str;
+use ipl\Web\Common\FormUid;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Allows to adjust the order of the items to display
+ */
+class SortControl extends Form
+{
+ use FormUid;
+
+ /** @var string Default sort param */
+ public const DEFAULT_SORT_PARAM = 'sort';
+
+ protected $defaultAttributes = ['class' => 'sort-control'];
+
+ /** @var string Name of the URL parameter which stores the sort column */
+ protected $sortParam = self::DEFAULT_SORT_PARAM;
+
+ /**
+ * @var Url Request URL
+ * @deprecated Access {@see self::getRequest()} instead.
+ * @todo Remove once cube calls {@see self::handleRequest()}.
+ */
+ protected $url;
+
+ /** @var array Possible sort columns as sort string-value pairs */
+ private $columns;
+
+ /** @var ?string Default sort string */
+ private $default;
+
+ protected $method = 'GET';
+
+ /**
+ * Create a new sort control
+ *
+ * @param array $columns Possible sort columns
+ * @param Url $url Request URL
+ *
+ * @internal Use {@see self::create()} instead.
+ */
+ private function __construct(array $columns, Url $url)
+ {
+ $this->setColumns($columns);
+ $this->url = $url;
+ }
+
+ /**
+ * Create a new sort control with the given options
+ *
+ * @param array<string,string> $options A sort spec to label map
+ *
+ * @return static
+ */
+ public static function create(array $options)
+ {
+ $normalized = [];
+ foreach ($options as $spec => $label) {
+ $normalized[SortUtil::normalizeSortSpec($spec)] = $label;
+ }
+
+ $self = new static($normalized, Url::fromRequest());
+
+ $self->on(self::ON_REQUEST, function (ServerRequestInterface $request) use ($self) {
+ if (! $self->hasBeenSent()) {
+ // If the form is submitted by POST, handleRequest() won't access the URL, so we have to
+ if (($sort = $request->getQueryParams()[$self->getSortParam()] ?? null)) {
+ $self->populate([$self->getSortParam() => $sort]);
+ }
+ }
+ });
+
+ return $self;
+ }
+
+ /**
+ * Get the possible sort columns
+ *
+ * @return array Sort string-value pairs
+ */
+ public function getColumns(): array
+ {
+ return $this->columns;
+ }
+
+ /**
+ * Set the possible sort columns
+ *
+ * @param array $columns Sort string-value pairs
+ *
+ * @return $this
+ */
+ public function setColumns(array $columns): self
+ {
+ // We're working with lowercase keys throughout the sort control
+ $this->columns = array_change_key_case($columns, CASE_LOWER);
+
+ return $this;
+ }
+
+ /**
+ * Get the default sort string
+ *
+ * @return ?string
+ */
+ public function getDefault(): ?string
+ {
+ return $this->default;
+ }
+
+ /**
+ * Set the default sort string
+ *
+ * @param string $default
+ *
+ * @return $this
+ */
+ public function setDefault(string $default): self
+ {
+ // We're working with lowercase keys throughout the sort control
+ $this->default = strtolower($default);
+
+ return $this;
+ }
+
+ /**
+ * Get the name of the URL parameter which stores the sort
+ *
+ * @return string
+ */
+ public function getSortParam(): string
+ {
+ return $this->sortParam;
+ }
+
+ /**
+ * Set the name of the URL parameter which stores the sort
+ *
+ * @param string $sortParam
+ *
+ * @return $this
+ */
+ public function setSortParam(string $sortParam): self
+ {
+ $this->sortParam = $sortParam;
+
+ return $this;
+ }
+
+ /**
+ * Get the sort string
+ *
+ * @return ?string
+ */
+ public function getSort(): ?string
+ {
+ if ($this->getRequest() === null) {
+ $sort = $this->url->getParam($this->getSortParam(), $this->getDefault());
+ } else {
+ $sort = $this->getPopulatedValue($this->getSortParam(), $this->getDefault());
+ }
+
+ if (! empty($sort)) {
+ $columns = $this->getColumns();
+
+ if (! isset($columns[$sort])) {
+ // Choose sort string based on the first closest match
+ foreach (array_keys($columns) as $key) {
+ if (Str::startsWith($key, $sort)) {
+ $this->populate([$this->getSortParam() => $key]);
+ $sort = $key;
+
+ break;
+ }
+ }
+ }
+ }
+
+ return $sort;
+ }
+
+ /**
+ * Sort the given query according to the request
+ *
+ * @param Query $query
+ * @param ?array|string $defaultSort
+ *
+ * @return $this
+ */
+ public function apply(Query $query, $defaultSort = null): self
+ {
+ if ($this->getRequest() === null) {
+ // handleRequest() has not been called yet
+ // TODO: Remove this once everything using this requires ipl v0.12.0
+ $this->handleRequest(ServerRequest::fromGlobals());
+ }
+
+ $default = $defaultSort ?? (array) $query->getModel()->getDefaultSort();
+ if (! empty($default)) {
+ $this->setDefault(SortUtil::normalizeSortSpec($default));
+ }
+
+ $sort = $this->getSort();
+ if (! empty($sort)) {
+ $query->orderBy(SortUtil::createOrderBy($sort));
+ }
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $columns = $this->getColumns();
+ $sort = $this->getSort();
+
+ if (empty($sort)) {
+ reset($columns);
+ $sort = key($columns);
+ }
+
+ $sort = explode(',', $sort, 2);
+ list($column, $direction) = Str::symmetricSplit(array_shift($sort), ' ', 2);
+
+ if (! $direction || strtolower($direction) === 'asc') {
+ $toggleIcon = 'sort-alpha-down';
+ $toggleDirection = 'desc';
+ } else {
+ $toggleIcon = 'sort-alpha-down-alt';
+ $toggleDirection = 'asc';
+ }
+
+ if ($direction !== null) {
+ $value = implode(',', array_merge(["{$column} {$direction}"], $sort));
+ if (! isset($columns[$value])) {
+ foreach ([$column, "{$column} {$toggleDirection}"] as $key) {
+ $key = implode(',', array_merge([$key], $sort));
+ if (isset($columns[$key])) {
+ $columns[$value] = $columns[$key];
+ unset($columns[$key]);
+
+ break;
+ }
+ }
+ }
+ } else {
+ $value = implode(',', array_merge([$column], $sort));
+ }
+
+ if (! isset($columns[$value])) {
+ $columns[$value] = 'Custom';
+ }
+
+ $this->addElement('select', $this->getSortParam(), [
+ 'class' => 'autosubmit',
+ 'label' => 'Sort By',
+ 'options' => $columns,
+ 'value' => $value
+ ]);
+ $select = $this->getElement($this->getSortParam());
+ (new DivDecorator())->decorate($select);
+
+ // Apply Icinga Web 2 style, for now
+ $select->prependWrapper(HtmlElement::create('div', ['class' => 'icinga-controls']));
+
+ $toggleButton = new ButtonElement($this->getSortParam(), [
+ 'class' => 'control-button spinner',
+ 'title' => t('Change sort direction'),
+ 'type' => 'submit',
+ 'value' => implode(',', array_merge(["{$column} {$toggleDirection}"], $sort))
+ ]);
+ $toggleButton->add(new Icon($toggleIcon));
+
+ $this->addHtml($toggleButton);
+
+ if ($this->getMethod() === 'POST' && $this->hasAttribute('name')) {
+ $this->addElement($this->createUidElement());
+ }
+ }
+}
diff --git a/vendor/ipl/web/src/Filter/ParseException.php b/vendor/ipl/web/src/Filter/ParseException.php
new file mode 100644
index 0000000..bcafd09
--- /dev/null
+++ b/vendor/ipl/web/src/Filter/ParseException.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace ipl\Web\Filter;
+
+use Exception;
+
+class ParseException extends Exception
+{
+ protected $char;
+
+ protected $charPos;
+
+ public function __construct($filter, $char, $charPos, $extra)
+ {
+ parent::__construct(sprintf(
+ 'Invalid filter "%s", unexpected %s at pos %d%s',
+ $filter,
+ $char,
+ $charPos,
+ $extra
+ ));
+
+ $this->char = $char;
+ $this->charPos = $charPos;
+ }
+
+ public function getChar()
+ {
+ return $this->char;
+ }
+
+ public function getCharPos()
+ {
+ return $this->charPos;
+ }
+}
diff --git a/vendor/ipl/web/src/Filter/Parser.php b/vendor/ipl/web/src/Filter/Parser.php
new file mode 100644
index 0000000..d33fd86
--- /dev/null
+++ b/vendor/ipl/web/src/Filter/Parser.php
@@ -0,0 +1,568 @@
+<?php
+
+namespace ipl\Web\Filter;
+
+use ipl\Stdlib\Events;
+use ipl\Stdlib\Filter;
+
+class Parser
+{
+ use Events;
+
+ /** @var string Emitted for every completely parsed condition */
+ const ON_CONDITION = 'on_condition';
+
+ /** @var string Emitted for every completely parsed chain */
+ const ON_CHAIN = 'on_chain';
+
+ /** @var string */
+ protected $string;
+
+ /** @var int */
+ protected $pos;
+
+ /** @var int */
+ protected $termIndex;
+
+ /** @var int */
+ protected $length;
+
+ /** @var bool Whether strict mode is enabled */
+ protected $strict = false;
+
+ /**
+ * Create a new Parser
+ *
+ * @param string $queryString The string to parse
+ */
+ public function __construct($queryString = null)
+ {
+ if ($queryString !== null) {
+ $this->setQueryString($queryString);
+ }
+ }
+
+ /**
+ * Set the query string to parse
+ *
+ * @param string $queryString
+ *
+ * @return $this
+ */
+ public function setQueryString($queryString)
+ {
+ $this->string = (string) $queryString;
+ $this->length = strlen($queryString);
+
+ return $this;
+ }
+
+ /**
+ * Set whether strict mode is enabled
+ *
+ * @param bool $strict
+ *
+ * @return $this
+ */
+ public function setStrict($strict = true)
+ {
+ $this->strict = (bool) $strict;
+
+ return $this;
+ }
+
+ /**
+ * Parse the string and derive a filter rule from it
+ *
+ * @return Filter\Rule
+ */
+ public function parse()
+ {
+ if ($this->length === 0) {
+ return Filter::all();
+ }
+
+ $this->pos = 0;
+ $this->termIndex = 0;
+
+ return $this->readFilters();
+ }
+
+ /**
+ * Read filters
+ *
+ * @param int $nestingLevel
+ * @param string $op
+ * @param array $filters
+ * @param bool $explicit
+ *
+ * @return Filter\Chain|Filter\Condition
+ * @throws ParseException
+ */
+ protected function readFilters($nestingLevel = 0, $op = null, $filters = null, $explicit = true)
+ {
+ $filters = empty($filters) ? [] : $filters;
+ $isNone = false;
+
+ while ($this->pos < $this->length) {
+ $filter = $this->readCondition();
+ $next = $this->readChar();
+
+ if ($filter === false) {
+ if ($next === '!') {
+ $isNone = true;
+ $this->termIndex++;
+ continue;
+ }
+
+ if ($op === null && ($this->strict || count($filters) > 0) && ($next === '&' || $next === '|')) {
+ $op = $next;
+ $this->termIndex++;
+ continue;
+ }
+
+ if ($next === false) {
+ // Nothing more to read
+ break;
+ }
+
+ if ($next === ')') {
+ if ($nestingLevel > 0) {
+ if (! $explicit) {
+ // The current chain was not initiated by a `(`,
+ // so this `)` does not belong to it, but still ends it
+ $this->pos--;
+ } else {
+ $this->termIndex++;
+ $next = $this->nextChar();
+ if ($next !== false && ! in_array($next, ['&', '|', ')'])) {
+ $this->pos++;
+ $this->parseError($next, 'Expected logical operator');
+ }
+ }
+
+ break;
+ }
+
+ $this->parseError($next);
+ }
+
+ if ($next === '(') {
+ $this->termIndex++;
+
+ $rule = $this->readFilters($nestingLevel + 1, $isNone ? '!' : null);
+ if ($this->strict || ! $rule instanceof Filter\Chain || ! $rule->isEmpty()) {
+ $filters[] = $rule;
+ }
+
+ $isNone = false;
+ continue;
+ }
+
+ if ($next === $op) {
+ $this->termIndex++;
+ continue;
+ }
+
+ if (in_array($next, ['&', '|'])) {
+ $this->termIndex++;
+
+ // It's a different logical operator, continue parsing based on its precedence
+ if ($op === '&') {
+ if (! empty($filters)) {
+ if (count($filters) > 1) {
+ $all = Filter::all(...$filters);
+ $filters = [$all];
+
+ $this->emit(self::ON_CHAIN, [$all]);
+ } else {
+ $filters = [$filters[0]];
+ }
+ }
+
+ $op = $next;
+ } elseif ($op === '|' || ($op === '!' && $next === '&')) {
+ $rule = $this->readFilters(
+ $nestingLevel + 1,
+ $next,
+ [array_pop($filters)],
+ false
+ );
+ if (! $rule instanceof Filter\Chain || ! $rule->isEmpty()) {
+ $filters[] = $rule;
+ }
+ }
+
+ continue;
+ }
+
+ $this->parseError($next, "$op level $nestingLevel");
+ } else {
+ if ($isNone) {
+ $isNone = false;
+ if ($filter->getValue() === true) {
+ // $filter is a result of `!column`
+ $filter->setValue(false);
+ $filters[] = $filter;
+
+ $this->emit(self::ON_CONDITION, [$filter]);
+ } else {
+ // $filter is a result of `!column=[value]`
+ $none = Filter::none($filter);
+ $filters[] = $none;
+
+ $this->emit(self::ON_CONDITION, [$filter]);
+ $this->emit(self::ON_CHAIN, [$none]);
+ }
+ } else {
+ $filters[] = $filter;
+ $this->emit(self::ON_CONDITION, [$filter]);
+ }
+
+ if ($next === false) {
+ // Got filter, nothing more to read
+ break;
+ }
+
+ if ($next === ')') {
+ if ($nestingLevel > 0) {
+ if (! $explicit) {
+ // The current chain was not initiated by a `(`,
+ // so this `)` does not belong to it, but still ends it
+ $this->pos--;
+ } else {
+ $this->termIndex++;
+ $next = $this->nextChar();
+ if ($next !== false && ! in_array($next, ['&', '|', ')'])) {
+ $this->pos++;
+ $this->parseError($next, 'Expected logical operator');
+ }
+ }
+
+ break;
+ }
+
+ $this->parseError($next);
+ }
+
+ if ($next === $op) {
+ $this->termIndex++;
+ continue;
+ }
+
+ if (in_array($next, ['&', '|'])) {
+ $this->termIndex++;
+
+ // It's a different logical operator, continue parsing based on its precedence
+ if ($op === null || $op === '&') {
+ if ($op === '&') {
+ if (count($filters) > 1) {
+ $all = Filter::all(...$filters);
+ $filters = [$all];
+
+ $this->emit(self::ON_CHAIN, [$all]);
+ } else {
+ $filters = [$filters[0]];
+ }
+ }
+
+ $op = $next;
+ } elseif ($op === '|' || ($op === '!' && $next === '&')) {
+ $rule = $this->readFilters(
+ $nestingLevel + 1,
+ $next,
+ [array_pop($filters)],
+ false
+ );
+ if (! $rule instanceof Filter\Chain || ! $rule->isEmpty()) {
+ $filters[] = $rule;
+ }
+ }
+
+ continue;
+ }
+
+ $this->parseError($next);
+ }
+ }
+
+ if ($nestingLevel === 0 && $this->pos < $this->length) {
+ $this->parseError($op, 'Did not read full filter');
+ }
+
+ switch ($op) {
+ case '&':
+ $chain = Filter::all(...$filters);
+ break;
+ case '|':
+ $chain = Filter::any(...$filters);
+ break;
+ case '!':
+ $chain = Filter::none(...$filters);
+ break;
+ case null:
+ if ((! $this->strict || $nestingLevel === 0) && ! empty($filters)) {
+ // There is only one filter expression, no chain
+ return $filters[0];
+ }
+
+ $chain = Filter::all(...$filters);
+ break;
+ default:
+ $this->parseError($op);
+ }
+
+ $this->emit(self::ON_CHAIN, [$chain]);
+
+ return $chain;
+ }
+
+ /**
+ * Read the next condition
+ *
+ * @return false|Filter\Condition
+ *
+ * @throws ParseException
+ */
+ protected function readCondition()
+ {
+ if ('' === ($column = $this->readColumn())) {
+ return false;
+ }
+
+ $columnIndex = $this->termIndex++;
+
+ foreach (['<', '>'] as $operator) {
+ if (($pos = strpos($column, $operator)) !== false) {
+ if ($this->nextChar() === '=') {
+ break;
+ }
+
+ $operatorIndex = $this->termIndex++;
+
+ $value = substr($column, $pos + 1);
+ $column = substr($column, 0, $pos);
+
+ $valueIndex = null;
+ if (ctype_digit($value)) {
+ $value = (float) $value;
+ $valueIndex = $this->termIndex++;
+ } elseif ($value) {
+ $valueIndex = $this->termIndex++;
+ }
+
+ $condition = $this->createCondition($column, $operator, $value);
+ $condition->metaData()
+ ->set('columnIndex', $columnIndex)
+ ->set('operatorIndex', $operatorIndex)
+ ->set('valueIndex', $valueIndex);
+
+ return $condition;
+ }
+ }
+
+ if (in_array($this->nextChar(), ['~', '=', '>', '<', '!'], true)) {
+ $operator = $this->readChar();
+ } else {
+ $operator = false;
+ }
+
+ if ($operator === false) {
+ $condition = Filter::equal($column, true);
+ $condition->metaData()
+ ->set('columnIndex', $columnIndex)
+ ->set('operatorIndex', null)
+ ->set('valueIndex', null);
+
+ return $condition;
+ }
+
+ $operatorIndex = $this->termIndex++;
+
+ $toFloat = false;
+ if ($operator === '=') {
+ $last = substr($column, -1);
+ if ($last === '>' || $last === '<') {
+ $operator = $last . $operator;
+ $column = substr($column, 0, -1);
+ $toFloat = true;
+ }
+ } elseif (in_array($operator, ['>', '<', '!'], true)) {
+ $toFloat = $operator === '>' || $operator === '<';
+ if (in_array($this->nextChar(), ['~', '='], true)) {
+ $operator .= $this->readChar();
+ }
+ }
+
+ $valueIndex = null;
+ $value = $this->readValue();
+ if ($toFloat && ctype_digit($value)) {
+ $value = (float) $value;
+ $valueIndex = $this->termIndex++;
+ } elseif ($value) {
+ $valueIndex = $this->termIndex++;
+ }
+
+ $condition = $this->createCondition($column, $operator, $value);
+ $condition->metaData()
+ ->set('columnIndex', $columnIndex)
+ ->set('operatorIndex', $operatorIndex)
+ ->set('valueIndex', $valueIndex);
+
+ return $condition;
+ }
+
+ /**
+ * Read the next column
+ *
+ * @return false|string false if there is none
+ */
+ protected function readColumn()
+ {
+ $str = $this->readUntil('~', '=', '(', ')', '&', '|', '>', '<', '!');
+
+ if ($str === false) {
+ return $str;
+ }
+
+ return rawurldecode($str);
+ }
+
+ /**
+ * Read the next value
+ *
+ * @return string|string[]
+ *
+ * @throws ParseException In case there's a missing `)`
+ */
+ protected function readValue()
+ {
+ if ($this->nextChar() === '(') {
+ $this->readChar();
+ $var = array_map('rawurldecode', preg_split('~\|~', $this->readUntil(')')));
+
+ if ($this->readChar() !== ')') {
+ $this->parseError(null, 'Expected ")"');
+ }
+ } else {
+ $var = rawurldecode($this->readUntil(')', '&', '|', '>', '<'));
+ }
+
+ return $var;
+ }
+
+ /**
+ * Read until any of the given chars appears
+ *
+ * @param string ...$chars
+ *
+ * @return string
+ */
+ protected function readUntil(...$chars)
+ {
+ $buffer = '';
+ while (($c = $this->readChar()) !== false) {
+ if (in_array($c, $chars, true)) {
+ $this->pos--;
+ break;
+ }
+
+ $buffer .= $c;
+ }
+
+ return $buffer;
+ }
+
+ /**
+ * Read a single character
+ *
+ * @return false|string false if there is no character left
+ */
+ protected function readChar()
+ {
+ if ($this->length > $this->pos) {
+ return $this->string[$this->pos++];
+ }
+
+ return false;
+ }
+
+ /**
+ * Look at the next character
+ *
+ * @return false|string false if there is no character left
+ */
+ protected function nextChar()
+ {
+ if ($this->length > $this->pos) {
+ return $this->string[$this->pos];
+ }
+
+ return false;
+ }
+
+ /**
+ * Create and return a condition
+ *
+ * @param string $column
+ * @param string $operator
+ * @param mixed $value
+ *
+ * @return Filter\Condition
+ */
+ protected function createCondition($column, $operator, $value)
+ {
+ $column = trim($column);
+
+ switch ($operator) {
+ case '~':
+ return Filter::like($column, $value);
+ case '!~':
+ return Filter::unlike($column, $value);
+ case '=':
+ return Filter::equal($column, $value);
+ case '!=':
+ return Filter::unequal($column, $value);
+ case '>':
+ return Filter::greaterThan($column, $value);
+ case '>=':
+ return Filter::greaterThanOrEqual($column, $value);
+ case '<':
+ return Filter::lessThan($column, $value);
+ case '<=':
+ return Filter::lessThanOrEqual($column, $value);
+ }
+ }
+
+ /**
+ * Throw a parse exception
+ *
+ * @param string $char
+ * @param string $extraMsg
+ *
+ * @throws ParseException
+ */
+ protected function parseError($char = null, $extraMsg = null)
+ {
+ if ($extraMsg === null) {
+ $extra = '';
+ } else {
+ $extra = ': ' . $extraMsg;
+ }
+
+ if ($char === null) {
+ if ($this->pos < $this->length) {
+ $char = $this->string[$this->pos];
+ } else {
+ $char = $this->string[--$this->pos];
+ }
+ }
+
+ throw new ParseException(
+ $this->string,
+ $char,
+ $this->pos,
+ $extra
+ );
+ }
+}
diff --git a/vendor/ipl/web/src/Filter/QueryString.php b/vendor/ipl/web/src/Filter/QueryString.php
new file mode 100644
index 0000000..e1bb533
--- /dev/null
+++ b/vendor/ipl/web/src/Filter/QueryString.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace ipl\Web\Filter;
+
+use InvalidArgumentException;
+use ipl\Stdlib\Filter;
+
+final class QueryString
+{
+ /** @var string Emitted for every completely parsed condition */
+ const ON_CONDITION = Parser::ON_CONDITION;
+
+ /** @var string Emitted for every completely parsed chain */
+ const ON_CHAIN = Parser::ON_CHAIN;
+
+ /**
+ * This class is only a factory / helper
+ */
+ private function __construct()
+ {
+ }
+
+ /**
+ * Derive a rule from the given query string
+ *
+ * @param string $string
+ *
+ * @return Parser
+ */
+ public static function fromString($string)
+ {
+ return new Parser($string);
+ }
+
+ /**
+ * Derive a rule from the given query string
+ *
+ * @param string $string
+ *
+ * @return Filter\Rule
+ */
+ public static function parse($string)
+ {
+ return (new Parser($string))->parse();
+ }
+
+ /**
+ * Assemble a query string for the given rule
+ *
+ * @param Filter\Rule $rule
+ *
+ * @return string
+ */
+ public static function render(Filter\Rule $rule)
+ {
+ return (new Renderer($rule))->render();
+ }
+
+ /**
+ * Get the symbol associated with the given rule
+ *
+ * @param Filter\Rule $rule
+ *
+ * @return string
+ */
+ public static function getRuleSymbol(Filter\Rule $rule)
+ {
+ switch (true) {
+ case $rule instanceof Filter\Unlike:
+ return '!~';
+ case $rule instanceof Filter\Unequal:
+ return '!=';
+ case $rule instanceof Filter\Like:
+ return '~';
+ case $rule instanceof Filter\Equal:
+ return '=';
+ case $rule instanceof Filter\GreaterThan:
+ return '>';
+ case $rule instanceof Filter\LessThan:
+ return '<';
+ case $rule instanceof Filter\GreaterThanOrEqual:
+ return '>=';
+ case $rule instanceof Filter\LessThanOrEqual:
+ return '<=';
+ case $rule instanceof Filter\All:
+ return '&';
+ case $rule instanceof Filter\Any:
+ case $rule instanceof Filter\None:
+ return '|';
+ default:
+ throw new InvalidArgumentException('Unknown rule type provided');
+ }
+ }
+}
diff --git a/vendor/ipl/web/src/Filter/Renderer.php b/vendor/ipl/web/src/Filter/Renderer.php
new file mode 100644
index 0000000..513470e
--- /dev/null
+++ b/vendor/ipl/web/src/Filter/Renderer.php
@@ -0,0 +1,186 @@
+<?php
+
+namespace ipl\Web\Filter;
+
+use ipl\Stdlib\Filter;
+
+class Renderer
+{
+ /** @var Filter\Rule */
+ protected $filter;
+
+ /** @var string */
+ protected $string;
+
+ /** @var bool Whether strict mode is enabled */
+ protected $strict = false;
+
+ /**
+ * Create a new filter Renderer
+ *
+ * @param Filter\Rule $filter
+ */
+ public function __construct(Filter\Rule $filter)
+ {
+ $this->filter = $filter;
+ }
+
+ /**
+ * Set whether strict mode is enabled
+ *
+ * @param bool $strict
+ *
+ * @return $this
+ */
+ public function setStrict($strict = true)
+ {
+ $this->strict = (bool) $strict;
+
+ return $this;
+ }
+
+ /**
+ * Assemble and return the filter as query string
+ *
+ * @return string
+ */
+ public function render()
+ {
+ if ($this->string !== null) {
+ return $this->string;
+ }
+
+ $this->string = '';
+ $filter = $this->filter;
+
+ if ($filter instanceof Filter\Chain) {
+ $this->renderChain($filter, $this->strict);
+ } else {
+ /** @var Filter\Condition $filter */
+ $this->renderCondition($filter);
+ }
+
+ return $this->string;
+ }
+
+ /**
+ * Assemble the given filter Chain
+ *
+ * @param Filter\Chain $chain
+ * @param bool $wrap
+ *
+ * @return void
+ */
+ protected function renderChain(Filter\Chain $chain, $wrap = false)
+ {
+ if (! $this->strict && $chain->isEmpty()) {
+ return;
+ }
+
+ $chainOperator = null;
+ switch (true) {
+ case $chain instanceof Filter\All:
+ $chainOperator = '&';
+ break;
+ case $chain instanceof Filter\None:
+ $this->string .= '!';
+
+ // Force wrap, it may be the root node
+ if (! $wrap) {
+ if ($chain->count() > 1) {
+ $wrap = true;
+ } else {
+ $iterator = $chain->getIterator();
+ $wrap = $iterator->current() instanceof Filter\None;
+ }
+ }
+
+ // None shares the operator with Any
+ case $chain instanceof Filter\Any:
+ $chainOperator = '|';
+ break;
+ }
+
+ if ($wrap) {
+ $this->string .= '(';
+ }
+
+ foreach ($chain as $rule) {
+ if ($rule instanceof Filter\Chain) {
+ $this->renderChain($rule, $this->strict || $rule->count() > 1);
+ } else {
+ /** @var Filter\Condition $rule */
+ $this->renderCondition($rule);
+ }
+
+ $this->string .= $chainOperator;
+ }
+
+ if (! $chain->isEmpty() && (! $this->strict || ! ($chain instanceof Filter\Any && $chain->count() === 1))) {
+ // Remove redundant chain operator added last
+ $this->string = substr($this->string, 0, -1);
+ } elseif ($chain->isEmpty() && $chain instanceof Filter\Any) {
+ // If the chain is empty and strict mode is on, we need a
+ // chain operator to designate it's an OR, not an AND
+ $this->string .= $chainOperator;
+ }
+
+ if ($wrap) {
+ $this->string .= ')';
+ }
+ }
+
+ /**
+ * Assemble the given filter Condition
+ *
+ * @param Filter\Condition $condition
+ *
+ * @return void
+ */
+ protected function renderCondition(Filter\Condition $condition)
+ {
+ $value = $condition->getValue();
+ if (is_bool($value) && ! $value) {
+ $this->string .= '!';
+ }
+
+ $this->string .= rawurlencode($condition->getColumn());
+
+ if (is_bool($value)) {
+ return;
+ }
+
+ switch (true) {
+ case $condition instanceof Filter\Unlike:
+ $this->string .= '!~';
+ break;
+ case $condition instanceof Filter\Unequal:
+ $this->string .= '!=';
+ break;
+ case $condition instanceof Filter\Like:
+ $this->string .= '~';
+ break;
+ case $condition instanceof Filter\Equal:
+ $this->string .= '=';
+ break;
+ case $condition instanceof Filter\GreaterThan:
+ $this->string .= rawurlencode('>');
+ break;
+ case $condition instanceof Filter\LessThan:
+ $this->string .= rawurlencode('<');
+ break;
+ case $condition instanceof Filter\GreaterThanOrEqual:
+ $this->string .= rawurlencode('>') . '=';
+ break;
+ case $condition instanceof Filter\LessThanOrEqual:
+ $this->string .= rawurlencode('<') . '=';
+ break;
+ }
+
+ if (is_array($value)) {
+ $this->string .= '(' . join('|', array_map('rawurlencode', $value)) . ')';
+ } elseif ($value !== null) {
+ $this->string .= rawurlencode($value);
+ }
+ }
+}
diff --git a/vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php b/vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php
new file mode 100644
index 0000000..f038931
--- /dev/null
+++ b/vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace ipl\Web\FormDecorator;
+
+use Icinga\Web\Window;
+use ipl\Html\Attributes;
+use ipl\Html\Contract\FormSubmitElement;
+use ipl\Html\FormDecorator\DivDecorator;
+use ipl\Html\FormElement\CheckboxElement;
+use ipl\Html\FormElement\FieldsetElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Web\Widget\Icon;
+
+class IcingaFormDecorator extends DivDecorator
+{
+ const SUBMIT_ELEMENT_CLASS = 'form-controls';
+ const INPUT_ELEMENT_CLASS = 'control-group';
+ const ERROR_CLASS = 'errors';
+
+ protected function assembleElement()
+ {
+ if ($this->formElement instanceof FormSubmitElement) {
+ $this->formElement->getAttributes()->add('class', 'btn-primary');
+ }
+
+ $element = parent::assembleElement();
+
+ if ($element instanceof CheckboxElement) {
+ return $this->createCheckbox($element);
+ }
+
+ return $element;
+ }
+
+ protected function createCheckbox(CheckboxElement $checkbox)
+ {
+ if (! $checkbox->getAttributes()->has('id')) {
+ $checkbox->setAttribute(
+ 'id',
+ $checkbox->getName() . '_' . Window::getInstance()->getContainerId()
+ );
+ }
+
+ $checkbox->getAttributes()->add('class', 'sr-only');
+
+ $classes = ['toggle-switch'];
+ if ($checkbox->getAttributes()->get('disabled')->getValue()) {
+ $classes[] = 'disabled';
+ }
+
+ $document = new HtmlDocument();
+ $document->addHtml(
+ $checkbox,
+ new HtmlElement(
+ 'label',
+ Attributes::create([
+ 'class' => $classes,
+ 'aria-hidden' => 'true',
+ 'for' => $checkbox->getAttributes()->get('id')->getValue()
+ ]),
+ new HtmlElement('span', Attributes::create(['class' => 'toggle-slider']))
+ )
+ );
+
+ $checkbox->prependWrapper($document);
+
+ return $checkbox;
+ }
+
+ protected function assembleLabel()
+ {
+ $label = parent::assembleLabel();
+ if (! $this->formElement instanceof FieldsetElement) {
+ if ($label !== null) {
+ $label->addWrapper(new HtmlElement('div', Attributes::create(['class' => 'control-label-group'])));
+ } elseif (! $this->formElement instanceof FormSubmitElement) {
+ $label = new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'control-label-group']),
+ HtmlString::create('&nbsp')
+ );
+ }
+ }
+
+ return $label;
+ }
+
+ protected function assembleDescription()
+ {
+ if ($this->formElement instanceof FieldsetElement) {
+ return parent::assembleDescription();
+ }
+
+ if (($description = $this->formElement->getDescription()) !== null) {
+ $iconAttributes = [
+ 'class' => 'control-info',
+ 'role' => 'image',
+ 'title' => $description
+ ];
+
+ $describedBy = null;
+ if ($this->formElement->getAttributes()->has('id')) {
+ $iconAttributes['aria-hidden'] = 'true';
+
+ $descriptionId = 'desc_' . $this->formElement->getAttributes()->get('id')->getValue();
+ $describedBy = new HtmlElement('span', Attributes::create([
+ 'id' => $descriptionId,
+ 'class' => 'sr-only'
+ ]), Text::create($description));
+
+ $this->formElement->getAttributes()->set('aria-describedby', $descriptionId);
+ }
+
+ return [
+ new Icon('info-circle', $iconAttributes),
+ $describedBy
+ ];
+ }
+ }
+}
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');
+ }
+}
diff --git a/vendor/ipl/web/src/Layout/Content.php b/vendor/ipl/web/src/Layout/Content.php
new file mode 100644
index 0000000..bded4ab
--- /dev/null
+++ b/vendor/ipl/web/src/Layout/Content.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace ipl\Web\Layout;
+
+use ipl\Html\BaseHtmlElement;
+
+/**
+ * Container for content
+ */
+class Content extends BaseHtmlElement
+{
+ protected $contentSeparator = "\n";
+
+ protected $defaultAttributes = ['class' => 'content'];
+
+ protected $tag = 'div';
+}
diff --git a/vendor/ipl/web/src/Layout/Controls.php b/vendor/ipl/web/src/Layout/Controls.php
new file mode 100644
index 0000000..8763775
--- /dev/null
+++ b/vendor/ipl/web/src/Layout/Controls.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace ipl\Web\Layout;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Web\Widget\Tabs;
+
+/**
+ * Container for controls
+ */
+class Controls extends BaseHtmlElement
+{
+ /** @var Tabs */
+ protected $tabs;
+
+ protected $contentSeparator = "\n";
+
+ protected $defaultAttributes = ['class' => 'controls'];
+
+ protected $tag = 'div';
+
+ /**
+ * Get the tabs
+ *
+ * @return Tabs
+ */
+ public function getTabs()
+ {
+ return $this->tabs;
+ }
+
+ /**
+ * Set the tabs
+ *
+ * @param Tabs $tabs
+ *
+ * @return $this
+ */
+ public function setTabs(Tabs $tabs)
+ {
+ $this->tabs = $tabs;
+
+ return $this;
+ }
+
+ public function isEmpty()
+ {
+ if (! parent::isEmpty()) {
+ return false;
+ }
+
+ return $this->tabs->count() === 0;
+ }
+
+ protected function assemble()
+ {
+ $this->prepend($this->getTabs());
+ }
+}
diff --git a/vendor/ipl/web/src/Layout/Footer.php b/vendor/ipl/web/src/Layout/Footer.php
new file mode 100644
index 0000000..21bf262
--- /dev/null
+++ b/vendor/ipl/web/src/Layout/Footer.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace ipl\Web\Layout;
+
+use ipl\Html\BaseHtmlElement;
+
+/**
+ * Container for footer
+ */
+class Footer extends BaseHtmlElement
+{
+ protected $contentSeparator = "\n";
+
+ protected $defaultAttributes = ['class' => 'footer'];
+
+ protected $tag = 'div';
+}
diff --git a/vendor/ipl/web/src/LessRuleset.php b/vendor/ipl/web/src/LessRuleset.php
new file mode 100644
index 0000000..2e30a4b
--- /dev/null
+++ b/vendor/ipl/web/src/LessRuleset.php
@@ -0,0 +1,177 @@
+<?php
+
+namespace ipl\Web;
+
+use ArrayObject;
+use Less_Parser;
+
+/**
+ * @extends ArrayObject<string, string>
+ */
+class LessRuleset extends ArrayObject
+{
+ /** @var ?string */
+ protected $selector;
+
+ /** @var array<LessRuleset> */
+ protected $children = [];
+
+ /**
+ * Create a new LessRuleset
+ *
+ * @param string $selector Selector to use
+ * @param array<string, string> $properties CSS properties
+ *
+ * @return self
+ */
+ public static function create(string $selector, array $properties): self
+ {
+ $ruleset = new static();
+ $ruleset->selector = $selector;
+ $ruleset->exchangeArray($properties);
+
+ return $ruleset;
+ }
+
+ /**
+ * Get the selector
+ *
+ * @return ?string
+ */
+ public function getSelector(): ?string
+ {
+ return $this->selector;
+ }
+
+ /**
+ * Set the selector
+ *
+ * @param string $selector
+ *
+ * @return $this
+ */
+ public function setSelector(string $selector): self
+ {
+ $this->selector = $selector;
+
+ return $this;
+ }
+
+ /**
+ * Get a property value
+ *
+ * @param string $property Name of the property
+ *
+ * @return string
+ */
+ public function getProperty(string $property): string
+ {
+ return (string) $this[$property];
+ }
+
+ /**
+ * Set a property
+ *
+ * @param string $property Name to use
+ * @param string $value Value to set
+ *
+ * @return $this
+ */
+ public function setProperty(string $property, string $value): self
+ {
+ $this[$property] = $value;
+
+ return $this;
+ }
+
+ /**
+ * Get all properties
+ *
+ * @return array<string, string>
+ */
+ public function getProperties(): array
+ {
+ return $this->getArrayCopy();
+ }
+
+ /**
+ * Set properties
+ *
+ * @param array<string, string> $properties
+ *
+ * @return $this
+ */
+ public function setProperties(array $properties): self
+ {
+ $this->exchangeArray($properties);
+
+ return $this;
+ }
+
+ /**
+ * Create and add a ruleset
+ *
+ * @param string $selector Selector to use
+ * @param array<string, string> $properties CSS properties
+ *
+ * @return $this
+ */
+ public function add(string $selector, array $properties): self
+ {
+ $this->children[] = static::create($selector, $properties);
+
+ return $this;
+ }
+
+ /**
+ * Add a ruleset
+ *
+ * @param LessRuleset $ruleset
+ *
+ * @return $this
+ */
+ public function addRuleset(LessRuleset $ruleset): self
+ {
+ $this->children[] = $ruleset;
+
+ return $this;
+ }
+
+ /**
+ * Compile the ruleset to CSS
+ *
+ * @return string
+ */
+ public function renderCss(): string
+ {
+ $parser = new Less_Parser(['compress' => true]);
+ $parser->parse($this->renderLess());
+
+ return $parser->getCss();
+ }
+
+ /**
+ * Render the ruleset to LESS
+ *
+ * @return string
+ */
+ protected function renderLess(): string
+ {
+ $less = [];
+
+ foreach ($this as $property => $value) {
+ $less[] = "$property: $value;";
+ }
+
+ foreach ($this->children as $ruleset) {
+ $less[] = $ruleset->renderLess();
+ }
+
+ if ($this->selector !== null) {
+ array_unshift($less, "$this->selector {");
+ $less[] = '}';
+ }
+
+ return implode("\n", $less);
+ }
+}
diff --git a/vendor/ipl/web/src/Style.php b/vendor/ipl/web/src/Style.php
new file mode 100644
index 0000000..56479d0
--- /dev/null
+++ b/vendor/ipl/web/src/Style.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace ipl\Web;
+
+use ipl\Html\Attribute;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\ValidHtml;
+use Throwable;
+
+class Style extends LessRuleset implements ValidHtml
+{
+ /** @var ?string */
+ protected $module;
+
+ /** @var ?string */
+ protected $nonce;
+
+ /**
+ * Get the used CSP nonce
+ *
+ * @return ?string
+ */
+ public function getNonce(): ?string
+ {
+ return $this->nonce;
+ }
+
+ /**
+ * Set the CSP nonce to use
+ *
+ * @param ?string $nonce
+ *
+ * @return $this
+ */
+ public function setNonce(?string $nonce): self
+ {
+ $this->nonce = $nonce;
+
+ return $this;
+ }
+
+ /**
+ * Get the Icinga module name the ruleset is scoped to
+ *
+ * @return ?string
+ */
+ public function getModule(): ?string
+ {
+ return $this->module;
+ }
+
+ /**
+ * Set the Icinga module name to use as scope for the ruleset
+ *
+ * @param ?string $name
+ *
+ * @return $this
+ */
+ public function setModule(?string $name): self
+ {
+ $this->module = $name;
+
+ return $this;
+ }
+
+ /**
+ * Add CSS properties for the given element
+ *
+ * The created ruleset will be applied by an `#ID` selector. If the given
+ * element does not have an ID set yet, one is automatically set.
+ *
+ * @param BaseHtmlElement $element Element to apply the properties to
+ * @param array<string, string> $properties CSS properties
+ *
+ * @return $this
+ */
+ public function addFor(BaseHtmlElement $element, array $properties): self
+ {
+ /** @var ?string $id */
+ $id = $element->getAttribute('id')->getValue();
+
+ if ($id === null) {
+ $id = uniqid('csp-style', false);
+ $element->setAttribute('id', $id);
+ }
+
+ return $this->add('#' . $id, $properties);
+ }
+
+ public function render(): string
+ {
+ if ($this->module !== null) {
+ $ruleset = (new static())
+ ->setSelector(".icinga-module.module-$this->module")
+ ->addRuleset($this);
+ } else {
+ $ruleset = $this;
+ }
+
+ return (new HtmlElement(
+ 'style',
+ (new Attributes())->addAttribute(new Attribute('nonce', $this->getNonce())),
+ HtmlString::create($ruleset->renderCss())
+ ))->render();
+ }
+
+ /**
+ * Render to HTML
+ *
+ * @return string
+ */
+ public function __toString(): string
+ {
+ try {
+ return $this->render();
+ } catch (Throwable $e) {
+ return sprintf('<!-- Failed to render style: %s -->', $e->getMessage());
+ }
+ }
+}
diff --git a/vendor/ipl/web/src/Url.php b/vendor/ipl/web/src/Url.php
new file mode 100644
index 0000000..adb96cd
--- /dev/null
+++ b/vendor/ipl/web/src/Url.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace ipl\Web;
+
+use Icinga\Web\UrlParams;
+use ipl\Stdlib\Filter\Rule;
+use ipl\Web\Filter\QueryString;
+
+/**
+ * @TODO(el): Don't depend on Icinga Web's Url
+ */
+class Url extends \Icinga\Web\Url
+{
+ /** @var ?Rule */
+ private $filter;
+
+ /**
+ * Set the filter
+ *
+ * @param ?Rule $filter
+ *
+ * @return $this
+ */
+ public function setFilter(?Rule $filter): self
+ {
+ $this->filter = $filter;
+
+ return $this;
+ }
+
+ /**
+ * Get the filter
+ *
+ * @return ?Rule
+ */
+ public function getFilter(): ?Rule
+ {
+ return $this->filter;
+ }
+
+ /**
+ * Render and return the filter and parameters as query string
+ *
+ * @param ?string $separator
+ *
+ * @return string
+ */
+ public function getQueryString($separator = null)
+ {
+ if ($this->filter === null) {
+ return parent::getQueryString($separator);
+ }
+
+ $params = UrlParams::fromQueryString(QueryString::render($this->filter));
+ foreach ($this->getParams()->toArray(false) as $name => $value) {
+ if (is_int($name)) {
+ $name = $value;
+ $value = true;
+ }
+
+ $params->addEncoded($name, $value);
+ }
+
+ return $params->toString($separator);
+ }
+
+ public function __toString()
+ {
+ return $this->getAbsoluteUrl('&');
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/ActionBar.php b/vendor/ipl/web/src/Widget/ActionBar.php
new file mode 100644
index 0000000..bf31845
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/ActionBar.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Web\Common\BaseTarget;
+use ipl\Web\Url;
+
+/**
+ * Action bar element for displaying a list of links
+ */
+class ActionBar extends BaseHtmlElement
+{
+ use BaseTarget;
+
+ protected $contentSeparator = ' ';
+
+ protected $defaultAttributes = [
+ 'class' => 'action-bar',
+ 'data-base-target' => '_self'
+ ];
+
+ protected $tag = 'div';
+
+ /**
+ * Create a action bar
+ *
+ * @param Attributes|array $attributes
+ */
+ public function __construct($attributes = null)
+ {
+ $this->getAttributes()->add($attributes);
+ }
+
+ /**
+ * Add a link to the action bar
+ *
+ * @param mixed $content
+ * @param Url|string $url
+ * @param string $icon
+ *
+ * @return $this
+ */
+ public function addLink($content, $url, $icon = null)
+ {
+ $this->add(new ActionLink($content, $url, $icon));
+
+ return $this;
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/ActionLink.php b/vendor/ipl/web/src/Widget/ActionLink.php
new file mode 100644
index 0000000..289d700
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/ActionLink.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use ipl\Html\Attributes;
+use ipl\Web\Url;
+
+/**
+ * Link generally pointing to CRUD actions
+ */
+class ActionLink extends Link
+{
+ protected $defaultAttributes = ['class' => 'action-link'];
+
+ /**
+ * Create a action link
+ *
+ * @param mixed $content
+ * @param Url|string $url
+ * @param string $icon
+ * @param Attributes|array $attributes
+ */
+ public function __construct($content, $url, $icon = null, $attributes = null)
+ {
+ parent::__construct($content, $url, $attributes);
+
+ if ($icon !== null) {
+ $this->prepend(new Icon($icon));
+ }
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/ButtonLink.php b/vendor/ipl/web/src/Widget/ButtonLink.php
new file mode 100644
index 0000000..2da5dfd
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/ButtonLink.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+/**
+ * Button like link generally pointing to CRUD actions
+ */
+class ButtonLink extends ActionLink
+{
+ protected $defaultAttributes = [
+ 'class' => 'button-link',
+ 'data-base-target' => '_main'
+ ];
+}
diff --git a/vendor/ipl/web/src/Widget/ContinueWith.php b/vendor/ipl/web/src/Widget/ContinueWith.php
new file mode 100644
index 0000000..1479e9a
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/ContinueWith.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseTarget;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+class ContinueWith extends BaseHtmlElement
+{
+ use BaseTarget;
+
+ protected $tag = 'span';
+
+ protected $defaultAttributes = ['class' => 'continue-with'];
+
+ /** @var Url */
+ protected $url;
+
+ /** @var Filter\Rule|callable */
+ protected $filter;
+
+ /** @var string */
+ protected $title;
+
+ public function __construct(Url $url, $filter)
+ {
+ $this->url = $url;
+ $this->filter = $filter;
+ }
+
+ /**
+ * Set title for the anchor
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ public function setTitle($title)
+ {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ public function assemble()
+ {
+ $filter = $this->filter;
+ if (is_callable($filter)) {
+ $filter = $filter(); /** @var Filter\Rule $filter */
+ }
+
+ if ($filter instanceof Filter\Chain && $filter->isEmpty()) {
+ $this->addHtml(new HtmlElement(
+ 'span',
+ Attributes::create(['class' => ['control-button', 'disabled']]),
+ new Icon('share')
+ ));
+ } else {
+ $this->addHtml(new ActionLink(
+ null,
+ $this->url->setFilter($filter),
+ 'share',
+ ['class' => 'control-button', 'title' => $this->title]
+ ));
+ }
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/CopyToClipboard.php b/vendor/ipl/web/src/Widget/CopyToClipboard.php
new file mode 100644
index 0000000..28e9347
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/CopyToClipboard.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\I18n\Translation;
+
+/**
+ * Copy to clipboard button
+ */
+class CopyToClipboard extends BaseHtmlElement
+{
+ use Translation;
+
+ protected $tag = 'button';
+
+ protected $defaultAttributes = ['type' => 'button'];
+
+ /**
+ * Create a copy to clipboard button
+ *
+ * Creates a copy to clipboard button, which when clicked copies the text from the html element identified as
+ * clipboard source that the clipboard button attaches itself to.
+ */
+ private function __construct()
+ {
+ $this->addAttributes(
+ [
+ 'class' => 'copy-to-clipboard',
+ 'data-icinga-clipboard' => true,
+ 'tabindex' => -1,
+ 'data-copied-label' => $this->translate('Copied'),
+ 'title' => $this->translate('Copy to clipboard'),
+ ]
+ );
+ }
+
+ /**
+ * Attach the copy to clipboard button to the given Html source element
+ *
+ * @param BaseHtmlElement $source
+ *
+ * @return void
+ */
+ public static function attachTo(BaseHtmlElement $source): void
+ {
+ $clipboardWrapper = new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'clipboard-wrapper'])
+ );
+
+ $clipboardWrapper->addHtml(new static());
+
+ $source->addAttributes(['data-clipboard-source' => true]);
+ $source->prependWrapper($clipboardWrapper);
+ }
+
+ public function assemble(): void
+ {
+ $this->setHtmlContent(new Icon('clone'));
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/Dropdown.php b/vendor/ipl/web/src/Widget/Dropdown.php
new file mode 100644
index 0000000..b6eb20d
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/Dropdown.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Url;
+
+/**
+ * Toggleable overlay dropdown element for displaying a list of links
+ */
+class Dropdown extends BaseHtmlElement
+{
+ /** @var array */
+ protected $links = [];
+
+ protected $defaultAttributes = ['class' => 'dropdown'];
+
+ protected $tag = 'div';
+
+ /**
+ * Create a dropdown element
+ *
+ * @param mixed $content
+ * @param Attributes|array $attributes
+ */
+ public function __construct($content, $attributes = null)
+ {
+ $toggle = new ActionLink($content, '#', null, [
+ 'aria-expanded' => false,
+ 'aria-haspopup' => true,
+ 'class' => 'dropdown-toggle',
+ 'role' => 'button'
+ ]);
+
+ $this
+ ->setContent($toggle)
+ ->getAttributes()
+ ->add($attributes);
+ }
+
+ /**
+ * Add a link to the dropdown
+ *
+ * @param mixed $content
+ * @param Url|string $url
+ * @param string $icon
+ *
+ * @return $this
+ */
+ public function addLink($content, $url, $icon = null)
+ {
+ $this->links[] = new ActionLink($content, $url, $icon, ['class' => 'dropdown-item']);
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $this->add(Html::tag('div', ['class' => 'dropdown-menu'], $this->links));
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/EmptyState.php b/vendor/ipl/web/src/Widget/EmptyState.php
new file mode 100644
index 0000000..5a055ac
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/EmptyState.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use ipl\Html\BaseHtmlElement;
+
+class EmptyState extends BaseHtmlElement
+{
+ /** @var mixed Content */
+ protected $content;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'empty-state'];
+
+ /**
+ * Create an empty state
+ *
+ * @param mixed $content
+ */
+ public function __construct($content)
+ {
+ $this->content = $content;
+ }
+
+ protected function assemble(): void
+ {
+ $this->add($this->content);
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/EmptyStateBar.php b/vendor/ipl/web/src/Widget/EmptyStateBar.php
new file mode 100644
index 0000000..2d04837
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/EmptyStateBar.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use ipl\Html\BaseHtmlElement;
+
+class EmptyStateBar extends BaseHtmlElement
+{
+ /** @var mixed Content */
+ protected $content;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'empty-state-bar'];
+
+ /**
+ * Create an empty list
+ *
+ * @param mixed $content
+ */
+ public function __construct($content)
+ {
+ $this->content = $content;
+ }
+
+ protected function assemble(): void
+ {
+ $this->add($this->content);
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/HorizontalKeyValue.php b/vendor/ipl/web/src/Widget/HorizontalKeyValue.php
new file mode 100644
index 0000000..1d1195e
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/HorizontalKeyValue.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+class HorizontalKeyValue extends BaseHtmlElement
+{
+ protected $key;
+
+ protected $value;
+
+ protected $defaultAttributes = ['class' => 'horizontal-key-value'];
+
+ protected $tag = 'div';
+
+ public function __construct($key, $value)
+ {
+ $this->key = $key;
+ $this->value = $value;
+ }
+
+ protected function assemble()
+ {
+ $this->add([
+ Html::tag('div', ['class' => 'key'], $this->key),
+ Html::tag('div', ['class' => 'value'], $this->value)
+ ]);
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/IcingaIcon.php b/vendor/ipl/web/src/Widget/IcingaIcon.php
new file mode 100644
index 0000000..1161fc6
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/IcingaIcon.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use ipl\Html\Attributes;
+
+class IcingaIcon extends Icon
+{
+ protected $style = '';
+
+ /**
+ * Create an icon element
+ *
+ * Creates an icon element from the given name and HTML attributes. The icon element's tag will be <i>. The given
+ * name will be used as automatically added CSS class for the icon element in the format 'iicon-$name'. In addition,
+ * the CSS class 'icon' will be automatically added too.
+ *
+ * @param string $name The name of the icon
+ * @param Attributes|array $attributes The HTML attributes for the element
+ */
+ public function __construct(string $name, $attributes = null)
+ {
+ $this
+ ->getAttributes()
+ ->add('class', ['icon', "iicon-$name"])
+ ->add($attributes);
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/Icon.php b/vendor/ipl/web/src/Widget/Icon.php
new file mode 100644
index 0000000..5c2617f
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/Icon.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+
+/**
+ * Icon element
+ */
+class Icon extends BaseHtmlElement
+{
+ protected $tag = 'i';
+
+ /** @var string Icon style */
+ protected $style;
+
+ /** @var string Icon default style */
+ protected $defaultStyle = 'fa';
+
+ /**
+ * Create an icon element
+ *
+ * Creates an icon element from the given name and HTML attributes. The icon element's tag will be <i>. The given
+ * name will be used as automatically added CSS class for the icon element in the format 'icon-$name'. In addition,
+ * the CSS class 'icon' will be automatically added too.
+ *
+ * @param string $name The name of the icon
+ * @param Attributes|array $attributes The HTML attributes for the element
+ */
+ public function __construct(string $name, $attributes = null)
+ {
+ $this
+ ->getAttributes()
+ ->add('class', ['icon', "fa-$name"])
+ ->add($attributes);
+ }
+
+ /**
+ * Get the icon style
+ *
+ * @return string
+ */
+ public function getStyle(): string
+ {
+ return $this->style ?? $this->defaultStyle;
+ }
+
+ /**
+ * Set the icon style
+ *
+ * @param string $style Style class with prefix
+ *
+ * @return $this
+ */
+ public function setStyle(string $style): self
+ {
+ $this->style = $style;
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $this->addAttributes(['class' => $this->getStyle()]);
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/Link.php b/vendor/ipl/web/src/Widget/Link.php
new file mode 100644
index 0000000..cbae3b9
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/Link.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use ipl\Html\Attribute;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Web\Common\BaseTarget;
+use ipl\Web\Url;
+
+/**
+ * Link element, i.e. <a href="...
+ */
+class Link extends BaseHtmlElement
+{
+ use BaseTarget;
+
+ /** @var Url */
+ protected $url;
+
+ protected $tag = 'a';
+
+ /**
+ * Create a link element
+ *
+ * @param mixed $content
+ * @param Url|string $url
+ * @param Attributes|array $attributes
+ */
+ public function __construct($content, $url, $attributes = null)
+ {
+ $this
+ ->setContent($content)
+ ->setUrl($url)
+ ->getAttributes()
+ ->add($attributes)
+ ->registerAttributeCallback('href', [$this, 'createHrefAttribute']);
+ }
+
+ /**
+ * Get the URL of the link
+ *
+ * @return Url
+ */
+ public function getUrl()
+ {
+ return $this->url;
+ }
+
+ /**
+ * Set the URL of the link
+ *
+ * @param Url|string $url
+ *
+ * @return $this
+ */
+ public function setUrl($url)
+ {
+ if (! $url instanceof Url) {
+ try {
+ $url = Url::fromPath($url);
+ } catch (\Exception $e) {
+ $url = 'invalid';
+ }
+ }
+
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * Create and return the href attribute
+ *
+ * Used as attribute callback for the href attribute.
+ *
+ * @return Attribute
+ */
+ public function createHrefAttribute()
+ {
+ return new Attribute('href', (string) $this->getUrl());
+ }
+
+ /**
+ * Open this link in a modal
+ *
+ * @return $this
+ */
+ public function openInModal(): self
+ {
+ $this->getAttributes()
+ ->set('data-icinga-modal', true)
+ ->set('data-no-icinga-ajax', true);
+
+ return $this;
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/StateBadge.php b/vendor/ipl/web/src/Widget/StateBadge.php
new file mode 100644
index 0000000..908a348
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/StateBadge.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use ipl\Html\BaseHtmlElement;
+
+class StateBadge extends BaseHtmlElement
+{
+ protected $defaultAttributes = ['class' => 'state-badge'];
+
+ /** @var mixed Badge content */
+ protected $content;
+
+ /** @var bool Whether the state is handled */
+ protected $isHandled;
+
+ /** @var string Textual representation of a state */
+ protected $state;
+
+ /**
+ * Create a new state badge
+ *
+ * @param mixed $content Content of the badge
+ * @param string $state Textual representation of a state
+ * @param bool $isHandled True if state is handled
+ */
+ public function __construct($content, string $state, bool $isHandled = false)
+ {
+ $this->content = $content;
+ $this->isHandled = $isHandled;
+ $this->state = $state;
+ }
+
+ protected function assemble()
+ {
+ $this->setTag('span');
+
+ $class = "state-{$this->state}";
+ if ($this->isHandled) {
+ $class .= ' handled';
+ }
+
+ $this->addAttributes(['class' => $class]);
+
+ $this->add($this->content);
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/StateBall.php b/vendor/ipl/web/src/Widget/StateBall.php
new file mode 100644
index 0000000..5a1216d
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/StateBall.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use ipl\Html\BaseHtmlElement;
+
+/**
+ * State ball element that supports different sizes and colors
+ */
+class StateBall extends BaseHtmlElement
+{
+ const SIZE_TINY = 'xs';
+ const SIZE_SMALL = 's';
+ const SIZE_MEDIUM = 'm';
+ const SIZE_MEDIUM_LARGE = 'ml';
+ const SIZE_BIG = 'l';
+ const SIZE_LARGE = 'xl';
+
+ protected $tag = 'span';
+
+ /**
+ * Create a new state ball element
+ *
+ * @param string $state
+ * @param string $size
+ */
+ public function __construct($state = 'none', $size = self::SIZE_SMALL)
+ {
+ $state = trim($state);
+
+ if (empty($state)) {
+ $state = 'none';
+ }
+
+ $size = trim($size);
+
+ if (empty($size)) {
+ $size = self::SIZE_MEDIUM;
+ }
+
+ $this->defaultAttributes = ['class' => "state-ball state-$state ball-size-$size"];
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/Tabs.php b/vendor/ipl/web/src/Widget/Tabs.php
new file mode 100644
index 0000000..32ba8e9
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/Tabs.php
@@ -0,0 +1,190 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use Exception;
+use Icinga\Web\Widget\Tabextension\DashboardAction;
+use Icinga\Web\Widget\Tabextension\MenuAction;
+use Icinga\Web\Widget\Tabextension\OutputFormat;
+use Icinga\Web\Widget\Tabextension\Tabextension;
+use InvalidArgumentException;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Web\Url;
+
+/**
+ * @TODO(el): Don't depend on Icinga Web's Tabs
+ */
+class Tabs extends BaseHtmlElement
+{
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'tabs primary-nav nav'];
+
+ /** @var \Icinga\Web\Widget\Tabs */
+ protected $tabs;
+
+ /** @var bool Whether data exports are enabled */
+ protected $dataExportsEnabled = false;
+
+ /** @var bool Whether the legacy extensions should be shown by default */
+ protected $legacyExtensionsEnabled = true;
+
+ /** @var Url */
+ protected $refreshUrl;
+
+ public function __construct()
+ {
+ $this->tabs = new \Icinga\Web\Widget\Tabs();
+ }
+
+ /**
+ * Don't show legacy extensions by default
+ */
+ public function disableLegacyExtensions()
+ {
+ $this->legacyExtensionsEnabled = false;
+ }
+
+ /**
+ * Show export actions for JSON and CSV
+ */
+ public function enableDataExports()
+ {
+ $this->dataExportsEnabled = true;
+ }
+
+ /**
+ * Set the url for the refresh button
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setRefreshUrl(Url $url)
+ {
+ $this->refreshUrl = $url;
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ if ($this->legacyExtensionsEnabled) {
+ $this->tabs->extend(new OutputFormat(
+ $this->dataExportsEnabled
+ ? []
+ : [OutputFormat::TYPE_CSV, OutputFormat::TYPE_JSON]
+ ))
+ ->extend(new DashboardAction())
+ ->extend(new MenuAction());
+ }
+
+ $tabHtml = substr($this->tabs->render(), 34, -5);
+ if ($this->refreshUrl !== null) {
+ $tabHtml = preg_replace(
+ '/(?<=class="refresh-container-control spinner" href=")([^"]*)/',
+ $this->refreshUrl->getAbsoluteUrl(),
+ $tabHtml
+ );
+ }
+
+ parent::add(HtmlString::create($tabHtml));
+ }
+
+ /**
+ * Activate the tab with the given name
+ *
+ * @param string $name
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException
+ */
+ public function activate($name)
+ {
+ try {
+ $this->tabs->activate($name);
+ } catch (Exception $e) {
+ throw new InvalidArgumentException($e->getMessage());
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get active tab
+ *
+ * @return \Icinga\Web\Widget\Tab
+ */
+ public function getActiveTab()
+ {
+ return $this->tabs->get($this->tabs->getActiveName());
+ }
+
+ /**
+ * Add the given tab
+ *
+ * @param string $name
+ * @param mixed $tab
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException
+ */
+ public function add($name, $tab = null)
+ {
+ if ($tab === null) {
+ throw new InvalidArgumentException('Argument $tab is required');
+ }
+
+ try {
+ $this->tabs->add($name, $tab);
+ } catch (Exception $e) {
+ throw new InvalidArgumentException($e->getMessage());
+ }
+
+ if (is_array($tab) && isset($tab['active']) && $tab['active']) {
+ // Otherwise Tabs::getActiveName() returns null
+ $this->tabs->activate($name);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get a tab
+ *
+ * @param string $name
+ *
+ * @return \Icinga\Web\Widget\Tab|null
+ */
+ public function get($name)
+ {
+ return $this->tabs->get($name);
+ }
+
+ /**
+ * Count tabs
+ *
+ * @return int
+ */
+ public function count(): int
+ {
+ return $this->tabs->count();
+ }
+
+ /**
+ * Apply a Tabextension on $this->tabs object not on this class
+ *
+ * @param Tabextension $extension
+ *
+ * @return $this
+ */
+ public function extend(Tabextension $extension)
+ {
+ $this->tabs->extend($extension);
+
+ return $this;
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/TimeAgo.php b/vendor/ipl/web/src/Widget/TimeAgo.php
new file mode 100644
index 0000000..cbd0dad
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/TimeAgo.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use Icinga\Date\DateFormatter;
+use ipl\Html\BaseHtmlElement;
+
+class TimeAgo extends BaseHtmlElement
+{
+ /** @var int */
+ protected $ago;
+
+ protected $tag = 'time';
+
+ protected $defaultAttributes = ['class' => 'time-ago'];
+
+ public function __construct($ago)
+ {
+ $this->ago = (int) $ago;
+ }
+
+ protected function assemble()
+ {
+ $dateTime = DateFormatter::formatDateTime($this->ago);
+
+ $this->addAttributes([
+ 'datetime' => $dateTime,
+ 'title' => $dateTime
+ ]);
+
+ $this->add(DateFormatter::timeAgo($this->ago));
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/TimeSince.php b/vendor/ipl/web/src/Widget/TimeSince.php
new file mode 100644
index 0000000..308e358
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/TimeSince.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use Icinga\Date\DateFormatter;
+use ipl\Html\BaseHtmlElement;
+
+class TimeSince extends BaseHtmlElement
+{
+ /** @var int */
+ protected $since;
+
+ protected $tag = 'time';
+
+ protected $defaultAttributes = ['class' => 'time-since'];
+
+ public function __construct($since)
+ {
+ $this->since = (int) $since;
+ }
+
+ protected function assemble()
+ {
+ $dateTime = DateFormatter::formatDateTime($this->since);
+
+ $this->addAttributes([
+ 'datetime' => $dateTime,
+ 'title' => $dateTime
+ ]);
+
+ $this->add(DateFormatter::timeSince($this->since));
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/TimeUntil.php b/vendor/ipl/web/src/Widget/TimeUntil.php
new file mode 100644
index 0000000..f16731a
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/TimeUntil.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use Icinga\Date\DateFormatter;
+use ipl\Html\BaseHtmlElement;
+
+class TimeUntil extends BaseHtmlElement
+{
+ /** @var int */
+ protected $until;
+
+ protected $tag = 'time';
+
+ protected $defaultAttributes = ['class' => 'time-until'];
+
+ public function __construct($until)
+ {
+ $this->until = (int) $until;
+ }
+
+ protected function assemble()
+ {
+ $dateTime = DateFormatter::formatDateTime($this->until);
+
+ $this->addAttributes([
+ 'datetime' => $dateTime,
+ 'title' => $dateTime,
+ 'data-ago-label' => DateFormatter::timeAgo(time())
+ ]);
+
+ $this->add(DateFormatter::timeUntil($this->until));
+ }
+}
diff --git a/vendor/ipl/web/src/Widget/VerticalKeyValue.php b/vendor/ipl/web/src/Widget/VerticalKeyValue.php
new file mode 100644
index 0000000..388c740
--- /dev/null
+++ b/vendor/ipl/web/src/Widget/VerticalKeyValue.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace ipl\Web\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+class VerticalKeyValue extends BaseHtmlElement
+{
+ protected $key;
+
+ protected $value;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'vertical-key-value'];
+
+ public function __construct($key, $value)
+ {
+ $this->key = $key;
+ $this->value = $value;
+ }
+
+ protected function assemble()
+ {
+ $this->add([
+ Html::tag('span', ['class' => 'value'], $this->value),
+ Html::tag('br'),
+ Html::tag('span', ['class' => 'key'], $this->key),
+ ]);
+ }
+}