From 351f12b441663d3d44866706f77441e9e568edbb Mon Sep 17 00:00:00 2001
From: Alex Pott <alex.a.pott@googlemail.com>
Date: Tue, 23 Jul 2024 09:16:45 +0100
Subject: [PATCH] Issue #3448131 by mandclu, phenaproxima, ultrabob,
 immaculatexavier, alexpott, thejimbirch, mtift, laura.j.johnson@gmail.com:
 Create flexible config actions to place a block in the admin or default
 themes

(cherry picked from commit fbc03520b12febb1d6eb26ef7398493be00f1ac6)
---
 .../src/Plugin/ConfigAction/PlaceBlock.php    |  95 ++++++++++++
 .../Plugin/ConfigAction/PlaceBlockDeriver.php |  32 ++++
 .../tests/src/Kernel/ConfigActionsTest.php    | 143 ++++++++++++++++++
 3 files changed, 270 insertions(+)
 create mode 100644 core/modules/block/src/Plugin/ConfigAction/PlaceBlock.php
 create mode 100644 core/modules/block/src/Plugin/ConfigAction/PlaceBlockDeriver.php
 create mode 100644 core/modules/block/tests/src/Kernel/ConfigActionsTest.php

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 000000000000..19f51ca08278
--- /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 000000000000..7c7ebbcdb8b0
--- /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 000000000000..e4078943f9b5
--- /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));
+  }
+
+}
-- 
GitLab