Intro to Drupal 6 Multistep Form Domination Using Chaos Tools

AttachmentSize
wombat.zip6.47 KB

The mere words "multistep form" once gave me a feeling of dread. There are several techniques (arguably hacks) that enable multistep forms in drupal 6. However, if you've ever used them, you'll know that they are a not techniques for the faint of heart.

While Merlinofchaos's multistep form wizard is not for the faint of heart either, I will say I found programming the forms to be fun. The setup takes a bit of focus, but after that, writing the steps is almost too easy.

Chaos Tool's wizard.inc is distinct:

wombat wizard
  1. See that stepper and buttons in the above image? You never will need to micromanage what step your form is on using the wizard. If you set up the form, it figures that sort of stuff out for you. Since it knows about stuff like "$step", its perfectly happy to figure out whether it should display a next, back, or finish button. I like code that works for me.
  2. Every step in a form is a distinct form_id, that has its own #submit, #validate, and(god knows what else can be done with it via form api). Within the steps, you should never need to think any harder than you would writing a simple form with a message, and an email address. Proof can be found below in the actual form arrays, and the submit functions.
  3. There are thousands of ways you can mess up a multistep form -- in a sense, the wizard lets you make your biggest mistakes in one $form_info array, while keeping the majority of the code (the formapi arrays, and form processing/validation functions) in small, easy to understand units that anyone with even intermediate formapi knowledge could work with.
  4. This wizard DOES power a number of complicated multistep forms that you may use every day - the multistep forms in panels in particular. In a way, its just the most recent chapter of Earl Miles vs. Drupal's formapi -- a saga that has gone on since version 4.7. [come to think of it, i think lullabot needs to make a feature film about that epic story]

Interested? Start Here

Here's a Live Demo of the Wombat Deployment tool I wrote that uses the wizard. Only impressive in how easily it was written, and how easily i could write a 3rd, 4th, 5th, or 20 steps following the same pattern [ the subject of future posts are hinted in a rogue modal.inc file in the download.]

To get started building multistep forms, follows these steps.

  1. Download Chaos Tools
  2. Advanced Help. If you start futzing with this, you'll want the docs merlinofchaos wrote on the wizard. They are available through this module only.
  3. My example module, the wombat tool to rule them all!
  4. check back in a few for all the corrections

The full code, and detailed doc on the $form_info array will only be made available to freaks who click the "read more" link.

Setting Up The Wizard

/*----- PART I CTOOLS WIZARD IMPLMENTATION ----- */
/**
 * menu callback for the multistep form 
 */
function wombat_wizard() { 
  // merlin hints that there's a better way to figure out the step
  $step = arg(1);
  // required includes for wizard
  ctools_include('wizard');
  ctools_include('object-cache');
  
  // *** SETUP ARRAY multistep setup **** 
 // these are defined in some docs at end of article
  $form_info = array(
    'id' => 'wombat_basic',
    'path' => "wombat/%step",
    'show trail' => TRUE,
    'show back' => TRUE,
    'show cancel' => true,
    'show return' =>false,
    'next text' => 'Proceed to next step',
    'next callback' =>  'wombat_basic_add_subtask_next',
    'finish callback' => 'wombat_basic_add_subtask_finish',
    'return callback' => 'wombat_basic_add_subtask_finish',
    'cancel callback' => 'wombat_basic_add_subtask_cancel',
   // this controls order, as well as form labels
    'order' => array(
      'create' => t('Step 1: Create Wombat'),
      'deploy' => t('Step 2: Deploy Wombat'),
    ),
   // here we map a step to a form id.
    'forms' => array(
      // e.g. this for the step at wombat/create 
      'create' => array(
        'form id' => 'wombat_add_form'
      ),
      'deploy' => array(
        'form id' => 'wombat_deployment_form'
      ),
    ),
  );
  
  // *** SETTING THE FORM UP FOR MULTISTEP *** //
  $form_state = array(
    'cache name' => NULL,
  );
  // no matter the step, you will load your values from the callback page
  $wombat = wombat_basic_get_page_cache(NULL);
  if (!$wombat) {
    // set form to first step -- we have no data
    $step = current(array_keys($form_info['order']));
    $wombat = new stdClass();
    // all battlewombats are fuzzy, fyi
    $wombat->fur_texture = 'Very fuzzy';
    // ** set the storage object so its ready for whatever comes next
    ctools_object_cache_set('wombat_basic', $form_state['cache name'], $wombat);
  }
  //THIS IS WHERE WILL STORE ALL FORM DATA
  $form_state['wombat_obj'] = $wombat;
  
  // and this is the witchcraft that makes it work
  $output = ctools_wizard_multistep_form($form_info, $step, $form_state);
  return $output;
}

