Passed
Push — master ( a409c4...ad0a33 )
by Thomas
11:42
created

TabulatorGrid::setDisplayFields()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 8
c 1
b 0
f 0
dl 0
loc 10
rs 10
cc 2
nc 2
nop 1
1
<?php
2
3
namespace LeKoala\Tabulator;
4
5
use Exception;
6
use RuntimeException;
7
use SilverStripe\i18n\i18n;
8
use SilverStripe\Forms\Form;
9
use InvalidArgumentException;
10
use SilverStripe\ORM\SS_List;
11
use SilverStripe\Core\Convert;
12
use SilverStripe\ORM\DataList;
13
use SilverStripe\ORM\ArrayList;
14
use SilverStripe\Core\ClassInfo;
15
use SilverStripe\ORM\DataObject;
16
use SilverStripe\View\ArrayData;
17
use SilverStripe\Forms\FieldList;
18
use SilverStripe\Forms\FormField;
19
use SilverStripe\Control\Director;
20
use SilverStripe\View\Requirements;
21
use SilverStripe\Control\Controller;
22
use SilverStripe\Control\HTTPRequest;
23
use SilverStripe\Control\HTTPResponse;
24
use SilverStripe\ORM\FieldType\DBEnum;
25
use SilverStripe\Control\RequestHandler;
26
use SilverStripe\Core\Injector\Injector;
27
use SilverStripe\Security\SecurityToken;
28
use SilverStripe\ORM\DataObjectInterface;
29
use SilverStripe\ORM\FieldType\DBBoolean;
30
use SilverStripe\Forms\GridField\GridFieldConfig;
31
use SilverStripe\ORM\Filters\PartialMatchFilter;
32
33
/**
34
 * This is a replacement for most GridField usages in SilverStripe
35
 * It can easily work in the frontend too
36
 *
37
 * @link http://www.tabulator.info/
38
 */
