TagField   C
last analyzed

Complexity

Total Complexity 57

Size/Duplication

Total Lines 526
Duplicated Lines 0 %

Importance

Changes 19
Bugs 2 Features 0
Metric Value
wmc 57
eloc 165
c 19
b 2
f 0
dl 0
loc 526
rs 5.04

29 Methods

Rating   Name   Duplication   Size   Complexity  
A getShouldLazyLoad() 0 3 1
A setLazyLoadItemLimit() 0 5 1
A Type() 0 3 1
A Field() 0 5 1
B getOrCreateTag() 0 34 6
A getTitleField() 0 3 1
A getTags() 0 25 3
A __construct() 0 6 1
A getSourceList() 0 3 1
A setTitleField() 0 5 1
A setIsMultiple() 0 5 1
A validate() 0 3 1
A setValue() 0 15 4
A performReadonlyTransformation() 0 6 1
A setShouldLazyLoad() 0 5 1
B saveInto() 0 34 7
A setSourceList() 0 4 1
A setSource() 0 10 2
A setCanCreate() 0 5 1
A getAttributes() 0 8 1
A getSuggestURL() 0 3 1
B getOptions() 0 46 7
A getSchemaDataDefaults() 0 22 4
A getIsMultiple() 0 3 1
A getSource() 0 6 2
A suggest() 0 9 1
A getSchemaStateDefaults() 0 13 2
A getCanCreate() 0 3 1
A getLazyLoadItemLimit() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like TagField often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use TagField, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace SilverStripe\TagField;
4
5
use Exception;
6
use SilverStripe\Control\Controller;
7
use SilverStripe\Control\HTTPRequest;
8
use SilverStripe\Control\HTTPResponse;
9
use SilverStripe\Core\Convert;
10
use SilverStripe\Core\Injector\Injector;
11
use SilverStripe\Forms\MultiSelectField;
12
use SilverStripe\Forms\Validator;
13
use SilverStripe\ORM\ArrayList;
14
use SilverStripe\ORM\DataList;
15
use SilverStripe\ORM\DataObject;
16
use SilverStripe\ORM\DataObjectInterface;
17
use SilverStripe\ORM\Relation;
18
use SilverStripe\ORM\SS_List;
19
use SilverStripe\View\ArrayData;
20
21
/**
22
 * Provides a tagging interface, storing links between tag DataObjects and a parent DataObject.
23
 *
24
 * @package forms
25
 * @subpackage fields
26
 */