Here's Earl Miles' docs on the $form_info array (found in the advanced help of chaos tools)

id
An id for wizard. This will primarily be used for things like trail theming.
path
The path to use when redirecting between forms. %step will be replaced with the key for the form.
return path
When a form is complete, this is the path to go to. This is required if the 'return' button is shown and not using AJAX. It is also used for the 'Finish' button. If it is not present and needed, the cancel path will also be checked.
cancel path
When a form is canceled, this is the path to go to. This is required if the 'cancel' is shown and not using AJAX.
show trail
If set to TRUE, the form trail will be shown like a breadcrumb at the top of each form. Defaults to FALSE.
show back
If set to TRUE, show a back button on each form. Defaults to FALSE.
show return
If set to TRUE, show a return button. Defaults to FALSE.
show cancel
If set to TRUE, show a cancel button. Defaults to FALSE.
back text
Set the text of the 'back' button. Defaults to t('Back').
next text
Set the text of the 'next' button. Defaults to t('Continue').
return text
Set the text of the 'return' button. Defaults to t('Update and return').
finish text
Set the text of the 'finish' button. Defaults to t('Finish').
cancel text
Set the text of the 'cancel' button. Defaults to t('Cancel').
ajax
Turn on AJAX capabilities, using CTools' ajax.inc. Defaults to FALSE.
modal
Put the wizard in the modal tool. The modal must already be open and called from an ajax button for this to work, which is easily accomplished using functions provided by the modal tool.
ajax render
A callback to display the rendered form via ajax. This is not required if using the modal tool, but is required otherwise since ajax by itself does not know how to render the results. Params: &$form_state, $output.
finish callback
The function to call when a form is complete and the finish button has been clicked. This function should finalize all data. Params: &$form_state.
cancel callback
The function to call when a form is canceled by the user. This function should clean up any data that is cached. Params: &$form_state.
return callback
The function to call when a form is complete and the return button has been clicked. This is often the same as the finish callback. Params: &$form_state.
next callback
The function to call when the next button has been clicked. This function should take the submitted data and cache it for later use by the finish callback. Params: &$form_state.
order
An array of forms, keyed by the step, which represents the default order the forms will be displayed in. Note that submit callbacks can override the order so that branching logic can be used.
forms
An array of form info arrays, keyed by step, describing every form available to the wizard. Each array contains:
form id
The id of the form, as used in the Drupal form system. This is also the name of the function that represents the form builder.
include
The name of a file to include which contains the code for this form. This makes it easy to include the form wizard in another file or set of files. This must be the full path of the file, so be sure to use drupal_get_path() when setting this. This can also be an array of files if multiple files need to be included.
title
The title of the form, to be optionally set via drupal_get_title. This is required when using the modal if $form_state['title'] is not set.

Behold -- it looks like regular formapi!


/*-------------------------- The Two Form Steps  ---------------------- */

/**
 * All forms within this wizard will take $form, and $form_state by reference
 * note that the form doesn't have a return value.
 */
function wombat_add_form(&$form, &$form_state) {
  $wombat = &$form_state['wombat_obj'];
  $form['name'] = array(
    '#type' => 'textfield',
    '#required' => 1,
    '#title' => 'Wombat name',
    '#default_value' => $wombat->name,
  );
  $form['temperment'] = array(
    '#title' => 'Temperment',
    '#type' => 'radios',
    '#required' => 1,
    '#default_value' => $wombat->temperment,
    '#options' => array(
      'docile' => 'Docile',
      'irritable' => 'Irritable',
      'dangerous' => 'Dangerous',
    )
  );
  // probably important -- i'm continuing to investigate
  $form_state['no buttons'] = TRUE; 
}

/**
 * A Pretty typical form validate callback
 */
function wombat_add_form_validate(&$from, &$form_state) {
  if ($form_state['values']['name'] == 'billy') {
    form_set_error('name', 'No wombat is allowed to be named billy!');
  }
}

/**
 * KEY CONCEPT: generally, you will never save data here -- you will simply add values to the 
 * yet to be saved object being fed into the ctools cache
 * 
*  Using this pattern -- you will likely want to save within $form_info['finish callback'];
 */
function wombat_add_form_submit(&$from, &$form_state) {
  $submitted = $form_state['values'];
  $save_values = array('name', 'temperment'); 
  // maybe don't imitate this foreach
  foreach($save_values as $value) {
    // set the values in the cache object -- it gets passed back to the next step
   // because of all that work we did in the form_info array
    $form_state['wombat_obj']->$value = $submitted[$value];
  }
}

/* step two is identical more or less */ 

function wombat_deployment_form(&$form, &$form_state) {
  $wombat = &$form_state['wombat_obj'];
  $dutys = array(
    'fuzzball' => 'Provide a fuzzball',
    'noise-maker' => 'Make irritating noises'    
  );
  /* we give different duty options based on wombat temper */
  switch($wombat->temperment) {
    case 'docile':
      // only docile wombats allow themselves to become fatballs
      $dutys['fatball'] = 'Sit around and be fat';

      break;
    case 'irritable':
      $dutys['menace'] = 'Basic Menace Duties';
      break;
    case 'dangerous':
      $dutys['menace'] = 'Basic Menace Duties';
      $dutys['scratch'] = "Scratch warfare";
      $dutys['bite'] = "Bite warfare";
      break;
  }
  $form['name'] = array(
    '#type' => 'item',
    '#title' => 'Wombat name',
    '#value' => $wombat->name,
  );
  $form['temper'] = array(
    '#type' => 'item',
    '#title' => 'Temperment',
    '#value' => $wombat->temperment,
  );
  $form['duties'] = array(
    '#type' => 'checkboxes',
    '#tree' => true,
    '#options' => $dutys,
    '#title' => 'Duties',
    '#value' => $wombat->duties,
    '#required' => 1,
  );
  $form_state['no buttons'] = TRUE; 
}


/**
 * Same idea as previous steps submit
 */
function wombat_deployment_form_submit(&$form, &$form_state) {
  $form_state['wombat_obj']->duties = $form_state['values']['duties'];
}

Details below

These are the callback functions fire when someone clicks a button and the form passes validation.

/*----PART 3  FORM BUTTON CALLBACKS   ---------------------- */

/**
 * Callback generated when the multistep form is complete
 * this is where you'd normally save. in this case, drupal_set_message just squaks something
 */
function wombat_basic_add_subtask_finish(&$form_state) {
  $wombat = &$form_state['wombat_obj'];
  drupal_set_message('Wombat '.$wombat->name.' successfully deployed. Wombat will be assigned the following duties: '.implode(",", $wombat->duties)).'.';
  // Clear the cache
  wombat_basic_clear_page_cache($form_state['cache name']);
  $form_state['redirect'] = 'wombat';
}

/**
 * Callback for the proceed step
 *
 */
function wombat_basic_add_subtask_next(&$form_state) {
  // get wombat
  $wombat = &$form_state['wombat_obj'];
  // set wombat in cache... pretty simple
  $cache = ctools_object_cache_set('wombat_basic', $form_state['cache name'], $wombat);
}

/**
 * Callback generated when the 'cancel' button is clicked.
 *
 * All we do here is clear the cache. 
* redirect them to where they started 
* and call them a coward
 */
function wombat_basic_add_subtask_cancel(&$form_state) {
  ctools_object_cache_clear('wombat_basic', $form_state['cache name']);
  $form_state['redirect'] = 'wombat';
  drupal_set_message('Coward.');
}

/*----PART 4 CTOOLS FORM STORAGE HANDLERS -- these usually don't have to be very unique  i think some of them are unused.. [ :- ) ]---------------------- */

/**
 * Remove an item from the object cache.
 */
function  wombat_basic_clear_page_cache($name) {
  ctools_object_cache_clear('wombat_basic', $name);
}

/**
 * Get the cached changes to a given task handler.
* (Earl wrote that, not me...)
 */
function wombat_basic_get_page_cache($name) {
  $cache = ctools_object_cache_get('wombat_basic', $name);
  return $cache;
}

This is about as *fresh* techniques come. I've only figured it out as far as writing out a very generalized example to build from. I'm sure many of you have a better idea of how such a pattern should be structured. Or perhaps you're completely lost (probably my fault). In any case, bringing a process to organize Drupal's gaggle of disconnected forms into something monkey brains can comprehend is * IMPORTANT*. I think multistep forms would be everywhere if they weren't so difficult to do. I think this tool is the practical way to tackle those difficulties. I actually enjoy writing multistep forms using this tool. I never thought i'd say that...

The API of wizard.inc is TBD. On IRC Merlin noted that he would have put more effort into the DX of the wizard had he not been the only person using it. Obviously, that conversation should move to move to the ctools queue.

Note

*Most muppets can be rid of by clearing all caches.... ... but not all...