Passed
Push — master ( 98c26c...53215a )
by Thomas
11:06
created

TabulatorGrid::isViewOnly()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

497
            $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...
498
        }
499
        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...
500
            $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

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

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

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

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

1196
        $col = $this->getColumn(/** @scrutinizer ignore-type */ $name);
Loading history...
1197
        if (!$col) {
1198
            return $this->httpError(403, "Invalid column");
1199
        }
1200
1201
        // Don't use % term as it prevents use of indexes
1202
        $term = $request->getVar('term') . '%';
1203
        $term = str_replace(' ', '%', $term);
1204
1205
        $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

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

1449
            throw new RuntimeException("Value is not a ArrayList, it is a: " . get_class(/** @scrutinizer ignore-type */ $this->list));
Loading history...
1450
        }
1451
        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...
1452
    }
1453
1454
    public function hasDataList(): bool
1455
    {
1456
        return $this->list instanceof DataList;
1457
    }
1458
1459
    /**
1460
     * A properly typed on which you can call byID
1461
     * @return ArrayList|DataList
1462
     */
1463
    public function getByIDList()
1464
    {
1465
        return $this->list;
1466
    }
1467
1468
    public function hasByIDList(): bool
1469
    {
1470
        return $this->hasDataList() || $this->hasArrayList();
1471
    }
1472
1473
    public function getDataList(): DataList
1474
    {
1475
        if (!$this->list instanceof DataList) {
1476
            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

1476
            throw new RuntimeException("Value is not a DataList, it is a: " . get_class(/** @scrutinizer ignore-type */ $this->list));
Loading history...
1477
        }
1478
        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...
1479
    }
1480
1481
    public function getManipulatedData(int $limit, int $offset, array $sort = null, array $filter = null): array
1482
    {
1483
        if (!$this->hasDataList()) {
1484
            $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

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