27
class TagField extends MultiSelectField
28
{
29
    /**
30
     * @var array
31
     */
32
    private static $allowed_actions = [
33
        'suggest',
34
    ];
35
36
    /**
37
     * @var bool
38
     */
39
    protected $shouldLazyLoad = false;
40
41
    /**
42
     * @var int
43
     */
44
    protected $lazyLoadItemLimit = 10;
45
46
    /**
47
     * @var bool
48
     */
49
    protected $canCreate = true;
50
51
    /**
52
     * @var string
53
     */
54
    protected $titleField = 'Title';
55
56
    /**
57
     * @var DataList
58
     */
59
    protected $sourceList;
60
61
    /**
62
     * @var bool
63
     */
64
    protected $isMultiple = true;
65
66
    /** @skipUpgrade */
67
    protected $schemaComponent = 'TagField';
68
69
    /**
70
     * @param string $name
71
     * @param string $title
72
     * @param null|DataList|array $source
73
     * @param null|DataList $value
74
     * @param string $titleField
75
     */
76
    public function __construct($name, $title = '', $source = [], $value = null, $titleField = 'Title')
77
    {
78
        $this->setTitleField($titleField);
79
        parent::__construct($name, $title, $source, $value);
80
81
        $this->addExtraClass('ss-tag-field');
82
    }
83
84
    /**
85
     * @return bool
86
     */
87
    public function getShouldLazyLoad()
88
    {
89
        return $this->shouldLazyLoad;
90
    }
91
92
    /**
93
     * @param bool $shouldLazyLoad
94
     *
95
     * @return static
96
     */
97
    public function setShouldLazyLoad($shouldLazyLoad)
98
    {
99
        $this->shouldLazyLoad = $shouldLazyLoad;
100
101
        return $this;
102
    }
103
104
    /**
105
     * @return int
106
     */
107
    public function getLazyLoadItemLimit()
108
    {
109
        return $this->lazyLoadItemLimit;
110
    }
111
112
    /**
113
     * @param int $lazyLoadItemLimit
114
     *
115
     * @return static
116
     */
117
    public function setLazyLoadItemLimit($lazyLoadItemLimit)
118
    {
119
        $this->lazyLoadItemLimit = $lazyLoadItemLimit;
120
121
        return $this;
122
    }
123
124
    /**
125
     * @return bool
126
     */
127
    public function getIsMultiple()
128
    {
129
        return $this->isMultiple;
130
    }
131
132
    /**
133
     * @param bool $isMultiple
134
     *
135
     * @return static
136
     */
137
    public function setIsMultiple($isMultiple)
138
    {
139
        $this->isMultiple = $isMultiple;
140
141
        return $this;
142
    }
143
144
    /**
145
     * @return bool
146
     */
147
    public function getCanCreate()
148
    {
149
        return $this->canCreate;
150
    }
151
152
    /**
153
     * @param bool $canCreate
154
     *
155
     * @return static
156
     */
157
    public function setCanCreate($canCreate)
158
    {
159
        $this->canCreate = $canCreate;
160
161
        return $this;
162
    }
163
164
    /**
165
     * @return string
166
     */
167
    public function getTitleField()
168
    {
169
        return $this->titleField;
170
    }
171
172
    /**
173
     * @param string $titleField
174
     *
175
     * @return $this
176
     */
177
    public function setTitleField($titleField)
178
    {
179
        $this->titleField = $titleField;
180
181
        return $this;
182
    }
183
184
    /**
185
     * Get the DataList source. The 4.x upgrade for SelectField::setSource starts to convert this to an array.
186
     * If empty use getSource() for array version
187
     *
188
     * @return DataList
189
     */
190
    public function getSourceList()
191
    {
192
        return $this->sourceList;
193
    }
194
195
    /**
196
     * Set the model class name for tags
197
     *
198
     * @param DataList $sourceList
199
     * @return self
200
     */
201
    public function setSourceList($sourceList)
202
    {
203
        $this->sourceList = $sourceList;
204
        return $this;
205
    }
206
207
    /**
208
     * {@inheritdoc}
209
     */
210
    public function Field($properties = [])
211
    {
212
        $this->addExtraClass('entwine');
213
214
        return $this->customise($properties)->renderWith(self::class);
215
    }
216
217
    /**
218
     * Provide TagField data to the JSON schema for the frontend component
219
     *
220
     * @return array
221
     */
222
    public function getSchemaDataDefaults()
223
    {
224
        $options = $this->getOptions(true);
225
        $schema = array_merge(
226
            parent::getSchemaDataDefaults(),
227
            [
228
                'name' => $this->getName() . '[]',
229
                'lazyLoad' => $this->getShouldLazyLoad(),
230
                'creatable' => $this->getCanCreate(),
231
                'multi' => $this->getIsMultiple(),
232
                'value' => $options->count() ? $options->toNestedArray() : null,
233
                'disabled' => $this->isDisabled() || $this->isReadonly(),
234
            ]
235
        );
236
237
        if (!$this->getShouldLazyLoad()) {
238
            $schema['options'] = array_values($this->getOptions()->toNestedArray());
239
        } else {
240
            $schema['optionUrl'] = $this->getSuggestURL();
241
        }
242
243
        return $schema;
244
    }
245
246
    /**
247
     * @return string
248
     */
249
    protected function getSuggestURL()
250
    {
251
        return Controller::join_links($this->Link(), 'suggest');
252
    }
253
254
    /**
255
     * @return ArrayList
256
     */
257
    protected function getOptions($onlySelected = false)
258
    {
259
        $options = ArrayList::create();
260
        $source = $this->getSourceList();
261
262
        // No source means we have no options
263
        if (!$source) {
0 ignored issues
show
introduced by
$source is of type SilverStripe\ORM\DataList, thus it always evaluated to true.
Loading history...
264
            return ArrayList::create();
265
        }
266
267
        $dataClass = $source->dataClass();
268
269
        $values = $this->Value();
270
271
        // If we have no values and we only want selected options we can bail here
272
        if (empty($values) && $onlySelected) {
273
            return ArrayList::create();
274
        }
275
276
        // Convert an array of values into a datalist of options
277
        if (is_array($values) && !empty($values)) {
278
            $values = DataList::create($dataClass)
279
                ->filter($this->getTitleField(), $values);
280
        } else {
281
            $values = ArrayList::create();
282
        }
283
284
        // Prep a function to parse a dataobject into an option
285
        $addOption = function (DataObject $item) use ($options, $values) {
286
            $titleField = $this->getTitleField();
287
            $option = $item->$titleField;
288
            $options->push(ArrayData::create([
289
                'Title' => $option,
290
                'Value' => $option,
291
                'Selected' => (bool) $values->find('ID', $item->ID)
292
            ]));
293
        };
294
295
        // Only parse the values if we only want the selected items in the values list (this is for lazy-loading)
296
        if ($onlySelected) {
297
            $values->each($addOption);
298
            return $options;
299
        }
300
301
        $source->each($addOption);
302
        return $options;
303
    }
304
305
    /**
306
     * {@inheritdoc}
307
     */
308
    public function setValue($value, $source = null)
309
    {
310
        if ($source instanceof DataObject) {
311
            $name = $this->getName();
312
313
            if ($source->hasMethod($name)) {
314
                $value = $source->$name()->column($this->getTitleField());
315
            }
316
        }
317
318
        if (!is_array($value)) {
319
            return parent::setValue($value);
320
        }
321
322
        return parent::setValue(array_filter($value));
323
    }
324
325
    /**
326
     * Gets the source array if required
327
     *
328
     * Note: this is expensive for a SS_List
329
     *
330
     * @return array
331
     */
332
    public function getSource()
333
    {
334
        if (is_null($this->source)) {
335
            $this->source = $this->getListMap($this->getSourceList());
336
        }
337
        return $this->source;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->source also could return the type ArrayAccess which is incompatible with the documented return type array.
Loading history...
338
    }
339
340
    /**
341
     * Intercept DataList source
342
     *
343
     * @param mixed $source
344
     * @return $this
345
     */
346
    public function setSource($source)
347
    {
348
        // When setting a datalist force internal list to null
349
        if ($source instanceof DataList) {
350
            $this->source = null;
351
            $this->setSourceList($source);
352
        } else {
353
            parent::setSource($source);
354
        }
355
        return $this;
356
    }
357
358
    /**
359
     * @param DataObject|DataObjectInterface $record DataObject to save data into
360
     * @throws Exception
361
     */
362
    public function getAttributes()
363
    {
364
        return array_merge(
365
            parent::getAttributes(),
366
            [
367
                'name' => $this->getName() . '[]',
368
                'style' => 'width: 100%',
369
                'data-schema' => json_encode($this->getSchemaData()),
370
            ]
371
        );
372
    }
373
374
    /**
375
     * {@inheritdoc}
376
     */
377
    public function saveInto(DataObjectInterface $record)
378
    {
379
        $name = $this->getName();
380
        $titleField = $this->getTitleField();
381
        $values = $this->Value();
382
383
        /** @var Relation $relation */
384
        $relation = $record->$name();
385
        $ids = [];
386
387
        if (!$values) {
388
            $values = [];
389
        }
390
391
        if (empty($record) || empty($titleField)) {
392
            return;
393
        }
394
395
        if (!$record->hasMethod($name)) {
396
            throw new Exception(
397
                sprintf("%s does not have a %s method", get_class($record), $name)
398
            );
399
        }
400
401
        foreach ($values as $key => $value) {
402
            // Get or create record
403
            $record = $this->getOrCreateTag($value);
404
            if ($record) {
405
                $ids[] = $record->ID;
406
                $values[$key] = $record->Title;
407
            }
408
        }
409
410
        $relation->setByIDList(array_filter($ids));
411
    }
412
413
    /**
414
     * Get or create tag with the given value
415
     *
416
     * @param  string $term
417
     * @return DataObject|bool
418
     */
419
    protected function getOrCreateTag($term)
420
    {
421
        // Check if existing record can be found
422
        $source = $this->getSourceList();
423
        if (!$source) {
0 ignored issues
show
introduced by
$source is of type SilverStripe\ORM\DataList, thus it always evaluated to true.
Loading history...
424
            return false;
425
        }
426
427
        $titleField = $this->getTitleField();
428
        $record = $source
429
            ->filter($titleField, $term)
430
            ->first();
431
        if ($record) {
0 ignored issues
show
introduced by
$record is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
432
            return $record;
433
        }
434
435
        // Create new instance if not yet saved
436
        if ($this->getCanCreate()) {
437
            $dataClass = $source->dataClass();
438
            $record = Injector::inst()->create($dataClass);
439
440
            if (is_array($term)) {
441
                $term = $term['Value'];
442
            }
443
444
            $record->{$titleField} = $term;
445
            $record->write();
446
            if ($source instanceof SS_List) {
447
                $source->add($record);
448
            }
449
            return $record;
450
        }
451
452
        return false;
453
    }
454
455
    /**
456
     * Returns a JSON string of tags, for lazy loading.
457
     *
458
     * @param  HTTPRequest $request
459
     * @return HTTPResponse
460
     */
461
    public function suggest(HTTPRequest $request)
462
    {
463
        $tags = $this->getTags($request->getVar('term'));
464
465
        $response = HTTPResponse::create();
466
        $response->addHeader('Content-Type', 'application/json');
467
        $response->setBody(json_encode(['items' => $tags]));
468
469
        return $response;
470
    }
471
472
    /**
473
     * Returns array of arrays representing tags.
474
     *
475
     * @param  string $term
476
     * @return array
477
     */
478
    protected function getTags($term)
479
    {
480
        $source = $this->getSourceList();
481
        if (!$source) {
0 ignored issues
show
introduced by
$source is of type SilverStripe\ORM\DataList, thus it always evaluated to true.
Loading history...
482
            return [];
483
        }
484
485
        $titleField = $this->getTitleField();
486
487
        $query = $source
488
            ->filter($titleField . ':PartialMatch:nocase', $term)
489
            ->sort($titleField)
490
            ->limit($this->getLazyLoadItemLimit());
491
492
        // Map into a distinct list
493
        $items = [];
494
        $titleField = $this->getTitleField();
495
        foreach ($query->map('ID', $titleField) as $id => $title) {
496
            $items[$title] = [
497
                'Title' => $title,
498
                'Value' => $title,
499
            ];
500
        }
501
502
        return array_values($items);
503
    }
504
505
    /**
506
     * DropdownField assumes value will be a scalar so we must
507
     * override validate. This only applies to Silverstripe 3.2+
508
     *
509
     * @param Validator $validator
510
     * @return bool
511
     */
512
    public function validate($validator)
513
    {
514
        return true;
515
    }
516
517
    /**
518
     * Converts the field to a readonly variant.
519
     *
520
     * @return ReadonlyTagField
521
     */
522
    public function performReadonlyTransformation()
523
    {
524
        /** @var ReadonlyTagField $copy */
525
        $copy = $this->castedCopy(ReadonlyTagField::class);
526
        $copy->setSourceList($this->getSourceList());
527
        return $copy;
528
    }
529
530
    /**
531
     * Prevent the default, which would return "tag"
532
     *
533
     * @return string
534
     */
535
    public function Type()
536
    {
537
        return '';
538
    }
539
540
    public function getSchemaStateDefaults()
541
    {
542
        $data = parent::getSchemaStateDefaults();
543
544
        // Add options to 'data'
545
        $data['lazyLoad'] = $this->getShouldLazyLoad();
546
        $data['multi'] = $this->getIsMultiple();
547
        $data['optionUrl'] = $this->getSuggestURL();
548
        $data['creatable'] = $this->getCanCreate();
549
        $options = $this->getOptions(true);
550
        $data['value'] = $options->count() ? $options->toNestedArray() : null;
551
552
        return $data;
553
    }
554
}
555