Drupal Custom Forms with AHAH

By Paulus, 13 August, 2013

I was assigned a project in which users are able to register for a sleepover. One of the requirements of the form is that a user must be able to enter multiple chaperones and children. Instead of providing 20 or so fields right off the bat for both chaperones and children, the user would be able to dynamically add one at a type without having the page reload each time. To accomplish this with Drupal, the use of Asynchronous HTML and HTTP (AHAH) is required. Unlike AJAX, AHAH returns HTML elements to be injected directly into the DOM opposed to receiving XML and parsing the response. There is a contributed module that provides examples of implementing this feature called Examples for Developers. Even after reviewing the examples provided in the example module, as well as looking at how the poll module implements AHAH, I was still confused. I quickly learned that this might be one of those instances that you can't quickly learn and fully understand what's going on, unless you have a full understanding of the form building process. After much Googling, and trial and error, I finally figured out how this is done.

AHAH works by:

  1. You creating a form with at least one AHAH enabled form element
  2. Writing an AHAH callback function, which gets the form from the cache
  3. Takes a specific portion of that form and stripes out the rest
  4. Retaining user submitted data
  5. Going through the rebuild process; executing the validate and submit handlers
  6. Return the portion of the form that needs to be re-rendered by replacing the old portion of the form with the newly re-rendered portion

At the very minimum, you need the following:

  1. Implementation of hook_menu that has a path for both the form page and the AHAH callback(s)
  2. Page callback function
  3. AHAH callback function (MENU_CALLBACK)
  4. A submit callback for the custom form
  5. Validate callback (providing there needs to be any special validation)

I've provided some code that I've written to illustrate how it all works. This is not a complete solution and your needs may be different, but the concept will be the same.

Implementation of hook_menu() 

/*
 * Implementation of hook_menu
 */

function overnights_menu() {
  $items = array();

  // Overnight form
  $items['user/%/overnight/roster-form'] = array(
    'title' => 'Overnight Roster',
    'page callback' => 'drupal_get_form',
    'page arguments' => array('overnight_roster_form'),
    'access callback' => TRUE,
    'type' => MENU_LOCAL_TASK,
    'weight' => 4,
  );

  // AHAH Callback
  $items['overnight/roster-form/add-chaperone'] = array(
    'page callback' => '_overnight_roster_add_chaperone_callback',
    'access callback' => TRUE,
    'type' => MENU_CALLBACK,
  );

  // AHAH Callback
  $items['overnight/roster-form/add-child'] = array(
    'page callback' => '_overnight_roster_add_child_callback',
    'access callback' => TRUE,
    'type' => MENU_CALLBACK,
  );

  return $items;
}

Page Callback for Form

This function gets called when the page loads and rebuilt when executing an AHAH callback.

function overnights_roster_form($form_state) {

  $form = array();

  $form['reservation_information'] = array(
      '#type' => 'fieldset',
      '#title' => t('Reservation Information'),
      '#collapsible' => FALSE,
      '#collapsed' => FALSE,
      '#attributes' => array('class' => 'protected', 'id' => 'reservation-information'),
  );

  $form['reservation_information']['confirmation_number'] = array(
      '#type' => 'textfield',
      '#title' => t('Confirmation Number'),
      '#size' => 16,
      '#required' => TRUE,
  );

  $form['reservation_information']['event_date'] = array(
      '#type' => 'textfield',
      '#title' => t('Overnight Date'),
      '#size' => 16,
  );

  $form['reservation_information']['group_name'] = array(
      '#type' => 'textfield',
      '#title' => t('Group Name'),
  );

  $form['reservation_information']['first_name'] = array(
      '#type' => 'textfield',
      '#title' => t('Supervising Adult\'s First Name'),
      '#required' => TRUE,
  );

  $form['reservation_information']['last_name'] = array(
      '#type' => 'textfield',
      '#title' => t('Supervising Adult\'s Last Name'),
      '#required' => TRUE,
  );

  $form['reservation_information']['need_restroom'] = array(
      '#type' => 'textfield',
      '#title' => t('I need to sleep by a restroom for the following medical reasons'),
  );

  $form['reservation_information']['need_outlet'] = array(
      '#type' => 'textfield',
      '#title' => t('I need to sleep by a outlet for the following medical reasons'),
  );

  // Chaperones / Adults
  $form['chaperones_wrapper'] = array(
      '#type' => 'fieldset',
      '#title' => 'Chaperones / Adults',
      '#prefix' => '<div id="chaperones-wrapper">',
      '#suffix' => '</div>',
  );

  $form['chaperones_wrapper']['chaperones'] = array(
      '#value' => ' ', // Not setting this will cause this element to not be rendered.
      '#prefix' => '<div id="chaperones">',
      '#suffix' => '</div>',
  );

  // If JS is disabled, grab the number of chaperones from the 'chaperone_count' array element.
  if (isset($form_state['chaperone_count'])) {
    $chaperones_count = $form_state['chaperone_count'];
  } else {
    // An AHAH callback function was executed and the form is being rebuilt. Get the number of chaperones prior to the function
    // being executed.
    $chaperones_count = (isset($form_state['values']['chaperones_wrapper'])) ? count($form_state['values']['chaperones_wrapper']) : 0;
    // If the user wants to add a new chaperone increment the chaperone count by one. If this is a new form, start off by
    // asking for the first chaperone.
    $chaperones_count += ($form_state['values']['op'] == 'Add Chaperone' || !isset($form_state['values']['op'])) ? 1 : 0;
  }

  $button_text = preg_split('/Remove Chaperone[\s]/',$form_state['clicked_button']['#value']);
  $remove_chaperone = (count($button_text) == 2) ? intval($button_text[1]) - 1 : -1;

  // Since this function is called when the form is rebuilt, this is where we're
  // going to add the additional fields.
  for ($delta = 0; $delta < $chaperones_count; $delta++) {
    // As we're iterating through the chaperones, we want to make sure we keep
    // already entered chaperone information. If the user has clicked on the
    // 'Add Chaperone' button, then we will simply add three new empty form fields
    // after the others.
    $first_name = isset($form_state['values']['chaperones_wrapper'][$delta]['first_name']) ? $form_state['values']['chaperones_wrapper'][$delta]['first_name'] : '';
    $last_name = isset($form_state['values']['chaperones_wrapper'][$delta]['last_name']) ? $form_state['values']['chaperones_wrapper'][$delta]['last_name'] : '';
    $phone_number = isset($form_state['values']['chaperones_wrapper'][$delta]['phone_number']) ? $form_state['values']['chaperones_wrapper'][$delta]['phone_number'] : '';

    // The _add_chaperone_form is a way of limiting the size of this particular
    // function. Once we get to the delta of the chaperone we wish to remove, we just skip over
    // it and continue on.
    if($remove_chaperone != $delta) {
      $form['chaperones_wrapper']['chaperones'][$delta] = _add_chaperone_form($delta, $first_name, $last_name, $phone_number);
    }
  }

  // Submit button for adding another chaperone.
  $form['chaperones_wrapper']['add_chaperone'] = array(
    '#type' => 'submit',
    '#value' => 'Add Chaperone',
    '#submit' => array('overnights_add_chaperone_form_submit'), // No JS version
    '#ahah' => array(
        // Default is 'click', other values are: blur and change.
        'event' => 'click',
        // Required, the path to the function that will handle the event.
        // This was defined in hook_menu().
        'path' => 'js/overnights/add/chaperone',
        // the ID of the HTML element on the current page that will be updated with the response.
        'wrapper' => 'chaperones',
        // How we want to handle the exisitng HTML element.
        // - 'replace' will replace all content in the wrapper.
        // - 'after' will insert the returned HTML after the wrapper element.
        // - 'append' will insert the returned HTML after the existing HTML content inside the wrapper.
        // - 'before' will insert the returned HTML before the wrapper element.
        // - 'prepend' will insert the returned HTML before the content inside the wrapper element.
        'method' => 'replace',
        // The effect to use when content is being updated: fade, slide, or none.
        'effect' => 'none',
        // The type of animation that will be displayed when retrieving new content.
        'progress' => array(
         // Type of animation, either bar or throbber
          'type' => 'throbber',
          // Optional message to be displayed
          // 'message' => '',
          // Optional URL to indicate how far along the AHAH call is.
          // 'url' => '',
        ),
      ),
    );

  // Children
  // Snipped due to redundancy. The children section works the same way as the chaperones with different callbacks.

  $form['register'] = array(
      '#type' => 'submit',
      '#value' => t('Submit'),
    );

  return $form;
}

