Verified Commit 9da91eb5 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3431330 by phenaproxima, Rajab Natshah, Wim Leers: Add a dedicated...

Issue #3431330 by phenaproxima, Rajab Natshah, Wim Leers: Add a dedicated config action to add a button plugin and settings into the active toolbar for a CKEditor 5 editor

(cherry picked from commit 448d98a0df939e065b623f73b7862f861398130b)
parent 20a2005e
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -18,8 +18,8 @@ variables:
  MYSQL_DATABASE: mysql
  MYSQL_USER: drupaltestbot
  MYSQL_PASSWORD: drupaltestbotpw
  TEST_DIRECTORIES: "core/tests/Drupal/Tests/Core/Recipe core/tests/Drupal/KernelTests/Core/Recipe core/tests/Drupal/FunctionalTests/Core/Recipe core/tests/Drupal/KernelTests/Core/Config/Action core/tests/Drupal/KernelTests/Core/Config/Storage/Checkpoint core/tests/Drupal/Tests/Core/Config/Checkpoint core/tests/Drupal/Tests/Core/Config/Action core/modules/content_moderation/tests/src/Kernel/ConfigAction"
  CODE_DIRECTORIES: "core/lib/Drupal/Core/Recipe core/lib/Drupal/Core/Config/Action core/modules/config/tests/config_action_duplicate_test core/tests/fixtures/recipes core/lib/Drupal/Core/Config/Checkpoint core/modules/content_moderation/src/Plugin/ConfigAction"
  TEST_DIRECTORIES: "core/tests/Drupal/Tests/Core/Recipe core/tests/Drupal/KernelTests/Core/Recipe core/tests/Drupal/FunctionalTests/Core/Recipe core/tests/Drupal/KernelTests/Core/Config/Action core/tests/Drupal/KernelTests/Core/Config/Storage/Checkpoint core/tests/Drupal/Tests/Core/Config/Checkpoint core/tests/Drupal/Tests/Core/Config/Action core/modules/content_moderation/tests/src/Kernel/ConfigAction core/modules/ckeditor5/tests/src/Kernel/ConfigAction"
  CODE_DIRECTORIES: "core/lib/Drupal/Core/Recipe core/lib/Drupal/Core/Config/Action core/modules/config/tests/config_action_duplicate_test core/tests/fixtures/recipes core/lib/Drupal/Core/Config/Checkpoint core/modules/content_moderation/src/Plugin/ConfigAction core/modules/ckeditor5/src/Plugin/ConfigAction core/modules/ckeditor5/tests/src/Kernel/ConfigAction"
  ALL_DIRECTORIES: "${CODE_DIRECTORIES} ${TEST_DIRECTORIES}"

default:
+102 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\ckeditor5\Plugin\ConfigAction;

use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface;
use Drupal\Core\Config\Action\Attribute\ConfigAction;
use Drupal\Core\Config\Action\ConfigActionException;
use Drupal\Core\Config\Action\ConfigActionPluginInterface;
use Drupal\Core\Config\ConfigManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\editor\EditorInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

#[ConfigAction(
  id: 'editor:addItemToToolbar',
  admin_label: new TranslatableMarkup('Add an item to a CKEditor 5 toolbar'),
  entity_types: ['editor'],
)]
final class AddItemToToolbar implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {

  public function __construct(
    private readonly ConfigManagerInterface $configManager,
    private readonly CKEditor5PluginManagerInterface $pluginManager,
    private readonly string $pluginId,
  ) {}

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $container->get(ConfigManagerInterface::class),
      $container->get(CKEditor5PluginManagerInterface::class),
      $plugin_id,
    );
  }

  /**
   * {@inheritdoc}
   */
  public function apply(string $configName, mixed $value): void {
    $editor = $this->configManager->loadConfigEntityByName($configName);
    assert($editor instanceof EditorInterface);

    if ($editor->getEditor() !== 'ckeditor5') {
      throw new ConfigActionException(sprintf('The %s config action only works with editors that use CKEditor 5.', $this->pluginId));
    }

    $editor_settings = $editor->getSettings();
    if (is_string($value)) {
      $editor_settings['toolbar']['items'][] = $item_name = $value;
    }
    else {
      assert(is_array($value));

      $item_name = $value['item_name'];
      assert(is_string($item_name));

      $replace = $value['replace'] ?? FALSE;
      assert(is_bool($replace));

      $position = $value['position'] ?? NULL;
      if (is_int($position)) {
        // If we want to replace the item at this position, then `replace`
        // should be true. This would be useful if, for example, we wanted to
        // replace the Image button with the Media Library.
        array_splice($editor_settings['toolbar']['items'], $position, $replace ? 1 : 0, $item_name);
      }
      else {
        $editor_settings['toolbar']['items'][] = $item_name;
      }
    }
    // If we're just adding a vertical separator, there's nothing else we need
    // to do at this point.
    if ($item_name === '|') {
      return;
    }

    // If this item is associated with a plugin, ensure that it's configured
    // at the editor level, if necessary.
    /** @var \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition $definition */
    foreach ($this->pluginManager->getDefinitions() as $id => $definition) {
      if (array_key_exists($item_name, $definition->getToolbarItems())) {
        // If plugin settings already exist, don't change them.
        if (array_key_exists($id, $editor_settings['plugins'])) {
          break;
        }
        elseif ($definition->isConfigurable()) {
          /** @var \Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface $plugin */
          $plugin = $this->pluginManager->getPlugin($id, NULL);
          $editor_settings['plugins'][$id] = $plugin->defaultConfiguration();
        }
        // No need to examine any other plugins.
        break;
      }
    }

    $editor->setSettings($editor_settings)->save();
  }

}
+142 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Tests\ckeditor5\Kernel\ConfigAction;

