Passed
Push — master ( b54473...98c26c )
by Thomas
02:49
created

TabulatorGrid::addDisplayFields()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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

480
            $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...
481
        }
482
        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...
483
            $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

483
            $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...
484
        }
485
486
        // - Custom actions are forwarded to the model itself
487
        if ($singl->hasMethod('tabulatorRowActions')) {
488
            $rowActions = $singl->tabulatorRowActions();
489
            if (!is_array($rowActions)) {
490
                throw new RuntimeException("tabulatorRowActions must return an array");
491
            }
492
            foreach ($rowActions as $key => $actionConfig) {
493
                $action = $actionConfig['action'] ?? $key;
494
                $url = $this->TempLink("item/{ID}/customAction/$action", false);
495
                $icon = $actionConfig['icon'] ?? "cog";
496
                $title = $actionConfig['title'] ?? "";
497
498
                $button = $this->makeButton($url, $icon, $title);
499
                if (!empty($actionConfig['ajax'])) {
500
                    $button['formatterParams']['ajax'] = true;
501
                }
502
                $this->addButtonFromArray("ui_customaction_$action", $button);
503
            }
504
        }
505
506
        $this->setRowClickTriggersAction(true);
507
    }
508
509
    public static function requirements(): void
510
    {
511
        $load_styles = self::config()->load_styles;
512
        $luxon_version = self::config()->luxon_version;
513
        $enable_luxon = self::config()->enable_luxon;
514
        $last_icon_version = self::config()->last_icon_version;
515
        $enable_last_icon = self::config()->enable_last_icon;
516
        $enable_js_modules = self::config()->enable_js_modules;
517
518
        $jsOpts = [];
519
        if ($enable_js_modules) {
520
            $jsOpts['type'] = 'module';
521
        }
522
523
        if ($luxon_version && $enable_luxon) {
524
            // Do not load as module or we would get undefined luxon global var
525
            Requirements::javascript("https://cdn.jsdelivr.net/npm/luxon@$luxon_version/build/global/luxon.min.js");
526
        }
527
        if ($last_icon_version && $enable_last_icon) {
528
            Requirements::css("https://cdn.jsdelivr.net/npm/last-icon@$last_icon_version/last-icon.min.css");
529
            // Do not load as module even if asked to ensure load speed
530
            Requirements::javascript("https://cdn.jsdelivr.net/npm/last-icon@$last_icon_version/last-icon.min.js");
531
        }
532
533
        Requirements::javascript('lekoala/silverstripe-tabulator:client/TabulatorField.js', $jsOpts);
534
        if ($load_styles) {
535
            Requirements::css('lekoala/silverstripe-tabulator:client/custom-tabulator.css');
536
            Requirements::javascript('lekoala/silverstripe-tabulator:client/tabulator-grid.min.js', $jsOpts);
537
        } else {
538
            // you must load th css yourself based on your preferences
539
            Requirements::javascript('lekoala/silverstripe-tabulator:client/tabulator-grid.raw.min.js', $jsOpts);
540
        }
541
    }
542
543
    public function setValue($value, $data = null)
544
    {
545
        // Allow set raw json as value
546
        if ($value && is_string($value) && strpos($value, '[') === 0) {
547
            $value = json_decode($value);
548
        }
549
        if ($value instanceof DataList) {
550
            $this->configureFromDataObject($value->dataClass());
551
        }
552
        return parent::setValue($value, $data);
553
    }
554
555
    public function Field($properties = [])
556
    {
557
        if (self::config()->enable_requirements) {
558
            self::requirements();
559
        }
560
561
        // Make sure we can use a standalone version of the field without a form
562
        // Function should match the name
563
        if (!$this->form) {
564
            $this->form = new Form(Controller::curr(), $this->getControllerFunction());
565
        }
566
567
        // Data attributes for our custom behaviour
568
        $this->setDataAttribute("row-click-triggers-action", $this->rowClickTriggersAction);
569
570
        $this->setDataAttribute("listeners", $this->listeners);
571
        if ($this->editUrl) {
572
            $url = $this->processLink($this->editUrl);
573
            $this->setDataAttribute("edit-url", $url);
574
        }
575
        if ($this->moveUrl) {
576
            $url = $this->processLink($this->moveUrl);
577
            $this->setDataAttribute("move-url", $url);
578
        }
579
        if (!empty($this->bulkActions)) {
580
            $url = $this->processLink($this->bulkUrl);
581
            $this->setDataAttribute("bulk-url", $url);
582
        }
583
584
        return parent::Field($properties);
585
    }
