diff options
Diffstat (limited to 'vendor/ipl/web/src/Control/SearchBar.php')
-rw-r--r-- | vendor/ipl/web/src/Control/SearchBar.php | 541 |
1 files changed, 541 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] : [])); + } +} |