From 13984a7394ec27a2e99b6c7437469f663a331cc3 Mon Sep 17 00:00:00 2001 From: Dries Buytaert <dries@buytaert.net> Date: Mon, 14 Dec 2009 20:18:56 +0000 Subject: [PATCH] - Patch #639466 by yched: fixed hook_options_list() and XSS filtering, and added more tests. --- modules/field/modules/list/list.info | 2 +- modules/field/modules/list/list.module | 165 ++++++++------ .../field/modules/list/{ => tests}/list.test | 122 ++++++---- .../field/modules/list/tests/list_test.info | 8 + .../field/modules/list/tests/list_test.module | 24 ++ modules/field/modules/options/options.api.php | 65 ++++++ modules/field/modules/options/options.module | 208 +++++++++++++----- modules/field/modules/options/options.test | 80 ++++++- modules/taxonomy/taxonomy.module | 27 +-- 9 files changed, 508 insertions(+), 193 deletions(-) rename modules/field/modules/list/{ => tests}/list.test (53%) create mode 100644 modules/field/modules/list/tests/list_test.info create mode 100644 modules/field/modules/list/tests/list_test.module create mode 100644 modules/field/modules/options/options.api.php diff --git a/modules/field/modules/list/list.info b/modules/field/modules/list/list.info index af520247459c..bf4bb8182007 100644 --- a/modules/field/modules/list/list.info +++ b/modules/field/modules/list/list.info @@ -5,5 +5,5 @@ package = Core - fields version = VERSION core = 7.x files[]=list.module -files[]=list.test +files[]=tests/list.test required = TRUE diff --git a/modules/field/modules/list/list.module b/modules/field/modules/list/list.module index 9838f0a7247a..29e4ea4d2d27 100644 --- a/modules/field/modules/list/list.module +++ b/modules/field/modules/list/list.module @@ -115,7 +115,7 @@ function list_field_settings_form($field, $instance, $has_data) { '#required' => FALSE, '#rows' => 10, '#description' => '<p>' . t('The possible values this field can contain. Enter one value per line, in the format key|label. The key is the value that will be stored in the database, and must be a %type value. The label is optional, and the key will be used as the label if no label is specified.', array('%type' => $field['type'] == 'list_text' ? t('text') : t('numeric'))) . '</p>', - '#element_validate' => array('list_allowed_values_validate'), + '#element_validate' => array('list_allowed_values_setting_validate'), '#list_field_type' => $field['type'], '#access' => empty($settings['allowed_values_function']), ); @@ -144,13 +144,35 @@ function list_field_settings_form($field, $instance, $has_data) { } /** - * Implements hook_field_create_field(). + * Element validate callback; check that the entered values are valid. */ -function list_field_create_field($field) { - if (array_key_exists($field['type'], list_field_info())) { - // Clear the static cache of allowed values for $field. - $allowed_values = &drupal_static('list_allowed_values', array()); - unset($allowed_values[$field['field_name']]); +function list_allowed_values_setting_validate($element, &$form_state) { + $values = list_extract_allowed_values($element['#value'], $element['#list_field_type'] == 'list'); + $field_type = $element['#list_field_type']; + + // Check that keys are valid for the field type. + foreach ($values as $key => $value) { + if ($field_type == 'list_number' && !is_numeric($key)) { + form_error($element, t('Allowed values list: each key must be a valid integer or decimal.')); + break; + } + elseif ($field_type == 'list_text' && strlen($key) > 255) { + form_error($element, t('Allowed values list: each key must be a string less than 255 characters.')); + break; + } + elseif ($field_type == 'list' && !preg_match('/^-?\d+$/', $key)) { + form_error($element, t('Allowed values list: keys must be integers.')); + break; + } + elseif ($field_type == 'list_boolean' && !in_array($key, array('0', '1'))) { + form_error($element, t('Allowed values list: keys must be either 0 or 1.')); + break; + } + } + + // Check that boolean fields get two values. + if ($field_type == 'list_boolean' && count($values) != 2) { + form_error($element, t('Allowed values list: two values are required.')); } } @@ -158,64 +180,67 @@ function list_field_create_field($field) { * Implements hook_field_update_field(). */ function list_field_update_field($field, $prior_field, $has_data) { - if (array_key_exists($field['type'], list_field_info())) { - // Clear the static cache of allowed values for $field. - $allowed_values = &drupal_static('list_allowed_values', array()); - unset($allowed_values[$field['field_name']]); - } + drupal_static_reset('list_allowed_values'); } /** - * Create an array of allowed values for this field. + * Returns the set of allowed values for a list field. + * + * The strings are not safe for output. Keys and values of the array should be + * sanitized through field_filter_xss() before being displayed. + * + * @param $field + * The field definition. + * + * @return + * The array of allowed values. Keys of the array are the raw stored values + * (integer or text), values of the array are the display aliases. */ function list_allowed_values($field) { - // This static cache must be cleared whenever $field['field_name'] - // changes. This includes when it is created because a different - // field with the same name may have previously existed, as well - // as when it is updated. $allowed_values = &drupal_static(__FUNCTION__, array()); - if (isset($allowed_values[$field['field_name']])) { - return $allowed_values[$field['field_name']]; - } + if (!isset($allowed_values[$field['id']])) { + $values = array(); - $allowed_values[$field['field_name']] = array(); + $function = $field['settings']['allowed_values_function']; + if (!empty($function) && function_exists($function)) { + $values = $function($field); + } + elseif (!empty($field['settings']['allowed_values'])) { + $position_keys = $field['type'] == 'list'; + $values = list_extract_allowed_values($field['settings']['allowed_values'], $position_keys); + } - $function = $field['settings']['allowed_values_function']; - if (!empty($function) && function_exists($function)) { - $allowed_values[$field['field_name']] = $function($field); - } - elseif (!empty($field['settings']['allowed_values'])) { - $allowed_values[$field['field_name']] = list_allowed_values_list($field['settings']['allowed_values'], $field['type'] == 'list'); + $allowed_values[$field['id']] = $values; } - return $allowed_values[$field['field_name']]; + return $allowed_values[$field['id']]; } /** - * Create an array of the allowed values for this field. + * Generates an array of values from a string. * * Explode a string with keys and labels separated with '|' and with each new * value on its own line. * * @param $string_values - * The list of choices as a string. + * The list of choices as a string, in the format expected by the + * 'allowed_values' setting: + * - Values are separated by a carriage return. + * - Each value is in the format "value|label" or "value". * @param $position_keys * Boolean value indicating whether to generate keys based on the position of * the value if a key is not manually specified, effectively generating * integer-based keys. This should only be TRUE for fields that have a type of * "list". Otherwise the value will be used as the key if not specified. */ -function list_allowed_values_list($string_values, $position_keys = FALSE) { - $allowed_values = array(); +function list_extract_allowed_values($string_values, $position_keys = FALSE) { + $values = array(); $list = explode("\n", $string_values); $list = array_map('trim', $list); $list = array_filter($list, 'strlen'); foreach ($list as $key => $value) { - // Sanitize the user input with a permissive filter. - $value = field_filter_xss($value); - // Check for a manually specified key. if (strpos($value, '|') !== FALSE) { list($key, $value) = explode('|', $value); @@ -225,43 +250,10 @@ function list_allowed_values_list($string_values, $position_keys = FALSE) { elseif (!$position_keys) { $key = $value; } - $allowed_values[$key] = (isset($value) && $value !== '') ? $value : $key; + $values[$key] = (isset($value) && $value !== '') ? $value : $key; } - return $allowed_values; -} - -/** - * Element validate callback; check that the entered values are valid. - */ -function list_allowed_values_validate($element, &$form_state) { - $values = list_allowed_values_list($element['#value'], $element['#list_field_type'] == 'list'); - $field_type = $element['#list_field_type']; - - // Check that keys are valid for the field type. - foreach ($values as $key => $value) { - if ($field_type == 'list_number' && !is_numeric($key)) { - form_error($element, t('Allowed values list: each key must be a valid integer or decimal.')); - break; - } - elseif ($field_type == 'list_text' && strlen($key) > 255) { - form_error($element, t('Allowed values list: each key must be a string less than 255 characters.')); - break; - } - elseif ($field_type == 'list' && !preg_match('/^-?\d+$/', $key)) { - form_error($element, t('Allowed values list: keys must be integers.')); - break; - } - elseif ($field_type == 'list_boolean' && !in_array($key, array('0', '1'))) { - form_error($element, t('Allowed values list: keys must be either 0 or 1.')); - break; - } - } - - // Check that boolean fields get two values. - if ($field_type == 'list_boolean' && count($values) != 2) { - form_error($element, t('Allowed values list: two values are required.')); - } + return $values; } /** @@ -294,6 +286,33 @@ function list_field_is_empty($item, $field) { return FALSE; } +/** + * Implements hook_field_widget_info_alter(). + * + * The List module does not implement widgets of its own, but reuses the + * widgets defined in options.module. + * + * @see list_options_list(). + */ +function list_field_widget_info_alter(&$info) { + $widgets = array( + 'options_select' => array('list', 'list_text', 'list_number', 'list_boolean'), + 'options_buttons' => array('list', 'list_text', 'list_number', 'list_boolean'), + 'options_onoff' => array('list_boolean'), + ); + + foreach ($widgets as $widget => $field_types) { + $info[$widget]['field types'] = array_merge($info[$widget]['field types'], $field_types); + } +} + +/** + * Implements hook_options_list(). + */ +function list_options_list($field) { + return list_allowed_values($field); +} + /** * Implements hook_field_formatter_info(). */ @@ -321,11 +340,11 @@ function list_field_formatter($object_type, $object, $field, $instance, $langcod $allowed_values = list_allowed_values($field); foreach ($items as $delta => $item) { if (isset($allowed_values[$item['value']])) { - $output = $allowed_values[$item['value']]; + $output = field_filter_xss($allowed_values[$item['value']]); } else { // If no match was found in allowed values, fall back to the key. - $output = $value; + $output = field_filter_xss($value); } $element[$delta] = array('#markup' => $output); } @@ -333,7 +352,7 @@ function list_field_formatter($object_type, $object, $field, $instance, $langcod case 'list_key': foreach ($items as $delta => $item) { - $element[$delta] = array('#markup' => $item['value']); + $element[$delta] = array('#markup' => field_filter_xss($item['value'])); } break; } diff --git a/modules/field/modules/list/list.test b/modules/field/modules/list/tests/list.test similarity index 53% rename from modules/field/modules/list/list.test rename to modules/field/modules/list/tests/list.test index d53a0fd05522..805d3485fd1e 100644 --- a/modules/field/modules/list/list.test +++ b/modules/field/modules/list/tests/list.test @@ -1,105 +1,133 @@ <?php // $Id$ -class ListFieldTestCase extends DrupalWebTestCase { +/** + * @file + * Tests for the 'List' field types. + */ + +/** + * Tests for the 'List' field types. + */ +class ListFieldTestCase extends FieldTestCase { public static function getInfo() { return array( - 'name' => 'List field', - 'description' => "Test the List field type.", - 'group' => 'Field types' + 'name' => 'List field', + 'description' => 'Test the List field type.', + 'group' => 'Field types', ); } function setUp() { parent::setUp('field_test'); - $this->card_1 = array( - 'field_name' => 'card_1', + $this->field_name = 'field_test'; + $this->field = array( + 'field_name' => $this->field_name, 'type' => 'list', 'cardinality' => 1, 'settings' => array( 'allowed_values' => "1|One\n2|Two\n3|Three\n", ), ); - $this->card_1 = field_create_field($this->card_1); + $this->field = field_create_field($this->field); - $this->instance_1 = array( - 'field_name' => $this->card_1['field_name'], + $this->instance = array( + 'field_name' => $this->field_name, 'object_type' => 'test_entity', 'bundle' => 'test_bundle', 'widget' => array( 'type' => 'options_buttons', ), ); - $this->instance_1 = field_create_instance($this->instance_1); + $this->instance = field_create_instance($this->instance); } /** - * Test that allowed values can be updated and that the updates are - * reflected in generated forms. + * Test that allowed values can be updated. */ function testUpdateAllowedValues() { + $langcode = LANGUAGE_NONE; + // All three options appear. $entity = field_test_create_stub_entity(); $form = drupal_get_form('field_test_entity_form', $entity); - $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][1]), t('Option 1 exists')); - $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][2]), t('Option 2 exists')); - $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][3]), t('Option 3 exists')); + $this->assertTrue(!empty($form[$this->field_name][$langcode][1]), t('Option 1 exists')); + $this->assertTrue(!empty($form[$this->field_name][$langcode][2]), t('Option 2 exists')); + $this->assertTrue(!empty($form[$this->field_name][$langcode][3]), t('Option 3 exists')); // Removed options do not appear. - $this->card_1['settings']['allowed_values'] = "2|Two"; - field_update_field($this->card_1); + $this->field['settings']['allowed_values'] = "2|Two"; + field_update_field($this->field); $entity = field_test_create_stub_entity(); $form = drupal_get_form('field_test_entity_form', $entity); - $this->assertTrue(empty($form['card_1'][LANGUAGE_NONE][1]), t('Option 1 does not exist')); - $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][2]), t('Option 2 exists')); - $this->assertTrue(empty($form['card_1'][LANGUAGE_NONE][3]), t('Option 3 does not exist')); + $this->assertTrue(empty($form[$this->field_name][$langcode][1]), t('Option 1 does not exist')); + $this->assertTrue(!empty($form[$this->field_name][$langcode][2]), t('Option 2 exists')); + $this->assertTrue(empty($form[$this->field_name][$langcode][3]), t('Option 3 does not exist')); // Completely new options appear. - $this->card_1['settings']['allowed_values'] = "10|Update\n20|Twenty"; - field_update_field($this->card_1); + $this->field['settings']['allowed_values'] = "10|Update\n20|Twenty"; + field_update_field($this->field); $form = drupal_get_form('field_test_entity_form', $entity); - $this->assertTrue(empty($form['card_1'][LANGUAGE_NONE][1]), t('Option 1 does not exist')); - $this->assertTrue(empty($form['card_1'][LANGUAGE_NONE][2]), t('Option 2 does not exist')); - $this->assertTrue(empty($form['card_1'][LANGUAGE_NONE][3]), t('Option 3 does not exist')); - $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][10]), t('Option 10 exists')); - $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][20]), t('Option 20 exists')); + $this->assertTrue(empty($form[$this->field_name][$langcode][1]), t('Option 1 does not exist')); + $this->assertTrue(empty($form[$this->field_name][$langcode][2]), t('Option 2 does not exist')); + $this->assertTrue(empty($form[$this->field_name][$langcode][3]), t('Option 3 does not exist')); + $this->assertTrue(!empty($form[$this->field_name][$langcode][10]), t('Option 10 exists')); + $this->assertTrue(!empty($form[$this->field_name][$langcode][20]), t('Option 20 exists')); // Options are reset when a new field with the same name is created. - field_delete_field($this->card_1['field_name']); - unset($this->card_1['id']); - $this->card_1['settings']['allowed_values'] = "1|One\n2|Two\n3|Three\n"; - $this->card_1 = field_create_field($this->card_1); - $this->instance_1 = array( - 'field_name' => $this->card_1['field_name'], + field_delete_field($this->field_name); + unset($this->field['id']); + $this->field['settings']['allowed_values'] = "1|One\n2|Two\n3|Three\n"; + $this->field = field_create_field($this->field); + $this->instance = array( + 'field_name' => $this->field_name, 'object_type' => 'test_entity', 'bundle' => 'test_bundle', 'widget' => array( 'type' => 'options_buttons', ), ); - $this->instance_1 = field_create_instance($this->instance_1); + $this->instance = field_create_instance($this->instance); $entity = field_test_create_stub_entity(); $form = drupal_get_form('field_test_entity_form', $entity); - $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][1]), t('Option 1 exists')); - $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][2]), t('Option 2 exists')); - $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][3]), t('Option 3 exists')); + $this->assertTrue(!empty($form[$this->field_name][$langcode][1]), t('Option 1 exists')); + $this->assertTrue(!empty($form[$this->field_name][$langcode][2]), t('Option 2 exists')); + $this->assertTrue(!empty($form[$this->field_name][$langcode][3]), t('Option 3 exists')); } - } /** * List module UI tests. */ -class ListFieldUITestCase extends FieldUITestCase { +class ListFieldUITestCase extends FieldTestCase { public static function getInfo() { return array( - 'name' => 'List field UI tests', + 'name' => 'List field UI', 'description' => 'Test the List field UI functionality.', 'group' => 'Field types', ); } + function setUp() { + parent::setUp('field_test', 'field_ui'); + + // Create test user. + $admin_user = $this->drupalCreateUser(array('access content', 'administer content types', 'administer taxonomy')); + $this->drupalLogin($admin_user); + + // Create content type, with underscores. + $type_name = strtolower($this->randomName(8)) . '_' .'test'; + $type = $this->drupalCreateContentType(array('name' => $type_name, 'type' => $type_name)); + $this->type = $type->type; + // Store a valid URL name, with hyphens instead of underscores. + $this->hyphen_type = str_replace('_', '-', $this->type); + + // Create random field name. + $this->field_label = $this->randomName(8); + $this->field_name = 'field_' . strtolower($this->randomName(8)); + } + /** * Tests that allowed values are properly validated in the UI. */ @@ -126,23 +154,23 @@ class ListFieldUITestCase extends FieldUITestCase { $edit = array($element_name => "1|one\n" . $this->randomName(255) . "|two"); $this->drupalPost($admin_path, $edit, t('Save settings')); $this->assertText("each key must be a string less than 255 characters", t('Form vaildation failed.')); - + // Test 'List (boolean)' field type. - $admin_path = $this->createListFieldAndEdit('list_boolean'); + $admin_path = $this->createListFieldAndEdit('list_boolean'); // Check that invalid option keys are rejected. $edit = array($element_name => "1|one\n2|two"); $this->drupalPost($admin_path, $edit, t('Save settings')); $this->assertText("keys must be either 0 or 1", t('Form vaildation failed.')); - + //Check that missing option causes failure. $edit = array($element_name => "1|one"); $this->drupalPost($admin_path, $edit, t('Save settings')); - $this->assertText("two values are required", t('Form vaildation failed.')); + $this->assertText("two values are required", t('Form vaildation failed.')); } - + /** * Helper function to create list field of a given type and get the edit page. - * + * * @param string $type * 'list', 'list_boolean', 'list_number', or 'list_text' */ @@ -164,6 +192,6 @@ class ListFieldUITestCase extends FieldUITestCase { $admin_path = 'admin/structure/types/manage/' . $this->hyphen_type . '/fields/' . $field_name; return $admin_path; } - + } diff --git a/modules/field/modules/list/tests/list_test.info b/modules/field/modules/list/tests/list_test.info new file mode 100644 index 000000000000..32fec873d1d6 --- /dev/null +++ b/modules/field/modules/list/tests/list_test.info @@ -0,0 +1,8 @@ +;$Id$ +name = "List test" +description = "Support module for the List module tests." +core = 7.x +package = Testing +files[] = list_test.module +version = VERSION +hidden = TRUE diff --git a/modules/field/modules/list/tests/list_test.module b/modules/field/modules/list/tests/list_test.module new file mode 100644 index 000000000000..4fb1998eb113 --- /dev/null +++ b/modules/field/modules/list/tests/list_test.module @@ -0,0 +1,24 @@ +<?php +// $Id$ + +/** + * @file + * Helper module for the List module tests. + */ + +/** + * Allowed values callback. + */ +function list_test_allowed_values_callback($field) { + $values = array( + 'Group 1' => array( + 0 => 'Zero', + ), + 1 => 'One', + 'Group 2' => array( + 2 => 'Some <script>dangerous</script> & unescaped <strong>markup</strong>', + ), + ); + + return $values; +} diff --git a/modules/field/modules/options/options.api.php b/modules/field/modules/options/options.api.php new file mode 100644 index 000000000000..72ee8674b0e2 --- /dev/null +++ b/modules/field/modules/options/options.api.php @@ -0,0 +1,65 @@ +<?php +// $Id$ + +/** + * @file + * Hooks provided by the Options module. + */ + +/** + * Returns the list of options to be displayed for a field. + * + * Field types willing to enable one or several of the widgets defined in + * options.module (select, radios/checkboxes, on/off checkbox) need to + * implement this hook to specify the list of options to display in the + * widgets. + * + * @param $field + * The field definition. + * + * @return + * The array of options for the field. Array keys are the values to be + * stored, and should be of the data type (string, number...) expected by + * the first 'column' for the field type. Array values are the labels to + * display within the widgets. The labels should NOT be sanitized, + * options.module takes care of sanitation according to the needs of each + * widget. The HTML tags defined in _field_filter_xss_allowed_tags() are + * allowed, other tags will be filtered. + */ +function hook_options_list($field) { + // Sample structure. + $options = array( + 0 => t('Zero'), + 1 => t('One'), + 2 => t('Two'), + 3 => t('Three'), + ); + + // Sample structure with groups. Only one level of nesting is allowed. This + // is only supported by the 'options_select' widget. Other widgets will + // flatten the array. + $options = array( + t('First group') => array( + 0 => t('Zero'), + ), + t('Second group') => array( + 1 => t('One'), + 2 => t('Two'), + ), + 3 => t('Three'), + ); + + // In actual implementations, the array of options will most probably depend + // on properties of the field. Example from taxonomy.module: + $options = array(); + foreach ($field['settings']['allowed_values'] as $tree) { + $terms = taxonomy_get_tree($tree['vid'], $tree['parent']); + if ($terms) { + foreach ($terms as $term) { + $options[$term->tid] = str_repeat('-', $term->depth) . $term->name; + } + } + } + + return $options; +} diff --git a/modules/field/modules/options/options.module b/modules/field/modules/options/options.module index 42f881088a15..78a32e2ca547 100644 --- a/modules/field/modules/options/options.module +++ b/modules/field/modules/options/options.module @@ -32,26 +32,32 @@ function options_theme() { /** * Implements hook_field_widget_info(). + * + * Field type modules willing to use those widgets should: + * - Use hook_field_widget_info_alter() to append their field own types to the + * list of types supported by the widgets, + * - Implement hook_options_list() to provide the list of options. + * See list.module. */ function options_field_widget_info() { return array( 'options_select' => array( 'label' => t('Select list'), - 'field types' => array('list', 'list_boolean', 'list_text', 'list_number'), + 'field types' => array(), 'behaviors' => array( 'multiple values' => FIELD_BEHAVIOR_CUSTOM, ), ), 'options_buttons' => array( 'label' => t('Check boxes/radio buttons'), - 'field types' => array('list', 'list_boolean', 'list_text', 'list_number'), + 'field types' => array(), 'behaviors' => array( 'multiple values' => FIELD_BEHAVIOR_CUSTOM, ), ), 'options_onoff' => array( 'label' => t('Single on/off checkbox'), - 'field types' => array('list_boolean'), + 'field types' => array(), 'behaviors' => array( 'multiple values' => FIELD_BEHAVIOR_CUSTOM, ), @@ -66,61 +72,64 @@ function options_field_widget(&$form, &$form_state, $field, $instance, $langcode // Abstract over the actual field columns, to allow different field types to // reuse those widgets. $value_key = key($field['columns']); + + $type = str_replace('options_', '', $instance['widget']['type']); $multiple = $field['cardinality'] > 1 || $field['cardinality'] == FIELD_CARDINALITY_UNLIMITED; - // Form API 'checkboxes' do not suport 0 as an option, so we replace it with - // a placeholder within the form workflow. - $zero_placeholder = $instance['widget']['type'] == 'options_buttons' && $multiple; - // Collect available options for the field. - $options = options_get_options($field, $instance, $zero_placeholder); + $required = $element['#required']; + $properties = _options_properties($type, $multiple, $required); + + // Prepare the list of options. + $options = _options_get_options($field, $instance, $properties); + // Put current field values in shape. - $default_value = _options_storage_to_form($items, $options, $value_key, $zero_placeholder); + $default_value = _options_storage_to_form($items, $options, $value_key, $properties); - switch ($instance['widget']['type']) { - case 'options_select': + switch ($type) { + case 'select': $element += array( '#type' => 'select', '#default_value' => $default_value, // Do not display a 'multiple' select box if there is only one option. '#multiple' => $multiple && count($options) > 1, '#options' => $options, - '#value_key' => $value_key, - '#element_validate' => array('options_field_widget_validate'), ); break; - case 'options_buttons': - $type = $multiple ? 'checkboxes' : 'radios'; + case 'buttons': // If required and there is one single option, preselect it. - if ($element['#required'] && count($options) == 1) { + if ($required && count($options) == 1) { + reset($options); $default_value = array(key($options)); } $element += array( - '#type' => $type, + '#type' => $multiple ? 'checkboxes' : 'radios', // Radio buttons need a scalar value. - '#default_value' => ($type == 'radios') ? reset($default_value) : $default_value, + '#default_value' => $multiple ? $default_value : reset($default_value), '#options' => $options, - '#zero_placeholder' => $zero_placeholder, - '#value_key' => $value_key, - '#element_validate' => array('options_field_widget_validate'), ); break; - case 'options_onoff': + case 'onoff': $keys = array_keys($options); - $off_value = (!empty($keys) && isset($keys[0])) ? $keys[0] : NULL; - $on_value = (!empty($keys) && isset($keys[1])) ? $keys[1] : NULL; + $off_value = array_shift($keys); + $on_value = array_shift($keys); $element += array( '#type' => 'checkbox', - '#title' => isset($options[$on_value]) ? $options[$on_value] : '', '#default_value' => (isset($default_value[0]) && $default_value[0] == $on_value) ? 1 : 0, '#on_value' => $on_value, '#off_value' => $off_value, - '#value_key' => $value_key, - '#element_validate' => array('options_field_widget_validate'), ); + // Override the title from the incoming $element. + $element['#title'] = isset($options[$on_value]) ? $options[$on_value] : ''; break; } + $element += array( + '#value_key' => $value_key, + '#element_validate' => array('options_field_widget_validate'), + '#properties' => $properties, + ); + return $element; } @@ -135,54 +144,123 @@ function options_field_widget_validate($element, &$form_state) { } /** - * Prepares the options for a field. + * Describes the preparation steps required by each widget. */ -function options_get_options($field, $instance, $zero_placeholder) { - // Check if there is a module hook for the option values, otherwise try - // list_allowed_values() for an options list. - // @todo This should be turned into a hook_options_allowed_values(), exposed - // by options.module. - $function = $field['module'] . '_allowed_values'; - $options = function_exists($function) ? $function($field) : (array) list_allowed_values($field); +function _options_properties($type, $multiple, $required) { + $base = array( + 'zero_placeholder' => FALSE, + 'filter_xss' => FALSE, + 'strip_tags' => FALSE, + 'empty_value' => FALSE, + 'optgroups' => FALSE, + ); + + switch ($type) { + case 'select': + $properties = array( + // Select boxes do not support any HTML tag. + 'strip_tags' => TRUE, + 'empty_value' => !$required, + 'optgroups' => TRUE, + ); + break; + + case 'buttons': + $properties = array( + 'filter_xss' => TRUE, + // Form API 'checkboxes' do not suport 0 as an option, so we replace it with + // a placeholder within the form workflow. + 'zero_placeholder' => $multiple, + // Checkboxes do not need a 'none' choice. + 'empty_value' => !$required && !$multiple, + ); + break; + + case 'onoff': + $properties = array( + 'filter_xss' => TRUE, + ); + break; + } + + return $properties + $base; +} + +/** + * Collects the options for a field. + */ +function _options_get_options($field, $instance, $properties) { + // Get the list of options. + $options = (array) module_invoke($field['module'], 'options_list', $field); + + // Sanitize the options. + _options_prepare_options($options, $properties); + + if (!$properties['optgroups']) { + $options = options_array_flatten($options); + } + + if ($properties['empty_value']) { + $options = array('_none' => theme('options_none', array('instance' => $instance))) + $options; + } + + return $options; +} +/** + * Sanitizes the options. + * + * The function is recursive to support optgroups. + */ +function _options_prepare_options(&$options, $properties) { // Substitute the '_0' placeholder. - if ($zero_placeholder) { + if ($properties['zero_placeholder']) { $values = array_keys($options); + $labels = array_values($options); // Use a strict comparison, because 0 == 'any string'. $index = array_search(0, $values, TRUE); - if ($index !== FALSE) { + if ($index !== FALSE && !is_array($options[$index])) { $values[$index] = '_0'; - $options = array_combine($values, array_values($options)); + $options = array_combine($values, $labels); } } - // Add an empty choice for - // - non required radios - // - non required selects - if (!$instance['required']) { - if (($instance['widget']['type'] == 'options_buttons' && ($field['cardinality'] == 1)) || ($instance['widget']['type'] == 'options_select')) { - $options = array('_none' => theme('options_none', array('instance' => $instance))) + $options; + foreach ($options as $value => $label) { + // Recurse for optgroups. + if (is_array($label)) { + _options_prepare_options($options[$value], $properties); + } + else { + if ($properties['strip_tags']) { + $options[$value] = strip_tags($label); + } + if ($properties['filter_xss']) { + $options[$value] = field_filter_xss($label); + } } } - return $options; } /** * Transforms stored field values into the format the widgets need. */ -function _options_storage_to_form($items, $options, $column, $zero_placeholder) { +function _options_storage_to_form($items, $options, $column, $properties) { $items_transposed = options_array_transpose($items); $values = (isset($items_transposed[$column]) && is_array($items_transposed[$column])) ? $items_transposed[$column] : array(); // Substitute the '_0' placeholder. - if ($zero_placeholder) { + if ($properties['zero_placeholder']) { $index = array_search('0', $values); if ($index !== FALSE) { $values[$index] = '_0'; } } - // Discard values that are not in the current list of options. + // Discard values that are not in the current list of options. Flatten the + // array if needed. + if ($properties['optgroups']) { + $options = options_array_flatten($options); + } $values = array_values(array_intersect($values, array_keys($options))); return $values; } @@ -192,6 +270,7 @@ function _options_storage_to_form($items, $options, $column, $zero_placeholder) */ function _options_form_to_storage($element) { $values = array_values((array) $element['#value']); + $properties = $element['#properties']; // On/off checkbox: transform '0 / 1' into the 'on / off' values. if ($element['#type'] == 'checkbox') { @@ -199,7 +278,7 @@ function _options_form_to_storage($element) { } // Substitute the '_0' placeholder. - if (!empty($element['#zero_placeholder'])) { + if ($properties['zero_placeholder']) { $index = array_search('_0', $values); if ($index !== FALSE) { $values[$index] = 0; @@ -208,9 +287,11 @@ function _options_form_to_storage($element) { // Filter out the 'none' option. Use a strict comparison, because // 0 == 'any string'. - $index = array_search('_none', $values, TRUE); - if ($index !== FALSE) { - unset($values[$index]); + if ($properties['empty_value']) { + $index = array_search('_none', $values, TRUE); + if ($index !== FALSE) { + unset($values[$index]); + } } // Make sure we populate at least an empty value. @@ -252,6 +333,29 @@ function options_array_transpose($array) { return $result; } +/** + * Flattens an array of allowed values. + * + * @param $array + * A single or multidimensional array. + * @return + * A flattened array. + */ +function options_array_flatten($array) { + $result = array(); + if (is_array($array)) { + foreach ($array as $key => $value) { + if (is_array($value)) { + $result += options_array_flatten($value); + } + else { + $result[$key] = $value; + } + } + } + return $result; +} + /** * Implements hook_field_widget_error(). */ diff --git a/modules/field/modules/options/options.test b/modules/field/modules/options/options.test index d8bfeac060c7..764c45c52bc7 100644 --- a/modules/field/modules/options/options.test +++ b/modules/field/modules/options/options.test @@ -11,7 +11,7 @@ class OptionsWidgetsTestCase extends FieldTestCase { } function setUp() { - parent::setUp('field_test'); + parent::setUp('field_test', 'list_test'); // Field with cardinality 1. $this->card_1 = array( @@ -20,7 +20,7 @@ class OptionsWidgetsTestCase extends FieldTestCase { 'cardinality' => 1, 'settings' => array( // Make sure that 0 works as an option. - 'allowed_values' => "0|Zero\n1|One\n2|Two\n", + 'allowed_values' => "0|Zero\n1|One\n2|Some <script>dangerous</script> & unescaped <strong>markup</strong>\n", ), ); $this->card_1 = field_create_field($this->card_1); @@ -32,7 +32,7 @@ class OptionsWidgetsTestCase extends FieldTestCase { 'cardinality' => 2, 'settings' => array( // Make sure that 0 works as an option. - 'allowed_values' => "0|Zero\n1|One\n2|Two\n", + 'allowed_values' => "0|Zero\n1|One\n2|Some <script>dangerous</script> & unescaped <strong>markup</strong>\n", ), ); $this->card_2 = field_create_field($this->card_2); @@ -44,7 +44,7 @@ class OptionsWidgetsTestCase extends FieldTestCase { 'cardinality' => 1, 'settings' => array( // Make sure that 0 works as a 'on' value'. - 'allowed_values' => "1|No\n0|Yes\n", + 'allowed_values' => "1|No\n0|Some <script>dangerous</script> & unescaped <strong>markup</strong>\n", ), ); $this->bool = field_create_field($this->bool); @@ -81,6 +81,7 @@ class OptionsWidgetsTestCase extends FieldTestCase { $this->assertNoFieldChecked("edit-card-1-$langcode-0"); $this->assertNoFieldChecked("edit-card-1-$langcode-1"); $this->assertNoFieldChecked("edit-card-1-$langcode-2"); + $this->assertRaw('Some dangerous & unescaped <strong>markup</strong>', t('Option text was properly filtered.')); // Select first option. $edit = array("card_1[$langcode]" => 0); @@ -98,7 +99,7 @@ class OptionsWidgetsTestCase extends FieldTestCase { $this->drupalPost(NULL, $edit, t('Save')); $this->assertFieldValues($entity_init, 'card_1', $langcode, array()); - // Required radios with one option is auto-selected. + // Check that required radios with one option is auto-selected. $this->card_1['settings']['allowed_values'] = '99|Only allowed value'; field_update_field($this->card_1); $instance['required'] = TRUE; @@ -137,6 +138,7 @@ class OptionsWidgetsTestCase extends FieldTestCase { $this->assertNoFieldChecked("edit-card-2-$langcode--0"); $this->assertNoFieldChecked("edit-card-2-$langcode-1"); $this->assertNoFieldChecked("edit-card-2-$langcode-2"); + $this->assertRaw('Some dangerous & unescaped <strong>markup</strong>', t('Option text was properly filtered.')); // Submit form: select first and third options. $edit = array( @@ -223,6 +225,7 @@ class OptionsWidgetsTestCase extends FieldTestCase { $this->assertNoOptionSelected("edit-card-1-$langcode", 0); $this->assertNoOptionSelected("edit-card-1-$langcode", 1); $this->assertNoOptionSelected("edit-card-1-$langcode", 2); + $this->assertRaw('Some dangerous & unescaped markup', t('Option text was properly filtered.')); // Submit form: select first option. $edit = array("card_1[$langcode]" => 0); @@ -248,6 +251,38 @@ class OptionsWidgetsTestCase extends FieldTestCase { // We do not have to test that a required select list with one option is // auto-selected because the browser does it for us. + + // Test optgroups. + + $this->card_1['settings']['allowed_values'] = NULL; + $this->card_1['settings']['allowed_values_function'] = 'list_test_allowed_values_callback'; + field_update_field($this->card_1); + $instance['required'] = FALSE; + field_update_instance($instance); + + // Display form: with no field data, nothing is selected + $this->drupalGet('test-entity/' . $entity->ftid .'/edit'); + $this->assertNoOptionSelected("edit-card-1-$langcode", 0); + $this->assertNoOptionSelected("edit-card-1-$langcode", 1); + $this->assertNoOptionSelected("edit-card-1-$langcode", 2); + $this->assertRaw('Some dangerous & unescaped markup', t('Option text was properly filtered.')); + $this->assertRaw('Group 1', t('Option groups are displayed.')); + + // Submit form: select first option. + $edit = array("card_1[$langcode]" => 0); + $this->drupalPost(NULL, $edit, t('Save')); + $this->assertFieldValues($entity_init, 'card_1', $langcode, array(0)); + + // Display form: check that the right options are selected. + $this->drupalGet('test-entity/' . $entity->ftid .'/edit'); + $this->assertOptionSelected("edit-card-1-$langcode", 0); + $this->assertNoOptionSelected("edit-card-1-$langcode", 1); + $this->assertNoOptionSelected("edit-card-1-$langcode", 2); + + // Submit form: Unselect the option. + $edit = array("card_1[$langcode]" => '_none'); + $this->drupalPost('test-entity/' . $entity->ftid .'/edit', $edit, t('Save')); + $this->assertFieldValues($entity_init, 'card_1', $langcode, array()); } /** @@ -277,6 +312,7 @@ class OptionsWidgetsTestCase extends FieldTestCase { $this->assertNoOptionSelected("edit-card-2-$langcode", 0); $this->assertNoOptionSelected("edit-card-2-$langcode", 1); $this->assertNoOptionSelected("edit-card-2-$langcode", 2); + $this->assertRaw('Some dangerous & unescaped markup', t('Option text was properly filtered.')); // Submit form: select first and third options. $edit = array("card_2[$langcode][]" => array(0 => 0, 2 => 2)); @@ -331,6 +367,39 @@ class OptionsWidgetsTestCase extends FieldTestCase { // We do not have to test that a required select list with one option is // auto-selected because the browser does it for us. + + // Test optgroups. + + // Use a callback function defining optgroups. + $this->card_2['settings']['allowed_values'] = NULL; + $this->card_2['settings']['allowed_values_function'] = 'list_test_allowed_values_callback'; + field_update_field($this->card_2); + $instance['required'] = FALSE; + field_update_instance($instance); + + // Display form: with no field data, nothing is selected. + $this->drupalGet('test-entity/' . $entity->ftid .'/edit'); + $this->assertNoOptionSelected("edit-card-2-$langcode", 0); + $this->assertNoOptionSelected("edit-card-2-$langcode", 1); + $this->assertNoOptionSelected("edit-card-2-$langcode", 2); + $this->assertRaw('Some dangerous & unescaped markup', t('Option text was properly filtered.')); + $this->assertRaw('Group 1', t('Option groups are displayed.')); + + // Submit form: select first option. + $edit = array("card_2[$langcode][]" => array(0 => 0)); + $this->drupalPost(NULL, $edit, t('Save')); + $this->assertFieldValues($entity_init, 'card_2', $langcode, array(0)); + + // Display form: check that the right options are selected. + $this->drupalGet('test-entity/' . $entity->ftid .'/edit'); + $this->assertOptionSelected("edit-card-2-$langcode", 0); + $this->assertNoOptionSelected("edit-card-2-$langcode", 1); + $this->assertNoOptionSelected("edit-card-2-$langcode", 2); + + // Submit form: Unselect the option. + $edit = array("card_2[$langcode][]" => array('_none' => '_none')); + $this->drupalPost('test-entity/' . $entity->ftid .'/edit', $edit, t('Save')); + $this->assertFieldValues($entity_init, 'card_2', $langcode, array()); } /** @@ -358,6 +427,7 @@ class OptionsWidgetsTestCase extends FieldTestCase { // Display form: with no field data, option is unchecked. $this->drupalGet('test-entity/' . $entity->ftid .'/edit'); $this->assertNoFieldChecked("edit-bool-$langcode"); + $this->assertRaw('Some dangerous & unescaped <strong>markup</strong>', t('Option text was properly filtered.')); // Submit form: check the option. $edit = array("bool[$langcode]" => TRUE); diff --git a/modules/taxonomy/taxonomy.module b/modules/taxonomy/taxonomy.module index e21728c9dea1..f17aa1b4e668 100644 --- a/modules/taxonomy/taxonomy.module +++ b/modules/taxonomy/taxonomy.module @@ -983,16 +983,6 @@ function taxonomy_field_info() { /** * Implements hook_field_widget_info(). - * - * We need custom handling of multiple values because we need - * to combine them into a options list rather than display - * cardinality elements. We will use the field module's default - * handling for default values. - * - * Callbacks can be omitted if default handing is used. - * They're included here just so this module can be used - * as an example for custom modules that might do things - * differently. */ function taxonomy_field_widget_info() { return array( @@ -1018,6 +1008,13 @@ function taxonomy_field_widget_info_alter(&$info) { $info['options_buttons']['field types'][] = 'taxonomy_term'; } +/** + * Implements hook_options_list(). + */ +function taxonomy_options_list($field) { + return taxonomy_allowed_values($field); +} + /** * Implements hook_field_schema(). */ @@ -1122,12 +1119,12 @@ function taxonomy_field_formatter($object_type, $object, $field, $instance, $lan } /** - * Create an array of the allowed values for this field. + * Returns the set of valid terms for a taxonomy field. * - * Call the field's allowed_values function to retrieve the allowed - * values array. - * - * @see _taxonomy_term_select() + * @param $field + * The field definition. + * @return + * The array of valid terms for this field, keyed by term id. */ function taxonomy_allowed_values($field) { $options = array(); -- GitLab