Passed
Push — master ( d7c2d4...a160e4 )
by Thomas
02:43
created

TabulatorGrid::clearAllStates()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 12
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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

474
            $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...
475
        }
476
        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...
477
            $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

477
            $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...
478
        }
479
480
        // - Custom actions are forwarded to the model itself
481
        if ($singl->hasMethod('tabulatorRowActions')) {
482
            $rowActions = $singl->tabulatorRowActions();
483
            if (!is_array($rowActions)) {
484
                throw new RuntimeException("tabulatorRowActions must return an array");
485
            }
486
            foreach ($rowActions as $key => $actionConfig) {
487
                $action = $actionConfig['action'] ?? $key;
488
                $url = $this->TempLink("item/{ID}/customAction/$action", false);
489
                $icon = $actionConfig['icon'] ?? "cog";
490
                $title = $actionConfig['title'] ?? "";
491
492
                $button = $this->makeButton($url, $icon, $title);
493
                if (!empty($actionConfig['ajax'])) {
494
                    $button['formatterParams']['ajax'] = true;
495
                }
496
                $this->addButtonFromArray("ui_customaction_$action", $button);
497
            }
498
        }
499
500
        $this->setRowClickTriggersAction(true);
501
    }
502
503
    public static function requirements(): void
504
    {
505
        $use_cdn = self::config()->use_cdn;
506
        $use_custom_build = self::config()->use_custom_build;
507
        $theme = self::config()->theme; // simple, midnight, modern or framework
508
        $version = self::config()->version;
509
        $luxon_version = self::config()->luxon_version;
510
        $enable_luxon = self::config()->enable_luxon;
511
        $last_icon_version = self::config()->last_icon_version;
512
        $enable_last_icon = self::config()->enable_last_icon;
513
        $enable_js_modules = self::config()->enable_js_modules;
514
515
        $jsOpts = [];
516
        if ($enable_js_modules) {
517
            $jsOpts['type'] = 'module';
518
        }
519
520
        if ($use_cdn) {
521
            $baseDir = "https://cdn.jsdelivr.net/npm/tabulator-tables@$version/dist";
522
        } else {
523
            $asset = ModuleResourceLoader::resourceURL('lekoala/silverstripe-tabulator:client/cdn/js/tabulator.min.js');
524
            $baseDir = dirname(dirname($asset));
525
        }
526
527
        if ($luxon_version && $enable_luxon) {
528
            // Do not load as module or we would get undefined luxon global var
529
            Requirements::javascript("https://cdn.jsdelivr.net/npm/luxon@$luxon_version/build/global/luxon.min.js");
530
        }
531
        if ($last_icon_version && $enable_last_icon) {
532
            Requirements::css("https://cdn.jsdelivr.net/npm/last-icon@$last_icon_version/last-icon.min.css");
533
            // Do not load as module even if asked to ensure load speed
534
            Requirements::javascript("https://cdn.jsdelivr.net/npm/last-icon@$last_icon_version/last-icon.min.js");
535
        }
536
        if ($use_custom_build) {
537
            // if (Director::isDev() && !Director::is_ajax()) {
538
            //     Requirements::javascript("lekoala/silverstripe-tabulator:client/custom-tabulator.js");
539
            // } else {
540
            Requirements::javascript("lekoala/silverstripe-tabulator:client/custom-tabulator.min.js", $jsOpts);
541
            // }
542
            Requirements::javascript('lekoala/silverstripe-tabulator:client/TabulatorField.js', $jsOpts);
543
        } else {
544
            Requirements::javascript("$baseDir/js/tabulator.min.js", $jsOpts);
545
            Requirements::javascript('lekoala/silverstripe-tabulator:client/TabulatorField.js', $jsOpts);
546
        }
547
548
        if ($theme) {
549
            Requirements::css("$baseDir/css/tabulator_$theme.min.css");
550
        } else {
551
            Requirements::css("$baseDir/css/tabulator.min.css");
552
        }
553
554
        if ($theme && $theme == "bootstrap5") {
555
            Requirements::css('lekoala/silverstripe-tabulator:client/custom-tabulator.min.css');
556
        }
557
    }
