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/SearchEditor.php | |
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/SearchEditor.php | 617 |
1 files changed, 617 insertions, 0 deletions
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 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\FormDecorator\CallbackDecorator; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Stdlib\Events; +use ipl\Stdlib\Filter; +use ipl\Web\Control\SearchBar\SearchException; +use ipl\Web\Filter\Parser; +use ipl\Web\Filter\QueryString; +use ipl\Web\Filter\Renderer; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; + +class SearchEditor extends Form +{ + use Events; + + /** @var string Emitted for every validated column */ + const ON_VALIDATE_COLUMN = 'validate-column'; + + /** @var string The column name used for empty conditions */ + const FAKE_COLUMN = '_fake_'; + + protected $defaultAttributes = [ + 'data-enrichment-type' => 'search-editor', + 'class' => 'search-editor' + ]; + + /** @var string */ + protected $queryString; + + /** @var Url */ + protected $suggestionUrl; + + /** @var Parser */ + protected $parser; + + /** @var Filter\Rule */ + protected $filter; + + /** @var bool */ + protected $cleared = false; + + /** + * Set the filter query string to populate the form with + * + * Use {@see SearchEditor::getParser()} to subscribe to parser events. + * + * @param string $query + * + * @return $this + */ + public function setQueryString($query) + { + $this->queryString = $query; + + return $this; + } + + /** + * Get the suggestion url + * + * @return ?Url + */ + public function getSuggestionUrl(): ?Url + { + return $this->suggestionUrl; + } + + /** + * Set the suggestion url + * + * @param Url $url + * + * @return $this + */ + public function setSuggestionUrl(Url $url) + { + $this->suggestionUrl = $url; + + return $this; + } + + /** + * Get the query string parser being used + * + * @return Parser + */ + public function getParser() + { + if ($this->parser === null) { + $this->parser = new Parser(); + } + + return $this->parser; + } + + /** + * Get the current filter + * + * @return Filter\Rule + */ + public function getFilter() + { + if ($this->filter === null) { + $this->filter = $this->getParser() + ->setQueryString($this->queryString) + ->parse(); + } + + return $this->filter; + } + + public function populate($values) + { + // applyChanges() is basically this form's own populate implementation, hence + // why it changes $values and needs to run before actually populating the form + $filter = (new Parser(isset($values['filter']) ? $values['filter'] : $this->queryString)) + ->setStrict() + ->parse(); + $filter = $this->applyChanges($filter, $values); + + parent::populate($values); + + $this->filter = $this->applyStructuralChange($filter); + if ($this->filter !== null && ($this->filter instanceof Filter\Condition || ! $this->filter->isEmpty())) { + $this->queryString = (new Renderer($this->filter))->setStrict()->render(); + } else { + $this->queryString = ''; + } + + return $this; + } + + public function hasBeenSubmitted() + { + if (parent::hasBeenSubmitted()) { + return true; + } + + return $this->cleared; + } + + public function validate() + { + if ($this->cleared) { + $this->isValid = true; + } else { + parent::validate(); + } + + return $this; + } + + protected function applyChanges(Filter\Rule $rule, array &$values, array $path = [0]) + { + $identifier = 'rule-' . join('-', $path); + + if ($rule instanceof Filter\Condition) { + $newColumn = $this->popKey($values, $identifier . '-column-search'); + if ($newColumn === null) { + $newColumn = $this->popKey($values, $identifier . '-column'); + } else { + // Make sure we don't forget to present the column labels again + $rule->metaData()->set('columnLabel', $this->popKey($values, $identifier . '-column')); + } + + if ($newColumn !== null && $rule->getColumn() !== $newColumn) { + $rule->setColumn($newColumn ?: static::FAKE_COLUMN); + // TODO: Clear meta data? + } + + $newValue = $this->popKey($values, $identifier . '-value'); + $oldValue = $rule->getValue(); + if ($newValue !== null && $oldValue !== $newValue) { + $rule->setValue($newValue); + } + + $newOperator = $this->popKey($values, $identifier . '-operator'); + if ($newOperator !== null && QueryString::getRuleSymbol($rule) !== $newOperator) { + $value = $rule->getValue(); + $column = $rule->getColumn(); + switch ($newOperator) { + case '=': + 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; + } +} |