Dynamic and Multipage forms with Form API

Last modified: June 15, 2009 - 17:10

The release of Drupal 4.7 introduced the Form API, a framework for building, displaying, validating, and processing HTML forms. With it, forms are defined as structured arrays, and those structured arrays contain all the information necessary to properly handle the form throughout its life cycle. This approach also makes it possible for modules to customize other forms (adding additional fields to a signup page, for example), and allows designers to customize the on-screen display of forms using overridable theming functions. It also makes validating form input, and avoiding form tampering, much easier. That's great!

The tradeoff of those enhancements was the loss of flexibility in certain complex scenarios -- in particular dynamic forms that change based on user input, and multi-step 'wizard' style forms. With the introduction of Form API 2 in version 5 of Drupal, though, we've eliminated the limitations that made those cases so difficult.

Dynamic Forms: Three Different Scenarios

For the purposes of this article, we'll be looking at three specific kinds of dynamic forms:

  1. The Long Form, a large form divided into multiple steps for ease of use
  2. The Wizard, a form where a user's answers in each step determine the options available in the next
  3. The Form That Builds Itself, a page where some options (clicking 'add more choices' when setting up a poll, for example) add more fields until the user chooses to submit the 'finished product.'

While the first scenerio isn't really dynamic (it simply presents different subsets of options to the user at each step along the way), all of these scenerios work by changing the form depending on the data that the user has just submitted. And that means that all three scenerios encounter the same options in the current Form API. Why is that? Read on...

Form API 1.0

Now that we have a handle on the different kinds of forms we're dealing with, let's take a look at the current state of affairs with Drupal's form-building. It will help us understand where the problem lies.

  1. Your page-building function is called.
  2. It builds a form definition array.
  3. It calls drupal_get_form($form_id, $form), passing in a unique ID for the form and the form's definition array.
  4. drupal_get_form() first checks to see if there is incoming POST data. If there is, and the form_id in that POST data matches the $form_id you passed in, it knows that the user is submitting the form and begins the 'processing' stage.
    1. In the processing stage, drupal_get_form() passes the form array to drupal_validate_form(). Any validation handlers added to specific fields are processed there.
    2. If the validation stage completes and no errors are found, drupal_get_form() hands the form off to drupal_submit_form(). In that stage, data is processed, database changes are made, and so on.
    3. drupal_submit_form() can return a URL to redirect the user to when form processing is done. If it does, drupal_get_form() redirects to that page and the Form API is finished.
  5. If there is no incoming POST data, the processing stage returned validation errors, or no redirect URL was returned by drupal_submit_form(), the form array is passed to drupal_render_form() and the actual HTML is generated.
  6. Your page building function receives the generated HTML from drupal_get_form(), complete with hilighted errors if appropriate, and displays it to the user.
  7. If the user enters in values (either filling them in for the first time, or correcting errors) and clicks the Submit button, the form submits to itself, triggering the page building function in step 1... And the cycle repeats.

The Problem With Dynamic Forms

That system works well for static forms: build the array, check for incoming POST values, validate, submit, and optionally render for display. Since a Form API form always submits to itself, the same definition array is used when first displaying the form, and validating it. Unless, that is, you need to display forms that change...

In that case, you run into the following problem:

  1. Your page building function generates the 'starting point' for your dynamic form.
  2. drupal_get_form() renders it, and displays it.
  3. The user enters their choices and clicks submit.
  4. Your page building function looks at the incoming POST values, and creates a form with new or additional options based on the user's choices.
  5. Your page building function hands the form off to drupal_get_form()... but the fields it contains no longer match the values coming in from the user! Validation functions throw errors, and everything grinds to a halt.

What we really need to do is build two form definition arrays. The first should be a duplicate of the form from step 1, to use during the processing of incoming form values. The second should be the 'new' form from step 4, to display to the user if the first one passes validation.

Form API 2.0: The Solution