558
559
    public function setValue($value, $data = null)
560
    {
561
        if ($value instanceof DataList) {
562
            $this->configureFromDataObject($value->dataClass());
563
        }
564
        return parent::setValue($value, $data);
565
    }
566
567
    public function getModularName()
568
    {
569
        return 'SSTabulator.createTabulator';
570
    }
571
572
    public function getModularSelector()
573
    {
574
        return '.tabulatorgrid';
575
    }
576
577
    public function getModularConfigName()
578
    {
579
        return str_replace('-', '_', $this->ID()) . '_config';
580
    }
581
582
    public function getModularConfig()
583
    {
584
        $JsonOptions = $this->JsonOptions();
585
        $configName = $this->getModularConfigName();
586
        $script = "var $configName = $JsonOptions";
587
        return $script;
588
    }
589
590
    public function Field($properties = [])
591
    {
592
        $this->addExtraClass(self::config()->theme);
593
        if ($this->lazyInit) {
594
            $this->setModularLazy($this->lazyInit);
595
        }
596
        if (self::config()->enable_requirements) {
597
            self::requirements();
598
        }
599
600
        // Make sure we can use a standalone version of the field without a form
601
        // Function should match the name
602
        if (!$this->form) {
603
            $this->form = new Form(Controller::curr(), $this->getControllerFunction());
604
        }
605
606
        // Data attributes for our custom behaviour
607
        $this->setDataAttribute("row-click-triggers-action", $this->rowClickTriggersAction);
608
        $customIcons = self::config()->custom_pagination_icons;
609
        $this->setDataAttribute("use-custom-pagination-icons", empty($customIcons));
610
611
        $this->setDataAttribute("listeners", $this->listeners);
612
        if ($this->editUrl) {
613
            $url = $this->processLink($this->editUrl);
614
            $this->setDataAttribute("edit-url", $url);
615
        }
616
        if ($this->moveUrl) {
617
            $url = $this->processLink($this->moveUrl);
618
            $this->setDataAttribute("move-url", $url);
619
        }
620
        if (!empty($this->bulkActions)) {
621
            $url = $this->processLink($this->bulkUrl);
622
            $this->setDataAttribute("bulk-url", $url);
623
        }
624
625
        if ($this->useConfigProvider) {
626
            $configLink = "/" . ltrim($this->Link("configProvider"), "/");
627
            $configLink .= "?t=" . time();
628
            // This cannot be loaded as a js module
629
            Requirements::javascript($configLink, ['type' => 'application/javascript', 'defer' => 'true']);
630
        } elseif ($this->useInitScript) {
631
            Requirements::customScript($this->getInitScript());
0 ignored issues
show
Deprecated Code introduced by
The function LeKoala\Tabulator\TabulatorGrid::getInitScript() has been deprecated. ( Ignorable by Annotation )

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

631
            Requirements::customScript(/** @scrutinizer ignore-deprecated */ $this->getInitScript());
Loading history...
632
        }
633
634
        // Skip modular behaviour
635
        if ($this->useConfigProvider || $this->useInitScript) {
636
            return FormField::Field($properties);
637
        }
638
        return parent::Field($properties);
639
    }
640
641
    public function ShowTools(): string
