diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:38:04 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:38:04 +0000 |
commit | 1ff5c35de5dbd70a782875a91dd2232fd01b002b (patch) | |
tree | 77d9ce5e1bf78b3e6ef79f8f6e7861e2ced3c09b /vendor/ipl/web/src/Control/SearchBar | |
parent | Initial commit. (diff) | |
download | icinga-php-library-db00f2fd38bc3063de0d7b1412949bc50f248a1f.tar.xz icinga-php-library-db00f2fd38bc3063de0d7b1412949bc50f248a1f.zip |
Adding upstream version 0.10.1.upstream/0.10.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | vendor/ipl/web/src/Control/SearchBar.php | 522 | ||||
-rw-r--r-- | vendor/ipl/web/src/Control/SearchBar/SearchException.php | 9 | ||||
-rw-r--r-- | vendor/ipl/web/src/Control/SearchBar/Suggestions.php | 447 | ||||
-rw-r--r-- | vendor/ipl/web/src/Control/SearchBar/Terms.php | 255 | ||||
-rw-r--r-- | vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php | 44 | ||||
-rw-r--r-- | vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php | 80 | ||||
-rw-r--r-- | vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php | 196 | ||||
-rw-r--r-- | vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php | 41 |
8 files changed, 1594 insertions, 0 deletions
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 @@ +<?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 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()) + ->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 @@ +<?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..eae4c97 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/Suggestions.php @@ -0,0 +1,447 @@ +<?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'), + '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 @@ +<?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..2ca6d77 --- /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($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 @@ +<?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 mixed 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 mixed $searchValue The search value + * @param ?string $label The label + */ + public function __construct($searchValue, $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 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 @@ +<?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; + } +} |