diff --git a/src/Forms/GridField/GridFieldFilterHeader.php b/src/Forms/GridField/GridFieldFilterHeader.php index f7c1e74aa..8af1a1fa1 100755 --- a/src/Forms/GridField/GridFieldFilterHeader.php +++ b/src/Forms/GridField/GridFieldFilterHeader.php @@ -12,6 +12,7 @@ use SilverStripe\Forms\FieldList; use SilverStripe\Forms\Form; use SilverStripe\Forms\Schema\FormSchema; use SilverStripe\ORM\Filterable; +use SilverStripe\ORM\Search\SearchContext; use SilverStripe\ORM\SS_List; use SilverStripe\View\ArrayData; use SilverStripe\View\SSViewer; @@ -33,7 +34,7 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi protected $throwExceptionOnBadDataType = true; /** - * @var \SilverStripe\ORM\Search\SearchContext + * @var SearchContext */ protected $searchContext = null; @@ -250,7 +251,7 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi * Generate a search context based on the model class of the of the GridField * * @param GridField $gridfield - * @return \SilverStripe\ORM\Search\SearchContext + * @return SearchContext */ public function getSearchContext(GridField $gridField) { @@ -261,6 +262,16 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi return $this->searchContext; } + /** + * Sets a specific SearchContext instance for this component to use, instead of the default + * context provided by the ModelClass. + */ + public function setSearchContext(SearchContext $context): static + { + $this->searchContext = $context; + return $this; + } + /** * Returns the search field schema for the component * @@ -287,8 +298,6 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi $searchField = $searchField && property_exists($searchField, 'name') ? $searchField->name : null; } - $name = $gridField->Title ?: $inst->i18n_plural_name(); - // Prefix "Search__" onto the filters for the React component $filters = $context->getSearchParams(); if (!empty($filters)) { @@ -302,7 +311,7 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi $schema = [ 'formSchemaUrl' => $schemaUrl, 'name' => $searchField, - 'placeholder' => _t(__CLASS__ . '.Search', 'Search "{name}"', ['name' => $name]), + 'placeholder' => _t(__CLASS__ . '.Search', 'Search "{name}"', ['name' => $this->getTitle($gridField, $inst)]), 'filters' => $filters ?: new \stdClass, // stdClass maps to empty json object '{}' 'gridfield' => $gridField->getName(), 'searchAction' => $searchAction->getAttribute('name'), @@ -314,6 +323,19 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi return json_encode($schema); } + private function getTitle(GridField $gridField, object $inst): string + { + if ($gridField->Title) { + return $gridField->Title; + } + + if (ClassInfo::hasMethod($inst, 'i18n_plural_name')) { + return $inst->i18n_plural_name(); + } + + return ClassInfo::shortName($inst); + } + /** * Returns the search form for the component * @@ -357,7 +379,7 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi $field->addExtraClass('stacked no-change-track'); } - $name = $gridField->Title ?: singleton($gridField->getModelClass())->i18n_plural_name(); + $name = $this->getTitle($gridField, singleton($gridField->getModelClass())); $this->searchForm = $form = new Form( $gridField, diff --git a/src/ORM/Search/BasicSearchContext.php b/src/ORM/Search/BasicSearchContext.php new file mode 100644 index 000000000..9bdd960a1 --- /dev/null +++ b/src/ORM/Search/BasicSearchContext.php @@ -0,0 +1,188 @@ += 3) && (!in_array(gettype($limit), ['array', 'NULL', 'string']))) { + Deprecation::notice( + '5.1.0', + '$limit should be type of array|string|null' + ); + $limit = null; + } + + $searchParams = $this->applySearchFilters($this->normaliseSearchParams($searchParams)); + $result = $this->applyGeneralSearchField($searchParams, $existingQuery); + + // Filter the list by the requested filters. + if (!empty($searchParams)) { + $result = $result->filter($searchParams); + } + + // Only sort if a sort value is provided - sort by "false" just means use the existing sort. + if ($sort) { + $result = $result->sort($sort); + } + + // Limit must be last so that ArrayList results don't have an applied limit before they can be filtered/sorted. + $result = $result->limit($limit); + + return $result; + } + + private function normaliseSearchParams(array $searchParams): array + { + $normalised = []; + foreach ($searchParams as $field => $searchTerm) { + if ($this->clearEmptySearchFields($searchTerm)) { + $normalised[str_replace('__', '.', $field)] = $searchTerm; + } + } + return $normalised; + } + + private function applySearchFilters(array $searchParams): array + { + $applied = []; + foreach ($searchParams as $fieldName => $searchTerm) { + // Ignore the general search field - we'll deal with that in a special way. + if ($fieldName === static::config()->get('general_search_field_name')) { + $applied[$fieldName] = $searchTerm; + continue; + } + $filterTerm = $this->getFilterTerm($fieldName); + $applied["{$fieldName}:{$filterTerm}"] = $searchTerm; + } + return $applied; + } + + private function applyGeneralSearchField(array &$searchParams, Filterable $existingQuery): Filterable + { + $generalFieldName = static::config()->get('general_search_field_name'); + if (array_key_exists($generalFieldName, $searchParams)) { + $searchTerm = $searchParams[$generalFieldName]; + if (Config::inst()->get($this->modelClass, 'general_search_split_terms') !== false) { + $searchTerm = explode(' ', $searchTerm); + } + $generalFilter = []; + foreach ($this->getSearchFields()->dataFieldNames() as $fieldName) { + if ($fieldName === $generalFieldName) { + continue; + } + if (!$this->getCanGeneralSearch($fieldName)) { + continue; + } + $filterTerm = $this->getGeneralSearchFilterTerm($fieldName); + $generalFilter["{$fieldName}:{$filterTerm}"] = $searchTerm; + } + $result = $existingQuery->filterAny($generalFilter); + unset($searchParams[$generalFieldName]); + } + + return $result ?? $existingQuery; + } + + private function getCanGeneralSearch(string $fieldName): bool + { + $singleton = singleton($this->modelClass); + + // Allowed if we're dealing with arbitrary data. + if (!ClassInfo::hasMethod($singleton, 'searchableFields')) { + return true; + } + + $fields = $singleton->searchableFields(); + + // Not allowed if the field isn't searchable. + if (!isset($fields[$fieldName])) { + return false; + } + + // Allowed if 'general' isn't part of the spec, or is explicitly truthy. + return !isset($fields[$fieldName]['general']) || $fields[$fieldName]['general']; + } + + /** + * Get the search filter for the given fieldname when searched from the general search field. + */ + private function getGeneralSearchFilterTerm(string $fieldName): string + { + $filterClass = Config::inst()->get($this->modelClass, 'general_search_field_filter'); + if ($filterClass) { + return $this->getTermFromFilter(Injector::inst()->create($filterClass, $fieldName)); + } + + if ($filterClass === '') { + return $this->getFilterTerm($fieldName); + } + + return 'PartialMatch:nocase'; + } + + private function getFilterTerm(string $fieldName): string + { + $filter = $this->getFilter($fieldName) ?? PartialMatchFilter::create($fieldName); + return $this->getTermFromFilter($filter); + } + + private function getTermFromFilter(SearchFilter $filter): string + { + $modifiers = $filter->getModifiers() ?? []; + + // Get the string used to refer to the filter, e.g. "PartialMatch" + // Ask the injector for it first - but for any not defined there, fall back to string manipulation. + $filterTerm = Injector::inst()->getServiceName(get_class($filter)); + if (!$filterTerm) { + $filterTerm = preg_replace('/Filter$/', '', ClassInfo::shortName($filter)); + } + + // Add modifiers to filter + foreach ($modifiers as $modifier) { + $filterTerm .= ":{$modifier}"; + } + + return $filterTerm; + } +} diff --git a/src/ORM/Search/SearchContext.php b/src/ORM/Search/SearchContext.php index 978ba3677..d41e26042 100644 --- a/src/ORM/Search/SearchContext.php +++ b/src/ORM/Search/SearchContext.php @@ -17,6 +17,7 @@ use SilverStripe\Forms\SelectField; use SilverStripe\Forms\CheckboxField; use InvalidArgumentException; use Exception; +use LogicException; use SilverStripe\Core\Config\Config; use SilverStripe\Dev\Deprecation; use SilverStripe\ORM\DataQuery; @@ -104,7 +105,18 @@ class SearchContext */ public function getSearchFields() { - return ($this->fields) ? $this->fields : singleton($this->modelClass)->scaffoldSearchFields(); + if ($this->fields->exists()) { + return $this->fields; + } + + $singleton = singleton($this->modelClass); + if (!$singleton->hasMethod('scaffoldSearchFields')) { + throw new LogicException( + 'Cannot dynamically determine search fields. Pass the fields to setFields()' + . " or implement a scaffoldSearchFields() method on {$this->modelClass}" + ); + } + return $singleton->scaffoldSearchFields(); } protected function applyBaseTableFields()