use Drupal\Component\Serialization\Yaml;
use Drupal\Core\Config\Action\ConfigActionException;
use Drupal\Core\Recipe\InvalidConfigException;
use Drupal\Core\Recipe\Recipe;
use Drupal\Core\Recipe\RecipeRunner;
use Drupal\editor\Entity\Editor;
use Drupal\KernelTests\KernelTestBase;

/**
 * @covers \Drupal\ckeditor5\Plugin\ConfigAction\AddItemToToolbar
 * @group ckeditor5
 * @group Recipe
 */
class AddItemToToolbarConfigActionTest extends KernelTestBase {

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'ckeditor5',
    'editor',
    'filter',
    'filter_test',
    'user',
  ];

  /**
   * {@inheritdoc}
   */
  protected static $configSchemaCheckerExclusions = [
    // This test must be allowed to save invalid config, we can confirm that
    // any invalid stuff is validated by the config actions system.
    'editor.editor.filter_test',
  ];

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->installConfig('filter_test');

    $editor = Editor::create([
      'editor' => 'ckeditor5',
      'format' => 'filter_test',
    ]);
    $editor->save();

    /** @var array{toolbar: array{items: array<int, string>}} $settings */
    $settings = Editor::load('filter_test')?->getSettings();
    $this->assertSame(['heading', 'bold', 'italic'], $settings['toolbar']['items']);
  }

  /**
   * @param string|array<string, mixed> $action
   *   The value to pass to the config action.
   * @param string[] $expected_toolbar_items
   *   The items which should be in the editor toolbar, in the expected order.
   *
   * @testWith ["sourceEditing", ["heading", "bold", "italic", "sourceEditing"]]
   *   [{"item_name": "sourceEditing"}, ["heading", "bold", "italic", "sourceEditing"]]
   *   [{"item_name": "sourceEditing", "position": 1}, ["heading", "sourceEditing", "bold", "italic"]]
   *   [{"item_name": "sourceEditing", "position": 1, "replace": true}, ["heading", "sourceEditing", "italic"]]
   */
  public function testAddItemToToolbar(string|array $action, array $expected_toolbar_items): void {
    $recipe = $this->createRecipe([
      'name' => 'CKEditor 5 toolbar item test',
      'config' => [
        'actions' => [
          'editor.editor.filter_test' => [
            'addItemToToolbar' => $action,
          ],
        ],
      ],
    ]);
    RecipeRunner::processRecipe($recipe);

    /** @var array{toolbar: array{items: string[]}, plugins: array<string, array<mixed>>} $settings */
    $settings = Editor::load('filter_test')?->getSettings();
    $this->assertSame($expected_toolbar_items, $settings['toolbar']['items']);
    // The plugin's default settings should have been added.
    $this->assertSame([], $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']);
  }

  public function testAddNonExistentItem(): void {
    $recipe = $this->createRecipe([
      'name' => 'Add an invalid toolbar item',
      'config' => [
        'actions' => [
          'editor.editor.filter_test' => [
            'addItemToToolbar' => 'bogus_item',
          ],
        ],
      ],
    ]);

    $this->expectException(InvalidConfigException::class);
    $this->expectExceptionMessage("There were validation errors in editor.editor.filter_test:\n- settings.toolbar.items.3: The provided toolbar item <em class=\"placeholder\">bogus_item</em> is not valid.");
    RecipeRunner::processRecipe($recipe);
  }

  public function testActionRequiresCKEditor5(): void {
    $this->enableModules(['editor_test']);
    Editor::load('filter_test')?->setEditor('unicorn')->setSettings([])->save();

    $recipe = <<<YAML
name: Not a CKEditor
config:
  actions:
    editor.editor.filter_test:
      addItemToToolbar: strikethrough
YAML;

    $this->expectException(ConfigActionException::class);
    $this->expectExceptionMessage('The editor:addItemToToolbar config action only works with editors that use CKEditor 5.');
    RecipeRunner::processRecipe($this->createRecipe($recipe));
  }

  /**
   * @param string|array<mixed> $contents
   *   The contents of recipe.yml, as either a YAML string or an array to encode
   *   to YAML.
   *
   * @return \Drupal\Core\Recipe\Recipe
   */
  private function createRecipe(string|array $contents): Recipe {
    if (is_array($contents)) {
      $contents = Yaml::encode($contents);
    }
    $dir = uniqid('public://');
    mkdir($dir);
    file_put_contents($dir . '/recipe.yml', $contents);
    return Recipe::createFromDirectory($dir);
  }

}
+5 −0
Original line number Diff line number Diff line
@@ -365,6 +365,11 @@ parameters:
			count: 1
			path: core/lib/Drupal/Core/Recipe/UnknownRecipeException.php

		-
			message: "#^Method Drupal\\\\ckeditor5\\\\Plugin\\\\ConfigAction\\\\AddItemToToolbar\\:\\:create\\(\\) has parameter \\$configuration with no value type specified in iterable type array\\.$#"
			count: 1
			path: core/modules/ckeditor5/src/Plugin/ConfigAction/AddItemToToolbar.php

		-
			message: "#^Method Drupal\\\\content_moderation\\\\Plugin\\\\ConfigAction\\\\AddModeration\\:\\:create\\(\\) has parameter \\$configuration with no value type specified in iterable type array\\.$#"
			count: 1