586
587
    public function ShowTools(): string
588
    {
589
        if (empty($this->tools)) {
590
            return '';
591
        }
592
        $html = '';
593
        $html .= '<div class="tabulator-tools">';
594
        $html .= '<div class="tabulator-tools-start">';
595
        foreach ($this->tools as $tool) {
596
            if ($tool['position'] != self::POS_START) {
597
                continue;
598
            }
599
            $html .= ($tool['tool'])->forTemplate();
600
        }
601
        $html .= '</div>';
602
        $html .= '<div class="tabulator-tools-end">';
603
        foreach ($this->tools as $tool) {
604
            if ($tool['position'] != self::POS_END) {
605
                continue;
606
            }
607
            $html .= ($tool['tool'])->forTemplate();
608
        }
609
        // Show bulk actions at the end
610
        if (!empty($this->bulkActions)) {
611
            $selectLabel = _t(__CLASS__ . ".BULKSELECT", "Select a bulk action");
612
            $confirmLabel = _t(__CLASS__ . ".BULKCONFIRM", "Go");
613
            $html .= "<select class=\"tabulator-bulk-select\">";
614
            $html .= "<option>" . $selectLabel . "</option>";
615
            foreach ($this->bulkActions as $bulkAction) {
616
                $v = $bulkAction->getName();
617
                $xhr = $bulkAction->getXhr();
618
                $destructive = $bulkAction->getDestructive();
619
                $html .= "<option value=\"$v\" data-xhr=\"$xhr\" data-destructive=\"$destructive\">" . $bulkAction->getLabel() . "</option>";
620
            }
621
            $html .= "</select>";
622
            $html .= "<button class=\"tabulator-bulk-confirm btn\">" . $confirmLabel . "</button>";
623
        }
624
        $html .= '</div>';
625
        $html .= '</div>';
626
        return $html;
627
    }
628
629
    public function JsonOptions(): string
