Smart Autocomplete Navigation with Drupal 7

Recently we were tasked with building a simple widget that would provide prospective students with suggestions for school programs based on a catalogue of pre-set keywords.  The idea was to help visitors narrow down their choices based on a variety of criteria including program topics (e.g., "ecology", "modern languages") and personal interests (e.g., "research", "singing"). Since some visitors would certainly know a program by name, it would also look up matches on the title.

Although this would technically be a form, no form would be submitted. Instead, making a selection would redirect the visitor directly to the selected program page. Sort of like a smart dropdown menu.

Programs for creative types. 

Fortunately the Drupal content management framework provides much of the base functionality required for this, such as fieldable entities and taxonomy management. Drupal's Form API also provides an #autocomplete_path property that allows us to attach a callback URL to a textfield. When a user types something into the textfield, Drupal sends a request to the callback function to process the string and reply with suggestions.

To begin this tutorial, you will be prompted to set up a few things via the Drupal administration interface. Next, we will write a simple custom module that sets up the form and provides a callback script. Finally, we will output the form to our theme.

If you are following along, you should already have a basic knowledge of Drupal content types, taxonomies and fields. If you have never created a custom module before, I recommend this primer at Drupalize.me.

 Important note: The select event for an autocomplete field was added in Drupal 7.37. If your version is older, you will need to apply this patch.

1. Set up a content type and create some tagged content

Log in to the administration UI and do the following:

  1. Create a new content type (e.g. "Program").
  2. Create a taxonomy vocabulary and enter some terms.
  3. Add a term reference field to the content type which will reference the vocabulary.
  4. Create some nodes (entities) from your content type and tag them. 

2. Create a custom module

In our custom module we will:

  1. Define a path to a callback function.
  2. Build a form containing a single textfield.
  3. Write a callback function that queries the database and returns a JSON object.
  4. Use jQuery to attach a select event to the field.

(Replace MODULENAME with the actual module name wherever it occurs.)

