summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorAlex Pott <alex.a.pott@googlemail.com>2024-11-21 23:03:15 +0000
committerAlex Pott <alex.a.pott@googlemail.com>2024-11-21 23:03:15 +0000
commit8b67272ac33224b5190d28a0c016935d8b97e393 (patch)
tree0be95d3ba93752d9f48634d78499d46de97bc60a
parent42fda13321fc9ae8fb319385ad25a5d2b6772599 (diff)
downloaddrupal-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
-rw-r--r--core/lib/Drupal/Core/Recipe/InputConfigurator.php12
-rw-r--r--core/lib/Drupal/Core/Recipe/Recipe.php18
-rw-r--r--core/lib/Drupal/Core/Recipe/RecipeInputFormTrait.php151
-rw-r--r--core/modules/system/tests/modules/form_test/form_test.routing.yml7
-rw-r--r--core/modules/system/tests/modules/form_test/src/Form/FormTestRecipeInputForm.php63
-rw-r--r--core/modules/system/tests/src/Functional/Form/RecipeFormInputTest.php52
-rw-r--r--core/recipes/feedback_contact_form/recipe.yml3
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php5
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php82
-rw-r--r--core/tests/fixtures/recipes/input_test/recipe.yml21
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"