642
    {
643
        if (empty($this->tools)) {
644
            return '';
645
        }
646
        $html = '';
647
        $html .= '<div class="tabulator-tools">';
648
        $html .= '<div class="tabulator-tools-start">';
649
        foreach ($this->tools as $tool) {
650
            if ($tool['position'] != self::POS_START) {
651
                continue;
652
            }
653
            $html .= ($tool['tool'])->forTemplate();
654
        }
655
        $html .= '</div>';
656
        $html .= '<div class="tabulator-tools-end">';
657
        foreach ($this->tools as $tool) {
658
            if ($tool['position'] != self::POS_END) {
659
                continue;
660
            }
661
            $html .= ($tool['tool'])->forTemplate();
662
        }
663
        // Show bulk actions at the end
664
        if (!empty($this->bulkActions)) {
665
            $selectLabel = _t(__CLASS__ . ".BULKSELECT", "Select a bulk action");
666
            $confirmLabel = _t(__CLASS__ . ".BULKCONFIRM", "Go");
667
            $html .= "<select class=\"tabulator-bulk-select\">";
668
            $html .= "<option>" . $selectLabel . "</option>";
669
            foreach ($this->bulkActions as $bulkAction) {
670
                $v = $bulkAction->getName();
671
                $xhr = $bulkAction->getXhr();
672
                $destructive = $bulkAction->getDestructive();
673
                $html .= "<option value=\"$v\" data-xhr=\"$xhr\" data-destructive=\"$destructive\">" . $bulkAction->getLabel() . "</option>";
674
            }
675
            $html .= "</select>";
676
            $html .= "<button class=\"tabulator-bulk-confirm btn\">" . $confirmLabel . "</button>";
677
        }
678
        $html .= '</div>';
679
        $html .= '</div>';
680
        return $html;
681
    }
682
683
    public function JsonOptions(): string
684
    {
685
        $this->processLinks();
686
687
        $data = $this->list ?? [];
688
        if ($this->autoloadDataList && $data instanceof DataList) {
689
            $data = null;
690
        }
691
        $opts = $this->options;
692
        $opts['columnDefaults'] = $this->columnDefaults;
693
694
        if (empty($this->columns)) {
695
            $opts['autoColumns'] = true;
696
        } else {
697
            $opts['columns'] = array_values($this->columns);
698
        }
699
700
        if ($data && is_iterable($data)) {
701
            if ($data instanceof ArrayList) {
702
                $data = $data->toArray();
703
            } else {
704
                if (is_iterable($data) && !is_array($data)) {
705
                    $data = iterator_to_array($data);
706
                }
707
            }
708
            $opts['data'] = $data;
709
        }
710
711
        // i18n
712
        $locale = strtolower(str_replace('_', '-', i18n::get_locale()));
713
        $paginationTranslations = [
714
            "first" => _t("TabulatorPagination.first", "First"),
715
            "first_title" =>  _t("TabulatorPagination.first_title", "First Page"),
716
            "last" =>  _t("TabulatorPagination.last", "Last"),
717
            "last_title" => _t("TabulatorPagination.last_title", "Last Page"),
718
            "prev" => _t("TabulatorPagination.prev", "Previous"),
719
            "prev_title" =>  _t("TabulatorPagination.prev_title", "Previous Page"),
720
            "next" => _t("TabulatorPagination.next", "Next"),
721
            "next_title" =>  _t("TabulatorPagination.next_title", "Next Page"),
722
            "all" =>  _t("TabulatorPagination.all", "All"),
723
        ];
724
        // This will always default to last icon if present
725
        $customIcons = self::config()->custom_pagination_icons;
726
        if (!empty($customIcons)) {
727
            $paginationTranslations['first'] = $customIcons['first'] ?? "<<";
728
            $paginationTranslations['last'] = $customIcons['last'] ?? ">>";
729
            $paginationTranslations['prev'] = $customIcons['prev'] ?? "<";
730
            $paginationTranslations['next'] = $customIcons['next'] ?? ">";
731
        }
732
        $dataTranslations = [
733
            "loading" => _t("TabulatorData.loading", "Loading"),
734
            "error" => _t("TabulatorData.error", "Error"),
735
        ];
736
        $groupsTranslations = [
737
            "item" => _t("TabulatorGroups.item", "Item"),
738
            "items" => _t("TabulatorGroups.items", "Items"),
739
        ];
740
        $headerFiltersTranslations = [
741
            "default" => _t("TabulatorHeaderFilters.default", "filter column..."),
742
        ];
743
        $bulkActionsTranslations = [
744
            "no_action" => _t("TabulatorBulkActions.no_action", "Please select an action"),
745
            "no_records" => _t("TabulatorBulkActions.no_records", "Please select a record"),
746
            "destructive" => _t("TabulatorBulkActions.destructive", "Confirm destructive action ?"),
747
        ];
748
        $translations = [
749
            'data' => $dataTranslations,
750
            'groups' => $groupsTranslations,
751
            'pagination' => $paginationTranslations,
752
            'headerFilters' => $headerFiltersTranslations,
753
            'bulkActions' => $bulkActionsTranslations,
754
        ];
755
        $opts['locale'] = $locale;
756
        $opts['langs'] = [
757
            $locale => $translations
758
        ];
759
760
        // Apply state
761
        $state = $this->getState();
762
        if (!empty($state['filter'])) {
763
            // @link https://tabulator.info/docs/5.4/filter#initial
764
            // We need to split between global filters and header filters
765
            $allFilters = $state['filter'] ?? [];
766
            $globalFilters = [];
767
            $headerFilters = [];
768
            foreach ($allFilters as $allFilter) {
769
                if (strpos($allFilter['field'], '__') === 0) {
770
                    $globalFilters[] = $allFilter;
771
                } else {
772
                    $headerFilters[] = $allFilter;
773
                }
774
            }
775
            $opts['initialFilter'] = $globalFilters;
776
            $opts['initialHeaderFilter'] = $headerFilters;
777
        }
778
        if (!empty($state['sort'])) {
779
            // @link https://tabulator.info/docs/5.4/sort#initial
780
            $opts['initialSort'] = $state['sort'];
781
        }
782
        $opts['_state'] = $state;
783
784
        $format = Director::isDev() ? JSON_PRETTY_PRINT : 0;
785
        $json = json_encode($opts, $format);
786
787
        // Escape functions by namespace (see TabulatorField.js)
788
        foreach ($this->jsNamespaces as $ns) {
789
            $json = preg_replace('/"(' . $ns . '\.[a-zA-Z]*)"/', "$1", $json);
790
            // Keep static namespaces
791
            $json = str_replace("*" . $ns, $ns, $json);
792
        }
793
794
        return $json;
795
    }
