Category Technology
Publication date
20 June 2009

AJAX-ifying Drupal Node Forms

Recently, for the first time with Drupal 6, I needed to create a form where a variable number of fields could be added to it by simply clicking a 'Add more' button.  I wanted to design a node form where users could create a custom compilation album of their favourite tracks.  However the number of tracks would vary from album to album and so I wanted a way for users to be able to add more fields to the form without reloading the page.  Now, yes I could have used CCK to build this custom content type, but I wanted to see how this could be done using Drupal's FAPI alone.  I used Drupal's poll module as a guide on how to implement this.

The process is fairly simple if you're familiar with Drupal's Form API.  However, a lot of code is needed but this is more for the other parts of the node form, rather than for the AJAX part itself.  In fact, the code needed for the AJAX functionality is rather short, and is even shorter again in Drupal 7.

The first step in creating a new content type is to implement hook_node_info().  This informs Drupal of the custom content types defined by our module, which we're going to call 'album'.

<?php
function album_node_info() {
  return array(
    'album' => array(
      'name' => t('Compilation album'),
      'module' => 'album',
      'description' => t('Create your very own custom compilation album.'),
      'title_label' => t('Album name'),
      'body_label' => t('Description'),
    ),
  );
}
?>

Once we've done that we need to define the node form itself.  At a minimum I decided it should have a title, a brief description along with the list of tracks.  Unlike the poll module I didn't want tracks already added to be editable.  Instead I wanted to display their details in a table, along with a remove link for each which would allow the user to delete individual tracks. New tracks could be added to the list using a set of form fields displayed beneath the table along with a 'Add another track' button.

So let's start by defining our form fields for the content type.

<?php
function album_form(&$node, $form_state) {
  $type = node_get_types('type', $node);

 

  // Title.
  $form['title'] = array(
    '#type' => 'textfield',
    '#title' => check_plain($type->title_label),
    '#default_value' => $node->title,
    '#required' => TRUE,
    '#weight' => 0,
  );

  // Body field.
  $form['body_field'] = node_body_field($node, $type->body_label, $type->min_word_count);

  // Define a wrapper in which to place both the track listing and the 'add
  // track' form.
  $form['track_wrapper'] = array(
    '#tree' => FALSE,
    '#weight' => 5,
    '#prefix' => '<div class="clear-block" id="album-track-wrapper">',
    '#suffix' => '</div>',
  );

  // Define a fieldset which will contain the form fields for the track title and
  // artist name, along with the 'add track' button.
  $form['track_wrapper']['add_track'] = array(
    '#type' => 'fieldset',
    '#title' => t('Add another track'),
    '#tree' => FALSE,
    '#weight' => 6,
  );

  // Define the form fields for the new track title and artist's name.
  $form['track_wrapper']['add_track']['new_track'] = array(
    '#tree' => TRUE,
    '#theme' => 'album_add_track_form',
  );
  $form['track_wrapper']['add_track']['new_track']['new_track_title'] = array(
    '#type' => 'textfield',
    '#title' => t('Track title'),
    '#weight' => 0,
  );
  $form['track_wrapper']['add_track']['new_track']['new_artist'] = array(
    '#type' => 'textfield',
    '#title' => t('Artist name'),
    '#weight' => 1,
  );

  // We name our button 'album_track_more' to avoid conflicts with other modules using
  // AHAH-enabled buttons with the id 'more'.
  $form['track_wrapper']['add_track']['album_track_more'] = array(
    '#type' => 'submit',
    '#value' => t('Add track'),
    '#weight' => 1,
    '#submit' => array('album_track_add_more_submit'),
    '#ahah' => array(
      'path' => 'album_track/js/0',
      'wrapper' => 'album-tracks',
      'method' => 'replace',
      'effect' => 'fade',
    ),
  );

  return $form;
}
?>

The most important part of the above code is the 'Add track' button.  Unlike other submit buttons you may have seen, it has an #ahah element.  This is an array and defines a number of settings required for the AJAX to work.  The most important item is the path.  This is the URL that will be requested from the server when the button is clicked, and which will return the necessary JSON data that we need to add to the current page.  We identify where on the page the data received should be inserted.  In the example above we've identified the div with the album-tracks id as the location.  By setting the method to replace the data received will not be appended to the div and will instead replace all of its existing content.  This may not be the desired behaviour in all cases.  Finally we've configured the JavaScript replacement effect to be fade.  An alternative to this is 'slide'.  More information on the effects available can be found on the jQuery site.  We've also defined a regular PHP submit handler for the 'Add track' button which will handle the setup of our new track.

However there is still more work that needs to be done.  We need to define the album_track_add_more_submit() submit handler and the function to handle the album_track/js/0 path.  

<?php
 /**
  * Submit handler for 'Add track' button on node form.
  */
function album_track_add_more_submit($form, &$form_state) {
  $form_state['remove_delta'] = 0;

 

  // Set the form to rebuild and run submit handlers.
  node_form_submit_build_node($form, $form_state);

  // Make the changes we want to the form state.
  if ($form_state['values']['album_track_more']) {
    $new_track = array();
    $new_track['track_title'] = $form_state['values']['new_track']['new_track_title'];
    $new_track['artist'] = $form_state['values']['new_track']['new_artist'];
    $form_state['new_track'] = $new_track;
  }
}
?>

The most important part of this submit handler is the call to node_form_submit_build_node() which sets the form to be rebuilt and runs the submit handlers.  It also creates a new $form_state variable to contain the new track data by pulling in the data submitted by the user.  This data will be returned to the form when it is rebuilt and can then be displayed on the form.  The remove_delta line will be needed later when we add links to remove existing tracks from the node.

Next we need to define a callback function for the album_track/js/0 path, which we can do in hook_menu()

<?php
function album_menu() {
  $items = array();

 

  $items['album_track/js/%'] = array(
    'page callback' => 'album_track_js',
    'page arguments' => array(2),
    'access arguments' => array('access content'),
    'type ' => MENU_CALLBACK,
  );
  return $items;
}
?>

Our menu path configuration above shows that a callback function album_track_js needs to be defined and takes the final path argument as it's one and only argument.  In our example, this argument is set to 0 for the 'Add track' button.  As we will see later, this will be set to other values when we re-use the same function to handle the removing of tracks from the node.

<?php
function album_track_js($delta = 0) {
  $form = album_ajax_form_handler($delta);

 

  // Render the new output.
  $track_form = $form['track_wrapper']['tracks'];
  // Prevent duplicate wrappers.
  unset($track_form['#prefix'], $track_form['#suffix']);

  $output = theme('status_messages') . drupal_render($track_form);

  // Final rendering callback.
  drupal_json(array('status' => TRUE, 'data' => $output));
}
?>

This callback function handles all of the AJAX functionality.  For all forms with AJAX / AHAH functionality there is a common set of functions and commands that must be run each time.  I've split these out into their own function album_ajax_form_handler() so they can be re-used by other AJAX-ified node forms.  In Drupal 7, a new utility function has been added to provide the same functionality.  Not only that, but you don't need to call it at all as it is handled by Drupal for you!

Once the new form is rebuilt, all our function needs to do is to extract the part of the form that we're interested in ($form['track_wrapper']['tracks']) and return it in JSON format.

The album_ajax_form_handler() function takes care of fetching the generated form from the cache, processing of the submit handlers and rebuilding it, and is defined below.

<?php
/**
 * AJAX form handler.
 */
