From 1ff5c35de5dbd70a782875a91dd2232fd01b002b Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 28 Apr 2024 14:38:04 +0200 Subject: Adding upstream version 0.10.1. Signed-off-by: Daniel Baumann --- vendor/ipl/web/src/Control/LimitControl.php | 123 ++++ vendor/ipl/web/src/Control/PaginationControl.php | 523 +++++++++++++++++ vendor/ipl/web/src/Control/SearchBar.php | 522 +++++++++++++++++ .../web/src/Control/SearchBar/SearchException.php | 9 + .../ipl/web/src/Control/SearchBar/Suggestions.php | 447 +++++++++++++++ vendor/ipl/web/src/Control/SearchBar/Terms.php | 255 +++++++++ .../web/src/Control/SearchBar/ValidatedColumn.php | 44 ++ .../src/Control/SearchBar/ValidatedOperator.php | 80 +++ .../web/src/Control/SearchBar/ValidatedTerm.php | 196 +++++++ .../web/src/Control/SearchBar/ValidatedValue.php | 41 ++ vendor/ipl/web/src/Control/SearchEditor.php | 617 +++++++++++++++++++++ vendor/ipl/web/src/Control/SortControl.php | 251 +++++++++ 12 files changed, 3108 insertions(+) create mode 100644 vendor/ipl/web/src/Control/LimitControl.php create mode 100644 vendor/ipl/web/src/Control/PaginationControl.php create mode 100644 vendor/ipl/web/src/Control/SearchBar.php create mode 100644 vendor/ipl/web/src/Control/SearchBar/SearchException.php create mode 100644 vendor/ipl/web/src/Control/SearchBar/Suggestions.php create mode 100644 vendor/ipl/web/src/Control/SearchBar/Terms.php create mode 100644 vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php create mode 100644 vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php create mode 100644 vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php create mode 100644 vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php create mode 100644 vendor/ipl/web/src/Control/SearchEditor.php create mode 100644 vendor/ipl/web/src/Control/SortControl.php (limited to 'vendor/ipl/web/src/Control') 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 @@ + '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 @@ +... + */ +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..2b18a83 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar.php @@ -0,0 +1,522 @@ + '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 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', '..'), + '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()) + ->setValue(substr($q, $charAt)) + ->addAttributes([ + 'title' => sprintf(t('Unexpected %s at start of input'), $char), + 'pattern' => sprintf('^(?!%s).*', $char === ')' ? '\)' : $char), + 'data-has-syntax-error' => true + ]); + + $probablyValidQueryString = substr($q, 0, $charAt); + $this->setFilter(QueryString::parse($probablyValidQueryString)); + return false; + } + + $this->getElement($this->getSearchParameter())->setValue(''); + $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->setWrapper($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 @@ +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'), + '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' => $label]); + } + + 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 @@ + '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 @@ +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..2ca6d77 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php @@ -0,0 +1,80 @@ +'; + 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($searchValue) + { + throw new LogicException('Operators cannot be changed'); + } + + public function setLabel($label) + { + 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..e552552 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php @@ -0,0 +1,196 @@ +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 mixed + */ + public function getSearchValue() + { + return $this->searchValue; + } + + /** + * Set the search value + * + * @param mixed $searchValue + * + * @return $this + */ + public function setSearchValue($searchValue) + { + $this->searchValue = $searchValue; + $this->changed = true; + + return $this; + } + + /** + * Get the label + * + * @return string + */ + public function getLabel() + { + return $this->label; + } + + /** + * Set the label + * + * @param string $label + * + * @return $this + */ + public function setLabel($label) + { + $this->label = (string) $label; + $this->changed = true; + + return $this; + } + + /** + * Get the validation message + * + * @return string + */ + public function getMessage() + { + return $this->message; + } + + /** + * Set the validation message + * + * @param ?string $message + * + * @return $this + */ + public function setMessage($message) + { + $this->message = $message; + + return $this; + } + + /** + * Get the validation constraint + * + * Returns the default constraint if none is set. + * + * @return string + */ + public function getPattern() + { + if ($this->pattern === null) { + return sprintf(self::DEFAULT_PATTERN, $this->getSearchValue()); + } + + return $this->pattern; + } + + /** + * Set the validation constraint + * + * @param string $pattern + * + * @return $this + */ + public function setPattern($pattern) + { + $this->pattern = (string) $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 @@ +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..dfcde2e --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchEditor.php @@ -0,0 +1,617 @@ + '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 '=': + if (is_string($value) && strpos($value, '*') !== false) { + return Filter::like($column, $value); + } + + return Filter::equal($column, $value); + case '!=': + if (is_string($value) && strpos($value, '*') !== false) { + return Filter::unlike($column, $value); + } + + 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..19954b3 --- /dev/null +++ b/vendor/ipl/web/src/Control/SortControl.php @@ -0,0 +1,251 @@ + 'icinga-form inline sort-control']; + + /** @var string Name of the URL parameter which stores the sort column */ + protected $sortParam = self::DEFAULT_SORT_PARAM; + + /** @var Url Request URL */ + 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 Url $url Request URL + */ + public function __construct(Url $url) + { + $this->url = $url; + } + + /** + * Create a new sort control with the given options + * + * @param array $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; + } + + return (new static(Url::fromRequest())) + ->setColumns($normalized); + } + + /** + * Get the possible sort columns + * + * @return array Sort string-value pairs + */ + public function getColumns() + { + return $this->columns; + } + + /** + * Set the possible sort columns + * + * @param array $columns Sort string-value pairs + * + * @return $this + */ + public function setColumns(array $columns) + { + // 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() + { + return $this->default; + } + + /** + * Set the default sort string + * + * @param array|string $default + * + * @return $this + */ + public function setDefault($default) + { + // 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() + { + return $this->sortParam; + } + + /** + * Set the name of the URL parameter which stores the sort + * + * @param string $sortParam + * + * @return $this + */ + public function setSortParam($sortParam) + { + $this->sortParam = $sortParam; + + return $this; + } + + /** + * Get the sort string + * + * @return string|null + */ + public function getSort() + { + $sort = $this->url->getParam($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)) { + $sort = $key; + + break; + } + } + } + } + + return $sort; + } + + /** + * Sort the given query according to the request + * + * @param Query $query + * + * @return $this + */ + public function apply(Query $query) + { + $default = (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 + ]) + ->getElement($this->getSortParam()) + ->getWrapper() + ->getAttributes() + ->add('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->addElement($toggleButton); + } +} -- cgit v1.2.3