From d5ce7f5281373044b8e1df6d95287270c0b33145 Mon Sep 17 00:00:00 2001 From: Dries Buytaert Date: Wed, 7 Apr 2010 17:30:43 +0000 Subject: [PATCH] - Patch #384992 by effulgentsia, sun: drupal_html_id() does not work correctly in AJAX context with multiple forms on page. --- includes/common.inc | 77 ++++++++++++-- misc/ajax.js | 6 ++ modules/field/field.form.inc | 4 +- modules/field/modules/options/options.test | 6 +- modules/field_ui/field_ui.test | 2 +- modules/simpletest/drupal_web_test_case.php | 112 +++++++++++++++++++- modules/simpletest/tests/ajax.test | 89 +++++++++++++++- modules/simpletest/tests/common.test | 6 +- modules/simpletest/tests/form.test | 39 +++---- modules/simpletest/tests/form_test.module | 29 +++++ 10 files changed, 324 insertions(+), 46 deletions(-) diff --git a/includes/common.inc b/includes/common.inc index d63d056c2fb..91632bf9272 100644 --- a/includes/common.inc +++ b/includes/common.inc @@ -3317,13 +3317,73 @@ function drupal_html_class($class) { /** * Prepare a string for use as a valid HTML ID and guarantee uniqueness. * + * This function ensures that each passed HTML ID value only exists once on the + * page. By tracking the already returned ids, this function enables forms, + * blocks, and other content to be output multiple times on the same page, + * without breaking (X)HTML validation. + * + * For already existing ids, a counter is appended to the id string. Therefore, + * JavaScript and CSS code should not rely on any value that was generated by + * this function and instead should rely on manually added CSS classes or + * similarly reliable constructs. + * + * Two consecutive hyphens separate the counter from the original id. To manage + * uniqueness across multiple AJAX requests on the same page, AJAX requests + * POST an array of all IDs currently present on the page, which are used to + * prime this function's cache upon first invocation. + * + * To allow reverse-parsing of ids submitted via AJAX, any multiple consecutive + * hyphens in the originally passed $id are replaced with a single hyphen. + * * @param $id * The ID to clean. + * * @return * The cleaned ID. */ function drupal_html_id($id) { - $seen_ids = &drupal_static(__FUNCTION__, array()); + // If this is an AJAX request, then content returned by this page request will + // be merged with content already on the base page. The HTML ids must be + // unique for the fully merged content. Therefore, initialize $seen_ids to + // take into account ids that are already in use on the base page. + $seen_ids_init = &drupal_static(__FUNCTION__ . ':init'); + if (!isset($seen_ids_init)) { + // Ideally, Drupal would provide an API to persist state information about + // prior page requests in the database, and we'd be able to add this + // function's $seen_ids static variable to that state information in order + // to have it properly initialized for this page request. However, no such + // page state API exists, so instead, ajax.js adds all of the in-use HTML + // ids to the POST data of AJAX submissions. Direct use of $_POST is + // normally not recommended as it could open up security risks, but because + // the raw POST data is cast to a number before being returned by this + // function, this usage is safe. + if (empty($_POST['ajax_html_ids'])) { + $seen_ids_init = array(); + } + else { + // This function ensures uniqueness by appending a counter to the base id + // requested by the calling function after the first occurrence of that + // requested id. $_POST['ajax_html_ids'] contains the ids as they were + // returned by this function, potentially with the appended counter, so + // we parse that to reconstruct the $seen_ids array. + foreach ($_POST['ajax_html_ids'] as $seen_id) { + // We rely on '--' being used solely for separating a base id from the + // counter, which this function ensures when returning an id. + $parts = explode('--', $seen_id, 2); + if (!empty($parts[1]) && is_numeric($parts[1])) { + list($seen_id, $i) = $parts; + } + else { + $i = 1; + } + if (!isset($seen_ids_init[$seen_id]) || ($i > $seen_ids_init[$seen_id])) { + $seen_ids_init[$seen_id] = $i; + } + } + } + } + $seen_ids = &drupal_static(__FUNCTION__, $seen_ids_init); + $id = strtr(drupal_strtolower($id), array(' ' => '-', '_' => '-', '[' => '-', ']' => '')); // As defined in http://www.w3.org/TR/html4/types.html#type-name, HTML IDs can @@ -3334,14 +3394,15 @@ function drupal_html_id($id) { // characters as well. $id = preg_replace('/[^A-Za-z0-9\-_]/', '', $id); - // Ensure IDs are unique. The first occurrence is held but left alone. - // Subsequent occurrences get a number appended to them. This incrementing - // will almost certainly break code that relies on explicit HTML IDs in forms - // that appear more than once on the page, but the alternative is outputting - // duplicate IDs, which would break JS code and XHTML validity anyways. For - // now, it's an acceptable stopgap solution. + // Removing multiple consecutive hyphens. + $id = preg_replace('/\-+/', '-', $id); + // Ensure IDs are unique by appending a counter after the first occurrence. + // The counter needs to be appended with a delimiter that does not exist in + // the base ID. Requiring a unique delimiter helps ensure that we really do + // return unique IDs and also helps us re-create the $seen_ids array during + // AJAX requests. if (isset($seen_ids[$id])) { - $id = $id . '-' . ++$seen_ids[$id]; + $id = $id . '--' . ++$seen_ids[$id]; } else { $seen_ids[$id] = 1; diff --git a/misc/ajax.js b/misc/ajax.js index f8e3ee6d3be..1d6c8295db8 100644 --- a/misc/ajax.js +++ b/misc/ajax.js @@ -200,6 +200,12 @@ Drupal.ajax.prototype.beforeSubmit = function (form_values, element, options) { // Disable the element that received the change. $(this.element).addClass('progress-disabled').attr('disabled', true); + // Prevent duplicate HTML ids in the returned markup. + // @see drupal_html_id() + $('[id]').each(function () { + form_values.push({ name: 'ajax_html_ids[]', value: this.id }); + }); + // Insert progressbar or throbber. if (this.progress.type == 'bar') { var progressBar = new Drupal.progressBar('ajax-progress-' + this.element.id, eval(this.progress.update_callback), this.progress.method, eval(this.progress.error_callback)); diff --git a/modules/field/field.form.inc b/modules/field/field.form.inc index fd7bedbb4ff..b5be9224cef 100644 --- a/modules/field/field.form.inc +++ b/modules/field/field.form.inc @@ -153,7 +153,7 @@ function field_multiple_value_form($field, $instance, $langcode, $items, &$form, $title = check_plain(t($instance['label'])); $description = field_filter_xss(t($instance['description'])); - $wrapper_id = drupal_html_class($field_name) . '-add-more-wrapper'; + $wrapper_id = drupal_html_id($field_name . '-add-more-wrapper'); $field_elements = array(); $function = $instance['widget']['module'] . '_field_widget_form'; @@ -241,7 +241,7 @@ function theme_field_multiple_value_form($variables) { $output = ''; if ($element['#cardinality'] > 1 || $element['#cardinality'] == FIELD_CARDINALITY_UNLIMITED) { - $table_id = $element['#field_name'] . '_values'; + $table_id = drupal_html_id($element['#field_name'] . '_values'); $order_class = $element['#field_name'] . '-delta-order'; $required = !empty($element['#required']) ? '*' : ''; diff --git a/modules/field/modules/options/options.test b/modules/field/modules/options/options.test index 6f3a4cd5178..8cbfd5bc7c2 100644 --- a/modules/field/modules/options/options.test +++ b/modules/field/modules/options/options.test @@ -135,7 +135,7 @@ class OptionsWidgetsTestCase extends FieldTestCase { // Display form: with no field data, nothing is checked. $this->drupalGet('test-entity/' . $entity->ftid .'/edit'); - $this->assertNoFieldChecked("edit-card-2-$langcode--0"); + $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 markup', t('Option text was properly filtered.')); @@ -151,7 +151,7 @@ class OptionsWidgetsTestCase extends FieldTestCase { // Display form: check that the right options are selected. $this->drupalGet('test-entity/' . $entity->ftid .'/edit'); - $this->assertFieldChecked("edit-card-2-$langcode--0"); + $this->assertFieldChecked("edit-card-2-$langcode-0"); $this->assertNoFieldChecked("edit-card-2-$langcode-1"); $this->assertFieldChecked("edit-card-2-$langcode-2"); @@ -166,7 +166,7 @@ class OptionsWidgetsTestCase extends FieldTestCase { // Display form: check that the right options are selected. $this->drupalGet('test-entity/' . $entity->ftid .'/edit'); - $this->assertFieldChecked("edit-card-2-$langcode--0"); + $this->assertFieldChecked("edit-card-2-$langcode-0"); $this->assertNoFieldChecked("edit-card-2-$langcode-1"); $this->assertNoFieldChecked("edit-card-2-$langcode-2"); diff --git a/modules/field_ui/field_ui.test b/modules/field_ui/field_ui.test index e1bdc8185ad..171d4c4bc32 100644 --- a/modules/field_ui/field_ui.test +++ b/modules/field_ui/field_ui.test @@ -94,7 +94,7 @@ class FieldUITestCase extends DrupalWebTestCase { // should also appear in the 'taxonomy term' entity. $vocabulary = taxonomy_vocabulary_load(1); $this->drupalGet('admin/structure/taxonomy/' . $vocabulary->machine_name . '/fields'); - $this->assertTrue($this->xpath('//select[@id="edit--add-existing-field-field-name"]//option[@value="' . $this->field_name . '"]'), t('Existing field was found in account settings.')); + $this->assertTrue($this->xpath('//select[@name="_add_existing_field[field_name]"]//option[@value="' . $this->field_name . '"]'), t('Existing field was found in account settings.')); } /** diff --git a/modules/simpletest/drupal_web_test_case.php b/modules/simpletest/drupal_web_test_case.php index 5d64abb5c94..ccc2f354beb 100644 --- a/modules/simpletest/drupal_web_test_case.php +++ b/modules/simpletest/drupal_web_test_case.php @@ -1659,7 +1659,9 @@ class DrupalWebTestCase extends DrupalTestCase { // http://www.w3.org/TR/html4/interact/forms.html#h-17.13.4.1 $post[$key] = urlencode($key) . '=' . urlencode($value); } - if ($ajax && isset($submit['triggering_element'])) { + // For AJAX requests, add '_triggering_element_*' and + // 'ajax_html_ids' to the POST data, as ajax.js does. + if ($ajax) { if (is_array($submit['triggering_element'])) { // Get the first key/value pair in the array. $post['_triggering_element_value'] = '_triggering_element_value=' . urlencode(reset($submit['triggering_element'])); @@ -1668,6 +1670,10 @@ class DrupalWebTestCase extends DrupalTestCase { else { $post['_triggering_element_name'] = '_triggering_element_name=' . urlencode($submit['triggering_element']); } + foreach ($this->xpath('//*[@id]') as $element) { + $id = (string) $element['id']; + $post[] = urlencode('ajax_html_ids[]') . '=' . urlencode($id); + } } $post = implode('&', $post); } @@ -1698,10 +1704,78 @@ class DrupalWebTestCase extends DrupalTestCase { } /** - * Execute a POST request on an AJAX path and JSON decode the result. + * Execute an AJAX submission. + * + * This executes a POST as ajax.js does. It uses the returned JSON data, an + * array of commands, to update $this->content using equivalent DOM + * manipulation as is used by ajax.js. It also returns the array of commands. + * + * @see ajax.js */ - protected function drupalPostAJAX($path, $edit, $triggering_element, $ajax_path = 'system/ajax', array $options = array(), array $headers = array()) { - return drupal_json_decode($this->drupalPost($path, $edit, array('path' => $ajax_path, 'triggering_element' => $triggering_element), $options, $headers)); + protected function drupalPostAJAX($path, $edit, $triggering_element, $ajax_path = 'system/ajax', array $options = array(), array $headers = array(), $form_html_id = NULL, $ajax_settings = array()) { + // Get the content of the initial page prior to calling drupalPost(), since + // drupalPost() replaces $this->content. + if (isset($path)) { + $this->drupalGet($path, $options); + } + $content = $this->content; + $return = drupal_json_decode($this->drupalPost(NULL, $edit, array('path' => $ajax_path, 'triggering_element' => $triggering_element), $options, $headers, $form_html_id)); + + // We need $ajax_settings['wrapper'] to perform DOM manipulation. + if (!empty($ajax_settings) && !empty($return)) { + // DOM can load HTML soup. But, HTML soup can throw warnings, suppress + // them. + @$dom = DOMDocument::loadHTML($content); + foreach ($return as $command) { + // @todo ajax.js can process commands other than 'insert' and can + // process commands that include a 'selector', but these are hard to + // emulate with DOMDocument. For now, we only implement 'insert' + // commands that use $ajax_settings['wrapper']. + if ($command['command'] == 'insert' && !isset($command['selector'])) { + // $dom->getElementById() doesn't work when drupalPostAJAX() is + // invoked multiple times for a page, so use XPath instead. This also + // sets us up for adding support for $command['selector'], though it + // will require transforming a jQuery selector to XPath. + $xpath = new DOMXPath($dom); + $wrapperNode = $xpath->query('//*[@id="' . $ajax_settings['wrapper'] . '"]')->item(0); + if ($wrapperNode) { + // ajax.js adds an enclosing DIV to work around a Safari bug. + $newDom = DOMDocument::loadHTML('
' . $command['data'] . '
'); + $newNode = $dom->importNode($newDom->documentElement->firstChild->firstChild, TRUE); + $method = isset($command['method']) ? $command['method'] : $ajax_settings['method']; + // The "method" is a jQuery DOM manipulation function. Emulate each + // one using PHP's DOMNode API. + switch ($method) { + case 'replaceWith': + $wrapperNode->parentNode->replaceChild($newNode, $wrapperNode); + break; + case 'append': + $wrapperNode->appendChild($newNode); + break; + case 'prepend': + // If no firstChild, insertBefore() falls back to appendChild(). + $wrapperNode->insertBefore($newNode, $wrapperNode->firstChild); + break; + case 'before': + $wrapperNode->parentNode->insertBefore($newNode, $wrapperNode); + break; + case 'after': + // If no nextSibling, insertBefore() falls back to appendChild(). + $wrapperNode->parentNode->insertBefore($newNode, $wrapperNode->nextSibling); + break; + case 'html': + foreach ($wrapperNode->childNodes as $childNode) { + $wrapperNode->removeChild($childNode); + } + $wrapperNode->appendChild($newNode); + break; + } + } + } + } + $this->drupalSetContent($dom->saveHTML()); + } + return $return; } /** @@ -2787,6 +2861,36 @@ class DrupalWebTestCase extends DrupalTestCase { return $this->assertNoFieldByXPath($this->constructFieldXpath('name', $field) . '|' . $this->constructFieldXpath('id', $field), '', $message, $group); } + /** + * Assert that each HTML ID is used for just a single element. + * + * @param $message + * Message to display. + * @param $group + * The group this message belongs to. + * @param $ids_to_skip + * An optional array of ids to skip when checking for duplicates. It is + * always a bug to have duplicate HTML IDs, so this parameter is to enable + * incremental fixing of core code. Whenever a test passes this parameter, + * it should add a "todo" comment above the call to this function explaining + * the legacy bug that the test wishes to ignore and including a link to an + * issue that is working to fix that legacy bug. + * @return + * TRUE on pass, FALSE on fail. + */ + protected function assertNoDuplicateIds($message = '', $group = 'Other', $ids_to_skip = array()) { + $status = TRUE; + foreach ($this->xpath('//*[@id]') as $element) { + $id = (string) $element['id']; + if (isset($seen_ids[$id]) && !in_array($id, $ids_to_skip)) { + $this->fail(t('The HTML ID %id is unique.', array('%id' => $id)), $group); + $status = FALSE; + } + $seen_ids[$id] = TRUE; + } + return $this->assert($status, $message, $group); + } + /** * Helper function: construct an XPath for the given set of attributes and value. * diff --git a/modules/simpletest/tests/ajax.test b/modules/simpletest/tests/ajax.test index ca6f718dd22..f6e2e28deb8 100644 --- a/modules/simpletest/tests/ajax.test +++ b/modules/simpletest/tests/ajax.test @@ -2,8 +2,8 @@ // $Id$ class AJAXTestCase extends DrupalWebTestCase { - function setUp() { - parent::setUp('ajax_test', 'ajax_forms_test'); + function setUp($modules = array()) { + parent::setUp(array_unique(array_merge(array('ajax_test', 'ajax_forms_test'), $modules))); } /** @@ -204,6 +204,91 @@ class AJAXFormValuesTestCase extends AJAXTestCase { } } +/** + * Tests that AJAX-enabled forms work when multiple instances of the same form are on a page. + */ +class AJAXMultiFormTestCase extends AJAXTestCase { + public static function getInfo() { + return array( + 'name' => 'AJAX multi form', + 'description' => 'Tests that AJAX-enabled forms work when multiple instances of the same form are on a page.', + 'group' => 'AJAX', + ); + } + + function setUp() { + parent::setUp(array('form_test')); + + // Create a multi-valued field for 'page' nodes to use for AJAX testing. + $field_name = 'field_ajax_test'; + $field = array( + 'field_name' => $field_name, + 'type' => 'text', + 'cardinality' => FIELD_CARDINALITY_UNLIMITED, + ); + field_create_field($field); + $instance = array( + 'field_name' => $field_name, + 'entity_type' => 'node', + 'bundle' => 'page', + ); + field_create_instance($instance); + + // Login a user who can create 'page' nodes. + $this->web_user = $this->drupalCreateUser(array('create page content')); + $this->drupalLogin($this->web_user); + } + + /** + * Test that a page with the 'page_node_form' included twice works correctly. + */ + function testMultiForm() { + // HTML IDs for elements within the field are potentially modified with + // each AJAX submission, but these variables are stable and help target the + // desired elements. + $field_name = 'field_ajax_test'; + $field_xpaths = array( + 'page-node-form' => '//form[@id="page-node-form"]//div[contains(@class, "field-name-field-ajax-test")]', + 'page-node-form--2' => '//form[@id="page-node-form--2"]//div[contains(@class, "field-name-field-ajax-test")]', + ); + $button_name = $field_name . '_add_more'; + $button_value = t('Add another item'); + $button_xpath_suffix = '//input[@name="' . $button_name . '"]'; + $field_items_xpath_suffix = '//input[@type="text"]'; + + // Ensure the initial page contains both node forms and the correct number + // of field items and "add more" button for the multi-valued field within + // each form. + $this->drupalGet('form-test/two-instances-of-same-form'); + foreach ($field_xpaths as $form_id => $field_xpath) { + $this->assert(count($this->xpath($field_xpath . $field_items_xpath_suffix)) == 1, t('Found the correct number of field items on the initial page.')); + $this->assertFieldByXPath($field_xpath . $button_xpath_suffix, NULL, t('Found the "add more" button on the initial page.')); + } + // @todo Legacy bug of duplicate ids for filter-guidelines-FORMAT. See + // http://drupal.org/node/755566. + $this->assertNoDuplicateIds(t('Initial page contains unique IDs'), 'Other', array('filter-guidelines-1', 'filter-guidelines-2', 'filter-guidelines-3')); + + // Submit the "add more" button of each form twice. After each corresponding + // page update, ensure the same as above. To successfully implement + // consecutive AJAX submissions, we need to manage $settings as ajax.js + // does for Drupal.settings. + preg_match('/jQuery\.extend\(Drupal\.settings, (.*?)\);/', $this->content, $matches); + $settings = drupal_json_decode($matches[1]); + foreach ($field_xpaths as $form_id => $field_xpath) { + for ($i=0; $i<2; $i++) { + $button = $this->xpath($field_xpath . $button_xpath_suffix); + $button_id = (string) $button[0]['id']; + $commands = $this->drupalPostAJAX(NULL, array(), array($button_name => $button_value), 'system/ajax', array(), array(), $form_id, $settings['ajax'][$button_id]); + $settings = array_merge_recursive($settings, $commands[0]['settings']); + $this->assert(count($this->xpath($field_xpath . $field_items_xpath_suffix)) == $i+2, t('Found the correct number of field items after an AJAX submission.')); + $this->assertFieldByXPath($field_xpath . $button_xpath_suffix, NULL, t('Found the "add more" button after an AJAX submission.')); + // @todo Legacy bug of duplicate ids for filter-guidelines-FORMAT. See + // http://drupal.org/node/755566. + $this->assertNoDuplicateIds(t('Updated page contains unique IDs'), 'Other', array('filter-guidelines-1', 'filter-guidelines-2', 'filter-guidelines-3')); + } + } + } +} /** * Miscellaneous AJAX tests using ajax_test module. diff --git a/modules/simpletest/tests/common.test b/modules/simpletest/tests/common.test index c1d294e7d1e..c6d0d6369e5 100644 --- a/modules/simpletest/tests/common.test +++ b/modules/simpletest/tests/common.test @@ -748,15 +748,15 @@ class DrupalHTMLIdentifierTestCase extends DrupalUnitTestCase { $this->assertIdentical(drupal_html_id('invalid,./:@\\^`{Üidentifier'), 'invalididentifier', t('Strip invalid characters.')); // Verify Drupal coding standards are enforced. - $this->assertIdentical(drupal_html_id('ID NAME_[1]'), 'id-name--1', t('Enforce Drupal coding standards.')); + $this->assertIdentical(drupal_html_id('ID NAME_[1]'), 'id-name-1', t('Enforce Drupal coding standards.')); // Reset the static cache so we can ensure the unique id count is at zero. drupal_static_reset('drupal_html_id'); // Clean up IDs with invalid starting characters. $this->assertIdentical(drupal_html_id('test-unique-id'), 'test-unique-id', t('Test the uniqueness of IDs #1.')); - $this->assertIdentical(drupal_html_id('test-unique-id'), 'test-unique-id-2', t('Test the uniqueness of IDs #2.')); - $this->assertIdentical(drupal_html_id('test-unique-id'), 'test-unique-id-3', t('Test the uniqueness of IDs #3.')); + $this->assertIdentical(drupal_html_id('test-unique-id'), 'test-unique-id--2', t('Test the uniqueness of IDs #2.')); + $this->assertIdentical(drupal_html_id('test-unique-id'), 'test-unique-id--3', t('Test the uniqueness of IDs #3.')); } } diff --git a/modules/simpletest/tests/form.test b/modules/simpletest/tests/form.test index 20fbbb0f516..c5639be8773 100644 --- a/modules/simpletest/tests/form.test +++ b/modules/simpletest/tests/form.test @@ -813,35 +813,28 @@ class FormsRebuildTestCase extends DrupalWebTestCase { $this->web_user = $this->drupalCreateUser(array('create page content')); $this->drupalLogin($this->web_user); - // Get the form for adding a 'page' node. Save the content in a local - // variable, because drupalPostAJAX() will replace $this->content. + // Get the form for adding a 'page' node. Submit an "add another item" AJAX + // submission and verify it worked by ensuring the updated page has two text + // field items in the field for which we just added an item. $this->drupalGet('node/add/page'); - $content = $this->content; + preg_match('/jQuery\.extend\(Drupal\.settings, (.*?)\);/', $this->content, $matches); + $settings = drupal_json_decode($matches[1]); + $button = $this->xpath('//input[@name="field_ajax_test_add_more"]'); + $button_id = (string) $button[0]['id']; + $this->drupalPostAJAX(NULL, array(), array('field_ajax_test_add_more' => t('Add another item')), 'system/ajax', array(), array(), 'page-node-form', $settings['ajax'][$button_id]); + $this->assert(count($this->xpath('//div[contains(@class, "field-name-field-ajax-test")]//input[@type="text"]')) == 2, t('AJAX submission succeeded.')); - // Submit an "add another item" AJAX submission and verify it worked by - // ensuring it returned two text fields. - $commands = $this->drupalPostAJAX(NULL, array(), array('field_ajax_test_add_more' => t('Add another item'))); - $fragment = simplexml_load_string('
' . $commands[1]['data'] . '
'); - $this->assert(count($fragment->xpath('//input[@type="text"]')) == 2, t('AJAX submission succeeded.')); - - // Submit the form with the non-AJAX "Save" button. Leave the title field - // blank to trigger a validation error. Restore $this->content first, as - // drupalPost() needs that to contain the form, not the JSON string left by - // drupalPostAJAX(). - // @todo While not necessary for this test, we would be emulating the - // browser better by calling drupalPost() with the AJAX-modified content - // rather than with the original content from the drupalGet(), but that's - // not possible with the current implementation of drupalPostAJAX(). See - // http://drupal.org/node/384992 - $this->drupalSetContent($content); + // Submit the form with the non-AJAX "Save" button, leaving the title field + // blank to trigger a validation error, and ensure that a validation error + // occurred, because this test is for testing what happens when a form is + // re-rendered without being re-built, which is what happens when there's + // a validation error. $this->drupalPost(NULL, array(), t('Save')); - - // Ensure that a validation error occurred, since this test is for testing - // what happens to the form action after a validation error. $this->assertText('Title field is required.', t('Non-AJAX submission correctly triggered a validation error.')); // Ensure that the form's action is correct. - $this->assertFieldByXPath('//form[@id="page-node-form" and @action="' . url('node/add/page') . '"]', NULL, t('Re-rendered form contains the correct action value.')); + $forms = $this->xpath('//form[contains(@class, "node-page-form")]'); + $this->assert(count($forms) == 1 && $forms[0]['action'] == url('node/add/page'), t('Re-rendered form contains the correct action value.')); } } diff --git a/modules/simpletest/tests/form_test.module b/modules/simpletest/tests/form_test.module index 09ca8f81ccd..d189fe56bc1 100644 --- a/modules/simpletest/tests/form_test.module +++ b/modules/simpletest/tests/form_test.module @@ -126,6 +126,18 @@ function form_test_menu() { 'type' => MENU_CALLBACK, ); + if (module_exists('node')) { + $items['form-test/two-instances-of-same-form'] = array( + 'title' => 'AJAX test with two form instances', + 'page callback' => 'form_test_two_instances', + 'access callback' => 'node_access', + 'access arguments' => array('create', 'page'), + 'file path' => drupal_get_path('module', 'node'), + 'file' => 'node.pages.inc', + 'type' => MENU_CALLBACK, + ); + } + return $items; } @@ -1040,3 +1052,20 @@ function form_test_user_register_form_rebuild($form, &$form_state) { drupal_set_message('Form rebuilt.'); $form_state['rebuild'] = TRUE; } + +/** + * Menu callback that returns two instances of the node form. + */ +function form_test_two_instances() { + global $user; + $node1 = (object) array( + 'uid' => $user->uid, + 'name' => (isset($user->name) ? $user->name : ''), + 'type' => 'page', + 'language' => LANGUAGE_NONE, + ); + $node2 = clone($node1); + $return['node_form_1'] = drupal_get_form('page_node_form', $node1); + $return['node_form_2'] = drupal_get_form('page_node_form', $node2); + return $return; +}