AHAH in Drupal: may it one day live up to its acronym
With a name like AHAH, one might expect positive experiences in one's dealings with it. But often a name like "AGAH!" would seem more appropriate (Asynchronous Groaning and Headbashing?). There's no doubt about it - AHAH in Drupal is hard. I'm referring here to the trick of dynamically changing elements on a form or adding new ones, as is done on the poll creation form in core. It was next to impossible in Drupal 5, promises to be fairly straight-forward in Drupal 7, but has many people tearing their hair out in Drupal 6.
In a nutshell, the reason it's hard is that Drupal needs to ensure that all form submissions are legal and secure, so it won't simply accept submissions from elements it didn't know about when it first rendered the form.
A few months ago, I wrote a blog post entitled The dual aspect of Drupal forms and what this means for your AHAH callback, after having been enlightened by chx as to Form API / AHAH best practices. It focused on explaining the correct way to do AHAH in Drupal 6, as opposed to the "old way", which at the time was still the most common. Poll module, many people's starting point for understanding AHAH in Drupal, was still doing it the "old way" but has since been updated. Dmitrig01, chx and I put together a handbook page entitled Doing AHAH correctly in Drupal 6 and Beyond, intended as the official instruction guide for learning AHAH.
But there had already been a fair amount of documentation written around the older, incorrect technique which had shaped people's conceptualisation of how AHAH should work, and so a lot of confusion and frustration ensued. The new way requires quite a shift in thinking regarding what the AHAH callback is all about. It can be hard to see it just looking at the code, but here essentially is the difference between the two methodologies:
The old way
You attach an #ahah binding to a form element which includes a path to an AHAH callback function, which changes a portion of your form and returns that changed portion.
The correct way
You attach an #ahah binding to a form element which includes a path to an AHAH callback function. That function processes the form, including calling the submit handler for the element, which updates $form_state, then rebuilds it and returns a specified portion.
The big difference here is that in the second approach, the AHAH callback does not change the form. That is not its purpose. Failure to understand this is, I believe, the main source of all the confusion.
I have found that one of the most common use cases for AHAH in Drupal (going by discussions in irc and the forums) is the idea of a dependent drop-down: the options available in a dropdown are dependent on the user's input in another field. In this post I will show how to do this using the recommended technique, in an attempt to convince readers that once you embrace this new way of thinking about AHAH, it doesn't need to be so frustrating.
The form I use in my example is a "musician signup" form, which has a dropdown for "instrument category", containing the options "brass", "strings", "woodwind" and "percussion". Then there's an "instrument" dropdown, the options of which change according to which category has been chosen. It's a pretty over-simplified example - the options are coming from a 2-dimensional array that's just returned from a function, whereas in most cases they'll probably be pulled from the database. I wanted to pare the example down to the bear basics so as to focus on the AHAH essentials.
It's not about magic
You need to stop thinking that you just build your form the same old way and then AHAH will work some magic on it and change it. It may look like magic to the user, but it shouldn't feel like magic to you, the developer. As you build your form function, you need to think of all the possible ways it could look to the end user: in our case we can say that it will have an instrument dropdown that will either contain the instrument options corresponding to the chosen category, or will not have any options yet if no category has been chosen. Let's get started...
<span style="color: #000000"><span style="color: #0000BB"><?php<br></span><span style="color: #FF8000">/**<br> * Musician signup form definition<br> */<br></span><span style="color: #007700">function </span><span style="color: #0000BB">musician_example_form</span><span style="color: #007700">(</span><span style="color: #0000BB">$form_state</span><span style="color: #007700">, </span><span style="color: #0000BB">$musician </span><span style="color: #007700">= array()) {<br> </span><span style="color: #0000BB">$form </span><span style="color: #007700">= array();<br> </span><span style="color: #0000BB">$form</span><span style="color: #007700">[</span><span style="color: #DD0000">'#cache'</span><span style="color: #007700">] = </span><span style="color: #0000BB">TRUE</span><span style="color: #007700">;<br><br> </span><span style="color: #FF8000">// the contents of $musician will either come from the db or from $form_state<br> </span><span style="color: #007700">if (isset(</span><span style="color: #0000BB">$form_state</span><span style="color: #007700">[</span><span style="color: #DD0000">'musician'</span><span style="color: #007700">])) {<br> </span><span style="color: #0000BB">$musician </span><span style="color: #007700">= </span><span style="color: #0000BB">$form_state</span><span style="color: #007700">[</span><span style="color: #DD0000">'musician'</span><span style="color: #007700">] + (array)</span><span style="color: #0000BB">$musician</span><span style="color: #007700">;<br> }<br><br> </span><span style="color: #FF8000">// ... element definitions<br><br></span><span style="color: #007700">}<br></span><span style="color: #0000BB">?></span></span>
The two important things to note here:
- We make sure our form is going to be cached so it can be retrieved by our AHAH callback
- We make it react to $form_state. The musician array could be populated from the database, if this is an edit form, or it could be populated from $form_state['musician'] which gets set by the submit handler of our #ahah element and assigned the contents of $form_state['values']. We will see this in action further on.
For the purposes of discussing this example we will always assume that $musician, if not empty, has been populated from $form_state, and not from the database as this is not an edit form.
Now for the dependent dropdowns logic:
<span style="color: #000000"><span style="color: #0000BB"><?php<br><br></span><span style="color: #007700">function </span><span style="color: #0000BB">musician_example_form</span><span style="color: #007700">(</span><span style="color: #0000BB">$form_state</span><span style="color: #007700">, </span><span style="color: #0000BB">$musician </span><span style="color: #007700">= array()) {<br> </span><span style="color: #FF8000">// ... form logic<br><br> // retrieve our array of arrays of instruments, keyed<br> // by category<br> </span><span style="color: #0000BB">$instruments </span><span style="color: #007700">= </span><span style="color: #0000BB">_get_instruments</span><span style="color: #007700">();<br> </span><span style="color: #0000BB">$instrument_categories </span><span style="color: #007700">= </span><span style="color: #0000BB">array_keys</span><span style="color: #007700">(</span><span style="color: #0000BB">$instruments</span><span style="color: #007700">);<br> </span><span style="color: #FF8000">// format the array into an array of dropdown options<br> </span><span style="color: #0000BB">$instrument_options </span><span style="color: #007700">= array();<br> foreach(</span><span style="color: #0000BB">$instrument_categories </span><span style="color: #007700">as </span><span style="color: #0000BB">$key </span><span style="color: #007700">=> </span><span style="color: #0000BB">$value</span><span style="color: #007700">) {<br> </span><span style="color: #0000BB">$instrument_options</span><span style="color: #007700">[</span><span style="color: #0000BB">$value</span><span style="color: #007700">] = </span><span style="color: #0000BB">$value</span><span style="color: #007700">;<br> }<br> </span><span style="color: #FF8000">// if our $musician array (which came from $form_state) has an<br> // instrument category value, we'll use that as the default value<br> </span><span style="color: #0000BB">$selected_category </span><span style="color: #007700">= isset(</span><span style="color: #0000BB">$musician</span><span style="color: #007700">[</span><span style="color: #DD0000">'instrument_category'</span><span style="color: #007700">]) ? </span><span style="color: #0000BB">$musician</span><span style="color: #007700">[</span><span style="color: #DD0000">'instrument_category'</span><span style="color: #007700">] : </span><span style="color: #DD0000">'none'</span><span style="color: #007700">;<br><br> </span><span style="color: #FF8000">// ... element definitions<br><br></span><span style="color: #007700">}<br></span><span style="color: #0000BB">?></span></span>
The comments hopefully make this code pretty clear - the main thing is that we now have a variable, $selected_category, which will act as both a #default_value for our category dropdown and the basis for building our instrument dropdown. If $musician['instrument_category'] is set, that means it came from $form_state when the user chose a category.
Here is the code for the dropdowns:
<span style="color: #000000"><span style="color: #0000BB"><?php<br> $form</span><span style="color: #007700">[</span><span style="color: #DD0000">'instrument_category'</span><span style="color: #007700">] = array(<br> </span><span style="color: #DD0000">'#type' </span><span style="color: #007700">=> </span><span style="color: #DD0000">'select'</span><span style="color: #007700">,<br> </span><span style="color: #DD0000">'#title' </span><span style="color: #007700">=> </span><span style="color: #0000BB">t</span><span style="color: #007700">(</span><span style="color: #DD0000">'Instrument category'</span><span style="color: #007700">),<br> </span><span style="color: #DD0000">'#options' </span><span style="color: #007700">=> array(</span><span style="color: #DD0000">'none' </span><span style="color: #007700">=> </span><span style="color: #DD0000">'Please select...'</span><span style="color: #007700">) + </span><span style="color: #0000BB">$instrument_options</span><span style="color: #007700">,<br> </span><span style="color: #DD0000">'#default_value' </span><span style="color: #007700">=> </span><span style="color: #0000BB">$selected_category</span><span style="color: #007700">,<br> </span><span style="color: #DD0000">'#ahah' </span><span style="color: #007700">=> array(<br> </span><span style="color: #DD0000">'path' </span><span style="color: #007700">=> </span><span style="color: #DD0000">'musicianform/ahah'</span><span style="color: #007700">,<br> </span><span style="color: #DD0000">'wrapper' </span><span style="color: #007700">=> </span><span style="color: #DD0000">'instrument-ahah'</span><span style="color: #007700">,<br> </span><span style="color: #DD0000">'event' </span><span style="color: #007700">=> </span><span style="color: #DD0000">'change'</span><span style="color: #007700">,<br> ),<br> );<br> </span><span style="color: #0000BB">$form</span><span style="color: #007700">[</span><span style="color: #DD0000">'instrument_select'</span><span style="color: #007700">] = array(<br> </span><span style="color: #DD0000">'#tree' </span><span style="color: #007700">=> </span><span style="color: #0000BB">TRUE</span><span style="color: #007700">,<br> </span><span style="color: #DD0000">'#prefix' </span><span style="color: #007700">=> </span><span style="color: #DD0000">'<div id="instrument-ahah">'</span><span style="color: #007700">,<br> </span><span style="color: #DD0000">'#suffix' </span><span style="color: #007700">=> </span><span style="color: #DD0000">'</div>'</span><span style="color: #007700">,<br> );<br><br> </span><span style="color: #0000BB">$form</span><span style="color: #007700">[</span><span style="color: #DD0000">'instrument_select'</span><span style="color: #007700">][</span><span style="color: #DD0000">'instrument'</span><span style="color: #007700">] = array(<br> </span><span style="color: #DD0000">'#type' </span><span style="color: #007700">=> </span><span style="color: #DD0000">'select'</span><span style="color: #007700">,<br> </span><span style="color: #DD0000">'#title' </span><span style="color: #007700">=> </span><span style="color: #0000BB">t</span><span style="color: #007700">(</span><span style="color: #DD0000">'Instrument'</span><span style="color: #007700">),<br> </span><span style="color: #DD0000">'#options' </span><span style="color: #007700">=> </span><span style="color: #0000BB">$selected_category </span><span style="color: #007700">== </span><span style="color: #DD0000">'none' </span><span style="color: #007700">? array() : </span><span style="color: #0000BB">$instruments</span><span style="color: #007700">[</span><span style="color: #0000BB">$selected_category</span><span style="color: #007700">],<br> );<br></span><span style="color: #0000BB">?></span></span>
Here we add our #ahah binding to the category dropdown, telling it the path to our AHAH callback, the id of the wrapper div constituting the section of the form we are going to replace, and the event that we want to trigger the callback, i.e. on change. We then create our wrapper div for the instrument dropdown and then the dropdown itself. The array of options for the dropdown will be empty if $selected_category is 'none', otherwise it will pull the array of instruments from the $selected_category key of the $instruments array. We will be replacing the instruments dropdown with a newly rendered version each time the user changes the category.
And now for the slightly awkward bit. We need a submit handler that's specific to our #ahah element which will make the required change to $form_state. But only buttons have submit handlers and our #ahah element is a dropdown. So what we need is to add a submit button, which we'll then have to hide with css, and set our submit handler function as the #submit property of this button.
Here's the button code:
<span style="color: #000000"><span style="color: #0000BB"><?php<br> $form</span><span style="color: #007700">[</span><span style="color: #DD0000">'get_instruments'</span><span style="color: #007700">] = array(<br> </span><span style="color: #DD0000">'#type' </span><span style="color: #007700">=> </span><span style="color: #DD0000">'submit'</span><span style="color: #007700">,<br> </span><span style="color: #DD0000">'#value' </span><span style="color: #007700">=> </span><span style="color: #DD0000">'submit_category'</span><span style="color: #007700">,<br> </span><span style="color: #DD0000">'#submit' </span><span style="color: #007700">=> array(</span><span style="color: #DD0000">'musicianform_get_instruments_submit'</span><span style="color: #007700">),<br> );<br></span><span style="color: #0000BB">?></span></span>
And here's the submit handler function:
<span style="color: #000000"><span style="color: #0000BB"><?php<br></span><span style="color: #FF8000">/**<br> * Submit handler for the Instrument category drop down.<br> */<br></span><span style="color: #007700">function </span><span style="color: #0000BB">musicianform_get_instruments_submit</span><span style="color: #007700">(</span><span style="color: #0000BB">$form</span><span style="color: #007700">, &</span><span style="color: #0000BB">$form_state</span><span style="color: #007700">) {<br> </span><span style="color: #0000BB">$musician </span><span style="color: #007700">= </span><span style="color: #0000BB">$form_state</span><span style="color: #007700">[</span><span style="color: #DD0000">'values'</span><span style="color: #007700">];<br> unset(</span><span style="color: #0000BB">$form_state</span><span style="color: #007700">[</span><span style="color: #DD0000">'submit_handlers'</span><span style="color: #007700">]);<br> </span><span style="color: #0000BB">form_execute_handlers</span><span style="color: #007700">(</span><span style="color: #DD0000">'submit'</span><span style="color: #007700">, </span><span style="color: #0000BB">$form</span><span style="color: #007700">, </span><span style="color: #0000BB">$form_state</span><span style="color: #007700">);<br> </span><span style="color: #0000BB">$form_state</span><span style="color: #007700">[</span><span style="color: #DD0000">'musician'</span><span style="color: #007700">] = </span><span style="color: #0000BB">$musician</span><span style="color: #007700">;<br> </span><span style="color: #0000BB">$form_state</span><span style="color: #007700">[</span><span style="color: #DD0000">'rebuild'</span><span style="color: #007700">] = </span><span style="color: #0000BB">TRUE</span><span style="color: #007700">;<br>}<br></span><span style="color: #0000BB">?></span></span>
This ensures that none of the other submit handlers get called and that $form_state['musician'] now contains the values submitted in the form.
And now, finally, to the AHAH callback. This is a standard sequence of steps, which are explained in detail in Doing AHAH correctly in Drupal 6. The only change you'll need to make in your own callback is in the part at the end that renders the new elements:
<span style="color: #000000"><span style="color: #0000BB"><?php<br></span><span style="color: #FF8000">/**<br> * ahah callback.<br> */<br></span><span style="color: #007700">function </span><span style="color: #0000BB">musicianform_ahah</span><span style="color: #007700">() {<br><br> </span><span style="color: #FF8000">// ... [steps to retrieve, process and rebuild the form] ...<br> // we now have a $form variable containing the<br> // rebuilt form<br><br> </span><span style="color: #0000BB">$changed_elements </span><span style="color: #007700">= </span><span style="color: #0000BB">$form</span><span style="color: #007700">[</span><span style="color: #DD0000">'instrument_select'</span><span style="color: #007700">];<br> unset(</span><span style="color: #0000BB">$changed_elements</span><span style="color: #007700">[</span><span style="color: #DD0000">'#prefix'</span><span style="color: #007700">], </span><span style="color: #0000BB">$changed_elements</span><span style="color: #007700">[</span><span style="color: #DD0000">'#suffix'</span><span style="color: #007700">]); </span><span style="color: #FF8000">// Prevent duplicate wrappers.<br> </span><span style="color: #0000BB">drupal_json</span><span style="color: #007700">(array(<br> </span><span style="color: #DD0000">'status' </span><span style="color: #007700">=> </span><span style="color: #0000BB">TRUE</span><span style="color: #007700">,<br> </span><span style="color: #DD0000">'data' </span><span style="color: #007700">=> </span><span style="color: #0000BB">theme</span><span style="color: #007700">(</span><span style="color: #DD0000">'status_messages'</span><span style="color: #007700">) . </span><span style="color: #0000BB">drupal_render</span><span style="color: #007700">(</span><span style="color: #0000BB">$changed_elements</span><span style="color: #007700">),<br> ));<br>}<br></span><span style="color: #0000BB">?></span></span>
Here we are choosing the portion of the form that needs to get re-rendered - essentially just the instruments dropdown and its parent, stripping out the wrapper div. We then call drupal_json() which prints the response in the format expected on the JavaScript side, where it will get inserted into the DOM, replacing the old version.
The fact that the bulk of the code in the AHAH callback is always exactly the same shows that this of course should be a utility function in Drupal. And indeed it is... in Drupal 7. In the meantime, you just need to copy and paste it ;-)
I suppose finishing off with "And that's all there is to it!" wouldn't be quite appropriate here - there are quite a few steps. But once you understand the rationale behind doing it this way (which is explained in my earlier post, The dual aspect of Drupal forms and what this means for your AHAH callback, and get your head around the technique itself, you will not only have an easier time of it in Drupal 6 but you will absolutely sail through all things AHAH in Drupal 7. To see why, here are some of the important changes that have either already been committed or are being worked on: