NEW Provide an easy way to filter arbitrary ViewableData in gridfields

This commit is contained in:
Guy Sartorelli 2023-11-13 16:28:41 +13:00
parent bc47d65cc5
commit b1295af281
No known key found for this signature in database
GPG Key ID: F313E3B9504D496A
3 changed files with 229 additions and 7 deletions

View File

@ -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,

View File

@ -0,0 +1,188 @@
<?php
namespace SilverStripe\ORM\Search;
use InvalidArgumentException;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Deprecation;
use SilverStripe\ORM\Filterable;
use SilverStripe\ORM\Filters\PartialMatchFilter;
use SilverStripe\ORM\Filters\SearchFilter;
use SilverStripe\ORM\Limitable;
use SilverStripe\ORM\Sortable;
/**
* A SearchContext that can be used with non-ORM data.
* This class isn't guaranteed to respect the full searchable fields spec defined on DataObject classes.
*/
class BasicSearchContext extends SearchContext
{
use Configurable;
/**
* Name of the field which, if included in search forms passed to this object, will be used
* to search across all searchable fields.
*/
private static $general_search_field_name = 'q';
/**
* Returns a list which has been limited, sorted, and filtered by the given parameters.
*
* @param array $searchParams Map of search criteria, mostly taken from $_REQUEST.
* If a filter is applied to a relationship in dot notation,
* the parameter name should have the dots replaced with double underscores,
* for example "Comments__Name" instead of the filter name "Comments.Name".
* @param array|bool|string $sort Field to sort on.
* @param array|null|string $limit
* @param Filterable&Sortable&Limitable $existingQuery
*/
public function getQuery($searchParams, $sort = false, $limit = false, $existingQuery = null): Filterable&Sortable&Limitable
{
if (!$existingQuery || !($existingQuery instanceof Filterable) || !($existingQuery instanceof Sortable) || !($existingQuery instanceof Limitable)) {
throw new InvalidArgumentException('getQuery requires a pre-existing filterable/sortable/limitable list to be passed as $existingQuery.');
}
if ((count(func_get_args()) >= 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;
}
}

View File

@ -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()