From daf77b4ce87a295be34864f2b9c828252d1f9f1a Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 3 Aug 2022 10:36:32 +0200 Subject: [PATCH 01/12] SearchBar: Always apply term changes during validation --- src/Control/SearchBar.php | 14 ++++++-------- src/Control/SearchBar/ValidatedTerm.php | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Control/SearchBar.php b/src/Control/SearchBar.php index 2b18a8342..5a1b88e1f 100644 --- a/src/Control/SearchBar.php +++ b/src/Control/SearchBar.php @@ -246,7 +246,7 @@ private function validateCondition($eventType, $indices, $termsData, &$changes) $changes[$indices[0]] = array_merge($termsData[0], $column->toTermData()); } - if ($operator && ! $operator->isValid()) { + if ($operator && (! $operator->isValid() || $operator->hasBeenChanged())) { $changes[$indices[1]] = array_merge($termsData[1], $operator->toTermData()); } @@ -402,9 +402,11 @@ protected function assemble() $condition->setColumn($column->getSearchValue()); $condition->setValue($value->getSearchValue()); - if (! $column->isValid()) { + if (! $column->isValid() || ! $operator->isValid() || ! $value->isValid()) { $invalid = true; + } + if (! $column->isValid() || $column->hasBeenChanged()) { if ($submitted) { $condition->metaData()->merge($column->toMetaData()); } else { @@ -412,9 +414,7 @@ protected function assemble() } } - if (! $operator->isValid()) { - $invalid = true; - + if (! $operator->isValid() || $operator->hasBeenChanged()) { if ($submitted) { $condition->metaData()->merge($operator->toMetaData()); } else { @@ -422,9 +422,7 @@ protected function assemble() } } - if (! $value->isValid()) { - $invalid = true; - + if (! $value->isValid() || $value->hasBeenChanged()) { if ($submitted) { $condition->metaData()->merge($value->toMetaData()); } else { diff --git a/src/Control/SearchBar/ValidatedTerm.php b/src/Control/SearchBar/ValidatedTerm.php index e5525523b..d6bea69fe 100644 --- a/src/Control/SearchBar/ValidatedTerm.php +++ b/src/Control/SearchBar/ValidatedTerm.php @@ -183,7 +183,7 @@ public function toTermData() 'search' => $this->getSearchValue(), 'label' => $this->getLabel() ?: $this->getSearchValue(), 'invalidMsg' => $this->getMessage(), - 'pattern' => $this->getPattern() + 'pattern' => $this->isValid() ? null : $this->getPattern() ]; } From 0a5a88c5c649430493ba65ded51ac5e16221bfe7 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 4 Aug 2022 12:54:16 +0200 Subject: [PATCH 02/12] QueryString: Add static method `createCondition()` --- src/Filter/Parser.php | 25 +----------------------- src/Filter/QueryString.php | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/src/Filter/Parser.php b/src/Filter/Parser.php index 248b41c82..09ba4f2b7 100644 --- a/src/Filter/Parser.php +++ b/src/Filter/Parser.php @@ -512,30 +512,7 @@ protected function nextChar() */ protected function createCondition($column, $operator, $value) { - $column = trim($column); - - switch ($operator) { - 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); - } + return QueryString::createCondition($column, $operator, $value); } /** diff --git a/src/Filter/QueryString.php b/src/Filter/QueryString.php index 235bf38db..f52500fb8 100644 --- a/src/Filter/QueryString.php +++ b/src/Filter/QueryString.php @@ -89,4 +89,44 @@ public static function getRuleSymbol(Filter\Rule $rule) throw new InvalidArgumentException('Unknown rule type provided'); } } + + /** + * Create and return a condition + * + * @param string $column + * @param string $operator + * @param mixed $value + * + * @return Filter\Condition + * @throws InvalidArgumentException In case the operator is invalid + */ + public static function createCondition(string $column, string $operator, $value): Filter\Condition + { + $column = trim($column); + + switch ($operator) { + 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); + default: + throw new InvalidArgumentException(sprintf("Invalid operator '%s'", $operator)); + } + } } From 4b736aa66a8b2b0903572a982970c6047f9ddda0 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 4 Aug 2022 13:06:55 +0200 Subject: [PATCH 03/12] ValidatedTerm: Properly escape search values in the pattern --- src/Control/SearchBar/ValidatedTerm.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Control/SearchBar/ValidatedTerm.php b/src/Control/SearchBar/ValidatedTerm.php index d6bea69fe..2706d2016 100644 --- a/src/Control/SearchBar/ValidatedTerm.php +++ b/src/Control/SearchBar/ValidatedTerm.php @@ -152,7 +152,12 @@ public function setMessage($message) public function getPattern() { if ($this->pattern === null) { - return sprintf(self::DEFAULT_PATTERN, $this->getSearchValue()); + // The search value might contain special characters. preg_quote can't be used here, + // since this is not a PCRE pattern and is evaluated by browsers. The pattern used + // here is from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + $searchValue = preg_replace('/[.*+?^${}()|[\\]\\\\]/', '\\\\$0', $this->getSearchValue()); + + return sprintf(self::DEFAULT_PATTERN, $searchValue); } return $this->pattern; From 4291453f87820553eb9b79a29c4127f9baf31a77 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 4 Aug 2022 13:07:37 +0200 Subject: [PATCH 04/12] ValidatedTerm: Check for word boundaries in the pattern.. ..only when dealing with words --- src/Control/SearchBar/ValidatedTerm.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Control/SearchBar/ValidatedTerm.php b/src/Control/SearchBar/ValidatedTerm.php index 2706d2016..f9bf545ef 100644 --- a/src/Control/SearchBar/ValidatedTerm.php +++ b/src/Control/SearchBar/ValidatedTerm.php @@ -7,7 +7,7 @@ abstract class ValidatedTerm { /** @var string The default validation constraint */ - const DEFAULT_PATTERN = '^\s*(?!%s\b).*\s*$'; + const DEFAULT_PATTERN = '^\s*(?!%s).*\s*$'; /** @var mixed The search value */ protected $searchValue; @@ -157,6 +157,11 @@ public function getPattern() // here is from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping $searchValue = preg_replace('/[.*+?^${}()|[\\]\\\\]/', '\\\\$0', $this->getSearchValue()); + if (ctype_alnum(substr($searchValue, -1))) { + // The word boundary check is only necessary when dealing with ... words + $searchValue .= '\\b'; + } + return sprintf(self::DEFAULT_PATTERN, $searchValue); } From 2cf813b5b30e4106945d5364616b97a96b88abba Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 4 Aug 2022 13:12:35 +0200 Subject: [PATCH 05/12] Suggestions: Require to implement method `getQuery()` This is a breaking change --- src/Control/SearchBar/Suggestions.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Control/SearchBar/Suggestions.php b/src/Control/SearchBar/Suggestions.php index 708d846d6..af5b8c0a2 100644 --- a/src/Control/SearchBar/Suggestions.php +++ b/src/Control/SearchBar/Suggestions.php @@ -8,9 +8,9 @@ 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\Orm\Query; use ipl\Stdlib\Contract\Paginatable; use ipl\Stdlib\Filter; use ipl\Web\Control\SearchEditor; @@ -78,6 +78,13 @@ public function setFailureMessage($message) return $this; } + /** + * Get the query that's used to fetch suggestions + * + * @return Query + */ + abstract public function getQuery(): Query; + /** * Return whether the relation should be shown for the given column * From 245e2f5de2cc0a93884d82c1763a4054194fc309 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 4 Aug 2022 13:15:37 +0200 Subject: [PATCH 06/12] Suggestions: Drop support for data sources to provide meta data It was meant for this feature but is now not required at all --- src/Control/SearchBar/Suggestions.php | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/Control/SearchBar/Suggestions.php b/src/Control/SearchBar/Suggestions.php index af5b8c0a2..a598fc93c 100644 --- a/src/Control/SearchBar/Suggestions.php +++ b/src/Control/SearchBar/Suggestions.php @@ -247,9 +247,9 @@ protected function assemble() $data = new LimitIterator(new IteratorIterator($this->data), 0, self::DEFAULT_LIMIT); } - foreach ($data as $term => $meta) { + foreach ($data as $term => $label) { if (is_int($term)) { - $term = $meta; + $term = $label; } $attributes = [ @@ -262,18 +262,7 @@ protected function assemble() $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; - } + $attributes['data-label'] = $label; $button = (new ButtonElement(null, $attributes)) ->setAttribute('value', $label) From f290d37832325b1bb4ded99e7f23056f39c6f0df Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 4 Aug 2022 13:19:03 +0200 Subject: [PATCH 07/12] Suggestions: Register column definition when showing value suggestions --- src/Control/SearchBar/Suggestions.php | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Control/SearchBar/Suggestions.php b/src/Control/SearchBar/Suggestions.php index a598fc93c..97ff3896c 100644 --- a/src/Control/SearchBar/Suggestions.php +++ b/src/Control/SearchBar/Suggestions.php @@ -10,6 +10,7 @@ use ipl\Html\FormElement\ButtonElement; use ipl\Html\HtmlElement; use ipl\Html\Text; +use ipl\Orm\ColumnDefinition; use ipl\Orm\Query; use ipl\Stdlib\Contract\Paginatable; use ipl\Stdlib\Filter; @@ -43,6 +44,9 @@ abstract class Suggestions extends BaseHtmlElement /** @var string */ protected $failureMessage; + /** @var ColumnDefinition */ + protected $columnDefinition; + public function setSearchTerm($term) { $this->searchTerm = $term; @@ -78,6 +82,13 @@ public function setFailureMessage($message) return $this; } + public function setColumnDefinition(ColumnDefinition $definition): self + { + $this->columnDefinition = $definition; + + return $this; + } + /** * Get the query that's used to fetch suggestions * @@ -338,11 +349,13 @@ public function forRequest(ServerRequestInterface $request) break; } - $searchFilter = QueryString::parse( - isset($requestData['searchFilter']) - ? $requestData['searchFilter'] - : '' + $this->setColumnDefinition( + $this->getQuery() + ->getResolver() + ->getColumnDefinition($requestData['column']) ); + + $searchFilter = QueryString::parse($requestData['searchFilter'] ?? ''); if ($searchFilter instanceof Filter\Condition) { $searchFilter = Filter::all($searchFilter); } From 348f0967abe0aabbde93798694e000ce34a83a94 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 4 Aug 2022 13:20:52 +0200 Subject: [PATCH 08/12] Suggestions: Hide wildcards in default suggestions.. ..for operators with no support for wildcards --- src/Control/SearchBar/Suggestions.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Control/SearchBar/Suggestions.php b/src/Control/SearchBar/Suggestions.php index 97ff3896c..3ecd0de4a 100644 --- a/src/Control/SearchBar/Suggestions.php +++ b/src/Control/SearchBar/Suggestions.php @@ -367,7 +367,15 @@ public function forRequest(ServerRequestInterface $request) } if ($search) { - $this->setDefault(['search' => $label]); + if ($requestData['operator'] !== '=' && $requestData['operator'] !== '!=') { + // The transferred label usually contains automatically added wildcards. + // The search term on the other hand may also have some, but then they + // were explicitly added by the user. This ensures only the latter is + // suggested as default. + $this->setDefault(['search' => $search]); + } else { + $this->setDefault(['search' => $label]); + } } break; From 2b545071f3f58eef5d25e3d13c1577161e903c5e Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 4 Aug 2022 13:29:14 +0200 Subject: [PATCH 09/12] SearchBar: Provide defaults during data validation --- src/Control/SearchBar.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Control/SearchBar.php b/src/Control/SearchBar.php index 5a1b88e1f..686738742 100644 --- a/src/Control/SearchBar.php +++ b/src/Control/SearchBar.php @@ -229,15 +229,13 @@ public function 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; + : new ValidatedOperator('='); $value = isset($termsData[2]) ? ValidatedValue::fromTermData($termsData[2]) - : null; + : new ValidatedValue(true); $this->emit($eventType, [$column, $operator, $value]); From 357f93e535ed66e28703698f79aa91c4c7a98646 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 4 Aug 2022 13:30:30 +0200 Subject: [PATCH 10/12] SearchBar: Pass a condition object to validation handlers --- src/Control/SearchBar.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Control/SearchBar.php b/src/Control/SearchBar.php index 686738742..99914e78f 100644 --- a/src/Control/SearchBar.php +++ b/src/Control/SearchBar.php @@ -236,8 +236,13 @@ private function validateCondition($eventType, $indices, $termsData, &$changes) $value = isset($termsData[2]) ? ValidatedValue::fromTermData($termsData[2]) : new ValidatedValue(true); + $condition = QueryString::createCondition( + $column->getSearchValue(), + $operator->getSearchValue(), + $value->getSearchValue() + ); - $this->emit($eventType, [$column, $operator, $value]); + $this->emit($eventType, [$column, $operator, $value, $condition]); if ($eventType !== self::ON_REMOVE) { if (! $column->isValid() || $column->hasBeenChanged()) { @@ -395,7 +400,9 @@ protected function assemble() $column = ValidatedColumn::fromFilterCondition($condition); $operator = ValidatedOperator::fromFilterCondition($condition); $value = ValidatedValue::fromFilterCondition($condition); - $this->emit(self::ON_ADD, [$column, $operator, $value]); + + // $condition is cloned as validators shouldn't be able to change it directly + $this->emit(self::ON_ADD, [$column, $operator, $value, clone $condition]); $condition->setColumn($column->getSearchValue()); $condition->setValue($value->getSearchValue()); From b4498bfa0c4b68157a46919c4ab254fe29b81aa2 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 4 Aug 2022 13:19:50 +0200 Subject: [PATCH 11/12] [TODO] Enrich value suggestions with meta data Type, min and max support is still missing/TBD Value labels are fully working Value validation is fully working (though, not necessarily finished) --- src/Compat/SearchControls.php | 18 +++++++++++++----- src/Control/SearchBar/Suggestions.php | 7 +++++++ src/Control/SearchBar/Terms.php | 12 +++++++++++- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/Compat/SearchControls.php b/src/Compat/SearchControls.php index c40204d74..c48ec5a7a 100644 --- a/src/Compat/SearchControls.php +++ b/src/Compat/SearchControls.php @@ -84,7 +84,7 @@ public function createSearchBar(Query $query, ...$params): SearchBar } $filterColumns = $this->fetchFilterColumns($query); - $columnValidator = function (SearchBar\ValidatedColumn $column) use ($query, $filterColumns) { + $columnValidator = function ($column, $operator, $value, $condition) use ($query, $filterColumns) { $searchPath = $column->getSearchValue(); if (strpos($searchPath, '.') === false) { $column->setSearchValue($query->getResolver()->qualifyPath( @@ -108,6 +108,12 @@ public function createSearchBar(Query $query, ...$params): SearchBar if (isset($definition)) { $column->setLabel($definition->getLabel()); + + if (! $definition->isValidValue($condition)) { + $value->setMessage(t('Is not a valid value')); + } else { + $value->setLabel($definition->getValueLabel($value->getSearchValue())); + } } }; @@ -257,13 +263,15 @@ protected function enrichFilterCondition(Filter\Condition $condition, Query $que } try { - $label = $query->getResolver()->getColumnDefinition($path)->getLabel(); + $definition = $query->getResolver()->getColumnDefinition($path); } catch (InvalidRelationException $_) { - $label = null; + // pass } - if (isset($label)) { - $condition->metaData()->set('columnLabel', $label); + if (isset($definition)) { + $condition->metaData()->set('columnLabel', $definition->getLabel()); + $condition->metaData()->set('valueLabel', $definition->getValueLabel($condition->getValue())); + //$condition->metaData()->set('valueType', $definition->getType()); } } } diff --git a/src/Control/SearchBar/Suggestions.php b/src/Control/SearchBar/Suggestions.php index 3ecd0de4a..4081558c5 100644 --- a/src/Control/SearchBar/Suggestions.php +++ b/src/Control/SearchBar/Suggestions.php @@ -273,6 +273,13 @@ protected function assemble() $attributes['data-type'] = $this->type; } + if ($this->type === 'value') { + $label = $this->columnDefinition->getValueLabel($term); + //$attributes['data-data-type'] = $this->columnDefinition->getType(); + //$attributes['data-min-size'] = $this->columnDefinition->getMin(); + //$attributes['data-max-size'] = $this->columnDefinition->getMax(); + } + $attributes['data-label'] = $label; $button = (new ButtonElement(null, $attributes)) diff --git a/src/Control/SearchBar/Terms.php b/src/Control/SearchBar/Terms.php index c81e3360d..1082d1398 100644 --- a/src/Control/SearchBar/Terms.php +++ b/src/Control/SearchBar/Terms.php @@ -153,6 +153,7 @@ protected function assembleCondition(Filter\Condition $filter, BaseHtmlElement $ $operator = QueryString::getRuleSymbol($filter); $value = $filter->getValue(); $columnLabel = $filter->metaData()->get('columnLabel', $column); + $valueLabel = $filter->metaData()->get('valueLabel', $value); $group = new HtmlElement( 'div', @@ -197,7 +198,7 @@ protected function assembleCondition(Filter\Condition $filter, BaseHtmlElement $ 'class' => 'value', 'type' => 'value', 'search' => rawurlencode($value), - 'label' => $value + 'label' => $valueLabel ]; if ($filter->metaData()->has('invalidValuePattern')) { $valueData['pattern'] = $filter->metaData()->get('invalidValuePattern'); @@ -206,6 +207,10 @@ protected function assembleCondition(Filter\Condition $filter, BaseHtmlElement $ } } + if ($filter->metaData()->has('valueType')) { + $valueData['data-type'] = $filter->metaData()->get('valueType'); + } + $this->assembleTerm($valueData, $group); } } @@ -248,6 +253,11 @@ protected function assembleTerm(array $data, BaseHtmlElement $where) } } + if (isset($data['data-type'])) { + $term->setAttribute('data-data-type', $data['data-type']); + $term->getFirst('input')->setAttribute('type', $data['data-type']); + } + $where->addHtml($term); return $term; From b604f4485110560a3684fb23212202e0a0d5d52c Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Thu, 4 Aug 2022 13:33:49 +0200 Subject: [PATCH 12/12] [TODO] Render specific input types * Currently only number inputs (disabled server-side) * Date inputs using flatpickr? * Relative ranges (-7days) must still work though * A compact input is required, datetime-local is overkill * Since the flatpickr is a flyout, it's compact enough * Though it needs to allow manual input for the ranges to work * And it needs to ignore them unless the flyout is opened * Requires a custom opener as click must not open the flyout --- asset/js/widget/FilterInput.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/asset/js/widget/FilterInput.js b/asset/js/widget/FilterInput.js index 97a35778c..1f8df7ab7 100644 --- a/asset/js/widget/FilterInput.js +++ b/asset/js/widget/FilterInput.js @@ -998,6 +998,10 @@ define(["../notjQuery", "BaseInput"], function ($, BaseInput) { let label = super.renderTerm(termData, termIndex); label.dataset.type = termData.type; + if (!! termData.dataType) { + label.firstChild.type = termData.dataType; + } + if (! termData.class) { label.classList.add(termData.type); }