Verified Commit e1c95b2f authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3382510 by phenaproxima, Wim Leers, alexpott, longwave,...

Issue #3382510 by phenaproxima, Wim Leers, alexpott, longwave, claudiu.cristea, borisson_, lauriii, effulgentsia, bircher: Introduce a new #config_target Form API property to make it super simple to use validation constraints on simple config forms, and adopt it in several core config forms
parent 7cab8103
Loading
Loading
Loading
Loading
Loading
+111 −39
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@
use Drupal\Core\Config\Config;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\TypedConfigManagerInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;

@@ -17,6 +18,18 @@
abstract class ConfigFormBase extends FormBase {
  use ConfigFormBaseTrait;

  /**
   * The $form_state key which stores a map of config keys to form elements.
   *
   * This map is generated and stored by ::storeConfigKeyToFormElementMap(),
   * which is one of the form's #after_build callbacks.
   *
   * @see ::storeConfigKeyToFormElementMap()
   *
   * @var string
   */
  protected const CONFIG_KEY_TO_FORM_ELEMENT_MAP = 'config_targets';

  /**
   * Constructs a \Drupal\system\ConfigFormBase object.
   *
@@ -60,14 +73,89 @@ public function buildForm(array $form, FormStateInterface $form_state) {
    // By default, render the form using system-config-form.html.twig.
    $form['#theme'] = 'system_config_form';

    // Load default values from config into any element with a #config_target
    // property.
    $form['#process'][] = '::loadDefaultValuesFromConfig';
    $form['#after_build'][] = '::storeConfigKeyToFormElementMap';

    return $form;
  }

  /**
   * Process callback to recursively load default values from #config_target.
   *
   * @param array $element
   *   The form element.
   *
   * @return array
   *   The form element, with its default value populated.
   */
  public function loadDefaultValuesFromConfig(array $element): array {
    if (array_key_exists('#config_target', $element) && !array_key_exists('#default_value', $element)) {
      $target = $element['#config_target'];
      if (is_string($target)) {
        $target = ConfigTarget::fromString($target);
      }

      $value = $this->config($target->configName)->get($target->propertyPath);
      if ($target->fromConfig) {
        $value = call_user_func($target->fromConfig, $value);
      }
      $element['#default_value'] = $value;
    }

    foreach (Element::children($element) as $key) {
      $element[$key] = $this->loadDefaultValuesFromConfig($element[$key]);
    }
    return $element;
  }

  /**
   * #after_build callback which stores a map of element names to config keys.
   *
   * This will store an array in the form state whose keys are strings in the
   * form of `CONFIG_NAME:PROPERTY_PATH`, and whose values are instances of
   * \Drupal\Core\Form\ConfigTarget.
   *
   * This callback is run in the form's #after_build stage, rather than
   * #process, to guarantee that all of the form's elements have their final
   * #name and #parents properties set.
   *
   * @param array $element
   *   The element being processed.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The current form state.
   *
   * @return array
   *   The processed element.
   */
  public function storeConfigKeyToFormElementMap(array $element, FormStateInterface $form_state): array {
    if (array_key_exists('#config_target', $element)) {
      $map = $form_state->get(static::CONFIG_KEY_TO_FORM_ELEMENT_MAP) ?? [];

      $target = $element['#config_target'];
      if (is_string($target)) {
        $target = ConfigTarget::fromString($target);
      }
      $target->elementName = $element['#name'];
      $target->elementParents = $element['#parents'];
      $map[$target->configName . ':' . $target->propertyPath] = $target;
      $form_state->set(static::CONFIG_KEY_TO_FORM_ELEMENT_MAP, $map);
    }
    foreach (Element::children($element) as $key) {
      $element[$key] = $this->storeConfigKeyToFormElementMap($element[$key], $form_state);
    }
    return $element;
  }

