Checkboxes in forms step by step

Submitted by Frederic Marand on

For some reason or another, I've noticed several new Drupal developers these last few days sweating on Forms API, and thought it would be nice to have a smallish example to complement the unavoidable FAPI reference and Guick start guide, for a typical non-basic form: one including set of checkboxes in a table, with a customized display, like the core user, content or modules administration forms. So follow me while we build this example.

The goal

We want to have a form containing a set of rows, displaying a checkbox with its label and the actual value stored when it is checked.

Oh, and we want it reversed. That is, the checkbox label will be on the right on a western display, not on the left as they are by default, and without using RTL tricks.

Basics

First we'll need an info file. I'll leave it to you; just don't forget it.

And a module file. And we'll have to enable them. D'oh !

The start of our module is unremarkable, except for the fact that we'll be following the OSInet coding style, not the standard Drupal style, for better readability:

<?php
/**
 * Demonstrate a few mildly advanced topics with Forms API
 *
 * @version $Id$
 * @author FG Marand
 * @license GPL 2 or later
 */

function form_demo_menu($may_cache)
  {
  if (
$may_cache)
    {
   
$items[] = array
      (
     
'path'               => 'form_demo',
     
'callback'           => 'drupal_get_form',
     
'callback arguments' => array('form_demo_form'),
     
'access'             => user_access('access content'),
     
'title'              => t('Form demo'),
      );
    }
   
  return
$items;
  }
?>

Building the form

So, as we've seen in form_demo_menu, we use drupal_get_form as the callback to invoke our actual form builder function. Nothing revolutionary, this is typical form generation:

<?php
/**
 * Just a simple form
 *
 * @return array
 */
function form_demo_form()
  {
 
$form = array();
 
 
$form['fd_boxes'] = array
    (
   
'#type'            => 'checkboxes',
   
'#options'         => array
      (
     
15 => 'a',
     
20 => 'b',
     
25 => 'c',
      ),
   
'#default_value'   => variable_get('fd_boxes', 0),
    );
   
 
$form['submit'] = array
    (
   
'#type'  => 'submit',
   
'#value' => t('Test'),
    );
   
  return
$form;
  }
?>

As you can see, nothing groundbreaking here: just a "checkboxes" widget, and a submit button.

Storing data

Now we need to store the data from this form, which is not done by default if we are not using system_settings_form. A quick check to the FAPI reference and guide show us we can simply implement a form_demo_submit function implementing this storage, and it will take two parameters: the form_id and the form_value containing our actual data.

A quick look at $form_value shows us it only contains the actual keys we need to store, not the captions of the checkboxes, which is quite fine. What it does show us, thought, is a few other elements:

  • Our own submit button
  • Three builtin elements from FAPI: form_id, op, and form_token

Although all of these are necessary, we certainly don't want to store them, so we can safely unset their keys from the form_values array, since it is passed by value:

<?php
function form_demo_form_submit($form_id, $form_values)
  {
 
/**
   * Look at form_values: it only contains the keys to the fd_boxes array:
   * We're not supposed to use the captions for storage
   */
  // dsm($form_values);
 
  // Exclude unnecessary elements. This one comes from our form:
 
unset($form_values['submit']);
 
 
// But these ones are put in by Forms API and always there
 
unset($form_values['form_id'], $form_values['op'], $form_values['form_token']);

 
// To be continued ...
?>

At this point, our submit handler only sees the actual elements to be saved, which in this case means the array row containing the selected options for our fd_boxes set of checkboxes. Let's store it.

<?php
 
// ... continuing
 
foreach ($form_values as $key => $element)
    {
   
variable_set($key, $element); // Serialized as necessary, so it will store an array  
   
}
  }
?>

Of course, we could have just saved it directly and ignored this unsetting and looping, but by coding it that way, you'll be able to reuse that piece of code unchanged.

Theming the form

How it works

The FAPI Quick start guide touches only lightly on the subject, but this is the actually interesting/unusual part: FAPI uses the theme layer to display forms, and will automagically invoke theme_form_demo_form to render a form bearing form_demo_form as its form_id.

So how does it work, from the application programmer's point of view ? Well, the key function here is drupal_render. It will recursively render the form element we pass it, and flag the element with the FAPI-internal #printed property to avoid rendering it another time if it comes to be invoked again. In effet, once an element has been drupal_render'ed, it's as if it was no longer in the form array.

The CSS touch

By default, table and checkbox display in most themes won't fit our needs, so we'll start by adding some local CSS for this specific form. Since it's only for one demo form, there's no need to put it in the CSS handling system:

<?php
function theme_form_demo_form($form)
  {
 
/*
   * First we need to override some builtin styles for our form table:
   * - the 100% width on tables
   * - the display:block on form-item divs containing checkboxes
   */
 
drupal_set_html_head
   
('
    <style type="text/css">
      table.special-checkboxes  
        {
        width: auto; /* Remove the 100% width default in many themes */
        }
      table.special-checkboxes div.form-item
        {
        display: inline;
        }
      table.special-checkboxes th.select-all
        {
        text-align: right;
        padding-right: 0.5em;
        }
      </style>
    '
);
?>

We'll apply class special-checkboxes to our table, which make a perfect CSS selector for the table. The checkboxes are contained in DIVs with class form-item, which by default has display: block, which we need to override.

The JS touch

And what about th.select-all, I hear you asking ? Well this is the default class for a neat feature in Drupal 5 and later: a Javascript checkbox handling selection for all rows in the table where it belongs, and the checkboxes they can contain.

So it is time to activate it in the header for our table, which we build according to the theme_table specification, and declare our rows array. Just two column: one for the values, the other for the checkboxes and their labels on the left.

<?php
  $arHeader
= array
    (
   
t('The option values'),
   
theme('table_select_header_cell'),
    );
 
$arData = array();
?>

Building the table rows

And since headers only get us so far, it's time for the actual data. theme_table uses the data as an array or rows, in which each row contains the individual cells. The cells, in turn, are either plain strings and displayed as such, or arrays themselves, in which the row indexed by key data is the string to be displayed, and other rows are passed as attributes of the TD element.

For this, we'll want to render/theme all the elements resulting from the checkboxes widget we declared, and which doesn't happen to exist in (X)HTML.

But $form['fd_boxes'] contains a ton of properties in addition to the data we actually need display, namely its individual children checkboxes and their labels and values !

This is where element_children comes in : it will return the keys to the children of any given form element, without the properties, which in this case are just "noise" for us. We can then loop on its result, and manually display the individual checkbox titles and the options values, which happen to be the keys to the children of the checkboxes form element:

<?php
 
/**
   * Loop on element_children, not on all array keys: we are not interested
   * in properties, only elements which are children of fd_boxes
   */
 
foreach (element_children($form['fd_boxes']) as $index)
    {
   
$checkboxTitle = $form['fd_boxes'][$index]['#title'];  // get box title
   
unset($form['fd_boxes'][$index]['#title']);            // then remove it
?>

Noticed the unset ? This is how we remove the default caption on the checkbox, to prevent it from being displayed on the right of the checkbox as any normal would.

Now we need to ask drupal to render the box in the table row we are building, using drupal_render:

<?php
    $box
= drupal_render($form['fd_boxes'][$index]);
?>

At this point, we have every cell element in individual variables:

  • $index contains the option value
  • $checkboxTitle contains the checkbox caption
  • $box contains the checkbox widget itself, i.e. the (X)HTML INPUT element

And we can fit it all together to build one table row and add it to the rows array:

<?php
   $row
= array // the row itself
     
(
     
$index, // its first cell, showing the actual option value
     
array   // its second cell, showing the reversed checkbox
       
(
       
'data' => "$checkboxTitle$box",
       
'style' => 'text-align: right',
        ),
      );
   
$arData[] = $row;
?>

That ugly inline styling ? It's just here to remind you of the fact that any cell in the data rendered theme_table can be a plain string (like $index for the first column, or an array as we are using for the second column.

Now we can finally hand over all these data to theme_table and invoke drupal_render once more to render the elements we didn't specifically theme. In this case this will mean the submit button. Remember how it knows what has already been rendered by using the #printed property ? This is where is proves to be useful !

<?php
 
// Now render the table representing fd_boxes
 
$ret = theme('table', $arHeader, $arData, array('class' => 'special-checkboxes'));
 
 
// And do not forget to render the rest of the form
 
$ret .= drupal_render($form);
 
  return
$ret;
  }
?>

Going further

There's nothing more to it. You can find the source code for the full module attached to this page.

Note that this example has been built and tested on Drupal 5, but should work with little difference on Drupal 6 and Drupal 7.

For a more complex, production quality, example built on this idea, have a look at devel.module, from which the idea for this example came, and specifically the following functions which make up the "Variable Editor" form:

  • devel_menu: look for Variable editor
  • devel_variable_page: shows an additional rendering trick, how to render a form with all blocks removed
  • devel_variable: the form builder
  • devel_variable_submit: the submit handler
  • theme_devel_variable: the theme function for the form

I wouldn't enter the coding standards flame, so suffice to confirm that anything going to be submitted to core should follow the Drupal coding standards which you pointed to. I definitely agree on that, not because the Drupal coding standards are better (or worse) but because they exist and following the standards is more important in such shared work than the standards' intrinsic quality. But for all purposes, I find the OSInet indenting and bracing style much easier to follow, especially when explaining things: braces line up vertically and make blocks stand out more visibly, which is supposed to help anyone trying to understand things, which is the purpose of this post.

Regarding system_settings_form, you probably noticed I mentioned it before showing how to do it "by hand". It's a neat function, which I use all the time for my own settings form, but not all forms are settings forms, and thus amenable to the "system settings" layout, especially considering the two buttons, their non-customizable wording, and the rigid storage model, which doesn't necessarily fit the needs of all forms. So I think you cannot really say it is The correct way, but just one way: keep in mind this is a tutorial, not something you'd want to use unchanged.

Jane (not verified)

Sat, 2008-08-23 22:37

I've followed your steps - created mymodule.info and mymodule.module files, and implemented them. Now what happens? I can't see any reference to my new module anywhere but the modules page. How do I use this module?

Once the module is enabled, you should see a "Form demo" link appear in your Navigation block. Click on it to see the form with its checkboxes.

Anonymous (not verified)

Mon, 2008-11-17 02:02

I'm a lonely drupal coder working on my own project. I always wanted to have a discussion about drupal form with a developer. I can see how your demo works. I wonder what's the price to pay using the form API, which calls a LOT functions to process a form. A equivalent of the same module can be coded as (sorry the code doesn't show up nice. Can you allow php code or you can help me to reformat code section):
<?php
function form_demo_menu($may_cache
  {
  if (
$may_cache)   
    {
   
$items[] = array 
      (
     
'path'               => 'form_demo',
     
'callback'           => 'form_demo_form',
     
'access'             => user_access('access content'),
     
'title'              => t('Form demo'),  
      );
    }
  return
$items;
  }

function
form_demo_form()
  {
 
$options = array('a', 'b', 'c');

  foreach (
$options as $op)
    {
   
variable_set($op, '');
    if (
$_POST['fd_boxes'])
      {
      foreach (
$_POST['fd_boxes'] as $abox)
        {
        if (
$abox == $op)
          {
         
variable_set($op, 'checked');
          }
        }
      }
    }                     

 
$out .= '<form method="post">';
  foreach (
$options as $op)
    {
   
$out .= '<input type="checkbox" name="fd_boxes[]" value="' . $op . '" ' . variable_get($op, '') . ">$op<br/>\n";
    }
 
$out .= '<input type="submit"></form>';
  return
$out;
  }
?>
I'd like to get developers feedback on this comparison.

Hi, "anonymous".

I enabled the php formatter on your comment, reformatted it, and added a few missing braces to make your code valid.

In typical Drupal setups, the CPU is not the limiting factor: the DB typically is, especially on the write side since MySQL is very efficient on reads. And in that case you're doing variable_set() in a loop, which means up to n+p writes where n is the number of checkboxes and p the number of selected checkboxes.

Also note that, when developing complex forms, bypassing FormsAPI like this is likely to let you write more vulnerable code, or code that takes longer to write and in most cases your time will be more expensive than the extra CPU/DB resources possibly needed by using "standard" APIs, not even mentioning the increased cost of ownership since this means more custom code, written in a unusual fashion, which the maintainer will need more time to grok.

More generally, to discuss such topics, you should use the forums or #drupal-dev or #drupal channels rather than comments on a blog: there core devs would typically answer you in more detail, and I'm not one: I have only a few patches in core.

Thanks for the response. I posted this same question on drupal site http://drupal.org/node/335172

With drupal form API, do you know what's the least intrusive way to reformat the check boxes or radios, so they appear on the same line, instead of spanning multiple lines?

Using your example, it would be like: "x" a "x" b "x" c, instead of

"x" a
"x" b
"x" c

thanks!

You'll typically want to apply class container-inline to the container for your elements. This is a standard Drupal class which essentially says its div and lable children have display: inline instead of display: block.

See system.css for the actual implementation (link is to D7, but this exists since D5. In D4.7 it was in drupal.css instead.

As you can imagine, it depends where you are in code when you are trying to find out these checkboxes. If it's in a submit handler, say foo_form_submit($form, $form_state) for a form called foo_form(), you'll typically just loop on $form_state['values'], which will hold the data you need.

Martin Q (not verified)

Tue, 2009-04-14 22:11

Hi,

thanks for this really useful and clear tutorial.

I found that in Drupal 6 it only worked if I also created a hook_theme function. In your example it would be:

<?php
/**
 * Implementation of hook_theme().
 */
function form_demo_theme() {
  return array(
   
'form_demo_form' => array(
     
'arguments' => array('form' => NULL),
    ),
  );
}
?>

After that, everything works just as you say it does. Thanks very much!

Anonymous (not verified)

Wed, 2009-06-24 16:43

I got one question. Is there a way that you can add everything just created (a themed form) to a fieldset. I want to make a module where the user can navigate easily, so collapsing is very important.

Thanks for sharing!

@drupal_phobic: Sorry, I don't even understand your question: this is a tutorial to explain how to write your own FAPI code, not a module you can use out of the box, so there is nothing to click around. What exactly are you asking ?

Fieldsets have zero impact on the submitted data. Technically, they are not "input" widgets for FAPI, so you can just embed the array holding these checkboxes as a child element of a fieldset element, and this will work.

This is exactly how the page at admin/build/modules is built, to allow grouping the packages instead of having one massive module list. And admin_menu module even allows automatic folding for these fieldsets.

Funny: apparently the path to the attachment was borked in the D5 to D6 upgrade: the files directory appeared twice in the automatic download link. Works now. Looks like an upload_update_N() bug, though.

mb (not verified)

Fri, 2009-09-25 07:26

Just to had insert a :

<?php
$form_values
= $form_state['values'];
?>

in form_demo_form_submit() for Drupal 6 and it just worked!

The menu must be like this:

<?php
function  form_menu() {
  {
 
$items['form_demo'] = array
    (
   
'page callback'    => 'drupal_get_form',
   
'page arguments'   => array('form_demo_form'),
   
'access arguments' => array('access content'),
   
'title'            => 'Form demo',
    );

  return

$items;
  }
?>

You must also change function form_demo_form_submit() so it will be like this:

<?php
function form_demo_form_submit($form, &$form_state)
  {
 
/**
   * Look at form values: it only contains the keys to the fd_boxes array:
   * We're not supposed to use the captions for storage
   */
  // dsm($form_state['values']);

  // Exclude unnecessary elements. This one comes from our form:
 

unset($form_state['values']['submit']);

 

// But these ones are put in by Forms API and always there
 
unset($form_state['values']['form_id'],
   
$form_state['values']['op'],
   
$form_state['values']['form_token']);
 
 
$form_values = $form_state['values'];
 
  foreach (
$form_state['values'] as $key => $element)
    {
   
variable_set($key, $element); // Serialized as necessary, so it will store an array  
   
}
  }
?>

Some changes I had to make to use this code in Drupal 6:
1. When you set the #default_value of an element of type checkboxes, the default value has to be an array. Accordingly, the second parameter of variable_get should be 'array()', rather than '0'.
2. The function signature for hook_form_submit has changed to (&$form, &form_state). So the value you want to save is at $form_state['values'][name of field].
Other than that, this was really helpful. Many thanks!

Gale (not verified)

Wed, 2013-07-17 16:34

Many years later, this tutorial is still very helpful, and was easily ported to D7. Thank you.

Hi Gale, thanks for your comment.

If you feel like adding some notes on what you had to change to use it on D7, I'm sure a number of visitors would appreciate it..