Commit 03fc2042 authored by catch's avatar catch

Issue #2537732 by Xano, tim.plunkett, gnuget, claudiu.cristea, drunken monkey,...

Issue #2537732 by Xano, tim.plunkett, gnuget, claudiu.cristea, drunken monkey, bojanz: PluginFormInterface must have access to the complete $form_state (introduce SubFormState for embedded forms)
parent 2ec23349
......@@ -5,6 +5,7 @@
use Drupal\Core\Executable\ExecutableManagerInterface;
use Drupal\Core\Executable\ExecutablePluginBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformStateInterface;
use Drupal\Core\Plugin\ContextAwarePluginAssignmentTrait;
/**
......@@ -47,6 +48,9 @@ public function isNegated() {
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
if ($form_state instanceof SubformStateInterface) {
$form_state = $form_state->getCompleteFormState();
}
$contexts = $form_state->getTemporaryValue('gathered_contexts') ?: [];
$form['context_mapping'] = $this->addContextAssignmentElement($this, $contexts);
$form['negate'] = array(
......@@ -68,6 +72,9 @@ public function validateConfigurationForm(array &$form, FormStateInterface $form
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['negate'] = $form_state->getValue('negate');
if ($form_state->hasValue('context_mapping')) {
$this->setContextMapping($form_state->getValue('context_mapping'));
}
}
/**
......
......@@ -11,6 +11,8 @@
*/
class FormState implements FormStateInterface {
use FormStateValuesTrait;
/**
* Tracks if any errors have been set on any form.
*
......@@ -246,7 +248,8 @@ class FormState implements FormStateInterface {
*
* This property is uncacheable.
*
* @var array
* @var array|null
* The submitted user input array, or NULL if no input was submitted yet.
*/
protected $input;
......@@ -977,67 +980,6 @@ public function &getValues() {
return $this->values;
}
/**
* {@inheritdoc}
*/
public function &getValue($key, $default = NULL) {
$exists = NULL;
$value = &NestedArray::getValue($this->getValues(), (array) $key, $exists);
if (!$exists) {
$value = $default;
}
return $value;
}
/**
* {@inheritdoc}
*/
public function setValues(array $values) {
$this->values = $values;
return $this;
}
/**
* {@inheritdoc}
*/
public function setValue($key, $value) {
NestedArray::setValue($this->getValues(), (array) $key, $value, TRUE);
return $this;
}
/**
* {@inheritdoc}
*/
public function unsetValue($key) {
NestedArray::unsetValue($this->getValues(), (array) $key);
return $this;
}
/**
* {@inheritdoc}
*/
public function hasValue($key) {
$exists = NULL;
$value = NestedArray::getValue($this->getValues(), (array) $key, $exists);
return $exists && isset($value);
}
/**
* {@inheritdoc}
*/
public function isValueEmpty($key) {
$exists = NULL;
$value = NestedArray::getValue($this->getValues(), (array) $key, $exists);
return !$exists || empty($value);
}
/**
* {@inheritdoc}
*/
public function setValueForElement(array $element, $value) {
return $this->setValue($element['#parents'], $value);
}
/**
* {@inheritdoc}
*/
......
This diff is collapsed.
<?php
namespace Drupal\Core\Form;
use Drupal\Component\Utility\NestedArray;
/**
* Provides methods to manage form state values.
*
* @see \Drupal\Core\Form\FormStateInterface
*
* @ingroup form_api
*/
trait FormStateValuesTrait {
/**
* Implements \Drupal\Core\Form\FormStateInterface::getValues()
*/
abstract public function &getValues();
/**
* Implements \Drupal\Core\Form\FormStateInterface::getValue()
*/
public function &getValue($key, $default = NULL) {
$exists = NULL;
$value = &NestedArray::getValue($this->getValues(), (array) $key, $exists);
if (!$exists) {
$value = $default;
}
return $value;
}
/**
* Implements \Drupal\Core\Form\FormStateInterface::setValues()
*/
public function setValues(array $values) {
$existing_values = &$this->getValues();
$existing_values = $values;
return $this;
}
/**
* Implements \Drupal\Core\Form\FormStateInterface::setValue()
*/
public function setValue($key, $value) {
NestedArray::setValue($this->getValues(), (array) $key, $value, TRUE);
return $this;
}
/**
* Implements \Drupal\Core\Form\FormStateInterface::unsetValue()
*/
public function unsetValue($key) {
NestedArray::unsetValue($this->getValues(), (array) $key);
return $this;
}
/**
* Implements \Drupal\Core\Form\FormStateInterface::hasValue()
*/
public function hasValue($key) {
$exists = NULL;
$value = NestedArray::getValue($this->getValues(), (array) $key, $exists);
return $exists && isset($value);
}
/**
* Implements \Drupal\Core\Form\FormStateInterface::isValueEmpty()
*/
public function isValueEmpty($key) {
$exists = NULL;
$value = NestedArray::getValue($this->getValues(), (array) $key, $exists);
return !$exists || empty($value);
}
/**
* Implements \Drupal\Core\Form\FormStateInterface::setValueForElement()
*/
public function setValueForElement(array $element, $value) {
return $this->setValue($element['#parents'], $value);
}
}
<?php
namespace Drupal\Core\Form;
use Drupal\Component\Utility\NestedArray;
/**
* Stores information about the state of a subform.
*/
class SubformState extends FormStateDecoratorBase implements SubformStateInterface {
use FormStateValuesTrait;
/**
* The parent form.
*
* @var mixed[]
*/
protected $parentForm;
/**
* The subform.
*
* @var mixed[]
*/
protected $subform;
/**
* Constructs a new instance.
*
* @param mixed[] $subform
* The subform for which to create a form state.
* @param mixed[] $parent_form
* The subform's parent form.
* @param \Drupal\Core\Form\FormStateInterface $parent_form_state
* The parent form state.
*/
protected function __construct(array &$subform, array &$parent_form, FormStateInterface $parent_form_state) {
$this->decoratedFormState = $parent_form_state;
$this->parentForm = $parent_form;
$this->subform = $subform;
}
/**
* Creates a new instance for a subform.
*
* @param mixed[] $subform
* The subform for which to create a form state.
* @param mixed[] $parent_form
* The subform's parent form.
* @param \Drupal\Core\Form\FormStateInterface $parent_form_state
* The parent form state.
*
* @return static
*/
public static function createForSubform(array &$subform, array &$parent_form, FormStateInterface $parent_form_state) {
return new static($subform, $parent_form, $parent_form_state);
}
/**
* Gets the subform's parents relative to its parent form.
*
* @param string $property
* The property name (#parents or #array_parents).
*
* @return mixed
*
* @throws \InvalidArgumentException
* Thrown when the requested property does not exist.
* @throws \UnexpectedValueException
* Thrown when the subform is not contained by the given parent form.
*/
protected function getParents($property) {
foreach ([$this->subform, $this->parentForm] as $form) {
if (!isset($form[$property]) || !is_array($form[$property])) {
throw new \RuntimeException(sprintf('The subform and parent form must contain the %s property, which must be an array. Try calling this method from a #process callback instead.', $property));
}
}
$relative_subform_parents = $this->subform[$property];
// Remove all of the subform's parents that are also the parent form's
// parents, so we are left with the parents relative to the parent form.
foreach ($this->parentForm[$property] as $parent_form_parent) {
if ($parent_form_parent !== $relative_subform_parents[0]) {
// The parent form's parents are the subform's parents as well. If we
// find no match, that means the given subform is not contained by the
// given parent form.
throw new \UnexpectedValueException('The subform is not contained by the given parent form.');
}
array_shift($relative_subform_parents);
}
return $relative_subform_parents;
}
/**
* {@inheritdoc}
*/
public function &getValues() {
$exists = NULL;
$values = &NestedArray::getValue(parent::getValues(), $this->getParents('#parents'), $exists);
if (!$exists) {
$values = [];
}
elseif (!is_array($values)) {
throw new \UnexpectedValueException('The form state values do not belong to the subform.');
}
return $values;
}
/**
* {@inheritdoc}
*/
public function getCompleteFormState() {
return $this->decoratedFormState instanceof SubformStateInterface ? $this->decoratedFormState->getCompleteFormState() : $this->decoratedFormState;
}
/**
* {@inheritdoc}
*/
public function setLimitValidationErrors($limit_validation_errors) {
if (is_array($limit_validation_errors)) {
$limit_validation_errors = array_merge($this->getParents('#parents'), $limit_validation_errors);
}
return parent::setLimitValidationErrors($limit_validation_errors);
}
/**
* {@inheritdoc}
*/
public function getLimitValidationErrors() {
$limit_validation_errors = parent::getLimitValidationErrors();
if (is_array($limit_validation_errors)) {
return array_slice($limit_validation_errors, count($this->getParents('#parents')));
}
return $limit_validation_errors;
}
/**
* {@inheritdoc}
*/
public function setErrorByName($name, $message = '') {
$parents = $this->subform['#array_parents'];
$parents[] = $name;
$name = implode('][', $parents);
parent::setErrorByName($name, $message);
return $this;
}
}
<?php
namespace Drupal\Core\Form;
/**
* Stores information about the state of a subform.
*
* In the context of Drupal's Form API, a subform is a form definition array
* that will be nested into a "parent" form. For instance:
*
* @code
* $subform = [
* 'method' => [
* '#type' => 'select',
* // …
* ],
* ];
* $form = [
* // …
* 'settings' => $subform,
* ];
* @endcode
*
* All input fields nested under "settings" are then considered part of that
* "subform". The concept is used mostly when the subform is defined by a
* different class (potentially even in a different module) than the parent
* form. This is often the case for plugins: a plugin's buildConfigurationForm()
* would then be handed an instance of this interface as the second parameter.
*
* The benefit of doing this is that the plugin can then just define the form –
* and use the form state – as if it would define a "proper" form, not nested in
* some other form structure. This means that it won't have to know the key(s)
* under which its form structure will be nested – for instance, when retrieving
* the form values during form validation or submission.
*
* Contrary to "proper" forms, subforms don't translate to a <form> tag in the
* HTML response. Instead, they can only be discerned in the HTML code by the
* nesting of the input tags' names.
*
* @see \Drupal\Core\Plugin\PluginFormInterface::buildConfigurationForm()
*/
interface SubformStateInterface extends FormStateInterface {
/**
* Gets the complete form state.
*
* @return \Drupal\Core\Form\FormStateInterface
*/
public function getCompleteFormState();
}
......@@ -31,7 +31,9 @@ interface PluginFormInterface {
* @param array $form
* An associative array containing the initial structure of the plugin form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the complete form.
* The current state of the form. Calling code should pass on a subform
* state created through
* \Drupal\Core\Form\SubformState::createForSubform().
*
* @return array
* The form structure.
......@@ -45,7 +47,9 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta
* An associative array containing the structure of the plugin form as built
* by static::buildConfigurationForm().
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the complete form.
* The current state of the form. Calling code should pass on a subform
* state created through
* \Drupal\Core\Form\SubformState::createForSubform().
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state);
......@@ -56,7 +60,9 @@ public function validateConfigurationForm(array &$form, FormStateInterface $form
* An associative array containing the structure of the plugin form as built
* by static::buildConfigurationForm().
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the complete form.
* The current state of the form. Calling code should pass on a subform
* state created through
* \Drupal\Core\Form\SubformState::createForSubform().
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state);
......
......@@ -9,8 +9,8 @@
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Executable\ExecutableManagerInterface;
use Drupal\Core\Extension\ThemeHandlerInterface;
use Drupal\Core\Form\FormState;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Plugin\ContextAwarePluginInterface;
use Drupal\Core\Plugin\Context\ContextRepositoryInterface;
......@@ -134,7 +134,9 @@ public function form(array $form, FormStateInterface $form_state) {
$form_state->setTemporaryValue('gathered_contexts', $this->contextRepository->getAvailableContexts());
$form['#tree'] = TRUE;
$form['settings'] = $this->getPluginForm($entity->getPlugin())->buildConfigurationForm(array(), $form_state);
$form['settings'] = [];
$subform_state = SubformState::createForSubform($form['settings'], $form, $form_state);
$form['settings'] = $this->getPluginForm($entity->getPlugin())->buildConfigurationForm($form['settings'], $subform_state);
$form['visibility'] = $this->buildVisibilityInterface([], $form_state);
// If creating a new block, calculate a safe default machine name.
......@@ -294,11 +296,7 @@ public function validateForm(array &$form, FormStateInterface $form_state) {
// The Block Entity form puts all block plugin form elements in the
// settings form element, so just pass that to the block for validation.
$settings = (new FormState())->setValues($form_state->getValue('settings'));
// Call the plugin validate handler.
$this->getPluginForm($this->entity->getPlugin())->validateConfigurationForm($form, $settings);
// Update the original form values.
$form_state->setValue('settings', $settings->getValues());
$this->getPluginForm($this->entity->getPlugin())->validateConfigurationForm($form['settings'], SubformState::createForSubform($form['settings'], $form, $form_state));
$this->validateVisibility($form, $form_state);
}
......@@ -322,11 +320,7 @@ protected function validateVisibility(array $form, FormStateInterface $form_stat
// Allow the condition to validate the form.
$condition = $form_state->get(['conditions', $condition_id]);
$condition_values = (new FormState())
->setValues($values);
$condition->validateConfigurationForm($form, $condition_values);
// Update the original form values.
$form_state->setValue(['visibility', $condition_id], $condition_values->getValues());
$condition->validateConfigurationForm($form['visibility'][$condition_id], SubformState::createForSubform($form['visibility'][$condition_id], $form, $form_state));
}
}
......@@ -339,19 +333,15 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
$entity = $this->entity;
// The Block Entity form puts all block plugin form elements in the
// settings form element, so just pass that to the block for submission.
// @todo Find a way to avoid this manipulation.
$settings = (new FormState())->setValues($form_state->getValue('settings'));
$sub_form_state = SubformState::createForSubform($form['settings'], $form, $form_state);
// Call the plugin submit handler.
$block = $entity->getPlugin();
$this->getPluginForm($block)->submitConfigurationForm($form, $settings);
$this->getPluginForm($block)->submitConfigurationForm($form, $sub_form_state);
// If this block is context-aware, set the context mapping.
if ($block instanceof ContextAwarePluginInterface && $block->getContextDefinitions()) {
$context_mapping = $settings->getValue('context_mapping', []);
$context_mapping = $sub_form_state->getValue('context_mapping', []);
$block->setContextMapping($context_mapping);
}
// Update the original form values.
$form_state->setValue('settings', $settings->getValues());
$this->submitVisibility($form, $form_state);
......@@ -380,16 +370,19 @@ protected function submitVisibility(array $form, FormStateInterface $form_state)
foreach ($form_state->getValue('visibility') as $condition_id => $values) {
// Allow the condition to submit the form.
$condition = $form_state->get(['conditions', $condition_id]);
$condition_values = (new FormState())
->setValues($values);
$condition->submitConfigurationForm($form, $condition_values);
$condition->submitConfigurationForm($form['visibility'][$condition_id], SubformState::createForSubform($form['visibility'][$condition_id], $form, $form_state));
// Setting conditions' context mappings is the plugins' responsibility.
// This code exists for backwards compatibility, because
// \Drupal\Core\Condition\ConditionPluginBase::submitConfigurationForm()
// did not set its own mappings until Drupal 8.2
// @todo Remove the code that sets context mappings in Drupal 9.0.0.
if ($condition instanceof ContextAwarePluginInterface) {
$context_mapping = isset($values['context_mapping']) ? $values['context_mapping'] : [];
$condition->setContextMapping($context_mapping);
}
// Update the original form values.
$condition_configuration = $condition->getConfiguration();
$form_state->setValue(['visibility', $condition_id], $condition_configuration);
// Update the visibility conditions on the block.
$this->entity->getVisibilityConditions()->addInstanceId($condition_id, $condition_configuration);
}
......
......@@ -288,4 +288,20 @@ public function testBlockPlacementIndicator() {
$this->assertUrl('admin/structure/block/list/classy');
}
/**
* Tests if validation errors are passed plugin form to the parent form.
*/
public function testBlockValidateErrors() {
$this->drupalPostForm('admin/structure/block/add/test_settings_validation/classy', ['settings[digits]' => 'abc'], t('Save block'));
$arguments = [':message' => 'Only digits are allowed'];
$pattern = '//div[contains(@class,"messages messages--error")]/div[contains(text()[2],:message)]';
$elements = $this->xpath($pattern, $arguments);
$this->assertTrue($elements, 'Plugin error message found in parent form.');
$error_class_pattern = '//div[contains(@class,"form-item-settings-digits")]/input[contains(@class,"error")]';
$error_class = $this->xpath($error_class_pattern);
$this->assertTrue($error_class, 'Plugin error class found in parent form.');
}
}
<?php
namespace Drupal\block_test\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Form\FormStateInterface;
/**
* Provides a test settings validation block.
*
* @Block(
* id = "test_settings_validation",
* admin_label = @Translation("Test settings validation block"),
* )
*/
class TestSettingsValidationBlock extends BlockBase {
/**
* {@inheritdoc}
*/
public function blockForm($form, FormStateInterface $form_state) {
return ['digits' => ['#type' => 'textfield']] + $form;
}
/**
* {@inheritdoc}
*/
public function blockValidate($form, FormStateInterface $form_state) {
if (!ctype_digit($form_state->getValue('digits'))) {
$form_state->setErrorByName('digits', $this->t('Only digits are allowed'));
}
}
/**
* {@inheritdoc}
*/
public function build() {
return ['#markup' => 'foo'];
}
}
......@@ -3,8 +3,8 @@
namespace Drupal\image\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormState;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\image\ConfigurableImageEffectInterface;
use Drupal\image\ImageStyleInterface;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
......@@ -25,7 +25,7 @@ abstract class ImageEffectFormBase extends FormBase {
/**
* The image effect.
*
* @var \Drupal\image\ImageEffectInterface
* @var \Drupal\image\ImageEffectInterface|\Drupal\image\ConfigurableImageEffectInterface
*/
protected $imageEffect;
......@@ -73,7 +73,9 @@ public function buildForm(array $form, FormStateInterface $form_state, ImageStyl
'#value' => $this->imageEffect->getPluginId(),
);
$form['data'] = $this->imageEffect->buildConfigurationForm(array(), $form_state);
$form['data'] = [];
$subform_state = SubformState::createForSubform($form['data'], $form, $form_state);
$form['data'] = $this->imageEffect->buildConfigurationForm($form['data'], $subform_state);
$form['data']['#tree'] = TRUE;
// Check the URL for a weight, then the image effect, otherwise use default.
......@@ -102,10 +104,7 @@ public function buildForm(array $form, FormStateInterface $form_state, ImageStyl
public function validateForm(array &$form, FormStateInterface $form_state) {
// The image effect configuration is stored in the 'data' key in the form,
// pass that through for validation.
$effect_data = (new FormState())->setValues($form_state->getValue('data'));
$this->imageEffect->validateConfigurationForm($form, $effect_data);
// Update the original form values.
$form_state->setValue('data', $effect_data->getValues());
$this->imageEffect->validateConfigurationForm($form['data'], SubformState::createForSubform($form['data'], $form, $form_state));
}
/**
......@@ -116,10 +115,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
// The image effect configuration is stored in the 'data' key in the form,
// pass that through for submission.
$effect_data = (new FormState())->setValues($form_state->getValue('data'));
$this->imageEffect->submitConfigurationForm($form, $effect_data);
// Update the original form values.
$form_state->setValue('data', $effect_data->getValues());
$this->imageEffect->submitConfigurationForm($form['data'], SubformState::createForSubform($form['data'], $form, $form_state));
$this->imageEffect->setWeight($form_state->getValue('weight'));
if (!$this->imageEffect->getUuid()) {
......
......@@ -2,6 +2,7 @@
namespace Drupal\image\Tests;
use Drupal\image\Entity\ImageStyle;
use Drupal\system\Tests\Image\ToolkitTestBase;
/**
......@@ -162,6 +163,26 @@ function testImageEffectsCaching() {
$this->assertTrue($effects == $cached_effects, 'Cached effects are the same as generated effects.');
}
/**
* Tests if validation errors are passed plugin form to the parent form.
*/
public function testEffectFormValidationErrors() {
$account = $this->drupalCreateUser(['administer image styles']);
$this->drupalLogin($account);
/** @var \Drupal\image\ImageStyleInterface $style */
$style = ImageStyle::load('thumbnail');
// Image Scale is the only effect shipped with 'thumbnail', by default.
$uuids = $style->getEffects()->getInstanceIds();
$uuid = key($uuids);
// We are posting the form with both, width and height, empty.
$edit = ['data[width]' => '', 'data[height]' => ''];
$path = 'admin/config/media/image-styles/manage/thumbnail/effects/' . $uuid;
$this->drupalPostForm($path, $edit, t('Update effect'));
// Check that the error message has been displayed.
$this->assertText(t('Width and height can not both be blank.'));
}
/**
* Asserts the effect processing of an image effect plugin.
*
......
......@@ -142,75 +142,6 @@ public function testFormErrorsDuringSubmission() {
$form_state->setErrorByName('test', 'message');
}
/**
* Tests that setting the value for an element adds to the values.
*
* @covers ::setValueForElement
*/
public function testSetValueForElement() {
$element = array(
'#parents' => array(
'foo',
'bar',
),
);
$value = $this->randomMachineName();
$form_state = new FormState();
$form_state->setValueForElement($element, $value);
$expected = array(
'foo' => array(
'bar' => $value,
),
);
$this->assertSame($expected, $form_state->getValues());
}
/**
* @covers ::getValue
*
* @dataProvider providerTestGetValue
*/