├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .scrutinizer.yml ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── _config.php ├── _config └── config.yml ├── codecov.yml ├── composer.json ├── doc ├── example_auto_complete.png ├── example_date_field.png ├── example_drop_down_field.png └── example_has_one.png ├── phpcs.xml.dist ├── phpunit.xml.dist ├── src ├── Extension │ └── GridFieldRichFilterHeaderRequestExtension.php └── Form │ └── GridField │ ├── RichFilterHeader.php │ └── RichSortableHeader.php └── tests └── php └── Form └── GridField ├── RichFilterHeaderTest.php ├── RichFilterHeaderTest.yml └── RichFilterHeaderTest ├── Cheerleader.php ├── CheerleaderHat.php └── Team.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # For more information about the properties used in 2 | # this file, please see the EditorConfig documentation: 3 | # http://editorconfig.org/ 4 | 5 | root = true 6 | 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | indent_size = 4 11 | indent_style = space 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | max_line_length = 120 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | 19 | [{*.yml,*.js,*.jsx,*.ss,*.scss,*.json}] 20 | indent_size = 2 21 | indent_style = space 22 | 23 | [{.travis.yml,package.json}] 24 | # The indent size used in the `package.json` file cannot be changed 25 | # https://github.com/npm/npm/pull/3180#issuecomment-16336516 26 | indent_size = 2 27 | indent_style = space 28 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | ci: 10 | uses: silverstripe/gha-ci/.github/workflows/ci.yml@v1 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | inherit: true 2 | 3 | build: 4 | nodes: 5 | analysis: 6 | tests: 7 | override: [php-scrutinizer-run] 8 | 9 | checks: 10 | php: 11 | code_rating: true 12 | duplication: true 13 | 14 | filter: 15 | paths: [src/*, tests/*] -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Any open source product is only as good as the community behind it. You can participate by sharing code, ideas, or simply helping others. No matter what your skill level is, every contribution counts. 4 | 5 | See our [high level overview](http://silverstripe.org/contributing-to-silverstripe) on silverstripe.org on how you can help out. 6 | 7 | ## Copyright 8 | 9 | **IMPORTANT: By supplying code to the SilverStripe core team in patches, tickets and pull requests, you agree to assign copyright of that code to SilverStripe Limited, on the condition that SilverStripe Limited releases that code under the BSD license.** 10 | 11 | We ask for this so that the ownership in the license is clear and unambiguous, and so that community involvement doesn't stop us from being able to continue supporting these projects. By releasing this code under a permissive license, this copyright assignment won't prevent you from using the code in any way you see fit. 12 | 13 | ## Contributing code 14 | 15 | See [contributing code](https://docs.silverstripe.org/en/5/contributing/code/) 16 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, SilverStripe Limited 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | * Neither the name of SilverStripe nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 12 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 13 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE 14 | GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 15 | STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY 16 | OF SUCH DAMAGE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GridField rich filter header 2 | 3 | [![Latest Stable Version](http://poser.pugx.org/silverstripe-terraformers/gridfield-rich-filter-header/v)](https://packagist.org/packages/silverstripe-terraformers/gridfield-rich-filter-header) 4 | [![Total Downloads](http://poser.pugx.org/silverstripe-terraformers/gridfield-rich-filter-header/downloads)](https://packagist.org/packages/silverstripe-terraformers/gridfield-rich-filter-header) 5 | [![Latest Unstable Version](http://poser.pugx.org/silverstripe-terraformers/gridfield-rich-filter-header/v/unstable)](https://packagist.org/packages/silverstripe-terraformers/gridfield-rich-filter-header) 6 | [![License](http://poser.pugx.org/silverstripe-terraformers/gridfield-rich-filter-header/license)](https://packagist.org/packages/silverstripe-terraformers/gridfield-rich-filter-header) 7 | [![PHP Version Require](http://poser.pugx.org/silverstripe-terraformers/gridfield-rich-filter-header/require/php)](https://packagist.org/packages/silverstripe-terraformers/gridfield-rich-filter-header) 8 | 9 | This `GridField` component is intended to replace the default `GridFieldFilterHeader` component and provide rich functionality not available in the original. 10 | 11 | Here is a brief comparison of these components: 12 | 13 | `GridFieldFilterHeader` (original component) 14 | 15 | * no filter related configuration available 16 | * filtering only works if `GridField` column name matches `DB` column name, which is not the case in many situations (date fromatting, column content getter function) 17 | * `TextField` is always used as a filter input `FormField` 18 | * `PartialMatchFilter` is always used for filtering 19 | * filtering is always applied to the DB field of respective `GridField` column 20 | * it's not possible to NOT display a filter 21 | 22 | `RichFilterHeader` (component in this module) 23 | 24 | * filters are fully configurable 25 | * you can specify the mapping between `GridField` column name and `DB` column name so you can freely use features like date fromatting, column content getter function 26 | * you can choose to use any `FormField` in your filters, `TextField` is used only as a default 27 | * `FormFields` which use XHR like `AutoCompleteField` are supported as well 28 | * any `SearchFilter` can be used, `PartialMatchFilter` is used only as a default 29 | * you can filter based on any data by specifying your own filter method 30 | 31 | Overall this module allows you to fully customise your `GridField` filters including rare edge cases and special requirements. 32 | 33 | ## Requirements 34 | 35 | * table header component needs to be present in the `GridField` (for example `GridFieldSortableHeader`) 36 | * the last column of the table needs to have a vacant header cell so the filter widget could be displayed there 37 | * for example you can't have the last column with sorting header widget and filter widget at the same time 38 | 39 | ## Installation 40 | 41 | `composer require silverstripe-terraformers/gridfield-rich-filter-header` 42 | 43 | ## Basic configuration 44 | 45 | Full filter configuration format looks like this: 46 | 47 | ```php 48 | 'GridField_column_name' => [ 49 | 'title' => 'DB_column_name', 50 | 'filter' => 'search_filter_type', 51 | ], 52 | ``` 53 | 54 | Concrete example: 55 | 56 | ```php 57 | 'Expires.Nice' => [ 58 | 'title' => 'Expires', 59 | 'filter' => 'ExactMatchFilter', 60 | ], 61 | ``` 62 | 63 | `search_filter_type` can be any `SearchFilter`, see search filter documentation for more information 64 | 65 | https://docs.silverstripe.org/en/4/developer_guides/model/searchfilters/ 66 | 67 | Shorter configuration formats are available as well: 68 | 69 | Field mapping version doesn't include filter specification and will use `PartialMatchFilter`. 70 | This should be used if you are happy with using `PartialMatchFilter` 71 | 72 | ```php 73 | 'GridField_column_name' => 'DB_column_name', 74 | ``` 75 | 76 | Whitelist version doesn't include filter specification nor field mapping. 77 | This configuration will use `PartialMatchFilter` and will assume that both `GridField_column_name` and `DB_column_name` are the same. 78 | 79 | ```php 80 | 'GridField_column_name', 81 | ``` 82 | 83 | Multiple filters configuration example: 84 | 85 | ```php 86 | $gridFieldConfig->removeComponentsByType( 87 | GridFieldSortableHeader::class, 88 | GridFieldFilterHeader::class, 89 | ); 90 | 91 | $sort = new RichSortableHeader(); 92 | $filter = new RichFilterHeader(); 93 | $filter 94 | ->setFilterConfig([ 95 | 'getFancyTitle' => 'Title', 96 | 'Expires.Nice' => [ 97 | 'title' => 'Expires', 98 | 'filter' => 'ExactMatchFilter', 99 | ], 100 | ]); 101 | 102 | 103 | $gridFieldConfig->addComponent($sort, GridFieldPaginator::class); 104 | $gridFieldConfig->addComponent($filter, GridFieldPaginator::class); 105 | ``` 106 | 107 | If no configuration is provided via `setFilterConfig` method, filter configuration will fall back to `searchable_fields` of the `DataObject` that is listed in the `GridField`. 108 | If `searchable_fields` configuration is not available, `summary_fields` will be used instead. 109 | 110 | Make sure you add the `RichFilterHeader` component BEFORE the `GridFieldPaginator` 111 | otherwise your pagination will be broken since you always want to filter before paginating. 112 | Sort header component also needs to be replaced to allow the filter expand button to be shown. 113 | 114 | ## Field configuration 115 | 116 | Any `FormField` can be used for filtering. You just need to add it to filter configuration like this: 117 | 118 | ```php 119 | ->setFilterFields([ 120 | 'Expires' => DateField::create('', ''), 121 | ]) 122 | ``` 123 | 124 | `Name` of the field can be left empty as it is populated automatically. 125 | I recommend to leave the `title` empty as well as you probably don't need to display it as it is redundant to `GridField` column header title in most cases. 126 | 127 | ## Filter methods 128 | 129 | This configuration covers most edge cases and special requirements where standard `SearchFilter` is not enough. 130 | If filter method is specified for a field, it will override the standard filter. 131 | Filter method is a callback which will be applied to the `DataList` and you are free to add any functionality you need 132 | inside the callback. Make sure that your callback returns a `DataList` with the same `DataClass` as the original. 133 | 134 | ```php 135 | ->setFilterMethods([ 136 | 'Title' => function (DataList $list, $name, $value) { 137 | // my custom filter logic is implemented here 138 | return $filteredList; 139 | }, 140 | ]) 141 | ``` 142 | 143 | Note that `$name` will have the value of `DB_column_name` from your config. 144 | 145 | For your convenience there are couple of filter methods available to cover some cases. 146 | 147 | * `AllKeywordsFilter` - will split the text input by space into keywords and will search for records that contains ALL keywords 148 | * `ManyManyRelationFilter` - will search for records that have relation to a specific record via `many_many` relation 149 | 150 | Both of these filters can be used in `setFilterMethods` like this: 151 | 152 | ```php 153 | ->setFilterMethods([ 154 | 'Title' => RichFilterHeader::FILTER_ALL_KEYWORDS, 155 | ]) 156 | ``` 157 | 158 | ## Additional examples 159 | 160 | ### Example #1 161 | 162 | * filter with `AutoCompleteField` and filtering by `AllKeywordsFilter` filter method 163 | * filter with `DateField` and filtering by `StartsWithFilter` 164 | 165 | ```php 166 | $gridFieldConfig->removeComponentsByType( 167 | GridFieldSortableHeader::class, 168 | GridFieldFilterHeader::class, 169 | ); 170 | 171 | $sort = new RichSortableHeader(); 172 | $filter = new RichFilterHeader(); 173 | $filter 174 | ->setFilterConfig([ 175 | 'Label', 176 | 'DisplayDateEnd.Nice' => [ 177 | 'title' => 'DisplayDateEnd', 178 | 'filter' => 'StartsWithFilter', 179 | ], 180 | ]) 181 | ->setFilterFields([ 182 | 'Label' => $dealsLookup = AutoCompleteField::create('', ''), 183 | 'DisplayDateEnd' => DateField::create('', ''), 184 | ]) 185 | ->setFilterMethods([ 186 | 'Label' => RichFilterHeader::FILTER_ALL_KEYWORDS, 187 | ]); 188 | 189 | $dealsLookup 190 | ->setSourceClass(SystemDeal::class) 191 | ->setSourceFields(['Label']) 192 | ->setDisplayField('Label') 193 | ->setLabelField('Label') 194 | ->setStoredField('Label') 195 | ->setSourceSort('Label ASC') 196 | ->setRequireSelection(false); 197 | 198 | $gridFieldConfig->addComponent($sort, GridFieldPaginator::class); 199 | $gridFieldConfig->addComponent($filter, GridFieldPaginator::class); 200 | ``` 201 | 202 | ![AutoCompleteField](doc/example_auto_complete.png) 203 | ![DateField](doc/example_date_field.png) 204 | 205 | ### Example #2 206 | 207 | * filter with `DropdownField` and filtering with `ManyManyRelationFilter` 208 | 209 | Note that the items that are listed in the `GridField` have a `many_many` relation with `TaxonomyTerm` called `TaxonomyTerms` 210 | 211 | ```php 212 | $gridFieldConfig->removeComponentsByType( 213 | GridFieldSortableHeader::class, 214 | GridFieldFilterHeader::class, 215 | ); 216 | 217 | $sort = new RichSortableHeader(); 218 | $filter = new RichFilterHeader(); 219 | $filter 220 | ->setFilterConfig([ 221 | 'Label' => 'TaxonomyTerms', 222 | ]) 223 | ->setFilterFields([ 224 | 'TaxonomyTerms' => DropdownField::create( 225 | '', 226 | '', 227 | TaxonomyTerm::get()->sort('Name', 'ASC')->map('ID', 'Name') 228 | ), 229 | ]) 230 | ->setFilterMethods([ 231 | 'TaxonomyTerms' => RichFilterHeader::FILTER_MANY_MANY_RELATION, 232 | ]); 233 | 234 | $gridFieldConfig->addComponent($sort, GridFieldPaginator::class); 235 | $gridFieldConfig->addComponent($filter, GridFieldPaginator::class); 236 | ``` 237 | 238 | ![DropdownField](doc/example_drop_down_field.png) 239 | 240 | ### Example #3 241 | 242 | * filter with `TextField` (with custom placeholder text) and custom filter method 243 | 244 | Our custom filter method filters records by three different `DB` columns via `PartialMatch` filter. 245 | 246 | ```php 247 | $gridFieldConfig->removeComponentsByType( 248 | GridFieldSortableHeader::class, 249 | GridFieldFilterHeader::class, 250 | ); 251 | 252 | $sort = new RichSortableHeader(); 253 | $filter = new RichFilterHeader(); 254 | $filter 255 | ->setFilterConfig([ 256 | 'Label', 257 | ]) 258 | ->setFilterFields([ 259 | 'Label' => $label = TextField::create('', ''), 260 | ]) 261 | ->setFilterMethods([ 262 | 'Label' => function (DataList $list, $name, $value) { 263 | return $list->filterAny([ 264 | 'Label:PartialMatch' => $value, 265 | 'TitleLineOne:PartialMatch' => $value, 266 | 'TitleLineTwo:PartialMatch' => $value, 267 | ]); 268 | }, 269 | ]); 270 | 271 | $label->setAttribute('placeholder', 'Filter by three different columns'); 272 | $gridFieldConfig->addComponent($sort, GridFieldPaginator::class); 273 | $gridFieldConfig->addComponent($filter, GridFieldPaginator::class); 274 | ``` 275 | 276 | ### Example #4 277 | 278 | * full code example on setup for `has_one` relation using a `DropdownField` 279 | 280 | This example covers 281 | * `Player` (has one `Team`) 282 | * `Team` (has many `Player`) 283 | * `PlayersAdmin` 284 | 285 | ```php 286 | 'Varchar', 309 | ]; 310 | 311 | /** 312 | * @var array 313 | */ 314 | private static $has_many = [ 315 | 'Players' => Player::class, 316 | ]; 317 | } 318 | ``` 319 | 320 | ```php 321 | 'Varchar', 344 | ]; 345 | 346 | /** 347 | * @var array 348 | */ 349 | private static $has_one = [ 350 | 'Team' => Team::class, 351 | ]; 352 | 353 | /** 354 | * @var array 355 | */ 356 | private static $summary_fields = [ 357 | 'Title', 358 | 'Team.Title' => 'Team', 359 | ]; 360 | } 361 | ``` 362 | 363 | ```php 364 | ['title' => 'Players'], 386 | ]; 387 | 388 | /** 389 | * @var string 390 | */ 391 | private static $menu_title = 'Players'; 392 | 393 | /** 394 | * @var string 395 | */ 396 | private static $url_segment = 'players'; 397 | 398 | /** 399 | * @param mixed|null $id 400 | * @param FieldList|null $fields 401 | * @return Form 402 | */ 403 | public function getEditForm($id = null, $fields = null): Form 404 | { 405 | $form = parent::getEditForm($id, $fields); 406 | 407 | /** @var GridField $gridField */ 408 | $gridField = $form->Fields()->fieldByName('App-Models-Player'); 409 | 410 | if ($gridField) { 411 | // Default sort order 412 | $config = $gridField->getConfig(); 413 | 414 | // custom filters 415 | $config->removeComponentsByType( 416 | GridFieldSortableHeader::class, 417 | GridFieldFilterHeader::class, 418 | ); 419 | 420 | $sort = new RichSortableHeader(); 421 | $filter = new RichFilterHeader(); 422 | $filter 423 | ->setFilterConfig([ 424 | 'Title', 425 | 'Team.Title' => [ 426 | 'title' => 'TeamID', 427 | 'filter' => 'ExactMatchFilter', 428 | ], 429 | ]) 430 | ->setFilterFields([ 431 | 'TeamID' => $team = DropdownField::create( 432 | '', 433 | '', 434 | Team::get()->sort('Title', 'ASC')->map('ID', 'Title') 435 | ), 436 | ]); 437 | 438 | 439 | $team->setEmptyString('-- select --'); 440 | $config->addComponent($sort, GridFieldPaginator::class); 441 | $config->addComponent($filter, GridFieldPaginator::class); 442 | } 443 | 444 | return $form; 445 | } 446 | } 447 | ``` 448 | 449 | We can now filter players by teams. 450 | 451 | ![DropdownField](doc/example_has_one.png) 452 | -------------------------------------------------------------------------------- /_config.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | CodeSniffer ruleset for SilverStripe coding conventions. 4 | 5 | src 6 | tests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | */SSTemplateParser.php$ 32 | 33 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | src/ 7 | 8 | 9 | tests/ 10 | 11 | 12 | 13 | 14 | tests/php/ 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Extension/GridFieldRichFilterHeaderRequestExtension.php: -------------------------------------------------------------------------------- 1 | dataFieldByName($fieldName); 42 | if (!empty($field)) { 43 | return $field; 44 | } 45 | 46 | // falling back to fieldByName, e.g. for getting tabs 47 | $field = $fields->fieldByName($fieldName); 48 | if (!empty($field)) { 49 | return $field; 50 | } 51 | 52 | return null; 53 | } 54 | 55 | /** 56 | * @param HTTPRequest $request 57 | * @return FormField 58 | */ 59 | public function handleFieldComposite(HTTPRequest $request) 60 | { 61 | $owner = $this->owner; 62 | 63 | // perform standard field lookup 64 | $field = $owner->handleField($request); 65 | if (!empty($field)) { 66 | return $field; 67 | } 68 | 69 | $fields = $owner->form->Fields(); 70 | $fieldName = $request->param('FieldName'); 71 | 72 | // try to find a field contained within the RichFilterHeader component 73 | $gridFieldData = RichFilterHeader::parseCompositeFieldName($fieldName); 74 | if (empty($gridFieldData)) { 75 | return null; 76 | } 77 | 78 | $gridField = $this->findField($fields, $gridFieldData['grid_field']); 79 | if (empty($gridField) || !($gridField instanceof GridField)) { 80 | return null; 81 | } 82 | 83 | $filterHeader = $gridField->getConfig()->getComponentByType(RichFilterHeader::class); 84 | if (empty($filterHeader)) { 85 | return null; 86 | } 87 | 88 | /** @var RichFilterHeader $filterHeader */ 89 | $field = $filterHeader->getFilterField($gridFieldData['child_field']); 90 | if (empty($field)) { 91 | return null; 92 | } 93 | 94 | return $field; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Form/GridField/RichFilterHeader.php: -------------------------------------------------------------------------------- 1 | 52 | * [ 53 | * 'field_name', 54 | * 'Title', 55 | * ]; 56 | * 57 | * 58 | * Column name to field name mapping - this is used when column name is a getter function, 59 | * relation lookup or data formatting 60 | * 61 | * 62 | * [ 63 | * 'column_name' => 'field_name', 64 | * 'getTitleSummary' => 'Title', 65 | * 'City.Name' => 'City', 66 | * 'Expires.Nice' => 'Expires', 67 | * ]; 68 | * 69 | * 70 | * Complex syntax - column name wth field mapping and basic filter 71 | * 72 | * 73 | * [ 74 | * 'column_name' => [ 75 | * 'title' => 'field_name', 76 | * 'filter' => 'filter_type', 77 | * ], 78 | * 'Organisation.ZipCode' => [ 79 | * 'title' => 'Organisation ZIP', 80 | * 'filter' => 'ExactMatchFilter', 81 | * ], 82 | * ]; 83 | * 84 | * 85 | * basic filter reference: 86 | * https://docs.silverstripe.org/en/4/developer_guides/model/searchfilters/ * 87 | * 88 | * FILTER FIELDS 89 | * 90 | * TextField is used as a default field for filters however this may be too crude in some situations 91 | * This configuration allows addition of fields of any type like DropdownField or AutocompleteField 92 | * 93 | * configuration format: 94 | * 95 | * 'field_name' => 'FormField' 96 | * 97 | * for example if we want to use Dropdown field configuration below has to be used: 98 | * 99 | * ->setFilterFields([ 100 | * 'City' => DropdownField::create('', '', $cities), 101 | * ]) 102 | * 103 | * Note that Title and Name of the field can be left empty as they are not used (Title) or are auto-populated (Name) 104 | * 105 | * FILTER METHODS 106 | * 107 | * Partial match filter is used by default but in some situations this can't be used as other filters are required 108 | * if filter method is specified it always overrides basic filter 109 | * 110 | * configuration format: 111 | * 112 | * 'field_name' => 'filter_method' (Closure or string) 113 | * 114 | * filter method can be either a string which identifies one of the filter methods that are available 115 | * this component comes 'AllKeywordsFilter' and 'ManyManyRelationFilter', for example 116 | * 117 | * ->setFilterMethods([ 118 | * 'Label' => RichFilterHeader::FILTER_ALL_KEYWORDS, 119 | * ]); 120 | * 121 | * ->setFilterMethods([ 122 | * 'Categories' => RichFilterHeader::FILTER_MANY_MANY_RELATION, 123 | * ]); 124 | * 125 | * 126 | * alternatively a custom filter method can be specified 127 | * for example we may want to filter by multiple fields 128 | * 129 | * ->setFilterMethods([ 130 | * 'Title' => function (DataList $list, $name, $value) { 131 | * return $list->filterAny([ 132 | * 'Title:PartialMatch' => $value, 133 | * 'Caption:PartialMatch' => $value, 134 | * 'Credit:PartialMatch' => $value, 135 | * ]); 136 | * }, 137 | * ]) 138 | * 139 | * this is a great way to cover edge cases as the implementation of the filter is completely up to the developer 140 | */ 141 | class RichFilterHeader extends GridFieldFilterHeader 142 | { 143 | use Configurable; 144 | 145 | // predefined filter methods 146 | const FILTER_ALL_KEYWORDS = 'filter_all_keywords'; 147 | const FILTER_MANY_MANY_RELATION = 'filter_many_many_relation'; 148 | 149 | /** 150 | * @config 151 | * @var string 152 | */ 153 | private static $field_name_encode = 'filter[%s][%s]'; 154 | 155 | /** 156 | * @config 157 | * @var string 158 | */ 159 | private static $field_name_decode = '/^filter\[([^]]+)\]\[([^]]+)\]$/'; 160 | 161 | /** 162 | * @var string 163 | */ 164 | protected $dataClass = ''; 165 | 166 | /** 167 | * Filter configuration uses syntax compatible with searchable_fields and summary_fields 168 | * 169 | * @var array 170 | */ 171 | protected $filter_config = []; 172 | 173 | /** 174 | * Custom fields list - all custom fields are stored here 175 | * 176 | * configuration format: 177 | * 178 | * 'field_name' => 'FormField' 179 | * 180 | * @var array 181 | */ 182 | protected $filter_fields = []; 183 | 184 | /** 185 | * Filter methods - filter callbacks can be specified per field or predefined filter methods can be chosen 186 | * 187 | * configuration format: 188 | * 189 | * 'field_name' => 'filter_method' (Closure or string) 190 | * 191 | * custom filter function can be specified to filter the list, see 'applyAllKeywordsFilter' filter function 192 | * 193 | * @var array 194 | */ 195 | protected $filter_methods = []; 196 | 197 | /** 198 | * @param string $class 199 | */ 200 | protected function setDataClass($class) 201 | { 202 | $this->dataClass = $class; 203 | } 204 | 205 | /** 206 | * @return array 207 | */ 208 | protected function getFilterConfig() 209 | { 210 | // primary config 211 | if (!empty($this->filter_config)) { 212 | return $this->filter_config; 213 | } 214 | 215 | // fallback to Model configuration 216 | if (!empty($this->dataClass)) { 217 | $class = $this->dataClass; 218 | 219 | // fallback to searchable fields 220 | $data = Config::inst()->get($class, 'searchable_fields'); 221 | if (!empty($data) && is_array($data)) { 222 | return $data; 223 | } 224 | 225 | // fallback to summary fields 226 | $data = Config::inst()->get($class, 'summary_fields'); 227 | if (!empty($data) && is_array($data)) { 228 | return $data; 229 | } 230 | } 231 | 232 | return []; 233 | } 234 | 235 | /** 236 | * @param string $name 237 | * @param string|array $config 238 | * @return array 239 | */ 240 | protected function parseFieldConfig($name, $config) 241 | { 242 | $data = []; 243 | if (is_array($config)) { 244 | // composite config 245 | $data['title'] = (array_key_exists('title', $config)) ? $config['title'] : $name; 246 | $data['filter'] = (array_key_exists('filter', $config)) ? $config['filter'] : 'PartialMatchFilter'; 247 | } else { 248 | // simple config 249 | $data['title'] = $config; 250 | $data['filter'] = 'PartialMatchFilter'; 251 | } 252 | 253 | return $data; 254 | } 255 | 256 | /** 257 | * @param string $name 258 | * @return array 259 | */ 260 | protected function findFieldConfig($name) 261 | { 262 | $fields = $this->getFilterConfig(); 263 | 264 | // match field by key 265 | if (array_key_exists($name, $fields)) { 266 | return $this->parseFieldConfig($name, $fields[$name]); 267 | } 268 | 269 | // match field by value 270 | if (in_array($name, $fields)) { 271 | return $this->parseFieldConfig($name, $name); 272 | } 273 | 274 | // match field by title 275 | foreach ($fields as $data) { 276 | if (is_array($data) && array_key_exists('title', $data) && $data['title'] === $name) { 277 | return $this->parseFieldConfig($name, $data); 278 | } 279 | } 280 | 281 | return []; 282 | } 283 | 284 | /** 285 | * @param string $field 286 | * @return bool 287 | */ 288 | protected function hasFilterMethod($field) 289 | { 290 | return array_key_exists($field, $this->filter_methods); 291 | } 292 | 293 | /** 294 | * @param GridField $gridField 295 | * @param string $name 296 | * @param string $value 297 | * @return FormField 298 | */ 299 | protected function createField(GridField $gridField, $name, $value) 300 | { 301 | $fieldName = static::createCompositeFieldName($gridField->getName(), $name); 302 | if ($this->hasFilterField($name)) { 303 | // custom field 304 | $field = $this->getFilterField($name); 305 | $field->setName($fieldName); 306 | $field->setValue($value); 307 | } else { 308 | // default field 309 | $field = TextField::create($fieldName, '', $value); 310 | } 311 | 312 | // form needs to be set manually as this is not done by default 313 | // this is useful for fields that have actions and need to know their url 314 | $field->setForm($gridField->getForm()); 315 | 316 | return $field; 317 | } 318 | 319 | /** 320 | * Search for items that contain all keywords 321 | * 322 | * @param Filterable $list 323 | * @param string $fieldName 324 | * @param string $value 325 | * @return Filterable 326 | */ 327 | protected function applyAllKeywordsFilter(Filterable $list, $fieldName, $value) 328 | { 329 | $keywords = preg_split('/[\s,]+/', $value); 330 | foreach ($keywords as $keyword) { 331 | $list = $list->filter(["{$fieldName}:PartialMatch" => $keyword]); 332 | } 333 | 334 | return $list; 335 | } 336 | 337 | /** 338 | * Search for items via a many many relation 339 | * 340 | * @param DataList $list 341 | * @param string $relationName 342 | * @param string $value 343 | * @return DataList 344 | */ 345 | protected function applyManyManyRelationFilter(DataList $list, $relationName, $value) 346 | { 347 | $columnName = null; 348 | $list = $list->applyRelation($relationName . '.ID', $columnName); 349 | 350 | return $list->where([$columnName => $value]); 351 | } 352 | 353 | /** 354 | * @param string $gridFieldName 355 | * @param string $childFieldName 356 | * @return string 357 | */ 358 | public static function createCompositeFieldName($gridFieldName, $childFieldName) 359 | { 360 | $format = static::config()->get('field_name_encode'); 361 | 362 | return sprintf($format, $gridFieldName, $childFieldName); 363 | } 364 | 365 | /** 366 | * @param string $name 367 | * @return array 368 | */ 369 | public static function parseCompositeFieldName($name) 370 | { 371 | $format = static::config()->get('field_name_decode'); 372 | 373 | $matches= []; 374 | preg_match($format, $name, $matches); 375 | 376 | if (isset($matches[1]) && $matches[2]) { 377 | return [ 378 | 'grid_field' => $matches[1], 379 | 'child_field' => $matches[2], 380 | ]; 381 | } 382 | 383 | return []; 384 | } 385 | 386 | /** 387 | * 'searchable_fields' and 'summary_fields' configuration formats are supported 388 | * 389 | * @see DataObject::$searchable_fields 390 | * @see DataObject::$summary_fields 391 | * 392 | * @param array $fields 393 | * @return $this 394 | */ 395 | public function setFilterConfig(array $fields) 396 | { 397 | $this->filter_config = $fields; 398 | 399 | return $this; 400 | } 401 | 402 | /** 403 | * configuration format: 404 | * 405 | * 'field_name' => 'FormField' 406 | * 407 | * @param array $fields 408 | * @return $this 409 | */ 410 | public function setFilterFields(array $fields) 411 | { 412 | $this->filter_fields = $fields; 413 | 414 | return $this; 415 | } 416 | 417 | /** 418 | * configuration format: 419 | * 420 | * 'field_name' => 'filter_specification' (Closure or string) 421 | * 422 | * @param array $fields 423 | * @return $this 424 | */ 425 | public function setFilterMethods(array $fields) 426 | { 427 | $this->filter_methods = $fields; 428 | 429 | return $this; 430 | } 431 | 432 | /** 433 | * @param string $field 434 | * @return FormField|null 435 | */ 436 | public function getFilterField($field) 437 | { 438 | if ($this->hasFilterField($field)) { 439 | return $this->filter_fields[$field]; 440 | } 441 | 442 | return null; 443 | } 444 | 445 | /** 446 | * Returns whether this {@link GridField} has any columns to sort on at all. 447 | * 448 | * @param GridField $gridField 449 | * @return boolean 450 | */ 451 | public function canFilterAnyColumns($gridField) 452 | { 453 | $list = $gridField->getList(); 454 | 455 | if (!$this->checkDataType($list)) { 456 | return false; 457 | } 458 | 459 | $columns = $gridField->getColumns(); 460 | foreach ($columns as $name) { 461 | $metadata = $gridField->getColumnMetadata($name); 462 | $title = $metadata['title']; 463 | 464 | $fieldConfig = $this->findFieldConfig($name); 465 | $name = (!empty($fieldConfig['title'])) ? $fieldConfig['title'] : $name; 466 | 467 | if ($title && !empty($fieldConfig) && ($list->canFilterBy($name) || $this->hasFilterMethod($name))) { 468 | return true; 469 | } 470 | } 471 | 472 | return false; 473 | } 474 | 475 | /** 476 | * @param GridField $gridField 477 | * @param SS_List $dataList 478 | * @return SS_List 479 | */ 480 | public function getManipulatedData(GridField $gridField, SS_List $dataList) 481 | { 482 | if (!$this->checkDataType($dataList)) { 483 | return $dataList; 484 | } 485 | 486 | /** @var DataList|Filterable $dataList */ 487 | $this->setDataClass($dataList->dataClass()); 488 | 489 | /** @var GridState_Data $columns */ 490 | $columns = $gridField->State->GridFieldFilterHeader->Columns(null); 491 | if (empty($columns)) { 492 | return $dataList; 493 | } 494 | 495 | $filterArguments = $columns->toArray(); 496 | 497 | /** @var $dataListClone DataList */ 498 | $dataListClone = clone($dataList); 499 | foreach ($filterArguments as $name => $value) { 500 | $fieldConfig = $this->findFieldConfig($name); 501 | if (empty($fieldConfig)) { 502 | continue; 503 | } 504 | 505 | $name = $fieldConfig['title']; 506 | 507 | if (($dataList->canFilterBy($name) || $this->hasFilterMethod($name)) && $value) { 508 | if ($this->hasFilterMethod($name)) { 509 | // filter method configuration is available 510 | $filter = $this->filter_methods[$name]; 511 | 512 | if ($filter instanceof \Closure) { 513 | // custom filter method 514 | $dataListClone = $filter($dataListClone, $name, $value); 515 | } elseif ($filter === static::FILTER_ALL_KEYWORDS) { 516 | $dataListClone = $this->applyAllKeywordsFilter($dataListClone, $name, $value); 517 | } elseif ($filter === static::FILTER_MANY_MANY_RELATION) { 518 | $dataListClone = $this->applyManyManyRelationFilter($dataListClone, $name, $value); 519 | } 520 | } else { 521 | // basic filter 522 | /** @var SearchFilter $filter */ 523 | $filter = Injector::inst()->create($fieldConfig['filter'], $name); 524 | if (empty($filter)) { 525 | continue; 526 | } 527 | 528 | $filter->setModel($dataListClone->dataClass()); 529 | $filter->setValue($value); 530 | $dataListClone = $dataListClone->alterDataQuery([$filter, 'apply']); 531 | } 532 | } 533 | } 534 | 535 | return $dataListClone; 536 | } 537 | 538 | /** 539 | * @param GridField $gridField 540 | * @param string $actionName 541 | * @param mixed $arguments 542 | * @param mixed $data 543 | */ 544 | public function handleAction(GridField $gridField, $actionName, $arguments, $data) 545 | { 546 | if (!$this->checkDataType($gridField->getList())) { 547 | return; 548 | } 549 | 550 | /** @var DataList|Filterable $list */ 551 | $list = $gridField->getList(); 552 | $this->setDataClass($list->dataClass()); 553 | 554 | $state = $gridField->State->GridFieldFilterHeader; 555 | if ($actionName === 'filter') { 556 | if (isset($data['filter'][$gridField->getName()])) { 557 | foreach ($data['filter'][$gridField->getName()] as $name => $value) { 558 | /** @var $filterField FormField */ 559 | $filterField = $this->getFilterField($name); 560 | 561 | // custom field 562 | if (!is_null($filterField)) { 563 | $filterField->setValue($value); 564 | } 565 | 566 | $state->Columns->{$name} = $value; 567 | } 568 | } 569 | } elseif ($actionName === 'reset') { 570 | $state->Columns = $state->Column instanceof GridState_Data 571 | // This is required since silverstripe/framework 4.8 572 | ? new GridState_Data() 573 | // Legacy compatibility case 574 | : null; 575 | 576 | // reset all custom fields 577 | foreach ($this->filter_fields as $field) { 578 | /** @var $field FormField */ 579 | $field->setValue(''); 580 | } 581 | } 582 | } 583 | 584 | /** 585 | * @param string $field 586 | * @return bool 587 | */ 588 | protected function hasFilterField($field) 589 | { 590 | return array_key_exists($field, $this->filter_fields); 591 | } 592 | 593 | /** 594 | * @param GridField $gridField 595 | * @return array|null 596 | */ 597 | public function getHTMLFragments(mixed $gridField): mixed 598 | { 599 | $list = $gridField->getList(); 600 | if (!$this->checkDataType($list)) { 601 | return null; 602 | } 603 | 604 | /** @var DataList|Filterable $list */ 605 | $this->setDataClass($list->dataClass()); 606 | 607 | $forTemplate = ArrayData::create([]); 608 | $forTemplate->Fields = ArrayList::create(); 609 | 610 | $columns = $gridField->getColumns(); 611 | $filterArguments = $gridField->State->GridFieldFilterHeader->Columns->toArray(); 612 | $currentColumn = 0; 613 | $canFilter = false; 614 | 615 | foreach ($columns as $name) { 616 | $currentColumn++; 617 | $metadata = $gridField->getColumnMetadata($name); 618 | $title = $metadata['title']; 619 | $fields = new FieldGroup(); 620 | 621 | $fieldConfig = $this->findFieldConfig($name); 622 | $name = (!empty($fieldConfig['title'])) ? $fieldConfig['title'] : $name; 623 | 624 | if ($title && !empty($fieldConfig) && ($list->canFilterBy($name) || $this->hasFilterMethod($name))) { 625 | $canFilter = true; 626 | 627 | $value = ''; 628 | if (isset($filterArguments[$name])) { 629 | $value = $filterArguments[$name]; 630 | } 631 | $field = $this->createField($gridField, $name, $value); 632 | $field->addExtraClass('grid-field__sort-field'); 633 | $field->addExtraClass('no-change-track'); 634 | 635 | // add placeholder attribute only if it's not provided already 636 | if (empty($field->getAttribute('placeholder'))) { 637 | $field->setAttribute( 638 | 'placeholder', 639 | _t('SilverStripe\\Forms\\GridField\\GridField.FilterBy', 'Filter by ') 640 | . _t('SilverStripe\\Forms\\GridField\\GridField.'.$metadata['title'], $metadata['title']) 641 | ); 642 | } 643 | 644 | $fields->push($field); 645 | $fields->push( 646 | GridField_FormAction::create($gridField, 'reset', false, 'reset', null) 647 | ->addExtraClass( 648 | 'btn font-icon-cancel btn-secondary btn--no-text ss-gridfield-button-reset' 649 | ) 650 | ->setAttribute( 651 | 'title', 652 | _t('SilverStripe\\Forms\\GridField\\GridField.ResetFilter', 'Reset') 653 | ) 654 | ->setAttribute('id', 'action_reset_' . $gridField->getModelClass() . '_' . $name) 655 | ); 656 | } 657 | 658 | if ($currentColumn == count($columns)) { 659 | $fields->push( 660 | GridField_FormAction::create($gridField, 'filter', false, 'filter', null) 661 | ->addExtraClass( 662 | 'btn font-icon-search btn--no-text btn--icon-large grid-field__filter-submit ss-gridfield-button-filter' 663 | ) 664 | ->setAttribute( 665 | 'title', 666 | _t('SilverStripe\\Forms\\GridField\\GridField.Filter', 'Filter') 667 | ) 668 | ->setAttribute('id', 'action_filter_' . $gridField->getModelClass() . '_' . $name) 669 | ); 670 | $fields->push( 671 | GridField_FormAction::create($gridField, 'reset', false, 'reset', null) 672 | ->addExtraClass( 673 | 'btn font-icon-cancel btn--no-text grid-field__filter-clear btn--icon-md ss-gridfield-button-close' 674 | ) 675 | ->setAttribute( 676 | 'title', 677 | _t('SilverStripe\\Forms\\GridField\\GridField.ResetFilter', 'Reset') 678 | ) 679 | ->setAttribute('id', 'action_reset_' . $gridField->getModelClass() . '_' . $name) 680 | ); 681 | $fields->addExtraClass('grid-field__filter-buttons'); 682 | $fields->addExtraClass('no-change-track'); 683 | } 684 | 685 | $forTemplate->Fields->push($fields); 686 | } 687 | 688 | if (!$canFilter) { 689 | return null; 690 | } 691 | 692 | $templates = SSViewer::get_templates_by_class($this, '_Row', parent::class); 693 | 694 | return [ 695 | 'header' => $forTemplate->renderWith($templates), 696 | ]; 697 | } 698 | } 699 | -------------------------------------------------------------------------------- /src/Form/GridField/RichSortableHeader.php: -------------------------------------------------------------------------------- 1 | section 25 | * 26 | * @param mixed $gridField 27 | * @return mixed 28 | */ 29 | public function getHTMLFragments(mixed $gridField): mixed 30 | { 31 | /** @var RichFilterHeader $filter */ 32 | $filter = $gridField 33 | ->getConfig() 34 | ->getComponentByType(RichFilterHeader::class); 35 | 36 | if (!$filter) { 37 | // We don't have a matching rich filter header component set up, 38 | // so we will fall back to the default behaviour 39 | return parent::getHTMLFragments($gridField); 40 | } 41 | 42 | $list = $gridField->getList(); 43 | 44 | if (!$this->checkDataType($list)) { 45 | return null; 46 | } 47 | 48 | /** @var Sortable $list */ 49 | $forTemplate = new ArrayData([]); 50 | $forTemplate->Fields = new ArrayList; 51 | 52 | $state = $this->getState($gridField); 53 | $columns = $gridField->getColumns(); 54 | $currentColumn = 0; 55 | 56 | $schema = DataObject::getSchema(); 57 | 58 | foreach ($columns as $columnField) { 59 | $currentColumn++; 60 | $metadata = $gridField->getColumnMetadata($columnField); 61 | $fieldName = str_replace('.', '-', $columnField ?? ''); 62 | $title = $metadata['title']; 63 | 64 | if (isset($this->fieldSorting[$columnField]) && $this->fieldSorting[$columnField]) { 65 | $columnField = $this->fieldSorting[$columnField]; 66 | } 67 | 68 | $allowSort = ($title && $list->canSortBy($columnField)); 69 | 70 | if (!$allowSort && strpos($columnField ?? '', '.') !== false) { 71 | // we have a relation column with dot notation 72 | // @see DataObject::relField for approximation 73 | $parts = explode('.', $columnField ?? ''); 74 | $tmpItem = singleton($list->dataClass()); 75 | 76 | for ($idx = 0; $idx < sizeof($parts ?? []); $idx++) { 77 | $methodName = $parts[$idx]; 78 | if ($tmpItem instanceof SS_List) { 79 | // It's impossible to sort on a HasManyList/ManyManyList 80 | break; 81 | } elseif ($tmpItem && method_exists($tmpItem, 'hasMethod') && $tmpItem->hasMethod($methodName)) { 82 | // The part is a relation name, so get the object/list from it 83 | $tmpItem = $tmpItem->$methodName(); 84 | } elseif ($tmpItem instanceof DataObject 85 | && $schema->fieldSpec($tmpItem, $methodName, DataObjectSchema::DB_ONLY) 86 | ) { 87 | // Else, if we've found a database field at the end of the chain, we can sort on it. 88 | // If a method is applied further to this field (E.g. 'Cost.Currency') then don't try to sort. 89 | $allowSort = $idx === sizeof($parts ?? []) - 1; 90 | break; 91 | } else { 92 | // If neither method nor field, then unable to sort 93 | break; 94 | } 95 | } 96 | } 97 | 98 | if ($allowSort) { 99 | $dir = 'asc'; 100 | if ($state->SortColumn(null) == $columnField && $state->SortDirection('asc') == 'asc') { 101 | $dir = 'desc'; 102 | } 103 | 104 | $field = GridField_FormAction::create( 105 | $gridField, 106 | 'SetOrder' . $fieldName, 107 | $title, 108 | "sort$dir", 109 | ['SortColumn' => $columnField] 110 | )->addExtraClass('grid-field__sort'); 111 | 112 | if ($state->SortColumn(null) == $columnField) { 113 | $field->addExtraClass('ss-gridfield-sorted'); 114 | 115 | if ($state->SortDirection('asc') == 'asc') { 116 | $field->addExtraClass('ss-gridfield-sorted-asc'); 117 | } else { 118 | $field->addExtraClass('ss-gridfield-sorted-desc'); 119 | } 120 | } 121 | } else { 122 | // start 123 | $sortActionFieldContents = $currentColumn == count($columns ?? []) && $filter->canFilterAnyColumns($gridField) 124 | ? sprintf( 125 | '', 127 | _t('SilverStripe\\Forms\\GridField\\GridField.OpenFilter', "Open search and filter") 128 | ) 129 | : '' . $title . ''; 130 | $field = LiteralField::create($fieldName, $sortActionFieldContents); 131 | // end 132 | } 133 | 134 | $forTemplate->Fields->push($field); 135 | } 136 | 137 | $template = SSViewer::get_templates_by_class($this, '_Row', GridFieldSortableHeader::class); 138 | 139 | return [ 140 | 'header' => $forTemplate->renderWith($template), 141 | ]; 142 | } 143 | 144 | /** 145 | * Copied from parent without any change due to the method being private 146 | * 147 | * @param GridField $gridField 148 | * @return GridState_Data 149 | */ 150 | private function getState(GridField $gridField): GridState_Data 151 | { 152 | return $gridField->State->GridFieldSortableHeader; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /tests/php/Form/GridField/RichFilterHeaderTest.php: -------------------------------------------------------------------------------- 1 | removeComponentsByType(GridFieldFilterHeader::class); 47 | $config->addComponent(new RichFilterHeader(), GridFieldPaginator::class); 48 | 49 | $this->gridField = new GridField('testfield', 'testfield', $list, $config); 50 | $this->form = new Form( 51 | null, 52 | 'mockform', 53 | new FieldList([$this->gridField]), 54 | new FieldList() 55 | ); 56 | } 57 | 58 | public function testCompositeFieldName(): void 59 | { 60 | $gridFieldName = 'test-grid-field1'; 61 | $childFieldName = 'test-child-field1'; 62 | $compositeFieldName = RichFilterHeader::createCompositeFieldName($gridFieldName, $childFieldName); 63 | $data = RichFilterHeader::parseCompositeFieldName($compositeFieldName); 64 | 65 | $this->assertNotEmpty($data); 66 | $this->assertEquals($gridFieldName, $data['grid_field'], 'We expect a specific GridField name'); 67 | $this->assertEquals($childFieldName, $data['child_field'], 'We expect a specific child field name'); 68 | } 69 | 70 | public function testRenderFilteredHeaderStandard(): void 71 | { 72 | $gridField = $this->gridField; 73 | $config = $gridField->getConfig(); 74 | 75 | /** @var $component RichFilterHeader */ 76 | $component = $config->getComponentByType(RichFilterHeader::class); 77 | $htmlFragment = $component->getHTMLFragments($gridField); 78 | 79 | $this->assertStringContainsString( 80 | '', 83 | $htmlFragment['header'], 84 | 'We expect a rendered filter' 85 | ); 86 | } 87 | 88 | public function testRenderFilterHeaderWithCustomFields(): void 89 | { 90 | $gridField = $this->gridField; 91 | $config = $gridField->getConfig(); 92 | 93 | /** @var $component RichFilterHeader */ 94 | $component = $config->getComponentByType(RichFilterHeader::class); 95 | $component->setFilterFields([ 96 | 'Name' => DropdownField::create('', '', ['Name1' => 'Name1', 'Name2' => 'Name2']), 97 | 'City' => DropdownField::create('', '', ['City' => 'City1', 'City2' => 'City2']), 98 | ]); 99 | 100 | $htmlFragment = $component->getHTMLFragments($gridField); 101 | 102 | $this->assertStringContainsString( 103 | '', 114 | $htmlFragment['header'], 115 | 'We expect a rendered City filter' 116 | ); 117 | } 118 | 119 | public function testRenderFilterHeaderWithFullConfig(): void 120 | { 121 | $gridField = $this->gridField; 122 | $config = $gridField->getConfig(); 123 | 124 | /** @var $component RichFilterHeader */ 125 | $component = $config->getComponentByType(RichFilterHeader::class); 126 | $component 127 | ->setFilterConfig([ 128 | 'Name' => [ 129 | 'title' => 'Name', 130 | 'filter' => 'ExactMatchFilter', 131 | ], 132 | 'City.Initial' => [ 133 | 'title' => 'City', 134 | 'filter' => 'ExactMatchFilter', 135 | ], 136 | ]) 137 | ->setFilterFields([ 138 | 'Name' => DropdownField::create('', '', ['Name1' => 'Name1', 'Name2' => 'Name2']), 139 | 'City' => DropdownField::create('', '', ['City' => 'City1', 'City2' => 'City2']), 140 | ]); 141 | 142 | $htmlFragment = $component->getHTMLFragments($gridField); 143 | 144 | $this->assertStringContainsString( 145 | '', 156 | $htmlFragment['header'], 157 | 'We expect a rendered City filter' 158 | ); 159 | } 160 | 161 | public function testRenderFilterHeaderBasicFilter(): void 162 | { 163 | $gridField = $this->gridField; 164 | $config = $gridField->getConfig(); 165 | 166 | /** @var $component RichFilterHeader */ 167 | $component = $config->getComponentByType(RichFilterHeader::class); 168 | $component->setFilterConfig([ 169 | 'City.Initial' => [ 170 | 'title' => 'City', 171 | 'filter' => 'ExactMatchFilter', 172 | ], 173 | ]); 174 | 175 | $city = 'Auckland'; 176 | 177 | $stateID = 'testGridStateActionField'; 178 | $session = Controller::curr()->getRequest()->getSession(); 179 | $session->set( 180 | $stateID, 181 | [ 182 | 'grid' => '', 183 | 'actionName' => 'filter', 184 | 'args' => [], 185 | ] 186 | ); 187 | 188 | $token = SecurityToken::inst(); 189 | $request = new HTTPRequest( 190 | 'POST', 191 | 'url', 192 | [], 193 | [ 194 | 'action_gridFieldAlterAction?StateID='.$stateID=>true, 195 | $token->getName() => $token->getValue(), 196 | 'filter' => [ 197 | $gridField->getName() => [ 198 | 'City' => $city, 199 | ], 200 | ], 201 | ] 202 | ); 203 | 204 | $request->setSession($session); 205 | $gridField->gridFieldAlterAction(['StateID' => $stateID], $this->form, $request); 206 | $list = $component->getManipulatedData($gridField, $gridField->getList()); 207 | 208 | $this->assertSame( 209 | [ 210 | $city, 211 | ], 212 | $list->column('City'), 213 | 'We expect a single item after filtering by City' 214 | ); 215 | } 216 | 217 | public function testRenderFilterHeaderAdvancedFilterAllKeywords(): void 218 | { 219 | $gridField = $this->gridField; 220 | $config = $gridField->getConfig(); 221 | 222 | /** @var $component RichFilterHeader */ 223 | $component = $config->getComponentByType(RichFilterHeader::class); 224 | $component 225 | ->setFilterConfig([ 226 | 'Name', 227 | ]) 228 | ->setFilterMethods([ 229 | 'Name' => RichFilterHeader::FILTER_ALL_KEYWORDS, 230 | ]); 231 | 232 | $keywords = 'Team 1'; 233 | 234 | $stateID = 'testGridStateActionField'; 235 | $session = Controller::curr()->getRequest()->getSession(); 236 | $session->set( 237 | $stateID, 238 | [ 239 | 'grid' => '', 240 | 'actionName' => 'filter', 241 | 'args' => [], 242 | ] 243 | ); 244 | 245 | $token = SecurityToken::inst(); 246 | $request = new HTTPRequest( 247 | 'POST', 248 | 'url', 249 | [], 250 | [ 251 | 'action_gridFieldAlterAction?StateID='.$stateID=>true, 252 | $token->getName() => $token->getValue(), 253 | 'filter' => [ 254 | $gridField->getName() => [ 255 | 'Name' => $keywords, 256 | ], 257 | ], 258 | ] 259 | ); 260 | 261 | $request->setSession($session); 262 | $gridField->gridFieldAlterAction(['StateID' => $stateID], $this->form, $request); 263 | $list = $component->getManipulatedData($gridField, $gridField->getList()); 264 | 265 | $this->assertSame( 266 | [ 267 | $keywords, 268 | ], 269 | $list->column('Name'), 270 | 'We expect a single item after filtering by Name' 271 | ); 272 | } 273 | 274 | public function testRenderFilterHeaderAdvancedFilterManyManyRelation(): void 275 | { 276 | $gridField = $this->gridField; 277 | $gridField->setList(DataList::create(Cheerleader::class)); 278 | $config = $gridField->getConfig(); 279 | 280 | /** @var $component RichFilterHeader */ 281 | $component = $config->getComponentByType(RichFilterHeader::class); 282 | $component 283 | ->setFilterConfig([ 284 | 'Name' => 'Hats', 285 | ]) 286 | ->setFilterMethods([ 287 | 'Hats' => RichFilterHeader::FILTER_MANY_MANY_RELATION, 288 | ]); 289 | 290 | $hat = CheerleaderHat::get() 291 | ->filter(['Colour' => 'Blue']) 292 | ->first(); 293 | 294 | $stateID = 'testGridStateActionField'; 295 | $session = Controller::curr()->getRequest()->getSession(); 296 | $session->set( 297 | $stateID, 298 | [ 299 | 'grid' => '', 300 | 'actionName' => 'filter', 301 | 'args' => [], 302 | ] 303 | ); 304 | 305 | $token = SecurityToken::inst(); 306 | $request = new HTTPRequest( 307 | 'POST', 308 | 'url', 309 | [], 310 | [ 311 | 'action_gridFieldAlterAction?StateID='.$stateID=>true, 312 | $token->getName() => $token->getValue(), 313 | 'filter' => [ 314 | $gridField->getName() => [ 315 | 'Hats' => $hat->ID, 316 | ], 317 | ], 318 | ] 319 | ); 320 | 321 | $request->setSession($session); 322 | $gridField->gridFieldAlterAction(['StateID' => $stateID], $this->form, $request); 323 | $list = $component->getManipulatedData($gridField, $gridField->getList()); 324 | 325 | $this->assertEquals(1, (int) $list->count(), 'We expect a single item after filtering'); 326 | $this->assertEquals($hat->ID, $list->first()->Hats()->first()->ID, 'We expect a specific result item'); 327 | } 328 | 329 | public function testRenderFilterHeaderAdvancedFilterCustomCallback(): void 330 | { 331 | $gridField = $this->gridField; 332 | $config = $gridField->getConfig(); 333 | 334 | /** @var $component RichFilterHeader */ 335 | $component = $config->getComponentByType(RichFilterHeader::class); 336 | $component 337 | ->setFilterConfig([ 338 | 'City', 339 | ]) 340 | ->setFilterMethods([ 341 | 'City' => function (DataList $list, $name, $value) { 342 | return $list->filterAny([ 343 | 'City:StartsWith' => $value, 344 | 'City:EndsWith' => $value, 345 | ]); 346 | }, 347 | ]); 348 | 349 | $stateID = 'testGridStateActionField'; 350 | $session = Controller::curr()->getRequest()->getSession(); 351 | $session->set( 352 | $stateID, 353 | [ 354 | 'grid' => '', 355 | 'actionName' => 'filter', 356 | 'args' => [], 357 | ] 358 | ); 359 | 360 | $token = SecurityToken::inst(); 361 | $request = new HTTPRequest( 362 | 'POST', 363 | 'url', 364 | [], 365 | [ 366 | 'action_gridFieldAlterAction?StateID='.$stateID=>true, 367 | $token->getName() => $token->getValue(), 368 | 'filter' => [ 369 | $gridField->getName() => [ 370 | 'City' => 'n', 371 | ], 372 | ], 373 | ] 374 | ); 375 | 376 | $request->setSession($session); 377 | $gridField->gridFieldAlterAction(['StateID' => $stateID], $this->form, $request); 378 | 379 | /** @var DataList $list */ 380 | $list = $component->getManipulatedData($gridField, $gridField->getList()); 381 | 382 | $cities = $list 383 | ->sort('City', 'ASC') 384 | ->column('City'); 385 | $this->assertEquals( 386 | [ 387 | 'newton', 388 | 'Wellington' 389 | ], 390 | $cities, 391 | 'We expect specific results after filtering' 392 | ); 393 | } 394 | } 395 | -------------------------------------------------------------------------------- /tests/php/Form/GridField/RichFilterHeaderTest.yml: -------------------------------------------------------------------------------- 1 | Terraformers\RichFilterHeader\Tests\Form\GridField\RichFilterHeaderTest\CheerleaderHat: 2 | hat1: 3 | Colour: 'Blue' 4 | hat2: 5 | Colour: 'Red' 6 | hat3: 7 | Colour: 'Green' 8 | hat4: 9 | Colour: 'Pink' 10 | Terraformers\RichFilterHeader\Tests\Form\GridField\RichFilterHeaderTest\Cheerleader: 11 | cheerleader1: 12 | Name: 'Heather' 13 | Hats: 14 | - =>Terraformers\RichFilterHeader\Tests\Form\GridField\RichFilterHeaderTest\CheerleaderHat.hat2 15 | cheerleader2: 16 | Name: 'Bob' 17 | Hats: 18 | - =>Terraformers\RichFilterHeader\Tests\Form\GridField\RichFilterHeaderTest\CheerleaderHat.hat4 19 | cheerleader3: 20 | Name: 'Jenny' 21 | Hats: 22 | - =>Terraformers\RichFilterHeader\Tests\Form\GridField\RichFilterHeaderTest\CheerleaderHat.hat1 23 | cheerleader4: 24 | Name: 'Sam' 25 | Hats: 26 | - =>Terraformers\RichFilterHeader\Tests\Form\GridField\RichFilterHeaderTest\CheerleaderHat.hat3 27 | 28 | Terraformers\RichFilterHeader\Tests\Form\GridField\RichFilterHeaderTest\Team: 29 | team1: 30 | Name: 'Team 1' 31 | City: 'newton' 32 | Cheerleader: =>Terraformers\RichFilterHeader\Tests\Form\GridField\RichFilterHeaderTest\Cheerleader.cheerleader3 33 | team2: 34 | Name: 'Team 2' 35 | City: 'Wellington' 36 | Cheerleader: =>Terraformers\RichFilterHeader\Tests\Form\GridField\RichFilterHeaderTest\Cheerleader.cheerleader2 37 | team3: 38 | Name: 'Team 3' 39 | City: 'Auckland' 40 | Cheerleader: =>Terraformers\RichFilterHeader\Tests\Form\GridField\RichFilterHeaderTest\Cheerleader.cheerleader4 41 | team4: 42 | Name: 'Team 4' 43 | City: 'Melbourne' 44 | Cheerleader: =>Terraformers\RichFilterHeader\Tests\Form\GridField\RichFilterHeaderTest\Cheerleader.cheerleader1 45 | -------------------------------------------------------------------------------- /tests/php/Form/GridField/RichFilterHeaderTest/Cheerleader.php: -------------------------------------------------------------------------------- 1 | 'Varchar', 26 | ]; 27 | 28 | /** 29 | * @var array 30 | */ 31 | private static $has_one = [ 32 | 'Team' => Team::class, 33 | ]; 34 | 35 | /** 36 | * @var array 37 | */ 38 | private static $many_many = [ 39 | 'Hats' => CheerleaderHat::class, 40 | ]; 41 | } 42 | -------------------------------------------------------------------------------- /tests/php/Form/GridField/RichFilterHeaderTest/CheerleaderHat.php: -------------------------------------------------------------------------------- 1 | 'Varchar', 25 | ]; 26 | 27 | /** 28 | * @var array 29 | */ 30 | private static $belongs_many_many = [ 31 | 'Cheerleaders' => Cheerleader::class, 32 | ]; 33 | } 34 | -------------------------------------------------------------------------------- /tests/php/Form/GridField/RichFilterHeaderTest/Team.php: -------------------------------------------------------------------------------- 1 | 'Name', 25 | 'City.Initial' => 'City', 26 | ]; 27 | 28 | /** 29 | * @var array 30 | */ 31 | private static $db = [ 32 | 'Name' => 'Varchar', 33 | 'City' => 'Varchar', 34 | ]; 35 | 36 | /** 37 | * @var array 38 | */ 39 | private static $has_one = [ 40 | 'Cheerleader' => Cheerleader::class, 41 | ]; 42 | } 43 | --------------------------------------------------------------------------------