Index: includes/form.inc =================================================================== RCS file: /cvs/drupal/drupal/includes/form.inc,v retrieving revision 1.405 diff -u -p -r1.405 form.inc --- includes/form.inc 23 Nov 2009 22:37:37 -0000 1.405 +++ includes/form.inc 24 Nov 2009 15:27:47 -0000 @@ -265,10 +265,9 @@ function drupal_build_form($form_id, &$f */ function form_state_defaults() { return array( - 'args' => array(), 'rebuild' => FALSE, 'redirect' => NULL, - 'build_info' => array(), + 'build_info' => array('args' => array()), 'storage' => array(), 'submitted' => FALSE, 'programmed' => FALSE, @@ -326,6 +325,13 @@ function drupal_rebuild_form($form_id, & form_set_cache($form_build_id, $form, $form_state); } + // AJAX callbacks and other special situations may trigger form rebuilding + // without it having been requested by the form processing functions. In + // these situations, maintain continuity of element values if they were valid. + if (!$form_state['rebuild'] && !form_get_errors()) { + $form_state['storage']['values'] = $form_state['values']; + } + // Clear out all post data, as we don't want the previous step's // data to pollute this one and trigger validate/submit handling, // then process the form for rendering. @@ -335,6 +341,9 @@ function drupal_rebuild_form($form_id, & // when rerendering the form. $form_state['groups'] = array(); + // Also clear out values. + $form_state['values'] = array(); + // Do not call drupal_process_form(), since it would prevent the rebuilt form // to submit. $form = form_builder($form_id, $form, $form_state); @@ -1188,6 +1197,7 @@ function _form_builder_handle_input_elem if (!isset($element['#value']) && !array_key_exists('#value', $element)) { $value_callback = !empty($element['#value_callback']) ? $element['#value_callback'] : 'form_type_' . $element['#type'] . '_value'; + // Set #value based on submitted input. if ($form_state['programmed'] || ($form_state['process_input'] && (!isset($element['#access']) || $element['#access']))) { $input = $form_state['input']; foreach ($element['#parents'] as $parent) { @@ -1195,10 +1205,13 @@ function _form_builder_handle_input_elem } // If we have input for the current element, assign it to the #value property. if (!$form_state['programmed'] || isset($input)) { - // Call #type_value to set the form value; + // Call the value callback to set the form element value. if (function_exists($value_callback)) { $element['#value'] = $value_callback($element, $input, $form_state); } + // If there's no value callback or it didn't return a value, then set + // the #value to $input. Validation functions will still have a chance + // to run. if (!isset($element['#value']) && isset($input)) { $element['#value'] = $input; } @@ -1208,6 +1221,21 @@ function _form_builder_handle_input_elem $element['#needs_validation'] = TRUE; } } + // Set #value based on previously validated values when rebuilding a form. + if (!isset($element['#value']) && !empty($form_state['storage']['values'])) { + $value = $form_state['storage']['values']; + foreach ($element['#parents'] as $parent) { + $value = isset($value[$parent]) ? $value[$parent] : NULL; + } + if (isset($value)) { + $element['#value'] = $value; + } + // New $form, so new validation needed, even if the values were validated + // in the prior step. + if (isset($element['#value']) || (!empty($element['#required']))) { + $element['#needs_validation'] = TRUE; + } + } // Load defaults. if (!isset($element['#value'])) { // Call #type_value without a second argument to request default_value handling. Index: modules/simpletest/tests/ajax.test =================================================================== RCS file: /cvs/drupal/drupal/modules/simpletest/tests/ajax.test,v retrieving revision 1.3 diff -u -p -r1.3 ajax.test --- modules/simpletest/tests/ajax.test 22 Nov 2009 02:48:37 -0000 1.3 +++ modules/simpletest/tests/ajax.test 24 Nov 2009 15:19:22 -0000 @@ -141,3 +141,46 @@ class AJAXCommandsTestCase extends AJAXT $this->assertTrue($command['command'] == 'restripe' && $command['selector'] == '#restripe_table', "'restripe' AJAX command issued with correct selector"); } } + +/** + * Test that $form_state['values'] is properly delivered to $ajax['callback']. + */ +class AJAXFormValuesTestCase extends AJAXTestCase { + public static function getInfo() { + return array( + 'name' => 'AJAX Form Values in command', + 'description' => 'Creates a form, then POSTS to system/ajax to change the form via pseudo-ajax.', + 'group' => 'AJAX', + ); + } + + /** + * Create a simple form with a select, then POST a change to it. + */ + function testSimpleAJAXFormValue() { + $web_user = $this->drupalCreateUser(array('access content')); + $this->drupalLogin($web_user); + + $form_path = 'ajax_forms_test_get_form'; + + $selects_to_test = array('red', 'green', 'blue'); + foreach($selects_to_test as $item) { + $edit = array(); + $edit['select'] = $item; + $commands = $this->drupalPostAJAX($form_path, $edit, 'select'); + $data_command = $commands[2]; + $this->assertEqual($data_command['value'], $edit['select']); + } + + $values_to_test = array(FALSE, TRUE); + foreach($values_to_test as $item) { + $edit = array(); + $edit['checkbox'] = $item; + $commands = $this->drupalPostAJAX($form_path, $edit, 'checkbox'); + $data_command = $commands[2]; + $value_expected = (int) $item; + $value_received = (int) $data_command['value']; + $this->assertEqual($value_received, $value_expected); + } + } +} Index: modules/simpletest/tests/form.test =================================================================== RCS file: /cvs/drupal/drupal/modules/simpletest/tests/form.test,v retrieving revision 1.25 diff -u -p -r1.25 form.test --- modules/simpletest/tests/form.test 18 Nov 2009 18:51:11 -0000 1.25 +++ modules/simpletest/tests/form.test 24 Nov 2009 16:47:37 -0000 @@ -533,3 +533,55 @@ class FormStateValuesCleanTestCase exten } } +/** + * Test that form values are kept or not kept after a rebuild, as appropriate. + */ +class FormsFormRebuildTestCase extends DrupalWebTestCase { + + public static function getInfo() { + return array( + 'name' => 'Form rebuilding', + 'description' => 'Tests forms using different rebuild options to make sure values from one step are or are not carried over into the next step, as appropriate.', + 'group' => 'Form API', + ); + } + + function setUp() { + parent::setUp('form_test'); + } + + /** + * Tests form rebuilding for a multistep form. + */ + function testStorage() { + $user = $this->drupalCreateUser(array('access content')); + $this->drupalLogin($user); + + $this->drupalPost('form_test/form-multistep', array('name' => 'foo'), 'Continue'); + $this->assertText('Friend\'s name', t('The correct step is shown.')); + $this->assertFieldByName('name', 'DEFAULT', t('The name field for the next step was correctly set to its default value.')); + } + + /** + * Tests form rebuilding for a confirmation form. + */ + function testRebuild() { + $user = $this->drupalCreateUser(array('access content')); + $this->drupalLogin($user); + + $this->drupalPost('form_test/form-rebuild', array('name' => 'foo'), 'Delete'); + $this->assertFieldByName('name', 'DEFAULT FOR DELETE', t('The name field for the rebuilt form was correctly set to its default value.')); + } + + /** + * Tests form rebuilding during an "add another item" step. + */ + function testRebuildProgressive() { + $user = $this->drupalCreateUser(array('access content')); + $this->drupalLogin($user); + + $this->drupalPost('form_test/form-progressive-rebuild', array('groceries[0]' => 'apples'), 'Add grocery item'); + $this->assertFieldByName('groceries[0]', 'apples', t('A submitted field for the progressively rebuilt form was correctly set to its previously submitted value.')); + $this->assertFieldByName('groceries[1]', 'DEFAULT', t('A new field for the progressively rebuilt form was correctly set to its default value.')); + } +} Index: modules/simpletest/tests/form_test.module =================================================================== RCS file: /cvs/drupal/drupal/modules/simpletest/tests/form_test.module,v retrieving revision 1.17 diff -u -p -r1.17 form_test.module --- modules/simpletest/tests/form_test.module 21 Nov 2009 17:01:31 -0000 1.17 +++ modules/simpletest/tests/form_test.module 24 Nov 2009 16:50:44 -0000 @@ -78,6 +78,30 @@ function form_test_menu() { 'type' => MENU_CALLBACK, ); + $items['form_test/form-multistep'] = array( + 'title' => 'Form multistep test', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('form_multistep_test_form'), + 'access arguments' => array('access content'), + 'type' => MENU_CALLBACK, + ); + + $items['form_test/form-rebuild'] = array( + 'title' => 'Form rebuild test', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('form_rebuild_test_form'), + 'access arguments' => array('access content'), + 'type' => MENU_CALLBACK, + ); + + $items['form_test/form-progressive-rebuild'] = array( + 'title' => 'Form progressive rebuild test', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('form_progressive_rebuild_test_form'), + 'access arguments' => array('access content'), + 'type' => MENU_CALLBACK, + ); + return $items; } @@ -477,3 +501,188 @@ function _form_test_checkbox_submit($for drupal_json_output($form_state['values']); exit(); } + +/** + * A wizard style multistep form. + * + * Used by form.test to ensure that the 'name' field on step 2 isn't polluted + * with data from the different 'name' field from step 1. + * + * @see form_multistep_test_form_submit(). + */ +function form_multistep_test_form($form, &$form_state) { + if (!isset($form_state['storage']['step'])) { + $form_state['storage']['step'] = 1; + } + if ($form_state['storage']['step'] == 1) { + $form['name'] = array( + '#type' => 'textfield', + '#title' => 'Your name', + '#default_value' => 'DEFAULT', + '#required' => TRUE, + ); + $form['submit'] = array( + '#type' => 'submit', + '#value' => 'Continue', + ); + } + else { + $form['name'] = array( + '#type' => 'textfield', + '#title' => 'Friend\'s name', + '#default_value' => 'DEFAULT', + '#required' => TRUE, + ); + $form['submit'] = array( + '#type' => 'submit', + '#value' => 'Done', + ); + } + return $form; +} + +/** + * Submit callback for a wizard style multistep form. + */ +function form_multistep_test_form_submit($form, &$form_state) { + if ($form_state['storage']['step'] == 1) { + $form_state['storage']['name1'] = $form_state['values']['name']; + $form_state['storage']['step']++; + $form_state['rebuild'] = TRUE; + } + else { + $form_state['storage']['name2'] = $form_state['values']['name']; + drupal_set_message(t("Your name: %name1, Friend's name: %name2", array('%name1' => $form_state['storage']['name1'], '%name2' => $form_state['storage']['name2']))); + } +} + +/** + * A non wizard-style form requiring a rebuild. + * + * This example is pretty contrived, but the goal is to test something similar + * to what is tested by form_multistep_test_form(), but for a form that uses + * 'rebuild' without 'storage'. This pattern is used for delete confirmation + * forms, so we want to make sure to test it. + * + * @see form_rebuild_test_form_submit(). + */ +function form_rebuild_test_form($form, &$form_state) { + if (!isset($form_state['confirm_delete'])) { + $form['name'] = array( + '#type' => 'textfield', + '#title' => 'Your name', + '#default_value' => 'DEFAULT', + ); + $form['color'] = array( + '#type' => 'textfield', + '#title' => 'Your favorite color', + '#default_value' => 'DEFAULT', + ); + $form['delete'] = array( + '#type' => 'submit', + '#value' => 'Delete', + '#submit' => array('form_rebuild_test_form_submit_delete'), + ); + $form['submit'] = array( + '#type' => 'submit', + '#value' => 'Save', + ); + } + else { + $form['name'] = array( + '#type' => 'textfield', + '#title' => 'Your name', + '#default_value' => 'DEFAULT FOR DELETE', + '#description' => 'To delete yourself from the system, enter your name.', + '#required' => TRUE, + ); + $form['submit'] = array( + '#type' => 'submit', + '#value' => 'Yes, Really Delete', + '#submit' => array('form_rebuild_test_form_submit_delete_confirmed'), + ); + } + return $form; +} + +/** + * Submit callbacks for form_rebuild_test_form(). + */ +function form_rebuild_test_form_submit($form, &$form_state) { + drupal_set_message(t("Your name: %name, Your favorite color: %color", array('%name' => $form_state['values']['name'], '%color' => $form_state['values']['color']))); +} +function form_rebuild_test_form_submit_delete($form, &$form_state) { + $form_state['rebuild'] = TRUE; + $form_state['confirm_delete'] = TRUE; +} +function form_rebuild_test_form_submit_delete_confirmed($form, &$form_state) { + drupal_set_message(t("Ok, %name has been deleted.", array('%name' => $form_state['values']['name']))); +} + +/** + * A form requiring a rebuild with values preserved. + * + * Tests that a submit handler can set $form_state['rebuild_options']['values'] + * in order to preserve form values during a rebuild. + * + * @see form_progressive_rebuild_test_form_submit(). + */ +function form_progressive_rebuild_test_form($form, &$form_state) { + $num_grocery_items = isset($form_state['num_grocery_items']) ? $form_state['num_grocery_items'] : 1; + $num_movies = isset($form_state['num_movies']) ? $form_state['num_movies'] : 1; + $form['#tree'] = TRUE; + + $form['groceries'] = array(); + for ($i=0; $i < $num_grocery_items; $i++) { + $form['groceries'][$i] = array( + '#type' => 'textfield', + '#title' => 'Grocery item ' . ($i + 1), + '#default_value' => 'DEFAULT', + ); + } + $form['add_grocery_item'] = array( + '#type' => 'submit', + '#value' => 'Add grocery item', + '#submit' => array('form_progressive_rebuild_test_form_submit_add_item'), + '#_which_counter' => 'num_grocery_items', + ); + $form['movies'] = array(); + for ($i=0; $i < $num_movies; $i++) { + $form['movies'][$i] = array( + '#type' => 'textfield', + '#title' => 'Movie to rent ' . ($i + 1), + '#default_value' => 'DEFAULT', + ); + } + $form['add_movie'] = array( + '#type' => 'submit', + '#value' => 'Add movie', + '#submit' => array('form_progressive_rebuild_test_form_submit_add_item'), + '#_which_counter' => 'num_movies', + ); + $form['submit'] = array( + '#type' => 'submit', + '#value' => 'Submit', + ); + return $form; +} + +/** + * Form submit callback for form_progressive_rebuild_test_form(). + */ +function form_progressive_rebuild_test_form_submit_add_item($form, &$form_state) { + $form_state['rebuild'] = TRUE; + // Preserve values during the rebuild. + $form_state['storage']['values'] = $form_state['values']; + $form_state['num_grocery_items'] = count($form_state['values']['groceries']); + $form_state['num_movies'] = count($form_state['values']['movies']); + $form_state[$form_state['clicked_button']['#_which_counter']]++; +} + +/** + * Form submit callback for form_progressive_rebuild_test_form(). + */ +function form_progressive_rebuild_test_form_submit($form, &$form_state) { + drupal_set_message(t("Your grocery items: %groceries, Your movies: %movies", array('%groceries' => implode(', ', $form_state['values']['groceries']), '%movies' => implode(', ', $form_state['values']['movies'])))); +} +