A recipe for extending Views handlers without subclassing

One of my long-standing gripes with Views is the inability to alter the behaviour of existing Views handlers (e.g. fields, filters, etc.) without having to subclass the desired handlers to add new functionality. While the subclassing approach is fine when the functionality targets a new field type, it is not ideal if the change required should affect existing fields, across different types of handlers.

I was recently commissioned to create a module that displays tooltips on field headers, regardless of field type. This is an example of the latter case above, and my solution, Views Label Tooltip, exemplifies my technique to achieve field alterations that are orthogonal to handler types. Following is an explanation of how I did it.

Problem definition

We want to extend Views field settings with additional options and modify their rendering and/or behaviour based on these options. In our case, each field has an additional "Tooltip" setting that gets rendered on the field's label.

Where to store the new field options?

This is the key to the technique. We need to store the custom field settings such that they behave just like the native ones:

  • Get imported/exported with standard view import/export
  • Get overridden when a field is overridden

For this, we use a Views display extender. The official documentation for this plugin type is that "Display extender plugins allow scaling of views options horizontally. This means that you can add options and do stuff on all views displays. One theoretical example is metatags for views." The key word is horizontal: it applies to all display plugins. What we're trying to achieve here is similar, but for field handlers. So until the Views maintainers decide to generalize the concept of extenders to other Views objects, we can use (some would say abuse) display extenders to hold the additional settings for us.

Here's the implementation of our display extender:

class views_label_tooltip_plugin_display_extender extends views_plugin_display_extender {
  function options_definition_alter(&$options) {
    $options['tooltips'] = array('default' => array(), 'unpack_translatable' => 'unpack_tooltips');
  }

  function unpack_tooltips(&$translatable, $storage, $option, $definition, $parents, $keys = array()) {
    $tooltips = $storage[$option];
    if (!empty($tooltips)) foreach ($tooltips as $field => $tooltip) {
      $translation_keys = array_merge($keys, array($field));
      $translatable[] = array(
        'value' => $tooltip,
        'keys' => $translation_keys,
        'format' => NULL,
      );
    }
  }
}