796
797
    /**
798
     * @param Controller $controller
799
     * @return CompatLayerInterface
800
     */
801
    public function getCompatLayer(Controller $controller)
802
    {
803
        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...
804
            return new SilverstripeAdminCompat();
805
        }
806
        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...
807
            return new AdminiCompat();
808
        }
809
    }
810
811
    public function getAttributes()
812
    {
813
        $attrs = parent::getAttributes();
814
        unset($attrs['type']);
815
        unset($attrs['name']);
816
        return $attrs;
817
    }
818
819
    public function getOption(string $k)
820
    {
821
        return $this->options[$k] ?? null;
822
    }
823
824
    public function setOption(string $k, $v): self
825
    {
826
        $this->options[$k] = $v;
827
        return $this;
828
    }
829
830
    public function makeHeadersSticky(): self
831
    {
832
        $this->addExtraClass("tabulator-sticky");
833
        return $this;
834
    }
835
836
    public function setRemoteSource(string $url, array $extraParams = [], bool $dataResponse = false): self
837
    {
838
        $this->setOption("ajaxURL", $url); //set url for ajax request
839
        $params = array_merge([
840
            'SecurityID' => SecurityToken::getSecurityID()
841
        ], $extraParams);
842
        $this->setOption("ajaxParams", $params);
843
        // Accept response where data is nested under the data key
844
        if ($dataResponse) {
845
            $this->setOption("ajaxResponse", self::JS_DATA_AJAX_RESPONSE);
846
        }
847
        return $this;
848
    }
849
850
    /**
851
     * @link http://www.tabulator.info/docs/5.4/page#remote
852
     * @param string $url
853
     * @param array $params
854
     * @param integer $pageSize
855
     * @param integer $initialPage
856
     */
857
    public function setRemotePagination(string $url, array $params = [], int $pageSize = 0, int $initialPage = 1): self
858
    {
859
        $this->setOption("pagination", true); //enable pagination
860
        $this->setOption("paginationMode", 'remote'); //enable remote pagination
861
        $this->setRemoteSource($url, $params);
862
        if (!$pageSize) {
863
            $pageSize = $this->pageSize;
864
        }
865
        $this->setOption("paginationSize", $pageSize);
866
        $this->setOption("paginationInitialPage", $initialPage);
867
        $this->setOption("paginationCounter", 'rows'); // http://www.tabulator.info/docs/5.4/page#counter
868
        return $this;
869
    }
