getPreparedFilterFields()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 15
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
eloc 10
c 2
b 0
f 0
nc 2
nop 0
dl 0
loc 15
rs 9.9332
1
<?php
2
3
namespace Signify\Forms\GridField;
4
5
use Signify\Forms\Validators\GridFieldDeleteRelationsValidator;
6
use SilverStripe\Forms\GridField\GridField_HTMLProvider;
7
use SilverStripe\Forms\GridField\GridField_URLHandler;
8
use SilverStripe\Forms\GridField\GridField;
9
use SilverStripe\Forms\GridField\GridField_FormAction;
10
use SilverStripe\Control\HTTPRequest;
11
use SilverStripe\Core\Extensible;
12
use SilverStripe\Security\Security;
13
use SilverStripe\View\ArrayData;
14
use SilverStripe\Forms\FormAction;
15
use SilverStripe\View\SSViewer;
16
use SilverStripe\Forms\FieldList;
17
use SilverStripe\Core\Injector\Injectable;
18
use SilverStripe\ORM\DataObject;
19
use SilverStripe\Forms\Form;
20
use SilverStripe\Forms\DropdownField;
21
use SilverStripe\Forms\FieldGroup;
22
use SilverStripe\Forms\FormField;
23
use SilverStripe\Forms\CheckboxField;
24
use SilverStripe\Core\Manifest\ModuleLoader;
25
use SilverStripe\Forms\ReadonlyField;
26
use SilverStripe\ORM\ArrayList;
27
use SilverStripe\View\Requirements;
28
use UncleCheese\DisplayLogic\Forms\Wrapper;
0 ignored issues
show
Bug introduced by
The type UncleCheese\DisplayLogic\Forms\Wrapper was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
29
30
/**
31
 * Adds an delete button to the bottom or top of a GridField.
32
 * Clicking the button opens a modal in which a user can select filter options.
33
 * The user can then delete models from the gridfield's list based on those filter options.
34
 */
