Custom Image Search with Solr, Filefield Sources, and Ctools - Part 2

Continuing on from my previous post, we are working on building a custom image search in Drupal using Apache Solr, Filefield Sources, and Ctools. So far, we have created our custom FileField Source element that will allow us to search. Now we get to the step of creating our modal window for displaying the search form and search results. As mentioned in the first post, our filefield source link has the ctools-use-modal class, which will cause the link to open our page callback in a modal window. The first thing we need is a menu item for our page callback:

/**
 * Implementation of hook_menu.
 */
function nb_image_search_menu() {
  $items = array();

  $items['imgsearch/%ctools_js/%/%'] = array(
    'page callback' => 'imgsearch_page',
    'page arguments' => array(1, 2, 3),
    'access callback' => TRUE,
    'type' => MENU_CALLBACK,
  );

  return $items;
}

And here is the form function for our search form:

/**
 * Drupal form to be put in a modal.
 */
function imgsearch_form($form, $form_state, $id) {
  $form = array();

  $form['search_terms'] = array(
    '#type' => 'textfield',
    '#title' => t('Search text'),
    '#description' => t('Enter the search terms'),
    '#required' => TRUE,
    '#default_value' => $form_state['values']['search_terms'],
  );

  $form['filter_fields'] = array(
    '#type' => 'fieldset',
    '#title' => t('Additional Filters'),
    '#collapsible' => TRUE,
    '#collapsed' => TRUE,
  );

  $form['filter_fields']['search_person'] = array(
    '#type' => 'textfield',
    '#title' => t('Person'),
    '#description' => t('The name of the person in the image.'),
    '#default_value' => isset($form_state['values']['search_person']) ? $form_state['values']['search_person'] : '',
  );

  $form['filter_fields']['search_organization'] = array(
    '#type' => 'textfield',
    '#title' => t('Organization'),
    '#description' => t('The organization the subject belongs to.'),
    '#default_value' => isset($form_state['values']['search_organization']) ? $form_state['values']['search_organization'] : '',
  );

  $form['filter_fields']['search_year'] = array(
    '#type' => 'textfield',
    '#title' => t('Year'),
    '#description' => t('Enter the year of the image'),
    '#size' => 4,
    '#default_value' => isset($form_state['values']['search_year']) ? $form_state['values']['search_year'] : '',
  );

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

  return $form;
}

As you can see this is just a pretty standard form with fields for the search criteria. However, one thing that is unique in this case is there is no submit function. This is because as we will see in a bit, when using a pager, the search functionality has to be  called in the page function. So what happens in this case is that since there is no submit (or validation function), it just goes right back to the page function.

Here is the page function:

/**
 * Page callback for imgsearch modal popup window.
 */