870
871
    public function wizardRemotePagination(int $pageSize = 0, int $initialPage = 1, array $params = []): self
872
    {
873
        $this->setRemotePagination($this->TempLink('load', false), $params, $pageSize, $initialPage);
874
        $this->setOption("sortMode", "remote"); // http://www.tabulator.info/docs/5.4/sort#ajax-sort
875
        $this->setOption("filterMode", "remote"); // http://www.tabulator.info/docs/5.4/filter#ajax-filter
876
        return $this;
877
    }
878
879
    public function setProgressiveLoad(string $url, array $params = [], int $pageSize = 0, int $initialPage = 1, string $mode = 'scroll', int $scrollMargin = 0): self
880
    {
881
        $this->setOption("ajaxURL", $url);
882
        if (!empty($params)) {
883
            $this->setOption("ajaxParams", $params);
884
        }
885
        $this->setOption("progressiveLoad", $mode);
886
        if ($scrollMargin > 0) {
887
            $this->setOption("progressiveLoadScrollMargin", $scrollMargin);
888
        }
889
        if (!$pageSize) {
890
            $pageSize = $this->pageSize;
891
        }
892
        $this->setOption("paginationSize", $pageSize);
893
        $this->setOption("paginationInitialPage", $initialPage);
894
        $this->setOption("paginationCounter", 'rows'); // http://www.tabulator.info/docs/5.4/page#counter
895
        return $this;
896
    }
897
898
    public function wizardProgressiveLoad(int $pageSize = 0, int $initialPage = 1, string $mode = 'scroll', int $scrollMargin = 0, array $extraParams = []): self
899
    {
900
        $params = array_merge([
901
            'SecurityID' => SecurityToken::getSecurityID()
902
        ], $extraParams);
903
        $this->setProgressiveLoad($this->TempLink('load', false), $params, $pageSize, $initialPage, $mode, $scrollMargin);
904
        $this->setOption("sortMode", "remote"); // http://www.tabulator.info/docs/5.4/sort#ajax-sort
905
        $this->setOption("filterMode", "remote"); // http://www.tabulator.info/docs/5.4/filter#ajax-filter
906
        return $this;
907
    }
908
909
    /**
910
     * @link https://tabulator.info/docs/5.4/layout#responsive
911
     * @param boolean $startOpen
912
     * @param string $mode collapse|hide|flexCollapse
913
     * @return self
914
     */
915
    public function wizardResponsiveCollapse(bool $startOpen = false, string $mode = "collapse"): self
916
    {
917
        $this->setOption("responsiveLayout", $mode);
918
        $this->setOption("responsiveLayoutCollapseStartOpen", $startOpen);
919
        if ($mode != "hide") {
920
            $this->columns = array_merge([
921
                'ui_responsive_collapse' => [
922
                    "cssClass" => 'tabulator-cell-btn',
923
                    'formatter' => 'responsiveCollapse',
924
                    'headerSort' => false,
925
                    'width' => 40,
926
                ]
927
            ], $this->columns);
928
        }
929
        return $this;
930
    }
931
932
    public function wizardDataTree(bool $startExpanded = false, bool $filter = false, bool $sort = false, string $el = null): self
933
    {
934
        $this->setOption("dataTree", true);
935
        $this->setOption("dataTreeStartExpanded", $startExpanded);
936
        $this->setOption("dataTreeFilter", $filter);
937
        $this->setOption("dataTreeSort", $sort);
938
        if ($el) {
939
            $this->setOption("dataTreeElementColumn", $el);
940
        }
941
        return $this;
942
    }
943
944
    public function wizardSelectable(array $actions = []): self
