diff --git a/core/lib/Drupal/Core/Recipe/InputConfigurator.php b/core/lib/Drupal/Core/Recipe/InputConfigurator.php index 9b324ed29aa..c9f1347a836 100644 --- a/core/lib/Drupal/Core/Recipe/InputConfigurator.php +++ b/core/lib/Drupal/Core/Recipe/InputConfigurator.php @@ -72,7 +72,7 @@ final class InputConfigurator { $definition['constraints'], ); $data_definition->setSettings($definition); - $this->data[$name] = $typedDataManager->create($data_definition); + $this->data[$name] = $typedDataManager->create($data_definition, name: "$prefix.$name"); } } @@ -112,9 +112,9 @@ final class InputConfigurator { foreach ($this->dependencies->recipes as $dependency) { $descriptions = array_merge($descriptions, $dependency->input->describeAll()); } - foreach ($this->getDataDefinitions() as $key => $definition) { - $name = $this->prefix . '.' . $key; - $descriptions[$name] = $definition->getDescription(); + foreach ($this->data as $data) { + $name = $data->getName(); + $descriptions[$name] = $data->getDataDefinition()->getDescription(); } return $descriptions; } @@ -153,7 +153,7 @@ final class InputConfigurator { $definition = $data->getDataDefinition(); $value = $collector->collectValue( - $this->prefix . '.' . $key, + $data->getName(), $definition, $this->getDefaultValue($definition), ); @@ -161,7 +161,7 @@ final class InputConfigurator { $violations = $data->validate(); if (count($violations) > 0) { - throw new ValidationFailedException($value, $violations); + throw new ValidationFailedException($data, $violations); } $this->values[$key] = $data->getCastedValue(); } diff --git a/core/lib/Drupal/Core/Recipe/Recipe.php b/core/lib/Drupal/Core/Recipe/Recipe.php index 5e0d55b2e13..a09dc17de66 100644 --- a/core/lib/Drupal/Core/Recipe/Recipe.php +++ b/core/lib/Drupal/Core/Recipe/Recipe.php @@ -9,6 +9,7 @@ use Drupal\Core\Extension\Dependency; use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Core\Extension\ThemeExtensionList; use Drupal\Component\Serialization\Yaml; +use Drupal\Core\Render\Element; use Drupal\Core\TypedData\PrimitiveInterface; use Drupal\Core\Validation\Plugin\Validation\Constraint\RegexConstraint; use Symfony\Component\Validator\Constraints\All; @@ -203,8 +204,8 @@ final class Recipe { 'interface' => PrimitiveInterface::class, ]), ], - // If there is a `prompt` element, it has its own set of - // constraints. + // The `prompt` and `form` elements, though optional, have their + // own sets of constraints, 'prompt' => new Optional([ new Collection([ 'method' => [ @@ -215,6 +216,19 @@ final class Recipe { ]), ]), ]), + 'form' => new Optional([ + new Sequentially([ + new Type('associative_array'), + // Every element in the `form` array has to be a form API + // property, prefixed with `#`. Because recipe inputs can only + // be primitive data types, child elements aren't allowed. + new Callback(function (array $element, ExecutionContextInterface $context): void { + if (Element::children($element)) { + $context->addViolation('Form elements for recipe inputs cannot have child elements.'); + } + }), + ]), + ]), // Every input must define a default value. 'default' => new Required([ new Collection([ diff --git a/core/lib/Drupal/Core/Recipe/RecipeInputFormTrait.php b/core/lib/Drupal/Core/Recipe/RecipeInputFormTrait.php new file mode 100644 index 00000000000..5327c9e315c --- /dev/null +++ b/core/lib/Drupal/Core/Recipe/RecipeInputFormTrait.php @@ -0,0 +1,151 @@ + [ + * 'input_1' => [ + * '#type' => 'textfield', + * '#title' => 'Some input value', + * ], + * 'input_2' => [ + * '#type' => 'checkbox', + * '#title' => 'Enable some feature or other?', + * ], + * ], + * 'dependency_recipe' => [ + * 'input_1' => [ + * '#type' => 'textarea', + * '#title' => 'An input defined by a dependency of recipe_1', + * ], + * ], + * '#tree' => TRUE, + * ]; + * @endcode + * + * The `#tree` property will always be set to TRUE. + * + * @var array + */ + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable + public array $form = []; + + /** + * {@inheritdoc} + */ + public function collectValue(string $name, DataDefinitionInterface $definition, mixed $default_value): mixed { + $element = $definition->getSetting('form'); + if ($element) { + $element += [ + '#description' => $definition->getDescription(), + '#default_value' => $default_value, + ]; + // Recipe inputs are always required. + $element['#required'] = TRUE; + NestedArray::setValue($this->form, explode('.', $name, 2), $element); + + // Always return the input elements as a tree. + $this->form['#tree'] = TRUE; + } + return $default_value; + } + + }; + $recipe->input->collectAll($collector); + return $collector->form; + } + + /** + * Validates user-inputted values to a recipe and its dependencies. + * + * @param \Drupal\Core\Recipe\Recipe $recipe + * A recipe. + * @param array $form + * The form being validated, which should include the tree of elements + * returned by ::buildRecipeInputForm(). + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current form state. The values should be organized in the tree + * structure that was returned by ::buildRecipeInputForm(). + */ + protected function validateRecipeInput(Recipe $recipe, array &$form, FormStateInterface $form_state): void { + try { + $this->setRecipeInput($recipe, $form_state); + } + catch (ValidationFailedException $e) { + $data = $e->getValue(); + + if ($data instanceof TypedDataInterface) { + $element = NestedArray::getValue($form, explode('.', $data->getName(), 2)); + $form_state->setError($element, $e->getMessage()); + } + else { + // If the data isn't a typed data object, we have no idea how to handle + // the situation, so just re-throw the exception. + throw $e; + } + } + } + + /** + * Supplies user-inputted values to a recipe and its dependencies. + * + * @param \Drupal\Core\Recipe\Recipe $recipe + * A recipe. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current form state. The values should be organized in the tree + * structure that was returned by ::buildRecipeInputForm(). + */ + protected function setRecipeInput(Recipe $recipe, FormStateInterface $form_state): void { + $recipe->input->collectAll(new class ($form_state) implements InputCollectorInterface { + + public function __construct(private readonly FormStateInterface $formState) { + } + + /** + * {@inheritdoc} + */ + public function collectValue(string $name, DataDefinitionInterface $definition, mixed $default_value): mixed { + return $this->formState->getValue(explode('.', $name, 2), $default_value); + } + + }); + } + +} diff --git a/core/modules/system/tests/modules/form_test/form_test.routing.yml b/core/modules/system/tests/modules/form_test/form_test.routing.yml index be0a1a34a8b..54b3d115323 100644 --- a/core/modules/system/tests/modules/form_test/form_test.routing.yml +++ b/core/modules/system/tests/modules/form_test/form_test.routing.yml @@ -564,3 +564,10 @@ form_test.incorrect_config_target: _admin_route: TRUE requirements: _access: 'TRUE' + +form_test.recipe_input: + path: '/form-test/recipe-input' + defaults: + _form: '\Drupal\form_test\Form\FormTestRecipeInputForm' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/modules/form_test/src/Form/FormTestRecipeInputForm.php b/core/modules/system/tests/modules/form_test/src/Form/FormTestRecipeInputForm.php new file mode 100644 index 00000000000..0619e4afd3a --- /dev/null +++ b/core/modules/system/tests/modules/form_test/src/Form/FormTestRecipeInputForm.php @@ -0,0 +1,63 @@ +buildRecipeInputForm($this->getRecipe()); + + $form['apply'] = [ + '#type' => 'submit', + '#value' => $this->t('Apply recipe'), + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state): void { + $this->validateRecipeInput($this->getRecipe(), $form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + $recipe = $this->getRecipe(); + $this->setRecipeInput($recipe, $form_state); + RecipeRunner::processRecipe($recipe); + } + +} diff --git a/core/modules/system/tests/src/Functional/Form/RecipeFormInputTest.php b/core/modules/system/tests/src/Functional/Form/RecipeFormInputTest.php new file mode 100644 index 00000000000..35303b740cd --- /dev/null +++ b/core/modules/system/tests/src/Functional/Form/RecipeFormInputTest.php @@ -0,0 +1,52 @@ +drupalGet('/form-test/recipe-input'); + + $assert_session = $this->assertSession(); + // There should only be one nested input element on the page: the one + // defined by the input_test recipe. + $assert_session->elementsCount('css', 'input[name*="["]', 1); + // The default value and description should be visible. + $assert_session->fieldValueEquals('input_test[owner]', 'Dries Buytaert'); + $assert_session->pageTextContains('The name of the site owner.'); + // All recipe inputs are required. + $this->submitForm(['input_test[owner]' => ''], 'Apply recipe'); + $assert_session->statusMessageContains("Site owner's name field is required.", 'error'); + // All inputs should be validated with their own constraints. + $this->submitForm(['input_test[owner]' => 'Hacker Joe'], 'Apply recipe'); + $assert_session->statusMessageContains("I don't think you should be owning sites.", 'error'); + // The correct element should be flagged as invalid. + $assert_session->elementAttributeExists('named', ['field', 'input_test[owner]'], 'aria-invalid'); + // Submit the form with a valid value and apply the recipe, to prove that + // it was passed through correctly. + $this->submitForm(['input_test[owner]' => 'Legitimate Human'], 'Apply recipe'); + $this->assertSame("Legitimate Human's Turf", $this->config('system.site')->get('name')); + } + +} diff --git a/core/recipes/feedback_contact_form/recipe.yml b/core/recipes/feedback_contact_form/recipe.yml index 084f2712537..c6bf74cf40c 100644 --- a/core/recipes/feedback_contact_form/recipe.yml +++ b/core/recipes/feedback_contact_form/recipe.yml @@ -13,6 +13,9 @@ input: method: ask arguments: question: 'What email address should receive website feedback?' + form: + '#type': email + '#title': 'Feedback form email address' default: source: config config: ['system.site', 'mail'] diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php index c366883e290..cc60172a960 100644 --- a/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php +++ b/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php @@ -11,6 +11,7 @@ use Drupal\Core\Recipe\InputCollectorInterface; use Drupal\Core\Recipe\Recipe; use Drupal\Core\Recipe\RecipeRunner; use Drupal\Core\TypedData\DataDefinitionInterface; +use Drupal\Core\TypedData\TypedDataInterface; use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait; use Drupal\KernelTests\KernelTestBase; use Symfony\Component\Console\Input\InputInterface; @@ -79,7 +80,9 @@ class InputTest extends KernelTestBase { $this->fail('Expected an exception due to validation failure, but none was thrown.'); } catch (ValidationFailedException $e) { - $this->assertSame('not-an-email-address', $e->getValue()); + $value = $e->getValue(); + $this->assertInstanceOf(TypedDataInterface::class, $value); + $this->assertSame('not-an-email-address', $value->getValue()); $this->assertSame('This value is not a valid email address.', (string) $e->getViolations()->get(0)->getMessage()); } } diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php index fe736e90062..5829898f176 100644 --- a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php @@ -505,6 +505,88 @@ YAML, '[input][foo][prompt][arguments]' => ['This value should be of type array.'], ], ]; + yield 'form element is not an array' => [ + << ['This value should be of type associative_array.'], + ], + ]; + yield 'form element is an indexed array' => [ + << ['This value should be of type associative_array.'], + ], + ]; + yield 'form element is an empty array' => [ + << ['This value should be of type associative_array.'], + ], + ]; + yield 'form element has children' => [ + << ['Form elements for recipe inputs cannot have child elements.'], + ], + ]; + yield 'Valid form element' => [ + << [ <<