diff --git a/core/includes/common.inc b/core/includes/common.inc index 4a8b1f0..7b1b11c 100644 --- a/core/includes/common.inc +++ b/core/includes/common.inc @@ -7173,17 +7173,22 @@ function drupal_explode_tags($tags) { // This regexp allows the following types of user input: // this, "somecompany, llc", "and ""this"" w,o.rks", foo bar $regexp = '%(?:^|,\ *)("(?>[^"]*)(?>""[^"]* )*"|(?: [^",]*))%x'; - preg_match_all($regexp, $tags, $matches); - $typed_tags = array_unique($matches[1]); + preg_match_all($regexp, $tags, $matches, PREG_OFFSET_CAPTURE); + $typed_tags = array(); + + foreach ($matches[1] as $match) { + $typed_tags[$match[1]] = $match[0]; + } + $typed_tags = array_unique($typed_tags); $tags = array(); - foreach ($typed_tags as $tag) { + foreach ($typed_tags as $position => $tag) { // If a user has escaped a term (to demonstrate that it is a group, // or includes a comma or quote character), we remove the escape // formatting so to save the term into the database as the user intends. $tag = trim(str_replace('""', '"', preg_replace('/^"(.*)"$/', '\1', $tag))); if ($tag != "") { - $tags[] = $tag; + $tags[$position] = $tag; } } diff --git a/core/misc/autocomplete.js b/core/misc/autocomplete.js index 8b0dd72..20ce682 100644 --- a/core/misc/autocomplete.js +++ b/core/misc/autocomplete.js @@ -206,7 +206,7 @@ Drupal.jsAC.prototype.populatePopup = function () { // Do search. this.db.owner = this; - this.db.search(this.input.value); + this.db.search(this.input.value, $input.get(0).selectionStart); }; /** @@ -272,7 +272,7 @@ Drupal.ACDB = function (uri) { /** * Performs a cached and delayed search. */ -Drupal.ACDB.prototype.search = function (searchString) { +Drupal.ACDB.prototype.search = function (searchString, cursorPosition) { var db = this; this.searchString = searchString; @@ -299,7 +299,7 @@ Drupal.ACDB.prototype.search = function (searchString) { // encodeURIComponent to allow autocomplete search terms to contain slashes. $.ajax({ type: 'GET', - url: db.uri + '/' + Drupal.encodePath(searchString), + url: db.uri + '/' + encodeURIComponent(searchString) + '/' + cursorPosition, dataType: 'json', success: function (matches) { if (typeof matches.status === 'undefined' || matches.status !== 0) { diff --git a/core/modules/taxonomy/taxonomy.pages.inc b/core/modules/taxonomy/taxonomy.pages.inc index c84a6bd..08b6db7 100644 --- a/core/modules/taxonomy/taxonomy.pages.inc +++ b/core/modules/taxonomy/taxonomy.pages.inc @@ -102,17 +102,16 @@ function taxonomy_term_feed(Term $term) { * (optional) A comma-separated list of term names entered in the * autocomplete form element. Only the last term is used for autocompletion. * Defaults to '' (an empty string). + * @param $cursor_position + * (optional) The current position of the cursor within the autocomplete + * form element. Defaults to 0. * * @see taxonomy_menu() * @see taxonomy_field_widget_info() */ -function taxonomy_autocomplete($field_name, $tags_typed = '') { +function taxonomy_autocomplete($field_name, $tags_typed = '', $cursor_position = 0) { // If the request has a '/' in the search text, then the menu system will have // split it into multiple arguments, recover the intended $tags_typed. - $args = func_get_args(); - // Shift off the $field_name argument. - array_shift($args); - $tags_typed = implode('/', $args); // Make sure the field exists and is a taxonomy field. if (!($field = field_info_field($field_name)) || $field['type'] !== 'taxonomy_term_reference') { @@ -122,12 +121,28 @@ function taxonomy_autocomplete($field_name, $tags_typed = '') { exit; } - // The user enters a comma-separated list of tags. We only autocomplete the last tag. - $tags_typed = drupal_explode_tags($tags_typed); - $tag_last = drupal_strtolower(array_pop($tags_typed)); + // The user enters a comma-separated list of tags. + $tags_typed_array = drupal_explode_tags($tags_typed); + // Try to find the tag based on the current cursor position. + $tag_current = ''; + $tag_positions = array_keys($tags_typed_array); + $tag_positions[] = drupal_strlen($tags_typed) + 1; + $position_last = -1; + foreach ($tag_positions as $position) { + if ($cursor_position >= $position_last && $cursor_position < $position) { + // Tag found on cursor position. + $tag_current = $tags_typed_array[$position_last]; + break; + } + // Save position for later use. + $position_last = $position; + } + if (isset($tags_typed_array[$position_last])) { + $tags_typed_array[$position_last] = ''; + } $matches = array(); - if ($tag_last != '') { + if ($tag_current != '') { // Part of the criteria for the query come from the field's own settings. $vids = array(); @@ -141,28 +156,32 @@ function taxonomy_autocomplete($field_name, $tags_typed = '') { $query->addTag('term_access'); // Do not select already entered terms. - if (!empty($tags_typed)) { - $query->condition('t.name', $tags_typed, 'NOT IN'); + if (!empty($tags_typed_array)) { + $query->condition('t.name', $tags_typed_array, 'NOT IN'); } // Select rows that match by term name. $tags_return = $query ->fields('t', array('tid', 'name')) ->condition('t.vid', $vids) - ->condition('t.name', '%' . db_like($tag_last) . '%', 'LIKE') + ->condition('t.name', '%' . db_like($tag_current) . '%', 'LIKE') ->range(0, 10) ->execute() ->fetchAllKeyed(); - $prefix = count($tags_typed) ? drupal_implode_tags($tags_typed) . ', ' : ''; - $term_matches = array(); foreach ($tags_return as $tid => $name) { $n = $name; + // Term names containing commas or quotes must be wrapped in quotes. if (strpos($name, ',') !== FALSE || strpos($name, '"') !== FALSE) { $n = '"' . str_replace('"', '""', $name) . '"'; } - $term_matches[$prefix . $n] = check_plain($name); + + $tags_typed_array[$position_last] = $n; + $prefix = count($tags_typed_array) ? implode(', ', $tags_typed_array) : ''; + + $term_matches[$prefix] = check_plain($name); + } } diff --git a/core/modules/taxonomy/taxonomy.test b/core/modules/taxonomy/taxonomy.test index 4a3db65..6171778 100644 --- a/core/modules/taxonomy/taxonomy.test +++ b/core/modules/taxonomy/taxonomy.test @@ -49,9 +49,9 @@ class TaxonomyWebTestCase extends WebTestBase { /** * Returns a new term with random properties in vocabulary $vid. */ - function createTerm($vocabulary) { + function createTerm($vocabulary, $term_name = NULL) { $term = entity_create('taxonomy_term', array( - 'name' => $this->randomName(), + 'name' => !empty($term_name) ? $term_name : $this->randomName(), 'description' => $this->randomName(), // Use the first available text format. 'format' => db_query_range('SELECT format FROM {filter_format}', 0, 1)->fetchField(), @@ -61,9 +61,7 @@ class TaxonomyWebTestCase extends WebTestBase { taxonomy_term_save($term); return $term; } - } - /** * Tests the taxonomy vocabulary interface. */ @@ -799,6 +797,40 @@ class TaxonomyTermTestCase extends TaxonomyWebTestCase { } /** + * Tests taxonomy term autocompletion functionality. + */ + function testTaxonomyAutocomplete() { + $vocabulary = $this->createVocabulary(); + + // Create two taxonomy terms without commas. + $term1 = $this->createTerm($vocabulary); + $term2 = $this->createTerm($vocabulary); + // Create a taxonomy term that contains a comma. + $term3 = $this->createTerm($vocabulary, $this->randomName() . ', ' . $this->randomName()); + + // Test autocompletion of term 1. The term is alphanumeric only, so + // there is no extra quoting. + $input = substr($term1->name, 0, 3); + $this->drupalGet('taxonomy/autocomplete/taxonomy_' . $vocabulary->machine_name . '/' . $input); + $this->assertRaw('{"' . $term1->name . '":"' . $term1->name . '"}', t('Autocomplete returns term %term_name after typing the first 3 letters.', array('%term_name' => $term1->name))); + // Test autocompletion of term 3. The term contains a comma, so expect + // the key to be quoted. + $input = substr($term3->name, 0, 3); + $this->drupalGet('taxonomy/autocomplete/taxonomy_' . $vocabulary->machine_name . '/' . $input); + $this->assertRaw('{"\"' . $term3->name . '\"":"' . $term3->name . '"}', t('Autocomplete returns term %term_name after typing the first 3 letters.', array('%term_name' => $term3->name))); + + + // Test autocompletion at a specific cursor position. + $term1_name = substr($term1->name, 0, 3); + $input = drupal_implode_tags(array($term1_name, $term2->name)); + // Retrieve the autocomplete callback results for the given input and set + // the cursor after the third character. + $this->drupalGet('taxonomy/autocomplete/taxonomy_' . $vocabulary->machine_name . '/' . $input . '/3'); + // The first term should be completed and after the second term. + $this->assertRaw('{"' . drupal_implode_tags(array($term2->name, $term1->name)) . '":"' . $term1->name . '"}', t('Autocomplete returns term %term1_name after %term2_name.', array('%term1_name' => $term1->name, '%term2_name' => $term2->name))); + } + + /** * Save, edit and delete a term using the user interface. */ function testTermInterface() {