Breaking: #107884 - Rework actions to use Buttons API with Components 

See forge#107884

Description 

The record list and file list action system (the "button bar" in every row of the table-like display) has been completely reworked to use the Buttons API, utilizing proper component objects instead of plain HTML strings.

This modernization improves type safety, provides better extensibility, and enables more structured manipulation of action buttons through PSR-14 events.

The following components have been affected by this change:

  • \TYPO3\CMS\Backend\RecordList\Event\ModifyRecordListRecordActionsEvent
  • \TYPO3\CMS\Filelist\Event\ProcessFileListActionsEvent
  • \TYPO3\CMS\Backend\RecordList\DatabaseRecordList::makeControl()

Buttons can now be placed into ActionGroups, which are identified by the PHP enum \TYPO3\CMS\Backend\Template\Components\ActionGroup and distinguish between a "primary" and a "secondary" group.

In addition, \TYPO3\CMS\Backend\Template\Components\ComponentGroup enhances the ability to group multiple Button API Components into a single data object and manage their state.

Impact 

Extensions that listen to the ModifyRecordListRecordActionsEvent or ProcessFileListActionsEvent to modify record or file actions need to be updated.

The events no longer work with HTML strings but with ComponentInterface objects (see forge#107823).

Extensions that directly call DatabaseRecordList::makeControl() must update their code, as the $table parameter has been removed.

ModifyRecordListRecordActionsEvent 

The method setAction() now requires a ComponentInterface object, and getAction() now returns either null or a ComponentInterface instance.

The following methods now expect an ActionGroup enum value as the $group parameter:

  • hasAction()
  • getAction()
  • removeAction()
  • getActionGroup()

The method getRecord() no longer returns a raw array but an instance of the Record API.

A new method getRequest() has been added to access the current PSR-7 request context.

Removed methods:

  • getActions()
  • setActions()
  • getTable()

ProcessFileListActionsEvent 

The \TYPO3\CMS\Filelist\Event\ProcessFileListActionsEvent has received identical API changes to ModifyRecordListRecordActionsEvent , allowing manipulation of items in both supported ActionGroup contexts (primary and secondary).

New methods:

  • setAction()
  • getAction()
  • removeAction()
  • moveActionTo()
  • getActionGroup()
  • getRequest()

Buttons can now also be repositioned or inserted at specific before/after locations within an action group.

Removed methods:

  • getActionItems()
  • setActionItems()

Affected installations 

TYPO3 installations with custom PHP code that modifies record or file list actions, or utilizes the mentioned PSR-14 events, are affected.

Migration 

DatabaseRecordList::makeControl() 

// Before
public function makeControl($table, RecordInterface $record): string

// After
public function makeControl(RecordInterface $record): string
Copied!

The $table parameter has been removed, as the table name can now be retrieved from the RecordInterface via $record->getMainType().

Adjust calls accordingly:

EXT:my_extension/Classes/ViewHelper/MyControlViewHelper.php
 // ...
 public function render(): string
 {
     $row = BackendUtility::getRecord($table, $someRowUid);
     $databaseRecordList = GeneralUtility::makeInstance(DatabaseRecordList::class);
-    return $databaseRecordList->makeControl($table, $row);
+    return $databaseRecordList->makeControl($row);
 }
Copied!

ProcessFileListActionsEvent 

Event listeners must now compose buttons via the Button API and add each component using the event’s setAction() method.

Internally, buttons are placed into an ActionGroup container, retrieved via ActionGroup::primary or ActionGroup::secondary.

The previous getActionItems() logic is replaced with getActionGroup() to fetch the corresponding button group.

Instead of manipulating raw HTML, you must now create components using the ComponentFactory .

// Before
use TYPO3\CMS\Filelist\Event\ProcessFileListActionsEvent;

class ProcessFileListActionsEventListener
{
    public function __invoke(ProcessFileListActionsEvent $event): void
    {
        $items = $event->getActionItems();
        $items['my-own-action'] = '<a href="..." class="btn btn-default">...</a>';
        unset($items['some-other-action']);
        $event->setActionItems($items);
    }
}
Copied!
// After
use TYPO3\CMS\Backend\Template\Components\ActionGroup;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Filelist\Event\ProcessFileListActionsEvent;

class ProcessFileListActionsEventListener
{
    public function __construct(
        private readonly ComponentFactory $componentFactory,
        private readonly IconFactory $iconFactory,
    ) {}

    public function __invoke(ProcessFileListActionsEvent $event): void
    {
        $viewButton = $this->componentFactory->createGenericButton()
            ->setIcon($this->iconFactory->getIcon('actions-view'))
            ->setTitle('My title');

        $event->setAction($viewButton, 'my-own-action', ActionGroup::primary);
        $event->removeAction('some-other-action', ActionGroup::primary);
    }
}
Copied!

ModifyRecordListRecordActionsEvent 

This event now behaves identically to the file list event: actions must be created via the Button API and added as ComponentInterface instances using setAction().

The setActions() and getActions() methods are removed and must be replaced by distinct calls to setAction() or use getActionGroup() to access existing actions.

The getRecord() method now returns a Record API object instead of an array. getTable() can be replaced with getRecord()->getMainType().

Modifying actions example:

// Before
use TYPO3\CMS\Backend\RecordList\Event\ModifyRecordListRecordActionsEvent;

class ModifyRecordListRecordActionsEventListener
{
    public function __invoke(ModifyRecordListRecordActionsEvent $event): void
    {
        $items = $event->getActions();
        unset($items['my-own-action']);
        $items['my-own-action'] = '<a href="..." class="btn btn-default">...</a>';
        unset($existing['some-other-action']);
        $event->setActions($items);

        $event->setAction('<button ...></button>', 'my-other-own-action', 'secondary');
    }
}
Copied!
// After
use TYPO3\CMS\Backend\RecordList\Event\ModifyRecordListRecordActionsEvent;
use TYPO3\CMS\Backend\Template\Components\ActionGroup;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\RecordList\Event\ModifyRecordListRecordActionsEvent;
use TYPO3\CMS\Core\Imaging\IconFactory;

class ModifyRecordListRecordActionsEventListener
{
    public function __construct(
        private readonly ComponentFactory $componentFactory,
        private readonly IconFactory $iconFactory,
    ) {}

    public function __invoke(ModifyRecordListRecordActionsEvent $event): void
    {
        $viewButton = $this->componentFactory->createGenericButton()
            ->setIcon($this->iconFactory->getIcon('actions-view'))
            ->setTitle('My title');

        $event->setAction($viewButton, 'my-own-action', ActionGroup::primary);
        $event->removeAction('some-other-action', ActionGroup::primary);

        $inputButton = $this->componentFactory->createInputButton()
            ->setTitle('My Button');

        $event->setAction($inputButton, 'my-other-own-action', ActionGroup::secondary);
    }
}
Copied!

Accessing groups 

// Before
$event->getAction('my-button', 'primary');
$event->hasAction('my-button', 'primary');
$event->removeAction('my-button', 'primary');
$event->getActionGroup('primary');
Copied!
// After
use TYPO3\CMS\Backend\Template\Components\ActionGroup;

$event->getAction('my-button', ActionGroup::primary);
$event->hasAction('my-button', ActionGroup::primary);
$event->removeAction('my-button', ActionGroup::primary);
$event->getActionGroup(ActionGroup::primary);
Copied!

Accessing record 

// Before
$uid = $event->getRecord()['uid'];
$title = $event->getRecord()['title'];
Copied!
// After
$uid = $event->getRecord()->getUid();
$title = $event->getRecord()->getRawRecord()['title'];
Copied!

Dual-version compatibility 

To support both TYPO3 v13 and v14, extensions can use a version check within event listeners:

use TYPO3\CMS\Backend\RecordList\Event\ModifyRecordListRecordActionsEvent;
use TYPO3\CMS\Backend\Template\Components\ActionGroup;
use TYPO3\CMS\Core\Information\Typo3Version;

class ModifyRecordListRecordActionsEventListener
{
    public function __invoke(ModifyRecordListRecordActionsEvent $event): void
    {
        if ((new Typo3Version())->getMajorVersion() >= 14) {
            $viewButton = $this->componentFactory->createGenericButton()
                ->setIcon($this->iconFactory->getIcon('actions-view'))
                ->setTitle('My title');
            $event->setAction($viewButton, 'my-own-action', ActionGroup::primary);
            $event->removeAction('some-other-action', ActionGroup::primary);
        } else {
            $items = $event->getActions();
            unset($items['my-own-action']);
            $items['my-own-action'] = '<a href="..." class="btn btn-default">...</a>';
            unset($existing['some-other-action']);
            $event->setActions($items);
        }
    }
}
Copied!