  /**
   * {@inheritdoc}
   */
  public function validateForm(array &$form, FormStateInterface $form_state) {
    assert($this->typedConfigManager instanceof TypedConfigManagerInterface);

    $map = $form_state->get(static::CONFIG_KEY_TO_FORM_ELEMENT_MAP) ?? [];

    foreach ($this->getEditableConfigNames() as $config_name) {
      $config = $this->config($config_name);
      try {
@@ -90,9 +178,9 @@ public function validateForm(array &$form, FormStateInterface $form_state) {
      // @see \Drupal\Core\Config\Schema\Sequence
      // @see \Drupal\Core\Config\Schema\SequenceDataDefinition
      $violations_per_form_element = [];
      /** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */
      foreach ($violations as $violation) {
        $property_path = $violation->getPropertyPath();
        $form_element_name = static::mapConfigKeyToFormElementName($config_name, $property_path);
        // Default to index 0.
        $index = 0;
        // Detect if this is a sequence property path, and if so, determine the
@@ -100,7 +188,11 @@ public function validateForm(array &$form, FormStateInterface $form_state) {
        $matches = [];
        if (preg_match("/.*\.(\d+)$/", $property_path, $matches) === 1) {
          $index = intval($matches[1]);
          // The property path as known in the config key-to-form element map
          // will not have the sequence index in it.
          $property_path = rtrim($property_path, '0123456789.');
        }
        $form_element_name = $map["$config_name:$property_path"]->elementName;
        $violations_per_form_element[$form_element_name][$index] = $violation;
      }

@@ -191,45 +283,25 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
   *
   * @see \Drupal\Core\Entity\EntityForm::copyFormValuesToEntity()
   */
  protected static function copyFormValuesToConfig(Config $config, FormStateInterface $form_state): void {
    // This allows ::submitForm() and ::validateForm() to know that this config
    // form is not yet using constraint-based validation.
  private static function copyFormValuesToConfig(Config $config, FormStateInterface $form_state): void {
    $map = $form_state->get(static::CONFIG_KEY_TO_FORM_ELEMENT_MAP);
    // If there's no map of config keys to form elements, this form does not
    // yet support config validation.
    // @see ::validateForm()
    if ($map === NULL) {
      throw new \BadMethodCallException();
    }

  /**
   * Maps the given Config key to a form element name.
   *
   * @param string $config_name
   *   The name of the Config whose value triggered a validation error.
   * @param string $key
   *   The Config key that triggered a validation error (which corresponds to a
   *   property path on the validation constraint violation).
   *
   * @return string
   *   The corresponding form element name.
   */
  protected static function mapConfigKeyToFormElementName(string $config_name, string $key) : string {
    return self::defaultMapConfigKeyToFormElementName($config_name, $key);
    /** @var \Drupal\Core\Form\ConfigTarget $target */
    foreach ($map as $target) {
      if ($target->configName === $config->getName()) {
        $value = $form_state->getValue($target->elementParents);
        if ($target->toConfig) {
          $value = call_user_func($target->toConfig, $value);
        }
        $config->set($target->propertyPath, $value);
      }
    }

  /**
   * Default implementation for ::mapConfigKeyToFormElementName().
   *
   * Suitable when the configuration is mapped 1:1 to form elements: when the
   * keys in the Config match the form element names exactly.
   *
   * @param string $config_name
   *   The name of the Config whose value triggered a validation error.
   * @param string $key
   *   The Config key that triggered a validation error (which corresponds to a
   *   property path on the validation constraint violation).
   *
   * @return string
   *   The corresponding form element name.
   */
  final protected static function defaultMapConfigKeyToFormElementName(string $config_name, string $key) : string {
    return str_replace('.', '][', $key);
  }

}
+98 −0
Original line number Diff line number Diff line
<?php

declare(strict_types = 1);

namespace Drupal\Core\Form;

/**
 * Represents the mapping of a config property to a form element.
 */
final class ConfigTarget {

  /**
   * The name of the form element which maps to this config property.
   *
   * @var string
   *
   * @see \Drupal\Core\Form\ConfigFormBase::storeConfigKeyToFormElementMap()
   *
   * @internal
   *   This property is for internal use only.
   */
  public string $elementName;

  /**
   * The parents of the form element which maps to this config property.
   *
   * @var array
   *
   * @see \Drupal\Core\Form\ConfigFormBase::storeConfigKeyToFormElementMap()
   *
   * @internal
   *   This property is for internal use only.
   */
  public array $elementParents;

  /**
   * Constructs a ConfigTarget object.
   *
   * @param string $configName
   *   The name of the config object being read from or written to, e.g.
   *   `system.site`.
   * @param string $propertyPath
   *   The property path being read or written, e.g., `page.front`.
   * @param string|null $fromConfig
   *   (optional) A callback which should transform the value loaded from
   *   config before it gets displayed by the form. If NULL, no transformation
   *   will be done. Defaults to NULL.
   * @param string|null $toConfig
   *   (optional) A callback which should transform the value submitted by the
   *   form before it is set in the config object. If NULL, no transformation
   *   will be done. Defaults to NULL.
   */
  public function __construct(
    public readonly string $configName,
    public readonly string $propertyPath,
    public readonly ?string $fromConfig = NULL,
    public readonly ?string $toConfig = NULL,
  ) {
    // If they're passed at all, $fromConfig and $toConfig need to be string
    // callables in order to guarantee that this object can be serialized as
    // part of a larger form array. If these could be arrays, then they could be
    // in the form of [$object, 'method'], which would break serialization if
    // $object was not serializable. This is also why we don't type hint these
    // parameters as ?callable, since that would allow closures (which can't
    // be serialized).
    if ($fromConfig) {
      assert(is_callable($fromConfig));
    }
    if ($toConfig) {
      assert(is_callable($toConfig));
    }
  }

  /**
   * Creates a ConfigTarget object.
   *
   * @param string $target
   *   The name of the config object, and property path, being read from or
   *   written to, in the form `CONFIG_NAME:PROPERTY_PATH`. For example,
   *   `system.site:page.front`.
   * @param string|null $fromConfig
   *   (optional) A callback which should transform the value loaded from
   *   config before it gets displayed by the form. If NULL, no transformation
   *   will be done. Defaults to NULL.
   * @param string|null $toConfig
   *   (optional) A callback which should transform the value submitted by the
   *   form before it is set in the config object. If NULL, no transformation
   *   will be done. Defaults to NULL.
   *
   * @return self
   *   A ConfigTarget instance.
   */
  public static function fromString(string $target, ?string $fromConfig = NULL, ?string $toConfig = NULL): self {
    [$configName, $propertyPath] = explode(':', $target, 2);
    return new self($configName, $propertyPath, $fromConfig, $toConfig);
  }

}
+13 −13
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@
namespace Drupal\book\Form;

use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\ConfigTarget;
use Drupal\Core\Form\FormStateInterface;

/**
@@ -31,11 +32,10 @@ protected function getEditableConfigNames() {
   */
  public function buildForm(array $form, FormStateInterface $form_state) {
    $types = node_type_get_names();
    $config = $this->config('book.settings');
    $form['book_allowed_types'] = [
      '#type' => 'checkboxes',
      '#title' => $this->t('Content types allowed in book outlines'),
      '#default_value' => $config->get('allowed_types'),
      '#config_target' => new ConfigTarget('book.settings', 'allowed_types', toConfig: static::class . '::filterAndSortAllowedTypes'),
      '#options' => $types,
      '#description' => $this->t('Users with the %outline-perm permission can add all content types.', ['%outline-perm' => $this->t('Administer book outlines')]),
      '#required' => TRUE,
@@ -43,7 +43,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
    $form['book_child_type'] = [
      '#type' => 'radios',
      '#title' => $this->t('Content type for the <em>Add child page</em> link'),
      '#default_value' => $config->get('child_type'),
      '#config_target' => 'book.settings:child_type',
      '#options' => $types,
      '#required' => TRUE,
    ];
@@ -64,21 +64,21 @@ public function validateForm(array &$form, FormStateInterface $form_state) {
  }

  /**
   * {@inheritdoc}
   * Transformation callback for the book_allowed_types config value.
   *
   * @param array $allowed_types
   *   The config value to transform.
   *
   * @return array
   *   The transformed value.
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    $allowed_types = array_filter($form_state->getValue('book_allowed_types'));
  public static function filterAndSortAllowedTypes(array $allowed_types): array {
    $allowed_types = array_filter($allowed_types);
    // We need to save the allowed types in an array ordered by machine_name so
    // that we can save them in the correct order if node type changes.
    // @see book_node_type_update().
    sort($allowed_types);
    $this->config('book.settings')
    // Remove unchecked types.
      ->set('allowed_types', $allowed_types)
      ->set('child_type', $form_state->getValue('book_child_type'))
      ->save();

    parent::submitForm($form, $form_state);
    return $allowed_types;
  }

}
+56 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Tests\book\Kernel;

use Drupal\book\Form\BookSettingsForm;
use Drupal\Core\Form\FormState;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;

/**
 * @covers \Drupal\book\Form\BookSettingsForm
 * @group book
 */
class BookSettingsFormTest extends KernelTestBase {

  use ContentTypeCreationTrait;

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'book',
    'field',
    'node',
    'system',
    'text',
    'user',
  ];

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->installConfig(['book', 'node']);
    $this->createContentType(['type' => 'chapter']);
    $this->createContentType(['type' => 'page']);
  }

  /**
   * Tests that submitted values are processed and saved correctly.
   */
  public function testConfigValuesSavedCorrectly(): void {
    $form_state = new FormState();
    $form_state->setValues([
      'book_allowed_types' => ['page', 'chapter', ''],
      'book_child_type' => 'page',
    ]);
    $this->container->get('form_builder')->submitForm(BookSettingsForm::class, $form_state);

    $config = $this->config('book.settings');
    $this->assertSame(['chapter', 'page'], $config->get('allowed_types'));
    $this->assertSame('page', $config->get('child_type'));
  }

}
+12 −16
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@
namespace Drupal\jsonapi\Form;

use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\ConfigTarget;
use Drupal\Core\Form\FormStateInterface;

/**
@@ -30,31 +31,26 @@ protected function getEditableConfigNames() {
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {
    $jsonapi_config = $this->config('jsonapi.settings');

    $form['read_only'] = [
      '#type' => 'radios',
      '#title' => $this->t('Allowed operations'),
      '#options' => [
        'r' => $this->t('Accept only JSON:API read operations.'),
        'rw' => $this->t('Accept all JSON:API create, read, update, and delete operations.'),
        1 => $this->t('Accept only JSON:API read operations.'),
        0 => $this->t('Accept all JSON:API create, read, update, and delete operations.'),
      ],
      '#default_value' => $jsonapi_config->get('read_only') === TRUE ? 'r' : 'rw',
      '#config_target' => new ConfigTarget(
        'jsonapi.settings',
        'read_only',
        // Convert the value to an integer when displaying the config value in
        // the form.
        'intval',
        // Convert the submitted value to a boolean before storing it in config.
        'boolval',
      ),
      '#description' => $this->t('Warning: Only enable all operations if the site requires it. <a href=":docs">Learn more about securing your site with JSON:API.</a>', [':docs' => 'https://www.drupal.org/docs/8/modules/jsonapi/security-considerations']),
    ];

    return parent::buildForm($form, $form_state);
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    $this->config('jsonapi.settings')
      ->set('read_only', $form_state->getValue('read_only') === 'r')
      ->save();

    parent::submitForm($form, $form_state);
  }

}
Loading