Skip to content
Snippets Groups Projects
Unverified Commit 0e4ae7c2 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3483435 by phenaproxima, alexpott, thejimbirch: Add a trait for forms...

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 8b67272a)
parent 5087d938
No related branches found
No related tags found
No related merge requests found
Pipeline #346409 passed with warnings
+1
Showing with 405 additions and 9 deletions
...@@ -72,7 +72,7 @@ public function __construct( ...@@ -72,7 +72,7 @@ public function __construct(
$definition['constraints'], $definition['constraints'],
); );
$data_definition->setSettings($definition); $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 @@ public function describeAll(): array { ...@@ -112,9 +112,9 @@ public function describeAll(): array {
foreach ($this->dependencies->recipes as $dependency) { foreach ($this->dependencies->recipes as $dependency) {
$descriptions = array_merge($descriptions, $dependency->input->describeAll()); $descriptions = array_merge($descriptions, $dependency->input->describeAll());
} }
foreach ($this->getDataDefinitions() as $key => $definition) { foreach ($this->data as $data) {
$name = $this->prefix . '.' . $key; $name = $data->getName();
$descriptions[$name] = $definition->getDescription(); $descriptions[$name] = $data->getDataDefinition()->getDescription();
} }
return $descriptions; return $descriptions;
} }
...@@ -153,7 +153,7 @@ public function collectAll(InputCollectorInterface $collector, array &$processed ...@@ -153,7 +153,7 @@ public function collectAll(InputCollectorInterface $collector, array &$processed
$definition = $data->getDataDefinition(); $definition = $data->getDataDefinition();
$value = $collector->collectValue( $value = $collector->collectValue(
$this->prefix . '.' . $key, $data->getName(),
$definition, $definition,
$this->getDefaultValue($definition), $this->getDefaultValue($definition),
); );
...@@ -161,7 +161,7 @@ public function collectAll(InputCollectorInterface $collector, array &$processed ...@@ -161,7 +161,7 @@ public function collectAll(InputCollectorInterface $collector, array &$processed
$violations = $data->validate(); $violations = $data->validate();
if (count($violations) > 0) { if (count($violations) > 0) {
throw new ValidationFailedException($value, $violations); throw new ValidationFailedException($data, $violations);
} }
$this->values[$key] = $data->getCastedValue(); $this->values[$key] = $data->getCastedValue();
} }
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Extension\ThemeExtensionList; use Drupal\Core\Extension\ThemeExtensionList;
use Drupal\Component\Serialization\Yaml; use Drupal\Component\Serialization\Yaml;
use Drupal\Core\Render\Element;
use Drupal\Core\TypedData\PrimitiveInterface; use Drupal\Core\TypedData\PrimitiveInterface;
use Drupal\Core\Validation\Plugin\Validation\Constraint\RegexConstraint; use Drupal\Core\Validation\Plugin\Validation\Constraint\RegexConstraint;
use Symfony\Component\Validator\Constraints\All; use Symfony\Component\Validator\Constraints\All;
...@@ -203,8 +204,8 @@ private static function parse(string $file): array { ...@@ -203,8 +204,8 @@ private static function parse(string $file): array {
'interface' => PrimitiveInterface::class, 'interface' => PrimitiveInterface::class,
]), ]),
], ],
// If there is a `prompt` element, it has its own set of // The `prompt` and `form` elements, though optional, have their
// constraints. // own sets of constraints,
'prompt' => new Optional([ 'prompt' => new Optional([
new Collection([ new Collection([
'method' => [ 'method' => [
...@@ -215,6 +216,19 @@ private static function parse(string $file): array { ...@@ -215,6 +216,19 @@ private static function parse(string $file): array {
]), ]),
]), ]),
]), ]),
'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. // Every input must define a default value.
'default' => new Required([ 'default' => new Required([
new Collection([ new Collection([
......
<?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: ...@@ -564,3 +564,10 @@ form_test.incorrect_config_target:
_admin_route: TRUE _admin_route: TRUE
requirements: requirements:
_access: 'TRUE' _access: 'TRUE'
form_test.recipe_input:
path: '/form-test/recipe-input'
defaults:
_form: '\Drupal\form_test\Form\FormTestRecipeInputForm'
requirements:
_access: 'TRUE'
<?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);
}
}
<?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: ...@@ -13,6 +13,9 @@ input:
method: ask method: ask
arguments: arguments:
question: 'What email address should receive website feedback?' question: 'What email address should receive website feedback?'
form:
'#type': email
'#title': 'Feedback form email address'
default: default:
source: config source: config
config: ['system.site', 'mail'] config: ['system.site', 'mail']
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
use Drupal\Core\Recipe\Recipe; use Drupal\Core\Recipe\Recipe;
use Drupal\Core\Recipe\RecipeRunner; use Drupal\Core\Recipe\RecipeRunner;
use Drupal\Core\TypedData\DataDefinitionInterface; use Drupal\Core\TypedData\DataDefinitionInterface;
use Drupal\Core\TypedData\TypedDataInterface;
use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait; use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
use Drupal\KernelTests\KernelTestBase; use Drupal\KernelTests\KernelTestBase;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
...@@ -79,7 +80,9 @@ public function testInputIsValidated(): void { ...@@ -79,7 +80,9 @@ public function testInputIsValidated(): void {
$this->fail('Expected an exception due to validation failure, but none was thrown.'); $this->fail('Expected an exception due to validation failure, but none was thrown.');
} }
catch (ValidationFailedException $e) { 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()); $this->assertSame('This value is not a valid email address.', (string) $e->getViolations()->get(0)->getMessage());
} }
} }
......
...@@ -505,6 +505,88 @@ public static function providerRecipeValidation(): iterable { ...@@ -505,6 +505,88 @@ public static function providerRecipeValidation(): iterable {
'[input][foo][prompt][arguments]' => ['This value should be of type array.'], '[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' => [ yield 'input definition without default value' => [
<<<YAML <<<YAML
name: Bad input definitions name: Bad input definitions
......
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"
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment