summaryrefslogtreecommitdiffstats
path: root/vendor/ipl/web/src/Control
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/ipl/web/src/Control')
-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
12 files changed, 3171 insertions, 0 deletions
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());
+ }
+ }
+}