Implement ParentEntityHandler plugin system for extensible entity support

Replace hardcoded entity type checks with a plugin-based architecture using
PHP 8 attributes. This allows adding new parent entity types without modifying
core module files.

Changes:
- Add ParentEntityHandler attribute, interface, base class, and manager
- Create built-in handlers for taxonomy_term, user, and node entities
- Move Group support to site_structure_group submodule (fixes class not found
  error when Group module is not installed)
- Refactor SiteStructureSettingsForm to use handler manager
- Refactor SiteStructureMenuBlock to use handler manager
- Refactor SectionBreadcrumbBuilder to use handler manager
- Update site_structure.module to use handler manager for clearsSiteSection
- Update documentation and translations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 08:35:04 -03:00
parent 43fa9208a9
commit 0c8f0fc778
17 changed files with 1153 additions and 117 deletions

View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace Drupal\site_structure\ParentEntityHandler;
use Drupal\Core\Breadcrumb\Breadcrumb;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Plugin\PluginBase;
use Drupal\Core\Routing\RouteMatchInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Base class for parent entity handler plugins.
*
* Provides default implementations for common handler functionality.
*/
abstract class ParentEntityHandlerBase extends PluginBase implements ParentEntityHandlerInterface, ContainerFactoryPluginInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected ModuleHandlerInterface $moduleHandler;
/**
* Constructs a ParentEntityHandlerBase object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin ID for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
*/
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
EntityTypeManagerInterface $entity_type_manager,
ModuleHandlerInterface $module_handler,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entity_type_manager;
$this->moduleHandler = $module_handler;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager'),
$container->get('module_handler'),
);
}
/**
* {@inheritdoc}
*/
public function getEntityTypeId(): string {
return $this->pluginDefinition['entity_type_id'];
}
/**
* {@inheritdoc}
*/
public function getLabel(): string {
return (string) $this->pluginDefinition['label'];
}
/**
* {@inheritdoc}
*/
public function isAvailable(): bool {
// Check if provider module is required and installed.
$provider = $this->pluginDefinition['provider_module'] ?? NULL;
if ($provider && !$this->moduleHandler->moduleExists($provider)) {
return FALSE;
}
// Check if entity type exists.
return $this->entityTypeManager->hasDefinition($this->getEntityTypeId());
}
/**
* {@inheritdoc}
*/
public function clearsSiteSection(): bool {
return $this->pluginDefinition['clears_site_section'] ?? FALSE;
}
/**
* {@inheritdoc}
*/
public function getSortField(): string {
return $this->pluginDefinition['sort_field'] ?? 'title';
}
/**
* {@inheritdoc}
*/
public function getRouteParameter(): string {
return $this->pluginDefinition['route_parameter'] ?? $this->getEntityTypeId();
}
/**
* {@inheritdoc}
*/
public function getBundleRestrictions(): array {
return $this->pluginDefinition['bundle_restrictions'] ?? [];
}
/**
* {@inheritdoc}
*/
public function getEntityFromRoute(RouteMatchInterface $route_match): ?EntityInterface {
$parameter = $this->getRouteParameter();
$entity = $route_match->getParameter($parameter);
if (!$entity instanceof EntityInterface) {
return NULL;
}
if (!$this->handlesEntity($entity)) {
return NULL;
}
return $entity;
}
/**
* {@inheritdoc}
*/
public function handlesEntity(EntityInterface $entity): bool {
if ($entity->getEntityTypeId() !== $this->getEntityTypeId()) {
return FALSE;
}
$bundle_restrictions = $this->getBundleRestrictions();
if (!empty($bundle_restrictions)) {
return in_array($entity->bundle(), $bundle_restrictions, TRUE);
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public function buildBreadcrumb(Breadcrumb $breadcrumb, EntityInterface $entity): void {
$breadcrumb->addCacheableDependency($entity);
$breadcrumb->addLink($entity->toLink());
}
/**
* {@inheritdoc}
*/
public function getSiteSectionId(EntityInterface $entity): int|string|null {
// Default implementation returns NULL.
// Subclasses should override for entity types that can provide a site section.
return NULL;
}
/**
* Loads an entity by ID.
*
* @param int|string $entity_id
* The entity ID.
*
* @return \Drupal\Core\Entity\EntityInterface|null
* The loaded entity, or NULL if not found.
*/
protected function loadEntity(int|string $entity_id): ?EntityInterface {
return $this->entityTypeManager
->getStorage($this->getEntityTypeId())
->load($entity_id);
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Drupal\site_structure\ParentEntityHandler;
use Drupal\Core\Breadcrumb\Breadcrumb;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Interface for parent entity handler plugins.
*
* Parent entity handlers define how different entity types behave when used
* as parent entities for content_page nodes in the site structure hierarchy.
*/
interface ParentEntityHandlerInterface {
/**
* Gets the entity type ID this handler manages.
*
* @return string
* The entity type ID.
*/
public function getEntityTypeId(): string;
/**
* Gets the human-readable label of this handler.
*
* @return string
* The handler label.
*/
public function getLabel(): string;
/**
* Checks if this handler is available.
*
* A handler is available if its provider module is installed and the
* entity type exists.
*
* @return bool
* TRUE if the handler is available, FALSE otherwise.
*/
public function isAvailable(): bool;
/**
* Checks if this handler clears field_site_section when used as parent.
*
* Some entity types (like users and groups) act as context containers
* themselves, so content pages under them don't inherit a site section.
*
* @return bool
* TRUE if field_site_section should be cleared, FALSE otherwise.
*/
public function clearsSiteSection(): bool;
/**
* Gets the field to use for sorting entities of this type.
*
* @return string
* The sort field name.
*/
public function getSortField(): string;
/**
* Gets the route parameter name for detecting entities on routes.
*
* @return string
* The route parameter name.
*/
public function getRouteParameter(): string;
/**
* Gets the bundle restrictions for this handler.
*
* @return array
* An array of bundle IDs, or empty array for no restrictions.
*/
public function getBundleRestrictions(): array;
/**
* Gets an entity from the current route.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The current route match.
*
* @return \Drupal\Core\Entity\EntityInterface|null
* The entity from the route, or NULL if not found or not applicable.
*/
public function getEntityFromRoute(RouteMatchInterface $route_match): ?EntityInterface;
/**
* Checks if this handler handles the given entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to check.
*
* @return bool
* TRUE if this handler handles the entity, FALSE otherwise.
*/
public function handlesEntity(EntityInterface $entity): bool;
/**
* Builds breadcrumb entries for the given entity.
*
* @param \Drupal\Core\Breadcrumb\Breadcrumb $breadcrumb
* The breadcrumb to add entries to.
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to build breadcrumb for.
*/
public function buildBreadcrumb(Breadcrumb $breadcrumb, EntityInterface $entity): void;
/**
* Gets the site section ID from an entity of this type.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
*
* @return int|string|null
* The site section term ID, or NULL if not applicable.
*/
public function getSiteSectionId(EntityInterface $entity): int|string|null;
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace Drupal\site_structure\ParentEntityHandler;
use Drupal\Core\Breadcrumb\Breadcrumb;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\site_structure\Attribute\ParentEntityHandler;
/**
* Plugin manager for parent entity handlers.
*
* @see \Drupal\site_structure\Attribute\ParentEntityHandler
* @see \Drupal\site_structure\ParentEntityHandler\ParentEntityHandlerInterface
* @see \Drupal\site_structure\ParentEntityHandler\ParentEntityHandlerBase
*/
class ParentEntityHandlerManager extends DefaultPluginManager implements ParentEntityHandlerManagerInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;
/**
* Static cache for handler instances.
*
* @var \Drupal\site_structure\ParentEntityHandler\ParentEntityHandlerInterface[]|null
*/
protected ?array $handlerInstances = NULL;
/**
* Constructs a ParentEntityHandlerManager object.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* The cache backend.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(
\Traversable $namespaces,
CacheBackendInterface $cache_backend,
ModuleHandlerInterface $module_handler,
EntityTypeManagerInterface $entity_type_manager,
) {
parent::__construct(
'Plugin/ParentEntityHandler',
$namespaces,
$module_handler,
ParentEntityHandlerInterface::class,
ParentEntityHandler::class,
);
$this->alterInfo('parent_entity_handler_info');
$this->setCacheBackend($cache_backend, 'parent_entity_handler_plugins');
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public function getAvailableHandlers(): array {
if ($this->handlerInstances !== NULL) {
return $this->handlerInstances;
}
$this->handlerInstances = [];
$definitions = $this->getDefinitions();
// Sort by weight.
uasort($definitions, function ($a, $b) {
return ($a['weight'] ?? 0) <=> ($b['weight'] ?? 0);
});
foreach ($definitions as $plugin_id => $definition) {
$handler = $this->createInstance($plugin_id);
if ($handler instanceof ParentEntityHandlerInterface && $handler->isAvailable()) {
$this->handlerInstances[$plugin_id] = $handler;
}
}
return $this->handlerInstances;
}
/**
* {@inheritdoc}
*/
public function getHandlerForEntityType(string $entity_type_id): ?ParentEntityHandlerInterface {
foreach ($this->getAvailableHandlers() as $handler) {
if ($handler->getEntityTypeId() === $entity_type_id) {
return $handler;
}
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function getHandlerForEntity(EntityInterface $entity): ?ParentEntityHandlerInterface {
foreach ($this->getAvailableHandlers() as $handler) {
if ($handler->handlesEntity($entity)) {
return $handler;
}
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function getSupportedEntityTypes(): array {
$types = [];
foreach ($this->getAvailableHandlers() as $handler) {
$types[$handler->getEntityTypeId()] = $handler->getLabel();
}
return $types;
}
/**
* {@inheritdoc}
*/
public function clearsSiteSection(string $entity_type_id): bool {
$handler = $this->getHandlerForEntityType($entity_type_id);
return $handler ? $handler->clearsSiteSection() : FALSE;
}
/**
* {@inheritdoc}
*/
public function getSortField(string $entity_type_id): string {
$handler = $this->getHandlerForEntityType($entity_type_id);
return $handler ? $handler->getSortField() : 'title';
}
/**
* {@inheritdoc}
*/
public function getEntityFromRoute(RouteMatchInterface $route_match): ?EntityInterface {
foreach ($this->getAvailableHandlers() as $handler) {
$entity = $handler->getEntityFromRoute($route_match);
if ($entity) {
return $entity;
}
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function buildBreadcrumbForEntity(Breadcrumb $breadcrumb, EntityInterface $entity): bool {
$handler = $this->getHandlerForEntity($entity);
if ($handler) {
$handler->buildBreadcrumb($breadcrumb, $entity);
return TRUE;
}
return FALSE;
}
/**
* {@inheritdoc}
*/
public function getSiteSectionFromParent(string $entity_type_id, int|string $entity_id): int|string|null {
$handler = $this->getHandlerForEntityType($entity_type_id);
if (!$handler) {
return NULL;
}
$entity = $this->entityTypeManager
->getStorage($entity_type_id)
->load($entity_id);
if (!$entity) {
return NULL;
}
return $handler->getSiteSectionId($entity);
}
/**
* {@inheritdoc}
*/
public function clearCachedDefinitions(): void {
parent::clearCachedDefinitions();
$this->handlerInstances = NULL;
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Drupal\site_structure\ParentEntityHandler;
use Drupal\Core\Breadcrumb\Breadcrumb;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Interface for the parent entity handler plugin manager.
*
* This manager provides centralized access to all registered parent entity
* handlers and their aggregated functionality.
*/
interface ParentEntityHandlerManagerInterface {
/**
* Gets all available handlers.
*
* Handlers are available if their provider module is installed and the
* entity type exists.
*
* @return \Drupal\site_structure\ParentEntityHandler\ParentEntityHandlerInterface[]
* An array of available handler instances, keyed by plugin ID.
*/
public function getAvailableHandlers(): array;
/**
* Gets a handler for the given entity type.
*
* @param string $entity_type_id
* The entity type ID.
*
* @return \Drupal\site_structure\ParentEntityHandler\ParentEntityHandlerInterface|null
* The handler for the entity type, or NULL if none found.
*/
public function getHandlerForEntityType(string $entity_type_id): ?ParentEntityHandlerInterface;
/**
* Gets a handler that can handle the given entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
*
* @return \Drupal\site_structure\ParentEntityHandler\ParentEntityHandlerInterface|null
* The handler for the entity, or NULL if none found.
*/
public function getHandlerForEntity(EntityInterface $entity): ?ParentEntityHandlerInterface;
/**
* Gets supported entity types for the settings form.
*
* @return array
* An associative array of entity_type_id => label for available handlers.
*/
public function getSupportedEntityTypes(): array;
/**
* Checks if the given entity type clears site section.
*
* @param string $entity_type_id
* The entity type ID.
*
* @return bool
* TRUE if field_site_section should be cleared, FALSE otherwise.
*/
public function clearsSiteSection(string $entity_type_id): bool;
/**
* Gets the sort field for the given entity type.
*
* @param string $entity_type_id
* The entity type ID.
*
* @return string
* The sort field name, or 'title' as default.
*/
public function getSortField(string $entity_type_id): string;
/**
* Gets an entity from the current route using available handlers.
*
* Iterates through available handlers to find an entity on the current route.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The current route match.
*
* @return \Drupal\Core\Entity\EntityInterface|null
* The entity from the route, or NULL if not found.
*/
public function getEntityFromRoute(RouteMatchInterface $route_match): ?EntityInterface;
/**
* Builds breadcrumb for the given entity using the appropriate handler.
*
* @param \Drupal\Core\Breadcrumb\Breadcrumb $breadcrumb
* The breadcrumb to add entries to.
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to build breadcrumb for.
*
* @return bool
* TRUE if a handler was found and built the breadcrumb, FALSE otherwise.
*/
public function buildBreadcrumbForEntity(Breadcrumb $breadcrumb, EntityInterface $entity): bool;
/**
* Gets the site section ID from a parent entity.
*
* @param string $entity_type_id
* The parent entity type ID.
* @param int|string $entity_id
* The parent entity ID.
*
* @return int|string|null
* The site section term ID, or NULL if not applicable.
*/
public function getSiteSectionFromParent(string $entity_type_id, int|string $entity_id): int|string|null;
}