Issues (48)

src/ActionsGridFieldItemRequest.php (18 issues)

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
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
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
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
    protected string $backlink = '';
91
92
    /**
93
     * @param FieldList $actions
94
     * @return array<string>
95
     */
96
    protected function getAvailableActions($actions)
97
    {
98
        $list = [];
99
        foreach ($actions as $action) {
100
            if (is_a($action, CompositeField::class)) {
101
                $list = array_merge($list, $this->getAvailableActions($action->FieldList()));
102
            } else {
103
                $list[] = $action->getName();
104
            }
105
        }
106
        return $list;
107
    }
108
109
    /**
110
     * This module does not interact with the /schema/SearchForm endpoint
111
     * and therefore all requests for these urls don't need any special treatement
112
     *
113
     * @return bool
114
     */
115
    protected function isSearchFormRequest(): bool
116
    {
117
        $curr = Controller::curr();
118
        if ($curr === null) {
119
            return false;
120
        }
121
        return str_contains($curr->getRequest()->getURL(), '/schema/SearchForm');
122
    }
123
124
    /**
125
     * Called by CMSMain, typically in the CMS or in the SiteConfig admin
126
     * CMSMain already uses getCMSActions so we are good to go with anything defined there
127
     *
128
     * @param Form $form
129
     * @return void
130
     */
131
    public function updateEditForm(Form $form)
132
    {
133
        // Ignore search form requests
134
        if ($this->isSearchFormRequest()) {
135
            return;
136
        }
137
138
        $actions = $form->Actions();
139
140
        // We create a Drop-Up menu afterwards because it may already exist in the $CMSActions
141
        // and we don't want to duplicate it
142
        $this->processDropUpMenu($actions);
143
    }
144
145
    /**
146
     * @return FieldList|false
147
     */
148
    public function recordCmsUtils()
149
    {
150
        /** @var VersionedGridFieldItemRequest|LeftAndMain $owner */
151
        $owner = $this->getOwner();
152
153
        // 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
154
        // maybe we could simply do:
155
        // $record = DataObject::singleton($controller->getModelClass());
156
        $reflectionMethod = new ReflectionMethod($owner, 'getRecord');
157
        $record = count($reflectionMethod->getParameters()) > 0 ? $owner->getRecord(0) : $owner->getRecord();
0 ignored issues
show
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

157
        $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...
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

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

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

1005
            $link = /** @scrutinizer ignore-deprecated */ $this->addGridState($link, $data);
Loading history...
1006
        }
1007
1008
        // Link to a specific tab if set, see cms-actions.js
1009
        if ($adj && !empty($data['_activetab'])) {
1010
            $link .= sprintf('#%s', $data['_activetab']);
1011
        }
1012
1013
        return $controller->redirect($link);
1014
    }
1015
1016
    /**
1017
     * Saves the form and goes back to the next item
1018
     *
1019
     * @param array<string,mixed> $data The form data
1020
     * @param Form $form The form object
1021
     * @return HTTPResponse
1022
     */
1023
    public function doSaveAndNext($data, $form)
1024
    {
1025
        return $this->doSaveAndAdjacent('next', $data, $form);
1026
    }
1027
1028
    /**
1029
     * Saves the form and goes to the previous item
1030
     *
1031
     * @param array<string,mixed> $data The form data
1032
     * @param Form $form The form object
1033
     * @return HTTPResponse
1034
     */
1035
    public function doSaveAndPrev($data, $form)
1036
    {
1037
        return $this->doSaveAndAdjacent('prev', $data, $form);
1038
    }
1039
1040
    /**
1041
     * Check if we can remove this safely
1042
     * @param string $url
1043
     * @param array<mixed> $data
1044
     * @return string
1045
     * @deprecated
1046
     */
1047
    protected function addGridState($url, $data)
1048
    {
1049
        // This should not be necessary at all if the state is correctly passed along
1050
        $BackURL = $data['BackURL'] ?? null;
1051
        if ($BackURL) {
1052
            $query = parse_url($BackURL, PHP_URL_QUERY);
1053
            if ($query) {
1054
                $url = strtok($url, '?');
1055
                $url .= '?' . $query;
1056
            }
1057
        }
1058
        return $url;
1059
    }
1060
1061
    /**
1062
     * Gets the top level controller.
1063
     *
1064
     * @return Controller|RequestHandler
1065
     */
1066
    protected function getToplevelController()
