Hooray! I'm wrong! See update at the bottom of the post.
Theming forms in Drupal is a challenge. Getting checkboxes and radios buttons into a table with other data can whip a small band of squirrels into a bird feeder frenzy. Add AHAH into the mix, and well, you've got grounds for a stiff drink and a nap. While I'm not sure I have done it "right" I'm posting some of my code here to give other people some grounds to fall into different pitfalls rather than mine.
So what I wanted was a table which had checkboxes for a series of files. Each checkbox would fire an AHAH request to populate data into another table. Seems easy enough- there are lots of examples of how to theme checkboxes into tables in Drupal. In core there are several- the admin >user > permissions comes to mind:
[img_assist|nid=210|title=Drupal perms|desc=|link=none|align=left|width=529|height=125]
Well, yes, this is true, but there is a small detail that I overlooked.
Feeding Frenzy number one
So because I'm dealing with files and I need/want to operate one file at a time, having single checkbox per table row was not going to work well since a user could click on multiple checkboxes at a time. I fixed this with validation but clearly this was not a good fix- the user could easily create a configuration that would create an error condition and the interface should prevent the user from doing this, not the validation process. My next approach was to use jQuery to watch the form and make sure that only one checkbox was being checked at a time. While this avenue was possible, it does not degrade well, and it leaves me feeling itchy.
So the next approach was to use radio buttons. This solves the interface issue. However things rapidly start spinning out of control. This is only obvious in hind sight. Ok- we need to build the radio buttons, and we need to have AHAH. This part is pretty simple. Here's the first part of my form structure:
$form['files'] = array(
'#theme' => 'ffmpeg_wrapper_files_radios',
'#type' => 'radios',
'#default_value' => $form_state['values']['files'],
'#ahah' => array(
'event' => 'change',
'path' => 'ffmpeg_wrapper/file_data',
'wrapper' => 'file_data_display_test',
'method' => 'replace',
),
);
This creates the form element files which is a bunch of radios (yet to be defined). Two pieces of note- the #theme value and the #ahah. Turns out the AHAH declaration is simple, hiding my lurking troubles. Now, we get into the fun stuff. One piece that I have neglected thus far is the fact that I needed two radio buttons per row that are distinct.
Enter the Squirrels of Doom
Ok, so we have to add another radios form element into the form. Thankfully it does not have AHAH or anything else that is really complicated.
// build the form elements for attaching a file back to a node
$form['files']['attach_files'] = array(
'#type' => 'radios',
'#default_value' => null,
);
All this seems simple enough, until we have to start dealing with the #options property. Because these elements are going into a form, they have to get broken up as we build them. My table is built around a list of files, but it could be anything.
...
// build the options for the files and sub elements
foreach($files as $key => $file) {
// build an option for this file with no name
$form['files']['#options'][] = $file;
// build the attach options
$form['files']['attach_files']['#options'][] = $file;
// build other table elements
$form['files'][$file]['name'] = array('#type' => 'markup', '#value' => basename($file));
$form['files'][$file]['size'] = array('#type' => 'markup', '#value' => format_size(filesize($file)));
$form['files'][$file]['mime'] = array('#type' => 'markup', '#value' => file_get_mimetype($file));
}
What happens here is that the other elements of the table row are being built in the array keyed by the $file which makes it possible to use the theming layer in the right place.
Now all this is fine and good except one small detail. Radios seem to (at least from my testing here) operate differently than checkboxes with their #options property. Where checkboxes will take a value and a name, radios will take a name- in the form generation the value gets set to the number of radios there are- if you have 3 radios, their values are 0-2 respectively. This seems to fly in the face of the documentation but I'm going to err on the side of caution and bet that I'm missing something.
Ok, so you're wondering- why is this important? Let me explain with an image that is worth less than a thousand words:
[img_assist|nid=209|desc=|link=none|align=center|width=416|height=198]
Probably 281 words but consider the following: we have a radio that references a file path (each of those radios is supposed control actions based on a file). However the value of a radio is just numeric.
Beware the Badgers of Evil
My first approach to this was to try to tackle it in the theme layer. I'm already building the form display in a theme function, I can just alter the #return_value for each of the radios, right? Here's the theming function. You can guess from it whether or not you can really do this.
/**
* Themes the list of radio buttons and other form data
* @param $form
* @return unknown_type
*/
function theme_ffmpeg_wrapper_files_radios(&$form) {
$rows = array();
$header = array(t('File Name'), t('Use'), t('Attach'), t('Mime type'), t('Size'));
// Each radio is stored as a numeric under $form[X], use $count to get each one
$count = 0;
// select each of the options from the $files form and build from the #options
foreach ($form['#options'] as $id => $key) {
$row = array();
// name of the file
$row[] = drupal_render($form[$key]['name']);
// remove the element title
$form[$id]['#title'] = null;
// render the radio
$row[] = drupal_render($form[$count]);
// unset the title
$form['attach_files'][$count]['#title'] = null;
// render the radio
$row[] = drupal_render($form['attach_files'][$count]);
$row[] = drupal_render($form[$key]['mime']);
$row[] = drupal_render($form[$key]['size']);
$rows[] = $row;
$count++;
}
$output = theme('table', $header, $rows);
$output .= drupal_render($form);
return $output;
}
One would think, or perhaps, I thought that I could just alter the form element as it was coming through- something like: $form[$id]['#return_value'] = $file;. Somebody might want to check me on this, but I was getting "illegal choice" errors which I attribute to the forms API being smart enough to not let people start changing the return values of the form after it has been generated. Essentially this means that this approach is a no go. It could be the case that I could add in a form_alter function to catch the radios as they go by and do this, however that seems to add a layer of complexity, and may not solve the problem which is about to get me.
It can't be an insurmountable problem. If we provide a look up table based on the same keys as the radio buttons, the correct file must be able to be extracted. We just need to build a form element that has the $files array attached to it and then we can extract the correct file at will. Ok so how to build the look up table?
This perhaps where somebody who knows all the dark corners of the forms API can illuminate if what I'm about to do is just a ridiculous hack or the only tool in the shed. So here we go and mind the badgers in the hedge row.
$form['files_lookup'] = array(
'#type' => 'hidden',
'#value' => serialize($files),
);
So what is so scary here? That little unsuspecting serialize(). I would have liked to do this with #type => 'value' and pass the array of $files but the problem- or at least in so far as I understand the forms API- is that the #type => 'value' does not get posted so pulling it out in AHAH is like ... something about badgers and squirrels.
Let me start over. Part of what I want to do is use AHAH to rebuild parts of my page based on which file is selected. Because I've chosen to use radio buttons the data that gets passed to the AHAH function includes the numeric value and not the file data. So I need to get AHAH the file data that it can use to extract out which file is used based on the radio choice. By using the '#type' => 'hidden', the AHAH function get the serialized array of data and then manipulate it. In action, the AHAH function looks like:
/**
* returns a json array of file type data
* @return string
*/
function ffmpeg_wrapper_file_type_js() {
// loop through the files to find the active one
if (isset($_POST['files'])) {
$files_lookup = unserialize($_POST['files_lookup']);
// set the path
$path = $files_lookup[$_POST['files']];
if (! file_exists($path)){
drupal_json(t('File does not exist'));
exit();
}
....
So what is happening is that the serialized data that was created in the form function is getting passed into the AHAH function via the $_POST and then just needs to be unserialized. Once we have this array, we can look up what the file path is, and use that for our operations.
There are a few other options- hopefully I'm just doing this wrong and you don't need to go this route. Another possibility would be to use a select form element to pass your array of data and just make sure that the select element is keyed the same way as your radios. You could take this a step further and use jQuery to make keep the values the same as what ever is selected with the radios. Then just hiding the select would tidy things up. However, this is perhaps more hacky than the serialize route, and I'm not sure it gains you much over it.
So to review what is going on here is a few components to build a table with radio buttons that do AHAH requests. This is only applicable if you are trying to pass data into an AHAH function that you can not just post in. Here is what you need:
You can check out the form in action in this screen cast, or just believe me that this image shows the result of clicking on one of the radio buttons:
[img_assist|nid=211|title=|desc=|link=none|align=center|width=415|height=214]
UPDATE
Ok, so the solution I came up with worked, but as was clearly pointed out in the comments, it was not a... happy one. So here's the updated code for your viewing pleasure:
Build the radios
First I need the radio buttons that control the selection of which file is going to be used for transcoding and data display. The radios for selecting which file will be attached back to the originating node come second.
$form['files'] = array(
'#theme' => 'ffmpeg_wrapper_files_radios',
);
// build the radio form element that the admin can choose from to process a
// file, option is tied to AHAH.
$form['files']['data'] = array(
'#type' => 'radios',
'#default_value' => $form_state['values']['files']['data'],
'#options' => $files,
'#ahah' => array(
'event' => 'change',
'path' => 'ffmpeg_wrapper/file_data',
'wrapper' => 'file_data_display_test',
'method' => 'replace',
),
);
// build the form elements for attaching a file back to a node
$form['files']['attach_files'] = array(
'#type' => 'radios',
'#default_value' => $form_state['values']['files']['data'],
'#options' => $files,
);
Now the radios are built. I need to create some additional data that will be in the form display:
// build the options for the files and sub elements
foreach ($files as $id => $file) {
$file_name = $files[$id];
$form['files'][$id]['name'] = array('#type' => 'markup', '#value' => check_plain(basename($file_name)));
$form['files'][$id]['size'] = array('#type' => 'markup', '#value' => format_size(filesize($file_name)));
$form['files'][$id]['mime'] = array('#type' => 'markup', '#value' => file_get_mimetype($file_name));
}
Two more things in the form creation. The placeholder for the AHAH data display and the file look up table. I chose to use the look up table approach because of issues with various characters in path names.
// placeholder for the AHAH data
$form['file_data_display'] = array(
'#type' => 'markup',
'#value' => '',
);
$form['files_lookup'] = array(
'#type' => 'value',
'#value' => $files,
);
Now the radios and table data is built, it needs to get themed. I used essentially the same theme function as above which does not need to be repeated here. The AHAH function got simpler for pull the correct file out:
/**
* returns a json array of file type data
* @return string
*/
function ffmpeg_wrapper_file_type_ahah() {
// Get the cache form data
$cached_form = form_get_cache($_POST['form_build_id'], &$cached_form_state);
// Did we find a file that we can use?
if ($file = $cached_form['files']['data']['#options'][$_POST['data']]) {
if (! file_exists($file)){
drupal_json(t('File does not exist'));
exit();
}
And there you have it.[img_assist|nid=210|title=Drupal perms|desc=|link=none|align=left|width=529|height=125]
Comments
Similar experience
I haven't been working anything together with File info (which this is really cool btw) but I can definitely share the mental pains :) I've recently implemented some AHAH forms into the Book Administration drag/drop page and had a lot of trouble doing so. Did you experience any issues other then having fun fighting with the code? It was definitely a shift in thinking for me to finally get it working. Did you have any issues with AHAH and clean-urls? That site doesn't but I'm having some weird issues w/ it not working on various servers (specific to the AHAH calls).
Debugging
Hi btopro-
I think you are right- AHAH is a hurdle to get over, but at least for me, once I made the leap, it seems really straightforward- except of course when you start running into issues that seem to be related to constraints either in html elements or the forms API.
As per clean urls, no I didn't have any issues with them, however, I did try passing arguments back to the AHAH url and had no luck doing so. I also tried to do things like:
'#ahah' => array( 'path' => 'ffmpeg_wrapper/file_data?path=sites/default/files/myfile.mov'Which either did not work, or I had something else going on that made me think it was not working. I think that maybe one of the hardest things to deal with in the AHAH integration- you have so many moving parts between the forms API, a multistep form (well, I guess this is called something else under D6), and the AHAH itself that it can be challenging to figure out just where it is not working.
overly complicated?
You might be making things more complicated than it needs to be. Do you really need to use AHAH, or just a bit of simple jQuery? You're not actually changing the form, just adding a bit of bonus information. I think you might be able to just add the file information with drupal_add_js($file_array, 'setting'); You can then access that data via jQuery when the value of the radios change and build the table.
Possibly, but maybe not
You're right that I could use jQuery to load the data in to the page. It is probably the case that it would make things simpler- for example, on the form submit, the $form['files_lookup'] could be a value instead of the serialized data (ultimately not that big a deal, but certainly not pretty) which would make the form workflow simpler.
From my perspective, since the actions are taking place on the form and I wanted an excuse to play around AHAH, I tied it to the form route. I like having all the action in one place, but I cost myself several hours doing it.
It is worth noting however that the AHAH stuff was remarkably fast and easy- except for the $_POST issue. In fact, I'd say it was as easy or easier than writing out the jQuery, but I think millage may vary.
No need for AHAH
Hi Arthur,
I'd have to agree with dalin on this one - AHAH is for rendering new form elements and it's all about the AHAH callback and how to render them to make FAPI happy (er... no rhyme intended :-P). It's true that it means you don't have to write any jQuery yourself but that also means you don't have much flexibility in terms of extra information you can send to your callback. When all you're rendering is some information, as opposed to new form elements, a simple ajax callback is all you need and as dalin suggested you can pass the file information to your jQuery as an array in your settings variable. Then your callback path can take the file id as an argument and pass back the information for that file. Personally, I think the jQuery side of it is way easier than what you need to do in a full-on AHAH callback. But it's all a question of what you're comfortable with I suppose.
KB
To query or not to query
Again, I do take the point that jQuery does take away the significant bulk of what I ran into. I think the argument against AHAH in this case is highlighted in the screen cast that I did- the form submission is does not use AHAH at all. However, what I'd like to do (and obviously, I was not clear about this) is use AHAH to check in on the status of the FFmpeg conversion and use the progress bar to report back to the user. This adds a huge level of complexity in that isn't at all in my post, but it was the direction that I'm aiming for. Or maybe I'm trying to make excuses :)
Radios seem to (at least
That's because you use:
See the PHP manual - "Creating/modifying with square bracket syntax" for an explanation of $array[].
If in doubt, try a simple test case such as below and you will see that the options key is what ends up in $form_state['values']:
Next:
Filenames are not HTML and they need to be escaped with check_plain before you mix them into an HTML context.
This is a ridiculous hack. Unserializing userinput will get you into trouble quickly. It is also unneeded. You can store information in custom properties in the form array. $form['#some_val'] = $data. In the JS callback you can use $cached_form = form_get_cache($_POST['form_build_id'], &$cached_form_state) to retrieve the form definition.
For more information read katbailey's excellent http://katbailey.net/blog/katherine/the-dual-aspect-of-drupal-forms-and-what-this-means-for-your-ahah-callback
As the last tip: when you bite off more than you can chew, scale back. Experiment with small subproblems until perfection, then integrate.
Thanks for the help!
Thanks for taking the time to go through this and illuminate where I was off base. I figured I was, but it is good to know where. A few threaded responses and I'll post the code changes once I complete them back to the post.
>That's because you use:
>$form['files']['#options'][] = $file;
>See the PHP manual - "Creating/modifying with square bracket syntax" for an explanation of $array[].
Ok good point. I should have caught that one. One of those to many things going on, not paying enough attention to things. Originally I tried $form['files']['#options'] = $files but I think other issues (namely the theming) got int the way. Sigh.
>$form['files'][$file]['name'] = array('#type' => 'markup', '#value' => basename($file))
>Filenames are not HTML and they need to be escaped with check_plain before you mix them into an HTML context.
Huh. I am sort of surprised by this. Not that I don't agree with you that check_plain() is good practice, but wouldn't the Drupal upload process sanitize things already?
>For more information read katbailey's excellent http://katbailey.net/blog/katherine/the-dual-aspect-of-drupal-forms-and-...
Thanks for this link. For what ever reason, I did not find it.
It's not just for security,
It's not just for security, but also correctness. Suppose you have the file "Bed&Breakfast.pdf"; an unencoded ampersand in HTML leads to a validation error, and in some cases to very surprising results, depending on the characters after the ampersand. In HTML you need "Bed&Breakfast.pdf". The function to do the conversion: check_plain.
Drupal has the philosophy to escape when mixing contexts (plaintext, html, mime headers, SQL).
The cursed (and much loved)
The cursed (and much loved) HTML filter preempted me from posting what I meant. Imagine there's no space between & and amp;
In HTML you need "Bed & amp; Breakfast.pdf"
More on radios #options
So I dug around a bit more and figured this out:
$form['test'] = array( '#type' => 'radios', '#default_value' => $form_state['values']['files'], '#options' => array('tnt_3.wmv.flv' => 'tnt_3.wmv.flv'), '#ahah' => array( 'event' => 'change', 'path' => 'ffmpeg_wrapper/file_data', 'wrapper' => 'file_data_display_test', 'method' => 'replace', ), );Seems to stop AHAH because of the "." in the file name. When I stripped them out, things work fine. I'm guessing this has to do with javascript, but I have not code dived this.