diff --git a/core/includes/form.inc b/core/includes/form.inc index fefa9b3b872b575244af6c8195f6f786c83ab8ff..bacab5ef71dc3432be90d50be7024bbb8593e3ee 100644 --- a/core/includes/form.inc +++ b/core/includes/form.inc @@ -18,14 +18,15 @@ * Default template: select.html.twig. * * It is possible to group options together; to do this, change the format of - * $options to an associative array in which the keys are group labels, and the - * values are associative arrays in the normal $options format. + * the #options property to an associative array in which the keys are group + * labels, and the values are associative arrays in the normal #options format. * * @param $variables * An associative array containing: * - element: An associative array containing the properties of the element. * Properties used: #title, #value, #options, #description, #extra, - * #multiple, #required, #name, #attributes, #size. + * #multiple, #required, #name, #attributes, #size, #sort_options, + * #sort_start. */ function template_preprocess_select(&$variables) { $element = $variables['element']; @@ -37,36 +38,17 @@ function template_preprocess_select(&$variables) { } /** - * Converts an options form element into a structured array for output. + * Converts the options in a select element into a structured array for output. * * This function calls itself recursively to obtain the values for each optgroup * within the list of options and when the function encounters an object with * an 'options' property inside $element['#options']. * * @param array $element - * An associative array containing the following key-value pairs: - * - #multiple: Optional Boolean indicating if the user may select more than - * one item. - * - #options: An associative array of options to render as HTML. Each array - * value can be a string, an array, or an object with an 'option' property: - * - A string or integer key whose value is a translated string is - * interpreted as a single HTML option element. Do not use placeholders - * that sanitize data: doing so will lead to double-escaping. Note that - * the key will be visible in the HTML and could be modified by malicious - * users, so don't put sensitive information in it. - * - A translated string key whose value is an array indicates a group of - * options. The translated string is used as the label attribute for the - * optgroup. Do not use placeholders to sanitize data: doing so will lead - * to double-escaping. The array should contain the options you wish to - * group and should follow the syntax of $element['#options']. - * - If the function encounters a string or integer key whose value is an - * object with an 'option' property, the key is ignored, the contents of - * the option property are interpreted as $element['#options'], and the - * resulting HTML is added to the output. - * - #value: Optional integer, string, or array representing which option(s) - * to pre-select when the list is first displayed. The integer or string - * must match the key of an option in the '#options' list. If '#multiple' is - * TRUE, this can be an array of integers or strings. + * An associative array containing properties of the select element. See + * \Drupal\Core\Render\Element\Select for details, but note that the + * #empty_option and #empty_value properties are processed, and the + * #value property is set, before reaching this function. * @param array|null $choices * (optional) Either an associative array of options in the same format as * $element['#options'] above, or NULL. This parameter is only used internally @@ -90,7 +72,17 @@ function form_select_options($element, $choices = NULL) { return []; } $choices = $element['#options']; + $sort_options = isset($element['#sort_options']) && $element['#sort_options']; + $sort_start = $element['#sort_start'] ?? 0; } + else { + // We are within an option group. + $sort_options = isset($choices['#sort_options']) && $choices['#sort_options']; + $sort_start = $choices['#sort_start'] ?? 0; + unset($choices['#sort_options']); + unset($choices['#sort_start']); + } + // array_key_exists() accommodates the rare event where $element['#value'] is NULL. // isset() fails in this situation. $value_valid = isset($element['#value']) || array_key_exists('#value', $element); @@ -125,6 +117,14 @@ function form_select_options($element, $choices = NULL) { $options[] = $option; } } + if ($sort_options) { + $unsorted = array_slice($options, 0, $sort_start); + $sorted = array_slice($options, $sort_start); + uasort($sorted, function ($a, $b) { + return strcmp((string) $a['label'], (string) $b['label']); + }); + $options = array_merge($unsorted, $sorted); + } return $options; } diff --git a/core/lib/Drupal/Core/Render/Element/Select.php b/core/lib/Drupal/Core/Render/Element/Select.php index 300c244fc123113918ec23357df9a9c116c1b3d4..fa2e7377c184f5c1357b56b90a5b281d5879029e 100644 --- a/core/lib/Drupal/Core/Render/Element/Select.php +++ b/core/lib/Drupal/Core/Render/Element/Select.php @@ -9,11 +9,32 @@ * Provides a form element for a drop-down menu or scrolling selection box. * * Properties: - * - #options: An associative array, where the keys are the values for each - * option, and the values are the option labels to be shown in the drop-down - * list. If a value is an array, it will be rendered similarly, but as an - * optgroup. The key of the sub-array will be used as the label for the - * optgroup. Nesting optgroups is not allowed. + * - #options: An associative array of options for the select. Do not use + * placeholders that sanitize data in any labels, as doing so will lead to + * double-escaping. Each array value can be: + * - A single translated string representing an HTML option element, where + * the outer array key is the option value and the translated string array + * value is the option label. The option value will be visible in the HTML + * and can be modified by malicious users, so it should not contain + * sensitive information and should be treated as possibly malicious data in + * processing. + * - An array representing an HTML optgroup element. The outer array key + * should be a translated string, and is used as the label for the group. + * The inner array contains the options for the group (with the keys as + * option values, and translated string values as option labels). Nesting + * option groups is not supported. + * - An object with an 'option' property. In this case, the outer array key + * is ignored, and the contents of the 'option' property are interpreted as + * an array of options to be merged with any other regular options and + * option groups found in the outer array. + * - #sort_options: (optional) If set to TRUE (default is FALSE), sort the + * options by their labels, after rendering and translation is complete. + * Can be set within an option group to sort that group. + * - #sort_start: (optional) Option index to start sorting at, where 0 is the + * first option. Can be used within an option group. If an empty option is + * being added automatically (see #empty_option and #empty_value properties), + * this defaults to 1 to keep the empty option at the top of the list. + * Otherwise, it defaults to 0. * - #empty_option: (optional) The label to show for the first default option. * By default, the label is automatically set to "- Select -" for a required * field and "- None -" for an optional field. @@ -68,6 +89,8 @@ public function getInfo() { return [ '#input' => TRUE, '#multiple' => FALSE, + '#sort_options' => FALSE, + '#sort_start' => NULL, '#process' => [ [$class, 'processSelect'], [$class, 'processAjaxForm'], @@ -129,6 +152,9 @@ public static function processSelect(&$element, FormStateInterface $form_state, $element['#options'] = $empty_option + $element['#options']; } } + // Provide the correct default value for #sort_start. + $element['#sort_start'] = $element['#sort_start'] ?? + (isset($element['#empty_value']) ? 1 : 0); return $element; } diff --git a/core/modules/system/tests/modules/form_test/src/Form/FormTestSelectForm.php b/core/modules/system/tests/modules/form_test/src/Form/FormTestSelectForm.php index d1b14d8d0e8789d67ef588ef816b5f46dcaa2447..becdef16180afe70975934297d1f1291dd0ac6e0 100644 --- a/core/modules/system/tests/modules/form_test/src/Form/FormTestSelectForm.php +++ b/core/modules/system/tests/modules/form_test/src/Form/FormTestSelectForm.php @@ -4,6 +4,7 @@ use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; use Symfony\Component\HttpFoundation\JsonResponse; /** @@ -125,10 +126,91 @@ public function buildForm(array $form, FormStateInterface $form_state) { ], ]; + // Add a select that should have its options left alone. + $form['unsorted'] = [ + '#type' => 'select', + '#options' => $this->makeSortableOptions('uso'), + ]; + + // Add a select to test sorting at the top level, and with some of the + // option groups sorted, some left alone, and at least one with #sort_start + // set to a non-default value. + $sortable_options = $this->makeSortableOptions('sso'); + $sortable_options['sso_zzgroup']['#sort_options'] = TRUE; + $sortable_options['sso_xxgroup']['#sort_options'] = TRUE; + $sortable_options['sso_xxgroup']['#sort_start'] = 1; + // Do not use a sort start on this one. + $form['sorted'] = [ + '#type' => 'select', + '#options' => $sortable_options, + '#sort_options' => TRUE, + ]; + + // Add a select to test sorting with a -NONE- option included, + // and #sort_start set. + $sortable_none_options = $this->makeSortableOptions('sno'); + $sortable_none_options['sno_zzgroup']['#sort_options'] = TRUE; + $form['sorted_none'] = [ + '#type' => 'select', + '#options' => $sortable_none_options, + '#sort_options' => TRUE, + '#sort_start' => 4, + '#empty_value' => 'sno_empty', + ]; + + // Add a select to test sorting with a -NONE- option included, + // and #sort_start not set. + $sortable_none_nostart_options = $this->makeSortableOptions('snn'); + $sortable_none_nostart_options['snn_zzgroup']['#sort_options'] = TRUE; + $form['sorted_none_nostart'] = [ + '#type' => 'select', + '#options' => $sortable_none_nostart_options, + '#sort_options' => TRUE, + '#empty_value' => 'snn_empty', + ]; + $form['submit'] = ['#type' => 'submit', '#value' => 'Submit']; return $form; } + /** + * Makes and returns a set of options to test sorting on. + * + * @param string $prefix + * Prefix for the keys of the options. + * + * @return array + * Options array, including option groups, for testing. + */ + protected function makeSortableOptions($prefix) { + return [ + // Don't use $this->t() here, to avoid adding strings to + // localize.drupal.org. Do use TranslatableMarkup in places, to test + // that labels are cast to strings before sorting. + $prefix . '_first_element' => new TranslatableMarkup('first element'), + $prefix . '_second' => new TranslatableMarkup('second element'), + $prefix . '_zzgroup' => [ + $prefix . '_gc' => new TranslatableMarkup('group c'), + $prefix . '_ga' => new TranslatableMarkup('group a'), + $prefix . '_gb' => 'group b', + ], + $prefix . '_yygroup' => [ + $prefix . '_ge' => new TranslatableMarkup('group e'), + $prefix . '_gd' => new TranslatableMarkup('group d'), + $prefix . '_gf' => new TranslatableMarkup('group f'), + ], + $prefix . '_xxgroup' => [ + $prefix . '_gz' => new TranslatableMarkup('group z'), + $prefix . '_gi' => new TranslatableMarkup('group i'), + $prefix . '_gh' => new TranslatableMarkup('group h'), + ], + $prefix . '_d' => 'd', + $prefix . '_c' => new TranslatableMarkup('main c'), + $prefix . '_b' => new TranslatableMarkup('main b'), + $prefix . '_a' => 'a', + ]; + } + /** * {@inheritdoc} */ diff --git a/core/modules/system/tests/src/Functional/Form/FormTest.php b/core/modules/system/tests/src/Functional/Form/FormTest.php index bd45bbcf788bdf1ba83f653d41943ae85ae0472b..0551c88322d3b9edae967acb4352dac36069710d 100644 --- a/core/modules/system/tests/src/Functional/Form/FormTest.php +++ b/core/modules/system/tests/src/Functional/Form/FormTest.php @@ -12,6 +12,7 @@ use Drupal\Tests\BrowserTestBase; use Drupal\user\RoleInterface; use Drupal\filter\Entity\FilterFormat; +use Behat\Mink\Element\NodeElement; /** * Tests various form element validation mechanisms. @@ -463,6 +464,126 @@ public function testEmptySelect() { $this->assertNoFieldByXPath("//select[1]/option", NULL, 'No option element found.'); } + /** + * Tests sorting and not sorting of options in a select element. + */ + public function testSelectSorting() { + $this->drupalGet('form-test/select'); + + // Verify the order of the select options. + $this->validateSelectSorting('unsorted', [ + 'uso_first_element', + 'uso_second', + 'uso_zzgroup', + 'uso_gc', + 'uso_ga', + 'uso_gb', + 'uso_yygroup', + 'uso_ge', + 'uso_gd', + 'uso_gf', + 'uso_xxgroup', + 'uso_gz', + 'uso_gi', + 'uso_gh', + 'uso_d', + 'uso_c', + 'uso_b', + 'uso_a', + ]); + + $this->validateSelectSorting('sorted', [ + 'sso_a', + 'sso_d', + 'sso_first_element', + 'sso_b', + 'sso_c', + 'sso_second', + 'sso_xxgroup', + 'sso_gz', + 'sso_gh', + 'sso_gi', + 'sso_yygroup', + 'sso_ge', + 'sso_gd', + 'sso_gf', + 'sso_zzgroup', + 'sso_ga', + 'sso_gb', + 'sso_gc', + ]); + + $this->validateSelectSorting('sorted_none', [ + 'sno_empty', + 'sno_first_element', + 'sno_second', + 'sno_zzgroup', + 'sno_ga', + 'sno_gb', + 'sno_gc', + 'sno_a', + 'sno_d', + 'sno_b', + 'sno_c', + 'sno_xxgroup', + 'sno_gz', + 'sno_gi', + 'sno_gh', + 'sno_yygroup', + 'sno_ge', + 'sno_gd', + 'sno_gf', + ]); + + $this->validateSelectSorting('sorted_none_nostart', [ + 'snn_empty', + 'snn_a', + 'snn_d', + 'snn_first_element', + 'snn_b', + 'snn_c', + 'snn_second', + 'snn_xxgroup', + 'snn_gz', + 'snn_gi', + 'snn_gh', + 'snn_yygroup', + 'snn_ge', + 'snn_gd', + 'snn_gf', + 'snn_zzgroup', + 'snn_ga', + 'snn_gb', + 'snn_gc', + ]); + + // Verify that #sort_order and #sort_start are not in the page. + $this->assertSession()->responseNotContains('#sort_order'); + $this->assertSession()->responseNotContains('#sort_start'); + } + + /** + * Validates that the options are in the right order in a select. + * + * @param string $select + * Name of the select to verify. + * @param string[] $order + * Expected order of its options. + */ + protected function validateSelectSorting($select, array $order) { + $option_map_function = function (NodeElement $node) { + return ($node->getTagName() === 'optgroup') ? + $node->getAttribute('label') : $node->getValue(); + }; + $option_nodes = $this->getSession() + ->getPage() + ->findField($select) + ->findAll('css', 'option, optgroup'); + + $options = array_map($option_map_function, $option_nodes); + $this->assertIdentical($order, $options); + } + /** * Tests validation of #type 'number' and 'range' elements. */