In Drupal 5.0, the freshly retooled Form API adds just that: the ability to process incoming values with one form array, while displaying a new form array for user input. Three changes make this possible.

  1. Forms are now built by dedicated functions, and only the form's ID needs to be submitted to drupal_get_form().
  2. These form building functions can accept parameters, like the node object to be edited or a collection of values representing the user's submitted data.
  3. When a form submits to itself, drupal_get_form() can save the parameters used to build the form array. The next time it submits, it will re-use the stored parameters to recreate the first form for processing, then display the newly built form to the user.

The third item in that list is one of the most important: all you need to do is create your form building function, and drupal_get_form() will handle pulling up the right version of the form (one for processing, one for display) during each phase. Because this behavior is only necessary for some forms, drupal_get_form() only stores that information if the #multistep property is set to TRUE in your form definition array.

How to Handle the Three Scenarios

Let's step back for a moment. We now understand the cause of the problem in Form API 1.0: a mismatch between the user's submitted input, and the form array that's build based on it. We also understand how Form API 2.0 fixes that, allowing one form array to be used for processing and another for display. With that in mind, how can your module handle the three dynamic form scenerios outlined at the beginning of the article?

The Long Form

As with all of these scenerios, the real action will happen in your form building function. It will use hidden fields to indicate what 'stage' is currently being displayed, and to store the user input from previous stages. While this was theoretically possible using Form API 1.0, the new features make it much simpler. Here's an example of how your form-building code would look:

<?php
function my_form($param1, $param2, $form_values = NULL) {
 
// In a multistep form, drupal_get_form() will always
  // pass the incoming form values to you after any other
  // parameters that you specify manually. Do this instead
  // of looking at the incoming $_POST variable manually.

 
if (!isset($form_values)) {
   
$step = 1;
  }
  else {
   
$step = $form_values['step'] + 1;
  }
 
 
$form['step'] = array(
   
'#type' => 'hidden',
   
'#value' => $step,
  );

  switch (
$step) {
    case
1:
     
// Create the fields for the first step of your form here
     
break;

    case
2:
     
// First, add a hidden field for each of the incoming
      // form values.
      // Then, add the fields regular form fields that the user
      // will see in this second step.
     
break;
   
    case
3:
     
// And so on and so forth, until you've reached the final
      // step.
     
break;
  }
 
 
// This part is important!
 
$form['#multistep'] = TRUE;
 
$form['#redirect'] = FALSE;
 
 
$form['submit'] = array(
   
'#type' => 'submit',
   
'#value' => t('Submit'),
  );

  return
$form;
}
?>

In the validation code for your form, you can check the 'step' field to see which set of fields you need to check, and display any errors. The function will keep accumulating hidden values and displaying a new set of fields until it reaches the final step. You'll probably want to prevent your form submission handler from processing the form data until all the steps have been completed. To do that, something like this would be effective:

<?php
function my_form_submit($form_id, $form_values) {
 
$final_step = 10;
 
  if (
$form_values['step'] == $final_step) {
   
// Process the form here!
 
}
}
?>

The Wizard

This scenerio, from a code perspective, is almost exactly the same as The Long Form. Depending on how your Wizard works, though, you may want to have your form submission handler actually process each step's data, rather than storing it as hidden fields in the next step. Or, you may want to process 'batches' of steps together before proceeding. For example:

<?php
function my_form_submit($form_id, $form_values) {
  switch (
$form_values['step']) {
    case
3:
     
// Process the form data accumulated in the first three steps
     
break;
    case
9:
     
// Process the form data accumulated in steps 4 through 9
     
break;
    case
10:
     
// Process the form data from step 10...
     
break;
  }
}
?>

For very complex wizards, you may also want to split out individual steps as helper functions, but you'll still need to use the 'core' function as the central dispatcher. It's the one that the Form API knows to call automatically during the building and processing stages.

The Form That Builds Itself

The final scenerio is a bit trickier. Rather than dividing the form submission process into discrete steps, it involves a user making choices that continue to change the form (by adding new fields, in most cases) until they're happy with the results. How would we handle it?