630
    {
631
        $this->processLinks();
632
633
        $data = $this->list ?? [];
634
        if ($this->autoloadDataList && $data instanceof DataList) {
635
            $data = null;
636
        }
637
        $opts = $this->options;
638
        $opts['columnDefaults'] = $this->columnDefaults;
639
640
        if (empty($this->columns)) {
641
            $opts['autoColumns'] = true;
642
        } else {
643
            $opts['columns'] = array_values($this->columns);
644
        }
645
646
        if ($data && is_iterable($data)) {
647
            if ($data instanceof ArrayList) {
648
                $data = $data->toArray();
649
            } else {
650
                if (is_iterable($data) && !is_array($data)) {
651
                    $data = iterator_to_array($data);
652
                }
653
            }
654
            $opts['data'] = $data;
655
        }
656
657
        // i18n
658
        $locale = strtolower(str_replace('_', '-', i18n::get_locale()));
659
        $paginationTranslations = [
660
            "first" => _t("TabulatorPagination.first", "First"),
661
            "first_title" =>  _t("TabulatorPagination.first_title", "First Page"),
662
            "last" =>  _t("TabulatorPagination.last", "Last"),
663
            "last_title" => _t("TabulatorPagination.last_title", "Last Page"),
664
            "prev" => _t("TabulatorPagination.prev", "Previous"),
665
            "prev_title" =>  _t("TabulatorPagination.prev_title", "Previous Page"),
666
            "next" => _t("TabulatorPagination.next", "Next"),
667
            "next_title" =>  _t("TabulatorPagination.next_title", "Next Page"),
668
            "all" =>  _t("TabulatorPagination.all", "All"),
669
        ];
670
        $dataTranslations = [
671
            "loading" => _t("TabulatorData.loading", "Loading"),
672
            "error" => _t("TabulatorData.error", "Error"),
673
        ];
674
        $groupsTranslations = [
675
            "item" => _t("TabulatorGroups.item", "Item"),
676
            "items" => _t("TabulatorGroups.items", "Items"),
677
        ];
678
        $headerFiltersTranslations = [
679
            "default" => _t("TabulatorHeaderFilters.default", "filter column..."),
680
        ];
681
        $bulkActionsTranslations = [
682
            "no_action" => _t("TabulatorBulkActions.no_action", "Please select an action"),
683
            "no_records" => _t("TabulatorBulkActions.no_records", "Please select a record"),
684
            "destructive" => _t("TabulatorBulkActions.destructive", "Confirm destructive action ?"),
685
        ];
686
        $translations = [
687
            'data' => $dataTranslations,
688
            'groups' => $groupsTranslations,
689
            'pagination' => $paginationTranslations,
690
            'headerFilters' => $headerFiltersTranslations,
691
            'bulkActions' => $bulkActionsTranslations,
692
        ];
693
        $opts['locale'] = $locale;
694
        $opts['langs'] = [
695
            $locale => $translations
696
        ];
697
698
        // Apply state
699
        // TODO: finalize persistence on the client side instead of this when using TabID
700
        $state = $this->getState();
701
        if ($state) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $state of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
702
            if (!empty($state['filter'])) {
703
                // @link https://tabulator.info/docs/5.5/filter#initial
704
                // We need to split between global filters and header filters
705
                $allFilters = $state['filter'] ?? [];
706
                $globalFilters = [];
707
                $headerFilters = [];
708
                foreach ($allFilters as $allFilter) {
709
                    if (strpos($allFilter['field'], '__') === 0) {
710
                        $globalFilters[] = $allFilter;
711
                    } else {
712
                        $headerFilters[] = $allFilter;
713
                    }
714
                }
715
                $opts['initialFilter'] = $globalFilters;
716
                $opts['initialHeaderFilter'] = $headerFilters;
717
            }
718
            if (!empty($state['sort'])) {
719
                // @link https://tabulator.info/docs/5.5/sort#initial
720
                $opts['initialSort'] = $state['sort'];
721
            }
722
723
            // Restore state from server
724
            $opts['_state'] = $state;
725
        }
726
727
        if ($this->enableGridManipulation) {
728
            // $opts['renderVertical'] = 'basic';
729
        }
730
731
        // Add our extension initCallback
732
        $opts['_initCallback'] = ['__fn' => self::JS_INIT_CALLBACK];
733
        $opts['_configCallback'] = ['__fn' => self::JS_CONFIG_CALLBACK];
734
735
        unset($opts['height']);
736
        $json = json_encode($opts);
737
738
        // Escape '
739
        $json = str_replace("'", '&#39;', $json);
740
741
        return $json;
742
    }
743
744
    /**
745
     * @param Controller $controller
746
     * @return CompatLayerInterface
747
     */
748
    public function getCompatLayer(Controller $controller)
749
    {
750
        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...
751
            return new SilverstripeAdminCompat();
752
        }
753
        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...
754
            return new AdminiCompat();
755
        }
756
    }
757
758
    public function getAttributes()
759
    {
760
        $attrs = parent::getAttributes();
761
        unset($attrs['type']);
762
        unset($attrs['name']);
763
        unset($attrs['value']);
764
        return $attrs;
765
    }
766
767
    public function getOption(string $k)
768
    {
769
        return $this->options[$k] ?? null;
770
    }
771
772
    public function setOption(string $k, $v): self
773
    {
774
        $this->options[$k] = $v;
775
        return $this;
776
    }
777
778
    public function getRowHeight(): int
779
    {
780
        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...
781
    }
782
783
    /**
784
     * Prevent row height automatic computation
785
     * @link https://tabulator.info/docs/5.5/layout#height-row
786
     */
787
    public function setRowHeight(int $v): self
788
    {
789
        $this->setOption('rowHeight', $v);
790
        return $this;
791
    }
792
793
    public function makeHeadersSticky(): self
794
    {
795
        // note: we could also use the "sticky" attribute on the custom element
796
        $this->addExtraClass("tabulator-sticky");
797
        return $this;
798
    }
799
800
    public function setRemoteSource(string $url, array $extraParams = [], bool $dataResponse = false): self
