Completed
Pull Request — master (#6843)
by Damian
08:11
created

TreeDropdownField::setSourceObject()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 1
dl 0
loc 5
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Forms;
4
5
use SilverStripe\Assets\Folder;
6
use SilverStripe\Control\HTTPRequest;
7
use SilverStripe\Control\HTTPResponse;
8
use SilverStripe\Core\Convert;
9
use SilverStripe\ORM\DataObject;
10
use SilverStripe\ORM\Hierarchy\Hierarchy;
11
use SilverStripe\ORM\Hierarchy\MarkedSet;
12
use SilverStripe\View\ViewableData;
13
use Exception;
14
use InvalidArgumentException;
15
16
/**
17
 * Dropdown-like field that allows you to select an item from a hierarchical
18
 * AJAX-expandable tree.
19
 *
20
 * Creates a field which opens a dropdown (actually a div via javascript
21
 * included for you) which contains a tree with the ability to select a singular
22
 * item for the value of the field. This field has the ability to store one-to-one
23
 * joins related to hierarchy or a hierarchy based filter.
24
 *
25
 * **Note:** your source object must use an implementation of hierarchy for this
26
 * field to generate the tree correctly, e.g. {@link Group}, {@link SiteTree} etc.
27
 *
28
 * All operations are carried out through javascript and provides no fallback
29
 * to non JS.
30
 *
31
 * <b>Usage</b>.
32
 *
33
 * <code>
34
 * static $has_one = array(
35
 *   'RightContent' => 'SiteTree'
36
 * );
37
 *
38
 * function getCMSFields() {
39
 * ...
40
 * $treedropdownfield = new TreeDropdownField("RightContentID", "Choose a page to show on the right:", "SiteTree");
41
 * ..
42
 * }
43
 * </code>
44
 *
45
 * This will generate a tree allowing the user to expand and contract subsections
46
 * to find the appropriate page to save to the field.
47
 *
48
 * Caution: The form field does not include any JavaScript or CSS when used outside of the CMS context,
49
 * since the required frontend dependencies are included through CMS bundling.
50
 *
51
 * @see TreeMultiselectField for the same implementation allowing multiple selections
52
 * @see DropdownField for a simple dropdown field.
53
 * @see CheckboxSetField for multiple selections through checkboxes.
54
 * @see OptionsetField for single selections via radiobuttons.
55
 */
56
class TreeDropdownField extends FormField
57
{
58
    protected $schemaDataType = self::SCHEMA_DATA_TYPE_SINGLESELECT;
59
60
    protected $schemaComponent = 'TreeDropdownField';
61
62
    private static $url_handlers = array(
63
        '$Action!/$ID' => '$Action'
64
    );
65
66
    private static $allowed_actions = array(
67
        'tree'
68
    );
69
70
    /**
71
     * Class name for underlying object
72
     *
73
     * @var string
74
     */
75
    protected $sourceObject = null;
76
77
    /**
78
     * Name of key field on underlying object
79
     *
80
     * @var string
81
     */
82
    protected $keyField = null;
83
84
    /**
85
     * Name of lavel field on underlying object
86
     *
87
     * @var string
88
     */
89
    protected $labelField = null;
90
91
    /**
92
     * Callback for filtering records
93
     *
94
     * @var callable
95
     */
96
    protected $filterCallback = null;
97
98
    /**
99
     * Callback for marking record as disabled
100
     *
101
     * @var callable
102
     */
103
    protected $disableCallback = null;
104
105
    /**
106
     * Callback for searching records. This callback takes the following arguments:
107
     *  - sourceObject Object class to search
108
     *  - labelField Label field
109
     *  - search Search text
110
     *
111
     * @var callable
112
     */
113
    protected $searchCallback = null;
114
115
    /**
116
     * Filter for base record
117
     *
118
     * @var int
119
     */
120
    protected $baseID = 0;
121
122
    /**
123
     * Default child method in Hierarchy->getChildrenAsUL
124
     *
125
     * @var string
126
     */
127
    protected $childrenMethod = 'AllChildrenIncludingDeleted';
128
129
    /**
130
     * Default child counting method in Hierarchy->getChildrenAsUL
131
     *
132
     * @var string
133
     */
134
    protected $numChildrenMethod = 'numChildren';
135
136
    /**
137
     * Current string value for search text to filter on
138
     *
139
     * @var string
140
     */
141
    protected $search = null;
142
143
    /**
144
     * List of ids in current search result (keys are ids, values are true)
145
     *
146
     * @var array
147
     */
148
    protected $searchIds = [];
149
150
    /**
151
     * Determine if search should be shown
152
     *
153
     * @var bool
154
     */
155
    protected $showSearch = false;
156
157
    /**
158
     * List of ids which have their search expanded (keys are ids, values are true)
159
     *
160
     * @var array
161
     */
162
    protected $searchExpanded = [];
163
164
    /**
165
     * CAVEAT: for search to work properly $labelField must be a database field,
166
     * or you need to setSearchFunction.
167
     *
168
     * @param string $name the field name
169
     * @param string $title the field label
170
     * @param string $sourceObject A DataObject class name with the {@link Hierarchy} extension.
171
     * @param string $keyField to field on the source class to save as the
172
     *      field value (default ID).
173
     * @param string $labelField the field name to show as the human-readable
174
     *      value on the tree (default Title).
175
     * @param bool $showSearch enable the ability to search the tree by
176
     *      entering the text in the input field.
177
     */
178
    public function __construct(
179
        $name,
180
        $title = null,
181
        $sourceObject = null,
182
        $keyField = 'ID',
183
        $labelField = 'TreeTitle',
184
        $showSearch = true
185
    ) {
186
        if (!is_a($sourceObject, DataObject::class, true)) {
187
            throw new InvalidArgumentException("SourceObject must be a DataObject subclass");
188
        }
189
        if (!DataObject::has_extension($sourceObject, Hierarchy::class)) {
190
            throw new InvalidArgumentException("SourceObject must have Hierarchy extension");
191
        }
192
        $this->sourceObject = $sourceObject;
193
        $this->keyField     = $keyField;
194
        $this->labelField   = $labelField;
195
        $this->showSearch   = $showSearch;
196
197
        // Extra settings for Folders
198
        if (strcasecmp($sourceObject, Folder::class) === 0) {
199
            $this->childrenMethod = 'ChildFolders';
200
            $this->numChildrenMethod = 'numChildFolders';
201
        }
202
203
        $this->addExtraClass('single');
204
205
        parent::__construct($name, $title);
206
    }
207
208
    /**
209
     * Set the ID of the root node of the tree. This defaults to 0 - i.e.
210
     * displays the whole tree.
211
     *
212
     * @param int $ID
213
     * @return $this
214
     */
215
    public function setTreeBaseID($ID)
216
    {
217
        $this->baseID = (int) $ID;
218
        return $this;
219
    }
220
221
    /**
222
     * Set a callback used to filter the values of the tree before
223
     * displaying to the user.
224
     *
225
     * @param callback $callback
226
     * @return $this
227
     */
228
    public function setFilterFunction($callback)
229
    {
230
        if (!is_callable($callback, true)) {
231
            throw new InvalidArgumentException('TreeDropdownField->setFilterCallback(): not passed a valid callback');
232
        }
233
234
        $this->filterCallback = $callback;
235
        return $this;
236
    }
237
238
    /**
239
     * Set a callback used to disable checkboxes for some items in the tree
240
     *
241
     * @param callback $callback
242
     * @return $this
243
     */
244
    public function setDisableFunction($callback)
245
    {
246
        if (!is_callable($callback, true)) {
247
            throw new InvalidArgumentException('TreeDropdownField->setDisableFunction(): not passed a valid callback');
248
        }
249
250
        $this->disableCallback = $callback;
251
        return $this;
252
    }
253
254
    /**
255
     * Set a callback used to search the hierarchy globally, even before
256
     * applying the filter.
257
     *
258
     * @param callback $callback
259
     * @return $this
260
     */
261
    public function setSearchFunction($callback)
262
    {
263
        if (!is_callable($callback, true)) {
264
            throw new InvalidArgumentException('TreeDropdownField->setSearchFunction(): not passed a valid callback');
265
        }
266
267
        $this->searchCallback = $callback;
268
        return $this;
269
    }
270
271
    /**
272
     * Check if search is shown
273
     *
274
     * @return bool
275
     */
276
    public function getShowSearch()
277
    {
278
        return $this->showSearch;
279
    }
280
281
    /**
282
     * @param bool $bool
283
     * @return $this
284
     */
285
    public function setShowSearch($bool)
286
    {
287
        $this->showSearch = $bool;
288
        return $this;
289
    }
290
291
    /**
292
     * @param string $method The parameter to ChildrenMethod to use when calling Hierarchy->getChildrenAsUL in
293
     * {@link Hierarchy}. The method specified determines the structure of the returned list. Use "ChildFolders"
294
     * in place of the default to get a drop-down listing with only folders, i.e. not including the child elements in
295
     * the currently selected folder. setNumChildrenMethod() should be used as well for proper functioning.
296
     *
297
     * See {@link Hierarchy} for a complete list of possible methods.
298
     * @return $this
299
     */
300
    public function setChildrenMethod($method)
301
    {
302
        $this->childrenMethod = $method;
303
        return $this;
304
    }
305
306
    /**
307
     * @param string $method The parameter to numChildrenMethod to use when calling Hierarchy->getChildrenAsUL in
308
     * {@link Hierarchy}. Should be used in conjunction with setChildrenMethod().
309
     *
310
     * @return $this
311
     */
312
    public function setNumChildrenMethod($method)
313
    {
314
        $this->numChildrenMethod = $method;
315
        return $this;
316
    }
317
318
    /**
319
     * @param array $properties
320
     * @return string
321
     */
322
    public function Field($properties = array())
323
    {
324
        $record = $this->Value() ? $this->objectForKey($this->Value()) : null;
325
        if ($record instanceof ViewableData) {
326
            $title = $record->obj($this->labelField)->forTemplate();
327
        } elseif ($record) {
328
            $title = Convert::raw2xml($record->{$this->labelField});
329
        } else {
330
            $title = $this->getEmptyTitle();
331
        }
332
333
        // TODO Implement for TreeMultiSelectField
334
        $metadata = array(
335
            'id' => $record ? $record->ID : null,
336
            'ClassName' => $record ? $record->ClassName : $this->sourceObject
337
        );
338
339
        $properties = array_merge(
340
            $properties,
341
            array(
342
                'Title' => $title,
343
                'EmptyTitle' => $this->getEmptyTitle(),
344
                'Metadata' => ($metadata) ? Convert::raw2json($metadata) : null,
345
            )
346
        );
347
348
        return parent::Field($properties);
349
    }
350
351
    public function extraClass()
352
    {
353
        return implode(' ', array(parent::extraClass(), ($this->showSearch ? "searchable" : null)));
354
    }
355
356
    /**
357
     * Get the whole tree of a part of the tree via an AJAX request.
358
     *
359
     * @param HTTPRequest $request
360
     * @return HTTPResponse
361
     * @throws Exception
362
     */
363
    public function tree(HTTPRequest $request)
364
    {
365
        // Regular source specification
366
        $isSubTree = false;
367
368
        $this->search = $request->requestVar('search');
369
        $id = (is_numeric($request->latestParam('ID')))
370
            ? (int)$request->latestParam('ID')
371
            : (int)$request->requestVar('ID');
372
373
        /** @var DataObject|Hierarchy $obj */
374
        $obj = null;
375
        if ($id && !$request->requestVar('forceFullTree')) {
376
            $obj = DataObject::get_by_id($this->sourceObject, $id);
377
            $isSubTree = true;
378
            if (!$obj) {
379
                throw new Exception(
380
                    "TreeDropdownField->tree(): the object #$id of type $this->sourceObject could not be found"
381
                );
382
            }
383
        } else {
384
            if ($this->baseID) {
385
                $obj = DataObject::get_by_id($this->sourceObject, $this->baseID);
386
            }
387
388
            if (!$this->baseID || !$obj) {
389
                $obj = DataObject::singleton($this->sourceObject);
390
            }
391
        }
392
393
        // pre-process the tree - search needs to operate globally, not locally as marking filter does
394
        if ($this->search) {
395
            $this->populateIDs();
396
        }
397
398
        // Create marking set
399
        $markingSet = MarkedSet::create($obj, $this->childrenMethod, $this->numChildrenMethod, 30);
400
401
        // Set filter on searched nodes
402
        if ($this->filterCallback || $this->search) {
403
            // Rely on filtering to limit tree
404
            $markingSet->setMarkingFilterFunction(function ($node) {
405
                return $this->filterMarking($node);
406
            });
407
            $markingSet->setLimitingEnabled(false);
408
        }
409
410
        // Begin marking
411
        $markingSet->markPartialTree();
412
413
        // Allow to pass values to be selected within the ajax request
414
        $value = $request->requestVar('forceValue') ?: $this->value;
415
        if ($value && ($values = preg_split('/,\s*/', $value))) {
416
            foreach ($values as $value) {
417
                if (!$value || $value == 'unchanged') {
418
                    continue;
419
                }
420
421
                $markingSet->markToExpose($this->objectForKey($value));
0 ignored issues
show
Bug introduced by
It seems like $this->objectForKey($value) can be null; however, markToExpose() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
422
            }
423
        }
424
425
        // Set title formatter
426
        $customised = function (DataObject $child) use ($isSubTree) {
427
            return [
428
                'name' => $this->getName(),
429
                'id' => $child->obj($this->keyField),
430
                'title' => $child->getTitle(),
431
                'treetitle' => $child->obj($this->labelField),
432
                'disabled' => $this->nodeIsDisabled($child),
433
                'isSubTree' => $isSubTree
434
            ];
435
        };
436
437
        // Determine output format
438
        if ($request->requestVar('format') === 'json') {
439
            // Format JSON output
440
            $json = $markingSet
441
                ->getChildrenAsArray($customised);
442
            return HTTPResponse::create()
443
                ->addHeader('Content-Type', 'application/json')
444
                ->setBody(json_encode($json));
445
        } else {
446
            // Return basic html
447
            $html = $markingSet->renderChildren(
448
                [self::class . '_HTML', 'type' => 'Includes'],
449
                $customised
450
            );
451
            return HTTPResponse::create()
452
                ->addHeader('Content-Type', 'text/html')
453
                ->setBody($html);
454
        }
455
    }
456
457
    /**
458
     * Marking public function for the tree, which combines different filters sensibly.
459
     * If a filter function has been set, that will be called. And if search text is set,
460
     * filter on that too. Return true if all applicable conditions are true, false otherwise.
461
     *
462
     * @param DataObject $node
463
     * @return bool
464
     */
465
    public function filterMarking($node)
466
    {
467
        if ($this->filterCallback && !call_user_func($this->filterCallback, $node)) {
468
            return false;
469
        }
470
471
        if ($this->search) {
472
            return isset($this->searchIds[$node->ID]) && $this->searchIds[$node->ID] ? true : false;
473
        }
474
475
        return true;
476
    }
477
478
    /**
479
     * Marking a specific node in the tree as disabled
480
     * @param $node
481
     * @return boolean
482
     */
483
    public function nodeIsDisabled($node)
484
    {
485
        return ($this->disableCallback && call_user_func($this->disableCallback, $node));
486
    }
487
488
    /**
489
     * @param string $field
490
     * @return $this
491
     */
492
    public function setLabelField($field)
493
    {
494
        $this->labelField = $field;
495
        return $this;
496
    }
497
498
    /**
499
     * @return String
500
     */
501
    public function getLabelField()
502
    {
503
        return $this->labelField;
504
    }
505
506
    /**
507
     * @param string $field
508
     * @return $this
509
     */
510
    public function setKeyField($field)
511
    {
512
        $this->keyField = $field;
513
        return $this;
514
    }
515
516
    /**
517
     * @return String
518
     */
519
    public function getKeyField()
520
    {
521
        return $this->keyField;
522
    }
523
524
    /**
525
     * @param string $class
526
     * @return $this
527
     */
528
    public function setSourceObject($class)
529
    {
530
        $this->sourceObject = $class;
531
        return $this;
532
    }
533
534
    /**
535
     * @return String
536
     */
537
    public function getSourceObject()
538
    {
539
        return $this->sourceObject;
540
    }
541
542
    /**
543
     * Populate $this->searchIds with the IDs of the pages matching the searched parameter and their parents.
544
     * Reverse-constructs the tree starting from the leaves. Initially taken from CMSSiteTreeFilter, but modified
545
     * with pluggable search function.
546
     */
547
    protected function populateIDs()
548
    {
549
        // get all the leaves to be displayed
550
        if ($this->searchCallback) {
551
            $res = call_user_func($this->searchCallback, $this->sourceObject, $this->labelField, $this->search);
552
        } else {
553
            $sourceObject = $this->sourceObject;
554
            $filters = array();
555
            if (singleton($sourceObject)->hasDatabaseField($this->labelField)) {
556
                $filters["{$this->labelField}:PartialMatch"]  = $this->search;
557
            } else {
558
                if (singleton($sourceObject)->hasDatabaseField('Title')) {
559
                    $filters["Title:PartialMatch"] = $this->search;
560
                }
561
                if (singleton($sourceObject)->hasDatabaseField('Name')) {
562
                    $filters["Name:PartialMatch"] = $this->search;
563
                }
564
            }
565
566
            if (empty($filters)) {
567
                throw new InvalidArgumentException(sprintf(
568
                    'Cannot query by %s.%s, not a valid database column',
569
                    $sourceObject,
570
                    $this->labelField
571
                ));
572
            }
573
            $res = DataObject::get($this->sourceObject)->filterAny($filters);
574
        }
575
576
        if ($res) {
577
            // iteratively fetch the parents in bulk, until all the leaves can be accessed using the tree control
578
            foreach ($res as $row) {
579
                if ($row->ParentID) {
580
                    $parents[$row->ParentID] = true;
0 ignored issues
show
Coding Style Comprehensibility introduced by
$parents was never initialized. Although not strictly required by PHP, it is generally a good practice to add $parents = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
581
                }
582
                $this->searchIds[$row->ID] = true;
583
            }
584
585
            $sourceObject = $this->sourceObject;
586
587
            while (!empty($parents)) {
588
                $items = DataObject::get($sourceObject)
589
                    ->filter("ID", array_keys($parents));
590
                $parents = array();
591
592
                foreach ($items as $item) {
593
                    if ($item->ParentID) {
594
                        $parents[$item->ParentID] = true;
595
                    }
596
                    $this->searchIds[$item->ID] = true;
597
                    $this->searchExpanded[$item->ID] = true;
598
                }
599
            }
600
        }
601
    }
602
603
    /**
604
     * Get the object where the $keyField is equal to a certain value
605
     *
606
     * @param string|int $key
607
     * @return DataObject
608
     */
609
    protected function objectForKey($key)
610
    {
611
        return DataObject::get($this->sourceObject)
612
            ->filter($this->keyField, $key)
613
            ->first();
614
    }
615
616
    /**
617
     * Changes this field to the readonly field.
618
     */
619
    public function performReadonlyTransformation()
620
    {
621
        /** @var TreeDropdownField_Readonly $copy */
622
        $copy = $this->castedCopy(TreeDropdownField_Readonly::class);
623
        $copy->setKeyField($this->keyField);
624
        $copy->setLabelField($this->labelField);
625
        $copy->setSourceObject($this->sourceObject);
626
        return $copy;
627
    }
628
629
    public function getSchemaStateDefaults()
630
    {
631
        // Check label for field
632
        $record = $this->Value() ? $this->objectForKey($this->Value()) : null;
633
        $selectedlabel = null;
634
635
        $data = parent::getSchemaStateDefaults();
636
        $data['data']['emptyTitle'] = $this->getEmptyTitle();
637
        if ($record) {
638
            $data['data']['valueObject'] = [
639
                'id' => $record->getField($this->keyField),
640
                'title' => $record->getTitle(),
641
                'treetitle' => $record->obj($this->labelField)->getSchemaValue(),
642
            ];
643
        }
644
645
        return $data;
646
    }
647
648
    public function getSchemaDataDefaults()
649
    {
650
        $data = parent::getSchemaDataDefaults();
651
        $data['data']['urlTree'] = $this->Link('tree');
652
        return $data;
653
    }
654
655
    /**
656
     * @return string
657
     */
658
    protected function getEmptyTitle()
659
    {
660
        $item = DataObject::singleton($this->sourceObject);
661
        $emptyTitle = _t(
662
            'DropdownField.CHOOSE_MODEL',
663
            '(Choose {name})',
664
            ['name' => $item->i18n_singular_name()]
665
        );
666
        return $emptyTitle;
667
    }
668
}
669