In the example below, our hypothetical module is displaying a form that allows a user to create a quiz. Some fields, like the title and description of the quiz, will always be present. The first three slots for 'quiz questions' will always be available, too. But if the user selects the 'Give me more questions' checkbox, it will build the form with three more boxes.

<?php
function my_form($param1, $param2, $form_values = NULL) {
 
// Build the fields that stay the same from form to form...
  // And populate the default_values for each field with the
  // corresponding entry from $form_values
 
$form['title'] = array(
   
'#type' => 'textfield',
   
'#title' => t('Quiz title'),
   
'#default_value' => isset($form_values) ? $form_values['title'] : '',
  );

 
// The current number of questions, plus three more if
  // the user requested them.
 
if (isset($form_values)) {
   
$question_count = $form_values['question_count'];
   
    if (
$form_values['more_questions'] == 1) {
     
$question_count = $question_count + 3;
    }
  }
  else {
   
$question_count = 3;
  }
 
 
$form['question_count'] = array(
   
'#type' => 'hidden',
   
'#value' => $question_count,
  );

 
// We'll loop from 1 to n, where n is the current number of questions to
  // be displayed. We'll automatically populate each question with any data
  // that was entered in the previous trip through the form.
 
for ($i = 1; $i <= $question_count; $i++) {
   
$form['question_' . $i] = array(
     
'#type' => 'textfield',
     
'#title' => t('Question !count', array('!count' => $i)),
     
'#default_value' => isset($form_values) ? $form_values['question_' . $i] : '',
    );
  }

 
$form['more_questions'] = array(
   
'#type' => 'checkbox',
   
'#title' => t('Give me more questions'),
   
'#return_value' => 1,
  );

 
// This part is important!
 
$form['#multistep'] = TRUE;
 
$form['#redirect'] = FALSE;
 
 
$form['submit'] = array(
   
'#type' => 'submit',
   
'#value' => t('Submit'),
  );

  return
$form;
}

function
my_form_submit($form_id, $form_values) {
  if (
$form_values['more_questions'] == 1) {
   
// Don't process the form. We're rebuilding it with more question fields.
 
}
  else {
   
// Loop through all of the questions
   
for ($i = 1; $i <= $form_values['question_count']; $i++) {
     
$current_question = $form_values['question_' . $i];
     
// Process $current_question
   
}
  }
}
?>

The above code is a bit more complicated than the previous scenerios, but what it's trying to accomplish is pretty simple. When it first displays, it presents the user with a Title field and three empty question fields. It also has a hidden field storing the current number of questions, and a checkbox indicating that the user wants more options.

The form submission function checks to make sure that the user isn't in the process of adding more options before trying to process anything. When they finally submit without that checkbox selected, it loops through the list of question fields and saves each one individually.

The Wrapup

What have we learned? With the new features in Form API 2.0, it's possible to create forms that change each time a user submits them, validating as they go, and processing the results at arbitrary points along the way. There are different approaches to this task, depending on what kind of user interface your form requires, but most depend on storing information about the form in hidden fields, and using it to control how the next step is built.

Large, complex forms can lead to large, complex code. If you run into challenges, or can't get something working, remember to break it down into pieces and trace your way through the Form API workflow. Make sure you're doing the right thing at the right time, and that the values you expect to be getting in your builder function are arriving properly.

Happy coding!

Ok, I do not use node

xell - February 8, 2007 - 08:11

but I still have a problem.

If I declare my form function as mymodule_form($form_values = NULL), it is ok. If I declare my form function as mymodule_form($param, $form_values = NULL), it cannot get $form_values. Actually all the form values are in the first parameter: $param.

// In a multistep form, drupal_get_form() will always
// pass the incoming form values to you after any other
// parameters that you specify manually. Do this instead
// of looking at the incoming $_POST variable manually.

How to understand the comment above??

short example

