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

Issue #3398982 by Wim Leers, phenaproxima, alexpott, borisson_: ConfigFormBase...

Issue #3398982 by Wim Leers, phenaproxima, alexpott, borisson_: ConfigFormBase + validation constraints: support non-1:1 form element-to-config property mapping again
parent 5e2dcabf
Loading
Loading
Loading
Loading
Loading
+13 −16
Original line number Diff line number Diff line
@@ -97,11 +97,8 @@ public function loadDefaultValuesFromConfig(array $element): array {
        $target = ConfigTarget::fromString($target);
      }

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

    foreach (Element::children($element) as $key) {
@@ -138,7 +135,9 @@ public function storeConfigKeyToFormElementMap(array $element, FormStateInterfac
      if (is_string($target)) {
        $target = ConfigTarget::fromString($target);
      }
      $map[$target->configName][$target->propertyPath] = $element['#array_parents'];
      foreach ($target->propertyPaths as $property_path) {
        $map[$target->configName][$property_path] = $element['#array_parents'];
      }
      $form_state->set(static::CONFIG_KEY_TO_FORM_ELEMENT_MAP, $map);
    }
    foreach (Element::children($element) as $key) {
@@ -173,10 +172,10 @@ public function validateForm(array &$form, FormStateInterface $form_state) {
        $property_path = $violation->getPropertyPath();
        // Default to index 0.
        $index = 0;
        // Detect if this is a sequence property path, and if so, determine the
        // actual sequence index.
        $matches = [];
        if (preg_match("/.*\.(\d+)$/", $property_path, $matches) === 1) {

        // Detect if this is a sequence item property path, and if so, attempt
        // to fall back to the containing sequence's property path.
        if (!isset($map[$config_name][$property_path]) && 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.
@@ -282,13 +281,11 @@ private static function copyFormValuesToConfig(Config $config, FormStateInterfac

    foreach ($map[$config->getName()] as $array_parents) {
      $target = ConfigTarget::fromForm($array_parents, $form);
      if ($target->configName === $config->getName()) {
        $value = $form_state->getValue($target->elementParents);
        if ($target->toConfig) {
          $value = ($target->toConfig)($value);
        }
        $config->set($target->propertyPath, $value);
      if ($target->configName !== $config->getName()) {
        continue;
      }
      $value = $form_state->getValue($target->elementParents);
      $target->setValue($config, $value, $form_state);
    }
  }

+149 −7
Original line number Diff line number Diff line
@@ -5,9 +5,12 @@
namespace Drupal\Core\Form;

use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Config\Config;

/**
 * Represents the mapping of a config property to a form element.
 *
 * @see \Drupal\Core\Form\ToConfig
 */
final class ConfigTarget {

@@ -23,10 +26,19 @@ final class ConfigTarget {
   */
  public array $elementParents;

  /**
   * The property paths to target.
   *
   * @var string[]
   */
  public readonly array $propertyPaths;

  /**
   * Transforms a value loaded from config before it gets displayed by the form.
   *
   * @var \Closure|null
   *
   * @see ::getValue()
   */
  public readonly ?\Closure $fromConfig;

@@ -34,6 +46,8 @@ final class ConfigTarget {
   * Transforms a value submitted by the form before it is set in the config.
   *
   * @var \Closure|null
   *
   * @see ::setValue()
   */
  public readonly ?\Closure $toConfig;

@@ -43,25 +57,45 @@ final class ConfigTarget {
   * @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|array $propertyPath
   *   The property path(s) being read or written, e.g., `page.front`.
   * @param callable|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.
   *   will be done. The callback will receive all of the values loaded from
   *   config as separate arguments, in the order specified by
   *   $this->propertyPaths. Defaults to NULL.
   * @param callable|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.
   *   will be done. The callback will receive the value submitted through the
   *   form; if this object is targeting multiple property paths, the value will
   *   be an array of the submitted values, keyed by property path, and must
   *   return an array with the transformed values, also keyed by property path.
   *   The callback will receive the form state object as its second argument.
   *   The callback may return a special values:
   *   - ToConfig::NoMapping, to indicate that the given form value does not
   *     need to be mapped onto the Config object
   *   - ToConfig::DeleteKey to indicate that the targeted property path should
   *     be deleted from config.
   *   Defaults to NULL.
   */
  public function __construct(
    public readonly string $configName,
    public readonly string $propertyPath,
    string|array $propertyPath,
    ?callable $fromConfig = NULL,
    ?callable $toConfig = NULL,
  ) {
    $this->fromConfig = $fromConfig ? $fromConfig(...) : NULL;
    $this->toConfig = $toConfig ? $toConfig(...) : NULL;

    if (is_string($propertyPath)) {
      $propertyPath = [$propertyPath];
    }
    elseif (count($propertyPath) > 1 && (empty($fromConfig) || empty($toConfig))) {
      throw new \LogicException('The $fromConfig and $toConfig arguments must be passed to ' . __METHOD__ . '() if multiple property paths are targeted.');
    }
    $this->propertyPaths = array_values($propertyPath);
  }

  /**
@@ -74,11 +108,18 @@ public function __construct(
   * @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.
   *   will be done. The callback will receive all of the values loaded from
   *   config as separate arguments, in the order specified by
   *   $this->propertyPaths. 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.
   *   will be done. The callback will receive the value submitted through the
   *   form; if this object is targeting multiple property paths, the value will
   *   be an array of the submitted values, keyed by property path, and must
   *   return an array with the transformed values, also keyed by property path.
   *   The callback will receive the form state object as its second argument.
   *   Defaults to NULL.
   *
   * @return self
   *   A ConfigTarget instance.
@@ -117,4 +158,105 @@ public static function fromForm(array $array_parents, array $form): self {
    return $target;
  }

  /**
   * Retrieves the mapped value from config.
   *
   * @param \Drupal\Core\Config\Config $config
   *   The config object we're reading from.
   *
   * @return mixed
   *   The mapped value, with any transformations applied.
   *
   * @throws \InvalidArgumentException
   *   Thrown if the given config object is not the one being targeted by
   *   $this->configName.
   */
  public function getValue(Config $config): mixed {
    if ($config->getName() !== $this->configName) {
      throw new \InvalidArgumentException(sprintf('Config target is associated with %s but %s given.', $this->configName, $config->getName()));
    }

    $is_multi_target = $this->isMultiTarget();

    $value = $is_multi_target
      ? array_map($config->get(...), $this->propertyPaths)
      : $config->get($this->propertyPaths[0]);

    if ($this->fromConfig) {
      $value = $is_multi_target
        ? ($this->fromConfig)(...$value)
        : ($this->fromConfig)($value);
    }
    return $value;
  }

  /**
   * Sets the submitted value from config.
   *
   * @param \Drupal\Core\Config\Config $config
   *   The config object we're changing.
   * @param mixed $value
   *   The value(s) to set. If this object is targeting multiple property paths,
   *   this must be an array with the values to set, keyed by property path.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The current form state.
   *
   * @throws \InvalidArgumentException
   *   Thrown if the given config object is not the one being targeted by
   *   $this->configName.
   * @throws \LogicException
   *   Thrown if this object is targeting multiple property paths and $value
   *   does not contain a value for every targeted property path.
   */
  public function setValue(Config $config, mixed $value, FormStateInterface $form_state): void {
    if ($config->getName() !== $this->configName) {
      throw new \InvalidArgumentException(sprintf('Config target is associated with %s but %s given.', $this->configName, $config->getName()));
    }

    $is_multi_target = $this->isMultiTarget();
    if ($this->toConfig) {
      $value = ($this->toConfig)($value, $form_state);
      if ($is_multi_target) {
        // If we're targeting multiple property paths, $value needs to be an array
        // with every targeted property path.
        if (!is_array($value)) {
          throw new \LogicException(sprintf('The toConfig callable returned a %s, but it must be an array with a key-value pair for each of the targeted property paths.', gettype($value)));
        }
        elseif ($missing_keys = array_diff($this->propertyPaths, array_keys($value))) {
          throw new \LogicException(sprintf('The toConfig callable returned an array that is missing key-value pairs for the following targeted property paths: %s.', implode(', ', $missing_keys)));
        }
        elseif ($unknown_keys = array_diff(array_keys($value), $this->propertyPaths)) {
          throw new \LogicException(sprintf('The toConfig callable returned an array that contains key-value pairs that do not match targeted property paths: %s.', implode(', ', $unknown_keys)));
        }
      }
    }

    // Match the structure expected for a multi-target ConfigTarget.
    if (!$is_multi_target) {
      $value = [$this->propertyPaths[0] => $value];
    }

    // Set the returned value, or if a special value (one of the cases in the
    // ConfigTargetValue enum): apply the appropriate action.
    array_walk($value, fn (mixed $value, string $property) => match ($value) {
      // No-op.
      ToConfig::NoOp => NULL,
      // Delete.
      ToConfig::DeleteKey => $config->clear($property),
      // Set.
      default => $config->set($property, $value),
    });
  }

  /**
   * Indicates if this object targets multiple property paths.
   *
   * @return bool
   *   TRUE if this object is targeting multiple property paths, otherwise
   *   FALSE.
   */
  private function isMultiTarget(): bool {
    return count($this->propertyPaths) > 1;
  }

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

declare(strict_types=1);

namespace Drupal\Core\Form;

/**
 * Enumeration of the special return values for ConfigTarget toConfig callables.
 *
 * @see \Drupal\Core\Form\ConfigTarget
 */
enum ToConfig {

  // Appropriate to return from a toConfig callable when another toConfig
  // callable handles setting this property path. In other words: "no-op".
  case NoOp;

  // Appropriate to return from a toConfig callable when the given form value
  // should result in the targeted property path getting deleted.
  // @see \Drupal\Core\Config\Config::clear()
  case DeleteKey;
}
+29 −41
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@
namespace Drupal\locale\Form;

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

@@ -36,7 +37,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
    $form['update_interval_days'] = [
      '#type' => 'radios',
      '#title' => $this->t('Check for updates'),
      '#default_value' => $config->get('translation.update_interval_days'),
      '#config_target' => 'locale.settings:translation.update_interval_days',
      '#options' => [
        '0' => $this->t('Never (manually)'),
        '7' => $this->t('Weekly'),
@@ -55,7 +56,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
    $form['use_source'] = [
      '#type' => 'radios',
      '#title' => $this->t('Translation source'),
      '#default_value' => $config->get('translation.use_source'),
      '#config_target' => 'locale.settings:translation.use_source',
      '#options' => [
        LOCALE_TRANSLATION_USE_SOURCE_REMOTE_AND_LOCAL => $this->t('Drupal translation server and local files'),
        LOCALE_TRANSLATION_USE_SOURCE_LOCAL => $this->t('Local files only'),
@@ -63,25 +64,41 @@ public function buildForm(array $form, FormStateInterface $form_state) {
      '#description' => $this->t('The source of translation files for automatic interface translation.') . ' ' . $description,
    ];

    if ($config->get('translation.overwrite_not_customized') == FALSE) {
      $default = LOCALE_TRANSLATION_OVERWRITE_NONE;
    }
    elseif ($config->get('translation.overwrite_customized') == TRUE) {
      $default = LOCALE_TRANSLATION_OVERWRITE_ALL;
    }
    else {
      $default = LOCALE_TRANSLATION_OVERWRITE_NON_CUSTOMIZED;
    }
    $form['overwrite'] = [
      '#type' => 'radios',
      '#title' => $this->t('Import behavior'),
      '#default_value' => $default,
      '#options' => [
        LOCALE_TRANSLATION_OVERWRITE_NONE => $this->t("Don't overwrite existing translations."),
        LOCALE_TRANSLATION_OVERWRITE_NON_CUSTOMIZED => $this->t('Only overwrite imported translations, customized translations are kept.'),
        LOCALE_TRANSLATION_OVERWRITE_ALL => $this->t('Overwrite existing translations.'),
      ],
      '#description' => $this->t('How to treat existing translations when automatically updating the interface translations.'),
      '#config_target' => new ConfigTarget(
        'locale.settings',
        [
          'translation.overwrite_customized',
          'translation.overwrite_not_customized',
        ],
        fromConfig: fn (bool $overwrite_customized, bool $overwrite_not_customized): string => match(TRUE) {
          $overwrite_not_customized == FALSE => LOCALE_TRANSLATION_OVERWRITE_NONE,
          $overwrite_customized == TRUE => LOCALE_TRANSLATION_OVERWRITE_ALL,
          default => LOCALE_TRANSLATION_OVERWRITE_NON_CUSTOMIZED,
        },
        toConfig: fn (string $radio_option): array => match($radio_option) {
          LOCALE_TRANSLATION_OVERWRITE_ALL => [
            'translation.overwrite_customized' => TRUE,
            'translation.overwrite_not_customized' => TRUE,
          ],
          LOCALE_TRANSLATION_OVERWRITE_NON_CUSTOMIZED => [
            'translation.overwrite_customized' => FALSE,
            'translation.overwrite_not_customized' => TRUE,
          ],
          LOCALE_TRANSLATION_OVERWRITE_NONE => [
            'translation.overwrite_customized' => FALSE,
            'translation.overwrite_not_customized' => FALSE,
          ],
        }
      ),
    ];

    return parent::buildForm($form, $form_state);
@@ -102,35 +119,6 @@ public function validateForm(array &$form, FormStateInterface $form_state) {
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    $values = $form_state->getValues();

    $config = $this->config('locale.settings');
    $config->set('translation.update_interval_days', $values['update_interval_days'])->save();
    $config->set('translation.use_source', $values['use_source'])->save();

    switch ($values['overwrite']) {
      case LOCALE_TRANSLATION_OVERWRITE_ALL:
        $config
          ->set('translation.overwrite_customized', TRUE)
          ->set('translation.overwrite_not_customized', TRUE)
          ->save();
        break;

      case LOCALE_TRANSLATION_OVERWRITE_NON_CUSTOMIZED:
        $config
          ->set('translation.overwrite_customized', FALSE)
          ->set('translation.overwrite_not_customized', TRUE)
          ->save();
        break;

      case LOCALE_TRANSLATION_OVERWRITE_NONE:
        $config
          ->set('translation.overwrite_customized', FALSE)
          ->set('translation.overwrite_not_customized', FALSE)
          ->save();
        break;
    }

    // Invalidate the cached translation status when the configuration setting
    // of 'use_source' changes.
    if ($form['use_source']['#default_value'] != $form_state->getValue('use_source')) {
+14 −1
Original line number Diff line number Diff line
@@ -5,9 +5,22 @@ form_test.object:
    bananas:
      type: string
      label: 'Bananas'
    favorite_fruits:
      type: sequence
      label: 'Favorite fruits'
      sequence:
        type: required_label
        label: 'Fruit'
    favorite_vegetable:
      type: required_label
      label: 'Favorite vegetable'
    nemesis_vegetable:
      type: required_label
      type: label
      label: 'Nemesis vegetable'
    could_not_live_without:
      type: string
      label: 'Which could you not live without: vegetables or fruits?'
      constraints:
        Choice:
          - fruits
          - vegetables
Loading