Issues (48)

src/ActionsGridFieldItemRequest.php (11 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;
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
    /**
91
     * @param FieldList $actions
92
     * @return array<string>
93
     */
94
    protected function getAvailableActions($actions)
95
    {
96
        $list = [];
97
        foreach ($actions as $action) {
98
            if (is_a($action, CompositeField::class)) {
99
                $list = array_merge($list, $this->getAvailableActions($action->FieldList()));
100
            } else {
101
                $list[] = $action->getName();
102
            }
103
        }
104
        return $list;
105
    }
106
107
    /**
108
     * This module does not interact with the /schema/SearchForm endpoint
109
     * and therefore all requests for these urls don't need any special treatement
110
     *
111
     * @return bool
112
     */
113
    protected function isSearchFormRequest(): bool
114
    {
115
        $curr = Controller::curr();
116
        if ($curr === null) {
117
            return false;
118
        }
119
        return str_contains($curr->getRequest()->getURL(), '/schema/SearchForm');
120
    }
121
122
    /**
123
     * Called by CMSMain, typically in the CMS or in the SiteConfig admin
124
     * CMSMain already uses getCMSActions so we are good to go with anything defined there
125
     *
126
     * @param Form $form
127
     * @return void
128
     */
129
    public function updateEditForm(Form $form)
130
    {
131
        // Ignore search form requests
132
        if ($this->isSearchFormRequest()) {
133
            return;
134
        }
135
136
        $actions = $form->Actions();
137
138
        // We create a Drop-Up menu afterwards because it may already exist in the $CMSActions
139
        // and we don't want to duplicate it
140
        $this->processDropUpMenu($actions);
141
    }
142
143
    /**
144
     * @return FieldList|false
145
     */
146
    public function recordCmsUtils()
147
    {
148
        /** @var VersionedGridFieldItemRequest|LeftAndMain $owner */
149
        $owner = $this->getOwner();
150
151
        // At this stage, the get record could be from a gridfield item request, or from a more general left and main which requires an id
152
        // maybe we could simply do:
153
        // $record = DataObject::singleton($controller->getModelClass());
154
        $reflectionMethod = new ReflectionMethod($owner, 'getRecord');
155
        $record = count($reflectionMethod->getParameters()) > 0 ? $owner->getRecord(0) : $owner->getRecord();
156
        if ($record && $record->hasMethod('getCMSUtils')) {
157
            //@phpstan-ignore-next-line
158
            $utils = $record->getCMSUtils();
159
            $this->extend('onCMSUtils', $utils, $record);
160
            $record->extend('onCMSUtils', $utils);
161
            return $utils;
162
        }
163
        return false;
164
    }
165
166
    /**
167
     * @param Form $form
168
     * @return void
169
     */
170
    public function updateItemEditForm($form)
171
    {
172
        /** @var ?DataObject $record */
173
        $record = $this->getOwner()->getRecord();
174
        if (!$record) {
175
            return;
176
        }
177
178
        // Display pending message after a X-Reload
179
        $curr = Controller::curr();
180
        if ($curr && !Director::is_ajax() && $pendingMessage = $curr->getRequest()->getSession()->get('CmsActionsPendingMessage')) {
181
            $curr->getRequest()->getSession()->clear('CmsActionsPendingMessage');
182
            $text = addslashes($pendingMessage['message'] ?? '');
183
            $type = addslashes($pendingMessage['status'] ?? 'good');
184
            Requirements::customScript("jQuery.noticeAdd({text: '$text', type: '$type', stayTime: 5000, inEffect: {left: '0', opacity: 'show'}});");
185
        }
186
187
        // We get the actions as defined on our record
188
        $CMSActions = $this->getCmsActionsFromRecord($record);
189
190
        $FormActions = $form->Actions();
191
192
        // Push our actions that are otherwise ignored by SilverStripe
193
        if ($CMSActions) {
0 ignored issues
show
$CMSActions is of type SilverStripe\Forms\FieldList, thus it always evaluated to true.
Loading history...
194
            foreach ($CMSActions as $CMSAction) {
195
                $action = $FormActions->fieldByName($CMSAction->getName());
196
197
                if ($action) {
198
                    // If it has been made readonly, revert
199
                    if ($CMSAction->isReadonly() != $action->isReadonly()) {
200
                        $FormActions->replaceField($action->getName(), $action->setReadonly($CMSAction->isReadonly()));
201
                    }
202
                }
203
            }
204
        }
205
    }
206
207
    /**
208
     * Called by GridField_ItemRequest
209
     * We add our custom save&close, save&next and other tweaks
210
     * Actions can be made readonly after this extension point
211
     * @param FieldList $actions
212
     * @return void
213
     */
214
    public function updateFormActions($actions)
215
    {
216
        // Ignore search form requests
217
        if ($this->isSearchFormRequest()) {
218
            return;
219
        }
220
221
        /** @var DataObject|ModelData|null $record */
222
        $record = $this->getOwner()->getRecord();
223
        if (!$record) {
224
            return;
225
        }
226
227
        // We get the actions as defined on our record
228
        $CMSActions = $this->getCmsActionsFromRecord($record);
229
230
        // The default button group that contains the Save or Create action
231
        // @link https://docs.silverstripe.org/en/4/developer_guides/customising_the_admin_interface/how_tos/extend_cms_interface/#extending-the-cms-actions
232
        $MajorActions = $actions->fieldByName('MajorActions');
233
234
        // If it doesn't exist, push to default group
235
        if (!$MajorActions) {
0 ignored issues
show
$MajorActions is of type SilverStripe\Forms\FormField, thus it always evaluated to true.
Loading history...
236
            $MajorActions = $actions;
237
        }
238
239
        // Push our actions that are otherwise ignored by SilverStripe
240
        if ($CMSActions) {
241
            foreach ($CMSActions as $action) {
242
                // Avoid duplicated actions (eg: when added by SilverStripe\Versioned\VersionedGridFieldItemRequest)
243
                if ($actions->fieldByName($action->getName())) {
244
                    continue;
245
                }
246
                $actions->push($action);
247
            }
248
        }
249
250
        // We create a Drop-Up menu afterwards because it may already exist in the $CMSActions
251
        // and we don't want to duplicate it
252
        $this->processDropUpMenu($actions);
253
254
        // Add extension hook
255
        $this->extend('onBeforeUpdateCMSActions', $actions, $record);
256
        $record->extend('onBeforeUpdateCMSActions', $actions);
257
258
        $ActionMenus = $actions->fieldByName('ActionMenus');
259
        // Re-insert ActionMenus to make sure they always follow the buttons
260
        if ($ActionMenus) {
261
            $actions->remove($ActionMenus);
262
            $actions->push($ActionMenus);
263
        }
264
265
        // We have a 4.4 setup, before that there was no RightGroup
266
        $RightGroup = $this->getRightGroupActions($actions);
267
268
        // Insert again to make sure our actions are properly placed after apply changes
269
        if ($RightGroup) {
0 ignored issues
show
$RightGroup is of type SilverStripe\Forms\CompositeField, thus it always evaluated to true.
Loading history...
270
            $actions->remove($RightGroup);
271
            $actions->push($RightGroup);
272
        }
273
274
        $opts = [
275
            'save_close' => self::config()->enable_save_close,
276
            'save_prev_next' => self::config()->enable_save_prev_next,
277
            'delete_right' => self::config()->enable_delete_right,
278
        ];
279
        if ($record->hasMethod('getCMSActionsOptions')) {
280
            $opts = array_merge($opts, $record->getCMSActionsOptions());
281
        }
282
283
        if ($opts['save_close']) {
284
            $this->addSaveAndClose($actions, $record);
285
        }
286
287
        if ($opts['save_prev_next']) {
288
            $this->addSaveNextAndPrevious($actions, $record);
289
        }
290
291
        if ($opts['delete_right']) {
292
            $this->moveCancelAndDelete($actions, $record);
293
        }
294
295
        // Fix gridstate being lost when running custom actions
296
        if (method_exists($this->getOwner(), 'getStateManager')) {
297
            $request = $this->getOwner()->getRequest();
298
            $stateManager = $this->getOwner()->getStateManager();
299
            $gridField = $this->getOwner()->getGridField();
300
            $state = $stateManager->getStateFromRequest($gridField, $request);
301
            $actions->push(HiddenField::create($stateManager->getStateKey($gridField), null, $state));
302
        }
303
304
        // Add extension hook
305
        $this->extend('onAfterUpdateCMSActions', $actions, $record);
306
        $record->extend('onAfterUpdateCMSActions', $actions);
307
    }
308
309
    /**
310
     * Collect all Drop-Up actions into a menu.
311
     * @param FieldList $actions
312
     * @return void
313
     */
314
    protected function processDropUpMenu($actions)
315
    {
316
        // The Drop-up container may already exist
317
        /** @var ?Tab $dropUpContainer */
318
        $dropUpContainer = $actions->fieldByName('ActionMenus.MoreOptions');
319
        foreach ($actions as $action) {
320
            //@phpstan-ignore-next-line
321
            if ($action->hasMethod('getDropUp') && $action->getDropUp()) {
322
                if (!$dropUpContainer) {
323
                    $dropUpContainer = $this->createDropUpContainer($actions);
324
                }
325
                $action->getContainerFieldList()->removeByName($action->getName());
326
                $dropUpContainer->push($action);
327
            }
328
        }
329
    }
330
331
    /**
332
     * Prepares a Drop-Up menu
333
     * @param FieldList $actions
334
     * @return Tab
335
     */
336
    protected function createDropUpContainer($actions)
337
    {
338
        $rootTabSet = TabSet::create('ActionMenus');
339
        $dropUpContainer = Tab::create(
340
            'MoreOptions',
341
            _t(__CLASS__ . '.MoreOptions', 'More options', 'Expands a view for more buttons')
342
        );
343
        $dropUpContainer->addExtraClass('popover-actions-simulate');
344
        $rootTabSet->push($dropUpContainer);
345
        $rootTabSet->addExtraClass('ss-ui-action-tabset action-menus noborder');
346
347
        $actions->insertBefore('RightGroup', $rootTabSet);
348
349
        return $dropUpContainer;
350
    }
351
352
    /**
353
     * Check if a record can be edited/created/exists
354
     * @param ModelData $record
355
     * @param bool $editOnly
356
     * @return bool
357
     */
358
    protected function checkCan($record, $editOnly = false)
359
    {
360
        // For ViewableData, we assume all methods should be implemented
361
        // @link https://docs.silverstripe.org/en/5/developer_guides/forms/using_gridfield_with_arbitrary_data/#custom-edit
362
        if (!method_exists($record, 'canEdit') || !method_exists($record, 'canCreate')) {
363
            return false;
364
        }
365
        //@phpstan-ignore-next-line
366
        if (!$record->ID && ($editOnly || !$record->canCreate())) {
367
            return false;
368
        }
369
        if (!$record->canEdit()) {
370
            return false;
371
        }
372
373
        return true;
374
    }
375
376
    /**
377
     * @param ModelData $record
378
     * @return ?FieldList
379
     */
380
    protected function getCmsActionsFromRecord(ModelData $record)
381
    {
382
        if ($record instanceof DataObject) {
383
            return $record->getCMSActions();
384
        }
385
        if (method_exists($record, 'getCMSActions')) {
386
            return $record->getCMSActions();
387
        }
388
        return null;
389
    }
390
391
    /**
392
     * @param FieldList $actions
393
     * @param ModelData $record
394
     * @return void
395
     */
396
    public function moveCancelAndDelete(FieldList $actions, ModelData $record)
397
    {
398
        // We have a 4.4 setup, before that there was no RightGroup
399
        $RightGroup = $actions->fieldByName('RightGroup');
400
401
        // Move delete at the end
402
        $deleteAction = $actions->fieldByName('action_doDelete');
403
        if ($deleteAction) {
0 ignored issues
show
$deleteAction is of type SilverStripe\Forms\FormField, thus it always evaluated to true.
Loading history...
404
            // Move at the end of the stack
405
            $actions->remove($deleteAction);
406
            $actions->push($deleteAction);
407
408
            $deleteAction->addExtraClass('btn-group-spacer');
409
            if (!$RightGroup) {
0 ignored issues
show
$RightGroup is of type SilverStripe\Forms\FormField, thus it always evaluated to true.
Loading history...
410
                // Only necessary pre 4.4
411
                $deleteAction->addExtraClass('align-right');
412
            }
413
            // Set custom title
414
            if ($record->hasMethod('getDeleteButtonTitle')) {
415
                //@phpstan-ignore-next-line
416
                $deleteAction->setTitle($record->getDeleteButtonTitle());
417
            }
418
        } else {
419
            // Add disabled delete action to avoid clicking by mistake on delete
420
            // if it was not there when navigating
421
            $actions->push($fakeDelete = new FormAction('doDelete', _t('SilverStripe\\Forms\\GridField\\GridFieldDetailForm.Delete', 'Delete')));
422
            $fakeDelete
423
                ->setUseButtonTag(true)
424
                ->addExtraClass('btn-group-spacer btn-hide-outline font-icon-trash-bin')
425
                ->setDisabled(true);
426
        }
427
        // Move cancel at the end
428
        $cancelButton = $actions->fieldByName('cancelbutton');
429
        if ($cancelButton) {
0 ignored issues
show
$cancelButton is of type SilverStripe\Forms\FormField, thus it always evaluated to true.
Loading history...
430
            // Move at the end of the stack
431
            $actions->remove($cancelButton);
432
            $actions->push($cancelButton);
433
434
            $cancelButton->addExtraClass('btn-group-spacer');
435
            if (!$RightGroup) {
0 ignored issues
show
$RightGroup is of type SilverStripe\Forms\FormField, thus it always evaluated to true.
Loading history...
436
                // Only necessary pre 4.4
437
                $cancelButton->addExtraClass('align-right');
438
            }
439
            // Set custom titlte
440
            if ($record->hasMethod('getCancelButtonTitle')) {
441
                //@phpstan-ignore-next-line
442
                $cancelButton->setTitle($record->getCancelButtonTitle());
443
            }
444
        }
445
    }
446
447
    /**
448
     * @param ModelData $record
449
     * @return bool
450
     */
451
    public function useCustomPrevNext(ModelData $record): bool
452
    {
453
        if (self::config()->enable_custom_prevnext) {
454
            return $record->hasMethod('PrevRecord') && $record->hasMethod('NextRecord');
455
        }
456
        return false;
457
    }
458
459
    /**
460
     * @param ModelData $record
461
     * @return int
462
     */
463
    public function getCustomPreviousRecordID(ModelData $record)
464
    {
465
        // This will overwrite state provided record
466
        if ($this->useCustomPrevNext($record)) {
467
            //@phpstan-ignore-next-line
468
            return $record->PrevRecord()->ID ?? 0;
469
        }
470
        return $this->getOwner()->getPreviousRecordID();
471
    }
472
473
    /**
474
     * @param ModelData $record
475
     * @return int
476
     */
477
    public function getCustomNextRecordID(ModelData $record)
478
    {
479
        // This will overwrite state provided record
480
        if ($this->useCustomPrevNext($record)) {
481
            //@phpstan-ignore-next-line
482
            return $record->NextRecord()->ID ?? 0;
483
        }
484
        return $this->getOwner()->getNextRecordID();
485
    }
486
487
    /**
488
     * @param FieldList $actions
489
     * @return CompositeField|FieldList
490
     */
491
    protected function getMajorActions(FieldList $actions)
492
    {
493
        /** @var ?CompositeField $MajorActions */
494
        $MajorActions = $actions->fieldByName('MajorActions');
495
496
        // If it doesn't exist, push to default group
497
        if (!$MajorActions) {
0 ignored issues
show
$MajorActions is of type SilverStripe\Forms\CompositeField, thus it always evaluated to true.
Loading history...
498
            $MajorActions = $actions;
499
        }
500
        return $MajorActions;
501
    }
502
503
    /**
504
     * @param FieldList $actions
505
     * @return CompositeField
506
     */
507
    protected function getRightGroupActions(FieldList $actions)
508
    {
509
        /** @var ?CompositeField $RightGroup */
510
        $RightGroup = $actions->fieldByName('RightGroup');
511
        return $RightGroup;
512
    }
513
514
    /**
515
     * @param FieldList $actions
516
     * @param ModelData $record
517
     * @return void
518
     */
519
    public function addSaveNextAndPrevious(FieldList $actions, ModelData $record)
520
    {
521
        if (!$this->checkCan($record, true)) {
522
            return;
523
        }
524
525
        $MajorActions = $this->getMajorActions($actions);
526
527
        // @link https://github.com/silverstripe/silverstripe-framework/issues/10742
528
        $getPreviousRecordID = $this->getCustomPreviousRecordID($record);
529
        $getNextRecordID = $this->getCustomNextRecordID($record);
530
        $isCustom = $this->useCustomPrevNext($record);
531
532
        // Coupling for HasPrevNextUtils
533
        if (Controller::curr() instanceof Controller) {
534
            $prevLink = $nextLink = null;
535
            if (!$isCustom && $this->getOwner() instanceof GridFieldDetailForm_ItemRequest) {
536
                if ($getPreviousRecordID) {
537
                    $prevLink = $this->getPublicEditLinkForAdjacentRecord(-1);
538
                }
539
                if ($getNextRecordID) {
540
                    $nextLink = $this->getPublicEditLinkForAdjacentRecord(+1);
541
                }
542
            }
543
544
            /** @var HTTPRequest $request */
545
            $request = Controller::curr()->getRequest();
546
            $routeParams = $request->routeParams();
547
            $recordClass = get_class($record);
548
            $routeParams['cmsactions'][$recordClass]['PreviousRecordID'] = $getPreviousRecordID;
549
            $routeParams['cmsactions'][$recordClass]['NextRecordID'] = $getNextRecordID;
550
            $routeParams['cmsactions'][$recordClass]['PrevRecordLink'] = $prevLink;
551
            $routeParams['cmsactions'][$recordClass]['NextRecordLink'] = $nextLink;
552
            $request->setRouteParams($routeParams);
553
        }
554
555
        if ($getPreviousRecordID) {
556
            $doSaveAndPrev = FormAction::create(
557
                'doSaveAndPrev',
558
                _t('ActionsGridFieldItemRequest.SAVEANDPREVIOUS', 'Save and Previous')
559
            );
560
            $doSaveAndPrev->addExtraClass($this->getBtnClassForRecord($record));
561
            $doSaveAndPrev->addExtraClass('font-icon-angle-double-left btn-mobile-collapse');
562
            $doSaveAndPrev->setUseButtonTag(true);
563
            $MajorActions->push($doSaveAndPrev);
564
        }
565
        if ($getNextRecordID) {
566
            $doSaveAndNext = FormAction::create(
567
                'doSaveAndNext',
568
                _t('ActionsGridFieldItemRequest.SAVEANDNEXT', 'Save and Next')
569
            );
570
            $doSaveAndNext->addExtraClass($this->getBtnClassForRecord($record));
571
            $doSaveAndNext->addExtraClass('font-icon-angle-double-right btn-mobile-collapse');
572
            $doSaveAndNext->setUseButtonTag(true);
573
            $MajorActions->push($doSaveAndNext);
574
        }
575
    }
576
577
    public function getPublicEditLinkForAdjacentRecord(int $offset): ?string
578
    {
579
        $this->getOwner()->getStateManager();
580
        $reflObject = new ReflectionObject($this->getOwner());
581
        $reflMethod = $reflObject->getMethod('getEditLinkForAdjacentRecord');
582
        $reflMethod->setAccessible(true);
583
584
        try {
585
            return $reflMethod->invoke($this->getOwner(), $offset);
586
        } catch (Exception $e) {
587
            return null;
588
        }
589
    }
590
591
    /**
592
     * @param FieldList $actions
593
     * @param ModelData $record
594
     * @return void
595
     */
596
    public function addSaveAndClose(FieldList $actions, ModelData $record)
597
    {
598
        if (!$this->checkCan($record)) {
599
            return;
600
        }
601
602
        $MajorActions = $this->getMajorActions($actions);
603
604
        //@phpstan-ignore-next-line
605
        if ($record->ID) {
606
            $label = _t('ActionsGridFieldItemRequest.SAVEANDCLOSE', 'Save and Close');
607
        } else {
608
            $label = _t('ActionsGridFieldItemRequest.CREATEANDCLOSE', 'Create and Close');
609
        }
610
        $saveAndClose = FormAction::create('doSaveAndClose', $label);
611
        $saveAndClose->addExtraClass($this->getBtnClassForRecord($record));
612
        $saveAndClose->setAttribute('data-text-alternate', $label);
613
614
        if ($record->ID) {
615
            $saveAndClose->setAttribute('data-btn-alternate-add', 'btn-primary');
616
            $saveAndClose->setAttribute('data-btn-alternate-remove', 'btn-outline-primary');
617
        }
618
        $saveAndClose->addExtraClass('font-icon-level-up btn-mobile-collapse');
619
        $saveAndClose->setUseButtonTag(true);
620
        $MajorActions->push($saveAndClose);
621
    }
622
623
    /**
624
     * New and existing records have different classes
625
     *
626
     * @param ModelData $record
627
     * @return string
628
     */
629
    protected function getBtnClassForRecord(ModelData $record)
630
    {
631
        //@phpstan-ignore-next-line
632
        if ($record->ID) {
633
            return 'btn-outline-primary';
634
        }
635
        return 'btn-primary';
636
    }
637
638
    /**
639
     * @param string $action
640
     * @param array<FormField>|FieldList $definedActions
641
     * @return FormField|null
642
     */
643
    protected static function findAction($action, $definedActions)
644
    {
645
        $result = null;
646
647
        foreach ($definedActions as $definedAction) {
648
            if (is_a($definedAction, CompositeField::class)) {
649
                $result = self::findAction($action, $definedAction->FieldList());
650
                if ($result) {
651
                    break;
652
                }
653
            }
654
655
            $definedActionName = $definedAction->getName();
656
657
            if ($definedAction->hasMethod('actionName')) {
658
                //@phpstan-ignore-next-line
659
                $definedActionName = $definedAction->actionName();
660
            }
661
            if ($definedActionName === $action) {
662
                $result = $definedAction;
663
                break;
664
            }
665
        }
666
667
        return $result;
668
    }
669
670
    /**
671
     * Forward a given action to a DataObject
672
     *
673
     * Action must be declared in getCMSActions to be called
674
     *
675
     * @param string $action
676
     * @param array<string,mixed> $data
677
     * @param Form $form
678
     * @return HTTPResponse|DBHTMLText|string
679
     * @throws HTTPResponse_Exception
680
     */
681
    protected function forwardActionToRecord($action, $data = [], $form = null)
682
    {
683
        $controller = $this->getToplevelController();
684
685
        // We have an item request or a controller that can provide a record
686
        $record = null;
687
        if ($this->getOwner()->hasMethod('ItemEditForm')) {
688
            // It's a request handler. Don't check for a specific class as it may be subclassed
689
            //@phpstan-ignore-next-line
690
            $record = $this->getOwner()->record;
691
        } elseif ($controller->hasMethod('save_siteconfig')) {
692
            // Check for any type of siteconfig controller
693
            $record = SiteConfig::current_site_config();
694
        } elseif (!empty($data['ClassName']) && !empty($data['ID'])) {
695
            $record = DataObject::get_by_id($data['ClassName'], $data['ID']);
696
        } elseif ($controller->hasMethod("getRecord")) {
697
            // LeftAndMain requires an id
698
            if ($controller instanceof LeftAndMain && !empty($data['ID'])) {
699
                $record = $controller->getRecord($data['ID']);
700
            } elseif ($controller instanceof ModelAdmin) {
701
                // Otherwise fallback to singleton
702
                $record = DataObject::singleton($controller->getModelClass());
703
            }
704
        }
705
706
        if (!$record) {
707
            throw new Exception("No record to handle the action $action on " . get_class($controller));
708
        }
709
        $CMSActions = $this->getCmsActionsFromRecord($record);
710
711
        // Check if the action is indeed available
712
        $clickedAction = null;
713
        if (!empty($CMSActions)) {
714
            $clickedAction = self::findAction($action, $CMSActions);
715
        }
716
        if (!$clickedAction) {
717
            $class = get_class($record);
718
            $availableActions = null;
719
            if ($CMSActions) {
720
                $availableActions = implode(',', $this->getAvailableActions($CMSActions));
721
            }
722
            if (!$availableActions) {
723
                $availableActions = "(no available actions, please check getCMSActions)";
724
            }
725
726
            return $this->getOwner()->httpError(403, sprintf(
727
                'Action not available on %s. It must be one of : %s',
728
                $class,
729
                $availableActions
730
            ));
731
        }
732
733
        if ($clickedAction->isReadonly() || $clickedAction->isDisabled()) {
734
            return $this->getOwner()->httpError(403, sprintf(
735
                'Action %s is disabled',
736
                $clickedAction->getName(),
737
            ));
738
        }
739
740
        $message = null;
741
        $error = false;
742
743
        // Check record BEFORE the action
744
        // It can be deleted by the action, and it will return to the list
745
        $isNewRecord = isset($record->ID) && $record->ID === 0;
746
747
        $actionTitle = $clickedAction->getName();
748
        if (method_exists($clickedAction, 'getTitle')) {
749
            $actionTitle = $clickedAction->getTitle();
750
        }
751
752
        $recordName = $record instanceof DataObject ? $record->i18n_singular_name() : _t(
753
            'ActionsGridFieldItemRequest.record',
754
            'record'
755
        );
756
757
        try {
758
            $result = $record->$action($data, $form, $controller);
759
760
            // We have a response
761
            if ($result instanceof HTTPResponse) {
762
                return $result;
763
            }
764
765
            if ($result === false) {
766
                // Result returned an error (false)
767
                $error = true;
768
                $message = _t(
769
                    'ActionsGridFieldItemRequest.FAILED',
770
                    'Action {action} failed on {name}',
771
                    [
772
                        'action' => $actionTitle,
773
                        'name' => $recordName,
774
                    ]
775
                );
776
            } elseif (is_string($result)) {
777
                // Result is a message
778
                $message = $result;
779
            }
780
        } catch (Exception $ex) {
781
            $result = null;
782
            $error = true;
783
            $message = $ex->getMessage();
784
        }
785
786
        // Build default message
787
        if (!$message) {
788
            $message = _t(
789
                'ActionsGridFieldItemRequest.DONE',
790
                'Action {action} was done on {name}',
791
                [
792
                    'action' => $actionTitle,
793
                    'name' => $recordName,
794
                ]
795
            );
796
        }
797
        $status = 'good';
798
        if ($error) {
799
            $status = 'bad';
800
        }
801
802
        // Progressive actions return array with json data
803
        if (method_exists($clickedAction, 'getProgressive') && $clickedAction->getProgressive()) {
804
            $response = $controller->getResponse();
805
            $response->addHeader('Content-Type', 'application/json');
806
            if ($result) {
807
                $encodedResult = json_encode($result);
808
                if (!$encodedResult) {
809
                    $encodedResult = json_last_error_msg();
810
                }
811
                $response->setBody($encodedResult);
812
            }
813
814
            return $response;
815
        }
816
817
        // We don't have a form, simply return the result
818
        if (!$form) {
819
            if ($error) {
820
                return $this->getOwner()->httpError(403, $message);
821
            }
822
823
            return $message;
824
        }
825
826
        $shouldRefresh = method_exists($clickedAction, 'getShouldRefresh') && $clickedAction->getShouldRefresh();
827
828
        // When loading using pjax, we can show toasts through X-Status
829
        if (Director::is_ajax()) {
830
            $controller->getResponse()->addHeader('X-Status', rawurlencode($message));
831
            // 4xx status makes a red box
832
            if ($error) {
833
                $controller->getResponse()->setStatusCode(400);
834
            }
835
836
            if ($shouldRefresh) {
837
                self::addXReload($controller);
838
839
                // Store a pending session message to display after reload
840
                $controller->getRequest()->getSession()->set('CmsActionsPendingMessage', [
841
                    'message' => $message,
842
                    'status' => $status,
843
                ]);
844
            }
845
        } else {
846
            // If the controller support sessionMessage, use it instead of form
847
            if ($controller->hasMethod('sessionMessage')) {
848
                //@phpstan-ignore-next-line
849
                $controller->sessionMessage($message, $status, ValidationResult::CAST_HTML);
850
            } else {
851
                $form->sessionMessage($message, $status, ValidationResult::CAST_HTML);
852
            }
853
        }
854
855
        // Custom redirect
856
        /** @var CustomAction $clickedAction */
857
        if (method_exists($clickedAction, 'getRedirectURL') && $clickedAction->getRedirectURL()) {
858
            // we probably need a full ui refresh
859
            self::addXReload($controller, $clickedAction->getRedirectURL());
860
            return $controller->redirect($clickedAction->getRedirectURL());
861
        }
862
863
        // Redirect after action
864
        return $this->redirectAfterAction($isNewRecord, $record);
865
    }
866
867
    /**
868
     * Adds X-Reload headers
869
     *
870
     * @param Controller $controller
871
     * @param string|null $url
872
     * @return bool Returns true if will reload
873
     */
874
    public static function addXReload(Controller $controller, ?string $url = null): bool
875
    {
876
        if (!$url) {
877
            $url = $controller->getReferer();
878
        }
879
        if (!$url) {
880
            $url = $controller->getBackURL();
881
        }
882
        if (!$url) {
883
            return false;
884
        }
885
        // Triggers a full reload. Needs both headers to work
886
        // @link https://github.com/silverstripe/silverstripe-admin/blob/3/client/src/legacy/LeftAndMain.js#L819
887
        $controller->getResponse()->addHeader('X-ControllerURL', $url);
888
        $controller->getResponse()->addHeader('X-Reload', "true");
889
        return true;
890
    }
891
892
    /**
893
     * Handles custom links
894
     *
895
     * Use CustomLink with default behaviour to trigger this
896
     *
897
     * See:
898
     * DefaultLink::getModelLink
899
     * GridFieldCustomLink::getLink
900
     *
901
     * @param HTTPRequest $request
902
     * @return HTTPResponse|DBHTMLText|string
903
     * @throws Exception
904
     */
905
    public function doCustomLink(HTTPRequest $request)
906
    {
907
        $action = $request->getVar('CustomLink');
908
        return $this->forwardActionToRecord($action);
909
    }
910
911
    /**
912
     * Handles custom actions
913
     *
914
     * Use CustomAction class to trigger this
915
     *
916
     * Nested actions are submitted like this
917
     * [action_doCustomAction] => Array
918
     * (
919
     *   [doTestAction] => 1
920
     * )
921
     *
922
     * @param array<string,mixed> $data The form data
923
     * @param Form $form The form object
924
     * @return HTTPResponse|DBHTMLText|string
925
     * @throws Exception
926
     */
927
    public function doCustomAction($data, $form)
928
    {
929
        $action = key($data['action_doCustomAction']);
930
        return $this->forwardActionToRecord($action, $data, $form);
931
    }
932
933
    /**
934
     * Saves the form and goes back to list view
935
     *
936
     * @param array<string,mixed> $data The form data
937
     * @param Form $form The form object
938
     * @return HTTPResponse
939
     */
940
    public function doSaveAndClose($data, $form)
941
    {
942
        $this->getOwner()->doSave($data, $form);
943
        // Redirect after save
944
        $controller = $this->getToplevelController();
945
946
        $link = $this->getBackLink();
947
948
        // Doesn't seem to be needed anymore
949
        // $link = $this->addGridState($link, $data);
950
951
        $controller->getResponse()->addHeader("X-Pjax", "Content");
952
953
        return $controller->redirect($link);
954
    }
955
956
    /**
957
     * @param string $dir prev|next
958
     * @param array<string,mixed> $data The form data
959
     * @param Form|null $form
960
     * @return HTTPResponse
961
     */
962
    protected function doSaveAndAdjacent(string $dir, array $data, ?Form $form)
963
    {
964
        //@phpstan-ignore-next-line
965
        $record = $this->getOwner()->record;
966
        $this->getOwner()->doSave($data, $form);
967
        // Redirect after save
968
        $controller = $this->getToplevelController();
969
        $controller->getResponse()->addHeader("X-Pjax", "Content");
970
971
        if (!($record instanceof DataObject)) {
972
            throw new Exception("Works only with DataObject");
973
        }
974
975
        $class = get_class($record);
976
        if (!$class) {
977
            throw new Exception("Could not get class");
978
        }
979
980
        if (!in_array($dir, ['prev', 'next'])) {
981
            throw new Exception("Invalid dir $dir");
982
        }
983
984
        $method = match ($dir) {
985
            'prev' => 'getCustomPreviousRecordID',
986
            'next' => 'getCustomNextRecordID',
987
        };
988
989
        $offset = match ($dir) {
990
            'prev' => -1,
991
            'next' => +1,
992
        };
993
994
        $adjRecordID = $this->$method($record);
995
996
        /** @var ?DataObject $adj */
997
        $adj = $class::get()->byID($adjRecordID);
998
999
        $useCustom = $this->useCustomPrevNext($record);
1000
        $link = $this->getPublicEditLinkForAdjacentRecord($offset);
1001
        if (!$link || $useCustom) {
1002
            $link = $this->getOwner()->getEditLink($adjRecordID);
1003
            $link = $this->addGridState($link, $data);
1004
        }
1005
1006
        // Link to a specific tab if set, see cms-actions.js
1007
        if ($adj && !empty($data['_activetab'])) {
1008
            $link .= sprintf('#%s', $data['_activetab']);
1009
        }
1010
1011
        return $controller->redirect($link);
1012
    }
1013
1014
    /**
1015
     * Saves the form and goes back to the next item
1016
     *
1017
     * @param array<string,mixed> $data The form data
1018
     * @param Form $form The form object
1019
     * @return HTTPResponse
1020
     */
1021
    public function doSaveAndNext($data, $form)
1022
    {
1023
        return $this->doSaveAndAdjacent('next', $data, $form);
1024
    }
1025
1026
    /**
1027
     * Saves the form and goes to the previous item
1028
     *
1029
     * @param array<string,mixed> $data The form data
1030
     * @param Form $form The form object
1031
     * @return HTTPResponse
1032
     */
1033
    public function doSaveAndPrev($data, $form)
1034
    {
1035
        return $this->doSaveAndAdjacent('prev', $data, $form);
1036
    }
1037
1038
    /**
1039
     * Check if we can remove this safely
1040
     * @param string $url
1041
     * @param array<mixed> $data
1042
     * @return string
1043
     * @deprecated
1044
     */
1045
    protected function addGridState($url, $data)
1046
    {
1047
        // This should not be necessary at all if the state is correctly passed along
1048
        $BackURL = $data['BackURL'] ?? null;
1049
        if ($BackURL) {
1050
            $query = parse_url($BackURL, PHP_URL_QUERY);
1051
            if ($query) {
1052
                $url = strtok($url, '?');
1053
                $url .= '?' . $query;
1054
            }
1055
        }
1056
        return $url;
1057
    }
1058
1059
    /**
1060
     * Gets the top level controller.
1061
     *
1062
     * @return Controller|RequestHandler
1063
     */
1064
    protected function getToplevelController()
1065
    {
1066
        if ($this->isLeftAndMain($this->getOwner())) {
1067
            return $this->getOwner();
1068
        }
1069
        if (!$this->getOwner()->hasMethod("getController")) {
1070
            return Controller::curr();
1071
        }
1072
        $controller = $this->getOwner()->getController();
1073
        while ($controller instanceof GridFieldDetailForm_ItemRequest) {
1074
            $controller = $controller->getController();
1075
        }
1076
1077
        return $controller;
1078
    }
1079
1080
    /**
1081
     * @param Controller $controller
1082
     * @return boolean
1083
     */
1084
    protected function isLeftAndMain($controller)
1085
    {
1086
        return is_subclass_of($controller, LeftAndMain::class);
1087
    }
1088
1089
    /**
1090
     * Gets the back link
1091
     *
1092
     * @return string
1093
     */
1094
    public function getBackLink()
1095
    {
1096
        $backlink = '';
1097
        $toplevelController = $this->getToplevelController();
1098
        // Check for LeftAndMain and alike controllers with a Backlink or Breadcrumbs methods
1099
        if ($toplevelController->hasMethod('Backlink')) {
1100
            //@phpstan-ignore-next-line
1101
            $backlink = $toplevelController->Backlink();
1102
        } elseif ($this->getOwner()->getController()->hasMethod('Breadcrumbs')) {
1103
            //@phpstan-ignore-next-line
1104
            $parents = $this->getOwner()->getController()->Breadcrumbs(false)->items;
1105
            $backlink = array_pop($parents)->Link;
1106
        }
1107
        if (!$backlink) {
1108
            $backlink = $toplevelController->Link();
1109
        }
1110
1111
        return $backlink;
1112
    }
1113
1114
    /**
1115
     * Response object for this request after a successful save
1116
     *
1117
     * @param bool $isNewRecord True if this record was just created
1118
     * @param ModelData $record
1119
     * @return HTTPResponse|DBHTMLText|string
1120
     */
1121
    protected function redirectAfterAction($isNewRecord, $record = null)
1122
    {
1123
        $controller = $this->getToplevelController();
1124
1125
        if ($this->isLeftAndMain($controller)) {
1126
            // CMSMain => redirect to show
1127
            if ($this->getOwner()->hasMethod("LinkPageEdit")) {
1128
                //@phpstan-ignore-next-line
1129
                return $controller->redirect($this->getOwner()->LinkPageEdit($record->ID));
1130
            }
1131
        }
1132
1133
        if ($isNewRecord) {
1134
            return $controller->redirect($this->getOwner()->Link());
1135
        }
1136
        //@phpstan-ignore-next-line
1137
        if ($this->getOwner()->gridField && $this->getOwner()->gridField->getList()->byID($this->getOwner()->record->ID)) {
1138
            // Return new view, as we can't do a "virtual redirect" via the CMS Ajax
1139
            // to the same URL (it assumes that its content is already current, and doesn't reload)
1140
            return $this->getOwner()->edit($controller->getRequest());
1141
        }
1142
        // Changes to the record properties might've excluded the record from
1143
        // a filtered list, so return back to the main view if it can't be found
1144
        $noActionURL = $url = $controller->getRequest()->getURL();
1145
        if (!$url) {
1146
            $url = '';
1147
        }
1148
1149
        // The controller may not have these
1150
        if ($controller->hasMethod('getAction')) {
1151
            $action = $controller->getAction();
1152
            // Handle GridField detail form editing
1153
            if (strpos($url, 'ItemEditForm') !== false) {
1154
                $action = 'ItemEditForm';
1155
            }
1156
            if ($action) {
1157
                $noActionURL = $controller->removeAction($url, $action);
1158
            }
1159
        } else {
1160
            // Simple fallback (last index of)
1161
            $pos = strrpos($url, 'ItemEditForm');
1162
            if (is_int($pos)) {
0 ignored issues
show
The condition is_int($pos) is always true.
Loading history...
1163
                $noActionURL = substr($url, 0, $pos);
1164
            }
1165
        }
1166
1167
        $controller->getRequest()->addHeader('X-Pjax', 'Content');
1168
        return $controller->redirect($noActionURL, 302);
1169
    }
1170
}
1171