Issues (66)

src/ActionsGridFieldItemRequest.php (1 issue)

1
<?php
2
3
namespace LeKoala\CmsActions;
4
5
use Exception;
6
use ReflectionMethod;
7
use SilverStripe\Forms\Tab;
8
use SilverStripe\Forms\Form;
9
use SilverStripe\Forms\TabSet;
10
use SilverStripe\ORM\DataObject;
11
use SilverStripe\Core\Extensible;
12
use SilverStripe\Forms\FieldList;
13
use SilverStripe\Forms\FormField;
14
use SilverStripe\Control\Director;
15
use SilverStripe\Forms\FormAction;
16
use SilverStripe\Admin\LeftAndMain;
17
use SilverStripe\Forms\HiddenField;
18
use SilverStripe\Control\Controller;
19
use SilverStripe\Control\HTTPRequest;
20
use SilverStripe\Control\HTTPResponse;
21
use SilverStripe\Forms\CompositeField;
22
use SilverStripe\ORM\ValidationResult;
23
use SilverStripe\SiteConfig\SiteConfig;
24
use SilverStripe\Core\Config\Configurable;
25
use SilverStripe\ORM\FieldType\DBHTMLText;
26
use SilverStripe\Control\HTTPResponse_Exception;
27
use SilverStripe\Forms\GridField\GridFieldDetailForm_ItemRequest;
28
use ReflectionObject;
29
use SilverStripe\Admin\ModelAdmin;
30
use SilverStripe\Core\Extension;
31
use SilverStripe\View\ViewableData;
32
33
/**
34
 * Decorates GridDetailForm_ItemRequest to use new form actions and buttons.
35
 *
36
 * This is also applied to LeftAndMain to allow actions on pages
37
 * Warning: LeftAndMain doesn't call updateItemEditForm
38
 *
39
 * This is a lightweight version of BetterButtons that use default getCMSActions functionnality
40
 * on DataObjects
41
 *
42
 * @link https://github.com/unclecheese/silverstripe-gridfield-betterbuttons
43
 * @link https://github.com/unclecheese/silverstripe-gridfield-betterbuttons/blob/master/src/Extensions/GridFieldBetterButtonsItemRequest.php
44
 * @property \SilverStripe\Admin\LeftAndMain|\SilverStripe\Forms\GridField\GridFieldDetailForm_ItemRequest|ActionsGridFieldItemRequest $owner
45
 * @extends \SilverStripe\Core\Extension<object>
46
 */
