Skip to content
Snippets Groups Projects
Verified Commit 1384a2e1 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3303126 by phenaproxima, narendrar, thejimbirch, alexpott, aangel, Wim...

Issue #3303126 by phenaproxima, narendrar, thejimbirch, alexpott, aangel, Wim Leers, b_sharpe, longwave, akhil babu: Make it possible for recipes to prompt for input values
parent 276df7ec
Branches
Tags
23 merge requests!12227Issue #3181946 by jonmcl, mglaman,!11131[10.4.x-only-DO-NOT-MERGE]: Issue ##2842525 Ajax attached to Views exposed filter form does not trigger callbacks,!9470[10.3.x-only-DO-NOT-MERGE]: #3331771 Fix file_get_contents(): Passing null to parameter,!8736Update the Documention As per the Function uses.,!8513Issue #3453786: DefaultSelection should document why values for target_bundles NULL and [] behave as they do,!5423Draft: Resolve #3329907 "Test2",!3878Removed unused condition head title for views,!3818Issue #2140179: $entity->original gets stale between updates,!3742Issue #3328429: Create item list field formatter for displaying ordered and unordered lists,!3731Claro: role=button on status report items,!3478Issue #3337882: Deleted menus are not removed from content type config,!3154Fixes #2987987 - CSRF token validation broken on routes with optional parameters.,!3133core/modules/system/css/components/hidden.module.css,!2964Issue #2865710 : Dependencies from only one instance of a widget are used in display modes,!2812Issue #3312049: [Followup] Fix Drupal.Commenting.FunctionComment.MissingReturnType returns for NULL,!2378Issue #2875033: Optimize joins and table selection in SQL entity query implementation,!2062Issue #3246454: Add weekly granularity to views date sort,!10223132456: Fix issue where views instances are emptied before an ajax request is complete,!877Issue #2708101: Default value for link text is not saved,!579Issue #2230909: Simple decimals fail to pass validation,!560Move callback classRemove outside of the loop,!555Issue #3202493,!213Issue #2906496: Give Media a menu item under Content
Pipeline #266171 passed with warnings
Pipeline: drupal

