diff --git a/core/core.services.yml b/core/core.services.yml index c7f809998a75709e6e44295feefe6297a036bc8c..da5011df77c4da8567ceb3d87cb420aa6b75f4cf 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -67,6 +67,17 @@ parameters: services: _defaults: autoconfigure: true + plugin.manager.config_action: + class: Drupal\Core\Config\Action\ConfigActionManager + parent: default_plugin_manager + arguments: ['@config.manager', '@config.storage', '@config.typed', '@config.factory'] + Drupal\Core\DefaultContent\Importer: + autowire: true + Drupal\Core\DefaultContent\AdminAccountSwitcher: + arguments: + $isSuperUserAccessEnabled: '%security.enable_super_user%' + autowire: true + public: false # Simple cache contexts, directly derived from the request context. cache_context.ip: class: Drupal\Core\Cache\Context\IpCacheContext @@ -385,6 +396,14 @@ services: public: false tags: - { name: backend_overridable } + config.storage.checkpoint: + class: Drupal\Core\Config\Checkpoint\CheckpointStorage + arguments: [ '@config.storage', '@config.checkpoints', '@keyvalue' ] + Drupal\Core\Config\Checkpoint\CheckpointStorageInterface: '@config.storage.checkpoint' + config.checkpoints: + class: Drupal\Core\Config\Checkpoint\LinearHistory + arguments: [ '@state', '@datetime.time' ] + Drupal\Core\Config\Checkpoint\CheckpointListInterface: '@config.checkpoints' config.import_transformer: class: Drupal\Core\Config\ImportStorageTransformer arguments: ['@event_dispatcher', '@database', '@lock', '@lock.persistent'] diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc index a52761642d89f4af4f33dffd5eac2c0e2877fbf7..def94662f534f3ad8423d5a8c9f5ce7475e476a2 100644 --- a/core/includes/install.core.inc +++ b/core/includes/install.core.inc @@ -26,6 +26,8 @@ use Drupal\Core\Installer\InstallerKernel; use Drupal\Core\Language\Language; use Drupal\Core\Language\LanguageManager; +use Drupal\Core\Recipe\Recipe; +use Drupal\Core\Recipe\RecipeRunner; use Drupal\Core\Site\Settings; use Drupal\Core\StringTranslation\Translator\FileTranslation; use Drupal\Core\StackMiddleware\ReverseProxyMiddleware; @@ -839,6 +841,27 @@ function install_tasks($install_state) { array_slice($tasks, $key, NULL, TRUE); } + if (!empty($install_state['parameters']['recipe'])) { + // The install state indicates that we are installing from a recipe. + $key = array_search('install_profile_modules', array_keys($tasks), TRUE); + unset($tasks['install_profile_modules']); + unset($tasks['install_profile_themes']); + unset($tasks['install_install_profile']); + $recipe_tasks = [ + 'install_recipe_required_modules' => [ + 'display_name' => t('Install required modules'), + 'type' => 'batch', + ], + 'install_recipe_batch' => [ + 'display_name' => t('Install recipe'), + 'type' => 'batch', + ], + ]; + $tasks = array_slice($tasks, 0, $key, TRUE) + + $recipe_tasks + + array_slice($tasks, $key, NULL, TRUE); + } + // Now add any tasks defined by the installation profile. if (!empty($install_state['parameters']['profile'])) { // Load the profile install file, because it is not always loaded when @@ -2548,3 +2571,71 @@ function _install_config_locale_overrides_process_batch(array $names, array $lan } $context['finished'] = 1; } + +/** + * Installs required modules prior to applying a recipe via the installer. + * + * @see install_tasks() + * + * @internal + * All installer code is internal. + */ +function install_recipe_required_modules() { + // We need to manually trigger the installation of core-provided entity types, + // as those will not be handled by the module installer. + // @see install_profile_modules() + install_core_entity_type_definitions(); + + $batch_builder = new BatchBuilder(); + $batch_builder + ->setFinishCallback([ConfigImporterBatch::class, 'finish']) + ->setTitle(t('Installing required modules')) + ->setInitMessage(t('Starting required module installation.')) + ->setErrorMessage(t('Required module installation has encountered an error.')); + + $files = \Drupal::service('extension.list.module')->getList(); + + // Always install required modules first. + $required = []; + + foreach ($files as $module => $extension) { + if (!empty($extension->info['required'])) { + $required[$module] = $extension->sort; + } + } + arsort($required); + + // The system module is already installed. See install_base_system(). + unset($required['system']); + + foreach ($required as $module => $weight) { + $batch_builder->addOperation( + '_install_module_batch', + [$module, $files[$module]->info['name']], + ); + } + return $batch_builder->toArray(); +} + +/** + * Creates a batch for the recipe system to process. + * + * @see install_tasks() + * + * @internal + * This API is experimental. + */ +function install_recipe_batch(&$install_state) { + $batch_builder = new BatchBuilder(); + $batch_builder + ->setTitle(t('Installing recipe')) + ->setInitMessage(t('Starting recipe installation.')) + ->setErrorMessage(t('Recipe installation has encountered an error.')); + + $recipe = Recipe::createFromDirectory($install_state['parameters']['recipe']); + foreach (RecipeRunner::toBatchOperations($recipe) as $step) { + $batch_builder->addOperation(...$step); + } + + return $batch_builder->toArray(); +} diff --git a/core/lib/Drupal/Core/Command/InstallCommand.php b/core/lib/Drupal/Core/Command/InstallCommand.php index ef4ec3c1080a2b017c9887d4aa3f635389596c06..1c00717cfb35760546e1b63ad88a42fbdf02baa4 100644 --- a/core/lib/Drupal/Core/Command/InstallCommand.php +++ b/core/lib/Drupal/Core/Command/InstallCommand.php @@ -48,11 +48,12 @@ public function __construct($class_loader) { protected function configure() { $this->setName('install') ->setDescription('Installs a Drupal demo site. This is not meant for production and might be too simple for custom development. It is a quick and easy way to get Drupal running.') - ->addArgument('install-profile', InputArgument::OPTIONAL, 'Install profile to install the site in.') + ->addArgument('install-profile-or-recipe', InputArgument::OPTIONAL, 'Install profile or recipe directory from which to install the site.') ->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in.', 'en') ->addOption('site-name', NULL, InputOption::VALUE_OPTIONAL, 'Set the site name.', 'Drupal') ->addUsage('demo_umami --langcode fr') - ->addUsage('standard --site-name QuickInstall'); + ->addUsage('standard --site-name QuickInstall') + ->addUsage('core/recipes/standard --site-name RecipeBuiltSite'); parent::configure(); } @@ -78,15 +79,43 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } - $install_profile = $input->getArgument('install-profile'); - if ($install_profile && !$this->validateProfile($install_profile, $io)) { - return 1; - } - if (!$install_profile) { + $install_profile_or_recipe = $input->getArgument('install-profile-or-recipe'); + + if (!$install_profile_or_recipe) { + // User did not provide a recipe or install profile. $install_profile = $this->selectProfile($io); } + // Determine if an install profile or a recipe has been provided. + elseif ($this->validateProfile($install_profile_or_recipe)) { + // User provided an install profile. + $install_profile = $install_profile_or_recipe; + } + elseif ($this->validateRecipe($install_profile_or_recipe)) { + // User provided a recipe. + $recipe = $install_profile_or_recipe; + } + else { + $error_msg = sprintf("'%s' is not a valid install profile or recipe.", $install_profile_or_recipe); - return $this->install($this->classLoader, $io, $install_profile, $input->getOption('langcode'), $this->getSitePath(), $input->getOption('site-name')); + // If it does not look like a path make suggestions based upon available + // profiles. + if (!str_contains('/', $install_profile_or_recipe)) { + $alternatives = []; + foreach (array_keys($this->getProfiles(TRUE, FALSE)) as $profile_name) { + $lev = levenshtein($install_profile_or_recipe, $profile_name); + if ($lev <= strlen($profile_name) / 4 || str_contains($profile_name, $install_profile_or_recipe)) { + $alternatives[] = $profile_name; + } + } + if (!empty($alternatives)) { + $error_msg .= sprintf(" Did you mean '%s'?", implode("' or '", $alternatives)); + } + } + $io->getErrorStyle()->error($error_msg); + return 1; + } + + return $this->install($this->classLoader, $io, $install_profile ?? '', $input->getOption('langcode'), $this->getSitePath(), $input->getOption('site-name'), $recipe ?? ''); } /** @@ -123,6 +152,8 @@ protected function isDrupalInstalled() { * The path to install the site to, like 'sites/default'. * @param string $site_name * The site name. + * @param string $recipe + * The recipe to use for installing. * * @throws \Exception * Thrown when failing to create the $site_path directory or settings.php. @@ -130,7 +161,7 @@ protected function isDrupalInstalled() { * @return int * The command exit status. */ - protected function install($class_loader, SymfonyStyle $io, $profile, $langcode, $site_path, $site_name) { + protected function install($class_loader, SymfonyStyle $io, $profile, $langcode, $site_path, $site_name, string $recipe) { $password = Crypt::randomBytesBase64(12); $parameters = [ 'interactive' => FALSE, @@ -165,6 +196,9 @@ protected function install($class_loader, SymfonyStyle $io, $profile, $langcode, ], ], ]; + if ($recipe) { + $parameters['parameters']['recipe'] = $recipe; + } // Create the directory and settings.php if not there so that the installer // works. @@ -276,29 +310,29 @@ protected function selectProfile(SymfonyStyle $io) { * * @param string $install_profile * Install profile to validate. - * @param \Symfony\Component\Console\Style\SymfonyStyle $io - * Symfony style output decorator. * * @return bool * TRUE if the profile is valid, FALSE if not. */ - protected function validateProfile($install_profile, SymfonyStyle $io) { + protected function validateProfile($install_profile): bool { // Allow people to install hidden and non-distribution profiles if they // supply the argument. - $profiles = $this->getProfiles(TRUE, FALSE); - if (!isset($profiles[$install_profile])) { - $error_msg = sprintf("'%s' is not a valid install profile.", $install_profile); - $alternatives = []; - foreach (array_keys($profiles) as $profile_name) { - $lev = levenshtein($install_profile, $profile_name); - if ($lev <= strlen($profile_name) / 4 || str_contains($profile_name, $install_profile)) { - $alternatives[] = $profile_name; - } - } - if (!empty($alternatives)) { - $error_msg .= sprintf(" Did you mean '%s'?", implode("' or '", $alternatives)); - } - $io->getErrorStyle()->error($error_msg); + return array_key_exists($install_profile, $this->getProfiles(TRUE, FALSE)); + } + + /** + * Validates a user provided recipe. + * + * @param string $recipe + * The path to the recipe to validate. + * + * @return bool + * TRUE if the recipe exists, FALSE if not. + */ + protected function validateRecipe(string $recipe): bool { + // It is impossible to validate a recipe fully at this point because that + // requires a container. + if (!is_dir($recipe) || !is_file($recipe . '/recipe.yml')) { return FALSE; } return TRUE; diff --git a/core/lib/Drupal/Core/Command/QuickStartCommand.php b/core/lib/Drupal/Core/Command/QuickStartCommand.php index 572f6415c20f2f23a0204c43fdd7773b0bcc28c9..6f16ccb9101256b53882c790778661b15b177404 100644 --- a/core/lib/Drupal/Core/Command/QuickStartCommand.php +++ b/core/lib/Drupal/Core/Command/QuickStartCommand.php @@ -28,7 +28,7 @@ class QuickStartCommand extends Command { protected function configure() { $this->setName('quick-start') ->setDescription('Installs a Drupal site and runs a web server. This is not meant for production and might be too simple for custom development. It is a quick and easy way to get Drupal running.') - ->addArgument('install-profile', InputArgument::OPTIONAL, 'Install profile to install the site in.') + ->addArgument('install-profile-or-recipe', InputArgument::OPTIONAL, 'Install profile or recipe directory from which to install the site.') ->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in. Defaults to en.', 'en') ->addOption('site-name', NULL, InputOption::VALUE_OPTIONAL, 'Set the site name. Defaults to Drupal.', 'Drupal') ->addOption('host', NULL, InputOption::VALUE_OPTIONAL, 'Provide a host for the server to run on. Defaults to 127.0.0.1.', '127.0.0.1') @@ -36,7 +36,8 @@ protected function configure() { ->addOption('suppress-login', 's', InputOption::VALUE_NONE, 'Disable opening a login URL in a browser.') ->addUsage('demo_umami --langcode fr') ->addUsage('standard --site-name QuickInstall --host localhost --port 8080') - ->addUsage('minimal --host my-site.com --port 80'); + ->addUsage('minimal --host my-site.com --port 80') + ->addUsage('core/recipes/standard --site-name MyDrupalRecipe'); parent::configure(); } @@ -49,7 +50,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $arguments = [ 'command' => 'install', - 'install-profile' => $input->getArgument('install-profile'), + 'install-profile-or-recipe' => $input->getArgument('install-profile-or-recipe'), '--langcode' => $input->getOption('langcode'), '--site-name' => $input->getOption('site-name'), ]; diff --git a/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php b/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php new file mode 100644 index 0000000000000000000000000000000000000000..c6abf041d6c24cdd348d90b485e826c18e0c64e0 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action\Attribute; + +// cspell:ignore inflector +use Drupal\Core\Config\Action\Exists; +use Drupal\Core\StringTranslation\TranslatableMarkup; + +/** + * @internal + * This API is experimental. + */ +#[\Attribute(\Attribute::TARGET_METHOD)] +final class ActionMethod { + + /** + * @param \Drupal\Core\Config\Action\Exists $exists + * Determines behavior of action depending on entity existence. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|string $adminLabel + * The admin label for the user interface. + * @param bool|string $pluralize + * Determines whether to create a pluralized version of the method to enable + * the action to be called multiple times before saving the entity. The + * default behavior is to create an action with a plural form as determined + * by \Symfony\Component\String\Inflector\EnglishInflector::pluralize(). + * For example, 'grantPermission' has a pluralized version of + * 'grantPermissions'. If a string is provided this will be the full action + * ID. For example, if the method is called 'addArray' this can be set to + * 'addMultipleArrays'. Set to FALSE if a pluralized version does not make + * logical sense. + */ + public function __construct( + public readonly Exists $exists = Exists::ErrorIfNotExists, + public readonly TranslatableMarkup|string $adminLabel = '', + public readonly bool|string $pluralize = TRUE + ) { + } + +} diff --git a/core/lib/Drupal/Core/Config/Action/Attribute/ConfigAction.php b/core/lib/Drupal/Core/Config/Action/Attribute/ConfigAction.php new file mode 100644 index 0000000000000000000000000000000000000000..d8df99cf100cc8441e6384bfad65e9a6ea08b8ac --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/Attribute/ConfigAction.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action\Attribute; + +use Drupal\Component\Plugin\Attribute\Plugin; +use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException; +use Drupal\Core\StringTranslation\TranslatableMarkup; + +/** + * Defines a ConfigAction attribute object. + * + * Plugin Namespace: Plugin\ConfigAction + * + * @ingroup config_action_api + * + * @internal + * This API is experimental. + * + * @see \Drupal\Core\Config\Action\ConfigActionPluginInterface + * @see \Drupal\Core\Config\Action\ConfigActionManager + * @see plugin_api + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +final class ConfigAction extends Plugin { + + /** + * Constructs a ConfigAction attribute. + * + * @param string $id + * The plugin ID. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $admin_label + * The administrative label of the config action. This is optional when + * using a deriver, but in that case the deriver should add an admin label. + * @param string[] $entity_types + * (optional) Allows action shorthand IDs for the listed config entity + * types. If '*' is present in the array then it can apply to all entity + * types. An empty array means that shorthand action IDs are not available + * for this plugin. See ConfigActionManager::convertActionToPluginId(). + * @param class-string|null $deriver + * (optional) The deriver class. + * + * @see \Drupal\Core\Config\Action\ConfigActionManager::convertActionToPluginId() + */ + public function __construct( + public readonly string $id, + public readonly ?TranslatableMarkup $admin_label = NULL, + public readonly array $entity_types = [], + public readonly ?string $deriver = NULL, + ) { + if ($this->admin_label === NULL && $this->deriver === NULL) { + throw new InvalidPluginDefinitionException($id, sprintf("The '%s' config action plugin must have either an admin label or a deriver", $id)); + } + } + +} diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionException.php b/core/lib/Drupal/Core/Config/Action/ConfigActionException.php new file mode 100644 index 0000000000000000000000000000000000000000..3ad3579b260ba643ae7b03c2a1d68f776e69a4e8 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/ConfigActionException.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action; + +/** + * @internal + * This API is experimental. + */ +final class ConfigActionException extends \RuntimeException { +} diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php b/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php new file mode 100644 index 0000000000000000000000000000000000000000..cb030d6ba333c856958d5da5b9c9258ebd474d84 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php @@ -0,0 +1,221 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action; + +use Drupal\Component\Plugin\PluginBase; +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Config\Action\Attribute\ConfigAction; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Config\ConfigManagerInterface; +use Drupal\Core\Config\Schema\Mapping; +use Drupal\Core\Config\StorageInterface; +use Drupal\Core\Config\TypedConfigManagerInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Plugin\DefaultPluginManager; +use Drupal\Core\Recipe\InvalidConfigException; +use Drupal\Core\Validation\Plugin\Validation\Constraint\FullyValidatableConstraint; + +/** + * @defgroup config_action_api Config Action API + * @{ + * Information about the classes and interfaces that make up the Config Action + * API. + * + * Configuration actions are plugins that manipulate simple configuration or + * configuration entities. The configuration action plugin manager can apply + * configuration actions. For example, the API is leveraged by recipes to create + * roles if they do not exist already and grant permissions to those roles. + * + * To define a configuration action in a module you need to: + * - Define a Config Action plugin by creating a new class that implements the + * \Drupal\Core\Config\Action\ConfigActionPluginInterface, in namespace + * Plugin\ConfigAction under your module namespace. For more information about + * creating plugins, see the @link plugin_api Plugin API topic. @endlink + * - Config action plugins use the attributes defined by + * \Drupal\Core\Config\Action\Attribute\ConfigAction. See the + * @link attribute Attributes topic @endlink for more information about + * attributes. + * + * Further information and examples: + * - \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod derives + * configuration actions from config entity methods which have the + * \Drupal\Core\Config\Action\Attribute\ActionMethod attribute. + * - \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityCreate allows you to + * create configuration entities if they do not exist. + * - \Drupal\Core\Config\Action\Plugin\ConfigAction\SimpleConfigUpdate allows + * you to update simple configuration using a config action. + * @} + * + * @internal + * This API is experimental. + */ +class ConfigActionManager extends DefaultPluginManager { + + /** + * Constructs a new \Drupal\Core\Config\Action\ConfigActionManager object. + * + * @param \Traversable $namespaces + * An object that implements \Traversable which contains the root paths + * keyed by the corresponding namespace to look for plugin implementations. + * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend + * Cache backend instance to use. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler to invoke the alter hook with. + * @param \Drupal\Core\Config\ConfigManagerInterface $configManager + * The config manager. + * @param \Drupal\Core\Config\StorageInterface $configStorage + * The active config storage. + * @param \Drupal\Core\Config\TypedConfigManagerInterface $typedConfig + * The typed configuration manager service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + */ + public function __construct( + \Traversable $namespaces, + CacheBackendInterface $cache_backend, + ModuleHandlerInterface $module_handler, + protected readonly ConfigManagerInterface $configManager, + protected readonly StorageInterface $configStorage, + protected readonly TypedConfigManagerInterface $typedConfig, + protected readonly ConfigFactoryInterface $configFactory, + ) { + assert($namespaces instanceof \ArrayAccess, '$namespaces can be accessed like an array'); + // Enable this namespace to be searched for plugins. + $namespaces[__NAMESPACE__] = 'core/lib/Drupal/Core/Config/Action'; + + parent::__construct('Plugin/ConfigAction', $namespaces, $module_handler, ConfigActionPluginInterface::class, ConfigAction::class); + + $this->alterInfo('config_action'); + $this->setCacheBackend($cache_backend, 'config_action'); + } + + /** + * Applies a config action. + * + * @param string $action_id + * The ID of the action to apply. This can be a complete configuration + * action plugin ID or a shorthand action ID that is available for the + * entity type of the provided configuration name. + * @param string $configName + * The configuration name. This may be the full name of a config object, or + * it may contain wildcards (to target all config entities of a specific + * type, or a subset thereof). See + * ConfigActionManager::getConfigNamesMatchingExpression() for more detail. + * @param mixed $data + * The data for the action. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * Thrown when the config action cannot be found. + * @throws \Drupal\Core\Config\Action\ConfigActionException + * Thrown when the config action fails to apply. + * + * @see \Drupal\Core\Config\Action\ConfigActionManager::getConfigNamesMatchingExpression() + */ + public function applyAction(string $action_id, string $configName, mixed $data): void { + if (!$this->hasDefinition($action_id)) { + // Get the full plugin ID from the shorthand map, if it is available. + $entity_type = $this->configManager->getEntityTypeIdByName($configName); + if ($entity_type) { + $action_id = $this->getShorthandActionIdsForEntityType($entity_type)[$action_id] ?? $action_id; + } + } + /** @var \Drupal\Core\Config\Action\ConfigActionPluginInterface $action */ + $action = $this->createInstance($action_id); + foreach ($this->getConfigNamesMatchingExpression($configName) as $name) { + $action->apply($name, $data); + $typed_config = $this->typedConfig->createFromNameAndData($name, $this->configFactory->get($name)->getRawData()); + // All config objects are mappings. + assert($typed_config instanceof Mapping); + foreach ($typed_config->getConstraints() as $constraint) { + // Only validate the config if it has explicitly been marked as being + // validatable. + if ($constraint instanceof FullyValidatableConstraint) { + /** @var \Symfony\Component\Validator\ConstraintViolationList $violations */ + $violations = $typed_config->validate(); + if (count($violations) > 0) { + throw new InvalidConfigException($violations, $typed_config); + } + break; + } + } + } + } + + /** + * Gets the names of all active config objects that match an expression. + * + * @param string $expression + * The expression to match. This may be the full name of a config object, + * or it may contain wildcards (to target all config entities of a specific + * type, or a subset thereof). For example: + * - `user.role.*` would target all user roles. + * - `user.role.anonymous` would target only the anonymous user role. + * - `core.entity_view_display.node.*.default` would target the default + * view display of every content type. + * - `core.entity_form_display.*.*.default` would target the default form + * display of every bundle of every entity type. + * The expression MUST begin with the prefix of a config entity type -- + * for example, `field.field.` in the case of fields, or `user.role.` for + * user roles. The prefix cannot contain wildcards. + * + * @return string[] + * The names of all active config objects that match the expression. + * + * @throws \Drupal\Core\Config\Action\ConfigActionException + * Thrown if the expression does not match any known config entity type's + * prefix, or if the expression cannot be parsed. + */ + private function getConfigNamesMatchingExpression(string $expression): array { + // If there are no wildcards, we can return the config name as-is. + if (!str_contains($expression, '.*')) { + return [$expression]; + } + + $entity_type = $this->configManager->getEntityTypeIdByName($expression); + if (empty($entity_type)) { + throw new ConfigActionException("No installed config entity type uses the prefix in the expression '$expression'. Either there is a typo in the expression or this recipe should install an additional module or depend on another recipe."); + } + /** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */ + $entity_type = $this->configManager->getEntityTypeManager() + ->getDefinition($entity_type); + $prefix = $entity_type->getConfigPrefix(); + + // Convert the expression to a regular expression. We assume that * should + // match the characters allowed by + // \Drupal\Core\Config\ConfigBase::validateName(), which is permissive. + $expression = str_replace('\\*', '[^.:?*<>"\'\/\\\\]+', preg_quote($expression)); + $matches = @preg_grep("/^$expression$/", $this->configStorage->listAll("$prefix.")); + if ($matches === FALSE) { + throw new ConfigActionException("The expression '$expression' could not be parsed."); + } + return $matches; + } + + /** + * Gets a map of shorthand action IDs to plugin IDs for an entity type. + * + * @param string $entityType + * The entity type ID to get the map for. + * + * @return string[] + * An array of plugin IDs keyed by shorthand action ID for the provided + * entity type. + */ + protected function getShorthandActionIdsForEntityType(string $entityType): array { + $map = []; + foreach ($this->getDefinitions() as $plugin_id => $definition) { + if (in_array($entityType, $definition['entity_types'], TRUE) || in_array('*', $definition['entity_types'], TRUE)) { + $regex = '/' . PluginBase::DERIVATIVE_SEPARATOR . '([^' . PluginBase::DERIVATIVE_SEPARATOR . ']*)$/'; + $action_id = preg_match($regex, $plugin_id, $matches) ? $matches[1] : $plugin_id; + if (isset($map[$action_id])) { + throw new DuplicateConfigActionIdException(sprintf('The plugins \'%s\' and \'%s\' both resolve to the same shorthand action ID for the \'%s\' entity type', $plugin_id, $map[$action_id], $entityType)); + } + $map[$action_id] = $plugin_id; + } + } + return $map; + } + +} diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php b/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..6431ec959e8c135985f878e9c1c660c48eab9ee3 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action; + +/** + * @internal + * This API is experimental. + */ +interface ConfigActionPluginInterface { + + /** + * Applies the config action. + * + * @param string $configName + * The name of the config to apply the action to. + * @param mixed $value + * The value for the action to use. + * + * @throws ConfigActionException + */ + public function apply(string $configName, mixed $value): void; + +} diff --git a/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php b/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php new file mode 100644 index 0000000000000000000000000000000000000000..f1d69c57a311d88e8805206f947efe412e225ed4 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action; + +/** + * Exception thrown if there are conflicting shorthand action IDs. + * + * @internal + * This API is experimental. + */ +class DuplicateConfigActionIdException extends \RuntimeException { +} diff --git a/core/lib/Drupal/Core/Config/Action/EntityMethodException.php b/core/lib/Drupal/Core/Config/Action/EntityMethodException.php new file mode 100644 index 0000000000000000000000000000000000000000..3f4e71ec797ce8ec882cbe3feed5129fbb40db42 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/EntityMethodException.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action; + +/** + * @internal + * This API is experimental. + */ +final class EntityMethodException extends \RuntimeException { +} diff --git a/core/lib/Drupal/Core/Config/Action/Exists.php b/core/lib/Drupal/Core/Config/Action/Exists.php new file mode 100644 index 0000000000000000000000000000000000000000..6649962477b7ab4ff04ab484f2343cd3152d8863 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/Exists.php @@ -0,0 +1,44 @@ +<?php +// phpcs:ignoreFile + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action; + +use Drupal\Core\Config\Entity\ConfigEntityInterface; + +/** + * @internal + * This API is experimental. + */ +enum Exists { + case ErrorIfExists; + case ErrorIfNotExists; + case ReturnEarlyIfExists; + case ReturnEarlyIfNotExists; + + /** + * Determines if an action should return early depending on $entity. + * + * @param string $configName + * The config name supplied to the action. + * @param \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity + * The entity, if it exists. + * + * @return bool + * TRUE if the action should return early, FALSE if not. + * + * @throws \Drupal\Core\Config\Action\ConfigActionException + * Thrown depending on $entity and the value of $this. + */ + public function returnEarly(string $configName, ?ConfigEntityInterface $entity): bool { + return match (TRUE) { + $this === self::ReturnEarlyIfExists && $entity !== NULL, + $this === self::ReturnEarlyIfNotExists && $entity === NULL => TRUE, + $this === self::ErrorIfExists && $entity !== NULL => throw new ConfigActionException(sprintf('Entity %s exists', $configName)), + $this === self::ErrorIfNotExists && $entity === NULL => throw new ConfigActionException(sprintf('Entity %s does not exist', $configName)), + default => FALSE + }; + } + +} diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php new file mode 100644 index 0000000000000000000000000000000000000000..c5f73f243859fc2bd477589aaff562f8f0885ab7 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver; + +use Drupal\Component\Plugin\Derivative\DeriverBase; +use Drupal\Core\Config\Action\Exists; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * @internal + * This API is experimental. + */ +final class EntityCreateDeriver extends DeriverBase { + use StringTranslationTrait; + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition) { + // These derivatives apply to all entity types. + $base_plugin_definition['entity_types'] = ['*']; + + $this->derivatives['ensure_exists'] = $base_plugin_definition + ['constructor_args' => ['exists' => Exists::ReturnEarlyIfExists]]; + $this->derivatives['ensure_exists']['admin_label'] = $this->t('Ensure entity exists'); + + $this->derivatives['create'] = $base_plugin_definition + ['constructor_args' => ['exists' => Exists::ErrorIfExists]]; + $this->derivatives['create']['admin_label'] = $this->t('Entity create'); + + return $this->derivatives; + } + +} diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php new file mode 100644 index 0000000000000000000000000000000000000000..2577d8d7b923847be2d54a61abfd8fb4a16f8cf1 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php @@ -0,0 +1,143 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver; + +// cspell:ignore inflector +use Drupal\Component\Plugin\Derivative\DeriverBase; +use Drupal\Component\Plugin\PluginBase; +use Drupal\Core\Config\Action\Attribute\ActionMethod; +use Drupal\Core\Config\Action\EntityMethodException; +use Drupal\Core\Config\Entity\ConfigEntityTypeInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\String\Inflector\EnglishInflector; +use Symfony\Component\String\Inflector\InflectorInterface; + +/** + * Derives config action methods from attributed config entity methods. + * + * @internal + * This API is experimental. + */ +final class EntityMethodDeriver extends DeriverBase implements ContainerDeriverInterface { + + use StringTranslationTrait; + + /** + * Inflector to pluralize words. + */ + protected readonly InflectorInterface $inflector; + + /** + * Constructs new EntityMethodDeriver. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The entity type manager. + */ + public function __construct(protected readonly EntityTypeManagerInterface $entityTypeManager) { + $this->inflector = new EnglishInflector(); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, $base_plugin_id) { + return new static( + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition) { + // Scan all the config entity classes for attributes. + foreach ($this->entityTypeManager->getDefinitions() as $entity_type) { + if ($entity_type instanceof ConfigEntityTypeInterface) { + $reflectionClass = new \ReflectionClass($entity_type->getClass()); + while ($reflectionClass) { + foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + // Only process a method if it is declared on the current class. + // Methods on the parent class will be processed later. This allows + // for a parent to have an attribute and an overriding class does + // not need one. For example, + // \Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay::setComponent() + // and \Drupal\Core\Entity\EntityDisplayBase::setComponent(). + if ($method->getDeclaringClass()->getName() === $reflectionClass->getName()) { + foreach ($method->getAttributes(ActionMethod::class) as $attribute) { + $this->processMethod($method, $attribute->newInstance(), $entity_type, $base_plugin_definition); + } + } + } + $reflectionClass = $reflectionClass->getParentClass(); + } + } + } + return $this->derivatives; + } + + /** + * Processes a method to create derivatives. + * + * @param \ReflectionMethod $method + * The entity method. + * @param \Drupal\Core\Config\Action\Attribute\ActionMethod $action_attribute + * The entity method attribute. + * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type + * The entity type. + * @param array $derivative + * The base plugin definition that will used to create the derivative. + */ + private function processMethod(\ReflectionMethod $method, ActionMethod $action_attribute, ConfigEntityTypeInterface $entity_type, array $derivative): void { + $derivative['admin_label'] = $action_attribute->adminLabel ?: $this->t('@entity_type @method', ['@entity_type' => $entity_type->getLabel(), '@method' => $method->name]); + $derivative['constructor_args'] = [ + 'method' => $method->name, + 'exists' => $action_attribute->exists, + 'numberOfParams' => $method->getNumberOfParameters(), + 'numberOfRequiredParams' => $method->getNumberOfRequiredParameters(), + 'pluralized' => FALSE, + ]; + $derivative['entity_types'] = [$entity_type->id()]; + // Build a config action identifier from the entity type's config + // prefix and the method name. For example, the Role entity adds a + // 'user.role:grantPermission' action. + $this->addDerivative($method->name, $entity_type, $derivative, $method->name); + + $pluralized_name = match(TRUE) { + is_string($action_attribute->pluralize) => $action_attribute->pluralize, + $action_attribute->pluralize === FALSE => '', + default => $this->inflector->pluralize($method->name)[0] + }; + // Add a pluralized version of the plugin. + if (strlen($pluralized_name) > 0) { + $derivative['constructor_args']['pluralized'] = TRUE; + $derivative['admin_label'] = $this->t('@admin_label (multiple calls)', ['@admin_label' => $derivative['admin_label']]); + $this->addDerivative($pluralized_name, $entity_type, $derivative, $method->name); + } + } + + /** + * Adds a derivative. + * + * @param string $action_id + * The action ID. + * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type + * The entity type. + * @param array $derivative + * The derivative definition. + * @param string $methodName + * The method name. + */ + private function addDerivative(string $action_id, ConfigEntityTypeInterface $entity_type, array $derivative, string $methodName): void { + $id = $entity_type->getConfigPrefix() . PluginBase::DERIVATIVE_SEPARATOR . $action_id; + if (isset($this->derivatives[$id])) { + throw new EntityMethodException(sprintf('Duplicate action can not be created for ID \'%s\' for %s::%s(). The existing action is for the ::%s() method', $id, $entity_type->getClass(), $methodName, $this->derivatives[$id]['constructor_args']['method'])); + } + $this->derivatives[$id] = $derivative; + } + +} diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/PermissionsPerBundleDeriver.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/PermissionsPerBundleDeriver.php new file mode 100644 index 0000000000000000000000000000000000000000..0b30e51480598f749c023a8bf9280e546fb92606 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/PermissionsPerBundleDeriver.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver; + +use Drupal\Component\Plugin\Derivative\DeriverBase; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface; +use Symfony\Component\DependencyInjection\Container; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * @internal + * This API is experimental. + */ +final class PermissionsPerBundleDeriver extends DeriverBase implements ContainerDeriverInterface { + + public function __construct( + private readonly EntityTypeManagerInterface $entityTypeManager, + ) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, $base_plugin_id) { + return new static( + $container->get(EntityTypeManagerInterface::class), + ); + } + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition) { + foreach ($this->entityTypeManager->getDefinitions() as $id => $entity_type) { + if ($entity_type->getPermissionGranularity() === 'bundle' && ($bundle_entity_type = $entity_type->getBundleEntityType()) !== NULL) { + // Convert unique plugin IDs, like `taxonomy_vocabulary`, into strings + // like `TaxonomyVocabulary`. + $suffix = Container::camelize($bundle_entity_type); + + $this->derivatives["grantPermissionsForEach{$suffix}"] = [ + 'target_entity_type' => $id, + ] + $base_plugin_definition; + } + } + return parent::getDerivativeDefinitions($base_plugin_definition); + } + +} diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php new file mode 100644 index 0000000000000000000000000000000000000000..ad33645b3ddf57b2f40ceb767c57d3d8cbc2ed10 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action\Plugin\ConfigAction; + +use Drupal\Core\Config\Action\Attribute\ConfigAction; +use Drupal\Core\Config\Action\ConfigActionException; +use Drupal\Core\Config\Action\ConfigActionPluginInterface; +use Drupal\Core\Config\Action\Exists; +use Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityCreateDeriver; +use Drupal\Core\Config\ConfigManagerInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * @internal + * This API is experimental. + */ +#[ConfigAction( + id: 'entity_create', + deriver: EntityCreateDeriver::class, +)] +final class EntityCreate implements ConfigActionPluginInterface, ContainerFactoryPluginInterface { + + /** + * Constructs a EntityCreate object. + * + * @param \Drupal\Core\Config\ConfigManagerInterface $configManager + * The config manager. + * @param \Drupal\Core\Config\Action\Exists $exists + * Determines behavior of action depending on entity existence. + */ + public function __construct( + protected readonly ConfigManagerInterface $configManager, + protected readonly Exists $exists + ) { + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + assert(is_array($plugin_definition) && is_array($plugin_definition['constructor_args']), '$plugin_definition contains the expected settings'); + return new static($container->get('config.manager'), ...$plugin_definition['constructor_args']); + } + + /** + * {@inheritdoc} + */ + public function apply(string $configName, mixed $value): void { + if (!is_array($value)) { + throw new ConfigActionException(sprintf("The value provided to create %s must be an array", $configName)); + } + + /** @var \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity */ + $entity = $this->configManager->loadConfigEntityByName($configName); + if ($this->exists->returnEarly($configName, $entity)) { + return; + } + + $entity_type_manager = $this->configManager->getEntityTypeManager(); + $entity_type_id = $this->configManager->getEntityTypeIdByName($configName); + if ($entity_type_id === NULL) { + throw new ConfigActionException(sprintf("Cannot determine a config entity type from %s", $configName)); + } + /** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */ + $entity_type = $entity_type_manager->getDefinition($entity_type_id); + + $id = substr($configName, strlen($entity_type->getConfigPrefix()) + 1); + $entity_type_manager + ->getStorage($entity_type->id()) + ->create($value + ['id' => $id]) + ->save(); + } + +} diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php new file mode 100644 index 0000000000000000000000000000000000000000..73189f4511670c359c80d5cd4e4ad2c99561b3be --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php @@ -0,0 +1,149 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action\Plugin\ConfigAction; + +use Drupal\Core\Config\Action\Attribute\ConfigAction; +use Drupal\Core\Config\Action\ConfigActionPluginInterface; +use Drupal\Core\Config\Action\EntityMethodException; +use Drupal\Core\Config\Action\Exists; +use Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver; +use Drupal\Core\Config\ConfigManagerInterface; +use Drupal\Core\Config\Entity\ConfigEntityInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Makes config entity methods with the ActionMethod attribute into actions. + * + * For example, adding the ActionMethod attribute to + * \Drupal\user\Entity\Role::grantPermission() allows permissions to be added to + * roles via config actions. + * + * When calling \Drupal\Core\Config\Action\ConfigActionManager::applyAction() + * the $data parameter is mapped to the method's arguments using the following + * rules: + * - If $data is not an array, the method must only have one argument or one + * required argument. + * - If $data is an array and the method only accepts a single argument, the + * array will be passed to the first argument. + * - If $data is an array and the method accepts more than one argument, $data + * will be unpacked into the method arguments. + * + * @internal + * This API is experimental. + * + * @see \Drupal\Core\Config\Action\Attribute\ActionMethod + */ +#[ConfigAction( + id: 'entity_method', + deriver: EntityMethodDeriver::class, +)] +final class EntityMethod implements ConfigActionPluginInterface, ContainerFactoryPluginInterface { + + /** + * Constructs a EntityMethod object. + * + * @param string $pluginId + * The config action plugin ID. + * @param \Drupal\Core\Config\ConfigManagerInterface $configManager + * The config manager. + * @param string $method + * The method to call on the config entity. + * @param \Drupal\Core\Config\Action\Exists $exists + * Determines behavior of action depending on entity existence. + * @param int $numberOfParams + * The number of parameters the method has. + * @param int $numberOfRequiredParams + * The number of required parameters the method has. + * @param bool $pluralized + * Determines whether an array maps to multiple calls. + */ + public function __construct( + protected readonly string $pluginId, + protected readonly ConfigManagerInterface $configManager, + protected readonly string $method, + protected readonly Exists $exists, + protected readonly int $numberOfParams, + protected readonly int $numberOfRequiredParams, + protected readonly bool $pluralized + ) { + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + assert(is_array($plugin_definition) && is_array($plugin_definition['constructor_args']), '$plugin_definition contains the expected settings'); + return new static( + $plugin_id, + $container->get('config.manager'), + ...$plugin_definition['constructor_args'] + ); + } + + /** + * {@inheritdoc} + */ + public function apply(string $configName, mixed $value): void { + /** @var \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity */ + $entity = $this->configManager->loadConfigEntityByName($configName); + if ($this->exists->returnEarly($configName, $entity)) { + return; + } + + $entity = $this->pluralized ? $this->applyPluralized($entity, $value) : $this->applySingle($entity, $value); + $entity->save(); + } + + /** + * Applies the action to entity treating the $values array as multiple calls. + * + * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity + * The entity to apply the action to. + * @param mixed $values + * The values for the action to use. + * + * @return \Drupal\Core\Config\Entity\ConfigEntityInterface + * The unsaved entity with the action applied. + */ + private function applyPluralized(ConfigEntityInterface $entity, mixed $values): ConfigEntityInterface { + if (!is_array($values)) { + throw new EntityMethodException(sprintf('The pluralized entity method config action \'%s\' requires an array value in order to call %s::%s() multiple times', $this->pluginId, $entity->getEntityType()->getClass(), $this->method)); + } + foreach ($values as $value) { + $entity = $this->applySingle($entity, $value); + } + return $entity; + } + + /** + * Applies the action to entity treating the $values array a single call. + * + * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity + * The entity to apply the action to. + * @param mixed $value + * The value for the action to use. + * + * @return \Drupal\Core\Config\Entity\ConfigEntityInterface + * The unsaved entity with the action applied. + */ + private function applySingle(ConfigEntityInterface $entity, mixed $value): ConfigEntityInterface { + // If $value is not an array then we only support calling the method if the + // number of parameters or required parameters is 1. If there is only 1 + // parameter and $value is an array then assume that the parameter expects + // an array. + if (!is_array($value) || $this->numberOfParams === 1) { + if ($this->numberOfRequiredParams !== 1 && $this->numberOfParams !== 1) { + throw new EntityMethodException(sprintf('Entity method config action \'%s\' requires an array value. The number of parameters or required parameters for %s::%s() is not 1', $this->pluginId, $entity->getEntityType()->getClass(), $this->method)); + } + $entity->{$this->method}($value); + } + else { + $entity->{$this->method}(...$value); + } + return $entity; + } + +} diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/PermissionsPerBundle.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/PermissionsPerBundle.php new file mode 100644 index 0000000000000000000000000000000000000000..503196869801d71e7cfff6b005fafcc59e8b2a74 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/PermissionsPerBundle.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action\Plugin\ConfigAction; + +use Drupal\Component\Assertion\Inspector; +use Drupal\Core\Config\Action\Attribute\ConfigAction; +use Drupal\Core\Config\Action\ConfigActionException; +use Drupal\Core\Config\Action\ConfigActionPluginInterface; +use Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\PermissionsPerBundleDeriver; +use Drupal\Core\Config\ConfigManagerInterface; +use Drupal\Core\Entity\EntityTypeBundleInfoInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\user\RoleInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * @internal + * This API is experimental. + */ +#[ConfigAction( + id: 'permissions_per_bundle', + entity_types: ['user_role'], + deriver: PermissionsPerBundleDeriver::class, +)] +final class PermissionsPerBundle implements ConfigActionPluginInterface, ContainerFactoryPluginInterface { + + public function __construct( + private readonly ConfigManagerInterface $configManager, + private readonly EntityTypeBundleInfoInterface $entityTypeBundleInfo, + private readonly string $targetEntityType, + ) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + assert(is_array($plugin_definition)); + $target_entity_type = $plugin_definition['target_entity_type']; + + return new static( + $container->get(ConfigManagerInterface::class), + $container->get(EntityTypeBundleInfoInterface::class), + $target_entity_type, + ); + } + + /** + * {@inheritdoc} + */ + public function apply(string $configName, mixed $value): void { + $role = $this->configManager->loadConfigEntityByName($configName); + if (!($role instanceof RoleInterface)) { + throw new ConfigActionException(sprintf("Cannot determine role from %s", $configName)); + } + + assert(is_string($value) || is_array($value)); + [$permissions, $except_bundles] = self::parseValue($value); + + if (empty($permissions) || !Inspector::assertAllMatch('%bundle', $permissions, TRUE)) { + throw new ConfigActionException(sprintf("The permissions provided %s must be an array of strings that contain '%%bundle'.", var_export($value, TRUE))); + } + + $bundles = $this->entityTypeBundleInfo->getBundleInfo($this->targetEntityType); + foreach (array_keys($bundles) as $bundle_id) { + if (in_array($bundle_id, $except_bundles, TRUE)) { + continue; + } + /** @var string[] $actual_permissions */ + $actual_permissions = str_replace('%bundle', $bundle_id, $permissions); + array_walk($actual_permissions, $role->grantPermission(...)); + } + $role->save(); + } + + /** + * Parses the value supplied to ::apply(). + * + * @param string|array<string|string[]> $value + * One of: + * - A single string (a permission template). + * - An array of strings (several permission templates). + * - An array with a `permissions` element, and an optional `except` + * element, either of which can be an array or a string. `except` accepts + * a single bundle, or a list of bundles, to exclude from the permissions + * being granted. + * + * @return array<int, array<int<0, max>, array<string>|string>> + * An indexed array with two elements: the array of permissions to grant, + * and the list of bundles to ignore. + */ + private static function parseValue(string|array $value): array { + if (is_string($value)) { + return [[$value], []]; + } + + if (array_is_list($value)) { + return [$value, []]; + } + + $permissions = $value['permissions'] ?? []; + $except_bundles = $value['except'] ?? []; + return [(array) $permissions, (array) $except_bundles]; + } + +} diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php new file mode 100644 index 0000000000000000000000000000000000000000..d6485304cc0fa0d24ccbb450f428367e42906695 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Action\Plugin\ConfigAction; + +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\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * @internal + * This API is experimental. + */ +#[ConfigAction( + id: 'simple_config_update', + admin_label: new TranslatableMarkup('Simple configuration update'), +)] +final class SimpleConfigUpdate implements ConfigActionPluginInterface, ContainerFactoryPluginInterface { + + /** + * Constructs a SimpleConfigUpdate object. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory. + */ + public function __construct( + protected readonly ConfigFactoryInterface $configFactory, + ) { + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + return new static($container->get('config.factory')); + } + + /** + * {@inheritdoc} + */ + public function apply(string $configName, mixed $value): void { + $config = $this->configFactory->getEditable($configName); + // @todo https://www.drupal.org/i/3439713 Should we error if this is a + // config entity? + if ($config->isNew()) { + throw new ConfigActionException(sprintf('Config %s does not exist so can not be updated', $configName)); + } + + // Expect $value to be an array whose keys are the config keys to update. + if (!is_array($value)) { + throw new ConfigActionException(sprintf('Config %s can not be updated because $value is not an array', $configName)); + } + foreach ($value as $key => $value) { + $config->set($key, $value); + } + $config->save(); + } + +} diff --git a/core/lib/Drupal/Core/Config/Checkpoint/Checkpoint.php b/core/lib/Drupal/Core/Config/Checkpoint/Checkpoint.php new file mode 100644 index 0000000000000000000000000000000000000000..46e1247f728ed9487040a30df7b3d9b6bf1c5d39 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Checkpoint/Checkpoint.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Checkpoint; + +/** + * A value object to store information about a checkpoint. + * + * @internal + * This API is experimental. + */ +final class Checkpoint { + + /** + * Constructs a checkpoint object. + * + * @param string $id + * The checkpoint's ID. + * @param \Stringable|string $label + * The human-readable label. + * @param int $timestamp + * The timestamp when the checkpoint was created. + * @param string|null $parent + * The ID of the checkpoint's parent. + */ + public function __construct( + public readonly string $id, + public readonly \Stringable|string $label, + public readonly int $timestamp, + public readonly ?string $parent, + ) { + } + +} diff --git a/core/lib/Drupal/Core/Config/Checkpoint/CheckpointExistsException.php b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointExistsException.php new file mode 100644 index 0000000000000000000000000000000000000000..35a203b8be767c71a09b48a2c81eed2a2304cf4e --- /dev/null +++ b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointExistsException.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Checkpoint; + +/** + * Thrown when trying to add a checkpoint with an ID that already exists. + * + * @internal + * This API is experimental. + */ +final class CheckpointExistsException extends \RuntimeException { +} diff --git a/core/lib/Drupal/Core/Config/Checkpoint/CheckpointListInterface.php b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointListInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..dd3af0b07ddfff85e5685012035b8dae6475a608 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointListInterface.php @@ -0,0 +1,88 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Checkpoint; + +/** + * Maintains a list of checkpoints. + * + * @internal + * This API is experimental. + * + * @see \Drupal\Core\Config\Checkpoint\Checkpoint + * + * @phpstan-extends \IteratorAggregate<string, \Drupal\Core\Config\Checkpoint\Checkpoint> + */ +interface CheckpointListInterface extends \IteratorAggregate, \Countable { + + /** + * Gets the active checkpoint. + * + * @return \Drupal\Core\Config\Checkpoint\Checkpoint|null + * The active checkpoint or NULL if there are no checkpoints. + */ + public function getActiveCheckpoint(): ?Checkpoint; + + /** + * Gets a checkpoint. + * + * @param string $id + * The checkpoint ID. + * + * @return \Drupal\Core\Config\Checkpoint\Checkpoint + * The checkpoint. + * + * @throws \Drupal\Core\Config\Checkpoint\UnknownCheckpointException + * Thrown when the provided checkpoint does not exist. + */ + public function get(string $id): Checkpoint; + + /** + * Gets a checkpoint's parents. + * + * @param string $id + * The checkpoint ID. + * + * @return iterable<string, \Drupal\Core\Config\Checkpoint\Checkpoint> + */ + public function getParents(string $id): iterable; + + /** + * Adds a new checkpoint. + * + * @param string $id + * The ID of the checkpoint add. + * @param string|\Stringable $label + * The checkpoint label. + * + * @return \Drupal\Core\Config\Checkpoint\Checkpoint + * The new checkpoint, which is now at the end of the checkpoint sequence. + * + * @throws \Drupal\Core\Config\Checkpoint\CheckpointExistsException + * Thrown when the ID already exists. + */ + public function add(string $id, string|\Stringable $label): Checkpoint; + + /** + * Deletes a checkpoint. + * + * @param string $id + * The ID of the checkpoint to delete up to: only checkpoints after this one + * will remain. + * + * @return $this + * + * @throws \Drupal\Core\Config\Checkpoint\UnknownCheckpointException + * Thrown when provided checkpoint ID does not exist. + */ + public function delete(string $id): static; + + /** + * Deletes all checkpoints. + * + * @return $this + */ + public function deleteAll(): static; + +} diff --git a/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorage.php b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorage.php new file mode 100644 index 0000000000000000000000000000000000000000..d139ce82b6b9f39e2bec261b25a29f7e880539eb --- /dev/null +++ b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorage.php @@ -0,0 +1,494 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Checkpoint; + +use Drupal\Core\Config\Config; +use Drupal\Core\Config\ConfigCollectionEvents; +use Drupal\Core\Config\ConfigCrudEvent; +use Drupal\Core\Config\ConfigEvents; +use Drupal\Core\Config\ConfigRenameEvent; +use Drupal\Core\Config\StorableConfigBase; +use Drupal\Core\Config\StorageInterface; +use Drupal\Core\KeyValueStore\KeyValueFactoryInterface; +use Drupal\Core\KeyValueStore\KeyValueStoreInterface; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Provides a config storage that can make checkpoints. + * + * This storage wraps the active storage, and provides the ability to take + * checkpoints. Once a checkpoint has been created all configuration operations + * made after the checkpoint will be recorded, so it is possible to revert to + * original state when the checkpoint was taken. + * + * This class cannot be used to checkpoint another storage since it relies on + * events triggered by the configuration system in order to work. It is the + * responsibility of the caller to construct this class with the active storage. + * + * @internal + * This API is experimental. + */ +final class CheckpointStorage implements CheckpointStorageInterface, EventSubscriberInterface, LoggerAwareInterface { + + use LoggerAwareTrait; + + /** + * Used as prefix to a config checkpoint collection. + * + * If this code is copied in order to checkpoint a different storage then + * this value must be changed. + */ + private const KEY_VALUE_COLLECTION_PREFIX = 'config.checkpoint.'; + + /** + * Used to store the list of collections in each checkpoint. + * + * Note this cannot be a valid configuration name. + * + * @see \Drupal\Core\Config\ConfigBase::validateName() + */ + private const CONFIG_COLLECTION_KEY = 'collections'; + + /** + * The key value stores that store configuration changed for each checkpoint. + * + * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface[] + */ + private array $keyValueStores; + + /** + * The checkpoint to read from. + * + * @var \Drupal\Core\Config\Checkpoint\Checkpoint|null + */ + private ?Checkpoint $readFromCheckpoint = NULL; + + /** + * Constructs a CheckpointStorage object. + * + * @param \Drupal\Core\Config\StorageInterface $activeStorage + * The active configuration storage. + * @param \Drupal\Core\Config\Checkpoint\CheckpointListInterface $checkpoints + * The list of checkpoints. + * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $keyValueFactory + * The key value factory. + * @param string $collection + * (optional) The configuration collection. + */ + public function __construct( + private readonly StorageInterface $activeStorage, + private readonly CheckpointListInterface $checkpoints, + private readonly KeyValueFactoryInterface $keyValueFactory, + private readonly string $collection = StorageInterface::DEFAULT_COLLECTION, + ) { + } + + /** + * {@inheritdoc} + */ + public function exists($name) { + if (count($this->checkpoints) === 0) { + throw new NoCheckpointsException(); + } + + foreach ($this->getCheckpointsToReadFrom() as $checkpoint) { + $in_checkpoint = $this->getKeyValue($checkpoint->id, $this->collection)->get($name); + if ($in_checkpoint !== NULL) { + // If $in_checkpoint is FALSE then the configuration has been deleted. + return $in_checkpoint !== FALSE; + } + } + return $this->activeStorage->exists($name); + } + + /** + * {@inheritdoc} + */ + public function read($name) { + $return = $this->readMultiple([$name]); + return $return[$name] ?? FALSE; + } + + /** + * {@inheritdoc} + */ + public function readMultiple(array $names) { + if (count($this->checkpoints) === 0) { + throw new NoCheckpointsException(); + } + $return = []; + + foreach ($this->getCheckpointsToReadFrom() as $checkpoint) { + $return = array_merge( + $return, + $this->getKeyValue($checkpoint->id, $this->collection)->getMultiple($names) + ); + // Remove the read names from the list to fetch. + $names = array_diff($names, array_keys($return)); + if (empty($names)) { + // All the configuration has been read. Nothing more to do. + break; + } + } + + // Names not found in the checkpoints have not been modified: read from + // active storage. + if (!empty($names)) { + $return = array_merge( + $return, + $this->activeStorage->readMultiple($names) + ); + } + + // Remove any renamed or new configuration (FALSE has been recorded for + // these operations in the checkpoint). + // @see ::onConfigRename() + // @see ::onConfigSaveAndDelete() + return array_filter($return); + } + + /** + * {@inheritdoc} + */ + public function encode($data) { + return $this->activeStorage->encode($data); + } + + /** + * {@inheritdoc} + */ + public function decode($raw) { + return $this->activeStorage->decode($raw); + } + + /** + * {@inheritdoc} + */ + public function listAll($prefix = '') { + if (count($this->checkpoints) === 0) { + throw new NoCheckpointsException(); + } + + $names = $new_configuration = []; + + foreach ($this->getCheckpointsToReadFrom() as $checkpoint) { + $checkpoint_names = array_keys(array_filter($this->getKeyValue($checkpoint->id, $this->collection)->getAll(), function (mixed $value, string $name) use (&$new_configuration, $prefix) { + if ($name === static::CONFIG_COLLECTION_KEY) { + return FALSE; + } + // Remove any that don't start with the prefix. + if ($prefix !== '' && !str_starts_with($name, $prefix)) { + return FALSE; + } + // We've determined in a previous checkpoint that the configuration did + // not exist. + if (in_array($name, $new_configuration, TRUE)) { + return FALSE; + } + // If the value is FALSE then the configuration was created after the + // checkpoint. + if ($value === FALSE) { + $new_configuration[] = $name; + return FALSE; + } + return TRUE; + }, ARRAY_FILTER_USE_BOTH)); + $names = array_merge($names, $checkpoint_names); + } + + // Remove any names that did not exist prior to the checkpoint. + $active_names = array_diff($this->activeStorage->listAll($prefix), $new_configuration); + + $names = array_unique(array_merge($names, $active_names)); + sort($names); + return $names; + } + + /** + * {@inheritdoc} + */ + public function createCollection($collection) { + $collection = new self( + $this->activeStorage->createCollection($collection), + $this->checkpoints, + $this->keyValueFactory, + $collection + ); + // \Drupal\Core\Config\Checkpoint\CheckpointStorage::$readFromCheckpoint is + // assigned by reference so that it is consistent across all collection + // objects created from the same initial object. + $collection->readFromCheckpoint = &$this->readFromCheckpoint; + return $collection; + } + + /** + * {@inheritdoc} + */ + public function getAllCollectionNames() { + $names = []; + foreach ($this->getCheckpointsToReadFrom() as $checkpoint) { + $names = array_merge( + $names, + $this->getKeyValue($checkpoint->id, StorageInterface::DEFAULT_COLLECTION)->get(static::CONFIG_COLLECTION_KEY, []) + ); + } + return array_unique(array_merge($this->activeStorage->getAllCollectionNames(), $names)); + } + + /** + * {@inheritdoc} + */ + public function getCollectionName() { + return $this->collection; + } + + /** + * {@inheritdoc} + */ + public function checkpoint(string|\Stringable $label): Checkpoint { + // Generate a new ID based on the state of the current active checkpoint. + $active_checkpoint = $this->checkpoints->getActiveCheckpoint(); + if (!$active_checkpoint instanceof Checkpoint) { + // @todo https://www.drupal.org/i/3408525 Consider options for generating + // a real fingerprint. + $id = hash('sha1', random_bytes(32)); + return $this->checkpoints->add($id, $label); + } + + // Determine if we need to create a new checkpoint by checking if + // configuration has changed since the last checkpoint. + $collections = $this->getAllCollectionNames(); + $collections[] = StorageInterface::DEFAULT_COLLECTION; + foreach ($collections as $collection) { + $current_checkpoint_data[$collection] = $this->getKeyValue($active_checkpoint->id, $collection)->getAll(); + // Remove the collections key because it is irrelevant. + unset($current_checkpoint_data[$collection][static::CONFIG_COLLECTION_KEY]); + // If there is no data in the collection then there is no need to hash + // the empty array. + if (empty($current_checkpoint_data[$collection])) { + unset($current_checkpoint_data[$collection]); + } + } + + if (!empty($current_checkpoint_data)) { + // Use json_encode() here because it is both quicker and results in + // smaller output than serialize(). + $id = hash('sha1', ($active_checkpoint->parent ?? '') . json_encode($current_checkpoint_data)); + return $this->checkpoints->add($id, $label); + } + + $this->logger?->notice('A backup checkpoint was not created because nothing has changed since the "{active}" checkpoint was created.', [ + 'active' => $active_checkpoint->label, + ]); + return $active_checkpoint; + } + + /** + * {@inheritdoc} + */ + public function setCheckpointToReadFrom(string|Checkpoint $checkpoint_id): static { + if ($checkpoint_id instanceof Checkpoint) { + $checkpoint_id = $checkpoint_id->id; + } + $this->readFromCheckpoint = $this->checkpoints->get($checkpoint_id); + return $this; + } + + /** + * Gets the key value storage for the provided checkpoint. + * + * @param string $checkpoint + * The checkpoint to get the key value storage for. + * @param string $collection + * The config collection to get the key value storage for. + * + * @return \Drupal\Core\KeyValueStore\KeyValueStoreInterface + * The key value storage for the provided checkpoint. + */ + private function getKeyValue(string $checkpoint, string $collection): KeyValueStoreInterface { + $checkpoint_key = $checkpoint; + if ($collection !== StorageInterface::DEFAULT_COLLECTION) { + $checkpoint_key = $collection . '.' . $checkpoint_key; + } + return $this->keyValueStores[$checkpoint_key] ??= $this->keyValueFactory->get(self::KEY_VALUE_COLLECTION_PREFIX . $checkpoint_key); + } + + /** + * Gets the checkpoints to read from. + * + * @return \Traversable<string, \Drupal\Core\Config\Checkpoint\Checkpoint> + * The checkpoints, keyed by ID. + */ + private function getCheckpointsToReadFrom(): \Traversable { + $checkpoint = $this->checkpoints->getActiveCheckpoint(); + + /** @var \Drupal\Core\Config\Checkpoint\Checkpoint[] $checkpoints_to_read_from */ + $checkpoints_to_read_from = [$checkpoint]; + if ($checkpoint->id !== $this->readFromCheckpoint?->id) { + // Follow ancestors to find the checkpoint to start reading from. + foreach ($this->checkpoints->getParents($checkpoint->id) as $checkpoint) { + array_unshift($checkpoints_to_read_from, $checkpoint); + if ($checkpoint->id === $this->readFromCheckpoint?->id) { + break; + } + } + } + + // Replay in parent to child order. + foreach ($checkpoints_to_read_from as $checkpoint) { + yield $checkpoint->id => $checkpoint; + } + } + + /** + * Updates checkpoint when configuration is saved. + * + * @param \Drupal\Core\Config\ConfigCrudEvent $event + * The configuration event. + */ + public function onConfigSaveAndDelete(ConfigCrudEvent $event): void { + $active_checkpoint = $this->checkpoints->getActiveCheckpoint(); + if ($active_checkpoint === NULL) { + return; + } + + $saved_config = $event->getConfig(); + $collection = $saved_config->getStorage()->getCollectionName(); + $this->storeCollectionName($collection); + + $key_value = $this->getKeyValue($active_checkpoint->id, $collection); + + // If we have not yet stored a checkpoint for this configuration we should. + if ($key_value->get($saved_config->getName()) === NULL) { + $original_data = $this->getOriginalConfig($saved_config); + // An empty array indicates that the config has to be new as a sequence + // cannot be the root of a config object. We need to make this assumption + // because $saved_config->isNew() will always return FALSE here. + if (empty($original_data)) { + $original_data = FALSE; + } + // Only save change to state if there is a change, even if it's just keys + // being re-ordered. + if ($original_data !== $saved_config->getRawData()) { + $key_value->set($saved_config->getName(), $original_data); + } + } + } + + /** + * Updates checkpoint when configuration is saved. + * + * @param \Drupal\Core\Config\ConfigRenameEvent $event + * The configuration event. + */ + public function onConfigRename(ConfigRenameEvent $event): void { + $active_checkpoint = $this->checkpoints->getActiveCheckpoint(); + if ($active_checkpoint === NULL) { + return; + } + $collection = $event->getConfig()->getStorage()->getCollectionName(); + $this->storeCollectionName($collection); + + $key_value = $this->getKeyValue($active_checkpoint->id, $collection); + + $old_name = $event->getOldName(); + + // If we have not yet stored a checkpoint for this configuration, store a + // complete copy of the original configuration. Note that renames do not + // change data but storing the complete data allows + // \Drupal\Core\Config\ConfigImporter to track renames using UUIDs. + if ($key_value->get($old_name) === NULL) { + $key_value->set($old_name, $this->getOriginalConfig($event->getConfig())); + } + + // Record that the new name did not exist prior to the checkpoint. + $new_name = $event->getConfig()->getName(); + if ($key_value->get($new_name) === NULL) { + $key_value->set($new_name, FALSE); + } + } + + /** + * Gets the original data from the configuration. + * + * @param \Drupal\Core\Config\StorableConfigBase $config + * The config to get the original data from. + * + * @return mixed + * The original data. + */ + private function getOriginalConfig(StorableConfigBase $config): mixed { + if ($config instanceof Config) { + return $config->getOriginal(apply_overrides: FALSE); + } + return $config->getOriginal(); + } + + /** + * Stores the collection name so the storage knows its own collections. + * + * @param string $collection + * The name of the collection. + */ + private function storeCollectionName(string $collection): void { + // We do not need to store the default collection. + if ($collection === StorageInterface::DEFAULT_COLLECTION) { + return; + } + + $key_value = $this->getKeyValue($this->checkpoints->getActiveCheckpoint()->id, StorageInterface::DEFAULT_COLLECTION); + $collections = $key_value->get(static::CONFIG_COLLECTION_KEY, []); + assert(is_array($collections)); + if (in_array($collection, $collections, TRUE)) { + return; + } + $collections[] = $collection; + $key_value->set(static::CONFIG_COLLECTION_KEY, $collections); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + $events[ConfigEvents::SAVE][] = 'onConfigSaveAndDelete'; + $events[ConfigEvents::DELETE][] = 'onConfigSaveAndDelete'; + $events[ConfigEvents::RENAME][] = 'onConfigRename'; + $events[ConfigCollectionEvents::SAVE_IN_COLLECTION][] = 'onConfigSaveAndDelete'; + $events[ConfigCollectionEvents::DELETE_IN_COLLECTION][] = 'onConfigSaveAndDelete'; + $events[ConfigCollectionEvents::RENAME_IN_COLLECTION][] = 'onConfigRename'; + return $events; + } + + /** + * {@inheritdoc} + */ + public function write($name, array $data): never { + throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage'); + } + + /** + * {@inheritdoc} + */ + public function delete($name): never { + throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage'); + } + + /** + * {@inheritdoc} + */ + public function rename($name, $new_name): never { + throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage'); + } + + /** + * {@inheritdoc} + */ + public function deleteAll($prefix = ''): never { + throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage'); + } + +} diff --git a/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorageInterface.php b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorageInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..fe476ac92757dc0bd756d04f3bbe9ef2a1028d2e --- /dev/null +++ b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorageInterface.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Checkpoint; + +use Drupal\Core\Config\StorageInterface; + +/** + * Provides an interface for checkpoint storages. + * + * @internal + * This API is experimental. + */ +interface CheckpointStorageInterface extends StorageInterface { + + /** + * Creates a checkpoint, if required, and returns the active checkpoint. + * + * If the storage determines that the current active checkpoint would contain + * the same information, it does not have to create a new checkpoint. + * + * @param string|\Stringable $label + * The checkpoint label to use if a new checkpoint is created. + * + * @return \Drupal\Core\Config\Checkpoint\Checkpoint + * The currently active checkpoint. + */ + public function checkpoint(string|\Stringable $label): Checkpoint; + + /** + * Sets the checkpoint to read from. + * + * Calling read() or readMultiple() will return the configuration data at the + * time of the checkpoint that was set here. If none is set, then the + * configuration from the initial checkpoint will be returned. + * + * @param string|\Drupal\Core\Config\Checkpoint\Checkpoint $checkpoint_id + * The checkpoint ID to read from. + * + * @return $this + * + * @throws \Drupal\Core\Config\Checkpoint\UnknownCheckpointException + * Thrown when the provided checkpoint does not exist. + */ + public function setCheckpointToReadFrom(string|Checkpoint $checkpoint_id): static; + +} diff --git a/core/lib/Drupal/Core/Config/Checkpoint/LinearHistory.php b/core/lib/Drupal/Core/Config/Checkpoint/LinearHistory.php new file mode 100644 index 0000000000000000000000000000000000000000..34047523d62378437779e1d31e941e683170e8ac --- /dev/null +++ b/core/lib/Drupal/Core/Config/Checkpoint/LinearHistory.php @@ -0,0 +1,144 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Checkpoint; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\State\StateInterface; + +/** + * A chronological list of Checkpoint objects. + * + * @internal + * This API is experimental. + */ +final class LinearHistory implements CheckpointListInterface { + + /** + * The store of all the checkpoint names in state. + */ + private const CHECKPOINT_KEY = 'config.checkpoints'; + + /** + * The active checkpoint. + * + * In our implementation this is always the last in the list. + * + * @var \Drupal\Core\Config\Checkpoint\Checkpoint|null + */ + private ?Checkpoint $activeCheckpoint; + + /** + * The list of checkpoints, keyed by ID. + * + * @var \Drupal\Core\Config\Checkpoint\Checkpoint[] + */ + private array $checkpoints; + + /** + * Constructs a checkpoints object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + */ + public function __construct( + private readonly StateInterface $state, + private readonly TimeInterface $time, + ) { + $this->checkpoints = $this->state->get(self::CHECKPOINT_KEY, []); + $this->activeCheckpoint = end($this->checkpoints) ?: NULL; + } + + /** + * {@inheritdoc} + */ + public function getActiveCheckpoint(): ?Checkpoint { + return $this->activeCheckpoint; + } + + /** + * {@inheritdoc} + */ + public function get(string $id): Checkpoint { + if (!isset($this->checkpoints[$id])) { + throw new UnknownCheckpointException(sprintf('The checkpoint "%s" does not exist', $id)); + } + return $this->checkpoints[$id]; + } + + /** + * {@inheritdoc} + */ + public function getParents(string $id): \Traversable { + if (!isset($this->checkpoints[$id])) { + throw new UnknownCheckpointException(sprintf('The checkpoint "%s" does not exist', $id)); + } + $checkpoint = $this->checkpoints[$id]; + while ($checkpoint->parent !== NULL) { + $checkpoint = $this->get($checkpoint->parent); + yield $checkpoint->id => $checkpoint; + } + } + + /** + * {@inheritdoc} + */ + public function getIterator(): \Traversable { + return new \ArrayIterator($this->checkpoints); + } + + /** + * {@inheritdoc} + */ + public function count(): int { + return count($this->checkpoints); + } + + /** + * {@inheritdoc} + */ + public function add(string $id, string|\Stringable $label): Checkpoint { + if (isset($this->checkpoints[$id])) { + throw new CheckpointExistsException(sprintf('Cannot create a checkpoint with the ID "%s" as it already exists', $id)); + } + $checkpoint = new Checkpoint($id, $label, $this->time->getCurrentTime(), $this->activeCheckpoint?->id); + $this->checkpoints[$checkpoint->id] = $checkpoint; + $this->activeCheckpoint = $checkpoint; + $this->state->set(self::CHECKPOINT_KEY, $this->checkpoints); + + return $checkpoint; + } + + /** + * {@inheritdoc} + */ + public function delete(string $id): static { + if (!isset($this->checkpoints[$id])) { + throw new UnknownCheckpointException(sprintf('Cannot delete a checkpoint with the ID "%s" as it does not exist', $id)); + } + + foreach ($this->checkpoints as $key => $checkpoint) { + unset($this->checkpoints[$key]); + if ($checkpoint->id === $id) { + break; + } + } + $this->state->set(self::CHECKPOINT_KEY, $this->checkpoints); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function deleteAll(): static { + $this->checkpoints = []; + $this->activeCheckpoint = NULL; + $this->state->delete(self::CHECKPOINT_KEY); + return $this; + } + +} diff --git a/core/lib/Drupal/Core/Config/Checkpoint/NoCheckpointsException.php b/core/lib/Drupal/Core/Config/Checkpoint/NoCheckpointsException.php new file mode 100644 index 0000000000000000000000000000000000000000..f0822b6210ca0f27c7618c953552d935e480fc61 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Checkpoint/NoCheckpointsException.php @@ -0,0 +1,20 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Checkpoint; + +/** + * Thrown when using the checkpoint storage with no checkpoints. + * + * @internal + * This API is experimental. + */ +final class NoCheckpointsException extends \RuntimeException { + + /** + * {@inheritdoc} + */ + protected $message = 'This storage cannot be read because there are no checkpoints'; + +} diff --git a/core/lib/Drupal/Core/Config/Checkpoint/UnknownCheckpointException.php b/core/lib/Drupal/Core/Config/Checkpoint/UnknownCheckpointException.php new file mode 100644 index 0000000000000000000000000000000000000000..0f99855822c421f6420b517208669d4e783032b3 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Checkpoint/UnknownCheckpointException.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Config\Checkpoint; + +/** + * Thrown when trying to access a checkpoint that does not exist. + * + * @internal + * This API is experimental. + */ +final class UnknownCheckpointException extends \RuntimeException { +} diff --git a/core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php b/core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php index feed9bbb391a1513771ceb7dc7488c8daeff2d52..e7a692cdaefad6928e83d3d327a4545300668fcb 100644 --- a/core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php +++ b/core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php @@ -4,6 +4,7 @@ use Drupal\Component\Utility\NestedArray; use Drupal\Core\Cache\Cache; +use Drupal\Core\Config\Action\Attribute\ActionMethod; use Drupal\Core\Config\Schema\SchemaIncompleteException; use Drupal\Core\Entity\EntityBase; use Drupal\Core\Config\ConfigDuplicateUUIDException; @@ -12,6 +13,7 @@ use Drupal\Core\Entity\EntityWithPluginCollectionInterface; use Drupal\Core\Entity\SynchronizableEntityTrait; use Drupal\Core\Plugin\PluginDependencyTrait; +use Drupal\Core\StringTranslation\TranslatableMarkup; /** * Defines a base configuration entity class. @@ -503,6 +505,7 @@ protected static function invalidateTagsOnDelete(EntityTypeInterface $entity_typ /** * {@inheritdoc} */ + #[ActionMethod(adminLabel: new TranslatableMarkup('Set third-party setting'))] public function setThirdPartySetting($module, $key, $value) { $this->third_party_settings[$module][$key] = $value; return $this; diff --git a/core/lib/Drupal/Core/DefaultContent/AdminAccountSwitcher.php b/core/lib/Drupal/Core/DefaultContent/AdminAccountSwitcher.php new file mode 100644 index 0000000000000000000000000000000000000000..ac353632ab52b2b9071ad3b24e301d8b192f6c60 --- /dev/null +++ b/core/lib/Drupal/Core/DefaultContent/AdminAccountSwitcher.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\DefaultContent; + +use Drupal\Core\Access\AccessException; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\Core\Session\AccountSwitcherInterface; + +/** + * @internal + * This API is experimental. + */ +final class AdminAccountSwitcher implements AccountSwitcherInterface { + + public function __construct( + private readonly AccountSwitcherInterface $decorated, + private readonly EntityTypeManagerInterface $entityTypeManager, + private readonly bool $isSuperUserAccessEnabled, + ) {} + + /** + * Switches to an administrative account. + * + * This will switch to the first available account with a role that has the + * `is_admin` flag. If there are no such roles, or no such users, this will + * try to switch to user 1 if superuser access is enabled. + * + * @return \Drupal\Core\Session\AccountInterface + * The account that was switched to. + * + * @throws \Drupal\Core\Access\AccessException + * Thrown if there are no users with administrative roles. + */ + public function switchToAdministrator(): AccountInterface { + $admin_roles = $this->entityTypeManager->getStorage('user_role') + ->getQuery() + ->condition('is_admin', TRUE) + ->execute(); + + $user_storage = $this->entityTypeManager->getStorage('user'); + + if ($admin_roles) { + $accounts = $user_storage->getQuery() + ->accessCheck(FALSE) + ->condition('roles', $admin_roles, 'IN') + ->condition('status', 1) + ->sort('uid') + ->range(0, 1) + ->execute(); + } + else { + $accounts = []; + } + $account = $user_storage->load(reset($accounts) ?: 1); + assert($account instanceof AccountInterface); + + if (array_intersect($account->getRoles(), $admin_roles) || ((int) $account->id() === 1 && $this->isSuperUserAccessEnabled)) { + $this->switchTo($account); + return $account; + } + throw new AccessException("There are no user accounts with administrative roles."); + } + + /** + * {@inheritdoc} + */ + public function switchTo(AccountInterface $account): AccountSwitcherInterface { + $this->decorated->switchTo($account); + return $this; + } + + /** + * {@inheritdoc} + */ + public function switchBack(): AccountSwitcherInterface { + $this->decorated->switchBack(); + return $this; + } + +} diff --git a/core/lib/Drupal/Core/DefaultContent/Existing.php b/core/lib/Drupal/Core/DefaultContent/Existing.php new file mode 100644 index 0000000000000000000000000000000000000000..75c5d8fff01279f42692f1023c969227c39db92d --- /dev/null +++ b/core/lib/Drupal/Core/DefaultContent/Existing.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\DefaultContent; + +/** + * Defines what to do if importing an entity that already exists (by UUID). + * + * @internal + * This API is experimental. + */ +enum Existing { + + case Error; + case Skip; + +} diff --git a/core/lib/Drupal/Core/DefaultContent/Finder.php b/core/lib/Drupal/Core/DefaultContent/Finder.php new file mode 100644 index 0000000000000000000000000000000000000000..fb35891f6ff01e70a759a46d4f51a17efc125908 --- /dev/null +++ b/core/lib/Drupal/Core/DefaultContent/Finder.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\DefaultContent; + +use Drupal\Component\Graph\Graph; +use Drupal\Component\Serialization\Yaml; +use Drupal\Component\Utility\SortArray; +use Symfony\Component\Finder\Exception\DirectoryNotFoundException; +use Symfony\Component\Finder\Finder as SymfonyFinder; + +/** + * Finds all default content in a directory, in dependency order. + * + * @internal + * This API is experimental. + */ +final class Finder { + + /** + * The content entity data to import, in dependency order, keyed by entity UUID. + * + * @var array<string, array<mixed>> + */ + public readonly array $data; + + public function __construct(string $path) { + try { + // Scan for all YAML files in the content directory. + $finder = SymfonyFinder::create() + ->in($path) + ->files() + ->name('*.yml'); + } + catch (DirectoryNotFoundException) { + $this->data = []; + return; + } + + $graph = $files = []; + /** @var \Symfony\Component\Finder\SplFileInfo $file */ + foreach ($finder as $file) { + /** @var array{_meta: array{uuid: string|null, depends: array<string, string>|null}} $decoded */ + $decoded = Yaml::decode($file->getContents()); + $decoded['_meta']['path'] = $file->getPathname(); + $uuid = $decoded['_meta']['uuid'] ?? throw new ImportException($decoded['_meta']['path'] . ' does not have a UUID.'); + $files[$uuid] = $decoded; + + // For the graph to work correctly, every entity must be mentioned in it. + // This is inspired by + // \Drupal\Core\Config\Entity\ConfigDependencyManager::getGraph(). + $graph += [ + $uuid => [ + 'edges' => [], + 'uuid' => $uuid, + ], + ]; + + foreach ($decoded['_meta']['depends'] ?? [] as $dependency_uuid => $entity_type) { + $graph[$dependency_uuid]['edges'][$uuid] = TRUE; + $graph[$dependency_uuid]['uuid'] = $dependency_uuid; + } + } + ksort($graph); + + // Sort the dependency graph. The entities that are dependencies of other + // entities should come first. + $graph_object = new Graph($graph); + $sorted = $graph_object->searchAndSort(); + uasort($sorted, SortArray::sortByWeightElement(...)); + + $entities = []; + foreach ($sorted as ['uuid' => $uuid]) { + if (array_key_exists($uuid, $files)) { + $entities[$uuid] = $files[$uuid]; + } + } + $this->data = $entities; + } + +} diff --git a/core/lib/Drupal/Core/DefaultContent/ImportException.php b/core/lib/Drupal/Core/DefaultContent/ImportException.php new file mode 100644 index 0000000000000000000000000000000000000000..873b796bd2f85a1a6b76c9d1a52e9de07668754a --- /dev/null +++ b/core/lib/Drupal/Core/DefaultContent/ImportException.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\DefaultContent; + +/** + * Exception thrown when there is an error importing content. + * + * @internal + * This API is experimental. + */ +final class ImportException extends \RuntimeException { +} diff --git a/core/lib/Drupal/Core/DefaultContent/Importer.php b/core/lib/Drupal/Core/DefaultContent/Importer.php new file mode 100644 index 0000000000000000000000000000000000000000..21814ce364881e01dfc1ff01681f8f046ea1997c --- /dev/null +++ b/core/lib/Drupal/Core/DefaultContent/Importer.php @@ -0,0 +1,378 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\DefaultContent; + +use Drupal\Component\Plugin\PluginInspectionInterface; +use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Entity\EntityRepositoryInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\Plugin\DataType\EntityReference; +use Drupal\Core\Field\FieldItemInterface; +use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\Installer\InstallerKernel; +use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\file\FileInterface; +use Drupal\link\Plugin\Field\FieldType\LinkItem; +use Drupal\user\EntityOwnerInterface; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; + +/** + * A service for handling import of content. + * + * @internal + * This API is experimental. + */ +final class Importer implements LoggerAwareInterface { + + use LoggerAwareTrait; + + /** + * The dependencies of the currently importing entity, if any. + * + * The keys are the UUIDs of the dependencies, and the values are arrays with + * two members: the entity type ID of the dependency, and the UUID to load. + * + * @var array<string, string[]>|null + */ + private ?array $dependencies = NULL; + + public function __construct( + private readonly EntityTypeManagerInterface $entityTypeManager, + private readonly AdminAccountSwitcher $accountSwitcher, + private readonly FileSystemInterface $fileSystem, + private readonly LanguageManagerInterface $languageManager, + private readonly EntityRepositoryInterface $entityRepository, + ) {} + + /** + * Imports content entities from disk. + * + * @param \Drupal\Core\DefaultContent\Finder $content + * The content finder, which has information on the entities to create + * in the necessary dependency order. + * @param \Drupal\Core\DefaultContent\Existing $existing + * (optional) What to do if one of the entities being imported already + * exists, by UUID: + * - \Drupal\Core\DefaultContent\Existing::Error: Throw an exception. + * - \Drupal\Core\DefaultContent\Existing::Skip: Leave the existing entity + * as-is. + * + * @throws \Drupal\Core\DefaultContent\ImportException + * - If any of the entities being imported are not content entities. + * - If any of the entities being imported already exists, by UUID, and + * $existing is \Drupal\Core\DefaultContent\Existing::Error. + */ + public function importContent(Finder $content, Existing $existing = Existing::Error): void { + if (count($content->data) === 0) { + return; + } + + $account = $this->accountSwitcher->switchToAdministrator(); + + try { + /** @var array{_meta: array<mixed>} $decoded */ + foreach ($content->data as $decoded) { + ['uuid' => $uuid, 'entity_type' => $entity_type_id, 'path' => $path] = $decoded['_meta']; + assert(is_string($uuid)); + assert(is_string($entity_type_id)); + assert(is_string($path)); + + $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); + /** @var \Drupal\Core\Entity\EntityTypeInterface $entity_type */ + if (!$entity_type->entityClassImplements(ContentEntityInterface::class)) { + throw new ImportException("Content entity $uuid is a '$entity_type_id', which is not a content entity type."); + } + + $entity = $this->entityRepository->loadEntityByUuid($entity_type_id, $uuid); + if ($entity) { + if ($existing === Existing::Skip) { + continue; + } + else { + throw new ImportException("$entity_type_id $uuid already exists."); + } + } + + $entity = $this->toEntity($decoded)->enforceIsNew(); + + // Ensure that the entity is not owned by the anonymous user. + if ($entity instanceof EntityOwnerInterface && empty($entity->getOwnerId())) { + $entity->setOwnerId($account->id()); + } + + // If a file exists in the same folder, copy it to the designated + // target URI. + if ($entity instanceof FileInterface) { + $this->copyFileAssociatedWithEntity(dirname($path), $entity); + } + $violations = $entity->validate(); + if (count($violations) > 0) { + throw new InvalidEntityException($violations, $path); + } + $entity->save(); + } + } + finally { + $this->accountSwitcher->switchBack(); + } + } + + /** + * Copies a file from default content directory to the site's file system. + * + * @param string $path + * The path to the file to copy. + * @param \Drupal\file\FileInterface $entity + * The file entity. + */ + private function copyFileAssociatedWithEntity(string $path, FileInterface &$entity): void { + $destination = $entity->getFileUri(); + assert(is_string($destination)); + + // If the source file doesn't exist, there's nothing we can do. + $source = $path . '/' . basename($destination); + if (!file_exists($source)) { + $this->logger?->warning("File entity %name was imported, but the associated file (@path) was not found.", [ + '%name' => $entity->label(), + '@path' => $source, + ]); + return; + } + + $copy_file = TRUE; + if (file_exists($destination)) { + $source_hash = hash_file('sha256', $source); + assert(is_string($source_hash)); + $destination_hash = hash_file('sha256', $destination); + assert(is_string($destination_hash)); + + if (hash_equals($source_hash, $destination_hash) && $this->entityTypeManager->getStorage('file')->loadByProperties(['uri' => $destination]) === []) { + // If the file hashes match and the file is not already a managed file + // then do not copy a new version to the file system. This prevents + // re-installs during development from creating unnecessary duplicates. + $copy_file = FALSE; + } + } + + $target_directory = dirname($destination); + $this->fileSystem->prepareDirectory($target_directory, FileSystemInterface::CREATE_DIRECTORY); + if ($copy_file) { + $uri = $this->fileSystem->copy($source, $destination); + $entity->setFileUri($uri); + } + } + + /** + * Converts an array of content entity data to a content entity object. + * + * @param array<string, array<mixed>> $data + * The entity data. + * + * @return \Drupal\Core\Entity\ContentEntityInterface + * The unsaved entity. + * + * @throws \Drupal\Core\DefaultContent\ImportException + * If the `entity_type` or `uuid` meta keys are not set. + */ + private function toEntity(array $data): ContentEntityInterface { + if (empty($data['_meta']['entity_type'])) { + throw new ImportException('The entity type metadata must be specified.'); + } + if (empty($data['_meta']['uuid'])) { + throw new ImportException('The uuid metadata must be specified.'); + } + + $is_root = FALSE; + // @see ::loadEntityDependency() + if ($this->dependencies === NULL && !empty($data['_meta']['depends'])) { + $is_root = TRUE; + foreach ($data['_meta']['depends'] as $uuid => $entity_type) { + assert(is_string($uuid)); + assert(is_string($entity_type)); + $this->dependencies[$uuid] = [$entity_type, $uuid]; + } + } + + ['entity_type' => $entity_type] = $data['_meta']; + assert(is_string($entity_type)); + /** @var \Drupal\Core\Entity\EntityTypeInterface $entity_type */ + $entity_type = $this->entityTypeManager->getDefinition($entity_type); + + $values = [ + 'uuid' => $data['_meta']['uuid'], + ]; + if (!empty($data['_meta']['bundle'])) { + $values[$entity_type->getKey('bundle')] = $data['_meta']['bundle']; + } + + if (!empty($data['_meta']['default_langcode'])) { + $data = $this->verifyNormalizedLanguage($data); + $values[$entity_type->getKey('langcode')] = $data['_meta']['default_langcode']; + } + + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $this->entityTypeManager->getStorage($entity_type->id())->create($values); + foreach ($data['default'] as $field_name => $values) { + $this->setFieldValues($entity, $field_name, $values); + } + + foreach ($data['translations'] ?? [] as $langcode => $translation_data) { + if ($this->languageManager->getLanguage($langcode)) { + $translation = $entity->addTranslation($langcode, $entity->toArray()); + foreach ($translation_data as $field_name => $values) { + $this->setFieldValues($translation, $field_name, $values); + } + } + } + + if ($is_root) { + $this->dependencies = NULL; + } + return $entity; + } + + /** + * Sets field values based on the normalized data. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The content entity. + * @param string $field_name + * The name of the field. + * @param array $values + * The normalized data for the field. + */ + private function setFieldValues(ContentEntityInterface $entity, string $field_name, array $values): void { + foreach ($values as $delta => $item_value) { + if (!$entity->get($field_name)->get($delta)) { + $entity->get($field_name)->appendItem(); + } + /** @var \Drupal\Core\Field\FieldItemInterface $item */ + $item = $entity->get($field_name)->get($delta); + + // Update the URI based on the target UUID for link fields. + if (isset($item_value['target_uuid']) && $item instanceof LinkItem) { + $target_entity = $this->loadEntityDependency($item_value['target_uuid']); + if ($target_entity) { + $item_value['uri'] = 'entity:' . $target_entity->getEntityTypeId() . '/' . $target_entity->id(); + } + unset($item_value['target_uuid']); + } + + $serialized_property_names = $this->getCustomSerializedPropertyNames($item); + foreach ($item_value as $property_name => $value) { + if (\in_array($property_name, $serialized_property_names)) { + if (\is_string($value)) { + throw new ImportException("Received string for serialized property $field_name.$delta.$property_name"); + } + $value = serialize($value); + } + + $property = $item->get($property_name); + + if ($property instanceof EntityReference) { + if (is_array($value)) { + $value = $this->toEntity($value); + } + else { + $value = $this->loadEntityDependency($value); + } + } + $property->setValue($value); + } + } + } + + /** + * Gets the names of all properties the plugin treats as serialized data. + * + * This allows the field storage definition or entity type to provide a + * setting for serialized properties. This can be used for fields that + * handle serialized data themselves and do not rely on the serialized schema + * flag. + * + * @param \Drupal\Core\Field\FieldItemInterface $field_item + * The field item. + * + * @return string[] + * The property names for serialized properties. + * + * @see \Drupal\serialization\Normalizer\SerializedColumnNormalizerTrait::getCustomSerializedPropertyNames + */ + private function getCustomSerializedPropertyNames(FieldItemInterface $field_item): array { + if ($field_item instanceof PluginInspectionInterface) { + $definition = $field_item->getPluginDefinition(); + $serialized_fields = $field_item->getEntity()->getEntityType()->get('serialized_field_property_names'); + $field_name = $field_item->getFieldDefinition()->getName(); + if (is_array($serialized_fields) && isset($serialized_fields[$field_name]) && is_array($serialized_fields[$field_name])) { + return $serialized_fields[$field_name]; + } + if (isset($definition['serialized_property_names']) && is_array($definition['serialized_property_names'])) { + return $definition['serialized_property_names']; + } + } + return []; + } + + /** + * Loads the entity dependency by its UUID. + * + * @param string $target_uuid + * The entity UUID. + * + * @return \Drupal\Core\Entity\ContentEntityInterface|null + * The loaded entity. + */ + private function loadEntityDependency(string $target_uuid): ?ContentEntityInterface { + if ($this->dependencies && array_key_exists($target_uuid, $this->dependencies)) { + $entity = $this->entityRepository->loadEntityByUuid(...$this->dependencies[$target_uuid]); + assert($entity instanceof ContentEntityInterface || $entity === NULL); + return $entity; + } + return NULL; + } + + /** + * Verifies that the site knows the default language of the normalized entity. + * + * Will attempt to switch to an alternative translation or just import it + * with the site default language. + * + * @param array $data + * The normalized entity data. + * + * @return array + * The normalized entity data, possibly with altered default language + * and translations. + */ + private function verifyNormalizedLanguage(array $data): array { + $default_langcode = $data['_meta']['default_langcode']; + $default_language = $this->languageManager->getDefaultLanguage(); + // Check the language. If the default language isn't known, import as one + // of the available translations if one exists with those values. If none + // exists, create the entity in the default language. + // During the installer, when installing with an alternative language, + // `en` is still the default when modules are installed so check the default language + // instead. + if (!$this->languageManager->getLanguage($default_langcode) || (InstallerKernel::installationAttempted() && $default_language->getId() !== $default_langcode)) { + $use_default = TRUE; + foreach ($data['translations'] ?? [] as $langcode => $translation_data) { + if ($this->languageManager->getLanguage($langcode)) { + $data['_meta']['default_langcode'] = $langcode; + $data['default'] = \array_merge($data['default'], $translation_data); + unset($data['translations'][$langcode]); + $use_default = FALSE; + break; + } + } + + if ($use_default) { + $data['_meta']['default_langcode'] = $default_language->getId(); + } + } + return $data; + } + +} diff --git a/core/lib/Drupal/Core/DefaultContent/InvalidEntityException.php b/core/lib/Drupal/Core/DefaultContent/InvalidEntityException.php new file mode 100644 index 0000000000000000000000000000000000000000..412f35b09c24afa0d9f34428254f144c27cdcf1a --- /dev/null +++ b/core/lib/Drupal/Core/DefaultContent/InvalidEntityException.php @@ -0,0 +1,27 @@ +<?php + +namespace Drupal\Core\DefaultContent; + +use Drupal\Core\Entity\EntityConstraintViolationListInterface; +use Symfony\Component\Validator\ConstraintViolationInterface; + +/** + * Thrown if an entity being imported has validation errors. + * + * @internal + * This API is experimental. + */ +final class InvalidEntityException extends \RuntimeException { + + public function __construct(public readonly EntityConstraintViolationListInterface $violations, public readonly string $filePath) { + $messages = []; + + foreach ($violations as $violation) { + assert($violation instanceof ConstraintViolationInterface); + $messages[] = $violation->getPropertyPath() . '=' . $violation->getMessage(); + } + // Example: "/path/to/file.yml: field_a=Violation 1., field_b=Violation 2.". + parent::__construct("$filePath: " . implode('||', $messages)); + } + +} diff --git a/core/lib/Drupal/Core/Entity/EntityDisplayBase.php b/core/lib/Drupal/Core/Entity/EntityDisplayBase.php index 768835903f62dc778771674f05ffd05f933a0cd4..4951ef32d72d8121910cf698823fbf2889be1101 100644 --- a/core/lib/Drupal/Core/Entity/EntityDisplayBase.php +++ b/core/lib/Drupal/Core/Entity/EntityDisplayBase.php @@ -2,10 +2,12 @@ namespace Drupal\Core\Entity; +use Drupal\Core\Config\Action\Attribute\ActionMethod; use Drupal\Core\Config\Entity\ConfigEntityBase; use Drupal\Core\Config\Entity\ConfigEntityInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Entity\Display\EntityDisplayInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; /** * Provides a common base class for entity view and form displays. @@ -345,6 +347,7 @@ public function getComponent($name) { /** * {@inheritdoc} */ + #[ActionMethod(adminLabel: new TranslatableMarkup('Add component to display'))] public function setComponent($name, array $options = []) { // If no weight specified, make sure the field sinks at the bottom. if (!isset($options['weight'])) { diff --git a/core/lib/Drupal/Core/Field/FieldConfigBase.php b/core/lib/Drupal/Core/Field/FieldConfigBase.php index ca9a5b5285ead4c5285ec4cf6b7a091b670f6405..610977ec2dee8b6c9c1ee9b60ce1c1e6b69055e7 100644 --- a/core/lib/Drupal/Core/Field/FieldConfigBase.php +++ b/core/lib/Drupal/Core/Field/FieldConfigBase.php @@ -2,10 +2,12 @@ namespace Drupal\Core\Field; +use Drupal\Core\Config\Action\Attribute\ActionMethod; use Drupal\Core\Config\Entity\ConfigEntityBase; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Field\TypedData\FieldItemDataDefinition; +use Drupal\Core\StringTranslation\TranslatableMarkup; /** * Base class for configurable field definitions. @@ -327,6 +329,7 @@ public function getLabel() { /** * {@inheritdoc} */ + #[ActionMethod(adminLabel: new TranslatableMarkup('Change field label'))] public function setLabel($label) { $this->label = $label; return $this; diff --git a/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php b/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php new file mode 100644 index 0000000000000000000000000000000000000000..1a46e601e6c09b40ab7b639b353e0cc9e772dd16 --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php @@ -0,0 +1,123 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +use Drupal\Core\Config\FileStorage; +use Drupal\Core\Config\StorageInterface; + +/** + * @internal + * This API is experimental. + */ +final class ConfigConfigurator { + + public readonly ?string $recipeConfigDirectory; + + /** + * @param array $config + * Config options for a recipe. + * @param string $recipe_directory + * The path to the recipe. + * @param \Drupal\Core\Config\StorageInterface $active_configuration + * The active configuration storage. + */ + public function __construct(public readonly array $config, string $recipe_directory, StorageInterface $active_configuration) { + $this->recipeConfigDirectory = is_dir($recipe_directory . '/config') ? $recipe_directory . '/config' : NULL; + $recipe_storage = $this->getConfigStorage(); + foreach ($recipe_storage->listAll() as $config_name) { + if ($active_data = $active_configuration->read($config_name)) { + // @todo https://www.drupal.org/i/3439714 Investigate if there is any + // generic code in core for this. + unset($active_data['uuid'], $active_data['_core']); + if (empty($active_data['dependencies'])) { + unset($active_data['dependencies']); + } + $recipe_data = $recipe_storage->read($config_name); + if (empty($recipe_data['dependencies'])) { + unset($recipe_data['dependencies']); + } + // Ensure we don't get a false mismatch due to differing key order. + // @todo When https://www.drupal.org/project/drupal/issues/3230826 is + // fixed in core, use that API instead to sort the config data. + self::recursiveSortByKey($active_data); + self::recursiveSortByKey($recipe_data); + if ($active_data !== $recipe_data) { + throw new RecipePreExistingConfigException($config_name, sprintf("The configuration '%s' exists already and does not match the recipe's configuration", $config_name)); + } + } + } + } + + /** + * Sorts an array recursively, by key, alphabetically. + * + * @param mixed[] $data + * The array to sort, passed by reference. + * + * @todo Remove when https://www.drupal.org/project/drupal/issues/3230826 is + * fixed in core. + */ + private static function recursiveSortByKey(array &$data): void { + // If the array is a list, it is by definition already sorted. + if (!array_is_list($data)) { + ksort($data); + } + foreach ($data as &$value) { + if (is_array($value)) { + self::recursiveSortByKey($value); + } + } + } + + /** + * Gets a config storage object for reading config from the recipe. + * + * @return \Drupal\Core\Config\StorageInterface + * The config storage object for reading config from the recipe. + */ + public function getConfigStorage(): StorageInterface { + $storages = []; + + if ($this->recipeConfigDirectory) { + // Config provided by the recipe should take priority over config from + // extensions. + $storages[] = new FileStorage($this->recipeConfigDirectory); + } + if (!empty($this->config['import'])) { + /** @var \Drupal\Core\Extension\ModuleExtensionList $module_list */ + $module_list = \Drupal::service('extension.list.module'); + /** @var \Drupal\Core\Extension\ThemeExtensionList $theme_list */ + $theme_list = \Drupal::service('extension.list.theme'); + foreach ($this->config['import'] as $extension => $config) { + // If the recipe explicitly does not want to import any config from this + // extension, skip it. + if ($config === NULL) { + continue; + } + $path = match (TRUE) { + $module_list->exists($extension) => $module_list->getPath($extension), + $theme_list->exists($extension) => $theme_list->getPath($extension), + default => throw new \RuntimeException("$extension is not a theme or module") + }; + $config = $config === '*' ? [] : $config; + $storages[] = new RecipeExtensionConfigStorage($path, $config); + } + } + + return RecipeConfigStorageWrapper::createStorageFromArray($storages); + } + + /** + * Determines if the recipe has any config or config actions to apply. + * + * @return bool + * TRUE if the recipe has any config or config actions to apply, FALSE if + * not. + */ + public function hasTasks(): bool { + return $this->recipeConfigDirectory !== NULL || count($this->config); + } + +} diff --git a/core/lib/Drupal/Core/Recipe/InstallConfigurator.php b/core/lib/Drupal/Core/Recipe/InstallConfigurator.php new file mode 100644 index 0000000000000000000000000000000000000000..abc51f5b269acc50797311a2e6d3e73c1ac8aeb0 --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/InstallConfigurator.php @@ -0,0 +1,120 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +use Drupal\Component\Assertion\Inspector; +use Drupal\Core\Extension\Dependency; +use Drupal\Core\Extension\ModuleExtensionList; +use Drupal\Core\Extension\ThemeExtensionList; + +/** + * @internal + * This API is experimental. + */ +final class InstallConfigurator { + + /** + * The list of modules to install. + * + * This list is sorted an includes any module dependencies of the provided + * extensions. + * + * @var string[] + */ + public readonly array $modules; + + /** + * The list of themes to install. + * + * This list is sorted an includes any theme dependencies of the provided + * extensions. + * + * @var string[] + */ + public readonly array $themes; + + /** + * @param string[] $extensions + * A list of extensions for a recipe to install. + * @param \Drupal\Core\Extension\ModuleExtensionList $module_list + * The module list service. + * @param \Drupal\Core\Extension\ThemeExtensionList $theme_list + * The theme list service. + */ + public function __construct(array $extensions, ModuleExtensionList $module_list, ThemeExtensionList $theme_list) { + assert(Inspector::assertAllStrings($extensions), 'Extension names must be strings.'); + $extensions = array_map(fn($extension) => Dependency::createFromString($extension)->getName(), $extensions); + $extensions = array_combine($extensions, $extensions); + $module_data = $module_list->reset()->getList(); + $theme_data = $theme_list->reset()->getList(); + + $modules = array_intersect_key($extensions, $module_data); + $themes = array_intersect_key($extensions, $theme_data); + + $missing_extensions = array_diff($extensions, $modules, $themes); + + // Add theme module dependencies. + foreach ($themes as $theme => $value) { + $modules = array_merge($modules, array_keys($theme_data[$theme]->module_dependencies)); + } + + // Add modules that other modules depend on. + foreach ($modules as $module) { + if ($module_data[$module]->requires) { + $modules = array_merge($modules, array_keys($module_data[$module]->requires)); + } + } + + // Remove all modules that have been installed already. + $modules = array_diff(array_unique($modules), array_keys($module_list->getAllInstalledInfo())); + $modules = array_combine($modules, $modules); + + // Create a sortable list of modules. + foreach ($modules as $name => $value) { + if (isset($module_data[$name])) { + $modules[$name] = $module_data[$name]->sort; + } + else { + $missing_extensions[$name] = $name; + } + } + + // Add any missing base themes to the list of themes to install. + foreach ($themes as $theme => $value) { + // $theme_data[$theme]->requires contains both theme and module + // dependencies keyed by the extension machine names. + // $theme_data[$theme]->module_dependencies contains only the module + // dependencies keyed by the module extension machine name. Therefore, + // we can find the theme dependencies by finding array keys for + // 'requires' that are not in $module_dependencies. + $theme_dependencies = array_diff_key($theme_data[$theme]->requires, $theme_data[$theme]->module_dependencies); + $themes = array_merge($themes, array_keys($theme_dependencies)); + } + + // Remove all themes that have been installed already. + $themes = array_diff(array_unique($themes), array_keys($theme_list->getAllInstalledInfo())); + $themes = array_combine($themes, $themes); + + // Create a sortable list of themes. + foreach ($themes as $name => $value) { + if (isset($theme_data[$name])) { + $themes[$name] = $theme_data[$name]->sort; + } + else { + $missing_extensions[$name] = $name; + } + } + + if (!empty($missing_extensions)) { + throw new RecipeMissingExtensionsException(array_values($missing_extensions)); + } + + arsort($modules); + arsort($themes); + $this->modules = array_keys($modules); + $this->themes = array_keys($themes); + } + +} diff --git a/core/lib/Drupal/Core/Recipe/InvalidConfigException.php b/core/lib/Drupal/Core/Recipe/InvalidConfigException.php new file mode 100644 index 0000000000000000000000000000000000000000..0c92a42f55cd758bc33106960dc478d9264bbecc --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/InvalidConfigException.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +use Drupal\Core\Config\Schema\Mapping; +use Symfony\Component\Validator\ConstraintViolationList; + +/** + * Thrown if config created or changed by a recipe fails validation. + * + * @internal + * This API is experimental. + */ +final class InvalidConfigException extends \RuntimeException { + + /** + * Constructs an InvalidConfigException object. + * + * @param \Symfony\Component\Validator\ConstraintViolationList $violations + * The validation constraint violations. + * @param \Drupal\Core\Config\Schema\Mapping $data + * A typed data wrapper around the invalid config data. + * @param string $message + * (optional) The exception message. Defaults to the string representation + * of the constraint violation list. + * @param int $code + * (optional) The exception code. Defaults to 0. + * @param \Throwable|null $previous + * (optional) The previous exception, if any. + */ + public function __construct( + public readonly ConstraintViolationList $violations, + public readonly Mapping $data, + string $message = '', + int $code = 0, + ?\Throwable $previous = NULL, + ) { + parent::__construct($message ?: $this->formatMessage(), $code, $previous); + } + + /** + * Formats the constraint violation list as a human-readable message. + * + * @return string + * The formatted message. + */ + private function formatMessage(): string { + $lines = [ + sprintf('There were validation errors in %s:', $this->data->getName()), + ]; + /** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */ + foreach ($this->violations as $violation) { + $lines[] = sprintf('- %s: %s', $violation->getPropertyPath(), $violation->getMessage()); + } + return implode("\n", $lines); + } + +} diff --git a/core/lib/Drupal/Core/Recipe/Recipe.php b/core/lib/Drupal/Core/Recipe/Recipe.php new file mode 100644 index 0000000000000000000000000000000000000000..4da07d2743e575bb53e07c73475a269c6b8d5f9b --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/Recipe.php @@ -0,0 +1,301 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +use Drupal\Core\DefaultContent\Finder; +use Drupal\Core\Extension\Dependency; +use Drupal\Core\Extension\ModuleExtensionList; +use Drupal\Core\Extension\ThemeExtensionList; +use Drupal\Component\Serialization\Yaml; +use Drupal\Core\Validation\Plugin\Validation\Constraint\RegexConstraint; +use Symfony\Component\Validator\Constraints\All; +use Symfony\Component\Validator\Constraints\AtLeastOneOf; +use Symfony\Component\Validator\Constraints\Callback; +use Symfony\Component\Validator\Constraints\Collection; +use Symfony\Component\Validator\Constraints\IdenticalTo; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotIdenticalTo; +use Symfony\Component\Validator\Constraints\Optional; +use Symfony\Component\Validator\Constraints\Regex; +use Symfony\Component\Validator\Constraints\Required; +use Symfony\Component\Validator\Constraints\Sequentially; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\Context\ExecutionContextInterface; +use Symfony\Component\Validator\Validation; + +/** + * @internal + * This API is experimental. + */ +final class Recipe { + + const COMPOSER_PROJECT_TYPE = 'drupal-recipe'; + + public function __construct( + public readonly string $name, + public readonly string $description, + public readonly string $type, + public readonly RecipeConfigurator $recipes, + public readonly InstallConfigurator $install, + public readonly ConfigConfigurator $config, + public readonly Finder $content, + public readonly string $path, + ) { + } + + /** + * Creates a recipe object from the provided path. + * + * @param string $path + * The path to a recipe. + * + * @return static + * The Recipe object. + */ + public static function createFromDirectory(string $path): static { + $recipe_data = self::parse($path . '/recipe.yml'); + + $recipe_discovery = static::getRecipeDiscovery(dirname($path)); + $recipes = new RecipeConfigurator(is_array($recipe_data['recipes']) ? $recipe_data['recipes'] : [], $recipe_discovery); + $install = new InstallConfigurator($recipe_data['install'], \Drupal::service('extension.list.module'), \Drupal::service('extension.list.theme')); + $config = new ConfigConfigurator($recipe_data['config'], $path, \Drupal::service('config.storage')); + $content = new Finder($path . '/content'); + return new static($recipe_data['name'], $recipe_data['description'], $recipe_data['type'], $recipes, $install, $config, $content, $path); + } + + /** + * Parses and validates a recipe.yml file. + * + * @param string $file + * The path of a recipe.yml file. + * + * @return mixed[] + * The parsed and validated data from the file. + * + * @throws \Drupal\Core\Recipe\RecipeFileException + * Thrown if the recipe.yml file is unreadable, invalid, or cannot be + * validated. + */ + private static function parse(string $file): array { + if (!file_exists($file)) { + throw new RecipeFileException($file, "There is no $file file"); + } + $recipe_contents = file_get_contents($file); + if (!$recipe_contents) { + throw new RecipeFileException($file, "$file does not exist or could not be read."); + } + // Certain parts of our validation need to be able to scan for other + // recipes. + // @see ::validateRecipeExists() + // @see ::validateConfigActions() + $discovery = self::getRecipeDiscovery(dirname($file, 2)); + + $constraints = new Collection([ + 'name' => new Required([ + new Type('string'), + new NotBlank(), + // Matching `type: label` in core.data_types.schema.yml. + new RegexConstraint( + pattern: '/([^\PC])/u', + message: 'Recipe names cannot span multiple lines or contain control characters.', + match: FALSE, + ), + ]), + 'description' => new Optional([ + new NotBlank(), + // Matching `type: text` in core.data_types.schema.yml. + new RegexConstraint( + pattern: '/([^\PC\x09\x0a\x0d])/u', + message: 'The recipe description cannot contain control characters, only visible characters.', + match: FALSE, + ), + ]), + 'type' => new Optional([ + new Type('string'), + new NotBlank(), + // Matching `type: label` in core.data_types.schema.yml. + new RegexConstraint( + pattern: '/([^\PC])/u', + message: 'Recipe type cannot span multiple lines or contain control characters.', + match: FALSE, + ), + ]), + 'recipes' => new Optional([ + new All([ + new Type('string'), + new NotBlank(), + // If recipe depends on itself, ::validateRecipeExists() will set off + // an infinite loop. We can avoid that by skipping that validation if + // the recipe depends on itself, which is what Sequentially does. + new Sequentially([ + new NotIdenticalTo( + value: basename(dirname($file)), + message: 'The {{ compared_value }} recipe cannot depend on itself.', + ), + new Callback( + callback: self::validateRecipeExists(...), + payload: $discovery, + ), + ]), + ]), + ]), + // @todo https://www.drupal.org/i/3424603 Validate the corresponding + // import. + 'install' => new Optional([ + new All([ + new Type('string'), + new Sequentially([ + new NotBlank(), + new Callback(self::validateExtensionIsAvailable(...)), + ]), + ]), + ]), + 'config' => new Optional([ + new Collection([ + // Each entry in the `import` list can either be `*` (import all of + // the extension's config), or a list of config names to import from + // the extension. + // @todo https://www.drupal.org/i/3439716 Validate config file name, + // if given. + 'import' => new Optional([ + new All([ + new AtLeastOneOf([ + new IdenticalTo('*'), + new All([ + new Type('string'), + new NotBlank(), + new Regex('/^.+\./'), + ]), + ]), + ]), + ]), + 'actions' => new Optional([ + new All([ + new Type('array'), + new NotBlank(), + new Callback( + callback: self::validateConfigActions(...), + payload: $discovery, + ), + ]), + ]), + ]), + ]), + 'content' => new Optional([ + new Type('array'), + ]), + ]); + + $recipe_data = Yaml::decode($recipe_contents); + /** @var \Symfony\Component\Validator\ConstraintViolationList $violations */ + $violations = Validation::createValidator()->validate($recipe_data, $constraints); + if (count($violations) > 0) { + throw RecipeFileException::fromViolationList($file, $violations); + } + $recipe_data += [ + 'description' => '', + 'type' => '', + 'recipes' => [], + 'install' => [], + 'config' => [], + 'content' => [], + ]; + return $recipe_data; + } + + /** + * Validates that the value is an available module/theme (installed or not). + * + * @param string $value + * The value to validate. + * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context + * The validator execution context. + * + * @see \Drupal\Core\Extension\ExtensionList::getAllAvailableInfo() + */ + private static function validateExtensionIsAvailable(string $value, ExecutionContextInterface $context): void { + $name = Dependency::createFromString($value)->getName(); + $all_available = \Drupal::service(ModuleExtensionList::class)->getAllAvailableInfo() + \Drupal::service(ThemeExtensionList::class)->getAllAvailableInfo(); + if (!array_key_exists($name, $all_available)) { + $context->addViolation('"%extension" is not a known module or theme.', [ + '%extension' => $name, + ]); + } + } + + /** + * Validates that a recipe exists. + * + * @param string $name + * The machine name of the recipe to look for. + * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context + * The validator execution context. + * @param \Drupal\Core\Recipe\RecipeDiscovery $discovery + * A discovery object to find other recipes. + */ + private static function validateRecipeExists(string $name, ExecutionContextInterface $context, RecipeDiscovery $discovery): void { + if (empty($name)) { + return; + } + try { + $discovery->getRecipe($name); + } + catch (UnknownRecipeException) { + $context->addViolation('The %name recipe does not exist.', ['%name' => $name]); + } + } + + /** + * Gets the recipe discovery object for a recipe. + * + * @param string $recipeDirectory + * The directory the contains the recipe. + * + * @return \Drupal\Core\Recipe\RecipeDiscovery + */ + private static function getRecipeDiscovery(string $recipeDirectory): RecipeDiscovery { + return new RecipeDiscovery($recipeDirectory); + } + + /** + * Validates that the corresponding extension is enabled for a config action. + * + * @param mixed $value + * The config action; not used. + * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context + * The validator execution context. + * @param \Drupal\Core\Recipe\RecipeDiscovery $discovery + * A discovery object to find other recipes. + */ + private static function validateConfigActions(mixed $value, ExecutionContextInterface $context, RecipeDiscovery $discovery): void { + $config_name = str_replace(['[config][actions]', '[', ']'], '', $context->getPropertyPath()); + [$config_provider] = explode('.', $config_name); + if ($config_provider === 'core') { + return; + } + + $recipe_being_validated = $context->getRoot(); + assert(is_array($recipe_being_validated)); + + $configurator = new RecipeConfigurator($recipe_being_validated['recipes'] ?? [], $discovery); + + // The config provider must either be an already-installed module or theme, + // or an extension being installed by this recipe or a recipe it depends on. + $all_extensions = [ + ...array_keys(\Drupal::service('extension.list.module')->getAllInstalledInfo()), + ...array_keys(\Drupal::service('extension.list.theme')->getAllInstalledInfo()), + ...$recipe_being_validated['install'] ?? [], + ...$configurator->listAllExtensions(), + ]; + + if (!in_array($config_provider, $all_extensions, TRUE)) { + $context->addViolation('Config actions cannot be applied to %config_name because the %config_provider extension is not installed, and is not installed by this recipe or any of the recipes it depends on.', [ + '%config_name' => $config_name, + '%config_provider' => $config_provider, + ]); + } + } + +} diff --git a/core/lib/Drupal/Core/Recipe/RecipeAppliedEvent.php b/core/lib/Drupal/Core/Recipe/RecipeAppliedEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..3e3078d23ca21b7d404651aab79aeff0fff203a6 --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/RecipeAppliedEvent.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +use Symfony\Contracts\EventDispatcher\Event; + +/** + * Event dispatched after a recipe has been applied. + * + * Subscribers to this event should avoid modifying config or content, because + * it is very likely that the recipe was applied as part of a chain of recipes, + * so config and content are probably about to change again. This event is best + * used for tasks like notifications, logging or updating a value in state. + */ +final class RecipeAppliedEvent extends Event { + + /** + * Constructs a RecipeAppliedEvent object. + * + * @param \Drupal\Core\Recipe\Recipe $recipe + * The recipe that was applied. + */ + public function __construct(public readonly Recipe $recipe) { + } + +} diff --git a/core/lib/Drupal/Core/Recipe/RecipeCommand.php b/core/lib/Drupal/Core/Recipe/RecipeCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..84ee9e2d654bd64e4b9d0fc52092c0fad240d707 --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/RecipeCommand.php @@ -0,0 +1,216 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +use Drupal\Component\Render\PlainTextOutput; +use Drupal\Core\Config\Checkpoint\Checkpoint; +use Drupal\Core\Config\ConfigImporter; +use Drupal\Core\Config\ConfigImporterException; +use Drupal\Core\Config\StorageComparer; +use Drupal\Core\DrupalKernel; +use Drupal\Core\Site\Settings; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LogLevel; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Logger\ConsoleLogger; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\HttpFoundation\Request; + +/** + * Applies recipe. + * + * @internal + * This API is experimental. + */ +final class RecipeCommand extends Command { + + /** + * The class loader. + * + * @var object + */ + protected $classLoader; + + /** + * Constructs a new RecipeCommand command. + * + * @param object $class_loader + * The class loader. + */ + public function __construct($class_loader) { + parent::__construct('recipe'); + $this->classLoader = $class_loader; + } + + /** + * {@inheritdoc} + */ + protected function configure(): void { + $this + ->setDescription('Applies a recipe to a site.') + ->addArgument('path', InputArgument::REQUIRED, 'The path to the recipe\'s folder to apply'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); + + $recipe_path = $input->getArgument('path'); + if (!is_string($recipe_path) || !is_dir($recipe_path)) { + $io->error(sprintf('The supplied path %s is not a directory', $recipe_path)); + } + // Recipes can only be applied to an already-installed site. + $container = $this->boot()->getContainer(); + + /** @var \Drupal\Core\Config\Checkpoint\CheckpointStorageInterface $checkpoint_storage */ + $checkpoint_storage = $container->get('config.storage.checkpoint'); + $recipe = Recipe::createFromDirectory($recipe_path); + if ($checkpoint_storage instanceof LoggerAwareInterface) { + $logger = new ConsoleLogger($output, [ + // The checkpoint storage logs a notice if it decides to not create a + // checkpoint, and we want to be sure those notices are seen even + // without additional verbosity. + LogLevel::NOTICE => OutputInterface::VERBOSITY_NORMAL, + ]); + $checkpoint_storage->setLogger($logger); + } + $backup_checkpoint = $checkpoint_storage + ->checkpoint("Backup before the '$recipe->name' recipe."); + try { + $steps = RecipeRunner::toBatchOperations($recipe); + $progress_bar = $io->createProgressBar(); + $progress_bar->setFormat("%current%/%max% [%bar%]\n%message%\n"); + $progress_bar->setMessage($this->toPlainString(t('Applying recipe'))); + $progress_bar->start(count($steps)); + + /** @var array{message?: \Stringable|string, results: array{module?: string[], theme?: string[], content?: string[], recipe?: string[]}} $context */ + $context = ['results' => []]; + foreach ($steps as $step) { + call_user_func_array($step[0], array_merge($step[1], [&$context])); + if (isset($context['message'])) { + $progress_bar->setMessage($this->toPlainString($context['message'])); + } + unset($context['message']); + $progress_bar->advance(); + } + if ($io->isVerbose()) { + if (!empty($context['results']['module'])) { + $io->section($this->toPlainString(t('Modules installed'))); + $modules = array_map(fn ($module) => \Drupal::service('extension.list.module')->getName($module), $context['results']['module']); + sort($modules, SORT_NATURAL); + $io->listing($modules); + } + if (!empty($context['results']['theme'])) { + $io->section($this->toPlainString(t('Themes installed'))); + $themes = array_map(fn ($theme) => \Drupal::service('extension.list.theme')->getName($theme), $context['results']['theme']); + sort($themes, SORT_NATURAL); + $io->listing($themes); + } + if (!empty($context['results']['content'])) { + $io->section($this->toPlainString(t('Content created for recipes'))); + $io->listing($context['results']['content']); + } + if (!empty($context['results']['recipe'])) { + $io->section($this->toPlainString(t('Recipes applied'))); + $io->listing($context['results']['recipe']); + } + } + $io->success($this->toPlainString(t('%recipe applied successfully', ['%recipe' => $recipe->name]))); + return 0; + } + catch (\Throwable $e) { + try { + $this->rollBackToCheckpoint($backup_checkpoint); + } + catch (ConfigImporterException $importer_exception) { + $io->error($importer_exception->getMessage()); + } + throw $e; + } + } + + /** + * Converts a stringable like TranslatableMarkup to a plain text string. + * + * @param \Stringable|string $text + * The string to convert. + * + * @return string + * The plain text string. + */ + private function toPlainString(\Stringable|string $text): string { + return PlainTextOutput::renderFromHtml((string) $text); + } + + /** + * Rolls config back to a particular checkpoint. + * + * @param \Drupal\Core\Config\Checkpoint\Checkpoint $checkpoint + * The checkpoint to roll back to. + */ + private function rollBackToCheckpoint(Checkpoint $checkpoint): void { + $container = \Drupal::getContainer(); + + /** @var \Drupal\Core\Config\Checkpoint\CheckpointStorageInterface $checkpoint_storage */ + $checkpoint_storage = $container->get('config.storage.checkpoint'); + $checkpoint_storage->setCheckpointToReadFrom($checkpoint); + + $storage_comparer = new StorageComparer($checkpoint_storage, $container->get('config.storage')); + $storage_comparer->reset(); + + $config_importer = new ConfigImporter( + $storage_comparer, + $container->get('event_dispatcher'), + $container->get('config.manager'), + $container->get('lock'), + $container->get('config.typed'), + $container->get('module_handler'), + $container->get('module_installer'), + $container->get('theme_handler'), + $container->get('string_translation'), + $container->get('extension.list.module'), + $container->get('extension.list.theme'), + ); + $config_importer->import(); + } + + /** + * Boots up a Drupal environment. + * + * @return \Drupal\Core\DrupalKernelInterface + * The Drupal kernel. + * + * @throws \Exception + * Exception thrown if kernel does not boot. + */ + protected function boot() { + $kernel = new DrupalKernel('prod', $this->classLoader, FALSE); + $kernel::bootEnvironment(); + $kernel->setSitePath($this->getSitePath()); + Settings::initialize($kernel->getAppRoot(), $kernel->getSitePath(), $this->classLoader); + $kernel->boot(); + $kernel->preHandle(Request::createFromGlobals()); + return $kernel; + } + + /** + * Gets the site path. + * + * Defaults to 'sites/default'. For testing purposes this can be overridden + * using the DRUPAL_DEV_SITE_PATH environment variable. + * + * @return string + * The site path to use. + */ + protected function getSitePath() { + return getenv('DRUPAL_DEV_SITE_PATH') ?: 'sites/default'; + } + +} diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php b/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php new file mode 100644 index 0000000000000000000000000000000000000000..db0e6e24b0b6b31a6717a7be6e50dc8431eb2819 --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +use Drupal\Core\Config\ConfigInstaller; +use Drupal\Core\Config\Entity\ConfigDependencyManager; +use Drupal\Core\Config\StorageInterface; +use Drupal\Core\Installer\InstallerKernel; +use Drupal\Core\Validation\Plugin\Validation\Constraint\FullyValidatableConstraint; + +/** + * Extends the ConfigInstaller service for recipes. + * + * @internal + * This API is experimental. + */ +final class RecipeConfigInstaller extends ConfigInstaller { + + /** + * {@inheritdoc} + */ + public function installRecipeConfig(ConfigConfigurator $recipe_config): void { + $storage = $recipe_config->getConfigStorage(); + + // Build the list of new configuration to create. + $list = array_diff($storage->listAll(), $this->getActiveStorages()->listAll()); + + // If there is nothing to do. + if (empty($list)) { + return; + } + + $config_to_create = $storage->readMultiple($list); + + // Sort $config_to_create in the order of the least dependent first. + $dependency_manager = new ConfigDependencyManager(); + $dependency_manager->setData($config_to_create); + $config_to_create = array_merge(array_flip($dependency_manager->sortAll()), $config_to_create); + + // Create the optional configuration if there is any left after filtering. + if (!empty($config_to_create)) { + $this->createConfiguration(StorageInterface::DEFAULT_COLLECTION, $config_to_create); + } + + // Validation during the installer is hard. For example: + // Drupal\ckeditor5\Plugin\Validation\Constraint\EnabledConfigurablePluginsConstraintValidator + // ends up calling _ckeditor5_theme_css() via + // Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition->validateDrupalAspects() + // and this expects the theme system to be set up correctly but we're in the + // installer so this cannot happen. + // @todo https://www.drupal.org/i/3443603 consider adding a validation step + // for recipes to the installer via install_tasks(). + if (InstallerKernel::installationAttempted()) { + return; + } + + foreach (array_keys($config_to_create) as $name) { + // All config objects are mappings. + /** @var \Drupal\Core\Config\Schema\Mapping $typed_config */ + $typed_config = $this->typedConfig->createFromNameAndData($name, $this->configFactory->get($name)->getRawData()); + foreach ($typed_config->getConstraints() as $constraint) { + // Only validate the config if it has explicitly been marked as being + // validatable. + if ($constraint instanceof FullyValidatableConstraint) { + /** @var \Symfony\Component\Validator\ConstraintViolationList $violations */ + $violations = $typed_config->validate(); + if (count($violations) > 0) { + throw new InvalidConfigException($violations, $typed_config); + } + break; + } + } + } + } + +} diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php b/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php new file mode 100644 index 0000000000000000000000000000000000000000..9af54bfcb733b8e0d472189eefb8814cddcffd40 --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php @@ -0,0 +1,162 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +use Drupal\Core\Config\NullStorage; +use Drupal\Core\Config\StorageInterface; + +/** + * Merges two storages together. + * + * @internal + * This API is experimental. + */ +final class RecipeConfigStorageWrapper implements StorageInterface { + + /** + * @param \Drupal\Core\Config\StorageInterface $storageA + * First config storage to wrap. + * @param \Drupal\Core\Config\StorageInterface $storageB + * Second config storage to wrap. + * @param string $collection + * (optional) The collection to store configuration in. Defaults to the + * default collection. + */ + public function __construct( + protected readonly StorageInterface $storageA, + protected readonly StorageInterface $storageB, + protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION, + ) { + } + + /** + * Creates a single config storage for an array of storages. + * + * If the same configuration is contained in multiple storages then the + * version returned is from the first storage supplied in the $storages array. + * + * @param \Drupal\Core\Config\StorageInterface[] $storages + * An array of storages to merge into a single storage. + * + * @return \Drupal\Core\Config\StorageInterface + * A config storage that represents a merge of all the provided storages. + */ + public static function createStorageFromArray(array $storages): StorageInterface { + // If storages is empty use the NullStorage to represent an empty storage. + if (empty($storages)) { + return new NullStorage(); + } + + // When there is only one storage there is no point wrapping it. + if (count($storages) === 1) { + return reset($storages); + } + + // Reduce all the storages to a single RecipeConfigStorageWrapper object. + // The storages are prioritized in the order they are added to $storages. + return array_reduce($storages, fn(StorageInterface $carry, StorageInterface $storage) => new static($carry, $storage), new static( + array_shift($storages), + array_shift($storages) + )); + } + + /** + * {@inheritdoc} + */ + public function exists($name): bool { + return $this->storageA->exists($name) || $this->storageB->exists($name); + } + + /** + * {@inheritdoc} + */ + public function read($name): array|bool { + return $this->storageA->read($name) ?: $this->storageB->read($name); + } + + /** + * {@inheritdoc} + */ + public function readMultiple(array $names): array { + // If both storageA and storageB contain the same configuration, the value + // for storageA takes precedence. + return array_merge($this->storageB->readMultiple($names), $this->storageA->readMultiple($names)); + } + + /** + * {@inheritdoc} + */ + public function write($name, array $data): bool { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function delete($name): bool { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function rename($name, $new_name): bool { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function encode($data): string { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function decode($raw): array { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function listAll($prefix = ''): array { + return array_unique(array_merge($this->storageA->listAll($prefix), $this->storageB->listAll($prefix))); + } + + /** + * {@inheritdoc} + */ + public function deleteAll($prefix = ''): bool { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function createCollection($collection): static { + return new static( + $this->storageA->createCollection($collection), + $this->storageB->createCollection($collection), + $collection + ); + } + + /** + * {@inheritdoc} + */ + public function getAllCollectionNames(): array { + return array_unique(array_merge($this->storageA->getAllCollectionNames(), $this->storageB->getAllCollectionNames())); + } + + /** + * {@inheritdoc} + */ + public function getCollectionName(): string { + return $this->collection; + } + +} diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php b/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php new file mode 100644 index 0000000000000000000000000000000000000000..927bcfc17fffa80c4971aced46a17dd18bceeb0b --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +/** + * @internal + * This API is experimental. + */ +final class RecipeConfigurator { + + public readonly array $recipes; + + /** + * @param string[] $recipes + * A list of recipes for a recipe to apply. The recipes will be applied in + * the order listed. + * @param \Drupal\Core\Recipe\RecipeDiscovery $recipeDiscovery + * Recipe discovery. + */ + public function __construct(array $recipes, RecipeDiscovery $recipeDiscovery) { + $this->recipes = array_map([$recipeDiscovery, 'getRecipe'], $recipes); + } + + /** + * Returns all the recipes installed by this recipe. + * + * @return \Drupal\Core\Recipe\Recipe[] + * An array of all the recipes being installed. + */ + private function listAllRecipes(): array { + $recipes = []; + foreach ($this->recipes as $recipe) { + $recipes[] = $recipe; + $recipes = array_merge($recipes, $recipe->recipes->listAllRecipes()); + } + return array_values(array_unique($recipes, SORT_REGULAR)); + } + + /** + * List all the extensions installed by this recipe and its dependencies. + * + * @return string[] + * All the modules and themes that will be installed by the current + * recipe and all the recipes it depends on. + */ + public function listAllExtensions(): array { + $extensions = []; + foreach ($this->listAllRecipes() as $recipe) { + $extensions = array_merge($extensions, $recipe->install->modules, $recipe->install->themes); + } + return array_values(array_unique($extensions)); + } + +} diff --git a/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php b/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php new file mode 100644 index 0000000000000000000000000000000000000000..e27bcee390124008642479d70d04be2fd262a36b --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +/** + * @internal + * This API is experimental. + */ +final class RecipeDiscovery { + + /** + * Constructs a recipe discovery object. + * + * @param string $path + * The path will be searched folders containing a recipe.yml. There will be + * no traversal further into the directory structure. + */ + public function __construct(protected string $path) { + } + + /** + * Gets a recipe object. + * + * @param string $name + * The machine name of the recipe to find. + * + * @return \Drupal\Core\Recipe\Recipe + * The recipe object. + * + * @throws \Drupal\Core\Recipe\UnknownRecipeException + * Thrown when the recipe cannot be found. + */ + public function getRecipe(string $name): Recipe { + // In order to allow recipes to include core provided recipes, $name can be + // a Drupal root relative path to a recipe folder. For example, a recipe can + // include the core provided 'article_tags' recipe by listing the recipe as + // 'core/recipes/article_tags'. It is strongly recommended not to rely on + // relative paths for including recipes. Required recipes should be put in + // the same parent directory as the recipe being applied. Note, only linux + // style directory separators are supported. PHP on Windows can resolve the + // mix of directory separators. + if (str_contains($name, '/')) { + $path = \Drupal::root() . "/$name/recipe.yml"; + } + else { + $path = $this->path . "/$name/recipe.yml"; + } + + if (file_exists($path)) { + return Recipe::createFromDirectory(dirname($path)); + } + $search_path = dirname($path, 2); + throw new UnknownRecipeException($name, $search_path, sprintf("Can not find the %s recipe, search path: %s", $name, $search_path)); + } + +} diff --git a/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php b/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php new file mode 100644 index 0000000000000000000000000000000000000000..604b566d76550aab1d9c4c3b3cedd02e2c0f0a62 --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php @@ -0,0 +1,146 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +use Drupal\Core\Config\FileStorage; +use Drupal\Core\Config\StorageInterface; + +/** + * Allows the recipe to select configuration from the module. + * + * @internal + * This API is experimental. + */ +final class RecipeExtensionConfigStorage implements StorageInterface { + + protected readonly StorageInterface $storage; + + /** + * @param string $extensionPath + * The path extension to read configuration from + * @param array $configNames + * The list of config to read from the extension. An empty array means all + * configuration. + * @param string $collection + * (optional) The collection to store configuration in. Defaults to the + * default collection. + */ + public function __construct(protected readonly string $extensionPath, protected readonly array $configNames, protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION) { + $this->storage = new RecipeConfigStorageWrapper( + new FileStorage($this->extensionPath . '/config/install', $this->collection), + new FileStorage($this->extensionPath . '/config/optional', $this->collection), + $collection + ); + } + + /** + * {@inheritdoc} + */ + public function exists($name): bool { + if (!empty($this->configNames) && !in_array($name, $this->configNames, TRUE)) { + return FALSE; + } + return $this->storage->exists($name); + } + + /** + * {@inheritdoc} + */ + public function read($name): array|bool { + if (!empty($this->configNames) && !in_array($name, $this->configNames, TRUE)) { + return FALSE; + } + return $this->storage->read($name); + } + + /** + * {@inheritdoc} + */ + public function readMultiple(array $names): array { + if (!empty($this->configNames)) { + $names = array_intersect($this->configNames, $names); + } + return $this->storage->readMultiple($names); + } + + /** + * {@inheritdoc} + */ + public function write($name, array $data): bool { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function delete($name): bool { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function rename($name, $new_name): bool { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function encode($data): string { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function decode($raw): array { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function listAll($prefix = ''): array { + $names = $this->storage->listAll($prefix); + if (!empty($this->configNames)) { + $names = array_intersect($this->configNames, $names); + } + return $names; + } + + /** + * {@inheritdoc} + */ + public function deleteAll($prefix = ''): bool { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function createCollection($collection): static { + return new static( + $this->extensionPath, + $this->configNames, + $collection + ); + } + + /** + * {@inheritdoc} + */ + public function getAllCollectionNames(): array { + return $this->storage->getAllCollectionNames(); + } + + /** + * {@inheritdoc} + */ + public function getCollectionName(): string { + return $this->collection; + } + +} diff --git a/core/lib/Drupal/Core/Recipe/RecipeFileException.php b/core/lib/Drupal/Core/Recipe/RecipeFileException.php new file mode 100644 index 0000000000000000000000000000000000000000..d45cc7ba494e5ed0b680555eb327c59a890f160e --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/RecipeFileException.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +use Symfony\Component\Validator\ConstraintViolationList; + +/** + * @internal + * This API is experimental. + */ +final class RecipeFileException extends \RuntimeException { + + /** + * Constructs a RecipeFileException object. + * + * @param string $path + * The path of the offending recipe file. + * @param string $message + * (optional) The exception message. + * @param \Symfony\Component\Validator\ConstraintViolationList|null $violations + * (optional) A list of validation constraint violations in the recipe file, + * if any. + * @param int $code + * (optional) The exception code. + * @param \Throwable|null $previous + * (optional) The previous exception, if any. + */ + public function __construct( + public readonly string $path, + string $message = '', + public readonly ?ConstraintViolationList $violations = NULL, + int $code = 0, + \Throwable $previous = NULL, + ) { + parent::__construct($message, $code, $previous); + } + + /** + * Creates an instance of this exception from a set of validation errors. + * + * @param string $path + * The path of the offending recipe file. + * @param \Symfony\Component\Validator\ConstraintViolationList $violations + * The list of validation constraint violations. + * + * @return static + */ + public static function fromViolationList(string $path, ConstraintViolationList $violations): static { + $lines = ["Validation errors were found in $path:"]; + + foreach ($violations as $violation) { + $lines[] = sprintf('- %s: %s', $violation->getPropertyPath(), $violation->getMessage()); + } + return new static($path, implode("\n", $lines), $violations); + } + +} diff --git a/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php b/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php new file mode 100644 index 0000000000000000000000000000000000000000..cdc2102196365b4bb6e8e6bbbfe299052c24b84e --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +use Drupal\Component\Assertion\Inspector; + +/** + * Exception thrown when recipes contain or depend on missing extensions. + * + * @internal + * This API is experimental. + */ +final class RecipeMissingExtensionsException extends \RuntimeException { + + /** + * Constructs a RecipeMissingExtensionsException. + * + * @param array $extensions + * The list of missing extensions. + * @param string $message + * [optional] The Exception message to throw. + * @param int $code + * [optional] The Exception code. + * @param null|\Throwable $previous + * [optional] The previous throwable used for the exception chaining. + */ + public function __construct(public readonly array $extensions, string $message = "", int $code = 0, ?\Throwable $previous = NULL) { + assert(Inspector::assertAllStrings($extensions), 'Extension names must be strings.'); + if (!$message) { + $sorted = $this->extensions; + sort($sorted); + $message = sprintf("The following extensions are missing and are required for this recipe: %s", implode(", ", $sorted)); + } + parent::__construct($message, $code, $previous); + } + +} diff --git a/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php b/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php new file mode 100644 index 0000000000000000000000000000000000000000..da5f3d713f19071ffe150b16184ddfec9fe1a8dc --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php @@ -0,0 +1,137 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +use Drupal\Core\Config\StorageInterface; + +/** + * Wraps a config storage to allow recipe provided configuration to override it. + * + * @internal + * This API is experimental. + */ +final class RecipeOverrideConfigStorage implements StorageInterface { + + /** + * @param \Drupal\Core\Config\StorageInterface $recipeStorage + * The recipe's configuration storage. + * @param \Drupal\Core\Config\StorageInterface $wrappedStorage + * The storage to override. + * @param string $collection + * (optional) The collection to store configuration in. Defaults to the + * default collection. + */ + public function __construct( + protected readonly StorageInterface $recipeStorage, + protected readonly StorageInterface $wrappedStorage, + protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION, + ) { + } + + /** + * {@inheritdoc} + */ + public function exists($name): bool { + return $this->wrappedStorage->exists($name); + } + + /** + * {@inheritdoc} + */ + public function read($name): array|bool { + if ($this->wrappedStorage->exists($name) && $this->recipeStorage->exists($name)) { + return $this->recipeStorage->read($name); + } + return $this->wrappedStorage->read($name); + } + + /** + * {@inheritdoc} + */ + public function readMultiple(array $names): array { + $data = $this->wrappedStorage->readMultiple($names); + foreach ($data as $name => $value) { + if ($this->recipeStorage->exists($name)) { + $data[$name] = $this->recipeStorage->read($name); + } + } + return $data; + } + + /** + * {@inheritdoc} + */ + public function write($name, array $data): bool { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function delete($name): bool { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function rename($name, $new_name): bool { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function encode($data): string { + return $this->wrappedStorage->encode($data); + } + + /** + * {@inheritdoc} + */ + public function decode($raw): array { + return $this->wrappedStorage->decode($raw); + } + + /** + * {@inheritdoc} + */ + public function listAll($prefix = ''): array { + return $this->wrappedStorage->listAll($prefix); + } + + /** + * {@inheritdoc} + */ + public function deleteAll($prefix = ''): bool { + throw new \BadMethodCallException(); + } + + /** + * {@inheritdoc} + */ + public function createCollection($collection): static { + return new static( + $this->recipeStorage->createCollection($collection), + $this->wrappedStorage->createCollection($collection), + $collection + ); + } + + /** + * {@inheritdoc} + */ + public function getAllCollectionNames(): array { + return $this->wrappedStorage->getAllCollectionNames(); + } + + /** + * {@inheritdoc} + */ + public function getCollectionName(): string { + return $this->collection; + } + +} diff --git a/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php b/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php new file mode 100644 index 0000000000000000000000000000000000000000..39e5d4e2c7273c5c3dbded28d7bf9790e533ecae --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +/** + * Exception thrown when a recipe has configuration that exists already. + * + * @internal + * This API is experimental. + */ +class RecipePreExistingConfigException extends \RuntimeException { + + /** + * Constructs a RecipePreExistingConfigException. + * + * @param string $configName + * The configuration name that has missing dependencies. + * @param string $message + * [optional] The Exception message to throw. + * @param int $code + * [optional] The Exception code. + * @param null|\Throwable $previous + * [optional] The previous throwable used for the exception chaining. + */ + public function __construct(public readonly string $configName, string $message = "", int $code = 0, ?\Throwable $previous = NULL) { + parent::__construct($message, $code, $previous); + } + +} diff --git a/core/lib/Drupal/Core/Recipe/RecipeRunner.php b/core/lib/Drupal/Core/Recipe/RecipeRunner.php new file mode 100644 index 0000000000000000000000000000000000000000..626c203d0371f00142283f6d037039908fcc3855 --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/RecipeRunner.php @@ -0,0 +1,319 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +use Drupal\Core\Config\FileStorage; +use Drupal\Core\Config\InstallStorage; +use Drupal\Core\Config\StorageInterface; +use Drupal\Core\DefaultContent\Existing; +use Drupal\Core\DefaultContent\Importer; +use Drupal\Core\DefaultContent\Finder; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * Applies a recipe. + * + * This class is currently static and use \Drupal::service() in order to put off + * having to solve issues caused by container rebuilds during module install and + * configuration import. + * + * @internal + * This API is experimental. + * + * @todo https://www.drupal.org/i/3439717 Determine if there is a better to + * inject and re-inject services. + */ +final class RecipeRunner { + + /** + * @param \Drupal\Core\Recipe\Recipe $recipe + * The recipe to apply. + */ + public static function processRecipe(Recipe $recipe): void { + static::processRecipes($recipe->recipes); + static::processInstall($recipe->install, $recipe->config->getConfigStorage()); + static::processConfiguration($recipe->config); + static::processContent($recipe->content); + static::triggerEvent($recipe); + } + + /** + * Triggers the RecipeAppliedEvent. + * + * @param \Drupal\Core\Recipe\Recipe $recipe + * The recipe to apply. + * @param array<mixed>|null $context + * The batch context if called by a batch. + */ + public static function triggerEvent(Recipe $recipe, ?array &$context = NULL): void { + $event = new RecipeAppliedEvent($recipe); + \Drupal::service(EventDispatcherInterface::class)->dispatch($event); + $context['message'] = t('Applied %recipe recipe.', ['%recipe' => $recipe->name]); + $context['results']['recipe'][] = $recipe->name; + } + + /** + * Applies any recipes listed by the recipe. + * + * @param \Drupal\Core\Recipe\RecipeConfigurator $recipes + * The list of recipes to apply. + */ + protected static function processRecipes(RecipeConfigurator $recipes): void { + foreach ($recipes->recipes as $recipe) { + static::processRecipe($recipe); + } + } + + /** + * Installs the extensions. + * + * @param \Drupal\Core\Recipe\InstallConfigurator $install + * The list of extensions to install. + * @param \Drupal\Core\Config\StorageInterface $recipeConfigStorage + * The recipe's configuration storage. Used to override extension provided + * configuration. + */ + protected static function processInstall(InstallConfigurator $install, StorageInterface $recipeConfigStorage): void { + foreach ($install->modules as $name) { + static::installModule($name, $recipeConfigStorage); + } + + // Themes can depend on modules so have to be installed after modules. + foreach ($install->themes as $name) { + static::installTheme($name, $recipeConfigStorage); + } + } + + /** + * Creates configuration and applies configuration actions. + * + * @param \Drupal\Core\Recipe\ConfigConfigurator $config + * The config configurator from the recipe. + */ + protected static function processConfiguration(ConfigConfigurator $config): void { + $config_installer = new RecipeConfigInstaller( + \Drupal::service('config.factory'), + \Drupal::service('config.storage'), + \Drupal::service('config.typed'), + \Drupal::service('config.manager'), + \Drupal::service('event_dispatcher'), + NULL, + \Drupal::service('extension.path.resolver')); + + // Create configuration that is either supplied by the recipe or listed in + // the config.import section that does not exist. + $config_installer->installRecipeConfig($config); + + if (!empty($config->config['actions'])) { + // Process the actions. + /** @var \Drupal\Core\Config\Action\ConfigActionManager $config_action_manager */ + $config_action_manager = \Drupal::service('plugin.manager.config_action'); + foreach ($config->config['actions'] as $config_name => $actions) { + foreach ($actions as $action_id => $data) { + $config_action_manager->applyAction($action_id, $config_name, $data); + } + } + } + } + + /** + * Creates content contained in a recipe. + * + * @param \Drupal\Core\DefaultContent\Finder $content + * The content finder object for the recipe. + */ + protected static function processContent(Finder $content): void { + /** @var \Drupal\Core\DefaultContent\Importer $importer */ + $importer = \Drupal::service(Importer::class); + $importer->setLogger(\Drupal::logger('recipe')); + $importer->importContent($content, Existing::Skip); + } + + /** + * Converts a recipe into a series of batch operations. + * + * @param \Drupal\Core\Recipe\Recipe $recipe + * The recipe to convert to batch operations. + * + * @return array<int, array{0: callable, 1: array{mixed}}> + * The array of batch operations. Each value is an array with two values. + * The first value is a callable and the second value are the arguments to + * pass to the callable. + * + * @see \Drupal\Core\Batch\BatchBuilder::addOperation() + */ + public static function toBatchOperations(Recipe $recipe): array { + $modules = []; + $themes = []; + $recipes = []; + return static::toBatchOperationsRecipe($recipe, $recipes, $modules, $themes); + } + + /** + * Helper method to convert a recipe to batch operations. + * + * @param \Drupal\Core\Recipe\Recipe $recipe + * The recipe to convert to batch operations. + * @param string[] $recipes + * The paths of the recipes that have already been converted to batch operations. + * @param string[] $modules + * The modules that will already be installed due to previous recipes in the + * batch. + * @param string[] $themes + * The themes that will already be installed due to previous recipes in the + * batch. + * + * @return array<int, array{0: callable, 1: array{mixed}}> + * The array of batch operations. Each value is an array with two values. + * The first value is a callable and the second value are the arguments to + * pass to the callable. + */ + protected static function toBatchOperationsRecipe(Recipe $recipe, array $recipes, array &$modules, array &$themes): array { + if (in_array($recipe->path, $recipes, TRUE)) { + return []; + } + $steps = []; + $recipes[] = $recipe->path; + + foreach ($recipe->recipes->recipes as $sub_recipe) { + $steps = array_merge($steps, static::toBatchOperationsRecipe($sub_recipe, $recipes, $modules, $themes)); + } + $steps = array_merge($steps, static::toBatchOperationsInstall($recipe, $modules, $themes)); + if ($recipe->config->hasTasks()) { + $steps[] = [[RecipeRunner::class, 'installConfig'], [$recipe]]; + } + if (!empty($recipe->content->data)) { + $steps[] = [[RecipeRunner::class, 'installContent'], [$recipe]]; + } + $steps[] = [[RecipeRunner::class, 'triggerEvent'], [$recipe]]; + + return $steps; + } + + /** + * Converts a recipe's install tasks to batch operations. + * + * @param \Drupal\Core\Recipe\Recipe $recipe + * The recipe to convert install tasks to batch operations. + * @param string[] $modules + * The modules that will already be installed due to previous recipes in the + * batch. + * @param string[] $themes + * The themes that will already be installed due to previous recipes in the + * batch. + * + * @return array<int, array{0: callable, 1: array{mixed}}> + * The array of batch operations. Each value is an array with two values. + * The first value is a callable and the second value are the arguments to + * pass to the callable. + */ + protected static function toBatchOperationsInstall(Recipe $recipe, array &$modules, array &$themes): array { + foreach ($recipe->install->modules as $name) { + if (in_array($name, $modules, TRUE)) { + continue; + } + $modules[] = $name; + $steps[] = [[RecipeRunner::class, 'installModule'], [$name, $recipe]]; + } + foreach ($recipe->install->themes as $name) { + if (in_array($name, $themes, TRUE)) { + continue; + } + $themes[] = $name; + $steps[] = [[RecipeRunner::class, 'installTheme'], [$name, $recipe]]; + } + return $steps ?? []; + } + + /** + * Installs a module for a recipe. + * + * @param string $module + * The name of the module to install. + * @param \Drupal\Core\Config\StorageInterface|\Drupal\Core\Recipe\Recipe $recipeConfigStorage + * The recipe or recipe's config storage. + * @param array<mixed>|null $context + * The batch context if called by a batch. + */ + public static function installModule(string $module, StorageInterface|Recipe $recipeConfigStorage, ?array &$context = NULL): void { + if ($recipeConfigStorage instanceof Recipe) { + $recipeConfigStorage = $recipeConfigStorage->config->getConfigStorage(); + } + // Disable configuration entity install but use the config directory from + // the module. + \Drupal::service('config.installer')->setSyncing(TRUE); + $default_install_path = \Drupal::service('extension.list.module')->get($module)->getPath() . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY; + // Allow the recipe to override simple configuration from the module. + $storage = new RecipeOverrideConfigStorage( + $recipeConfigStorage, + new FileStorage($default_install_path, StorageInterface::DEFAULT_COLLECTION) + ); + \Drupal::service('config.installer')->setSourceStorage($storage); + + \Drupal::service('module_installer')->install([$module]); + \Drupal::service('config.installer')->setSyncing(FALSE); + $context['message'] = t('Installed %module module.', ['%module' => \Drupal::service('extension.list.module')->getName($module)]); + $context['results']['module'][] = $module; + } + + /** + * Installs a theme for a recipe. + * + * @param string $theme + * The name of the theme to install. + * @param \Drupal\Core\Config\StorageInterface|\Drupal\Core\Recipe\Recipe $recipeConfigStorage + * The recipe or recipe's config storage. + * @param array<mixed>|null $context + * The batch context if called by a batch. + */ + public static function installTheme(string $theme, StorageInterface|Recipe $recipeConfigStorage, ?array &$context = NULL): void { + if ($recipeConfigStorage instanceof Recipe) { + $recipeConfigStorage = $recipeConfigStorage->config->getConfigStorage(); + } + // Disable configuration entity install. + \Drupal::service('config.installer')->setSyncing(TRUE); + $default_install_path = \Drupal::service('extension.list.theme')->get($theme)->getPath() . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY; + // Allow the recipe to override simple configuration from the theme. + $storage = new RecipeOverrideConfigStorage( + $recipeConfigStorage, + new FileStorage($default_install_path, StorageInterface::DEFAULT_COLLECTION) + ); + \Drupal::service('config.installer')->setSourceStorage($storage); + + \Drupal::service('theme_installer')->install([$theme]); + \Drupal::service('config.installer')->setSyncing(FALSE); + $context['message'] = t('Installed %theme theme.', ['%theme' => \Drupal::service('extension.list.theme')->getName($theme)]); + $context['results']['theme'][] = $theme; + } + + /** + * Installs a config for a recipe. + * + * @param \Drupal\Core\Recipe\Recipe $recipe + * The recipe to install config for. + * @param array<mixed>|null $context + * The batch context if called by a batch. + */ + public static function installConfig(Recipe $recipe, ?array &$context = NULL): void { + static::processConfiguration($recipe->config); + $context['message'] = t('Installed configuration for %recipe recipe.', ['%recipe' => $recipe->name]); + $context['results']['config'][] = $recipe->name; + } + + /** + * Installs a content for a recipe. + * + * @param \Drupal\Core\Recipe\Recipe $recipe + * The recipe to install content for. + * @param array<mixed>|null $context + * The batch context if called by a batch. + */ + public static function installContent(Recipe $recipe, ?array &$context = NULL): void { + static::processContent($recipe->content); + $context['message'] = t('Created content for %recipe recipe.', ['%recipe' => $recipe->name]); + $context['results']['content'][] = $recipe->name; + } + +} diff --git a/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php b/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php new file mode 100644 index 0000000000000000000000000000000000000000..b0f63c002824d9c2eea400e0cdeca7643c1b5789 --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +/** + * Exception thrown when recipe is can not be found. + * + * @internal + * This API is experimental. + */ +final class UnknownRecipeException extends \RuntimeException { + + /** + * @param string $recipe + * The recipe's name. + * @param string $searchPath + * The path searched for the recipe. + * @param string $message + * (optional) The exception message. + * @param int $code + * (optional) The exception code. + * @param \Throwable|null $previous + * (optional) The previous exception. + */ + public function __construct(public readonly string $recipe, public readonly string $searchPath, string $message = "", int $code = 0, ?\Throwable $previous = NULL) { + parent::__construct($message, $code, $previous); + } + +} diff --git a/core/modules/ckeditor5/src/Plugin/ConfigAction/AddItemToToolbar.php b/core/modules/ckeditor5/src/Plugin/ConfigAction/AddItemToToolbar.php new file mode 100644 index 0000000000000000000000000000000000000000..f239e226bc2654792a88c8f9d0be37e284030659 --- /dev/null +++ b/core/modules/ckeditor5/src/Plugin/ConfigAction/AddItemToToolbar.php @@ -0,0 +1,102 @@ +<?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(); + } + +} diff --git a/core/modules/ckeditor5/tests/src/Kernel/ConfigAction/AddItemToToolbarConfigActionTest.php b/core/modules/ckeditor5/tests/src/Kernel/ConfigAction/AddItemToToolbarConfigActionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c4c8b8e953aee0be256395bebf131844d6e10672 --- /dev/null +++ b/core/modules/ckeditor5/tests/src/Kernel/ConfigAction/AddItemToToolbarConfigActionTest.php @@ -0,0 +1,127 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\ckeditor5\Kernel\ConfigAction; + +use Drupal\Core\Config\Action\ConfigActionException; +use Drupal\Core\Recipe\InvalidConfigException; +use Drupal\Core\Recipe\RecipeRunner; +use Drupal\editor\Entity\Editor; +use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait; +use Drupal\KernelTests\KernelTestBase; + +/** + * @covers \Drupal\ckeditor5\Plugin\ConfigAction\AddItemToToolbar + * @group ckeditor5 + * @group Recipe + */ +class AddItemToToolbarConfigActionTest extends KernelTestBase { + + use RecipeTestTrait; + + /** + * {@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', + 'image_upload' => ['status' => FALSE], + ]); + $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)); + } + +} diff --git a/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml b/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..2f1dd51d4c677358813c1a0e50ccd0915c93e094 --- /dev/null +++ b/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml @@ -0,0 +1,4 @@ +name: 'Config action duplicate test' +type: module +package: Testing +version: VERSION diff --git a/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php b/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php new file mode 100644 index 0000000000000000000000000000000000000000..560b2c7241b60ae822146da5e053ba840bd46f02 --- /dev/null +++ b/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php @@ -0,0 +1,24 @@ +<?php + +namespace Drupal\config_action_duplicate_test\Plugin\ConfigAction; + +use Drupal\Core\Config\Action\Attribute\ConfigAction; +use Drupal\Core\Config\Action\ConfigActionPluginInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; + +#[ConfigAction( + id: 'config_action_duplicate_test:config_test.dynamic:setProtectedProperty', + admin_label: new TranslatableMarkup('A duplicate config action'), + entity_types: ['config_test'], +)] +final class DuplicateConfigAction implements ConfigActionPluginInterface { + + /** + * {@inheritdoc} + */ + public function apply(string $configName, mixed $value): void { + // This method should never be called. + throw new \BadMethodCallException(); + } + +} diff --git a/core/modules/config/tests/config_test/config/schema/config_test.schema.yml b/core/modules/config/tests/config_test/config/schema/config_test.schema.yml index b0118f6821161dea15b8f573a70fc8f894c8aa5e..99b7cfd8f06485340e380d34c69ca1109b176dec 100644 --- a/core/modules/config/tests/config_test/config/schema/config_test.schema.yml +++ b/core/modules/config/tests/config_test/config/schema/config_test.schema.yml @@ -24,6 +24,9 @@ config_test_dynamic: protected_property: type: string label: 'Protected property' + array_property: + type: ignore + label: 'Array property' config_test.dynamic.*: type: config_test_dynamic diff --git a/core/modules/config/tests/config_test/config_test.module b/core/modules/config/tests/config_test/config_test.module index b9fac8f793bc13cb758d4bceb812daa915f4b3c9..98e50125141d5ff08f460245734a73771ccd5fcd 100644 --- a/core/modules/config/tests/config_test/config_test.module +++ b/core/modules/config/tests/config_test/config_test.module @@ -42,6 +42,10 @@ function config_test_entity_type_alter(array &$entity_types) { if (\Drupal::service('state')->get('config_test.lookup_keys', FALSE)) { $entity_types['config_test']->set('lookup_keys', ['uuid', 'style']); } + + if (\Drupal::service('state')->get('config_test.class_override', FALSE)) { + $entity_types['config_test']->setClass(\Drupal::service('state')->get('config_test.class_override')); + } } /** diff --git a/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php new file mode 100644 index 0000000000000000000000000000000000000000..b51e41af5d95dac9b5403cf24fde5701fef47328 --- /dev/null +++ b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php @@ -0,0 +1,17 @@ +<?php + +namespace Drupal\config_test\ConfigActionErrorEntity; + +use Drupal\config_test\Entity\ConfigTest; +use Drupal\Core\Config\Action\Attribute\ActionMethod; + +/** + * Test entity class. + */ +class DuplicatePluralizedMethodName extends ConfigTest { + + #[ActionMethod(pluralize: 'testMethod')] + public function testMethod() { + } + +} diff --git a/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php new file mode 100644 index 0000000000000000000000000000000000000000..98c5d07559aa698df029c93484a0f186496b06a3 --- /dev/null +++ b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php @@ -0,0 +1,21 @@ +<?php + +namespace Drupal\config_test\ConfigActionErrorEntity; + +use Drupal\config_test\Entity\ConfigTest; +use Drupal\Core\Config\Action\Attribute\ActionMethod; + +/** + * Test entity class. + */ +class DuplicatePluralizedOtherMethodName extends ConfigTest { + + #[ActionMethod(pluralize: 'testMethod2')] + public function testMethod() { + } + + #[ActionMethod()] + public function testMethod2() { + } + +} diff --git a/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php b/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php index 6b9e8007dbf22cc99d704d715b662369e1230a63..b461828a635e0095d1dcdb5f4fc9dc422937d78d 100644 --- a/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php +++ b/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php @@ -46,4 +46,13 @@ class ConfigQueryTest extends ConfigTest { */ public $array = []; + /** + * {@inheritdoc} + */ + public function concatProtectedProperty(string $value1, string $value2): static { + // This method intentionally does not have the config action attribute to + // ensure it is still discovered. + return parent::concatProtectedProperty($value1, $value2); + } + } diff --git a/core/modules/config/tests/config_test/src/Entity/ConfigTest.php b/core/modules/config/tests/config_test/src/Entity/ConfigTest.php index 30a7a3526d0757c55d763faa88825dfb5be4bddb..e2597f0277d9680a0d5305adcee2c50f22bdf8e4 100644 --- a/core/modules/config/tests/config_test/src/Entity/ConfigTest.php +++ b/core/modules/config/tests/config_test/src/Entity/ConfigTest.php @@ -2,10 +2,12 @@ namespace Drupal\config_test\Entity; +use Drupal\Core\Config\Action\Attribute\ActionMethod; use Drupal\Core\Config\Entity\ConfigEntityBase; use Drupal\config_test\ConfigTestInterface; use Drupal\Core\Config\Entity\ConfigEntityInterface; use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; /** * Defines the ConfigTest configuration entity. @@ -36,6 +38,7 @@ * "size", * "size_value", * "protected_property", + * "array_property", * }, * links = { * "edit-form" = "/admin/structure/config_test/manage/{config_test}", @@ -83,6 +86,13 @@ class ConfigTest extends ConfigEntityBase implements ConfigTestInterface { */ protected $protected_property; + /** + * An array property of the configuration entity. + * + * @var array + */ + protected array $array_property = []; + /** * {@inheritdoc} */ @@ -188,4 +198,142 @@ public function isInstallable() { return $this->id != 'isinstallable' || \Drupal::state()->get('config_test.isinstallable'); } + /** + * Sets the protected property value. + * + * @param $value + * The value to set. + * + * @return $this + * The config entity. + */ + #[ActionMethod(pluralize: FALSE)] + public function setProtectedProperty(string $value): static { + $this->protected_property = $value; + return $this; + } + + /** + * Gets the protected property value. + * + * @return string + * The protected property value. + */ + public function getProtectedProperty(): string { + return $this->protected_property; + } + + /** + * Concatenates the two params and sets the protected property value. + * + * @param $value1 + * The first value to concatenate. + * @param $value2 + * The second value to concatenate. + * + * @return $this + * The config entity. + */ + #[ActionMethod()] + public function concatProtectedProperty(string $value1, string $value2): static { + $this->protected_property = $value1 . $value2; + return $this; + } + + /** + * Concatenates up to two params and sets the protected property value. + * + * @param $value1 + * The first value to concatenate. + * @param $value2 + * (optional) The second value to concatenate. Defaults to ''. + * + * @return $this + * The config entity. + */ + #[ActionMethod(pluralize: FALSE)] + public function concatProtectedPropertyOptional(string $value1, string $value2 = ''): static { + $this->protected_property = $value1 . $value2; + return $this; + } + + /** + * Appends to protected property. + * + * @param $value + * The value to append. + * + * @return $this + * The config entity. + */ + #[ActionMethod()] + public function append(string $value): static { + $this->protected_property .= $value; + return $this; + } + + /** + * Sets the protected property to a default value. + * + * @return $this + * The config entity. + */ + #[ActionMethod(pluralize: FALSE, adminLabel: new TranslatableMarkup('Set default name'))] + public function defaultProtectedProperty(): static { + $this->protected_property = 'Set by method'; + return $this; + } + + /** + * Adds a value to the array property. + * + * @param mixed $value + * The value to add. + * + * @return $this + * The config entity. + */ + #[ActionMethod(pluralize: 'addToArrayMultipleTimes')] + public function addToArray(mixed $value): static { + $this->array_property[] = $value; + return $this; + } + + /** + * Gets the array property value. + * + * @return array + * The array property value. + */ + public function getArrayProperty(): array { + return $this->array_property; + } + + /** + * Sets the array property. + * + * @param $value + * The value to set. + * + * @return $this + * The config entity. + */ + #[ActionMethod(pluralize: FALSE)] + public function setArray(array $value): static { + $this->array_property = $value; + return $this; + } + + /** + * {@inheritdoc} + */ + public function toArray() { + $properties = parent::toArray(); + // Only export the 'array_property' is there is data. + if (empty($properties['array_property'])) { + unset($properties['array_property']); + } + return $properties; + } + } diff --git a/core/modules/content_moderation/src/Plugin/ConfigAction/AddModeration.php b/core/modules/content_moderation/src/Plugin/ConfigAction/AddModeration.php new file mode 100644 index 0000000000000000000000000000000000000000..a2df177036ba003bf9c1c51c3e96132c2c5817f4 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/ConfigAction/AddModeration.php @@ -0,0 +1,74 @@ +<?php + +namespace Drupal\content_moderation\Plugin\ConfigAction; + +use Drupal\content_moderation\Plugin\WorkflowType\ContentModerationInterface; +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\Entity\EntityTypeManagerInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\workflows\WorkflowInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +#[ConfigAction( + id: 'add_moderation', + entity_types: ['workflow'], + deriver: AddModerationDeriver::class, +)] +final class AddModeration implements ConfigActionPluginInterface, ContainerFactoryPluginInterface { + + public function __construct( + private readonly ConfigManagerInterface $configManager, + private readonly EntityTypeManagerInterface $entityTypeManager, + private readonly string $pluginId, + private readonly string $targetEntityType, + ) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + assert(is_array($plugin_definition)); + $target_entity_type = $plugin_definition['target_entity_type']; + + return new static( + $container->get(ConfigManagerInterface::class), + $container->get(EntityTypeManagerInterface::class), + $plugin_id, + $target_entity_type, + ); + } + + /** + * {@inheritdoc} + */ + public function apply(string $configName, mixed $value): void { + $workflow = $this->configManager->loadConfigEntityByName($configName); + assert($workflow instanceof WorkflowInterface); + + $plugin = $workflow->getTypePlugin(); + if (!$plugin instanceof ContentModerationInterface) { + throw new ConfigActionException("The $this->pluginId config action only works with Content Moderation workflows."); + } + + assert($value === '*' || is_array($value)); + if ($value === '*') { + /** @var \Drupal\Core\Entity\EntityTypeInterface $definition */ + $definition = $this->entityTypeManager->getDefinition($this->targetEntityType); + /** @var string $bundle_entity_type */ + $bundle_entity_type = $definition->getBundleEntityType(); + + $value = $this->entityTypeManager->getStorage($bundle_entity_type) + ->getQuery() + ->accessCheck(FALSE) + ->execute(); + } + foreach ($value as $bundle) { + $plugin->addEntityTypeAndBundle($this->targetEntityType, $bundle); + } + $workflow->save(); + } + +} diff --git a/core/modules/content_moderation/src/Plugin/ConfigAction/AddModerationDeriver.php b/core/modules/content_moderation/src/Plugin/ConfigAction/AddModerationDeriver.php new file mode 100644 index 0000000000000000000000000000000000000000..fec8c773e66303f7bde3b41db91a8b92f725c205 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/ConfigAction/AddModerationDeriver.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\content_moderation\Plugin\ConfigAction; + +// cspell:ignore inflector + +use Drupal\Component\Plugin\Derivative\DeriverBase; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Symfony\Component\DependencyInjection\Container; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\String\Inflector\EnglishInflector; + +final class AddModerationDeriver extends DeriverBase implements ContainerDeriverInterface { + + use StringTranslationTrait; + + 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) { + $inflector = new EnglishInflector(); + + foreach ($this->entityTypeManager->getDefinitions() as $id => $entity_type) { + if ($bundle_entity_type = $entity_type->getBundleEntityType()) { + /** @var \Drupal\Core\Entity\EntityTypeInterface $bundle_entity_type */ + $bundle_entity_type = $this->entityTypeManager->getDefinition($bundle_entity_type); + // Convert unique plugin IDs, like `taxonomy_vocabulary`, into strings + // like `TaxonomyVocabulary`. + $suffix = Container::camelize($bundle_entity_type->id()); + [$suffix] = $inflector->pluralize($suffix); + $this->derivatives["add{$suffix}"] = [ + 'target_entity_type' => $id, + 'admin_label' => $this->t('Add moderation to all @bundles', [ + '@bundles' => $bundle_entity_type->getPluralLabel() ?: $bundle_entity_type->id(), + ]), + ] + $base_plugin_definition; + } + } + return parent::getDerivativeDefinitions($base_plugin_definition); + } + +} diff --git a/core/modules/content_moderation/tests/src/Kernel/ConfigAction/AddModerationConfigActionTest.php b/core/modules/content_moderation/tests/src/Kernel/ConfigAction/AddModerationConfigActionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4d5afd395e2ab2bf133a13125d69c362f5987fb1 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/ConfigAction/AddModerationConfigActionTest.php @@ -0,0 +1,110 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\content_moderation\Kernel\ConfigAction; + +use Drupal\Component\Plugin\Exception\PluginNotFoundException; +use Drupal\Core\Config\Action\ConfigActionException; +use Drupal\Core\Recipe\Recipe; +use Drupal\Core\Recipe\RecipeRunner; +use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait; +use Drupal\KernelTests\KernelTestBase; +use Drupal\Tests\node\Traits\ContentTypeCreationTrait; +use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait; +use Drupal\workflows\Entity\Workflow; + +/** + * @covers \Drupal\content_moderation\Plugin\ConfigAction\AddModeration + * @covers \Drupal\content_moderation\Plugin\ConfigAction\AddModerationDeriver + * @group content_moderation + * @group Recipe + */ +class AddModerationConfigActionTest extends KernelTestBase { + + use ContentTypeCreationTrait; + use RecipeTestTrait { + createRecipe as traitCreateRecipe; + } + use TaxonomyTestTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'field', + 'node', + 'system', + 'taxonomy', + 'text', + 'user', + ]; + + public function testAddEntityTypeAndBundle(): void { + $this->installConfig('node'); + + $this->createContentType(['type' => 'a']); + $this->createContentType(['type' => 'b']); + $this->createContentType(['type' => 'c']); + $this->createVocabulary(['vid' => 'tags']); + + $recipe = $this->createRecipe('workflows.workflow.editorial'); + RecipeRunner::processRecipe($recipe); + + /** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModerationInterface $plugin */ + $plugin = Workflow::load('editorial')?->getTypePlugin(); + $this->assertSame(['a', 'b'], $plugin->getBundlesForEntityType('node')); + $this->assertSame(['tags'], $plugin->getBundlesForEntityType('taxonomy_term')); + } + + public function testWorkflowMustBeContentModeration(): void { + $this->enableModules(['workflows', 'workflow_type_test']); + + $workflow = Workflow::create([ + 'id' => 'test', + 'label' => 'Test workflow', + 'type' => 'workflow_type_test', + ]); + $workflow->save(); + + $recipe = $this->createRecipe($workflow->getConfigDependencyName()); + $this->expectException(ConfigActionException::class); + $this->expectExceptionMessage("The add_moderation:addNodeTypes config action only works with Content Moderation workflows."); + RecipeRunner::processRecipe($recipe); + } + + public function testActionOnlyTargetsWorkflows(): void { + $recipe = $this->createRecipe('user.role.anonymous'); + $this->expectException(PluginNotFoundException::class); + $this->expectExceptionMessage('The "addNodeTypes" plugin does not exist.'); + RecipeRunner::processRecipe($recipe); + } + + public function testDeriverAdminLabel(): void { + $this->enableModules(['workflows', 'content_moderation']); + + /** @var array<string, array{admin_label: \Stringable}> $definitions */ + $definitions = $this->container->get('plugin.manager.config_action') + ->getDefinitions(); + + $this->assertSame('Add moderation to all content types', (string) $definitions['add_moderation:addNodeTypes']['admin_label']); + $this->assertSame('Add moderation to all vocabularies', (string) $definitions['add_moderation:addTaxonomyVocabularies']['admin_label']); + } + + private function createRecipe(string $config_name): Recipe { + $recipe = <<<YAML +name: 'Add entity types and bundles to workflow' +recipes: + - core/recipes/editorial_workflow +config: + actions: + $config_name: + addNodeTypes: + - a + - b + addTaxonomyVocabularies: '*' +YAML; + return $this->traitCreateRecipe($recipe); + } + +} diff --git a/core/modules/field/src/Plugin/ConfigAction/AddToAllBundles.php b/core/modules/field/src/Plugin/ConfigAction/AddToAllBundles.php new file mode 100644 index 0000000000000000000000000000000000000000..e1642a80bba8b2943d02c58b00f868e3c3477c2f --- /dev/null +++ b/core/modules/field/src/Plugin/ConfigAction/AddToAllBundles.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\field\Plugin\ConfigAction; + +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\Entity\EntityTypeBundleInfoInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\field\FieldStorageConfigInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Adds a field to all bundles of its target entity type. + * + * @internal + * This API is experimental. + */ +#[ConfigAction( + id: 'field_storage_config:addToAllBundles', + admin_label: new TranslatableMarkup('Add a field to all bundles'), + entity_types: ['field_storage_config'], +)] +final class AddToAllBundles implements ConfigActionPluginInterface, ContainerFactoryPluginInterface { + + public function __construct( + private readonly EntityTypeManagerInterface $entityTypeManager, + private readonly EntityTypeBundleInfoInterface $entityTypeBundleInfo, + private readonly ConfigManagerInterface $configManager, + ) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + return new static( + $container->get(EntityTypeManagerInterface::class), + $container->get(EntityTypeBundleInfoInterface::class), + $container->get(ConfigManagerInterface::class), + ); + + } + + /** + * {@inheritdoc} + */ + public function apply(string $configName, mixed $value): void { + assert(is_array($value)); + + $field_storage = $this->configManager->loadConfigEntityByName($configName); + assert($field_storage instanceof FieldStorageConfigInterface); + + $storage = $this->entityTypeManager->getStorage('field_config'); + + $entity_type_id = $field_storage->getTargetEntityTypeId(); + $field_name = $field_storage->getName(); + + $existing_fields = $storage->getQuery() + ->condition('entity_type', $entity_type_id) + ->condition('field_name', $field_name) + ->execute(); + + // Get all bundles of the target entity type. + $bundles = array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type_id)); + foreach ($bundles as $bundle) { + $id = "$entity_type_id.$bundle.$field_name"; + if (in_array($id, $existing_fields, TRUE)) { + if (empty($value['fail_if_exists'])) { + continue; + } + throw new ConfigActionException(sprintf('Field %s already exists.', $id)); + } + $storage->create([ + 'label' => $value['label'], + 'bundle' => $bundle, + 'description' => $value['description'], + 'field_storage' => $field_storage, + ])->save(); + } + } + +} diff --git a/core/modules/field/tests/src/Kernel/AddToAllBundlesConfigActionTest.php b/core/modules/field/tests/src/Kernel/AddToAllBundlesConfigActionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4f6ec730877deadad06456ec680cf5d246b9944b --- /dev/null +++ b/core/modules/field/tests/src/Kernel/AddToAllBundlesConfigActionTest.php @@ -0,0 +1,148 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\field\Kernel; + +use Drupal\Component\Plugin\Exception\PluginNotFoundException; +use Drupal\Core\Config\Action\ConfigActionException; +use Drupal\Core\Entity\EntityFieldManagerInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Recipe\RecipeRunner; +use Drupal\field\Entity\FieldConfig; +use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait; +use Drupal\KernelTests\KernelTestBase; +use Drupal\node\Entity\NodeType; + +/** + * @covers \Drupal\field\Plugin\ConfigAction\AddToAllBundles + * + * @group Recipe + * @group field + */ +class AddToAllBundlesConfigActionTest extends KernelTestBase { + + use RecipeTestTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = ['field', 'node', 'system', 'text', 'user']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + NodeType::create([ + 'type' => 'one', + 'name' => 'One', + ])->save(); + NodeType::create([ + 'type' => 'two', + 'name' => 'Two', + ])->save(); + } + + /** + * Tests instantiating a field on all bundles of an entity type. + */ + public function testInstantiateNewFieldOnAllBundles(): void { + // Ensure the body field doesn't actually exist yet. + $storage_definitions = $this->container->get(EntityFieldManagerInterface::class) + ->getFieldStorageDefinitions('node'); + $this->assertArrayNotHasKey('body', $storage_definitions); + + $this->applyAction('field.storage.node.body'); + + // Fields and expected data exist. + /** @var \Drupal\field\FieldConfigInterface[] $body_fields */ + $body_fields = $this->container->get(EntityTypeManagerInterface::class) + ->getStorage('field_config') + ->loadByProperties([ + 'entity_type' => 'node', + 'field_name' => 'body', + ]); + ksort($body_fields); + $this->assertSame(['node.one.body', 'node.two.body'], array_keys($body_fields)); + foreach ($body_fields as $field) { + $this->assertSame('Body field label', $field->label()); + $this->assertSame('Set by config actions.', $field->getDescription()); + } + + // Expect an error when the 'addToAllBundles' action is invoked on anything + // other than a field storage config entity. + $this->expectException(PluginNotFoundException::class); + $this->expectExceptionMessage('The "addToAllBundles" plugin does not exist.'); + $this->applyAction('user.role.anonymous'); + } + + /** + * Tests that the action can be set to fail if the field already exists. + */ + public function testFailIfExists(): void { + $this->installConfig('node'); + node_add_body_field(NodeType::load('one')); + + $this->expectException(ConfigActionException::class); + $this->expectExceptionMessage('Field node.one.body already exists.'); + $this->applyAction('field.storage.node.body', TRUE); + } + + /** + * Tests that the action will ignore existing fields by default. + */ + public function testIgnoreExistingFields(): void { + $this->installConfig('node'); + + node_add_body_field(NodeType::load('one')) + ->setLabel('Original label') + ->setDescription('Original description') + ->save(); + + $this->applyAction('field.storage.node.body'); + + // The existing field should not be changed. + $field = FieldConfig::loadByName('node', 'one', 'body'); + $this->assertInstanceOf(FieldConfig::class, $field); + $this->assertSame('Original label', $field->label()); + $this->assertSame('Original description', $field->getDescription()); + + // But the new field should be created as expected. + $field = FieldConfig::loadByName('node', 'two', 'body'); + $this->assertInstanceOf(FieldConfig::class, $field); + $this->assertSame('Body field label', $field->label()); + $this->assertSame('Set by config actions.', $field->getDescription()); + } + + /** + * Applies a recipe with the addToAllBundles action. + * + * @param string $config_name + * The name of the config object which should run the addToAllBundles + * action. + * @param bool $fail_if_exists + * (optional) Whether the action should fail if the field already exists on + * any bundle. Defaults to FALSE. + */ + private function applyAction(string $config_name, bool $fail_if_exists = FALSE): void { + $fail_if_exists = var_export($fail_if_exists, TRUE); + $contents = <<<YAML +name: Instantiate field on all bundles +config: + import: + node: + - field.storage.node.body + actions: + $config_name: + addToAllBundles: + label: Body field label + description: Set by config actions. + fail_if_exists: $fail_if_exists +YAML; + $recipe = $this->createRecipe($contents); + RecipeRunner::processRecipe($recipe); + } + +} diff --git a/core/modules/filter/src/Entity/FilterFormat.php b/core/modules/filter/src/Entity/FilterFormat.php index 1742bcf1b43b5ebe299742c3a1fcf5b2922235b4..699b6a99d4a4b682e8b6b4d14e02bc4a4fcbdc38 100644 --- a/core/modules/filter/src/Entity/FilterFormat.php +++ b/core/modules/filter/src/Entity/FilterFormat.php @@ -3,9 +3,11 @@ namespace Drupal\filter\Entity; use Drupal\Component\Plugin\PluginInspectionInterface; +use Drupal\Core\Config\Action\Attribute\ActionMethod; use Drupal\Core\Config\Entity\ConfigEntityBase; use Drupal\Core\Entity\EntityWithPluginCollectionInterface; use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\filter\FilterFormatInterface; use Drupal\filter\FilterPluginCollection; use Drupal\filter\Plugin\FilterInterface; @@ -160,6 +162,7 @@ public function getPluginCollections() { /** * {@inheritdoc} */ + #[ActionMethod(adminLabel: new TranslatableMarkup('Sets configuration for a filter plugin'))] public function setFilterConfig($instance_id, array $configuration) { $this->filters[$instance_id] = $configuration; if (isset($this->filterCollection)) { diff --git a/core/modules/user/src/Entity/Role.php b/core/modules/user/src/Entity/Role.php index 18194f3390eb4f5324fd44c455e82d453fd1712d..68ddd501f514f95cf7a7b46179fe7694dad3d918 100644 --- a/core/modules/user/src/Entity/Role.php +++ b/core/modules/user/src/Entity/Role.php @@ -2,8 +2,10 @@ namespace Drupal\user\Entity; +use Drupal\Core\Config\Action\Attribute\ActionMethod; use Drupal\Core\Config\Entity\ConfigEntityBase; use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\user\RoleInterface; /** @@ -126,6 +128,7 @@ public function hasPermission($permission) { /** * {@inheritdoc} */ + #[ActionMethod(adminLabel: new TranslatableMarkup('Add permission to role'))] public function grantPermission($permission) { if ($this->isAdmin()) { return $this; diff --git a/core/profiles/standard/tests/src/Functional/StandardTest.php b/core/profiles/standard/tests/src/Functional/StandardTest.php index cbe8f38fc608f1f117c46718e8bcb82a93025fbd..4bb993ffc947108a03ef6ebe8021aa8b05d33d15 100644 --- a/core/profiles/standard/tests/src/Functional/StandardTest.php +++ b/core/profiles/standard/tests/src/Functional/StandardTest.php @@ -4,22 +4,8 @@ namespace Drupal\Tests\standard\Functional; -use Drupal\ckeditor5\Plugin\Editor\CKEditor5; -use Drupal\Component\Utility\Html; -use Drupal\editor\Entity\Editor; -use Drupal\image\Entity\ImageStyle; -use Drupal\media\Entity\MediaType; -use Drupal\media\Plugin\media\Source\Image; -use Drupal\Tests\SchemaCheckTestTrait; -use Drupal\contact\Entity\ContactForm; -use Drupal\Core\Url; -use Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber; -use Drupal\filter\Entity\FilterFormat; use Drupal\Tests\BrowserTestBase; -use Drupal\Tests\RequirementsPageTrait; -use Drupal\user\Entity\Role; -use Drupal\user\Entity\User; -use Symfony\Component\Validator\ConstraintViolation; +use Drupal\Tests\standard\Traits\StandardTestTrait; /** * Tests Standard installation profile expectations. @@ -27,289 +13,8 @@ * @group standard */ class StandardTest extends BrowserTestBase { - - use SchemaCheckTestTrait; - use RequirementsPageTrait; + use StandardTestTrait; protected $profile = 'standard'; - /** - * The admin user. - * - * @var \Drupal\user\UserInterface - */ - protected $adminUser; - - /** - * Tests Standard installation profile. - */ - public function testStandard() { - $this->drupalGet(''); - $this->assertSession()->pageTextContains('Powered by Drupal'); - - // Test anonymous user can access 'Main navigation' block. - $this->adminUser = $this->drupalCreateUser([ - 'administer blocks', - 'administer block content', - 'post comments', - 'skip comment approval', - 'create article content', - 'create page content', - ]); - $this->drupalLogin($this->adminUser); - // Configure the block. - $this->drupalGet('admin/structure/block/add/system_menu_block:main/olivero'); - $this->submitForm([ - 'region' => 'sidebar', - 'id' => 'main_navigation', - ], 'Save block'); - // Verify admin user can see the block. - $this->drupalGet(''); - $this->assertSession()->pageTextContains('Main navigation'); - - // Verify we have role = complementary on help_block blocks. - $this->drupalGet('admin/structure/block'); - $this->assertSession()->elementAttributeContains('xpath', "//div[@id='block-olivero-help']", 'role', 'complementary'); - - // Verify anonymous user can see the block. - $this->drupalLogout(); - $this->assertSession()->pageTextContains('Main navigation'); - - // Ensure comments don't show in the front page RSS feed. - // Create an article. - $this->drupalCreateNode([ - 'type' => 'article', - 'title' => 'Foobar', - 'promote' => 1, - 'status' => 1, - 'body' => [['value' => 'Then she picked out two somebodies,<br />Sally and me', 'format' => 'basic_html']], - ]); - - // Add a comment. - $this->drupalLogin($this->adminUser); - $this->drupalGet('node/1'); - // Verify that a line break is present. - $this->assertSession()->responseContains('Then she picked out two somebodies,<br>Sally and me'); - $this->submitForm([ - 'subject[0][value]' => 'Bar foo', - 'comment_body[0][value]' => 'Then she picked out two somebodies, Sally and me', - ], 'Save'); - // Fetch the feed. - $this->drupalGet('rss.xml'); - $this->assertSession()->responseContains('Foobar'); - $this->assertSession()->responseNotContains('Then she picked out two somebodies, Sally and me'); - - // Ensure block body exists. - $this->drupalGet('block/add'); - $this->assertSession()->fieldExists('body[0][value]'); - - // Now we have all configuration imported, test all of them for schema - // conformance. Ensures all imported default configuration is valid when - // standard profile modules are enabled. - $names = $this->container->get('config.storage')->listAll(); - /** @var \Drupal\Core\Config\TypedConfigManagerInterface $typed_config */ - $typed_config = $this->container->get('config.typed'); - foreach ($names as $name) { - $config = $this->config($name); - $this->assertConfigSchema($typed_config, $name, $config->get()); - } - - // Validate all configuration. - // @todo Generalize in https://www.drupal.org/project/drupal/issues/2164373 - foreach (Editor::loadMultiple() as $editor) { - // Currently only text editors using CKEditor 5 can be validated. - if ($editor->getEditor() !== 'ckeditor5') { - continue; - } - - $this->assertSame([], array_map( - function (ConstraintViolation $v) { - return (string) $v->getMessage(); - }, - iterator_to_array(CKEditor5::validatePair( - $editor, - $editor->getFilterFormat() - )) - )); - } - - // Ensure that configuration from the Standard profile is not reused when - // enabling a module again since it contains configuration that can not be - // installed. For example, editor.editor.basic_html is editor configuration - // that depends on the CKEditor 5 module. The CKEditor 5 module can not be - // installed before the editor module since it depends on the editor module. - // The installer does not have this limitation since it ensures that all of - // the install profiles dependencies are installed before creating the - // editor configuration. - foreach (FilterFormat::loadMultiple() as $filter) { - // Ensure that editor can be uninstalled by removing use in filter - // formats. It is necessary to prime the filter collection before removing - // the filter. - $filter->filters(); - $filter->removeFilter('editor_file_reference'); - $filter->save(); - } - \Drupal::service('module_installer')->uninstall(['editor', 'ckeditor5']); - $this->rebuildContainer(); - \Drupal::service('module_installer')->install(['editor']); - /** @var \Drupal\contact\ContactFormInterface $contact_form */ - $contact_form = ContactForm::load('feedback'); - $recipients = $contact_form->getRecipients(); - $this->assertEquals(['simpletest@example.com'], $recipients); - - $role = Role::create([ - 'id' => 'admin_theme', - 'label' => 'Admin theme', - ]); - $role->grantPermission('view the administration theme'); - $role->save(); - $this->adminUser->addRole($role->id()); - $this->adminUser->save(); - $this->drupalGet('node/add'); - $this->assertSession()->statusCodeEquals(200); - - // Ensure that there are no pending updates after installation. - $this->drupalLogin($this->rootUser); - $this->drupalGet('update.php/selection'); - $this->updateRequirementsProblem(); - $this->drupalGet('update.php/selection'); - $this->assertSession()->pageTextContains('No pending updates.'); - - // Ensure that there are no pending entity updates after installation. - $this->assertFalse($this->container->get('entity.definition_update_manager')->needsUpdates(), 'After installation, entity schema is up to date.'); - - // Make sure the optional image styles are not installed. - $this->drupalGet('admin/config/media/image-styles'); - $this->assertSession()->pageTextNotContains('Max 325x325'); - $this->assertSession()->pageTextNotContains('Max 650x650'); - $this->assertSession()->pageTextNotContains('Max 1300x1300'); - $this->assertSession()->pageTextNotContains('Max 2600x2600'); - - // Make sure the optional image styles are installed after enabling - // the responsive_image module. - \Drupal::service('module_installer')->install(['responsive_image']); - $this->rebuildContainer(); - $this->drupalGet('admin/config/media/image-styles'); - $this->assertSession()->pageTextContains('Max 325x325'); - $this->assertSession()->pageTextContains('Max 650x650'); - $this->assertSession()->pageTextContains('Max 1300x1300'); - $this->assertSession()->pageTextContains('Max 2600x2600'); - - // Make sure all image styles has webp conversion as last effect. - foreach (ImageStyle::loadMultiple() as $style) { - $effects = $style->getEffects()->getInstanceIds(); - $last = $style->getEffects()->get(end($effects)); - $this->assertSame('image_convert', $last->getConfiguration()['id']); - $this->assertSame('webp', $last->getConfiguration()['data']['extension']); - } - - // Verify certain routes' responses are cacheable by Dynamic Page Cache, to - // ensure these responses are very fast for authenticated users. - $this->drupalLogin($this->adminUser); - $url = Url::fromRoute('contact.site_page'); - $this->drupalGet($url); - // Verify that site-wide contact page cannot be cached by Dynamic Page - // Cache. - $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE'); - - $url = Url::fromRoute('<front>'); - $this->drupalGet($url); - $this->drupalGet($url); - // Verify that frontpage is cached by Dynamic Page Cache. - $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT'); - - $url = Url::fromRoute('entity.node.canonical', ['node' => 1]); - $this->drupalGet($url); - $this->drupalGet($url); - // Verify that full node page is cached by Dynamic Page Cache. - $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT'); - - $url = Url::fromRoute('entity.user.canonical', ['user' => 1]); - $this->drupalGet($url); - $this->drupalGet($url); - // Verify that user profile page is cached by Dynamic Page Cache. - $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT'); - - // Make sure the editorial workflow is installed after enabling the - // content_moderation module. - \Drupal::service('module_installer')->install(['content_moderation']); - $role = Role::create([ - 'id' => 'admin_workflows', - 'label' => 'Admin workflow', - ]); - $role->grantPermission('administer workflows'); - $role->save(); - $this->adminUser->addRole($role->id()); - $this->adminUser->save(); - $this->rebuildContainer(); - $this->drupalGet('admin/config/workflow/workflows/manage/editorial'); - $this->assertSession()->pageTextContains('Draft'); - $this->assertSession()->pageTextContains('Published'); - $this->assertSession()->pageTextContains('Archived'); - $this->assertSession()->pageTextContains('Create New Draft'); - $this->assertSession()->pageTextContains('Publish'); - $this->assertSession()->pageTextContains('Archive'); - $this->assertSession()->pageTextContains('Restore to Draft'); - $this->assertSession()->pageTextContains('Restore'); - - \Drupal::service('module_installer')->install(['media']); - $role = Role::create([ - 'id' => 'admin_media', - 'label' => 'Admin media', - ]); - $role->grantPermission('administer media'); - $role->grantPermission('administer media display'); - $role->save(); - $this->adminUser->addRole($role->id()); - $this->adminUser->save(); - $assert_session = $this->assertSession(); - $page = $this->getSession()->getPage(); - /** @var \Drupal\media\Entity\MediaType $media_type */ - foreach (MediaType::loadMultiple() as $media_type) { - $media_type_machine_name = $media_type->id(); - $this->drupalGet('media/add/' . $media_type_machine_name); - // Get the form element, and its HTML representation. - $form_selector = '#media-' . Html::cleanCssIdentifier($media_type_machine_name) . '-add-form'; - $form = $assert_session->elementExists('css', $form_selector); - $form_html = $form->getOuterHtml(); - - // The name field should be hidden. - $assert_session->fieldNotExists('Name', $form); - // The source field should be shown before the vertical tabs. - $source_field_label = $media_type->getSource()->getSourceFieldDefinition($media_type)->getLabel(); - $test_source_field = $assert_session->elementExists('xpath', "//*[contains(text(), '$source_field_label')]", $form)->getOuterHtml(); - $vertical_tabs = $assert_session->elementExists('css', '.js-form-type-vertical-tabs', $form)->getOuterHtml(); - $this->assertGreaterThan(strpos($form_html, $test_source_field), strpos($form_html, $vertical_tabs)); - // The "Published" checkbox should be the last element. - $date_field = $assert_session->fieldExists('Date', $form)->getOuterHtml(); - $published_checkbox = $assert_session->fieldExists('Published', $form)->getOuterHtml(); - $this->assertGreaterThan(strpos($form_html, $date_field), strpos($form_html, $published_checkbox)); - if (is_a($media_type->getSource(), Image::class, TRUE)) { - // Assert the default entity view display is configured with an image - // style. - $this->drupalGet('/admin/structure/media/manage/' . $media_type->id() . '/display'); - $assert_session->fieldValueEquals('fields[field_media_image][type]', 'image'); - $assert_session->elementTextContains('css', 'tr[data-drupal-selector="edit-fields-field-media-image"]', 'Image style: Large (480×480)'); - // By default for media types with an image source, only the image - // component should be enabled. - $assert_session->elementsCount('css', 'input[name$="_settings_edit"]', 1); - } - - } - - // Tests that user 1 does not have an all-access pass. - $this->drupalLogin($this->rootUser); - $this->drupalGet('admin'); - $this->assertSession()->statusCodeEquals(200); - - User::load(1) - ->removeRole('administrator') - ->save(); - // Clear caches so change take effect in system under test. - $this->rebuildAll(); - - $this->drupalGet('admin'); - $this->assertSession()->statusCodeEquals(403); - } - } diff --git a/core/profiles/standard/tests/src/Traits/StandardTestTrait.php b/core/profiles/standard/tests/src/Traits/StandardTestTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..e2e3c005219f68293907b4c343dd75c0ddc1ab42 --- /dev/null +++ b/core/profiles/standard/tests/src/Traits/StandardTestTrait.php @@ -0,0 +1,318 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\standard\Traits; + +use Drupal\ckeditor5\Plugin\Editor\CKEditor5; +use Drupal\Component\Utility\Html; +use Drupal\contact\Entity\ContactForm; +use Drupal\Core\Url; +use Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber; +use Drupal\editor\Entity\Editor; +use Drupal\filter\Entity\FilterFormat; +use Drupal\image\Entity\ImageStyle; +use Drupal\media\Entity\MediaType; +use Drupal\media\Plugin\media\Source\Image; +use Drupal\Tests\RequirementsPageTrait; +use Drupal\Tests\SchemaCheckTestTrait; +use Drupal\user\Entity\Role; +use Drupal\user\Entity\User; +use Symfony\Component\Validator\ConstraintViolation; + +/** + * Provides a test method to test the Standard installation profile or recipe. + */ +trait StandardTestTrait { + use SchemaCheckTestTrait; + use RequirementsPageTrait; + + /** + * The admin user. + * + * @var \Drupal\user\UserInterface + */ + protected $adminUser; + + /** + * Tests Standard installation profile or recipe. + */ + public function testStandard() { + $this->drupalGet(''); + $this->assertSession()->pageTextContains('Powered by Drupal'); + $this->assertSession()->pageTextContains('Congratulations and welcome to the Drupal community.'); + + // Test anonymous user can access 'Main navigation' block. + $this->adminUser = $this->drupalCreateUser([ + 'administer blocks', + 'administer block content', + 'post comments', + 'skip comment approval', + 'create article content', + 'create page content', + ]); + $this->drupalLogin($this->adminUser); + // Configure the block. + $this->drupalGet('admin/structure/block/add/system_menu_block:main/olivero'); + $this->submitForm([ + 'region' => 'sidebar', + 'id' => 'main_navigation', + ], 'Save block'); + // Verify admin user can see the block. + $this->drupalGet(''); + $this->assertSession()->pageTextContains('Main navigation'); + + // Verify we have role = complementary on help_block blocks. + $this->drupalGet('admin/structure/block'); + $this->assertSession()->elementAttributeContains('xpath', "//div[@id='block-olivero-help']", 'role', 'complementary'); + + // Verify anonymous user can see the block. + $this->drupalLogout(); + $this->assertSession()->pageTextContains('Main navigation'); + + // Ensure comments don't show in the front page RSS feed. + // Create an article. + $this->drupalCreateNode([ + 'type' => 'article', + 'title' => 'Foobar', + 'promote' => 1, + 'status' => 1, + 'body' => [['value' => 'Then she picked out two somebodies,<br />Sally and me', 'format' => 'basic_html']], + ]); + + // Add a comment. + $this->drupalLogin($this->adminUser); + $this->drupalGet('node/1'); + // Verify that a line break is present. + $this->assertSession()->responseContains('Then she picked out two somebodies,<br>Sally and me'); + $this->submitForm([ + 'subject[0][value]' => 'Bar foo', + 'comment_body[0][value]' => 'Then she picked out two somebodies, Sally and me', + ], 'Save'); + // Fetch the feed. + $this->drupalGet('rss.xml'); + $this->assertSession()->responseContains('Foobar'); + $this->assertSession()->responseNotContains('Then she picked out two somebodies, Sally and me'); + + // Ensure block body exists. + $this->drupalGet('block/add'); + $this->assertSession()->fieldExists('body[0][value]'); + + // Now we have all configuration imported, test all of them for schema + // conformance. Ensures all imported default configuration is valid when + // standard profile modules are enabled. + $names = $this->container->get('config.storage')->listAll(); + /** @var \Drupal\Core\Config\TypedConfigManagerInterface $typed_config */ + $typed_config = $this->container->get('config.typed'); + foreach ($names as $name) { + $config = $this->config($name); + $this->assertConfigSchema($typed_config, $name, $config->get()); + } + + // Validate all configuration. + // @todo Generalize in https://www.drupal.org/project/drupal/issues/2164373 + foreach (Editor::loadMultiple() as $editor) { + // Currently only text editors using CKEditor 5 can be validated. + if ($editor->getEditor() !== 'ckeditor5') { + continue; + } + + $this->assertSame([], array_map( + function (ConstraintViolation $v) { + return (string) $v->getMessage(); + }, + iterator_to_array(CKEditor5::validatePair( + $editor, + $editor->getFilterFormat() + )) + )); + } + + // Ensure that configuration from the Standard profile is not reused when + // enabling a module again since it contains configuration that can not be + // installed. For example, editor.editor.basic_html is editor configuration + // that depends on the CKEditor 5 module. The CKEditor 5 module can not be + // installed before the editor module since it depends on the editor module. + // The installer does not have this limitation since it ensures that all of + // the install profiles dependencies are installed before creating the + // editor configuration. + foreach (FilterFormat::loadMultiple() as $filter) { + // Ensure that editor can be uninstalled by removing use in filter + // formats. It is necessary to prime the filter collection before removing + // the filter. + $filter->filters(); + $filter->removeFilter('editor_file_reference'); + $filter->save(); + } + \Drupal::service('module_installer')->uninstall(['editor', 'ckeditor5']); + $this->rebuildContainer(); + \Drupal::service('module_installer')->install(['editor']); + /** @var \Drupal\contact\ContactFormInterface $contact_form */ + $contact_form = ContactForm::load('feedback'); + $recipients = $contact_form->getRecipients(); + $this->assertEquals(['simpletest@example.com'], $recipients); + + $role = Role::create([ + 'id' => 'admin_theme', + 'label' => 'Admin theme', + ]); + $role->grantPermission('view the administration theme'); + $role->save(); + $this->adminUser->addRole($role->id()); + $this->adminUser->save(); + $this->drupalGet('node/add'); + $this->assertSession()->statusCodeEquals(200); + + // Ensure that there are no pending updates after installation. + $this->drupalLogin($this->rootUser); + $this->drupalGet('update.php/selection'); + $this->updateRequirementsProblem(); + $this->drupalGet('update.php/selection'); + $this->assertSession()->pageTextContains('No pending updates.'); + + // Ensure that there are no pending entity updates after installation. + $this->assertFalse($this->container->get('entity.definition_update_manager')->needsUpdates(), 'After installation, entity schema is up to date.'); + + // Make sure the optional image styles are not installed. + $this->drupalGet('admin/config/media/image-styles'); + $this->assertSession()->pageTextNotContains('Max 325x325'); + $this->assertSession()->pageTextNotContains('Max 650x650'); + $this->assertSession()->pageTextNotContains('Max 1300x1300'); + $this->assertSession()->pageTextNotContains('Max 2600x2600'); + + // Make sure the optional image styles are installed after enabling + // the responsive_image module. + $this->installResponsiveImage(); + $this->drupalGet('admin/config/media/image-styles'); + $this->assertSession()->pageTextContains('Max 325x325'); + $this->assertSession()->pageTextContains('Max 650x650'); + $this->assertSession()->pageTextContains('Max 1300x1300'); + $this->assertSession()->pageTextContains('Max 2600x2600'); + + // Make sure all image styles has webp conversion as last effect. + foreach (ImageStyle::loadMultiple() as $style) { + $effects = $style->getEffects()->getInstanceIds(); + $last = $style->getEffects()->get(end($effects)); + $this->assertSame('image_convert', $last->getConfiguration()['id']); + $this->assertSame('webp', $last->getConfiguration()['data']['extension']); + } + + // Verify certain routes' responses are cacheable by Dynamic Page Cache, to + // ensure these responses are very fast for authenticated users. + $this->drupalLogin($this->adminUser); + $url = Url::fromRoute('contact.site_page'); + $this->drupalGet($url); + // Verify that site-wide contact page cannot be cached by Dynamic Page + // Cache. + $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE'); + + $url = Url::fromRoute('<front>'); + $this->drupalGet($url); + $this->drupalGet($url); + // Verify that frontpage is cached by Dynamic Page Cache. + $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT'); + + $url = Url::fromRoute('entity.node.canonical', ['node' => 1]); + $this->drupalGet($url); + $this->drupalGet($url); + // Verify that full node page is cached by Dynamic Page Cache. + $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT'); + + $url = Url::fromRoute('entity.user.canonical', ['user' => 1]); + $this->drupalGet($url); + $this->drupalGet($url); + // Verify that user profile page is cached by Dynamic Page Cache. + $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT'); + + // Make sure the editorial workflow is installed after enabling the + // content_moderation module. + \Drupal::service('module_installer')->install(['content_moderation']); + $role = Role::create([ + 'id' => 'admin_workflows', + 'label' => 'Admin workflow', + ]); + $role->grantPermission('administer workflows'); + $role->save(); + $this->adminUser->addRole($role->id()); + $this->adminUser->save(); + $this->rebuildContainer(); + $this->drupalGet('admin/config/workflow/workflows/manage/editorial'); + $this->assertSession()->pageTextContains('Draft'); + $this->assertSession()->pageTextContains('Published'); + $this->assertSession()->pageTextContains('Archived'); + $this->assertSession()->pageTextContains('Create New Draft'); + $this->assertSession()->pageTextContains('Publish'); + $this->assertSession()->pageTextContains('Archive'); + $this->assertSession()->pageTextContains('Restore to Draft'); + $this->assertSession()->pageTextContains('Restore'); + + \Drupal::service('module_installer')->install(['media']); + $role = Role::create([ + 'id' => 'admin_media', + 'label' => 'Admin media', + ]); + $role->grantPermission('administer media'); + $role->grantPermission('administer media display'); + $role->save(); + $this->adminUser->addRole($role->id()); + $this->adminUser->save(); + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + /** @var \Drupal\media\Entity\MediaType $media_type */ + foreach (MediaType::loadMultiple() as $media_type) { + $media_type_machine_name = $media_type->id(); + $this->drupalGet('media/add/' . $media_type_machine_name); + // Get the form element, and its HTML representation. + $form_selector = '#media-' . Html::cleanCssIdentifier($media_type_machine_name) . '-add-form'; + $form = $assert_session->elementExists('css', $form_selector); + $form_html = $form->getOuterHtml(); + + // The name field should be hidden. + $assert_session->fieldNotExists('Name', $form); + // The source field should be shown before the vertical tabs. + $source_field_label = $media_type->getSource()->getSourceFieldDefinition($media_type)->getLabel(); + $test_source_field = $assert_session->elementExists('xpath', "//*[contains(text(), '$source_field_label')]", $form)->getOuterHtml(); + $vertical_tabs = $assert_session->elementExists('css', '.js-form-type-vertical-tabs', $form)->getOuterHtml(); + $this->assertGreaterThan(strpos($form_html, $test_source_field), strpos($form_html, $vertical_tabs)); + // The "Published" checkbox should be the last element. + $date_field = $assert_session->fieldExists('Date', $form)->getOuterHtml(); + $published_checkbox = $assert_session->fieldExists('Published', $form)->getOuterHtml(); + $this->assertGreaterThan(strpos($form_html, $date_field), strpos($form_html, $published_checkbox)); + if (is_a($media_type->getSource(), Image::class, TRUE)) { + // Assert the default entity view display is configured with an image + // style. + $this->drupalGet('/admin/structure/media/manage/' . $media_type->id() . '/display'); + $assert_session->fieldValueEquals('fields[field_media_image][type]', 'image'); + $assert_session->elementTextContains('css', 'tr[data-drupal-selector="edit-fields-field-media-image"]', 'Image style: Large (480×480)'); + // By default for media types with an image source, only the image + // component should be enabled. + $assert_session->elementsCount('css', 'input[name$="_settings_edit"]', 1); + } + + } + + // Tests that user 1 does not have an all-access pass. + $this->drupalLogin($this->rootUser); + $this->drupalGet('admin'); + $this->assertSession()->statusCodeEquals(200); + + User::load(1) + ->removeRole('administrator') + ->save(); + // Clear caches so change take effect in system under test. + $this->rebuildAll(); + + $this->drupalGet('admin'); + $this->assertSession()->statusCodeEquals(403); + } + + /** + * Installs the responsive image module. + */ + protected function installResponsiveImage(): void { + // Install responsive_image module. + \Drupal::service('module_installer')->install(['responsive_image']); + $this->rebuildContainer(); + } + +} diff --git a/core/recipes/article_comment/config/field.field.node.article.comment.yml b/core/recipes/article_comment/config/field.field.node.article.comment.yml new file mode 100644 index 0000000000000000000000000000000000000000..cf3b12af98f5e10296a4e896f974f8aa5487155b --- /dev/null +++ b/core/recipes/article_comment/config/field.field.node.article.comment.yml @@ -0,0 +1,32 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.node.comment + - node.type.article + module: + - comment +id: node.article.comment +field_name: comment +entity_type: node +bundle: article +label: Comments +description: '' +required: false +translatable: true +default_value: + - + status: 2 + cid: 0 + last_comment_timestamp: 0 + last_comment_name: null + last_comment_uid: 0 + comment_count: 0 +default_value_callback: '' +settings: + default_mode: 1 + per_page: 50 + anonymous: 0 + form_location: true + preview: 1 +field_type: comment diff --git a/core/recipes/article_comment/recipe.yml b/core/recipes/article_comment/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..f4ac4e4be49a2a7d59638ea8a041ef97c2c00497 --- /dev/null +++ b/core/recipes/article_comment/recipe.yml @@ -0,0 +1,29 @@ +name: 'Article comments' +description: 'Provides commenting on article content.' +type: 'Content field' +recipes: + - article_content_type + - comment_base +config: + actions: + core.entity_form_display.node.article.default: + setComponent: + name: comment + options: + type: comment_default + weight: 20 + region: content + settings: {} + third_party_settings: {} + core.entity_view_display.node.article.default: + setComponent: + name: comment + options: + type: comment_default + label: above + settings: + view_mode: default + pager_id: 0 + third_party_settings: { } + weight: 110 + region: content diff --git a/core/recipes/article_content_type/config/core.entity_form_display.node.article.default.yml b/core/recipes/article_content_type/config/core.entity_form_display.node.article.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..f29f17bc0465a876007e8081219dc56b42b7f22b --- /dev/null +++ b/core/recipes/article_content_type/config/core.entity_form_display.node.article.default.yml @@ -0,0 +1,87 @@ +langcode: en +status: true +dependencies: + config: + - field.field.node.article.body + - field.field.node.article.field_image + - image.style.thumbnail + - node.type.article + module: + - image + - path + - text +id: node.article.default +targetEntityType: node +bundle: article +mode: default +content: + body: + type: text_textarea_with_summary + weight: 2 + region: content + settings: + rows: 9 + summary_rows: 3 + placeholder: '' + show_summary: false + third_party_settings: { } + created: + type: datetime_timestamp + weight: 10 + region: content + settings: { } + third_party_settings: { } + field_image: + type: image_image + weight: 1 + region: content + settings: + progress_indicator: throbber + preview_image_style: thumbnail + third_party_settings: { } + path: + type: path + weight: 30 + region: content + settings: { } + third_party_settings: { } + promote: + type: boolean_checkbox + weight: 15 + region: content + settings: + display_label: true + third_party_settings: { } + status: + type: boolean_checkbox + weight: 120 + region: content + settings: + display_label: true + third_party_settings: { } + sticky: + type: boolean_checkbox + weight: 16 + region: content + settings: + display_label: true + third_party_settings: { } + title: + type: string_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 5 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } +hidden: { } diff --git a/core/recipes/article_content_type/config/core.entity_view_display.node.article.default.yml b/core/recipes/article_content_type/config/core.entity_view_display.node.article.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..a129e14dd6a938959de9de919355e91c163e3027 --- /dev/null +++ b/core/recipes/article_content_type/config/core.entity_view_display.node.article.default.yml @@ -0,0 +1,41 @@ +langcode: en +status: true +dependencies: + config: + - field.field.node.article.body + - field.field.node.article.field_image + - image.style.wide + - node.type.article + module: + - image + - text + - user +id: node.article.default +targetEntityType: node +bundle: article +mode: default +content: + body: + type: text_default + label: hidden + settings: { } + third_party_settings: { } + weight: 0 + region: content + field_image: + type: image + label: hidden + settings: + image_style: wide + image_link: '' + image_loading: + attribute: eager + third_party_settings: { } + weight: -1 + region: content + links: + settings: { } + third_party_settings: { } + weight: 100 + region: content +hidden: { } diff --git a/core/recipes/article_content_type/config/core.entity_view_display.node.article.rss.yml b/core/recipes/article_content_type/config/core.entity_view_display.node.article.rss.yml new file mode 100644 index 0000000000000000000000000000000000000000..05896dd3d74c1b83295087f7f789464b45963eb7 --- /dev/null +++ b/core/recipes/article_content_type/config/core.entity_view_display.node.article.rss.yml @@ -0,0 +1,21 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.node.rss + - field.field.node.article.body + - field.field.node.article.field_image + - node.type.article + module: + - user +id: node.article.rss +targetEntityType: node +bundle: article +mode: rss +content: + links: + weight: 100 + region: content +hidden: + body: true + field_image: true diff --git a/core/recipes/article_content_type/config/core.entity_view_display.node.article.teaser.yml b/core/recipes/article_content_type/config/core.entity_view_display.node.article.teaser.yml new file mode 100644 index 0000000000000000000000000000000000000000..5ef60b519f424b33a4bbc4509c3a7db6ee637edf --- /dev/null +++ b/core/recipes/article_content_type/config/core.entity_view_display.node.article.teaser.yml @@ -0,0 +1,41 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.node.teaser + - field.field.node.article.body + - field.field.node.article.field_image + - image.style.medium + - node.type.article + module: + - image + - text + - user +id: node.article.teaser +targetEntityType: node +bundle: article +mode: teaser +content: + body: + type: text_summary_or_trimmed + label: hidden + settings: + trim_length: 600 + third_party_settings: { } + weight: 0 + region: content + field_image: + type: image + label: hidden + settings: + image_style: medium + image_link: content + image_loading: + attribute: lazy + third_party_settings: { } + weight: -1 + region: content + links: + weight: 100 + region: content +hidden: { } diff --git a/core/recipes/article_content_type/config/field.field.node.article.body.yml b/core/recipes/article_content_type/config/field.field.node.article.body.yml new file mode 100644 index 0000000000000000000000000000000000000000..b36fbd584493fcbd6c7d5a813b458845cdd67dd7 --- /dev/null +++ b/core/recipes/article_content_type/config/field.field.node.article.body.yml @@ -0,0 +1,22 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.node.body + - node.type.article + module: + - text +id: node.article.body +field_name: body +entity_type: node +bundle: article +label: Body +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + display_summary: true + required_summary: false +field_type: text_with_summary diff --git a/core/recipes/article_content_type/config/field.field.node.article.field_image.yml b/core/recipes/article_content_type/config/field.field.node.article.field_image.yml new file mode 100644 index 0000000000000000000000000000000000000000..af4daeca6d0564561bc58941752a6f0c07db3c51 --- /dev/null +++ b/core/recipes/article_content_type/config/field.field.node.article.field_image.yml @@ -0,0 +1,37 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.node.field_image + - node.type.article + module: + - image +id: node.article.field_image +field_name: field_image +entity_type: node +bundle: article +label: Image +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: 'default:file' + handler_settings: { } + file_directory: '[date:custom:Y]-[date:custom:m]' + file_extensions: 'png gif jpg jpeg webp' + max_filesize: '' + max_resolution: '' + min_resolution: '' + alt_field: true + alt_field_required: true + title_field: false + title_field_required: false + default_image: + uuid: null + alt: '' + title: '' + width: null + height: null +field_type: image diff --git a/core/recipes/article_content_type/config/field.storage.node.field_image.yml b/core/recipes/article_content_type/config/field.storage.node.field_image.yml new file mode 100644 index 0000000000000000000000000000000000000000..a6964d3b0aabe3f63b497d4619afde15408f349a --- /dev/null +++ b/core/recipes/article_content_type/config/field.storage.node.field_image.yml @@ -0,0 +1,31 @@ +langcode: en +status: true +dependencies: + module: + - file + - image + - node +id: node.field_image +field_name: field_image +entity_type: node +type: image +settings: + target_type: file + display_field: false + display_default: false + uri_scheme: public + default_image: + uuid: null + alt: '' + title: '' + width: null + height: null +module: image +locked: false +cardinality: 1 +translatable: true +indexes: + target_id: + - target_id +persist_with_no_fields: false +custom_storage: false diff --git a/core/recipes/article_content_type/config/node.type.article.yml b/core/recipes/article_content_type/config/node.type.article.yml new file mode 100644 index 0000000000000000000000000000000000000000..ae8e9d12580b22cb2a38e40384818ac1bff2ce79 --- /dev/null +++ b/core/recipes/article_content_type/config/node.type.article.yml @@ -0,0 +1,10 @@ +langcode: en +status: true +dependencies: { } +name: Article +type: article +description: 'Use <em>articles</em> for time-sensitive content like news, press releases or blog posts.' +help: null +new_revision: true +preview_mode: 1 +display_submitted: true diff --git a/core/recipes/article_content_type/recipe.yml b/core/recipes/article_content_type/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..30eaf30b121abb565e8b49a1c1780ad313553131 --- /dev/null +++ b/core/recipes/article_content_type/recipe.yml @@ -0,0 +1,28 @@ +name: 'Article content type' +description: 'Provides Article content type and related configuration. Use <em>articles</em> for time-sensitive content like news, press releases or blog posts.' +type: 'Content type' +install: + - image + - node + - path +config: + import: + node: + # Only import config which is also imported by the Standard profile. + - core.entity_view_mode.node.full + - core.entity_view_mode.node.rss + - core.entity_view_mode.node.teaser + - field.storage.node.body + - system.action.node_delete_action + - system.action.node_make_sticky_action + - system.action.node_make_unsticky_action + - system.action.node_promote_action + - system.action.node_publish_action + - system.action.node_save_action + - system.action.node_unpromote_action + - system.action.node_unpublish_action + image: + # Only import config which is also imported by the Standard profile. + - image.style.medium + - image.style.thumbnail + - image.style.wide diff --git a/core/recipes/article_tags/config/field.field.node.article.field_tags.yml b/core/recipes/article_tags/config/field.field.node.article.field_tags.yml new file mode 100644 index 0000000000000000000000000000000000000000..1b9c4cc4ee1e1143722aeac9ef20e9aaaf27199d --- /dev/null +++ b/core/recipes/article_tags/config/field.field.node.article.field_tags.yml @@ -0,0 +1,26 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.node.field_tags + - node.type.article + - taxonomy.vocabulary.tags +id: node.article.field_tags +field_name: field_tags +entity_type: node +bundle: article +label: Tags +description: 'Enter a comma-separated list. For example: Amsterdam, Mexico City, "Cleveland, Ohio"' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: 'default:taxonomy_term' + handler_settings: + target_bundles: + tags: tags + sort: + field: _none + auto_create: true +field_type: entity_reference diff --git a/core/recipes/article_tags/config/field.storage.node.field_tags.yml b/core/recipes/article_tags/config/field.storage.node.field_tags.yml new file mode 100644 index 0000000000000000000000000000000000000000..73f821f2c0dea6f2e8cfbd7ddc14795dcda91acd --- /dev/null +++ b/core/recipes/article_tags/config/field.storage.node.field_tags.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + module: + - node + - taxonomy +id: node.field_tags +field_name: field_tags +entity_type: node +type: entity_reference +settings: + target_type: taxonomy_term +module: core +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/core/recipes/article_tags/recipe.yml b/core/recipes/article_tags/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..59566afecb798e0496b8505f367b46503cbb77dc --- /dev/null +++ b/core/recipes/article_tags/recipe.yml @@ -0,0 +1,39 @@ +name: 'Article tags' +description: 'Provides tags on article content.' +type: 'Content field' +recipes: + - article_content_type + - tags_taxonomy +install: + - views +config: + import: + taxonomy: + - views.view.taxonomy_term + actions: + core.entity_form_display.node.article.default: + setComponent: + name: field_tags + options: + type: entity_reference_autocomplete_tags + weight: 3 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + core.entity_view_display.node.article.teaser: &entity_view_display_node_article_teaser + setComponent: + name: field_tags + options: + type: entity_reference_label + label: above + settings: + link: true + third_party_settings: { } + weight: 10 + region: content + core.entity_view_display.node.article.default: + <<: *entity_view_display_node_article_teaser diff --git a/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.default.yml b/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..55854bcb88ab1b8c288c4ef2b351286cf179f8d3 --- /dev/null +++ b/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.default.yml @@ -0,0 +1,52 @@ +langcode: en +status: true +dependencies: + config: + - field.field.media.audio.field_media_audio_file + - media.type.audio + module: + - file + - path +id: media.audio.default +targetEntityType: media +bundle: audio +mode: default +content: + created: + type: datetime_timestamp + weight: 10 + region: content + settings: { } + third_party_settings: { } + field_media_audio_file: + type: file_generic + weight: 0 + region: content + settings: + progress_indicator: throbber + third_party_settings: { } + path: + type: path + weight: 30 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 100 + region: content + settings: + display_label: true + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 5 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } +hidden: + name: true diff --git a/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.media_library.yml b/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.media_library.yml new file mode 100644 index 0000000000000000000000000000000000000000..9179618e640c9cc7427ba576765cda0dd5b93a26 --- /dev/null +++ b/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.media_library.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_form_mode.media.media_library + - field.field.media.audio.field_media_audio_file + - media.type.audio +id: media.audio.media_library +targetEntityType: media +bundle: audio +mode: media_library +content: { } +hidden: + created: true + field_media_audio_file: true + name: true + path: true + status: true + uid: true diff --git a/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.default.yml b/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..2956f6913195ea52a6b768979df7ec400235b4f7 --- /dev/null +++ b/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.default.yml @@ -0,0 +1,29 @@ +langcode: en +status: true +dependencies: + config: + - field.field.media.audio.field_media_audio_file + - media.type.audio + module: + - file +id: media.audio.default +targetEntityType: media +bundle: audio +mode: default +content: + field_media_audio_file: + type: file_audio + label: visually_hidden + settings: + controls: true + autoplay: false + loop: false + multiple_file_display_type: tags + third_party_settings: { } + weight: 0 + region: content +hidden: + created: true + name: true + thumbnail: true + uid: true diff --git a/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.media_library.yml b/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.media_library.yml new file mode 100644 index 0000000000000000000000000000000000000000..85dac7b2db425191726d4ea41127a6c2de909b21 --- /dev/null +++ b/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.media_library.yml @@ -0,0 +1,31 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.media.media_library + - field.field.media.audio.field_media_audio_file + - image.style.thumbnail + - media.type.audio + module: + - image +id: media.audio.media_library +targetEntityType: media +bundle: audio +mode: media_library +content: + thumbnail: + type: image + label: hidden + settings: + image_style: thumbnail + image_link: '' + image_loading: + attribute: lazy + third_party_settings: { } + weight: 0 + region: content +hidden: + created: true + field_media_audio_file: true + name: true + uid: true diff --git a/core/recipes/audio_media_type/config/field.field.media.audio.field_media_audio_file.yml b/core/recipes/audio_media_type/config/field.field.media.audio.field_media_audio_file.yml new file mode 100644 index 0000000000000000000000000000000000000000..a4bb52eb859a2a18c2409964ea3c6d3ee1e0e579 --- /dev/null +++ b/core/recipes/audio_media_type/config/field.field.media.audio.field_media_audio_file.yml @@ -0,0 +1,26 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.media.field_media_audio_file + - media.type.audio + module: + - file +id: media.audio.field_media_audio_file +field_name: field_media_audio_file +entity_type: media +bundle: audio +label: 'Audio file' +description: '' +required: true +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: 'default:file' + handler_settings: { } + file_directory: '[date:custom:Y]-[date:custom:m]' + file_extensions: 'mp3 wav aac' + max_filesize: '' + description_field: false +field_type: file diff --git a/core/recipes/audio_media_type/config/field.storage.media.field_media_audio_file.yml b/core/recipes/audio_media_type/config/field.storage.media.field_media_audio_file.yml new file mode 100644 index 0000000000000000000000000000000000000000..1626b607eacd50d1aea611d925fd34b8307b26c1 --- /dev/null +++ b/core/recipes/audio_media_type/config/field.storage.media.field_media_audio_file.yml @@ -0,0 +1,22 @@ +langcode: en +status: true +dependencies: + module: + - file + - media +id: media.field_media_audio_file +field_name: field_media_audio_file +entity_type: media +type: file +settings: + target_type: file + display_field: false + display_default: false + uri_scheme: public +module: file +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/core/recipes/audio_media_type/config/media.type.audio.yml b/core/recipes/audio_media_type/config/media.type.audio.yml new file mode 100644 index 0000000000000000000000000000000000000000..233b2042dc4194781d46c199a6064af0de688506 --- /dev/null +++ b/core/recipes/audio_media_type/config/media.type.audio.yml @@ -0,0 +1,13 @@ +langcode: en +status: true +dependencies: { } +id: audio +label: Audio +description: 'A locally hosted audio file.' +source: audio_file +queue_thumbnail_downloads: false +new_revision: true +source_configuration: + source_field: field_media_audio_file +field_map: + name: name diff --git a/core/recipes/audio_media_type/recipe.yml b/core/recipes/audio_media_type/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..bd86d21d819439266473758fd13744ac97c28f51 --- /dev/null +++ b/core/recipes/audio_media_type/recipe.yml @@ -0,0 +1,27 @@ +name: 'Audio media' +description: 'Provides "Audio" media type and related configuration. A locally hosted audio file.' +type: 'Media type' +install: + - image + - media_library + - path + - views +config: + import: + file: + - views.view.files + media_library: + - core.entity_view_display.media.audio.media_library + - core.entity_view_mode.media.media_library + - core.entity_form_mode.media.media_library + - image.style.media_library + - views.view.media_library + media: + - core.entity_view_mode.media.full + - system.action.media_delete_action + - system.action.media_publish_action + - system.action.media_save_action + - system.action.media_unpublish_action + - views.view.media + image: + - image.style.thumbnail diff --git a/core/recipes/basic_block_type/config/block_content.type.basic.yml b/core/recipes/basic_block_type/config/block_content.type.basic.yml new file mode 100644 index 0000000000000000000000000000000000000000..52ee484241013cc785dbf51ce2834cef83935cfd --- /dev/null +++ b/core/recipes/basic_block_type/config/block_content.type.basic.yml @@ -0,0 +1,7 @@ +langcode: en +status: true +dependencies: { } +id: basic +label: 'Basic block' +revision: false +description: 'A basic block contains a title and a body.' diff --git a/core/recipes/basic_block_type/config/core.entity_form_display.block_content.basic.default.yml b/core/recipes/basic_block_type/config/core.entity_form_display.block_content.basic.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..fe49840e80b25cecceb4e285f598ce0ad3eab8c1 --- /dev/null +++ b/core/recipes/basic_block_type/config/core.entity_form_display.block_content.basic.default.yml @@ -0,0 +1,32 @@ +langcode: en +status: true +dependencies: + config: + - block_content.type.basic + - field.field.block_content.basic.body + module: + - text +id: block_content.basic.default +targetEntityType: block_content +bundle: basic +mode: default +content: + body: + type: text_textarea_with_summary + weight: -4 + region: content + settings: + rows: 9 + summary_rows: 3 + placeholder: '' + show_summary: false + third_party_settings: { } + info: + type: string_textfield + weight: -5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } +hidden: { } diff --git a/core/recipes/basic_block_type/config/core.entity_view_display.block_content.basic.default.yml b/core/recipes/basic_block_type/config/core.entity_view_display.block_content.basic.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..f4bb96567d4c43fae23e28d563a405967818ea34 --- /dev/null +++ b/core/recipes/basic_block_type/config/core.entity_view_display.block_content.basic.default.yml @@ -0,0 +1,21 @@ +langcode: en +status: true +dependencies: + config: + - block_content.type.basic + - field.field.block_content.basic.body + module: + - text +id: block_content.basic.default +targetEntityType: block_content +bundle: basic +mode: default +content: + body: + type: text_default + label: hidden + settings: { } + third_party_settings: { } + weight: 0 + region: content +hidden: { } diff --git a/core/recipes/basic_block_type/config/field.field.block_content.basic.body.yml b/core/recipes/basic_block_type/config/field.field.block_content.basic.body.yml new file mode 100644 index 0000000000000000000000000000000000000000..dab4f9818109a13e668e02bb3105ad024cae6189 --- /dev/null +++ b/core/recipes/basic_block_type/config/field.field.block_content.basic.body.yml @@ -0,0 +1,22 @@ +langcode: en +status: true +dependencies: + config: + - block_content.type.basic + - field.storage.block_content.body + module: + - text +id: block_content.basic.body +field_name: body +entity_type: block_content +bundle: basic +label: Body +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + display_summary: false + required_summary: false +field_type: text_with_summary diff --git a/core/recipes/basic_block_type/recipe.yml b/core/recipes/basic_block_type/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..d1d7fa8755e2d04789cccce89c8fd8c954c77610 --- /dev/null +++ b/core/recipes/basic_block_type/recipe.yml @@ -0,0 +1,12 @@ +name: 'Basic block' +description: 'Provides "Basic block" custom block type and related configuration. A basic block contains a title and a body.' +type: 'Block type' +install: + - block_content + - views +config: + import: + block_content: + - field.storage.block_content.body + - core.entity_view_mode.block_content.full + - views.view.block_content diff --git a/core/recipes/basic_html_format_editor/config/editor.editor.basic_html.yml b/core/recipes/basic_html_format_editor/config/editor.editor.basic_html.yml new file mode 100644 index 0000000000000000000000000000000000000000..a31e41506fd5df040a3f84e324c6643867425c1f --- /dev/null +++ b/core/recipes/basic_html_format_editor/config/editor.editor.basic_html.yml @@ -0,0 +1,65 @@ +langcode: en +status: true +dependencies: + config: + - filter.format.basic_html + module: + - ckeditor5 +format: basic_html +editor: ckeditor5 +settings: + toolbar: + items: + - bold + - italic + - '|' + - link + - '|' + - bulletedList + - numberedList + - '|' + - blockQuote + - drupalInsertImage + - '|' + - heading + - code + - '|' + - sourceEditing + plugins: + ckeditor5_heading: + enabled_headings: + - heading2 + - heading3 + - heading4 + - heading5 + - heading6 + ckeditor5_imageResize: + allow_resize: true + ckeditor5_list: + properties: + reversed: false + startIndex: true + multiBlock: true + ckeditor5_sourceEditing: + allowed_tags: + - '<cite>' + - '<dl>' + - '<dt>' + - '<dd>' + - '<a hreflang>' + - '<blockquote cite>' + - '<ul type>' + - '<ol type>' + - '<h2 id>' + - '<h3 id>' + - '<h4 id>' + - '<h5 id>' + - '<h6 id>' +image_upload: + status: true + scheme: public + directory: inline-images + max_size: null + max_dimensions: + width: null + height: null diff --git a/core/recipes/basic_html_format_editor/config/filter.format.basic_html.yml b/core/recipes/basic_html_format_editor/config/filter.format.basic_html.yml new file mode 100644 index 0000000000000000000000000000000000000000..d81fc17303f258cdc486754a03e9b2cd706bf2f1 --- /dev/null +++ b/core/recipes/basic_html_format_editor/config/filter.format.basic_html.yml @@ -0,0 +1,50 @@ +langcode: en +status: true +dependencies: + module: + - editor +name: 'Basic HTML' +format: basic_html +weight: 0 +roles: + - authenticated +filters: + editor_file_reference: + id: editor_file_reference + provider: editor + status: true + weight: 11 + settings: { } + filter_align: + id: filter_align + provider: filter + status: true + weight: 7 + settings: { } + filter_caption: + id: filter_caption + provider: filter + status: true + weight: 8 + settings: { } + filter_html: + id: filter_html + provider: filter + status: true + weight: -10 + settings: + allowed_html: '<br> <p> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id> <cite> <dl> <dt> <dd> <a hreflang href> <blockquote cite> <ul type> <ol start type> <strong> <em> <code> <li> <img src alt data-entity-uuid data-entity-type height width data-caption data-align>' + filter_html_help: false + filter_html_nofollow: false + filter_html_image_secure: + id: filter_html_image_secure + provider: filter + status: true + weight: 9 + settings: { } + filter_image_lazy_load: + id: filter_image_lazy_load + provider: filter + status: true + weight: 15 + settings: { } diff --git a/core/recipes/basic_html_format_editor/recipe.yml b/core/recipes/basic_html_format_editor/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..d1e5e6ec8ca6120c5e4f4e3020dd4d3bb57d7360 --- /dev/null +++ b/core/recipes/basic_html_format_editor/recipe.yml @@ -0,0 +1,9 @@ +name: 'Basic HTML editor' +description: 'Provides "Basic HTML" text format along with WYSIWYG editor and related configuration.' +type: 'Text format editor' +install: + - ckeditor5 +config: + actions: + user.role.authenticated: + grantPermission: 'use text format basic_html' diff --git a/core/recipes/basic_shortcuts/content/shortcut/478b3170-1dfd-49d8-8eb3-f1b285445ab7.yml b/core/recipes/basic_shortcuts/content/shortcut/478b3170-1dfd-49d8-8eb3-f1b285445ab7.yml new file mode 100644 index 0000000000000000000000000000000000000000..13985a85739b5d594f3a7d8358e2345fccde91d0 --- /dev/null +++ b/core/recipes/basic_shortcuts/content/shortcut/478b3170-1dfd-49d8-8eb3-f1b285445ab7.yml @@ -0,0 +1,18 @@ +_meta: + version: '1.0' + entity_type: shortcut + uuid: 478b3170-1dfd-49d8-8eb3-f1b285445ab7 + bundle: default + default_langcode: en +default: + title: + - + value: 'All content' + weight: + - + value: -19 + link: + - + uri: 'internal:/admin/content' + title: '' + options: { } diff --git a/core/recipes/basic_shortcuts/content/shortcut/d5377721-d6de-4fdf-82e2-557c50f84ceb.yml b/core/recipes/basic_shortcuts/content/shortcut/d5377721-d6de-4fdf-82e2-557c50f84ceb.yml new file mode 100644 index 0000000000000000000000000000000000000000..ad2dc75d6871ca91ea3580ea6344b434285a1b80 --- /dev/null +++ b/core/recipes/basic_shortcuts/content/shortcut/d5377721-d6de-4fdf-82e2-557c50f84ceb.yml @@ -0,0 +1,18 @@ +_meta: + version: '1.0' + entity_type: shortcut + uuid: d5377721-d6de-4fdf-82e2-557c50f84ceb + bundle: default + default_langcode: en +default: + title: + - + value: 'Add content' + weight: + - + value: -20 + link: + - + uri: 'internal:/node/add' + title: '' + options: { } diff --git a/core/recipes/basic_shortcuts/recipe.yml b/core/recipes/basic_shortcuts/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..8f52500eacef26cca4b31bf926e10c2270971786 --- /dev/null +++ b/core/recipes/basic_shortcuts/recipe.yml @@ -0,0 +1,12 @@ +name: 'Basic shortcuts' +description: 'Provides a basic set of shortcuts for logged-in users.' +type: Administration +install: + - shortcut +config: + import: + shortcut: + - shortcut.set.default + actions: + user.role.authenticated: + grantPermission: 'access shortcuts' diff --git a/core/recipes/comment_base/config/comment.type.comment.yml b/core/recipes/comment_base/config/comment.type.comment.yml new file mode 100644 index 0000000000000000000000000000000000000000..ddcbbc986095a6bfd062679f0990d011b956db97 --- /dev/null +++ b/core/recipes/comment_base/config/comment.type.comment.yml @@ -0,0 +1,7 @@ +langcode: en +status: true +dependencies: { } +id: comment +label: 'Default comments' +target_entity_type_id: node +description: 'Allows commenting on content' diff --git a/core/recipes/comment_base/config/core.entity_form_display.comment.comment.default.yml b/core/recipes/comment_base/config/core.entity_form_display.comment.comment.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..1010be292488569234b654548ba3234bf8d9ae1e --- /dev/null +++ b/core/recipes/comment_base/config/core.entity_form_display.comment.comment.default.yml @@ -0,0 +1,33 @@ +langcode: en +status: true +dependencies: + config: + - comment.type.comment + - field.field.comment.comment.comment_body + module: + - text +id: comment.comment.default +targetEntityType: comment +bundle: comment +mode: default +content: + author: + weight: -2 + region: content + comment_body: + type: text_textarea + weight: 11 + region: content + settings: + rows: 5 + placeholder: '' + third_party_settings: { } + subject: + type: string_textfield + weight: 10 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } +hidden: { } diff --git a/core/recipes/comment_base/config/core.entity_view_display.comment.comment.default.yml b/core/recipes/comment_base/config/core.entity_view_display.comment.comment.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..b9fdd2bac71dfaf1a3caa8547127cbebd11b10a9 --- /dev/null +++ b/core/recipes/comment_base/config/core.entity_view_display.comment.comment.default.yml @@ -0,0 +1,24 @@ +langcode: en +status: true +dependencies: + config: + - comment.type.comment + - field.field.comment.comment.comment_body + module: + - text +id: comment.comment.default +targetEntityType: comment +bundle: comment +mode: default +content: + comment_body: + type: text_default + label: hidden + settings: { } + third_party_settings: { } + weight: 0 + region: content + links: + weight: 100 + region: content +hidden: { } diff --git a/core/recipes/comment_base/config/field.field.comment.comment.comment_body.yml b/core/recipes/comment_base/config/field.field.comment.comment.comment_body.yml new file mode 100644 index 0000000000000000000000000000000000000000..1337070d16b859309099244a0e475bc7b6be2d77 --- /dev/null +++ b/core/recipes/comment_base/config/field.field.comment.comment.comment_body.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + config: + - comment.type.comment + - field.storage.comment.comment_body + module: + - text +id: comment.comment.comment_body +field_name: comment_body +entity_type: comment +bundle: comment +label: Comment +description: '' +required: true +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: text_long diff --git a/core/recipes/comment_base/config/field.storage.node.comment.yml b/core/recipes/comment_base/config/field.storage.node.comment.yml new file mode 100644 index 0000000000000000000000000000000000000000..c5eee2c2841d49ca883d3254094e95962b2cd78c --- /dev/null +++ b/core/recipes/comment_base/config/field.storage.node.comment.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + module: + - comment + - node +id: node.comment +field_name: comment +entity_type: node +type: comment +settings: + comment_type: comment +module: comment +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/core/recipes/comment_base/recipe.yml b/core/recipes/comment_base/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..0cf967155a4193f763c7dd7fa55dad87bebded8b --- /dev/null +++ b/core/recipes/comment_base/recipe.yml @@ -0,0 +1,26 @@ +name: 'Default comments' +description: 'Allows commenting on content.' +type: 'Comment type' +install: + - comment + - node + - views +config: + import: + comment: + - core.entity_view_mode.comment.full + - field.storage.comment.comment_body + - system.action.comment_delete_action + - system.action.comment_publish_action + - system.action.comment_save_action + - system.action.comment_unpublish_action + - views.view.comment + - views.view.comments_recent + actions: + user.role.authenticated: + grantPermissions: + - 'access comments' + - 'post comments' + - 'skip comment approval' + user.role.anonymous: + grantPermission: 'access comments' diff --git a/core/recipes/content_search/recipe.yml b/core/recipes/content_search/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..b9f60c59a7f19c1c04c9d2b979c23a058789cf7f --- /dev/null +++ b/core/recipes/content_search/recipe.yml @@ -0,0 +1,19 @@ +name: 'Content search' +type: Search +description: 'Adds a page that can search site content.' +install: + - node + - search +config: + import: + node: + - core.entity_view_mode.node.search_index + - core.entity_view_mode.node.search_result + - search.page.node_search + actions: + user.role.anonymous: + grantPermissions: + - 'search content' + user.role.authenticated: + grantPermissions: + - 'search content' diff --git a/core/recipes/core_recommended_admin_theme/recipe.yml b/core/recipes/core_recommended_admin_theme/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..5d60e2f5c9bfe6c8595951f5037b264b8794efb1 --- /dev/null +++ b/core/recipes/core_recommended_admin_theme/recipe.yml @@ -0,0 +1,24 @@ +name: 'Admin theme' +description: 'Sets up Claro as the administrative (backend) theme.' +type: 'Themes' +install: + - claro + - block +config: + import: + system: + - system.menu.account + - system.menu.main + - system.theme + claro: + - block.block.claro_breadcrumbs + - block.block.claro_content + - block.block.claro_local_actions + - block.block.claro_messages + - block.block.claro_page_title + - block.block.claro_primary_local_tasks + - block.block.claro_secondary_local_tasks + actions: + system.theme: + simple_config_update: + admin: claro diff --git a/core/recipes/core_recommended_front_end_theme/recipe.yml b/core/recipes/core_recommended_front_end_theme/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..cdfb36b369ee482b177480023cb116a6a3518ce2 --- /dev/null +++ b/core/recipes/core_recommended_front_end_theme/recipe.yml @@ -0,0 +1,29 @@ +name: 'Front end theme' +description: 'Sets up Olivero as the front-end theme.' +type: 'Themes' +install: + - olivero + - block +config: + import: + system: + - system.menu.account + - system.menu.main + - system.theme + olivero: + - block.block.olivero_account_menu + - block.block.olivero_breadcrumbs + - block.block.olivero_content + - block.block.olivero_main_menu + - block.block.olivero_messages + - block.block.olivero_page_title + - block.block.olivero_powered + - block.block.olivero_primary_admin_actions + - block.block.olivero_primary_local_tasks + - block.block.olivero_secondary_local_tasks + - block.block.olivero_site_branding + - core.date_format.olivero_medium + actions: + system.theme: + simple_config_update: + default: olivero diff --git a/core/recipes/core_recommended_maintenance/recipe.yml b/core/recipes/core_recommended_maintenance/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..ef6ad24ee645e9bb40c26e146924310968600cad --- /dev/null +++ b/core/recipes/core_recommended_maintenance/recipe.yml @@ -0,0 +1,16 @@ +name: 'Recommended Maintenance' +description: 'Sets up modules recommended for site maintenance.' +type: 'Maintenance' +install: + - automated_cron + - announcements_feed + - dblog + - views +config: + import: + automated_cron: + - automated_cron.settings + dblog: + - views.view.watchdog + system: + - system.cron diff --git a/core/recipes/core_recommended_performance/recipe.yml b/core/recipes/core_recommended_performance/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..0a60e3f26ca9b9330ad880b20253dd892b5683cf --- /dev/null +++ b/core/recipes/core_recommended_performance/recipe.yml @@ -0,0 +1,7 @@ +name: 'Recommended Performance' +description: 'Sets up modules for improved site performance.' +type: 'Performance' +install: + - page_cache + - dynamic_page_cache + - big_pipe diff --git a/core/recipes/document_media_type/config/core.entity_form_display.media.document.default.yml b/core/recipes/document_media_type/config/core.entity_form_display.media.document.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..7233f32e06566a11d30d922b554291b41509e4d0 --- /dev/null +++ b/core/recipes/document_media_type/config/core.entity_form_display.media.document.default.yml @@ -0,0 +1,52 @@ +langcode: en +status: true +dependencies: + config: + - field.field.media.document.field_media_document + - media.type.document + module: + - file + - path +id: media.document.default +targetEntityType: media +bundle: document +mode: default +content: + created: + type: datetime_timestamp + weight: 10 + region: content + settings: { } + third_party_settings: { } + field_media_document: + type: file_generic + weight: 0 + region: content + settings: + progress_indicator: throbber + third_party_settings: { } + path: + type: path + weight: 30 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 100 + region: content + settings: + display_label: true + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 5 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } +hidden: + name: true diff --git a/core/recipes/document_media_type/config/core.entity_form_display.media.document.media_library.yml b/core/recipes/document_media_type/config/core.entity_form_display.media.document.media_library.yml new file mode 100644 index 0000000000000000000000000000000000000000..b7abbe903fe894bb3c951716edd1d0471f95f69d --- /dev/null +++ b/core/recipes/document_media_type/config/core.entity_form_display.media.document.media_library.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_form_mode.media.media_library + - field.field.media.document.field_media_document + - media.type.document +id: media.document.media_library +targetEntityType: media +bundle: document +mode: media_library +content: { } +hidden: + created: true + field_media_document: true + name: true + path: true + status: true + uid: true diff --git a/core/recipes/document_media_type/config/core.entity_view_display.media.document.default.yml b/core/recipes/document_media_type/config/core.entity_view_display.media.document.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..0c44314110750142aa4a9d7d91ece1527dcc2c3a --- /dev/null +++ b/core/recipes/document_media_type/config/core.entity_view_display.media.document.default.yml @@ -0,0 +1,25 @@ +langcode: en +status: true +dependencies: + config: + - field.field.media.document.field_media_document + - media.type.document + module: + - file +id: media.document.default +targetEntityType: media +bundle: document +mode: default +content: + field_media_document: + type: file_default + label: visually_hidden + settings: { } + third_party_settings: { } + weight: 1 + region: content +hidden: + created: true + name: true + thumbnail: true + uid: true diff --git a/core/recipes/document_media_type/config/core.entity_view_display.media.document.media_library.yml b/core/recipes/document_media_type/config/core.entity_view_display.media.document.media_library.yml new file mode 100644 index 0000000000000000000000000000000000000000..1f8eb004afe56c75a1b933196c953af3e97850f7 --- /dev/null +++ b/core/recipes/document_media_type/config/core.entity_view_display.media.document.media_library.yml @@ -0,0 +1,31 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.media.media_library + - field.field.media.document.field_media_document + - image.style.thumbnail + - media.type.document + module: + - image +id: media.document.media_library +targetEntityType: media +bundle: document +mode: media_library +content: + thumbnail: + type: image + label: hidden + settings: + image_style: thumbnail + image_link: '' + image_loading: + attribute: lazy + third_party_settings: { } + weight: 0 + region: content +hidden: + created: true + field_media_document: true + name: true + uid: true diff --git a/core/recipes/document_media_type/config/field.field.media.document.field_media_document.yml b/core/recipes/document_media_type/config/field.field.media.document.field_media_document.yml new file mode 100644 index 0000000000000000000000000000000000000000..fb0b9909e2e870210c3c927eb40e1de8f10947f2 --- /dev/null +++ b/core/recipes/document_media_type/config/field.field.media.document.field_media_document.yml @@ -0,0 +1,29 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.media.field_media_document + - media.type.document + module: + - file + enforced: + module: + - media +id: media.document.field_media_document +field_name: field_media_document +entity_type: media +bundle: document +label: Document +description: '' +required: true +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: 'default:file' + handler_settings: { } + file_directory: '[date:custom:Y]-[date:custom:m]' + file_extensions: 'txt rtf doc docx ppt pptx xls xlsx pdf odf odg odp ods odt fodt fods fodp fodg key numbers pages' + max_filesize: '' + description_field: false +field_type: file diff --git a/core/recipes/document_media_type/config/field.storage.media.field_media_document.yml b/core/recipes/document_media_type/config/field.storage.media.field_media_document.yml new file mode 100644 index 0000000000000000000000000000000000000000..309e509de09fd29015823f560dcd738f4866db47 --- /dev/null +++ b/core/recipes/document_media_type/config/field.storage.media.field_media_document.yml @@ -0,0 +1,25 @@ +langcode: en +status: true +dependencies: + module: + - file + - media + enforced: + module: + - media +id: media.field_media_document +field_name: field_media_document +entity_type: media +type: file +settings: + target_type: file + display_field: false + display_default: false + uri_scheme: public +module: file +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/core/recipes/document_media_type/config/media.type.document.yml b/core/recipes/document_media_type/config/media.type.document.yml new file mode 100644 index 0000000000000000000000000000000000000000..35d7f1a60d57ca439eb9f6922d4c3323629635b1 --- /dev/null +++ b/core/recipes/document_media_type/config/media.type.document.yml @@ -0,0 +1,13 @@ +langcode: en +status: true +dependencies: { } +id: document +label: Document +description: 'An uploaded file or document, such as a PDF.' +source: file +queue_thumbnail_downloads: false +new_revision: true +source_configuration: + source_field: field_media_document +field_map: + name: name diff --git a/core/recipes/document_media_type/recipe.yml b/core/recipes/document_media_type/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..868610953e0957def8d27fb376b7f1ea5d2a4f38 --- /dev/null +++ b/core/recipes/document_media_type/recipe.yml @@ -0,0 +1,25 @@ +name: 'Document media type' +description: 'Provides "Document" media type and related configuration to enable uploaded files or documents, such as a PDF.' +type: 'Media type' +install: + - media_library + - path + - views +config: + import: + file: + - views.view.files + media_library: + - core.entity_view_mode.media.media_library + - core.entity_form_mode.media.media_library + - image.style.media_library + - views.view.media_library + media: + - core.entity_view_mode.media.full + - system.action.media_delete_action + - system.action.media_publish_action + - system.action.media_save_action + - system.action.media_unpublish_action + - views.view.media + image: + - image.style.thumbnail diff --git a/core/recipes/editorial_workflow/config/workflows.workflow.editorial.yml b/core/recipes/editorial_workflow/config/workflows.workflow.editorial.yml new file mode 100644 index 0000000000000000000000000000000000000000..e462b2ab956f59d54c1ec3f4b92f66aed50b8c76 --- /dev/null +++ b/core/recipes/editorial_workflow/config/workflows.workflow.editorial.yml @@ -0,0 +1,60 @@ +langcode: en +status: true +dependencies: + module: + - content_moderation +id: editorial +label: Editorial +type: content_moderation +type_settings: + states: + archived: + label: Archived + weight: 5 + published: false + default_revision: true + draft: + label: Draft + weight: -5 + published: false + default_revision: false + published: + label: Published + weight: 0 + published: true + default_revision: true + transitions: + archive: + label: Archive + from: + - published + to: archived + weight: 2 + archived_draft: + label: 'Restore to Draft' + from: + - archived + to: draft + weight: 3 + archived_published: + label: Restore + from: + - archived + to: published + weight: 4 + create_new_draft: + label: 'Create New Draft' + from: + - draft + - published + to: draft + weight: 0 + publish: + label: Publish + from: + - draft + - published + to: published + weight: 1 + entity_types: { } + default_moderation_state: draft diff --git a/core/recipes/editorial_workflow/recipe.yml b/core/recipes/editorial_workflow/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..baa7f86de7f2470da5889bcc4df73fce8e194d8e --- /dev/null +++ b/core/recipes/editorial_workflow/recipe.yml @@ -0,0 +1,12 @@ +name: 'Editorial workflow' +description: 'Provides an editorial workflow for moderating content.' +type: 'Workflow' +install: + - content_moderation + # The moderated_content view depends on Node. + - node + - views +config: + import: + content_moderation: + - views.view.moderated_content diff --git a/core/recipes/example/composer.json b/core/recipes/example/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..1d231ba7eec30462cd0f0e0a839f1457c92aed1c --- /dev/null +++ b/core/recipes/example/composer.json @@ -0,0 +1,9 @@ +{ + "name": "drupal_recipe/example", + "description": "An example Drupal recipe description", + "type": "drupal-recipe", + "require": { + "drupal/core": "^10.0.x-dev" + }, + "license": "GPL-2.0-or-later" +} diff --git a/core/recipes/example/recipe.yml b/core/recipes/example/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..6f53bebee39aacc5f06cc2e6e12f2a7d8a8db736 --- /dev/null +++ b/core/recipes/example/recipe.yml @@ -0,0 +1,46 @@ +# The type key is similar to the package key in module.info.yml. It +# can be used by the UI to group Drupal recipes. Additionally, +# the type 'Site' means that the Drupal recipe will be listed in +# the installer. +type: 'Content type' + +install: + # An array of modules or themes to install, if they are not already. + # The system will detect if it is a theme or a module. During the + # install only simple configuration from the new modules is created. + # This allows the Drupal recipe control over the configuration. + - node + - text + +config: + # A Drupal recipe can have a config directory. All configuration + # is this directory will be imported after the modules have been + # installed. + + # Additionally, the Drupal recipe can install configuration entities + # provided by the modules it configures. This allows them to not have + # to maintain or copy this configuration. Note the examples below are + # fictitious. + import: + node: + - node.type.article + # Import all configuration that is provided by the text module and any + # optional configuration that depends on the text module that is provided by + # modules already installed. + text: '*' + + # Configuration actions may be defined. The structure here should be + # entity_type.ID.action. Below the user role entity type with an ID of + # editor is having the permissions added. The permissions key will be + # mapped to the \Drupal\user\Entity\Role::grantPermission() method. + actions: + user.role.editor: + ensure_exists: + label: 'Editor' + grantPermissions: + - 'delete any article content' + - 'edit any article content' + +content: {} +# A Drupal recipe can have a content directory. All content in this +# directory will be created after the configuration is installed. diff --git a/core/recipes/feedback_contact_form/config/contact.form.feedback.yml b/core/recipes/feedback_contact_form/config/contact.form.feedback.yml new file mode 100644 index 0000000000000000000000000000000000000000..e222ecd4bffa6dce2acb19120676a919878f0c09 --- /dev/null +++ b/core/recipes/feedback_contact_form/config/contact.form.feedback.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: { } +id: feedback +label: 'Website feedback' +recipients: + - admin@example.com +reply: '' +weight: 0 +message: 'Your message has been sent.' +redirect: '' diff --git a/core/recipes/feedback_contact_form/recipe.yml b/core/recipes/feedback_contact_form/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..c6368c6cfe5670a1ed81f795fc5fd7a425879d42 --- /dev/null +++ b/core/recipes/feedback_contact_form/recipe.yml @@ -0,0 +1,24 @@ +name: 'Website feedback contact form' +description: 'Provides a website feedback contact form.' +type: 'Contact form' +install: + - contact +config: + import: + contact: + - contact.form.personal + system: + - system.menu.footer + actions: + core.menu.static_menu_link_overrides: + simple_config_update: + definitions.contact__site_page: + menu_name: footer + parent: '' + weight: 0 + expanded: false + enabled: true + user.role.anonymous: + grantPermission: 'access site-wide contact form' + user.role.authenticated: + grantPermission: 'access site-wide contact form' diff --git a/core/recipes/full_html_format_editor/config/editor.editor.full_html.yml b/core/recipes/full_html_format_editor/config/editor.editor.full_html.yml new file mode 100644 index 0000000000000000000000000000000000000000..e30fc15eaf3cfca53e4a554966604ddc570e7762 --- /dev/null +++ b/core/recipes/full_html_format_editor/config/editor.editor.full_html.yml @@ -0,0 +1,102 @@ +langcode: en +status: true +dependencies: + config: + - filter.format.full_html + module: + - ckeditor5 +format: full_html +editor: ckeditor5 +settings: + toolbar: + items: + - bold + - italic + - strikethrough + - superscript + - subscript + - removeFormat + - '|' + - link + - '|' + - bulletedList + - numberedList + - '|' + - blockQuote + - drupalInsertImage + - insertTable + - horizontalLine + - '|' + - heading + - codeBlock + - '|' + - sourceEditing + plugins: + ckeditor5_codeBlock: + languages: + - + label: 'Plain text' + language: plaintext + - + label: C + language: c + - + label: 'C#' + language: cs + - + label: C++ + language: cpp + - + label: CSS + language: css + - + label: Diff + language: diff + - + label: HTML + language: html + - + label: Java + language: java + - + label: JavaScript + language: javascript + - + label: PHP + language: php + - + label: Python + language: python + - + label: Ruby + language: ruby + - + label: TypeScript + language: typescript + - + label: XML + language: xml + ckeditor5_heading: + enabled_headings: + - heading2 + - heading3 + - heading4 + - heading5 + - heading6 + ckeditor5_imageResize: + allow_resize: true + ckeditor5_list: + properties: + reversed: true + startIndex: true + multiBlock: true + ckeditor5_sourceEditing: + allowed_tags: { } +image_upload: + status: true + scheme: public + directory: inline-images + max_size: null + max_dimensions: + width: null + height: null diff --git a/core/recipes/full_html_format_editor/config/filter.format.full_html.yml b/core/recipes/full_html_format_editor/config/filter.format.full_html.yml new file mode 100644 index 0000000000000000000000000000000000000000..a0e616a4989feaa028a3496ffd73357454191807 --- /dev/null +++ b/core/recipes/full_html_format_editor/config/filter.format.full_html.yml @@ -0,0 +1,41 @@ +langcode: en +status: true +dependencies: + module: + - editor +name: 'Full HTML' +format: full_html +weight: 2 +roles: + - administrator +filters: + editor_file_reference: + id: editor_file_reference + provider: editor + status: true + weight: 11 + settings: { } + filter_align: + id: filter_align + provider: filter + status: true + weight: 8 + settings: { } + filter_caption: + id: filter_caption + provider: filter + status: true + weight: 9 + settings: { } + filter_htmlcorrector: + id: filter_htmlcorrector + provider: filter + status: true + weight: 10 + settings: { } + filter_image_lazy_load: + id: filter_image_lazy_load + provider: filter + status: true + weight: 15 + settings: { } diff --git a/core/recipes/full_html_format_editor/recipe.yml b/core/recipes/full_html_format_editor/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..171c75ac7b8188bca9eae26f2311c4e0f1d76549 --- /dev/null +++ b/core/recipes/full_html_format_editor/recipe.yml @@ -0,0 +1,5 @@ +name: 'Full HTML editor' +description: 'Provides "Full HTML" text format along with WYSIWYG editor and related configuration.' +type: 'Text format editor' +install: + - ckeditor5 diff --git a/core/recipes/image_media_type/config/core.entity_form_display.media.image.default.yml b/core/recipes/image_media_type/config/core.entity_form_display.media.image.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..1d7a19b5932822e4ca23b9a874f28673a02861c7 --- /dev/null +++ b/core/recipes/image_media_type/config/core.entity_form_display.media.image.default.yml @@ -0,0 +1,54 @@ +langcode: en +status: true +dependencies: + config: + - field.field.media.image.field_media_image + - image.style.thumbnail + - media.type.image + module: + - image + - path +id: media.image.default +targetEntityType: media +bundle: image +mode: default +content: + created: + type: datetime_timestamp + weight: 10 + region: content + settings: { } + third_party_settings: { } + field_media_image: + type: image_image + weight: 0 + region: content + settings: + progress_indicator: throbber + preview_image_style: thumbnail + third_party_settings: { } + path: + type: path + weight: 30 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 100 + region: content + settings: + display_label: true + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 5 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } +hidden: + name: true diff --git a/core/recipes/image_media_type/config/core.entity_form_display.media.image.media_library.yml b/core/recipes/image_media_type/config/core.entity_form_display.media.image.media_library.yml new file mode 100644 index 0000000000000000000000000000000000000000..63babd6f80d29c4bc5af130db3f4363a488e1773 --- /dev/null +++ b/core/recipes/image_media_type/config/core.entity_form_display.media.image.media_library.yml @@ -0,0 +1,29 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_form_mode.media.media_library + - field.field.media.image.field_media_image + - image.style.thumbnail + - media.type.image + module: + - image +id: media.image.media_library +targetEntityType: media +bundle: image +mode: media_library +content: + field_media_image: + type: image_image + weight: 1 + region: content + settings: + progress_indicator: throbber + preview_image_style: thumbnail + third_party_settings: { } +hidden: + created: true + name: true + path: true + status: true + uid: true diff --git a/core/recipes/image_media_type/config/core.entity_view_display.media.image.default.yml b/core/recipes/image_media_type/config/core.entity_view_display.media.image.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..e2b85e90ac2ae468baab41d4e532edea96dece2a --- /dev/null +++ b/core/recipes/image_media_type/config/core.entity_view_display.media.image.default.yml @@ -0,0 +1,30 @@ +langcode: en +status: true +dependencies: + config: + - field.field.media.image.field_media_image + - image.style.large + - media.type.image + module: + - image +id: media.image.default +targetEntityType: media +bundle: image +mode: default +content: + field_media_image: + type: image + label: visually_hidden + settings: + image_style: large + image_link: '' + image_loading: + attribute: lazy + third_party_settings: { } + weight: 1 + region: content +hidden: + created: true + name: true + thumbnail: true + uid: true diff --git a/core/recipes/image_media_type/config/core.entity_view_display.media.image.media_library.yml b/core/recipes/image_media_type/config/core.entity_view_display.media.image.media_library.yml new file mode 100644 index 0000000000000000000000000000000000000000..15469d4def11e4b0f6583f2f17fb9efe8d975013 --- /dev/null +++ b/core/recipes/image_media_type/config/core.entity_view_display.media.image.media_library.yml @@ -0,0 +1,31 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.media.media_library + - field.field.media.image.field_media_image + - image.style.medium + - media.type.image + module: + - image +id: media.image.media_library +targetEntityType: media +bundle: image +mode: media_library +content: + thumbnail: + type: image + label: hidden + settings: + image_style: medium + image_link: '' + image_loading: + attribute: lazy + third_party_settings: { } + weight: 0 + region: content +hidden: + created: true + field_media_image: true + name: true + uid: true diff --git a/core/recipes/image_media_type/config/field.field.media.image.field_media_image.yml b/core/recipes/image_media_type/config/field.field.media.image.field_media_image.yml new file mode 100644 index 0000000000000000000000000000000000000000..2e2cebf91fe0fcbd8c3024da71ad0e0fafa680bc --- /dev/null +++ b/core/recipes/image_media_type/config/field.field.media.image.field_media_image.yml @@ -0,0 +1,40 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.media.field_media_image + - media.type.image + module: + - image + enforced: + module: + - media +id: media.image.field_media_image +field_name: field_media_image +entity_type: media +bundle: image +label: Image +description: '' +required: true +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: 'default:file' + handler_settings: { } + file_directory: '[date:custom:Y]-[date:custom:m]' + file_extensions: 'png gif jpg jpeg webp' + max_filesize: '' + max_resolution: '' + min_resolution: '' + alt_field: true + alt_field_required: true + title_field: false + title_field_required: false + default_image: + uuid: null + alt: '' + title: '' + width: null + height: null +field_type: image diff --git a/core/recipes/image_media_type/config/field.storage.media.field_media_image.yml b/core/recipes/image_media_type/config/field.storage.media.field_media_image.yml new file mode 100644 index 0000000000000000000000000000000000000000..59a6fbe14297a668552edd686b66388d5fd58d70 --- /dev/null +++ b/core/recipes/image_media_type/config/field.storage.media.field_media_image.yml @@ -0,0 +1,32 @@ +langcode: en +status: true +dependencies: + module: + - file + - image + - media + enforced: + module: + - media +id: media.field_media_image +field_name: field_media_image +entity_type: media +type: image +settings: + target_type: file + display_field: false + display_default: false + uri_scheme: public + default_image: + uuid: null + alt: '' + title: '' + width: null + height: null +module: image +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/core/recipes/image_media_type/config/media.type.image.yml b/core/recipes/image_media_type/config/media.type.image.yml new file mode 100644 index 0000000000000000000000000000000000000000..b92ef428c3a21c7dd8589a607eb49febd0b16ecc --- /dev/null +++ b/core/recipes/image_media_type/config/media.type.image.yml @@ -0,0 +1,13 @@ +langcode: en +status: true +dependencies: { } +id: image +label: Image +description: 'Use local images for reusable media.' +source: image +queue_thumbnail_downloads: false +new_revision: true +source_configuration: + source_field: field_media_image +field_map: + name: name diff --git a/core/recipes/image_media_type/recipe.yml b/core/recipes/image_media_type/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..9fe2163049457488c3cb853e13b0a384cb07b38d --- /dev/null +++ b/core/recipes/image_media_type/recipe.yml @@ -0,0 +1,27 @@ +name: 'Image media type' +description: 'Provides "Image" media type and related configuration. Use local images for reusable media.' +type: 'Media type' +install: + - media_library + - path + - views +config: + import: + file: + - views.view.files + media_library: + - core.entity_view_mode.media.media_library + - core.entity_form_mode.media.media_library + - image.style.media_library + - views.view.media_library + media: + - core.entity_view_mode.media.full + - system.action.media_delete_action + - system.action.media_publish_action + - system.action.media_save_action + - system.action.media_unpublish_action + - views.view.media + image: + - image.style.medium + - image.style.large + - image.style.thumbnail diff --git a/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.default.yml b/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..e3fdffe0dab7a4d19f597ddc09b93a804494de7c --- /dev/null +++ b/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.default.yml @@ -0,0 +1,52 @@ +langcode: en +status: true +dependencies: + config: + - field.field.media.video.field_media_video_file + - media.type.video + module: + - file + - path +id: media.video.default +targetEntityType: media +bundle: video +mode: default +content: + created: + type: datetime_timestamp + weight: 10 + region: content + settings: { } + third_party_settings: { } + field_media_video_file: + type: file_generic + weight: 0 + region: content + settings: + progress_indicator: throbber + third_party_settings: { } + path: + type: path + weight: 30 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 100 + region: content + settings: + display_label: true + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 5 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } +hidden: + name: true diff --git a/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.media_library.yml b/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.media_library.yml new file mode 100644 index 0000000000000000000000000000000000000000..db2cf0b19e98506e3b1b54f4185412ccd1e0f51a --- /dev/null +++ b/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.media_library.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_form_mode.media.media_library + - field.field.media.video.field_media_video_file + - media.type.video +id: media.video.media_library +targetEntityType: media +bundle: video +mode: media_library +content: { } +hidden: + created: true + field_media_video_file: true + name: true + path: true + status: true + uid: true diff --git a/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.default.yml b/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..3c26f17aae3e9a4dd263a008233424f1bd06ad13 --- /dev/null +++ b/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.default.yml @@ -0,0 +1,32 @@ +langcode: en +status: true +dependencies: + config: + - field.field.media.video.field_media_video_file + - media.type.video + module: + - file +id: media.video.default +targetEntityType: media +bundle: video +mode: default +content: + field_media_video_file: + type: file_video + label: visually_hidden + settings: + controls: true + autoplay: false + loop: false + multiple_file_display_type: tags + muted: false + width: 640 + height: 480 + third_party_settings: { } + weight: 0 + region: content +hidden: + created: true + name: true + thumbnail: true + uid: true diff --git a/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.media_library.yml b/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.media_library.yml new file mode 100644 index 0000000000000000000000000000000000000000..a4db1a99b8e9d156375f405075a0041297a52200 --- /dev/null +++ b/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.media_library.yml @@ -0,0 +1,31 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.media.media_library + - field.field.media.video.field_media_video_file + - image.style.thumbnail + - media.type.video + module: + - image +id: media.video.media_library +targetEntityType: media +bundle: video +mode: media_library +content: + thumbnail: + type: image + label: hidden + settings: + image_style: thumbnail + image_link: '' + image_loading: + attribute: lazy + third_party_settings: { } + weight: 0 + region: content +hidden: + created: true + field_media_video_file: true + name: true + uid: true diff --git a/core/recipes/local_video_media_type/config/field.field.media.video.field_media_video_file.yml b/core/recipes/local_video_media_type/config/field.field.media.video.field_media_video_file.yml new file mode 100644 index 0000000000000000000000000000000000000000..b6c0be146e45a376e680c704c72391f5bbd55eca --- /dev/null +++ b/core/recipes/local_video_media_type/config/field.field.media.video.field_media_video_file.yml @@ -0,0 +1,26 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.media.field_media_video_file + - media.type.video + module: + - file +id: media.video.field_media_video_file +field_name: field_media_video_file +entity_type: media +bundle: video +label: 'Video file' +description: '' +required: true +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: 'default:file' + handler_settings: { } + file_directory: '[date:custom:Y]-[date:custom:m]' + file_extensions: mp4 + max_filesize: '' + description_field: false +field_type: file diff --git a/core/recipes/local_video_media_type/config/field.storage.media.field_media_video_file.yml b/core/recipes/local_video_media_type/config/field.storage.media.field_media_video_file.yml new file mode 100644 index 0000000000000000000000000000000000000000..0ac96a14b7c2375193f151de5773e9c707e7bdcf --- /dev/null +++ b/core/recipes/local_video_media_type/config/field.storage.media.field_media_video_file.yml @@ -0,0 +1,22 @@ +langcode: en +status: true +dependencies: + module: + - file + - media +id: media.field_media_video_file +field_name: field_media_video_file +entity_type: media +type: file +settings: + target_type: file + display_field: false + display_default: false + uri_scheme: public +module: file +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/core/recipes/local_video_media_type/config/media.type.video.yml b/core/recipes/local_video_media_type/config/media.type.video.yml new file mode 100644 index 0000000000000000000000000000000000000000..b07ac27059a97f9e4ff21b6bbf78016e6c2154f8 --- /dev/null +++ b/core/recipes/local_video_media_type/config/media.type.video.yml @@ -0,0 +1,13 @@ +langcode: en +status: true +dependencies: { } +id: video +label: Video +description: 'A locally hosted video file.' +source: video_file +queue_thumbnail_downloads: false +new_revision: true +source_configuration: + source_field: field_media_video_file +field_map: + name: name diff --git a/core/recipes/local_video_media_type/recipe.yml b/core/recipes/local_video_media_type/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..8bb1c6f6f630d7fe729dd8d885cf53f7eac01b8c --- /dev/null +++ b/core/recipes/local_video_media_type/recipe.yml @@ -0,0 +1,25 @@ +name: 'Local video media' +description: 'Provides a media type for self-hosted video files.' +type: 'Media type' +install: + - media_library + - path + - views +config: + import: + file: + - views.view.files + media_library: + - core.entity_view_mode.media.media_library + - core.entity_form_mode.media.media_library + - image.style.media_library + - views.view.media_library + media: + - core.entity_view_mode.media.full + - system.action.media_delete_action + - system.action.media_publish_action + - system.action.media_save_action + - system.action.media_unpublish_action + - views.view.media + image: + - image.style.thumbnail diff --git a/core/recipes/page_content_type/config/core.base_field_override.node.page.promote.yml b/core/recipes/page_content_type/config/core.base_field_override.node.page.promote.yml new file mode 100644 index 0000000000000000000000000000000000000000..27226a1a6f263dc53df1994a2eff3b2a43d7ac21 --- /dev/null +++ b/core/recipes/page_content_type/config/core.base_field_override.node.page.promote.yml @@ -0,0 +1,21 @@ +langcode: en +status: true +dependencies: + config: + - node.type.page +id: node.page.promote +field_name: promote +entity_type: node +bundle: page +label: 'Promoted to front page' +description: '' +required: false +translatable: false +default_value: + - + value: 0 +default_value_callback: '' +settings: + on_label: 'On' + off_label: 'Off' +field_type: boolean diff --git a/core/recipes/page_content_type/config/core.entity_form_display.node.page.default.yml b/core/recipes/page_content_type/config/core.entity_form_display.node.page.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..edb853ed3de0c5fcef565eaeb2f5dd266fae50c8 --- /dev/null +++ b/core/recipes/page_content_type/config/core.entity_form_display.node.page.default.yml @@ -0,0 +1,76 @@ +langcode: en +status: true +dependencies: + config: + - field.field.node.page.body + - node.type.page + module: + - path + - text +id: node.page.default +targetEntityType: node +bundle: page +mode: default +content: + body: + type: text_textarea_with_summary + weight: 31 + region: content + settings: + rows: 9 + summary_rows: 3 + placeholder: '' + show_summary: false + third_party_settings: { } + created: + type: datetime_timestamp + weight: 10 + region: content + settings: { } + third_party_settings: { } + path: + type: path + weight: 30 + region: content + settings: { } + third_party_settings: { } + promote: + type: boolean_checkbox + weight: 15 + region: content + settings: + display_label: true + third_party_settings: { } + status: + type: boolean_checkbox + weight: 120 + region: content + settings: + display_label: true + third_party_settings: { } + sticky: + type: boolean_checkbox + weight: 16 + region: content + settings: + display_label: true + third_party_settings: { } + title: + type: string_textfield + weight: -5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 5 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } +hidden: { } diff --git a/core/recipes/page_content_type/config/core.entity_view_display.node.page.default.yml b/core/recipes/page_content_type/config/core.entity_view_display.node.page.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..bd70482cd1da447dc2a938c29ebb2a438dac321e --- /dev/null +++ b/core/recipes/page_content_type/config/core.entity_view_display.node.page.default.yml @@ -0,0 +1,25 @@ +langcode: en +status: true +dependencies: + config: + - field.field.node.page.body + - node.type.page + module: + - text + - user +id: node.page.default +targetEntityType: node +bundle: page +mode: default +content: + body: + type: text_default + label: hidden + settings: { } + third_party_settings: { } + weight: 100 + region: content + links: + weight: 101 + region: content +hidden: { } diff --git a/core/recipes/page_content_type/config/core.entity_view_display.node.page.teaser.yml b/core/recipes/page_content_type/config/core.entity_view_display.node.page.teaser.yml new file mode 100644 index 0000000000000000000000000000000000000000..34a70d932faa89e65454018070db50a6df23a8d2 --- /dev/null +++ b/core/recipes/page_content_type/config/core.entity_view_display.node.page.teaser.yml @@ -0,0 +1,27 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.node.teaser + - field.field.node.page.body + - node.type.page + module: + - text + - user +id: node.page.teaser +targetEntityType: node +bundle: page +mode: teaser +content: + body: + type: text_summary_or_trimmed + label: hidden + settings: + trim_length: 600 + third_party_settings: { } + weight: 100 + region: content + links: + weight: 101 + region: content +hidden: { } diff --git a/core/recipes/page_content_type/config/field.field.node.page.body.yml b/core/recipes/page_content_type/config/field.field.node.page.body.yml new file mode 100644 index 0000000000000000000000000000000000000000..4ff17d0e711a22307564a3a19fb04549ee779fb2 --- /dev/null +++ b/core/recipes/page_content_type/config/field.field.node.page.body.yml @@ -0,0 +1,22 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.node.body + - node.type.page + module: + - text +id: node.page.body +field_name: body +entity_type: node +bundle: page +label: Body +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + display_summary: true + required_summary: false +field_type: text_with_summary diff --git a/core/recipes/page_content_type/config/node.type.page.yml b/core/recipes/page_content_type/config/node.type.page.yml new file mode 100644 index 0000000000000000000000000000000000000000..755e8ed8ce5be5a015b5f34b80d10d332e0976b0 --- /dev/null +++ b/core/recipes/page_content_type/config/node.type.page.yml @@ -0,0 +1,10 @@ +langcode: en +status: true +dependencies: { } +name: 'Basic page' +type: page +description: "Use <em>basic pages</em> for your static content, such as an 'About us' page." +help: null +new_revision: true +preview_mode: 1 +display_submitted: false diff --git a/core/recipes/page_content_type/recipe.yml b/core/recipes/page_content_type/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..e9193c024f2765e1d006aa74e166c5a58d2e2ec7 --- /dev/null +++ b/core/recipes/page_content_type/recipe.yml @@ -0,0 +1,21 @@ +name: 'Basic page' +description: "Provides Basic page content type and related configuration. Use <em>basic pages</em> for your static content, such as an 'About us' page." +type: 'Content type' +install: + - node + - path +config: + import: + node: + - core.entity_view_mode.node.full + - core.entity_view_mode.node.rss + - core.entity_view_mode.node.teaser + - field.storage.node.body + - system.action.node_delete_action + - system.action.node_make_sticky_action + - system.action.node_make_unsticky_action + - system.action.node_promote_action + - system.action.node_publish_action + - system.action.node_save_action + - system.action.node_unpromote_action + - system.action.node_unpublish_action diff --git a/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.default.yml b/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..0f57855dd0a453b9c1c52ffa74b8c461a180c5e0 --- /dev/null +++ b/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.default.yml @@ -0,0 +1,53 @@ +langcode: en +status: true +dependencies: + config: + - field.field.media.remote_video.field_media_oembed_video + - media.type.remote_video + module: + - media + - path +id: media.remote_video.default +targetEntityType: media +bundle: remote_video +mode: default +content: + created: + type: datetime_timestamp + weight: 10 + region: content + settings: { } + third_party_settings: { } + field_media_oembed_video: + type: oembed_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + path: + type: path + weight: 30 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 100 + region: content + settings: + display_label: true + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 5 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } +hidden: + name: true diff --git a/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.media_library.yml b/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.media_library.yml new file mode 100644 index 0000000000000000000000000000000000000000..6a1461cded73a6724ff6673fc2fe41f33d3cbc05 --- /dev/null +++ b/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.media_library.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_form_mode.media.media_library + - field.field.media.remote_video.field_media_oembed_video + - media.type.remote_video +id: media.remote_video.media_library +targetEntityType: media +bundle: remote_video +mode: media_library +content: { } +hidden: + created: true + field_media_oembed_video: true + name: true + path: true + status: true + uid: true diff --git a/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.default.yml b/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..5dd5a52d6cc28c832307fcafdc33f218fb10f62f --- /dev/null +++ b/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.default.yml @@ -0,0 +1,29 @@ +langcode: en +status: true +dependencies: + config: + - field.field.media.remote_video.field_media_oembed_video + - media.type.remote_video + module: + - media +id: media.remote_video.default +targetEntityType: media +bundle: remote_video +mode: default +content: + field_media_oembed_video: + type: oembed + label: hidden + settings: + max_width: 0 + max_height: 0 + loading: + attribute: lazy + third_party_settings: { } + weight: 0 + region: content +hidden: + created: true + name: true + thumbnail: true + uid: true diff --git a/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.media_library.yml b/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.media_library.yml new file mode 100644 index 0000000000000000000000000000000000000000..268b1b37fe0918498e045b92894a312408f3b1e2 --- /dev/null +++ b/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.media_library.yml @@ -0,0 +1,31 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.media.media_library + - field.field.media.remote_video.field_media_oembed_video + - image.style.medium + - media.type.remote_video + module: + - image +id: media.remote_video.media_library +targetEntityType: media +bundle: remote_video +mode: media_library +content: + thumbnail: + type: image + label: hidden + settings: + image_style: medium + image_link: '' + image_loading: + attribute: lazy + third_party_settings: { } + weight: 0 + region: content +hidden: + created: true + field_media_oembed_video: true + name: true + uid: true diff --git a/core/recipes/remote_video_media_type/config/field.field.media.remote_video.field_media_oembed_video.yml b/core/recipes/remote_video_media_type/config/field.field.media.remote_video.field_media_oembed_video.yml new file mode 100644 index 0000000000000000000000000000000000000000..6ff378fa17a10995c6880da15b2bac564a6d747b --- /dev/null +++ b/core/recipes/remote_video_media_type/config/field.field.media.remote_video.field_media_oembed_video.yml @@ -0,0 +1,18 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.media.field_media_oembed_video + - media.type.remote_video +id: media.remote_video.field_media_oembed_video +field_name: field_media_oembed_video +entity_type: media +bundle: remote_video +label: 'Video URL' +description: '' +required: true +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/core/recipes/remote_video_media_type/config/field.storage.media.field_media_oembed_video.yml b/core/recipes/remote_video_media_type/config/field.storage.media.field_media_oembed_video.yml new file mode 100644 index 0000000000000000000000000000000000000000..e8664f0b181c38b770d06841346ea1d7c2f1fea9 --- /dev/null +++ b/core/recipes/remote_video_media_type/config/field.storage.media.field_media_oembed_video.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + module: + - media +id: media.field_media_oembed_video +field_name: field_media_oembed_video +entity_type: media +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/core/recipes/remote_video_media_type/config/media.type.remote_video.yml b/core/recipes/remote_video_media_type/config/media.type.remote_video.yml new file mode 100644 index 0000000000000000000000000000000000000000..203d69832765562befb5851a4fe374b3cd9e64bf --- /dev/null +++ b/core/recipes/remote_video_media_type/config/media.type.remote_video.yml @@ -0,0 +1,17 @@ +langcode: en +status: true +dependencies: { } +id: remote_video +label: 'Remote video' +description: 'A remotely hosted video from YouTube or Vimeo.' +source: 'oembed:video' +queue_thumbnail_downloads: false +new_revision: true +source_configuration: + source_field: field_media_oembed_video + thumbnails_directory: 'public://oembed_thumbnails/[date:custom:Y-m]' + providers: + - YouTube + - Vimeo +field_map: + title: name diff --git a/core/recipes/remote_video_media_type/recipe.yml b/core/recipes/remote_video_media_type/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..1f66ebfc199689dfcc17273e30ee996f9b01259b --- /dev/null +++ b/core/recipes/remote_video_media_type/recipe.yml @@ -0,0 +1,23 @@ +name: 'Remote video media' +description: 'Provides a media type for videos hosted on YouTube and Vimeo.' +type: 'Media type' +install: + - media_library + - path + - views +config: + import: + media_library: + - core.entity_view_mode.media.media_library + - core.entity_form_mode.media.media_library + - image.style.media_library + - views.view.media_library + media: + - core.entity_view_mode.media.full + - system.action.media_delete_action + - system.action.media_publish_action + - system.action.media_save_action + - system.action.media_unpublish_action + - views.view.media + image: + - image.style.medium diff --git a/core/recipes/restricted_html_format/config/filter.format.restricted_html.yml b/core/recipes/restricted_html_format/config/filter.format.restricted_html.yml new file mode 100644 index 0000000000000000000000000000000000000000..5656b14514820ac7890382f41d131338663371d4 --- /dev/null +++ b/core/recipes/restricted_html_format/config/filter.format.restricted_html.yml @@ -0,0 +1,29 @@ +langcode: en +status: true +dependencies: { } +name: 'Restricted HTML' +format: restricted_html +weight: 1 +filters: + filter_html: + id: filter_html + provider: filter + status: true + weight: -10 + settings: + allowed_html: '<a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>' + filter_html_help: true + filter_html_nofollow: false + filter_autop: + id: filter_autop + provider: filter + status: true + weight: 0 + settings: { } + filter_url: + id: filter_url + provider: filter + status: true + weight: 0 + settings: + filter_url_length: 72 diff --git a/core/recipes/restricted_html_format/recipe.yml b/core/recipes/restricted_html_format/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..8aec764918f93e9d44a5735a61778b5e1d8b6b5c --- /dev/null +++ b/core/recipes/restricted_html_format/recipe.yml @@ -0,0 +1,11 @@ +name: 'Restricted HTML' +description: 'Provides "Restricted HTML" text format.' +type: 'Text format' +install: + - filter +config: + import: + filter: '*' + actions: + user.role.anonymous: + grantPermission: 'use text format restricted_html' diff --git a/core/recipes/standard/config/user.role.administrator.yml b/core/recipes/standard/config/user.role.administrator.yml new file mode 100644 index 0000000000000000000000000000000000000000..ca48a58b4eed312dd87bfbeca5cd6ba01367171d --- /dev/null +++ b/core/recipes/standard/config/user.role.administrator.yml @@ -0,0 +1,8 @@ +langcode: en +status: true +dependencies: { } +id: administrator +label: Administrator +weight: 3 +is_admin: true +permissions: { } diff --git a/core/recipes/standard/config/user.role.content_editor.yml b/core/recipes/standard/config/user.role.content_editor.yml new file mode 100644 index 0000000000000000000000000000000000000000..b1dbe10170a18acda742419e8de469568c2b57f9 --- /dev/null +++ b/core/recipes/standard/config/user.role.content_editor.yml @@ -0,0 +1,23 @@ +langcode: en +status: true +dependencies: { } +id: content_editor +label: 'Content editor' +weight: 2 +is_admin: false +permissions: + - 'access administration pages' + - 'access content overview' + - 'access contextual links' + - 'access files overview' + - 'access toolbar' + - 'administer url aliases' + - 'create terms in tags' + - 'create url aliases' + - 'edit own comments' + - 'edit terms in tags' + - 'delete own files' + - 'revert all revisions' + - 'view all revisions' + - 'view own unpublished content' + - 'view the administration theme' diff --git a/core/recipes/standard/recipe.yml b/core/recipes/standard/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..ae577ff8a905900315924be30fa0ac4531fd213d --- /dev/null +++ b/core/recipes/standard/recipe.yml @@ -0,0 +1,85 @@ +name: 'Standard' +description: 'Provides a standard site with commonly used features pre-configured.' +type: 'Site' +recipes: + - basic_block_type + - basic_shortcuts + - article_comment + - article_tags + - feedback_contact_form + - article_content_type + - page_content_type + - basic_html_format_editor + - full_html_format_editor + - content_search + - core_recommended_performance + - core_recommended_maintenance + - core_recommended_admin_theme + - core_recommended_front_end_theme + - user_picture + # Provides a fallback text format which is available to all users. + - restricted_html_format +install: + - image + - help + - history + - config + - contextual + - menu_link_content + - datetime + - menu_ui + - options + - toolbar + - field_ui + - views_ui + - shortcut +config: + import: + claro: + - block.block.claro_help + help: + - search.page.help_search + - block.block.claro_help_search + image: + - image.style.large + - image.style.thumbnail + node: + - views.view.archive + - views.view.content + - views.view.content_recent + - views.view.frontpage + - views.view.glossary + olivero: + - block.block.olivero_help + - block.block.olivero_search_form_narrow + - block.block.olivero_search_form_wide + - block.block.olivero_syndicate + user: + - core.entity_view_mode.user.compact + - search.page.user_search + - views.view.user_admin_people + - views.view.who_s_new + - views.view.who_s_online + actions: + node.settings: + simple_config_update: + use_admin_theme: true + system.site: + simple_config_update: + page.front: /node + user.role.authenticated: + grantPermission: 'delete own files' + user.role.content_editor: + grantPermissionsForEachNodeType: + - 'create %bundle content' + - 'delete %bundle revisions' + - 'delete own %bundle content' + - 'edit own %bundle content' + user.role.anonymous: + # This recipe assumes all published content should be publicly accessible. + grantPermission: 'access content' + user.settings: + simple_config_update: + verify_mail: true + register: visitors_admin_approval + cancel_method: user_cancel_block diff --git a/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml b/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml new file mode 100644 index 0000000000000000000000000000000000000000..fde3282498d0d450699629709a37990b404c517c --- /dev/null +++ b/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml @@ -0,0 +1,24 @@ +langcode: en +dependencies: + module: + - responsive_image + enforced: + module: + - responsive_image +name: max_1300x1300 +label: 'Max 1300x1300' +effects: + 04caae9a-fa3e-4ea6-ae09-9c26aec7d308: + uuid: 04caae9a-fa3e-4ea6-ae09-9c26aec7d308 + id: image_scale + weight: 1 + data: + width: 1300 + height: 1300 + upscale: false + e8c9d6ba-a017-4a87-9999-7ce52e138e1d: + uuid: e8c9d6ba-a017-4a87-9999-7ce52e138e1d + id: image_convert + weight: 2 + data: + extension: webp diff --git a/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml b/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml new file mode 100644 index 0000000000000000000000000000000000000000..a63e72ab6f358c094919bea2b540b947d54d7c38 --- /dev/null +++ b/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml @@ -0,0 +1,24 @@ +langcode: en +dependencies: + module: + - responsive_image + enforced: + module: + - responsive_image +name: max_2600x2600 +label: 'Max 2600x2600' +effects: + 9b311dd1-0351-45a1-9500-cd069e4670cb: + uuid: 9b311dd1-0351-45a1-9500-cd069e4670cb + id: image_scale + weight: 1 + data: + width: 2600 + height: 2600 + upscale: false + 3c42f186-7beb-4dbf-b720-bff9dfeaa677: + uuid: 3c42f186-7beb-4dbf-b720-bff9dfeaa677 + id: image_convert + weight: 2 + data: + extension: webp diff --git a/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml b/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml new file mode 100644 index 0000000000000000000000000000000000000000..e820c8bb01dad1f36e695d18de27ae421d712b05 --- /dev/null +++ b/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml @@ -0,0 +1,24 @@ +langcode: en +dependencies: + module: + - responsive_image + enforced: + module: + - responsive_image +name: max_325x325 +label: 'Max 325x325' +effects: + cb842cc8-682f-42a6-bd05-5a1ac726f0d8: + uuid: cb842cc8-682f-42a6-bd05-5a1ac726f0d8 + id: image_scale + weight: 1 + data: + width: 325 + height: 325 + upscale: false + f2b6c795-26ae-4130-aa18-aa120ea3ba98: + uuid: f2b6c795-26ae-4130-aa18-aa120ea3ba98 + id: image_convert + weight: 2 + data: + extension: webp diff --git a/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml b/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml new file mode 100644 index 0000000000000000000000000000000000000000..d5beda6259f3bfd3e842a31e8b914b5ad1d176e1 --- /dev/null +++ b/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml @@ -0,0 +1,24 @@ +langcode: en +dependencies: + module: + - responsive_image + enforced: + module: + - responsive_image +name: max_650x650 +label: 'Max 650x650' +effects: + 949c201a-77f5-48f6-ba00-be91eb1aad47: + uuid: 949c201a-77f5-48f6-ba00-be91eb1aad47 + id: image_scale + weight: 1 + data: + width: 650 + height: 650 + upscale: false + 4a2a7af8-8ea3-419d-b5f8-256d57016102: + uuid: 4a2a7af8-8ea3-419d-b5f8-256d57016102 + id: image_convert + weight: 2 + data: + extension: webp diff --git a/core/recipes/standard_responsive_images/config/responsive_image.styles.narrow.yml b/core/recipes/standard_responsive_images/config/responsive_image.styles.narrow.yml new file mode 100644 index 0000000000000000000000000000000000000000..51590cd7b205221aa8e87f18808c64e2dd539911 --- /dev/null +++ b/core/recipes/standard_responsive_images/config/responsive_image.styles.narrow.yml @@ -0,0 +1,22 @@ +langcode: en +status: true +dependencies: + config: + - image.style.max_1300x1300 + - image.style.max_325x325 + - image.style.max_650x650 +id: narrow +label: Narrow +image_style_mappings: + - + image_mapping_type: sizes + image_mapping: + sizes: '(min-width: 1290px) 325px, (min-width: 851px) 25vw, (min-width: 560px) 50vw, 100vw' + sizes_image_styles: + - max_1300x1300 + - max_650x650 + - max_325x325 + breakpoint_id: responsive_image.viewport_sizing + multiplier: 1x +breakpoint_group: responsive_image +fallback_image_style: max_325x325 diff --git a/core/recipes/standard_responsive_images/config/responsive_image.styles.wide.yml b/core/recipes/standard_responsive_images/config/responsive_image.styles.wide.yml new file mode 100644 index 0000000000000000000000000000000000000000..06cb8a98e80599b57f739fbd8bf7e92959899987 --- /dev/null +++ b/core/recipes/standard_responsive_images/config/responsive_image.styles.wide.yml @@ -0,0 +1,24 @@ +langcode: en +status: true +dependencies: + config: + - image.style.max_1300x1300 + - image.style.max_2600x2600 + - image.style.max_325x325 + - image.style.max_650x650 +id: wide +label: Wide +image_style_mappings: + - + image_mapping_type: sizes + image_mapping: + sizes: '(min-width: 1290px) 1290px, 100vw' + sizes_image_styles: + - max_2600x2600 + - max_1300x1300 + - max_650x650 + - max_325x325 + breakpoint_id: responsive_image.viewport_sizing + multiplier: 1x +breakpoint_group: responsive_image +fallback_image_style: max_325x325 diff --git a/core/recipes/standard_responsive_images/recipe.yml b/core/recipes/standard_responsive_images/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..210a3286908c5083f3c7661a700df8aadc611345 --- /dev/null +++ b/core/recipes/standard_responsive_images/recipe.yml @@ -0,0 +1,8 @@ +name: 'Standard Responsive Images' +description: 'Provides basic responsive images and accompanying image styles.' +type: 'Media' +install: + - responsive_image +config: + import: + image: '*' diff --git a/core/recipes/tags_taxonomy/config/taxonomy.vocabulary.tags.yml b/core/recipes/tags_taxonomy/config/taxonomy.vocabulary.tags.yml new file mode 100644 index 0000000000000000000000000000000000000000..4c754e86c71598ec2002718b7568e91d413557ea --- /dev/null +++ b/core/recipes/tags_taxonomy/config/taxonomy.vocabulary.tags.yml @@ -0,0 +1,7 @@ +langcode: en +status: true +dependencies: { } +name: Tags +vid: tags +description: 'Use tags to group articles on similar topics into categories.' +weight: 0 diff --git a/core/recipes/tags_taxonomy/recipe.yml b/core/recipes/tags_taxonomy/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..46436718a889804abfbf60bdc3783c9d89bd4999 --- /dev/null +++ b/core/recipes/tags_taxonomy/recipe.yml @@ -0,0 +1,11 @@ +name: Tags +description: 'Provides "Tags" taxonomy vocabulary and related configuration. Use tags to group content on similar topics into categories.' +type: 'Taxonomy' +install: + - taxonomy +config: + import: + taxonomy: + - core.entity_view_mode.taxonomy_term.full + - system.action.taxonomy_term_publish_action + - system.action.taxonomy_term_unpublish_action diff --git a/core/recipes/user_picture/config/core.entity_form_display.user.user.default.yml b/core/recipes/user_picture/config/core.entity_form_display.user.user.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..8098d4688a6d08b86f143bf9f85e55ec6f4abb87 --- /dev/null +++ b/core/recipes/user_picture/config/core.entity_form_display.user.user.default.yml @@ -0,0 +1,35 @@ +langcode: en +status: true +dependencies: + config: + - field.field.user.user.user_picture + - image.style.thumbnail + module: + - image + - user +id: user.user.default +targetEntityType: user +bundle: user +mode: default +content: + account: + weight: -10 + region: content + contact: + weight: 5 + region: content + language: + weight: 0 + region: content + timezone: + weight: 6 + region: content + user_picture: + type: image_image + weight: -1 + region: content + settings: + progress_indicator: throbber + preview_image_style: thumbnail + third_party_settings: { } +hidden: { } diff --git a/core/recipes/user_picture/config/core.entity_view_display.user.user.compact.yml b/core/recipes/user_picture/config/core.entity_view_display.user.user.compact.yml new file mode 100644 index 0000000000000000000000000000000000000000..1e0ea7c9f7e2ab26ceb56e038aaf5adea88bfcff --- /dev/null +++ b/core/recipes/user_picture/config/core.entity_view_display.user.user.compact.yml @@ -0,0 +1,28 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.user.compact + - field.field.user.user.user_picture + - image.style.thumbnail + module: + - image + - user +id: user.user.compact +targetEntityType: user +bundle: user +mode: compact +content: + user_picture: + type: image + label: hidden + settings: + image_style: thumbnail + image_link: content + image_loading: + attribute: lazy + third_party_settings: { } + weight: 0 + region: content +hidden: + member_for: true diff --git a/core/recipes/user_picture/config/core.entity_view_display.user.user.default.yml b/core/recipes/user_picture/config/core.entity_view_display.user.user.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..9bc86dc8906a46cd563aa76e986573ac0db82261 --- /dev/null +++ b/core/recipes/user_picture/config/core.entity_view_display.user.user.default.yml @@ -0,0 +1,29 @@ +langcode: en +status: true +dependencies: + config: + - field.field.user.user.user_picture + - image.style.thumbnail + module: + - image + - user +id: user.user.default +targetEntityType: user +bundle: user +mode: default +content: + member_for: + weight: 5 + region: content + user_picture: + type: image + label: hidden + settings: + image_style: thumbnail + image_link: content + image_loading: + attribute: lazy + third_party_settings: { } + weight: 0 + region: content +hidden: { } diff --git a/core/recipes/user_picture/config/field.field.user.user.user_picture.yml b/core/recipes/user_picture/config/field.field.user.user.user_picture.yml new file mode 100644 index 0000000000000000000000000000000000000000..54a59c087f89146ed57d363f9d1cd54030486dd9 --- /dev/null +++ b/core/recipes/user_picture/config/field.field.user.user.user_picture.yml @@ -0,0 +1,37 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.user.user_picture + module: + - image + - user +id: user.user.user_picture +field_name: user_picture +entity_type: user +bundle: user +label: Picture +description: 'Your virtual face or picture.' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: 'default:file' + handler_settings: { } + file_directory: 'pictures/[date:custom:Y]-[date:custom:m]' + file_extensions: 'png gif jpg jpeg webp' + max_filesize: '' + max_resolution: '' + min_resolution: '' + alt_field: false + alt_field_required: false + title_field: false + title_field_required: false + default_image: + uuid: null + alt: '' + title: '' + width: null + height: null +field_type: image diff --git a/core/recipes/user_picture/config/field.storage.user.user_picture.yml b/core/recipes/user_picture/config/field.storage.user.user_picture.yml new file mode 100644 index 0000000000000000000000000000000000000000..6d0476df6c2049d9876079a71f80482478b01bb8 --- /dev/null +++ b/core/recipes/user_picture/config/field.storage.user.user_picture.yml @@ -0,0 +1,31 @@ +langcode: en +status: true +dependencies: + module: + - file + - image + - user +id: user.user_picture +field_name: user_picture +entity_type: user +type: image +settings: + target_type: file + display_field: false + display_default: false + uri_scheme: public + default_image: + uuid: null + alt: '' + title: '' + width: null + height: null +module: image +locked: false +cardinality: 1 +translatable: true +indexes: + target_id: + - target_id +persist_with_no_fields: false +custom_storage: false diff --git a/core/recipes/user_picture/recipe.yml b/core/recipes/user_picture/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..ba84c830020cbbd058a36fe4a831ff5c197ea23e --- /dev/null +++ b/core/recipes/user_picture/recipe.yml @@ -0,0 +1,8 @@ +name: User pictures +description: 'Adds the ability for user accounts to have pictures (avatars).' +type: Users +install: + - field + - file + - image + - user diff --git a/core/scripts/drupal b/core/scripts/drupal index 891d5b8117800cfbe424864f7488f9c829b174ce..0c9eb300cde9cc6c99d7f986d141ddf0c3bdfeb7 100644 --- a/core/scripts/drupal +++ b/core/scripts/drupal @@ -10,6 +10,7 @@ use Drupal\Core\Command\GenerateTheme; use Drupal\Core\Command\QuickStartCommand; use Drupal\Core\Command\InstallCommand; use Drupal\Core\Command\ServerCommand; +use Drupal\Core\Recipe\RecipeCommand; use Symfony\Component\Console\Application; if (PHP_SAPI !== 'cli') { @@ -24,5 +25,6 @@ $application->add(new QuickStartCommand()); $application->add(new InstallCommand($classloader)); $application->add(new ServerCommand($classloader)); $application->add(new GenerateTheme()); +$application->add(new RecipeCommand($classloader)); $application->run(); diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/CoreRecipesTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/CoreRecipesTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f1efd741390f1ecbef76e6b231afb2b5b18dfd15 --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/CoreRecipesTest.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\FunctionalTests\Core\Recipe; + +use Drupal\Tests\BrowserTestBase; +use Symfony\Component\Finder\Finder; + +/** + * Tests applying all core-provided recipes on top of the Empty profile. + * + * @group Recipe + */ +class CoreRecipesTest extends BrowserTestBase { + + use RecipeTestTrait; + + /** + * {@inheritdoc} + */ + protected $profile = 'minimal'; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * The data provider for apply recipe test. + * + * @return iterable<array<string>> + * An iterable containing paths to recipe files. + */ + public static function providerApplyRecipe(): iterable { + $finder = Finder::create() + ->in([ + static::getDrupalRoot() . '/core/recipes', + ]) + ->directories() + // Recipes can't contain other recipes, so we don't need to search in + // subdirectories. + ->depth(0) + // The Example recipe is for documentation only, and cannot be applied. + ->notName(['example']); + + $scenarios = []; + /** @var \Symfony\Component\Finder\SplFileInfo $recipe */ + foreach ($finder as $recipe) { + $name = $recipe->getBasename(); + $scenarios[$name] = [ + $recipe->getPathname(), + ]; + } + return $scenarios; + } + + /** + * Test the recipe apply. + * + * @param string $path + * The path to the recipe file. + * + * @dataProvider providerApplyRecipe + */ + public function testApplyRecipe(string $path): void { + $this->setUpCurrentUser(admin: TRUE); + $this->applyRecipe($path); + } + +} diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php new file mode 100644 index 0000000000000000000000000000000000000000..39149729e78e2e08b6e2e7feb8031fc2a20c3087 --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\FunctionalTests\Core\Recipe; + +use Drupal\Core\Config\Checkpoint\Checkpoint; +use Drupal\Tests\BrowserTestBase; + +/** + * @coversDefaultClass \Drupal\Core\Recipe\RecipeCommand + * @group Recipe + * + * BrowserTestBase is used for a proper Drupal install. + */ +class RecipeCommandTest extends BrowserTestBase { + + use RecipeTestTrait; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + * + * Disable strict config schema because this test explicitly makes the + * recipe system save invalid config, to prove that it validates it after + * the fact and raises an error. + */ + protected $strictConfigSchema = FALSE; + + public function testRecipeCommand(): void { + $this->assertFalse(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is not installed'); + $this->assertCheckpointsExist([]); + + $process = $this->applyRecipe('core/tests/fixtures/recipes/install_node_with_config'); + $this->assertSame(0, $process->getExitCode()); + $this->assertStringContainsString("Applied Install node with config recipe.", $process->getErrorOutput()); + $this->assertStringContainsString('Install node with config applied successfully', $process->getOutput()); + $this->assertTrue(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is installed'); + $this->assertCheckpointsExist(["Backup before the 'Install node with config' recipe."]); + + // Ensure recipes can be applied without affecting pre-existing checkpoints. + $process = $this->applyRecipe('core/tests/fixtures/recipes/install_two_modules'); + $this->assertSame(0, $process->getExitCode()); + $this->assertStringContainsString("Applied Install two modules recipe.", $process->getErrorOutput()); + $this->assertStringContainsString('Install two modules applied successfully', $process->getOutput()); + $this->assertTrue(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is installed'); + $this->assertCheckpointsExist([ + "Backup before the 'Install node with config' recipe.", + "Backup before the 'Install two modules' recipe.", + ]); + + // Ensure recipes that fail have an exception message. + $process = $this->applyRecipe('core/tests/fixtures/recipes/invalid_config', 1); + $this->assertStringContainsString("There were validation errors in core.date_format.invalid", $process->getErrorOutput()); + $this->assertCheckpointsExist([ + "Backup before the 'Install node with config' recipe.", + "Backup before the 'Install two modules' recipe.", + // Although the recipe command tried to create a checkpoint, it did not + // actually happen, because of https://drupal.org/i/3408523. + ]); + + // Create a checkpoint so we can test what happens when a recipe does not + // create a checkpoint before applying. + \Drupal::service('config.storage.checkpoint')->checkpoint('Test log message'); + $process = $this->applyRecipe('core/tests/fixtures/recipes/no_extensions'); + $this->assertSame(0, $process->getExitCode()); + $this->assertStringContainsString("Applied No extensions recipe.", $process->getErrorOutput()); + $this->assertCheckpointsExist([ + "Backup before the 'Install node with config' recipe.", + "Backup before the 'Install two modules' recipe.", + "Test log message", + ]); + $this->assertStringContainsString('[notice] A backup checkpoint was not created because nothing has changed since the "Test log message" checkpoint was created.', $process->getOutput()); + } + + /** + * Tests that errors during config rollback won't steamroll validation errors. + */ + public function testExceptionOnRollback(): void { + $process = $this->applyRecipe('core/tests/fixtures/recipes/config_rollback_exception', 1); + + // The error from the config importer should be visible. + $output = $process->getOutput(); + $this->assertStringContainsString('There were errors validating the config synchronization.', $output); + $this->assertStringContainsString('Provides a filter plugin that is in use', $output); + // And the exception that actually *caused* the error should be visible too. + $this->assertStringContainsString('There were validation errors in system.image:', $process->getErrorOutput()); + } + + /** + * Asserts that the current set of checkpoints matches the given labels. + * + * @param string[] $expected_labels + * The labels of every checkpoint that is expected to exist currently, in + * the expected order. + */ + private function assertCheckpointsExist(array $expected_labels): void { + $checkpoints = \Drupal::service('config.checkpoints'); + $labels = array_map(fn (Checkpoint $c) => $c->label, iterator_to_array($checkpoints)); + $this->assertSame($expected_labels, array_values($labels)); + } + +} diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeTestTrait.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeTestTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..601dced29634dc77c3e9e5d1fbf87d913655759d --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeTestTrait.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\FunctionalTests\Core\Recipe; + +use Drupal\Component\Serialization\Yaml; +use Drupal\Core\Recipe\Recipe; +use Drupal\Tests\BrowserTestBase; +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * Contains helper methods for interacting with recipes in functional tests. + */ +trait RecipeTestTrait { + + /** + * Creates a recipe in a temporary directory. + * + * @param string|array<mixed> $data + * The contents of recipe.yml. If passed as an array, will be encoded to + * YAML. + * @param string|null $machine_name + * The machine name for the recipe. Will be used as the directory name. + * + * @return \Drupal\Core\Recipe\Recipe + * The recipe object. + */ + protected function createRecipe(string|array $data, ?string $machine_name = NULL): Recipe { + if (is_array($data)) { + $data = Yaml::encode($data); + } + $recipes_dir = $this->siteDirectory . '/recipes'; + if ($machine_name === NULL) { + $dir = uniqid($recipes_dir . '/'); + } + else { + $dir = $recipes_dir . '/' . $machine_name; + } + mkdir($dir, recursive: TRUE); + file_put_contents($dir . '/recipe.yml', $data); + + return Recipe::createFromDirectory($dir); + } + + /** + * Applies a recipe to the site. + * + * @param string $path + * The path of the recipe to apply. Must be a directory. + * @param int $expected_exit_code + * The expected exit code of the `drupal recipe` process. Defaults to 0, + * which indicates that no error occurred. + * + * @return \Symfony\Component\Process\Process + * The `drupal recipe` command process, after having run. + */ + protected function applyRecipe(string $path, int $expected_exit_code = 0): Process { + assert($this instanceof BrowserTestBase); + $this->assertDirectoryExists($path); + + $arguments = [ + (new PhpExecutableFinder())->find(), + 'core/scripts/drupal', + 'recipe', + $path, + ]; + $process = (new Process($arguments)) + ->setWorkingDirectory($this->getDrupalRoot()) + ->setEnv([ + 'DRUPAL_DEV_SITE_PATH' => $this->siteDirectory, + // Ensure that the command boots Drupal into a state where it knows it's + // a test site. + // @see drupal_valid_test_ua() + 'HTTP_USER_AGENT' => drupal_generate_test_ua($this->databasePrefix), + ]) + ->setTimeout(500); + + $process->run(); + $this->assertSame($expected_exit_code, $process->getExitCode(), $process->getErrorOutput()); + // Applying a recipe: + // - creates new checkpoints, hence the "state" service in the test runner + // is outdated + // - may install modules, which would cause the entire container in the test + // runner to be outdated. + // Hence the entire environment must be rebuilt for assertions to target the + // actual post-recipe-application result. + // @see \Drupal\Core\Config\Checkpoint\LinearHistory::__construct() + $this->rebuildAll(); + return $process; + } + +} diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeInstallTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeInstallTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e7509aa8528120c487fffb6df94df3c7f7b1aeaa --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeInstallTest.php @@ -0,0 +1,129 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\FunctionalTests\Core\Recipe; + +use Drupal\contact\Entity\ContactForm; +use Drupal\FunctionalTests\Installer\InstallerTestBase; +use Drupal\shortcut\Entity\Shortcut; +use Drupal\Tests\standard\Traits\StandardTestTrait; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Yaml\Yaml as SymfonyYaml; + +/** + * Tests installing the Standard recipe via the installer. + * + * @group #slow + * @group Recipe + */ +class StandardRecipeInstallTest extends InstallerTestBase { + use StandardTestTrait { + testStandard as doTestStandard; + } + use RecipeTestTrait; + + /** + * {@inheritdoc} + */ + protected $profile = ''; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + // Skip permissions hardening so we can write a services file later. + $this->settings['settings']['skip_permissions_hardening'] = (object) [ + 'value' => TRUE, + 'required' => TRUE, + ]; + + parent::setUp(); + } + + /** + * {@inheritdoc} + */ + protected function visitInstaller(): void { + // Use a URL to install from a recipe. + $this->drupalGet($GLOBALS['base_url'] . '/core/install.php' . '?profile=&recipe=core/recipes/standard'); + } + + /** + * {@inheritdoc} + */ + public function testStandard(): void { + if (!isset($this->rootUser->passRaw) && isset($this->rootUser->pass_raw)) { + $this->rootUser->passRaw = $this->rootUser->pass_raw; + } + // These recipes provide functionality that is only optionally part of the + // Standard profile, so we need to explicitly apply them. + $this->applyRecipe('core/recipes/editorial_workflow'); + $this->applyRecipe('core/recipes/audio_media_type'); + $this->applyRecipe('core/recipes/document_media_type'); + $this->applyRecipe('core/recipes/image_media_type'); + $this->applyRecipe('core/recipes/local_video_media_type'); + $this->applyRecipe('core/recipes/remote_video_media_type'); + + // Add a Home link to the main menu as Standard expects "Main navigation" + // block on the page. + $this->drupalGet('admin/structure/menu/manage/main/add'); + $this->submitForm([ + 'title[0][value]' => 'Home', + 'link[0][uri]' => '<front>', + ], 'Save'); + + // Standard expects to set the contact form's recipient email to the + // system's email address, but our feedback_contact_form recipe hard-codes + // it to another value. + // @todo This can be removed after https://drupal.org/i/3303126, which + // should make it possible for a recipe to reuse an already-set config + // value. + ContactForm::load('feedback')?->setRecipients(['simpletest@example.com']) + ->save(); + + // Standard ships two shortcuts; ensure they exist. + $this->assertCount(2, Shortcut::loadMultiple()); + + // The installer logs you in. + $this->drupalLogout(); + + $this->doTestStandard(); + } + + /** + * {@inheritdoc} + */ + protected function setUpProfile(): void { + // Noop. This form is skipped due the parameters set on the URL. + } + + protected function installDefaultThemeFromClassProperty(ContainerInterface $container): void { + // In this context a default theme makes no sense. + } + + /** + * {@inheritdoc} + */ + protected function installResponsiveImage(): void { + // Overrides StandardTest::installResponsiveImage() in order to use the + // recipe. + $this->applyRecipe('core/recipes/standard_responsive_images'); + } + + /** + * {@inheritdoc} + */ + protected function setUpSite(): void { + $services_file = DRUPAL_ROOT . '/' . $this->siteDirectory . '/services.yml'; + // $content = file_get_contents($services_file); + + // Disable the super user access. + $yaml = new SymfonyYaml(); + $services = []; + $services['parameters']['security.enable_super_user'] = FALSE; + file_put_contents($services_file, $yaml->dump($services)); + parent::setUpSite(); + } + +} diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2e66805c898cdc5cafa71e8f0412bdf0953604cb --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php @@ -0,0 +1,151 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\FunctionalTests\Core\Recipe; + +use Drupal\contact\Entity\ContactForm; +use Drupal\shortcut\Entity\Shortcut; +use Drupal\Tests\standard\Functional\StandardTest; +use Drupal\user\RoleInterface; + +/** + * Tests Standard recipe installation expectations. + * + * @group #slow + * @group Recipe + */ +class StandardRecipeTest extends StandardTest { + + use RecipeTestTrait; + + /** + * Tests Standard installation recipe. + */ + public function testStandard(): void { + // Install some modules that Standard has optional integrations with. + \Drupal::service('module_installer')->install(['media_library', 'content_moderation']); + + // Export all the configuration so we can compare later. + $this->copyConfig(\Drupal::service('config.storage'), \Drupal::service('config.storage.sync')); + + // Set theme to stark and uninstall the other themes. + $theme_installer = \Drupal::service('theme_installer'); + $theme_installer->install(['stark']); + $this->config('system.theme')->set('admin', '')->set('default', 'stark')->save(); + $theme_installer->uninstall(['claro', 'olivero']); + + // Determine which modules to uninstall. + $uninstall = array_diff(array_keys(\Drupal::moduleHandler()->getModuleList()), ['user', 'system', 'path_alias', \Drupal::database()->getProvider()]); + foreach (['shortcut', 'field_config', 'filter_format', 'field_storage_config'] as $entity_type) { + $storage = \Drupal::entityTypeManager()->getStorage($entity_type); + $storage->delete($storage->loadMultiple()); + } + + // Uninstall all the modules including the Standard profile. + \Drupal::service('module_installer')->uninstall($uninstall); + + // Clean up entity displays before recipe import. + foreach (['entity_form_display', 'entity_view_display'] as $entity_type) { + $storage = \Drupal::entityTypeManager()->getStorage($entity_type); + $storage->delete($storage->loadMultiple()); + } + + // Clean up roles before recipe import. + $storage = \Drupal::entityTypeManager()->getStorage('user_role'); + $roles = $storage->loadMultiple(); + // Do not delete the administrator role. There would be no user with the + // permissions to create content. + unset($roles[RoleInterface::ANONYMOUS_ID], $roles[RoleInterface::AUTHENTICATED_ID], $roles['administrator']); + $storage->delete($roles); + + $this->applyRecipe('core/recipes/standard'); + // These recipes provide functionality that is only optionally part of the + // Standard profile, so we need to explicitly apply them. + $this->applyRecipe('core/recipes/editorial_workflow'); + $this->applyRecipe('core/recipes/audio_media_type'); + $this->applyRecipe('core/recipes/document_media_type'); + $this->applyRecipe('core/recipes/image_media_type'); + $this->applyRecipe('core/recipes/local_video_media_type'); + $this->applyRecipe('core/recipes/remote_video_media_type'); + + // Remove the theme we had to install. + \Drupal::service('theme_installer')->uninstall(['stark']); + + // Add a Home link to the main menu as Standard expects "Main navigation" + // block on the page. + $this->drupalLogin($this->rootUser); + $this->drupalGet('admin/structure/menu/manage/main/add'); + $this->submitForm([ + 'title[0][value]' => 'Home', + 'link[0][uri]' => '<front>', + ], 'Save'); + + // Standard expects to set the contact form's recipient email to the + // system's email address, but our feedback_contact_form recipe hard-codes + // it to another value. + // @todo This can be removed after https://drupal.org/i/3303126, which + // should make it possible for a recipe to reuse an already-set config + // value. + ContactForm::load('feedback')?->setRecipients(['simpletest@example.com']) + ->save(); + + // Update sync directory config to have the same UUIDs so we can compare. + /** @var \Drupal\Core\Config\StorageInterface $sync */ + $sync = \Drupal::service('config.storage.sync'); + /** @var \Drupal\Core\Config\StorageInterface $active */ + $active = \Drupal::service('config.storage'); + // @todo https://www.drupal.org/i/3439749 Determine if the the _core unset + // is correct. + foreach ($active->listAll() as $name) { + /** @var mixed[] $active_data */ + $active_data = $active->read($name); + if ($sync->exists($name)) { + /** @var mixed[] $sync_data */ + $sync_data = $sync->read($name); + if (isset($sync_data['uuid'])) { + $sync_data['uuid'] = $active_data['uuid']; + } + if (isset($sync_data['_core'])) { + unset($sync_data['_core']); + } + /** @var array $sync_data */ + $sync->write($name, $sync_data); + } + if (isset($active_data['_core'])) { + unset($active_data['_core']); + $active->write($name, $active_data); + } + // @todo Remove this once https://drupal.org/i/3427564 lands. + if ($name === 'node.settings') { + unset($active_data['langcode']); + $active->write($name, $active_data); + } + } + + // Ensure we have truly rebuilt the standard profile using recipes. + // Uncomment the code below to see the differences in a single file. + // $this->assertSame($sync->read('node.settings'), $active->read('node.settings')); + $comparer = $this->configImporter()->getStorageComparer(); + $expected_list = $comparer->getEmptyChangelist(); + // We expect core.extension to be different because standard is no longer + // installed. + $expected_list['update'] = ['core.extension']; + $this->assertSame($expected_list, $comparer->getChangelist()); + + // Standard ships two shortcuts; ensure they exist. + $this->assertCount(2, Shortcut::loadMultiple()); + + parent::testStandard(); + } + + /** + * {@inheritdoc} + */ + protected function installResponsiveImage(): void { + // Overrides StandardTest::installResponsiveImage() in order to use the + // recipe. + $this->applyRecipe('core/recipes/standard_responsive_images'); + } + +} diff --git a/core/tests/Drupal/FunctionalTests/DefaultContent/ContentImportTest.php b/core/tests/Drupal/FunctionalTests/DefaultContent/ContentImportTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a4191b29cc115d9ea7d35bfcadcc3ddc75755cf5 --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/DefaultContent/ContentImportTest.php @@ -0,0 +1,263 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\FunctionalTests\DefaultContent; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\block_content\BlockContentInterface; +use Drupal\block_content\Entity\BlockContentType; +use Drupal\Component\Serialization\Yaml; +use Drupal\Core\DefaultContent\Existing; +use Drupal\Core\DefaultContent\Finder; +use Drupal\Core\DefaultContent\Importer; +use Drupal\Core\DefaultContent\ImportException; +use Drupal\Core\DefaultContent\InvalidEntityException; +use Drupal\Core\Entity\EntityRepositoryInterface; +use Drupal\Core\File\FileExists; +use Drupal\Core\Url; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; +use Drupal\file\FileInterface; +use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait; +use Drupal\language\Entity\ConfigurableLanguage; +use Drupal\language\Entity\ContentLanguageSettings; +use Drupal\media\MediaInterface; +use Drupal\menu_link_content\MenuLinkContentInterface; +use Drupal\node\NodeInterface; +use Drupal\taxonomy\TermInterface; +use Drupal\Tests\BrowserTestBase; +use Drupal\Tests\field\Traits\EntityReferenceFieldCreationTrait; +use Drupal\Tests\media\Traits\MediaTypeCreationTrait; +use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait; +use Psr\Log\LogLevel; + +/** + * @covers \Drupal\Core\DefaultContent\Importer + * @group DefaultContent + * @group Recipe + */ +class ContentImportTest extends BrowserTestBase { + + use EntityReferenceFieldCreationTrait; + use MediaTypeCreationTrait; + use RecipeTestTrait; + use TaxonomyTestTrait; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'block_content', + 'content_translation', + 'entity_test', + 'media', + 'menu_link_content', + 'node', + 'path', + 'path_alias', + 'system', + 'taxonomy', + 'user', + ]; + + private readonly string $contentDir; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->setUpCurrentUser(admin: TRUE); + + BlockContentType::create(['id' => 'basic', 'label' => 'Basic'])->save(); + block_content_add_body_field('basic'); + + $this->createVocabulary(['vid' => 'tags']); + $this->createMediaType('image', ['id' => 'image']); + $this->drupalCreateContentType(['type' => 'page']); + $this->drupalCreateContentType(['type' => 'article']); + $this->createEntityReferenceField('node', 'article', 'field_tags', 'Tags', 'taxonomy_term'); + + // Create a field with custom serialization, so we can ensure that the + // importer handles that properly. + $field_storage = FieldStorageConfig::create([ + 'entity_type' => 'taxonomy_term', + 'field_name' => 'field_serialized_stuff', + 'type' => 'serialized_property_item_test', + ]); + $field_storage->save(); + FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => 'tags', + ])->save(); + + ConfigurableLanguage::createFromLangcode('fr')->save(); + ContentLanguageSettings::create([ + 'target_entity_type_id' => 'node', + 'target_bundle' => 'article', + ]) + ->setThirdPartySetting('content_translation', 'enabled', TRUE) + ->save(); + + $this->contentDir = $this->getDrupalRoot() . '/core/tests/fixtures/default_content'; + \Drupal::service('file_system')->copy($this->contentDir . '/file/druplicon_copy.png', $this->publicFilesDirectory . '/druplicon_copy.png', FileExists::Error); + } + + /** + * @return array<array<mixed>> + */ + public static function providerImportEntityThatAlreadyExists(): array { + return [ + [Existing::Error], + [Existing::Skip], + ]; + } + + /** + * @dataProvider providerImportEntityThatAlreadyExists + */ + public function testImportEntityThatAlreadyExists(Existing $existing): void { + $this->drupalCreateUser(values: ['uuid' => '94503467-be7f-406c-9795-fc25baa22203']); + + if ($existing === Existing::Error) { + $this->expectException(ImportException::class); + $this->expectExceptionMessage('user 94503467-be7f-406c-9795-fc25baa22203 already exists.'); + } + + $this->container->get(Importer::class) + ->importContent(new Finder($this->contentDir), $existing); + } + + /** + * Tests importing content directly, via the API. + */ + public function testDirectContentImport(): void { + $logger = new TestLogger(); + + /** @var \Drupal\Core\DefaultContent\Importer $importer */ + $importer = $this->container->get(Importer::class); + $importer->setLogger($logger); + $importer->importContent(new Finder($this->contentDir)); + + $this->assertContentWasImported(); + // We should see a warning about importing a file entity associated with a + // file that doesn't exist. + $predicate = function (array $record): bool { + return ( + $record['message'] === 'File entity %name was imported, but the associated file (@path) was not found.' && + $record['context']['%name'] === 'dce9cdc3-d9fc-4d37-849d-105e913bb5ad.png' && + $record['context']['@path'] === $this->contentDir . '/file/dce9cdc3-d9fc-4d37-849d-105e913bb5ad.png' + ); + }; + $this->assertTrue($logger->hasRecordThatPasses($predicate, LogLevel::WARNING)); + } + + /** + * Tests that the importer validates entities before saving them. + */ + public function testEntityValidationIsTriggered(): void { + $dir = uniqid('public://'); + mkdir($dir); + + /** @var string $data */ + $data = file_get_contents($this->contentDir . '/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml'); + $data = Yaml::decode($data); + /** @var array{default: array{sticky: array<int, array{value: mixed}>}} $data */ + $data['default']['sticky'][0]['value'] = 'not a boolean!'; + file_put_contents($dir . '/invalid.yml', Yaml::encode($data)); + + $this->expectException(InvalidEntityException::class); + $this->expectExceptionMessage("$dir/invalid.yml: sticky.0.value=This value should be of the correct primitive type."); + $this->container->get(Importer::class)->importContent(new Finder($dir)); + } + + /** + * Asserts that the default content was imported as expected. + */ + private function assertContentWasImported(): void { + /** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */ + $entity_repository = $this->container->get(EntityRepositoryInterface::class); + + $node = $entity_repository->loadEntityByUuid('node', 'e1714f23-70c0-4493-8e92-af1901771921'); + $this->assertInstanceOf(NodeInterface::class, $node); + $this->assertSame('Crikey it works!', $node->body->value); + $this->assertSame('article', $node->bundle()); + $this->assertSame('Test Article', $node->label()); + $tag = $node->field_tags->entity; + $this->assertInstanceOf(TermInterface::class, $tag); + $this->assertSame('Default Content', $tag->label()); + $this->assertSame('tags', $tag->bundle()); + $this->assertSame('550f86ad-aa11-4047-953f-636d42889f85', $tag->uuid()); + // The tag carries a field with serialized data, so ensure it came through + // properly. + $this->assertSame('a:2:{i:0;s:2:"Hi";i:1;s:6:"there!";}', $tag->field_serialized_stuff->value); + $this->assertSame('94503467-be7f-406c-9795-fc25baa22203', $node->getOwner()->uuid()); + // The node's URL should use the path alias shipped with the recipe. + $node_url = $node->toUrl()->toString(); + $this->assertSame(Url::fromUserInput('/test-article')->toString(), $node_url); + + $media = $entity_repository->loadEntityByUuid('media', '344b943c-b231-4d73-9669-0b0a2be12aa5'); + $this->assertInstanceOf(MediaInterface::class, $media); + $this->assertSame('image', $media->bundle()); + $this->assertSame('druplicon.png', $media->label()); + $file = $media->field_media_image->entity; + $this->assertInstanceOf(FileInterface::class, $file); + $this->assertSame('druplicon.png', $file->getFilename()); + $this->assertSame('d8404562-efcc-40e3-869e-40132d53fe0b', $file->uuid()); + + // Another file entity referencing an existing file but already in use by + // another entity, should be imported. + $same_file_different_entity = $entity_repository->loadEntityByUuid('file', '23a7f61f-1db3-407d-a6dd-eb4731995c9f'); + $this->assertInstanceOf(FileInterface::class, $same_file_different_entity); + $this->assertSame('druplicon-duplicate.png', $same_file_different_entity->getFilename()); + $this->assertStringEndsWith('/druplicon_0.png', (string) $same_file_different_entity->getFileUri()); + + // Another file entity that references a file with the same name as, but + // different contents than, an existing file, should be imported and the + // file should be renamed. + $different_file = $entity_repository->loadEntityByUuid('file', 'a6b79928-838f-44bd-a8f0-44c2fff9e4cc'); + $this->assertInstanceOf(FileInterface::class, $different_file); + $this->assertSame('druplicon-different.png', $different_file->getFilename()); + $this->assertStringEndsWith('/druplicon_1.png', (string) $different_file->getFileUri()); + + // Another file entity referencing an existing file but one that is not in + // use by another entity, should be imported but use the existing file. + $different_file = $entity_repository->loadEntityByUuid('file', '7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d'); + $this->assertInstanceOf(FileInterface::class, $different_file); + $this->assertSame('druplicon_copy.png', $different_file->getFilename()); + $this->assertStringEndsWith('/druplicon_copy.png', (string) $different_file->getFileUri()); + + // Our node should have a menu link, and it should use the path alias we + // included with the recipe. + $menu_link = $entity_repository->loadEntityByUuid('menu_link_content', '3434bd5a-d2cd-4f26-bf79-a7f6b951a21b'); + $this->assertInstanceOf(MenuLinkContentInterface::class, $menu_link); + $this->assertSame($menu_link->getUrlObject()->toString(), $node_url); + $this->assertSame('main', $menu_link->getMenuName()); + + $block_content = $entity_repository->loadEntityByUuid('block_content', 'd9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf'); + $this->assertInstanceOf(BlockContentInterface::class, $block_content); + $this->assertSame('basic', $block_content->bundle()); + $this->assertSame('Useful Info', $block_content->label()); + $this->assertSame("I'd love to put some useful info here.", $block_content->body->value); + + // A node with a non-existent owner should be reassigned to the current + // user. + $node = $entity_repository->loadEntityByUuid('node', '7f1dd75a-0be2-4d3b-be5d-9d1a868b9267'); + $this->assertInstanceOf(NodeInterface::class, $node); + $this->assertSame(\Drupal::currentUser()->id(), $node->getOwner()->id()); + + // Ensure a node with a translation is imported properly. + $node = $entity_repository->loadEntityByUuid('node', '2d3581c3-92c7-4600-8991-a0d4b3741198'); + $this->assertInstanceOf(NodeInterface::class, $node); + $translation = $node->getTranslation('fr'); + $this->assertSame('Perdu en traduction', $translation->label()); + $this->assertSame("Içi c'est la version français.", $translation->body->value); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php b/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..ec34c1036b738d87a44df79d85d769b316e2fa44 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php @@ -0,0 +1,324 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Config\Action; + +// cspell:ignore inflector +use Drupal\Component\Plugin\Exception\PluginNotFoundException; +use Drupal\Component\Uuid\Uuid; +use Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedMethodName; +use Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedOtherMethodName; +use Drupal\Core\Config\Action\ConfigActionException; +use Drupal\Core\Config\Action\DuplicateConfigActionIdException; +use Drupal\Core\Config\Action\EntityMethodException; +use Drupal\KernelTests\KernelTestBase; + +/** + * Tests the config action system. + * + * @group config + */ +class ConfigActionTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['config_test']; + + /** + * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityCreate + */ + public function testEntityCreate(): void { + $this->assertCount(0, \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(), 'There are no config_test entities'); + /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ + $manager = $this->container->get('plugin.manager.config_action'); + $manager->applyAction('entity_create:ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test']); + /** @var \Drupal\config_test\Entity\ConfigTest[] $config_test_entities */ + $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(); + $this->assertCount(1, \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(), 'There is 1 config_test entity'); + $this->assertSame('Action test', $config_test_entities['action_test']->label()); + $this->assertTrue(Uuid::isValid((string) $config_test_entities['action_test']->uuid()), 'Config entity assigned a valid UUID'); + + // Calling ensure exists action again will not error. + $manager->applyAction('entity_create:ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test']); + + try { + $manager->applyAction('entity_create:create', 'config_test.dynamic.action_test', ['label' => 'Action test']); + $this->fail('Expected exception not thrown'); + } + catch (ConfigActionException $e) { + $this->assertSame('Entity config_test.dynamic.action_test exists', $e->getMessage()); + } + } + + /** + * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod + */ + public function testEntityMethod(): void { + $this->installConfig('config_test'); + $storage = \Drupal::entityTypeManager()->getStorage('config_test'); + + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame('Default', $config_test_entity->getProtectedProperty()); + + /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ + $manager = $this->container->get('plugin.manager.config_action'); + // Call a method action. + $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value'); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame('Test value', $config_test_entity->getProtectedProperty()); + + $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value 2'); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame('Test value 2', $config_test_entity->getProtectedProperty()); + + $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperty', 'config_test.dynamic.dotted.default', ['Test value ', '3']); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame('Test value 3', $config_test_entity->getProtectedProperty()); + + $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', ['Test value ', '4']); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame('Test value 4', $config_test_entity->getProtectedProperty()); + + // Test calling an action that has 2 arguments but one is optional with an + // array value. + $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', ['Test value 5']); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame('Test value 5', $config_test_entity->getProtectedProperty()); + + // Test calling an action that has 2 arguments but one is optional with a + // non array value. + $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', 'Test value 6'); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame('Test value 6', $config_test_entity->getProtectedProperty()); + + // Test calling an action that expects no arguments. + $manager->applyAction('entity_method:config_test.dynamic:defaultProtectedProperty', 'config_test.dynamic.dotted.default', []); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame('Set by method', $config_test_entity->getProtectedProperty()); + + $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', 'foo'); + $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', 'bar'); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame(['foo', 'bar'], $config_test_entity->getArrayProperty()); + + $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', ['a', 'b', 'c']); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame(['foo', 'bar', ['a', 'b', 'c']], $config_test_entity->getArrayProperty()); + + $manager->applyAction('entity_method:config_test.dynamic:setArray', 'config_test.dynamic.dotted.default', ['a', 'b', 'c']); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame(['a', 'b', 'c'], $config_test_entity->getArrayProperty()); + + $manager->applyAction('entity_method:config_test.dynamic:setArray', 'config_test.dynamic.dotted.default', [['a', 'b', 'c'], ['a']]); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame([['a', 'b', 'c'], ['a']], $config_test_entity->getArrayProperty()); + + $config_test_entity->delete(); + try { + $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value'); + $this->fail('Expected exception not thrown'); + } + catch (ConfigActionException $e) { + $this->assertSame('Entity config_test.dynamic.dotted.default does not exist', $e->getMessage()); + } + + // Test custom and default admin labels. + $this->assertSame('Test configuration append', (string) $manager->getDefinition('entity_method:config_test.dynamic:append')['admin_label']); + $this->assertSame('Set default name', (string) $manager->getDefinition('entity_method:config_test.dynamic:defaultProtectedProperty')['admin_label']); + } + + /** + * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod + */ + public function testPluralizedEntityMethod(): void { + $this->installConfig('config_test'); + $storage = \Drupal::entityTypeManager()->getStorage('config_test'); + + /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ + $manager = $this->container->get('plugin.manager.config_action'); + // Call a pluralized method action. + $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', ['a', 'b', 'c', 'd']); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame(['a', 'b', 'c', 'd'], $config_test_entity->getArrayProperty()); + + $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', [['foo'], 'bar']); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame(['a', 'b', 'c', 'd', ['foo'], 'bar'], $config_test_entity->getArrayProperty()); + + $config_test_entity->setProtectedProperty('')->save(); + $manager->applyAction('entity_method:config_test.dynamic:appends', 'config_test.dynamic.dotted.default', ['1', '2', '3']); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame('123', $config_test_entity->getProtectedProperty()); + + // Test that the inflector converts to a good plural form. + $config_test_entity->setProtectedProperty('')->save(); + $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperties', 'config_test.dynamic.dotted.default', [['1', '2'], ['3', '4']]); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('dotted.default'); + $this->assertSame('34', $config_test_entity->getProtectedProperty()); + + $this->assertTrue($manager->hasDefinition('entity_method:config_test.dynamic:setProtectedProperty'), 'The setProtectedProperty action exists'); + // cspell:ignore Propertys + $this->assertFalse($manager->hasDefinition('entity_method:config_test.dynamic:setProtectedPropertys'), 'There is no automatically pluralized version of the setProtectedProperty action'); + + // Admin label for pluralized form. + $this->assertSame('Test configuration append (multiple calls)', (string) $manager->getDefinition('entity_method:config_test.dynamic:appends')['admin_label']); + } + + /** + * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod + */ + public function testPluralizedEntityMethodException(): void { + $this->installConfig('config_test'); + /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ + $manager = $this->container->get('plugin.manager.config_action'); + $this->expectException(EntityMethodException::class); + $this->expectExceptionMessage('The pluralized entity method config action \'entity_method:config_test.dynamic:addToArrayMultipleTimes\' requires an array value in order to call Drupal\config_test\Entity\ConfigTest::addToArray() multiple times'); + $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', 'Test value'); + } + + /** + * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver + */ + public function testDuplicatePluralizedMethodNameException(): void { + \Drupal::state()->set('config_test.class_override', DuplicatePluralizedMethodName::class); + \Drupal::entityTypeManager()->clearCachedDefinitions(); + $this->installConfig('config_test'); + /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ + $manager = $this->container->get('plugin.manager.config_action'); + $this->expectException(EntityMethodException::class); + $this->expectExceptionMessage('Duplicate action can not be created for ID \'config_test.dynamic:testMethod\' for Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedMethodName::testMethod(). The existing action is for the ::testMethod() method'); + $manager->getDefinitions(); + } + + /** + * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver + */ + public function testDuplicatePluralizedOtherMethodNameException(): void { + \Drupal::state()->set('config_test.class_override', DuplicatePluralizedOtherMethodName::class); + \Drupal::entityTypeManager()->clearCachedDefinitions(); + $this->installConfig('config_test'); + /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ + $manager = $this->container->get('plugin.manager.config_action'); + $this->expectException(EntityMethodException::class); + $this->expectExceptionMessage('Duplicate action can not be created for ID \'config_test.dynamic:testMethod2\' for Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedOtherMethodName::testMethod2(). The existing action is for the ::testMethod() method'); + $manager->getDefinitions(); + } + + /** + * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod + */ + public function testEntityMethodException(): void { + $this->installConfig('config_test'); + /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ + $manager = $this->container->get('plugin.manager.config_action'); + $this->expectException(EntityMethodException::class); + $this->expectExceptionMessage('Entity method config action \'entity_method:config_test.dynamic:concatProtectedProperty\' requires an array value. The number of parameters or required parameters for Drupal\config_test\Entity\ConfigTest::concatProtectedProperty() is not 1'); + $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value'); + } + + /** + * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\SimpleConfigUpdate + */ + public function testSimpleConfigUpdate(): void { + $this->installConfig('config_test'); + $this->assertSame('bar', $this->config('config_test.system')->get('foo')); + + /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ + $manager = $this->container->get('plugin.manager.config_action'); + // Call the simple config update action. + $manager->applyAction('simple_config_update', 'config_test.system', ['foo' => 'Yay!']); + $this->assertSame('Yay!', $this->config('config_test.system')->get('foo')); + + try { + $manager->applyAction('simple_config_update', 'config_test.system', 'Test'); + $this->fail('Expected exception not thrown'); + } + catch (ConfigActionException $e) { + $this->assertSame('Config config_test.system can not be updated because $value is not an array', $e->getMessage()); + } + + $this->config('config_test.system')->delete(); + try { + $manager->applyAction('simple_config_update', 'config_test.system', ['foo' => 'Yay!']); + $this->fail('Expected exception not thrown'); + } + catch (ConfigActionException $e) { + $this->assertSame('Config config_test.system does not exist so can not be updated', $e->getMessage()); + } + } + + /** + * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType() + */ + public function testShorthandActionIds(): void { + $storage = \Drupal::entityTypeManager()->getStorage('config_test'); + $this->assertCount(0, $storage->loadMultiple(), 'There are no config_test entities'); + /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ + $manager = $this->container->get('plugin.manager.config_action'); + $manager->applyAction('ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test', 'protected_property' => '']); + /** @var \Drupal\config_test\Entity\ConfigTest[] $config_test_entities */ + $config_test_entities = $storage->loadMultiple(); + $this->assertCount(1, $config_test_entities, 'There is 1 config_test entity'); + $this->assertSame('Action test', $config_test_entities['action_test']->label()); + + $this->assertSame('', $config_test_entities['action_test']->getProtectedProperty()); + + /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ + $manager = $this->container->get('plugin.manager.config_action'); + // Call a method action. + $manager->applyAction('setProtectedProperty', 'config_test.dynamic.action_test', 'Test value'); + /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */ + $config_test_entity = $storage->load('action_test'); + $this->assertSame('Test value', $config_test_entity->getProtectedProperty()); + } + + /** + * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType() + */ + public function testDuplicateShorthandActionIds(): void { + $this->enableModules(['config_action_duplicate_test']); + /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */ + $manager = $this->container->get('plugin.manager.config_action'); + $this->expectException(DuplicateConfigActionIdException::class); + $this->expectExceptionMessage("The plugins 'entity_method:config_test.dynamic:setProtectedProperty' and 'config_action_duplicate_test:config_test.dynamic:setProtectedProperty' both resolve to the same shorthand action ID for the 'config_test' entity type"); + $manager->applyAction('ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test', 'protected_property' => '']); + } + + /** + * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType() + */ + public function testParentAttributes(): void { + $definitions = $this->container->get('plugin.manager.config_action')->getDefinitions(); + // The \Drupal\config_test\Entity\ConfigQueryTest::concatProtectedProperty() + // does not have an attribute but the parent does so this is discovered. + $this->assertArrayHasKey('entity_method:config_test.query:concatProtectedProperty', $definitions); + } + + /** + * @see \Drupal\Core\Config\Action\ConfigActionManager + */ + public function testMissingAction(): void { + $this->expectException(PluginNotFoundException::class); + $this->expectExceptionMessageMatches('/^The "does_not_exist" plugin does not exist/'); + $this->container->get('plugin.manager.config_action')->applyAction('does_not_exist', 'config_test.system', ['foo' => 'Yay!']); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Config/Storage/Checkpoint/CheckpointStorageTest.php b/core/tests/Drupal/KernelTests/Core/Config/Storage/Checkpoint/CheckpointStorageTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9032d234bce6be894ca04d4a33c9420150fd77cd --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Config/Storage/Checkpoint/CheckpointStorageTest.php @@ -0,0 +1,310 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Config\Storage\Checkpoint; + +use Drupal\Core\Config\Checkpoint\CheckpointStorageInterface; +use Drupal\Core\Config\ConfigImporter; +use Drupal\Core\Config\StorageComparer; +use Drupal\KernelTests\KernelTestBase; + +/** + * Tests CheckpointStorage operations. + * + * @group config + */ +class CheckpointStorageTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['system', 'config_test']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installConfig(['system', 'config_test']); + } + + public function testConfigSaveAndRead(): void { + $checkpoint_storage = $this->container->get('config.storage.checkpoint'); + + $this->config('system.site')->set('name', 'Test1')->save(); + $check1 = $checkpoint_storage->checkpoint('A'); + $this->config('system.site')->set('name', 'Test2')->save(); + $check2 = $checkpoint_storage->checkpoint('B'); + $this->config('system.site')->set('name', 'Test3')->save(); + + $this->assertSame('Test3', $this->config('system.site')->get('name')); + $this->assertSame('Test1', $checkpoint_storage->read('system.site')['name']); + + // The config listings should be exactly the same. + $this->assertSame($checkpoint_storage->listAll(), $this->container->get('config.storage')->listAll()); + + $checkpoint_storage->setCheckpointToReadFrom($check2); + $this->assertSame('Test2', $checkpoint_storage->read('system.site')['name']); + $this->assertSame($checkpoint_storage->listAll(), $this->container->get('config.storage')->listAll()); + + $checkpoint_storage->setCheckpointToReadFrom($check1); + $this->assertSame('Test1', $checkpoint_storage->read('system.site')['name']); + $this->assertSame($checkpoint_storage->listAll(), $this->container->get('config.storage')->listAll()); + } + + public function testConfigDelete(): void { + $checkpoint_storage = $this->container->get('config.storage.checkpoint'); + + $check1 = $checkpoint_storage->checkpoint('A'); + $this->config('config_test.system')->delete(); + + $this->assertFalse($this->container->get('config.storage')->exists('config_test.system')); + $this->assertTrue($checkpoint_storage->exists('config_test.system')); + $this->assertSame('bar', $checkpoint_storage->read('config_test.system')['foo']); + + $this->assertContains('config_test.system', $checkpoint_storage->listAll()); + $this->assertContains('config_test.system', $checkpoint_storage->listAll('config_test.')); + $this->assertNotContains('config_test.system', $checkpoint_storage->listAll('system.')); + // Should not be part of the active storage anymore. + $this->assertNotContains('config_test.system', $this->container->get('config.storage')->listAll()); + + $check2 = $checkpoint_storage->checkpoint('B'); + + $this->config('config_test.system')->set('foo', 'foobar')->save(); + $this->assertTrue($this->container->get('config.storage')->exists('config_test.system')); + $this->assertTrue($checkpoint_storage->exists('config_test.system')); + $this->assertSame('bar', $checkpoint_storage->read('config_test.system')['foo']); + + $checkpoint_storage->setCheckpointToReadFrom($check2); + $this->assertFalse($checkpoint_storage->exists('config_test.system')); + $this->assertFalse($checkpoint_storage->read('config_test.system')); + $this->assertNotContains('config_test.system', $checkpoint_storage->listAll()); + + $checkpoint_storage->setCheckpointToReadFrom($check1); + $this->assertTrue($checkpoint_storage->exists('config_test.system')); + $this->assertSame('bar', $checkpoint_storage->read('config_test.system')['foo']); + $this->assertContains('config_test.system', $checkpoint_storage->listAll()); + } + + public function testConfigCreate(): void { + $checkpoint_storage = $this->container->get('config.storage.checkpoint'); + + $this->config('config_test.system')->delete(); + $check1 = $checkpoint_storage->checkpoint('A'); + $this->config('config_test.system')->set('foo', 'foobar')->save(); + + $this->assertTrue($this->container->get('config.storage')->exists('config_test.system')); + $this->assertFalse($checkpoint_storage->exists('config_test.system')); + $this->assertFalse($checkpoint_storage->read('config_test.system')); + + $this->assertNotContains('config_test.system', $checkpoint_storage->listAll()); + $this->assertNotContains('config_test.system', $checkpoint_storage->listAll('config_test.')); + $this->assertContains('system.site', $checkpoint_storage->listAll('system.')); + $this->assertContains('config_test.system', $this->container->get('config.storage')->listAll()); + + $check2 = $checkpoint_storage->checkpoint('B'); + $this->config('config_test.system')->delete(); + + $this->assertFalse($this->container->get('config.storage')->exists('config_test.system')); + $this->assertFalse($checkpoint_storage->exists('config_test.system')); + $this->assertFalse($checkpoint_storage->read('config_test.system')); + + $this->config('config_test.system')->set('foo', 'foobar')->save(); + $this->assertTrue($this->container->get('config.storage')->exists('config_test.system')); + $this->assertFalse($checkpoint_storage->exists('config_test.system')); + $this->assertFalse($checkpoint_storage->read('config_test.system')); + + $checkpoint_storage->setCheckpointToReadFrom($check2); + $this->assertTrue($checkpoint_storage->exists('config_test.system')); + $this->assertSame('foobar', $checkpoint_storage->read('config_test.system')['foo']); + $this->assertContains('config_test.system', $checkpoint_storage->listAll()); + + $checkpoint_storage->setCheckpointToReadFrom($check1); + $this->assertFalse($checkpoint_storage->exists('config_test.system')); + $this->assertFalse($checkpoint_storage->read('config_test.system')); + $this->assertNotContains('config_test.system', $checkpoint_storage->listAll()); + } + + public function testConfigRename(): void { + $checkpoint_storage = $this->container->get('config.storage.checkpoint'); + $check1 = $checkpoint_storage->checkpoint('A'); + $this->container->get('config.factory')->rename('config_test.dynamic.dotted.default', 'config_test.dynamic.renamed'); + $this->config('config_test.dynamic.renamed')->set('id', 'renamed')->save(); + + $this->assertFalse($checkpoint_storage->exists('config_test.dynamic.renamed')); + $this->assertTrue($checkpoint_storage->exists('config_test.dynamic.dotted.default')); + $this->assertSame('dotted.default', $checkpoint_storage->read('config_test.dynamic.dotted.default')['id']); + $this->assertSame($checkpoint_storage->read('config_test.dynamic.dotted.default')['uuid'], $this->config('config_test.dynamic.renamed')->get('uuid')); + + $check2 = $checkpoint_storage->checkpoint('B'); + /** @var \Drupal\Core\Config\Entity\ConfigEntityStorage $storage */ + $storage = $this->container->get('entity_type.manager')->getStorage('config_test'); + // Entity1 will be deleted by the test. + $entity1 = $storage->create( + [ + 'id' => 'dotted.default', + 'label' => 'Another one', + ] + ); + $entity1->save(); + + $check3 = $checkpoint_storage->checkpoint('C'); + + $checkpoint_storage->setCheckpointToReadFrom($check2); + $this->assertFalse($checkpoint_storage->exists('config_test.dynamic.dotted.default')); + + $checkpoint_storage->setCheckpointToReadFrom($check3); + $this->assertTrue($checkpoint_storage->exists('config_test.dynamic.dotted.default')); + $this->assertNotEquals($checkpoint_storage->read('config_test.dynamic.dotted.default')['uuid'], $this->config('config_test.dynamic.renamed')->get('uuid')); + $this->assertSame('Another one', $checkpoint_storage->read('config_test.dynamic.dotted.default')['label']); + + $checkpoint_storage->setCheckpointToReadFrom($check1); + $this->assertSame('Default', $checkpoint_storage->read('config_test.dynamic.dotted.default')['label']); + } + + public function testRevert(): void { + $checkpoint_storage = $this->container->get('config.storage.checkpoint'); + $check1 = $checkpoint_storage->checkpoint('A'); + $this->assertTrue($this->container->get('module_installer')->uninstall(['config_test'])); + $checkpoint_storage = $this->container->get('config.storage.checkpoint'); + $check2 = $checkpoint_storage->checkpoint('B'); + + $importer = $this->getConfigImporter($checkpoint_storage); + $config_changelist = $importer->getStorageComparer()->createChangelist()->getChangelist(); + $this->assertContains('config_test.dynamic.dotted.default', $config_changelist['create']); + $this->assertSame(['core.extension'], $config_changelist['update']); + $this->assertSame([], $config_changelist['delete']); + $this->assertSame([], $config_changelist['rename']); + + $importer->import(); + $this->assertSame([], $importer->getErrors()); + + $this->assertTrue($this->container->get('module_handler')->moduleExists('config_test')); + + $checkpoint_storage = $this->container->get('config.storage.checkpoint'); + $checkpoint_storage->setCheckpointToReadFrom($check2); + + $importer = $this->getConfigImporter($checkpoint_storage); + $config_changelist = $importer->getStorageComparer()->createChangelist()->getChangelist(); + $this->assertContains('config_test.dynamic.dotted.default', $config_changelist['delete']); + $this->assertSame(['core.extension'], $config_changelist['update']); + $this->assertSame([], $config_changelist['create']); + $this->assertSame([], $config_changelist['rename']); + $importer->import(); + $this->assertFalse($this->container->get('module_handler')->moduleExists('config_test')); + + $checkpoint_storage->setCheckpointToReadFrom($check1); + $importer = $this->getConfigImporter($checkpoint_storage); + $importer->getStorageComparer()->createChangelist(); + $importer->import(); + $this->assertTrue($this->container->get('module_handler')->moduleExists('config_test')); + } + + public function testRevertWithCollections(): void { + $collections = [ + 'another_collection', + 'collection.test1', + 'collection.test2', + ]; + // Set the event listener to return three possible collections. + // @see \Drupal\config_collection_install_test\EventSubscriber + \Drupal::state()->set('config_collection_install_test.collection_names', $collections); + + $checkpoint_storage = $this->container->get('config.storage.checkpoint'); + $checkpoint_storage->checkpoint('A'); + + // Install the test module. + $this->assertTrue($this->container->get('module_installer')->install(['config_collection_install_test'])); + $checkpoint_storage = $this->container->get('config.storage.checkpoint'); + + /** @var \Drupal\Core\Config\StorageInterface $active_storage */ + $active_storage = \Drupal::service('config.storage'); + $this->assertEquals($collections, $active_storage->getAllCollectionNames()); + foreach ($collections as $collection) { + $collection_storage = $active_storage->createCollection($collection); + $data = $collection_storage->read('config_collection_install_test.test'); + $this->assertEquals($collection, $data['collection']); + } + + $check2 = $checkpoint_storage->checkpoint('B'); + + $importer = $this->getConfigImporter($checkpoint_storage); + $storage_comparer = $importer->getStorageComparer(); + $config_changelist = $storage_comparer->createChangelist()->getChangelist(); + $this->assertSame([], $config_changelist['create']); + $this->assertSame(['core.extension'], $config_changelist['update']); + $this->assertSame([], $config_changelist['delete']); + $this->assertSame([], $config_changelist['rename']); + foreach ($collections as $collection) { + $config_changelist = $storage_comparer->getChangelist(NULL, $collection); + $this->assertSame([], $config_changelist['create']); + $this->assertSame([], $config_changelist['update']); + $this->assertSame(['config_collection_install_test.test'], $config_changelist['delete'], $collection); + $this->assertSame([], $config_changelist['rename']); + } + + $importer->import(); + $this->assertSame([], $importer->getErrors()); + + $checkpoint_storage = $this->container->get('config.storage.checkpoint'); + /** @var \Drupal\Core\Config\StorageInterface $active_storage */ + $active_storage = \Drupal::service('config.storage'); + $this->assertEmpty($active_storage->getAllCollectionNames()); + foreach ($collections as $collection) { + $collection_storage = $active_storage->createCollection($collection); + $this->assertFalse($collection_storage->read('config_collection_install_test.test')); + } + + $checkpoint_storage->setCheckpointToReadFrom($check2); + + $importer = $this->getConfigImporter($checkpoint_storage); + + $storage_comparer = $importer->getStorageComparer(); + $config_changelist = $storage_comparer->createChangelist()->getChangelist(); + $this->assertSame([], $config_changelist['create']); + $this->assertSame(['core.extension'], $config_changelist['update']); + $this->assertSame([], $config_changelist['delete']); + $this->assertSame([], $config_changelist['rename']); + foreach ($collections as $collection) { + $config_changelist = $storage_comparer->getChangelist(NULL, $collection); + $this->assertSame(['config_collection_install_test.test'], $config_changelist['create']); + $this->assertSame([], $config_changelist['update']); + $this->assertSame([], $config_changelist['delete'], $collection); + $this->assertSame([], $config_changelist['rename']); + } + $importer->import(); + $this->assertSame([], $importer->getErrors()); + + $this->assertTrue($this->container->get('module_handler')->moduleExists('config_collection_install_test')); + /** @var \Drupal\Core\Config\StorageInterface $active_storage */ + $active_storage = \Drupal::service('config.storage'); + $this->assertEquals($collections, $active_storage->getAllCollectionNames()); + foreach ($collections as $collection) { + $collection_storage = $active_storage->createCollection($collection); + $data = $collection_storage->read('config_collection_install_test.test'); + $this->assertEquals($collection, $data['collection']); + } + } + + private function getConfigImporter(CheckpointStorageInterface $storage): ConfigImporter { + $storage_comparer = new StorageComparer( + $storage, + $this->container->get('config.storage') + ); + return new ConfigImporter( + $storage_comparer, + $this->container->get('event_dispatcher'), + $this->container->get('config.manager'), + $this->container->get('lock'), + $this->container->get('config.typed'), + $this->container->get('module_handler'), + $this->container->get('module_installer'), + $this->container->get('theme_handler'), + $this->container->get('string_translation'), + $this->container->get('extension.list.module'), + $this->container->get('extension.list.theme') + ); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/DefaultContent/AdminAccountSwitcherTest.php b/core/tests/Drupal/KernelTests/Core/DefaultContent/AdminAccountSwitcherTest.php new file mode 100644 index 0000000000000000000000000000000000000000..700fc7d43791dbea02b51051ab4fff420e51d7d4 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/DefaultContent/AdminAccountSwitcherTest.php @@ -0,0 +1,97 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\DefaultContent; + +use Drupal\Core\Access\AccessException; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\DefaultContent\AdminAccountSwitcher; +use Drupal\Core\Session\AccountSwitcherInterface; +use Drupal\KernelTests\KernelTestBase; +use Drupal\Tests\user\Traits\UserCreationTrait; + +/** + * @covers \Drupal\Core\DefaultContent\AdminAccountSwitcher + * @group DefaultContent + */ +class AdminAccountSwitcherTest extends KernelTestBase { + + use UserCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = ['system', 'user']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installEntitySchema('user'); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + $container->getDefinition(AdminAccountSwitcher::class)->setPublic(TRUE); + } + + /** + * Tests switching to a user with an administrative role. + */ + public function testSwitchToAdministrator(): void { + /** @var \Drupal\Core\Session\AccountInterface $account */ + $account = $this->createUser(admin: TRUE); + + $this->assertSame($account->id(), $this->container->get(AdminAccountSwitcher::class)->switchToAdministrator()->id()); + $this->assertSame($account->id(), $this->container->get('current_user')->id()); + } + + /** + * Tests that there is an error if there are no administrative users. + */ + public function testNoAdministratorsExist(): void { + /** @var \Drupal\Core\Session\AccountInterface $account */ + $account = $this->createUser(); + $this->assertSame(1, (int) $account->id()); + + $this->expectException(AccessException::class); + $this->expectExceptionMessage("There are no user accounts with administrative roles."); + $this->container->get(AdminAccountSwitcher::class)->switchToAdministrator(); + } + + /** + * Tests switching to user 1 when the superuser access policy is enabled. + */ + public function testSuperUser(): void { + /** @var \Drupal\Core\Session\AccountInterface $account */ + $account = $this->createUser(); + $this->assertSame(1, (int) $account->id()); + + $switcher = new AdminAccountSwitcher( + $this->container->get(AccountSwitcherInterface::class), + $this->container->get(EntityTypeManagerInterface::class), + TRUE, + ); + $this->assertSame(1, (int) $switcher->switchToAdministrator()->id()); + } + + public function testSwitchToAndSwitchBack(): void { + $this->assertTrue($this->container->get('current_user')->isAnonymous()); + + /** @var \Drupal\Core\Session\AccountInterface $account */ + $account = $this->createUser(); + $switcher = $this->container->get(AdminAccountSwitcher::class); + $this->assertSame($switcher, $switcher->switchTo($account)); + $this->assertSame($account->id(), $this->container->get('current_user')->id()); + + $this->assertSame($switcher, $switcher->switchBack()); + $this->assertTrue($this->container->get('current_user')->isAnonymous()); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/ConfigActionValidationTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigActionValidationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..eef3ba5f8a6051ed50cb781b8b0cd816932fe030 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigActionValidationTest.php @@ -0,0 +1,137 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Recipe; + +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Recipe\InvalidConfigException; +use Drupal\Core\Recipe\Recipe; +use Drupal\Core\Recipe\RecipeFileException; +use Drupal\Core\Recipe\RecipeRunner; +use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait; +use Drupal\KernelTests\KernelTestBase; + +/** + * @group Recipe + */ +class ConfigActionValidationTest extends KernelTestBase { + + use RecipeTestTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'block_content', + 'link', + 'node', + 'shortcut', + 'system', + ]; + + /** + * {@inheritdoc} + * + * This test requires that we save invalid config, so we can test that it gets + * validated after applying a recipe. + */ + protected $strictConfigSchema = FALSE; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installConfig('shortcut'); + $this->installEntitySchema('shortcut'); + } + + /** + * @testWith ["block_content_type"] + * ["node_type"] + * ["shortcut_set"] + * ["menu"] + */ + public function testConfigActionsAreValidated(string $entity_type_id): void { + /** @var \Drupal\Core\Config\Entity\ConfigEntityStorageInterface $storage */ + $storage = $this->container->get(EntityTypeManagerInterface::class) + ->getStorage($entity_type_id); + + /** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */ + $entity_type = $storage->getEntityType(); + // If there is a label key, it's safe to assume that it's not allowed to be + // empty. We don't care whether it's immutable; we just care that the value + // the config action sets it to (an empty string) violates config schema. + $label_key = $entity_type->getKey('label'); + $this->assertNotEmpty($label_key); + $entity = $storage->create([ + $entity_type->getKey('id') => 'test', + $label_key => 'Test', + ]); + $entity->save(); + + $config_name = $entity->getConfigDependencyName(); + $recipe_data = <<<YAML +name: Config actions making bad decisions +config: + actions: + $config_name: + simple_config_update: + $label_key: '' +YAML; + + $recipe = $this->createRecipe($recipe_data); + try { + RecipeRunner::processRecipe($recipe); + $this->fail('An exception should have been thrown.'); + } + catch (InvalidConfigException $e) { + $this->assertCount(1, $e->violations); + $violation = $e->violations->get(0); + $this->assertSame($label_key, $violation->getPropertyPath()); + $this->assertSame("This value should not be blank.", (string) $violation->getMessage()); + } + } + + /** + * Tests validating that config actions' dependencies are present. + * + * Tests that the all of the config listed in a recipe's config actions are + * provided by extensions that will be installed by the recipe, or one of its + * dependencies (no matter how deeply nested). + * + * @testWith ["direct_dependency"] + * ["indirect_dependency_one_level_down"] + * ["indirect_dependency_two_levels_down"] + */ + public function testConfigActionDependenciesAreValidated(string $name): void { + Recipe::createFromDirectory("core/tests/fixtures/recipes/config_actions_dependency_validation/$name"); + } + + /** + * Tests config action validation for missing dependency. + */ + public function testConfigActionMissingDependency(): void { + $recipe_data = <<<YAML +name: Config actions making bad decisions +config: + actions: + random.config: + simple_config_update: + label: '' +YAML; + + try { + $this->createRecipe($recipe_data); + $this->fail('An exception should have been thrown.'); + } + catch (RecipeFileException $e) { + $this->assertIsObject($e->violations); + $this->assertCount(1, $e->violations); + $this->assertSame('[config][actions][random.config]', $e->violations[0]->getPropertyPath()); + $this->assertSame("Config actions cannot be applied to random.config because the random extension is not installed, and is not installed by this recipe or any of the recipes it depends on.", (string) $e->violations[0]->getMessage()); + } + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/ConfigConfiguratorTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigConfiguratorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b91ea75b37b42a96d205b286a732089d3acf6def --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigConfiguratorTest.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Recipe; + +use Drupal\Component\Serialization\Yaml; +use Drupal\Core\Recipe\Recipe; +use Drupal\KernelTests\KernelTestBase; + +/** + * @covers \Drupal\Core\Recipe\ConfigConfigurator + * @group Recipe + */ +class ConfigConfiguratorTest extends KernelTestBase { + + public function testExistingConfigWithKeysInDifferentOrder(): void { + $recipe_dir = uniqid('public://recipe_test_'); + mkdir($recipe_dir . '/config', recursive: TRUE); + + $this->enableModules(['system']); + $this->installConfig('system'); + /** @var mixed[][] $original_data */ + $original_data = $this->config('system.site')->get(); + // Remove keys that are ignored during the comparison. + unset($original_data['uuid'], $original_data['_core']); + $recipe_data = $original_data; + // Reorder an inner array, to ensure keys are sorted recursively. + $recipe_data['page'] = array_reverse($original_data['page'], TRUE); + $this->assertNotSame($original_data, $recipe_data); + file_put_contents($recipe_dir . '/config/system.site.yml', Yaml::encode($recipe_data)); + + $recipe = [ + 'name' => 'Same config, different order', + 'type' => 'Testing', + ]; + file_put_contents($recipe_dir . '/recipe.yml', Yaml::encode($recipe)); + + // If there was a conflict with the pre-existing config, ConfigConfigurator + // would throw an exception and the recipe would not be created. So all we + // need to do here is assert that, in fact, we were able to create a recipe + // object. + $this->assertInstanceOf(Recipe::class, Recipe::createFromDirectory($recipe_dir)); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/ConfigValidationTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigValidationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0d6119703c913f3b6558d5225e5e86c47a11f2e8 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigValidationTest.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Recipe; + +use Drupal\Component\Serialization\Yaml; +use Drupal\Core\Recipe\InvalidConfigException; +use Drupal\Core\Recipe\Recipe; +use Drupal\Core\Recipe\RecipeRunner; +use Drupal\KernelTests\KernelTestBase; + +/** + * @group Recipe + */ +class ConfigValidationTest extends KernelTestBase { + + /** + * {@inheritdoc} + * + * This test depends on us being able to create invalid config, so we can + * ensure that validatable config is validated by the recipe runner. + */ + protected $strictConfigSchema = FALSE; + + /** + * Creates a recipe with invalid config data in a particular file. + * + * @param string $file + * The name of the file (in the recipe's `config` directory) which should + * have invalid data. + * + * @return \Drupal\Core\Recipe\Recipe + * A wrapper around the created recipe. + */ + private function createRecipeWithInvalidDataInFile(string $file): Recipe { + $dir = uniqid('public://'); + mkdir($dir . '/config', recursive: TRUE); + + $data = file_get_contents($this->getDrupalRoot() . '/core/modules/config/tests/config_test/config/install/config_test.types.yml'); + assert(is_string($data)); + $data = Yaml::decode($data); + // The `array` key needs to be an array, not an integer. If the config is + // validated, this will raise a validation error. + /** @var mixed[] $data */ + $data['array'] = 39; + file_put_contents($dir . '/config/' . $file, Yaml::encode($data)); + + $recipe = <<<YAML +name: Config validation test +install: + - config_test +YAML; + file_put_contents($dir . '/recipe.yml', $recipe); + + return Recipe::createFromDirectory($dir); + } + + /** + * Tests that the recipe runner only validates config which is validatable. + */ + public function testValidatableConfigIsValidated(): void { + // Since config_test.types is not validatable, there should not be a + // validation error. + $recipe = $this->createRecipeWithInvalidDataInFile('config_test.types.yml'); + RecipeRunner::processRecipe($recipe); + $this->assertFalse($this->config('config_test.types')->isNew()); + + // If we create a config object which IS fully validatable, and has invalid + // data, we should get a validation error. + $recipe = $this->createRecipeWithInvalidDataInFile('config_test.types.fully_validatable.yml'); + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('There were validation errors in config_test.types.fully_validatable'); + RecipeRunner::processRecipe($recipe); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/EntityMethodConfigActionsTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/EntityMethodConfigActionsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5590fe0e75ad6a020c81d0601000bceee2b43053 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Recipe/EntityMethodConfigActionsTest.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Recipe; + +use Drupal\Core\Entity\EntityDisplayRepositoryInterface; +use Drupal\Core\Recipe\RecipeRunner; +use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait; +use Drupal\KernelTests\KernelTestBase; +use Drupal\Tests\node\Traits\ContentTypeCreationTrait; + +/** + * @group Recipe + */ +class EntityMethodConfigActionsTest extends KernelTestBase { + + use ContentTypeCreationTrait; + use RecipeTestTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'field', + 'layout_builder', + 'layout_discovery', + 'node', + 'system', + 'text', + 'user', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installConfig('node'); + $this->createContentType(['type' => 'test']); + + $this->container->get(EntityDisplayRepositoryInterface::class) + ->getViewDisplay('node', 'test', 'full') + ->save(); + } + + public function testSetSingleThirdPartySetting(): void { + $recipe = <<<YAML +name: Third-party setting +config: + actions: + core.entity_view_display.node.test.full: + setThirdPartySetting: + module: layout_builder + key: enabled + value: true +YAML; + $recipe = $this->createRecipe($recipe); + RecipeRunner::processRecipe($recipe); + + /** @var \Drupal\Core\Config\Entity\ThirdPartySettingsInterface $display */ + $display = $this->container->get(EntityDisplayRepositoryInterface::class) + ->getViewDisplay('node', 'test', 'full'); + $this->assertTrue($display->getThirdPartySetting('layout_builder', 'enabled')); + } + + public function testSetMultipleThirdPartySettings(): void { + $recipe = <<<YAML +name: Third-party setting +config: + actions: + core.entity_view_display.node.test.full: + setThirdPartySettings: + - + module: layout_builder + key: enabled + value: true + - + module: layout_builder + key: allow_custom + value: true +YAML; + $recipe = $this->createRecipe($recipe); + RecipeRunner::processRecipe($recipe); + + /** @var \Drupal\Core\Config\Entity\ThirdPartySettingsInterface $display */ + $display = $this->container->get(EntityDisplayRepositoryInterface::class) + ->getViewDisplay('node', 'test', 'full'); + $this->assertTrue($display->getThirdPartySetting('layout_builder', 'enabled')); + $this->assertTrue($display->getThirdPartySetting('layout_builder', 'allow_custom')); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/InstallConfiguratorTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/InstallConfiguratorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..fa9f9f3c31d88abe0f7d859cf807408e933f5ae6 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Recipe/InstallConfiguratorTest.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Recipe; + +use Drupal\Core\Extension\ModuleExtensionList; +use Drupal\Core\Extension\ThemeExtensionList; +use Drupal\Core\Recipe\InstallConfigurator; +use Drupal\KernelTests\KernelTestBase; + +/** + * @covers \Drupal\Core\Recipe\InstallConfigurator + * @group Recipe + */ +class InstallConfiguratorTest extends KernelTestBase { + + public function testDependenciesAreAutomaticallyIncluded(): void { + $configurator = new InstallConfigurator( + ['node', 'test_theme_depending_on_modules'], + $this->container->get(ModuleExtensionList::class), + $this->container->get(ThemeExtensionList::class), + ); + + // Node and its dependencies should be listed. + $this->assertContains('node', $configurator->modules); + $this->assertContains('text', $configurator->modules); + $this->assertContains('field', $configurator->modules); + $this->assertContains('filter', $configurator->modules); + // The test theme, along with its module AND theme dependencies, should be + // listed. + $this->assertContains('test_theme_depending_on_modules', $configurator->themes); + $this->assertContains('test_module_required_by_theme', $configurator->modules); + $this->assertContains('test_another_module_required_by_theme', $configurator->modules); + $this->assertContains('stark', $configurator->themes); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/PermissionsPerBundleTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/PermissionsPerBundleTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c9465a31d49f84aa5933e715fcede6241b641d9d --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Recipe/PermissionsPerBundleTest.php @@ -0,0 +1,231 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Recipe; + +use Drupal\Component\Plugin\Exception\PluginNotFoundException; +use Drupal\Component\Serialization\Json; +use Drupal\Core\Config\Action\ConfigActionException; +use Drupal\Core\Recipe\RecipeRunner; +use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait; +use Drupal\KernelTests\KernelTestBase; +use Drupal\Tests\media\Traits\MediaTypeCreationTrait; +use Drupal\Tests\node\Traits\ContentTypeCreationTrait; +use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait; +use Drupal\Tests\user\Traits\UserCreationTrait; +use Drupal\user\Entity\Role; +use Drupal\user\RoleInterface; + +/** + * @covers \Drupal\Core\Config\Action\Plugin\ConfigAction\PermissionsPerBundle + * @covers \Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\PermissionsPerBundleDeriver + * + * @group Recipe + */ +class PermissionsPerBundleTest extends KernelTestBase { + + use ContentTypeCreationTrait; + use MediaTypeCreationTrait; + use RecipeTestTrait; + use TaxonomyTestTrait; + use UserCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'field', + 'media', + 'media_test_source', + 'node', + 'system', + 'taxonomy', + 'text', + 'user', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installConfig('node'); + + $this->createRole([], 'super_editor'); + + $this->createContentType(['type' => 'article']); + $this->createContentType(['type' => 'blog']); + $this->createContentType(['type' => 'landing_page']); + + $this->createMediaType('test', ['id' => 'beautiful']); + $this->createMediaType('test', ['id' => 'controversial']); + $this->createMediaType('test', ['id' => 'special']); + + $this->createVocabulary(['vid' => 'tags']); + $this->createVocabulary(['vid' => 'categories']); + } + + /** + * Tests granting multiple bundle-specific permissions. + */ + public function testGrantPermissionsPerBundle(): void { + $recipe_data = <<<YAML +name: 'Multi permissions!' +config: + actions: + user.role.super_editor: + grantPermissionsForEachNodeType: + - create %bundle content + - edit own %bundle content + grantPermissionsForEachMediaType: + permissions: + - create %bundle media + - edit own %bundle media + grantPermissionsForEachTaxonomyVocabulary: create terms in %bundle +YAML; + $this->applyRecipeFromString($recipe_data); + + $expected_permissions = [ + 'create article content', + 'create blog content', + 'create landing_page content', + 'edit own article content', + 'edit own blog content', + 'edit own landing_page content', + 'create beautiful media', + 'create controversial media', + 'create special media', + 'edit own beautiful media', + 'edit own controversial media', + 'edit own special media', + 'create terms in tags', + 'create terms in categories', + ]; + $role = Role::load('super_editor'); + assert($role instanceof RoleInterface); + foreach ($expected_permissions as $permission) { + $this->assertTrue($role->hasPermission($permission)); + } + } + + /** + * Tests that the permissions-per-bundle action can only be applied to roles. + */ + public function testActionIsOnlyAvailableToUserRoles(): void { + $recipe_data = <<<YAML +name: 'Only for roles...' +config: + actions: + field.storage.node.body: + grantPermissionsForEachNodeType: + - create %bundle content + - edit own %bundle content +YAML; + + $this->expectException(PluginNotFoundException::class); + $this->expectExceptionMessage('The "grantPermissionsForEachNodeType" plugin does not exist.'); + $this->applyRecipeFromString($recipe_data); + } + + /** + * Tests granting permissions for one bundle, then all of them. + */ + public function testGrantPermissionsOnOneBundleThenAll(): void { + $recipe_data = <<<YAML +name: 'All bundles except one' +config: + actions: + user.role.super_editor: + grantPermissions: + - create beautiful media + - edit own beautiful media + grantPermissionsForEachMediaType: + - create %bundle media + - edit own %bundle media +YAML; + $this->applyRecipeFromString($recipe_data); + + $role = Role::load('super_editor'); + $this->assertInstanceOf(Role::class, $role); + $this->assertTrue($role->hasPermission('create beautiful media')); + $this->assertTrue($role->hasPermission('edit own beautiful media')); + $this->assertTrue($role->hasPermission('create controversial media')); + $this->assertTrue($role->hasPermission('edit own beautiful media')); + } + + /** + * Tests granting permissions for all bundles except certain ones. + */ + public function testGrantPermissionsToAllBundlesExceptSome(): void { + $recipe_data = <<<YAML +name: 'Bundle specific permissions with some exceptions' +config: + actions: + user.role.super_editor: + grantPermissionsForEachNodeType: + permissions: + - view %bundle revisions + except: + - article + - blog + grantPermissionsForEachMediaType: + permissions: view any %bundle media revisions + except: + - controversial + grantPermissionsForEachTaxonomyVocabulary: + permissions: + - view term revisions in %bundle + except: tags +YAML; + $this->applyRecipeFromString($recipe_data); + + $role = Role::load('super_editor'); + $this->assertInstanceOf(Role::class, $role); + $this->assertTrue($role->hasPermission('view landing_page revisions')); + $this->assertFalse($role->hasPermission('view article revisions')); + $this->assertFalse($role->hasPermission('view blog revisions')); + $this->assertTrue($role->hasPermission('view any beautiful media revisions')); + $this->assertTrue($role->hasPermission('view any special media revisions')); + $this->assertFalse($role->hasPermission('view any controversial media revisions')); + $this->assertTrue($role->hasPermission('view term revisions in categories')); + $this->assertFalse($role->hasPermission('view term revisions in tags')); + } + + /** + * Tests that there is an exception if the permission templates are invalid. + * + * @param mixed $value + * The permission template which should raise an error. + * + * @testWith [["a %Bundle permission"]] + * [""] + * [[]] + */ + public function testInvalidValue(mixed $value): void { + $value = Json::encode($value); + + $recipe_data = <<<YAML +name: 'Bad permission value' +config: + actions: + user.role.super_editor: + grantPermissionsForEachMediaType: $value +YAML; + $this->expectException(ConfigActionException::class); + $this->expectExceptionMessage(" must be an array of strings that contain '%bundle'."); + $this->applyRecipeFromString($recipe_data); + } + + /** + * Given a string of `recipe.yml` contents, applies it to the site. + * + * @param string $recipe_data + * The contents of `recipe.yml`. + */ + private function applyRecipeFromString(string $recipe_data): void { + $recipe = $this->createRecipe($recipe_data); + RecipeRunner::processRecipe($recipe); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeConfiguratorTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeConfiguratorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..640cc182de87cc155d326f16cbe1594b04df26c9 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeConfiguratorTest.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Recipe; + +use Drupal\Core\Recipe\Recipe; +use Drupal\Core\Recipe\RecipeConfigurator; +use Drupal\Core\Recipe\RecipeDiscovery; +use Drupal\KernelTests\KernelTestBase; + +/** + * @covers \Drupal\Core\Recipe\RecipeConfigurator + * @group Recipe + */ +class RecipeConfiguratorTest extends KernelTestBase { + + public function testRecipeConfigurator(): void { + $discovery = new RecipeDiscovery('core/tests/fixtures/recipes'); + $recipe_configurator = new RecipeConfigurator( + ['install_two_modules', 'install_node_with_config', 'recipe_include'], + $discovery + ); + // Private method "listAllRecipes". + $reflection = new \ReflectionMethod('\Drupal\Core\Recipe\RecipeConfigurator', 'listAllRecipes'); + + // Test methods. + /** @var \Drupal\Core\Recipe\Recipe[] $recipes */ + $recipes = (array) $reflection->invoke($recipe_configurator); + $recipes_names = array_map(fn(Recipe $recipe) => $recipe->name, $recipes); + $recipe_extensions = $recipe_configurator->listAllExtensions(); + $expected_recipes_names = [ + 'Install two modules', + 'Install node with config', + 'Recipe include', + ]; + $expected_recipe_extensions = [ + 'system', + 'user', + 'filter', + 'field', + 'text', + 'node', + 'dblog', + ]; + + $this->assertEquals($expected_recipes_names, $recipes_names); + $this->assertEquals($expected_recipe_extensions, $recipe_extensions); + $this->assertEquals(1, array_count_values($recipes_names)['Install node with config']); + $this->assertEquals(1, array_count_values($recipe_extensions)['field']); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..165fb07de23691a9e2f6b189e4c727ddded7ba16 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Recipe; + +use Drupal\Core\Recipe\RecipeDiscovery; +use Drupal\Core\Recipe\UnknownRecipeException; +use Drupal\KernelTests\KernelTestBase; + +/** + * @coversDefaultClass \Drupal\Core\Recipe\RecipeDiscovery + * @group Recipe + */ +class RecipeDiscoveryTest extends KernelTestBase { + + /** + * Tests that recipe discovery can find recipes. + * + * @testWith ["install_two_modules", "Install two modules"] + * ["recipe_include", "Recipe include"] + */ + public function testRecipeDiscovery(string $recipe, string $name): void { + $discovery = new RecipeDiscovery('core/tests/fixtures/recipes'); + $recipe = $discovery->getRecipe($recipe); + $this->assertSame($name, $recipe->name); + } + + /** + * Tests the exception thrown when recipe discovery cannot find a recipe. + * + * @testWith ["no_recipe"] + * ["does_not_exist"] + */ + public function testRecipeDiscoveryException(string $recipe): void { + $discovery = new RecipeDiscovery('core/tests/fixtures/recipes'); + try { + $discovery->getRecipe($recipe); + $this->fail('Expected exception not thrown'); + } + catch (UnknownRecipeException $e) { + $this->assertSame($recipe, $e->recipe); + $this->assertSame('core/tests/fixtures/recipes', $e->searchPath); + $this->assertSame('Can not find the ' . $recipe . ' recipe, search path: ' . $e->searchPath, $e->getMessage()); + } + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeEventsTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeEventsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..20d12bae4c3852bd59931435dded77d87f2de930 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeEventsTest.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Recipe; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Recipe\Recipe; +use Drupal\Core\Recipe\RecipeAppliedEvent; +use Drupal\Core\Recipe\RecipeRunner; +use Drupal\KernelTests\KernelTestBase; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * @group Recipe + */ +class RecipeEventsTest extends KernelTestBase implements EventSubscriberInterface { + + /** + * The human-readable names of the recipes that have been applied. + * + * @var string[] + */ + private array $recipesApplied = []; + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + RecipeAppliedEvent::class => 'onRecipeApply', + ]; + } + + public function onRecipeApply(RecipeAppliedEvent $event): void { + $this->recipesApplied[] = $event->recipe->name; + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + // Every time the container is rebuilt, ensure this object is subscribing to + // events. + $container->getDefinition('event_dispatcher') + ->addMethodCall('addSubscriber', [$this]); + } + + public function testRecipeAppliedEvent(): void { + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/recipe_include'); + RecipeRunner::processRecipe($recipe); + + $this->assertSame(['Install node with config', 'Recipe include'], $this->recipesApplied); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0af066b626cde69a59b94efc548b1bfbbf6f4e49 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php @@ -0,0 +1,254 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Recipe; + +use Drupal\Component\Plugin\Exception\PluginNotFoundException; +use Drupal\config_test\Entity\ConfigTest; +use Drupal\Core\Recipe\Recipe; +use Drupal\Core\Recipe\RecipePreExistingConfigException; +use Drupal\Core\Recipe\RecipeRunner; +use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait; +use Drupal\KernelTests\KernelTestBase; +use Drupal\node\Entity\NodeType; +use Drupal\views\Entity\View; + +/** + * @coversDefaultClass \Drupal\Core\Recipe\RecipeRunner + * @group Recipe + */ +class RecipeRunnerTest extends KernelTestBase { + + use RecipeTestTrait; + + public function testModuleInstall(): void { + // Test the state prior to applying the recipe. + $this->assertFalse($this->container->get('module_handler')->moduleExists('filter'), 'The filter module is not installed'); + $this->assertFalse($this->container->get('module_handler')->moduleExists('text'), 'The text module is not installed'); + $this->assertFalse($this->container->get('module_handler')->moduleExists('node'), 'The node module is not installed'); + $this->assertFalse($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration does not exist'); + + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_two_modules'); + RecipeRunner::processRecipe($recipe); + + // Test the state after applying the recipe. + $this->assertTrue($this->container->get('module_handler')->moduleExists('filter'), 'The filter module is installed'); + $this->assertTrue($this->container->get('module_handler')->moduleExists('text'), 'The text module is installed'); + $this->assertTrue($this->container->get('module_handler')->moduleExists('node'), 'The node module is installed'); + $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created'); + $this->assertFalse($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to FALSE'); + } + + public function testModuleAndThemeInstall(): void { + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/base_theme_and_views'); + RecipeRunner::processRecipe($recipe); + + // Test the state after applying the recipe. + $this->assertTrue($this->container->get('module_handler')->moduleExists('views'), 'The views module is installed'); + $this->assertTrue($this->container->get('module_handler')->moduleExists('node'), 'The node module is installed'); + $this->assertTrue($this->container->get('theme_handler')->themeExists('test_basetheme'), 'The test_basetheme theme is installed'); + $this->assertTrue($this->container->get('theme_handler')->themeExists('test_subtheme'), 'The test_subtheme theme is installed'); + $this->assertTrue($this->container->get('theme_handler')->themeExists('test_subsubtheme'), 'The test_subsubtheme theme is installed'); + $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created'); + $this->assertFalse($this->container->get('config.storage')->exists('views.view.archive'), 'The views.view.archive configuration has not been created'); + $this->assertEmpty(View::loadMultiple(), "No views exist"); + } + + public function testThemeModuleDependenciesInstall(): void { + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/theme_with_module_dependencies'); + RecipeRunner::processRecipe($recipe); + + // Test the state after applying the recipe. + $this->assertTrue($this->container->get('module_handler')->moduleExists('test_module_required_by_theme'), 'The test_module_required_by_theme module is installed'); + $this->assertTrue($this->container->get('module_handler')->moduleExists('test_another_module_required_by_theme'), 'The test_another_module_required_by_theme module is installed'); + $this->assertTrue($this->container->get('theme_handler')->themeExists('test_theme_depending_on_modules'), 'The test_theme_depending_on_modules theme is installed'); + } + + public function testModuleConfigurationOverride(): void { + // Test the state prior to applying the recipe. + $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration'); + + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config'); + RecipeRunner::processRecipe($recipe); + + // Test the state after applying the recipe. + $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created'); + $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created'); + $this->assertTrue($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to TRUE'); + $this->assertSame('Test content type', NodeType::load('test')?->label()); + $node_type_data = $this->config('node.type.test')->get(); + $this->assertGreaterThan(0, strlen($node_type_data['uuid']), 'The node type configuration has been assigned a UUID.'); + // cSpell:disable-next-line + $this->assertSame('8Jlq8CmNXHVtNIHBHgFGpnAKthlUz0XoW_D0g56QXqY', $node_type_data['_core']['default_config_hash']); + } + + public function testApplySameRecipe(): void { + // Test the state prior to applying the recipe. + $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration'); + + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config'); + RecipeRunner::processRecipe($recipe); + + // Test the state prior to applying the recipe. + $this->assertNotEmpty($this->container->get('config.factory')->listAll('node.'), 'There is node configuration'); + + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config'); + RecipeRunner::processRecipe($recipe); + $this->assertTrue(TRUE, 'Applying a recipe for the second time with no config changes results in a successful application'); + + $type = NodeType::load('test'); + $type->setNewRevision(FALSE); + $type->save(); + + $this->expectException(RecipePreExistingConfigException::class); + $this->expectExceptionMessage("The configuration 'node.type.test' exists already and does not match the recipe's configuration"); + Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config'); + } + + public function testConfigFromModule(): void { + // Test the state prior to applying the recipe. + $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration'); + + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_from_module'); + RecipeRunner::processRecipe($recipe); + + // Test the state after to applying the recipe. + $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration'); + $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(); + $this->assertSame(['dotted.default', 'override'], array_keys($config_test_entities)); + } + + public function testConfigWildcard(): void { + // Test the state prior to applying the recipe. + $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration'); + + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_wildcard'); + RecipeRunner::processRecipe($recipe); + + // Test the state after to applying the recipe. + $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration'); + $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(); + $this->assertSame(['dotted.default', 'override', 'override_unmet'], array_keys($config_test_entities)); + $this->assertSame('Default', $config_test_entities['dotted.default']->label()); + $this->assertSame('herp', $this->config('config_test.system')->get('404')); + } + + public function testConfigFromModuleAndRecipe(): void { + // Test the state prior to applying the recipe. + $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration'); + + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_from_module_and_recipe'); + RecipeRunner::processRecipe($recipe); + + // Test the state after to applying the recipe. + $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration'); + $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(); + $this->assertSame(['dotted.default', 'override', 'override_unmet'], array_keys($config_test_entities)); + $this->assertSame('Provided by recipe', $config_test_entities['dotted.default']->label()); + $this->assertSame('foo', $this->config('config_test.system')->get('404')); + } + + public function testRecipeInclude(): void { + // Test the state prior to applying the recipe. + $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration'); + $this->assertFalse($this->container->get('module_handler')->moduleExists('dblog'), 'Dblog module not installed'); + + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/recipe_include'); + RecipeRunner::processRecipe($recipe); + + // Test the state after to applying the recipe. + $this->assertTrue($this->container->get('module_handler')->moduleExists('dblog'), 'Dblog module installed'); + $this->assertSame('Test content type', NodeType::load('test')?->label()); + $this->assertSame('Another test content type', NodeType::load('another_test')?->label()); + } + + public function testConfigActions() :void { + // Test the state prior to applying the recipe. + $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration'); + + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_actions'); + RecipeRunner::processRecipe($recipe); + + // Test the state after to applying the recipe. + $storage = \Drupal::entityTypeManager()->getStorage('config_test'); + $config_test_entity = $storage->load('recipe'); + $this->assertInstanceOf(ConfigTest::class, $config_test_entity); + $this->assertSame('Created by recipe', $config_test_entity->label()); + $this->assertSame('Set by recipe', $config_test_entity->getProtectedProperty()); + $this->assertSame('not bar', $this->config('config_test.system')->get('foo')); + } + + public function testConfigActionsPreExistingConfig() :void { + $this->enableModules(['config_test']); + $this->installConfig(['config_test']); + $this->assertSame('bar', $this->config('config_test.system')->get('foo')); + $storage = \Drupal::entityTypeManager()->getStorage('config_test'); + $config_test_entity = $storage->create(['id' => 'recipe', 'label' => 'Created by test']); + $this->assertInstanceOf(ConfigTest::class, $config_test_entity); + $config_test_entity->setProtectedProperty('Set by test'); + $config_test_entity->save(); + + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_actions'); + RecipeRunner::processRecipe($recipe); + + // Test the state after to applying the recipe. + $config_test_entity = $storage->load('recipe'); + $this->assertInstanceOf(ConfigTest::class, $config_test_entity); + $this->assertSame('Created by test', $config_test_entity->label()); + $this->assertSame('Set by recipe', $config_test_entity->getProtectedProperty()); + $this->assertSame('not bar', $this->config('config_test.system')->get('foo')); + } + + public function testInvalidConfigAction() :void { + $recipe_data = <<<YAML +name: Invalid config action +install: + - config_test +config: + actions: + config_test.dynamic.recipe: + ensure_exists: + label: 'Created by recipe' + setBody: 'Description set by recipe' +YAML; + + $recipe = $this->createRecipe($recipe_data); + $this->expectException(PluginNotFoundException::class); + $this->expectExceptionMessage('The "setBody" plugin does not exist.'); + RecipeRunner::processRecipe($recipe); + } + + public function testRecipesAreDisambiguatedByPath(): void { + $recipe_data = <<<YAML +name: 'Recipe include' +recipes: + - core/tests/fixtures/recipes/recipe_include +install: + - config_test +YAML; + + $recipe = $this->createRecipe($recipe_data, 'recipe_include'); + RecipeRunner::processRecipe($recipe); + + // Test the state after to applying the recipe. + $this->assertTrue($this->container->get('module_handler')->moduleExists('dblog'), 'Dblog module installed'); + $this->assertTrue($this->container->get('module_handler')->moduleExists('config_test'), 'Config test module installed'); + $this->assertSame('Test content type', NodeType::load('test')?->label()); + $this->assertSame('Another test content type', NodeType::load('another_test')?->label()); + + $operations = RecipeRunner::toBatchOperations($recipe); + $this->assertSame('triggerEvent', $operations[7][0][1]); + $this->assertSame('Install node with config', $operations[7][1][0]->name); + $this->assertStringEndsWith('core/tests/fixtures/recipes/install_node_with_config', $operations[7][1][0]->path); + + $this->assertSame('triggerEvent', $operations[10][0][1]); + $this->assertSame('Recipe include', $operations[10][1][0]->name); + $this->assertStringEndsWith('core/tests/fixtures/recipes/recipe_include', $operations[10][1][0]->path); + + $this->assertSame('triggerEvent', $operations[12][0][1]); + $this->assertSame('Recipe include', $operations[12][1][0]->name); + $this->assertSame($this->siteDirectory . '/recipes/recipe_include', $operations[12][1][0]->path); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7df23e467ac25893a2c5525d0813fd8376f91cce --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Recipe; + +use Drupal\Core\Recipe\Recipe; +use Drupal\Core\Recipe\RecipeFileException; +use Drupal\Core\Recipe\RecipePreExistingConfigException; +use Drupal\KernelTests\KernelTestBase; + +/** + * @coversDefaultClass \Drupal\Core\Recipe\Recipe + * @group Recipe + */ +class RecipeTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['system', 'user', 'field']; + + /** + * @testWith ["no_extensions", "No extensions" , "Testing", [], "A recipe description"] + * ["install_two_modules", "Install two modules" , "Content type", ["filter", "text", "node"], ""] + */ + public function testCreateFromDirectory2(string $recipe_name, string $expected_name, string $expected_type, array $expected_modules, string $expected_description): void { + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/' . $recipe_name); + $this->assertSame($expected_name, $recipe->name); + $this->assertSame($expected_type, $recipe->type); + $this->assertSame($expected_modules, $recipe->install->modules); + $this->assertSame($expected_description, $recipe->description); + } + + public function testCreateFromDirectoryNoRecipe(): void { + $dir = uniqid('public://'); + mkdir($dir); + + $this->expectException(RecipeFileException::class); + $this->expectExceptionMessage('There is no ' . $dir . '/recipe.yml file'); + Recipe::createFromDirectory($dir); + } + + public function testPreExistingDifferentConfiguration(): void { + // Install the node module, its dependencies and configuration. + $this->container->get('module_installer')->install(['node']); + $this->assertFalse($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to FALSE'); + + try { + Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config'); + $this->fail('Expected exception not thrown'); + } + catch (RecipePreExistingConfigException $e) { + $this->assertSame("The configuration 'node.settings' exists already and does not match the recipe's configuration", $e->getMessage()); + $this->assertSame('node.settings', $e->configName); + } + } + + public function testPreExistingMatchingConfiguration(): void { + // Install the node module, its dependencies and configuration. + $this->container->get('module_installer')->install(['node']); + // Change the config to match the recipe's config to prevent the exception + // being thrown. + $this->config('node.settings')->set('use_admin_theme', TRUE)->save(); + + $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config'); + $this->assertSame('core/tests/fixtures/recipes/install_node_with_config/config', $recipe->config->recipeConfigDirectory); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..20551fb972fff7c4fbd47491a15be8179e52f3b4 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php @@ -0,0 +1,340 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Recipe; + +use Drupal\Core\Recipe\Recipe; +use Drupal\Core\Recipe\RecipeFileException; +use Drupal\KernelTests\KernelTestBase; + +/** + * @group Recipe + */ +class RecipeValidationTest extends KernelTestBase { + + /** + * Data provider for ::testRecipeValidation(). + * + * @return \Generator + * The test cases. + */ + public static function providerRecipeValidation(): iterable { + yield 'name is correct' => [ + 'name: Correct name', + NULL, + ]; + yield 'name missing' => [ + '{}', + [ + '[name]' => ['This field is missing.'], + ], + ]; + yield 'name is not a string' => [ + 'name: 39', + [ + '[name]' => ['This value should be of type string.'], + ], + ]; + yield 'name is null' => [ + 'name: ~', + [ + '[name]' => ['This value should not be blank.'], + ], + ]; + yield 'name is blank' => [ + "name: ''", + [ + '[name]' => ['This value should not be blank.'], + ], + ]; + yield 'name has invalid characters' => [ + <<<YAML +name: | + My + Amazing Recipe +YAML, + [ + '[name]' => ['Recipe names cannot span multiple lines or contain control characters.'], + ], + ]; + yield 'description is correct' => [ + <<<YAML +name: Correct description +description: 'This is the correct description of a recipe.' +YAML, + NULL, + ]; + yield 'description is not a string' => [ + <<<YAML +name: Bad description +description: [Nope!] +YAML, + [ + '[description]' => ['This value should be of type string.'], + ], + ]; + yield 'description is blank' => [ + <<<YAML +name: Blank description +description: '' +YAML, + [ + '[description]' => ['This value should not be blank.'], + ], + ]; + yield 'description is null' => [ + <<<YAML +name: Null description +description: ~ +YAML, + [ + '[description]' => ['This value should not be blank.'], + ], + ]; + yield 'description contains control characters' => [ + <<<YAML +name: Bad description +description: "I have a\b bad character." +YAML, + [ + '[description]' => ['The recipe description cannot contain control characters, only visible characters.'], + ], + ]; + yield 'type is correct' => [ + <<<YAML +name: Correct type +type: Testing +YAML, + NULL, + ]; + yield 'type is not a string' => [ + <<<YAML +name: Bad type +type: 39 +YAML, + [ + '[type]' => ['This value should be of type string.'], + ], + ]; + yield 'type is blank' => [ + <<<YAML +name: Blank type +type: '' +YAML, + [ + '[type]' => ['This value should not be blank.'], + ], + ]; + yield 'type is null' => [ + <<<YAML +name: Null type +type: ~ +YAML, + [ + '[type]' => ['This value should not be blank.'], + ], + ]; + yield 'type has invalid characters' => [ + <<<YAML +name: Invalid type +type: | + My + Amazing Recipe +YAML, + [ + '[type]' => ['Recipe type cannot span multiple lines or contain control characters.'], + ], + ]; + // @todo Test valid recipe once https://www.drupal.org/i/3421197 is in. + yield 'recipes list is scalar' => [ + <<<YAML +name: Bad recipe list +recipes: 39 +YAML, + [ + '[recipes]' => ['This value should be of type iterable.'], + ], + ]; + yield 'recipes list has a blank entry' => [ + <<<YAML +name: Invalid recipe +recipes: [''] +YAML, + [ + '[recipes][0]' => ['This value should not be blank.'], + ], + ]; + yield 'recipes list has a non-existent recipe' => [ + <<<YAML +name: Non-existent recipe +recipes: + - vaporware +YAML, + [ + '[recipes][0]' => ['The vaporware recipe does not exist.'], + ], + ]; + yield 'recipe depends on itself' => [ + <<<YAML +name: 'Inception' +recipes: + - no_extensions +YAML, + [ + '[recipes][0]' => ['The "no_extensions" recipe cannot depend on itself.'], + ], + 'no_extensions', + ]; + yield 'extension list is scalar' => [ + <<<YAML +name: Bad extension list +install: 39 +YAML, + [ + '[install]' => ['This value should be of type iterable.'], + ], + ]; + yield 'extension list has a blank entry' => [ + <<<YAML +name: Blank extension list +install: [''] +YAML, + [ + '[install][0]' => ['This value should not be blank.'], + ], + ]; + yield 'installing unknown extensions' => [ + <<<YAML +name: 'Unknown extensions' +install: + - config test + - drupal:color +YAML, + [ + '[install][0]' => ['"config test" is not a known module or theme.'], + '[install][1]' => ['"color" is not a known module or theme.'], + ], + ]; + yield 'only installs extension' => [ + <<<YAML +name: 'Only installs extensions' +install: + - filter + - drupal:claro +YAML, + NULL, + ]; + yield 'config import list is valid' => [ + <<<YAML +name: 'Correct config import list' +config: + import: + config_test: '*' + claro: + - claro.settings +YAML, + NULL, + ]; + yield 'config import list is scalar' => [ + <<<YAML +name: 'Bad config import list' +config: + import: 23 +YAML, + [ + '[config][import]' => ['This value should be of type iterable.'], + ], + ]; + yield 'config import list has a blank entry' => [ + <<<YAML +name: Blank config import list +config: + import: [''] +YAML, + [ + '[config][import][0]' => ['This value should satisfy at least one of the following constraints: [1] This value should be identical to string "*". [2] Each element of this collection should satisfy its own set of constraints.'], + ], + ]; + yield 'config actions list is valid' => [ + <<<YAML +name: 'Correct config actions list' +install: + - config_test +config: + actions: + config_test.dynamic.recipe: + ensure_exists: + label: 'Created by recipe' + setProtectedProperty: 'Set by recipe' +YAML, + NULL, + ]; + yield 'config actions list is scalar' => [ + <<<YAML +name: 'Bad config actions list' +config: + actions: 23 +YAML, + [ + '[config][actions]' => ['This value should be of type iterable.'], + ], + ]; + yield 'config actions list has a blank entry' => [ + <<<YAML +name: Blank config actions list +config: + actions: [''] +YAML, + [ + '[config][actions][0]' => [ + 'This value should be of type array.', + 'This value should not be blank.', + 'Config actions cannot be applied to 0 because the 0 extension is not installed, and is not installed by this recipe or any of the recipes it depends on.', + ], + ], + ]; + } + + /** + * Tests the validation of recipe.yml file. + * + * @param string $recipe + * The contents of the `recipe.yml` file. + * @param string[][]|null $expected_violations + * (Optional) The expected validation violations, keyed by property path. + * Each value should be an array of error messages expected for that + * property. + * @param string|null $recipe_name + * (optional) The name of the directory containing `recipe.yml`, or NULL to + * randomly generate one. + * + * @dataProvider providerRecipeValidation + */ + public function testRecipeValidation(string $recipe, ?array $expected_violations, ?string $recipe_name = NULL): void { + $dir = 'public://' . ($recipe_name ?? uniqid()); + mkdir($dir); + file_put_contents($dir . '/recipe.yml', $recipe); + + try { + Recipe::createFromDirectory($dir); + // If there was no error, we'd better not have been expecting any. + $this->assertNull($expected_violations, 'Validation errors were expected, but there were none.'); + } + catch (RecipeFileException $e) { + $this->assertIsArray($expected_violations, 'There were validation errors, but none were expected.'); + $this->assertIsObject($e->violations); + + $actual_violations = []; + /** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */ + foreach ($e->violations as $violation) { + $property_path = $violation->getPropertyPath(); + $actual_violations[$property_path][] = (string) $violation->getMessage(); + } + ksort($actual_violations); + ksort($expected_violations); + $this->assertSame($expected_violations, $actual_violations); + } + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RollbackTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RollbackTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7a9ea5a228734b404d475ebdf10510852e70f5f4 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Recipe/RollbackTest.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Recipe; + +use Drupal\Core\Config\Checkpoint\Checkpoint; +use Drupal\Core\Datetime\Entity\DateFormat; +use Drupal\Core\Recipe\Recipe; +use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait; +use Drupal\Tests\BrowserTestBase; + +/** + * @group Recipe + */ +class RollbackTest extends BrowserTestBase { + + use RecipeTestTrait; + + /** + * {@inheritdoc} + * + * Disable strict config schema because this test explicitly makes the + * recipe system save invalid config, to prove that it validates it after + * the fact and raises an error. + */ + protected $strictConfigSchema = FALSE; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'system', + 'user', + ]; + + /** + * @testWith ["invalid_config", "core.date_format.invalid"] + * ["recipe_depend_on_invalid", "core.date_format.invalid"] + * ["recipe_depend_on_invalid_config_and_valid_modules", "core.date_format.invalid"] + */ + public function testRollbackForInvalidConfig(string $recipe_fixture, string $expected_invalid_config_name): void { + $expected_core_extension_modules = $this->config('core.extension')->get('module'); + + /** @var string $recipe_fixture */ + $recipe_fixture = realpath(__DIR__ . "/../../../../fixtures/recipes/$recipe_fixture"); + $process = $this->applyRecipe($recipe_fixture, 1); + $this->assertStringContainsString("There were validation errors in $expected_invalid_config_name:", $process->getErrorOutput()); + $this->assertCheckpointsExist([ + "Backup before the '" . Recipe::createFromDirectory($recipe_fixture)->name . "' recipe.", + ]); + + // @see invalid_config + $date_formats = DateFormat::loadMultiple(['valid', 'invalid']); + $this->assertEmpty($date_formats, "The recipe's imported config was not rolled back."); + + // @see recipe_depend_on_invalid_config_and_valid_module + $this->assertSame($expected_core_extension_modules, $this->config('core.extension')->get('module')); + } + + /** + * Asserts that the current set of checkpoints matches the given labels. + * + * @param string[] $expected_labels + * The labels of every checkpoint that is expected to exist currently, in + * the expected order. + */ + private function assertCheckpointsExist(array $expected_labels): void { + $checkpoints = \Drupal::service('config.checkpoints'); + $labels = array_map(fn (Checkpoint $c) => $c->label, iterator_to_array($checkpoints)); + $this->assertSame($expected_labels, array_values($labels)); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/WildcardConfigActionsTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/WildcardConfigActionsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..29b1a7e784e6a35efe3ccf53ae2d7faf99a1d009 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Recipe/WildcardConfigActionsTest.php @@ -0,0 +1,135 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Recipe; + +use Drupal\Core\Config\Action\ConfigActionException; +use Drupal\Core\Entity\EntityTypeManagerInterface; +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\KernelTests\KernelTestBase; +use Drupal\Tests\node\Traits\ContentTypeCreationTrait; + +/** + * Tests config actions targeting multiple entities using wildcards. + * + * @group Recipe + */ +class WildcardConfigActionsTest extends KernelTestBase { + + use ContentTypeCreationTrait; + use RecipeTestTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'entity_test', + 'field', + 'node', + 'system', + 'text', + 'user', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installConfig('node'); + + $this->createContentType(['type' => 'one']); + $this->createContentType(['type' => 'two']); + + EntityTestBundle::create(['id' => 'one'])->save(); + EntityTestBundle::create(['id' => 'two'])->save(); + + $field_storage = FieldStorageConfig::create([ + 'entity_type' => 'entity_test_with_bundle', + 'field_name' => 'field_test', + 'type' => 'boolean', + ]); + $field_storage->save(); + FieldConfig::create(['field_storage' => $field_storage, 'bundle' => 'one']) + ->save(); + FieldConfig::create(['field_storage' => $field_storage, 'bundle' => 'two']) + ->save(); + + $field_storage = FieldStorageConfig::create([ + 'entity_type' => 'node', + 'field_name' => 'field_test', + 'type' => 'boolean', + ]); + $field_storage->save(); + FieldConfig::create(['field_storage' => $field_storage, 'bundle' => 'one']) + ->save(); + FieldConfig::create(['field_storage' => $field_storage, 'bundle' => 'two']) + ->save(); + } + + /** + * Tests targeting multiple config entities for an action, using wildcards. + * + * @param string $expression + * The expression the recipe will use to target multiple config entities. + * @param string[] $expected_changed_entities + * The IDs of the config entities that we expect the recipe to change. + * + * @testWith ["field.field.node.one.*", ["node.one.body", "node.one.field_test"]] + * ["field.field.node.*.body", ["node.one.body", "node.two.body"]] + * ["field.field.*.one.field_test", ["entity_test_with_bundle.one.field_test", "node.one.field_test"]] + * ["field.field.node.*.*", ["node.one.body", "node.one.field_test", "node.two.body", "node.two.field_test"]] + * ["field.field.*.one.*", ["entity_test_with_bundle.one.field_test", "node.one.field_test", "node.one.body"]] + * ["field.field.*.*.field_test", ["entity_test_with_bundle.one.field_test", "entity_test_with_bundle.two.field_test", "node.one.field_test", "node.two.field_test"]] + * ["field.field.*.*.*", ["entity_test_with_bundle.one.field_test", "entity_test_with_bundle.two.field_test", "node.one.field_test", "node.two.field_test", "node.one.body", "node.two.body"]] + */ + public function testTargetEntitiesByWildcards(string $expression, array $expected_changed_entities): void { + $contents = <<<YAML +name: 'Wildcards!' +config: + actions: + $expression: + setLabel: 'Changed by config action' +YAML; + + $recipe = $this->createRecipe($contents); + RecipeRunner::processRecipe($recipe); + + $changed = $this->container->get(EntityTypeManagerInterface::class) + ->getStorage('field_config') + ->getQuery() + ->condition('label', 'Changed by config action') + ->execute(); + sort($expected_changed_entities); + sort($changed); + $this->assertSame($expected_changed_entities, array_values($changed)); + } + + /** + * Tests that an invalid wildcard expression will raise an error. + * + * @testWith ["field.*.node.one.*", "No installed config entity type uses the prefix in the expression 'field.*.node.one.*'. Either there is a typo in the expression or this recipe should install an additional module or depend on another recipe."] + * ["field.field.node.*.body/", " could not be parsed."] + */ + public function testInvalidExpression(string $expression, string $expected_exception_message): void { + $contents = <<<YAML +name: 'Wildcards gone wild...' +config: + actions: + $expression: + simple_config_update: + label: 'Changed by config action' +YAML; + $recipe = $this->createRecipe($contents); + + $this->expectException(ConfigActionException::class); + $this->expectExceptionMessage($expected_exception_message); + RecipeRunner::processRecipe($recipe); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php b/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php index 735624438ae7cdea53c6cc2c2f66210c077faa6a..535b1d8c3168dc7dd4f0a6664c84150000fefac2 100644 --- a/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php +++ b/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php @@ -235,7 +235,7 @@ public function testQuickStartCommandProfileValidation() { ]; $process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]); $process->run(); - $this->assertStringContainsString('\'umami\' is not a valid install profile. Did you mean \'demo_umami\'?', $process->getErrorOutput()); + $this->assertMatchesRegularExpression("/'umami' is not a valid install profile or recipe\. Did you mean \W*'demo_umami'?/", $process->getErrorOutput()); } /** diff --git a/core/tests/Drupal/Tests/Core/Config/Action/ConfigActionAttributeTest.php b/core/tests/Drupal/Tests/Core/Config/Action/ConfigActionAttributeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e61f3c16e48feb92d24604588d7196336876c9de --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Config/Action/ConfigActionAttributeTest.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Config\Action; + +use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException; +use Drupal\Core\Config\Action\Attribute\ConfigAction; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\Core\Config\Action\Attribute\ConfigAction + * @group Config + */ +class ConfigActionAttributeTest extends UnitTestCase { + + /** + * @covers ::__construct + */ + public function testNoLabelNoDeriver(): void { + $this->expectException(InvalidPluginDefinitionException::class); + $this->expectExceptionMessage("The 'test' config action plugin must have either an admin label or a deriver"); + new ConfigAction('test'); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Config/Checkpoint/CheckpointStorageTest.php b/core/tests/Drupal/Tests/Core/Config/Checkpoint/CheckpointStorageTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1e1064dcdf2937e1374fcc2b7669b4b222070492 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Config/Checkpoint/CheckpointStorageTest.php @@ -0,0 +1,296 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Config\Checkpoint; + +use Drupal\Component\Datetime\Time; +use Drupal\Core\Cache\NullBackend; +use Drupal\Core\Config\Checkpoint\Checkpoint; +use Drupal\Core\Config\Checkpoint\LinearHistory; +use Drupal\Core\Config\Checkpoint\CheckpointStorage; +use Drupal\Core\Config\Config; +use Drupal\Core\Config\ConfigCrudEvent; +use Drupal\Core\Config\MemoryStorage; +use Drupal\Core\Config\StorageCopyTrait; +use Drupal\Core\Config\StorageInterface; +use Drupal\Core\KeyValueStore\KeyValueMemoryFactory; +use Drupal\Core\Lock\NullLockBackend; +use Drupal\Core\State\State; +use Drupal\Tests\UnitTestCase; +use Drupal\TestTools\Random; + +/** + * @coversDefaultClass \Drupal\Core\Config\Checkpoint\CheckpointStorage + * @group Config + */ +class CheckpointStorageTest extends UnitTestCase { + + use StorageCopyTrait; + + /** + * The memory storage containing the data. + * + * @var \Drupal\Core\Config\MemoryStorage + */ + protected MemoryStorage $memory; + + /** + * The checkpoint storage under test. + * + * @var \Drupal\Core\Config\Checkpoint\CheckpointStorage + */ + protected CheckpointStorage $storage; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Set up a memory storage we can manipulate to set fixtures. + $this->memory = new MemoryStorage(); + $keyValueMemoryFactory = new KeyValueMemoryFactory(); + $state = new State($keyValueMemoryFactory, new NullBackend('test'), new NullLockBackend()); + $time = new Time(); + $checkpoints = new LinearHistory($state, $time); + $this->storage = new CheckpointStorage($this->memory, $checkpoints, $keyValueMemoryFactory); + } + + /** + * @covers ::checkpoint + * @covers \Drupal\Core\Config\Checkpoint\Checkpoint + */ + public function testCheckpointCreation(): void { + $checkpoint = $this->storage->checkpoint('Test'); + $this->assertInstanceOf(Checkpoint::class, $checkpoint); + $this->assertSame('Test', $checkpoint->label); + + $checkpoint2 = $this->storage->checkpoint('This will not make a checkpoint because nothing has changed'); + $this->assertSame($checkpoint2, $checkpoint); + $config = $this->prophesize(Config::class); + $config->getName()->willReturn('test.config'); + $config->getOriginal('', FALSE)->willReturn([]); + $config->getRawData()->willReturn(['foo' => 'bar']); + $config->getStorage()->willReturn($this->storage); + $event = new ConfigCrudEvent($config->reveal()); + $this->storage->onConfigSaveAndDelete($event); + + $checkpoint3 = $this->storage->checkpoint('Created test.config'); + $this->assertNotSame($checkpoint3, $checkpoint); + $this->assertSame('Created test.config', $checkpoint3->label); + + $checkpoint4 = $this->storage->checkpoint('This will not create a checkpoint either'); + $this->assertSame($checkpoint4, $checkpoint3); + + // Simulate a save with no change. + $config = $this->prophesize(Config::class); + $config->getName()->willReturn('test.config'); + $config->getOriginal('', FALSE)->willReturn(['foo' => 'bar']); + $config->getRawData()->willReturn(['foo' => 'bar']); + $config->getStorage()->willReturn($this->storage); + $event = new ConfigCrudEvent($config->reveal()); + $this->storage->onConfigSaveAndDelete($event); + + $checkpoint5 = $this->storage->checkpoint('Save with no change'); + $this->assertSame($checkpoint5, $checkpoint3); + + // Create collection and ensure that checkpoints are kept in sync. + $collection = $this->storage->createCollection('test'); + $config = $this->prophesize(Config::class); + $config->getName()->willReturn('test.config'); + $config->getOriginal('', FALSE)->willReturn(['foo' => 'bar']); + $config->getRawData()->willReturn(['foo' => 'collection_bar']); + $config->getStorage()->willReturn($collection); + $event = new ConfigCrudEvent($config->reveal()); + $collection->onConfigSaveAndDelete($event); + + $checkpoint6 = $this->storage->checkpoint('Save in collection'); + $this->assertNotSame($checkpoint6, $checkpoint3); + $this->assertSame($collection->checkpoint('Calling checkpoint on collection'), $checkpoint6); + } + + /** + * @covers ::exists + * @covers ::read + * @covers ::readMultiple + * @covers ::listAll + * + * @dataProvider readMethodsProvider + */ + public function testReadOperations(string $method, array $arguments, array $fixture): void { + // Create a checkpoint so the checkpoint storage can be read from. + $this->storage->checkpoint(''); + $this->setRandomFixtureConfig($fixture); + + $expected = call_user_func_array([$this->memory, $method], $arguments); + $actual = call_user_func_array([$this->storage, $method], $arguments); + $this->assertEquals($expected, $actual); + } + + /** + * Provide the methods that work transparently. + * + * @return array + * The data. + */ + public static function readMethodsProvider(): array { + $fixture = [ + StorageInterface::DEFAULT_COLLECTION => ['config.a', 'config.b', 'other.a'], + ]; + + $data = []; + $data[] = ['exists', ['config.a'], $fixture]; + $data[] = ['exists', ['not.existing'], $fixture]; + $data[] = ['read', ['config.a'], $fixture]; + $data[] = ['read', ['not.existing'], $fixture]; + $data[] = ['readMultiple', [['config.a', 'config.b', 'not']], $fixture]; + $data[] = ['listAll', [''], $fixture]; + $data[] = ['listAll', ['config'], $fixture]; + $data[] = ['listAll', ['none'], $fixture]; + + return $data; + } + + /** + * @covers ::write + * @covers ::delete + * @covers ::rename + * @covers ::deleteAll + * + * @dataProvider writeMethodsProvider + */ + public function testWriteOperations(string $method, array $arguments, array $fixture): void { + $this->setRandomFixtureConfig($fixture); + + // Create an independent memory storage as a backup. + $backup = new MemoryStorage(); + static::replaceStorageContents($this->memory, $backup); + + try { + call_user_func_array([$this->storage, $method], $arguments); + $this->fail("exception not thrown"); + } + catch (\BadMethodCallException $exception) { + $this->assertEquals(CheckpointStorage::class . '::' . $method . ' is not allowed on a CheckpointStorage', $exception->getMessage()); + } + + // Assert that the memory storage has not been altered. + $this->assertEquals($backup, $this->memory); + } + + /** + * Provide the methods that throw an exception. + * + * @return array + * The data + */ + public static function writeMethodsProvider(): array { + $fixture = [ + StorageInterface::DEFAULT_COLLECTION => ['config.a', 'config.b'], + ]; + + $data = []; + $data[] = ['write', ['config.a', (array) Random::getGenerator()->object()], $fixture]; + $data[] = ['write', [Random::MachineName(), (array) Random::getGenerator()->object()], $fixture]; + $data[] = ['delete', ['config.a'], $fixture]; + $data[] = ['delete', [Random::MachineName()], $fixture]; + $data[] = ['rename', ['config.a', 'config.b'], $fixture]; + $data[] = ['rename', ['config.a', Random::MachineName()], $fixture]; + $data[] = ['rename', [Random::MachineName(), Random::MachineName()], $fixture]; + $data[] = ['deleteAll', [''], $fixture]; + $data[] = ['deleteAll', ['config'], $fixture]; + $data[] = ['deleteAll', ['other'], $fixture]; + + return $data; + } + + /** + * @covers ::getAllCollectionNames + * @covers ::getCollectionName + * @covers ::createCollection + */ + public function testCollections(): void { + $ref_readFromCheckpoint = new \ReflectionProperty($this->storage, 'readFromCheckpoint'); + + // Create some checkpoints so the checkpoint storage can be read from. + $checkpoint1 = $this->storage->checkpoint('1'); + $config = $this->prophesize(Config::class); + $config->getName()->willReturn('test.config'); + $config->getOriginal('', FALSE)->willReturn([]); + $config->getRawData()->willReturn(['foo' => 'bar']); + $config->getStorage()->willReturn($this->storage); + $event = new ConfigCrudEvent($config->reveal()); + $this->storage->onConfigSaveAndDelete($event); + $checkpoint2 = $this->storage->checkpoint('2'); + + $fixture = [ + StorageInterface::DEFAULT_COLLECTION => [$this->randomMachineName()], + 'A' => [$this->randomMachineName()], + 'B' => [$this->randomMachineName()], + 'C' => [$this->randomMachineName()], + ]; + $this->setRandomFixtureConfig($fixture); + + $this->assertEquals(['A', 'B', 'C'], $this->storage->getAllCollectionNames()); + foreach (array_keys($fixture) as $collection) { + $storage = $this->storage->createCollection($collection); + // Assert that the collection storage is still a checkpoint storage. + $this->assertInstanceOf(CheckpointStorage::class, $storage); + $this->assertEquals($collection, $storage->getCollectionName()); + + // Ensure that the + // \Drupal\Core\Config\Checkpoint\CheckpointStorage::$readFromCheckpoint + // property is kept in sync. + $this->storage->setCheckpointToReadFrom($checkpoint2); + $this->assertSame($checkpoint2->id, $ref_readFromCheckpoint->getValue($storage->createCollection($collection))?->id); + if (isset($previous_collection)) { + $previous_collection->setCheckpointToReadFrom($checkpoint1); + $this->assertSame($checkpoint1->id, $ref_readFromCheckpoint->getValue($storage->createCollection($collection))?->id); + $this->assertSame($checkpoint1->id, $ref_readFromCheckpoint->getValue($this->storage->createCollection($collection))?->id); + } + + // Save the storage in a variable so we can test use + // setCheckpointToReadFrom() on it. + $previous_collection = $storage; + } + } + + /** + * @covers ::encode + * @covers ::decode + */ + public function testEncodeDecode(): void { + $array = (array) $this->getRandomGenerator()->object(); + $string = $this->getRandomGenerator()->string(); + + // Assert reversibility of encoding and decoding. + $this->assertEquals($array, $this->storage->decode($this->storage->encode($array))); + $this->assertEquals($string, $this->storage->encode($this->storage->decode($string))); + // Assert same results as the decorated storage. + $this->assertEquals($this->memory->encode($array), $this->storage->encode($array)); + $this->assertEquals($this->memory->decode($string), $this->storage->decode($string)); + } + + /** + * Generate random config in the memory storage. + * + * @param array $config + * The config keys, keyed by the collection. + */ + protected function setRandomFixtureConfig(array $config): void { + // Erase previous fixture. + foreach (array_merge([StorageInterface::DEFAULT_COLLECTION], $this->memory->getAllCollectionNames()) as $collection) { + $this->memory->createCollection($collection)->deleteAll(); + } + + foreach ($config as $collection => $keys) { + $storage = $this->memory->createCollection($collection); + foreach ($keys as $key) { + // Create some random config. + $storage->write($key, (array) $this->getRandomGenerator()->object()); + } + } + } + +} diff --git a/core/tests/Drupal/Tests/Core/Config/Checkpoint/LinearHistoryTest.php b/core/tests/Drupal/Tests/Core/Config/Checkpoint/LinearHistoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1a3ee6cd7ccef98bcae46c8768f9817498f7b1ea --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Config/Checkpoint/LinearHistoryTest.php @@ -0,0 +1,190 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Config\Checkpoint; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Component\Render\FormattableMarkup; +use Drupal\Core\Config\Checkpoint\Checkpoint; +use Drupal\Core\Config\Checkpoint\CheckpointExistsException; +use Drupal\Core\Config\Checkpoint\UnknownCheckpointException; +use Drupal\Core\Config\Checkpoint\LinearHistory; +use Drupal\Core\State\StateInterface; +use Drupal\Tests\UnitTestCase; +use Prophecy\Argument; + +/** + * @coversDefaultClass \Drupal\Core\Config\Checkpoint\LinearHistory + * @group Config + */ +class LinearHistoryTest extends UnitTestCase { + + /** + * The key used store of all the checkpoint names in state. + * + * @see \Drupal\Core\Config\Checkpoint\Checkpoints::CHECKPOINT_KEY + */ + private const CHECKPOINT_KEY = 'config.checkpoints'; + + /** + * @covers ::add + * @covers ::count + * @covers ::getActiveCheckpoint + * @covers \Drupal\Core\Config\Checkpoint\Checkpoint + */ + public function testAdd(): void { + $state = $this->prophesize(StateInterface::class); + $state->get(self::CHECKPOINT_KEY, [])->willReturn([]); + $state->set(self::CHECKPOINT_KEY, Argument::any())->willReturn(NULL); + $time = $this->prophesize(TimeInterface::class); + $time->getCurrentTime()->willReturn(1701539520, 1701539994); + $checkpoints = new LinearHistory($state->reveal(), $time->reveal()); + + $this->assertCount(0, $checkpoints); + $this->assertNull($checkpoints->getActiveCheckpoint()); + + $checkpoint = $checkpoints->add('hash1', 'Label'); + + $this->assertSame('hash1', $checkpoint->id); + $this->assertSame('Label', $checkpoint->label); + $this->assertNull($checkpoint->parent); + $this->assertSame(1701539520, $checkpoint->timestamp); + + $this->assertCount(1, $checkpoints); + $this->assertSame('hash1', $checkpoints->getActiveCheckpoint()?->id); + + // Test that on the second call to add the ancestor is set correctly. + $checkpoint2 = $checkpoints->add('hash2', new FormattableMarkup('Another label', [])); + $this->assertSame('hash2', $checkpoint2->id); + $this->assertSame('Another label', (string) $checkpoint2->label); + $this->assertSame($checkpoint->id, $checkpoint2->parent); + $this->assertSame(1701539994, $checkpoint2->timestamp); + + $this->assertCount(2, $checkpoints); + $this->assertSame('hash2', $checkpoints->getActiveCheckpoint()?->id); + + // Test that the checkpoints object can be iterated over. + $i = 0; + foreach ($checkpoints as $value) { + $i++; + $this->assertInstanceOf(Checkpoint::class, $value); + $this->assertSame('hash' . $i, $value->id); + } + } + + /** + * @covers ::add + */ + public function testAddException(): void { + $state = $this->prophesize(StateInterface::class); + $state->get(self::CHECKPOINT_KEY, [])->willReturn([]); + $state->set(self::CHECKPOINT_KEY, Argument::any())->willReturn(NULL); + $time = $this->prophesize(TimeInterface::class); + $time->getCurrentTime()->willReturn(1701539520, 1701539994); + $checkpoints = new LinearHistory($state->reveal(), $time->reveal()); + $checkpoints->add('hash1', 'Label'); + // Add another checkpoint with the same ID and an exception should be + // triggered. + $this->expectException(CheckpointExistsException::class); + $this->expectExceptionMessage('Cannot create a checkpoint with the ID "hash1" as it already exists'); + $checkpoints->add('hash1', 'Label'); + } + + /** + * @covers ::delete + */ + public function testDeleteAll(): void { + $state = $this->prophesize(StateInterface::class); + $state->get(self::CHECKPOINT_KEY, [])->willReturn([ + 'hash1' => new Checkpoint('hash1', 'One', 1701539510, NULL), + 'hash2' => new Checkpoint('hash2', 'Two', 1701539520, 'hash1'), + 'hash3' => new Checkpoint('hash3', 'Three', 1701539530, 'hash2'), + ]); + $state->delete(self::CHECKPOINT_KEY)->willReturn(); + $time = $this->prophesize(TimeInterface::class); + $checkpoints = new LinearHistory($state->reveal(), $time->reveal()); + + $this->assertCount(3, $checkpoints); + $this->assertSame('hash3', $checkpoints->getActiveCheckpoint()?->id); + $checkpoints->deleteAll(); + $this->assertCount(0, $checkpoints); + $this->assertNull($checkpoints->getActiveCheckpoint()); + } + + /** + * @covers ::delete + */ + public function testDelete(): void { + $state = $this->prophesize(StateInterface::class); + $test_data = [ + 'hash1' => new Checkpoint('hash1', 'One', 1701539510, NULL), + 'hash2' => new Checkpoint('hash2', 'Two', 1701539520, 'hash1'), + 'hash3' => new Checkpoint('hash3', 'Three', 1701539530, 'hash2'), + ]; + $state->get(self::CHECKPOINT_KEY, [])->willReturn($test_data); + unset($test_data['hash1'], $test_data['hash2']); + $state->set(self::CHECKPOINT_KEY, $test_data)->willReturn(); + $time = $this->prophesize(TimeInterface::class); + $checkpoints = new LinearHistory($state->reveal(), $time->reveal()); + + $this->assertCount(3, $checkpoints); + $this->assertSame('hash3', $checkpoints->getActiveCheckpoint()?->id); + $checkpoints->delete('hash2'); + $this->assertCount(1, $checkpoints); + $this->assertSame('hash3', $checkpoints->getActiveCheckpoint()?->id); + } + + /** + * @covers ::delete + */ + public function testDeleteException(): void { + $state = $this->prophesize(StateInterface::class); + $state->get(self::CHECKPOINT_KEY, [])->willReturn([]); + $time = $this->prophesize(TimeInterface::class); + $checkpoints = new LinearHistory($state->reveal(), $time->reveal()); + + $this->expectException(UnknownCheckpointException::class); + $this->expectExceptionMessage('Cannot delete a checkpoint with the ID "foo" as it does not exist'); + + $checkpoints->delete('foo'); + } + + /** + * @covers ::getParents + */ + public function testGetParents(): void { + $state = $this->prophesize(StateInterface::class); + $test_data = [ + 'hash1' => new Checkpoint('hash1', 'One', 1701539510, NULL), + 'hash2' => new Checkpoint('hash2', 'Two', 1701539520, 'hash1'), + 'hash3' => new Checkpoint('hash3', 'Three', 1701539530, 'hash2'), + ]; + $state->get(self::CHECKPOINT_KEY, [])->willReturn($test_data); + $time = $this->prophesize(TimeInterface::class); + $checkpoints = new LinearHistory($state->reveal(), $time->reveal()); + + $this->assertSame(['hash2' => $test_data['hash2'], 'hash1' => $test_data['hash1']], iterator_to_array($checkpoints->getParents('hash3'))); + $this->assertSame(['hash1' => $test_data['hash1']], iterator_to_array($checkpoints->getParents('hash2'))); + $this->assertSame([], iterator_to_array($checkpoints->getParents('hash1'))); + } + + /** + * @covers ::getParents + */ + public function testGetParentsException(): void { + $state = $this->prophesize(StateInterface::class); + $test_data = [ + 'hash1' => new Checkpoint('hash1', 'One', 1701539510, NULL), + 'hash2' => new Checkpoint('hash2', 'Two', 1701539520, 'hash1'), + ]; + $state->get(self::CHECKPOINT_KEY, [])->willReturn($test_data); + $time = $this->prophesize(TimeInterface::class); + $checkpoints = new LinearHistory($state->reveal(), $time->reveal()); + + $this->expectException(UnknownCheckpointException::class); + $this->expectExceptionMessage('The checkpoint "hash3" does not exist'); + iterator_to_array($checkpoints->getParents('hash3')); + } + +} diff --git a/core/tests/Drupal/Tests/Core/DefaultContent/FinderTest.php b/core/tests/Drupal/Tests/Core/DefaultContent/FinderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6abfb5b6733164623b159f4a3c2f5cb0cefcbcc9 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/DefaultContent/FinderTest.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\DefaultContent; + +use Drupal\Component\FileSystem\FileSystem; +use Drupal\Core\DefaultContent\Finder; +use Drupal\Core\DefaultContent\ImportException; +use Drupal\Tests\UnitTestCase; + +/** + * @covers \Drupal\Core\DefaultContent\Finder + * @group DefaultContent + */ +class FinderTest extends UnitTestCase { + + /** + * Tests that any discovered entity data is sorted into dependency order. + */ + public function testFoundDataIsInDependencyOrder(): void { + $finder = new Finder(__DIR__ . '/../../../../fixtures/default_content'); + + $expected_order = [ + // First is the author of the node. + '94503467-be7f-406c-9795-fc25baa22203', + // Next, the taxonomy term referenced by the node. + '550f86ad-aa11-4047-953f-636d42889f85', + // Then we have the node itself, since it has no other dependencies. + 'e1714f23-70c0-4493-8e92-af1901771921', + // Finally, the menu link to the node. + '3434bd5a-d2cd-4f26-bf79-a7f6b951a21b', + ]; + $this->assertSame($expected_order, array_slice(array_keys($finder->data), 0, 4)); + } + + /** + * Tests that files without UUIDs will raise an exception. + */ + public function testExceptionIfNoUuid(): void { + $dir = FileSystem::getOsTemporaryDirectory(); + $this->assertIsString($dir); + /** @var string $dir */ + file_put_contents($dir . '/no-uuid.yml', '_meta: {}'); + + $this->expectException(ImportException::class); + $this->expectExceptionMessage("$dir/no-uuid.yml does not have a UUID."); + new Finder($dir); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php b/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5ff124fbe0a93da3e090dfac299870648ddc28f7 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php @@ -0,0 +1,306 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Recipe; + +use Drupal\Core\Config\MemoryStorage; +use Drupal\Core\Config\NullStorage; +use Drupal\Core\Config\StorageInterface; +use Drupal\Core\Recipe\RecipeConfigStorageWrapper; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\Core\Recipe\RecipeConfigStorageWrapper + * @group Recipe + */ +class RecipeConfigStorageWrapperTest extends UnitTestCase { + + /** + * Validate that an empty set of storage backends returns null storage. + */ + public function testNullStorage(): void { + $this->assertInstanceOf( + NullStorage::class, + RecipeConfigStorageWrapper::createStorageFromArray([]) + ); + } + + /** + * Validate that a single storage returns exactly the same instance. + */ + public function testSingleStorage(): void { + $storages = [new NullStorage()]; + $this->assertSame( + $storages[0], + RecipeConfigStorageWrapper::createStorageFromArray($storages) + ); + } + + /** + * Validate that multiple storages return underlying values correctly. + */ + public function testMultipleStorages(): void { + $a = new MemoryStorage(); + $a->write('a_key', ['a_data_first']); + $b = new MemoryStorage(); + + // Add a conflicting key so that we can test the first value is returned. + $b->write('a_key', ['a_data_second']); + $b->write('b_key', ['b_data']); + + // We test with a third storage as well since only two storages can be done + // via the constructor alone. + $c = new MemoryStorage(); + $c->write('c_key', ['c_data']); + + $storages = [$a, $b, $c]; + $wrapped = RecipeConfigStorageWrapper::createStorageFromArray($storages); + + $this->assertSame($a->read('a_key'), $wrapped->read('a_key')); + $this->assertNotEquals($b->read('a_key'), $wrapped->read('a_key')); + $this->assertSame($b->read('b_key'), $wrapped->read('b_key')); + $this->assertSame($c->read('c_key'), $wrapped->read('c_key')); + } + + /** + * Validate that the first storage checks existence first. + */ + public function testLeftSideExists(): void { + $a = $this->createMock(StorageInterface::class); + $a->expects($this->once())->method('exists')->with('a_key') + ->willReturn(TRUE); + $b = $this->createMock(StorageInterface::class); + $b->expects($this->never())->method('exists'); + + $storage = new RecipeConfigStorageWrapper($a, $b); + $this->assertTrue($storage->exists('a_key')); + } + + /** + * Validate that we fall back to the second storage. + */ + public function testRightSideExists(): void { + [$a, $b] = $this->generateStorages(TRUE); + + $storage = new RecipeConfigStorageWrapper($a, $b); + $storage->exists('a_key'); + } + + /** + * Validate FALSE when neither storage contains a key. + */ + public function testNotExists(): void { + [$a, $b] = $this->generateStorages(FALSE); + + $storage = new RecipeConfigStorageWrapper($a, $b); + $this->assertFalse($storage->exists('a_key')); + } + + /** + * Validate that we read from storage A first. + */ + public function testReadFromA(): void { + $a = $this->createMock(StorageInterface::class); + $value = ['a_value']; + $a->expects($this->once())->method('read')->with('a_key') + ->willReturn($value); + $b = $this->createMock(StorageInterface::class); + $b->expects($this->never())->method('read'); + + $storage = new RecipeConfigStorageWrapper($a, $b); + $this->assertSame($value, $storage->read('a_key')); + } + + /** + * Validate that we read from storage B second. + */ + public function testReadFromB(): void { + $a = $this->createMock(StorageInterface::class); + $a->expects($this->once())->method('read')->with('a_key') + ->willReturn(FALSE); + $b = $this->createMock(StorageInterface::class); + $value = ['a_value']; + $b->expects($this->once())->method('read')->with('a_key') + ->willReturn($value); + + $storage = new RecipeConfigStorageWrapper($a, $b); + $this->assertSame($value, $storage->read('a_key')); + } + + /** + * Validate when neither storage can read a value. + */ + public function testReadFails(): void { + $a = $this->createMock(StorageInterface::class); + $a->expects($this->once())->method('read')->with('a_key') + ->willReturn(FALSE); + $b = $this->createMock(StorageInterface::class); + $b->expects($this->once())->method('read')->with('a_key') + ->willReturn(FALSE); + + $storage = new RecipeConfigStorageWrapper($a, $b); + $this->assertFalse($storage->read('a_key')); + } + + /** + * Test reading multiple values. + */ + public function testReadMultiple(): void { + $a = $this->createMock(StorageInterface::class); + $a->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key']) + ->willReturn(['a_key' => ['a_value']]); + $b = $this->createMock(StorageInterface::class); + $b->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key']) + ->willReturn(['b_key' => ['b_value']]); + + $storage = new RecipeConfigStorageWrapper($a, $b); + $this->assertEquals([ + 'a_key' => ['a_value'], + 'b_key' => ['b_value'], + ], $storage->readMultiple(['a_key', 'b_key'])); + } + + /** + * Test that storage A has precedence over storage B. + */ + public function testReadMultipleStorageA(): void { + $a = $this->createMock(StorageInterface::class); + $a->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key']) + ->willReturn(['a_key' => ['a_value']]); + $b = $this->createMock(StorageInterface::class); + $b->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key']) + ->willReturn(['a_key' => ['a_conflicting_value'], 'b_key' => ['b_value']]); + + $storage = new RecipeConfigStorageWrapper($a, $b); + $this->assertEquals([ + 'a_key' => ['a_value'], + 'b_key' => ['b_value'], + ], $storage->readMultiple(['a_key', 'b_key'])); + } + + /** + * Test methods that are unsupported. + * + * @param string $method + * The method to call. + * @param array $args + * The arguments to pass to the method. + * + * @testWith ["write", "name", []] + * ["delete", "name"] + * ["rename", "old_name", "new_name"] + * ["deleteAll"] + */ + public function testUnsupportedMethods(string $method, ...$args): void { + $this->expectException(\BadMethodCallException::class); + $storage = new RecipeConfigStorageWrapper(new NullStorage(), new NullStorage()); + $storage->{$method}(...$args); + } + + /** + * Test that we only use storage A's encode method. + */ + public function testEncode(): void { + $a = $this->createMock(StorageInterface::class); + $b = $this->createMock(StorageInterface::class); + $storage = new RecipeConfigStorageWrapper($a, $b); + $this->expectException(\BadMethodCallException::class); + $storage->encode(['value']); + } + + /** + * Test that we only use storage A's decode method. + */ + public function testDecode(): void { + $a = $this->createMock(StorageInterface::class); + $b = $this->createMock(StorageInterface::class); + $storage = new RecipeConfigStorageWrapper($a, $b); + $this->expectException(\BadMethodCallException::class); + $storage->decode('value'); + } + + /** + * Test that list all merges values and makes them unique. + */ + public function testListAll(): void { + $a = $this->createMock(StorageInterface::class); + $a->method('listAll')->with('node.') + ->willReturn(['node.type']); + $b = $this->createMock(StorageInterface::class); + $b->method('listAll')->with('node.') + ->willReturn(['node.type', 'node.id']); + $storage = new RecipeConfigStorageWrapper($a, $b); + $this->assertEquals([ + 0 => 'node.type', + 2 => 'node.id', + ], $storage->listAll('node.')); + } + + /** + * Test creating a collection passes the name through to the child storages. + */ + public function testCreateCollection(): void { + $collection_name = 'collection'; + $a = $this->createMock(StorageInterface::class); + $b = $this->createMock(StorageInterface::class); + /** @var \PHPUnit\Framework\MockObject\MockObject $mock */ + foreach ([$a, $b] as $mock) { + $mock->expects($this->once())->method('createCollection') + ->with($collection_name)->willReturn(new NullStorage($collection_name)); + } + $storage = new RecipeConfigStorageWrapper($a, $b); + $new = $storage->createCollection($collection_name); + $this->assertInstanceOf(RecipeConfigStorageWrapper::class, $new); + $this->assertEquals($collection_name, $new->getCollectionName()); + $this->assertNotEquals($storage, $new); + } + + /** + * Test that we merge and return only unique collection names. + */ + public function testGetAllCollectionNames(): void { + $a = $this->createMock(StorageInterface::class); + $a->expects($this->once())->method('getAllCollectionNames') + ->willReturn(['collection_1', 'collection_2']); + $b = $this->createMock(StorageInterface::class); + $b->expects($this->once())->method('getAllCollectionNames') + ->willReturn(['collection_3', 'collection_1', 'collection_2']); + $storage = new RecipeConfigStorageWrapper($a, $b); + $this->assertEquals([ + 'collection_1', + 'collection_2', + 'collection_3', + ], $storage->getAllCollectionNames()); + } + + /** + * Test the collection name is stored properly. + */ + public function testGetCollection(): void { + $a = $this->createMock(StorageInterface::class); + $b = $this->createMock(StorageInterface::class); + $storage = new RecipeConfigStorageWrapper($a, $b, 'collection'); + $this->assertEquals('collection', $storage->getCollectionName()); + } + + /** + * Generate two storages where the second storage should return a value. + * + * @param bool $b_return + * The return value for storage $b's exist method. + * + * @return \Drupal\Core\Config\StorageInterface[] + * An array of two mocked storages. + */ + private function generateStorages(bool $b_return): array { + $a = $this->createMock(StorageInterface::class); + $a->expects($this->once())->method('exists')->with('a_key') + ->willReturn(FALSE); + $b = $this->createMock(StorageInterface::class); + $b->expects($this->once())->method('exists')->with('a_key') + ->willReturn($b_return); + return [$a, $b]; + } + +} diff --git a/core/tests/Drupal/Tests/Core/Recipe/RecipeQuickStartTest.php b/core/tests/Drupal/Tests/Core/Recipe/RecipeQuickStartTest.php new file mode 100644 index 0000000000000000000000000000000000000000..35505fe1fe5b294c9bf1b8f617644ebabffd674c --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Recipe/RecipeQuickStartTest.php @@ -0,0 +1,181 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Recipe; + +use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks; +use Drupal\Core\Test\TestDatabase; +use Drupal\Tests\BrowserTestBase; +use GuzzleHttp\Client; +use GuzzleHttp\Cookie\CookieJar; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * Tests the quick-start command with recipes. + * + * These tests are run in a separate process because they load Drupal code via + * an include. + * + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled + * @requires extension pdo_sqlite + * + * @group Command + * @group Recipe + */ +class RecipeQuickStartTest extends TestCase { + + /** + * The PHP executable path. + * + * @var string + */ + protected string $php; + + /** + * A test database object. + * + * @var \Drupal\Core\Test\TestDatabase + */ + protected TestDatabase $testDb; + + /** + * The Drupal root directory. + * + * @var string + */ + protected string $root; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $php_executable_finder = new PhpExecutableFinder(); + $this->php = (string) $php_executable_finder->find(); + $this->root = dirname(substr(__DIR__, 0, -strlen(__NAMESPACE__)), 2); + if (!is_writable("{$this->root}/sites/simpletest")) { + $this->markTestSkipped('This test requires a writable sites/simpletest directory'); + } + // Get a lock and a valid site path. + $this->testDb = new TestDatabase(); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + if ($this->testDb) { + $test_site_directory = $this->root . DIRECTORY_SEPARATOR . $this->testDb->getTestSitePath(); + if (file_exists($test_site_directory)) { + // @todo use the tear down command from + // https://www.drupal.org/project/drupal/issues/2926633 + // Delete test site directory. + $this->fileUnmanagedDeleteRecursive($test_site_directory, BrowserTestBase::filePreDeleteCallback(...)); + } + } + parent::tearDown(); + } + + /** + * Tests the quick-start command with a recipe. + */ + public function testQuickStartRecipeCommand(): void { + $sqlite = (string) (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0]; + if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) { + $this->markTestSkipped(); + } + + // Install a site using the standard recipe to ensure the one time login + // link generation works. + + $install_command = [ + $this->php, + 'core/scripts/drupal', + 'quick-start', + 'core/recipes/standard', + "--site-name='Test site {$this->testDb->getDatabasePrefix()}'", + '--suppress-login', + ]; + $process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]); + $process->setTimeout(500); + $process->start(); + $guzzle = new Client(); + $port = FALSE; + $process->waitUntil(function ($type, $output) use (&$port) { + if (preg_match('/127.0.0.1:(\d+)/', $output, $match)) { + $port = $match[1]; + return TRUE; + } + }); + // The progress bar uses STDERR to write messages. + $this->assertStringContainsString('Congratulations, you installed Drupal!', $process->getErrorOutput()); + // Ensure the command does not trigger any PHP deprecations. + $this->assertStringNotContainsStringIgnoringCase('deprecated', $process->getErrorOutput()); + $this->assertNotFalse($port, "Web server running on port $port"); + + // Give the server a couple of seconds to be ready. + sleep(2); + $this->assertStringContainsString("127.0.0.1:$port/user/reset/1/", $process->getOutput()); + + // Generate a cookie so we can make a request against the installed site. + define('DRUPAL_TEST_IN_CHILD_SITE', FALSE); + chmod($this->testDb->getTestSitePath(), 0755); + $cookieJar = CookieJar::fromArray([ + 'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()), + ], '127.0.0.1'); + + $response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]); + $content = (string) $response->getBody(); + $this->assertStringContainsString('Test site ' . $this->testDb->getDatabasePrefix(), $content); + // Test content from Standard front page. + $this->assertStringContainsString('Congratulations and welcome to the Drupal community.', $content); + + // Stop the web server. + $process->stop(); + } + + /** + * Deletes all files and directories in the specified path recursively. + * + * Note this method has no dependencies on Drupal core to ensure that the + * test site can be torn down even if something in the test site is broken. + * + * @param string $path + * A string containing either a URI or a file or directory path. + * @param callable $callback + * (optional) Callback function to run on each file prior to deleting it and + * on each directory prior to traversing it. For example, can be used to + * modify permissions. + * + * @return bool + * TRUE for success or if path does not exist, FALSE in the event of an + * error. + * + * @see \Drupal\Core\File\FileSystemInterface::deleteRecursive() + */ + protected function fileUnmanagedDeleteRecursive($path, $callback = NULL): bool { + if (isset($callback)) { + call_user_func($callback, $path); + } + if (is_dir($path)) { + $dir = dir($path); + assert($dir instanceof \Directory); + while (($entry = $dir->read()) !== FALSE) { + if ($entry == '.' || $entry == '..') { + continue; + } + $entry_path = $path . '/' . $entry; + $this->fileUnmanagedDeleteRecursive($entry_path, $callback); + } + $dir->close(); + + return rmdir($path); + } + return unlink($path); + } + +} diff --git a/core/tests/fixtures/default_content/a6b79928-838f-44bd-a8f0-44c2fff9e4cc.yml b/core/tests/fixtures/default_content/a6b79928-838f-44bd-a8f0-44c2fff9e4cc.yml new file mode 100644 index 0000000000000000000000000000000000000000..f126741702a33b94e93b48bbc43a76f631178006 --- /dev/null +++ b/core/tests/fixtures/default_content/a6b79928-838f-44bd-a8f0-44c2fff9e4cc.yml @@ -0,0 +1,27 @@ +_meta: + version: '1.0' + entity_type: file + uuid: a6b79928-838f-44bd-a8f0-44c2fff9e4cc + default_langcode: en +default: + uid: + - + target_id: 1 + filename: + - + value: druplicon-different.png + uri: + - + value: 'public://2024-03/druplicon.png' + filemime: + - + value: text/plain + filesize: + - + value: 11 + status: + - + value: true + created: + - + value: 1711121742 diff --git a/core/tests/fixtures/default_content/block_content/d9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf.yml b/core/tests/fixtures/default_content/block_content/d9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf.yml new file mode 100644 index 0000000000000000000000000000000000000000..df66730670da76403c02e5f99fe5774ed0181ce0 --- /dev/null +++ b/core/tests/fixtures/default_content/block_content/d9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf.yml @@ -0,0 +1,24 @@ +_meta: + version: '1.0' + entity_type: block_content + uuid: d9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf + bundle: basic + default_langcode: en +default: + status: + - + value: true + info: + - + value: 'Useful Info' + reusable: + - + value: true + revision_translation_affected: + - + value: true + body: + - + value: "I'd love to put some useful info here." + format: plain_text + summary: '' diff --git a/core/tests/fixtures/default_content/druplicon.png b/core/tests/fixtures/default_content/druplicon.png new file mode 100644 index 0000000000000000000000000000000000000000..51bb1074d33efe7eb561a572ca148b105657fd81 --- /dev/null +++ b/core/tests/fixtures/default_content/druplicon.png @@ -0,0 +1 @@ +Not a PNG. diff --git a/core/tests/fixtures/default_content/file/23a7f61f-1db3-407d-a6dd-eb4731995c9f.yml b/core/tests/fixtures/default_content/file/23a7f61f-1db3-407d-a6dd-eb4731995c9f.yml new file mode 100644 index 0000000000000000000000000000000000000000..f64d6f0eff580399f88bc7335df07d9b9372fb23 --- /dev/null +++ b/core/tests/fixtures/default_content/file/23a7f61f-1db3-407d-a6dd-eb4731995c9f.yml @@ -0,0 +1,27 @@ +_meta: + version: '1.0' + entity_type: file + uuid: 23a7f61f-1db3-407d-a6dd-eb4731995c9f + default_langcode: en +default: + uid: + - + target_id: 1 + filename: + - + value: druplicon-duplicate.png + uri: + - + value: 'public://2024-03/druplicon.png' + filemime: + - + value: image/png + filesize: + - + value: 3905 + status: + - + value: true + created: + - + value: 1711121742 diff --git a/core/tests/fixtures/default_content/file/2b8e0616-3ef0-4a91-8cfb-b31d9128f9f8.yml b/core/tests/fixtures/default_content/file/2b8e0616-3ef0-4a91-8cfb-b31d9128f9f8.yml new file mode 100644 index 0000000000000000000000000000000000000000..d07b0e634661a027deeb3890862f481f583aea82 --- /dev/null +++ b/core/tests/fixtures/default_content/file/2b8e0616-3ef0-4a91-8cfb-b31d9128f9f8.yml @@ -0,0 +1,27 @@ +_meta: + version: '1.0' + entity_type: file + uuid: 2b8e0616-3ef0-4a91-8cfb-b31d9128f9f8 + default_langcode: en +default: + uid: + - + target_id: 1 + filename: + - + value: dce9cdc3-d9fc-4d37-849d-105e913bb5ad.png + uri: + - + value: 'public://2024-03/dce9cdc3-d9fc-4d37-849d-105e913bb5ad.png' + filemime: + - + value: image/png + filesize: + - + value: 1233169 + status: + - + value: true + created: + - + value: 1711729897 diff --git a/core/tests/fixtures/default_content/file/7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d.yml b/core/tests/fixtures/default_content/file/7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d.yml new file mode 100644 index 0000000000000000000000000000000000000000..863174926295eaf037ef2398d70def7f1054ae03 --- /dev/null +++ b/core/tests/fixtures/default_content/file/7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d.yml @@ -0,0 +1,27 @@ +_meta: + version: '1.0' + entity_type: file + uuid: 7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d + default_langcode: en +default: + uid: + - + target_id: 1 + filename: + - + value: druplicon_copy.png + uri: + - + value: 'public://druplicon_copy.png' + filemime: + - + value: image/png + filesize: + - + value: 3905 + status: + - + value: true + created: + - + value: 1711121742 diff --git a/core/tests/fixtures/default_content/file/d8404562-efcc-40e3-869e-40132d53fe0b.yml b/core/tests/fixtures/default_content/file/d8404562-efcc-40e3-869e-40132d53fe0b.yml new file mode 100644 index 0000000000000000000000000000000000000000..7b2a0aa83a6d81d90a4b84e5ab3788c2eb6b80eb --- /dev/null +++ b/core/tests/fixtures/default_content/file/d8404562-efcc-40e3-869e-40132d53fe0b.yml @@ -0,0 +1,27 @@ +_meta: + version: '1.0' + entity_type: file + uuid: d8404562-efcc-40e3-869e-40132d53fe0b + default_langcode: en +default: + uid: + - + target_id: 1 + filename: + - + value: druplicon.png + uri: + - + value: 'public://2024-03/druplicon.png' + filemime: + - + value: image/png + filesize: + - + value: 3905 + status: + - + value: true + created: + - + value: 1711121742 diff --git a/core/tests/fixtures/default_content/file/druplicon.png b/core/tests/fixtures/default_content/file/druplicon.png new file mode 100644 index 0000000000000000000000000000000000000000..3b49a4ce78dc8b1ce754706f400b3b61a99857d1 --- /dev/null +++ b/core/tests/fixtures/default_content/file/druplicon.png @@ -0,0 +1,18 @@ +‰PNG + +��� IHDR���X���d���-÷â��IDATxÚí] PTW}€‰FͦÉĤ’Te2©ÔÄL*“Ô$•É:¥©LM1jÔ¸ÄwA6Ã*;"›H@\AEAQDÅEÁ]dß¡YîÜ÷œ/{ó»û74ðOÕ-–æÿþÿô}çÝ{ß}¢ò0tË$¦>ïʺŽMѵo!¦Û'ÃÔwò"à••û€è;WÃÄ{•]Ôuøe3? ³Ý²ˆ1ÛýÒñôbM)²æ#4ÏSD„@˜õGkQM3$åV?!Ø<ˆc/o"BA˜øÌúÊzPd<ªg3[}ˆ®So"BºFÞ(�ŠÌ²FÐXßIòÊh Ú6€½žˆor{wÙhïè�ŠÛèÁc7œ¦äv'Yמ’¼ŽˆIÆ7rnK-¬Çn—QR{Ûªã€:M59„ˆàÓíS×µo=ŒÒÀ¡º©–GeöI0§É('OB¸9¾DD¿ž«ý‚‰s{ìGÀ¡¥ö¥–Â.—(™RÅɳ¶¶¢dXݼvz`Ö'ëC »¼8T5¶Bȵ0O—Fl/]f±²¾S Ö/¾'£&Þ+'9µyÇgà„ ’vHÌ®ßKEðkÄP[KÉ“Ñ–¢çHcæÇHôdÔaÎöÐk³°Ûy•À¡ º‰-Ÿ‹OlšÿuJ˜ü¶ä %™zt-JÐ"2*`ìí2ÉØþLÊ‚¾p«¤þ)Á®çò)QŠÛÊ@‚i‚ҌǶ‘*Æ97¯¿�eõ-ИDpÛÄçR‚„³Ç�%ˆ–ujuìH‰Œ1tnX…ÕM0nÕ=%ø‡ÀB‘Û;~6ݬÎlâm>\£ƒ7ˆá¶Bm×(¸÷¨x�=[~—‹¹Ž á¹,{Sž8Jãg ׉×ûÚ0™Àvh#÷›BNy=ð‹ywßxÄÈõN*„÷œ.RÇ–F�ѱbì¹WÕuÖd¬¡“dëÑ AÂøB‚1Ú¡ôÇO¥áçà›ƒG.g«OÒ”›®œTÓŒRÕä`<µ»Ÿo +…ŒâZ8©Hë$×do¨Ñ*[´ÈL+ZD²Qr?TÓshqŒ¹ Xü’ µÍmœ,0[qÔ×Æ™ÒëúNT2b‡:B˜÷¼‰KG4.ëÈŠŠ K…9rWË‚1\ÍWÌ<Ž.OÑBRÞ§ú$^Aï,ñ…Û%µ +Jj[ (¹ø)¹‹"ï³ëYÄ z6ןzs5JƸA$×3âk«=¬ #+r*šX#×`w:¨³zƒ +Û¯¡tò«E’ǹa³=£iqF&4µ¶C\fÅSb½Ñ¦°DbxØü]”äJLõÕ•)þ¦ÛO ¹²±û ¼‚¯vJ‚WR|âqeøË™“‹"eŸî†^Ç¡M×mÂ,¶Ók™m8þ�^³97ìÈåŒM|ÚÖ»Å|´¾·¤Ã\n¯õF›šÆE +ÃØâØŠ6®ž,ª?ìå·û¶×4óLz{CÂCnÙgD›ô4-š‘dÅ»>ÇÏñ,={¿B+JÇõÂZ¿ºk^Ø-˜°é̈!—«] S’‹ÈëÔ™ÔÂç'A³i šœV\‡rPÒÍk—¾STOk…+à¹?‘Š™["åÕÝÞúÍ—•û�#ýJ~ ø_)êFì–ؘºÉÁÈ6cÏ'¥Î›Ú‰Ží7DfèÚ7/‰Hg¤¾_ ¥u-]Ñ)µq‹gº™KbüÇï:×â4òm®ß/6p¦^üXVip;?<.Pò¤ÓYCÌÆ&nf:;zÌŒÌ=˜FË oè9JÞ´=+•XO$ξxÝ–éìè³9¾ÁL*ЋËøzo�®JÀ³Ølçv>¿±6§ráû€\d0zÍÔ‡’ÛÝ‹µmÖòizn#«bØI^´L€¯}¯ÁŒàTøÔ3&m9K/7Éu%x¶+Nx›«¥†mxP8Ö>Eòx,ÿ#©½MËšJÅÒ/tìÚñ`‘Àì·ý}làB½¸¢¿bÎz,dˆäñ,]"¡}ÛLKQ|GzÙ/&ˉäñYJ2ñêŸ`}G*ñ=.Ÿ!Ú¶£ž<>†NˆDJ5Ìî{6ä9a,'’ÇK‚&˜É„Ýô®;zî“!"y¼Š<ŒL#Š½]c_‰=ä½\}BÓ"Óß—0<Éãûò·Ÿ7¶±¤Ã3Ô`‘Àl^,s:üAM9‰K "ÒlY¤lär:¬m³…ÐÖ ÌLDû³U1\Ü+“±M8ZV‡ ~SG–‰ì;©à +ë²+Ä[¦ÓúC†"™}Ù‚J–|f¸Ö%Ê Ûb'’ÙÓï‘›\®Ï2:1E–R-SÐàšdˆ„vµ¥…! %¢•Nr"©œÑÉÞÈCH‚Û‘`§¡¸Öl–kŽe‚û¹‡p õÄÞ+¿K`qâ>˜á‚ê4ÿ”A–F®p¦i ”àAÕàop/üz14JÚ€V6Á¶Ä‡ð¹W²ò6Ç,“™¼6ì†7–DpÁ«öÁ ö÷+ÌCAJA ü´ó†€qn,¦À;y“úÖò OºmíÀ¡º¡ÌwŸ5ã> –PnU®ÄƒuÜÖ´-2pçèWÛ¯*¾“ÈŒ_ñÂüívþH»… „ôÞ$kZ4ÒI®YYqð‹ ”SÊBèµ"˜luV¾ô×Ô‡¹Æ>'àqM#ðÁÖÃWz\G%¢Šü%8¹ã°qå\v%(• ÐKãß<½h¯Éìí߃àøÍ-ðâß®QD%}|a*¾©à²‘ö‡n•Â«Ö‰Ò½v®ÿ@IJa¾&üv¶<X¼óôÿSeZ‹¸C[¥<É/A‚üû‘»0` :!©}'ÆWÄ>\—ïƒ"ˆMÍíZM;JwkNÅ!#¹¯Y'²ýsC‰°”bxÉ2nðæµÌ3ÎÌ‹égö<+Š{E•]ëÁ6„AÏA¨P% +ª€Š†0öÜ/möCîã +õÍ’Î6*»i„·Š +1ÑÛxš=HN•p#·¦Û÷Z‘`¿K¼#¼3WÕs„„8i +”ˆxlR˜`½Ð4PU´¶·CRf¾z$˜((î±Z0&»vöL%†Š7ž\.€ÑŽÐó·ÙCKQ"BI'˜L4áÂ"øTf9ŒvXG\¢ÑÕßoI'˜LD“yÁ +œUÖ�ªŒìÒjH¸Êîå¦ F}_O.™¢hm8»¼T iye ívÔM:³·gçxÁ†}Ø?¡ñ¯õ»¨G‘>¡ï\¡È +óÕüPœÎȃ/¥/ý¼¹,†h‚FêZ[:P>èoóálE&»“wË`¨‘ƒ20Ëw›#ùv¡0s‡ß©Tž©ÀÇZÉéŬ,9D`¾Å‹˜•yË\<Ÿ¼È’”€¢˜f +¸ˆ¼z éÊÛ+ü±Ûel`–ÁÑZÞ™8ÏâÓó@^$c CMÓ¢’ð{tÊÒ¹HÎ(©ƒÁB^y-ü›ÓYLÝØ¢R(à½6æ„L¼¾Á}mdE´ÌáÌV/”‰ôü2˜ézT*Y~ñ#Ÿã`…qéG’Á!*V…%‚©ïIcâÙïqϘzÂÁ+™ NÜÌ¡Ú[Cd‚–u�«-9 3ÉNgr•3=®†9;Nr!WŸö:.DF&g°5µû.ô{ü�˜ìðA FSÌœ1r°Õ#2CÓ²˜æÕl_îrÙv YÅ +7á=ªn€å! 4~•æµlñ±Y¿P¾0(¾ßsià‡¸+1¤€Ôï¬ÂG{’¼ÿKèŸHr;NzìMYç‚]Àôye4·öÿ M|}†ßEx\Û$ɱ® ¬#/ HìÖþ§»š2 +ÊÙêÂÎGàXHÿÖî Ìóƒ#×î3b¸%žWû ô¡õ’<îØŸœÑ¬ GúKD^ TØbfÒ!u‚0ñÌ{ïßE{n®7˜íˆ¥)*¯Ì)³¸\cRàÓÍü›ï¢¯g‡ÊúfÐõˆ–ú÷›÷'ÔèÎÏ>œS·òÑ( ìýÞ[½‹õ=`XfJDôfAfjì- Enô¬Ã ç óâ`†Kü}íŸtXÊz>v,Œe©GòZw;·("®dÉu¨¹´b¶ŸÝ?Ô‘ä|¬¸Ñ“«”}´!ŒŒ¨Tð>îÁ¬>üþš9º×è‚fzŽÏã(Å¢*GòX3¯Á{?t2ÔÝÑÏÁ¡c÷ +ž¼ŠóäQfÜ£¼jq÷ÐÛDiвz‹ëqÂè"ï«ì¯ƒñ¯ '¡å£6ò0E–ZË`uræ¹['‘Á +½zóeœM•§ÿ. >Q× AJy}ÏÈÇšFfIeÿûÝ4-óñëd2$À”½¹3>ÁoÞ>*¹[Ö„Í!´ÓQáó>çX¼3RrJÆé=þÎ Ðh–vž9ÓBÛfÕ'¼A 6ßØWy’Ü®Æ×0 õ_^õÞŸ0£³‰¼LK’½2³$\jŸò›ÏHA‚÷eIT¨Çð?ϲ¾Ù®BÌâZ‡(©Õ9$¼ƒeoËjX•íbfm(a-L¥5 ìõþ€é4M¥{H‚•„œÌÞ'ª¼0=œªð+p5Eí3˽›&H…Ž%%Ç®gÓ°{æ¨kO½¶ %oQuà«á…zŸ7µ ÑBIæÿ!°µ»Íé<ߎÖ>pæüO'ËÞ#Ö*'²—É°‚ÓxÔ±@ôèJ4'BÙ_ûÃÔu¡ÌÿÛz鬖ëQ¶ÊñÁÚPܤØO%Îy,^W;ǪaÃè%DÛÚ¥Ú83ãÏC—,hÛИMx{‘ØWɈÃ,»/P>bˆ&ÍŠliáD^æ_˜a¤nnÁ+Ë‹‘Xu2€žüÞ4’mQȶ<!á°0Iæ¸ÚЕ‡ÇSBÙðG£#&G0pžŽæ€¤Ç#9¹ìa›3-ëñç4è4K$šE+Z#náÏÙøZŽ7ü tØD«ø~I]Eí¸����IEND®B`‚ \ No newline at end of file diff --git a/core/tests/fixtures/default_content/file/druplicon_copy.png b/core/tests/fixtures/default_content/file/druplicon_copy.png new file mode 100644 index 0000000000000000000000000000000000000000..3b49a4ce78dc8b1ce754706f400b3b61a99857d1 --- /dev/null +++ b/core/tests/fixtures/default_content/file/druplicon_copy.png @@ -0,0 +1,18 @@ +‰PNG + +��� IHDR���X���d���-÷â��IDATxÚí] PTW}€‰FͦÉĤ’Te2©ÔÄL*“Ô$•É:¥©LM1jÔ¸ÄwA6Ã*;"›H@\AEAQDÅEÁ]dß¡YîÜ÷œ/{ó»û74ðOÕ-–æÿþÿô}çÝ{ß}¢ò0tË$¦>ïʺŽMѵo!¦Û'ÃÔwò"à••û€è;WÃÄ{•]Ôuøe3? ³Ý²ˆ1ÛýÒñôbM)²æ#4ÏSD„@˜õGkQM3$åV?!Ø<ˆc/o"BA˜øÌúÊzPd<ªg3[}ˆ®So"BºFÞ(�ŠÌ²FÐXßIòÊh Ú6€½žˆor{wÙhïè�ŠÛèÁc7œ¦äv'Yמ’¼ŽˆIÆ7rnK-¬Çn—QR{Ûªã€:M59„ˆàÓíS×µo=ŒÒÀ¡º©–GeöI0§É('OB¸9¾DD¿ž«ý‚‰s{ìGÀ¡¥ö¥–Â.—(™RÅɳ¶¶¢dXݼvz`Ö'ëC »¼8T5¶Bȵ0O—Fl/]f±²¾S Ö/¾'£&Þ+'9µyÇgà„ ’vHÌ®ßKEðkÄP[KÉ“Ñ–¢çHcæÇHôdÔaÎöÐk³°Ûy•À¡ º‰-Ÿ‹OlšÿuJ˜ü¶ä %™zt-JÐ"2*`ìí2ÉØþLÊ‚¾p«¤þ)Á®çò)QŠÛÊ@‚i‚ҌǶ‘*Æ97¯¿�eõ-ИDpÛÄçR‚„³Ç�%ˆ–ujuìH‰Œ1tnX…ÕM0nÕ=%ø‡ÀB‘Û;~6ݬÎlâm>\£ƒ7ˆá¶Bm×(¸÷¨x�=[~—‹¹Ž á¹,{Sž8Jãg ׉×ûÚ0™Àvh#÷›BNy=ð‹ywßxÄÈõN*„÷œ.RÇ–F�ѱbì¹WÕuÖd¬¡“dëÑ AÂøB‚1Ú¡ôÇO¥áçà›ƒG.g«OÒ”›®œTÓŒRÕä`<µ»Ÿo +…ŒâZ8©Hë$×do¨Ñ*[´ÈL+ZD²Qr?TÓshqŒ¹ Xü’ µÍmœ,0[qÔ×Æ™ÒëúNT2b‡:B˜÷¼‰KG4.ëÈŠŠ K…9rWË‚1\ÍWÌ<Ž.OÑBRÞ§ú$^Aï,ñ…Û%µ +Jj[ (¹ø)¹‹"ï³ëYÄ z6ןzs5JƸA$×3âk«=¬ #+r*šX#×`w:¨³zƒ +Û¯¡tò«E’ǹa³=£iqF&4µ¶C\fÅSb½Ñ¦°DbxØü]”äJLõÕ•)þ¦ÛO ¹²±û ¼‚¯vJ‚WR|âqeøË™“‹"eŸî†^Ç¡M×mÂ,¶Ók™m8þ�^³97ìÈåŒM|ÚÖ»Å|´¾·¤Ã\n¯õF›šÆE +ÃØâØŠ6®ž,ª?ìå·û¶×4óLz{CÂCnÙgD›ô4-š‘dÅ»>ÇÏñ,={¿B+JÇõÂZ¿ºk^Ø-˜°é̈!—«] S’‹ÈëÔ™ÔÂç'A³i šœV\‡rPÒÍk—¾STOk…+à¹?‘Š™["åÕÝÞúÍ—•û�#ýJ~ ø_)êFì–ؘºÉÁÈ6cÏ'¥Î›Ú‰Ží7DfèÚ7/‰Hg¤¾_ ¥u-]Ñ)µq‹gº™KbüÇï:×â4òm®ß/6p¦^üXVip;?<.Pò¤ÓYCÌÆ&nf:;zÌŒÌ=˜FË oè9JÞ´=+•XO$ξxÝ–éìè³9¾ÁL*ЋËøzo�®JÀ³Ølçv>¿±6§ráû€\d0zÍÔ‡’ÛÝ‹µmÖòizn#«bØI^´L€¯}¯ÁŒàTøÔ3&m9K/7Éu%x¶+Nx›«¥†mxP8Ö>Eòx,ÿ#©½MËšJÅÒ/tìÚñ`‘Àì·ý}làB½¸¢¿bÎz,dˆäñ,]"¡}ÛLKQ|GzÙ/&ˉäñYJ2ñêŸ`}G*ñ=.Ÿ!Ú¶£ž<>†NˆDJ5Ìî{6ä9a,'’ÇK‚&˜É„Ýô®;zî“!"y¼Š<ŒL#Š½]c_‰=ä½\}BÓ"Óß—0<Éãûò·Ÿ7¶±¤Ã3Ô`‘Àl^,s:üAM9‰K "ÒlY¤lär:¬m³…ÐÖ ÌLDû³U1\Ü+“±M8ZV‡ ~SG–‰ì;©à +ë²+Ä[¦ÓúC†"™}Ù‚J–|f¸Ö%Ê Ûb'’ÙÓï‘›\®Ï2:1E–R-SÐàšdˆ„vµ¥…! %¢•Nr"©œÑÉÞÈCH‚Û‘`§¡¸Öl–kŽe‚û¹‡p õÄÞ+¿K`qâ>˜á‚ê4ÿ”A–F®p¦i ”àAÕàop/üz14JÚ€V6Á¶Ä‡ð¹W²ò6Ç,“™¼6ì†7–DpÁ«öÁ ö÷+ÌCAJA ü´ó†€qn,¦À;y“úÖò OºmíÀ¡º¡ÌwŸ5ã> –PnU®ÄƒuÜÖ´-2pçèWÛ¯*¾“ÈŒ_ñÂüívþH»… „ôÞ$kZ4ÒI®YYqð‹ ”SÊBèµ"˜luV¾ô×Ô‡¹Æ>'àqM#ðÁÖÃWz\G%¢Šü%8¹ã°qå\v%(• ÐKãß<½h¯Éìí߃àøÍ-ðâß®QD%}|a*¾©à²‘ö‡n•Â«Ö‰Ò½v®ÿ@IJa¾&üv¶<X¼óôÿSeZ‹¸C[¥<É/A‚üû‘»0` :!©}'ÆWÄ>\—ïƒ"ˆMÍíZM;JwkNÅ!#¹¯Y'²ýsC‰°”bxÉ2nðæµÌ3ÎÌ‹égö<+Š{E•]ëÁ6„AÏA¨P% +ª€Š†0öÜ/möCîã +õÍ’Î6*»i„·Š +1ÑÛxš=HN•p#·¦Û÷Z‘`¿K¼#¼3WÕs„„8i +”ˆxlR˜`½Ð4PU´¶·CRf¾z$˜((î±Z0&»vöL%†Š7ž\.€ÑŽÐó·ÙCKQ"BI'˜L4áÂ"øTf9ŒvXG\¢ÑÕßoI'˜LD“yÁ +œUÖ�ªŒìÒjH¸Êîå¦ F}_O.™¢hm8»¼T iye ívÔM:³·gçxÁ†}Ø?¡ñ¯õ»¨G‘>¡ï\¡È +óÕüPœÎȃ/¥/ý¼¹,†h‚FêZ[:P>èoóálE&»“wË`¨‘ƒ20Ëw›#ùv¡0s‡ß©Tž©ÀÇZÉéŬ,9D`¾Å‹˜•yË\<Ÿ¼È’”€¢˜f +¸ˆ¼z éÊÛ+ü±Ûel`–ÁÑZÞ™8ÏâÓó@^$c CMÓ¢’ð{tÊÒ¹HÎ(©ƒÁB^y-ü›ÓYLÝØ¢R(à½6æ„L¼¾Á}mdE´ÌáÌV/”‰ôü2˜ézT*Y~ñ#Ÿã`…qéG’Á!*V…%‚©ïIcâÙïqϘzÂÁ+™ NÜÌ¡Ú[Cd‚–u�«-9 3ÉNgr•3=®†9;Nr!WŸö:.DF&g°5µû.ô{ü�˜ìðA FSÌœ1r°Õ#2CÓ²˜æÕl_îrÙv YÅ +7á=ªn€å! 4~•æµlñ±Y¿P¾0(¾ßsià‡¸+1¤€Ôï¬ÂG{’¼ÿKèŸHr;NzìMYç‚]Àôye4·öÿ M|}†ßEx\Û$ɱ® ¬#/ HìÖþ§»š2 +ÊÙêÂÎGàXHÿÖî Ìóƒ#×î3b¸%žWû ô¡õ’<îØŸœÑ¬ GúKD^ TØbfÒ!u‚0ñÌ{ïßE{n®7˜íˆ¥)*¯Ì)³¸\cRàÓÍü›ï¢¯g‡ÊúfÐõˆ–ú÷›÷'ÔèÎÏ>œS·òÑ( ìýÞ[½‹õ=`XfJDôfAfjì- Enô¬Ã ç óâ`†Kü}íŸtXÊz>v,Œe©GòZw;·("®dÉu¨¹´b¶ŸÝ?Ô‘ä|¬¸Ñ“«”}´!ŒŒ¨Tð>îÁ¬>üþš9º×è‚fzŽÏã(Å¢*GòX3¯Á{?t2ÔÝÑÏÁ¡c÷ +ž¼ŠóäQfÜ£¼jq÷ÐÛDiвz‹ëqÂè"ï«ì¯ƒñ¯ '¡å£6ò0E–ZË`uræ¹['‘Á +½zóeœM•§ÿ. >Q× AJy}ÏÈÇšFfIeÿûÝ4-óñëd2$À”½¹3>ÁoÞ>*¹[Ö„Í!´ÓQáó>çX¼3RrJÆé=þÎ Ðh–vž9ÓBÛfÕ'¼A 6ßØWy’Ü®Æ×0 õ_^õÞŸ0£³‰¼LK’½2³$\jŸò›ÏHA‚÷eIT¨Çð?ϲ¾Ù®BÌâZ‡(©Õ9$¼ƒeoËjX•íbfm(a-L¥5 ìõþ€é4M¥{H‚•„œÌÞ'ª¼0=œªð+p5Eí3˽›&H…Ž%%Ç®gÓ°{æ¨kO½¶ %oQuà«á…zŸ7µ ÑBIæÿ!°µ»Íé<ߎÖ>pæüO'ËÞ#Ö*'²—É°‚ÓxÔ±@ôèJ4'BÙ_ûÃÔu¡ÌÿÛz鬖ëQ¶ÊñÁÚPܤØO%Îy,^W;ǪaÃè%DÛÚ¥Ú83ãÏC—,hÛИMx{‘ØWɈÃ,»/P>bˆ&ÍŠliáD^æ_˜a¤nnÁ+Ë‹‘Xu2€žüÞ4’mQȶ<!á°0Iæ¸ÚЕ‡ÇSBÙðG£#&G0pžŽæ€¤Ç#9¹ìa›3-ëñç4è4K$šE+Z#náÏÙøZŽ7ü tØD«ø~I]Eí¸����IEND®B`‚ \ No newline at end of file diff --git a/core/tests/fixtures/default_content/media/344b943c-b231-4d73-9669-0b0a2be12aa5.yml b/core/tests/fixtures/default_content/media/344b943c-b231-4d73-9669-0b0a2be12aa5.yml new file mode 100644 index 0000000000000000000000000000000000000000..d54c09c76931f633ea7ec0ba6f39b003393e1aeb --- /dev/null +++ b/core/tests/fixtures/default_content/media/344b943c-b231-4d73-9669-0b0a2be12aa5.yml @@ -0,0 +1,38 @@ +_meta: + version: '1.0' + entity_type: media + uuid: 344b943c-b231-4d73-9669-0b0a2be12aa5 + bundle: image + default_langcode: en + depends: + d8404562-efcc-40e3-869e-40132d53fe0b: file +default: + revision_user: + - + target_id: 1 + status: + - + value: true + uid: + - + target_id: 1 + name: + - + value: druplicon.png + created: + - + value: 1711121695 + revision_translation_affected: + - + value: true + path: + - + alias: '' + langcode: en + field_media_image: + - + entity: d8404562-efcc-40e3-869e-40132d53fe0b + alt: 'A Druplicon on a transparent background.' + title: '' + width: 88 + height: 100 diff --git a/core/tests/fixtures/default_content/menu_link_content/3434bd5a-d2cd-4f26-bf79-a7f6b951a21b.yml b/core/tests/fixtures/default_content/menu_link_content/3434bd5a-d2cd-4f26-bf79-a7f6b951a21b.yml new file mode 100644 index 0000000000000000000000000000000000000000..eecf8562c0cc06a7314a0f5b485ee6550ff5f207 --- /dev/null +++ b/core/tests/fixtures/default_content/menu_link_content/3434bd5a-d2cd-4f26-bf79-a7f6b951a21b.yml @@ -0,0 +1,38 @@ +_meta: + version: '1.0' + entity_type: menu_link_content + uuid: 3434bd5a-d2cd-4f26-bf79-a7f6b951a21b + bundle: menu_link_content + default_langcode: en + depends: + e1714f23-70c0-4493-8e92-af1901771921: node +default: + enabled: + - + value: true + title: + - + value: 'Test Article' + menu_name: + - + value: main + link: + - + target_uuid: e1714f23-70c0-4493-8e92-af1901771921 + title: '' + options: { } + external: + - + value: false + rediscover: + - + value: false + weight: + - + value: 0 + expanded: + - + value: false + revision_translation_affected: + - + value: true diff --git a/core/tests/fixtures/default_content/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml b/core/tests/fixtures/default_content/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml new file mode 100644 index 0000000000000000000000000000000000000000..c29e6b14a492f19c27089008212c64904d659c44 --- /dev/null +++ b/core/tests/fixtures/default_content/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml @@ -0,0 +1,83 @@ +_meta: + version: '1.0' + entity_type: node + uuid: 2d3581c3-92c7-4600-8991-a0d4b3741198 + bundle: article + default_langcode: en + depends: + 94503467-be7f-406c-9795-fc25baa22203: user +default: + revision_uid: + - + target_id: 1 + status: + - + value: true + uid: + - + entity: 94503467-be7f-406c-9795-fc25baa22203 + title: + - + value: 'Lost in translation' + created: + - + value: 1711976268 + promote: + - + value: true + sticky: + - + value: false + path: + - + alias: '' + langcode: en + content_translation_source: + - + value: und + content_translation_outdated: + - + value: false + body: + - + value: "Here's the English version." + format: plain_text + summary: '' +translations: + fr: + status: + - + value: true + uid: + - + target_id: 1 + title: + - + value: 'Perdu en traduction' + created: + - + value: 1711976291 + promote: + - + value: true + sticky: + - + value: false + revision_translation_affected: + - + value: true + path: + - + alias: '' + langcode: fr + content_translation_source: + - + value: en + content_translation_outdated: + - + value: false + body: + - + value: "Içi c'est la version français." + format: plain_text + summary: '' diff --git a/core/tests/fixtures/default_content/node/7f1dd75a-0be2-4d3b-be5d-9d1a868b9267.yml b/core/tests/fixtures/default_content/node/7f1dd75a-0be2-4d3b-be5d-9d1a868b9267.yml new file mode 100644 index 0000000000000000000000000000000000000000..023a176830494f741cad1c91c352f936c8bba156 --- /dev/null +++ b/core/tests/fixtures/default_content/node/7f1dd75a-0be2-4d3b-be5d-9d1a868b9267.yml @@ -0,0 +1,44 @@ +_meta: + version: '1.0' + entity_type: node + uuid: 7f1dd75a-0be2-4d3b-be5d-9d1a868b9267 + bundle: page + default_langcode: en + depends: + # This user does not actually exist; this lets us test that the node + # will be assigned to user 1 during the import. + e2b1b3fb-27ea-41ec-b70f-dbf2907fb658: user +default: + revision_uid: + - + target_id: 1 + status: + - + value: true + uid: + - + entity: e2b1b3fb-27ea-41ec-b70f-dbf2907fb658 + title: + - + value: 'No Owner' + created: + - + value: 1711638565 + promote: + - + value: false + sticky: + - + value: false + revision_translation_affected: + - + value: true + path: + - + alias: '' + langcode: en + body: + - + value: 'This page was authored by a non-existent user.' + format: plain_text + summary: '' diff --git a/core/tests/fixtures/default_content/node/e1714f23-70c0-4493-8e92-af1901771921.yml b/core/tests/fixtures/default_content/node/e1714f23-70c0-4493-8e92-af1901771921.yml new file mode 100644 index 0000000000000000000000000000000000000000..883cdfe15ad40f809f8d142d056ae9f7c16072d2 --- /dev/null +++ b/core/tests/fixtures/default_content/node/e1714f23-70c0-4493-8e92-af1901771921.yml @@ -0,0 +1,46 @@ +_meta: + version: '1.0' + entity_type: node + uuid: e1714f23-70c0-4493-8e92-af1901771921 + bundle: article + default_langcode: en + depends: + 94503467-be7f-406c-9795-fc25baa22203: user + 550f86ad-aa11-4047-953f-636d42889f85: taxonomy_term +default: + revision_uid: + - + target_id: 1 + status: + - + value: true + uid: + - + entity: 94503467-be7f-406c-9795-fc25baa22203 + title: + - + value: 'Test Article' + created: + - + value: 1711476803 + promote: + - + value: true + sticky: + - + value: false + revision_translation_affected: + - + value: true + path: + - + alias: /test-article + langcode: en + body: + - + value: 'Crikey it works!' + format: plain_text + summary: '' + field_tags: + - + entity: 550f86ad-aa11-4047-953f-636d42889f85 diff --git a/core/tests/fixtures/default_content/taxonomy_term/550f86ad-aa11-4047-953f-636d42889f85.yml b/core/tests/fixtures/default_content/taxonomy_term/550f86ad-aa11-4047-953f-636d42889f85.yml new file mode 100644 index 0000000000000000000000000000000000000000..be025a848a3dca5c6df692349f5c12884b56ce7d --- /dev/null +++ b/core/tests/fixtures/default_content/taxonomy_term/550f86ad-aa11-4047-953f-636d42889f85.yml @@ -0,0 +1,31 @@ +_meta: + version: '1.0' + entity_type: taxonomy_term + uuid: 550f86ad-aa11-4047-953f-636d42889f85 + bundle: tags + default_langcode: en +default: + status: + - + value: true + name: + - + value: 'Default Content' + weight: + - + value: 0 + parent: + - + target_id: 0 + revision_translation_affected: + - + value: true + path: + - + alias: '' + langcode: en + field_serialized_stuff: + - + value: + - Hi + - there! diff --git a/core/tests/fixtures/default_content/user/94503467-be7f-406c-9795-fc25baa22203.yml b/core/tests/fixtures/default_content/user/94503467-be7f-406c-9795-fc25baa22203.yml new file mode 100644 index 0000000000000000000000000000000000000000..8f1632105ebb2b543b86f46f8e430e6fccbcd2bc --- /dev/null +++ b/core/tests/fixtures/default_content/user/94503467-be7f-406c-9795-fc25baa22203.yml @@ -0,0 +1,43 @@ +_meta: + version: '1.0' + entity_type: user + uuid: 94503467-be7f-406c-9795-fc25baa22203 + default_langcode: en +default: + preferred_langcode: + - + value: en + preferred_admin_langcode: + - + value: en + name: + - + value: 'Naomi Malone' + pass: + - + # cspell:disable + value: $2y$10$3GlpQmjbJ9raJNQ.JZmg/OVS7avJ7KPQxucunwovUtOvpKbe3k8lK + # cspell:enable + existing: '' + pre_hashed: false + mail: + - + value: author@example.com + timezone: + - + value: UTC + status: + - + value: true + created: + - + value: 1711125883 + access: + - + value: 0 + login: + - + value: 0 + init: + - + value: author@example.com diff --git a/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml b/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..6d9589f9c3b67dc289b065c3b14d05bf117156f8 --- /dev/null +++ b/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml @@ -0,0 +1,6 @@ +name: 'Base theme and views' +type: 'Testing' +install: + - test_subsubtheme + - node + - views diff --git a/core/tests/fixtures/recipes/config_actions/recipe.yml b/core/tests/fixtures/recipes/config_actions/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..4e16eeb67b7329d1ab22fc9b0cfe6007d8be7121 --- /dev/null +++ b/core/tests/fixtures/recipes/config_actions/recipe.yml @@ -0,0 +1,13 @@ +name: 'Config actions' +type: 'Testing' +install: + - config_test +config: + actions: + config_test.dynamic.recipe: + ensure_exists: + label: 'Created by recipe' + setProtectedProperty: 'Set by recipe' + config_test.system: + simple_config_update: + foo: 'not bar' diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/direct_dependency/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/direct_dependency/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..369e09357e7c48d44aafe551ec14fbf1dfb895b2 --- /dev/null +++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/direct_dependency/recipe.yml @@ -0,0 +1,9 @@ +name: Recipe with direct dependency present +type: 'Testing' +install: + - node +config: + actions: + node.settings: + simple_config_update: + use_admin_theme: true diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_one_level_down/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_one_level_down/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..f6ebd037e865d7822a6de0243e74b25582f542b6 --- /dev/null +++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_one_level_down/recipe.yml @@ -0,0 +1,9 @@ +name: Recipe with first level indirect dependency +type: 'Testing' +recipes: + - level_2 +config: + actions: + node.settings: + simple_config_update: + use_admin_theme: true diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_two_levels_down/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_two_levels_down/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..ac2e9bdef7cd247bb01ec64689eb19f86c10ca77 --- /dev/null +++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_two_levels_down/recipe.yml @@ -0,0 +1,9 @@ +name: Recipe with second level indirect dependency +type: 'Testing' +recipes: + - level_1 +config: + actions: + node.settings: + simple_config_update: + use_admin_theme: true diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/level_1/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/level_1/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..3f902e7f419f14820f18308c5cfe7e57506166b5 --- /dev/null +++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/level_1/recipe.yml @@ -0,0 +1,4 @@ +name: First level sub recipe +type: 'Testing' +recipes: + - level_2 diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/level_2/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/level_2/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..5e467f72ccf7e27bf4bf51e01d8d78fa8488e4e5 --- /dev/null +++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/level_2/recipe.yml @@ -0,0 +1,4 @@ +name: Second level sub recipe +type: 'Testing' +install: + - node diff --git a/core/tests/fixtures/recipes/config_from_module/recipe.yml b/core/tests/fixtures/recipes/config_from_module/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..f88aa486c1ccccab9ab3f1c94e79ff87c0ce1862 --- /dev/null +++ b/core/tests/fixtures/recipes/config_from_module/recipe.yml @@ -0,0 +1,9 @@ +name: 'Config from module' +type: 'Testing' +install: + - config_test +config: + import: + config_test: + - config_test.dynamic.dotted.default + - config_test.dynamic.override diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml new file mode 100644 index 0000000000000000000000000000000000000000..ce5eb672c3de9e671fc4db8a065bd1efc05cb7c8 --- /dev/null +++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml @@ -0,0 +1,6 @@ +id: dotted.default +label: 'Provided by recipe' +weight: 0 +protected_property: Default +# Intentionally commented out to verify default status behavior. +# status: 1 diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml new file mode 100644 index 0000000000000000000000000000000000000000..5aa1d937ae256d5960abb2dd24e0fe72dab64624 --- /dev/null +++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml @@ -0,0 +1,2 @@ +foo: bar +404: foo diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..58771fd7b60ed977add15646a1a40a4c31303391 --- /dev/null +++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml @@ -0,0 +1,11 @@ +name: 'Config from module and recipe' +type: 'Testing' +install: + - config_test + - shortcut + - system +config: + import: + config_test: '*' + shortcut: + - shortcut.set.default diff --git a/core/tests/fixtures/recipes/config_rollback_exception/recipe.yml b/core/tests/fixtures/recipes/config_rollback_exception/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..2bf0db3433cbf109b991ff98ce784a7fafcbfa65 --- /dev/null +++ b/core/tests/fixtures/recipes/config_rollback_exception/recipe.yml @@ -0,0 +1,19 @@ +name: Config rollback exception +install: + - filter + - media +config: + import: + filter: '*' + media: '*' + actions: + filter.format.plain_text: + setFilterConfig: + instance_id: media_embed + configuration: [] + system.image: + # This will cause a validation error, which will trigger a rollback. + # The rollback should fail, since the Media module can't be uninstalled + # now that the plain_text format is using one of its filters. + simple_config_update: + non_existent_key: whatever! diff --git a/core/tests/fixtures/recipes/config_wildcard/recipe.yml b/core/tests/fixtures/recipes/config_wildcard/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..9c4e68af2490feba1adb30326c9c6b1ba91f21f0 --- /dev/null +++ b/core/tests/fixtures/recipes/config_wildcard/recipe.yml @@ -0,0 +1,10 @@ +name: 'Config wildcard' +type: 'Testing' +install: + - config_test + - shortcut + - system +config: + import: + config_test: '*' + shortcut: ~ diff --git a/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml b/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml new file mode 100644 index 0000000000000000000000000000000000000000..6cb95cbc42a2670a4bdc9245a1e27f351bf88a9d --- /dev/null +++ b/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml @@ -0,0 +1 @@ +use_admin_theme: true diff --git a/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml b/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml new file mode 100644 index 0000000000000000000000000000000000000000..adffa484d4b1b7edd71c485324548cdff6b982a1 --- /dev/null +++ b/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml @@ -0,0 +1,9 @@ +langcode: en +status: true +name: 'Test content type' +type: test +description: 'Test content type from a recipe' +help: null +new_revision: true +preview_mode: 1 +display_submitted: true diff --git a/core/tests/fixtures/recipes/install_node_with_config/recipe.yml b/core/tests/fixtures/recipes/install_node_with_config/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..e2fc243b529bd1b0afcc734e522a2abafbd176d1 --- /dev/null +++ b/core/tests/fixtures/recipes/install_node_with_config/recipe.yml @@ -0,0 +1,5 @@ +name: 'Install node with config' +type: 'Content type' +install: + - node + - drupal:text diff --git a/core/tests/fixtures/recipes/install_two_modules/recipe.yml b/core/tests/fixtures/recipes/install_two_modules/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..ee57ca146df305b9c487aa017751ff60244b0093 --- /dev/null +++ b/core/tests/fixtures/recipes/install_two_modules/recipe.yml @@ -0,0 +1,5 @@ +name: 'Install two modules' +type: 'Content type' +install: + - node + - text diff --git a/core/tests/fixtures/recipes/invalid_config/config/core.date_format.invalid.yml b/core/tests/fixtures/recipes/invalid_config/config/core.date_format.invalid.yml new file mode 100644 index 0000000000000000000000000000000000000000..6bfb62960d22137a0b66f8d3a86f5a3ba03f45bc --- /dev/null +++ b/core/tests/fixtures/recipes/invalid_config/config/core.date_format.invalid.yml @@ -0,0 +1,14 @@ +langcode: en +status: true +dependencies: + config: + # Depend on the valid date format in order to ensure it is imported first, + # which means we can ensure it was rolled back when this date format raises + # a validation error. + - core.date_format.valid +id: invalid +# Null isn't a valid value for the label, so this should raise a validation +# error. +label: null +locked: false +pattern: 'F j, Y' diff --git a/core/tests/fixtures/recipes/invalid_config/config/core.date_format.valid.yml b/core/tests/fixtures/recipes/invalid_config/config/core.date_format.valid.yml new file mode 100644 index 0000000000000000000000000000000000000000..7e4468198319251b5aca0d5b0e1602c998f27ab3 --- /dev/null +++ b/core/tests/fixtures/recipes/invalid_config/config/core.date_format.valid.yml @@ -0,0 +1,7 @@ +langcode: en +status: true +dependencies: { } +id: valid +label: 'Valid date format' +locked: false +pattern: 'F j, Y' diff --git a/core/tests/fixtures/recipes/invalid_config/recipe.yml b/core/tests/fixtures/recipes/invalid_config/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..5f5cb88e788bd1035e78f183c6fa2aa30c37cc25 --- /dev/null +++ b/core/tests/fixtures/recipes/invalid_config/recipe.yml @@ -0,0 +1,2 @@ +name: 'Invalid config' +type: 'Testing' diff --git a/core/tests/fixtures/recipes/no_extensions/recipe.yml b/core/tests/fixtures/recipes/no_extensions/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..b7d3aeb4f0900f33640f9c27ba9021cca569c9ea --- /dev/null +++ b/core/tests/fixtures/recipes/no_extensions/recipe.yml @@ -0,0 +1,3 @@ +name: 'No extensions' +description: 'A recipe description' +type: 'Testing' diff --git a/core/tests/fixtures/recipes/recipe_depend_on_invalid/recipe.yml b/core/tests/fixtures/recipes/recipe_depend_on_invalid/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..c2cd6b73cd00258e9c77312d437bac40541219d5 --- /dev/null +++ b/core/tests/fixtures/recipes/recipe_depend_on_invalid/recipe.yml @@ -0,0 +1,4 @@ +name: 'Recipe depending on an invalid recipe' +type: 'Testing' +recipes: + - invalid_config diff --git a/core/tests/fixtures/recipes/recipe_depend_on_invalid_config_and_valid_modules/recipe.yml b/core/tests/fixtures/recipes/recipe_depend_on_invalid_config_and_valid_modules/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..5e487e35a7954e933e04ce60ec5d9235b55c3865 --- /dev/null +++ b/core/tests/fixtures/recipes/recipe_depend_on_invalid_config_and_valid_modules/recipe.yml @@ -0,0 +1,5 @@ +name: 'Recipe depending on first installing modules, then a recipe with invalid config' +type: 'Testing' +recipes: + - install_two_modules + - invalid_config diff --git a/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml b/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml new file mode 100644 index 0000000000000000000000000000000000000000..884f144dc778e4452ac051a0231979a8a577d0ed --- /dev/null +++ b/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml @@ -0,0 +1,9 @@ +langcode: en +status: true +name: 'Another test content type' +type: another_test +description: 'Another test content type from a recipe' +help: null +new_revision: true +preview_mode: 1 +display_submitted: true diff --git a/core/tests/fixtures/recipes/recipe_include/recipe.yml b/core/tests/fixtures/recipes/recipe_include/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..a81aa075c09e3d54eba17ce06022a1f5b89898f0 --- /dev/null +++ b/core/tests/fixtures/recipes/recipe_include/recipe.yml @@ -0,0 +1,6 @@ +name: 'Recipe include' +type: 'Testing' +recipes: + - install_node_with_config +install: + - dblog diff --git a/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml b/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..550c3610a95fae1be2991019e3ef91012e73913d --- /dev/null +++ b/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml @@ -0,0 +1,5 @@ +name: 'Theme with module dependencies' +type: 'Testing' +install: + - test_theme_depending_on_modules + - test_module_required_by_theme diff --git a/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml b/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml new file mode 100644 index 0000000000000000000000000000000000000000..93a92f106d62a8ed6ae225bf3e34a45c505a9ee2 --- /dev/null +++ b/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml @@ -0,0 +1,12 @@ +langcode: en +status: true +name: 'Test content type' +type: test +description: 'Test content type from a recipe' +help: null +new_revision: true +preview_mode: 1 +display_submitted: true +dependencies: + config: + - core.date_format.non_existent diff --git a/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml b/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml new file mode 100644 index 0000000000000000000000000000000000000000..e90fca915780ec3206d385c9ad9fc435a8a42655 --- /dev/null +++ b/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml @@ -0,0 +1,2 @@ +name: 'Unmet config dependencies' +type: 'Testing'