Skip to content
Snippets Groups Projects
Unverified Commit b03d9ab8 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3464550 by phenaproxima, a.dmitriiev, b_sharpe, alexpott: Create config...

Issue #3464550 by phenaproxima, a.dmitriiev, b_sharpe, alexpott: Create config action which can create an entity for every bundle of another entity type
parent b72df449
No related branches found
No related tags found
13 merge requests!11131[10.4.x-only-DO-NOT-MERGE]: Issue ##2842525 Ajax attached to Views exposed filter form does not trigger callbacks,!3878Removed unused condition head title for views,!3818Issue #2140179: $entity->original gets stale between updates,!3154Fixes #2987987 - CSRF token validation broken on routes with optional parameters.,!3133core/modules/system/css/components/hidden.module.css,!2964Issue #2865710 : Dependencies from only one instance of a widget are used in display modes,!2812Issue #3312049: [Followup] Fix Drupal.Commenting.FunctionComment.MissingReturnType returns for NULL,!2062Issue #3246454: Add weekly granularity to views date sort,!10223132456: Fix issue where views instances are emptied before an ajax request is complete,!617Issue #3043725: Provide a Entity Handler for user cancelation,!579Issue #2230909: Simple decimals fail to pass validation,!560Move callback classRemove outside of the loop,!555Issue #3202493
Pipeline #313446 passed with warnings
Pipeline: drupal