#266188

    Pipeline: drupal

    #266184

      Pipeline: drupal

      #266175

        Showing
        with 1173 additions and 73 deletions
        <?php
        declare(strict_types=1);
        namespace Drupal\Core\Command;
        use Drupal\Core\DrupalKernel;
        use Drupal\Core\DrupalKernelInterface;
        use Drupal\Core\Site\Settings;
        use Symfony\Component\HttpFoundation\Request;
        /**
        * Contains helper methods for console commands that boot up Drupal.
        */
        trait BootableCommandTrait {
        /**
        * The class loader.
        *
        * @var object
        */
        protected object $classLoader;
        /**
        * Boots up a Drupal environment.
        *
        * @return \Drupal\Core\DrupalKernelInterface
        * The Drupal kernel.
        *
        * @throws \Exception
        * Exception thrown if kernel does not boot.
        */
        protected function boot(): DrupalKernelInterface {
        $kernel = new DrupalKernel('prod', $this->classLoader);
        $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(): string {
        return getenv('DRUPAL_DEV_SITE_PATH') ?: 'sites/default';
        }
        }
        <?php
        declare(strict_types=1);
        namespace Drupal\Core\Recipe;
        use Drupal\Core\TypedData\DataDefinitionInterface;
        use Symfony\Component\Console\Command\Command;
        use Symfony\Component\Console\Exception\InvalidArgumentException;
        use Symfony\Component\Console\Input\InputInterface;
        use Symfony\Component\Console\Input\InputOption;
        use Symfony\Component\Console\Style\StyleInterface;
        /**
        * Collects input values for recipes from the command line.
        *
        * @internal
        * This API is experimental.
        */
        final class ConsoleInputCollector implements InputCollectorInterface {
        /**
        * The name of the command-line option for passing input values.
        *
        * @var string
        */
        public const INPUT_OPTION = 'input';
        public function __construct(
        private readonly InputInterface $input,
        private readonly StyleInterface $io,
        ) {}
        /**
        * Configures a console command to support the `--input` option.
        *
        * This should be called by a command's configure() method.
        *
        * @param \Symfony\Component\Console\Command\Command $command
        * The command being configured.
        */
        public static function configureCommand(Command $command): void {
        $command->addOption(
        static::INPUT_OPTION,
        'i',
        InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
        sprintf('An input value to pass to the recipe or one of its dependencies, in the form `--%s=RECIPE_NAME.INPUT_NAME=VALUE`.', static::INPUT_OPTION),
        [],
        );
        }
        /**
        * Returns the `--input` options passed to the command.
        *
        * @return string[]
        * The values from the `--input` options passed to the command, keyed by
        * fully qualified name (i.e., prefixed with the name of their defining
        * recipe).
        */
        private function getInputFromOptions(): array {
        $options = [];
        try {
        foreach ($this->input->getOption(static::INPUT_OPTION) ?? [] as $option) {
        [$key, $value] = explode('=', $option, 2);
        $options[$key] = $value;
        }
        }
        catch (InvalidArgumentException) {
        // The option is undefined; there's nothing we need to do.
        }
        return $options;
        }
        /**
        * {@inheritdoc}
        */
        public function collectValue(string $name, DataDefinitionInterface $definition, mixed $default_value): mixed {
        $option_values = $this->getInputFromOptions();
        // If the value was passed as a `--input` option, return that.
        if (array_key_exists($name, $option_values)) {
        return $option_values[$name];
        }
        /** @var array{method: string, arguments?: array<mixed>}|null $settings */
        $settings = $definition->getSetting('prompt');
        // If there's no information on how to prompt the user, there's nothing else
        // for us to do; return the default value.
        if (empty($settings)) {
        return $default_value;
        }
        $method = $settings['method'];
        $arguments = $settings['arguments'] ?? [];
        // Most of the input-collecting methods of StyleInterface have a `default`
        // parameter.
        $arguments += [
        'default' => $default_value,
        ];
        // We don't support using Symfony Console's inline validation; instead,
        // input definitions should define constraints.
        unset($arguments['validator']);
        return $this->io->$method(...$arguments);
        }
        }
        <?php
        declare(strict_types=1);
        namespace Drupal\Core\Recipe;
        use Drupal\Core\TypedData\DataDefinitionInterface;
        interface InputCollectorInterface {
        public function collectValue(string $name, DataDefinitionInterface $definition, mixed $default_value): mixed;
        }
        <?php
        declare(strict_types=1);
        namespace Drupal\Core\Recipe;
        use Drupal\Core\TypedData\DataDefinition;
        use Drupal\Core\TypedData\TypedDataManagerInterface;
        use Symfony\Component\Validator\Exception\ValidationFailedException;
        /**
        * Collects and validates input values for a recipe.
        *
        * @internal
        * This API is experimental.
        */
        final class InputConfigurator {
        /**
        * The input data.
        *
        * @var \Drupal\Core\TypedData\TypedDataInterface[]
        */
        private array $data = [];
        /**
        * The collected input values, or NULL if none have been collected yet.
        *
        * @var mixed[]|null
        */
        private ?array $values = NULL;
        /**
        * @param array<string, array<string, mixed>> $definitions
        * The recipe's input definitions, keyed by name. This is an array of arrays
        * where each sub-array has, at minimum:
        * - `description`: A short, human-readable description of the input (e.g.,
        * what the recipe uses it for).
        * - `data_type`: A primitive data type known to the typed data system.
        * - `constraints`: An optional array of validation constraints to apply
        * to the value. This should be an associative array of arrays, keyed by
        * constraint name, where each sub-array is a set of options for that
        * constraint (identical to the way validation constraints are defined in
        * config schema).
        * - `default`: A default value for the input, if it cannot be collected
        * the user. See ::getDefaultValue() for more information.
        * @param \Drupal\Core\Recipe\RecipeConfigurator $dependencies
        * The recipes that this recipe depends on.
        * @param string $prefix
        * A prefix for each input definition, to give each one a unique name
        * when collecting input for multiple recipes. Usually this is the unique
        * name of the recipe.
        * @param \Drupal\Core\TypedData\TypedDataManagerInterface $typedDataManager
        * The typed data manager service.
        */
        public function __construct(
        array $definitions,
        private readonly RecipeConfigurator $dependencies,
        private readonly string $prefix,
        TypedDataManagerInterface $typedDataManager,
        ) {
        // Convert the input definitions to typed data definitions.
        foreach ($definitions as $name => $definition) {
        $data_definition = DataDefinition::create($definition['data_type'])
        ->setDescription($definition['description'])
        ->setConstraints($definition['constraints'] ?? []);
        unset(
        $definition['data_type'],
        $definition['description'],
        $definition['constraints'],
        );
        $data_definition->setSettings($definition);
        $this->data[$name] = $typedDataManager->create($data_definition);
        }
        }
        /**
        * Returns the collected input values, keyed by name.
        *
        * @return mixed[]
        * The collected input values, keyed by name.
        */
        public function getValues(): array {
        return $this->values ?? [];
        }
        /**
        * Returns the description for all inputs of this recipe and its dependencies.
        *
        * @return string[]
        * The descriptions of every input defined by the recipe and its
        * dependencies, keyed by the input's fully qualified name (i.e., prefixed
        * by the name of the recipe that defines it).
        */
        public function describeAll(): array {
        $descriptions = [];
        foreach ($this->dependencies->recipes as $dependency) {
        $descriptions = array_merge($descriptions, $dependency->input->describeAll());
        }
        foreach ($this->data as $key => $data) {
        $name = $this->prefix . '.' . $key;
        $descriptions[$name] = $data->getDataDefinition()->getDescription();
        }
        return $descriptions;
        }
        /**
        * Collects input values for this recipe and its dependencies.
        *
        * @param \Drupal\Core\Recipe\InputCollectorInterface $collector
        * The input collector to use.
        *
        * @throws \Symfony\Component\Validator\Exception\ValidationFailedException
        * Thrown if any of the collected values violate their validation
        * constraints.
        */
        public function collectAll(InputCollectorInterface $collector): void {
        if (is_array($this->values)) {
        throw new \LogicException('Input values cannot be changed once they have been set.');
        }
        // Don't bother collecting values for a recipe we've already seen.
        static $processed = [];
        if (in_array($this->prefix, $processed, TRUE)) {
        return;
        }
        // First, collect values for the recipe's dependencies.
        /** @var \Drupal\Core\Recipe\Recipe $dependency */
        foreach ($this->dependencies->recipes as $dependency) {
        $dependency->input->collectAll($collector);
        }
        $this->values = [];
        foreach ($this->data as $key => $data) {
        $definition = $data->getDataDefinition();
        $value = $collector->collectValue(
        $this->prefix . '.' . $key,
        $definition,
        $this->getDefaultValue($definition),
        );
        $data->setValue($value, FALSE);
        $violations = $data->validate();
        if (count($violations) > 0) {
        throw new ValidationFailedException($value, $violations);
        }
        $this->values[$key] = $data->getCastedValue();
        }
        $processed[] = $this->prefix;
        }
        /**
        * Returns the default value for an input definition.
        *
        * @param array $definition
        * An input definition. Must contain a `source` element, which can be either
        * 'config' or 'value'. If `source` is 'config', then there must also be a
        * `config` element, which is a two-element indexed array containing
        * (in order) the name of an extant config object, and a property path
        * within that object. If `source` is 'value', then there must be a `value`
        * element, which will be returned as-is.
        *
        * @return mixed
        * The default value.
        */
        private function getDefaultValue(DataDefinition $definition): mixed {
        $settings = $definition->getSetting('default');
        if ($settings['source'] === 'config') {
        [$name, $key] = $settings['config'];
        $config = \Drupal::config($name);
        if ($config->isNew()) {
        throw new \RuntimeException("The '$name' config object does not exist.");
        }
        return $config->get($key);
        }
        return $settings['value'];
        }
        }
        ......@@ -9,11 +9,14 @@
        use Drupal\Core\Extension\ModuleExtensionList;
        use Drupal\Core\Extension\ThemeExtensionList;
        use Drupal\Component\Serialization\Yaml;
        use Drupal\Core\TypedData\PrimitiveInterface;
        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\Choice;
        use Symfony\Component\Validator\Constraints\Collection;
        use Symfony\Component\Validator\Constraints\Count;
        use Symfony\Component\Validator\Constraints\IdenticalTo;
        use Symfony\Component\Validator\Constraints\NotBlank;
        use Symfony\Component\Validator\Constraints\NotIdenticalTo;
        ......@@ -33,6 +36,30 @@ final class Recipe {
        const COMPOSER_PROJECT_TYPE = 'drupal-recipe';
        /**
        * @param string $name
        * The human-readable name of the recipe.
        * @param string $description
        * A short description of the recipe.
        * @param string $type
        * The recipe type.
        * @param \Drupal\Core\Recipe\RecipeConfigurator $recipes
        * The recipe configurator, which lists the recipes that will be applied
        * before this one.
        * @param \Drupal\Core\Recipe\InstallConfigurator $install
        * The install configurator, which lists the extensions this recipe will
        * install.
        * @param \Drupal\Core\Recipe\ConfigConfigurator $config
        * The config configurator, which lists the config that this recipe will
        * install, and what config actions will be taken.
        * @param \Drupal\Core\Recipe\InputConfigurator $input
        * The input configurator, which collects any input values used by the
        * recipe.
        * @param \Drupal\Core\DefaultContent\Finder $content
        * The default content finder.
        * @param string $path
        * The recipe's path.
        */
        public function __construct(
        public readonly string $name,
        public readonly string $description,
        ......@@ -40,10 +67,10 @@ public function __construct(
        public readonly RecipeConfigurator $recipes,
        public readonly InstallConfigurator $install,
        public readonly ConfigConfigurator $config,
        public readonly InputConfigurator $input,
        public readonly Finder $content,
        public readonly string $path,
        ) {
        }
        ) {}
        /**
        * Creates a recipe object from the provided path.
        ......@@ -60,8 +87,9 @@ public static function createFromDirectory(string $path): static {
        $recipes = new RecipeConfigurator(is_array($recipe_data['recipes']) ? $recipe_data['recipes'] : [], dirname($path));
        $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'));
        $input = new InputConfigurator($recipe_data['input'] ?? [], $recipes, basename($path), \Drupal::typedDataManager());
        $content = new Finder($path . '/content');
        return new static($recipe_data['name'], $recipe_data['description'], $recipe_data['type'], $recipes, $install, $config, $content, $path);
        return new static($recipe_data['name'], $recipe_data['description'], $recipe_data['type'], $recipes, $install, $config, $input, $content, $path);
        }
        /**
        ......@@ -151,6 +179,65 @@ private static function parse(string $file): array {
        ]),
        ]),
        ]),
        'input' => new Optional([
        new Type('associative_array'),
        new All([
        new Collection(
        fields: [
        // Every input definition must have a description.
        'description' => [
        new Type('string'),
        new NotBlank(),
        ],
        // There can be an optional set of constraints, which is an
        // associative array of arrays, as in config schema.
        'constraints' => new Optional([
        new Type('associative_array'),
        ]),
        'data_type' => [
        // The data type must be known to the typed data system.
        \Drupal::service('validation.constraint')->createInstance('PluginExists', [
        'manager' => 'typed_data_manager',
        // Only primitives are supported because it's not always clear
        // how to collect, validate, and cast complex structures.
        'interface' => PrimitiveInterface::class,
        ]),
        ],
        // If there is a `prompt` element, it has its own set of
        // constraints.
        'prompt' => new Optional([
        new Collection([
        'method' => [
        new Choice(['ask', 'askHidden', 'confirm', 'choice']),
        ],
        'arguments' => new Optional([
        new Type('associative_array'),
        ]),
        ]),
        ]),
        // Every input must define a default value.
        'default' => new Required([
        new Collection([
        'source' => new Required([
        new Choice(['value', 'config']),
        ]),
        'value' => new Optional(),
        'config' => new Optional([
        new Sequentially([
        new Type('list'),
        new Count(2),
        new All([
        new Type('string'),
        new NotBlank(),
        ]),
        ]),
        ]),
        ]),
        new Callback(self::validateDefaultValueDefinition(...)),
        ]),
        ]),
        ]),
        ]),
        'config' => new Optional([
        new Collection([
        // Each entry in the `import` list can either be `*` (import all of
        ......@@ -204,6 +291,24 @@ private static function parse(string $file): array {
        return $recipe_data;
        }
        /**
        * Validates the definition of an input's default value.
        *
        * @param array $definition
        * The array to validate (part of a single input definition).
        * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context
        * The validator execution context.
        *
        * @see ::parse()
        */
        public static function validateDefaultValueDefinition(array $definition, ExecutionContextInterface $context): void {
        $source = $definition['source'];
        if (!array_key_exists($source, $definition)) {
        $context->addViolation("The '$source' key is required.");
        }
        }
        /**
        * Validates that the value is an available module/theme (installed or not).
        *
        ......
        ......
        ......@@ -5,12 +5,11 @@
        namespace Drupal\Core\Recipe;
        use Drupal\Component\Render\PlainTextOutput;
        use Drupal\Core\Command\BootableCommandTrait;
        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;
        ......@@ -19,7 +18,6 @@
        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.
        ......@@ -29,12 +27,7 @@
        */
        final class RecipeCommand extends Command {
        /**
        * The class loader.
        *
        * @var object
        */
        protected $classLoader;
        use BootableCommandTrait;
        /**
        * Constructs a new RecipeCommand command.
        ......@@ -54,6 +47,8 @@ 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');
        ConsoleInputCollector::configureCommand($this);
        }
        /**
        ......@@ -73,6 +68,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
        /** @var \Drupal\Core\Config\Checkpoint\CheckpointStorageInterface $checkpoint_storage */
        $checkpoint_storage = $container->get('config.storage.checkpoint');
        $recipe = Recipe::createFromDirectory($recipe_path);
        // Collect input for this recipe and all the recipes it directly and
        // indirectly applies.
        $recipe->input->collectAll(new ConsoleInputCollector($input, $io));
        if ($checkpoint_storage instanceof LoggerAwareInterface) {
        $logger = new ConsoleLogger($output, [
        // The checkpoint storage logs a notice if it decides to not create a
        ......@@ -182,36 +182,4 @@ private function rollBackToCheckpoint(Checkpoint $checkpoint): void {
        $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);
        $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';
        }
        }
        <?php
        declare(strict_types=1);
        namespace Drupal\Core\Recipe;
        use Drupal\Core\Command\BootableCommandTrait;
        use Symfony\Component\Console\Command\Command;
        use Symfony\Component\Console\Input\InputArgument;
        use Symfony\Component\Console\Input\InputInterface;
        use Symfony\Component\Console\Output\OutputInterface;
        use Symfony\Component\Console\Style\SymfonyStyle;
        /**
        * Shows information about a particular recipe.
        *
        * @internal
        * This API is experimental.
        */
        final class RecipeInfoCommand extends Command {
        use BootableCommandTrait;
        public function __construct($class_loader) {
        parent::__construct('recipe:info');
        $this->classLoader = $class_loader;
        }
        /**
        * {@inheritdoc}
        */
        protected function configure(): void {
        $this
        ->setDescription('Shows information about a recipe.')
        ->addArgument('path', InputArgument::REQUIRED, 'The path to the recipe\'s folder');
        }
        /**
        * {@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));
        return 1;
        }
        $this->boot();
        $recipe = Recipe::createFromDirectory($recipe_path);
        $io->section('Description');
        $io->text($recipe->description);
        $io->section('Inputs');
        $descriptions = $recipe->input->describeAll();
        if ($descriptions) {
        // Passing NULL as the callable to array_map() makes it act like a Python
        // zip() operation.
        // @see https://docs.python.org/3.8/library/functions.html#zip
        $rows = array_map(NULL, array_keys($descriptions), $descriptions);
        $io->table(['Name', 'Description'], $rows);
        }
        else {
        $io->writeln("This recipe does not accept any input.");
        }
        return 0;
        }
        }
        ......@@ -34,7 +34,7 @@ final class RecipeRunner {
        public static function processRecipe(Recipe $recipe): void {
        static::processRecipes($recipe->recipes);
        static::processInstall($recipe->install, $recipe->config->getConfigStorage());
        static::processConfiguration($recipe->config);
        static::processConfiguration($recipe);
        static::processContent($recipe->content);
        static::triggerEvent($recipe);
        }
        ......@@ -89,10 +89,10 @@ protected static function processInstall(InstallConfigurator $install, StorageIn
        /**
        * Creates configuration and applies configuration actions.
        *
        * @param \Drupal\Core\Recipe\ConfigConfigurator $config
        * The config configurator from the recipe.
        * @param \Drupal\Core\Recipe\Recipe $recipe
        * The recipe being applied.
        */
        protected static function processConfiguration(ConfigConfigurator $config): void {
        protected static function processConfiguration(Recipe $recipe): void {
        $config_installer = new RecipeConfigInstaller(
        \Drupal::service('config.factory'),
        \Drupal::service('config.storage'),
        ......@@ -102,17 +102,24 @@ protected static function processConfiguration(ConfigConfigurator $config): void
        NULL,
        \Drupal::service('extension.path.resolver'));
        $config = $recipe->config;
        // 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'])) {
        $values = $recipe->input->getValues();
        // Wrap the replacement strings with `${` and `}`, which is a fairly
        // common style of placeholder.
        $keys = array_map(fn ($k) => sprintf('${%s}', $k), array_keys($values));
        $replace = array_combine($keys, $values);
        // 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);
        $config_action_manager->applyAction($action_id, $config_name, static::replaceInputValues($data, $replace));
        }
        }
        }
        ......@@ -297,7 +304,7 @@ public static function installTheme(string $theme, StorageInterface|Recipe $reci
        * The batch context if called by a batch.
        */
        public static function installConfig(Recipe $recipe, ?array &$context = NULL): void {
        static::processConfiguration($recipe->config);
        static::processConfiguration($recipe);
        $context['message'] = t('Installed configuration for %recipe recipe.', ['%recipe' => $recipe->name]);
        $context['results']['config'][] = $recipe->name;
        }
        ......@@ -316,4 +323,26 @@ public static function installContent(Recipe $recipe, ?array &$context = NULL):
        $context['results']['content'][] = $recipe->name;
        }
        /**
        * @param mixed $data
        * The data that will have placeholders replaced.
        * @param array<string, mixed> $replace
        * An array whose keys are the placeholders to be replaced, and whose values
        * are the replacements.
        *
        * @return mixed
        * The passed data, with placeholders replaced.
        */
        private static function replaceInputValues(mixed $data, array $replace): mixed {
        if (is_string($data)) {
        $data = str_replace(array_keys($replace), $replace, $data);
        }
        elseif (is_array($data)) {
        foreach ($data as $key => $value) {
        $data[$key] = static::replaceInputValues($value, $replace);
        }
        }
        return $data;
        }
        }
        ......@@ -64,6 +64,7 @@ blockquotes
        blockrecipe
        blockrelated
        boing
        bootable
        bovigo
        brotli
        browserkit
        ......
        ......
        langcode: en
        status: true
        dependencies: { }
        id: feedback
        label: 'Website feedback'
        recipients:
        - admin@example.com
        reply: ''
        weight: 0
        message: 'Your message has been sent.'
        redirect: ''
        ......@@ -3,6 +3,19 @@ description: 'Provides a website feedback contact form.'
        type: 'Contact form'
        install:
        - contact
        input:
        recipient:
        data_type: email
        description: 'The email address that should receive submissions from the feedback form.'
        constraints:
        NotBlank: []
        prompt:
        method: ask
        arguments:
        question: 'What email address should receive website feedback?'
        default:
        source: config
        config: ['system.site', 'mail']
        config:
        import:
        contact:
        ......@@ -10,6 +23,13 @@ config:
        system:
        - system.menu.footer
        actions:
        contact.form.feedback:
        createIfNotExists:
        label: 'Website feedback'
        message: 'Your message has been sent.'
        redirect: ''
        setRecipients:
        - ${recipient}
        core.menu.static_menu_link_overrides:
        simpleConfigUpdate:
        definitions.contact__site_page:
        ......
        ......
        ......@@ -11,6 +11,7 @@ use Drupal\Core\Command\QuickStartCommand;
        use Drupal\Core\Command\InstallCommand;
        use Drupal\Core\Command\ServerCommand;
        use Drupal\Core\Recipe\RecipeCommand;
        use Drupal\Core\Recipe\RecipeInfoCommand;
        use Symfony\Component\Console\Application;
        if (PHP_SAPI !== 'cli') {
        ......@@ -26,5 +27,6 @@ $application->add(new InstallCommand($classloader));
        $application->add(new ServerCommand($classloader));
        $application->add(new GenerateTheme());
        $application->add(new RecipeCommand($classloader));
        $application->add(new RecipeInfoCommand($classloader));
        $application->run();
        ......@@ -4,6 +4,7 @@
        namespace Drupal\FunctionalTests\Core\Recipe;
        use Drupal\contact\Entity\ContactForm;
        use Drupal\Core\Config\Checkpoint\Checkpoint;
        use Drupal\Tests\BrowserTestBase;
        ......@@ -118,4 +119,40 @@ private function assertCheckpointsExist(array $expected_labels): void {
        $this->assertSame($expected_labels, array_values($labels));
        }
        public function testPassInput(): void {
        $dir = $this->getDrupalRoot() . '/core/recipes/feedback_contact_form';
        $this->applyRecipe($dir, options: [
        '--input=feedback_contact_form.recipient=hello@good.bye',
        ]);
        $this->assertSame(['hello@good.bye'], ContactForm::load('feedback')?->getRecipients());
        }
        public function testPassInvalidInput(): void {
        $dir = $this->getDrupalRoot() . '/core/recipes/feedback_contact_form';
        $process = $this->applyRecipe($dir, 1, options: [
        '--input=feedback_contact_form.recipient=nobody',
        ]);
        $this->assertStringContainsString('This value is not a valid email address.', $process->getErrorOutput());
        }
        public function testDefaultInputValueFromConfig(): void {
        $this->config('system.site')
        ->set('mail', 'goodbye@hello.net')
        ->save();
        $this->applyRecipe($this->getDrupalRoot() . '/core/recipes/feedback_contact_form');
        $this->assertSame(['goodbye@hello.net'], ContactForm::load('feedback')?->getRecipients());
        }
        public function testListInputs(): void {
        $root = $this->getDrupalRoot();
        $output = $this->applyRecipe($root . '/core/recipes/feedback_contact_form', command: 'recipe:info')->getOutput();
        $this->assertStringContainsString('feedback_contact_form.recipient', $output);
        $this->assertStringContainsString('The email address that should receive submissions from the feedback form.', $output);
        $output = $this->applyRecipe($root . '/core/recipes/page_content_type', command: 'recipe:info')->getOutput();
        $this->assertStringContainsString('This recipe does not accept any input.', $output);
        }
        }
        ......@@ -52,17 +52,24 @@ protected function createRecipe(string|array $data, ?string $machine_name = NULL
        * @param int $expected_exit_code
        * The expected exit code of the `drupal recipe` process. Defaults to 0,
        * which indicates that no error occurred.
        * @param string[] $options
        * (optional) Additional options to pass to the `drupal recipe` command.
        * @param string $command
        * (optional) The name of the command to run. Defaults to `recipe`.
        *
        * @return \Symfony\Component\Process\Process
        * The `drupal recipe` command process, after having run.
        */
        protected function applyRecipe(string $path, int $expected_exit_code = 0): Process {
        protected function applyRecipe(string $path, int $expected_exit_code = 0, array $options = [], string $command = 'recipe'): Process {
        assert($this instanceof BrowserTestBase);
        $arguments = [
        (new PhpExecutableFinder())->find(),
        'core/scripts/drupal',
        'recipe',
        $command,
        // Never apply recipes interactively.
        '--no-interaction',
        ...$options,
        $path,
        ];
        $process = (new Process($arguments))
        ......
        ......
        ......@@ -4,7 +4,6 @@
        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;
        ......@@ -81,15 +80,6 @@ public function testStandard(): void {
        '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');
        ......
        ......
        <?php
        declare(strict_types=1);
        namespace Drupal\KernelTests\Core\Recipe;
        use Drupal\Component\Uuid\UuidInterface;
        use Drupal\contact\Entity\ContactForm;
        use Drupal\Core\Recipe\ConsoleInputCollector;
        use Drupal\Core\Recipe\InputCollectorInterface;
        use Drupal\Core\Recipe\Recipe;
        use Drupal\Core\Recipe\RecipeRunner;
        use Drupal\Core\TypedData\DataDefinitionInterface;
        use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
        use Drupal\KernelTests\KernelTestBase;
        use Symfony\Component\Console\Input\InputInterface;
        use Symfony\Component\Console\Style\StyleInterface;
        use Symfony\Component\Validator\Exception\ValidationFailedException;
        /**
        * @group Recipe
        * @covers \Drupal\Core\Recipe\InputConfigurator
        */
        class InputTest extends KernelTestBase {
        use RecipeTestTrait;
        /**
        * {@inheritdoc}
        */
        protected static $modules = ['system', 'user'];
        private readonly Recipe $recipe;
        /**
        * {@inheritdoc}
        */
        protected function setUp(): void {
        parent::setUp();
        $this->installConfig('user');
        $this->config('core.menu.static_menu_link_overrides')
        ->set('definitions', [])
        ->save();
        $this->config('system.site')
        ->set('langcode', 'en')
        ->set('name', 'Testing!')
        ->set('mail', 'ben@deep.space')
        ->set('uuid', $this->container->get(UuidInterface::class)->generate())
        ->save();
        $this->recipe = Recipe::createFromDirectory($this->getDrupalRoot() . '/core/recipes/feedback_contact_form');
        }
        public function testDefaultValueFromConfig(): void {
        // Collect the input values before processing the recipe, using a mocked
        // collector that will always return the default value.
        $collector = $this->createMock(InputCollectorInterface::class);
        $collector->expects($this->any())
        ->method('collectValue')
        ->withAnyParameters()
        ->willReturnArgument(2);
        $this->recipe->input->collectAll($collector);
        RecipeRunner::processRecipe($this->recipe);
        $this->assertSame(['ben@deep.space'], ContactForm::load('feedback')?->getRecipients());
        }
        public function testInputIsValidated(): void {
        $collector = $this->createMock(InputCollectorInterface::class);
        $collector->expects($this->atLeastOnce())
        ->method('collectValue')
        ->with('feedback_contact_form.recipient', $this->isInstanceOf(DataDefinitionInterface::class), $this->anything())
        ->willReturn('not-an-email-address');
        try {
        $this->recipe->input->collectAll($collector);
        $this->fail('Expected an exception due to validation failure, but none was thrown.');
        }
        catch (ValidationFailedException $e) {
        $this->assertSame('not-an-email-address', $e->getValue());
        $this->assertSame('This value is not a valid email address.', (string) $e->getViolations()->get(0)->getMessage());
        }
        }
        /**
        * @covers \Drupal\Core\Recipe\ConsoleInputCollector::collectValue
        */
        public function testPromptArgumentsAreForwarded(): void {
        $io = $this->createMock(StyleInterface::class);
        $io->expects($this->once())
        ->method('ask')
        ->with('What is the capital of Assyria?', "I don't know that!")
        ->willReturn('<scream>');
        $recipe = $this->createRecipe(<<<YAML
        name: 'Collecting prompt input'
        input:
        capital:
        data_type: string
        description: The capital of a long-defunct country.
        prompt:
        method: ask
        arguments:
        question: What is the capital of Assyria?
        # This argument should be discarded.
        validator: 'test_validator'
        default:
        source: value
        value: "I don't know that!"
        YAML
        );
        $collector = new ConsoleInputCollector(
        $this->createMock(InputInterface::class),
        $io,
        );
        $recipe->input->collectAll($collector);
        $this->assertSame(['capital' => '<scream>'], $recipe->input->getValues());
        }
        /**
        * @covers \Drupal\Core\Recipe\ConsoleInputCollector::collectValue
        */
        public function testMissingArgumentsThrowsException(): void {
        $recipe = $this->createRecipe(<<<YAML
        name: 'Collecting prompt input'
        input:
        capital:
        data_type: string
        description: The capital of a long-defunct country.
        prompt:
        method: ask
        default:
        source: value
        value: "I don't know that!"
        YAML
        );
        $collector = new ConsoleInputCollector(
        $this->createMock(InputInterface::class),
        $this->createMock(StyleInterface::class),
        );
        $this->expectException(\ArgumentCountError::class);
        $this->expectExceptionMessage('Argument #1 ($question) not passed');
        $recipe->input->collectAll($collector);
        }
        public function testDefaultValueFromNonExistentConfig(): void {
        $recipe = $this->createRecipe(<<<YAML
        name: 'Default value from non-existent config'
        input:
        capital:
        data_type: string
        description: This will be erroneous.
        default:
        source: config
        config: ['foo.baz', 'bar']
        YAML
        );
        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage("The 'foo.baz' config object does not exist.");
        $recipe->input->collectAll($this->createMock(InputCollectorInterface::class));
        }
        public function testLiterals(): void {
        $recipe = $this->createRecipe(<<<YAML
        name: Literals as input
        install:
        - config_test
        input:
        capital:
        data_type: string
        description: Your favorite state capital.
        default:
        source: value
        value: Boston
        some_int:
        data_type: integer
        description: This is an integer and should be stored as an integer.
        default:
        source: value
        value: 1234
        some_bool:
        data_type: boolean
        description: This is a boolean and should be stored as a boolean.
        default:
        source: value
        value: false
        some_float:
        data_type: float
        description: Pi is a float, should be stored as a float.
        default:
        source: value
        value: 3.141
        config:
        actions:
        config_test.types:
        simpleConfigUpdate:
        int: \${some_int}
        boolean: \${some_bool}
        float: \${some_float}
        system.site:
        simpleConfigUpdate:
        name: '\${capital} rocks!'
        slogan: int is \${some_int}, bool is \${some_bool} and float is \${some_float}
        YAML
        );
        // Mock a collector that only returns the default value.
        $collector = $this->createMock(InputCollectorInterface::class);
        $collector->expects($this->any())
        ->method('collectValue')
        ->withAnyParameters()
        ->willReturnArgument(2);
        $recipe->input->collectAll($collector);
        RecipeRunner::processRecipe($recipe);
        $config = $this->config('config_test.types');
        $this->assertSame(1234, $config->get('int'));
        $this->assertFalse($config->get('boolean'));
        $this->assertSame(3.141, $config->get('float'));
        $config = $this->config('system.site');
        $this->assertSame("Boston rocks!", $config->get('name'));
        $this->assertSame('int is 1234, bool is and float is 3.141', $config->get('slogan'));
        }
        }
        ......@@ -6,6 +6,7 @@
        use Drupal\Core\Recipe\Recipe;
        use Drupal\Core\Recipe\RecipeFileException;
        use Drupal\Core\TypedData\PrimitiveInterface;
        use Drupal\KernelTests\KernelTestBase;
        /**
        ......@@ -295,6 +296,298 @@ public static function providerRecipeValidation(): iterable {
        ],
        ],
        ];
        yield 'input definitions are an indexed array' => [
        <<<YAML
        name: Bad input definitions
        input:
        - data_type: string
        description: A valid enough input, but in an indexed array.
        default:
        source: value
        value: Here be dragons
        YAML,
        [
        '[input]' => ['This value should be of type associative_array.'],
        ],
        ];
        yield 'input data type is missing' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        description: What's my data type?
        default:
        source: value
        value: Here be dragons
        YAML,
        [
        '[input][foo][data_type]' => ['This field is missing.'],
        ],
        ];
        yield 'input description is not a string' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: string
        description: 3.141
        default:
        source: value
        value: Here be dragons
        YAML,
        [
        '[input][foo][description]' => ['This value should be of type string.'],
        ],
        ];
        yield 'input description is blank' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: string
        description: ''
        default:
        source: value
        value: Here be dragons
        YAML,
        [
        '[input][foo][description]' => ['This value should not be blank.'],
        ],
        ];
        yield 'input constraints are an indexed array' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: string
        description: 'Constraints need to be associative'
        constraints:
        - Type: string
        default:
        source: value
        value: Here be dragons
        YAML,
        [
        '[input][foo][constraints]' => ['This value should be of type associative_array.'],
        ],
        ];
        yield 'input data type is unknown' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: power_tool
        description: 'Bad data type'
        prompt:
        method: ask
        default:
        source: value
        value: Here be dragons
        YAML,
        [
        '[input][foo][data_type]' => ["The 'power_tool' plugin does not exist."],
        ],
        ];
        yield 'data type is not a primitive' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: list
        description: 'Non-primitive data type'
        default:
        source: value
        value: [Yeah, No]
        YAML,
        [
        '[input][foo][data_type]' => [
        "The 'list' plugin must implement or extend " . PrimitiveInterface::class . '.',
        ],
        ],
        ];
        yield 'prompt definition is not an array' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: string
        description: 'Prompt info must be an array'
        prompt: ask
        default:
        source: value
        value: Here be dragons
        YAML,
        [
        '[input][foo][prompt]' => ['This value should be of type array|(Traversable&ArrayAccess).'],
        ],
        ];
        yield 'invalid prompt method' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: string
        description: 'Bad prompt type'
        prompt:
        method: whoops
        default:
        source: value
        value: Here be dragons
        YAML,
        [
        '[input][foo][prompt][method]' => ['The value you selected is not a valid choice.'],
        ],
        ];
        yield 'prompt arguments are an indexed array' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: string
        description: 'Prompt arguments must be associative'
        prompt:
        method: ask
        arguments: [1, 2]
        default:
        source: value
        value: Here be dragons
        YAML,
        [
        '[input][foo][prompt][arguments]' => ['This value should be of type associative_array.'],
        ],
        ];
        yield 'input definition without default value' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: string
        description: 'No default'
        prompt:
        method: ask
        YAML,
        [
        '[input][foo][default]' => ['This field is missing.'],
        ],
        ];
        yield 'default value from config is not defined' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: string
        description: 'Bad default definition'
        prompt:
        method: ask
        default:
        source: config
        YAML,
        [
        '[input][foo][default]' => ["The 'config' key is required."],
        ],
        ];
        yield 'default value from config is not an array' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: email
        description: 'Bad default definition'
        prompt:
        method: ask
        default:
        source: config
        config: 'system.site:mail'
        YAML,
        [
        '[input][foo][default][config]' => ['This value should be of type list.'],
        ],
        ];
        yield 'default value from config has too few values' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: email
        description: 'Bad default definition'
        prompt:
        method: ask
        default:
        source: config
        config: ['system.site:mail']
        YAML,
        [
        '[input][foo][default][config]' => ['This collection should contain exactly 2 elements.'],
        ],
        ];
        yield 'default value from config is an associative array' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: email
        description: 'Bad default definition'
        prompt:
        method: ask
        default:
        source: config
        config:
        name: system.site
        key: mail
        YAML,
        [
        '[input][foo][default][config]' => ['This value should be of type list.'],
        ],
        ];
        yield 'default value from config has non-string values' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: string
        description: 'Bad default definition'
        prompt:
        method: ask
        default:
        source: config
        config: ['system.site', 39]
        YAML,
        [
        '[input][foo][default][config][1]' => ['This value should be of type string.'],
        ],
        ];
        yield 'default value from config has empty strings' => [
        <<<YAML
        name: Bad input definitions
        input:
        foo:
        data_type: email
        description: 'Bad default definition'
        prompt:
        method: ask
        default:
        source: config
        config: ['', 'mail']
        YAML,
        [
        '[input][foo][default][config][0]' => ['This value should not be blank.'],
        ],
        ];
        yield 'valid default value from config' => [
        <<<YAML
        name: Good input definitions
        input:
        foo:
        data_type: email
        description: 'Good default definition'
        prompt:
        method: ask
        default:
        source: config
        config: ['system.site', 'mail']
        YAML,
        NULL,
        ];
        }
        /**
        ......
        ......
        0% Loading or .
        You are about to add 0 people to the discussion. Proceed with caution.
        Please to comment