Passed
Push — master ( 84c3e5...337499 )
by Thomas
11:41
created

TabulatorGrid::wizardMoveable()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 17
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

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

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

Loading history...
481
        }
482
        if (class_exists(\LeKoala\ExcelImportExport\ExcelImportExport::class)) {
0 ignored issues
show
Bug introduced by
The type LeKoala\ExcelImportExport\ExcelImportExport was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
483
            $this->addTool(self::POS_END, new TabulatorExportButton($this), 'export');
0 ignored issues
show
Unused Code introduced by
The call to LeKoala\Tabulator\Tabula...rtButton::__construct() has too many arguments starting with $this. ( Ignorable by Annotation )

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

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

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

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

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

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

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

1233
        $col = $this->getColumn(/** @scrutinizer ignore-type */ $name);
Loading history...
1234
        if (!$col) {
1235
            return $this->httpError(403, "Invalid column");
1236
        }
1237
1238
        // Don't use % term as it prevents use of indexes
1239
        $term = $request->getVar('term') . '%';
1240
        $term = str_replace(' ', '%', $term);
1241
1242
        $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

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

1485
            throw new RuntimeException("Value is not a ArrayList, it is a: " . get_class(/** @scrutinizer ignore-type */ $this->list));
Loading history...
1486
        }
1487
        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...
1488
    }
1489
1490
    public function hasDataList(): bool
1491
    {
1492
        return $this->list instanceof DataList;
1493
    }
1494
1495
    /**
1496
     * A properly typed on which you can call byID
1497
     * @return ArrayList|DataList
1498
     */
1499
    public function getByIDList()
1500
    {
1501
        return $this->list;
1502
    }
1503
1504
    public function hasByIDList(): bool
1505
    {
1506
        return $this->hasDataList() || $this->hasArrayList();
1507
    }
1508
1509
    public function getDataList(): DataList
1510
    {
1511
        if (!$this->list instanceof DataList) {
1512
            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

1512
            throw new RuntimeException("Value is not a DataList, it is a: " . get_class(/** @scrutinizer ignore-type */ $this->list));
Loading history...
1513
        }
1514
        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...
1515
    }
1516
1517
    public function getManipulatedData(int $limit, int $offset, array $sort = null, array $filter = null): array
1518
    {
1519
        if (!$this->hasDataList()) {
1520
            $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

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