Here, we define the new tooltips option, and since tooltips hold translatable text, we instruct Views on how to export this data structure. (Note: unpack_translatables don't work correctly for display extenders in the current version of Views, but I submitted a patch to fix that.)

Now this option gets imported/exported along with the all other Views settings, which fulfills our first storage requirement. But since there's only one copy of it in each display, we will use this option as an array of tooltips, one entry per field. The tooltips option will be manipulated on each field's admin UI, as is shown below. There is no need for the display extender to have its own admin UI.

How to alter the field admin UI?

We need to alter the Views UI views_ui_config_item_form in order to inject our new options. Here's the code from Views Label Tooltip:

/**
 * Implements hook_form_FORM_ID_alter() for `views_ui_config_item_form`.
 */
function views_label_tooltip_form_views_ui_config_item_form_alter(&$form, &$form_state) {
  if ($form_state['type'] != 'field') return;

  $form_state['tooltips'] = views_label_tooltip_get_option($form_state['view']);
  $form['options']['element_label_tooltip'] = array(
    '#type' => 'textarea',
    '#title' => t('Tooltip'),
    '#description' => t('Place your tooltip text here. HTML allowed.'),
    '#default_value' => @$form_state['tooltips'][$form_state['id']],
    '#attributes' => array(
      'class' => array('dependent-options'),
    ),
    '#dependency' => $form['options']['element_label_colon']['#dependency'],
    '#weight' => $form['options']['element_label_colon']['#weight'] + 1,
  );
  $form['buttons']['submit']['#submit'][] = 'views_label_tooltip_form_views_ui_config_item_form_submit';
}

Note how we need to explicitly add the CSS class dependent-options to our element. Here's how the form looks like after alteration: Tooltip setting in field UI

To place the new form element below an existing one, we set the #weight attribute to follow the latter. Note that we're using a custom function views_label_tooltip_get_option() to get the option's value, we'll see why below. Here's the implementation of the form submit handler:

/**
 * Submit function for `views_ui_config_item_form`.
 */
function views_label_tooltip_form_views_ui_config_item_form_submit($form, &$form_state) {
  // Set the tooltip in our display extender.
  $display_id = $form_state['values']['override']['dropdown'];
  $tooltips = $form_state['tooltips'];
  $form_state['view']->set_display($display_id);
  if ($form_state['values']['options']['element_label_tooltip']) {
    $tooltips[$form_state['id']] = $form_state['values']['options']['element_label_tooltip'];
  }
  else {
    unset($tooltips[$form_state['id']]);
  }
  $form_state['view']->display_handler->set_option('tooltips', $tooltips);

  // Write to cache.
  views_ui_cache_set($form_state['view']);
}

in this submit handler, we detect whether the user is overriding the field on the current display, or altering the default fields. Based on this, we decide to save the option to the current display, or to the default (master) display, respectively. That's why we need a custom function to read back the options: we need to emulate the standard Views overriding logic by choosing the correct display to read the options:

/**
 * Helper function to get tooltips setting.
 */
function views_label_tooltip_get_option($view) {
  if (isset($view->display_handler->display->display_options['fields'])) {
    // Fields are overridden: use this display's tooltips.
    $tooltips = @$view->display_handler->display->display_options['tooltips'];
  }
  else {
    // Fields are default: use default display's tooltips.
    $tooltips = @$view->display['default']->display_options['tooltips'];
  }
  return $tooltips;
}

We have now fulfilled the second storage requirement. On to rendering.

Rendering the altered fields

To render the tooltip on top of field labels that are generated by the Views theming system, we use JavaScript. We inject our JS code during hook_views_pre_render(). Our hook implementation calls a theme function to generate each tooltip - theme functions can be overridden, which allows theme developers to customize the tooltip markup. The hook implementation also marks each target field label with a special class that our JavaScript can recognize:

/**
 * Implements hook_views_pre_render().
 */
function views_label_tooltip_views_pre_render(&$view) {
  $tooltips = views_label_tooltip_get_option($view);
  if (empty($tooltips)) return;

  // Theme tooltip and add our label class before rendering.
  $themed = array();
  foreach ($tooltips as $field => $tooltip) {
    if (!empty($view->field[$field]) && empty($view->field[$field]->options['exclude'])) {
      $field_css = drupal_clean_css_identifier($field); 
      $themed[$field_css] = theme('views_label_tooltip', array(
        'view' => $view, 
        'field' => $field, 
        'tooltip' => t($tooltip),
      ));

      $label_class =& $view->field[$field]->options['element_label_class'];
      if ($label_class) {
        $label_class .= ' ';
      }
      $label_class .= 'views-label-tooltip-field-' . $field_css;
    }
  }

  // Bail early if nothing to do.
  if (empty($themed)) return;

  // Add our JS files.
  drupal_add_js(drupal_get_path('module', 'views_label_tooltip') . '/js/views_label_tooltip.js');
  drupal_add_js(array(
    'viewsLabelTooltip' => array(
      $view->name => array(
        $view->current_display => array(
          'tooltips' => $themed,
        ),
      ),
    ),
  ), 'setting');
}

/**
 * Theme function for `views_label_tooltip`.
 */
function theme_views_label_tooltip(&$variables) {
  return theme('image', array(
    'path' => drupal_get_path('module', 'views_label_tooltip') . '/images/help.png',
    'attributes' => array(
      'title' => $variables['tooltip'],
      'class' => array(
        'views-label-tooltip',
      ),
    ),
  ));
}

Finally, the JavaScript code is responsible for adding the theme for each tooltip to the appropriate field label:

(function ($) {

  Drupal.behaviors.viewsLabelTooltip = {
    attach: function(context) {
      $.each(Drupal.settings.viewsLabelTooltip, function(view, displays) {
        $.each(displays, function(display, settings) {
          $.each(settings.tooltips, function(field, tooltip) {
            $('.view-id-' + view + '.view-display-id-' + display + ' .views-label-tooltip-field-' + field + '.views-field-' + field)
              .once('views-label-tooltip')
              .append(tooltip);
          });
        });
      });
    }

})(jQuery);

And here's the result (additionally rendered with the qTip jQuery plugin): Field labels with tooltips

AttachmentSize
views_label_tooltip_ui.png24.07 KB
views_label_tooltip_0.png54.11 KB

Comments

Thanks a lot for posting this! Really invaluable if you want to implement something similar – I don't know how many hours or days it would have taken me to come up with that solution! First time I heard of display extenders … Anyways, you really helped me a lot there! Thanks again!

Hello, I tried to send you a message regarding consulting services using the contact form, but it told me that there was an error and the email could not be sent. If you might be interested in my inquiry regarding consulting services please let me know: Michael 604 895 3160

That's quite nice. Though I always hated Views and rather write my own code :)