Passed
Push — master ( f4f8d3...c1366b )
by Thomas
25:30 queued 12:09
created

ActionsGridFieldItemRequest::updateItemEditForm()   B

Complexity

Conditions 9
Paths 11

Size

Total Lines 31
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 10
Bugs 5 Features 0
Metric Value
cc 9
eloc 17
c 10
b 5
f 0
nc 11
nop 1
dl 0
loc 31
rs 8.0555
1
<?php
2
3
namespace LeKoala\CmsActions;
4
5
use Exception;
6
use ReflectionMethod;
7
use ReflectionObject;
8
use SilverStripe\Admin\LeftAndMain;
9
use SilverStripe\Admin\ModelAdmin;
10
use SilverStripe\Control\Controller;
11
use SilverStripe\Control\Director;
12
use SilverStripe\Control\HTTPRequest;
13
use SilverStripe\Control\HTTPResponse;
14
use SilverStripe\Control\HTTPResponse_Exception;
15
use SilverStripe\Core\Config\Configurable;
16
use SilverStripe\Core\Extensible;
17
use SilverStripe\Core\Extension;
18
use SilverStripe\Core\Validation\ValidationResult;
19
use SilverStripe\Forms\CompositeField;
20
use SilverStripe\Forms\FieldList;
21
use SilverStripe\Forms\Form;
22
use SilverStripe\Forms\FormAction;
23
use SilverStripe\Forms\FormField;
24
use SilverStripe\Forms\GridField\GridFieldDetailForm_ItemRequest;
25
use SilverStripe\Forms\HiddenField;
26
use SilverStripe\Forms\Tab;
27
use SilverStripe\Forms\TabSet;
28
use SilverStripe\Model\ModelData;
29
use SilverStripe\ORM\DataObject;
30
use SilverStripe\ORM\FieldType\DBHTMLText;
31
use SilverStripe\SiteConfig\SiteConfig;
0 ignored issues
show
Bug introduced by
The type SilverStripe\SiteConfig\SiteConfig 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...
32
use SilverStripe\Versioned\VersionedGridFieldItemRequest;
33
use SilverStripe\Control\RequestHandler;
34
use SilverStripe\View\Requirements;
35
36
/**
37
 * Decorates GridDetailForm_ItemRequest to use new form actions and buttons.
38
 *
39
 * This is also applied to LeftAndMain to allow actions on pages
40
 * Warning: LeftAndMain doesn't call updateItemEditForm
41
 *
42
 * This is a lightweight version of BetterButtons that use default getCMSActions functionnality
43
 * on DataObjects
44
 *
45
 * @link https://github.com/unclecheese/silverstripe-gridfield-betterbuttons
46
 * @link https://github.com/unclecheese/silverstripe-gridfield-betterbuttons/blob/master/src/Extensions/GridFieldBetterButtonsItemRequest.php
47
 * @property LeftAndMain|GridFieldDetailForm_ItemRequest|ActionsGridFieldItemRequest $owner
48
 * @extends Extension<object>
49
 */
