Drupal: A node edit form in a popup window

Tags: 

This was done for CyberCourse.W00f

Goal

We want a course page to have exercises inserted in it. The author does something like this to insert the exercises:

Text text texty text text.

Text text texty text text.

[[exercise:42]]

Text text texty text text.

Text text texty text text.

[[exercise: 52]]

Text text texty text text. Text text texty text text.

An exercise is a content type. Here's one:

Title: Do this thing

Body: Exercise text text texty text text.

Another content type is the exercise submission. Here is one:

Exercise: 42

User: 201

Body: Here is my solution. First, put them in a plastic bag, an airtight one. Seal it. Ignore the protests from inside the bag. Get a hammer.

W00fThis is user 201's submission to exercise 42. Actually, that's not how the final implementation will be, since there will be potentially a stream of submissions, comments, questions, etc., for one exercise. But that doesn't change things at this end.

Here's what we show the student:

Text text texty text text.

Text text texty text text.

Exercise: Do this thing.
Exercise text text texty text text.
Work on it.

Text text texty text text.

Text text texty text text.

Exercise: Do this other thing.
Other exercise text text texty text text.
Work on it.

Text text texty text text. Text text texty text text.

W00fEach Work on it is a link. If the student has already created a solution and is editing it, the link will be to an edit form for a submission. If the student has not created a submission, the link will be to an add form.

When a student clicks on a link, the edit form opens in a popup window. Not a floating div type of popup. A separate-browser-window type of popup.

The popup will use a different theme from the rest of the site. It will be simplified, with nav bars, sidebars, etc., stripped away.

Why? In Willow's name, WHY?

W00fWe want students to be able to cruise through the Web site while working on the exercise. Look at tools, reference content, etc. That's most easily done with a separate window.

Will this make students have to do some work, dragging windows about? Aye. That's OK. This is not a consumer site. It's a working learning system, with potentially many different tools: glossaries, notes, calculators, reference tables, drawing tools, videos, chat windows, cute kittens, etc. Flexibility is necessary.

Don't assume that popups always suck. They suit this use case quite well.

Back to the story...

Inserting the exercise

Let's break the problem into pieces. The first piece: how to get the exercise inserted into the page in the right format?

This code creates a new view mode for the exercise content type:

  1. function insert_exercise_entity_info_alter(&$entity_info) {
  2.   $entity_info['node']['view modes']['exercise_insert'] = array(
  3.     'label' => t('Insert exercise'),
  4.     'custom settings' => TRUE,
  5.   );
  6. }

W00fSite builders can mess with the view mode as usual, removing field labels and such.

Not enough, though. We also want to use a special template for that view mode. This adds a template suggestion:

  1. function insert_exercise_preprocess_node(&$vars) {
  2.   if ($vars['node']->type == 'exercise' && $vars['view_mode'] == 'exercise_insert') {
  3.     $vars['theme_hook_suggestions'][] = 'node__exercise__exercise_insert';
  4.   }
  5. }

Here's a template derived from node.tpl.php, with most of that template's code removed:

  1. <div id="node-<?php print $node->nid; ?>" class="<?php print $classes; ?> clearfix"<?php print $attributes; ?>>
  2.   <?php print render($title_prefix); ?>
  3.   <?php if (!$page): ?>
  4.     <h2<?php print $title_attributes; ?>>
  5.       Exercise: <?php print $title; ?>
  6.     </h2>
  7.   <?php endif; ?>
  8.   <?php print render($title_suffix); ?>
  9.   <div class="content clearfix"<?php print $content_attributes; ?>>
  10.     <?php
  11.       // We hide the comments and links now so that we can render them later.
  12.       hide($content['comments']);
  13.       hide($content['links']);
  14.       print render($content);
  15.     ?>
  16.   </div>
  17. </div>

Now, to use all that themey stuff. Let's make a new input filter to process the [[exercise:42]] things:

  1. function insert_exercise_filter_info() {
  2.   //Define a filter that can replace a reference to an exercise in page with the
  3.   //exercise content.
  4.   $filters['exercise_insert'] = array(
  5.     'title' => t('Insert exercise'),
  6.     'description' => t('[[exercise:(node_id)]], to insert the exercise with given NID'),
  7.     'process callback' => 'insert_exercise_filter_node_insert_process',
  8.     'tips callback'  => 'insert_exercise_filter_node_embed_tips',
  9.     'cache' => FALSE,
  10.   );
  11.   return $filters;
  12. }

Line 7 gives the function to be called when Drupal applies the filter:

  1. function insert_exercise_filter_node_insert_process($text, $filter, $format, $langcode, $cache, $cache_id) {
  2.   return preg_replace_callback('/\[\[exercise:(\d+)\s*\]\]/', '_insert_exercise_make_replacements', $text);
  3. }