function imgsearch_page($ajax, $id, $bundle) {
  if ($ajax) {
    //Load the modal library and add the modal javascript.
    ctools_include('ajax');
    ctools_include('modal');

    $form_state = array(
      'ajax' => TRUE,
      'title' => t('Image Search Form'),
      'next_field_id' => $id,
    );

    // Use ctools to generate ajax instructions for the browser to create
    // a form in a modal popup.
    $search_form = ctools_modal_form_wrapper('imgsearch_form', $form_state);

    if ($form_state['executed'] || $_GET['page'] || $_GET['pager_source']) {
      if ($form_state['values']['search_terms'] != '' || isset($_GET['search_terms'])) {
        // The search fields have no value in $form_state['values'] when this is paged, so we add them from $_GET[].
        foreach(array('terms', 'person', 'organization', 'year') as $search_field) {
          $search_field_name = 'search_' . $search_field;
          if ($_GET[$search_field_name]) {
            $form_state['values'][$search_field_name] = $_GET[$search_field_name];
          }
        }

        // $form_state['executed'] = TRUE only when the form has been submitted, which means we are on the first page.
        if ($form_state['executed']) {
          $page_num = 0;
        }
        // The form was not submitted this time, so determine what page of results we are on. $_GET['page'] is only set for pages past
        // the first page.
        else {
          $page_num = isset($_GET['page']) ? $_GET['page'] : 0;
        }


        // Search Solr for images.
        $results = nb_image_search_search($form_state['values'], $page_num);
        // Loop through returned images and create a table.
        if (is_array($results['images']) && count($results['images'] > 0)) {
          $next_field_id = $form_state['next_field_id'];
          // Create object to store file and target field info. To be stored in ctools cache.
          $file_info = new stdClass();
          // Generate the field name.  The bundle is the first part of the field name for a field collection, so we just need the next available option
          // in the $field_images['und'] array. The second number (for field_image) will always be 0 since it is a single value field.

          // First, get field information for the field collection.
          $instances = field_info_instances('field_collection_item', $bundle);
          // Find the image field in the collection (assuming there is only one) and get the name.
          foreach ($instances as $instance_name => $instance_settings) {
            if ($instance_settings['widget']['module'] == 'image') {
              $image_field_name = str_replace('_', '-', $instance_name);
              break;
            }
          }

          // Replace underscores with dashes in $bundle.
          $bundle = str_replace('_', '-', $bundle);

          // ctools caching requires an object, not an array.
          $file_info = new stdClass();
          $file_info->fieldname['url'] = '#edit-' . $bundle . '-und-' . $next_field_id . '-' . $image_field_name . '-und-0-imgsearch-file-url';
          $file_info->fieldname['person'] = '#edit-' . $bundle . '-und-' . $next_field_id . '-field-person-und-0-value';
          $file_info->fieldname['organization'] = '#edit-' . $bundle . '-und-' . $next_field_id . '-field-organization-und-0-value';
          $file_info->fieldname['year'] = '#edit-' . $bundle . '-und-' . $next_field_id . '-field-year-und-0-value';

          $file_info->fids = array();

          // Theme the results as a table.
          $header = array(t('Image'), t('File Name'), t('Add to field'));
          $rows = array();
          foreach ($results['images'] as $image) {
            // Create image style derivative for each image.
            $imagestyle = array(
              'style_name' => 'thumbnail',
              'path' => $image['filepath'] . $image['filename'],
              'width' => '',
              'height' => '',
              'alt' => '',
              'title' => $image['filename'],
            );
            $styled_image = theme('image_style', $imagestyle);
            $fid = $image['fid'];

            $rows[] = array(
              'image' => $styled_image,
              'name' => $image['filename'],
              'add' => ctools_ajax_text_button("select", "imgsearch/nojs/imgadd/" . $fid . '/' . $next_field_id, t('Select')),
            );

            $file_info->fids[$fid] = $image['filename'];

            // Cache values for Person, Organization, and Year if they exist.
            foreach (array('person', 'organization', 'year') as $field) {
              if (isset($image[$field])) {
                $file_info->meta[$fid][$field] = $image[$field];
              }
            }
          }
          //Cache image name in ctools object cache so it can be used later in nb_image_search_image_add().
          ctools_include('object-cache');
          ctools_object_cache_set('imgsearch', 'imgsearch_' . $next_field_id, $file_info);

          // Create a render array ($build) which will be themed as a table with a
          // pager.
          $build['search_form'] = isset($search_form[0]) ? drupal_build_form('imgsearch_form', $form_state) : $search_form;
          $build['imgsearch_table'] = array(
            '#theme' => 'table',
            '#header' => $header,
            '#rows' => $rows,
            '#empty' => t('There were no matching results found'),
          );

          // Attach the pager theme.
          $pager = pager_default_initialize($results['total_found'], $results['rows']);
          $build['imgsearch_pager'] = array(
            '#theme' => 'pager',
            '#parameters' => array(
              'search_terms' => $form_state['values']['search_terms'],
              ),
          );

          // Add values for person, organization, and year to $build['imgsearch_pager']['#parameters'] if they are set.
          foreach (array('person','organization', 'year') as $search_field) {
            $search_field_name = 'search_' . $search_field;
            if ($form_state['values'][$search_field_name] != '' && !isset($_GET[$search_field_name])) {
              $build['imgsearch_pager']['#parameters'][$search_field_name] = $form_state['values'][$search_field_name];
            }
            elseif (isset($_GET[$search_field_name])) {
              $build['imgsearch_pager']['#parameters'][$search_field_name] = $_GET[$search_field_name];
            }
          }

          // Set this to check for when coming back to the first page from a later page in the
          // paged search results. We have to do this because the pager itself has no indication of
          // where it is coming from when returning to the front page.
          if ($page_num > 0) {
            $build['imgsearch_pager']['#parameters']['pager_source'] = TRUE;
          }

          $form_state['values']['title'] = t('Search Results - Page !page', array('!page' => $pager + 1));
          $output = ctools_modal_form_render($form_state['values'], $build);

          print ajax_render($output);
          drupal_exit();
        }
       else {
          $build['no_results'] = array(
            'markup' => '<div class="no-results>No images found</div>',
          );
        }
      }
    }
    elseif (!isset($output)) {
      $output = ctools_modal_form_wrapper('imgsearch_form', $form_state);
      // Return the ajax instructions to the browser via ajax_render().
      print ajax_render($output);
      drupal_exit();
    }
  }
  else {
    return drupal_get_form('imgsearch_form', $id);
  }
}

