diff --git a/multiform.ajax.inc b/multiform.ajax.inc new file mode 100644 index 0000000..b2d5775 --- /dev/null +++ b/multiform.ajax.inc @@ -0,0 +1,81 @@ + $_POST['multiform'], + 'form_id' => $_POST['form_id'], + 'form_build_id' => $_POST['form_build_id'], + ); + foreach ($multiform as $key => $value) { + unset($_POST[$key]); + } + // Define triggering element's subform index. + $multiform_index = isset($_POST['_multiform_index']) ? $_POST['_multiform_index'] : FALSE; + if (!$multiform_index) { + return; + } + $_POST += $multiform['multiform'][$multiform_index]; + + // This part is based on ajax_form_callback(). + list($form, $form_state) = ajax_get_form(); + drupal_process_form($form['#form_id'], $form, $form_state); + + $multiform = array(); + multiform_process_form_state('restore', $form_state); + _multiform_delayed_submit_execute($form, $form_state, $form['#form_id'], $multiform); + + if (!empty($form_state['triggering_element'])) { + $callback = $form_state['triggering_element']['#ajax']['callback']; + } + if (!empty($callback) && function_exists($callback)) { + return $callback($form, $form_state); + } +} + +/** + * Override element's ajax settings to add multiform support. + */ +function multiform_ajax_process_form($element, &$form_state) { + // Get standard settings. + $element = ajax_process_form($element, $form_state); + // Override alax callback path and add _multiform_index. + if (isset($element['#attached']['js'])) { + // Replace ajax url with multiform specific. + foreach ($element['#attached']['js'] as $key => $setting) { + // Invoke hook_multiform_ajax_alter() implementations. + foreach (module_implements('multiform_ajax_alter') as $module) { + $function = $module . '_multiform_ajax_alter'; + $function($element, $key, $form_state); + } + } + } + return $element; +} + +/** + * Implements hook_multiform_ajax_alter(). + */ +function multiform_multiform_ajax_alter(&$element, $key, $form_state) { + $setting = $element['#attached']['js'][$key]; + if (isset($setting['data']['ajax'][$element['#id']]['url']) && $setting['data']['ajax'][$element['#id']]['url'] == '/system/ajax') { + $element['#attached']['js'][$key]['data']['ajax'][$element['#id']]['url'] = '/multiform/ajax'; + // Add multiform_index in order to define triggering element's subform when triggered. + if (isset($setting['data']['ajax'][$element['#id']]['submit']) && is_array($setting['data']['ajax'][$element['#id']]['submit'])) { + $multiform_index = $form_state['multiform_index']; + $element['#attached']['js'][$key]['data']['ajax'][$element['#id']]['submit']['_multiform_index'] = $multiform_index; + } + } +} diff --git a/multiform.inc b/multiform.inc new file mode 100644 index 0000000..2905f5b --- /dev/null +++ b/multiform.inc @@ -0,0 +1,326 @@ + 0 && is_array($all_args[$count-1])) { + $multiform_candidate = $all_args[$count-1]; + foreach ($multiform_candidate as $key => $value) { + // Check for settings keys. Settings keys start with # sign. + if (strpos($key, '#') === 0) { + // Remove multiform settings. + $multiform = array_pop($all_args); + break; + } + } + unset($key); + } + if (!isset($multiform['#subforms'])) { + $multiform['#subforms'] = array(); + } + // Move all subforms into $multiform + $subforms = array(); + $max_key = 0; + foreach ($all_args as $key => $args) { + $form_id = array_shift($args); + $subforms[$form_id . '_' . $key] = array( + 'key' => $key, + 'form_id' => $form_id, + 'args' => $args, + '#weight' => $key, + ); + } + $next_key = isset($key) ? $key+1 : 0; + // Check for additional subforms in $multiform. + if (!empty($multiform['#subforms'])) { + foreach ($multiform['#subforms'] as $subform) { + $index = $subform['form_id'] . '_' . $next_key; + $subform['key'] = $next_key; + $subform['#weight'] = $next_key; + $subform['args'] = !empty($subform['args']) ? $subform['args'] : array(); + $subforms[$index] = $subform; + $next_key++; + } + } + $multiform['#subforms'] = $subforms; + + // Set settings' defaults. + // TODO: work out redirection functionality. + $defaults = array( + '#redirect' => FALSE, // For internal use only + '#redirect_array' => array(), // For internal use only (by now) + '#multiform_id' => DEFAULT_MULTIFORM_ID, + '#submitted' => FALSE, + ); + foreach ($defaults as $key => $value) { + if (!isset($multiform[$key])) $multiform[$key] = $value; + } + + return $multiform; +} + +/** + * Change and Restore some $form_state values in order to avoid submit and other operations + * that follow validation in drupal_process_form(). This allows to perform all subforms validation + * before any submit is fired. Afterwards, restore form_state to pass into further workflow. + * + * $op = {'modify', 'restore'} + */ +// TODO: Check over $new_values array(). +function multiform_process_form_state($op, &$form_state) { + switch ($op) { + // Used in auxilary validation to prevent submit handlers execution. + case 'modify' : + $changed = array(); + // Force caching after validation + // since we need $unprocessed_form for _multiform_execute_delayed_submit(). + // But prevent other operations' execution. + $new_values = array( + 'submitted' => FALSE, + 'executed' => TRUE, + 'rebuild' => FALSE, + 'cache' => TRUE, + 'no_cache' => 'unset', + ); + foreach ($new_values as $key => $value) { + $changed[$key] = isset($form_state[$key]) ? $form_state[$key] : 'unset'; + if ($value !== 'unset') { + $form_state[$key] = $value; + } + else { + unset($form_state[$key]); + } + } + // Changed values will be restored after validation. + $form_state['#restore_values'] = $changed; + break; + case 'restore' : + if (isset($form_state['#restore_values'])) { + foreach ($form_state['#restore_values'] as $key => $value) { + if ($value !== 'unset') { + $form_state[$key] = $value; + } + else { + unset($form_state[$key]); + } + } + // Keep $form_state clean as possible. + unset($form_state['#restore_values']); + } + break; + } +} + +/** + * Restore actual $form_state data + * and cache $form and $form_state for further delayed submit execution. + * + */ +function multiform_delayed_submit_prepare(&$multiform, $current_form, &$form_state, $index) { + $index = $form_state['multiform_index']; + // IF #restore_values isn't set, it means that there is no delayed validation + // or validation hasn't worked out, i.e. form is built for rendering. + if (isset($form_state['#restore_values'])) { + // form_state values of any of the forms could only be modified if form was submitted. + $multiform['#submitted'] = TRUE; + // Restore $form_state['submitted'] (and possibly other values) from form_state['#restore_values']. + // See multiform_delay_form_submitting_validate(). + multiform_process_form_state('restore', $form_state); + $multiform['#subforms'][$index]['form'] = $current_form; + $multiform['#subforms'][$index]['form_state'] = $form_state; + } +} + +// TODO: Add capability to alter redirect behavior via hook_multiform_alter(). + +/** + * Invoke hook_multiform_alter() implementations and execute forms' handlers. + * + */ +function multiform_delayed_submit_execute(&$multiform) { + // Only execute when form was submitted. + if ($multiform['#submitted']) { + // Invoke hook_multiform_alter(&$multiform). + // It can be used to change exectution order or to pass results of one form processing + // into another form via changing $multiform['#subforms'][$index]['form_state']. + foreach (module_implements('multiform_alter') as $module) { + $function = $module . '_multiform_alter'; + $function($multiform); + } + + // Sort by #weight to change forms' execution order. + uasort($multiform['#subforms'], 'element_sort'); + + // Perform form submitting. This is central place where processing takes place. + $subforms = $multiform['#subforms']; + foreach ($subforms as $index => $data) { + $hooks = array( + '#before_execute' => isset($multiform['#subforms'][$index]['#before_execute']) + ? $multiform['#subforms'][$index]['#before_execute'] + : array(), + '#after_execute' => isset($multiform['#subforms'][$index]['#after_execute']) + ? $multiform['#subforms'][$index]['#after_execute'] + : array(), + ); + // Invoke #before_execute functions for each form before it is submitted. + // We apply hooks to initial multiform_settings array, so changes could be used further. + foreach ($hooks['#before_execute'] as $function) { + $function($multiform, $index); + } + + // We use here multiform_settings['#subforms'][$index] instead of $data + // because it could be changed in hooks (in case hooks use it by reference). + $subform_data = $multiform['#subforms'][$index]; + $form = $subform_data['form']; + $form_state = $subform_data['form_state']; + $form_id = $subform_data['form_id']; + + // Execute submit handlers. + _multiform_delayed_submit_execute($form, $form_state, $form_id, $multiform); + // Store submit results in order to use them in #after_execute hooks. + // Usually only $form_state is changed in submit handelers. + $multiform['#subforms'][$index]['form'] = $form; + $multiform['#subforms'][$index]['form_state'] = $form_state; + + // Invoke #after_execute functions for each form before it is submitted. + if ($form_state['executed'] == TRUE) { + foreach ($hooks['#after_execute'] as $function) { + $function($multiform, $index); + } + } + } + } +} + +// TODO: Remove cached form if needed (see validation). + +/** + * This is a copy of part of the code from drupal_process_form(), + * needed to execute delayed submit. + */ +function _multiform_delayed_submit_execute(&$form, &$form_state, $form_id, &$multiform) { + // Cache must exist since we forced it. + $unprocessed_form = cache_get('form_' . $form['#build_id'], 'cache_form'); + $unprocessed_form = $unprocessed_form->data; + // TODO: Maybe cache should be removed. + /* + cache_clear_all('form_' . $form['#build_id'], 'cache_form'); + cache_clear_all('form_state_' . $form['#build_id'], 'cache_form'); + */ + + if ($form_state['submitted'] && !form_get_errors() && !$form_state['rebuild']) { + // Execute form submit handlers. + form_execute_handlers('submit', $form, $form_state); + + // We'll clear out the cached copies of the form and its stored data + // here, as we've finished with them. The in-memory copies are still + // here, though. + if (!variable_get('cache', 0) && !empty($form_state['values']['form_build_id'])) { + cache_clear_all('form_' . $form_state['values']['form_build_id'], 'cache_form'); + cache_clear_all('form_state_' . $form_state['values']['form_build_id'], 'cache_form'); + } + + // If batches were set in the submit handlers, we process them now, + // possibly ending execution. We make sure we do not react to the batch + // that is already being processed (if a batch operation performs a + // drupal_form_submit). + if ($batch = & batch_get() && !isset($batch['current_set'])) { + // Store $form_state information in the batch definition. + // We need the full $form_state when either: + // - Some submit handlers were saved to be called during batch + // processing. See form_execute_handlers(). + // - The form is multistep. + // In other cases, we only need the information expected by + // drupal_redirect_form(). + if ($batch['has_form_submits'] || !empty($form_state['rebuild'])) { + $batch['form_state'] = $form_state; + } + else { + $batch['form_state'] = array_intersect_key($form_state, array_flip(array('programmed', 'rebuild', 'storage', 'no_redirect', 'redirect'))); + } + + $batch['progressive'] = !$form_state['programmed']; + batch_process(); + + // Execution continues only for programmatic forms. + // For 'regular' forms, we get redirected to the batch processing + // page. Form redirection will be handled in _batch_finished(), + // after the batch is processed. + } + + // Set a flag to indicate the the form has been processed and executed. + $form_state['executed'] = TRUE; + + // Redirect the form based on values in $form_state. + // drupal_redirect_form($form_state); + $multiform['#redirect'] = TRUE; + } + + // 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 are in a multi-step workflow that is not + // yet complete. A new $form needs to be constructed based on the changes + // made to $form_state during this request. Normally, a submit handler sets + // $form_state['rebuild'] if a fully executed form requires another step. + // However, for forms that have not been fully executed (e.g., Ajax + // submissions triggered by non-buttons), there is no submit handler to set + // $form_state['rebuild']. It would not make sense to redisplay the + // identical form without an error for the user to correct, so we also + // rebuild error-free non-executed forms, regardless of + // $form_state['rebuild']. + // @todo D8: Simplify this logic; considering Ajax and non-HTML front-ends, +// along with element-level #submit properties, it makes no sense to have +// divergent form execution based on whether the triggering element has +// #executes_submit_callback set to TRUE. + if (($form_state['rebuild'] || !$form_state['executed']) && !form_get_errors()) { + // Form building functions (e.g., _form_builder_handle_input_element()) + // may use $form_state['rebuild'] to determine if they are running in the + // context of a rebuild, so ensure it is set. + $form_state['rebuild'] = TRUE; + $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 accommodate new user + // input. Rebuilt forms are not cached here, because drupal_rebuild_form() + // already takes care of that. + if (!$form_state['rebuild'] && $form_state['cache'] && empty($form_state['no_cache'])) { + form_set_cache($form['#build_id'], $unprocessed_form, $form_state); + } +} + +/** + * Return subform index by tag + */ +function multiform_get_index_by_tag($multiform, $tag, $multiple = FALSE) { + $indices = array(); + foreach ($multiform['#subforms'] as $index => $subform) { + if (isset($subform['#tag']) && $subform['#tag'] == $tag) { + $indices[] = $index; + } + } + if (empty($indices)) { + return FALSE; + } + else { + return !$multiple ? $indices[0] : $indices; + } +} diff --git a/multiform.module b/multiform.module index 3f8a9db..c76a185 100644 --- a/multiform.module +++ b/multiform.module @@ -1,6 +1,27 @@ . + * + */ + +define('DEFAULT_MULTIFORM_ID', 'multiform'); + +function multiform_menu() { + $items['multiform/ajax'] = array( + 'title' => 'AHAH callback', + 'page callback' => 'multiform_ajax_form_callback', + 'delivery callback' => 'ajax_deliver', + 'access callback' => TRUE, + 'theme callback' => 'ajax_base_page_theme', + 'type' => MENU_CALLBACK, + 'file' => 'multiform.ajax.inc', + ); + return $items; +} + +/** * Returns a form containing a number of other forms. * * When the forms are submitted their first button is pressed in array order. @@ -15,8 +36,17 @@ * multiform_get_form(array($form_id1, $arg1, $arg2), array($form_id2, $arg3, $arg4)). */ function multiform_get_form() { + module_load_include('inc', 'multiform'); + + // Trigger multiform_element_info_alter(). + // Replace ajax_process_form() with multiform specific multiform_ajax_process_form(). + // We need it here for the case when subform is retrieved from cache and multiform_form_alter() isn't fired. + is_multiform_page(TRUE); + $all_args = func_get_args(); - $redirect = NULL; + // Prepare $multiform array + $multiform = multiform_init_multiform_settings($all_args); + $form = element_info('form'); $form['#id'] = 'multiform'; $form['#attributes'] = array(); @@ -61,13 +91,19 @@ function multiform_get_form() { } } } - foreach ($all_args as $key => $args) { - $form_id = array_shift($args); + // Get all subforms + $subforms = $multiform['#subforms']; + foreach ($subforms as $index => $subform) { + $key = $subform['key']; + $args = $subform['args']; + $form_id = $subform['form_id']; // Reset $form_state and disable redirection. $form_state = array('no_redirect' => TRUE); + // Add multiform ID. It is used in hook_form_alter() to detect subforms. + $form_state['multiform_id'] = $multiform['#multiform_id']; + $form_state['multiform_index'] = $index; // This line is copied literally from drupal_get_form(). $form_state['build_info']['args'] = $args; - $index = $form_id . '_' . $key; if (isset($form_state_save['input']['multiform'][$index])) { // drupal_build_form() honors our $form_state['input'] setup. $form_state['input'] = $form_state_save['input']['multiform'][$index]; @@ -87,8 +123,14 @@ function multiform_get_form() { } } } + // Build and process this form. + // Auxilary validation is called. $form_state['#restore_values'] added. $current_form = drupal_build_form($form_id, $form_state); + // At this point auxilary validation has already fired (if set). + // Prepare data for delayed submit if needed. + multiform_delayed_submit_prepare($multiform, $current_form, $form_state, $index); + // Do not render the
tags. Instead we render the as a
. $current_form['#theme_wrappers'] = array('container'); _multiform_get_form($current_form, $form['buttons'], $index); @@ -99,17 +141,28 @@ function multiform_get_form() { if (isset($form_state['has_file_element'])) { $form['#attributes']['enctype'] = 'multipart/form-data'; } - // Keep the redirect from the first form. - if (!$key) { - $redirect = isset($form_state['redirect']) ? $form_state : array(); + } + // Execute submit handlers and other required operations. + multiform_delayed_submit_execute($multiform); + + // Set form redirect. + // Keep the redirect from the first form. + foreach ($multiform['#subforms'] as $subform) { + if ($subform['key'] == 0 && isset($subform['form_state']['redirect'])) { + $multiform['#redirect_array']['redirect'] = $subform['form_state']['redirect'] + ? $subform['form_state']['redirect'] + : array(); + break; } } form_set_cache($build_id, $form['buttons'], $form_state_save); if (!empty($form_state_save['input'])) { // We forced $form_state['no_redirect'] to TRUE above, so unset it in order // to allow the redirection to proceed. - unset($redirect['no_redirect']); - drupal_redirect_form($redirect); + unset($multiform['#redirect_array']['no_redirect']); + if ($multiform['#redirect'] == TRUE) { + drupal_redirect_form($multiform['#redirect_array']); + } } return $form; } @@ -137,4 +190,106 @@ function _multiform_get_form(&$element, &$buttons, $form_id) { // foo[bar] then we want multiform[$form_id][foo][bar]. $element['#name'] = "multiform[$form_id]" . preg_replace('/^[^[]+/', '[\0]', $element['#name']); } -} \ No newline at end of file +} + +/** + * Implements hook_form_alter(). + * + * Add an after_build that would add an auxilary validation. + */ +function multiform_form_alter(&$form, &$form_state, $form_id) { + + // Check whether current form is a subform. + if (isset($form_state['multiform_id'])) { + // We need after_build function to add auxilary validation function, that should be last in the list. + // For the same purpose hook_module_implements_alter() is implemented. + $form['#after_build'][] = 'multiform_after_build'; + // Trigger multiform_element_info_alter(). + // Replace ajax_process_form() with multiform specific multiform_ajax_process_form(). + // We need it here for subform rebuild when ajax submit is done. + is_multiform_page(TRUE); + } +} + +/** + * Add validation function that would delay execution of submit handlers. + */ +function multiform_after_build($form, $form_state) { + $form['#validate'][] = 'multiform_delay_auxilary_validate'; + return $form; +} + +/** + * Auxilary validation function. + * Should be listed last in the list of all form validation functions + * in order to prevent further processing. + */ +function multiform_delay_auxilary_validate($form, &$form_state) { + // Set $form_state['submitted'] value to FALSE. + // And possibly perform other changes. See multiform_process_form_state(). + multiform_process_form_state('modify', $form_state); +} + +// TODO: Also we should change module's weight in order this implementation to be called last, +// since other modules could implement hook_module_implements_alter() too. + +/** + * Implements hook_module_implements_alter(). + * + * Hooks multiform_module_implements_alter() and multiform_form_alter() should be executed last + * amongst other modules' implementations. + */ +function multiform_module_implements_alter(&$implementations, $hook) { + if ($hook == 'form_alter') { + $group = $implementations['multiform']; + unset($implementations['multiform']); + $implementations['multiform'] = $group; + } + // Used to override system_element_info() for multiform ajax purposes. + elseif ($hook == 'element_info') { + $group = $implementations['multiform']; + unset($implementations['multiform']); + $implementations['multiform'] = $group; + } +} + +/** + * Implements hook_element_info_alter(). + * + * Replace ajax_process_form() #process for elements. + */ +function multiform_element_info_alter(&$type) { + // Alter element info only for multiform pages. + if (is_multiform_page()) { + module_load_include('inc', 'multiform', 'multiform.ajax'); + foreach ($type as $key => $data) { + if (isset($data['#process'])) { + if (is_array($data['#process'])) { + foreach ($data['#process'] as $id => $function) { + if ($function == 'ajax_process_form') { + $type[$key]['#process'][$id] = 'multiform_ajax_process_form'; + } + } + } + elseif ($data['#process'] == 'ajax_process_form') { + $type[$key]['#process'] = 'multiform_ajax_process_form'; + } + } + } + } +} + +/** + * Set flag for multiform_element_info(). + */ +function is_multiform_page($status = NULL) { + static $stored_status = FALSE; + if ($status) { + // Reset element_info only once. + if (!$stored_status) { + drupal_static_reset('element_info'); + } + $stored_status = $status; + } + return $stored_status; +}