The dual aspect of Drupal forms and what this means for your AHAH callback
Over the last week or two I've spent a lot of time on an aspect of my Quick Tabs module that I am certain none of its users will care a hoot about. It wasn't a case of adding a new feature or fixing a bug or even improving usability, but a question of, to put it succinctly, cutting down on its evilness. The admin form for creating and editing Quick Tabs blocks (where you choose either a block or a view for each tab) had a serious amount of ahah functionality: click a button to instantly add a new tab, click a button to instantly remove one of your tabs, select a view for your tab and have the view display drop-down be instantly populated with the correct options for that view. It was pretty user-friendly; there was just one problem: it flew in the face of Form API best practices.
When it comes to AHAH forms, there's the JavaScript side of things - where a behaviour is attached to, say, the onclick event of a button and new content is retrieved and inserted into the DOM - and then there's the PHP side of things, the AHAH callback where you rebuild your form with new or altered elements. Now, my understanding of FAPI voo-doo had been poor to begin with - I had competely copied poll.module for this side of things - and so when chx told me there was bad stuff going on in my AHAH callback and tried to point me in the direction of FAPI righteousness, I was pretty lost. Thankfully, he took the time to go through it with me, discovering that poll module was flawed in the same way, and though I didn't attain enlightenent straight away, the murk did eventually clear, I cleaned up my AHAH callback, and I think I have reached a stage where I can explain the crux of the problem to others.
You see, you need to think of your module's form (or any Drupal form) as being essentially dual in nature: there's the "material" form - what you actually see rendered on the page - and then there's the "spiritual" form, if you will, the form that exists out there in the ether (aka the form cache :-P). The material form and the spiritual form need to be representative of each other at all times. In fact, the material form should always be a "physical" manifestation of the spiritual form. If either gets altered without the other being altered accordingly, bad things will happen - at best, your form won't work properly, at worst, you will open the gates to FAPI Hell. For example, if you successfully add new elements to the spiritual form, so that the version of the form held in the cache now contains 4 poll choice textfield elements, but the material form, the form rendered on the page, still only has three poll choice textfield elements, then when you hit submit it will give you a validation error to the effect that you have left the fourth field blank, assuming such validation has been applied. On the other hand, say for example you have a dependent dropdown, where you populate the options in one dropdown based on a selection in another, if those new options don't exist in the spiritual form, you will get an error to the effect that "an illegal choice has been detected" when you submit. There are good and bad ways around this latter problem.
One way, the way many modules up to now, including my own, were doing it was to retrieve the form from the cache, tack on the new element, re-save it to the cache, rebuild the form and render the altered portion. This is illustrated below:
<span style="color: #000000"><span style="color: #0000BB"><?php<br></span><span style="color: #007700">function </span><span style="color: #0000BB">myform_ahah</span><span style="color: #007700">() {<br> </span><span style="color: #0000BB">$delta </span><span style="color: #007700">= </span><span style="color: #0000BB">count</span><span style="color: #007700">(</span><span style="color: #0000BB">$_POST</span><span style="color: #007700">[</span><span style="color: #DD0000">'addable_elements'</span><span style="color: #007700">]);<br><br> </span><span style="color: #FF8000">// Build our new form element.<br> </span><span style="color: #0000BB">$form_element </span><span style="color: #007700">= </span><span style="color: #0000BB">_create_new_element</span><span style="color: #007700">(</span><span style="color: #0000BB">$delta</span><span style="color: #007700">);<br><br> </span><span style="color: #FF8000">// Build the new form.<br> </span><span style="color: #0000BB">$form_state </span><span style="color: #007700">= array(</span><span style="color: #DD0000">'submitted' </span><span style="color: #007700">=> </span><span style="color: #0000BB">FALSE</span><span style="color: #007700">);<br> </span><span style="color: #0000BB">$form_build_id </span><span style="color: #007700">= </span><span style="color: #0000BB">$_POST</span><span style="color: #007700">[</span><span style="color: #DD0000">'form_build_id'</span><span style="color: #007700">];<br> </span><span style="color: #FF8000">// Add the new element to the stored form. Without adding the element to the<br> // form, Drupal is not aware of this new element's existence and will not<br> // process it. We retreive the cached form, add the element, and resave.<br> </span><span style="color: #007700">if (!</span><span style="color: #0000BB">$form </span><span style="color: #007700">= </span><span style="color: #0000BB">form_get_cache</span><span style="color: #007700">(</span><span style="color: #0000BB">$form_build_id</span><span style="color: #007700">, </span><span style="color: #0000BB">$form_state</span><span style="color: #007700">)) {<br> exit();<br> }<br> </span><span style="color: #0000BB">$form</span><span style="color: #007700">[</span><span style="color: #DD0000">'my_ahah_wrapper'</span><span style="color: #007700">][</span><span style="color: #DD0000">'addable_elements'</span><span style="color: #007700">][</span><span style="color: #0000BB">$delta</span><span style="color: #007700">] = </span><span style="color: #0000BB">$form_element</span><span style="color: #007700">;<br> </span><span style="color: #0000BB">form_set_cache</span><span style="color: #007700">(</span><span style="color: #0000BB">$form_build_id</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 </span><span style="color: #007700">+= array(<br> </span><span style="color: #DD0000">'#post' </span><span style="color: #007700">=> </span><span style="color: #0000BB">$_POST</span><span style="color: #007700">,<br> </span><span style="color: #DD0000">'#programmed' </span><span style="color: #007700">=> </span><span style="color: #0000BB">FALSE</span><span style="color: #007700">,<br> );<br><br> </span><span style="color: #FF8000">// Rebuild the form.<br> </span><span style="color: #0000BB">$form </span><span style="color: #007700">= </span><span style="color: #0000BB">form_builder</span><span style="color: #007700">(</span><span style="color: #DD0000">'mymodule_form'</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><br> </span><span style="color: #FF8000">// Render the new output.<br> </span><span style="color: #0000BB">$form_portion </span><span style="color: #007700">= </span><span style="color: #0000BB">$form</span><span style="color: #007700">[</span><span style="color: #DD0000">'my_ahah_wrapper'</span><span style="color: #007700">][</span><span style="color: #DD0000">'addable_elements'</span><span style="color: #007700">];<br> unset(</span><span style="color: #0000BB">$form_portion</span><span style="color: #007700">[</span><span style="color: #DD0000">'#prefix'</span><span style="color: #007700">], </span><span style="color: #0000BB">$form_portion</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">$form_portion</span><span style="color: #007700">[</span><span style="color: #0000BB">$delta</span><span style="color: #007700">][</span><span style="color: #DD0000">'#attributes'</span><span style="color: #007700">][</span><span style="color: #DD0000">'class'</span><span style="color: #007700">] = empty(</span><span style="color: #0000BB">$form_portion</span><span style="color: #007700">[</span><span style="color: #0000BB">$delta</span><span style="color: #007700">][</span><span style="color: #DD0000">'#attributes'</span><span style="color: #007700">][</span><span style="color: #DD0000">'class'</span><span style="color: #007700">]) ? </span><span style="color: #DD0000">'ahah-new-content' </span><span style="color: #007700">: </span><span style="color: #0000BB">$form_portion</span><span style="color: #007700">[</span><span style="color: #0000BB">$delta</span><span style="color: #007700">][</span><span style="color: #DD0000">'#attributes'</span><span style="color: #007700">][</span><span style="color: #DD0000">'class'</span><span style="color: #007700">] .</span><span style="color: #DD0000">' ahah-new-content'</span><span style="color: #007700">;<br> </span><span style="color: #0000BB">$output </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">$form_portion</span><span style="color: #007700">);<br><br> </span><span style="color: #0000BB">drupal_json</span><span style="color: #007700">(array(</span><span style="color: #DD0000">'status' </span><span style="color: #007700">=> </span><span style="color: #0000BB">TRUE</span><span style="color: #007700">, </span><span style="color: #DD0000">'data' </span><span style="color: #007700">=> </span><span style="color: #0000BB">$output</span><span style="color: #007700">));<br>}<br></span><span style="color: #0000BB">?></span></span>
The main problem here is that in between the form being re-saved to the cache and it being rebuilt, it is further altered by the lines
$form += array(<br> '#post' => $_POST,<br> '#programmed' => FALSE,<br> );
$_POST should not be used at all in the rebuilding of the form. Everything should come from $form_state, so that the rendered output is still a "physical manifestation", as I put it earlier, of the form in the cache.
In order to achieve this, a couple of things need to be done differently, the ahah callback needs to be altered - but this actually involves mostly removing code; and the function that generates the form needs to be structured so as to react to the contents of $form_state (when building the form it first checks $form_state for previously submitted information, then checks if it is receiving information from the database, then finally if it is not receiving information from anywhere, it renders a brand new clean form). The ahah callback then looks like this:
<span style="color: #000000"><span style="color: #0000BB"><?php<br></span><span style="color: #007700">function </span><span style="color: #0000BB">myform_ahah</span><span style="color: #007700">() {<br> </span><span style="color: #0000BB">$form_state </span><span style="color: #007700">= array(</span><span style="color: #DD0000">'storage' </span><span style="color: #007700">=> </span><span style="color: #0000BB">NULL</span><span style="color: #007700">, </span><span style="color: #DD0000">'submitted' </span><span style="color: #007700">=> </span><span style="color: #0000BB">FALSE</span><span style="color: #007700">);<br> </span><span style="color: #0000BB">$form_build_id </span><span style="color: #007700">= </span><span style="color: #0000BB">$_POST</span><span style="color: #007700">[</span><span style="color: #DD0000">'form_build_id'</span><span style="color: #007700">];<br> </span><span style="color: #0000BB">$form </span><span style="color: #007700">= </span><span style="color: #0000BB">form_get_cache</span><span style="color: #007700">(</span><span style="color: #0000BB">$form_build_id</span><span style="color: #007700">, </span><span style="color: #0000BB">$form_state</span><span style="color: #007700">);<br> </span><span style="color: #0000BB">$args </span><span style="color: #007700">= </span><span style="color: #0000BB">$form</span><span style="color: #007700">[</span><span style="color: #DD0000">'#parameters'</span><span style="color: #007700">];<br> </span><span style="color: #0000BB">$form_id </span><span style="color: #007700">= </span><span style="color: #0000BB">array_shift</span><span style="color: #007700">(</span><span style="color: #0000BB">$args</span><span style="color: #007700">);<br> </span><span style="color: #0000BB">$form</span><span style="color: #007700">[</span><span style="color: #DD0000">'#post'</span><span style="color: #007700">] = </span><span style="color: #0000BB">$_POST</span><span style="color: #007700">;<br> </span><span style="color: #0000BB">$form</span><span style="color: #007700">[</span><span style="color: #DD0000">'#redirect'</span><span style="color: #007700">] = </span><span style="color: #0000BB">FALSE</span><span style="color: #007700">;<br> </span><span style="color: #0000BB">$form</span><span style="color: #007700">[</span><span style="color: #DD0000">'#programmed'</span><span style="color: #007700">] = </span><span style="color: #0000BB">FALSE</span><span style="color: #007700">;<br> </span><span style="color: #0000BB">$form_state</span><span style="color: #007700">[</span><span style="color: #DD0000">'post'</span><span style="color: #007700">] = </span><span style="color: #0000BB">$_POST</span><span style="color: #007700">;<br> </span><span style="color: #0000BB">drupal_process_form</span><span style="color: #007700">(</span><span style="color: #0000BB">$form_id</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 </span><span style="color: #007700">= </span><span style="color: #0000BB">drupal_rebuild_form</span><span style="color: #007700">(</span><span style="color: #0000BB">$form_id</span><span style="color: #007700">, </span><span style="color: #0000BB">$form_state</span><span style="color: #007700">, </span><span style="color: #0000BB">$args</span><span style="color: #007700">, </span><span style="color: #0000BB">$form_build_id</span><span style="color: #007700">);<br> </span><span style="color: #0000BB">$form_portion </span><span style="color: #007700">= </span><span style="color: #0000BB">$form</span><span style="color: #007700">[</span><span style="color: #DD0000">'my_ahah_wrapper'</span><span style="color: #007700">][</span><span style="color: #DD0000">'addable_elements'</span><span style="color: #007700">];<br> unset(</span><span style="color: #0000BB">$form_portion</span><span style="color: #007700">[</span><span style="color: #DD0000">'#prefix'</span><span style="color: #007700">], </span><span style="color: #0000BB">$form_portion</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">$output </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">$form_portion</span><span style="color: #007700">);<br> </span><span style="color: #0000BB">drupal_json</span><span style="color: #007700">(array(</span><span style="color: #DD0000">'status' </span><span style="color: #007700">=> </span><span style="color: #0000BB">TRUE</span><span style="color: #007700">, </span><span style="color: #DD0000">'data' </span><span style="color: #007700">=> </span><span style="color: #0000BB">$output</span><span style="color: #007700">));<br>}<br></span><span style="color: #0000BB">?></span></span>
The form is retrieved from the cache, processed, and rebuilt. During rebuilding, $_POST gets destroyed and the form is re-saved to the cache. You can see that there is no room for alterations to the rendered form that aren't mirrored in the cached form - so, then, where do changes to the form take place? Well, changes happen not in the ahah callback but in the submit handler for the element that triggered the callback. Your form is going to get completely rebuilt, so in the submit handler, which gets called during drupal_process_form
, you can specify exactly how you want it rebuilt. Here's my submit handler for the "Add tab" button in quicktabs:
<span style="color: #000000"><span style="color: #0000BB"><?php<br></span><span style="color: #007700">function </span><span style="color: #0000BB">qt_more_tabs_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> 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">$quicktabs </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> </span><span style="color: #0000BB">$form_state</span><span style="color: #007700">[</span><span style="color: #DD0000">'quicktabs'</span><span style="color: #007700">] = </span><span style="color: #0000BB">$quicktabs</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> if (</span><span style="color: #0000BB">$form_state</span><span style="color: #007700">[</span><span style="color: #DD0000">'values'</span><span style="color: #007700">][</span><span style="color: #DD0000">'tabs_more'</span><span style="color: #007700">]) {<br> </span><span style="color: #0000BB">$form_state</span><span style="color: #007700">[</span><span style="color: #DD0000">'qt_count'</span><span style="color: #007700">] = </span><span style="color: #0000BB">count</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">][</span><span style="color: #DD0000">'tabs'</span><span style="color: #007700">]) + </span><span style="color: #0000BB">1</span><span style="color: #007700">;<br> }<br> return </span><span style="color: #0000BB">$quicktabs</span><span style="color: #007700">;<br>}<br></span><span style="color: #0000BB">?></span></span>
There are two important steps here: we're passing all the form values (what has been entered on the form) to $form_state['quicktabs'] so that when the form-generating function is called, it will check here first to see if there are already values available for the elements it's building; we are also incrementing the number of tabs by 1 and passing this number in $form_state['qt_count'], which is where we tell our form function to check first for the number of tabs to build. [Note: when I'm talking about tabs here I'm referring to sets of elements, where each set corresponds to a tab of content in a final Quick Tabs block - not to be confused with the myriad other meanings of the word in Drupal]. With those in place we know that the entire form can be rebuilt from scratch, will be rendered faithfully on the page, and will contain everything we need it to contain, new elements and all.
Here is the submit handler for the views dropdown on the quicktabs admin form:
<span style="color: #000000"><span style="color: #0000BB"><?php<br></span><span style="color: #007700">function </span><span style="color: #0000BB">qt_get_displays_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> 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">$quicktabs </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> </span><span style="color: #0000BB">$form_state</span><span style="color: #007700">[</span><span style="color: #DD0000">'quicktabs'</span><span style="color: #007700">] = </span><span style="color: #0000BB">$quicktabs</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> return </span><span style="color: #0000BB">$quicktabs</span><span style="color: #007700">;<br>}<br></span><span style="color: #0000BB">?></span></span>
Notice that it doesn't seem to make any change to the form at all - the form is simply going to be rebuilt but with a different selected value for this dropdown (coming from $form_state), and that will change the options in the display dropdown. The views dropdown uses the very same ahah callback function as the "Add tab" button, as does the "Remove tab" button - they only differ in their submit handlers. The code is therefore cleaner, easier to maintain, and most important of all - more secure.
So, despite its dual nature, with its "material" side and its "spiritual" side, the Drupal form can attain perfect oneness when this approach is taken :-)
To see the full Quick Tabs code, visit http://drupal.org/project/quicktabs and download the latest dev snapshot of the 6.x-1.x branch (2.x branch to be updated shortly). To see the form in action, click here. You will need to change the tab type to "View" in order to see the views display dropdown working.
Further Reading:Doing AHAH Correctly in Drupal 6 and beyond