diff --git a/core/modules/block/src/Plugin/ConfigAction/PlaceBlock.php b/core/modules/block/src/Plugin/ConfigAction/PlaceBlock.php new file mode 100644 index 0000000000000000000000000000000000000000..19f51ca082782ec82abc424edec3602722db20b1 --- /dev/null +++ b/core/modules/block/src/Plugin/ConfigAction/PlaceBlock.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\block\Plugin\ConfigAction; + +use Drupal\block\BlockInterface; +use Drupal\Core\Config\Action\Attribute\ConfigAction; +use Drupal\Core\Config\Action\ConfigActionException; +use Drupal\Core\Config\Action\ConfigActionPluginInterface; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Config\Entity\ConfigEntityStorageInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Places a block in either the admin or default theme. + * + * @internal + * This API is experimental. + */ +#[ConfigAction( + id: 'placeBlock', + admin_label: new TranslatableMarkup('Place a block'), + entity_types: ['block'], + deriver: PlaceBlockDeriver::class, +)] +final class PlaceBlock implements ConfigActionPluginInterface, ContainerFactoryPluginInterface { + + public function __construct( + private readonly ConfigActionPluginInterface $createAction, + private readonly string $whichTheme, + private readonly ConfigFactoryInterface $configFactory, + private readonly ConfigEntityStorageInterface $blockStorage, + ) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $container->get('plugin.manager.config_action')->createInstance('entity_create:createIfNotExists'), + $plugin_definition['which_theme'], + $container->get(ConfigFactoryInterface::class), + $container->get(EntityTypeManagerInterface::class)->getStorage('block'), + ); + } + + /** + * {@inheritdoc} + */ + public function apply(string $configName, mixed $value): void { + assert(is_array($value)); + + $theme = $this->configFactory->get('system.theme')->get($this->whichTheme); + $value['theme'] = $theme; + + if (array_key_exists('region', $value)) { + // Since the recipe author might not know ahead of time what theme the + // block is in, they should supply a map whose keys are theme names and + // values are region names, so we know where to place this block. If the + // target theme is not in the map, they should supply the name of a + // fallback region. If all that fails, give up with an exception. + assert(is_array($value['region'])); + $value['region'] = $value['region'][$theme] ?? $value['default_region'] ?? throw new ConfigActionException("Cannot determine which region to place this block into, because no default region was provided."); + } + + // Allow the recipe author to position the block in the region without + // needing to know exact weights. + if (array_key_exists('position', $value)) { + $blocks = $this->blockStorage->loadByProperties([ + 'theme' => $theme, + 'region' => $value['region'], + ]); + // Sort the blocks by weight. Don't use \Drupal\block\Entity\Block::sort() + // here because it seems to be intended to sort blocks in the UI, where + // we really just want to get the weights right in this situation. + uasort($blocks, fn (BlockInterface $a, BlockInterface $b) => $a->getWeight() <=> $b->getWeight()); + + $value['weight'] = match ($value['position']) { + 'first' => reset($blocks)->getWeight() - 1, + 'last' => end($blocks)->getWeight() + 1, + }; + } + // Remove values that are not valid properties of block entities. + unset($value['position'], $value['default_region']); + // Ensure a weight is set by default. + $value += ['weight' => 0]; + + $this->createAction->apply($configName, $value); + } + +} diff --git a/core/modules/block/src/Plugin/ConfigAction/PlaceBlockDeriver.php b/core/modules/block/src/Plugin/ConfigAction/PlaceBlockDeriver.php new file mode 100644 index 0000000000000000000000000000000000000000..7c7ebbcdb8b0d423985a5bfd113a3aa005db5f6f --- /dev/null +++ b/core/modules/block/src/Plugin/ConfigAction/PlaceBlockDeriver.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\block\Plugin\ConfigAction; + +use Drupal\Component\Plugin\Derivative\DeriverBase; + +/** + * Defines a deriver for the `placeBlock` config action. + * + * This creates two actions: `placeBlockInDefaultTheme`, and + * `placeBlockInAdminTheme`. They behave identically except for which theme + * they target. + */ +final class PlaceBlockDeriver extends DeriverBase { + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition) { + $this->derivatives['placeBlockInAdminTheme'] = [ + 'which_theme' => 'admin', + ] + $base_plugin_definition; + $this->derivatives['placeBlockInDefaultTheme'] = [ + 'which_theme' => 'default', + ] + $base_plugin_definition; + + return parent::getDerivativeDefinitions($base_plugin_definition); + } + +} diff --git a/core/modules/block/tests/src/Kernel/ConfigActionsTest.php b/core/modules/block/tests/src/Kernel/ConfigActionsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e4078943f9b5d782128c8d3516af8afbb9a8bc59 --- /dev/null +++ b/core/modules/block/tests/src/Kernel/ConfigActionsTest.php @@ -0,0 +1,143 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\block\Kernel; + +use Drupal\block\Entity\Block; +use Drupal\Component\Plugin\Exception\PluginNotFoundException; +use Drupal\Core\Config\Action\ConfigActionException; +use Drupal\Core\Config\Action\ConfigActionManager; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Extension\ThemeInstallerInterface; +use Drupal\KernelTests\KernelTestBase; + +/** + * @covers \Drupal\block\Plugin\ConfigAction\PlaceBlock + * @covers \Drupal\block\Plugin\ConfigAction\PlaceBlockDeriver + * @group block + */ +class ConfigActionsTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['block', 'user', 'system']; + + private readonly ConfigActionManager $configActionManager; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->container->get(ThemeInstallerInterface::class)->install([ + 'olivero', + 'claro', + 'umami', + ]); + $this->config('system.theme') + ->set('default', 'olivero') + ->set('admin', 'claro') + ->save(); + + $this->configActionManager = $this->container->get('plugin.manager.config_action'); + } + + /** + * @testWith ["placeBlockInDefaultTheme"] + * ["placeBlockInAdminTheme"] + */ + public function testActionOnlyWorksOnBlocks(string $action): void { + $this->expectException(PluginNotFoundException::class); + $this->expectExceptionMessage("The \"$action\" plugin does not exist."); + $this->configActionManager->applyAction($action, 'user.role.anonymous', []); + } + + public function testExistingBlockIsNotChanged(): void { + $extant_region = Block::load('olivero_powered')->getRegion(); + $this->assertNotSame('content', $extant_region); + + $this->configActionManager->applyAction('placeBlockInDefaultTheme', 'block.block.olivero_powered', [ + 'plugin' => 'system_powered_by_block', + 'region' => [ + 'olivero' => 'content', + ], + ]); + // The extant block should be unchanged. + $this->assertSame($extant_region, Block::load('olivero_powered')->getRegion()); + } + + /** + * @testWith ["placeBlockInDefaultTheme", "olivero", "header"] + * ["placeBlockInAdminTheme", "claro", "page_bottom"] + */ + public function testPlaceBlockInTheme(string $action, string $expected_theme, string $expected_region): void { + $this->configActionManager->applyAction($action, 'block.block.test_block', [ + 'plugin' => 'system_powered_by_block', + 'region' => [ + 'olivero' => 'header', + 'claro' => 'page_bottom', + ], + 'default_region' => 'content', + ]); + + $block = Block::load('test_block'); + $this->assertInstanceOf(Block::class, $block); + $this->assertSame('system_powered_by_block', $block->getPluginId()); + $this->assertSame($expected_theme, $block->getTheme()); + $this->assertSame($expected_region, $block->getRegion()); + + $this->expectException(ConfigActionException::class); + $this->expectExceptionMessage('Cannot determine which region to place this block into, because no default region was provided.'); + $this->configActionManager->applyAction($action, 'block.block.no_region', [ + 'plugin' => 'system_powered_by_block', + 'region' => [], + ]); + } + + public function testPlaceBlockInDefaultRegion(): void { + $this->config('system.theme')->set('default', 'umami')->save(); + $this->testPlaceBlockInTheme('placeBlockInDefaultTheme', 'umami', 'content'); + } + + public function testPlaceBlockAtPosition(): void { + // Ensure there's at least one block already in the region. + $block = Block::create([ + 'id' => 'block_1', + 'theme' => 'olivero', + 'region' => 'content_above', + 'weight' => 0, + 'plugin' => 'system_powered_by_block', + ]); + $block->save(); + + $this->configActionManager->applyAction('placeBlockInDefaultTheme', 'block.block.first', [ + 'plugin' => $block->getPluginId(), + 'region' => [ + $block->getTheme() => $block->getRegion(), + ], + 'position' => 'first', + ]); + $this->configActionManager->applyAction('placeBlockInDefaultTheme', 'block.block.last', [ + 'plugin' => $block->getPluginId(), + 'region' => [ + $block->getTheme() => $block->getRegion(), + ], + 'position' => 'last', + ]); + + // Query for blocks in the region, ordered by weight. + $blocks = $this->container->get(EntityTypeManagerInterface::class) + ->getStorage('block') + ->getQuery() + ->condition('theme', $block->getTheme()) + ->condition('region', $block->getRegion()) + ->sort('weight', 'ASC') + ->execute(); + $this->assertGreaterThanOrEqual(3, $blocks); + $this->assertSame('first', key($blocks)); + $this->assertSame('last', end($blocks)); + } + +}