There is a lot going on here, so I won't cover everything in detail, but I'll hit the high points.

First, we check to see if we're using ajax, and then include the required code to use ctools and ajax. We also build our search form using ctools_modal_form_wrapper().

if ($ajax) {
    //Load the modal library and add the modal javascript.
    ctools_include('ajax');
    ctools_include('modal');

    $form_state = array(
      'ajax' => TRUE,
      'title' => t('Image Search Form'),
      'next_field_id' => $id,
    );

    // Use ctools to generate ajax instructions for the browser to create
    // a form in a modal popup.
    $search_form = ctools_modal_form_wrapper('imgsearch_form', $form_state);

Next we determine if we even search, or just render the search form by itself, because when the modal is first rendered, we only want to render the form, since we haven't entered any search criteria yet.

if ($form_state['executed'] || $_GET['page'] || $_GET['pager_source']) {

These three conditions are used because they tell us when either a search has been done or if we are paging through results:

  • $form_state['executed'] - This is only set when the form has been submitted with the Submit button.
  • $_GET['page'] - This value is set by the pager, but only on pages other than the first.
  • $_GET['pager_source'] - Manually set later in the function to tell us when we're on the first page coming from a later page, since $_GET['page'] isn't set in that case and $form_state['executed'] isn't set since the form wasn't just submitted.

We then set the $page_num  value itself, based on the variables detailed above.

// $form_state['executed'] = TRUE only when the form has been submitted, which means we are on the first page.
if ($form_state['executed']) {
  $page_num = 0;
}
// The form was not submitted this time, so determine what page of results we are on. $_GET['page'] is only set for pages past
// the first page.
else {
  $page_num = isset($_GET['page']) ? $_GET['page'] : 0;
}

$page_num is used in the search function itself to determine which range of images to get from Solr.

The next section calls the search function and formats the results in a table format. There is a lot going on, but the one thing I want to point out is the use of Ctools object caching.

// ctools caching requires an object, not an array.
$file_info = new stdClass();
$file_info->fieldname['url'] = '#edit-' . $bundle . '-und-' . $next_field_id . '-' . $image_field_name . '-und-0-imgsearch-file-url';
$file_info->fieldname['person'] = '#edit-' . $bundle . '-und-' . $next_field_id . '-field-person-und-0-value';
$file_info->fieldname['organization'] = '#edit-' . $bundle . '-und-' . $next_field_id . '-field-organization-und-0-value';
$file_info->fieldname['year'] = '#edit-' . $bundle . '-und-' . $next_field_id . '-field-year-und-0-value';

$file_info->fids = array();

// Theme the results as a table.
$header = array(t('Image'), t('File Name'), t('Add to field'));
$rows = array();
foreach ($results['images'] as $image) {
  // Create image style derivative for each image.
  $imagestyle = array(
    'style_name' => 'thumbnail',
    'path' => $image['filepath'] . $image['filename'],
    'width' => '',
    'height' => '',
    'alt' => '',
    'title' => $image['filename'],
  );
  $styled_image = theme('image_style', $imagestyle);
  $fid = $image['fid'];

  $rows[] = array(
    'image' => $styled_image,
    'name' => $image['filename'],
    'add' => ctools_ajax_text_button("select", "imgsearch/nojs/imgadd/" . $fid . '/' . $next_field_id, t('Select')),
  );

  $file_info->fids[$fid] = $image['filename'];

  // Cache values for Person, Organization, and Year if they exist.
  foreach (array('person', 'organization', 'year') as $field) {
    if (isset($image[$field])) {
      $file_info->meta[$fid][$field] = $image[$field];
    }
  }
}
//Cache image name in ctools object cache so it can be used later in nb_image_search_image_add().
ctools_include('object-cache');
ctools_object_cache_set('imgsearch', 'imgsearch_' . $next_field_id, $file_info);

I need to pass a lot of data from this function to my function that writes image data back to the source field on the node form, so this is the best way to do it without creating a huge link to pass everything through the URL. Later on we'll see where I get the data from this cache and clear it out.

The rest of the function builds a render array and adds the pager links. A key part of this is adding parameters to the pager links so that we can access them later in our search function.

// Attach the pager theme.
$pager = pager_default_initialize($results['total_found'], $results['rows']);
$build['imgsearch_pager'] = array(
  '#theme' => 'pager',
  '#parameters' => array(
    'search_terms' => $form_state['values']['search_terms'],
  ),
);

// Add values for person, organization, and year to $build['imgsearch_pager']['#parameters'] if they are set.
foreach (array('person','organization', 'year') as $search_field) {
  $search_field_name = 'search_' . $search_field;
  if ($form_state['values'][$search_field_name] != '' && !isset($_GET[$search_field_name])) {
    $build['imgsearch_pager']['#parameters'][$search_field_name] = $form_state['values'][$search_field_name];
  }
  elseif (isset($_GET[$search_field_name])) {
    $build['imgsearch_pager']['#parameters'][$search_field_name] = $_GET[$search_field_name];
  }
}

// Set this to check for when coming back to the first page from a later page in the
// paged search results. We have to do this because the pager itself has no indication of
// where it is coming from when returning to the front page.
if ($page_num > 0) {
  $build['imgsearch_pager']['#parameters']['pager_source'] = TRUE;
}

Note that this is also where we set $_GET['pager_source'] so that we can tell if we are coming back to the first page of results from a later page.

One thing to note here is that in order for the pager links to work within the modal, they need to have the ctools-use-modal class applied to them. The theme_pager_link() function accepts attributes to apply to the links, but for some strange reason, the functions that actually call theme_pager_link() (e.g. theme_pager_previous(), theme_pager_next(), and theme_pager_last()) don't bother to actually pass attributes, so there's no way to get them to the link theme function. Sigh. To get around that, I created a simple jQuery behavior that adds the class to the pager links:

(function ($) {
  Drupal.behaviors.addCtoolsModalClass = {
    attach: function (context) {
        $('#modalContent .pagination ul li a').addClass('ctools-use-modal');
        $('#modalContent .pagination ul li a').addClass('ctools-modal-imgsearch-modal-style');
    }
  }
})(jQuery);

Finally, we set the modal window title with the page number so the user can tell which page he is on, send the output to the page, and exit.

$form_state['values']['title'] = t('Search Results - Page !page', array('!page' => $pager + 1));
  $output = ctools_modal_form_render($form_state['values'], $build);

  print ajax_render($output);  
  drupal_exit();
}

And last, but not least, our else condition for when there are no search results to display, and we just want to display the form.

elseif (!isset($output)) {
  $output = ctools_modal_form_wrapper('imgsearch_form', $form_state);
  // Return the ajax instructions to the browser via ajax_render().
  print ajax_render($output);
  drupal_exit();
}

So now, all that's left to do is our search function, and code to take the selected item from the modal and write it back to the filefield source field on the node form. All that will be done in the final post in this series.

Sep21