39
class TabulatorGrid extends FormField
40
{
41
    const POS_START = 'start';
42
    const POS_END = 'end';
43
44
    // @link http://www.tabulator.info/examples/5.5?#fittodata
45
    const LAYOUT_FIT_DATA = "fitData";
46
    const LAYOUT_FIT_DATA_FILL = "fitDataFill";
47
    const LAYOUT_FIT_DATA_STRETCH = "fitDataStretch";
48
    const LAYOUT_FIT_DATA_TABLE = "fitDataTable";
49
    const LAYOUT_FIT_COLUMNS = "fitColumns";
50
51
    const RESPONSIVE_LAYOUT_HIDE = "hide";
52
    const RESPONSIVE_LAYOUT_COLLAPSE = "collapse";
53
54
    // @link http://www.tabulator.info/docs/5.5/format
55
    const FORMATTER_PLAINTEXT = 'plaintext';
56
    const FORMATTER_TEXTAREA = 'textarea';
57
    const FORMATTER_HTML = 'html';
58
    const FORMATTER_MONEY = 'money';
59
    const FORMATTER_IMAGE = 'image';
60
    const FORMATTER_LINK = 'link';
61
    const FORMATTER_DATETIME = 'datetime';
62
    const FORMATTER_DATETIME_DIFF = 'datetimediff';
63
    const FORMATTER_TICKCROSS = 'tickCross';
64
    const FORMATTER_COLOR = 'color';
65
    const FORMATTER_STAR = 'star';
66
    const FORMATTER_TRAFFIC = 'traffic';
67
    const FORMATTER_PROGRESS = 'progress';
68
    const FORMATTER_LOOKUP = 'lookup';
69
    const FORMATTER_BUTTON_TICK = 'buttonTick';
70
    const FORMATTER_BUTTON_CROSS = 'buttonCross';
71
    const FORMATTER_ROWNUM = 'rownum';
72
    const FORMATTER_HANDLE = 'handle';
73
    // @link http://www.tabulator.info/docs/5.5/format#format-module
74
    const FORMATTER_ROW_SELECTION = 'rowSelection';
75
    const FORMATTER_RESPONSIVE_COLLAPSE = 'responsiveCollapse';
76
77
    // our built in functions
78
    const JS_BOOL_GROUP_HEADER = 'SSTabulator.boolGroupHeader';
79
    const JS_DATA_AJAX_RESPONSE = 'SSTabulator.dataAjaxResponse';
80
81
    /**
82
     * @config
83
     */
84
    private static array $allowed_actions = [
85
        'load',
86
        'handleItem',
87
        'handleTool',
88
        'configProvider',
89
        'autocomplete',
90
        'handleBulkAction',
91
    ];
92
93
    private static $url_handlers = [
94
        'item/$ID' => 'handleItem',
95
        'tool/$ID' => 'handleTool',
96
        'bulkAction/$ID' => 'handleBulkAction',
97
    ];
98
99
    private static array $casting = [
100
        'JsonOptions' => 'HTMLFragment',
101
        'ShowTools' => 'HTMLFragment',
102
        'dataAttributesHTML' => 'HTMLFragment',
103
    ];
104
105
    /**
106
     * @config
107
     */
108
    private static bool $load_styles = true;
109
110
    /**
111
     * @config
112
     */
113
    private static string $luxon_version = '3';
114
115
    /**
116
     * @config
117
     */
118
    private static string $last_icon_version = '2';
119
120
    /**
121
     * @config
122
     */
123
    private static bool $use_cdn = false;
124
125
    /**
126
     * @config
127
     */
128
    private static bool $enable_luxon = false;
129
130
    /**
131
     * @config
132
     */
133
    private static bool $enable_last_icon = false;
134
135
    /**
136
     * @config
137
     */
138
    private static bool $enable_requirements = true;
139
140
    /**
141
     * @config
142
     */
143
    private static bool $enable_js_modules = true;
144
145
    /**
146
     * @link http://www.tabulator.info/docs/5.5/options
147
     * @config
148
     */
149
    private static array $default_options = [
150
        'index' => "ID", // http://tabulator.info/docs/5.5/data#row-index
151
        'layout' => 'fitColumns', // http://www.tabulator.info/docs/5.5/layout#layout
152
        'height' => '100%', // http://www.tabulator.info/docs/5.5/layout#height-fixed
153
        'responsiveLayout' => "hide", // http://www.tabulator.info/docs/5.5/layout#responsive
154
    ];
155
156
    /**
157
     * @link http://tabulator.info/docs/5.5/columns#defaults
158
     * @config
159
     */
160
    private static array $default_column_options = [
161
        'resizable' => false,
162
    ];
163
164
    private static bool $enable_ajax_init = true;
165
166
    /**
167
     * @config
168
     */
169
    private static bool $default_lazy_init = false;
170
171
    /**
172
     * Data source.
173
     */
174
    protected ?SS_List $list;
175
176
    /**
177
     * @link http://www.tabulator.info/docs/5.5/columns
178
     */
179
    protected array $columns = [];
180
181
    /**
182
     * @link http://tabulator.info/docs/5.5/columns#defaults
183
     */
184
    protected array $columnDefaults = [];
185
186
    /**
187
     * @link http://www.tabulator.info/docs/5.5/options
188
     */
189
    protected array $options = [];
190
191
    protected bool $autoloadDataList = true;
192
193
    protected bool $rowClickTriggersAction = false;
194
195
    protected int $pageSize = 10;
196
197
    protected string $itemRequestClass = '';
198
199
    protected string $modelClass = '';
200
201
    protected bool $lazyInit = false;
202
203
    protected array $tools = [];
204
205
    /**
206
     * @var AbstractBulkAction[]
207
     */
208
    protected array $bulkActions = [];
209
210
    protected array $listeners = [];
211
212
    protected array $linksOptions = [
213
        'ajaxURL'
214
    ];
215
216
    protected array $dataAttributes = [];
217
218
    protected string $controllerFunction = "";
219
220
    protected string $editUrl = "";
221
222
    protected string $moveUrl = "";
223
224
    protected string $bulkUrl = "";
225
226
    protected bool $globalSearch = false;
227
228
    protected array $wildcardFields = [];
229
230
    protected array $quickFilters = [];
231
232
    protected string $defaultFilter = 'PartialMatch';
233
234
    protected bool $groupLayout = false;
235
236
    protected bool $enableGridManipulation = false;
237
238
    /**
239
     * @param string $fieldName
240
     * @param string|null|bool $title
241
     * @param SS_List $value
242
     */
243
    public function __construct($name, $title = null, $value = null)
244
    {
245
        // Set options and defaults first
246
        $this->options = self::config()->default_options ?? [];
247
        $this->columnDefaults = self::config()->default_column_options ?? [];
248
249
        parent::__construct($name, $title, $value);
250
        $this->setLazyInit(self::config()->default_lazy_init);
251
252
        // We don't want regular setValue for this since it would break with loadFrom logic
253
        if ($value) {
254
            $this->setList($value);
255
        }
256
    }
257
258
    /**
259
     * This helps if some third party code expects the TabulatorGrid to be a GridField
260
     * Only works to a really basic extent
261
     */
262
    public function getConfig(): GridFieldConfig
263
    {
264
        return new GridFieldConfig;
265
    }
266
267
    /**
268
     * This helps if some third party code expects the TabulatorGrid to be a GridField
269
     * Only works to a really basic extent
270
     */
271
    public function setConfig($config)
272
    {
273
        // ignore
274
    }
275
276
    /**
277
     * @return string
278
     */
279
    public function getValueJson()
280
    {
281
        $v = $this->value ?? '';
282
        if (is_array($v)) {
283
            $v = json_encode($v);
284
        }
285
        if (strpos($v, '[') !== 0) {
286
            return '[]';
287
        }
288
        return $v;
289
    }
290
291
    public function saveInto(DataObjectInterface $record)
292
    {
293
        if ($this->enableGridManipulation) {
294
            $value = $this->dataValue();
295
            if (is_array($value)) {
296
                $this->value = json_encode(array_values($value));
297
            }
298
            parent::saveInto($record);
299
        }
300
    }
301
302
    /**
303
     * Temporary link that will be replaced by a real link by processLinks
304
     * TODO: not really happy with this, find a better way
305
     *
306
     * @param string $action
307
     * @return string
308
     */
309
    public function TempLink(string $action, bool $controller = true): string
310
    {
311
        // It's an absolute link
312
        if (strpos($action, '/') === 0 || strpos($action, 'http') === 0) {
313
            return $action;
314
        }
315
        // Already temp
316
        if (strpos($action, ':') !== false) {
317
            return $action;
318
        }
319
        $prefix = $controller ? "controller" : "form";
320
        return "$prefix:$action";
321
    }
322
323
    public function ControllerLink(string $action): string
324
    {
325
        return $this->getForm()->getController()->Link($action);
326
    }
327
328
    public function getCreateLink(): string
329
    {
330
        return Controller::join_links($this->Link('item'), 'new');
331
    }
332
333
    /**
334
     * @param FieldList $fields
335
     * @param string $name
336
     * @return TabulatorGrid|null
337
     */
338
    public static function replaceGridField(FieldList $fields, string $name)
339
    {
340
        /** @var \SilverStripe\Forms\GridField\GridField $gridField */
341
        $gridField = $fields->dataFieldByName($name);
342
        if (!$gridField) {
0 ignored issues
show
introduced by
$gridField is of type SilverStripe\Forms\GridField\GridField, thus it always evaluated to true.
Loading history...
343
            return;
344
        }
345
        if ($gridField instanceof TabulatorGrid) {
0 ignored issues
show
introduced by
$gridField is never a sub-type of LeKoala\Tabulator\TabulatorGrid.
Loading history...
346
            return $gridField;
347
        }
348
        $tabulatorGrid = new TabulatorGrid($name, $gridField->Title(), $gridField->getList());
349
        // In the cms, this is mostly never happening
350
        if ($gridField->getForm()) {
351
            $tabulatorGrid->setForm($gridField->getForm());
352
        }
353
        $tabulatorGrid->configureFromDataObject($gridField->getModelClass());
354
        $tabulatorGrid->setLazyInit(true);
355
        $fields->replaceField($name, $tabulatorGrid);
356
357
        return $tabulatorGrid;
358
    }
359
360
    public function configureFromDataObject($className = null): void
361
    {
362
        $this->columns = [];
363
364
        if (!$className) {
365
            $className = $this->getModelClass();
366
        }
367
        if (!$className) {
368
            throw new RuntimeException("Could not find the model class");
369
        }
370
        $this->modelClass = $className;
371
372
        /** @var DataObject $singl */
373
        $singl = singleton($className);
374
375
        // Mock some base columns using SilverStripe built-in methods
376
        $columns = [];
377
378
        foreach ($singl->summaryFields() as $field => $title) {
379
            // Deal with this in load() instead
380
            // if (strpos($field, '.') !== false) {
381
            // $fieldParts = explode(".", $field);
382
383
            // It can be a relation Users.Count or a field Field.Nice
384
            // $classOrField = $fieldParts[0];
385
            // $relationOrMethod = $fieldParts[1];
386
            // }
387
            $title = str_replace(".", " ", $title);
388
            $columns[$field] = [
389
                'field' => $field,
390
                'title' => $title,
391
            ];
392
393
            $dbObject = $singl->dbObject($field);
394
            if ($dbObject) {
395
                if ($dbObject instanceof DBBoolean) {
396
                    $columns[$field]['formatter'] = "customTickCross";
397
                }
398
            }
399
        }
400
        foreach ($singl->searchableFields() as $key => $searchOptions) {
401
            /*
402
            "filter" => "NameOfTheFilter"
403
            "field" => "SilverStripe\Forms\FormField"
404
            "title" => "Title of the field"
405
            */
406
            if (!isset($columns[$key])) {
407
                continue;
408
            }
409
            $columns[$key]['headerFilter'] = true;
410
            // $columns[$key]['headerFilterPlaceholder'] = $searchOptions['title'];
411
            //TODO: implement filter mapping
412
            switch ($searchOptions['filter']) {
413
                default:
414
                    $columns[$key]['headerFilterFunc'] =  "like";
415
                    break;
416
            }
417
418
            // Restrict based on data type
419
            $dbObject = $singl->dbObject($key);
420
            if ($dbObject) {
421
                if ($dbObject instanceof DBBoolean) {
422
                    $columns[$key]['headerFilter'] = 'tickCross';
423
                    $columns[$key]['headerFilterFunc'] =  "=";
424
                    $columns[$key]['headerFilterParams'] =  [
425
                        'tristate' => true
426
                    ];
427
                }
428
                if ($dbObject instanceof DBEnum) {
429
                    $columns[$key]['headerFilter'] = 'list';
430
                    $columns[$key]['headerFilterFunc'] =  "=";
431
                    $columns[$key]['headerFilterParams'] =  [
432
                        'values' => $dbObject->enumValues()
433
                    ];
434
                }
435
            }
436
        }
437
438
        // Allow customizing our columns based on record
439
        if ($singl->hasMethod('tabulatorColumns')) {
440
            $fields = $singl->tabulatorColumns();
441
            if (!is_array($fields)) {
442
                throw new RuntimeException("tabulatorColumns must return an array");
443
            }
444
            foreach ($fields as $key => $columnOptions) {
445
                $baseOptions = $columns[$key] ?? [];
446
                $columns[$key] = array_merge($baseOptions, $columnOptions);
447
            }
448
        }
449
450
        $this->extend('updateConfiguredColumns', $columns);
451
452
        foreach ($columns as $col) {
453
            $this->addColumn($col['field'], $col['title'], $col);
454
        }
455
456
        // Sortable ?
457
        if ($singl->hasField('Sort')) {
458
            $this->wizardMoveable();
459
        }
460
461
        // Actions
462
        // We use a pseudo link, because maybe we cannot call Link() yet if it's not linked to a form
463
464
        $this->bulkUrl = $this->TempLink("bulkAction/", false);
465
466
        // - Core actions, handled by TabulatorGrid
467
        $itemUrl = $this->TempLink('item/{ID}', false);
468
        if ($singl->canEdit()) {
469
            $this->addButton("ui_edit", $itemUrl, "edit", "Edit");
470
            $this->editUrl = $this->TempLink("item/{ID}/ajaxEdit", false);
471
        } elseif ($singl->canView()) {
472
            $this->addButton("ui_view", $itemUrl, "visibility", "View");
473
        }
474
475
        // - Tools
476
        $this->tools = [];
477
        if ($singl->canCreate()) {
478
            $this->addTool(self::POS_START, new TabulatorAddNewButton($this), 'add_new');
0 ignored issues
show
Unused Code introduced by
The call to LeKoala\Tabulator\Tabula...ewButton::__construct() has too many arguments starting with $this. ( Ignorable by Annotation )

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

478
            $this->addTool(self::POS_START, /** @scrutinizer ignore-call */ new TabulatorAddNewButton($this), 'add_new');

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
479
        }
480
        if (class_exists(\LeKoala\ExcelImportExport\ExcelImportExport::class)) {
0 ignored issues
show
Bug introduced by
The type LeKoala\ExcelImportExport\ExcelImportExport 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...
481
            $this->addTool(self::POS_END, new TabulatorExportButton($this), 'export');
0 ignored issues
show
Unused Code introduced by
The call to LeKoala\Tabulator\Tabula...rtButton::__construct() has too many arguments starting with $this. ( Ignorable by Annotation )

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

481
            $this->addTool(self::POS_END, /** @scrutinizer ignore-call */ new TabulatorExportButton($this), 'export');

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
482
        }
483
484
        // - Custom actions are forwarded to the model itself
485
        if ($singl->hasMethod('tabulatorRowActions')) {
486
            $rowActions = $singl->tabulatorRowActions();
487
            if (!is_array($rowActions)) {
488
                throw new RuntimeException("tabulatorRowActions must return an array");
489
            }
490
            foreach ($rowActions as $key => $actionConfig) {
491
                $action = $actionConfig['action'] ?? $key;
492
                $url = $this->TempLink("item/{ID}/customAction/$action", false);
493
                $icon = $actionConfig['icon'] ?? "cog";
494
                $title = $actionConfig['title'] ?? "";
495
496
                $button = $this->makeButton($url, $icon, $title);
497
                if (!empty($actionConfig['ajax'])) {
498
                    $button['formatterParams']['ajax'] = true;
499
                }
500
                $this->addButtonFromArray("ui_customaction_$action", $button);
501
            }
502
        }
503
504
        $this->setRowClickTriggersAction(true);
505
    }
506
507
    public static function requirements(): void
508
    {
509
        $load_styles = self::config()->load_styles;
510
        $luxon_version = self::config()->luxon_version;
511
        $enable_luxon = self::config()->enable_luxon;
512
        $last_icon_version = self::config()->last_icon_version;
513
        $enable_last_icon = self::config()->enable_last_icon;
514
        $enable_js_modules = self::config()->enable_js_modules;
515
516
        $jsOpts = [];
517
        if ($enable_js_modules) {
518
            $jsOpts['type'] = 'module';
519
        }
520
521
        if ($luxon_version && $enable_luxon) {
522
            // Do not load as module or we would get undefined luxon global var
523
            Requirements::javascript("https://cdn.jsdelivr.net/npm/luxon@$luxon_version/build/global/luxon.min.js");
524
        }
525
        if ($last_icon_version && $enable_last_icon) {
526
            Requirements::css("https://cdn.jsdelivr.net/npm/last-icon@$last_icon_version/last-icon.min.css");
527
            // Do not load as module even if asked to ensure load speed
528
            Requirements::javascript("https://cdn.jsdelivr.net/npm/last-icon@$last_icon_version/last-icon.min.js");
529
        }
530
531
        Requirements::javascript('lekoala/silverstripe-tabulator:client/TabulatorField.js', $jsOpts);
532
        if ($load_styles) {
533
            Requirements::css('lekoala/silverstripe-tabulator:client/custom-tabulator.css');
534
            Requirements::javascript('lekoala/silverstripe-tabulator:client/tabulator-grid.min.js', $jsOpts);
535
        } else {
536
            // you must load th css yourself based on your preferences
537
            Requirements::javascript('lekoala/silverstripe-tabulator:client/tabulator-grid.raw.min.js', $jsOpts);
538
        }
539
    }
540
541
    public function setValue($value, $data = null)
542
    {
543
        // Allow set raw json as value
544
        if ($value && is_string($value) && strpos($value, '[') === 0) {
545
            $value = json_decode($value);
546
        }
547
        if ($value instanceof DataList) {
548
            $this->configureFromDataObject($value->dataClass());
549
        }
550
        return parent::setValue($value, $data);
551
    }
552
553
    public function Field($properties = [])
554
    {
555
        if (self::config()->enable_requirements) {
556
            self::requirements();
557
        }
558
559
        // Make sure we can use a standalone version of the field without a form
560
        // Function should match the name
561
        if (!$this->form) {
562
            $this->form = new Form(Controller::curr(), $this->getControllerFunction());
563
        }
564
565
        // Data attributes for our custom behaviour
566
        $this->setDataAttribute("row-click-triggers-action", $this->rowClickTriggersAction);
567
568
        $this->setDataAttribute("listeners", $this->listeners);
569
        if ($this->editUrl) {
570
            $url = $this->processLink($this->editUrl);
571
            $this->setDataAttribute("edit-url", $url);
572
        }
573
        if ($this->moveUrl) {
574
            $url = $this->processLink($this->moveUrl);
575
            $this->setDataAttribute("move-url", $url);
576
        }
577
        if (!empty($this->bulkActions)) {
578
            $url = $this->processLink($this->bulkUrl);
579
            $this->setDataAttribute("bulk-url", $url);
580
        }
581
582
        return parent::Field($properties);
583
    }
584
585
    public function ShowTools(): string
586
    {
587
        if (empty($this->tools)) {
588
            return '';
589
        }
590
        $html = '';
591
        $html .= '<div class="tabulator-tools">';
592
        $html .= '<div class="tabulator-tools-start">';
593
        foreach ($this->tools as $tool) {
594
            if ($tool['position'] != self::POS_START) {
595
                continue;
596
            }
597
            $html .= ($tool['tool'])->forTemplate();
598
        }
599
        $html .= '</div>';
600
        $html .= '<div class="tabulator-tools-end">';
601
        foreach ($this->tools as $tool) {
602
            if ($tool['position'] != self::POS_END) {
603
                continue;
604
            }
605
            $html .= ($tool['tool'])->forTemplate();
606
        }
607
        // Show bulk actions at the end
608
        if (!empty($this->bulkActions)) {
609
            $selectLabel = _t(__CLASS__ . ".BULKSELECT", "Select a bulk action");
610
            $confirmLabel = _t(__CLASS__ . ".BULKCONFIRM", "Go");
611
            $html .= "<select class=\"tabulator-bulk-select\">";
612
            $html .= "<option>" . $selectLabel . "</option>";
613
            foreach ($this->bulkActions as $bulkAction) {
614
                $v = $bulkAction->getName();
615
                $xhr = $bulkAction->getXhr();
616
                $destructive = $bulkAction->getDestructive();
617
                $html .= "<option value=\"$v\" data-xhr=\"$xhr\" data-destructive=\"$destructive\">" . $bulkAction->getLabel() . "</option>";
618
            }
619
            $html .= "</select>";
620
            $html .= "<button class=\"tabulator-bulk-confirm btn\">" . $confirmLabel . "</button>";
621
        }
622
        $html .= '</div>';
623
        $html .= '</div>';
624
        return $html;
625
    }
626
627
    public function JsonOptions(): string
628
    {
629
        $this->processLinks();
630
631
        $data = $this->list ?? [];
632
        if ($this->autoloadDataList && $data instanceof DataList) {
633
            $data = null;
634
        }
635
        $opts = $this->options;
636
        $opts['columnDefaults'] = $this->columnDefaults;
637
638
        if (empty($this->columns)) {
639
            $opts['autoColumns'] = true;
640
        } else {
641
            $opts['columns'] = array_values($this->columns);
642
        }
643
644
        if ($data && is_iterable($data)) {
645
            if ($data instanceof ArrayList) {
646
                $data = $data->toArray();
647
            } else {
648
                if (is_iterable($data) && !is_array($data)) {
649
                    $data = iterator_to_array($data);
650
                }
651
            }
652
            $opts['data'] = $data;
653
        }
654
655
        // i18n
656
        $locale = strtolower(str_replace('_', '-', i18n::get_locale()));
657
        $paginationTranslations = [
658
            "first" => _t("TabulatorPagination.first", "First"),
659
            "first_title" =>  _t("TabulatorPagination.first_title", "First Page"),
660
            "last" =>  _t("TabulatorPagination.last", "Last"),
661
            "last_title" => _t("TabulatorPagination.last_title", "Last Page"),
662
            "prev" => _t("TabulatorPagination.prev", "Previous"),
663
            "prev_title" =>  _t("TabulatorPagination.prev_title", "Previous Page"),
664
            "next" => _t("TabulatorPagination.next", "Next"),
665
            "next_title" =>  _t("TabulatorPagination.next_title", "Next Page"),
666
            "all" =>  _t("TabulatorPagination.all", "All"),
667
        ];
668
        $dataTranslations = [
669
            "loading" => _t("TabulatorData.loading", "Loading"),
670
            "error" => _t("TabulatorData.error", "Error"),
671
        ];
672
        $groupsTranslations = [
673
            "item" => _t("TabulatorGroups.item", "Item"),
674
            "items" => _t("TabulatorGroups.items", "Items"),
675
        ];
676
        $headerFiltersTranslations = [
677
            "default" => _t("TabulatorHeaderFilters.default", "filter column..."),
678
        ];
679
        $bulkActionsTranslations = [
680
            "no_action" => _t("TabulatorBulkActions.no_action", "Please select an action"),
681
            "no_records" => _t("TabulatorBulkActions.no_records", "Please select a record"),
682
            "destructive" => _t("TabulatorBulkActions.destructive", "Confirm destructive action ?"),
683
        ];
684
        $translations = [
685
            'data' => $dataTranslations,
686
            'groups' => $groupsTranslations,
687
            'pagination' => $paginationTranslations,
688
            'headerFilters' => $headerFiltersTranslations,
689
            'bulkActions' => $bulkActionsTranslations,
690
        ];
691
        $opts['locale'] = $locale;
692
        $opts['langs'] = [
693
            $locale => $translations
694
        ];
695
696
        // Apply state
697
        $state = $this->getState();
698
        if (!empty($state['filter'])) {
699
            // @link https://tabulator.info/docs/5.5/filter#initial
700
            // We need to split between global filters and header filters
701
            $allFilters = $state['filter'] ?? [];
702
            $globalFilters = [];
703
            $headerFilters = [];
704
            foreach ($allFilters as $allFilter) {
705
                if (strpos($allFilter['field'], '__') === 0) {
706
                    $globalFilters[] = $allFilter;
707
                } else {
708
                    $headerFilters[] = $allFilter;
709
                }
710
            }
711
            $opts['initialFilter'] = $globalFilters;
712
            $opts['initialHeaderFilter'] = $headerFilters;
713
        }
714
        if (!empty($state['sort'])) {
715
            // @link https://tabulator.info/docs/5.5/sort#initial
716
            $opts['initialSort'] = $state['sort'];
717
        }
718
719
        // Restore state from server
720
        $opts['_state'] = $state;
721
722
        // Add our extension initCallback
723
        $opts['_initCallback'] = ['__fn' => 'SSTabulator.initCallback'];
724
725
        unset($opts['height']);
726
        $json = json_encode($opts);
727
728
        // Escape '
729
        $json = str_replace("'", '&#39;', $json);
730
731
732
        return $json;
733
    }
734
735
    /**
736
     * @param Controller $controller
737
     * @return CompatLayerInterface
738
     */
739
    public function getCompatLayer(Controller $controller)
740
    {
741
        if (is_subclass_of($controller, \SilverStripe\Admin\LeftAndMain::class)) {
0 ignored issues
show
Bug introduced by
The type SilverStripe\Admin\LeftAndMain 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...
742
            return new SilverstripeAdminCompat();
743
        }
744
        if (is_subclass_of($controller, \LeKoala\Admini\LeftAndMain::class)) {
0 ignored issues
show
Bug introduced by
The type LeKoala\Admini\LeftAndMain 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...
745
            return new AdminiCompat();
746
        }
747
    }
748
749
    public function getAttributes()
750
    {
751
        $attrs = parent::getAttributes();
752
        unset($attrs['type']);
753
        unset($attrs['name']);
754
        unset($attrs['value']);
755
        return $attrs;
756
    }
757
758
    public function getOption(string $k)
759
    {
760
        return $this->options[$k] ?? null;
761
    }
762
763
    public function setOption(string $k, $v): self
764
    {
765
        $this->options[$k] = $v;
766
        return $this;
767
    }
768
769
    public function getRowHeight(): int
770
    {
771
        return $this->getOption('rowHeight');
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->getOption('rowHeight') could return the type null which is incompatible with the type-hinted return integer. Consider adding an additional type-check to rule them out.
Loading history...
772
    }
773
774
    /**
775
     * Prevent row height automatic computation
776
     * @link https://tabulator.info/docs/5.5/layout#height-row
777
     */
778
    public function setRowHeight(int $v): self
779
    {
780
        $this->setOption('rowHeight', $v);
781
        return $this;
782
    }
783
784
    public function makeHeadersSticky(): self
785
    {
786
        // note: we could also use the "sticky" attribute on the custom element
787
        $this->addExtraClass("tabulator-sticky");
788
        return $this;
789
    }
790
791
    public function setRemoteSource(string $url, array $extraParams = [], bool $dataResponse = false): self
792
    {
793
        $this->setOption("ajaxURL", $url); //set url for ajax request
794
        $params = array_merge([
795
            'SecurityID' => SecurityToken::getSecurityID()
796
        ], $extraParams);
797
        $this->setOption("ajaxParams", $params);
798
        // Accept response where data is nested under the data key
799
        if ($dataResponse) {
800
            $this->setOption("ajaxResponse", ['__fn' => self::JS_DATA_AJAX_RESPONSE]);
801
        }
802
        return $this;
803
    }
804
805
    /**
806
     * @link http://www.tabulator.info/docs/5.5/page#remote
807
     * @param string $url
808
     * @param array $params
809
     * @param integer $pageSize
810
     * @param integer $initialPage
811
     */
812
    public function setRemotePagination(string $url, array $params = [], int $pageSize = 0, int $initialPage = 1): self
813
    {
814
        $this->setOption("pagination", true); //enable pagination
815
        $this->setOption("paginationMode", 'remote'); //enable remote pagination
816
        $this->setRemoteSource($url, $params);
817
        if (!$pageSize) {
818
            $pageSize = $this->pageSize;
819
        } else {
820
            $this->pageSize = $pageSize;
821
        }
822
        $this->setOption("paginationSize", $pageSize);
823
        $this->setOption("paginationInitialPage", $initialPage);
824
        $this->setOption("paginationCounter", 'rows'); // http://www.tabulator.info/docs/5.5/page#counter
825
        return $this;
826
    }
827
828
    public function wizardRemotePagination(int $pageSize = 0, int $initialPage = 1, array $params = []): self
829
    {
830
        $this->setRemotePagination($this->TempLink('load', false), $params, $pageSize, $initialPage);
831
        $this->setOption("sortMode", "remote"); // http://www.tabulator.info/docs/5.5/sort#ajax-sort
832
        $this->setOption("filterMode", "remote"); // http://www.tabulator.info/docs/5.5/filter#ajax-filter
833
        return $this;
834
    }
835
836
    public function setProgressiveLoad(string $url, array $params = [], int $pageSize = 0, int $initialPage = 1, string $mode = 'scroll', int $scrollMargin = 0): self
837
    {
838
        $this->setOption("ajaxURL", $url);
839
        if (!empty($params)) {
840
            $this->setOption("ajaxParams", $params);
841
        }
842
        $this->setOption("progressiveLoad", $mode);
843
        if ($scrollMargin > 0) {
844
            $this->setOption("progressiveLoadScrollMargin", $scrollMargin);
845
        }
846
        if (!$pageSize) {
847
            $pageSize = $this->pageSize;
848
        } else {
849
            $this->pageSize = $pageSize;
850
        }
851
        $this->setOption("paginationSize", $pageSize);
852
        $this->setOption("paginationInitialPage", $initialPage);
853
        $this->setOption("paginationCounter", 'rows'); // http://www.tabulator.info/docs/5.5/page#counter
854
        return $this;
855
    }
856
857
    public function wizardProgressiveLoad(int $pageSize = 0, int $initialPage = 1, string $mode = 'scroll', int $scrollMargin = 0, array $extraParams = []): self
858
    {
859
        $params = array_merge([
860
            'SecurityID' => SecurityToken::getSecurityID()
861
        ], $extraParams);
862
        $this->setProgressiveLoad($this->TempLink('load', false), $params, $pageSize, $initialPage, $mode, $scrollMargin);
863
        $this->setOption("sortMode", "remote"); // http://www.tabulator.info/docs/5.5/sort#ajax-sort
864
        $this->setOption("filterMode", "remote"); // http://www.tabulator.info/docs/5.5/filter#ajax-filter
865
        return $this;
866
    }
867
868
    /**
869
     * @link https://tabulator.info/docs/5.5/layout#responsive
870
     * @param boolean $startOpen
871
     * @param string $mode collapse|hide|flexCollapse
872
     * @return self
873
     */
874
    public function wizardResponsiveCollapse(bool $startOpen = false, string $mode = "collapse"): self
875
    {
876
        $this->setOption("responsiveLayout", $mode);
877
        $this->setOption("responsiveLayoutCollapseStartOpen", $startOpen);
878
        if ($mode != "hide") {
879
            $this->columns = array_merge([
880
                'ui_responsive_collapse' => [
881
                    "cssClass" => 'tabulator-cell-btn',
882
                    'formatter' => 'responsiveCollapse',
883
                    'headerSort' => false,
884
                    'width' => 40,
885
                ]
886
            ], $this->columns);
887
        }
888
        return $this;
889
    }
890
891
    public function wizardDataTree(bool $startExpanded = false, bool $filter = false, bool $sort = false, string $el = null): self
892
    {
893
        $this->setOption("dataTree", true);
894
        $this->setOption("dataTreeStartExpanded", $startExpanded);
895
        $this->setOption("dataTreeFilter", $filter);
896
        $this->setOption("dataTreeSort", $sort);
897
        if ($el) {
898
            $this->setOption("dataTreeElementColumn", $el);
899
        }
900
        return $this;
901
    }
902
903
    public function wizardSelectable(array $actions = []): self
904
    {
905
        $this->columns = array_merge([
906
            'ui_selectable' => [
907
                "hozAlign" => 'center',
908
                "cssClass" => 'tabulator-cell-btn tabulator-cell-selector',
909
                'formatter' => 'rowSelection',
910
                'titleFormatter' => 'rowSelection',
911
                'headerSort' => false,
912
                'width' => 40,
913
            ]
914
        ], $this->columns);
915
        $this->setBulkActions($actions);
916
        return $this;
917
    }
918
919
    public function wizardMoveable(string $callback = "SSTabulator.rowMoved", $field = "Sort"): self
920
    {
921
        $this->moveUrl = $this->TempLink("item/{ID}/ajaxMove", false);
922
        $this->setOption("movableRows", true);
923
        $this->addListener("rowMoved", $callback);
924
        $this->columns = array_merge([
925
            'ui_move' => [
926
                "hozAlign" => 'center',
927
                "cssClass" => 'tabulator-cell-btn tabulator-cell-selector',
928
                'rowHandle' => true,
929
                'formatter' => 'handle',
930
                'headerSort' => false,
931
                'frozen' => true,
932
                'width' => 40,
933
            ],
934
            // We need a hidden sort column
935
            'ui_sort' => [
936
                "field" => $field,
937
                'visible' => false,
938
            ],
939
        ], $this->columns);
940
        return $this;
941
    }
942
943
    /**
944
     * @param string $field
945
     * @param string $toggleElement arrow|header|false (header by default)
946
     * @param boolean $isBool
947
     * @return void
948
     */
949
    public function wizardGroupBy(string $field, string $toggleElement = 'header', bool $isBool = false)
950
    {
951
        $this->setOption("groupBy", $field);
952
        $this->setOption("groupToggleElement", $toggleElement);
953
        if ($isBool) {
954
            $this->setOption("groupHeader", ['_fn' => self::JS_BOOL_GROUP_HEADER]);
955
        }
956
    }
957
958
    /**
959
     * @param HTTPRequest $request
960
     * @return HTTPResponse
961
     */
962
    public function handleItem($request)
963
    {
964
        // Our getController could either give us a true Controller, if this is the top-level GridField.
965
        // It could also give us a RequestHandler in the form of (GridFieldDetailForm_ItemRequest, TabulatorGrid...)
966
        $requestHandler = $this->getForm()->getController();
967
        $record = $this->getRecordFromRequest($request);
968
        if (!$record) {
969
            return $requestHandler->httpError(404, 'That record was not found');
970
        }
971
        $handler = $this->getItemRequestHandler($record, $requestHandler);
972
        return $handler->handleRequest($request);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $handler->handleRequest($request) also could return the type SilverStripe\Control\RequestHandler|array|string which is incompatible with the documented return type SilverStripe\Control\HTTPResponse.
Loading history...
973
    }
974
975
    /**
976
     * @param HTTPRequest $request
977
     * @return HTTPResponse
978
     */
979
    public function handleTool($request)
980
    {
981
        // Our getController could either give us a true Controller, if this is the top-level GridField.
982
        // It could also give us a RequestHandler in the form of (GridFieldDetailForm_ItemRequest, TabulatorGrid...)
983
        $requestHandler = $this->getForm()->getController();
984
        $tool = $this->getToolFromRequest($request);
985
        if (!$tool) {
986
            return $requestHandler->httpError(404, 'That tool was not found');
987
        }
988
        return $tool->handleRequest($request);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $tool->handleRequest($request) also could return the type SilverStripe\Control\RequestHandler|array|string which is incompatible with the documented return type SilverStripe\Control\HTTPResponse.
Loading history...
989
    }
990
991
    /**
992
     * @param HTTPRequest $request
993
     * @return HTTPResponse
994
     */
995
    public function handleBulkAction($request)
996
    {
997
        // Our getController could either give us a true Controller, if this is the top-level GridField.
998
        // It could also give us a RequestHandler in the form of (GridFieldDetailForm_ItemRequest, TabulatorGrid...)
999
        $requestHandler = $this->getForm()->getController();
1000
        $bulkAction = $this->getBulkActionFromRequest($request);
1001
        if (!$bulkAction) {
1002
            return $requestHandler->httpError(404, 'That bulk action was not found');
1003
        }
1004
        return $bulkAction->handleRequest($request);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $bulkAction->handleRequest($request) also could return the type SilverStripe\Control\RequestHandler|array|string which is incompatible with the documented return type SilverStripe\Control\HTTPResponse.
Loading history...
1005
    }
1006
1007
    /**
1008
     * @return string name of {@see TabulatorGrid_ItemRequest} subclass
1009
     */
1010
    public function getItemRequestClass(): string
1011
    {
1012
        if ($this->itemRequestClass) {
1013
            return $this->itemRequestClass;
1014
        } elseif (ClassInfo::exists(static::class . '_ItemRequest')) {
1015
            return static::class . '_ItemRequest';
1016
        }
1017
        return TabulatorGrid_ItemRequest::class;
1018
    }
1019
1020
    /**
1021
     * Build a request handler for the given record
1022
     *
1023
     * @param DataObject $record
1024
     * @param RequestHandler $requestHandler
1025
     * @return TabulatorGrid_ItemRequest
1026
     */
1027
    protected function getItemRequestHandler($record, $requestHandler)
1028
    {
1029
        $class = $this->getItemRequestClass();
1030
        $assignedClass = $this->itemRequestClass;
1031
        $this->extend('updateItemRequestClass', $class, $record, $requestHandler, $assignedClass);
1032
        /** @var TabulatorGrid_ItemRequest $handler */
1033
        $handler = Injector::inst()->createWithArgs(
1034
            $class,
1035
            [$this, $record, $requestHandler]
1036
        );
1037
        if ($template = $this->getTemplate()) {
1038
            $handler->setTemplate($template);
1039
        }
1040
        $this->extend('updateItemRequestHandler', $handler);
1041
        return $handler;
1042
    }
1043
1044
    public function getStateKey()
1045
    {
1046
        $nested = [];
1047
        $form = $this->getForm();
1048
        $scope = $this->modelClass ? str_replace('_', '\\', $this->modelClass) :  "default";
1049
        if ($form) {
0 ignored issues
show
introduced by
$form is of type SilverStripe\Forms\Form, thus it always evaluated to true.
Loading history...
1050
            $controller = $form->getController();
1051
1052
            // We are in a nested form, track by id since each records needs it own state
1053
            while ($controller instanceof TabulatorGrid_ItemRequest) {
1054
                $record = $controller->getRecord();
1055
                $nested[str_replace('_', '\\', get_class($record))] = $record->ID;
1056
1057
                // Move to parent controller
1058
                $controller = $controller->getController();
1059
            }
1060
1061
            // Scope by top controller class
1062
            $scope = str_replace('_', '\\', get_class($controller));
1063
        }
1064
1065
        $prefix = "TabulatorState";
1066
        $name = $this->getName();
1067
        $key = "$prefix.$scope.$name";
1068
        foreach ($nested as $k => $v) {
1069
            $key .= "$k.$v";
1070
        }
1071
        return $key;
1072
    }
1073
1074
    /**
1075
     * @param HTTPRequest|null $request
1076
     * @return array{'page': int, 'limit': int, 'sort': array, 'filter': array}
1077
     */
1078
    public function getState(HTTPRequest $request = null)
1079
    {
1080
        if ($request === null) {
1081
            $request = Controller::curr()->getRequest();
1082
        }
1083
        $stateKey = $this->getStateKey();
1084
        $state = $request->getSession()->get($stateKey);
1085
        return $state ?? [
1086
            'page' => 1,
1087
            'limit' => $this->pageSize,
1088
            'sort' => [],
1089
            'filter' => [],
1090
        ];
1091
    }
1092
1093
    public function setState(HTTPRequest $request, $state)
1094
    {
1095
        $stateKey = $this->getStateKey();
1096
        $request->getSession()->set($stateKey, $state);
1097
        // If we are in a new controller, we can clear other states
1098
        // Note: this would break tabbed navigation if you try to open multiple tabs with various grid
1099
        // in various controllers, see below for more info
1100
        // @link https://github.com/silverstripe/silverstripe-framework/issues/9556
1101
        $matches = [];
1102
        preg_match_all('/\.(.*?)\./', $stateKey, $matches);
1103
        $scope = $matches[1][0] ?? null;
1104
        if ($scope) {
1105
            self::clearAllStates($scope);
1106
        }
1107
    }
1108
1109
    public function clearState(HTTPRequest $request)
1110
    {
1111
        $stateKey = $this->getStateKey();
1112
        $request->getSession()->clear($stateKey);
1113
    }
1114
1115
    public static function clearAllStates(string $exceptScope = null)
1116
    {
1117
        $request = Controller::curr()->getRequest();
1118
        $allStates = $request->getSession()->get('TabulatorState');
1119
        if (!$allStates) {
1120
            return;
1121
        }
1122
        foreach ($allStates as $scope => $data) {
1123
            if ($exceptScope && $scope == $exceptScope) {
1124
                continue;
1125
            }
1126
            $request->getSession()->clear("TabulatorState.$scope");
1127
        }
1128
    }
1129
1130
    public function StateValue($key, $field): ?string
1131
    {
1132
        $state = $this->getState();
1133
        $arr = $state[$key] ?? [];
1134
        foreach ($arr as $s) {
1135
            if ($s['field'] === $field) {
1136
                return $s['value'];
1137
            }
1138
        }
1139
        return null;
1140
    }
1141
1142
    /**
1143
     * Provides autocomplete lists
1144
     *
1145
     * @param HTTPRequest $request
1146
     * @return HTTPResponse
1147
     */
1148
    public function autocomplete(HTTPRequest $request)
1149
    {
1150
        if ($this->isDisabled() || $this->isReadonly()) {
1151
            return $this->httpError(403);
1152
        }
1153
        $SecurityID = $request->getVar('SecurityID');
1154
        if (!SecurityToken::inst()->check($SecurityID)) {
1155
            return $this->httpError(404, "Invalid SecurityID");
1156
        }
1157
1158
        $name = $request->getVar("Column");
1159
        $col = $this->getColumn($name);
0 ignored issues
show
Bug introduced by
It seems like $name can also be of type null; however, parameter $key of LeKoala\Tabulator\TabulatorGrid::getColumn() 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

1159
        $col = $this->getColumn(/** @scrutinizer ignore-type */ $name);
Loading history...
1160
        if (!$col) {
1161
            return $this->httpError(403, "Invalid column");
1162
        }
1163
1164
        // Don't use % term as it prevents use of indexes
1165
        $term = $request->getVar('term') . '%';
1166
        $term = str_replace(' ', '%', $term);
1167
1168
        $parts = explode(".", $name);
0 ignored issues
show
Bug introduced by
It seems like $name 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

1168
        $parts = explode(".", /** @scrutinizer ignore-type */ $name);
Loading history...
1169
        if (count($parts) > 2) {
1170
            array_pop($parts);
1171
        }
1172
        if (count($parts) == 2) {
1173
            $class = $parts[0];
1174
            $field = $parts[1];
1175
        } elseif (count($parts) == 1) {
1176
            $class = preg_replace("/ID$/", "", $parts[0]);
1177
            $field = 'Title';
1178
        } else {
1179
            return $this->httpError(403, "Invalid field");
1180
        }
1181
1182
        /** @var DataObject $sng */
1183
        $sng = $class::singleton();
1184
        $baseTable = $sng->baseTable();
1185
1186
        $searchField = null;
1187
        $searchCandidates = [
1188
            $field, 'Name', 'Surname', 'Email', 'ID'
1189
        ];
1190
1191
        // Ensure field exists, this is really rudimentary
1192
        $db = $class::config()->db;
1193
        foreach ($searchCandidates as $searchCandidate) {
1194
            if ($searchField) {
1195
                continue;
1196
            }
1197
            if (isset($db[$searchCandidate])) {
1198
                $searchField = $searchCandidate;
1199
            }
1200
        }
1201
        $searchCols = [$searchField];
1202
1203
        // For members, do something better
1204
        if ($baseTable == 'Member') {
1205
            $searchField = ['FirstName', 'Surname'];
1206
            $searchCols = ['FirstName', 'Surname', 'Email'];
1207
        }
1208
1209
        if (!empty($col['editorParams']['customSearchField'])) {
1210
            $searchField = $col['editorParams']['customSearchField'];
1211
        }
1212
        if (!empty($col['editorParams']['customSearchCols'])) {
1213
            $searchCols = $col['editorParams']['customSearchCols'];
1214
        }
1215
1216
        // Note: we need to use the orm, even if it's slower, to make sure any extension is properly applied
1217
        /** @var DataList $list */
1218
        $list = $sng::get();
1219
1220
        // Make sure at least one field is not null...
1221
        $where = [];
1222
        foreach ($searchCols as $searchCol) {
1223
            $where[] = $searchCol . ' IS NOT NULL';
1224
        }
1225
        $list = $list->where($where);
1226
        // ... and matches search term ...
1227
        $where = [];
1228
        foreach ($searchCols as $searchCol) {
1229
            $where[$searchCol . ' LIKE ?'] = $term;
1230
        }
1231
        $list = $list->whereAny($where);
1232
1233
        // ... and any user set requirements
1234
        if (!empty($col['editorParams']['where'])) {
1235
            // Deal with in clause
1236
            $customWhere = [];
1237
            foreach ($col['editorParams']['where'] as $col => $param) {
1238
                // For array, we need a IN statement with a ? for each value
1239
                if (is_array($param)) {
1240
                    $prepValue = [];
1241
                    $params = [];
1242
                    foreach ($param as $paramValue) {
1243
                        $params[] = $paramValue;
1244
                        $prepValue[] = "?";
1245
                    }
1246
                    $customWhere["$col IN (" . implode(',', $prepValue) . ")"] = $params;
1247
                } else {
1248
                    $customWhere["$col = ?"] = $param;
1249
                }
1250
            }
1251
            $list = $list->where($customWhere);
1252
        }
1253
1254
        $results = iterator_to_array($list);
1255
        $data = [];
1256
        foreach ($results as $record) {
1257
            if (is_array($searchField)) {
1258
                $labelParts = [];
1259
                foreach ($searchField as $sf) {
1260
                    $labelParts[] = $record->$sf;
1261
                }
1262
                $label = implode(" ", $labelParts);
1263
            } else {
1264
                $label = $record->$searchField;
1265
            }
1266
            $data[] = [
1267
                'value' => $record->ID,
1268
                'label' => $label,
1269
            ];
1270
        }
1271
1272
        $json = json_encode($data);
1273
        $response = new HTTPResponse($json);
1274
        $response->addHeader('Content-Type', 'application/script');
1275
        return $response;
1276
    }
1277
1278
    /**
1279
     * @link http://www.tabulator.info/docs/5.5/page#remote-response
1280
     * @param HTTPRequest $request
1281
     * @return HTTPResponse
1282
     */
1283
    public function load(HTTPRequest $request)
1284
    {
1285
        if ($this->isDisabled() || $this->isReadonly()) {
1286
            return $this->httpError(403);
1287
        }
1288
        $SecurityID = $request->getVar('SecurityID');
1289
        if (!SecurityToken::inst()->check($SecurityID)) {
1290
            return $this->httpError(404, "Invalid SecurityID");
1291
        }
1292
1293
        $page = (int) $request->getVar('page');
1294
        $limit = (int) $request->getVar('size');
1295
1296
        $sort = $request->getVar('sort');
1297
        $filter = $request->getVar('filter');
1298
1299
        // Persist state to allow the ItemEditForm to display navigation
1300
        $state = [
1301
            'page' => $page,
1302
            'limit' => $limit,
1303
            'sort' => $sort,
1304
            'filter' => $filter,
1305
        ];
1306
        $this->setState($request, $state);
1307
1308
        $offset = ($page - 1) * $limit;
1309
        $data = $this->getManipulatedData($limit, $offset, $sort, $filter);
1310
        $data['state'] = $state;
1311
1312
        $encodedData = json_encode($data);
1313
        if (!$encodedData) {
1314
            throw new Exception(json_last_error_msg());
1315
        }
1316
1317
        $response = new HTTPResponse($encodedData);
1318
        $response->addHeader('Content-Type', 'application/json');
1319
        return $response;
1320
    }
1321
1322
    /**
1323
     * @param HTTPRequest $request
1324
     * @return DataObject|null
1325
     */
1326
    protected function getRecordFromRequest(HTTPRequest $request): ?DataObject
1327
    {
1328
        /** @var DataObject $record */
1329
        if (is_numeric($request->param('ID'))) {
1330
            /** @var Filterable $dataList */
1331
            $dataList = $this->getList();
1332
            $record = $dataList->byID($request->param('ID'));
1333
        } else {
1334
            $record = Injector::inst()->create($this->getModelClass());
1335
        }
1336
        return $record;
1337
    }
1338
1339
    /**
1340
     * @param HTTPRequest $request
1341
     * @return AbstractTabulatorTool|null
1342
     */
1343
    protected function getToolFromRequest(HTTPRequest $request): ?AbstractTabulatorTool
1344
    {
1345
        $toolID = $request->param('ID');
1346
        $tool = $this->getTool($toolID);
1347
        return $tool;
1348
    }
1349
1350
    /**
1351
     * @param HTTPRequest $request
1352
     * @return AbstractBulkAction|null
1353
     */
1354
    protected function getBulkActionFromRequest(HTTPRequest $request): ?AbstractBulkAction
1355
    {
1356
        $toolID = $request->param('ID');
1357
        $tool = $this->getBulkAction($toolID);
1358
        return $tool;
1359
    }
1360
1361
    /**
1362
     * Get the value of a named field  on the given record.
1363
     *
1364
     * Use of this method ensures that any special rules around the data for this gridfield are
1365
     * followed.
1366
     *
1367
     * @param DataObject $record
1368
     * @param string $fieldName
1369
     *
1370
     * @return mixed
1371
     */
1372
    public function getDataFieldValue($record, $fieldName)
1373
    {
1374
        if ($record->hasMethod('relField')) {
1375
            return $record->relField($fieldName);
1376
        }
1377
1378
        if ($record->hasMethod($fieldName)) {
1379
            return $record->$fieldName();
1380
        }
1381
1382
        return $record->$fieldName;
1383
    }
1384
1385
    public function getManipulatedList(): SS_List
1386
    {
1387
        return $this->list;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->list could return the type null which is incompatible with the type-hinted return SilverStripe\ORM\SS_List. Consider adding an additional type-check to rule them out.
Loading history...
1388
    }
1389
1390
    public function getList(): SS_List
1391
    {
1392
        return $this->list;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->list could return the type null which is incompatible with the type-hinted return SilverStripe\ORM\SS_List. Consider adding an additional type-check to rule them out.
Loading history...
1393
    }
1394
1395
    public function setList(SS_List $list): self
1396
    {
1397
        if ($this->autoloadDataList && $list instanceof DataList) {
1398
            $this->wizardRemotePagination();
1399
        }
1400
        $this->list = $list;
1401
        return $this;
1402
    }
1403
1404
    public function hasArrayList(): bool
1405
    {
1406
        return $this->list instanceof ArrayList;
1407
    }
1408
1409
    public function getArrayList(): ArrayList
1410
    {
1411
        if (!$this->list instanceof ArrayList) {
1412
            throw new RuntimeException("Value is not a ArrayList, it is a: " . get_class($this->list));
0 ignored issues
show
Bug introduced by
It seems like $this->list can also be of type null; however, parameter $object of get_class() does only seem to accept object, 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

1412
            throw new RuntimeException("Value is not a ArrayList, it is a: " . get_class(/** @scrutinizer ignore-type */ $this->list));
Loading history...
1413
        }
1414
        return $this->list;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->list returns the type null which is incompatible with the type-hinted return SilverStripe\ORM\ArrayList.
Loading history...
1415
    }
1416
1417
    public function hasDataList(): bool
1418
    {
1419
        return $this->list instanceof DataList;
1420
    }
1421
1422
    /**
1423
     * A properly typed on which you can call byID
1424
     * @return ArrayList|DataList
1425
     */
1426
    public function getByIDList()
1427
    {
1428
        return $this->list;
1429
    }
1430
1431
    public function hasByIDList(): bool
1432
    {
1433
        return $this->hasDataList() || $this->hasArrayList();
1434
    }
1435
1436
    public function getDataList(): DataList
1437
    {
1438
        if (!$this->list instanceof DataList) {
1439
            throw new RuntimeException("Value is not a DataList, it is a: " . get_class($this->list));
0 ignored issues
show
Bug introduced by
It seems like $this->list can also be of type null; however, parameter $object of get_class() does only seem to accept object, 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

1439
            throw new RuntimeException("Value is not a DataList, it is a: " . get_class(/** @scrutinizer ignore-type */ $this->list));
Loading history...
1440
        }
1441
        return $this->list;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->list returns the type null which is incompatible with the type-hinted return SilverStripe\ORM\DataList.
Loading history...
1442
    }
1443
1444
    public function getManipulatedData(int $limit, int $offset, array $sort = null, array $filter = null): array
1445
    {
1446
        if (!$this->hasDataList()) {
1447
            $data = $this->list->toNestedArray();
0 ignored issues
show
Bug introduced by
The method toNestedArray() does not exist on null. ( Ignorable by Annotation )

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

1447
            /** @scrutinizer ignore-call */ 
1448
            $data = $this->list->toNestedArray();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
1448
1449
            $lastRow = $this->list->count();
1450
            $lastPage = ceil($lastRow / $limit);
1451
1452
            $result = [
1453
                'last_row' => $lastRow,
1454
                'last_page' => $lastPage,
1455
                'data' => $data,
1456
            ];
1457
1458
            return $result;
1459
        }
1460
1461
        $dataList = $this->getDataList();
1462
1463
        $schema = DataObject::getSchema();
1464
        $dataClass = $dataList->dataClass();
1465
1466
        /** @var DataObject $singleton */
1467
        $singleton = singleton($dataClass);
1468
        $resolutionMap = [];
1469
1470
        $sortSql = [];
1471
        if ($sort) {
1472
            foreach ($sort as $sortValues) {
1473
                $cols = array_keys($this->columns);
1474
                $field = $sortValues['field'];
1475
                if (!in_array($field, $cols)) {
1476
                    throw new Exception("Invalid sort field: $field");
1477
                }
1478
                $dir = $sortValues['dir'];
1479
                if (!in_array($dir, ['asc', 'desc'])) {
1480
                    throw new Exception("Invalid sort dir: $dir");
1481
                }
1482
1483
                // Nested sort
1484
                if (strpos($field, '.') !== false) {
1485
                    $parts = explode(".", $field);
1486
1487
                    // Resolve relation only once in case of multiples similar keys
1488
                    if (!isset($resolutionMap[$parts[0]])) {
1489
                        $resolutionMap[$parts[0]] = $singleton->relObject($parts[0]);
1490
                    }
1491
                    // Not matching anything (maybe a formatting .Nice ?)
1492
                    if (!$resolutionMap[$parts[0]] || !($resolutionMap[$parts[0]] instanceof DataList)) {
1493
                        $field = $parts[0];
0 ignored issues
show
Unused Code introduced by
The assignment to $field is dead and can be removed.
Loading history...
1494
                        continue;
1495
                    }
1496
                    $relatedObject = get_class($resolutionMap[$parts[0]]);
1497
                    $tableName = $schema->tableForField($relatedObject, $parts[1]);
1498
                    $baseIDColumn = $schema->sqlColumnForField($dataClass, 'ID');
1499
                    $tableAlias = $parts[0];
1500
                    $dataList = $dataList->leftJoin($tableName, "\"{$tableAlias}\".\"ID\" = {$baseIDColumn}", $tableAlias);
1501
                }
1502
1503
                $sortSql[] = $field . ' ' . $dir;
1504
            }
1505
        } else {
1506
            // If we have a sort column
1507
            if (isset($this->columns['ui_sort'])) {
1508
                $sortSql[] = $this->columns['ui_sort']['field'] . ' ASC';
1509
            }
1510
        }
1511
        if (!empty($sortSql)) {
1512
            $dataList = $dataList->sort(implode(", ", $sortSql));
1513
        }
1514
1515
        // Filtering is an array of field/type/value arrays
1516
        $where = [];
1517
        $anyWhere = [];
1518
        if ($filter) {
1519
            foreach ($filter as $filterValues) {
1520
                $cols = array_keys($this->columns);
1521
                $field = $filterValues['field'];
1522
                if (strpos($field, '__') !== 0 && !in_array($field, $cols)) {
1523
                    throw new Exception("Invalid filter field: $field");
1524
                }
1525
                $value = $filterValues['value'];
1526
                $type = $filterValues['type'];
1527
1528
                $rawValue = $value;
1529
1530
                // Strict value
1531
                if ($value === "true") {
1532
                    $value = true;
1533
                } elseif ($value === "false") {
1534
                    $value = false;
1535
                }
1536
1537
                switch ($type) {
1538
                    case "=":
1539
                        if ($field === "__wildcard") {
1540
                            // It's a wildcard search
1541
                            $anyWhere = $this->createWildcardFilters($rawValue);
1542
                        } elseif ($field === "__quickfilter") {
1543
                            // It's a quickfilter search
1544
                            $this->createQuickFilter($rawValue, $dataList);
1545
                        } else {
1546
                            $where["$field"] = $value;
1547
                        }
1548
                        break;
1549
                    case "!=":
1550
                        $where["$field:not"] = $value;
1551
                        break;
1552
                    case "like":
1553
                        $where["$field:PartialMatch:nocase"] = $value;
1554
                        break;
1555
                    case "keywords":
1556
                        $where["$field:PartialMatch:nocase"] = str_replace(" ", "%", $value);
1557
                        break;
1558
                    case "starts":
1559
                        $where["$field:StartsWith:nocase"] = $value;
1560
                        break;
1561
                    case "ends":
1562
                        $where["$field:EndsWith:nocase"] = $value;
1563
                        break;
1564
                    case "<":
1565
                        $where["$field:LessThan:nocase"] = $value;
1566
                        break;
1567
                    case "<=":
1568
                        $where["$field:LessThanOrEqual:nocase"] = $value;
1569
                        break;
1570
                    case ">":
1571
                        $where["$field:GreaterThan:nocase"] = $value;
1572
                        break;
1573
                    case ">=":
1574
                        $where["$field:GreaterThanOrEqual:nocase"] = $value;
1575
                        break;
1576
                    case "in":
1577
                        $where["$field"] = $value;
1578
                        break;
1579
                    case "regex":
1580
                        $dataList = $dataList->where('REGEXP ' . Convert::raw2sql($value));
1581
                        break;
1582
                    default:
1583
                        throw new Exception("Invalid sort dir: $dir");
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $dir does not seem to be defined for all execution paths leading up to this point.
Loading history...
1584
                }
1585
            }
1586
        }
1587
        if (!empty($where)) {
1588
            $dataList = $dataList->filter($where);
1589
        }
1590
        if (!empty($anyWhere)) {
1591
            $dataList = $dataList->filterAny($anyWhere);
1592
        }
1593
1594
        $lastRow = $dataList->count();
1595
        $lastPage = ceil($lastRow / $limit);
1596
1597
        $data = [];
1598
        /** @var DataObject $record */
1599
        foreach ($dataList->limit($limit, $offset) as $record) {
1600
            if ($record->hasMethod('canView') && !$record->canView()) {
1601
                continue;
1602
            }
1603
1604
            $item = [
1605
                'ID' => $record->ID,
1606
            ];
1607
1608
            // Add row class
1609
            if ($record->hasMethod('TabulatorRowClass')) {
1610
                $item['_class'] = $record->TabulatorRowClass();
1611
            } elseif ($record->hasMethod('getRowClass')) {
1612
                $item['_class'] = $record->getRowClass();
1613
            }
1614
            // Add row color
1615
            if ($record->hasMethod('TabulatorRowColor')) {
1616
                $item['_color'] = $record->TabulatorRowColor();
1617
            }
1618
1619
            $nested = [];
1620
            foreach ($this->columns as $col) {
1621
                if (empty($col['field'])) {
1622
                    continue;
1623
                }
1624
                $field = $col['field'];
1625
                if (strpos($field, '.') !== false) {
1626
                    $parts = explode('.', $field);
1627
                    $classOrField = $parts[0];
1628
                    $relationOrMethod = $parts[1];
1629
                    // For relations, like Users.count
1630
                    if ($singleton->getRelationClass($classOrField)) {
1631
                        $nested[$classOrField][] = $relationOrMethod;
1632
                        continue;
1633
                    } else {
1634
                        // For fields, like SomeValue.Nice
1635
                        $dbObject = $record->dbObject($classOrField);
1636
                        if ($dbObject) {
1637
                            $item[$classOrField] = [
1638
                                $relationOrMethod => $dbObject->$relationOrMethod()
1639
                            ];
1640
                            continue;
1641
                        }
1642
                    }
1643
                }
1644
                if (!isset($item[$field])) {
1645
                    if ($record->hasMethod($field)) {
1646
                        $item[$field] = $record->$field();
1647
                    } else {
1648
                        $item[$field] = $record->getField($field);
1649
                    }
1650
                }
1651
            }
1652
            // Fill in nested data, like Users.count
1653
            foreach ($nested as $nestedClass => $nestedColumns) {
1654
                /** @var DataObject $relObject */
1655
                $relObject = $record->relObject($nestedClass);
1656
                $nestedData = [];
1657
                foreach ($nestedColumns as $nestedColumn) {
1658
                    $nestedData[$nestedColumn] = $this->getDataFieldValue($relObject, $nestedColumn);
1659
                }
1660
                $item[$nestedClass] = $nestedData;
1661
            }
1662
            $data[] = $item;
1663
        }
1664
1665
        $result = [
1666
            'last_row' => $lastRow,
1667
            'last_page' => $lastPage,
1668
            'data' => $data,
1669
        ];
1670
1671
        if (Director::isDev()) {
1672
            $result['sql'] = $dataList->sql();
1673
        }
1674
1675
        return $result;
1676
    }
1677
1678
    public function QuickFiltersList()
1679
    {
1680
        $current = $this->StateValue('filter', '__quickfilter');
1681
        $list = new ArrayList();
1682
        foreach ($this->quickFilters as $k => $v) {
1683
            $list->push([
1684
                'Value' => $k,
1685
                'Label' => $v['label'],
1686
                'Selected' => $k == $current
1687
            ]);
1688
        }
1689
        return $list;
1690
    }
1691
1692
    protected function createQuickFilter($filter, &$list)
1693
    {
1694
        $qf = $this->quickFilters[$filter] ?? null;
1695
        if (!$qf) {
1696
            return;
1697
        }
1698
1699
        $callback = $qf['callback'] ?? null;
1700
        if (!$callback) {
1701
            return;
1702
        }
1703
1704
        $callback($list);
1705
    }
1706
1707
    protected function createWildcardFilters(string $value)
1708
    {
1709
        $wildcardFields = $this->wildcardFields;
1710
1711
        // Create from model
1712
        if (empty($wildcardFields)) {
1713
            /** @var DataObject $singl */
1714
            $singl = singleton($this->modelClass);
1715
            $searchableFields = $singl->searchableFields();
1716
1717
            foreach ($searchableFields as $k => $v) {
1718
                $general = $v['general'] ?? true;
1719
                if (!$general) {
1720
                    continue;
1721
                }
1722
                $wildcardFields[] = $k;
1723
            }
1724
        }
1725
1726
        // Queries can have the format s:... or e:... or =:.... or %:....
1727
        $filter = $this->defaultFilter;
1728
        if (strpos($value, ':') === 1) {
1729
            $parts = explode(":", $value);
1730
            $shortcut = array_shift($parts);
1731
            $value = implode(":", $parts);
1732
            switch ($shortcut) {
1733
                case 's':
1734
                    $filter = 'StartsWith';
1735
                    break;
1736
                case 'e':
1737
                    $filter = 'EndsWith';
1738
                    break;
1739
                case '=':
1740
                    $filter = 'ExactMatch';
1741
                    break;
1742
                case '%':
1743
                    $filter = 'PartialMatch';
1744
                    break;
1745
            }
1746
        }
1747
1748
        // Process value
1749
        $baseValue = $value;
1750
        $value = str_replace(" ", "%", $value);
1751
        $value = str_replace(['.', '_', '-'], ' ', $value);
1752
1753
        // Create filters
1754
        $anyWhere = [];
1755
        foreach ($wildcardFields as $f) {
1756
            if (!$value) {
1757
                continue;
1758
            }
1759
            $key = $f . ":" . $filter;
1760
            $anyWhere[$key] = $value;
1761
1762
            // also look on unfiltered data
1763
            if ($value != $baseValue) {
1764
                $anyWhere[$key] = $baseValue;
1765
            }
1766
        }
1767
1768
        return $anyWhere;
1769
    }
1770
1771
    public function getModelClass(): ?string
1772
    {
1773
        if ($this->modelClass) {
1774
            return $this->modelClass;
1775
        }
1776
        if ($this->list && $this->list instanceof DataList) {
1777
            return $this->list->dataClass();
1778
        }
1779
        return null;
1780
    }
1781
1782
    public function setModelClass(string $modelClass): self
1783
    {
1784
        $this->modelClass = $modelClass;
1785
        return $this;
1786
    }
1787
1788
1789
    public function getDataAttribute(string $k)
1790
    {
1791
        if (isset($this->dataAttributes[$k])) {
1792
            return $this->dataAttributes[$k];
1793
        }
1794
        return $this->getAttribute("data-$k");
1795
    }
1796
1797
    public function setDataAttribute(string $k, $v): self
1798
    {
1799
        $this->dataAttributes[$k] = $v;
1800
        return $this;
1801
    }
1802
1803
    public function dataAttributesHTML(): string
1804
    {
1805
        $parts = [];
1806
        foreach ($this->dataAttributes as $k => $v) {
1807
            if (!$v) {
1808
                continue;
1809
            }
1810
            if (is_array($v)) {
1811
                $v = json_encode($v);
1812
            }
1813
            $parts[] = "data-$k='$v'";
1814
        }
1815
        return implode(" ", $parts);
1816
    }
1817
1818
    protected function processLink(string $url): string
1819
    {
1820
        // It's not necessary to process
1821
        if ($url == '#') {
1822
            return $url;
1823
        }
1824
        // It's a temporary link on the form
1825
        if (strpos($url, 'form:') === 0) {
1826
            return $this->Link(preg_replace('/^form:/', '', $url));
1827
        }
1828
        // It's a temporary link on the controller
1829
        if (strpos($url, 'controller:') === 0) {
1830
            return $this->ControllerLink(preg_replace('/^controller:/', '', $url));
1831
        }
1832
        // It's a custom protocol (mailto: etc)
1833
        if (strpos($url, ':') !== false) {
1834
            return $url;
1835
        }
1836
        return $url;
1837
    }
1838
1839
    protected function processLinks(): void
1840
    {
1841
        // Process editor and formatter links
1842
        foreach ($this->columns as $name => $params) {
1843
            if (!empty($params['formatterParams']['url'])) {
1844
                $url = $this->processLink($params['formatterParams']['url']);
1845
                $this->columns[$name]['formatterParams']['url'] = $url;
1846
            }
1847
            if (!empty($params['editorParams']['url'])) {
1848
                $url = $this->processLink($params['editorParams']['url']);
1849
                $this->columns[$name]['editorParams']['url'] = $url;
1850
            }
1851
            // Set valuesURL automatically if not already set
1852
            if (!empty($params['editorParams']['autocomplete'])) {
1853
                if (empty($params['editorParams']['valuesURL'])) {
1854
                    $params = [
1855
                        'Column' => $name,
1856
                        'SecurityID' => SecurityToken::getSecurityID(),
1857
                    ];
1858
                    $url = $this->Link('autocomplete') . '?' . http_build_query($params);
1859
                    $this->columns[$name]['editorParams']['valuesURL'] = $url;
1860
                    $this->columns[$name]['editorParams']['filterRemote'] = true;
1861
                }
1862
            }
1863
        }
1864
1865
        // Other links
1866
        $url = $this->getOption('ajaxURL');
1867
        if ($url) {
1868
            $this->setOption('ajaxURL', $this->processLink($url));
1869
        }
1870
    }
1871
1872
    public function makeButton(string $urlOrAction, string $icon, string $title): array
1873
    {
1874
        $opts = [
1875
            "responsive" => 0,
1876
            "cssClass" => 'tabulator-cell-btn',
1877
            "tooltip" => $title,
1878
            "formatter" => "button",
1879
            "formatterParams" => [
1880
                "icon" => $icon,
1881
                "title" => $title,
1882
                "url" => $this->TempLink($urlOrAction), // On the controller by default
1883
            ],
1884
            "cellClick" => ["__fn" => "SSTabulator.buttonHandler"],
1885
            "width" => 70,
1886
            "hozAlign" => "center",
1887
            "headerSort" => false,
1888
        ];
1889
        return $opts;
1890
    }
1891
1892
    public function addButtonFromArray(string $action, array $opts = [], string $before = null): self
1893
    {
1894
        // Insert before given column
1895
        if ($before) {
1896
            $this->addColumnBefore("action_$action", $opts, $before);
1897
        } else {
1898
            $this->columns["action_$action"] = $opts;
1899
        }
1900
        return $this;
1901
    }
1902
1903
    /**
1904
     * @param string $action Action name
1905
     * @param string $url Parameters between {} will be interpolated by row values.
1906
     * @param string $icon
1907
     * @param string $title
1908
     * @param string|null $before
1909
     * @return self
1910
     */
1911
    public function addButton(string $action, string $url, string $icon, string $title, string $before = null): self
1912
    {
1913
        $opts = $this->makeButton($url, $icon, $title);
1914
        $this->addButtonFromArray($action, $opts, $before);
1915
        return $this;
1916
    }
1917
1918
    public function shiftButton(string $action, string $url, string $icon, string $title): self
1919
    {
1920
        // Find first action
1921
        foreach ($this->columns as $name => $options) {
1922
            if (strpos($name, 'action_') === 0) {
1923
                return $this->addButton($action, $url, $icon, $title, $name);
1924
            }
1925
        }
1926
        return $this->addButton($action, $url, $icon, $title);
1927
    }
1928
1929
    public function getActions(): array
1930
    {
1931
        $cols = [];
1932
        foreach ($this->columns as $name => $options) {
1933
            if (strpos($name, 'action_') === 0) {
1934
                $cols[$name] = $options;
1935
            }
1936
        }
1937
        return $cols;
1938
    }
1939
1940
    public function getUiColumns(): array
1941
    {
1942
        $cols = [];
1943
        foreach ($this->columns as $name => $options) {
1944
            if (strpos($name, 'ui_') === 0) {
1945
                $cols[$name] = $options;
1946
            }
1947
        }
1948
        return $cols;
1949
    }
1950
1951
    public function getSystemColumns(): array
1952
    {
1953
        return array_merge($this->getActions(), $this->getUiColumns());
1954
    }
1955
1956
    public function removeButton(string $action): self
1957
    {
1958
        if (isset($this->columns["action_$action"])) {
1959
            unset($this->columns["action_$action"]);
1960
        }
1961
        return $this;
1962
    }
1963
1964
    /**
1965
     * @link http://www.tabulator.info/docs/5.5/columns#definition
1966
     * @param string $field (Required) this is the key for this column in the data array
1967
     * @param string $title (Required) This is the title that will be displayed in the header for this column
1968
     * @param array $opts Other options to merge in
1969
     * @return $this
1970
     */
1971
    public function addColumn(string $field, string $title = null, array $opts = []): self
1972
    {
1973
        if ($title === null) {
1974
            $title = $field;
1975
        }
1976
1977
        $baseOpts = [
1978
            "field" => $field,
1979
            "title" => $title,
1980
        ];
1981
1982
        if (!empty($opts)) {
1983
            $baseOpts = array_merge($baseOpts, $opts);
1984
        }
1985
1986
        $this->columns[$field] = $baseOpts;
1987
        return $this;
1988
    }
1989
1990
    /**
1991
     * @link http://www.tabulator.info/docs/5.5/columns#definition
1992
     * @param array $opts Other options to merge in
1993
     * @param ?string $before
1994
     * @return $this
1995
     */
1996
    public function addColumnFromArray(array $opts = [], $before = null)
1997
    {
1998
        if (empty($opts['field']) || !isset($opts['title'])) {
1999
            throw new Exception("Missing field or title key");
2000
        }
2001
        $field = $opts['field'];
2002
2003
        if ($before) {
2004
            $this->addColumnBefore($field, $opts, $before);
2005
        } else {
2006
            $this->columns[$field] = $opts;
2007
        }
2008
2009
        return $this;
2010
    }
2011
2012
    protected function addColumnBefore($field, $opts, $before)
2013
    {
2014
        if (array_key_exists($before, $this->columns)) {
2015
            $new = [];
2016
            foreach ($this->columns as $k => $value) {
2017
                if ($k === $before) {
2018
                    $new[$field] = $opts;
2019
                }
2020
                $new[$k] = $value;
2021
            }
2022
            $this->columns = $new;
2023
        }
2024
    }
2025
2026
    public function makeColumnEditable(string $field, string $editor = "input", array $params = [])
2027
    {
2028
        $col = $this->getColumn($field);
2029
        if (!$col) {
2030
            throw new InvalidArgumentException("$field is not a valid column");
2031
        }
2032
2033
        switch ($editor) {
2034
            case 'date':
2035
                $editor = "input";
2036
                $params = [
2037
                    'mask' => "9999-99-99",
2038
                    'maskAutoFill' => 'true',
2039
                ];
2040
                break;
2041
            case 'datetime':
2042
                $editor = "input";
2043
                $params = [
2044
                    'mask' => "9999-99-99 99:99:99",
2045
                    'maskAutoFill' => 'true',
2046
                ];
2047
                break;
2048
        }
2049
2050
        if (empty($col['cssClass'])) {
2051
            $col['cssClass'] = 'no-change-track';
2052
        } else {
2053
            $col['cssClass'] .= ' no-change-track';
2054
        }
2055
2056
        $col['editor'] = $editor;
2057
        $col['editorParams'] = $params;
2058
        if ($editor == "list") {
2059
            if (!empty($params['autocomplete'])) {
2060
                $col['headerFilter'] = "input"; // force input
2061
            } else {
2062
                $col['headerFilterParams'] = $params; // editor is used as base filter editor
2063
            }
2064
        }
2065
2066
2067
        $this->setColumn($field, $col);
2068
    }
2069
2070
    /**
2071
     * Get column details
2072
2073
     * @param string $key
2074
     */
2075
    public function getColumn(string $key): ?array
2076
    {
2077
        if (isset($this->columns[$key])) {
2078
            return $this->columns[$key];
2079
        }
2080
        return null;
2081
    }
2082
2083
    /**
2084
     * Set column details
2085
     *
2086
     * @param string $key
2087
     * @param array $col
2088
     */
2089
    public function setColumn(string $key, array $col): self
2090
    {
2091
        $this->columns[$key] = $col;
2092
        return $this;
2093
    }
2094
2095
    /**
2096
     * Update column details
2097
     *
2098
     * @param string $key
2099
     * @param array $col
2100
     */
2101
    public function updateColumn(string $key, array $col): self
2102
    {
2103
        $data = $this->getColumn($key);
2104
        if ($data) {
2105
            $this->setColumn($key, array_merge($data, $col));
2106
        }
2107
        return $this;
2108
    }
2109
2110
    /**
2111
     * Remove a column
2112
     *
2113
     * @param string $key
2114
     */
2115
    public function removeColumn(string $key): void
2116
    {
2117
        unset($this->columns[$key]);
2118
    }
2119
2120
2121
    /**
2122
     * Get the value of columns
2123
     */
2124
    public function getColumns(): array
2125
    {
2126
        return $this->columns;
2127
    }
2128
2129
    /**
2130
     * Set the value of columns
2131
     */
2132
    public function setColumns(array $columns): self
2133
    {
2134
        $this->columns = $columns;
2135
        return $this;
2136
    }
2137
2138
    public function clearColumns(bool $keepSystem = true): void
2139
    {
2140
        $sysNames = array_keys($this->getSystemColumns());
2141
        foreach ($this->columns as $k => $v) {
2142
            if ($keepSystem && in_array($k, $sysNames)) {
2143
                continue;
2144
            }
2145
            $this->removeColumn($k);
2146
        }
2147
    }
2148
2149
    /**
2150
     * This should be the rough equivalent to GridFieldDataColumns::getDisplayFields
2151
     */
2152
    public function getDisplayFields(): array
2153
    {
2154
        $fields = [];
2155
        foreach ($this->columns as $col) {
2156
            if (empty($col['field'])) {
2157
                continue;
2158
            }
2159
            $fields[$col['field']] = $col['title'];
2160
        }
2161
        return $fields;
2162
    }
2163
2164
    /**
2165
     * This should be the rough equivalent to GridFieldDataColumns::setDisplayFields
2166
     */
2167
    public function setDisplayFields(array $arr): void
2168
    {
2169
        $this->clearColumns();
2170
        $actions = array_keys($this->getActions());
2171
        $before = $actions[0] ?? null;
2172
        foreach ($arr as $k => $v) {
2173
            $this->addColumnFromArray([
2174
                'field' => $k,
2175
                'title' => $v,
2176
            ], $before);
2177
        }
2178
    }
2179
2180
    /**
2181
     * @param string|AbstractTabulatorTool $tool Pass name or class
2182
     * @return AbstractTabulatorTool|null
2183
     */
2184
    public function getTool($tool): ?AbstractTabulatorTool
2185
    {
2186
        if (is_object($tool)) {
2187
            $tool = get_class($tool);
2188
        }
2189
        if (!is_string($tool)) {
0 ignored issues
show
introduced by
The condition is_string($tool) is always true.
Loading history...
2190
            throw new InvalidArgumentException('Tool must be an object or a class name');
2191
        }
2192
        foreach ($this->tools as $t) {
2193
            if ($t['name'] === $tool) {
2194
                return $t['tool'];
2195
            }
2196
            if ($t['tool'] instanceof $tool) {
2197
                return $t['tool'];
2198
            }
2199
        }
2200
        return null;
2201
    }
2202
2203
    public function addTool(string $pos, AbstractTabulatorTool $tool, string $name = ''): self
2204
    {
2205
        $tool->setTabulatorGrid($this);
2206
        $tool->setName($name);
2207
2208
        $this->tools[] = [
2209
            'position' => $pos,
2210
            'tool' => $tool,
2211
            'name' => $name,
2212
        ];
2213
        return $this;
2214
    }
2215
2216
    public function removeTool($tool): self
2217
    {
2218
        if (is_object($tool)) {
2219
            $tool = get_class($tool);
2220
        }
2221
        if (!is_string($tool)) {
2222
            throw new InvalidArgumentException('Tool must be an object or a class name');
2223
        }
2224
        foreach ($this->tools as $idx => $tool) {
0 ignored issues
show
introduced by
$tool is overwriting one of the parameters of this function.
Loading history...
2225
            if ($tool['name'] === $tool) {
2226
                unset($this->tools[$idx]);
2227
            }
2228
            if ($tool['tool'] instanceof $tool) {
2229
                unset($this->tools[$idx]);
2230
            }
2231
        }
2232
        return $this;
2233
    }
2234
2235
    /**
2236
     * @param string|AbstractBulkAction $bulkAction Pass name or class
2237
     * @return AbstractBulkAction|null
2238
     */
2239
    public function getBulkAction($bulkAction): ?AbstractBulkAction
2240
    {
2241
        if (is_object($bulkAction)) {
2242
            $bulkAction = get_class($bulkAction);
2243
        }
2244
        if (!is_string($bulkAction)) {
0 ignored issues
show
introduced by
The condition is_string($bulkAction) is always true.
Loading history...
2245
            throw new InvalidArgumentException('BulkAction must be an object or a class name');
2246
        }
2247
        foreach ($this->bulkActions as $ba) {
2248
            if ($ba->getName() == $bulkAction) {
2249
                return $ba;
2250
            }
2251
            if ($ba instanceof $bulkAction) {
2252
                return $ba;
2253
            }
2254
        }
2255
        return null;
2256
    }
2257
2258
    public function getBulkActions(): array
2259
    {
2260
        return $this->bulkActions;
2261
    }
2262
2263
    /**
2264
     * @param AbstractBulkAction[] $bulkActions
2265
     * @return self
2266
     */
2267
    public function setBulkActions(array $bulkActions): self
2268
    {
2269
        foreach ($bulkActions as $bulkAction) {
2270
            $bulkAction->setTabulatorGrid($this);
2271
        }
2272
        $this->bulkActions = $bulkActions;
2273
        return $this;
2274
    }
2275
2276
    public function addBulkAction(AbstractBulkAction $handler): self
2277
    {
2278
        $handler->setTabulatorGrid($this);
2279
2280
        $this->bulkActions[] = $handler;
2281
        return $this;
2282
    }
2283
2284
    public function removeBulkAction($bulkAction): self
2285
    {
2286
        if (is_object($bulkAction)) {
2287
            $bulkAction = get_class($bulkAction);
2288
        }
2289
        if (!is_string($bulkAction)) {
2290
            throw new InvalidArgumentException('Bulk action must be an object or a class name');
2291
        }
2292
        foreach ($this->bulkActions as $idx => $ba) {
2293
            if ($ba->getName() == $bulkAction) {
2294
                unset($this->bulkAction[$idx]);
0 ignored issues
show
Bug Best Practice introduced by
The property bulkAction does not exist on LeKoala\Tabulator\TabulatorGrid. Since you implemented __get, consider adding a @property annotation.
Loading history...
2295
            }
2296
            if ($ba instanceof $bulkAction) {
2297
                unset($this->bulkAction[$idx]);
2298
            }
2299
        }
2300
        return $this;
2301
    }
2302
2303
    public function getColumnDefault(string $opt)
2304
    {
2305
        return $this->columnDefaults[$opt] ?? null;
2306
    }
2307
2308
    public function setColumnDefault(string $opt, $value)
2309
    {
2310
        $this->columnDefaults[$opt] = $value;
2311
    }
2312
2313
    public function getColumnDefaults(): array
2314
    {
2315
        return $this->columnDefaults;
2316
    }
2317
2318
    public function setColumnDefaults(array $columnDefaults): self
2319
    {
2320
        $this->columnDefaults = $columnDefaults;
2321
        return $this;
2322
    }
2323
2324
    public function getListeners(): array
2325
    {
2326
        return $this->listeners;
2327
    }
2328
2329
    public function setListeners(array $listeners): self
2330
    {
2331
        $this->listeners = $listeners;
2332
        return $this;
2333
    }
2334
2335
    public function addListener(string $event, string $functionName): self
2336
    {
2337
        $this->listeners[$event] = $functionName;
2338
        return $this;
2339
    }
2340
2341
    public function removeListener(string $event): self
2342
    {
2343
        if (isset($this->listeners[$event])) {
2344
            unset($this->listeners[$event]);
2345
        }
2346
        return $this;
2347
    }
2348
2349
    public function getLinksOptions(): array
2350
    {
2351
        return $this->linksOptions;
2352
    }
2353
2354
    public function setLinksOptions(array $linksOptions): self
2355
    {
2356
        $this->linksOptions = $linksOptions;
2357
        return $this;
2358
    }
2359
2360
    public function registerLinkOption(string $linksOption): self
2361
    {
2362
        $this->linksOptions[] = $linksOption;
2363
        return $this;
2364
    }
2365
2366
    public function unregisterLinkOption(string $linksOption): self
2367
    {
2368
        $this->linksOptions = array_diff($this->linksOptions, [$linksOption]);
2369
        return $this;
2370
    }
2371
2372
    /**
2373
     * Get the value of pageSize
2374
     */
2375
    public function getPageSize(): int
2376
    {
2377
        return $this->pageSize;
2378
    }
2379
2380
    /**
2381
     * Set the value of pageSize
2382
     *
2383
     * @param int $pageSize
2384
     */
2385
    public function setPageSize(int $pageSize): self
2386
    {
2387
        $this->pageSize = $pageSize;
2388
        return $this;
2389
    }
2390
2391
    /**
2392
     * Get the value of autoloadDataList
2393
     */
2394
    public function getAutoloadDataList(): bool
2395
    {
2396
        return $this->autoloadDataList;
2397
    }
2398
2399
    /**
2400
     * Set the value of autoloadDataList
2401
     *
2402
     * @param bool $autoloadDataList
2403
     */
2404
    public function setAutoloadDataList(bool $autoloadDataList): self
2405
    {
2406
        $this->autoloadDataList = $autoloadDataList;
2407
        return $this;
2408
    }
2409
2410
    /**
2411
     * Set the value of itemRequestClass
2412
     */
2413
    public function setItemRequestClass(string $itemRequestClass): self
2414
    {
2415
        $this->itemRequestClass = $itemRequestClass;
2416
        return $this;
2417
    }
2418
2419
    /**
2420
     * Get the value of lazyInit
2421
     */
2422
    public function getLazyInit(): bool
2423
    {
2424
        return $this->lazyInit;
2425
    }
2426
2427
    /**
2428
     * Set the value of lazyInit
2429
     */
2430
    public function setLazyInit(bool $lazyInit): self
2431
    {
2432
        $this->lazyInit = $lazyInit;
2433
        return $this;
2434
    }
2435
2436
    /**
2437
     * Get the value of rowClickTriggersAction
2438
     */
2439
    public function getRowClickTriggersAction(): bool
2440
    {
2441
        return $this->rowClickTriggersAction;
2442
    }
2443
2444
    /**
2445
     * Set the value of rowClickTriggersAction
2446
     */
2447
    public function setRowClickTriggersAction(bool $rowClickTriggersAction): self
2448
    {
2449
        $this->rowClickTriggersAction = $rowClickTriggersAction;
2450
        return $this;
2451
    }
2452
2453
    /**
2454
     * Get the value of controllerFunction
2455
     */
2456
    public function getControllerFunction(): string
2457
    {
2458
        if (!$this->controllerFunction) {
2459
            return $this->getName() ?? "TabulatorGrid";
2460
        }
2461
        return $this->controllerFunction;
2462
    }
2463
2464
    /**
2465
     * Set the value of controllerFunction
2466
     */
2467
    public function setControllerFunction(string $controllerFunction): self
2468
    {
2469
        $this->controllerFunction = $controllerFunction;
2470
        return $this;
2471
    }
2472
2473
    /**
2474
     * Get the value of editUrl
2475
     */
2476
    public function getEditUrl(): string
2477
    {
2478
        return $this->editUrl;
2479
    }
2480
2481
    /**
2482
     * Set the value of editUrl
2483
     */
2484
    public function setEditUrl(string $editUrl): self
2485
    {
2486
        $this->editUrl = $editUrl;
2487
        return $this;
2488
    }
2489
2490
    /**
2491
     * Get the value of moveUrl
2492
     */
2493
    public function getMoveUrl(): string
2494
    {
2495
        return $this->moveUrl;
2496
    }
2497
2498
    /**
2499
     * Set the value of moveUrl
2500
     */
2501
    public function setMoveUrl(string $moveUrl): self
2502
    {
2503
        $this->moveUrl = $moveUrl;
2504
        return $this;
2505
    }
2506
2507
    /**
2508
     * Get the value of bulkUrl
2509
     */
2510
    public function getBulkUrl(): string
2511
    {
2512
        return $this->bulkUrl;
2513
    }
2514
2515
    /**
2516
     * Set the value of bulkUrl
2517
     */
2518
    public function setBulkUrl(string $bulkUrl): self
2519
    {
2520
        $this->bulkUrl = $bulkUrl;
2521
        return $this;
2522
    }
2523
2524
    /**
2525
     * Get the value of globalSearch
2526
     */
2527
    public function getGlobalSearch(): bool
2528
    {
2529
        return $this->globalSearch;
2530
    }
2531
2532
    /**
2533
     * Set the value of globalSearch
2534
     *
2535
     * @param bool $globalSearch
2536
     */
2537
    public function setGlobalSearch($globalSearch): self
2538
    {
2539
        $this->globalSearch = $globalSearch;
2540
        return $this;
2541
    }
2542
2543
    /**
2544
     * Get the value of wildcardFields
2545
     */
2546
    public function getWildcardFields(): array
2547
    {
2548
        return $this->wildcardFields;
2549
    }
2550
2551
    /**
2552
     * Set the value of wildcardFields
2553
     *
2554
     * @param array $wildcardFields
2555
     */
2556
    public function setWildcardFields($wildcardFields): self
2557
    {
2558
        $this->wildcardFields = $wildcardFields;
2559
        return $this;
2560
    }
2561
2562
    /**
2563
     * Get the value of quickFilters
2564
     */
2565
    public function getQuickFilters(): array
2566
    {
2567
        return $this->quickFilters;
2568
    }
2569
2570
    /**
2571
     * Set the value of quickFilters
2572
     *
2573
     * @param array $quickFilters
2574
     */
2575
    public function setQuickFilters($quickFilters): self
2576
    {
2577
        $this->quickFilters = $quickFilters;
2578
        return $this;
2579
    }
2580
2581
    /**
2582
     * Get the value of groupLayout
2583
     */
2584
    public function getGroupLayout(): bool
2585
    {
2586
        return $this->groupLayout;
2587
    }
2588
2589
    /**
2590
     * Set the value of groupLayout
2591
     *
2592
     * @param bool $groupLayout
2593
     */
2594
    public function setGroupLayout($groupLayout): self
2595
    {
2596
        $this->groupLayout = $groupLayout;
2597
        return $this;
2598
    }
2599
2600
    /**
2601
     * Get the value of enableGridManipulation
2602
     */
2603
    public function getEnableGridManipulation(): bool
2604
    {
2605
        return $this->enableGridManipulation;
2606
    }
2607
2608
    /**
2609
     * Set the value of enableGridManipulation
2610
     *
2611
     * @param bool $enableGridManipulation
2612
     */
2613
    public function setEnableGridManipulation($enableGridManipulation): self
2614
    {
2615
        $this->enableGridManipulation = $enableGridManipulation;
2616
        return $this;
2617
    }
2618
2619
    /**
2620
     * Get the value of defaultFilter
2621
     */
2622
    public function getDefaultFilter(): string
2623
    {
2624
        return $this->defaultFilter;
2625
    }
2626
2627
    /**
2628
     * Set the value of defaultFilter
2629
     *
2630
     * @param string $defaultFilter
2631
     */
2632
    public function setDefaultFilter($defaultFilter): self
2633
    {
2634
        $this->defaultFilter = $defaultFilter;
2635
        return $this;
2636
    }
2637
}
2638