From 09b18e5e5f54fad2ff10dfb75b386373d9b95268 Mon Sep 17 00:00:00 2001 From: catch <catch@35733.no-reply.drupal.org> Date: Tue, 7 Nov 2023 16:26:38 +0000 Subject: [PATCH] Issue #3399295 by alexpott, Wim Leers, bircher, andypost, borisson_: Allow all callables in ConfigTarget --- core/lib/Drupal/Core/Form/ConfigFormBase.php | 26 +++-- core/lib/Drupal/Core/Form/ConfigTarget.php | 68 ++++++++--- .../jsonapi/src/Form/JsonApiSettingsForm.php | 11 +- .../tests/src/Functional/SettingsFormTest.php | 8 +- .../media/src/Form/MediaSettingsForm.php | 15 +-- .../Tests/Core/Form/ConfigTargetTest.php | 110 ++++++++++++++++++ 6 files changed, 185 insertions(+), 53 deletions(-) create mode 100644 core/tests/Drupal/Tests/Core/Form/ConfigTargetTest.php diff --git a/core/lib/Drupal/Core/Form/ConfigFormBase.php b/core/lib/Drupal/Core/Form/ConfigFormBase.php index 0bcfbe28981f..0d4460970c90 100644 --- a/core/lib/Drupal/Core/Form/ConfigFormBase.php +++ b/core/lib/Drupal/Core/Form/ConfigFormBase.php @@ -99,7 +99,7 @@ public function loadDefaultValuesFromConfig(array $element): array { $value = $this->config($target->configName)->get($target->propertyPath); if ($target->fromConfig) { - $value = call_user_func($target->fromConfig, $value); + $value = ($target->fromConfig)($value); } $element['#default_value'] = $value; } @@ -134,11 +134,10 @@ public function storeConfigKeyToFormElementMap(array $element, FormStateInterfac $map = $form_state->get(static::CONFIG_KEY_TO_FORM_ELEMENT_MAP) ?? []; $target = $element['#config_target']; - if (is_string($target)) { - $target = ConfigTarget::fromString($target); + if ($target instanceof ConfigTarget) { + $target = $target->configName . ':' . $target->propertyPath; } - $target->elementParents = $element['#parents']; - $map[$target->configName . ':' . $target->propertyPath] = $target; + $map[$target] = $element['#array_parents']; $form_state->set(static::CONFIG_KEY_TO_FORM_ELEMENT_MAP, $map); } foreach (Element::children($element) as $key) { @@ -158,7 +157,7 @@ public function validateForm(array &$form, FormStateInterface $form_state) { foreach ($this->getEditableConfigNames() as $config_name) { $config = $this->config($config_name); try { - static::copyFormValuesToConfig($config, $form_state); + static::copyFormValuesToConfig($config, $form_state, $form); } catch (\BadMethodCallException $e) { // Nothing to do: this config form does not yet use validation @@ -193,7 +192,8 @@ public function validateForm(array &$form, FormStateInterface $form_state) { } if (isset($map["$config_name:$property_path"])) { - $form_element_name = implode('][', $map["$config_name:$property_path"]->elementParents); + $config_target = ConfigTarget::fromForm($map["$config_name:$property_path"], $form); + $form_element_name = implode('][', $config_target->elementParents); } else { // We cannot determine where to place the violation. The only option @@ -264,7 +264,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) { foreach ($this->getEditableConfigNames() as $config_name) { $config = $this->config($config_name); try { - static::copyFormValuesToConfig($config, $form_state); + static::copyFormValuesToConfig($config, $form_state, $form); $config->save(); } catch (\BadMethodCallException $e) { @@ -287,10 +287,12 @@ public function submitForm(array &$form, FormStateInterface $form_state) { * The configuration being edited. * @param \Drupal\Core\Form\FormStateInterface $form_state * The current state of the form. + * @param array $form + * The form array. * * @see \Drupal\Core\Entity\EntityForm::copyFormValuesToEntity() */ - private static function copyFormValuesToConfig(Config $config, FormStateInterface $form_state): void { + private static function copyFormValuesToConfig(Config $config, FormStateInterface $form_state, array $form): 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. @@ -299,12 +301,12 @@ private static function copyFormValuesToConfig(Config $config, FormStateInterfac throw new \BadMethodCallException(); } - /** @var \Drupal\Core\Form\ConfigTarget $target */ - foreach ($map as $target) { + foreach ($map as $element_parents) { + $target = ConfigTarget::fromForm($element_parents, $form); if ($target->configName === $config->getName()) { $value = $form_state->getValue($target->elementParents); if ($target->toConfig) { - $value = call_user_func($target->toConfig, $value); + $value = ($target->toConfig)($value); } $config->set($target->propertyPath, $value); } diff --git a/core/lib/Drupal/Core/Form/ConfigTarget.php b/core/lib/Drupal/Core/Form/ConfigTarget.php index ddf7bc500071..b7039a3c58c8 100644 --- a/core/lib/Drupal/Core/Form/ConfigTarget.php +++ b/core/lib/Drupal/Core/Form/ConfigTarget.php @@ -4,6 +4,8 @@ namespace Drupal\Core\Form; +use Drupal\Component\Utility\NestedArray; + /** * Represents the mapping of a config property to a form element. */ @@ -21,6 +23,20 @@ final class ConfigTarget { */ public array $elementParents; + /** + * Transforms a value loaded from config before it gets displayed by the form. + * + * @var \Closure|null + */ + public readonly ?\Closure $fromConfig; + + /** + * Transforms a value submitted by the form before it is set in the config. + * + * @var \Closure|null + */ + public readonly ?\Closure $toConfig; + /** * Constructs a ConfigTarget object. * @@ -29,11 +45,11 @@ final class ConfigTarget { * `system.site`. * @param string $propertyPath * The property path being read or written, e.g., `page.front`. - * @param string|null $fromConfig + * @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. - * @param string|null $toConfig + * @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. @@ -41,22 +57,11 @@ final class ConfigTarget { public function __construct( public readonly string $configName, public readonly string $propertyPath, - public readonly ?string $fromConfig = NULL, - public readonly ?string $toConfig = NULL, + ?callable $fromConfig = NULL, + ?callable $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)); - } + $this->fromConfig = $fromConfig ? $fromConfig(...) : NULL; + $this->toConfig = $toConfig ? $toConfig(...) : NULL; } /** @@ -83,4 +88,33 @@ public static function fromString(string $target, ?string $fromConfig = NULL, ?s return new self($configName, $propertyPath, $fromConfig, $toConfig); } + /** + * Gets the config target object for an element from a form array. + * + * @param array $array_parents + * The array to locate the element in the form. + * @param array $form + * The form array. + * + * @return self + * A ConfigTarget instance. + */ + public static function fromForm(array $array_parents, array $form): self { + $element = NestedArray::getValue($form, $array_parents); + if (!isset($element['#config_target'])) { + throw new \LogicException('The form element [' . implode('][', $array_parents) . '] does not have the #config_target property set'); + } + $target = $element['#config_target']; + if (is_string($target)) { + $target = ConfigTarget::fromString($target); + } + if (!$target instanceof ConfigTarget) { + throw new \LogicException('The form element [' . implode('][', $array_parents) . '] #config_target property is not a string or a ConfigTarget object'); + } + + // Add the element information to the config target object. + $target->elementParents = $element['#parents']; + return $target; + } + } diff --git a/core/modules/jsonapi/src/Form/JsonApiSettingsForm.php b/core/modules/jsonapi/src/Form/JsonApiSettingsForm.php index 978ee6fee06a..d47a9df1df4e 100644 --- a/core/modules/jsonapi/src/Form/JsonApiSettingsForm.php +++ b/core/modules/jsonapi/src/Form/JsonApiSettingsForm.php @@ -35,17 +35,16 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#type' => 'radios', '#title' => $this->t('Allowed operations'), '#options' => [ - 1 => $this->t('Accept only JSON:API read operations.'), - 0 => $this->t('Accept all JSON:API create, read, update, and delete operations.'), + 'r' => $this->t('Accept only JSON:API read operations.'), + 'rw' => $this->t('Accept all JSON:API create, read, update, and delete operations.'), ], '#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 bool config value to an expected string. + fn($value) => $value ? 'r' : 'rw', // Convert the submitted value to a boolean before storing it in config. - 'boolval', + fn($value) => $value === 'r', ), '#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']), ]; diff --git a/core/modules/jsonapi/tests/src/Functional/SettingsFormTest.php b/core/modules/jsonapi/tests/src/Functional/SettingsFormTest.php index 3a590e922da2..d9912630ddc8 100644 --- a/core/modules/jsonapi/tests/src/Functional/SettingsFormTest.php +++ b/core/modules/jsonapi/tests/src/Functional/SettingsFormTest.php @@ -29,15 +29,15 @@ public function testSettingsForm(): void { $this->drupalGet('/admin/config/services/jsonapi'); $page = $this->getSession()->getPage(); - $page->selectFieldOption('read_only', 0); + $page->selectFieldOption('read_only', 'rw'); $page->pressButton('Save configuration'); $assert_session = $this->assertSession(); $assert_session->pageTextContains('The configuration options have been saved.'); - $assert_session->fieldValueEquals('read_only', 0); + $assert_session->fieldValueEquals('read_only', 'rw'); - $page->selectFieldOption('read_only', 1); + $page->selectFieldOption('read_only', 'r'); $page->pressButton('Save configuration'); - $assert_session->fieldValueEquals('read_only', '1'); + $assert_session->fieldValueEquals('read_only', 'r'); $assert_session->pageTextContains('The configuration options have been saved.'); } diff --git a/core/modules/media/src/Form/MediaSettingsForm.php b/core/modules/media/src/Form/MediaSettingsForm.php index 0a6b97e98a4b..84c8a39bd32a 100644 --- a/core/modules/media/src/Form/MediaSettingsForm.php +++ b/core/modules/media/src/Form/MediaSettingsForm.php @@ -104,7 +104,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#title' => $this->t('iFrame domain'), '#size' => 40, '#maxlength' => 255, - '#config_target' => new ConfigTarget('media.settings', 'iframe_domain', toConfig: static::class . '::nullIfEmptyString'), + '#config_target' => new ConfigTarget('media.settings', 'iframe_domain', toConfig: fn(?string $value) => $value ?: NULL), '#description' => $this->t('Enter a different domain from which to serve oEmbed content, including the <em>http://</em> or <em>https://</em> prefix. This domain needs to point back to this site, or existing oEmbed content may not display correctly, or at all.'), ]; @@ -118,17 +118,4 @@ public function buildForm(array $form, FormStateInterface $form_state) { return parent::buildForm($form, $form_state); } - /** - * Converts an empty string to NULL. - * - * @param string|null $value - * The value to transform. - * - * @return string|null - * The given string, or NULL if it was empty. - */ - public static function nullIfEmptyString(?string $value): ?string { - return $value ?: NULL; - } - } diff --git a/core/tests/Drupal/Tests/Core/Form/ConfigTargetTest.php b/core/tests/Drupal/Tests/Core/Form/ConfigTargetTest.php new file mode 100644 index 000000000000..14605daf7e1c --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Form/ConfigTargetTest.php @@ -0,0 +1,110 @@ +<?php + +namespace Drupal\Tests\Core\Form; + +use Drupal\Core\Form\ConfigTarget; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\Core\Form\ConfigTarget + * @group Form + */ +class ConfigTargetTest extends UnitTestCase { + + /** + * @covers ::fromForm + * @covers ::fromString + */ + public function testFromFormString() { + $form = [ + 'group' => [ + '#type' => 'details', + 'test' => [ + '#type' => 'text', + '#default_value' => 'A test', + '#config_target' => 'system.site:name', + '#name' => 'test', + '#parents' => ['test'], + ], + ], + ]; + $config_target = ConfigTarget::fromForm(['group', 'test'], $form); + $this->assertSame('system.site', $config_target->configName); + $this->assertSame('name', $config_target->propertyPath); + $this->assertSame(['test'], $config_target->elementParents); + } + + /** + * @covers ::fromForm + */ + public function testFromFormConfigTarget() { + $form = [ + 'test' => [ + '#type' => 'text', + '#default_value' => 'A test', + '#config_target' => new ConfigTarget('system.site', 'admin_compact_mode', 'intval', 'boolval'), + '#name' => 'test', + '#parents' => ['test'], + ], + ]; + $config_target = ConfigTarget::fromForm(['test'], $form); + $this->assertSame('system.site', $config_target->configName); + $this->assertSame('admin_compact_mode', $config_target->propertyPath); + $this->assertSame(['test'], $config_target->elementParents); + $this->assertSame(1, ($config_target->fromConfig)(TRUE)); + $this->assertFalse(($config_target->toConfig)('0')); + } + + /** + * @covers ::fromForm + * @dataProvider providerTestFromFormException + */ + public function testFromFormException(array $form, array $array_parents, string $exception_message) { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage($exception_message); + ConfigTarget::fromForm($array_parents, $form); + } + + public function providerTestFromFormException(): array { + return [ + 'No #config_target' => [ + [ + 'test' => [ + '#type' => 'text', + '#default_value' => 'A test', + ], + ], + ['test'], + 'The form element [test] does not have the #config_target property set', + ], + 'No #config_target nested' => [ + [ + 'group' => [ + '#type' => 'details', + 'test' => [ + '#type' => 'text', + '#default_value' => 'A test', + ], + ], + ], + ['group', 'test'], + 'The form element [group][test] does not have the #config_target property set', + ], + 'Boolean #config_target nested' => [ + [ + 'group' => [ + '#type' => 'details', + 'test' => [ + '#type' => 'text', + '#config_target' => FALSE, + '#default_value' => 'A test', + ], + ], + ], + ['group', 'test'], + 'The form element [group][test] #config_target property is not a string or a ConfigTarget object', + ], + ]; + } + +} -- GitLab