Lines 68 to 77 determine how many chaperones are being submitted and how many empty fields that need to be submitted. The code immediately following this (lines 79 to 80) determine if the user wants to remove a chaperone and if so, which one. The next step is to rebuild the chaperone section of the form by iterating through all the chaperones that were submitted and removing the chaperone, if one is to be removed by skipping over it. The Add Chaperone button has been documented in the code on lines 102 to 133.

overnights_add_chaperone_form_submit Function

This function is only used if Java Script is not available. When Java Script is not available, then we tack on 5 more fields for the user to enter data.

function overnights_add_chaperone_form_submit(&$form, &$form_state) {
  if($form_state['chaperone_count']) {
    $n = $_GET['q'] == 'js/overnights/add/chaperone' ? 1 : 5;
    $form_state['chaperones_count'] = count($form_state['values']['chaperones_wrapper']['chaperones']) + $n;
  }
}

_overnight_roster_add_chaperone_callback Function

This function can also be used when adding children. However, some modifications need to be made to handle children (lines 33 and 36).

function _overnights_add_chaperone_callback() {
  // Create the form_state which will be used in rebuilding the form.
  $form_state = array('storage' => NULL, 'submitted' => FALSE);
  // Get the form's build ID.
  $form_build_id = $_POST['form_build_id'];

  // Retrieve the form from cache.
  $form = form_get_cache($form_build_id, $form_state);

  // Get the form's parameters.
  $args = $form['#parameters'];
  // Retrieve the form_id from the parameters.
  $form_id = array_shift($args);

  // We don't want to do any redirecting.
  $form['#redirect'] = FALSE;

  // Add the submitted data to both the form and form_state variables.
  $form['#post'] = $_POST;
  $form['#programmed'] = FALSE;
  $form_state['post'] = $_POST;

  // Skip validation only during an AHAH call.
  _ahah_disable_validation($form);

  // Build the form and validate it.
  drupal_process_form($form_id, $form, $form_state);

  // Reteives, caches, and processes the form with an empty $_POST variable.
  $form = drupal_rebuild_form($form_id, $form_state, $args, $form_build_id);

  // Get the porition of the form we want to update on the user's end.
  $chaperone_form = $form['chaperones_wrapper']['chaperones'];

  // Unsets the #prefix and #suffix so it's not duplicated on the front end.
  unset($chaperone_form['#prefix'], $chaperone_form['#suffix']);

  // Generate the output to be returned.
  $output = drupal_render($chaperone_form);

  // This code is only necessary if you need to add more AHAH elements.
  $javascript = drupal_add_js(NULL, NULL);
  $overnights_ahah_settings = array();
  if (isset($javascript['setting'])) {
    foreach ($javascript['setting'] as $settings) {
      if (isset($settings['ahah'])) {
        foreach ($settings['ahah'] as $id => $ahah_settings) {
          if (strpos($id, 'remove') && strpos($id, 'chaperone')) {
            $overnights_ahah_settings[$id] = $ahah_settings;
          }
        }
      }
    }
  }

  // Add the AHAH settings needed for our new buttons.
  if (!empty($overnights_ahah_settings)) {
    $output .= '';
  }

  $output = theme('status_message') . $output;

  // Return the new portion of the form so the browser can update the appropriate
  // part of the form.
  drupal_json(array('status' => TRUE, 'data' => $output));
}

The _ahah_disable_validation is not a built in function and needs to be implemented. The code on lines 41 to 65 is only necessary if you are adding buttons with AHAH callbacks. Since AHAH events are only attached when the page first loads, you must execute the Java Script that attaches the events to the returned section.

_ahah_disable_validation Function

This recursive function is used to prevent errors being generated each time a user performs an AHAH action. The form will still validate as normal when the user submits the form.

function _ahah_disable_validation(&$form) {
  foreach(element_children($form) as $el) {
      $form[$el]['#validated'] = TRUE;
      _ahah_disable_validation($form[$el]);
    }
}
  

Putting it All Together

When a user hits the page that has the overnight_roster_form as a page argument (/user/%/overnight/roster-form), the form is generated normally. Clicking the Add Chaperone button:

  • Submits the form via ajax call to the overnight/roster-form/add-chaperone address where Drupal executes the AHAH callback
  • The callback function will retrieve the form that was cached on the initial page load
  • $_POST variables that were sent when the user clicked the Add Chaperone button will be merged into the $form_state
  • The form is built and validated. This also calls the overnight_roster_form function again.
  • The form is retrieve, cached, and processed 
  • Grab the section of the form we want to update to be rendered
  • Return the rendered form section to replace the old one.