945
    {
946
        $this->columns = array_merge([
947
            'ui_selectable' => [
948
                "hozAlign" => 'center',
949
                "cssClass" => 'tabulator-cell-btn tabulator-cell-selector',
950
                'formatter' => 'rowSelection',
951
                'titleFormatter' => 'rowSelection',
952
                'headerSort' => false,
953
                'width' => 40,
954
                'cellClick' => 'SSTabulator.forwardClick',
955
            ]
956
        ], $this->columns);
957
        $this->setBulkActions($actions);
958
        return $this;
959
    }
960
961
    public function wizardMoveable(string $callback = "SSTabulator.rowMoved"): self
962
    {
963
        $this->moveUrl = $this->TempLink("item/{ID}/ajaxMove", false);
964
        $this->setOption("movableRows", true);
965
        $this->addListener("rowMoved", $callback);
966
        $this->columns = array_merge([
967
            'ui_move' => [
968
                "hozAlign" => 'center',
969
                "cssClass" => 'tabulator-cell-btn tabulator-cell-selector',
970
                'rowHandle' => true,
971
                'formatter' => 'handle',
972
                'headerSort' => false,
973
                'frozen' => true,
974
                'width' => 40,
975
            ]
976
        ], $this->columns);
977
        return $this;
978
    }
979
980
    /**
981
     * @param string $field
982
     * @param string $toggleElement arrow|header|false (header by default)
983
     * @param boolean $isBool
984
     * @return void
985
     */
986
    public function wizardGroupBy(string $field, string $toggleElement = 'header', bool $isBool = false)
987
    {
988
        $this->setOption("groupBy", $field);
989
        $this->setOption("groupToggleElement", $toggleElement);
990
        if ($isBool) {
991
            $this->setOption("groupHeader", self::JS_BOOL_GROUP_HEADER);
992
        }
993
    }
994
995
    /**
996
     * @param HTTPRequest $request
997
     * @return HTTPResponse
998
     */
999
    public function handleItem($request)
1000
    {
1001
        // Our getController could either give us a true Controller, if this is the top-level GridField.
1002
        // It could also give us a RequestHandler in the form of (GridFieldDetailForm_ItemRequest, TabulatorGrid...)
1003
        $requestHandler = $this->getForm()->getController();
1004
        $record = $this->getRecordFromRequest($request);
1005
        if (!$record) {
1006
            return $requestHandler->httpError(404, 'That record was not found');
1007
        }
1008
        $handler = $this->getItemRequestHandler($record, $requestHandler);
1009
        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...
1010
    }
1011
1012
    /**
1013
     * @param HTTPRequest $request
1014
     * @return HTTPResponse
1015
     */
1016
    public function handleTool($request)
1017
    {
1018
        // Our getController could either give us a true Controller, if this is the top-level GridField.
1019
        // It could also give us a RequestHandler in the form of (GridFieldDetailForm_ItemRequest, TabulatorGrid...)
1020
        $requestHandler = $this->getForm()->getController();
1021
        $tool = $this->getToolFromRequest($request);
1022
        if (!$tool) {
1023
            return $requestHandler->httpError(404, 'That tool was not found');
1024
        }
1025
        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...
1026
    }
1027
1028
    /**
1029
     * @param HTTPRequest $request
1030
     * @return HTTPResponse
1031
     */
1032
    public function handleBulkAction($request)
1033
    {
1034
        // Our getController could either give us a true Controller, if this is the top-level GridField.
1035
        // It could also give us a RequestHandler in the form of (GridFieldDetailForm_ItemRequest, TabulatorGrid...)
1036
        $requestHandler = $this->getForm()->getController();
1037
        $bulkAction = $this->getBulkActionFromRequest($request);
1038
        if (!$bulkAction) {
1039
            return $requestHandler->httpError(404, 'That bulk action was not found');
1040
        }
1041
        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...
1042
    }
1043
1044
    /**
1045
     * @return string name of {@see TabulatorGrid_ItemRequest} subclass
1046
     */
1047
    public function getItemRequestClass(): string
1048
    {
1049
        if ($this->itemRequestClass) {
1050
            return $this->itemRequestClass;
1051
        } elseif (ClassInfo::exists(static::class . '_ItemRequest')) {
1052
            return static::class . '_ItemRequest';
1053
        }
1054
        return TabulatorGrid_ItemRequest::class;
1055
    }