The module should contain a minimum of 3 files: MODULENAME.info, MODULENAME.module, and MODULENAME.js. If you don't know how to set up an *.info file, refer to this handbook page. From hereon we will be working with the latter two.

  1. Define a path to a callback function

    File: MODULENAME.module

    /**
     * Implements hook_menu().
     */
    function MODULENAME_menu() {  
      $items['MODULENAME/autocomplete'] = array(
        'page callback' => 'MODULENAME_autocomplete',
        'access arguments' => array('access content'),
        'type' => MENU_CALLBACK
      );
      return $items;
    }
    

    Implement hook_menu() to define a path to the callback function as well as access rights. Here, the path is "MODULENAME/autocomplete" and access to the results (suggestions) are restricted to users with the 'View published content' permission.

  2. Build a form containing a single textfield

    File: MODULENAME.module

    /**
     * Build a basic form. 
     */
    function MODULENAME_search_form($form, &$form_state) {
      $form['autocomplete_field'] = array(
        '#title' => t(''),
        '#type' => 'textfield',
        '#default_value' => t('Start typing...'),
        '#autocomplete_path' => 'MODULENAME/autocomplete',
      );
      return $form;
    }
    

    This function returns a simple form with one textfield. Later we will call this function from our theme with drupal_get_form(). Note that we have filled in the #autocomplete_path property with the path to the callback function defined above.

  3. Write a callback function that queries the database and returns a JSON object

    File: MODULENAME.module

    /**
     * Menu callback function.
     *
     * @param string $string
     *  The string that will be searched.
     * @return object
     *  A JSON object.
     */
    function MODULENAME_autocomplete($string) {  
    
      $matches = array();
    
      $query = db_select('node', 'n');
    
      $query->join('field_data_field_MODULENAME_terms', 'st', 'st.entity_id = n.nid');
      $query->join('taxonomy_term_data', 'td', 'td.tid = st.field_MODULENAME_terms_tid');
      
      $or = db_or();
      $or->condition('n.title', '%' . db_like($string) . '%', 'LIKE');
      $or->condition('td.name', '%' . db_like($string) . '%', 'LIKE');
      
      $query->fields('n', array('nid', 'title'));
      $query->condition('n.type', 'programme', '=');
      $query->condition($or);
      $query->range(0, 20);
      
      $return = $query->execute();
      
      foreach ($return as $row) {
        $matches[drupal_get_path_alias('node/' . $row->nid)] = check_plain($row->title);
      }
    
      if (empty($matches)) {
        $matches['0'] = t('No matches.');
      }  
    
      drupal_json_output($matches);
    }

    This function queries the database for matches on $string, where $string is the text typed into the textfield. In this case we are looking for matches on either the entity title or its referenced terms. drupal_json_output() will return the matching nodes in JSON format. (For more information on Drupal 7's Database API, visit these handbook pages.)

  4. Use jQuery to attach a select event to the field.

    File: MODULENAME.js

    (function ($) {
    
      Drupal.behaviors.programmeSearch = {
        attach: function (context) {
          
          $('#edit-autocomplete-field').focus(
            function(){
              $(this).val('');
          });
    
          $("#edit-autocomplete-field", context).bind('autocompleteSelect', function(event, node) {
            
            var key = $(node).data('autocompleteValue');
            var label = $(node).text();
            
            // If matches found...
            if (key != '0') {
              
              // Set the value of this field.
              $(this).val(label);
              
              // Redirect user to entity path.
              window.location = Drupal.settings.basePath + key;
            }
            else {
              
              // If no matches, reset.
              $(this).val('');
              $(this).focus();
            }
          });
        }
      };
    
    })(jQuery);

    The code above defines certain actions to take when a user clicks on a suggestion; specifically, populate the textfield with the entity title and then redirect the user to the entity path. It also handily clears the textfield on focus.

     A note regarding Google Analytics: With this type of redirect, GA will only record the originating page (the page with the form) as the referrer. If you want to track clicks on the form itself, consider adding click tracking.

    You can find our original code for this module on Github: https://github.com/othermachines/programme_search

3. Output to your theme

Finally, we need to output the form to our website.

In this section we will:

  1.  Set up a preprocessor function.
  2. Programmatically load a Javascript file on every page.
  3. Populate a new variable to be accessed from a template file.
  4. Output the form in a template file.
  1. Set up a preprocessor function

    If you don't already have a template.php file in your theme directory, create one. Add the following code, replacing THEMENAME with the actual name of your theme:

    File: template.php

    function THEMENAME_preprocess_page(&$vars) {
      
      // We will be adding more code here.
    }

    Preprocessor functions are used primarily to set up and alter variables that can be accessed from template files. The function above is a preprocessor for the template file page.tpl.php

  2. Programmatically load a JavaScript file.

    Add the following code to your preprocessor:

    File: template.php

    function THEMENAME_preprocess_page(&$vars) {
      
      // Load JavaScript.
      drupal_add_js(drupal_get_path('module', 'MODULENAME') . '/MODULENAME.js', array('type' => 'file', 'scope' => 'footer'));
    }

    The drupal_add_js() function will load the JavaScript file that we created for our custom module.

  3. Create a variable that will contain the form.

    Add this last bit of code to the preprocessor:

    File: template.php

    function THEMENAME_preprocess_page(&$vars) {
      
      // Load JavaScript.
      drupal_add_js(drupal_get_path('module', 'MODULENAME') . '/MODULENAME.js', array('type' => 'file', 'scope' => 'footer'));
        
      // Create a new template variable and populate it with form output.
      $vars['autocomplete_form'] = drupal_get_form('MODULENAME_search_form');
    }

    We have defined a new variable by adding the variable name to the $vars array. Again, by defining the variable here we will be able to access it from our template file. 

    You will need to clear the cache for Drupal to recognize the new preprocessor.

  4. Output the form in a template file.

    This is the last step!

    If the theme directory doesn't already contain a file called page.tpl.php, you will need to find one and copy it over. If you are using a base theme, check there first. Otherwise, copy the default file at modules/system/page.tpl.php. You can place it anywhere in your theme directory. Once you've done that, clear the cache.

    Below is the code we will be adding. You can place it in any region you like (header, content, sidebar, etc.).

    File: page.tpl.php

    <?php if (isset($autocomplete_form)): ?>
    <?php print drupal_render($autocomplete_form); ?>
    <?php endif; ?>

    This ensures our variable exists and, if so, renders the form as HTML. That's it!

Conclusion

We call this "smart" navigation because it takes information from the user and returns a set of suggestions.  Because the suggestions contain related terms in addition to literal ones, this allows the school to offer matches based on fuzzier criteria.  There are many scenarios in which this would be useful - ours is only one. Mind you, we wouldn't recommend this as a replacement for traditional navigation. It should be a supplementary tool only.

Drupal 7 provides a robust set of tools for finding and displaying content in creative ways.  If you found this tutorial helpful, and especially if you have an interesting use case, we would love to hear about it. Leave us a comment!

Links