801
    {
802
        $this->setOption("ajaxURL", $url); //set url for ajax request
803
        $params = array_merge([
804
            'SecurityID' => SecurityToken::getSecurityID()
805
        ], $extraParams);
806
        $this->setOption("ajaxParams", $params);
807
        // Accept response where data is nested under the data key
808
        if ($dataResponse) {
809
            $this->setOption("ajaxResponse", ['__fn' => self::JS_DATA_AJAX_RESPONSE]);
810
        }
811
        return $this;
812
    }
813
814
    /**
815
     * @link http://www.tabulator.info/docs/5.5/page#remote
816
     * @param string $url
817
     * @param array $params
818
     * @param integer $pageSize
819
     * @param integer $initialPage
820
     */
821
    public function setRemotePagination(string $url, array $params = [], int $pageSize = 0, int $initialPage = 1): self
822
    {
823
        $this->setOption("pagination", true); //enable pagination
824
        $this->setOption("paginationMode", 'remote'); //enable remote pagination
825
        $this->setRemoteSource($url, $params);
826
        if (!$pageSize) {
827
            $pageSize = $this->pageSize;
828
        } else {
829
            $this->pageSize = $pageSize;
830
        }
831
        $this->setOption("paginationSize", $pageSize);
832
        $this->setOption("paginationInitialPage", $initialPage);
833
        $this->setOption("paginationCounter", 'rows'); // http://www.tabulator.info/docs/5.5/page#counter
834
        return $this;
835
    }
836
837
    public function wizardRemotePagination(int $pageSize = 0, int $initialPage = 1, array $params = []): self
838
    {
839
        $this->setRemotePagination($this->TempLink('load', false), $params, $pageSize, $initialPage);
840
        $this->setOption("sortMode", "remote"); // http://www.tabulator.info/docs/5.5/sort#ajax-sort
841
        $this->setOption("filterMode", "remote"); // http://www.tabulator.info/docs/5.5/filter#ajax-filter
842
        return $this;
843
    }
844
845
    public function setProgressiveLoad(string $url, array $params = [], int $pageSize = 0, int $initialPage = 1, string $mode = 'scroll', int $scrollMargin = 0): self
846
    {
847
        $this->setOption("ajaxURL", $url);
848
        if (!empty($params)) {
849
            $this->setOption("ajaxParams", $params);
850
        }
851
        $this->setOption("progressiveLoad", $mode);
852
        if ($scrollMargin > 0) {
853
            $this->setOption("progressiveLoadScrollMargin", $scrollMargin);
854
        }
855
        if (!$pageSize) {
856
            $pageSize = $this->pageSize;
857
        } else {
858
            $this->pageSize = $pageSize;
859
        }
860
        $this->setOption("paginationSize", $pageSize);
861
        $this->setOption("paginationInitialPage", $initialPage);
862
        $this->setOption("paginationCounter", 'rows'); // http://www.tabulator.info/docs/5.5/page#counter
863
        return $this;
864
    }
865
866
    public function wizardProgressiveLoad(int $pageSize = 0, int $initialPage = 1, string $mode = 'scroll', int $scrollMargin = 0, array $extraParams = []): self
867
    {
868
        $params = array_merge([
869
            'SecurityID' => SecurityToken::getSecurityID()
870
        ], $extraParams);
871
        $this->setProgressiveLoad($this->TempLink('load', false), $params, $pageSize, $initialPage, $mode, $scrollMargin);
872
        $this->setOption("sortMode", "remote"); // http://www.tabulator.info/docs/5.5/sort#ajax-sort
873
        $this->setOption("filterMode", "remote"); // http://www.tabulator.info/docs/5.5/filter#ajax-filter
874
        return $this;
875
    }
876
877
    /**
878
     * @link https://tabulator.info/docs/5.5/layout#responsive
879
     * @param boolean $startOpen
880
     * @param string $mode collapse|hide|flexCollapse
881
     * @return self
882
     */
883
    public function wizardResponsiveCollapse(bool $startOpen = false, string $mode = "collapse"): self
884
    {
885
        $this->setOption("responsiveLayout", $mode);
886
        $this->setOption("responsiveLayoutCollapseStartOpen", $startOpen);
887
        if ($mode != "hide") {
888
            $this->columns = array_merge([
889
                'ui_responsive_collapse' => [
890
                    "cssClass" => 'tabulator-cell-btn',
891
                    'formatter' => 'responsiveCollapse',
892
                    'headerSort' => false,
893
                    'width' => 40,
894
                ]
895
            ], $this->columns);
896
        }
897
        return $this;
898
    }