#313455

    <?php
    declare(strict_types=1);
    namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
    use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
    use Drupal\Core\Config\Action\Attribute\ConfigAction;
    use Drupal\Core\Config\Action\ConfigActionManager;
    use Drupal\Core\Config\Action\ConfigActionPluginInterface;
    use Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\CreateForEachBundleDeriver;
    use Drupal\Core\Config\ConfigManagerInterface;
    use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
    use Drupal\Core\StringTranslation\TranslatableMarkup;
    use Symfony\Component\DependencyInjection\ContainerInterface;
    /**
    * Creates config entities for each bundle of a particular entity type.
    *
    * An example of using this in a recipe's config actions would be:
    * @code
    * node.type.*:
    * createForEach:
    * language.content_settings.node.%bundle:
    * target_entity_type_id: node
    * target_bundle: %bundle
    * image.style.node_%bundle_big:
    * label: 'Big images for %label content'
    * @endcode
    * This will create two entities for each existing content type: a content
    * language settings entity, and an image style. For example, for a content type
    * called `blog`, this will create `language.content_settings.node.blog` and
    * `image.style.node_blog_big`, with the given values. The `%bundle` and
    * `%label` placeholders will be replaced with the ID and label of the content
    * type, respectively.
    *
    * @internal
    * This API is experimental.
    */
    #[ConfigAction(
    id: 'create_for_each_bundle',
    admin_label: new TranslatableMarkup('Create entities for each bundle of an entity type'),
    deriver: CreateForEachBundleDeriver::class,
    )]
    final class CreateForEachBundle implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
    /**
    * The placeholder which is replaced with the ID of the current bundle.
    *
    * @var string
    */
    private const BUNDLE_PLACEHOLDER = '%bundle';
    /**
    * The placeholder which is replaced with the label of the current bundle.
    *
    * @var string
    */
    private const LABEL_PLACEHOLDER = '%label';
    public function __construct(
    private readonly ConfigManagerInterface $configManager,
    private readonly string $createAction,
    private readonly ConfigActionManager $configActionManager,
    ) {}
    /**
    * {@inheritdoc}
    */
    public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
    // If there are no bundle entity types, this plugin should not be usable.
    if (empty($plugin_definition['entity_types'])) {
    throw new InvalidPluginDefinitionException($plugin_id, "The $plugin_id config action must be restricted to entity types that are bundles of another entity type.");
    }
    return new static(
    $container->get(ConfigManagerInterface::class),
    $plugin_definition['create_action'],
    $container->get('plugin.manager.config_action'),
    );
    }
    /**
    * {@inheritdoc}
    */
    public function apply(string $configName, mixed $value): void {
    assert(is_array($value));
    $bundle = $this->configManager->loadConfigEntityByName($configName);
    assert(is_object($bundle));
    $value = static::replacePlaceholders($value, [
    static::BUNDLE_PLACEHOLDER => $bundle->id(),
    static::LABEL_PLACEHOLDER => $bundle->label(),
    ]);
    foreach ($value as $name => $values) {
    // Invoke the actual create action via the config action manager, so that
    // the created entity will be validated.
    $this->configActionManager->applyAction('entity_create:' . $this->createAction, $name, $values);
    }
    }
    /**
    * Replaces placeholders recursively.
    *
    * @param mixed $data
    * The data to process. If this is an array, it'll be processed recursively.
    * @param array $replacements
    * An array whose keys are the placeholders to replace in the data, and
    * whose values are the the replacements. Normally this will only mention
    * the `%bundle` and `%label` placeholders. If $data is an array, the only
    * placeholder that is replaced in the array's keys is `%bundle`.
    *
    * @return mixed
    * The given $data, with the `%bundle` and `%label` placeholders replaced.
    */
    private static function replacePlaceholders(mixed $data, array $replacements): mixed {
    assert(array_key_exists(static::BUNDLE_PLACEHOLDER, $replacements));
    if (is_string($data)) {
    $data = str_replace(array_keys($replacements), $replacements, $data);
    }
    elseif (is_array($data)) {
    foreach ($data as $old_key => $value) {
    $value = static::replacePlaceholders($value, $replacements);
    // Only replace the `%bundle` placeholder in array keys.
    $new_key = str_replace(static::BUNDLE_PLACEHOLDER, $replacements[static::BUNDLE_PLACEHOLDER], $old_key);
    if ($old_key !== $new_key) {
    unset($data[$old_key]);
    }
    $data[$new_key] = $value;
    }
    }
    return $data;
    }
    }
    <?php
    declare(strict_types=1);
    namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver;
    use Drupal\Component\Plugin\Derivative\DeriverBase;
    use Drupal\Core\Entity\EntityTypeInterface;
    use Drupal\Core\Entity\EntityTypeManagerInterface;
    use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
    use Symfony\Component\DependencyInjection\ContainerInterface;
    /**
    * Generates derivatives for the create_for_each_bundle config action.
    *
    * @internal
    * This API is experimental.
    */
    final class CreateForEachBundleDeriver extends DeriverBase implements ContainerDeriverInterface {
    public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
    ) {}
    /**
    * {@inheritdoc}
    */
    public static function create(ContainerInterface $container, $base_plugin_id): static {
    return new static(
    $container->get(EntityTypeManagerInterface::class),
    );
    }
    /**
    * {@inheritdoc}
    */
    public function getDerivativeDefinitions($base_plugin_definition): array {
    // The action should only be available for entity types that are bundles of
    // another entity type, such as node types, media types, taxonomy
    // vocabularies, and so forth.
    $bundle_entity_types = array_filter(
    $this->entityTypeManager->getDefinitions(),
    fn (EntityTypeInterface $entity_type) => is_string($entity_type->getBundleOf()),
    );
    $base_plugin_definition['entity_types'] = array_keys($bundle_entity_types);
    $this->derivatives['createForEachIfNotExists'] = $base_plugin_definition + [
    'create_action' => 'createIfNotExists',
    ];
    $this->derivatives['createForEach'] = $base_plugin_definition + [
    'create_action' => 'create',
    ];
    return $this->derivatives;
    }
    }
    ......@@ -70,7 +70,9 @@ public function apply(string $configName, mixed $value): void {
    $id = substr($configName, strlen($entity_type->getConfigPrefix()) + 1);
    $entity_type_manager
    ->getStorage($entity_type->id())
    ->create($value + ['id' => $id])
    ->create($value + [
    $entity_type->getKey('id') => $id,
    ])
    ->save();
    }
    ......
    ......@@ -4,19 +4,27 @@
    namespace Drupal\KernelTests\Core\Recipe;
    use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
    use Drupal\Component\Plugin\Exception\PluginNotFoundException;
    use Drupal\Core\Config\Action\ConfigActionException;
    use Drupal\Core\Entity\EntityTypeManagerInterface;
    use Drupal\Core\Recipe\InvalidConfigException;
    use Drupal\Core\Recipe\RecipeRunner;
    use Drupal\entity_test\Entity\EntityTestBundle;
    use Drupal\field\Entity\FieldConfig;
    use Drupal\field\Entity\FieldStorageConfig;
    use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
    use Drupal\image\Entity\ImageStyle;
    use Drupal\KernelTests\KernelTestBase;
    use Drupal\language\Entity\ContentLanguageSettings;
    use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
    use Symfony\Component\Validator\Constraints\NotNull;
    use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
    /**
    * Tests config actions targeting multiple entities using wildcards.
    *
    * @covers \Drupal\Core\Config\Action\Plugin\ConfigAction\CreateForEachBundle
    * @group Recipe
    */
    class WildcardConfigActionsTest extends KernelTestBase {
    ......@@ -43,8 +51,8 @@ protected function setUp(): void {
    parent::setUp();
    $this->installConfig('node');
    $this->createContentType(['type' => 'one']);
    $this->createContentType(['type' => 'two']);
    $this->createContentType(['type' => 'one', 'name' => 'Type A']);
    $this->createContentType(['type' => 'two', 'name' => 'Type B']);
    EntityTestBundle::create(['id' => 'one'])->save();
    EntityTestBundle::create(['id' => 'two'])->save();
    ......@@ -132,4 +140,138 @@ public function testInvalidExpression(string $expression, string $expected_excep
    RecipeRunner::processRecipe($recipe);
    }
    /**
    * Tests that the createForEach action works as expected in normal conditions.
    */
    public function testCreateForEach(): void {
    $this->enableModules(['image', 'language']);
    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
    $manager = $this->container->get('plugin.manager.config_action');
    $manager->applyAction('createForEach', 'node.type.*', [
    'language.content_settings.node.%bundle' => [
    'target_entity_type_id' => 'node',
    'target_bundle' => '%bundle',
    ],
    ]);
    $this->assertIsObject(ContentLanguageSettings::load('node.one'));
    $this->assertIsObject(ContentLanguageSettings::load('node.two'));
    }
    /**
    * Tests that the createForEach action validates the config it creates.
    */
    public function testCreateForEachValidatesCreatedEntities(): void {
    $this->enableModules(['image']);
    // To prove that the validation runs, we need to disable strict schema
    // checking in this test. We need to explicitly unsubscribe it from events
    // because by this point in the test it has been fully wired up into the
    // container and can't be changed.
    $schema_checker = $this->container->get('testing.config_schema_checker');
    $this->container->get(EventDispatcherInterface::class)
    ->removeSubscriber($schema_checker);
    try {
    $this->container->get('plugin.manager.config_action')
    ->applyAction('createForEach', 'node.type.*', [
    'image.style.node__%bundle' => [],
    ]);
    $this->fail('Expected an exception to be thrown but it was not.');
    }
    catch (InvalidConfigException $e) {
    $this->assertSame('image.style.node__one', $e->data->getName());
    $this->assertCount(1, $e->violations);
    $this->assertSame('label', $e->violations[0]->getPropertyPath());
    $this->assertSame(NotNull::IS_NULL_ERROR, $e->violations[0]->getCode());
    }
    }
    /**
    * Tests using the `%label` placeholder with the createForEach action.
    */
    public function testCreateForEachWithLabel(): void {
    $this->enableModules(['image']);
    // We should be able to use the `%label` placeholder.
    $this->container->get('plugin.manager.config_action')
    ->applyAction('createForEach', 'node.type.*', [
    'image.style.node_%bundle_big' => [
    'label' => 'Big image for %label content',
    ],
    ]);
    $this->assertSame('Big image for Type A content', ImageStyle::load('node_one_big')?->label());
    $this->assertSame('Big image for Type B content', ImageStyle::load('node_two_big')?->label());
    }
    /**
    * Tests that the createForEachIfNotExists action ignores existing config.
    */
    public function testCreateForEachIfNotExists(): void {
    $this->enableModules(['language']);
    ContentLanguageSettings::create([
    'target_entity_type_id' => 'node',
    'target_bundle' => 'one',
    ])->save();
    $this->container->get('plugin.manager.config_action')
    ->applyAction('createForEachIfNotExists', 'node.type.*', [
    'language.content_settings.node.%bundle' => [
    'target_entity_type_id' => 'node',
    'target_bundle' => '%bundle',
    ],
    ]);
    $this->assertIsObject(ContentLanguageSettings::loadByEntityTypeBundle('node', 'two'));
    }
    /**
    * Tests that the createForEach action errs on conflict with existing config.
    */
    public function testCreateForEachErrorsIfAlreadyExists(): void {
    $this->enableModules(['language']);
    ContentLanguageSettings::create([
    'target_entity_type_id' => 'node',
    'target_bundle' => 'one',
    ])->save();
    $this->expectExceptionMessage(ConfigActionException::class);
    $this->expectExceptionMessage('Entity language.content_settings.node.one exists');
    $this->container->get('plugin.manager.config_action')
    ->applyAction('createForEach', 'node.type.*', [
    'language.content_settings.node.%bundle' => [
    'target_entity_type_id' => 'node',
    'target_bundle' => '%bundle',
    ],
    ]);
    }
    /**
    * Tests that the createForEach action only works on bundle entities.
    */
    public function testCreateForEachNotAvailableOnNonBundleEntities(): void {
    $this->enableModules(['language']);
    // We should not be able to use this action on entities that aren't
    // themselves bundles of another entity type.
    $this->expectException(PluginNotFoundException::class);
    $this->expectExceptionMessage('The "language_content_settings" entity does not support the "createForEach" config action.');
    $this->container->get('plugin.manager.config_action')
    ->applyAction('createForEach', 'language.content_settings.node.*', []);
    }
    /**
    * Tests that the createForEach action requires bundle entity types to exist.
    */
    public function testCreateForEachErrorsIfNoBundleEntityTypesExist(): void {
    $this->disableModules(['node', 'entity_test']);
    $manager = $this->container->get('plugin.manager.config_action');
    $manager->clearCachedDefinitions();
    $this->expectException(InvalidPluginDefinitionException::class);
    $this->expectExceptionMessage('The create_for_each_bundle:createForEach config action must be restricted to entity types that are bundles of another entity type.');
    $manager->applyAction('create_for_each_bundle:createForEach', 'node.type.*', []);
    }
    }
    0% Loading or .
    You are about to add 0 people to the discussion. Proceed with caution.
    Finish editing this message first!
    Please register or to comment