35
class GridFieldDeleteRelationsButton implements GridField_HTMLProvider, GridField_URLHandler
36
{
37
    use Injectable;
38
    use Extensible;
39
40
    /**
41
     * Fragment to write the button to
42
     */
43
    protected $targetFragment;
44
45
    /**
46
     * @var GridField
47
     */
48
    protected $gridField;
49
50
    /**
51
     * A singleton of the class held by the gridfield
52
     * @var DataObject
53
     */
54
    protected $dummyObject;
55
56
    /**
57
     * @var string
58
     */
59
    protected $modalTitle = null;
60
61
    /**
62
     * @var FieldList
63
     */
64
    protected $filterFields;
65
66
    /**
67
     * @var array
68
     */
69
    protected $filterOptions = [
70
        '__default' => [
71
            'ExactMatch',
72
            'PartialMatch',
73
            'LessThan',
74
            'LessThanOrEqual',
75
            'GreaterThan',
76
            'GreaterThanOrEqual',
77
            'StartsWith',
78
            'EndsWith',
79
        ]
80
    ];
81
82
    public const DEFAULT_OPTION = '__default';
83
84
    public const OPTION_FIELD_SUFFIX = '__FilterOption';
85
86
    public const FILTER_BY_SUFFIX = '__FilterBy';
87
88
    public const FILTER_INVERT_SUFFIX = '__FilterInvert';
89
90
    public const DELETE_ALL = 'DeleteAll__FilterAll';
91
92
    /**
93
     * Filter options which are commonly used with string values.
94
     * @var string[]
95
     */
96
    public const STRING_FILTER_OPTIONS = [
97
        'ExactMatch',
98
        'PartialMatch',
99
        'StartsWith',
100
        'EndsWith',
101
    ];
102
103
    /**
104
     * Filter options which are commonly used with numbers or date values.
105
     * @var string[]
106
     */
107
    public const NUMBER_DATE_FILTER_OPTIONS = [
108
        'ExactMatch',
109
        'LessThan',
110
        'LessThanOrEqual',
111
        'GreaterThan',
112
        'GreaterThanOrEqual',
113
    ];
114
115
    /**
116
     * @param string $targetFragment The HTML fragment to write the button into
117
     */
118
    public function __construct($targetFragment = "after")
119
    {
120
        $this->targetFragment = $targetFragment;
121
    }
122
123
    /**
124
     * Place the export button in a <p> tag below the field
125
     *
126
     * @param GridField $gridField
127
     * @return array
128
     */
129
    public function getHTMLFragments($gridField)
130
    {
131
        if (ModuleLoader::inst()->getManifest()->moduleExists('unclecheese/display-logic')) {
132
            Requirements::javascript('signify-nz/silverstripe-security-headers:client/dist/main.js');
133
        }
134
        $modalID = $gridField->ID() . '_DeleteRelationsModal';
135
136
        // Check for form message prior to rendering form (which clears session messages)
137
        $form = $this->DeletionForm($gridField);
138
        $hasMessage = $form && $form->getMessage();
0 ignored issues
show
introduced by
$form is of type mixed, thus it always evaluated to false.
Loading history...
139
140
        // Render modal
141
        $template = SSViewer::get_templates_by_class(__CLASS__, '_Modal');
142
        $viewer = new ArrayData([
143
            'ModalTitle' => $this->getModalTitle(),
144
            'ModalID' => $modalID,
145
            'ModalForm' => $form,
146
        ]);
147
        $modal = $viewer->renderWith($template)->forTemplate();
148
149
        // Build action button
150
        $button = new GridField_FormAction(
151
            $gridField,
152
            'deletionForm',
153
            "Delete {$this->getDummyObject()->plural_name()}",
154
            'deletionForm',
155
            null
156
        );
157
        $button
158
        ->addExtraClass('btn btn-outline-danger font-icon-trash btn--icon-large action_import')
159
        ->setForm($gridField->getForm())
160
        ->setAttribute('data-toggle', 'modal')
161
        ->setAttribute('aria-controls', $modalID)
162
        ->setAttribute('data-target', "#{$modalID}")
163
        ->setAttribute('data-modal', $modal);
164
165
        // If form has a message, trigger it to automatically open
166
        if ($hasMessage) {
0 ignored issues
show
introduced by
The condition $hasMessage is always false.
Loading history...
167
            $button->setAttribute('data-state', 'open');
168
        }
169
170
        return [
171
            $this->targetFragment => $button->Field()
172
        ];
173
    }
174
175
    /**
176
     * Map URL paths to action methods.
177
     *
178
     * @param GridField $gridField
179
     *
180
     * @return array
181
     */
182
    public function getURLHandlers($gridField)
183
    {
184
        return [
185
            'delete' => 'handleDelete',
186
            'deletionForm' => 'DeletionForm',
187
        ];
188
    }
189
190
    /**
191
     * Generate a modal form for a single {@link DataObject} subclass.
192
     *
193
     * @param GridField $gridField
194
     * @return Form|false
195
     */
196
    public function DeletionForm($gridField = null)
197
    {
198
        if (!$gridField && !$gridField = $this->gridField) {
199
            return user_error('This button must be used in a gridfield.');
0 ignored issues
show
Bug Best Practice introduced by
The expression return user_error('This ... used in a gridfield.') returns the type boolean which is incompatible with the documented return type SilverStripe\Forms\Form|false.
Loading history...
200
        }
201
        $this->gridField = $gridField;
202
203
        $dummyObj = $this->getDummyObject();
204
205
        if (!$dummyObj->canCreate(Security::getCurrentUser())) {
206
            return false;
207
        }
208
209
        $fields = $this->getPreparedFilterFields();
210
211
        $actions = new FieldList(
212
            FormAction::create('delete', _t(
213
                self::class . '.DELETE',
214
                'Delete {pluralName}',
215
                ['pluralName' => $dummyObj->plural_name()]
216
            ))
217
            ->addExtraClass('btn btn-danger font-icon-trash')
218
        );
219
220
        $form = new Form(
221
            $gridField,
222
            'deletionForm',
223
            $fields,
224
            $actions,
225
            new GridFieldDeleteRelationsValidator()
226
        );
227
        $form->setFormAction($gridField->Link('delete'));
228
        if ($form->getMessage()) {
229
            $form->addExtraClass('validationerror');
230
        }
231
232
        $this->extend('updateDeletionForm', $form);
233
234
        return $form;
235
    }
236
237
    /**
238
     * Deletes models from the gridfield list based on user-supplied filters.
239
     *
240
     * @param GridField $gridField
241
     * @param HTTPRequest $request
242
     * @return bool|HTTPResponse
0 ignored issues
show
Bug introduced by
The type Signify\Forms\GridField\HTTPResponse was not found. Did you mean HTTPResponse? If so, make sure to prefix the type with \.
Loading history...
243
     */
244
    public function handleDelete($gridField, HTTPRequest $request)
245
    {
246
        $data = $this->parseQueryString($request->getBody());
247
        if (empty($data)) {
248
            $data = $request->requestVars();
249
        }
250
        $form = $this->DeletionForm($gridField);
251
        $form->loadDataFrom($data);
252
        $validationResult = $form->validationResult();
253
        if (!$validationResult->isValid()) {
254
            $form->setSessionValidationResult($validationResult);
255
            $form->setSessionData($data);
256
            return $gridField->redirectBack();
0 ignored issues
show
Bug Best Practice introduced by
The expression return $gridField->redirectBack() returns the type SilverStripe\Control\HTTPResponse which is incompatible with the documented return type Signify\Forms\GridField\HTTPResponse|boolean.
Loading history...
257
        }
258
259
        // Prepare filters based on user input.
260
        $filters = array();
261
        foreach ($data as $key => $value) {
262
            // If this fields is a "filter by" field, and the value is truthy, add the filter.
263
            if (preg_match('/' . self::FILTER_BY_SUFFIX . '$/', $key) && $value) {
264
                $fieldName = str_replace(self::FILTER_BY_SUFFIX, '', $key);
265
                $filterType = $data[$fieldName . self::OPTION_FIELD_SUFFIX];
266
                if (empty($filterType)) {
267
                    $filterType = 'ExactMatch';
268
                }
269
                if (!empty($data[$fieldName . self::FILTER_INVERT_SUFFIX])) {
270
                    $filterType .= ':not';
271
                }
272
                $filters["$fieldName:$filterType"] = empty($data[$fieldName]) ? null : $data[$fieldName];
273
            }
274
        }
275
276
        // Ensure data objects are filtered to only include items in this gridfield.
277
        $list = $gridField->getManipulatedList();
278
        if (method_exists($list, 'limit')) {
279
            $list = $list->limit(null);
280
        }
281
        $filters['ID'] = $list->column('ID');
282
        if (empty($filters['ID'])) {
283
            $deletions = new ArrayList();
284
        } else {
285
            $deletions = $gridField->getModelClass()::get()->filter($filters);
286
        }
287
288
        $message = '';
289
        if ($count = $deletions->count()) {
290
            /* @var $dataObject DataObject */
291
            foreach ($deletions as $dataObject) {
292
                $dataObject->delete();
293
                $dataObject->destroy();
294
            }
295
            $message .= _t(
296
                self::class . '.DELETED',
297
                'Deleted one record.|Deleted {count} records.',
298
                ['count' => $count]
299
            );
300
        } else {
301
            $message .= _t(self::class . '.NOT_DELETED', 'Nothing to delete.');
302
        }
303
304
        $gridField->getForm()->sessionMessage($message, 'good');
305
        return $gridField->redirectBack();
0 ignored issues
show
Bug Best Practice introduced by
The expression return $gridField->redirectBack() returns the type SilverStripe\Control\HTTPResponse which is incompatible with the documented return type Signify\Forms\GridField\HTTPResponse|boolean.
Loading history...
306
    }
307
308
    /**
309
     * Get the fields to display in the filter modal.
310
     * If {@link setFilterFields()} has not been called, this will be based on the class's getCMSFields
311
     * implementation or the default scaffolded fields for the class.
312
     *
313
     * @return FieldList
314
     */
315
    public function getFilterFields()
316
    {
317
        if (!$this->filterFields) {
318
            $obj = $this->getDummyObject();
319
            $fields = array_keys(DataObject::getSchema()->databaseFields($obj->ClassName));
320
            $fieldList = FieldList::create();
321
            // Get fields from object's CMSFields.
322
            foreach ($obj->getCMSFields()->flattenFields() as $field) {
323
                if (!in_array($field->Name, $fields)) {
324
                    continue;
325
                }
326
                $fieldList->add($field);
327
            }
328
            // Get scaffolded DB fields if getCMSFields has no DB Fields.
329
            if (!$fieldList->count()) {
330
                foreach ($obj->scaffoldFormFields() as $field) {
331
                    $fieldList->add($field);
332
                }
333
            }
334
            $this->filterFields = $fieldList;
335
        }
336
        return $this->filterFields;
337
    }
338
339
    /**
340
     * Set the fields to display in the filter modal.
341
     * Names of fields must match the names of database fields on the class which is held by the gridfield.
342
     *
343
     * @param array|FieldList $fields
344
     * @return $this
345
     */
346
    public function setFilterFields($fields)
347
    {
348
        if (is_array($fields)) {
349
            $fields = FieldList::create($fields);
350
        }
351
        if (!$fields instanceof FieldList) {
352
            throw new \BadMethodCallException('"fields" must be a FieldList or array.');
353
        }
354
355
        $this->filterFields = $fields;
356
        return $this;
357
    }
358
359
    /**
360
     * Get the options by which each field can be filtered.
361
     *
362
     * @return array
363
     */
364
    public function getFilterOptions()
365
    {
366
        return $this->filterOptions;
367
    }
368
369
    /**
370
     * Get the options by which each field can be filtered.
371
     *
372
     * The keys are names of database fields on the class which is held by the gridfield.
373
     * Values must be an array of search filter options.
374
     *
375
     * Note that if a given field is not set, this will fall back to the default options.
376
     * The key for the default options is {@link GridFieldDeleteRelationsButton::DEFAULT_OPTION}
377
     *
378
     * @param array $options
379
     * @return $this
380
     * @link https://docs.silverstripe.org/en/4/developer_guides/model/searchfilters/
381
     */
382
    public function setFilterOptions(array $options)
383
    {
384
        $this->filterOptions = array_merge($this->filterOptions, $options);
385
        return $this;
386
    }
387
388
    /**
389
     * Get the title of the filter modal.
390
     *
391
     * @return string
392
     */
393
    public function getModalTitle()
394
    {
395
        if (!$this->modalTitle) {
396
            $this->modalTitle = _t(
397
                self::class . '.DELETE',
398
                'Delete {pluralName}',
399
                ['pluralName' => $this->getDummyObject()->plural_name()]
400
            );
401
        }
402
        return $this->modalTitle;
403
    }
404
405
    /**
406
     * Set the title of the filter modal.
407
     *
408
     * @param string $modalTitle
409
     * @return $this
410
     */
411
    public function setModalTitle($modalTitle)
412
    {
413
        $this->modalTitle = $modalTitle;
414
        return $this;
415
    }
416
417
    /**
418
     * Get all composite fields for the modal form.
419
     *
420
     * @return FieldList
421
     */
422
    protected function getPreparedFilterFields()
423
    {
424
        $fields = FieldList::create();
425
        $fields->add(CheckboxField::create(
426
            self::DELETE_ALL,
427
            _t(
428
                self::class . '.DELETE_ALL',
429
                'Delete all {pluralName}',
430
                ['pluralName' => $this->getDummyObject()->plural_name()]
431
            )
432
        ));
433
        foreach ($this->getFilterFields() as $field) {
434
            $fields->add($this->getFieldAsComposite($field));
0 ignored issues
show
Bug introduced by
It seems like $field can also be of type SilverStripe\View\ArrayData; however, parameter $field of Signify\Forms\GridField\...::getFieldAsComposite() does only seem to accept SilverStripe\Forms\FormField, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

434
            $fields->add($this->getFieldAsComposite(/** @scrutinizer ignore-type */ $field));
Loading history...
435
        }
436
        return $fields;
437
    }
438
439
    /**
440
     * Get a CompositeField for the given field which contains all
441
     * necessary filter fields to support the given field.
442
     *
443
     * @param FormField $field
444
     * @return \SilverStripe\Forms\CompositeField
445
     */
446
    protected function getFieldAsComposite(FormField $field)
447
    {
448
        $fields = [
449
            $filterBy = CheckboxField::create(
450
                $field->Name . self::FILTER_BY_SUFFIX,
451
                _t(
452
                    self::class . '.FILTER_BY',
453
                    'Filter by "{fieldName}"',
454
                    ['fieldName' => $field->Title()]
455
                )
456
            ),
457
            $field,
458
            $options = $this->getFilterTypesField($field->Name, $field->Title()),
459
            $invert = CheckboxField::create(
460
                $field->Name . self::FILTER_INVERT_SUFFIX,
461
                _t(
462
                    self::class . '.FILTER_INVERT',
463
                    'Invert Filter'
464
                )
465
            )
466
        ];
467
468
        $group = FieldGroup::create(
469
            _t(
470
                self::class . '.FILTER_GROUP',
471
                '"{fieldName}" filter group',
472
                ['fieldName' => $field->Title()]
473
            ),
474
            $fields
475
        )->setDescription('<span class="js-placeholder-txt"></span>');
476
        if (ModuleLoader::inst()->getManifest()->moduleExists('unclecheese/display-logic')) {
477
            $group = Wrapper::create($group);
478
            $field->displayIf($filterBy->Name)->isChecked();
479
            $options->displayIf($filterBy->Name)->isChecked();
480
            $invert->displayIf($filterBy->Name)->isChecked();
481
            $group->hideIf(self::DELETE_ALL)->isChecked();
482
        }
483
484
        return $group;
485
    }
486
487
    /**
488
     * Get a DropdownField with filter types as defined in
489
     * {@link GridFieldDeleteRelationsButton::setFilterOptions()}.
490
     *
491
     * @param string $fieldName
492
     * @param string $fieldTitle
493
     * @return FormField
494
     */
495
    protected function getFilterTypesField($fieldName, $fieldTitle)
496
    {
497
        $allOptions = $this->filterOptions;
498
        if (array_key_exists($fieldName, $allOptions)) {
499
            $options = $allOptions[$fieldName];
500
        } else {
501
            $options = $allOptions[self::DEFAULT_OPTION];
502
        }
503
        $filterFieldName = $fieldName . self::OPTION_FIELD_SUFFIX;
504
        $filterFieldTitle = _t(
505
            self::class . '.FILTER_TYPE',
506
            '"{fieldName}" Filter Type',
507
            ['fieldName' => $fieldTitle]
508
        );
509
        if (count($options) == 1) {
510
            $field = ReadonlyField::create(
511
                $filterFieldName,
512
                $filterFieldTitle,
513
                $options[0]
514
            )->setIncludeHiddenField(true)
515
            ->setTemplate('Signify\Forms\ReadonlyField');
516
        } else {
517
            $field = DropdownField::create(
518
                $filterFieldName,
519
                $filterFieldTitle,
520
                array_combine($options, $options)
521
            );
522
            $field->setHasEmptyDefault(true);
523
            if (in_array('ExactMatch', $options)) {
524
                $field->setValue('ExactMatch');
525
            }
526
        }
527
        $this->extend('updateFilterOptionsField', $field, $fieldName);
528
        return $field;
529
    }
530
531
    /**
532
     * Returns a singleton of the class held by the gridfield.
533
     *
534
     * @return \SilverStripe\ORM\DataObject
535
     */
536
    protected function getDummyObject()
537
    {
538
        if (!$this->dummyObject && $this->gridField) {
539
            $this->dummyObject = $this->gridField->getModelClass()::singleton();
540
        }
541
        return $this->dummyObject;
542
    }
543
544
    /**
545
     * An alternative to {@link parse_str()} which keeps periods intact.
546
     * This allows using dot syntax for filtering by relationships.
547
     *
548
     * @param string $data
549
     * @return array
550
     */
551
    protected function parseQueryString($data)
552
    {
553
        if (empty($data)) {
554
            return array();
555
        }
556
        $data = urldecode($data);
557
558
        $data = preg_replace_callback('/(?:^|(?<=&))[^=[]+/', function ($match) {
559
            return bin2hex(urldecode($match[0]));
560
        }, $data);
561
562
        parse_str($data, $result);
563
564
        return array_combine(array_map('hex2bin', array_keys($result)), $result);
565
    }
566
}
567