899
900
    public function wizardDataTree(bool $startExpanded = false, bool $filter = false, bool $sort = false, string $el = null): self
901
    {
902
        $this->setOption("dataTree", true);
903
        $this->setOption("dataTreeStartExpanded", $startExpanded);
904
        $this->setOption("dataTreeFilter", $filter);
905
        $this->setOption("dataTreeSort", $sort);
906
        if ($el) {
907
            $this->setOption("dataTreeElementColumn", $el);
908
        }
909
        return $this;
910
    }
911
912
    public function wizardSelectable(array $actions = []): self
913
    {
914
        $this->columns = array_merge([
915
            'ui_selectable' => [
916
                "hozAlign" => 'center',
917
                "cssClass" => 'tabulator-cell-btn tabulator-cell-selector',
918
                'formatter' => 'rowSelection',
919
                'titleFormatter' => 'rowSelection',
920
                'width' => 40,
921
                'maxWidth' => 40,
922
            ]
923
        ], $this->columns);
924
        $this->setBulkActions($actions);
925
        return $this;
926
    }
927
928
    public function wizardMoveable(string $callback = "SSTabulator.rowMoved", $field = "Sort"): self
929
    {
930
        $this->moveUrl = $this->TempLink("item/{ID}/ajaxMove", false);
931
        $this->setOption("movableRows", true);
932
        $this->addListener("rowMoved", $callback);
933
        $this->columns = array_merge([
934
            'ui_move' => [
935
                "hozAlign" => 'center',
936
                "cssClass" => 'tabulator-cell-btn tabulator-cell-selector tabulator-ui-sort',
937
                'rowHandle' => true,
938
                'formatter' => 'handle',
939
                'headerSort' => false,
940
                'frozen' => true,
941
                'width' => 40,
942
                'maxWidth' => 40,
943
            ],
944
            // We need a hidden sort column
945
            'ui_sort' => [
946
                "field" => $field,
947
                'visible' => false,
948
            ],
949
        ], $this->columns);
950
        return $this;
951
    }
952
953
    /**
954
     * @param string $field
955
     * @param string $toggleElement arrow|header|false (header by default)
956
     * @param boolean $isBool
957
     * @return void
958
     */
959
    public function wizardGroupBy(string $field, string $toggleElement = 'header', bool $isBool = false)
960
    {
961
        $this->setOption("groupBy", $field);
962
        $this->setOption("groupToggleElement", $toggleElement);
963
        if ($isBool) {
964
            $this->setOption("groupHeader", ['_fn' => self::JS_BOOL_GROUP_HEADER]);
965
        }
966
    }
967
968
    /**
969
     * @param HTTPRequest $request
970
     * @return HTTPResponse
971
     */
972
    public function handleItem($request)
973
    {
974
        // Our getController could either give us a true Controller, if this is the top-level GridField.
975
        // It could also give us a RequestHandler in the form of (GridFieldDetailForm_ItemRequest, TabulatorGrid...)
976
        $requestHandler = $this->getForm()->getController();
977
        $record = $this->getRecordFromRequest($request);
978
        if (!$record) {
979
            return $requestHandler->httpError(404, 'That record was not found');
980
        }
981
        $handler = $this->getItemRequestHandler($record, $requestHandler);
982
        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...
983
    }
984
985
    /**
986
     * @param HTTPRequest $request
987
     * @return HTTPResponse
988
     */
989
    public function handleTool($request)
990
    {
991
        // Our getController could either give us a true Controller, if this is the top-level GridField.
992
        // It could also give us a RequestHandler in the form of (GridFieldDetailForm_ItemRequest, TabulatorGrid...)
993
        $requestHandler = $this->getForm()->getController();
994
        $tool = $this->getToolFromRequest($request);
995
        if (!$tool) {
996
            return $requestHandler->httpError(404, 'That tool was not found');
997
        }
998
        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...
999
    }
1000
1001
    /**
1002
     * @param HTTPRequest $request
1003
     * @return HTTPResponse
1004
     */
