diff --git a/core/modules/options/config/schema/options.schema.yml b/core/modules/options/config/schema/options.schema.yml index 017b396e05..1a38255806 100644 --- a/core/modules/options/config/schema/options.schema.yml +++ b/core/modules/options/config/schema/options.schema.yml @@ -17,6 +17,9 @@ field.storage_settings.list_integer: label: type: label label: 'Label' + predefined_options_plugin: + type: string + label: 'Predefined options plugin' allowed_values_function: type: string label: 'Allowed values function' @@ -50,6 +53,9 @@ field.storage_settings.list_float: label: type: label label: 'Label' + predefined_options_plugin: + type: string + label: 'Predefined options plugin' allowed_values_function: type: string label: 'Allowed values function' @@ -83,6 +89,9 @@ field.storage_settings.list_string: label: type: label label: 'Label' + predefined_options_plugin: + type: string + label: 'Predefined options plugin' allowed_values_function: type: string label: 'Allowed values function' diff --git a/core/modules/options/options.api.php b/core/modules/options/options.api.php index fbcc809318..33fa9d045a 100644 --- a/core/modules/options/options.api.php +++ b/core/modules/options/options.api.php @@ -36,6 +36,23 @@ function hook_options_list_alter(array &$options, array $context) { } } +/** + * Allow modules to alter predefined options plugins metadata. + * + * This hook is called after the plugins have been discovered, but before they + * are cached. Hence any alterations will be cached. + * + * @param array &$definitions + * An array of metadata on existing plugins, as collected by the discovery + * mechanism. + * + * @see \Drupal\options\Annotation\PredefinedOptions + * @see \Drupal\options\Plugin\PredefinedOptionsPluginManager + */ +function hook_options_predefined_options_info_alter(&$definitions) { + $definitions['timezones']['class'] = 'Drupal\options\Plugin\Options\Timezones'; +} + /** * Provide the allowed values for a 'list_*' field. * @@ -78,6 +95,11 @@ function hook_options_list_alter(array &$options, array $context) { * @see options_allowed_values() * @see options_test_allowed_values_callback() * @see options_test_dynamic_values_callback() + * + * @deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. Once the + * predefined options plugin feature is completed. + * + * @see https://www.drupal.org/project/drupal/issues/1909744 */ function callback_allowed_values_function(FieldStorageDefinitionInterface $definition, FieldableEntityInterface $entity = NULL, &$cacheable = TRUE) { if (isset($entity) && ($entity->bundle() == 'not_a_programmer')) { diff --git a/core/modules/options/options.module b/core/modules/options/options.module index 30aefdd33c..18c433279f 100644 --- a/core/modules/options/options.module +++ b/core/modules/options/options.module @@ -11,6 +11,7 @@ use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\field\FieldStorageConfigInterface; +use Drupal\options\Annotation\PredefinedOptions; /** * Implements hook_help(). @@ -80,12 +81,30 @@ function options_allowed_values(FieldStorageDefinitionInterface $definition, Fie $cache_id = implode(':', $cache_keys); if (!isset($allowed_values[$cache_id])) { - $function = $definition->getSetting('allowed_values_function'); // If $cacheable is FALSE, then the allowed values are not statically // cached. See options_test_dynamic_values_callback() for an example of // generating dynamic and uncached values. $cacheable = TRUE; - if (!empty($function)) { + if (!empty($plugin_id = $definition->getSetting('predefined_options_plugin'))) { + try { + /** @var \Drupal\options\Plugin\PredefinedOptionsPluginManager $manager */ + $manager = \Drupal::service('plugin.manager.options.predefined_options'); + /** @var \Drupal\options\Plugin\PredefinedOptionsPluginInterface $plugin */ + $plugin = $manager->createInstance($plugin_id); + $values = $plugin->getAllowedValues($definition, $entity, $cacheable); + } + catch (\Exception $e) { + // Fail silently if the plugin does not exists or cannot be instantiated. + watchdog_exception('options', $e); + return []; + } + } + // @TODO To be removed once the predefined options plugin feature is completed. + elseif (is_callable($definition->getSetting('allowed_values_function'))) { + watchdog_exception('options', new \RuntimeException(t("The use of 'allowed_values_function' is deprecated, use :class plugins instead", [ + ':class' => PredefinedOptions::class, + ]))); + $function = $definition->getSetting('allowed_values_function'); $values = $function($definition, $entity, $cacheable); } else { diff --git a/core/modules/options/options.services.yml b/core/modules/options/options.services.yml new file mode 100644 index 0000000000..3de46a38ca --- /dev/null +++ b/core/modules/options/options.services.yml @@ -0,0 +1,4 @@ +services: + plugin.manager.options.predefined_options: + class: Drupal\options\Plugin\PredefinedOptionsPluginManager + parent: default_plugin_manager diff --git a/core/modules/options/src/Annotation/PredefinedOptions.php b/core/modules/options/src/Annotation/PredefinedOptions.php new file mode 100644 index 0000000000..4d00a09719 --- /dev/null +++ b/core/modules/options/src/Annotation/PredefinedOptions.php @@ -0,0 +1,33 @@ +predefinedOptionsManager = \Drupal::service('plugin.manager.options.predefined_options'); + } + /** * {@inheritdoc} */ public static function defaultStorageSettings() { return [ 'allowed_values' => [], + 'predefined_options_plugin' => '', 'allowed_values_function' => '', ] + parent::defaultStorageSettings(); } @@ -80,22 +103,37 @@ public function isEmpty() { */ public function storageSettingsForm(array &$form, FormStateInterface $form_state, $has_data) { $allowed_values = $this->getSetting('allowed_values'); + $predefined_options_plugin = $this->getSetting('predefined_options_plugin'); $allowed_values_function = $this->getSetting('allowed_values_function'); + $options = ['' => t('Custom')] + $this->predefinedOptionsManager->getAvailablePlugins(); + + $element['predefined_options_plugin'] = [ + '#type' => 'select', + '#title' => t('Allowed values'), + '#options' => $options, + '#default_value' => !empty($predefined_options_plugin) ? $predefined_options_plugin : NULL, + '#weight' => -20, + ]; $element['allowed_values'] = [ '#type' => 'textarea', '#title' => t('Allowed values list'), '#default_value' => $this->allowedValuesString($allowed_values), '#rows' => 10, - '#access' => empty($allowed_values_function), '#element_validate' => [[static::class, 'validateAllowedValues']], '#field_has_data' => $has_data, '#field_name' => $this->getFieldDefinition()->getName(), '#entity_type' => $this->getEntity()->getEntityTypeId(), '#allowed_values' => $allowed_values, + '#access' => empty($allowed_values_function), ]; $element['allowed_values']['#description'] = $this->allowedValuesDescription(); + $element['allowed_values']['#states'] = [ + 'invisible' => [ + '[name*="predefined_options_plugin"]' => ['!value' => ''], + ], + ]; $element['allowed_values_function'] = [ '#type' => 'item', @@ -105,6 +143,36 @@ public function storageSettingsForm(array &$form, FormStateInterface $form_state '#value' => $allowed_values_function, ]; + $this->throwDeprecationMessage($allowed_values_function); + + return $element; + } + + /** + * {@inheritdoc} + */ + public function fieldSettingsForm(array $form, FormStateInterface $form_state) { + $element = parent::fieldSettingsForm($form, $form_state); + $plugin_id = $this->getSetting('predefined_options_plugin'); + + if (!empty($plugin_id)) { + try { + $plugin = $this->predefinedOptionsManager->getDefinition($plugin_id); + $element['predefined_options_plugin'] = [ + '#markup' => $this->t('Predefined options plugin: %plugin', [ + '%plugin' => $plugin['label'], + ]), + ]; + } + catch (\Exception $e) { + watchdog_exception('options', $e); + $this->messenger()->addError($e->getMessage()); + } + } + elseif (!empty($allowed_values_function = $this->getSetting('allowed_values_function'))) { + $this->throwDeprecationMessage($allowed_values_function); + } + return $element; } @@ -117,17 +185,17 @@ public function storageSettingsForm(array &$form, FormStateInterface $form_state abstract protected function allowedValuesDescription(); /** - * #element_validate callback for options field allowed values. + * The #element_validate callback for options field allowed values. * - * @param $element + * @param array $element * An associative array containing the properties and children of the * generic form element. - * @param $form_state + * @param \Drupal\Core\Form\FormStateInterface $form_state * The current state of the form for the form this element belongs to. * * @see \Drupal\Core\Render\Element\FormElement::processPattern() */ - public static function validateAllowedValues($element, FormStateInterface $form_state) { + public static function validateAllowedValues(array $element, FormStateInterface $form_state) { $values = static::extractAllowedValues($element['#value'], $element['#field_has_data']); if (!is_array($values)) { @@ -235,7 +303,7 @@ protected static function validateAllowedValue($option) {} * - Values are separated by a carriage return. * - Each value is in the format "value|label" or "value". */ - protected function allowedValuesString($values) { + protected function allowedValuesString(array $values) { $lines = []; foreach ($values as $key => $value) { $lines[] = "$key|$value"; @@ -328,4 +396,24 @@ protected static function castAllowedValue($value) { return $value; } + /** + * Throw an error message warning about callback deprecation. + * + * This is a temporary method to warn developers using callback + * 'allowed_values_function'. This can be safely removed on any minor + * release after the predefined options plugin feature is added to core. + * + * @param string $allowed_values_function + * The 'allowed_values_function' name. + */ + private function throwDeprecationMessage($allowed_values_function) { + if (!empty($allowed_values_function)) { + $this->messenger() + ->addWarning($this->t('The use of callback functions is deprecated. Please replace %function function with a plugin. :link.', [ + '%function' => $allowed_values_function, + ':link' => Link::fromTextAndUrl('Check the docs.', Url::fromUri('https://www.drupal.org/docs/8/core/modules/options')), + ]), TRUE); + } + } + } diff --git a/core/modules/options/src/Plugin/Options/Timezones.php b/core/modules/options/src/Plugin/Options/Timezones.php new file mode 100644 index 0000000000..9db027ba39 --- /dev/null +++ b/core/modules/options/src/Plugin/Options/Timezones.php @@ -0,0 +1,26 @@ +alterInfo('options_predefined_options_info'); + $this->setCacheBackend($cache_backend, 'options_predefined_options_plugins'); + } + + /** + * Returns a list of available predefined options plugins. + * + * @return array + * An array keyed by plugin ID whose values are the plugin labels. + */ + public function getAvailablePlugins() { + $options = []; + foreach ($this->getDefinitions() as $key => $definition) { + $options[$key] = $definition['label']; + } + natcasesort($options); + return $options; + } + +} diff --git a/core/modules/options/tests/options_config_install_test/config/install/field.storage.node.field_options_float.yml b/core/modules/options/tests/options_config_install_test/config/install/field.storage.node.field_options_float.yml index fc28fa1980..7a77c9fe11 100644 --- a/core/modules/options/tests/options_config_install_test/config/install/field.storage.node.field_options_float.yml +++ b/core/modules/options/tests/options_config_install_test/config/install/field.storage.node.field_options_float.yml @@ -16,6 +16,7 @@ settings: - value: 0.5 label: 'Point five' + predefined_options_plugin: '' allowed_values_function: '' module: options locked: false diff --git a/core/modules/options/tests/options_test/src/Plugin/Options/PredefinedOptionsTestPlugin.php b/core/modules/options/tests/options_test/src/Plugin/Options/PredefinedOptionsTestPlugin.php new file mode 100644 index 0000000000..fc12c1b767 --- /dev/null +++ b/core/modules/options/tests/options_test/src/Plugin/Options/PredefinedOptionsTestPlugin.php @@ -0,0 +1,37 @@ +label(), + $entity->toUrl()->toString(), + $entity->uuid(), + $entity->bundle(), + ]; + + return array_combine($values, $values); + } + +} diff --git a/core/modules/options/tests/src/Functional/OptionsFieldUITest.php b/core/modules/options/tests/src/Functional/OptionsFieldUITest.php index e3d732464e..5af1680b83 100644 --- a/core/modules/options/tests/src/Functional/OptionsFieldUITest.php +++ b/core/modules/options/tests/src/Functional/OptionsFieldUITest.php @@ -59,6 +59,13 @@ class OptionsFieldUITest extends FieldTestBase { */ protected $adminPath; + /** + * The plugin manager service. + * + * @var \Drupal\options\Plugin\PredefinedOptionsPluginManager + */ + protected $pluginManager; + protected function setUp(): void { parent::setUp(); @@ -80,6 +87,7 @@ protected function setUp(): void { $this->typeName = 'test_' . strtolower($this->randomMachineName()); $type = $this->drupalCreateContentType(['name' => $this->typeName, 'type' => $this->typeName]); $this->type = $type->id(); + $this->pluginManager = $this->container->get('plugin.manager.options.predefined_options'); } /** @@ -276,6 +284,17 @@ public function testOptionsTrimmedValuesText() { $this->assertAllowedValuesInput($string, $array, 'Explicit keys are accepted and trimmed.'); } + /** + * Options (text) : test 'predefined_options_plugin' selection. + */ + public function testOptionsPredefinedOptionsPlugin() { + $this->fieldName = 'field_options_predefined_options'; + $this->createOptionsField('list_string'); + $plugin_id = 'timezones'; + $expected = system_time_zones(); + $this->assertPredefinedOptionsInput($plugin_id, $expected, 'The plugin is loaded and selected.'); + } + /** * Helper function to create list field of a given type. * @@ -306,13 +325,13 @@ protected function createOptionsField($type) { /** * Tests a string input for the 'allowed values' form element. * - * @param $input_string + * @param string $input_string * The input string, in the pipe-linefeed format expected by the form * element. - * @param $result + * @param string|array $result * Either an expected resulting array in * $field->getSetting('allowed_values'), or an expected error message. - * @param $message + * @param string $message * Message to display. */ public function assertAllowedValuesInput($input_string, $result, $message) { @@ -331,6 +350,35 @@ public function assertAllowedValuesInput($input_string, $result, $message) { } } + /** + * Tests a plugin selection for 'predefined_options_plugin' form element. + * + * @param string $plugin_id + * The plugin id. + * @param string|array $result + * Either an expected resulting array options_allowed_values(), or an + * expected error message. + * @param string $message + * Message to display. + */ + public function assertPredefinedOptionsInput($plugin_id, $result, $message) { + $edit = ['settings[predefined_options_plugin]' => $plugin_id]; + $this->drupalGet($this->adminPath); + $this->submitForm($edit, t('Save field settings')); + $this->assertSession()->responseNotContains('<'); + + if (is_string($result)) { + $this->assertText($result, $message); + } + else { + $field_storage = FieldStorageConfig::loadByName('node', $this->fieldName); + $configured_plugin_id = $field_storage->getSetting('predefined_options_plugin'); + /** @var \Drupal\options\Plugin\PredefinedOptionsPluginInterface $plugin */ + $plugin = $this->pluginManager->createInstance($configured_plugin_id); + self::assertEquals($plugin->getAllowedValues($field_storage), $result, $message); + } + } + /** * Tests normal and key formatter display on node display. */ diff --git a/core/modules/options/tests/src/Functional/OptionsPredefinedOptionsPluginTest.php b/core/modules/options/tests/src/Functional/OptionsPredefinedOptionsPluginTest.php new file mode 100644 index 0000000000..c64403b12b --- /dev/null +++ b/core/modules/options/tests/src/Functional/OptionsPredefinedOptionsPluginTest.php @@ -0,0 +1,34 @@ +fieldStorage); + $this->assertEquals($values, []); + + $values = options_allowed_values($this->fieldStorage, $this->entity); + + $expected_values = $this->plugin->getAllowedValues($this->fieldStorage, $this->entity); + $expected_values = array_combine($expected_values, $expected_values); + $this->assertEquals($values, $expected_values); + } + +} diff --git a/core/modules/options/tests/src/Functional/OptionsPredefinedOptionsTestBase.php b/core/modules/options/tests/src/Functional/OptionsPredefinedOptionsTestBase.php new file mode 100644 index 0000000000..e22cee6191 --- /dev/null +++ b/core/modules/options/tests/src/Functional/OptionsPredefinedOptionsTestBase.php @@ -0,0 +1,95 @@ +fieldStorage = FieldStorageConfig::create([ + 'field_name' => $field_name, + 'entity_type' => 'entity_test_rev', + 'type' => 'list_string', + 'cardinality' => 1, + 'settings' => [ + 'predefined_options_plugin' => 'predefined_options_test', + ], + ]); + $this->fieldStorage->save(); + + $this->field = FieldConfig::create([ + 'field_name' => $field_name, + 'entity_type' => 'entity_test_rev', + 'bundle' => 'entity_test_rev', + 'required' => TRUE, + ])->save(); + $this->container->get('entity_display.repository') + ->getFormDisplay('entity_test_rev', 'entity_test_rev') + ->setComponent($field_name, [ + 'type' => 'options_select', + ]) + ->save(); + + // Create an entity and prepare test data that will be used by + // options_test_dynamic_values_callback(). + $values = [ + 'user_id' => mt_rand(1, 10), + 'name' => $this->randomMachineName(), + ]; + + $this->entity = EntityTestRev::create($values); + $this->entity->save(); + + $this->plugin = \Drupal::service('plugin.manager.options.predefined_options') + ->createInstance('predefined_options_test'); + } + +} diff --git a/core/modules/options/tests/src/Functional/OptionsPredefinedOptionsValidationTest.php b/core/modules/options/tests/src/Functional/OptionsPredefinedOptionsValidationTest.php new file mode 100644 index 0000000000..e238065fd0 --- /dev/null +++ b/core/modules/options/tests/src/Functional/OptionsPredefinedOptionsValidationTest.php @@ -0,0 +1,44 @@ +plugin->getAllowedValues($this->fieldStorage, $this->entity); + foreach ($allowed_values as $key => $value) { + $this->entity->test_options->value = $value; + $violations = $this->entity->test_options->validate(); + $this->assertCount(0, $violations, "$key is a valid value"); + } + + // Now verify that validation does not pass against anything else. + foreach ($allowed_values as $key => $value) { + $this->entity->test_options->value = is_numeric($value) ? (100 - $value) : ('X' . $value); + $violations = $this->entity->test_options->validate(); + $this->assertCount(1, $violations, "$key is not a valid value"); + } + } + +} diff --git a/core/modules/options/tests/src/Functional/OptionsSelectPredefinedOptionsTest.php b/core/modules/options/tests/src/Functional/OptionsSelectPredefinedOptionsTest.php new file mode 100644 index 0000000000..25ed800770 --- /dev/null +++ b/core/modules/options/tests/src/Functional/OptionsSelectPredefinedOptionsTest.php @@ -0,0 +1,41 @@ +entity->save(); + + // Create a web user. + $web_user = $this->drupalCreateUser(['view test entity', 'administer entity_test content']); + $this->drupalLogin($web_user); + + // Display form. + $this->drupalGet('entity_test_rev/manage/' . $this->entity->id() . '/edit'); + $options = $this->xpath('//select[@id="edit-test-options"]/option'); + $values = $this->plugin->getAllowedValues($this->fieldStorage, $this->entity); + $this->assertCount(count($values) + 1, $options); + foreach ($options as $option) { + $value = $option->getValue(); + if ($value !== '_none') { + self::assertNotEmpty(array_search($value, $values, TRUE)); + } + } + } + +} diff --git a/core/profiles/demo_umami/config/install/field.storage.node.field_difficulty.yml b/core/profiles/demo_umami/config/install/field.storage.node.field_difficulty.yml index d8cbe89a36..bc5ac88a9b 100644 --- a/core/profiles/demo_umami/config/install/field.storage.node.field_difficulty.yml +++ b/core/profiles/demo_umami/config/install/field.storage.node.field_difficulty.yml @@ -20,6 +20,7 @@ settings: value: hard label: Hard allowed_values_function: '' + predefined_options_plugin: '' module: options locked: false cardinality: 1