diff --git a/includes/ajax.inc b/includes/ajax.inc index 49ff7e46b0f007e0efdfa040e10f6a0854054a20..e1ea518d78328b72645fa2a56adbfb80d1955a86 100644 --- a/includes/ajax.inc +++ b/includes/ajax.inc @@ -239,6 +239,12 @@ function ajax_get_form() { // Since some of the submit handlers are run, redirects need to be disabled. $form_state['no_redirect'] = TRUE; + // When a form is rebuilt after AJAX processing, its #build_id and #action + // should not change. + // @see drupal_rebuild_form() + $form_state['rebuild_info']['copy']['#build_id'] = TRUE; + $form_state['rebuild_info']['copy']['#action'] = TRUE; + // The form needs to be processed; prepare for that by setting a few internal // variables. $form_state['input'] = $_POST; @@ -263,18 +269,15 @@ function ajax_get_form() { * enhanced function. */ function ajax_form_callback() { - list($form, $form_state, $form_id, $form_build_id) = ajax_get_form(); - - // Build, validate and if possible, submit the form. - drupal_process_form($form_id, $form, $form_state); - - // This call recreates the form relying solely on the $form_state that - // drupal_process_form() set up. - $form = drupal_rebuild_form($form_id, $form_state, $form); - - // As part of drupal_process_form(), the element that triggered the form - // submission is determined, and in the case of AJAX, it might not be a - // button. This lets us route to the appropriate callback. + list($form, $form_state) = ajax_get_form(); + drupal_process_form($form['#form_id'], $form, $form_state); + + // We need to return the part of the form (or some other content) that needs + // to be re-rendered so the browser can update the page with changed content. + // Since this is the generic menu callback used by many AJAX elements, it is + // up to the #ajax['callback'] function of the element (may or may not be a + // button) that triggered the AJAX request to determine what needs to be + // rendered. if (!empty($form_state['triggering_element'])) { $callback = $form_state['triggering_element']['#ajax']['callback']; } diff --git a/includes/batch.inc b/includes/batch.inc index 7fcc915668cf2a8b8c309e5d2bad4ed0fba52883..d847646bf36ad561793529006ab0da1c6d779f3e 100644 --- a/includes/batch.inc +++ b/includes/batch.inc @@ -495,9 +495,12 @@ function _batch_finished() { // Use drupal_redirect_form() to handle the redirection logic. drupal_redirect_form($_batch['form_state']); - // If no redirection happened, save the final $form_state value to be - // retrieved by drupal_get_form() and redirect to the originating page. - $_SESSION['batch_form_state'] = $_batch['form_state']; + // If no redirection happened, redirect to the originating page. In case the + // form needs to be rebuilt, save the final $form_state for + // drupal_build_form(). + if (!empty($_batch['form_state']['rebuild'])) { + $_SESSION['batch_form_state'] = $_batch['form_state']; + } $function = $_batch['redirect_callback']; if (function_exists($function)) { $function($_batch['source_url'], array('query' => array('op' => 'finish', 'id' => $_batch['id']))); diff --git a/includes/form.inc b/includes/form.inc index e2909c64bac11f8e83b51e30a4d6afb50035331e..0c50e190b1da0653c3e3eacc74e95987bf141a12 100644 --- a/includes/form.inc +++ b/includes/form.inc @@ -275,153 +275,79 @@ function drupal_build_form($form_id, &$form_state) { } if (isset($_SESSION['batch_form_state'])) { - // We've been redirected here after a batch processing : the form has - // already been processed, so we grab the post-process $form_state value - // and move on to form display. See _batch_finished() function. + // We've been redirected here after a batch processing. The form has + // already been processed, but needs to be rebuilt. See _batch_finished(). $form_state = $_SESSION['batch_form_state']; unset($_SESSION['batch_form_state']); - } - else { - // If the incoming input contains a form_build_id, we'll check the - // cache for a copy of the form in question. If it's there, we don't - // have to rebuild the form to proceed. In addition, if there is stored - // form_state data from a previous step, we'll retrieve it so it can - // be passed on to the form processing code. - $check_cache = isset($form_state['input']['form_id']) && $form_state['input']['form_id'] == $form_id && !empty($form_state['input']['form_build_id']); + return drupal_rebuild_form($form_id, $form_state); + } + + // If the incoming input contains a form_build_id, we'll check the cache for a + // copy of the form in question. If it's there, we don't have to rebuild the + // form to proceed. In addition, if there is stored form_state data from a + // previous step, we'll retrieve it so it can be passed on to the form + // processing code. + $check_cache = isset($form_state['input']['form_id']) && $form_state['input']['form_id'] == $form_id && !empty($form_state['input']['form_build_id']); + if ($check_cache) { + $form = form_get_cache($form_state['input']['form_build_id'], $form_state); + } + + // If the previous bit of code didn't result in a populated $form object, we + // are hitting the form for the first time and we need to build it from + // scratch. + if (!isset($form)) { + // If we attempted to serve the form from cache, uncacheable $form_state + // keys need to be removed after retrieving and preparing the form, except + // any that were already set prior to retrieving the form. if ($check_cache) { - $form_build_id = $form_state['input']['form_build_id']; - $form = form_get_cache($form_build_id, $form_state); - } - - // If the previous bit of code didn't result in a populated $form - // object, we're hitting the form for the first time and we need - // to build it from scratch. - if (!isset($form)) { - // Record the filepath of the include file containing the original form, - // so the form builder callbacks can be loaded when the form is being - // rebuilt from cache on a different path (such as 'system/ajax'). See - // form_get_cache(). - // $menu_get_item() is not available at installation time. - if (!isset($form_state['build_info']['files']['menu']) && !defined('MAINTENANCE_MODE')) { - $item = menu_get_item(); - if (!empty($item['include_file'])) { - $form_state['build_info']['files']['menu'] = $item['include_file']; - } - } - - // If we attempted to serve the form from cache, uncacheable $form_state - // keys need to be removed after retrieving and preparing the form, except - // any that were already set prior to retrieving the form. - if ($check_cache) { - $form_state_before_retrieval = $form_state; - } - - $form = drupal_retrieve_form($form_id, $form_state); - $form_build_id = 'form-' . drupal_hash_base64(uniqid(mt_rand(), TRUE) . mt_rand()); - $form['#build_id'] = $form_build_id; - - // Fix the form method, if it is 'get' in $form_state, but not in $form. - if ($form_state['method'] == 'get' && !isset($form['#method'])) { - $form['#method'] = 'get'; - } - - drupal_prepare_form($form_id, $form, $form_state); - // Store a copy of the unprocessed form to cache in case - // $form_state['cache'] is set. - $original_form = $form; - - // form_set_cache() removes uncacheable $form_state keys defined in - // form_state_keys_no_cache() in order for multi-step forms to work - // properly. This means that form processing logic for single-step forms - // using $form_state['cache'] may depend on data stored in those keys - // during drupal_retrieve_form()/drupal_prepare_form(), but form - // processing should not depend on whether the form is cached or not, so - // $form_state is adjusted to match what it would be after a - // form_set_cache()/form_get_cache() sequence. These exceptions are - // allowed to survive here: - // - always_process: Does not make sense in conjunction with form caching - // in the first place, since passing form_build_id as a GET parameter is - // not desired. - // - temporary: Any assigned data is expected to survives within the same - // page request. - if ($check_cache) { - $form_state = array_diff_key($form_state, array_flip(array_diff(form_state_keys_no_cache(), array('always_process', 'temporary')))) + $form_state_before_retrieval; - } - } - - // Now that we know we have a form, we'll process it (validating, - // submitting, and handling the results returned by its submission - // handlers. Submit handlers accumulate data in the form_state by - // altering the $form_state variable, which is passed into them by - // reference. - drupal_process_form($form_id, $form, $form_state); - } - - // Most simple, single-step forms will be finished by this point -- - // drupal_process_form() usually redirects to another page (or to - // a 'fresh' copy of the form) once processing is complete. If one - // of the form's handlers has set $form_state['redirect'] to FALSE, - // the form will simply be re-rendered with the values still in its - // fields. - // - // If $form_state['rebuild'] has been set and input has been processed, we - // know that we're in a multi-part process of some sort and the form's - // workflow is not complete. We need to construct a fresh copy of the form, - // passing in the latest $form_state in addition to any other variables passed - // into drupal_get_form(). - if ($form_state['rebuild'] && $form_state['process_input'] && !form_get_errors()) { - $form = drupal_rebuild_form($form_id, $form_state); - } - // After processing the form, the form builder or a #process callback may - // have set $form_state['cache'] to indicate that the original form and the - // $form_state shall be cached. But the form may only be cached if the - // special 'no_cache' property is not set to TRUE and we are not rebuilding. - elseif (isset($form_build_id) && $form_state['cache'] && empty($form_state['no_cache'])) { - // Cache the original, unprocessed form upon initial build of the form. - if (isset($original_form)) { - form_set_cache($form_build_id, $original_form, $form_state); - } - // After processing a cached form, only update the cached form state. - else { - form_set_cache($form_build_id, NULL, $form_state); - } - } - - // Check theme functions for this form. - // theme_form() itself is in #theme_wrappers and not #theme. Therefore, the - // #theme function only has to care for rendering the inner form elements, - // not the form itself. - drupal_theme_initialize(); - $registry = theme_get_registry(); - // If #theme has been set, check whether the theme function(s) exist, or - // remove the suggestion(s), so drupal_render() renders the children. - if (isset($form['#theme'])) { - if (is_array($form['#theme'])) { - foreach ($form['#theme'] as $key => $suggestion) { - if (!isset($registry[$suggestion])) { - unset($form['#theme'][$key]); - } - } - if (empty($form['#theme'])) { - unset($form['#theme']); - } - } - else { - if (!isset($registry[$form['#theme']])) { - unset($form['#theme']); - } - } - } - // Only try to auto-suggest theme functions, if #theme has not been set. - else { - if (isset($registry[$form_id])) { - $form['#theme'] = $form_id; - } - elseif (isset($form_state['build_info']['base_form_id']) && isset($registry[$form_state['build_info']['base_form_id']])) { - $form['#theme'] = $form_state['build_info']['base_form_id']; - } - } + $form_state_before_retrieval = $form_state; + } + + $form = drupal_retrieve_form($form_id, $form_state); + drupal_prepare_form($form_id, $form, $form_state); + + // form_set_cache() removes uncacheable $form_state keys defined in + // form_state_keys_no_cache() in order for multi-step forms to work + // properly. This means that form processing logic for single-step forms + // using $form_state['cache'] may depend on data stored in those keys + // during drupal_retrieve_form()/drupal_prepare_form(), but form + // processing should not depend on whether the form is cached or not, so + // $form_state is adjusted to match what it would be after a + // form_set_cache()/form_get_cache() sequence. These exceptions are + // allowed to survive here: + // - always_process: Does not make sense in conjunction with form caching + // in the first place, since passing form_build_id as a GET parameter is + // not desired. + // - temporary: Any assigned data is expected to survives within the same + // page request. + if ($check_cache) { + $uncacheable_keys = array_flip(array_diff(form_state_keys_no_cache(), array('always_process', 'temporary'))); + $form_state = array_diff_key($form_state, $uncacheable_keys); + $form_state += $form_state_before_retrieval; + } + } + + // Now that we have a constructed form, process it. This is where: + // - Element #process functions get called to further refine $form. + // - User input, if any, gets incorporated in the #value property of the + // corresponding elements and into $form_state['values']. + // - Validation and submission handlers are called. + // - If this submission is part of a multistep workflow, the form is rebuilt + // to contain the information of the next step. + // - If necessary, the form and form state are cached or re-cached, so that + // appropriate information persists to the next page request. + // All of the handlers in the pipeline receive $form_state by reference and + // can use it to know or update information about the state of the form. + drupal_process_form($form_id, $form, $form_state); + // If this was a successful submission of a single-step form or the last step + // of a multi-step form, then drupal_process_form() issued a redirect to + // another page, or back to this page, but as a new request. Therefore, if + // we're here, it means that this is either a form being viewed initially + // before any user input, or there was a validation error requiring the form + // to be re-displayed, or we're in a multi-step workflow and need to display + // the form's next step. In any case, we have what we need in $form, and can + // return it for rendering. return $form; } @@ -431,6 +357,7 @@ function drupal_build_form($form_id, &$form_state) { function form_state_defaults() { return array( 'rebuild' => FALSE, + 'rebuild_info' => array(), 'redirect' => NULL, 'build_info' => array('args' => array()), 'temporary' => array(), @@ -444,16 +371,19 @@ function form_state_defaults() { } /** - * Retrieves a form, caches it and processes it again. + * Constructs a new $form from the information in $form_state. + * + * This is the key function for making multi-step forms advance from step to + * step. It is called by drupal_process_form() when all user input processing, + * including calling validation and submission handlers, for the request is + * finished. If a validate or submit handler set $form_state['rebuild'] to TRUE, + * and if other conditions don't preempt a rebuild from happening, then this + * function is called to generate a new $form, the next step in the form + * workflow, to be returned for rendering. * - * If your AJAX callback simulates the pressing of a button, then your AJAX - * callback will need to do the same as what drupal_get_form() would do when the - * button is pressed: get the form from the cache, run drupal_process_form over - * it and then if it needs rebuild, run drupal_rebuild_form() over it. Then send - * back a part of the returned form. - * $form_state['triggering_element']['#array_parents'] will help you to find - * which part. - * @see ajax_form_callback() for an example. + * AJAX form submissions are almost always multi-step workflows, so that is one + * common use-case during which form rebuilding occurs. See ajax_form_callback() + * for more information about creating AJAX-enabled forms. * * @param $form_id * The unique string identifying the desired form. If a function @@ -466,21 +396,20 @@ function form_state_defaults() { * A keyed array containing the current state of the form. * @param $old_form * (optional) A previously built $form. Used to retain the #build_id and - * #action properties in AJAX callbacks and similar partial form rebuilds. - * Should not be passed for regular rebuilds, for which the entire $form - * should be rebuilt freshly. + * #action properties in AJAX callbacks and similar partial form rebuilds. The + * only properties copied from $old_form are the ones which both exist in + * $old_form and for which $form_state['rebuild_info']['copy'][PROPERTY] is + * TRUE. If $old_form is not passed, the entire $form is rebuilt freshly. + * 'rebuild_info' needs to be a separate top-level property next to + * 'build_info', since the contained data must not be cached. * * @return * The newly built form. + * + * @see drupal_process_form() + * @see ajax_form_callback() */ function drupal_rebuild_form($form_id, &$form_state, $old_form = NULL) { - // AJAX and other contexts may call drupal_rebuild_form() even when - // $form_state['rebuild'] isn't set, but _form_builder_handle_input_element() - // needs to distinguish a rebuild from an initial build in order to process - // user input correctly. Form constructors and form processing functions may - // also need to handle a rebuild differently than an initial build. - $form_state['rebuild'] = TRUE; - $form = drupal_retrieve_form($form_id, $form_state); // If only parts of the form will be returned to the browser (e.g. AJAX or @@ -489,20 +418,28 @@ function drupal_rebuild_form($form_id, &$form_state, $old_form = NULL) { // Otherwise, a new #build_id is generated, to not clobber the previous // build's data in the form cache; also allowing the user to go back to an // earlier build, make changes, and re-submit. - $form['#build_id'] = isset($old_form['#build_id']) ? $old_form['#build_id'] : 'form-' . drupal_hash_base64(uniqid(mt_rand(), TRUE) . mt_rand()); + // @see drupal_prepare_form() + if (isset($old_form['#build_id']) && !empty($form_state['rebuild_info']['copy']['#build_id'])) { + $form['#build_id'] = $old_form['#build_id']; + } + else { + $form['#build_id'] = 'form-' . drupal_hash_base64(uniqid(mt_rand(), TRUE) . mt_rand()); + } // #action defaults to request_uri(), but in case of AJAX and other partial // rebuilds, the form is submitted to an alternate URL, and the original // #action needs to be retained. - if (isset($old_form['#action'])) { + if (isset($old_form['#action']) && !empty($form_state['rebuild_info']['copy']['#action'])) { $form['#action'] = $old_form['#action']; } drupal_prepare_form($form_id, $form, $form_state); + // Caching is normally done in drupal_process_form(), but what needs to be + // cached is the $form structure before it passes through form_builder(), + // so we need to do it here. + // @todo For Drupal 8, find a way to avoid this code duplication. if (empty($form_state['no_cache'])) { - // We cache the form structure and the form state so it can be retrieved - // later for validation. form_set_cache($form['#build_id'], $form, $form_state); } @@ -510,10 +447,8 @@ function drupal_rebuild_form($form_id, &$form_state, $old_form = NULL) { // re-rendering the form. $form_state['groups'] = array(); - // Do not call drupal_process_form(), since it would prevent the rebuilt form - // to submit. - $form = form_builder($form_id, $form, $form_state); - return $form; + // Return a fully built form that is ready for rendering. + return form_builder($form_id, $form, $form_state); } /** @@ -577,6 +512,7 @@ function form_state_keys_no_cache() { 'always_process', 'must_validate', 'rebuild', + 'rebuild_info', 'redirect', 'no_redirect', 'temporary', @@ -691,6 +627,18 @@ function drupal_form_submit($form_id, &$form_state) { function drupal_retrieve_form($form_id, &$form_state) { $forms = &drupal_static(__FUNCTION__); + // Record the filepath of the include file containing the original form, so + // the form builder callbacks can be loaded when the form is being rebuilt + // from cache on a different path (such as 'system/ajax'). See + // form_get_cache(). + // $menu_get_item() is not available at installation time. + if (!isset($form_state['build_info']['files']['menu']) && !defined('MAINTENANCE_MODE')) { + $item = menu_get_item(); + if (!empty($item['include_file'])) { + $form_state['build_info']['files']['menu'] = $item['include_file']; + } + } + // We save two copies of the incoming arguments: one for modules to use // when mapping form ids to constructor functions, and another to pass to // the constructor function itself. @@ -758,7 +706,7 @@ function drupal_retrieve_form($form_id, &$form_state) { * Processes a form submission. * * This function is the heart of form API. The form gets built, validated and in - * appropriate cases, submitted. + * appropriate cases, submitted and rebuilt. * * @param $form_id * The unique string identifying the current form. @@ -787,7 +735,11 @@ function drupal_process_form($form_id, &$form, &$form_state) { } } - // Build the form. + // form_builder() finishes building the form by calling element #process + // functions and mapping user input, if any, to #value properties, and also + // storing the values in $form_state['values']. We need to retain the + // unprocessed $form in case it needs to be cached. + $unprocessed_form = $form; $form = form_builder($form_id, $form, $form_state); // Only process the input if we have a correct form submission. @@ -847,6 +799,29 @@ function drupal_process_form($form_id, &$form, &$form_state) { // Redirect the form based on values in $form_state. drupal_redirect_form($form_state); } + + // Don't rebuild or cache form submissions invoked via drupal_form_submit(). + if (!empty($form_state['programmed'])) { + return; + } + } + + // If $form_state['rebuild'] has been set and input has been processed without + // validation errors, we're in a multi-step workflow that is not yet complete. + // We need to construct a new $form based on the changes made to $form_state + // during this request. + if ($form_state['rebuild'] && $form_state['process_input'] && !form_get_errors()) { + $form = drupal_rebuild_form($form_id, $form_state, $form); + } + // After processing the form, the form builder or a #process callback may + // have set $form_state['cache'] to indicate that the form and form state + // shall be cached. But the form may only be cached if the 'no_cache' property + // is not set to TRUE. Only cache $form as it was prior to form_builder(), + // because form_builder() must run for each request to accomodate new user + // input. We do not cache here for forms that have been rebuilt, because + // drupal_rebuild_form() takes care of that. + elseif ($form_state['cache'] && empty($form_state['no_cache'])) { + form_set_cache($form['#build_id'], $unprocessed_form, $form_state); } } @@ -870,15 +845,27 @@ function drupal_prepare_form($form_id, &$form, &$form_state) { $form['#type'] = 'form'; $form_state['programmed'] = isset($form_state['programmed']) ? $form_state['programmed'] : FALSE; - if (isset($form['#build_id'])) { - $form['form_build_id'] = array( - '#type' => 'hidden', - '#value' => $form['#build_id'], - '#id' => $form['#build_id'], - '#name' => 'form_build_id', - ); + // Fix the form method, if it is 'get' in $form_state, but not in $form. + if ($form_state['method'] == 'get' && !isset($form['#method'])) { + $form['#method'] = 'get'; } + // Generate a new #build_id for this form, if none has been set already. The + // form_build_id is used as key to cache a particular build of the form. For + // multi-step forms, this allows the user to go back to an earlier build, make + // changes, and re-submit. + // @see drupal_build_form() + // @see drupal_rebuild_form() + if (!isset($form['#build_id'])) { + $form['#build_id'] = 'form-' . drupal_hash_base64(uniqid(mt_rand(), TRUE) . mt_rand()); + } + $form['form_build_id'] = array( + '#type' => 'hidden', + '#value' => $form['#build_id'], + '#id' => $form['#build_id'], + '#name' => 'form_build_id', + ); + // Add a token, based on either #token or form_id, to any form displayed to // authenticated users. This ensures that any submitted form was actually // requested previously by the user and protects against cross site request @@ -942,6 +929,41 @@ function drupal_prepare_form($form_id, &$form, &$form_state) { } } + // Check theme functions for this form. + // theme_form() itself is in #theme_wrappers and not #theme. Therefore, the + // #theme function only has to care for rendering the inner form elements, + // not the form itself. + drupal_theme_initialize(); + $registry = theme_get_registry(); + // If #theme has been set, check whether the theme function(s) exist, or + // remove the suggestion(s), so drupal_render() renders the children. + if (isset($form['#theme'])) { + if (is_array($form['#theme'])) { + foreach ($form['#theme'] as $key => $suggestion) { + if (!isset($registry[$suggestion])) { + unset($form['#theme'][$key]); + } + } + if (empty($form['#theme'])) { + unset($form['#theme']); + } + } + else { + if (!isset($registry[$form['#theme']])) { + unset($form['#theme']); + } + } + } + // Only try to auto-suggest theme functions, if #theme has not been set. + else { + if (isset($registry[$form_id])) { + $form['#theme'] = $form_id; + } + elseif (isset($form_state['build_info']['base_form_id']) && isset($registry[$form_state['build_info']['base_form_id']])) { + $form['#theme'] = $form_state['build_info']['base_form_id']; + } + } + // Invoke hook_form_alter(), hook_form_BASE_FORM_ID_alter(), and // hook_form_FORM_ID_alter() implementations. $hooks = array('form'); diff --git a/modules/file/file.module b/modules/file/file.module index 1388f179be84b8a8612e8ecc6917ff5cd4893d96..5d76b6c82e02b339a134e79b2558a6ca95fb89fd 100644 --- a/modules/file/file.module +++ b/modules/file/file.module @@ -254,7 +254,7 @@ function file_ajax_upload() { return array('#type' => 'ajax', '#commands' => $commands, '#header' => FALSE); } - list($form, $form_state, $form_id, $form_build_id) = ajax_get_form(); + list($form, $form_state) = ajax_get_form(); if (!$form) { // Invalid form_build_id. @@ -271,12 +271,8 @@ function file_ajax_upload() { } $current_file_count = isset($current_element['#file_upload_delta']) ? $current_element['#file_upload_delta'] : 0; - // Build, validate and if possible, submit the form. - drupal_process_form($form_id, $form, $form_state); - - // This call recreates the form relying solely on the form_state that the - // drupal_process_form() set up. - $form = drupal_rebuild_form($form_id, $form_state, $form); + // Process user input. $form and $form_state are modified in the process. + drupal_process_form($form['#form_id'], $form, $form_state); // Retrieve the element to be rendered. foreach ($form_parents as $parent) { diff --git a/modules/simpletest/tests/form.test b/modules/simpletest/tests/form.test index d628f488b281c1b1d57c0a47061a9bdd181a9c93..ce4bc9681f932b490f1029b5b90326f00ba983a7 100644 --- a/modules/simpletest/tests/form.test +++ b/modules/simpletest/tests/form.test @@ -1011,6 +1011,10 @@ class FormsRebuildTestCase extends DrupalWebTestCase { $this->drupalPost(NULL, array(), t('Save')); $this->assertText('Title field is required.', t('Non-AJAX submission correctly triggered a validation error.')); + // Ensure that the form contains two items in the multi-valued field, so we + // know we're testing a form that was correctly retrieved from cache. + $this->assert(count($this->xpath('//form[contains(@id, "page-node-form")]//div[contains(@class, "form-item-field-ajax-test")]//input[@type="text"]')) == 2, t('Form retained its state from cache.')); + // Ensure that the form's action is correct. $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.'));