_insert_exercise_make_replacements is passed the exercise id typed by the author, e.g., 42.

  1. function _insert_exercise_make_replacements($matches) {
  2.   $exercise_nid = $matches[1];
  3.   $exercise_node = node_load($exercise_nid);
  4.   //Make sure that this is the right type of node.
  5.   if (   $exercise_node == FALSE
  6.       || !node_access('view', $exercise_node)
  7.       || !$exercise_node->status
  8.       || $exercise_node->type != 'exercise') {
  9.     $message = t('Invalid exercise id: @id', array('@id' => $exercise_nid) );
  10.     drupal_set_message($message);
  11.     watchdog('insert_exercise', $message);
  12.     return '<p>' . $message . '</p>';
  13.   }
  14.   else {
  15.     //Work out what to link to. Submission add or edit?
  16.     //Find submission node with uid of logged in user, and exercise reference
  17.     //to $exercise_node.
  18.     global $base_url;
  19.     global $user;
  20.     $query = new EntityFieldQuery();
  21.     $query->entityCondition('entity_type', 'node')
  22.       ->entityCondition('bundle', 'exercise_submission')
  23.       ->propertyCondition('status', 1)
  24.       ->fieldCondition('field_exercise', 'target_id', $exercise_nid, '=')
  25.       ->propertyCondition('uid', $user->uid);
  26.     $result = $query->execute();
  27.     if (isset($result['node'])) {
  28.       //There is already a submission for this exercise for this user.
  29.       //Show an edit link.
  30.       $submissions_nids = array_keys($result['node']);
  31.       if ( sizeof($submissions_nids) != 1 ) {
  32.         throw new Exception('Too many submission ids: <pre>'
  33.             . print_r($submissions_nids, TRUE) . '</pre>');
  34.       }
  35.       $url = $base_url . '/node/' . $submissions_nids[0] . '/edit';
  36.     }
  37.     else {
  38.       //No submission yet. Show an add link.
  39.       $url = $base_url . '/node/add/exercise-submission';
  40.     }
  41.     //Add exercise id for use by Entity reference prepopulate module.
  42.     $url .= '?field_exercise=' . $exercise_nid;
  43.     //Add destination after submission that will close the window.
  44.     $url .= '&destination=insert-exercise-close-popup';
  45.     $link = l( t('Work on it'), $url,
  46.         array('attributes' =>
  47.           array(
  48.             'title' => 'Work on exercise',
  49.             'rel' => 'scrollbars:1',
  50.             'class' => array( 'popupwindow' ),
  51.           )
  52.         )
  53.     );
  54.     //Add the Work On It link to the exercise to be rendered.
  55.     $exercise_node->body['und'][0]['value'] =
  56.         $exercise_node->body['und'][0]['value'] . $link;
  57.     //Prep the exer for viewing, with a custom view mode.
  58.     $view = node_view($exercise_node, 'exercise_insert', NULL);
  59.     $render = drupal_render($view);
  60.     return $render;
  61.   }
  62. }

W00fLines 4 to 13 make sure that the id typed by the author is in fact an exercise id.