1067
    {
1068
        if ($this->isLeftAndMain($this->getOwner())) {
1069
            return $this->getOwner();
1070
        }
1071
        if (!$this->getOwner()->hasMethod("getController")) {
1072
            return Controller::curr();
1073
        }
1074
        $controller = $this->getOwner()->getController();
1075
        while ($controller instanceof GridFieldDetailForm_ItemRequest) {
1076
            $controller = $controller->getController();
1077
        }
1078
1079
        return $controller;
1080
    }
1081
1082
    /**
1083
     * @param Controller $controller
1084
     * @return boolean
1085
     */
1086
    protected function isLeftAndMain($controller)
1087
    {
1088
        return is_subclass_of($controller, LeftAndMain::class);
1089
    }
1090
1091
    /**
1092
     * Sets the backlink for save and close buttons.
1093
     *
1094
     * @param string $backlink
1095
     * @return $this
1096
     */
1097
    public function setBacklink(string $backlink): self
1098
    {
1099
        $this->backlink = $backlink;
1100
        return $this;
1101
    }
1102
1103
    /**
1104
     * Gets the back link
1105
     *
1106
     * @return string
1107
     */
1108
    public function getBackLink()
1109
    {
1110
        if ($this->backlink) {
1111
            return $this->backlink;
1112
        }
1113
1114
        $backlink = '';
1115
        $toplevelController = $this->getToplevelController();
1116
        // Check for LeftAndMain and alike controllers with a Backlink or Breadcrumbs methods
1117
        if ($toplevelController->hasMethod('Backlink')) {
1118
            //@phpstan-ignore-next-line
1119
            $backlink = $toplevelController->Backlink();
1120
        } elseif ($this->getOwner()->getController()->hasMethod('Breadcrumbs')) {
1121
            //@phpstan-ignore-next-line
1122
            $parents = $this->getOwner()->getController()->Breadcrumbs(false)->items;
1123
            $backlink = array_pop($parents)->Link;
1124
        }
1125
        if (!$backlink) {
1126
            $backlink = $toplevelController->Link();
0 ignored issues
show
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...
1127
        }
1128
1129
        return $backlink;
1130
    }
1131
1132
    /**
1133
     * Response object for this request after a successful save
1134
     *
1135
     * @param bool $isNewRecord True if this record was just created
1136
     * @param ModelData $record
1137
     * @return HTTPResponse|DBHTMLText|string
1138
     */
1139
    protected function redirectAfterAction($isNewRecord, $record = null)
1140
    {
1141
        $controller = $this->getToplevelController();
1142
1143
        if ($this->isLeftAndMain($controller)) {
1144
            // CMSMain => redirect to show
1145
            if ($this->getOwner()->hasMethod("LinkPageEdit")) {
1146
                //@phpstan-ignore-next-line
1147
                return $controller->redirect($this->getOwner()->LinkPageEdit($record->ID));
1148
            }
1149
        }
1150
1151
        if ($isNewRecord) {
1152
            return $controller->redirect($this->getOwner()->Link());
1153
        }
1154
        //@phpstan-ignore-next-line
1155
        if ($this->getOwner()->gridField && $this->getOwner()->gridField->getList()->byID($this->getOwner()->record->ID)) {
1156
            // Return new view, as we can't do a "virtual redirect" via the CMS Ajax
1157
            // to the same URL (it assumes that its content is already current, and doesn't reload)
1158
            return $this->getOwner()->edit($controller->getRequest());
1159
        }
1160
        // Changes to the record properties might've excluded the record from
1161
        // a filtered list, so return back to the main view if it can't be found
1162
        $noActionURL = $url = $controller->getRequest()->getURL();
1163
        if (!$url) {
1164
            $url = '';
1165
        }
1166
1167
        // The controller may not have these
1168
        if ($controller->hasMethod('getAction')) {
1169
            $action = $controller->getAction();
1170
            // Handle GridField detail form editing
1171
            if (strpos($url, 'ItemEditForm') !== false) {
1172
                $action = 'ItemEditForm';
1173
            }
1174
            if ($action) {
1175
                $noActionURL = $controller->removeAction($url, $action);
1176
            }
1177
        } else {
1178
            // Simple fallback (last index of)
1179
            $pos = strrpos($url, 'ItemEditForm');
1180
            if (is_int($pos)) {
0 ignored issues
show
The condition is_int($pos) is always true.
Loading history...
1181
                $noActionURL = substr($url, 0, $pos);
1182
            }
1183
        }
1184
1185
        $controller->getRequest()->addHeader('X-Pjax', 'Content');
1186
        return $controller->redirect($noActionURL, 302);
1187
    }
1188
}
1189