function album_ajax_form_handler($delta = 0) {
  // The form is generated in an include file which we need to include manually.
  include_once 'modules/node/node.pages.inc';
  $form_state = array('storage' => NULL, 'submitted' => FALSE);
  $form_build_id = $_POST['form_build_id'];

 

  // Get the form from the cache.
  $form = form_get_cache($form_build_id, $form_state);
  $args = $form['#parameters'];
  $form_id = array_shift($args);

  // We need to process the form, prepare for that by setting a few internals.
  $form_state['post'] = $form['#post'] = $_POST;
  $form['#programmed'] = $form['#redirect'] = FALSE;

  // Set up our form state variable, needed for removing tracks.
  $form_state['remove_delta'] = $delta;

  // Build, validate and if possible, submit the form.
  drupal_process_form($form_id, $form, $form_state);

  // If validation fails, force form submission - this is my own "hack" for overcoming
  // issues where all required fields need to be filled out before the 'add more' button
  // can be clicked.  A better solution is being worked on in Drupal's issue queue.
  if (form_get_errors()) {
    form_execute_handlers('submit', $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, $args, $form_build_id);

  return $form;
}
?>

We now have a form that allows users to add new tracks to the node using AJAX / AHAH, and have defined both a submit handler and a AJAX callback function to handle the 'Add track' form submission.  However, we have omitted one important step which is the rendering of the new tracks on the form when it is rebuilt, along with a 'remove' link for each.  To do this, we must modify our album_form() function that we defined earlier.

<?php
function album_form(&$node, $form_state) {
  $type = node_get_types('type', $node);

 

  // Title.
  $form['title'] = array(
    '#type' => 'textfield',
    '#title' => check_plain($type->title_label),
    '#default_value' => $node->title,
    '#required' => TRUE,
    '#weight' => 0,
  );

  // Body field.
  $form['body_field'] = node_body_field($node, $type->body_label, $type->min_word_count);

  $form['track_wrapper'] = array(
    '#tree' => FALSE,
    '#weight' => 5,
    '#prefix' => '<div class="clear-block" id="album-track-wrapper">',
    '#suffix' => '</div>',
  );

  // Get number of tracks.
  $track_count = empty($node->tracks) ? 0 : count($node->tracks);

  // If a new track added, add to list and update the track count.
  if (isset($form_state['new_track'])) {
    if (!isset($node->tracks)) {
      $node->tracks = array();
    }
    $node->tracks = array_merge($node->tracks, array($form_state['new_track']));
    $track_count++;
  }

  // If a track removed, remove from list and update the track count.
  $remove_delta = -1;
  if (!empty($form_state['remove_delta'])) {
    $remove_delta = $form_state['remove_delta'] - 1;
    unset($node->tracks[$remove_delta]);
    // Re-number the values.
    $node->tracks = array_values($node->tracks);
    $track_count--;
  }

  // Container to display existing tracks.
  $form['track_wrapper']['tracks'] = array(
    '#prefix' => '<div id="album-tracks">',
    '#suffix' => '</div>',
    '#theme' => 'album_track_table',
  );

  // Add the existing tracks to the form.
  for ($delta = 0; $delta < $track_count; $delta++) {
    $title = isset($node->tracks[$delta]['track_title']) ? $node->tracks[$delta]['track_title'] : '';
    $artist = isset($node->tracks[$delta]['artist']) ? $node->tracks[$delta]['artist'] : '';
    // Display existing tracks using helper function album_track_display_form().
    $form['track_wrapper']['tracks'][$delta] = album_track_display_form($delta, $title, $artist);
  }


  // Add new tracks
  $form['track_wrapper']['add_track'] = array(
    '#type' => 'fieldset',
    '#title' => t('Add another track'),
    '#tree' => FALSE,
    '#weight' => 6,
  );

  // Define the form fields for the new track title and artist's name.
  $form['track_wrapper']['add_track']['new_track'] = array(
    '#tree' => TRUE,
    '#theme' => 'album_add_track_form',
  );
  $form['track_wrapper']['add_track']['new_track']['new_track_title'] = array(
    '#type' => 'textfield',
    '#title' => t('Track title'),
    '#weight' => 0,
  );
  $form['track_wrapper']['add_track']['new_track']['new_artist'] = array(
    '#type' => 'textfield',
    '#title' => t('Artist name'),
    '#weight' => 1,
  );

  // We name our button 'album_track_more' to avoid conflicts with other modules using
  // AHAH-enabled buttons with the id 'more'.
  $form['track_wrapper']['add_track']['album_track_more'] = array(
    '#type' => 'submit',
    '#value' => t('Add track'),
    '#weight' => 1,
    '#submit' => array('album_track_add_more_submit'),
    '#ahah' => array(
      'path' => 'album_track/js/0',
      'wrapper' => 'album-tracks',
      'method' => 'replace',
      'effect' => 'fade',
    ),
  );

  return $form;
}
?>

The above changes show the track_count being both incremented and decremented depending on whether a track has been added or removed via the AJAX form submission.  If a new track is added, then the $form_state['new_track'] variable set up in album_track_add_more_submit() is merged with the existing array of tracks in $node->tracks.  If a track is being removed, then the specified track is deleted from $node->tracks.  These tracks are then rendered in a table using two helper functions theme_album_track_table() and album_track_display_form()

The theme function to use is specified on the $form['track_wrapper']['add_track']['new_track'] field above.  In our case we specified album_track_table so we need to define a theme_album_track_table() function, along with a hook_theme(), which is needed to register theme functions with Drupal.

<?php
function album_theme() {
  return array(
    'album_track_table' => array(
      'arguments' => array('form'),
    ),
  );
}

function theme_album_track_table($form) {
  $rows = array();
  $headers = array(
    t('Title'),
    t('Artist'),
    '',  // Blank header title for the remove link.
  );

  foreach (element_children($form) as $key) {
    // No need to print the field title every time.
    unset(
      $form[$key]['track_title_text']['#title'],
      $form[$key]['artist_text']['#title'],
      $form[$key]['remove_track']['#title']
    );

    // Build the table row.
    $row = array(
      'data' => array(
        array('data' => drupal_render($form[$key]['track_title']) . drupal_render($form[$key]['track_title_text']), 'class' => 'track-title'),
        array('data' => drupal_render($form[$key]['artist']) . drupal_render($form[$key]['artist_text']), 'class' => 'artist'),
        array('data' => drupal_render($form[$key]['remove_track']), 'class' => 'remove-track'),
      ),
    );

    // Add additional attributes to the row, such as a class for this row.
    if (isset($form[$key]['#attributes'])) {
      $row = array_merge($row, $form[$key]['#attributes']);
    }
    $rows[] = $row;
  }

  $output = theme('table', $headers, $rows);
  $output .= drupal_render($form);
  return $output;
}
?>

As we want to display the existing tracks in a table as text, and not display editable form fields, we need to define two form elements for each field.  One will be a hidden field so the field data is available to the submit handler when the node is saved, and the other is an item field to show the track data to the user.

<?php
/**
 * Helper function to define populated form field elements for album track node form.
 */
function album_track_display_form($delta, $title, $artist) {

  $form = array(
    '#tree' => TRUE,
  );

  // Track title.
  $form['track_title'] = array(
    '#type' => 'hidden',
    '#value' => $title,
    '#parents' => array('tracks', $delta, 'track_title'),
  );
  $form['track_title_text'] = array(
    '#type' => 'item',
    '#title' => t('Title'),
    '#weight' => 1,
    '#parents' => array('tracks', $delta, 'track_title'),
    '#value' => $title,
  );

  // Artist.
  $form['artist'] = array(
    '#type' => 'hidden',
    '#value' => $artist,
    '#parents' => array('tracks', $delta, 'artist'),
  );
  $form['artist_text'] = array(
    '#type' => 'item',
    '#title' => t('Artist'),
    '#weight' => 2,
    '#parents' => array('tracks', $delta, 'artist'),
    '#value' => $artist,
  );

  // Remove button.
  $form['remove_track'] = array(
    '#type' => 'submit',
    '#name' => 'remove_track_' . $delta,
    '#value' => t('Remove'),
    '#weight' => 1,
    '#submit' => array('album_remove_row_submit'),
    '#parents' => array('tracks', $delta, 'remove_track'),
    '#ahah' => array(
      'path' => 'album_track/js/' . ($delta + 1),
      'wrapper' => 'album-tracks',
      'method' => 'replace',
      'effect' => 'fade',
    ),
  );

  return $form;
}
?>

As you can see, we have defined the 'Remove' track button to use the same AJAX callback path album_track/js/ as the 'Add track' button but have set the last argument to be non-zero.  We've set it to the track id and it is this id that identifies the track to be removed from the list when the remove button is clicked.  To do this we need to make two further changes.  First we need to define a new submit handler for this button, album_remove_row_submit()

<?php
function album_remove_row_submit($form, &$form_state) {
  // Set the form to rebuild and run submit handlers.
  node_form_submit_build_node($form, $form_state);
}
?>

Finally we need to modify album_track_js() to handle the remove button submission.  As the remove buttons are added dynamically to the form after the page has been generated, we need to re-attach Drupal JavaScript Behaviours each time the form is modified.

<?php
function album_track_js($delta = 0) {
  $form = album_ajax_form_handler($delta);

  // Render the new output.
  $track_form = $form['track_wrapper']['tracks'];
  // Prevent duplicate wrappers.
  unset($track_form['#prefix'], $track_form['#suffix']);

  $output = theme('status_messages') . drupal_render($track_form);

  // AHAH is not being nice to us and doesn't know about the "Remove" button.
  // This causes it not to attach AHAH behaviours to it after modifying the form.
  // So we need to tell it first.
  $javascript = drupal_add_js(NULL, NULL);
  if (isset($javascript['setting'])) {
    $output .= '<script type="text/javascript">jQuery.extend(Drupal.settings, '. drupal_to_js(call_user_func_array('array_merge_recursive', $javascript['setting'])) .');</script>';
  }

  // Final rendering callback.
  drupal_json(array('status' => TRUE, 'data' => $output));
}
?>

Finally, we have a node form with AJAX functionality which allows us to both add new tracks and remove existing tracks from a node without having to reload the entire form.

I would also recommend checking out the following sites for more information:

Profile picture for user Stella Power

Stella Power Managing Director

As well as being the founder and managing director of Annertech, Stella is one of the best known Drupal contributors in the world.