Dev Tools part 2: multi-step forms in dialog modal

I recently introduced the DevTools module, and today I'm going to show you one of my favourite components, dialog, which builds upon the Dialog module (fear not the 7.x-dev version, it's perfectly stable) and allows you to present single- or multi-state forms in a modal.

First and foremost, a small disclaimer: the dialog component was built for use with custom forms, and may not have expected results with forms from core modules.

The plan

In the example module we're about to write, we're going to create a page with a link that will open a custom form in a dialog, proceed through three form states, and upon successful submission of the last state, update the original link with new text.

Getting started

The first thing we're going to do is download & enable the DevTools and Dialog modules, then (in a custom module) load DevTools, and create a menu callback for the page which will contain a link that'll trigger the modal:

// example.module

if (module_exists('devtools')) {
  devtools_load();
}

/**
 * Implements hook_menu().
 */
function example_menu() {
  $base = array(
    'file' => 'example.pages.inc',
    'file path' => drupal_get_path('module', 'example'),
  );

  $items['dialog-example'] = array(
    'title' => 'Dialog Example',
    'page callback' => 'dialog_example_page',
    'access callback' => TRUE,
  ) + $base;

  return $items;
}

As you can see, we're creating a page at the /dialog-example URI, specifying the dialog_example_page() function (in example.pages.inc) as the callback, and granting it unrestricted access.

The page callback will look as follows:

// example.pages.inc

/**
 * Menu callback for /dialog-example
 */
function dialog_example_page() {
  $output = '';

  drupal_add_library('dialog', 'dialog');

  $trigger_id = 'example-dialog-trigger';

  $output .= l(t('Open dialog'), 'dialog-example/form/start/' . $trigger_id, array(
    'attributes' => array(
      'class' => array(
        'use-ajax',
        'use-dialog',
      ),
      'id' => $trigger_id,
    ),
  ));

  return $output;
}

In the callback we're loading the Dialog library, and then creating a unique (per page) ID for the link that'll trigger the modal to open (this is necessary so that we can easily target the link for HTML replacement on final form submission) and to make the form aware of this ID, we pass it as the last part of the URI.

The /dialog-example/form URI will be created in our hook_menu(), and the start part of the URI specifies the initial form state that should be loaded (more on this later). You could add additional arguments to reference the content you're dealing with, user, or anything else that may be useful to your form. The only remaining tidbits is the assignment of use-ajax and use-dialog classes on the link, both of which are required.

On to the hook_menu() for the addition of our new /dialog-example/form URI:

// example.module

if (module_exists('devtools')) {
  devtools_load();
}

/**
 * Implements hook_menu().
 */
function example_menu() {
  $base = array(
    'file' => 'example.pages.inc',
    'file path' => drupal_get_path('module', 'example'),
  );

  $items['dialog-example'] = array(
    'title' => 'Dialog Example',
    'page callback' => 'dialog_example_page',
    'access callback' => TRUE,
  ) + $base;

  $items['dialog-example/form'] = array(
    'title' => 'Dialog Form',
    'page callback' => 'dialog_example_form_ajax_delivery',
    'page arguments' => array(2, 3),
    'access callback' => TRUE,
    'type' => MENU_CALLBACK,
  ) + $base;

  return $items;
}

The dialog_example_form_ajax_delivery() function will handle the delivery of the AJAX response, while passing through arguments 2 (form state) and 3 (trigger id) to the form.

dialog_example_form_ajax_delivery() is defined as follows:

/**
 * AJAX menu callback for /dialog-example/form
 * @param  [type] $state      Requested form state.
 * @param  [type] $trigger_id DOM id of AJAX-triggering element.
 * @return [type]             [description]
 */
function dialog_example_form_ajax_delivery($state, $trigger_id) {
  $commands = dialog_show_form('dialog_example_form', $state, $trigger_id);

  ajax_deliver(array('#type' => 'ajax', '#commands' => $commands));
}

dialog_show_form() is, in simplified terms, a wrapper for Drupal's drupal_build_form() function, and will pass on all arguments following the form id (in our case, dialog_example_form) to the form builder.

The form

Our $form will be structured as three top-level state containers (beginning with the state_ prefix), and a series of child elements for each state. The $form_state will be updated to contain various settings, and finally, $form and $form_state will be processed by dialog_form_prepare().

/**
 * The example form itself.
 * @return [type] [description]
 */
function dialog_example_form($form, &$form_state, $state, $trigger_id) {
  $form = array();

  // start state
    $form['state_start']['name'] = array(
      '#title' => t('Name'),
      '#type' => 'textfield',
      '#required' => TRUE,
    );

    $form['state_start']['submit'] = array(
      '#type' => 'submit',
      '#value' => t('Continue'),
    );

  // second state
    $form['state_title']['title'] = array(
      '#title' => t('Title'),
      '#type' => 'textfield',
    );

    $form['state_title']['submit_checkbox'] = array(
      '#type' => 'checkbox',
      '#title' => t('Continue'),
    );

  // third state
    $form['state_final']['title'] = array(
      '#title' => t('What step is this?'),
      '#type' => 'radios',
      '#options' => array(
        '1' => t('First'),
        '2' => t('Second'),
        '3' => t('Third'),
      ),
    );

    $form['state_final']['submit'] = array(
      '#type' => 'submit',
      '#value' => t('Final submit'),
    );

  // dialog configuration
    $dialog_common_options = array(
      'width' => '700px',
      'position' => 'center',
    );

  // state configuration
    $state_common_options = array(
      'close_link' => TRUE,
      'close_link_text' => t('Cancel'),
      'close_link_class' => array(
        'class-one',
        'class-two',
      ),
    );

    $form_state['dialog_settings'] = array(
      'current_state' => $state,
      'trigger_id' => $trigger_id,
      'states' => array(

        'start' => array(
          'dialog' => array('title' => t('First state')) + $dialog_common_options,
          'submit' => array(
            'submit' => array(
              'element' => &$form['state_start']['submit'],
              'next_state' => 'title',
            )
          ),
        ) + $state_common_options,

        'title' => array(
          'dialog' => array('title' => t('Second state')) + $dialog_common_options,
          'close_link_text' => t('Different Cancel Text'),
          'submit' => array(
            'submit_checkbox' => array(
              'element' => &$form['state_title']['submit_checkbox'],
              'next_state' => 'final',
            )
          ),
        ) + $state_common_options,

        'final' => array(
          'dialog' => array('title' => t('Third state')) + $dialog_common_options,
          'close_link' => FALSE,
          'submit' => array(
            'submit' => array(
              'element' => &$form['state_final']['submit'],
            ),
          ),
          'submit_autoclose' => TRUE,
        ) + $state_common_options,

      ),
    );

  dialog_form_prepare($form, $form_state);

  return $form;
}

The form declares the three top-level state containers and their children as regular FAPI elements. Each state has a child element that will be used to submit that particular state (for example's sake, the second state uses a checkbox).

Finally, we're going to add validation & submit handlers with drupal_set_message() statements to see how our form submissions are processing:

/**
 * Validation handler for dialog_example_form().
 */
function dialog_example_form_validate($form, &$form_state) {
  $state = $form_state['dialog_settings']['current_state'];

  drupal_set_message(t('Validate for state: !state', array('!state' => $state)));
}

/**
 * Submit handler for dialog_example_form().
 */
function dialog_example_form_submit($form, &$form_state) {
  $state = $form_state['dialog_settings']['current_state'];

  drupal_set_message(t('Submit for state: !state', array('!state' => $state)));
}

The dialog settings configured in $form_state specify each state, its title and an array of options passed to the actual jQuery Dialog plugin. Furthermore, the submit key specifies an array of elements which trigger the state's form submission. The element key should contain a reference to the element within $form, while the optional next_state key specifies the next form state to load (this could be used to jump to any state); since this key is optional, a single form can be used for multiple "stand-alone" form states.</code>

By default, a 'Cancel' link is appended to each form state, but this can be disabled with the close_link key, and its text can be changed by using close_link_text.

Some additional options include: submit_autoclose, submit_redirect (should be an absolute URL), and submit_js_callback, which takes an array with behavior and method keys that specify the JS method to call upon successful form submission. For example, the values

'submit_js_callback' => array(
  'behavior' => 'example',
  'method' => 'form_submit_js_callback',
),

will result in Drupal.behaviors.example.form_submit_js_callback() being called.

If you're interested in seeing the internal details of how DevTools' Dialog component works, have a look at devtools/components/dialog/dialong.inc.

This wraps up the process of creating multi-step forms in modals with quite a bit of additional functionality. If you have any questions, hit the comments below!