1005
    public function handleBulkAction($request)
1006
    {
1007
        // Our getController could either give us a true Controller, if this is the top-level GridField.
1008
        // It could also give us a RequestHandler in the form of (GridFieldDetailForm_ItemRequest, TabulatorGrid...)
1009
        $requestHandler = $this->getForm()->getController();
1010
        $bulkAction = $this->getBulkActionFromRequest($request);
1011
        if (!$bulkAction) {
1012
            return $requestHandler->httpError(404, 'That bulk action was not found');
1013
        }
1014
        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...
1015
    }
1016
1017
    /**
1018
     * @return string name of {@see TabulatorGrid_ItemRequest} subclass
1019
     */
1020
    public function getItemRequestClass(): string
1021
    {
1022
        if ($this->itemRequestClass) {
1023
            return $this->itemRequestClass;
1024
        } elseif (ClassInfo::exists(static::class . '_ItemRequest')) {
1025
            return static::class . '_ItemRequest';
1026
        }
1027
        return TabulatorGrid_ItemRequest::class;
1028
    }
1029
1030
    /**
1031
     * Build a request handler for the given record
1032
     *
1033
     * @param DataObject $record
1034
     * @param RequestHandler $requestHandler
1035
     * @return TabulatorGrid_ItemRequest
1036
     */
1037
    protected function getItemRequestHandler($record, $requestHandler)
1038
    {
1039
        $class = $this->getItemRequestClass();
1040
        $assignedClass = $this->itemRequestClass;
1041
        $this->extend('updateItemRequestClass', $class, $record, $requestHandler, $assignedClass);
1042
        /** @var TabulatorGrid_ItemRequest $handler */
1043
        $handler = Injector::inst()->createWithArgs(
1044
            $class,
1045
            [$this, $record, $requestHandler]
1046
        );
1047
        if ($template = $this->getTemplate()) {
1048
            $handler->setTemplate($template);
1049
        }
1050
        $this->extend('updateItemRequestHandler', $handler);
1051
        return $handler;
1052
    }
1053
1054
    public function getStateKey(string $TabID = null)
1055
    {
1056
        $nested = [];
1057
        $form = $this->getForm();
1058
        $scope = $this->modelClass ? str_replace('_', '\\', $this->modelClass) :  "default";
1059
        if ($form) {
0 ignored issues
show
introduced by
$form is of type SilverStripe\Forms\Form, thus it always evaluated to true.
Loading history...
1060
            $controller = $form->getController();
1061
1062
            // We are in a nested form, track by id since each records needs it own state
1063
            while ($controller instanceof TabulatorGrid_ItemRequest) {
1064
                $record = $controller->getRecord();
1065
                $nested[str_replace('_', '\\', get_class($record))] = $record->ID;
1066
1067
                // Move to parent controller
1068
                $controller = $controller->getController();
1069
            }
1070
1071
            // Scope by top controller class
1072
            $scope = str_replace('_', '\\', get_class($controller));
1073
        }
1074
1075
        $baseKey = 'TabulatorState';
1076
        if ($TabID) {
1077
            $baseKey .= '_' . $TabID;
1078
        }
1079
        $name = $this->getName();
1080
        $key = "$baseKey.$scope.$name";
1081
        foreach ($nested as $k => $v) {
1082
            $key .= "$k.$v";
1083
        }
1084
        return $key;
1085
    }
1086
1087
    /**
1088
     * @param HTTPRequest|null $request
1089
     * @return array{'page': int, 'limit': int, 'sort': array, 'filter': array}
1090
     */
1091
    public function getState(HTTPRequest $request = null)
1092
    {
1093
        if ($request === null) {
1094
            $request = Controller::curr()->getRequest();
1095
        }
1096
        $TabID = $request->requestVar('TabID') ?? null;
1097
        $stateKey = $this->getStateKey($TabID);
1098
        $state = $request->getSession()->get($stateKey);
1099
        return $state ?? [
1100
            'page' => 1,
1101
            'limit' => $this->pageSize,
1102
            'sort' => [],
1103
            'filter' => [],
1104
        ];
1105
    }
1106
1107
    public function setState(HTTPRequest $request, $state)
