Files

433 lines
15 KiB
PHP

<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Breadcrumb\BreadcrumbContext;
use TYPO3\CMS\Backend\Breadcrumb\BreadcrumbFactory;
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
use TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton;
use TYPO3\CMS\Backend\Template\Components\Buttons\ButtonInterface;
use TYPO3\CMS\Core\Resource\ResourceInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Document header component for backend modules.
*
* This component manages the header area of backend module views, providing:
* - Breadcrumb navigation (via BreadcrumbContext)
* - Button bar for action buttons (save, close, delete, etc.)
* - Drop-down menus for module-specific actions
*
* The component can be enabled or disabled to control visibility of the entire
* document header. It integrates with the ModuleTemplate to provide a consistent
* header across all backend modules.
*
* Usage in a controller:
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* public function myAction(): ResponseInterface
* {
* $view = $this->moduleTemplateFactory->create($request);
* $docHeader = $view->getDocHeaderComponent();
*
* // Set breadcrumb for a page
* $docHeader->setPageBreadcrumb($pageInfo);
*
* // Add action buttons using ComponentFactory
* $buttonBar = $docHeader->getButtonBar();
* $saveButton = $this->componentFactory->createSaveButton('editform');
* $buttonBar->addButton($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 1);
* }
* ```
*/
#[Autoconfigure(public: true)]
class DocHeaderComponent
{
/**
* Button bar component for managing action buttons.
*/
protected ButtonBar $buttonBar;
/**
* Breadcrumb component for rendering navigation trails.
*/
protected Breadcrumb $breadcrumb;
/**
* Context information for breadcrumb rendering.
*
* Contains the main context (page, record, or resource) and optional suffix nodes
* for additional navigation elements.
*/
protected ?BreadcrumbContext $breadcrumbContext = null;
/**
* Whether the document header is enabled and should be rendered.
*/
protected bool $enabled = true;
/**
* Language selector component.
*/
protected ?ComponentInterface $languageSelector = null;
/**
* The automatic shortcut button instance, if configured.
*/
protected ?ShortcutButton $automaticShortcutButton = null;
/**
* Whether the automatic reload button should be added.
*/
protected bool $automaticReloadButton = true;
public function __construct(
protected readonly MenuRegistry $menuRegistry,
protected readonly BreadcrumbFactory $breadcrumbFactory,
protected readonly ComponentFactory $componentFactory,
) {
$this->buttonBar = GeneralUtility::makeInstance(ButtonBar::class);
$this->breadcrumb = GeneralUtility::makeInstance(Breadcrumb::class);
}
/**
* Sets the breadcrumb context for rendering.
*
* This is the main API for providing breadcrumb information.
*
* For common scenarios, use the convenience methods instead:
* - setPageBreadcrumb() for page records
* - setRecordBreadcrumb() for any record
* - setResourceBreadcrumb() for files or folders
*
* @param BreadcrumbContext|null $breadcrumbContext The breadcrumb context
*/
public function setBreadcrumbContext(?BreadcrumbContext $breadcrumbContext): void
{
$this->breadcrumbContext = $breadcrumbContext;
}
/**
* Sets breadcrumb from a page record array.
*
* Example:
* $view->getDocHeaderComponent()->setPageBreadcrumb($pageInfo);
*
* @param array $pageRecord The page record array (must contain 'uid')
*/
public function setPageBreadcrumb(array $pageRecord): void
{
$this->breadcrumbContext = $this->breadcrumbFactory->forPageArray($pageRecord);
}
/**
* Sets breadcrumb for editing a record.
*
* Example:
* $view->getDocHeaderComponent()->setRecordBreadcrumb('tt_content', 123);
*
* @param string $table The table name
* @param int $uid The record UID
*/
public function setRecordBreadcrumb(string $table, int $uid): void
{
$this->breadcrumbContext = $this->breadcrumbFactory->forEditAction($table, $uid);
}
/**
* Sets breadcrumb for any resource (file or folder).
*
* Example:
* $view->getDocHeaderComponent()->setResourceBreadcrumb($file);
* $view->getDocHeaderComponent()->setResourceBreadcrumb($folder);
*
* @param ResourceInterface $resource The resource (file or folder)
*/
public function setResourceBreadcrumb(ResourceInterface $resource): void
{
$this->breadcrumbContext = $this->breadcrumbFactory->forResource($resource);
}
/**
* Adds a suffix node to the current breadcrumb context.
*
* Suffix nodes are appended after the main breadcrumb trail and are useful for:
* - Indicating "Create New" actions
* - Showing "Edit Multiple" states
* - Adding custom contextual information
*
* Example:
*
* $docHeader->setPageBreadcrumb($pageInfo);
* $docHeader->addBreadcrumbSuffixNode(
* new BreadcrumbNode(
* identifier: 'new',
* label: 'Create New Content Element',
* icon: 'actions-add'
* )
* );
*
* Note: This creates or modifies the breadcrumb context. If you need to build
* a complete context, use BreadcrumbFactory instead.
*
* @param BreadcrumbNode $node The node to append
*/
public function addBreadcrumbSuffixNode(BreadcrumbNode $node): void
{
if ($this->breadcrumbContext === null) {
$this->breadcrumbContext = new BreadcrumbContext(null, [$node]);
} else {
// Create new context with added suffix node
$existingSuffixNodes = $this->breadcrumbContext->suffixNodes;
$existingSuffixNodes[] = $node;
$this->breadcrumbContext = new BreadcrumbContext(
$this->breadcrumbContext->mainContext,
$existingSuffixNodes
);
}
}
/**
* Returns the menu registry for adding drop-down menus to the document header.
*/
public function getMenuRegistry(): MenuRegistry
{
return $this->menuRegistry;
}
/**
* Returns the button bar for adding action buttons to the document header.
*
* The button bar supports multiple button positions (left, right) and groups
* to organize buttons logically.
*/
public function getButtonBar(): ButtonBar
{
return $this->buttonBar;
}
/**
* Determines whether this component is enabled and should be rendered.
*
* When disabled, the entire document header (including breadcrumbs, buttons,
* and menus) will not be displayed in the backend module.
*/
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* Enables this component for rendering.
*/
public function enable(): void
{
$this->enabled = true;
}
/**
* Disables this component to prevent rendering.
*/
public function disable(): void
{
$this->enabled = false;
}
public function setLanguageSelector(?ComponentInterface $component): void
{
$this->languageSelector = $component;
}
public function getLanguageSelector(): ?ComponentInterface
{
return $this->languageSelector;
}
/**
* Sets the context for the automatic shortcut button.
*
* Controllers can use this method to provide shortcut information without
* manually creating and adding the shortcut button. The button will be
* automatically added to the button bar in the correct position.
*
* Example:
*
* $docHeader->setShortcutContext('site_configuration.edit', sprintf('Edit site: %s', $siteIdentifier), ['site' => $siteIdentifier]);
*
* @param string $routeIdentifier The route identifier for the shortcut
* @param string $displayName The display name shown in the bookmark list
* @param array $arguments Optional arguments to include in the shortcut URL
*/
public function setShortcutContext(string $routeIdentifier, string $displayName, array $arguments = []): void
{
$this->automaticShortcutButton = $this->componentFactory->createShortcutButton()
->setRouteIdentifier($routeIdentifier)
->setDisplayName($displayName)
->setArguments($arguments);
}
/**
* Disables the automatic reload button for this module.
*
* Use this if your module needs custom reload behavior or should not
* have a reload button at all.
*/
public function disableAutomaticReloadButton(): void
{
$this->automaticReloadButton = false;
}
/**
* Disables the automatic shortcut button for this module.
*
* Use this if your module should not have a shortcut button.
*/
public function disableAutomaticShortcutButton(): void
{
$this->automaticShortcutButton = null;
}
/**
* Returns the complete document header content as an array for rendering.
*
* This method aggregates all components (buttons, breadcrumbs) into
* a structured array that can be consumed by the Fluid template rendering
* the backend module layout.
*
* The returned array structure:
* - 'enabled': Whether the document header should be rendered
* - 'buttons': Array of button configurations from the button bar
* - 'breadcrumb': Breadcrumb trail data from the breadcrumb context
* - 'languageSelector': Language Selector
*/
public function docHeaderContent(?ServerRequestInterface $request): array
{
// Process MenuRegistry and add any menus as dropdown buttons to the button bar
$moduleMenuButton = $this->processMenuRegistry();
if ($moduleMenuButton !== null) {
$this->buttonBar->addButton($moduleMenuButton, ButtonBar::BUTTON_POSITION_LEFT, 0);
}
// Add automatic buttons (reload, shortcut)
$this->addAutomaticButtons($request);
return [
'enabled' => $this->isEnabled(),
'buttons' => $this->buttonBar->getButtons($request),
'breadcrumb' => $this->breadcrumb->getBreadcrumb($request, $this->breadcrumbContext),
'languageSelector' => $this->getLanguageSelector(),
];
}
/**
* Adds automatic reload and shortcut buttons to the button bar.
*
* This method is called automatically by docHeaderContent() and handles:
* - Adding automatic reload button (if enabled)
* - Adding automatic shortcut button (if configured)
*
* The buttons are added to groups 90 and 91 on the right side, which are conventionally
* used for these system buttons. This ensures they appear at the end of the button bar
* while still allowing PSR-14 event listeners to modify or remove them via ModifyButtonBarEvent.
*/
private function addAutomaticButtons(?ServerRequestInterface $request): void
{
if ($request === null) {
return;
}
// Add automatic reload button if enabled
if ($this->automaticReloadButton) {
$reloadButton = $this->componentFactory->createReloadButton(
$request->getAttribute('normalizedParams')->getRequestUri()
);
// Add to group 90 on the right (conventionally second-to-last position)
$this->buttonBar->addButton($reloadButton, ButtonBar::BUTTON_POSITION_RIGHT, 90);
}
// Add automatic shortcut button if configured
if ($this->automaticShortcutButton !== null) {
// Add to group 91 on the right (conventionally last position)
$this->buttonBar->addButton($this->automaticShortcutButton);
}
}
/**
* Processes registered menus from the MenuRegistry into a dropdown button component.
*
* Takes the first registered menu from the MenuRegistry and creates a dropdown button
* component that can be added to the button bar.
*
* @return ButtonInterface|null The dropdown button, or null if no menus registered
*/
private function processMenuRegistry(): ?ButtonInterface
{
$menus = $this->menuRegistry->getMenus();
if ($menus === []) {
return null;
}
if (count($menus) > 1) {
throw new \RuntimeException('The menuRegistry should only contain one menu. '
. 'Multiple DocHeaderComponents can not be displayed - prefer to add distinct dropdown '
. 'buttons to add more view possibilities, or create actual submodules instead of secondary menus.', 1783447740);
}
// Use the first menu (most controllers only register one menu)
$menu = reset($menus);
// Hide menu if it's either empty or offers only one item
if (count($menu->getMenuItems()) < 2) {
return null;
}
$label = $menu->getLabel();
$dropdownButton = $this->componentFactory->createDropDownButton()
->setShowActiveLabelText(true)
->setShowLabelText(true);
foreach ($menu->getMenuItems() as $menuItem) {
if ($label === '') {
// Previously, the menu was rendered as a <select>, which meant the first or
// currently selected <option> acted as the visible label. The menu itself had
// no separate label. As a fallback, we now use the first menu item title as the
// button label, ensuring the DropDownButton is valid. The button will still
// always display the active item, because setShowActiveLabelText(true) is set.
$label = $menuItem->getTitle();
}
$dropdownItem = $this->componentFactory->createDropDownRadio()
->setHref($menuItem->getHref())
->setLabel($menuItem->getTitle())
->setActive($menuItem->isActive());
$dropdownButton->addItem($dropdownItem);
}
$dropdownButton->setLabel($label);
return $dropdownButton;
}
}