diff options
author | Alex Pott <alex.a.pott@googlemail.com> | 2024-11-21 23:03:15 +0000 |
---|---|---|
committer | Alex Pott <alex.a.pott@googlemail.com> | 2024-11-21 23:03:15 +0000 |
commit | 8b67272ac33224b5190d28a0c016935d8b97e393 (patch) | |
tree | 0be95d3ba93752d9f48634d78499d46de97bc60a | |
parent | 42fda13321fc9ae8fb319385ad25a5d2b6772599 (diff) | |
download | drupal-8b67272ac33224b5190d28a0c016935d8b97e393.tar.gz drupal-8b67272ac33224b5190d28a0c016935d8b97e393.zip |
Issue #3483435 by phenaproxima, alexpott, thejimbirch: Add a trait for forms that want to collect input on behalf of a recipe
10 files changed, 405 insertions, 9 deletions
diff --git a/core/lib/Drupal/Core/Recipe/InputConfigurator.php b/core/lib/Drupal/Core/Recipe/InputConfigurator.php index 77bac34c6f1..f6ace4b489b 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; } @@ -151,7 +151,7 @@ final class InputConfigurator { $definition = $data->getDataDefinition(); $value = $collector->collectValue( - $this->prefix . '.' . $key, + $data->getName(), $definition, $this->getDefaultValue($definition), ); @@ -159,7 +159,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 c9f5f055932..54307bc9897 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 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Recipe; + +use Drupal\Component\Utility\NestedArray; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\TypedData\DataDefinitionInterface; +use Drupal\Core\TypedData\TypedDataInterface; +use Symfony\Component\Validator\Exception\ValidationFailedException; + +/** + * Defines helper methods for forms which collect input on behalf of recipes. + */ +trait RecipeInputFormTrait { + + /** + * Generates a tree of form elements for a recipe's inputs. + * + * @param \Drupal\Core\Recipe\Recipe $recipe + * A recipe. + * + * @return array[] + * A nested array of form elements for collecting input values for the given + * recipe and its dependencies. The elements will be grouped by the recipe + * that defined the input -- for example, $return['recipe_name']['input1'], + * $return['recipe_name']['input2'], $return['dependency']['input_name'], + * and so forth. The returned array will have the `#tree` property set to + * TRUE. + */ + protected function buildRecipeInputForm(Recipe $recipe): array { + $collector = new class () implements InputCollectorInterface { + + /** + * A form array containing the input elements for the given recipe. + * + * This will be a tree of input elements, grouped by the name of the + * recipe that defines them. For example: + * + * @code + * $form = [ + * 'recipe_1' => [ + * '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 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\form_test\Form; + +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Recipe\Recipe; +use Drupal\Core\Recipe\RecipeInputFormTrait; +use Drupal\Core\Recipe\RecipeRunner; + +class FormTestRecipeInputForm extends FormBase { + + use RecipeInputFormTrait; + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'form_test_recipe_input'; + } + + /** + * Returns the recipe object under test. + * + * @return \Drupal\Core\Recipe\Recipe + * A Recipe object for the input_test recipe. + */ + private function getRecipe(): Recipe { + return Recipe::createFromDirectory('core/tests/fixtures/recipes/input_test'); + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $form += $this->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 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\system\Functional\Form; + +use Drupal\Tests\BrowserTestBase; + +/** + * @covers \Drupal\Core\Recipe\RecipeInputFormTrait + * @group system + */ +class RecipeFormInputTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['form_test']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Tests collecting recipe input via a form. + */ + public function testRecipeInputViaForm(): void { + $this->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 55c140f2b87..8e4f621b2ad 100644 --- a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php @@ -508,6 +508,88 @@ YAML, '[input][foo][prompt][arguments]' => ['This value should be of type associative_array.'], ], ]; + yield 'form element is not an array' => [ + <<<YAML +name: Bad input definitions +input: + foo: + data_type: string + description: 'Form element must be array' + form: true + default: + source: value + value: Here be dragons +YAML, + [ + '[input][foo][form]' => ['This value should be of type associative_array.'], + ], + ]; + yield 'form element is an indexed array' => [ + <<<YAML +name: Bad input definitions +input: + foo: + data_type: string + description: 'Form element must be associative' + form: [text] + default: + source: value + value: Here be dragons +YAML, + [ + '[input][foo][form]' => ['This value should be of type associative_array.'], + ], + ]; + yield 'form element is an empty array' => [ + <<<YAML +name: Bad input definitions +input: + foo: + data_type: string + description: 'Form elements cannot be empty' + form: [] + default: + source: value + value: Here be dragons +YAML, + [ + '[input][foo][form]' => ['This value should be of type associative_array.'], + ], + ]; + yield 'form element has children' => [ + <<<YAML +name: Bad input definitions +input: + foo: + data_type: string + description: 'Form elements cannot have children' + form: + '#type': textfield + child: + '#type': select + default: + source: value + value: Here be dragons +YAML, + [ + '[input][foo][form]' => ['Form elements for recipe inputs cannot have child elements.'], + ], + ]; + yield 'Valid form element' => [ + <<<YAML +name: Form input definitions +input: + foo: + data_type: string + description: 'This has a valid form element' + form: + '#type': textfield + default: + source: value + value: Here be dragons +YAML, + NULL, + ]; yield 'input definition without default value' => [ <<<YAML name: Bad input definitions diff --git a/core/tests/fixtures/recipes/input_test/recipe.yml b/core/tests/fixtures/recipes/input_test/recipe.yml new file mode 100644 index 00000000000..5558ada7c9b --- /dev/null +++ b/core/tests/fixtures/recipes/input_test/recipe.yml @@ -0,0 +1,21 @@ +name: Input Test +input: + owner: + data_type: string + description: 'The name of the site owner.' + constraints: + Regex: + pattern: '/hack/i' + match: false + message: "I don't think you should be owning sites." + form: + '#type': textfield + '#title': "Site owner's name" + default: + source: value + value: 'Dries Buytaert' +config: + actions: + system.site: + simpleConfigUpdate: + name: "${owner}'s Turf" |