1108
    {
1109
        $TabID = $request->requestVar('TabID') ?? null;
1110
        $stateKey = $this->getStateKey($TabID);
1111
        $request->getSession()->set($stateKey, $state);
1112
        // If we are in a new controller, we can clear other states
1113
        // Note: this would break tabbed navigation if you try to open multiple tabs, see below for more info
1114
        // @link https://github.com/silverstripe/silverstripe-framework/issues/9556
1115
        $matches = [];
1116
        preg_match_all('/\.(.*?)\./', $stateKey, $matches);
1117
        $scope = $matches[1][0] ?? null;
1118
        if ($scope) {
1119
            self::clearAllStates($scope);
1120
        }
1121
    }
1122
1123
    public function clearState(HTTPRequest $request)
1124
    {
1125
        $TabID = $request->requestVar('TabID') ?? null;
1126
        $stateKey = $this->getStateKey($TabID);
1127
        $request->getSession()->clear($stateKey);
1128
    }
1129
1130
    public static function clearAllStates(string $exceptScope = null, string $TabID = null)
1131
    {
1132
        $request = Controller::curr()->getRequest();
1133
        $baseKey = 'TabulatorState';
1134
        if ($TabID) {
1135
            $baseKey .= '_' . $TabID;
1136
        }
1137
        $allStates = $request->getSession()->get($baseKey);
1138
        if (!$allStates) {
1139
            return;
1140
        }
1141
        foreach ($allStates as $scope => $data) {
1142
            if ($exceptScope && $scope == $exceptScope) {
1143
                continue;
1144
            }
1145
            $request->getSession()->clear("TabulatorState.$scope");
1146
        }
1147
    }
1148
1149
    public function StateValue($key, $field): ?string
1150
    {
1151
        $state = $this->getState();
1152
        $arr = $state[$key] ?? [];
1153
        foreach ($arr as $s) {
1154
            if ($s['field'] === $field) {
1155
                return $s['value'];
1156
            }
1157
        }
1158
        return null;
1159
    }
1160
1161
    /**
1162
     * Provides autocomplete lists
1163
     *
1164
     * @param HTTPRequest $request
1165
     * @return HTTPResponse
1166
     */
1167
    public function autocomplete(HTTPRequest $request)
1168
    {
1169
        if ($this->isDisabled() || $this->isReadonly()) {
1170
            return $this->httpError(403);
1171
        }
1172
        $SecurityID = $request->getVar('SecurityID');
1173
        if (!SecurityToken::inst()->check($SecurityID)) {
1174
            return $this->httpError(404, "Invalid SecurityID");
1175
        }
1176
1177
        $name = $request->getVar("Column");
1178
        $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

1178
        $col = $this->getColumn(/** @scrutinizer ignore-type */ $name);
Loading history...
1179
        if (!$col) {
1180
            return $this->httpError(403, "Invalid column");
1181
        }
1182
1183
        // Don't use % term as it prevents use of indexes
1184
        $term = $request->getVar('term') . '%';
1185
        $term = str_replace(' ', '%', $term);
1186
1187
        $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

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

1431
            throw new RuntimeException("Value is not a ArrayList, it is a: " . get_class(/** @scrutinizer ignore-type */ $this->list));
Loading history...
1432
        }
1433
        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...
1434
    }
1435
1436
    public function hasDataList(): bool
1437
    {
1438
        return $this->list instanceof DataList;
1439
    }
1440
1441
    /**
1442
     * A properly typed on which you can call byID
1443
     * @return ArrayList|DataList
1444
     */
1445
    public function getByIDList()
1446
    {
1447
        return $this->list;
1448
    }
1449
1450
    public function hasByIDList(): bool
1451
    {
1452
        return $this->hasDataList() || $this->hasArrayList();
1453
    }
1454
1455
    public function getDataList(): DataList
1456
    {
1457
        if (!$this->list instanceof DataList) {
1458
            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

1458
            throw new RuntimeException("Value is not a DataList, it is a: " . get_class(/** @scrutinizer ignore-type */ $this->list));
Loading history...
1459
        }
1460
        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...
1461
    }
1462
1463
    public function getManipulatedData(int $limit, int $offset, array $sort = null, array $filter = null): array
1464
    {
1465
        if (!$this->hasDataList()) {
1466
            $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

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