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
parent
af72eee404
commit
45843c3758
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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'));
|
||||
}
|
||||
|
||||
}
|
|
@ -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']
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
Loading…
Reference in New Issue