ExcelGridFieldExportButton   F
last analyzed

Complexity

Total Complexity 82

Size/Duplication

Total Lines 594
Duplicated Lines 0 %

Importance

Changes 8
Bugs 3 Features 0
Metric Value
wmc 82
eloc 187
c 8
b 3
f 0
dl 0
loc 594
rs 2

35 Methods

Rating   Name   Duplication   Size   Complexity  
A getURLHandlers() 0 3 1
A getActions() 0 3 1
A getActionName() 0 4 1
A handleAction() 0 8 2
A getHTMLFragments() 0 23 2
A __construct() 0 4 1
A setListFilters() 0 4 1
A setIgnoreFilters() 0 4 1
A setHasHeader() 0 4 1
F generateExportFileData() 0 103 26
A getSanitizeXls() 0 3 1
A getAfterExportCallback() 0 3 1
A getListFilters() 0 3 1
A setExportType() 0 7 2
A setCheckCanView() 0 4 1
A sanitizeValue() 0 20 5
A getRealExportColumns() 0 4 2
A setButtonTitle() 0 4 1
A setExportName() 0 4 1
A getIsLimited() 0 3 1
A handleExport() 0 29 3
A setSanitizeXls() 0 4 1
A getExportColumns() 0 3 1
A getExportType() 0 3 1
A getHasHeader() 0 3 1
A setAfterExportCallback() 0 4 1
A isSanitizeEnabled() 0 5 3
A getIgnoreFilters() 0 3 1
A updateExportName() 0 11 3
A getCheckCanView() 0 3 1
A getExportName() 0 3 1
A setIsLimited() 0 4 1
A setExportColumns() 0 4 1
B retrieveList() 0 29 9
A getButtonTitle() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like ExcelGridFieldExportButton 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 ExcelGridFieldExportButton, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace LeKoala\ExcelImportExport;
4
5
use Generator;
6
use InvalidArgumentException;
7
use SilverStripe\ORM\DataList;
8
use SilverStripe\ORM\ArrayList;
9
use SilverStripe\ORM\SS_List;
10
use SilverStripe\Control\HTTPRequest;
11
use LeKoala\SpreadCompat\SpreadCompat;
0 ignored issues
show
Bug introduced by
The type LeKoala\SpreadCompat\SpreadCompat 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...
12
use SilverStripe\Assets\FileNameFilter;
13
use SilverStripe\Forms\GridField\GridField;
14
use SilverStripe\Forms\GridField\GridFieldPaginator;
15
use SilverStripe\Forms\GridField\GridField_FormAction;
16
use SilverStripe\Forms\GridField\GridField_URLHandler;
17
use SilverStripe\Forms\GridField\GridFieldFilterHeader;
18
use SilverStripe\Forms\GridField\GridField_HTMLProvider;
19
use SilverStripe\Forms\GridField\GridFieldSortableHeader;
20
use SilverStripe\Forms\GridField\GridField_ActionProvider;
21
22
/**
23
 * Adds an "Export list" button to the bottom of a {@link GridField}.
24
 */
25
class ExcelGridFieldExportButton implements
26
    GridField_HTMLProvider,
27
    GridField_ActionProvider,
28
    GridField_URLHandler