wojtha - February 8, 2007 - 12:55

<?php
function mymodule_edit_record($record) {
 
$output = 'This is my edit page!';
 
$output .= drupal_get_form('mymodule_edit_record_form', $record);
  return
$output;
}

function
mymodule_edit_record_form($record,$form_values=NULL) {
 
// your multistep or dynamically built form ...
?>

Example was taken from http://drupal.org/node/64279#drupal-get-form and little bit edited

--
wojtHA

slight bug

rorysolomon - April 4, 2007 - 23:01

In the example under the first section, "The Long Form," where the comment instructs to "add a hidden field for each of the incoming form values," you probably want to do this for all incoming values except the "step" variable -- since setting that value in the form array here will overwrite the value that you've set for it several lines up. This will prevent your form from progressing past the 2nd stage.

Steps are processed more than once

KarenS - April 14, 2007 - 11:52

I was struggling to create a complex multistep form that was trying to trigger some actions based on the step the form was at, like to display tailored messages for the step at hand, and kept running into problems when the wrong messages were displayed. After digging around I found that what actually happens in multistep forms was different than I was expecting, and understanding that makes it easier to figure out how to design them.

So if I have a 3 step form, when I process step 1, everything I wrote in the step 1 section happens. No problem.

When I process step 2, first step 1 is recalled, which triggers everything in step 1, and then step 2 is processed, which triggers everything in step 2.

When I process step 3, first step 2 is recalled, which triggers everything in step 2, and then step 3 is processed, which triggers everything in step 3.

So if you do things like drupal_set_message() with different messages in different steps, you'll see bizarre results. I found a thread somewhere where Jeff explained that the solution was to use form_set_error() instead of drupal_set_message(), but in some cases it's not an error, it's really a message.

Anyway, my solution in part was to put things that should only happen once into the submit function instead of the form function, since the submit actually does only happen once. So a message for step 2 can go into the submit for step 1, etc. etc.

The other thing I'm doing in some cases is creating a $_SESSION variable that keeps track of where I am, so I can use it outside the form itself to take action.

multiple steps processed

jztinfinity - March 26, 2009 - 18:36

Are you sure you included all the breaks in your case statements and isolated each step into its own conditional? Try var-dumping the step variable at various stages. If the step variable is one value it shouldn't be possible to render other steps. Course, everything is possible.

Never mind about my solution, since I'm dealing with the same issue, well, not quite the same, but same enough that I lack confidence to recommend

Hidden vs Value

kingandy - April 21, 2009 - 13:39

I'm still getting my head around this crazy form-building business, but as I understand it the form will be passed through the form-building function - with the original values that were passed to it - before the "validate" and "submit" functions are called.

(For example: With a three-step form, when you click 'submit' on step 2, the form building function is first called using the $form_values submitted from step 1, before moving onto validate and submit using the $form_values from step 2. Then, after validation and submission, the form building function is called again with the $form_values from step 2, in order to present 3. This makes total sense, as you want to validate the values from step 2 against the form that was built for step 2, but still manages to bend my head whenever I think about it.)

Since the form building function runs, you don't actually need to include the values from the previous as hidden fields - you can store them in the form with a #type of value. These values will behave exactly as the hidden fields, but get processed entirely serverside, whereas hidden fields have to be passed to the browser and back again. Seeing as these values aren't changing, this is basically a waste of bandwidth. (Especially since the code above uses '#value' instead of '#default_value' - anything actually submitted by the hidden field will be overridden by the form-building function anyway, making the whole affair moot.)

To preserve a question_count field as above, simply change 'hidden' to 'value' as follows:

<?php
  $form
['question_count'] = array(
   
'#type' => 'value',
   
'#value' => $question_count,
  );
?>

On a final note - this does not hold true for the 'step' value. Apparently part of the multi-step logic relies on the 'step' field being present as a $_POST variable, so it needs to be passed to the browser and submitted back.

 
 

Drupal is a registered trademark of Dries Buytaert.