1056
1057
    /**
1058
     * Build a request handler for the given record
1059
     *
1060
     * @param DataObject $record
1061
     * @param RequestHandler $requestHandler
1062
     * @return TabulatorGrid_ItemRequest
1063
     */
1064
    protected function getItemRequestHandler($record, $requestHandler)
1065
    {
1066
        $class = $this->getItemRequestClass();
1067
        $assignedClass = $this->itemRequestClass;
1068
        $this->extend('updateItemRequestClass', $class, $record, $requestHandler, $assignedClass);
1069
        /** @var TabulatorGrid_ItemRequest $handler */
1070
        $handler = Injector::inst()->createWithArgs(
1071
            $class,
1072
            [$this, $record, $requestHandler]
1073
        );
1074
        if ($template = $this->getTemplate()) {
1075
            $handler->setTemplate($template);
1076
        }
1077
        $this->extend('updateItemRequestHandler', $handler);
1078
        return $handler;
1079
    }
1080
1081
    public function getStateKey()
1082
    {
1083
        $nested = [];
1084
        $form = $this->getForm();
1085
        $scope = "default";
1086
        if ($form) {
0 ignored issues
show
introduced by
$form is of type SilverStripe\Forms\Form, thus it always evaluated to true.
Loading history...
1087
            $controller = $form->getController();
1088
            // We are in a nested form, track by id since each records needs it own state
1089
            while ($controller instanceof TabulatorGrid_ItemRequest) {
1090
                $record = $controller->getRecord();
1091
                $nested[str_replace('_', '\\', get_class($record))] = $record->ID;
1092
1093
                // Move to parent controller
1094
                $controller = $controller->getController();
1095
            }
1096
1097
            // Scope by top controller class
1098
            $scope = str_replace('_', '\\', get_class($controller));
1099
        }
1100
1101
        $prefix = "TabulatorState";
1102
        $name = $this->getName();
1103
        $key = "$prefix.$scope.$name";
1104
        foreach ($nested as $k => $v) {
1105
            $key .= "$k.$v";
1106
        }
1107
        return $key;
1108
    }
1109
1110
    /**
1111
     * @param HTTPRequest|null $request
1112
     * @return array{'page': int, 'limit': int, 'sort': array, 'filter': array}
1113
     */
1114
    public function getState(HTTPRequest $request = null)
1115
    {
1116
        if ($request === null) {
1117
            $request = Controller::curr()->getRequest();
1118
        }
1119
        $stateKey = $this->getStateKey();
1120
        $state = $request->getSession()->get($stateKey);
1121
        return $state ?? [
1122
            'page' => 1,
1123
            'limit' => $this->pageSize,
1124
            'sort' => [],
1125
            'filter' => [],
1126
        ];
1127
    }
1128
1129
    public function setState(HTTPRequest $request, $state)
1130
    {
1131
        $stateKey = $this->getStateKey();
1132
        $request->getSession()->set($stateKey, $state);
1133
        // If we are in a new controller, we can clear other states
1134
        $matches = [];
1135
        preg_match_all('/\.(.*?)\./', $stateKey, $matches);
1136
        $scope = $matches[1][0] ?? null;
1137
        if ($scope) {
1138
            self::clearAllStates($scope);
1139
        }
1140
    }
1141
1142
    public function clearState(HTTPRequest $request)
1143
    {
1144
        $stateKey = $this->getStateKey();
1145
        $request->getSession()->clear($stateKey);
1146
    }
1147
1148
    public static function clearAllStates(string $exceptScope = null)
1149
    {
1150
        $request = Controller::curr()->getRequest();
1151
        $allStates = $request->getSession()->get('TabulatorState');
1152
        if (!$allStates) {
1153
            return;
1154
        }
1155
        foreach ($allStates as $scope => $data) {
1156
            if ($exceptScope && $scope == $exceptScope) {
1157
                continue;
1158
            }
1159
            $request->getSession()->clear("TabulatorState.$scope");
1160
        }
1161
    }
