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. The full code can be downloaded from here.
I would also recommend checking out the following sites for more information:
| Attachment | Size |
|---|---|
| album.tgz | 3.82 KB |



oh man, this is great great. i'd been meaning to try to get the station module's playlists (which seem very similar to the example you're using) ajaxified and now all i've got to do is copy paste.
Ive been looking for a solution to how the poll module has been implemented for months :)
excellent explanation tks for sharing
Hi Stella,
Great article you wrote. Thanks for that. I wonder if this part of your code is ever called though:
<?php// Get number of tracks - from AJAX form submission if available.
if (isset($form_state['track_count'])) {
$track_count = $form_state['track_count'];
}
?>
I looked to your code and couldn't find any place where the $form_state is updated with that information. AFAIK, in all cases your ELSE-clause will be called:
<?php// Otherwise set the track count to 0 (new node) or calculate the number of existing tracks (node edit).
else {
$track_count = empty($node->tracks) ? 0 : count($node->tracks);
}
?>
It does not pose a problem to your functionality, but could you still elaborate on this part of the code please?
Yep, you're right. It was some old legacy code that I forgot to remove. I've updated the post and the attachment. Thanks for spotting this!
Been looking at using some of this on my project and having a bit of a head scratch at this bit:
<?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);
// Make the changes we want to the form state.
$form_state['remove_delta'] = $form_state['remove_delta'];
}
?>
The last option seems like it won't do anything. Was this a stub left over from some previous code? Where's the magic?
Yup, the last line of that function is not needed. So I've updated the post and the download with that change. Thanks for spotting it!
Your post is very useful. I am looking for such implementation if user can log in or register if these two forms are ajaxified. What do you suggest when node form is being submitted and it is needed to show login and registeration form in modalar dialog to follow either of them and then return back to node form with user and its related information in the form of JSON. So that node submition can be under the authorship of registered user.
That is an INSANE amount of code....."there's got to be an easier way."
Is there a way to prevent blocks from rendering on Ajax requests? E.g., if you had a block set to load on every page, what would keep it from rendering (I realize it wouldn't show up) on the server when you call a "album_track/js/%" page?
thanks.
I believe if you set the 'album_track/js/%' menu item to have the type "MENU_CALLBACK", then things like blocks aren't processed or rendered.
It does seem to still process blocks with a menu callback - this seems something to keep in mind when doing ajax calls to a page. I was able to find a couple ways around this but was wondering if there is a best practice that allows for access permissions, etc. to still be adhered to.
But i keep getting this error
warning: Invalid argument supplied for foreach() in /Library/WebServer/Documents/sparc/modules/node/node.module on line 756.
Dont know if this helps or not.
Cheers
Change album_load() to return
$nodeinstead of$resultand it should go away.This was a very helpful and educating tutorial! Thank you!
PS: There is a small typo in the downloadable source:
album.module, line 79:
<?phpfunction alabum_delete(&$node) {
?>
One "a" too many in the function name ;)
Just wanted to say thank you. This article is quite possibly the most helpful of any drupal form documents, right up there with api reference page.
Great tutorial,
I'm looking for similar "add more" AHAH form, but without node functionality. Just regular form that can be added to any page.
I'm new in Drupal and it is hard for me. I'm getting lot of errors, the Add track still adds only one track and the Remove button don't work.
Is here somebody who has implemented this "Drupal Node Form" without the Node ? (that can be used in any module). I realy need it for my school project.
If somebody can help me, plese write me to daionionnn[at]gmail.com or post to this forum.
Thank You
Hellu am looking on how to do exactly the thing you describe in this articel and testet your module. There seems to be some error in the attatched files becaus the only form fields showing up in a finished node is title and body. The title anda artist fields are not shown, but when editing a node they seem to be there.
Thanks for a great article
You need to modify the hook_view() implementation. As you can see from the code, I left a big TODO in there on how to render the output. This tutorial only focuses on how to AJAX-ify the node form, and not on displaying the entered data.
Just wanted to post a big thankyou, your example enabled me to add dynamic AHAH add/remove of invoice/quote/etc lines to the ERP module set - http://drupal.org/project/erp.
This functionality was holding up the 6.x version, and your code slotted in quite well. The 6.x version of ERP is now in beta, thanks in no small part to this article, which got me over the hump of how to do it.
Thankyou!
thank you a lot for this beautiful and well done example
i had to find your article few days ago because this is solving 2 of my previous problems with :
<?phpinclude_once 'modules/node/node.pages.inc';
?>
that finally i replaced with
<?phpmodule_load_include('inc', 'node', 'node.pages');
?>
and
<?phpif (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>';
}
?>
Hi Stella,
thanks a lot for your example which was the first one that helped me understand AHAH stuff in Drupal completely. I am currently trying to implement some AHAH in a node form of a project of mine and was wondering how to do validation on the 'new_track_title' and 'new_track_artist' fields (to stay in the example's context)?
Thanks in advance for your comments on this.
Best regards,
Bjoern
Me again. I figured it out. To whom it may be of interest you just have to add
'#validate' => array('name_of_validation_function')to the ajaxified submit button settings and use form_set_error in the validation function. Unfortunately the 'error' class is not added to the form because it won't be rendered again but that is just cosmetics. Also the new values will be added to the form even in case of failing validation so I added
$form_state['new_track_valid'] = FALSE;and evaluate it when the form gets rebuild.
Best regards,
Bjoern
Hi,
I wanted exactly this, but without $node functionality,, just simple form.
I made remake this module and now it is without $node.
Very useful if you want use such functionality in your own module/form.
You can donwload it here:
http://rapidshare.com/files/327666082/ahah_add_more_forms.rar
Your implementation is surplus. The job could be done in shorter code. Read the API about ahah: http://api.drupal.org/api/drupal/developer--topics--forms_api_reference.html/6
PS: anyway, thanx for pickup ;))