summaryrefslogtreecommitdiffstats
path: root/vendor/ipl/web/src/Control/SearchBar
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--vendor/ipl/web/src/Control/SearchBar.php541
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/SearchException.php9
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/Suggestions.php451
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/Terms.php255
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php44
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php80
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php196
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php41
8 files changed, 1617 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..ab935ef
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar.php
@@ -0,0 +1,541 @@
+<?php
+
+namespace ipl\Web\Control;
+
+use ipl\Html\Attributes;
+use ipl\Html\Form;
+use ipl\Html\FormElement\HiddenElement;
+use ipl\Html\FormElement\InputElement;
+use ipl\Html\FormElement\SubmitElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Stdlib\Filter;
+use ipl\Validator\CallbackValidator;
+use ipl\Web\Common\FormUid;
+use ipl\Web\Control\SearchBar\Terms;
+use ipl\Web\Control\SearchBar\ValidatedColumn;
+use ipl\Web\Control\SearchBar\ValidatedOperator;
+use ipl\Web\Control\SearchBar\ValidatedValue;
+use ipl\Web\Filter\ParseException;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+
+class SearchBar extends Form
+{
+ use FormUid;
+
+ /** @var string Emitted in case the user added a new condition */
+ const ON_ADD = 'on_add';
+
+ /** @var string Emitted in case the user inserted a new condition */
+ const ON_INSERT = 'on_insert';
+
+ /** @var string Emitted in case the user changed an existing condition */
+ const ON_SAVE = 'on_save';
+
+ /** @var string Emitted in case the user removed a condition */
+ const ON_REMOVE = 'on_remove';
+
+ protected $defaultAttributes = [
+ 'data-enrichment-type' => 'search-bar',
+ 'class' => 'search-bar',
+ 'name' => 'search-bar',
+ 'role' => 'search'
+ ];
+
+ /** @var Url */
+ protected $editorUrl;
+
+ /** @var Filter\Rule */
+ protected $filter;
+
+ /** @var string */
+ protected $searchParameter;
+
+ /** @var Url */
+ protected $suggestionUrl;
+
+ /** @var string */
+ protected $submitLabel;
+
+ /** @var callable */
+ protected $protector;
+
+ /** @var array */
+ protected $changes;
+
+ /**
+ * Set the url from which to load the editor
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setEditorUrl(Url $url)
+ {
+ $this->editorUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * Get the url from which to load the editor
+ *
+ * @return Url
+ */
+ public function getEditorUrl()
+ {
+ return $this->editorUrl;
+ }
+
+ /**
+ * Set the filter to use
+ *
+ * @param Filter\Rule $filter
+ * @return $this
+ */
+ public function setFilter(Filter\Rule $filter)
+ {
+ $this->filter = $filter;
+
+ return $this;
+ }
+
+ /**
+ * Get the filter in use
+ *
+ * @return Filter\Rule
+ */
+ public function getFilter()
+ {
+ return $this->filter;
+ }
+
+ /**
+ * Set the search parameter to use
+ *
+ * @param string $name
+ * @return $this
+ */
+ public function setSearchParameter($name)
+ {
+ $this->searchParameter = $name;
+
+ return $this;
+ }
+
+ /**
+ * Get the search parameter in use
+ *
+ * @return string
+ */
+ public function getSearchParameter()
+ {
+ return $this->searchParameter ?: 'q';
+ }
+
+ /**
+ * Set the suggestion url
+ *
+ * @param Url $url
+ * @return $this
+ */
+ public function setSuggestionUrl(Url $url)
+ {
+ $this->suggestionUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * Get the suggestion url
+ *
+ * @return Url
+ */
+ public function getSuggestionUrl()
+ {
+ return $this->suggestionUrl;
+ }
+
+ /**
+ * Set the submit label
+ *
+ * @param string $label
+ * @return $this
+ */
+ public function setSubmitLabel($label)
+ {
+ $this->submitLabel = $label;
+
+ return $this;
+ }
+
+ /**
+ * Get the submit label
+ *
+ * @return string
+ */
+ public function getSubmitLabel()
+ {
+ return $this->submitLabel;
+ }
+
+ /**
+ * Set callback to protect ids with
+ *
+ * @param callable $protector
+ *
+ * @return $this
+ */
+ public function setIdProtector($protector)
+ {
+ $this->protector = $protector;
+
+ return $this;
+ }
+
+ /**
+ * Get changes to be applied on the client
+ *
+ * @return array
+ */
+ public function getChanges()
+ {
+ return $this->changes;
+ }
+
+ private function protectId($id)
+ {
+ if (is_callable($this->protector)) {
+ return call_user_func($this->protector, $id);
+ }
+
+ return $id;
+ }
+
+ public function populate($values)
+ {
+ if (array_key_exists($this->getSearchParameter(), (array) $values)) {
+ // If a filter is set, it must be reset in case new data arrives. The new data controls the filter,
+ // though if no data is sent, (populate() is only called if the form is sent) then the filter must
+ // be reset explicitly here to not keep the outdated filter.
+ $this->filter = Filter::all();
+ }
+
+ parent::populate($values);
+ }
+
+ public function isValidEvent($event)
+ {
+ switch ($event) {
+ case self::ON_ADD:
+ case self::ON_SAVE:
+ case self::ON_INSERT:
+ case self::ON_REMOVE:
+ return true;
+ default:
+ return parent::isValidEvent($event);
+ }
+ }
+
+ private function validateCondition($eventType, $indices, $termsData, &$changes)
+ {
+ // TODO: In case of the query string validation, all three are guaranteed to be set.
+ // The Parser also provides defaults, why shouldn't we here?
+ $column = ValidatedColumn::fromTermData($termsData[0]);
+ $operator = isset($termsData[1])
+ ? ValidatedOperator::fromTermData($termsData[1])
+ : null;
+ $value = isset($termsData[2])
+ ? ValidatedValue::fromTermData($termsData[2])
+ : null;
+
+ $this->emit($eventType, [$column, $operator, $value]);
+
+ if ($eventType !== self::ON_REMOVE) {
+ if (! $column->isValid() || $column->hasBeenChanged()) {
+ $changes[$indices[0]] = array_merge($termsData[0], $column->toTermData());
+ }
+
+ if ($operator && ! $operator->isValid()) {
+ $changes[$indices[1]] = array_merge($termsData[1], $operator->toTermData());
+ }
+
+ if ($value && (! $value->isValid() || $value->hasBeenChanged())) {
+ $changes[$indices[2]] = array_merge($termsData[2], $value->toTermData());
+ }
+ }
+
+ return $column->isValid() && (! $operator || $operator->isValid()) && (! $value || $value->isValid());
+ }
+
+
+ protected function assemble()
+ {
+ $termContainerId = $this->protectId('terms');
+ $termInputId = $this->protectId('term-input');
+ $dataInputId = $this->protectId('data-input');
+ $searchInputId = $this->protectId('search-input');
+ $suggestionsId = $this->protectId('suggestions');
+
+ $termContainer = (new Terms())->setAttribute('id', $termContainerId);
+ $termInput = new HiddenElement($this->getSearchParameter(), [
+ 'id' => $termInputId,
+ 'disabled' => true
+ ]);
+
+ if (! $this->getRequest()->getHeaderLine('X-Icinga-Autorefresh')) {
+ $termContainer->setFilter(function () {
+ return $this->getFilter();
+ });
+ $termInput->getAttributes()->registerAttributeCallback('value', function () {
+ return QueryString::render($this->getFilter());
+ });
+ }
+
+ $dataInput = new HiddenElement('data', [
+ 'id' => $dataInputId,
+ 'validators' => [
+ new CallbackValidator(function ($data, CallbackValidator $_) use ($termContainer, $searchInputId) {
+ $data = $data ? json_decode($data, true) : null;
+ if (empty($data)) {
+ return true;
+ }
+
+ switch ($data['type']) {
+ case 'add':
+ case 'exchange':
+ $type = self::ON_ADD;
+
+ break;
+ case 'insert':
+ $type = self::ON_INSERT;
+
+ break;
+ case 'save':
+ $type = self::ON_SAVE;
+
+ break;
+ case 'remove':
+ $type = self::ON_REMOVE;
+
+ break;
+ default:
+ return true;
+ }
+
+ $changes = [];
+ $invalid = false;
+ $indices = [null, null, null];
+ $termsData = [null, null, null];
+ foreach (isset($data['terms']) ? $data['terms'] : [] as $termIndex => $termData) {
+ switch ($termData['type']) {
+ case 'column':
+ $indices[0] = $termIndex;
+ $termsData[0] = $termData;
+
+ break;
+ case 'operator':
+ $indices[1] = $termIndex;
+ $termsData[1] = $termData;
+
+ break;
+ case 'value':
+ $indices[2] = $termIndex;
+ $termsData[2] = $termData;
+
+ break;
+ default:
+ if ($termsData[0] !== null) {
+ if (! $this->validateCondition($type, $indices, $termsData, $changes)) {
+ $invalid = true;
+ }
+ }
+
+ $indices = $termsData = [null, null, null];
+ }
+ }
+
+ if ($termsData[0] !== null) {
+ if (! $this->validateCondition($type, $indices, $termsData, $changes)) {
+ $invalid = true;
+ }
+ }
+
+ if (! empty($changes)) {
+ $this->changes = ['#' . $searchInputId, $changes];
+ $termContainer->applyChanges($changes);
+ }
+
+ return ! $invalid;
+ })
+ ]
+ ]);
+ $this->registerElement($dataInput);
+
+ $filterInput = new InputElement($this->getSearchParameter(), [
+ 'type' => 'text',
+ 'placeholder' => 'Type to search. Use * as wildcard.',
+ 'class' => 'filter-input',
+ 'id' => $searchInputId,
+ 'autocomplete' => 'off',
+ 'data-enrichment-type' => 'filter',
+ 'data-data-input' => '#' . $dataInputId,
+ 'data-term-input' => '#' . $termInputId,
+ 'data-term-container' => '#' . $termContainerId,
+ 'data-term-suggestions' => '#' . $suggestionsId,
+ 'data-missing-log-op' => t('Please add a logical operator on the left.'),
+ 'data-incomplete-group' => t('Please close or remove this group.'),
+ 'data-choose-template' => t('Please type one of: %s', '..<comma separated list>'),
+ 'data-choose-column' => t('Please enter a valid column.'),
+ 'validators' => [
+ new CallbackValidator(function ($q, CallbackValidator $validator) use ($searchInputId) {
+ $submitted = $this->hasBeenSubmitted();
+ $invalid = false;
+ $changes = [];
+
+ $parser = QueryString::fromString($q);
+ $parser->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use (
+ &$invalid,
+ &$changes,
+ $submitted
+ ) {
+ $columnIndex = $condition->metaData()->get('columnIndex');
+ if (isset($this->changes[1][$columnIndex])) {
+ $change = $this->changes[1][$columnIndex];
+ $condition->setColumn($change['search']);
+ } elseif (empty($this->changes)) {
+ $column = ValidatedColumn::fromFilterCondition($condition);
+ $operator = ValidatedOperator::fromFilterCondition($condition);
+ $value = ValidatedValue::fromFilterCondition($condition);
+ $this->emit(self::ON_ADD, [$column, $operator, $value]);
+
+ $condition->setColumn($column->getSearchValue());
+ $condition->setValue($value->getSearchValue());
+
+ if (! $column->isValid()) {
+ $invalid = true;
+
+ if ($submitted) {
+ $condition->metaData()->merge($column->toMetaData());
+ } else {
+ $changes[$columnIndex] = $column->toTermData();
+ }
+ }
+
+ if (! $operator->isValid()) {
+ $invalid = true;
+
+ if ($submitted) {
+ $condition->metaData()->merge($operator->toMetaData());
+ } else {
+ $changes[$condition->metaData()->get('operatorIndex')] = $operator->toTermData();
+ }
+ }
+
+ if (! $value->isValid()) {
+ $invalid = true;
+
+ if ($submitted) {
+ $condition->metaData()->merge($value->toMetaData());
+ } else {
+ $changes[$condition->metaData()->get('valueIndex')] = $value->toTermData();
+ }
+ }
+ }
+ });
+
+ try {
+ $filter = $parser->parse();
+ } catch (ParseException $e) {
+ $charAt = $e->getCharPos() - 1;
+ $char = $e->getChar();
+
+ $this->getElement($this->getSearchParameter())
+ ->addAttributes([
+ 'title' => sprintf(t('Unexpected %s at start of input'), $char),
+ 'pattern' => sprintf('^(?!%s).*', $char === ')' ? '\)' : $char),
+ 'data-has-syntax-error' => true
+ ])
+ ->getAttributes()
+ ->registerAttributeCallback('value', function () use ($q, $charAt) {
+ return substr($q, $charAt);
+ });
+
+ $probablyValidQueryString = substr($q, 0, $charAt);
+ $this->setFilter(QueryString::parse($probablyValidQueryString));
+ return false;
+ }
+
+ $this->getElement($this->getSearchParameter())
+ ->getAttributes()
+ ->registerAttributeCallback('value', function () {
+ return '';
+ });
+ $this->setFilter($filter);
+
+ if (! empty($changes)) {
+ $this->changes = ['#' . $searchInputId, $changes];
+ }
+
+ return ! $invalid;
+ })
+ ]
+ ]);
+ if ($this->getSuggestionUrl() !== null) {
+ $filterInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () {
+ return (string) $this->getSuggestionUrl();
+ });
+ }
+
+ $this->registerElement($filterInput);
+
+ $submitButton = new SubmitElement('submit', ['label' => $this->getSubmitLabel() ?: 'hidden']);
+ $this->registerElement($submitButton);
+
+ $editorOpener = null;
+ if ($this->getEditorUrl() !== null) {
+ $editorOpener = new HtmlElement(
+ 'button',
+ Attributes::create([
+ 'type' => 'button',
+ 'class' => 'search-editor-opener control-button',
+ 'title' => t('Adjust Filter')
+ ])->registerAttributeCallback('data-search-editor-url', function () {
+ return (string) $this->getEditorUrl();
+ }),
+ new Icon('cog')
+ );
+ }
+
+ $this->addHtml(
+ new HtmlElement(
+ 'button',
+ Attributes::create(['type' => 'button', 'class' => 'search-options']),
+ new Icon('search')
+ ),
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'filter-input-area']),
+ $termContainer,
+ new HtmlElement('label', Attributes::create(['data-label' => '']), $filterInput)
+ ),
+ $dataInput,
+ $termInput,
+ $submitButton,
+ $this->createUidElement(),
+ new HtmlElement('div', Attributes::create([
+ 'id' => $suggestionsId,
+ 'class' => 'search-suggestions',
+ 'data-base-target' => $suggestionsId
+ ]))
+ );
+
+ // Render the editor container outside of this form. It will contain a form as well later on
+ // loaded by XHR and HTML prohibits nested forms. It's style-wise also better...
+ $doc = new HtmlDocument();
+ $this->prependWrapper($doc);
+ $doc->addHtml($this, ...($editorOpener ? [$editorOpener] : []));
+ }
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar/SearchException.php b/vendor/ipl/web/src/Control/SearchBar/SearchException.php
new file mode 100644
index 0000000..a89c6ce
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar/SearchException.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace ipl\Web\Control\SearchBar;
+
+use Exception;
+
+class SearchException extends Exception
+{
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar/Suggestions.php b/vendor/ipl/web/src/Control/SearchBar/Suggestions.php
new file mode 100644
index 0000000..fe4a2db
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar/Suggestions.php
@@ -0,0 +1,451 @@
+<?php
+
+namespace ipl\Web\Control\SearchBar;
+
+use Countable;
+use Generator;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\FormattedString;
+use ipl\Html\FormElement\ButtonElement;
+use ipl\Html\FormElement\InputElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Contract\Paginatable;
+use ipl\Stdlib\Filter;
+use ipl\Web\Control\SearchEditor;
+use ipl\Web\Filter\QueryString;
+use IteratorIterator;
+use LimitIterator;
+use OuterIterator;
+use Psr\Http\Message\ServerRequestInterface;
+use Traversable;
+
+use function ipl\I18n\t;
+
+abstract class Suggestions extends BaseHtmlElement
+{
+ const DEFAULT_LIMIT = 50;
+ const SUGGESTION_TITLE_CLASS = 'suggestion-title';
+
+ protected $tag = 'ul';
+
+ /** @var string */
+ protected $searchTerm;
+
+ /** @var Traversable */
+ protected $data;
+
+ /** @var array */
+ protected $default;
+
+ /** @var string */
+ protected $type;
+
+ /** @var string */
+ protected $failureMessage;
+
+ public function setSearchTerm($term)
+ {
+ $this->searchTerm = $term;
+
+ return $this;
+ }
+
+ public function setData($data)
+ {
+ $this->data = $data;
+
+ return $this;
+ }
+
+ public function setDefault($default)
+ {
+ $this->default = $default;
+
+ return $this;
+ }
+
+ public function setType($type)
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ public function setFailureMessage($message)
+ {
+ $this->failureMessage = $message;
+
+ return $this;
+ }
+
+ /**
+ * Return whether the relation should be shown for the given column
+ *
+ * @param string $column
+ *
+ * @return bool
+ */
+ protected function shouldShowRelationFor(string $column): bool
+ {
+ return false;
+ }
+
+ /**
+ * Create a filter to provide as default for column suggestions
+ *
+ * @param string $searchTerm
+ *
+ * @return Filter\Rule
+ */
+ abstract protected function createQuickSearchFilter($searchTerm);
+
+ /**
+ * Fetch value suggestions for a particular column
+ *
+ * @param string $column
+ * @param string $searchTerm
+ * @param Filter\Chain $searchFilter
+ *
+ * @return Traversable
+ */
+ abstract protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $searchFilter);
+
+ /**
+ * Fetch column suggestions
+ *
+ * @param string $searchTerm
+ *
+ * @return Traversable
+ */
+ abstract protected function fetchColumnSuggestions($searchTerm);
+
+ protected function filterToTerms(Filter\Chain $filter)
+ {
+ $logicalSep = [
+ 'label' => QueryString::getRuleSymbol($filter),
+ 'search' => QueryString::getRuleSymbol($filter),
+ 'class' => 'logical_operator',
+ 'type' => 'logical_operator'
+ ];
+
+ $terms = [];
+ foreach ($filter as $child) {
+ if ($child instanceof Filter\Chain) {
+ $terms[] = [
+ 'search' => '(',
+ 'label' => '(',
+ 'type' => 'grouping_operator',
+ 'class' => 'grouping_operator_open'
+ ];
+ $terms = array_merge($terms, $this->filterToTerms($child));
+ $terms[] = [
+ 'search' => ')',
+ 'label' => ')',
+ 'type' => 'grouping_operator',
+ 'class' => 'grouping_operator_close'
+ ];
+ } else {
+ /** @var Filter\Condition $child */
+
+ $terms[] = [
+ 'search' => $child->getColumn(),
+ 'label' => $child->metaData()->get('columnLabel') ?? $child->getColumn(),
+ 'type' => 'column'
+ ];
+ $terms[] = [
+ 'search' => QueryString::getRuleSymbol($child),
+ 'label' => QueryString::getRuleSymbol($child),
+ 'type' => 'operator'
+ ];
+ $terms[] = [
+ 'search' => $child->getValue(),
+ 'label' => $child->getValue(),
+ 'type' => 'value'
+ ];
+ }
+
+ $terms[] = $logicalSep;
+ }
+
+ array_pop($terms);
+ return $terms;
+ }
+
+ protected function assembleDefault()
+ {
+ if ($this->default === null) {
+ return;
+ }
+
+ $attributes = [
+ 'type' => 'button',
+ 'tabindex' => -1,
+ 'data-label' => $this->default['search'],
+ 'value' => $this->default['search']
+ ];
+ if (isset($this->default['type'])) {
+ $attributes['data-type'] = $this->default['type'];
+ } elseif ($this->type !== null) {
+ $attributes['data-type'] = $this->type;
+ }
+
+ $button = new ButtonElement(null, $attributes);
+ if (isset($this->default['type']) && $this->default['type'] === 'terms') {
+ $terms = $this->filterToTerms($this->default['terms']);
+ $list = new HtmlElement('ul', Attributes::create(['class' => 'comma-separated']));
+ foreach ($terms as $data) {
+ if ($data['type'] === 'column') {
+ $list->addHtml(new HtmlElement(
+ 'li',
+ null,
+ new HtmlElement('em', null, Text::create($data['label']))
+ ));
+ }
+ }
+
+ $button->setAttribute('data-terms', json_encode($terms));
+ $button->addHtml(FormattedString::create(
+ t('Search for %s in: %s'),
+ new HtmlElement('em', null, Text::create($this->default['search'])),
+ $list
+ ));
+ } else {
+ $button->addHtml(FormattedString::create(
+ t('Search for %s'),
+ new HtmlElement('em', null, Text::create($this->default['search']))
+ ));
+ }
+
+ $this->prependHtml(new HtmlElement('li', Attributes::create(['class' => 'default']), $button));
+ }
+
+ protected function assemble()
+ {
+ if ($this->failureMessage !== null) {
+ $this->addHtml(new HtmlElement(
+ 'li',
+ Attributes::create(['class' => 'failure-message']),
+ new HtmlElement('em', null, Text::create(t('Can\'t search:'))),
+ Text::create($this->failureMessage)
+ ));
+ return;
+ }
+
+ if ($this->data === null) {
+ $data = [];
+ } elseif ($this->data instanceof Paginatable) {
+ $this->data->limit(self::DEFAULT_LIMIT);
+ $data = $this->data;
+ } else {
+ $data = new LimitIterator(new IteratorIterator($this->data), 0, self::DEFAULT_LIMIT);
+ }
+
+ foreach ($data as $term => $meta) {
+ if (is_int($term)) {
+ $term = $meta;
+ }
+
+ $attributes = [
+ 'type' => 'button',
+ 'tabindex' => -1,
+ 'data-search' => $term,
+ 'data-title' => $term
+ ];
+ if ($this->type !== null) {
+ $attributes['data-type'] = $this->type;
+ }
+
+ if (is_array($meta)) {
+ foreach ($meta as $key => $value) {
+ if ($key === 'label') {
+ $label = $value;
+ }
+
+ $attributes['data-' . $key] = $value;
+ }
+ } else {
+ $label = $meta;
+ $attributes['data-label'] = $meta;
+ }
+
+ $button = (new ButtonElement(null, $attributes))
+ ->setAttribute('value', $label)
+ ->addHtml(Text::create($label));
+ if ($this->type === 'column' && $this->shouldShowRelationFor($term)) {
+ $relationPath = substr($term, 0, strrpos($term, '.'));
+ $button->getAttributes()->add('class', 'has-details');
+ $button->addHtml(new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'relation-path']),
+ Text::create($relationPath)
+ ));
+ }
+
+ $this->addHtml(new HtmlElement('li', null, $button));
+ }
+
+ if ($this->hasMore($data, self::DEFAULT_LIMIT)) {
+ $this->getAttributes()->add('class', 'has-more');
+ }
+
+ $showDefault = true;
+ if ($this->searchTerm && $this->count() === 1) {
+ // The default option is only shown if the user's input does not result in an exact match
+ $input = $this->getFirst('li')->getFirst('button');
+ $showDefault = $input->getContent() != $this->searchTerm
+ && $input->getAttributes()->get('data-search')->getValue() != $this->searchTerm;
+ }
+
+ if ($this->type === 'column' && ! $this->isEmpty() && ! $this->getFirst('li')->getAttributes()->has('class')) {
+ // The column title is only added if there are any suggestions and the first item is not a title already
+ $this->prependHtml(new HtmlElement(
+ 'li',
+ Attributes::create(['class' => static::SUGGESTION_TITLE_CLASS]),
+ Text::create(t('Columns'))
+ ));
+ }
+
+ if ($showDefault) {
+ $this->assembleDefault();
+ }
+
+ if (! $this->searchTerm && $this->isEmpty()) {
+ $this->addHtml(new HtmlElement(
+ 'li',
+ Attributes::create(['class' => 'nothing-to-suggest']),
+ new HtmlElement('em', null, Text::create(t('Nothing to suggest')))
+ ));
+ }
+ }
+
+ /**
+ * Load suggestions as requested by the client
+ *
+ * @param ServerRequestInterface $request
+ *
+ * @return $this
+ */
+ public function forRequest(ServerRequestInterface $request)
+ {
+ if ($request->getMethod() !== 'POST') {
+ return $this;
+ }
+
+ $requestData = json_decode($request->getBody()->read(8192), true);
+ if (empty($requestData)) {
+ return $this;
+ }
+
+ $search = $requestData['term']['search'];
+ $label = $requestData['term']['label'];
+ $type = $requestData['term']['type'];
+
+ $this->setSearchTerm($search);
+ $this->setType($type);
+
+ switch ($type) {
+ case 'value':
+ if (! $requestData['column'] || $requestData['column'] === SearchEditor::FAKE_COLUMN) {
+ $this->setFailureMessage(t('Missing column name'));
+ break;
+ }
+
+ $searchFilter = QueryString::parse(
+ isset($requestData['searchFilter'])
+ ? $requestData['searchFilter']
+ : ''
+ );
+ if ($searchFilter instanceof Filter\Condition) {
+ $searchFilter = Filter::all($searchFilter);
+ }
+
+ try {
+ $this->setData($this->fetchValueSuggestions($requestData['column'], $label, $searchFilter));
+ } catch (SearchException $e) {
+ $this->setFailureMessage($e->getMessage());
+ }
+
+ if ($search) {
+ $this->setDefault([
+ 'search' => $requestData['operator'] === '~' || $requestData['operator'] === '!~'
+ ? $label
+ : $search
+ ]);
+ }
+
+ break;
+ case 'column':
+ $this->setData($this->filterColumnSuggestions($this->fetchColumnSuggestions($label), $label));
+
+ if ($search && isset($requestData['showQuickSearch']) && $requestData['showQuickSearch']) {
+ $quickFilter = $this->createQuickSearchFilter($label);
+ if (! $quickFilter instanceof Filter\Chain || ! $quickFilter->isEmpty()) {
+ $this->setDefault([
+ 'search' => $label,
+ 'type' => 'terms',
+ 'terms' => $quickFilter
+ ]);
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ protected function hasMore($data, $than)
+ {
+ if (is_array($data)) {
+ return count($data) > $than;
+ } elseif ($data instanceof Countable) {
+ return $data->count() > $than;
+ } elseif ($data instanceof OuterIterator) {
+ return $this->hasMore($data->getInnerIterator(), $than);
+ }
+
+ return false;
+ }
+
+ /**
+ * Filter the given suggestions by the client's input
+ *
+ * @param Traversable $data
+ * @param string $searchTerm
+ *
+ * @return Generator
+ */
+ protected function filterColumnSuggestions($data, $searchTerm)
+ {
+ foreach ($data as $key => $value) {
+ if ($this->matchSuggestion($key, $value, $searchTerm)) {
+ yield $key => $value;
+ }
+ }
+ }
+
+ /**
+ * Get whether the given suggestion should be provided to the client
+ *
+ * @param string $path
+ * @param string $label
+ * @param string $searchTerm
+ *
+ * @return bool
+ */
+ protected function matchSuggestion($path, $label, $searchTerm)
+ {
+ return fnmatch($searchTerm, $label, FNM_CASEFOLD) || fnmatch($searchTerm, $path, FNM_CASEFOLD);
+ }
+
+ public function renderUnwrapped()
+ {
+ $this->ensureAssembled();
+
+ if ($this->isEmpty()) {
+ return '';
+ }
+
+ return parent::renderUnwrapped();
+ }
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar/Terms.php b/vendor/ipl/web/src/Control/SearchBar/Terms.php
new file mode 100644
index 0000000..c81e336
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar/Terms.php
@@ -0,0 +1,255 @@
+<?php
+
+namespace ipl\Web\Control\SearchBar;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\Icon;
+
+class Terms extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'terms'];
+
+ /** @var callable|Filter\Rule */
+ protected $filter;
+
+ /** @var array */
+ protected $changes;
+
+ /** @var int */
+ private $changeIndexCorrection = 0;
+
+ /** @var int */
+ private $currentIndex = 0;
+
+ public function setFilter($filter)
+ {
+ $this->filter = $filter;
+
+ return $this;
+ }
+
+ /**
+ * Apply term changes
+ *
+ * @param array $changes
+ *
+ * @return $this
+ */
+ public function applyChanges(array $changes)
+ {
+ $this->changes = $changes;
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $filter = $this->filter;
+ if (is_callable($filter)) {
+ $filter = $filter();
+ }
+
+ if ($filter === null) {
+ return;
+ }
+
+ if ($filter instanceof Filter\Chain) {
+ if ($filter->isEmpty()) {
+ return;
+ }
+
+ if ($filter instanceof Filter\None) {
+ $this->assembleChain($filter, $this, $filter->count() > 1);
+ } else {
+ $this->assembleConditions($filter, $this);
+ }
+ } else {
+ /** @var Filter\Condition $filter */
+ $this->assembleCondition($filter, $this);
+ }
+ }
+
+ protected function assembleConditions(Filter\Chain $filters, BaseHtmlElement $where)
+ {
+ foreach ($filters as $i => $filter) {
+ if ($i > 0) {
+ $logicalOperator = QueryString::getRuleSymbol($filters);
+ $this->assembleTerm([
+ 'class' => 'logical_operator',
+ 'type' => 'logical_operator',
+ 'search' => $logicalOperator,
+ 'label' => $logicalOperator
+ ], $where);
+ }
+
+ if ($filter instanceof Filter\Chain) {
+ $this->assembleChain($filter, $where, $filter->count() > 1);
+ } else {
+ /** @var Filter\Condition $filter */
+ $this->assembleCondition($filter, $where);
+ }
+ }
+ }
+
+ protected function assembleChain(Filter\Chain $chain, BaseHtmlElement $where, $wrap = false)
+ {
+ if ($wrap) {
+ $group = new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'filter-chain', 'data-group-type' => 'chain'])
+ );
+ } else {
+ $group = $where;
+ }
+
+ if ($chain instanceof Filter\None) {
+ $this->assembleTerm([
+ 'class' => 'logical_operator',
+ 'type' => 'negation_operator',
+ 'search' => '!',
+ 'label' => '!'
+ ], $where);
+ }
+
+ if ($wrap) {
+ $opening = $this->assembleTerm([
+ 'class' => 'grouping_operator_open',
+ 'type' => 'grouping_operator',
+ 'search' => '(',
+ 'label' => '('
+ ], $group);
+ }
+
+ $this->assembleConditions($chain, $group);
+
+ if ($wrap) {
+ $closing = $this->assembleTerm([
+ 'class' => 'grouping_operator_close',
+ 'type' => 'grouping_operator',
+ 'search' => ')',
+ 'label' => ')'
+ ], $group);
+
+ $opening->addAttributes([
+ 'data-counterpart' => $closing->getAttributes()->get('data-index')->getValue()
+ ]);
+ $closing->addAttributes([
+ 'data-counterpart' => $opening->getAttributes()->get('data-index')->getValue()
+ ]);
+
+ $where->addHtml($group);
+ }
+ }
+
+ protected function assembleCondition(Filter\Condition $filter, BaseHtmlElement $where)
+ {
+ $column = $filter->getColumn();
+ $operator = QueryString::getRuleSymbol($filter);
+ $value = $filter->getValue();
+ $columnLabel = $filter->metaData()->get('columnLabel', $column);
+
+ $group = new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'filter-condition', 'data-group-type' => 'condition']),
+ new HtmlElement('button', Attributes::create(['type' => 'button']), new Icon('trash'))
+ );
+
+ $columnData = [
+ 'class' => 'column',
+ 'type' => 'column',
+ 'search' => rawurlencode($column),
+ 'label' => $columnLabel,
+ 'title' => $column
+ ];
+ if ($filter->metaData()->has('invalidColumnPattern')) {
+ $columnData['pattern'] = $filter->metaData()->get('invalidColumnPattern');
+ if ($filter->metaData()->has('invalidColumnMessage')) {
+ $columnData['invalidMsg'] = $filter->metaData()->get('invalidColumnMessage');
+ }
+ }
+
+ $this->assembleTerm($columnData, $group);
+
+ if ($value !== true) {
+ $operatorData = [
+ 'class' => 'operator',
+ 'type' => 'operator',
+ 'search' => $operator,
+ 'label' => $operator
+ ];
+ if ($filter->metaData()->has('invalidOperatorPattern')) {
+ $operatorData['pattern'] = $filter->metaData()->get('invalidOperatorPattern');
+ if ($filter->metaData()->has('invalidOperatorMessage')) {
+ $operatorData['invalidMsg'] = $filter->metaData()->get('invalidOperatorMessage');
+ }
+ }
+
+ $this->assembleTerm($operatorData, $group);
+
+ if (! empty($value) || ! is_string($value) || ctype_digit($value)) {
+ $valueData = [
+ 'class' => 'value',
+ 'type' => 'value',
+ 'search' => rawurlencode($value),
+ 'label' => $value
+ ];
+ if ($filter->metaData()->has('invalidValuePattern')) {
+ $valueData['pattern'] = $filter->metaData()->get('invalidValuePattern');
+ if ($filter->metaData()->has('invalidValueMessage')) {
+ $valueData['invalidMsg'] = $filter->metaData()->get('invalidValueMessage');
+ }
+ }
+
+ $this->assembleTerm($valueData, $group);
+ }
+ }
+
+ $where->addHtml($group);
+ }
+
+ protected function assembleTerm(array $data, BaseHtmlElement $where)
+ {
+ if (isset($this->changes[$this->currentIndex - $this->changeIndexCorrection])) {
+ $change = $this->changes[$this->currentIndex - $this->changeIndexCorrection];
+ if ($change['type'] !== $data['type']) {
+ // This can happen because the user didn't insert parentheses but the parser did
+ $this->changeIndexCorrection++;
+ } else {
+ $data = array_merge($data, $change);
+ }
+ }
+
+ $term = new HtmlElement('label', Attributes::create([
+ 'class' => $data['class'],
+ 'data-index' => $this->currentIndex++,
+ 'data-type' => $data['type'],
+ 'data-search' => $data['search'],
+ 'data-label' => $data['label']
+ ]), new HtmlElement('input', Attributes::create([
+ 'type' => 'text',
+ 'value' => $data['label']
+ ])));
+
+ if (isset($data['title'])) {
+ $term->setAttribute('title', $data['title']);
+ }
+
+ if (isset($data['pattern'])) {
+ $term->getFirst('input')->setAttribute('pattern', $data['pattern']);
+
+ if (isset($data['invalidMsg'])) {
+ $term->getFirst('input')->setAttribute('data-invalid-msg', $data['invalidMsg']);
+ }
+ }
+
+ $where->addHtml($term);
+
+ return $term;
+ }
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php
new file mode 100644
index 0000000..5825790
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace ipl\Web\Control\SearchBar;
+
+use ipl\Stdlib\Data;
+use ipl\Stdlib\Filter\Condition;
+
+class ValidatedColumn extends ValidatedTerm
+{
+ /**
+ * Create a new ValidatedColumn from the given filter condition
+ *
+ * @param Condition $condition
+ *
+ * @return static
+ */
+ public static function fromFilterCondition(Condition $condition)
+ {
+ return new static($condition->getColumn(), $condition->metaData()->get('columnLabel'));
+ }
+
+ public function toTermData()
+ {
+ $termData = parent::toTermData();
+ $termData['type'] = 'column';
+
+ return $termData;
+ }
+
+ public function toMetaData()
+ {
+ $data = new Data();
+ if (($label = $this->getLabel()) !== null) {
+ $data->set('columnLabel', $label);
+ }
+
+ if (! $this->isValid()) {
+ $data->set('invalidColumnMessage', $this->getMessage())
+ ->set('invalidColumnPattern', $this->getPattern());
+ }
+
+ return $data;
+ }
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php
new file mode 100644
index 0000000..67fdbf0
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace ipl\Web\Control\SearchBar;
+
+use InvalidArgumentException;
+use ipl\Stdlib\Data;
+use ipl\Stdlib\Filter;
+use LogicException;
+
+class ValidatedOperator extends ValidatedTerm
+{
+ /**
+ * Create a new ValidatedColumn from the given filter condition
+ *
+ * @param Filter\Condition $condition
+ *
+ * @return static
+ *
+ * @throws InvalidArgumentException In case the condition type is unknown
+ */
+ public static function fromFilterCondition(Filter\Condition $condition)
+ {
+ switch (true) {
+ case $condition instanceof Filter\Unlike:
+ case $condition instanceof Filter\Unequal:
+ $operator = '!=';
+ break;
+ case $condition instanceof Filter\Like:
+ case $condition instanceof Filter\Equal:
+ $operator = '=';
+ break;
+ case $condition instanceof Filter\GreaterThan:
+ $operator = '>';
+ break;
+ case $condition instanceof Filter\LessThan:
+ $operator = '<';
+ break;
+ case $condition instanceof Filter\GreaterThanOrEqual:
+ $operator = '>=';
+ break;
+ case $condition instanceof Filter\LessThanOrEqual:
+ $operator = '<=';
+ break;
+ default:
+ throw new InvalidArgumentException('Unknown condition type');
+ }
+
+ return new static($operator);
+ }
+
+ public function toTermData()
+ {
+ $termData = parent::toTermData();
+ $termData['type'] = 'operator';
+
+ return $termData;
+ }
+
+ public function toMetaData()
+ {
+ $data = new Data();
+
+ if (! $this->isValid()) {
+ $data->set('invalidOperatorMessage', $this->getMessage())
+ ->set('invalidOperatorPattern', $this->getPattern());
+ }
+
+ return $data;
+ }
+
+ public function setSearchValue(string $searchValue): ValidatedTerm
+ {
+ throw new LogicException('Operators cannot be changed');
+ }
+
+ public function setLabel(?string $label): ValidatedTerm
+ {
+ throw new LogicException('Operators cannot be changed');
+ }
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php
new file mode 100644
index 0000000..f616880
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php
@@ -0,0 +1,196 @@
+<?php
+
+namespace ipl\Web\Control\SearchBar;
+
+use ipl\Stdlib\Data;
+
+abstract class ValidatedTerm
+{
+ /** @var string The default validation constraint */
+ const DEFAULT_PATTERN = '^\s*(?!%s\b).*\s*$';
+
+ /** @var string The search value */
+ protected $searchValue;
+
+ /** @var ?string The label */
+ protected $label;
+
+ /** @var ?string The validation message */
+ protected $message;
+
+ /** @var ?string The validation constraint */
+ protected $pattern;
+
+ /** @var bool Whether the term has been adjusted */
+ protected $changed = false;
+
+ /**
+ * Create a new ValidatedTerm
+ *
+ * @param string $searchValue The search value
+ * @param ?string $label The label
+ */
+ public function __construct(string $searchValue, ?string $label = null)
+ {
+ $this->searchValue = $searchValue;
+ $this->label = $label;
+ }
+
+ /**
+ * Create a new ValidatedTerm from the given data
+ *
+ * @param array $data
+ *
+ * @return static
+ */
+ public static function fromTermData(array $data)
+ {
+ return new static($data['search'], isset($data['label']) ? $data['label'] : null);
+ }
+
+ /**
+ * Check whether the term is valid
+ *
+ * @return bool
+ */
+ public function isValid()
+ {
+ return $this->message === null;
+ }
+
+ /**
+ * Check whether the term has been adjusted
+ *
+ * @return bool
+ */
+ public function hasBeenChanged()
+ {
+ return $this->changed;
+ }
+
+ /**
+ * Get the search value
+ *
+ * @return string
+ */
+ public function getSearchValue(): string
+ {
+ return $this->searchValue;
+ }
+
+ /**
+ * Set the search value
+ *
+ * @param string $searchValue
+ *
+ * @return $this
+ */
+ public function setSearchValue(string $searchValue): self
+ {
+ $this->searchValue = $searchValue;
+ $this->changed = true;
+
+ return $this;
+ }
+
+ /**
+ * Get the label
+ *
+ * @return string
+ */
+ public function getLabel(): ?string
+ {
+ return $this->label;
+ }
+
+ /**
+ * Set the label
+ *
+ * @param ?string $label
+ *
+ * @return $this
+ */
+ public function setLabel(?string $label): self
+ {
+ $this->label = $label;
+ $this->changed = true;
+
+ return $this;
+ }
+
+ /**
+ * Get the validation message
+ *
+ * @return ?string
+ */
+ public function getMessage(): ?string
+ {
+ return $this->message;
+ }
+
+ /**
+ * Set the validation message
+ *
+ * @param string $message
+ *
+ * @return $this
+ */
+ public function setMessage(string $message): self
+ {
+ $this->message = $message;
+
+ return $this;
+ }
+
+ /**
+ * Get the validation constraint
+ *
+ * Returns the default constraint if none is set.
+ *
+ * @return string
+ */
+ public function getPattern(): ?string
+ {
+ if ($this->message === null) {
+ return null;
+ }
+
+ return $this->pattern ?? sprintf(self::DEFAULT_PATTERN, $this->getLabel() ?: $this->getSearchValue());
+ }
+
+ /**
+ * Set the validation constraint
+ *
+ * @param string $pattern
+ *
+ * @return $this
+ */
+ public function setPattern(string $pattern): self
+ {
+ $this->pattern = $pattern;
+
+ return $this;
+ }
+
+ /**
+ * Get this term's data
+ *
+ * @return array
+ */
+ public function toTermData()
+ {
+ return [
+ 'search' => $this->getSearchValue(),
+ 'label' => $this->getLabel() ?: $this->getSearchValue(),
+ 'invalidMsg' => $this->getMessage(),
+ 'pattern' => $this->getPattern()
+ ];
+ }
+
+ /**
+ * Get this term's metadata
+ *
+ * @return Data
+ */
+ abstract public function toMetaData();
+}
diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php
new file mode 100644
index 0000000..423102d
--- /dev/null
+++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace ipl\Web\Control\SearchBar;
+
+use ipl\Stdlib\Data;
+use ipl\Stdlib\Filter\Condition;
+
+class ValidatedValue extends ValidatedTerm
+{
+ /**
+ * Create a new ValidatedColumn from the given filter condition
+ *
+ * @param Condition $condition
+ *
+ * @return static
+ */
+ public static function fromFilterCondition(Condition $condition)
+ {
+ return new static($condition->getValue());
+ }
+
+ public function toTermData()
+ {
+ $termData = parent::toTermData();
+ $termData['type'] = 'value';
+
+ return $termData;
+ }
+
+ public function toMetaData()
+ {
+ $data = new Data();
+
+ if (! $this->isValid()) {
+ $data->set('invalidValueMessage', $this->getMessage())
+ ->set('invalidValuePattern', $this->getPattern());
+ }
+
+ return $data;
+ }
+}