├── .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 | [](https://packagist.org/packages/silverstripe-terraformers/gridfield-rich-filter-header)
4 | [](https://packagist.org/packages/silverstripe-terraformers/gridfield-rich-filter-header)
5 | [](https://packagist.org/packages/silverstripe-terraformers/gridfield-rich-filter-header)
6 | [](https://packagist.org/packages/silverstripe-terraformers/gridfield-rich-filter-header)
7 | [](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 | 
203 | 
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 | 
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 | 
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 | '