1162
1163
    public function StateValue($key, $field): ?string
1164
    {
1165
        $state = $this->getState();
1166
        $arr = $state[$key] ?? [];
1167
        foreach ($arr as $s) {
1168
            if ($s['field'] === $field) {
1169
                return $s['value'];
1170
            }
1171
        }
1172
        return null;
1173
    }
1174
1175
    /**
1176
     * Deprecated in favor of modular behaviour
1177
     * @deprecated
1178
     * @return string
1179
     */
1180
    public function getInitScript(): string
1181
    {
1182
        $JsonOptions = $this->JsonOptions();
1183
        $ID = $this->ID();
1184
        $script = "SSTabulator.init(\"#$ID\", $JsonOptions);";
1185
        return $script;
1186
    }
1187
1188
    /**
1189
     * Provides the configuration for this instance
1190
     *
1191
     * This is really useful in the context of the admin as it will be served over
1192
     * ajax
1193
     *
1194
     * Deprecated in favor of modular behaviour
1195
     * @deprecated
1196
     * @param HTTPRequest $request
1197
     * @return HTTPResponse
1198
     */
1199
    public function configProvider(HTTPRequest $request)
1200
    {
1201
        if (!$this->useConfigProvider) {
1202
            return $this->httpError(404);
1203
        }
1204
        $response = new HTTPResponse($this->getInitScript());
0 ignored issues
show
Deprecated Code introduced by
The function LeKoala\Tabulator\TabulatorGrid::getInitScript() has been deprecated. ( Ignorable by Annotation )

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

1204
        $response = new HTTPResponse(/** @scrutinizer ignore-deprecated */ $this->getInitScript());
Loading history...
1205
        $response->addHeader('Content-Type', 'application/script');
1206
        return $response;
1207
    }
1208
1209
    /**
1210
     * Provides autocomplete lists
1211
     *
1212
     * @param HTTPRequest $request
1213
     * @return HTTPResponse
1214
     */
1215
    public function autocomplete(HTTPRequest $request)
1216
    {
1217
        if ($this->isDisabled() || $this->isReadonly()) {
1218
            return $this->httpError(403);
1219
        }
1220
        $SecurityID = $request->getVar('SecurityID');
1221
        if (!SecurityToken::inst()->check($SecurityID)) {
1222
            return $this->httpError(404, "Invalid SecurityID");
1223
        }
1224
1225
        $name = $request->getVar("Column");
1226
        $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

1226
        $col = $this->getColumn(/** @scrutinizer ignore-type */ $name);
Loading history...
1227
        if (!$col) {
1228
            return $this->httpError(403, "Invalid column");
1229
        }
1230
1231
        // Don't use % term as it prevents use of indexes
1232
        $term = $request->getVar('term') . '%';
1233
        $term = str_replace(' ', '%', $term);
1234
1235
        $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

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

1473
            throw new RuntimeException("Value is not a ArrayList, it is a: " . get_class(/** @scrutinizer ignore-type */ $this->list));
Loading history...
1474
        }
1475
        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...
1476
    }
1477
1478
    public function hasDataList(): bool
1479
    {
1480
        return $this->list instanceof DataList;
1481
    }
1482
1483
    /**
1484
     * A properly typed on which you can call byID
1485
     * @return ArrayList|DataList
1486
     */
1487
    public function getByIDList()
1488
    {
1489
        return $this->list;
1490
    }
1491
1492
    public function hasByIDList(): bool
1493
    {
1494
        return $this->hasDataList() || $this->hasArrayList();
1495
    }
1496
1497
    public function getDataList(): DataList
1498
    {
1499
        if (!$this->list instanceof DataList) {
1500
            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

1500
            throw new RuntimeException("Value is not a DataList, it is a: " . get_class(/** @scrutinizer ignore-type */ $this->list));
Loading history...
1501
        }
1502
        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...
1503
    }
1504
1505
    public function getManipulatedData(int $limit, int $offset, array $sort = null, array $filter = null): array
1506
    {
1507
        if (!$this->hasDataList()) {
1508
            $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

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