Issue #3399295 by alexpott, Wim Leers, bircher, andypost, borisson_: Allow all callables in ConfigTarget
parent
0940ba003b
commit
09b18e5e5f
|
@ -99,7 +99,7 @@ abstract class ConfigFormBase extends FormBase {
|
|||
|
||||
$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 @@ abstract class ConfigFormBase extends FormBase {
|
|||
$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 @@ abstract class ConfigFormBase extends FormBase {
|
|||
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 @@ abstract class ConfigFormBase extends FormBase {
|
|||
}
|
||||
|
||||
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 @@ abstract class ConfigFormBase extends FormBase {
|
|||
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 @@ abstract class ConfigFormBase extends FormBase {
|
|||
* 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 @@ abstract class ConfigFormBase extends FormBase {
|
|||
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);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,8 @@ declare(strict_types = 1);
|
|||
|
||||
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 @@ final class ConfigTarget {
|
|||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -35,17 +35,16 @@ class JsonApiSettingsForm extends ConfigFormBase {
|
|||
'#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']),
|
||||
];
|
||||
|
|
|
@ -29,15 +29,15 @@ class SettingsFormTest extends BrowserTestBase {
|
|||
$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.');
|
||||
}
|
||||
|
||||
|
|
|
@ -104,7 +104,7 @@ class MediaSettingsForm extends ConfigFormBase {
|
|||
'#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 @@ class MediaSettingsForm extends ConfigFormBase {
|
|||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue