Drupal Tableselect with fields

We all know that Drupal provides the FormAPI, in which very powerful form elements exist (in our case, the tableselect element). This magical element behaves like a table, but has a checkbox for each line, and associated JS/CSS to handle checking rows. But it is normally impossible to add additional form elements on each row ( there have been many workarounds, but all seem hackish) due to the way Drupal handles form submission. This is how to have a clean (and simple!) way to go around those restrictions.

First off, some background - for this example I'll be showing you how to create a tableselect element with one additional textfield per row, but it should be apparent that you can have any number of fields per row. Let's start with some code for a basic tableselect element :

function mymodule_form($form, $form_state) {

   $options = array(
      array(
        'title' => 'How to Learn Drupal',
        'content_type' => 'Article',
        'status' => 'published',
      ),
      array(
        'title' => 'Privacy Policy',
        'content_type' => 'Page',
        'status' => 'published',
      ), 
    ); 
    $header = array( 
      'title' => t('Title'), 
      'content_type' => t('Content type'), 
      'status' => t('Status'), 
    ); 
    $form['tableselect_element'] = array( 
      '#type' => 'tableselect', 
      '#header' => $header, 
      '#options' => $options, 
      '#empty' => t('No content available.'), 
    );

  return $form;
}

This little example form, will return a four column table - checkboxes column, plus the three defined data columns, and should look like so:

Tableselect example screenshot

This is pretty much textbook tableselect. If you'd want to add another column, you just add an element in the $header array to serve as title, as well as a corresponding entry in the elements of the$options array. Unfortunately, you cannot enter form elements directly, since #options is not normally traversed by the Form API -- the way around it, is to use the data attribute of the cell, since that actually DOES get processed. So, to add a simple textfield element as a fourth column, here's the above code amended :

function mymodule_form($form, $form_state) {
   $commentfield = array(
     '#type' => 'textfield',
     '#default_value' => '',
     '#title' => 'Comment',
     '#title_display' => 'invisible',
     '#name' => 'commentfield'
   );
   $options = array( 
      array( 
        'title' => 'How to Learn Drupal', 
        'content_type' => 'Article', 
        'status' => 'published', 
        'comment' => array('data'=>$commentfield), 
      ), 
      array( 
        'title' => 'Privacy Policy', 
        'content_type' => 'Page', 
        'status' => 'published', 
        'comment' => array('data'=>$commentfield),
      ),
    );
    $header = array(
      'title' => t('Title'),
      'content_type' => t('Content type'),
      'status' => t('Status'),
      'comment' => t('Comment'),
    );
    $form['tableselect_element'] = array(
      '#type' => 'tableselect',
      '#header' => $header,
      '#options' => $options,
      '#empty' => t('No content available.'),
    );

  return $form;
}

Tableselect with additional textfields example

I've used a common element for all rows, which is definently wrong, for shortness - but to also demonstrate a couple of issues here :

  • Normally your elements get their name property from the key of the array -- in this case, you need to explicitly define it via the #nameproperty.
  • The above is wrong because all elements get the same name -- that's not the problem yet though
  • Drupal's submit mehod will wipe the textfield value from both the$_POST array and the $form_state['values'] array because it's not in the $form declaration
  • Note that the #title property is required to avoid warnings -- just make it invisible via #title_display and core CSS

There have been a lot of ways around this, with the most prominent being also the most troublesome -- tampering with the form theming to place actual form elements in the rows of the tableselect output when it gets rendered. However, the thing is - the visual output is just fine! If we could only get the values back, everything would be fine and dandy across the board.

So here's how to do it. First off, remember that PHP has a quirk for HTML form processing -- if the name of an element is in array syntax, it becomes an array server side. This is used all the time for checkboxes. That means, thatitem[name] and item[name2] elements will become the$_POST['items'] array with all values.

Furthermore - Drupal only checks the first level of the $_POSTarray for filtering variables -- so we need to whitelist the common name, without sending additional stuff to the browser. Drupal already has an element that is saved in the $_SESSION array and not sent to the browser -- that's the value element.

To recap - make all fields belong in one (or more!) arrays, and add a value element (or more!) with the array name to your form definition. Here's the corrected example from above:

function mymodule_form($form, $form_state) {
   $options = array( 
      array( 
        'title' => 'How to Learn Drupal', 
        'content_type' => 'Article', 
        'status' => 'published', 
        'comment' => array('data'=> array(
            '#type' => 'textfield', 
            '#title' => 'Comment for row1',
            '#title_display'=> 'invisible',
            '#default_value'=> '',
            '#name' => 'comment[row1]',
          ),
        ),
      ), 
      array( 
        'title' => 'Privacy Policy', 
        'content_type' => 'Page', 
        'status' => 'published', 
        'comment' => array('data'=> array(
            '#type' => 'textfield',
            '#title' => 'Comment for row2',
            '#title_display' => 'invisible',
            '#default_value' => '',
            '#name' => 'comment[row2]'),
           ),
         ),
      ),
    );
    $header = array(
      'title' => t('Title'),
      'content_type' => t('Content type'),
      'status' => t('Status'),
      'comment' => t('Comment'),
    );

    $form['tableselect_element'] = array(
      '#type' => 'tableselect',
      '#header' => $header,
      '#options' => $options,
      '#empty' => t('No content available.'),
    );

    $form['comment'] = array(
      '#type' => 'value',
    );

  return $form;
}

And you're cooking! Now you get the same visual as the previous example, however in this case, once the form is submitted, the$form_state['values'] will look similar to the following:

Therefore, you just need to access$form_state['values']['comment']['row1'] to get to the first textfield! No messing around with theming function, preprocessing or any other under-the-hood trickery! :)

If you want to set a default value for the field, you need to use #value instead of #default_value. Not sure why that is, but since some people have asked, here you go ;)

To get autocomplete working, each field needs a unique id. With the method above, it doesn't get an ID at all, so we need to add it, using the#id property.

Here is one of the above comment fields, complete with an id:

      'comment' => array(
          'data'=> array( 
            '#id'  => 'comment-row2',
            '#type' => 'textfield', 
            '#title' => 'Comment for row1', 
            '#title_display'=> 'invisible', 
            '#default_value'=> '', 
            '#name' => 'comment[row2]',
            '#autocomplete_path' => 'user/autocomplete',          
          ),
      ), 
you obviously need to make sure each of the fields has adifferent ID, otherwise you're gonna have...unexpected behavior.

I really hope this can help other people out, since it is... well, hard to figure out, and it's certainly not documented anywhere. Works just fine though (already on one production site by yours truly).