Skip to line 58. (We'll come back to the rest later.) Line 58 prepares the exercise for rendering, using the custom view mode created earlier. Recall that this custom view mode has a custom template associated with it.

OK, that inserts the exercise into the content. But we need to add a link as well, so the student can work on the exercise.

Work on it link

Here's what we want:

Text text texty text text.

Exercise: Do this thing.
Exercise text text texty text text.
Work on it.

Text text texty text text.

If the student has not created a submission for this exercise, the link will open node/add/exercise-submission. If not, it will open the edit form for the existing submission.

Here's some code from earlier:

  1.     global $base_url;
  2.     global $user;
  3.     $query = new EntityFieldQuery();
  4.     $query->entityCondition('entity_type', 'node')
  5.       ->entityCondition('bundle', 'exercise_submission')
  6.       ->propertyCondition('status', 1)
  7.       ->fieldCondition('field_exercise', 'target_id', $exercise_nid, '=')
  8.       ->propertyCondition('uid', $user->uid);
  9.     $result = $query->execute();
  10.     if (isset($result['node'])) {
  11.       //There is already a submission for this exercise for this user.
  12.       //Show an edit link.
  13.       $submissions_nids = array_keys($result['node']);
  14.       if ( sizeof($submissions_nids) != 1 ) {
  15.         throw new Exception('Too many submission ids: <pre>'
  16.             . print_r($submissions_nids, TRUE) . '</pre>');
  17.       }
  18.       $url = $base_url . '/node/' . $submissions_nids[0] . '/edit';
  19.     }
  20.     else {
  21.       //No submission yet. Show an add link.
  22.       $url = $base_url . '/node/add/exercise-submission';
  23.     }
  24.     //Add exercise id for use by Entity reference prepopulate module.
  25.     $url .= '?field_exercise=' . $exercise_nid;
  26.     //Add destination after submission that will close the window.
  27.     $url .= '&destination=insert-exercise-close-popup';
  28.     $link = l( t('Work on it'), $url,
  29.         array('attributes' =>
  30.           array(
  31.             'title' => 'Work on exercise',
  32.             'rel' => 'scrollbars:1',
  33.             'class' => array( 'popupwindow' ),
  34.           )
  35.         )
  36.     );
  37.     //Add the Work On It link to the exercise to be rendered.
  38.     $exercise_node->body['und'][0]['value'] =
  39.         $exercise_node->body['und'][0]['value'] . $link;

Lines 20 to 26 run an EFQ to find a submission for the exercise for the logged in user. Line 27 tests whether the EFQ found one. If so, then we need an edit link. Lines 30 to 34 are a sanity check. Line 35 creates the edit link.

If there was no existing submission, line 39 creates an add link.

W00fThe submission content type has an entity reference field pointing to the exercise the submission is for. There is no point having the user set that, since we already know what the exercise is. The Entity reference prepopulate module will handle this for us. We can tell it to inspect the URL for an edit/add form, and look for a parameter with the name field_exercise, which is the name of the entity reference field in the content type. If the module find the parameter, it copies the parameter's value into the content type's entity reference field, and hides the field on the form (you have to configure this). So, line 42 adds that parameter to the URL, so the Entity reference prepopulate module can do its thing.

Opening a popup

We want the link to open a form in a popup window. Line 50 adds the class popupwindow to the link. When triggered, the link will create the popup and load it from the URL. Well, it will, if we have loaded and configured a jQuery plugin. So:

  1. function insert_exercise_init() {
  2.   global $base_url;
  3.   drupal_add_js(
  4.       $base_url . '/sites/all/libraries/popup/jquery.popupwindow.js'
  5.   );
  6.   drupal_add_js(
  7.       $base_url . '/' . drupal_get_path('module', 'insert_exercise')
  8.         . '/insert_exercise.js'
  9.   );
  10. }

Lines 3 - 5 load the plugin. Lines 6 and 7 load code that will set up the plugin to trigger for links that have a class of popupwindow. Here is that code:

  1. jQuery(function(){
  2.   jQuery(".popupwindow").popupwindow();
  3. });

Maybe this should be a Drupal behavior, but I don't see any advantage to that.

Closing the popup

W00fAfter the user has edited/added the exercise submission and clicked Save, we want Drupal to save the data, then close the popup. How?

One way is to add a destination parameter to the Work on it link's URL. Drupal will jump to that page after the student has completed the add/edit. That's what this line does:

  1. $url .= '&destination=insert-exercise-close-popup';

We need to define that destination:

  1. function insert_exercise_menu() {
  2.   $items = array();
  3.   //A page that just closes the window it is in.
  4.   $items['insert-exercise-close-popup'] = array(
  5.     'title' => 'Done',
  6.     'page callback' => '_insert_exercise_close_popup',
  7.     'access callback' => TRUE,
  8.     'type' => MENU_CALLBACK,
  9.   );
  10.   return $items;  
  11. }

Here's the page callback function:

  1. function _insert_exercise_close_popup() {
  2.   global $base_url;
  3.   $path = $base_url . '/' . drupal_get_path('module', 'insert_exercise') . '/close_popup.js';
  4.   drupal_add_js( $path );
  5.   return '<h1>Done</h1>';
  6. }

W00fThis has to return something to work, even though the user may never see the h1 tag.

Here's the contents of that JS file:

window.close();

It would be best to make sure that the popups have as little content as possible. No menus, sidebars, etc. How? Here's what I did, though there is probably a better way.

I created a new theme, called MT (that's "empty"). Then:

  1. function insert_exercise_custom_theme() {
  2.   if ( ( isset($_GET['destination'])
  3.          && $_GET['destination'] == 'insert-exercise-close-popup' )
  4.        ||
  5.        current_path() == 'insert-exercise-close-popup'
  6.       ) {
  7.     return 'mt';
  8.   }
  9. }

In lines 2 and 3, this hook tells Drupal to use MT when the URL has a destination parameter with insert-exercise-close-popup. Recall that the links to the popups have such a parameter.

Recall that the page insert-exercise-close-popup actually exists, even if it just closes itself. It should use MT as well. That's what line 5 does.

MT has a customized page.tpl.php. Here it is:

  1. <div id="page-wrapper"><div id="page">
  2.     <div id="content" class="column"><div class="section">
  3.       <?php print render($page['content']); ?>
  4.     </div></div> <!-- /.section, /#content -->
  5. </div></div> <!-- /#page, /#page-wrapper -->

Just the content, ma'am. I also needed to go to Admin | Structure | Blocks, and remove all blocks from MT, except for the content block in the content region.

W00fThat's all, folks!

Whew! Many moving parts, though not a great deal of code. Different Drupal components are tweaked to get everything to work. This shows one aspect of Drupal. You can get it to do lot, but, sometimes, only if you know a lot about Drupal.

The dog photos are from http://www.pdpics.com.