47
class ActionsGridFieldItemRequest extends Extension
48
{
49
    use Configurable;
50
    use Extensible;
51
52
    /**
53
     * @config
54
     * @var boolean
55
     */
56
    private static $enable_save_prev_next = true;
57
58
    /**
59
     * @config
60
     * @var boolean
61
     */
62
    private static $enable_save_close = true;
63
64
    /**
65
     * @config
66
     * @var boolean
67
     */
68
    private static $enable_delete_right = true;
69
70
    /**
71
     * @config
72
     * @var boolean
73
     */
74
    private static $enable_utils_prev_next = false;
75
76
    /**
77
     * @var array<string> Allowed controller actions
78
     */
79
    private static $allowed_actions = [
80
        'doSaveAndClose',
81
        'doSaveAndNext',
82
        'doSaveAndPrev',
83
        'doCustomAction', // For CustomAction
84
        'doCustomLink', // For CustomLink
85
    ];
86
87
    /**
88
     * @param FieldList $actions
89
     * @return array<string>
90
     */
91
    protected function getAvailableActions($actions)
92
    {
93
        $list = [];
94
        foreach ($actions as $action) {
95
            if (is_a($action, CompositeField::class)) {
96
                $list = array_merge($list, $this->getAvailableActions($action->FieldList()));
97
            } else {
98
                $list[] = $action->getName();
99
            }
100
        }
101
        return $list;
102
    }
103
104
    /**
105
     * This module does not interact with the /schema/SearchForm endpoint
106
     * and therefore all requests for these urls don't need any special treatement
107
     *
108
     * @return bool
109
     */
110
    protected function isSearchFormRequest(): bool
111
    {
112
        if (!Controller::has_curr()) {
113
            return false;
114
        }
115
        $curr =  Controller::curr();
116
        if ($curr) {
117
            return str_contains($curr->getRequest()->getURL(), '/schema/SearchForm');
118
        }
119
        return false;
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 \SilverStripe\Versioned\VersionedGridFieldItemRequest|\SilverStripe\Admin\LeftAndMain $owner */
149
        $owner = $this->owner;
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();
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->owner->getRecord();
174
        if (!$record) {
175
            return;
176
        }
177
178
        // We get the actions as defined on our record
179
        $CMSActions = $this->getCmsActionsFromRecord($record);
180
181
        $FormActions = $form->Actions();
182
183
        // Push our actions that are otherwise ignored by SilverStripe
184
        if ($CMSActions) {
185
            foreach ($CMSActions as $CMSAction) {
186
                $action = $FormActions->fieldByName($CMSAction->getName());
187
188
                if ($action) {
189
                    // If it has been made readonly, revert
190
                    if ($CMSAction->isReadonly() != $action->isReadonly()) {
191
                        $FormActions->replaceField($action->getName(), $action->setReadonly($CMSAction->isReadonly()));
192
                    }
193
                }
194
            }
195
        }
196
    }
197
198
    /**
199
     * Called by GridField_ItemRequest
200
     * We add our custom save&close, save&next and other tweaks
201
     * Actions can be made readonly after this extension point
202
     * @param FieldList $actions
203
     * @return void
204
     */
205
    public function updateFormActions($actions)
206
    {
207
        // Ignore search form requests
208
        if ($this->isSearchFormRequest()) {
209
            return;
210
        }
211
212
        /** @var DataObject|ViewableData|null $record */
213
        $record = $this->owner->getRecord();
214
        if (!$record) {
215
            return;
216
        }
217
218
        // We get the actions as defined on our record
219
        $CMSActions = $this->getCmsActionsFromRecord($record);
220
221
        // The default button group that contains the Save or Create action
222
        // @link https://docs.silverstripe.org/en/4/developer_guides/customising_the_admin_interface/how_tos/extend_cms_interface/#extending-the-cms-actions
223
        $MajorActions = $actions->fieldByName('MajorActions');
224
225
        // If it doesn't exist, push to default group
226
        if (!$MajorActions) {
227
            $MajorActions = $actions;
228
        }
229
230
        // Push our actions that are otherwise ignored by SilverStripe
231
        if ($CMSActions) {
232
            foreach ($CMSActions as $action) {
233
                // Avoid duplicated actions (eg: when added by SilverStripe\Versioned\VersionedGridFieldItemRequest)
234
                if ($actions->fieldByName($action->getName())) {
235
                    continue;
236
                }
237
                $actions->push($action);
238
            }
239
        }
240
241
        // We create a Drop-Up menu afterwards because it may already exist in the $CMSActions
242
        // and we don't want to duplicate it
243
        $this->processDropUpMenu($actions);
244
245
        // Add extension hook
246
        $this->extend('onBeforeUpdateCMSActions', $actions, $record);
247
        $record->extend('onBeforeUpdateCMSActions', $actions);
248
249
        $ActionMenus = $actions->fieldByName('ActionMenus');
250
        // Re-insert ActionMenus to make sure they always follow the buttons
251
        if ($ActionMenus) {
252
            $actions->remove($ActionMenus);
253
            $actions->push($ActionMenus);
254
        }
255
256
        // We have a 4.4 setup, before that there was no RightGroup
257
        $RightGroup = $this->getRightGroupActions($actions);
258
259
        // Insert again to make sure our actions are properly placed after apply changes
260
        if ($RightGroup) {
261
            $actions->remove($RightGroup);
262
            $actions->push($RightGroup);
263
        }
264
265
        $opts = [
266
            'save_close'     => self::config()->enable_save_close,
267
            'save_prev_next' => self::config()->enable_save_prev_next,
268
            'delete_right'   => self::config()->enable_delete_right,
269
        ];
270
        if ($record->hasMethod('getCMSActionsOptions')) {
271
            $opts = array_merge($opts, $record->getCMSActionsOptions());
272
        }
273
274
        if ($opts['save_close']) {
275
            $this->addSaveAndClose($actions, $record);
276
        }
277
278
        if ($opts['save_prev_next']) {
279
            $this->addSaveNextAndPrevious($actions, $record);
280
        }
281
282
        if ($opts['delete_right']) {
283
            $this->moveCancelAndDelete($actions, $record);
284
        }
285
286
        // Fix gridstate being lost when running custom actions
287
        if (method_exists($this->owner, 'getStateManager')) {
288
            $request = $this->owner->getRequest();
289
            $stateManager = $this->owner->getStateManager();
290
            $gridField = $this->owner->getGridField();
291
            $state = $stateManager->getStateFromRequest($gridField, $request);
292
            $actions->push(new HiddenField($stateManager->getStateKey($gridField), null, $state));
293
        }
294
295
        // Add extension hook
296
        $this->extend('onAfterUpdateCMSActions', $actions, $record);
297
        $record->extend('onAfterUpdateCMSActions', $actions);
298
    }
299
300
    /**
301
     * Collect all Drop-Up actions into a menu.
302
     * @param FieldList $actions
303
     * @return void
304
     */
305
    protected function processDropUpMenu($actions)
306
    {
307
        // The Drop-up container may already exist
308
        /** @var ?Tab $dropUpContainer */
309
        $dropUpContainer = $actions->fieldByName('ActionMenus.MoreOptions');
310
        foreach ($actions as $action) {
311
            //@phpstan-ignore-next-line
312
            if ($action->hasMethod('getDropUp') && $action->getDropUp()) {
313
                if (!$dropUpContainer) {
314
                    $dropUpContainer = $this->createDropUpContainer($actions);
315
                }
316
                $action->getContainerFieldList()->removeByName($action->getName());
317
                $dropUpContainer->push($action);
0 ignored issues
show
It seems like $action can also be of type SilverStripe\View\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

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