29
{
30
    /**
31
     * Map of a property name on the exported objects, with values being the column title in the file.
32
     * Note that titles are only used when {@link $hasHeader} is set to TRUE.
33
     * @var array<int|string,mixed>
34
     */
35
    protected ?array $exportColumns;
36
37
    /**
38
     * Fragment to write the button to
39
     */
40
    protected string $targetFragment;
41
42
    protected bool $hasHeader = true;
43
44
    protected string $exportType = 'xlsx';
45
46
    protected ?string $exportName = null;
47
48
    protected ?string $buttonTitle = null;
49
50
    protected bool $checkCanView = true;
51
52
    protected bool $isLimited = true;
53
54
    /**
55
     * @var array<mixed>
56
     */
57
    protected array $listFilters = [];
58
59
    /**
60
     *
61
     * @var null|callable
62
     */
63
    protected $afterExportCallback;
64
65
    protected bool $ignoreFilters = false;
66
67
    protected bool $sanitizeXls = true;
68
69
    /**
70
     * @param string $targetFragment The HTML fragment to write the button into
71
     * @param array<string> $exportColumns The columns to include in the export
72
     */
73
    public function __construct($targetFragment = "after", $exportColumns = null)
74
    {
75
        $this->targetFragment = $targetFragment;
76
        $this->exportColumns = $exportColumns;
77
    }
78
79
    /**
80
     * @param GridField $gridField
81
     * @return string
82
     */
83
    public function getActionName($gridField)
84
    {
85
        $name = strtolower($gridField->getName());
86
        return 'excelexport_' . $name . '_' . $this->exportType;
87
    }
88
89
    /**
90
     * Place the export button in a <p> tag below the field
91
     * @return array<string|int,mixed>
92
     */
93
    public function getHTMLFragments($gridField)
94
    {
95
        $defaultTitle = _t(
96
            'ExcelImportExport.FORMATEXPORT',
97
            'Export to {format}',
98
            ['format' => $this->exportType]
99
        );
100
        $title = $this->buttonTitle ? $this->buttonTitle : $defaultTitle;
101
102
        $name = $this->getActionName($gridField);
103
104
        $button = new GridField_FormAction(
105
            $gridField,
106
            $name,
107
            $title,
108
            $name,
109
            []
110
        );
111
        $button->addExtraClass('btn btn-secondary no-ajax font-icon-down-circled action_export');
112
        $button->setForm($gridField->getForm());
113
114
        return array(
115
            $this->targetFragment => $button->Field()
116
        );
117
    }
118
119
    /**
120
     * export is an action button
121
     * @param GridField $gridField
122
     * @return array<string>
123
     */
124
    public function getActions($gridField)
125
    {
126
        return array($this->getActionName($gridField));
127
    }
128
129
    /**
130
     * @param GridField $gridField
131
     * @param string $actionName
132
     * @param array<mixed> $arguments
133
     * @param array<mixed> $data
134
     * @return void
135
     */
136
    public function handleAction(
137
        GridField $gridField,
138
        $actionName,
139
        $arguments,
140
        $data
141
    ) {
142
        if (in_array($actionName, $this->getActions($gridField))) {
143
            $this->handleExport($gridField);
144
        }
145
    }
146
147
    /**
148
     * it is also a URL
149
     * @param GridField $gridField
150
     * @return array<string,string>
151
     */
152
    public function getURLHandlers($gridField)
153
    {
154
        return array($this->getActionName($gridField) => 'handleExport');
155
    }
156
157
    /**
158
     * Handle the export, for both the action button and the URL
159
     * @param GridField $gridField
160
     * @param HTTPRequest $request
161
     * @return void
162
     */
163
    public function handleExport($gridField, $request = null)
164
    {
165
        $now = date("Ymd_Hi");
166
167
        $this->updateExportName($gridField);
168
169
        $data = $this->generateExportFileData($gridField);
170
171
        $ext = $this->exportType;
172
        $name = $this->exportName;
173
        $fileName = "$name-$now.$ext";
174
175
        if ($this->afterExportCallback) {
176
            $func = $this->afterExportCallback;
177
            $func();
178
        }
179
180
        $opts = [
181
            'extension' => $ext,
182
        ];
183
184
        if ($ext != 'csv') {
185
            $end = ExcelImportExport::getLetter(count($this->getRealExportColumns($gridField)));
186
            $opts['creator'] = ExcelImportExport::config()->default_creator;
187
            $opts['autofilter'] = "A1:{$end}1";
188
        }
189
190
        SpreadCompat::output($data, $fileName, ...$opts);
191
        exit();
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
192
    }
193
194
195
    /**
196
     * Make sure export name is a valid file name
197
     * @param GridField|\LeKoala\Tabulator\TabulatorGrid $gridField
0 ignored issues
show
Bug introduced by
The type LeKoala\Tabulator\TabulatorGrid 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...
198
     * @return void
199
     */
200
    protected function updateExportName($gridField)
201
    {
202
        $filter = new FileNameFilter;
203
        if ($this->exportName) {
204
            $this->exportName = $filter->filter($this->exportName);
205
        } else {
206
            $class = $gridField->getModelClass();
207
            $singl = singleton($class);
208
            $plural = $class ? $singl->i18n_plural_name() : '';
209
210
            $this->exportName = $filter->filter('export-' . $plural);
211
        }
212
    }
213
214
    /**
215
     * @param GridField|\LeKoala\Tabulator\TabulatorGrid $gridField
216
     * @return DataList|ArrayList|SS_List|null
217
     */
218
    protected function retrieveList($gridField)
219
    {
220
        // Remove GridFieldPaginator as we're going to export the entire list.
221
        $gridField->getConfig()->removeComponentsByType(GridFieldPaginator::class);
222
223
        /** @var DataList|ArrayList $items */
224
        $items = $gridField->getManipulatedList();
225
226
        // Keep filters
227
        if (!$this->ignoreFilters) {
228
            foreach ($gridField->getConfig()->getComponents() as $component) {
229
                if ($component instanceof GridFieldFilterHeader || $component instanceof GridFieldSortableHeader) {
230
                    //@phpstan-ignore-next-line
231
                    $items = $component->getManipulatedData($gridField, $items);
232
                }
233
            }
234
        }
235
236
        $list = $items;
237
        $limit = ExcelImportExport::getExportLimit();
238
        if ($list instanceof DataList) {
239
            if ($this->isLimited && $limit > 0) {
240
                $list = $list->limit($limit);
241
            }
242
            if (!empty($this->listFilters)) {
243
                $list = $list->filter($this->listFilters);
244
            }
245
        }
246
        return $list;
247
    }
248
249
    /**
250
     * @param GridField|\LeKoala\Tabulator\TabulatorGrid $gridField
251
     * @return array<int|string,mixed|null>
252
     */
253
    protected function getRealExportColumns($gridField)
254
    {
255
        $class = $gridField->getModelClass();
256
        return ($this->exportColumns) ? $this->exportColumns : ExcelImportExport::exportFieldsForClass($class);
257
    }
258
259
    /**
260
     * Generate export fields for Excel.
261
     *
262
     * @param GridField|\LeKoala\Tabulator\TabulatorGrid $gridField
263
     */
264
    public function generateExportFileData($gridField): Generator
265
    {
266
        $columns = $this->getRealExportColumns($gridField);
267
268
        if ($this->hasHeader) {
269
            $headers = [];
270
271
            // determine the headers. If a field is callable (e.g. anonymous function) then use the
272
            // source name as the header instead
273
            foreach ($columns as $columnSource => $columnHeader) {
274
                //@phpstan-ignore-next-line
275
                if (is_array($columnHeader) && array_key_exists('title', $columnHeader ?? [])) {
276
                    $headers[] = $columnHeader['title'];
277
                } else {
278
                    $headers[] = (!is_string($columnHeader) && is_callable($columnHeader)) ? $columnSource : $columnHeader;
279
                }
280
            }
281
282
            yield $headers;
283
        }
284
285
        $list = $this->retrieveList($gridField);
286
287
        if (!$list) {
288
            return;
289
        }
290
291
        // Auto format using DBField methods based on column name
292
        $export_format = ExcelImportExport::config()->export_format;
293
294
        $sanitize = $this->isSanitizeEnabled();
295
296
        foreach ($list as $item) {
297
            // This can be really slow for large exports depending on how canView is implemented
298
            if ($this->checkCanView) {
299
                $canView = true;
300
                if ($item->hasMethod('canView') && !$item->canView()) {
301
                    $canView = false;
302
                }
303
                if (!$canView) {
304
                    continue;
305
                }
306
            }
307
308
            $dataRow = [];
309
310
            // Loop and transforms records as needed
311
            foreach ($columns as $columnSource => $columnHeader) {
312
                if (!is_string($columnHeader) && is_callable($columnHeader)) {
313
                    if ($item->hasMethod($columnSource)) {
314
                        $relObj = $item->{$columnSource}();
315
                    } else {
316
                        $relObj = $item->relObject($columnSource);
317
                    }
318
319
                    $value = $columnHeader($relObj);
320
                } else {
321
                    if (is_string($columnSource)) {
322
                        // It can be a method
323
                        if (strpos($columnSource, '(') !== false) {
324
                            $matches = [];
325
                            preg_match('/([a-zA-Z]*)\((.*)\)/', $columnSource, $matches);
326
                            $func = $matches[1];
327
                            $params = explode(",", $matches[2]);
328
                            // Support only one param for now
329
                            $value = $item->$func($params[0]);
330
                        } else {
331
                            if (array_key_exists($columnSource, $export_format)) {
332
                                $format = $export_format[$columnSource];
333
                                $value = $item->dbObject($columnSource)->$format();
334
                            } else {
335
                                $value = $gridField->getDataFieldValue($item, $columnSource);
336
                            }
337
                        }
338
                    } else {
339
                        // We can also use a simple dot notation
340
                        $parts = explode(".", $columnHeader);
0 ignored issues
show
Bug introduced by
It seems like $columnHeader can also be of type null; however, parameter $string of explode() does only seem to accept string, 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

340
                        $parts = explode(".", /** @scrutinizer ignore-type */ $columnHeader);
Loading history...
341
                        if (count($parts) == 1) {
342
                            $value = $item->$columnHeader;
343
                        } else {
344
                            $value = $item->relObject($parts[0]);
345
                            if ($value) {
346
                                $relObjField = $parts[1];
347
                                $value = $value->$relObjField;
348
                            }
349
                        }
350
                    }
351
                }
352
353
                // @link https://owasp.org/www-community/attacks/CSV_Injection
354
                // [SS-2017-007] Sanitise XLS executable column values with a leading tab
355
                if ($sanitize && $value && is_string($value)) {
356
                    $value = self::sanitizeValue($value);
357
                }
358
359
                $dataRow[] = $value;
360
            }
361
362
            if ($item->hasMethod('destroy')) {
363
                $item->destroy();
364
            }
365
366
            yield $dataRow;
367
        }
368
    }
369
370
    /**
371
     * Sanitization is necessary for csv
372
     * It can be turned off if needed
373
     * If we have no chars to sanitize, it's not enabled
374
     *
375
     * @return boolean
376
     */
377
    public function isSanitizeEnabled(): bool
378
    {
379
        $sanitize_xls_chars = ExcelImportExport::config()->sanitize_xls_chars ?? "=";
380
        $sanitize = $this->sanitizeXls && $sanitize_xls_chars && $this->exportType == "csv";
381
        return $sanitize;
382
    }
383
384
    /**
385
     * @link https://owasp.org/www-community/attacks/CSV_Injection
386
     * [SS-2017-007] Sanitise XLS executable column values with a leading tab
387
     */
388
    public static function sanitizeValue(string $value = null): ?string
389
    {
390
        if (!$value) {
391
            return $value;
392
        }
393
394
        $sanitize_xls_chars = ExcelImportExport::config()->sanitize_xls_chars ?? "=";
395
        $sanitize_xls_chars_len = strlen($sanitize_xls_chars);
396
397
        // If we have only one char we can make it simpler
398
        if ($sanitize_xls_chars_len === 1) {
399
            if ($value[0] === $sanitize_xls_chars) {
400
                $value = "\t" . $value;
401
            }
402
        } else {
403
            if (preg_match('/^[' . $sanitize_xls_chars . '].*/', $value)) {
404
                $value = "\t" . $value;
405
            }
406
        }
407
        return $value;
408
    }
409
410
    /**
411
     * @return array<int|string,mixed>
412
     */
413
    public function getExportColumns()
414
    {
415
        return $this->exportColumns;
416
    }
417
418
    /**
419
     * @param array<int|string,mixed> $cols
420
     * @return $this
421
     */
422
    public function setExportColumns($cols)
423
    {
424
        $this->exportColumns = $cols;
425
        return $this;
426
    }
427
428
    /**
429
     * @return boolean
430
     */
431
    public function getHasHeader()
432
    {
433
        return $this->hasHeader;
434
    }
435
436
    /**
437
     * @param boolean $bool
438
     * @return $this
439
     */
440
    public function setHasHeader($bool)
441
    {
442
        $this->hasHeader = $bool;
443
        return $this;
444
    }
445
446
    /**
447
     * @return string
448
     */
449
    public function getExportType()
450
    {
451
        return $this->exportType;
452
    }
453
454
    /**
455
     * @param string $exportType xlsx (default), xls or csv
456
     * @return $this
457
     */
458
    public function setExportType($exportType)
459
    {
460
        if (!in_array($exportType, ['xls', 'xlsx', 'csv'])) {
461
            throw new InvalidArgumentException("Export type must be one of : xls, xlsx, csv");
462
        }
463
        $this->exportType = $exportType;
464
        return $this;
465
    }
466
467
    /**
468
     * @return string
469
     */
470
    public function getExportName()
471
    {
472
        return $this->exportName;
473
    }
474
475
    /**
476
     * @param string $exportName
477
     * @return ExcelGridFieldExportButton
478
     */
479
    public function setExportName($exportName)
480
    {
481
        $this->exportName = $exportName;
482
        return $this;
483
    }
484
485
    /**
486
     * @return string
487
     */
488
    public function getButtonTitle()
489
    {
490
        return $this->buttonTitle;
491
    }
492
493
    /**
494
     * @param string $buttonTitle
495
     * @return ExcelGridFieldExportButton
496
     */
497
    public function setButtonTitle($buttonTitle)
498
    {
499
        $this->buttonTitle = $buttonTitle;
500
        return $this;
501
    }
502
503
    /**
504
     *
505
     * @return bool
506
     */
507
    public function getCheckCanView()
508
    {
509
        return $this->checkCanView;
510
    }
511
512
    /**
513
     *
514
     * @param bool $checkCanView
515
     * @return ExcelGridFieldExportButton
516
     */
517
    public function setCheckCanView($checkCanView)
518
    {
519
        $this->checkCanView = $checkCanView;
520
        return $this;
521
    }
522
523
    /**
524
     *
525
     * @return array<mixed>
526
     */
527
    public function getListFilters()
528
    {
529
        return $this->listFilters;
530
    }
531
532
    /**
533
     *
534
     * @param array<mixed> $listFilters
535
     * @return ExcelGridFieldExportButton
536
     */
537
    public function setListFilters($listFilters)
538
    {
539
        $this->listFilters = $listFilters;
540
        return $this;
541
    }
542
543
    /**
544
     *
545
     * @return callable
546
     */
547
    public function getAfterExportCallback()
548
    {
549
        return $this->afterExportCallback;
550
    }
551
552
    /**
553
     *
554
     * @param callable $afterExportCallback
555
     * @return ExcelGridFieldExportButton
556
     */
557
    public function setAfterExportCallback(callable $afterExportCallback)
558
    {
559
        $this->afterExportCallback = $afterExportCallback;
560
        return $this;
561
    }
562
563
    /**
564
     * Get the value of isLimited
565
     */
566
    public function getIsLimited(): bool
567
    {
568
        return $this->isLimited;
569
    }
570
571
    /**
572
     * Set the value of isLimited
573
     *
574
     * @param bool $isLimited
575
     * @return self
576
     */
577
    public function setIsLimited(bool $isLimited)
578
    {
579
        $this->isLimited = $isLimited;
580
        return $this;
581
    }
582
583
    /**
584
     * Get the value of ignoreFilters
585
     */
586
    public function getIgnoreFilters(): bool
587
    {
588
        return $this->ignoreFilters;
589
    }
590
591
    /**
592
     * Set the value of ignoreFilters
593
     *
594
     * @param bool $ignoreFilters
595
     */
596
    public function setIgnoreFilters(bool $ignoreFilters): self
597
    {
598
        $this->ignoreFilters = $ignoreFilters;
599
        return $this;
600
    }
601
602
    /**
603
     * Get the value of sanitizeXls
604
     */
605
    public function getSanitizeXls(): bool
606
    {
607
        return $this->sanitizeXls;
608
    }
609
610
    /**
611
     * Set the value of sanitizeXls
612
     *
613
     * @param bool $sanitizeXls
614
     */
615
    public function setSanitizeXls(bool $sanitizeXls): self
616
    {
617
        $this->sanitizeXls = $sanitizeXls;
618
        return $this;
619
    }
620
}
621