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
b72df4495e
commit
b03d9ab801
|
@ -0,0 +1,138 @@
|
|||
<?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;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
<?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 @@ final class EntityCreate implements ConfigActionPluginInterface, ContainerFactor
|
|||
$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 @@ declare(strict_types=1);
|
|||
|
||||
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 @@ class WildcardConfigActionsTest extends KernelTestBase {
|
|||
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 @@ YAML;
|
|||
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.*', []);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue