Issue #3483435 by phenaproxima, alexpott, thejimbirch: Add a trait for forms that want to collect input on behalf of a recipe

(cherry picked from commit 8b67272ac3)
merge-requests/9736/merge
Alex Pott 2024-11-21 23:03:15 +00:00
parent af72eee404
commit 45843c3758
No known key found for this signature in database
GPG Key ID: BDA67E7EE836E5CE
10 changed files with 405 additions and 9 deletions

View File

@ -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();
}

View File

@ -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([

View File

@ -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);
}
});
}
}

View File

@ -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'

View File

@ -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);
}
}

View File

@ -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'));
}
}

View File

@ -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']

View File

@ -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());
}
}

View File

@ -505,6 +505,88 @@ YAML,
'[input][foo][prompt][arguments]' => ['This value should be of type 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

View File

@ -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"