50
class ActionsGridFieldItemRequest extends Extension
51
{
52
    use Configurable;
53
    use Extensible;
54
55
    /**
56
     * @config
57
     * @var boolean
58
     */
59
    private static $enable_save_prev_next = true;
60
61
    /**
62
     * @config
63
     * @var boolean
64
     */
65
    private static $enable_save_close = true;
66
67
    /**
68
     * @config
69
     * @var boolean
70
     */
71
    private static $enable_delete_right = true;
72
73
    /**
74
     * @config
75
     * @var boolean
76
     */
77
    private static $enable_utils_prev_next = false;
0 ignored issues
show
introduced by
The private property $enable_utils_prev_next is not used, and could be removed.
Loading history...
78
79
    /**
80
     * @var array<string> Allowed controller actions
81
     */
82
    private static $allowed_actions = [
0 ignored issues
show
introduced by
The private property $allowed_actions is not used, and could be removed.
Loading history...
83
        'doSaveAndClose',
84
        'doSaveAndNext',
85
        'doSaveAndPrev',
86
        'doCustomAction', // For CustomAction
87
        'doCustomLink', // For CustomLink
88
    ];
89
90
    /**
91
     * @param FieldList $actions
92
     * @return array<string>
93
     */
94
    protected function getAvailableActions($actions)
95
    {
96
        $list = [];
97
        foreach ($actions as $action) {
98
            if (is_a($action, CompositeField::class)) {
99
                $list = array_merge($list, $this->getAvailableActions($action->FieldList()));
100
            } else {
101
                $list[] = $action->getName();
102
            }
103
        }
104
        return $list;
105
    }
106
107
    /**
108
     * This module does not interact with the /schema/SearchForm endpoint
109
     * and therefore all requests for these urls don't need any special treatement
110
     *
111
     * @return bool
112
     */
113
    protected function isSearchFormRequest(): bool
114
    {
115
        $curr = Controller::curr();
116
        if ($curr === null) {
117
            return false;
118
        }
119
        return str_contains($curr->getRequest()->getURL(), '/schema/SearchForm');
120
    }
121
122
    /**
123
     * Called by CMSMain, typically in the CMS or in the SiteConfig admin
124
     * CMSMain already uses getCMSActions so we are good to go with anything defined there
125
     *
126
     * @param Form $form
127
     * @return void
128
     */
129
    public function updateEditForm(Form $form)
130
    {
131
        // Ignore search form requests
132
        if ($this->isSearchFormRequest()) {
133
            return;
134
        }
135
136
        $actions = $form->Actions();
137
138
        // We create a Drop-Up menu afterwards because it may already exist in the $CMSActions
139
        // and we don't want to duplicate it
140
        $this->processDropUpMenu($actions);
141
    }
142
143
    /**
144
     * @return FieldList|false
145
     */
146
    public function recordCmsUtils()
147
    {
148
        /** @var VersionedGridFieldItemRequest|LeftAndMain $owner */
149
        $owner = $this->getOwner();
150
151
        // At this stage, the get record could be from a gridfield item request, or from a more general left and main which requires an id
152
        // maybe we could simply do:
153
        // $record = DataObject::singleton($controller->getModelClass());
154
        $reflectionMethod = new ReflectionMethod($owner, 'getRecord');
155
        $record = count($reflectionMethod->getParameters()) > 0 ? $owner->getRecord(0) : $owner->getRecord();
0 ignored issues
show
Unused Code introduced by
The call to SilverStripe\Forms\GridF...temRequest::getRecord() has too many arguments starting with 0. ( Ignorable by Annotation )

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

155
        $record = count($reflectionMethod->getParameters()) > 0 ? $owner->/** @scrutinizer ignore-call */ getRecord(0) : $owner->getRecord();

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...
Bug introduced by
The call to SilverStripe\Admin\LeftAndMain::getRecord() has too few arguments starting with id. ( Ignorable by Annotation )

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

155
        $record = count($reflectionMethod->getParameters()) > 0 ? $owner->getRecord(0) : $owner->/** @scrutinizer ignore-call */ getRecord();

This check compares calls to functions or methods with their respective definitions. If the call has less 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...
156
        if ($record && $record->hasMethod('getCMSUtils')) {
157
            //@phpstan-ignore-next-line
158
            $utils = $record->getCMSUtils();
159
            $this->extend('onCMSUtils', $utils, $record);
160
            $record->extend('onCMSUtils', $utils);
161
            return $utils;
162
        }
163
        return false;
164
    }
165
166
    /**
167
     * @param Form $form
168
     * @return void
169
     */
170
    public function updateItemEditForm($form)
171
    {
172
        /** @var ?DataObject $record */
173
        $record = $this->getOwner()->getRecord();
174
        if (!$record) {
175
            return;
176
        }
177
178
        // Display pending message after a X-Reload
179
        $curr = Controller::curr();
180
        if ($curr && !Director::is_ajax() && $pendingMessage = $curr->getRequest()->getSession()->get('CmsActionsPendingMessage')) {
181
            $curr->getRequest()->getSession()->clear('CmsActionsPendingMessage');
182
            $text = addslashes($pendingMessage['message'] ?? '');
183
            $type = addslashes($pendingMessage['status'] ?? 'good');
184
            Requirements::customScript("jQuery.noticeAdd({text: '$text', type: '$type', stayTime: 5000, inEffect: {left: '0', opacity: 'show'}});");
185
        }
186
187
        // We get the actions as defined on our record
188
        $CMSActions = $this->getCmsActionsFromRecord($record);
189
190
        $FormActions = $form->Actions();
191
192
        // Push our actions that are otherwise ignored by SilverStripe
193
        if ($CMSActions) {
0 ignored issues
show
introduced by
$CMSActions is of type SilverStripe\Forms\FieldList, thus it always evaluated to true.
Loading history...
194
            foreach ($CMSActions as $CMSAction) {
195
                $action = $FormActions->fieldByName($CMSAction->getName());
196
197
                if ($action) {
198
                    // If it has been made readonly, revert
199
                    if ($CMSAction->isReadonly() != $action->isReadonly()) {
200
                        $FormActions->replaceField($action->getName(), $action->setReadonly($CMSAction->isReadonly()));
201
                    }
202
                }
203
            }
204
        }
205
    }
206
207
    /**
208
     * Called by GridField_ItemRequest
209
     * We add our custom save&close, save&next and other tweaks
210
     * Actions can be made readonly after this extension point
211
     * @param FieldList $actions
212
     * @return void
213
     */
214
    public function updateFormActions($actions)
215
    {
216
        // Ignore search form requests
217
        if ($this->isSearchFormRequest()) {
218
            return;
219
        }
220
221
        /** @var DataObject|ModelData|null $record */
222
        $record = $this->getOwner()->getRecord();
223
        if (!$record) {
224
            return;
225
        }
226
227
        // We get the actions as defined on our record
228
        $CMSActions = $this->getCmsActionsFromRecord($record);
229
230
        // The default button group that contains the Save or Create action
231
        // @link https://docs.silverstripe.org/en/4/developer_guides/customising_the_admin_interface/how_tos/extend_cms_interface/#extending-the-cms-actions
232
        $MajorActions = $actions->fieldByName('MajorActions');
233
234
        // If it doesn't exist, push to default group
235
        if (!$MajorActions) {
0 ignored issues
show
introduced by
$MajorActions is of type SilverStripe\Forms\FormField, thus it always evaluated to true.
Loading history...
236
            $MajorActions = $actions;
0 ignored issues
show
Unused Code introduced by
The assignment to $MajorActions is dead and can be removed.
Loading history...
237
        }
238
239
        // Push our actions that are otherwise ignored by SilverStripe
240
        if ($CMSActions) {
241
            foreach ($CMSActions as $action) {
242
                // Avoid duplicated actions (eg: when added by SilverStripe\Versioned\VersionedGridFieldItemRequest)
243
                if ($actions->fieldByName($action->getName())) {
244
                    continue;
245
                }
246
                $actions->push($action);
247
            }
248
        }
249
250
        // We create a Drop-Up menu afterwards because it may already exist in the $CMSActions
251
        // and we don't want to duplicate it
252
        $this->processDropUpMenu($actions);
253
254
        // Add extension hook
255
        $this->extend('onBeforeUpdateCMSActions', $actions, $record);
256
        $record->extend('onBeforeUpdateCMSActions', $actions);
257
258
        $ActionMenus = $actions->fieldByName('ActionMenus');
259
        // Re-insert ActionMenus to make sure they always follow the buttons
260
        if ($ActionMenus) {
261
            $actions->remove($ActionMenus);
262
            $actions->push($ActionMenus);
263
        }
264
265
        // We have a 4.4 setup, before that there was no RightGroup
266
        $RightGroup = $this->getRightGroupActions($actions);
267
268
        // Insert again to make sure our actions are properly placed after apply changes
269
        if ($RightGroup) {
0 ignored issues
show
introduced by
$RightGroup is of type SilverStripe\Forms\CompositeField, thus it always evaluated to true.
Loading history...
270
            $actions->remove($RightGroup);
271
            $actions->push($RightGroup);
272
        }
273
274
        $opts = [
275
            'save_close' => self::config()->enable_save_close,
276
            'save_prev_next' => self::config()->enable_save_prev_next,
277
            'delete_right' => self::config()->enable_delete_right,
278
        ];
279
        if ($record->hasMethod('getCMSActionsOptions')) {
280
            $opts = array_merge($opts, $record->getCMSActionsOptions());
281
        }
282
283
        if ($opts['save_close']) {
284
            $this->addSaveAndClose($actions, $record);
285
        }
286
287
        if ($opts['save_prev_next']) {
288
            $this->addSaveNextAndPrevious($actions, $record);
289
        }
290
291
        if ($opts['delete_right']) {
292
            $this->moveCancelAndDelete($actions, $record);
293
        }
294
295
        // Fix gridstate being lost when running custom actions
296
        if (method_exists($this->getOwner(), 'getStateManager')) {
297
            $request = $this->getOwner()->getRequest();
298
            $stateManager = $this->getOwner()->getStateManager();
299
            $gridField = $this->getOwner()->getGridField();
300
            $state = $stateManager->getStateFromRequest($gridField, $request);
301
            $actions->push(HiddenField::create($stateManager->getStateKey($gridField), null, $state));
302
        }
303
304
        // Add extension hook
305
        $this->extend('onAfterUpdateCMSActions', $actions, $record);
306
        $record->extend('onAfterUpdateCMSActions', $actions);
307
    }
308
309
    /**
310
     * Collect all Drop-Up actions into a menu.
311
     * @param FieldList $actions
312
     * @return void
313
     */
314
    protected function processDropUpMenu($actions)
315
    {
316
        // The Drop-up container may already exist
317
        /** @var ?Tab $dropUpContainer */
318
        $dropUpContainer = $actions->fieldByName('ActionMenus.MoreOptions');
319
        foreach ($actions as $action) {
320
            //@phpstan-ignore-next-line
321
            if ($action->hasMethod('getDropUp') && $action->getDropUp()) {
322
                if (!$dropUpContainer) {
323
                    $dropUpContainer = $this->createDropUpContainer($actions);
324
                }
325
                $action->getContainerFieldList()->removeByName($action->getName());
326
                $dropUpContainer->push($action);
0 ignored issues
show
Bug introduced by
It seems like $action can also be of type SilverStripe\Model\ArrayData; however, parameter $field of SilverStripe\Forms\CompositeField::push() does only seem to accept SilverStripe\Forms\FormField, 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

326
                $dropUpContainer->push(/** @scrutinizer ignore-type */ $action);
Loading history...
327
            }
328
        }
329
    }
330
331
    /**
332
     * Prepares a Drop-Up menu
333
     * @param FieldList $actions
334
     * @return Tab
335
     */
336
    protected function createDropUpContainer($actions)
337
    {
338
        $rootTabSet = TabSet::create('ActionMenus');
339
        $dropUpContainer = Tab::create(
340
            'MoreOptions',
341
            _t(__CLASS__ . '.MoreOptions', 'More options', 'Expands a view for more buttons')
342
        );
343
        $dropUpContainer->addExtraClass('popover-actions-simulate');
344
        $rootTabSet->push($dropUpContainer);
345
        $rootTabSet->addExtraClass('ss-ui-action-tabset action-menus noborder');
346
347
        $actions->insertBefore('RightGroup', $rootTabSet);
348
349
        return $dropUpContainer;
350
    }
351
352
    /**
353
     * Check if a record can be edited/created/exists
354
     * @param ModelData $record
355
     * @param bool $editOnly
356
     * @return bool
357
     */
358
    protected function checkCan($record, $editOnly = false)
359
    {
360
        // For ViewableData, we assume all methods should be implemented
361
        // @link https://docs.silverstripe.org/en/5/developer_guides/forms/using_gridfield_with_arbitrary_data/#custom-edit
362
        if (!method_exists($record, 'canEdit') || !method_exists($record, 'canCreate')) {
363
            return false;
364
        }
365
        //@phpstan-ignore-next-line
366
        if (!$record->ID && ($editOnly || !$record->canCreate())) {
367
            return false;
368
        }
369
        if (!$record->canEdit()) {
370
            return false;
371
        }
372
373
        return true;
374
    }
375
376
    /**
377
     * @param ModelData $record
378
     * @return ?FieldList
379
     */
380
    protected function getCmsActionsFromRecord(ModelData $record)
381
    {
382
        if ($record instanceof DataObject) {
383
            return $record->getCMSActions();
384
        }
385
        if (method_exists($record, 'getCMSActions')) {
386
            return $record->getCMSActions();
387
        }
388
        return null;
389
    }
390
391
    /**
392
     * @param FieldList $actions
393
     * @param ModelData $record
394
     * @return void
395
     */
396
    public function moveCancelAndDelete(FieldList $actions, ModelData $record)
397
    {
398
        // We have a 4.4 setup, before that there was no RightGroup
399
        $RightGroup = $actions->fieldByName('RightGroup');
400
401
        // Move delete at the end
402
        $deleteAction = $actions->fieldByName('action_doDelete');
403
        if ($deleteAction) {
0 ignored issues
show
introduced by
$deleteAction is of type SilverStripe\Forms\FormField, thus it always evaluated to true.
Loading history...
404
            // Move at the end of the stack
405
            $actions->remove($deleteAction);
406
            $actions->push($deleteAction);
407
408
            if (!$RightGroup) {
0 ignored issues
show
introduced by
$RightGroup is of type SilverStripe\Forms\FormField, thus it always evaluated to true.
Loading history...
409
                // Only necessary pre 4.4
410
                $deleteAction->addExtraClass('align-right');
411
            }
412
            // Set custom title
413
            if ($record->hasMethod('getDeleteButtonTitle')) {
414
                //@phpstan-ignore-next-line
415
                $deleteAction->setTitle($record->getDeleteButtonTitle());
416
            }
417
        }
418
        // Move cancel at the end
419
        $cancelButton = $actions->fieldByName('cancelbutton');
420
        if ($cancelButton) {
0 ignored issues
show
introduced by
$cancelButton is of type SilverStripe\Forms\FormField, thus it always evaluated to true.
Loading history...
421
            // Move at the end of the stack
422
            $actions->remove($cancelButton);
423
            $actions->push($cancelButton);
424
            if (!$RightGroup) {
0 ignored issues
show
introduced by
$RightGroup is of type SilverStripe\Forms\FormField, thus it always evaluated to true.
Loading history...
425
                // Only necessary pre 4.4
426
                $cancelButton->addExtraClass('align-right');
427
            }
428
            // Set custom titlte
429
            if ($record->hasMethod('getCancelButtonTitle')) {
430
                //@phpstan-ignore-next-line
431
                $cancelButton->setTitle($record->getCancelButtonTitle());
432
            }
433
        }
434
    }
435
436
    /**
437
     * @param ModelData $record
438
     * @return bool
439
     */
440
    public function useCustomPrevNext(ModelData $record): bool
441
    {
442
        if (self::config()->enable_custom_prevnext) {
443
            return $record->hasMethod('PrevRecord') && $record->hasMethod('NextRecord');
444
        }
445
        return false;
446
    }
447
448
    /**
449
     * @param ModelData $record
450
     * @return int
451
     */
452
    public function getCustomPreviousRecordID(ModelData $record)
453
    {
454
        // This will overwrite state provided record
455
        if ($this->useCustomPrevNext($record)) {
456
            //@phpstan-ignore-next-line
457
            return $record->PrevRecord()->ID ?? 0;
458
        }
459
        return $this->getOwner()->getPreviousRecordID();
460
    }
461
462
    /**
463
     * @param ModelData $record
464
     * @return int
465
     */
466
    public function getCustomNextRecordID(ModelData $record)
467
    {
468
        // This will overwrite state provided record
469
        if ($this->useCustomPrevNext($record)) {
470
            //@phpstan-ignore-next-line
471
            return $record->NextRecord()->ID ?? 0;
472
        }
473
        return $this->getOwner()->getNextRecordID();
474
    }
475
476
    /**
477
     * @param FieldList $actions
478
     * @return CompositeField|FieldList
479
     */
480
    protected function getMajorActions(FieldList $actions)
481
    {
482
        /** @var ?CompositeField $MajorActions */
483
        $MajorActions = $actions->fieldByName('MajorActions');
484
485
        // If it doesn't exist, push to default group
486
        if (!$MajorActions) {
0 ignored issues
show
introduced by
$MajorActions is of type SilverStripe\Forms\CompositeField, thus it always evaluated to true.
Loading history...
487
            $MajorActions = $actions;
488
        }
489
        return $MajorActions;
490
    }
491
492
    /**
493
     * @param FieldList $actions
494
     * @return CompositeField
495
     */
496
    protected function getRightGroupActions(FieldList $actions)
497
    {
498
        /** @var ?CompositeField $RightGroup */
499
        $RightGroup = $actions->fieldByName('RightGroup');
500
        return $RightGroup;
501
    }
502
503
    /**
504
     * @param FieldList $actions
505
     * @param ModelData $record
506
     * @return void
507
     */
508
    public function addSaveNextAndPrevious(FieldList $actions, ModelData $record)
509
    {
510
        if ($this->checkCan($record, true)) {
511
            return;
512
        }
513
514
        $MajorActions = $this->getMajorActions($actions);
515
516
        // @link https://github.com/silverstripe/silverstripe-framework/issues/10742
517
        $getPreviousRecordID = $this->getCustomPreviousRecordID($record);
518
        $getNextRecordID = $this->getCustomNextRecordID($record);
519
        $isCustom = $this->useCustomPrevNext($record);
520
521
        // Coupling for HasPrevNextUtils
522
        if (Controller::curr() instanceof Controller) {
523
            $prevLink = $nextLink = null;
524
            if (!$isCustom && $this->getOwner() instanceof GridFieldDetailForm_ItemRequest) {
525
                if ($getPreviousRecordID) {
526
                    $prevLink = $this->getPublicEditLinkForAdjacentRecord(-1);
527
                }
528
                if ($getNextRecordID) {
529
                    $nextLink = $this->getPublicEditLinkForAdjacentRecord(+1);
530
                }
531
            }
532
533
            /** @var HTTPRequest $request */
534
            $request = Controller::curr()->getRequest();
535
            $routeParams = $request->routeParams();
536
            $recordClass = get_class($record);
537
            $routeParams['cmsactions'][$recordClass]['PreviousRecordID'] = $getPreviousRecordID;
538
            $routeParams['cmsactions'][$recordClass]['NextRecordID'] = $getNextRecordID;
539
            $routeParams['cmsactions'][$recordClass]['PrevRecordLink'] = $prevLink;
540
            $routeParams['cmsactions'][$recordClass]['NextRecordLink'] = $nextLink;
541
            $request->setRouteParams($routeParams);
542
        }
543
544
        if ($getPreviousRecordID) {
545
            $doSaveAndPrev = FormAction::create(
546
                'doSaveAndPrev',
547
                _t('ActionsGridFieldItemRequest.SAVEANDPREVIOUS', 'Save and Previous')
548
            );
549
            $doSaveAndPrev->addExtraClass($this->getBtnClassForRecord($record));
550
            $doSaveAndPrev->addExtraClass('font-icon-angle-double-left btn-mobile-collapse');
551
            $doSaveAndPrev->setUseButtonTag(true);
552
            $MajorActions->push($doSaveAndPrev);
553
        }
554
        if ($getNextRecordID) {
555
            $doSaveAndNext = FormAction::create(
556
                'doSaveAndNext',
557
                _t('ActionsGridFieldItemRequest.SAVEANDNEXT', 'Save and Next')
558
            );
559
            $doSaveAndNext->addExtraClass($this->getBtnClassForRecord($record));
560
            $doSaveAndNext->addExtraClass('font-icon-angle-double-right btn-mobile-collapse');
561
            $doSaveAndNext->setUseButtonTag(true);
562
            $MajorActions->push($doSaveAndNext);
563
        }
564
    }
565
566
    public function getPublicEditLinkForAdjacentRecord(int $offset): ?string
567
    {
568
        $this->getOwner()->getStateManager();
569
        $reflObject = new ReflectionObject($this->getOwner());
570
        $reflMethod = $reflObject->getMethod('getEditLinkForAdjacentRecord');
571
        $reflMethod->setAccessible(true);
572
573
        try {
574
            return $reflMethod->invoke($this->getOwner(), $offset);
575
        } catch (Exception $e) {
576
            return null;
577
        }
578
    }
579
580
    /**
581
     * @param FieldList $actions
582
     * @param ModelData $record
583
     * @return void
584
     */
585
    public function addSaveAndClose(FieldList $actions, ModelData $record)
586
    {
587
        if (!$this->checkCan($record)) {
588
            return;
589
        }
590
591
        $MajorActions = $this->getMajorActions($actions);
592
593
        //@phpstan-ignore-next-line
594
        if ($record->ID) {
595
            $label = _t('ActionsGridFieldItemRequest.SAVEANDCLOSE', 'Save and Close');
596
        } else {
597
            $label = _t('ActionsGridFieldItemRequest.CREATEANDCLOSE', 'Create and Close');
598
        }
599
        $saveAndClose = FormAction::create('doSaveAndClose', $label);
600
        $saveAndClose->addExtraClass($this->getBtnClassForRecord($record));
601
        $saveAndClose->setAttribute('data-text-alternate', $label);
602
603
        if ($record->ID) {
604
            $saveAndClose->setAttribute('data-btn-alternate-add', 'btn-primary');
605
            $saveAndClose->setAttribute('data-btn-alternate-remove', 'btn-outline-primary');
606
        }
607
        $saveAndClose->addExtraClass('font-icon-level-up btn-mobile-collapse');
608
        $saveAndClose->setUseButtonTag(true);
609
        $MajorActions->push($saveAndClose);
610
    }
611
612
    /**
613
     * New and existing records have different classes
614
     *
615
     * @param ModelData $record
616
     * @return string
617
     */
618
    protected function getBtnClassForRecord(ModelData $record)
619
    {
620
        //@phpstan-ignore-next-line
621
        if ($record->ID) {
622
            return 'btn-outline-primary';
623
        }
624
        return 'btn-primary';
625
    }
626
627
    /**
628
     * @param string $action
629
     * @param array<FormField>|FieldList $definedActions
630
     * @return FormField|null
631
     */
632
    protected static function findAction($action, $definedActions)
633
    {
634
        $result = null;
635
636
        foreach ($definedActions as $definedAction) {
637
            if (is_a($definedAction, CompositeField::class)) {
638
                $result = self::findAction($action, $definedAction->FieldList());
639
                if ($result) {
640
                    break;
641
                }
642
            }
643
644
            $definedActionName = $definedAction->getName();
645
646
            if ($definedAction->hasMethod('actionName')) {
647
                //@phpstan-ignore-next-line
648
                $definedActionName = $definedAction->actionName();
649
            }
650
            if ($definedActionName === $action) {
651
                $result = $definedAction;
652
                break;
653
            }
654
        }
655
656
        return $result;
657
    }
658
659
    /**
660
     * Forward a given action to a DataObject
661
     *
662
     * Action must be declared in getCMSActions to be called
663
     *
664
     * @param string $action
665
     * @param array<string,mixed> $data
666
     * @param Form $form
667
     * @return HTTPResponse|DBHTMLText|string
668
     * @throws HTTPResponse_Exception
669
     */
670
    protected function forwardActionToRecord($action, $data = [], $form = null)
671
    {
672
        $controller = $this->getToplevelController();
673
674
        // We have an item request or a controller that can provide a record
675
        $record = null;
676
        if ($this->getOwner()->hasMethod('ItemEditForm')) {
677
            // It's a request handler. Don't check for a specific class as it may be subclassed
678
            //@phpstan-ignore-next-line
679
            $record = $this->getOwner()->record;
680
        } elseif ($controller->hasMethod('save_siteconfig')) {
681
            // Check for any type of siteconfig controller
682
            $record = SiteConfig::current_site_config();
683
        } elseif (!empty($data['ClassName']) && !empty($data['ID'])) {
684
            $record = DataObject::get_by_id($data['ClassName'], $data['ID']);
685
        } elseif ($controller->hasMethod("getRecord")) {
686
            // LeftAndMain requires an id
687
            if ($controller instanceof LeftAndMain && !empty($data['ID'])) {
688
                $record = $controller->getRecord($data['ID']);
689
            } elseif ($controller instanceof ModelAdmin) {
690
                // Otherwise fallback to singleton
691
                $record = DataObject::singleton($controller->getModelClass());
692
            }
693
        }
694
695
        if (!$record) {
696
            throw new Exception("No record to handle the action $action on " . get_class($controller));
697
        }
698
        $CMSActions = $this->getCmsActionsFromRecord($record);
699
700
        // Check if the action is indeed available
701
        $clickedAction = null;
702
        if (!empty($CMSActions)) {
703
            $clickedAction = self::findAction($action, $CMSActions);
704
        }
705
        if (!$clickedAction) {
706
            $class = get_class($record);
707
            $availableActions = null;
708
            if ($CMSActions) {
709
                $availableActions = implode(',', $this->getAvailableActions($CMSActions));
710
            }
711
            if (!$availableActions) {
712
                $availableActions = "(no available actions, please check getCMSActions)";
713
            }
714
715
            return $this->getOwner()->httpError(403, sprintf(
716
                'Action not available on %s. It must be one of : %s',
717
                $class,
718
                $availableActions
719
            ));
720
        }
721
722
        if ($clickedAction->isReadonly() || $clickedAction->isDisabled()) {
723
            return $this->getOwner()->httpError(403, sprintf(
724
                'Action %s is disabled',
725
                $clickedAction->getName(),
726
            ));
727
        }
728
729
        $message = null;
730
        $error = false;
731
732
        // Check record BEFORE the action
733
        // It can be deleted by the action, and it will return to the list
734
        $isNewRecord = isset($record->ID) && $record->ID === 0;
735
736
        $actionTitle = $clickedAction->getName();
737
        if (method_exists($clickedAction, 'getTitle')) {
738
            $actionTitle = $clickedAction->getTitle();
739
        }
740
741
        $recordName = $record instanceof DataObject ? $record->i18n_singular_name() : _t(
742
            'ActionsGridFieldItemRequest.record',
743
            'record'
744
        );
745
746
        try {
747
            $result = $record->$action($data, $form, $controller);
748
749
            // We have a response
750
            if ($result instanceof HTTPResponse) {
751
                return $result;
752
            }
753
754
            if ($result === false) {
755
                // Result returned an error (false)
756
                $error = true;
757
                $message = _t(
758
                    'ActionsGridFieldItemRequest.FAILED',
759
                    'Action {action} failed on {name}',
760
                    [
761
                        'action' => $actionTitle,
762
                        'name' => $recordName,
763
                    ]
764
                );
765
            } elseif (is_string($result)) {
766
                // Result is a message
767
                $message = $result;
768
            }
769
        } catch (Exception $ex) {
770
            $result = null;
771
            $error = true;
772
            $message = $ex->getMessage();
773
        }
774
775
        // Build default message
776
        if (!$message) {
777
            $message = _t(
778
                'ActionsGridFieldItemRequest.DONE',
779
                'Action {action} was done on {name}',
780
                [
781
                    'action' => $actionTitle,
782
                    'name' => $recordName,
783
                ]
784
            );
785
        }
786
        $status = 'good';
787
        if ($error) {
788
            $status = 'bad';
789
        }
790
791
        // Progressive actions return array with json data
792
        if (method_exists($clickedAction, 'getProgressive') && $clickedAction->getProgressive()) {
793
            $response = $controller->getResponse();
794
            $response->addHeader('Content-Type', 'application/json');
795
            if ($result) {
796
                $encodedResult = json_encode($result);
797
                if (!$encodedResult) {
798
                    $encodedResult = json_last_error_msg();
799
                }
800
                $response->setBody($encodedResult);
801
            }
802
803
            return $response;
804
        }
805
806
        // We don't have a form, simply return the result
807
        if (!$form) {
808
            if ($error) {
809
                return $this->getOwner()->httpError(403, $message);
810
            }
811
812
            return $message;
813
        }
814
815
        $shouldRefresh = method_exists($clickedAction, 'getShouldRefresh') && $clickedAction->getShouldRefresh();
816
817
        // When loading using pjax, we can show toasts through X-Status
818
        if (Director::is_ajax()) {
819
            $controller->getResponse()->addHeader('X-Status', rawurlencode($message));
820
            // 4xx status makes a red box
821
            if ($error) {
822
                $controller->getResponse()->setStatusCode(400);
823
            }
824
825
            if ($shouldRefresh) {
826
                self::addXReload($controller);
827
828
                // Store a pending session message to display after reload
829
                $controller->getRequest()->getSession()->set('CmsActionsPendingMessage', [
830
                    'message' => $message,
831
                    'status' => $status,
832
                ]);
833
            }
834
        } else {
835
            // If the controller support sessionMessage, use it instead of form
836
            if ($controller->hasMethod('sessionMessage')) {
837
                //@phpstan-ignore-next-line
838
                $controller->sessionMessage($message, $status, ValidationResult::CAST_HTML);
839
            } else {
840
                $form->sessionMessage($message, $status, ValidationResult::CAST_HTML);
841
            }
842
        }
843
844
        // Custom redirect
845
        /** @var CustomAction $clickedAction */
846
        if (method_exists($clickedAction, 'getRedirectURL') && $clickedAction->getRedirectURL()) {
847
            // we probably need a full ui refresh
848
            self::addXReload($controller, $clickedAction->getRedirectURL());
849
            return $controller->redirect($clickedAction->getRedirectURL());
850
        }
851
852
        // Redirect after action
853
        return $this->redirectAfterAction($isNewRecord, $record);
854
    }
855
856
    /**
857
     * Adds X-Reload headers
858
     *
859
     * @param Controller $controller
860
     * @param string|null $url
861
     * @return bool Returns true if will reload
862
     */
863
    public static function addXReload(Controller $controller, ?string $url = null): bool
864
    {
865
        if (!$url) {
866
            $url = $controller->getReferer();
867
        }
868
        if (!$url) {
869
            $url = $controller->getBackURL();
870
        }
871
        if (!$url) {
872
            return false;
873
        }
874
        // Triggers a full reload. Needs both headers to work
875
        // @link https://github.com/silverstripe/silverstripe-admin/blob/3/client/src/legacy/LeftAndMain.js#L819
876
        $controller->getResponse()->addHeader('X-ControllerURL', $url);
877
        $controller->getResponse()->addHeader('X-Reload', "true");
878
        return true;
879
    }
880
881
    /**
882
     * Handles custom links
883
     *
884
     * Use CustomLink with default behaviour to trigger this
885
     *
886
     * See:
887
     * DefaultLink::getModelLink
888
     * GridFieldCustomLink::getLink
889
     *
890
     * @param HTTPRequest $request
891
     * @return HTTPResponse|DBHTMLText|string
892
     * @throws Exception
893
     */
894
    public function doCustomLink(HTTPRequest $request)
895
    {
896
        $action = $request->getVar('CustomLink');
897
        return $this->forwardActionToRecord($action);
898
    }
899
900
    /**
901
     * Handles custom actions
902
     *
903
     * Use CustomAction class to trigger this
904
     *
905
     * Nested actions are submitted like this
906
     * [action_doCustomAction] => Array
907
     * (
908
     *   [doTestAction] => 1
909
     * )
910
     *
911
     * @param array<string,mixed> $data The form data
912
     * @param Form $form The form object
913
     * @return HTTPResponse|DBHTMLText|string
914
     * @throws Exception
915
     */
916
    public function doCustomAction($data, $form)
917
    {
918
        $action = key($data['action_doCustomAction']);
919
        return $this->forwardActionToRecord($action, $data, $form);
920
    }
921
922
    /**
923
     * Saves the form and goes back to list view
924
     *
925
     * @param array<string,mixed> $data The form data
926
     * @param Form $form The form object
927
     * @return HTTPResponse
928
     */
929
    public function doSaveAndClose($data, $form)
930
    {
931
        $this->getOwner()->doSave($data, $form);
932
        // Redirect after save
933
        $controller = $this->getToplevelController();
934
935
        $link = $this->getBackLink();
936
937
        // Doesn't seem to be needed anymore
938
        // $link = $this->addGridState($link, $data);
939
940
        $controller->getResponse()->addHeader("X-Pjax", "Content");
941
942
        return $controller->redirect($link);
943
    }
944
945
    /**
946
     * @param string $dir prev|next
947
     * @param array<string,mixed> $data The form data
948
     * @param Form|null $form
949
     * @return HTTPResponse
950
     */
951
    protected function doSaveAndAdjacent(string $dir, array $data, ?Form $form)
952
    {
953
        //@phpstan-ignore-next-line
954
        $record = $this->getOwner()->record;
955
        $this->getOwner()->doSave($data, $form);
956
        // Redirect after save
957
        $controller = $this->getToplevelController();
958
        $controller->getResponse()->addHeader("X-Pjax", "Content");
959
960
        if (!($record instanceof DataObject)) {
961
            throw new Exception("Works only with DataObject");
962
        }
963
964
        $class = get_class($record);
965
        if (!$class) {
966
            throw new Exception("Could not get class");
967
        }
968
969
        if (!in_array($dir, ['prev', 'next'])) {
970
            throw new Exception("Invalid dir $dir");
971
        }
972
973
        $method = match ($dir) {
974
            'prev' => 'getCustomPreviousRecordID',
975
            'next' => 'getCustomNextRecordID',
976
        };
977
978
        $offset = match ($dir) {
979
            'prev' => -1,
980
            'next' => +1,
981
        };
982
983
        $adjRecordID = $this->$method($record);
984
985
        /** @var ?DataObject $adj */
986
        $adj = $class::get()->byID($adjRecordID);
987
988
        $useCustom = $this->useCustomPrevNext($record);
989
        $link = $this->getPublicEditLinkForAdjacentRecord($offset);
990
        if (!$link || $useCustom) {
991
            $link = $this->getOwner()->getEditLink($adjRecordID);
992
            $link = $this->addGridState($link, $data);
0 ignored issues
show
Deprecated Code introduced by
The function LeKoala\CmsActions\Actio...Request::addGridState() has been deprecated. ( Ignorable by Annotation )

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

992
            $link = /** @scrutinizer ignore-deprecated */ $this->addGridState($link, $data);
Loading history...
993
        }
994
995
        // Link to a specific tab if set, see cms-actions.js
996
        if ($adj && !empty($data['_activetab'])) {
997
            $link .= sprintf('#%s', $data['_activetab']);
998
        }
999
1000
        return $controller->redirect($link);
1001
    }
1002
1003
    /**
1004
     * Saves the form and goes back to the next item
1005
     *
1006
     * @param array<string,mixed> $data The form data
1007
     * @param Form $form The form object
1008
     * @return HTTPResponse
1009
     */
1010
    public function doSaveAndNext($data, $form)
1011
    {
1012
        return $this->doSaveAndAdjacent('next', $data, $form);
1013
    }
1014
1015
    /**
1016
     * Saves the form and goes to the previous item
1017
     *
1018
     * @param array<string,mixed> $data The form data
1019
     * @param Form $form The form object
1020
     * @return HTTPResponse
1021
     */
1022
    public function doSaveAndPrev($data, $form)
1023
    {
1024
        return $this->doSaveAndAdjacent('prev', $data, $form);
1025
    }
1026
1027
    /**
1028
     * Check if we can remove this safely
1029
     * @param string $url
1030
     * @param array<mixed> $data
1031
     * @return string
1032
     * @deprecated
1033
     */
1034
    protected function addGridState($url, $data)
1035
    {
1036
        // This should not be necessary at all if the state is correctly passed along
1037
        $BackURL = $data['BackURL'] ?? null;
1038
        if ($BackURL) {
1039
            $query = parse_url($BackURL, PHP_URL_QUERY);
1040
            if ($query) {
1041
                $url = strtok($url, '?');
1042
                $url .= '?' . $query;
1043
            }
1044
        }
1045
        return $url;
1046
    }
1047
1048
    /**
1049
     * Gets the top level controller.
1050
     *
1051
     * @return Controller|RequestHandler
1052
     */
1053
    protected function getToplevelController()
1054
    {
1055
        if ($this->isLeftAndMain($this->getOwner())) {
1056
            return $this->getOwner();
1057
        }
1058
        if (!$this->getOwner()->hasMethod("getController")) {
1059
            return Controller::curr();
1060
        }
1061
        $controller = $this->getOwner()->getController();
1062
        while ($controller instanceof GridFieldDetailForm_ItemRequest) {
1063
            $controller = $controller->getController();
1064
        }
1065
1066
        return $controller;
1067
    }
1068
1069
    /**
1070
     * @param Controller $controller
1071
     * @return boolean
1072
     */
1073
    protected function isLeftAndMain($controller)
1074
    {
1075
        return is_subclass_of($controller, LeftAndMain::class);
1076
    }
1077
1078
    /**
1079
     * Gets the back link
1080
     *
1081
     * @return string
1082
     */
1083
    public function getBackLink()
1084
    {
1085
        $backlink = '';
1086
        $toplevelController = $this->getToplevelController();
1087
        // Check for LeftAndMain and alike controllers with a Backlink or Breadcrumbs methods
1088
        if ($toplevelController->hasMethod('Backlink')) {
1089
            //@phpstan-ignore-next-line
1090
            $backlink = $toplevelController->Backlink();
1091
        } elseif ($this->getOwner()->getController()->hasMethod('Breadcrumbs')) {
1092
            //@phpstan-ignore-next-line
1093
            $parents = $this->getOwner()->getController()->Breadcrumbs(false)->items;
1094
            $backlink = array_pop($parents)->Link;
1095
        }
1096
        if (!$backlink) {
1097
            $backlink = $toplevelController->Link();
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $backlink is correct as $toplevelController->Link() targeting SilverStripe\Control\RequestHandler::Link() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
1098
        }
1099
1100
        return $backlink;
1101
    }
1102
1103
    /**
1104
     * Response object for this request after a successful save
1105
     *
1106
     * @param bool $isNewRecord True if this record was just created
1107
     * @param ModelData $record
1108
     * @return HTTPResponse|DBHTMLText|string
1109
     */
1110
    protected function redirectAfterAction($isNewRecord, $record = null)
1111
    {
1112
        $controller = $this->getToplevelController();
1113
1114
        if ($this->isLeftAndMain($controller)) {
1115
            // CMSMain => redirect to show
1116
            if ($this->getOwner()->hasMethod("LinkPageEdit")) {
1117
                //@phpstan-ignore-next-line
1118
                return $controller->redirect($this->getOwner()->LinkPageEdit($record->ID));
1119
            }
1120
        }
1121
1122
        if ($isNewRecord) {
1123
            return $controller->redirect($this->getOwner()->Link());
1124
        }
1125
        //@phpstan-ignore-next-line
1126
        if ($this->getOwner()->gridField && $this->getOwner()->gridField->getList()->byID($this->getOwner()->record->ID)) {
1127
            // Return new view, as we can't do a "virtual redirect" via the CMS Ajax
1128
            // to the same URL (it assumes that its content is already current, and doesn't reload)
1129
            return $this->getOwner()->edit($controller->getRequest());
1130
        }
1131
        // Changes to the record properties might've excluded the record from
1132
        // a filtered list, so return back to the main view if it can't be found
1133
        $noActionURL = $url = $controller->getRequest()->getURL();
1134
        if (!$url) {
1135
            $url = '';
1136
        }
1137
1138
        // The controller may not have these
1139
        if ($controller->hasMethod('getAction')) {
1140
            $action = $controller->getAction();
1141
            // Handle GridField detail form editing
1142
            if (strpos($url, 'ItemEditForm') !== false) {
1143
                $action = 'ItemEditForm';
1144
            }
1145
            if ($action) {
1146
                $noActionURL = $controller->removeAction($url, $action);
1147
            }
1148
        } else {
1149
            // Simple fallback (last index of)
1150
            $pos = strrpos($url, 'ItemEditForm');
1151
            if (is_int($pos)) {
0 ignored issues
show
introduced by
The condition is_int($pos) is always true.
Loading history...
1152
                $noActionURL = substr($url, 0, $pos);
1153
            }
1154
        }
1155
1156
        $controller->getRequest()->addHeader('X-Pjax', 'Content');
1157
        return $controller->redirect($noActionURL, 302);
1158
    }
1159
}
1160