Mar 26 2017
Mar 26
Last time we've created a style plugin. Now we will learn how to cache panel panes with custom cache plugins.

In this tutorial we will create a panel cache plugin which will cache panel pane for a given time (5, 10 or 15 seconds). For now we have a module with next file structure:
example_panels_module
  |__ plugins
  |  |__ styles
  |  |  |__ example_panels_module.styles_plugin.inc
  |__ example_panels_module.info
  |__ example_panels_module.module
All that we need to do is put file example_panels_module.cache_plugin.inc into plugins/cache directory with next content:
<?php

/**
 * @file
 * Example panels cache plugin.
 */

$plugin = [
  // Plugin title.
  'title' => t('Example panel cache'),
  // Plugin description.
  'description' => t('Example panel cache.'),
  // Cache get callback.
  'cache get' => 'example_panels_module_cache_get_cache',
  // Cache set callback.
  'cache set' => 'example_panels_module_cache_set_cache',
  // Cache clear callback.
  'cache clear' => 'example_panels_module_cache_clear_cache',
  // Settings form.
  'settings form' => 'example_panels_module_cache_settings_form',
  // Settings form submit.
  'settings form submit' => 'example_panels_module_cache_settings_form_submit',
  // Default values.
  'defaults' => [
    'lifetime' => 5,
  ],
];

/**
 * Get cached content.
 */
function example_panels_module_cache_get_cache($conf, $display, $args, $contexts, $pane = NULL) {
  $cache = cache_get('example_panels_module_cache:' . $pane->pid, 'cache_panels');

  // No cached data available.
  if (!$cache) {
    return FALSE;
  }

  // Cache is expired.
  if ((REQUEST_TIME - $cache->created) > $conf['lifetime']) {
    return FALSE;
  }

  return $cache->data;
}

/**
 * Set cached content.
 */
function example_panels_module_cache_set_cache($conf, $content, $display, $args, $contexts, $pane = NULL) {
 cache_set('example_panels_module_cache:' . $pane->pid, $content, 'cache_panels');
}

/**
 * Clear cached content.
 */
function example_panels_module_cache_clear_cache($display) {
  cache_clear_all('example_panels_module_cache:', 'cache_panels', TRUE);
}

function example_panels_module_cache_settings_form($conf, $display, $pid) {
  // Cache lifetime.
  $form['lifetime'] = [
    '#title' => t('Lifetime'),
    '#type' => 'select',
    '#options' => drupal_map_assoc([5, 10, 15], 'format_interval'),
    '#default_value' => $conf['lifetime'],
  ];

  return $form;
}
So now module structure looks like:
example_panels_module
  |__ plugins
  |  |__ styles
  |  |  |__ example_panels_module.styles_plugin.inc
  |__|__ cache
  |__|__|__ example_panels_module.cache_plugin.inc
  |__ example_panels_module.info
  |__ example_panels_module.module
Clear cache (on enable module if it isn't enable yet) and navigate to panel pages settings (for this tutorial I've created a test panel page for displaying a single node). Choose a panel pane to cache and set up a cache type as Example panel cache (for this tutorial I've created a panel pane that shows a REQUEST_TIME value and put it into node/view panel page):

Set up cache for a given panel pane

Then chose cache lifetime:

Cache lifetime

Save settings and create some test node. For example if you set up cache life time as 5 seconds then value will be static for 5 seconds and only after this time will be refreshed.

Key notes:


Source code of all examples are available here.

Feb 25 2017
Feb 25
Panels style plugins are made for wrapping panel panes and panel regions into extra markup. 99% of your needs are covered by a "Panels Extra Styles" module. Please look at that module if you need extra styles for panels. But if you need some specific style you can easily implement it.

In this tutorial we will create plugin style for region or pane. It will allow site builders to wrap region or pane into a custom markup entered in settings form.

1. Create a directory example_panels_module and put there a file example_panels_module.info:

name = Example panels module
description = Provides example implementation of panels plugins
core = 7.x

; Since we're using panels for creating
; plugins we have to define this
; dependency.
dependencies[] = panels
2. Tell ctools where it has to search plugins (file example_panels_module.module):
<?php

/**
 * Implements hook_ctools_plugin_directory().
 *
 * Integrate our module with Ctools. Tell where
 * Ctools has to search plugins. Please note that
 * we should return path to plugins directory only
 * if $module equals 'panels'.
 */
function example_panels_module_ctools_plugin_directory($module, $plugin) {
  if ($module == 'panels' && !empty($plugin)) {
    return "plugins/$plugin";
  }
}
3. Create directory plugins/styles and put there a file example_panels_module.style_plugin.inc:
<?php
/**
 * @file
 * 'Example panel style' style.
 */

$plugin = [
  // Plugin title.
  'title' => t('Example panel style'),
  // Plugin description.
  'description' => t('Raw HTML wrapper.'),
  // Render region callback.
  'render region' => 'example_panels_module_raw_wrapper_render_region',
  // Render pane callback
  'render pane' => 'example_panels_module_raw_wrapper_render_pane',
  // Region settings form.
  'settings form' => 'example_panels_module_raw_wrapper_region_settings_form',
  // Pane settings form.
  'pane settings form' => 'example_panels_module_raw_wrapper_pane_settings_form',
];

/**
 * Region settings form callback.
 */
function example_panels_module_raw_wrapper_region_settings_form($settings) {
  // Define a settings form with prefix and suffix text areas
  // for region style.
  $form['wrapper_region_prefix'] = [
    '#type' => 'textarea',
    '#title' => t('Region wrapper prefix'),
    '#default_value' => !empty($settings['wrapper_region_prefix']) ? $settings['wrapper_region_prefix'] : '',
  ];

  $form['wrapper_region_suffix'] = [
    '#type' => 'textarea',
    '#title' => t('Region wrapper suffix'),
    '#default_value' => !empty($settings['wrapper_region_suffix']) ? $settings['wrapper_region_suffix'] : '',
  ];

  return $form;
}

/**
 * Region render callback.
 *
 * Please note that it's a theme function
 * and has to start with 'theme_' prefix.
 */
function theme_example_panels_module_raw_wrapper_render_region($vars) {
  $output = '';

  // Variable $vars['panes'] contains an array of all
  // panel panes in current region. Collect them into
  // variable.
  foreach ($vars['panes'] as $pane) {
    $output .= $pane;
  }

  // Variable $vars['settings'] contains settings
  // entered in settings form. Wrap region content
  // into custom markup.
  return $vars['settings']['wrapper_region_prefix'] . $output . $vars['settings']['wrapper_region_suffix'];
}

/**
 * Pane settings form callback.
 */
function example_panels_module_raw_wrapper_pane_settings_form($settings) {
  // Define a settings form with prefix and suffix text areas
  // for pane style.
  $form['wrapper_pane_prefix'] = [
    '#type' => 'textarea',
    '#title' => t('Pane wrapper prefix'),
    '#default_value' => !empty($settings['wrapper_pane_prefix']) ? $settings['wrapper_pane_prefix'] : '',
  ];

  $form['wrapper_pane_suffix'] = [
    '#type' => 'textarea',
    '#title' => t('Pane wrapper suffix'),
    '#default_value' => !empty($settings['wrapper_pane_suffix']) ? $settings['wrapper_pane_suffix'] : '',
  ];

  return $form;
}

/**
 * Pane render callback.
 *
 * Please note that it's a theme function
 * and has to start with 'theme_' prefix.
 */
function theme_example_panels_module_raw_wrapper_render_pane($vars) {
  // Variable $vars['settings'] contains settings
  // entered in settings form. Variable
  // $vars['content']->content is a renderable array
  // of a current pane. Wrap pane content
  // into custom markup.
  return $vars['settings']['wrapper_pane_prefix'] . render($vars['content']->content) . $vars['settings']['wrapper_pane_suffix'];
}
For now we have a module with next structure:
example_panels_module
  |__ plugins
  |  |__ styles
  |  |  |__ example_panels_module.styles_plugin.inc
  |__ example_panels_module.info
  |__ example_panels_module.module
When it's enabled you will be able to setup style for a region or a pane. We've created style for both of them but you can define style only for region or only for pane.

Let's test our style. I want to edit node/%node page: wrap content region into div tag with a class "content-region-wrapper". And wrap node_beind_viewed pane into span tag with id "pane-node-view-wrapper". That's how can I do that:

Change style for a region

Select newly created style

Set up prefix and suffix for a region

The same way I've set up prefix and suffix for a panel pane and that's what I've got:

Result markup

Key notes:

Feb 14 2017
Feb 14
Relationships plugins are "bridge" between existing context (that is already set up in a panel) and a context which you want to get from existing one. Let's say your panel contains "Node" context and you want to get a node author (user from node:uid property). To do that you can just set up "Node author" relationship in a panel (under a "contexts" tab) and that's all. That's why relationships plugins are so important - they provide easy way for getting context from existing contexts. Please have a look at this post before continue reading - there is described how to create module integrated with ctools API which allows us to define own plugins.

As you remember we've created a custom server_info context plugin that provides information from $_SERVER superglobal php variable. All that you can grab from that context are strings. Ctools comes with a simple context plugin string. It provides three ways of string representation: html_safe, raw and uppercase_words_html_safe. In this tutorial we will create a relationship plugin that will convert a string from server_info to a string context.

Let's add new plugin to our existing example_module which for now looks like:

example_module
  |__ plugins
  |  |__ content_types
  |  |  |__ example_module.example_content_type_plugin.inc
  |  |__ access
  |  |  |__ example_module.example_access_plugin.inc
  |  |__ contexts
  |  |  |__ example_module.example_context_plugin.inc
  |  |  |__ example_module.example_context_plugin.node_status.inc
  |  |__ arguments
  |     |__ example_module.example_argument_plugin.inc
  |__ example_module.info
  |__ example_module.module
1. Create file example_module/plugins/arguments/example_module.example_argument_plugin.inc:
<?php


/**
 * @file
 *
 * Plugin to provide a string relationship from server_info context.
 */

/**
 * Plugins are described by creating a $plugin array which will be used
 * by the system that includes this file.
 */
$plugin = [
  // Plugin title.
  'title' => t('String from "Server info" context'),
  // Plugin description.
  'description' => t('Adds a string context from existing server_info context.'),
  // Keyword.
  'keyword' => 'string_from_server_info',
  // We want to create string context from server_info context.
  // It means that server_info has to be already set up into
  // a panel page. So server_info context is required for
  // this relationship.
  'required context' => new ctools_context_required(t('Server info'), 'server_info'),
  // Context builder function.
  'context' => 'example_relationship_plugin',
  // Settings form. We will provide a property from
  // server_info context we want to present as string context.
  'edit form' => 'example_relationship_plugin_settings_form',
  // Default values for settings form.
  'defaults' => [
    'server_info_property' => 'HTTP_HOST',
  ],
];

/**
 * Return a new context based on an existing context.
 */
function example_relationship_plugin($context = NULL, $conf) {
  $string_context = NULL;

  // If empty it wants a generic, unfilled context, which is just NULL.
  if (empty($context->data)) {
    $string_context = ctools_context_create_empty('string', NULL);
  }
  else {
    if (!empty($conf['server_info_property'])) {
      // Create the new string context from server_info parent context.
      $string_context = ctools_context_create('string', $context->data[$conf['server_info_property']]);
    }
  }

  return $string_context;
}

/**
 * Settings form for the relationship.
 */
function example_relationship_plugin_settings_form($form, $form_state) {
  $conf = $form_state['conf'];
  $keys = array_keys($_SERVER);

  $form['server_info_property'] = [
    '#required' => TRUE,
    '#type' => 'select',
    '#title' => t('"Server info" property'),
    '#options' => array_combine($keys, $keys),
    '#default_value' => $conf['server_info_property'],
  ];

  return $form;
}

/**
 * Configuration form submit.
 */
function example_relationship_plugin_settings_form_submit($form, &$form_state) {
  foreach ($form_state['plugin']['defaults'] as $key => $default) {
    $form_state['conf'][$key] = $form_state['values'][$key];
  }
}
Module structure:
example_module
  |__ plugins
  |  |__ content_types
  |  |  |__ example_module.example_content_type_plugin.inc
  |  |__ access
  |  |  |__ example_module.example_access_plugin.inc
  |  |__ contexts
  |  |  |__ example_module.example_context_plugin.inc
  |  |  |__ example_module.example_context_plugin.node_status.inc
  |  |__ arguments
  |  |  |__ example_module.example_argument_plugin.inc
  |  |__ relationships
  |     |__ example_module.example_relationship_plugin.inc
  |__ example_module.info
  |__ example_module.module
2. Clear cache. After that you will see a new relationship on panel settings page under contexts tab (please note that you need to set up server_info context first before be able to set up a relationship based on server_info):

New relationship is available for this panel

3. Set up newly created relationship:

Choose a value from server_info context you want to represent as a string context

4. At this point you will see additional context on a panel - "String from 'Server info' context".

A string context available from server_info context

5. It means you can use, for example, %string_from_server_info:uppercase_words_html_safe token as a substitution for a page title. Let's try to set up title as "%server_info:HTTP_HOST : %string_from_server_info:uppercase_words_html_safe" and see what will happen:
Set up page title from contexts values

My host is "drpual.loc" so page title looks like "drupal.loc : Drupal.loc" where first part (before ":") is a value grabbed from server_info context and second part is a value from string_from_server_info context in "uppercase_words_html_safe" format.
Page title consists of context and relationship values

The above is true for:

  • Ctools: 7.x-1.12
  • Panels: 7.x-3.8

Key notes:


Feb 12 2017
Feb 12
This time we will consider an argument plugin. Arguments are pretty similar to contexts. Actually arguments are context objects loaded from url. By default ctools provides a full set of needed arguments such as "Node: ID", "User: ID", "User: name" etc. But what if you've created a custom context? You might need to create a custom argument for your context (if you want to use your context as an argument of course). I advise you to read previous articles from "Ctools custom plugin" series such as "Ctools: custom access plugin" and "Ctools: custom context plugin". It's also required to read "Ctools: custom content type plugin" before reading this post because there I've described how to create a module integrated with ctools API which can contain ctools plugins.

For this tutorial we need a custom context (because there is no sense to copy paste default ctools argument plugins for creating default ctools context plugins - let's be creative) so I've created one: node_status. It's pretty simple and similar to default 'node' context by it provides extra node properties: 'status' and 'promote' (default node context doesn't provide them). It behaves similar to 'node' context. I mean it has a 'nid' option in settings form. Once you've entered a node id you will be able to use %argument_name:status and %argument_name:promote values. Context definition looks like:
<?php

/**
 * @file
 *
 * Plugin to provide a node_status context.
 */

/**
 * Plugins are described by creating a $plugin array which will be used
 * by the system that includes this file.
 */
$plugin = [
  // Plugin title.
  'title' => t('Node status'),
  // Plugin description.
  'description' => t('Node status.'),
  // A function that will return context.
  'context' => 'example_context_node_status_plugin',
  // Context keyword to use for
  // substitution in titles.
  'keyword' => 'node_status',
  // Context machine name.
  'context name' => 'node_status',
  // Settings form callback.
  'edit form' => 'example_context_node_status_plugin_settings_form',
  // Default values for settings form.
  'defaults' => [
    'nid' => 1,
  ],
  // Array of available context values.
  'convert list' => [
    'status' => t('Node status'),
    'promote' => t('Node promote'),
  ],
  // A function gets data from a given
  // context and returns values defined
  // in 'convert list' property.
  'convert' => 'example_context_node_status_plugin_convert',
];

/**
 * Context callback.
 */
function example_context_node_status_plugin($empty, $data = NULL, $conf = FALSE) {
  // Create context object.
  $context = new ctools_context('node_status');

  // This property should contain file name where
  // plugin definition is placed.
  $context->plugin = 'example_module.example_context_plugin.node_status';

  if (empty($empty)) {
    // Define context data.
    // Variable $data can be an array or an object.
    // It depends on how this context is created.
    // If it's created by putting context directly to
    // a panel page then $data - array containing
    // settings from form.
    if (is_array($data) && !empty($data['nid'])) {
      $node = node_load($data['nid']);
    }

    // If context is created by
    // an argument then $data - object.
    if (is_object($data)) {
      $node = $data;
    }

    if (!empty($node)) {
      $context->data = $node;
      $context->title = $node->title;

      // Specify argument value - node id.
      $context->argument = $node->nid;
    }
  }

  return $context;
}

/**
 * Returns property value by property type.
 */
function example_context_node_status_plugin_convert($context, $type) {
  $result = '';

  // Return node property (status or promote).
  if (!empty($context->data)) {
    $result = $context->data->{$type};
  }

  return $result;
}

/**
 * Settings form for cookies context.
 */
function example_context_node_status_plugin_settings_form($form, &$form_state) {
  // Node id option.
  $form['nid'] = [
    '#type' => 'textfield',
    '#title' => t('Node nid'),
    '#default_value' => $form_state['conf']['nid'],
    '#element_validate' => [
      'element_validate_integer_positive',
    ],
  ];

  return $form;
}

/**
 * Settings form submit.
 */
function example_context_node_status_plugin_settings_form_submit($form, &$form_state) {
  // Save submitted value.
  $form_state['conf']['nid'] = $form_state['values']['nid'];
}
I won't describe what goes here because we've already discussed custom context theme last time. Just put this code into example_module/plugins/contexts/example_module.example_context_plugin.node_status.inc file. Make sure that your module file structure now looks like this one:
example_module
  |__ plugins
  |  |__ content_types
  |  |  |__ example_module.example_content_type_plugin.inc
  |  |__ access
  |  |  |__ example_module.example_access_plugin.inc
  |  |__ contexts
  |     |__ example_module.example_context_plugin.inc
  |     |__ example_module.example_context_plugin.node_status.inc
  |__ example_module.info
  |__ example_module.module
Ok, finally we've got a context which we want to use as argument. For this tutorial I've created a custom page with a "node-view-page/%nid" url.

Page with required argument

Let's move on. Now we will create an argument plugin.

1. Create file example_module/plugins/arguments/example_module.example_argument_plugin.inc:

<?php

/**
 * @file
 *
 * Plugin to provide an argument handler for a node_status context.
 */

/**
 * Plugins are described by creating a $plugin array which will be used
 * by the system that includes this file.
 */
$plugin = [
  // Plugin title.
  'title' => t('Node status argument'),
  // Plugin description.
  'description' => t('Creates a node status context from a node ID argument.'),
  // Keyword.
  'keyword' => 'node_status_argument',
  // Context builder function.
  'context' => 'example_argument_plugin',
];

/**
 * Discover if this argument gives us the node we crave.
 */
function example_argument_plugin($arg = NULL, $conf = NULL, $empty = FALSE) {
  $context = FALSE;

  // If empyt it wants a generic, unfilled context.
  if ($empty) {
    $context = ctools_context_create_empty('example_module.example_context_plugin.node_status');
  }
  else {
    // We can accept either a node object or a pure nid.
    if (is_object($arg)) {
      $context = ctools_context_create('example_module.example_context_plugin.node_status', $arg);
    }
    elseif (is_numeric($arg)) {
      $node = node_load($arg);

      if (!empty($node)) {
        $context = ctools_context_create('example_module.example_context_plugin.node_status', $node);
      }
    }
  }

  return $context;
}
2. Clear cache and navigate to panel page settings: arguments. Assign "Node status argument" to %nid.

Assigned argument

3. Navigate to panel page settings: contexts. You will see that argument provides properties from defined context.
Context loaded from an argument

4. Now you can use context properties for example for page title:
Context properties are used in a page title

Now if you open node-view-page/1, for example, you will see:
Custom argument plugin in action

If you unpublish a node then title will be "Status: 0 Promoted: 1". That's all.

The above is true for:

  • Ctools: 7.x-1.12
  • Panels: 7.x-3.8

Key notes:

Feb 11 2017
Feb 11
In previous post we created an access ctools plugin which can be used as a selection or visibility rule in panels. It's time to learn how to create another important custom plugin - a context. It provides additional information for a panel page. For example if you've put a node context to the page you will be able to use node properties as substitutions for a page title. Moreover you will be able to put node fields as panes to a page. By default ctools module provides useful contexts (node, user, taxonomy_term, entity etc) but you can define your own. Please, read first post of "Ctools custom plugins" series before continue reading this. There we've created a module integrated with ctools.

In this tutorial we will create a context plugin which will provide information about a server from superglobal php variable $_SERVER. As you remember for now we have an example_module with next file structure:
example_module
  |__ plugins
  |  |__ content_types
  |  |  |__ example_module.example_content_type_plugin.inc
  |  |__ access
  |     |__ example_module.example_access_plugin.inc
  |__ example_module.info
  |__ example_module.module
To create a context plugin we need to create contexts directory and put there a file with plugin definition. Let's start:

1. Create example_module/plugins/contexts/example_module.example_context_plugin.inc file:

<?php

/**
 * @file
 *
 * Plugin to provide a server_info context.
 */

/**
 * Plugins are described by creating a $plugin array which will be used
 * by the system that includes this file.
 */
$plugin = [
  // Plugin title.
  'title' => t('Server info'),
  // Plugin description.
  'description' => t('Available information about a server.'),
  // A function that will return context.
  'context' => 'example_context_plugin',
  // Settings form callback.
  'edit form' => 'example_context_plugin_settings_form',
  // Default values for settings form.
  'defaults' => [
    'decode_request_uri' => FALSE,
  ],
  // Context keyword to use for
  // substitution in titles.
  'keyword' => 'server_info',
  // Context machine name.
  'context name' => 'server_info',
  // A function returns a list of
  // available values from a context.
  // By the way it can be just an array.
  'convert list' => 'example_context_plugin_get_convert_list',
  // A function gets data from a given
  // context and returns values defined
  // in 'convert list' property.
  'convert' => 'example_context_plugin_convert',
];

/**
 * Context callback.
 */
function example_context_plugin($empty, $data = NULL, $conf = FALSE) {
  // Create context object.
  $context = new ctools_context('server_info');

  // This property should contain file name where
  // plugin definition is placed.
  $context->plugin = 'example_module.example_context_plugin';

  if (empty($empty)) {
    // Define context data.
    $context->data = $_SERVER;

    // Decode property if selected.
    // Just for demonstration how to use
    // plugin settings.
    if (!empty($conf) && !empty($data['decode_request_uri'])) {
      $context->data['REQUEST_URI'] = urldecode($context->data['REQUEST_URI']);
    }
  }

  return $context;
}

/**
 * Returns available properties list.
 */
function example_context_plugin_get_convert_list() {
  $list = [];

  // Get all $_SERVER properties and return them.
  foreach ($_SERVER as $property_name => $property_value) {
    $list[$property_name] = t('$_SERVER["!name"]', [
      '!name' => $property_name,
    ]);
  }

  return $list;
}

/**
 * Returns property value by property type.
 */
function example_context_plugin_convert($context, $type) {
  // Return $_SERVER property value.
  return $context->data[$type];
}

/**
 * Settings form for server_info context.
 */
function example_context_plugin_settings_form($form, &$form_state) {
  // Demo setting.
  $form['decode_request_uri'] = [
    '#type' => 'checkbox',
    '#title' => t('Decode $_SERVER["REQUEST_URI"] value'),
    '#default_value' => $form_state['conf']['decode_request_uri'],
  ];

  return $form;
}

/**
 * Settings form submit.
 */
function example_context_plugin_settings_form_submit($form, &$form_state) {
  // Save submitted value.
  $form_state['conf']['decode_request_uri'] = $form_state['values']['decode_request_uri'];
}
Updated module structure looks like:
example_module
  |__ plugins
  |  |__ content_types
  |  |  |__ example_module.example_content_type_plugin.inc
  |  |__ access
  |  |  |__ example_module.example_access_plugin.inc
  |  |__ contexts
  |     |__ example_module.example_context_plugin.inc
  |__ example_module.info
  |__ example_module.module
2. Clear cache and set up Server info context for a panel page:

Newly created context

Plugin settings form

Available context properties

Let's test context and set up page title as %server_info:REQUEST_URI. By the way it's a node/%nid page on screeshot so to test it we should open a node view page (but you can set up this context to any panel page you want):
Page title will contain value from $_SERVER['REQUESTED_URI'] variable

Request uri as a page title

The last thing we have to test is an option "Decode $_SERVER["REQUEST_URI"] value". When it's checked uri will be decoded. Check this checkbox and open node view page with a query parameter such as "?test=parameter to decode" and update page. Browser will encode spaces in url and it will look like "?test=string%20to%20decode". But page title will contain original (decoded) value.

The above is true for:

  • Ctools: 7.x-1.12
  • Panels: 7.x-3.8

Key notes:

Feb 05 2017
Feb 05
Last time we've learned how to create custom ctools content type plugin. In that post we've already created a module example_module where we defined the plugin. This time we will learn how to create custom ctools access plugin. This type of ctools plugins can be used as a selection rule for a panel variant or as a visibility rule for a panel pane. Please, read previous post before continue reading this. There is described how to create a module and integrate it with ctools.

Let's say we have a node/%node panel page where are placed "Powered by Drupal" and "Node being viewed" panes.

Node view panel page with "Powered by Drupal" and "Node being viewed" panes

And we want first panel pane be shown only if node title equals some specific string. For example "Test article". Well, for this case we have to create an access plugin and apply it to the panel pane. Let's start.
For now if you remember we have an example_module with next structure:
example_module
  |__ plugins
  |  |__ content_types
  |     |__ example_module.example_content_type_plugin.inc
  |__ example_module.info
  |__ example_module.module
1. Create file example_module/plugins/access/example_module.example_access_plugin.inc:
<?php

/**
 * @file
 * File with access plugin definition.
 */

/**
 * Plugins are described by creating a $plugin array which will be used
 * by the system that includes this file.
 */
$plugin = [
  // Plugin title.
  'title' => t('Example access rule'),
  // Plugin description.
  'description' => t('Example access rule'),
  // Access rule callback. This function has to return
  // TRUE of FALSE.
  'callback' => 'example_access_plugin',
  // A required context for this pane. If there is no required
  // context on a panel page than you will not be able to apply
  // this access rule to a pane or page variant. In this example
  // we need a 'Node' context.
  'required context' => new ctools_context_required(t('Node'), 'node'),
  // Settings form constructor callback. If you need custom submit/validate
  // callback you can define it by 'settings form submit' and
  // 'settings form validate' properties.
  'settings form' => 'example_access_plugin_settings_form',
  // Default values for edit form inputs.
  'default' => [
    'example_option' => 'example_value',
  ],
  // Summary callback. Returns plugin summary text which is visible
  // in drop-down setting menu of a pane.
  'summary' => 'example_access_plugin_summary',
];

/**
 * Settings form a pane.
 */
function example_access_plugin_settings_form(array $form, array $form_state, $conf) {
  // Please note that all custom form elements
  // should be placed in 'settings' array.
  $form['settings']['example_option'] = [
    '#type' => 'textfield',
    '#title' => t('Example text option'),
    '#default_value' => $conf['example_option'],
    '#required' => TRUE,
  ];

  return $form;
}

/**
 * Access rule callback.
 */
function example_access_plugin($conf, $context) {
  $access = FALSE;

  // Variable $context contains a context grabbed by a panel page.
  // In this case it contains a node object in a 'data' property.
  if (!empty($context->data)) {
    $node_from_context = $context->data;

    // Access will be granted only if node title equals a string
    // set up in settings form.
    $access = $node_from_context->title == $conf['example_option'];
  }

  return $access;
}

/**
 * Summary callback.
 */
function example_access_plugin_summary($conf, $context) {
  return t('Example access plugin');
}
Now our module structure looks like:
example_module
  |__ plugins
  |  |__ content_types
  |  |  |__ example_module.example_content_type_plugin.inc
  |  |__ access
  |     |__ example_module.example_access_plugin.inc
  |__ example_module.info
  |__ example_module.module
2. Clear cache. Apply newly created access rule for the 'Node being viewed' pane in node view panel page:

Set up visibility rule for a pane

Select 'Example access rule'

Set up string to match with a node title

It's time to test our access plugin. Create a node with title "Test" and look at it's page. There will be no "Powered by Drupal" widget because node title doesn't equal "Test article":

Node view page without "Powered by Drupal" widget

And now edit node and change title to "Test article". See what will happen:
Node view page with "Powered by Drupal" widget

Access plugin works correctly.

P.S.
As you could notice there is a little difference between content_type and access plugin definition:

  • 'edit form' VS 'settings form' properties.
  • 'defaults' VS 'default' properties.
  • you have to define custom inputs for access plugin settings form inside 'settings' array. At the same time there is no so strict rule for defining settings inputs for content type plugin.
  • in a form callback for content type plugins configuration is available in $form_state variable, but for access plugins it isn't there. It available in an extra variable $conf.


The above is true for:

  • Ctools: 7.x-1.12
  • Panels: 7.x-3.8

Key notes:

Feb 04 2017
Feb 04
Ctools content types are an alternative to standard Drupal blocks. They are more comfortable and powerfull than blocks. Ctools content type plugins also known as panel panes. In this post you will learn how to create a configurable ctools content pane plugin.

For example we will create a custom content type plugin that will render a user name and user email. Let's start.

1. Create a module which will contain all defined plugins. File example_module/example_module.info:

name = Example module
description = Provides ctools content type plugin
core = 7.x

; Since we're using ctools api for creating
; ctools plugins we have to define this
; dependency.
dependencies[] = ctools
File example_module/example_module.module:
<?php

/**
 * Implements hook_ctools_plugin_directory().
 *
 * Integrate our module with Ctools. Tell where
 * Ctools have to search plugins. Usually a place
 * where developers store all defined plugins is
 * "module_name/plugins" directory. Variable
 * $plugin contains a name of a plugin type. It can be
 * content_types, context, argument, cache, access. So all
 * of listed plugin types should be located in corresponding
 * subdirectories (plugins/content_types if you create a content
 * type plugin).
 */
function example_module_ctools_plugin_directory($module, $plugin) {
  if ($module == 'ctools' && !empty($plugin)) {
    return "plugins/$plugin";
  }
}
2. Create a file which will contain plugin definition. File example_module/plugins/content_types/example_module.example_content_type_plugin.inc:
<?php

/**
 * @file
 * File with plugin definition.
 */

$plugin = [
  // Pane title.
  'title' => t('Example pane'),
  // Pane description. 
  'description' => t('Example pane'),
  // Tell ctools that it's not a child of another pane.
  'single' => TRUE,
  // You can categorize all defined panes by
  // defining a category name for a pane.
  'category' => [t('Example panes')],
  // A machine name of your pane.
  'content_types' => ['example_content_type_plugin'],
  // Function that will render markup of this pane.
  'render callback' => 'example_content_type_plugin_render',
  // A required context for this pane. If there is no required
  // context on a panel page than you will not be able to add
  // this pane to the panel page.
  'required context' => new ctools_context_required(t('User'), 'user'),
  // Edit form constructor callback.
  'edit form' => 'example_content_type_plugin_edit_form',
  // Default values for edit form inputs.
  'defaults' => [
    'example_option' => 'example_value',
  ],
];
A file name is just a combination of a module name and plugin name joined by a dot. But actually you can name it as you want. Please note that you can omit 'required context' property if you want to be able to add this pane to all existing panel pages. If you want to add your pane only to pages that have a context just use required context like described above. If you want to add your pane to all pages (but in some cases you need a context) just define it as optional:
'required context' => new ctools_context_optional(t('User'), 'user'),
Let's define plugin configuration form:
/**
 * Configuration form for a pane.
 */
function example_content_type_plugin_edit_form($form, $form_state) {
  $form['example_option'] = [
    '#type' => 'textfield',
    '#title' => t('Example text option'),
    '#default_value' => $form_state['conf']['example_option'],
    '#required' => TRUE,
  ];

  return $form;
}

/**
 * Configuration form submit.
 */
function example_content_type_plugin_edit_form_submit($form, &$form_state) {
  foreach ($form_state['plugin']['defaults'] as $key => $default) {
    $form_state['conf'][$key] = $form_state['values'][$key];
  }
}
The last thing we shoud define is render callback:
/**
 * Render callback.
 */
function example_content_type_plugin_render($subtype, $conf, $args, $context) {
  $block = new stdClass();

  // Variable $context contains a context grabbed by a panel page.
  // In this case we will put this pane on user/%uid page which
  // has a user context.
  if (!empty($context->data)) {
    $user_from_context = $context->data;

    // Just output user's name and email.
    $block->content = 'User name: ' . $user_from_context->name . '</br>User mail: ' . $user_from_context->mail . '</br>Example option: ' . $conf['example_option'];
  }

  return $block;
}
3. Clear cache. Edit user view panel page and put this pane into some region:

Example ctools content type pane

Enter settings for a pane:

Configuration form

Open user/1 page and here's a result:

Rendered example ctools content type pane

The above is true for:

  • Ctools: 7.x-1.12
  • Panels: 7.x-3.8

Key notes:

Oct 02 2015
Oct 02

cTools is one of those critical Drupal 7 modules many others depend on. It provides a lot of APIs and functionality that makes life easier when developing modules. Views and Panels are just two examples of such powerhouses that depend on it.

cTools makes available different kinds of functionality. Object caching, configuration exportability, form wizards, dialogs and plugins are but a few. A lot of the credit you would normally attribute to Views or Panels is actually owed to cTools.

Drupal logo

In this article, we are going to take a look at cTools plugins, especially how we can create our very own. After a brief introduction, we will immediately go hands on with a custom module that will use the cTools plugins to make defining Drupal blocks nicer (more in tune to how we define them in Drupal 8).

Introduction

cTools plugins in Drupal 7 (conceptually not so dissimilar to the plugin system in Drupal 8) are meant for easily defining reusable bits of functionality. That is to say, for the ability to define isolated business logic that is used in some context. The goal is to set up that context and plugin type once, and allow other modules to then define plugins that can be used in that context automatically.

If you’ve been developing Drupal sites for more than a year you’ve probably encountered cTools plugins in one shape or form. I think the first plugin type we usually deal with is the content_type plugin which allows us to create our own custom panel panes that display dynamic content. And that is awesome. Some of the others you may have encountered in the same realm of Panels are probably context and access (visibility rules). Maybe even relationships and arguments. These are all provided by cTools. Panels adds to this list by introducing layouts and styles that we normally use for creating Panels layouts and individual pane styles. These are I think the more common ones.

However, all of the above are to a certain extent a black box to many. All we know is that we need to define a hook to specify a directory and then provide an include file with some definition and logic code and the rest happens by magic. Going forward, I would like us to look into how a plugin type is defined so that if the case arises, we can create our own plugins to represent some reusable bits of functionality. To demonstrate this, we will create a module that turns the pesky hook system of defining custom Drupal blocks into a plugin based approach similar to what Drupal 8 is using.

The final code (+ a bit more) can be found in this repository if you want to follow along. And I do expect you are familiar with the steps necessary for defining custom Drupal blocks.

The block_plugin module

As I mentioned, I would like to illustrate the power of cTools plugins with a custom plugin type that makes defining Drupal 7 blocks saner. Instead of implementing the 2 main hooks (hook_block_info() and hook_block_view()) necessary to define a block, we’ll be able to have separate plugin files each responsible for all the logic related to their own block. No more switch cases and changing the hook implementation every time we need a new block. So how do we do this?

First, let’s create our block_plugin.info file to get started with our module:

name = Block Plugin
description = Using cTools plugins to define Drupal core blocks
core = 7.x
dependencies[] = ctools

Simple enough.

The plugin type

In order to define our news plugin type, inside the block_plugin.module file we need to implement hook_ctools_plugin_type() which is responsible for defining new plugin types cTools will recognize:

function block_plugin_ctools_plugin_type() {
  return array(
    'block' => array(
      'label' => 'Block',
      'use hooks' => FALSE,
      'process' => 'block_plugin_process_plugin'
    )
  );
}

In this hook we need to return an associative array of all the plugin type definitions we need keyed by the machine name of the plugin type name. Today we are only creating one called block. For more information on all the options available here, feel free to consult the plugins-creating.html help file within the cTools module. No use repeating all that information here.

The process key defines a function name that gets triggered every time cTools loads for us a plugin and is responsible for shaping or massaging the plugin data before we use it. It’s sort of a helper function that prepares the plugin for us each time so we don’t have to bother. So let’s see what we can do inside that function:

function block_plugin_process_plugin(&$plugin, $info) {
  // Add a block admin title
  if (!isset($plugin['admin title'])) {
    $exploded = explode('_', $plugin['name']);
    $name = '';
    foreach ($exploded as $part) {
      $name .= ucfirst($part) . ' ';
    }
    $plugin['admin title'] = $name;
  }

  // By default we also show a block title but this can be overwritten
  if (!isset($plugin['show title'])) {
    $plugin['show title'] = TRUE;
  }

  // Add a block view function
  if (!isset($plugin['view'])) {
    $plugin['view'] = $plugin['module'] . '_' . $plugin['name'] . '_view';
  }

  // Add a block form function
  if (!isset($plugin['configure'])) {
    $plugin['configure'] = $plugin['module'] . '_' . $plugin['name'] . '_configure';
  }

  // Add a block save function
  if (!isset($plugin['save'])) {
    $plugin['save'] = $plugin['module'] . '_' . $plugin['name'] . '_save';
  }
}

This callback receives the plugin array as a reference and some information about the plugin type. The task at hand is to either change or add data to the plugin dynamically. So what do we achieve above?

First, if the developer hasn’t defined an admin title for the block plugin, we generate one automatically based on the machine name of the plugin. This is so that we always have an admin title in the Drupal block interface.

Second, we choose to always display the title of the block so we mark the show title key of the plugin array as TRUE. When defining the block plugin, the developer has the option of setting this to FALSE in which case we won’t show a block title (subject).

Third, fourth and fifth, we generate a callback function for the block view, save and configure actions (if they haven’t already been set by the developer for a given plugin). These callbacks will be used when implementing hook_block_view(), hook_block_configure() and hook_block_save(), respectively. We won’t be covering the latter two in this article but feel free to check out the repository to see what these can look like.

And that’s pretty much all we need for defining our custom plugin type. We should, however, also implement hook_ctools_plugin_directory() which, as you may know, is responsible for telling cTools where a plugin of a certain type can be found in the current module:

function block_plugin_ctools_plugin_directory($module, $plugin) {
  if ($module == 'block_plugin' && in_array($plugin, array_keys(block_plugin_ctools_plugin_type())) ) {
    return 'plugins/' . $plugin;
  }
}

This will need to be implemented also by any other module that wants to define block plugins.

Drupal blocks

Now that we have the plugin type, let’s write the code which turns any defined block plugin into a Drupal block. Will start with the hook_block_info() implementation:

function block_plugin_block_info() {
  $blocks = array();

  $plugins = block_plugin_get_all_plugins();
  foreach ($plugins as $plugin) {
    $blocks[DELTA_PREFIX . $plugin['name']] = array(
      'info' => $plugin['admin title'],
    );
  }

  return $blocks;
}

Here we load all of the plugins using a helper function and define the minimum required information for the block. Here you can add also more information but we are keeping it simple for brevity.

We know each plugin will have a machine name (the name of the include file basically) and an admin title because we generate one in the processing phase if one doesn’t exist. The DELTA_PREFIX is a simple constant in which we define the prefix we want for the block machine name because we need to reuse it and should be able to easily change it if we want to:

define('DELTA_PREFIX', 'block_plugin_');

Our helper function we saw earlier looks like this:

function block_plugin_get_all_plugins() {
  return ctools_get_plugins('block_plugin', 'block');
}

It’s a simple wrapper around the respective cTools function. And for that matter, we also have the following function responsible for loading a single plugin by its machine name:

function block_plugin_get_plugin($name) {
  return ctools_get_plugins('block_plugin', 'block', $name);
}

This is very similar to the one before.

In order to make our Drupal block definitions complete, we need to implement hook_block_view():

function block_plugin_block_view($delta = '') {
  $plugin = block_plugin_plugin_from_delta($delta);
  if (!$plugin) {
    return;
  }

  $block = array();

  // Optional title
  if (isset($plugin['title']) && $plugin['show title'] !== FALSE) {
    $block['subject'] = $plugin['title'];
  }

  // Block content
  $block['content'] = $plugin['view']($delta);

  return $block;
}

So what’s happening here?

First, we use another helper function to try to load a plugin based on the delta of the current block and do nothing if we are not dealing with a plugin block.

Second, we build the block. If the user specified a title key on the plugin and the show title key is not false, we set the subject of the block (its title basically) as the former’s value. As for the actual block content, we simply call the view callback defined in the plugin. And that’s it.

Let us quickly see also the helper function responsible for loading a plugin based on a block delta:

function block_plugin_plugin_from_delta($delta) {
  $prefix_length = strlen(DELTA_PREFIX);
  $name = substr($delta, $prefix_length);
  $plugin = block_plugin_get_plugin($name);
  return $plugin ? $plugin : FALSE;
}

Nothing complicated going on here.

Defining block plugins

Since we told cTools that it can find block plugins inside the plugins/block folder of our module, let’s go ahead and create that folder. In it, we can add our first block inside a file with the .inc extension, for example my_block.inc:

<?php

$plugin = array(
  'title' => t('This is my block'),
);

/**
 * Returns a renderable array that represents the block content
 */
function block_plugin_my_block_view($delta) {
  return array(
    '#type' => 'markup',
    '#markup' => 'Yo block!'
  );
}

Like we do with all other plugins (content_type, context, etc), the plugin definition is in the form of an array inside a variable called $plugin. And for our case all we need at this point is a title (and not even that since without it the block simply won’t show a title).

Below it, we defined our callback function to display the block. The naming of this function is important. It matches the pattern we used for it during the processing phase (module_name_plugin_name_view). If we want to name it differently, all we have to do is reference the function name in the view key of the $plugin and it will use that one instead.

And that is basically it. We can now clear our caches and go to the Block administration screen where we can find our block and add it to a region. Showing that block on the page should trigger the view callback for that block plugin and render the contents.

Conclusion

In this article we’ve talked a bit about cTools plugins and saw how we can define our own type of plugin. We used the latter for transforming the Drupal block system into a rudimentary plugin system. This can be extended further to allow also for the block related configuration hooks to be replaced by callbacks inside the plugin include file. Additionally, as mentioned earlier, you can also make sure all the data available in hook_block_info() can be defined inside the plugin. I leave these tasks up to you.

Daniel Sipos

Meet the author

Daniel Sipos is a Drupal developer who lives in Brussels, Belgium. He works professionally with Drupal but likes to use other PHP frameworks and technologies as well. He runs webomelette.com, a Drupal blog where he writes articles and tutorials about Drupal development, theming and site building.
Sep 21 2015
Sep 21

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

In our quest to build a custom image search functionality (see parts I and II), we are at the last two steps. We have the ability to display a modal window, search for images, and page through the results; now we just need to be able to write the file name and file id back to the source text field so it can be added to the node. Also, in the last post, we just glossed over the code that actually gathers the search parameters and searches Solr, so we will cover that code in detail here.

Writing the selected image to the field

First, let's look at the Select link that is displayed for each image back in our page callback:

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

The ctools_ajax_text_button() wrapper function just includes the core ajax.inc file and adds the use-ajax class to the link. $fid is the file ID for the image (returned from Solr), and $next_field_id is the number of the element in the field collection.

The menu item for this function is:

$items['imgsearch/%ctools_js/imgadd/%/%'] = array(
  'title' => 'Add Image',
  'page callback' => 'nb_image_search_add_image',
  'page arguments' => array(1, 3, 4),
  'access callback' => TRUE,
  'type' => MENU_CALLBACK,
);

And the corresponding function to add the image:

/**
 *  Add the selected image to field_images
 */
function nb_image_search_add_image($js, $fid, $id) {
  if (!$js) {
    // We don't support degrading this from js because we're not
    // using the server to remember the state of the table.
    return MENU_ACCESS_DENIED;
  }
  ctools_include('ajax');
  ctools_include('modal');
  ctools_include('object-cache');

  //Get file name and selector from ctools object cache.
  $cache = ctools_object_cache_get('imgsearch', 'imgsearch_' . $id);

  $filename = $cache->fids[$fid];
  $imageurl = $filename .' [fid:' . $fid . ']';
  $url_selector = $cache->fieldname['url'];
  $person = $cache->meta[$fid]['person'];
  $person_selector = $cache->fieldname['person'];
  $organization = $cache->meta[$fid]['organization'];
  $organization_selector = $cache->fieldname['organization'];
  $year = $cache->meta[$fid]['year'];
  $year_selector = $cache->fieldname['year'];

  $ajax_commands = array();
  // Tell the browser to close the modal.
  $ajax_commands[] = ctools_modal_command_dismiss();
  // Add our custom insertImagePath command to the commands array.
  $ajax_commands[] = array
  (
    // The command will be used in our JavaScript file (see next section)
    'command' => 'insertImagePath',
    // We pass the field name and the image URL returned from the modal window.
    'url_selector' => $url_selector,
    'imageurl' => $imageurl,
    'person_selector' => $person_selector,
    'person' => $person,
    'organization_selector' => $organization_selector,
    'organization' => $organization,
    'year_selector' => $year_selector,
    'year' => $year,
  );

  // Clear ctools cache to avoid any possible conflicts on the same node.
  ctools_object_cache_clear('imgsearch', 'imgsearch_' . $id);

  print ajax_render($ajax_commands);
}

This is where the image data cached in the ctools_object_cache comes into play. Based on the $next_field_id passed by the modal window link, we get the image data and build an array to pass to our ajax command that will write the image name and file ID to the source field. Once the $ajax_commands array has been created and filled, we make sure to clear out the ctools object cache to avoid conflicts.

The sharp-eyed person will notice in the code above that the Drupal ajax command - insertImagePath - is a custom command. This is because for some reason, the core Drupal ajax code does not include a function that uses the jQuery val() method to write a value to a text field. The command looks like this:

/**
 * Add custom behaviors to Drupal.ajax.prototype.commands
 */
(function($, Drupal)
{
  // Our function name is prototyped as part of the Drupal.ajax namespace, adding to the commands:
  Drupal.ajax.prototype.commands.insertImagePath = function(ajax, response, status)
  {
    // The values we passed in our Ajax callback function will be available inside the
    // response object.
    $(response.url_selector).val(response.imageurl);
    $(response.person_selector).val(response.person);
    $(response.organization_selector).val(response.organization);
    $(response.year_selector).val(response.year);
    // Set focus on field.
    $(response.url_selector).focus();
  };
}(jQuery, Drupal));

So the result is that when the Select link in the modal window is clicked, the modal is closed, and the image name and fid are written to the filefield source text field, like so.

my_image_file.jpg [fid:12345]

Indexing image data into Solr

Finally, we are to the Solr functionality. Before we can search Solr for the image data, we need to index it, and we need to add some custom indexing specifically for images. To do that, we use hook_apachesolr_index_document_build_ENTITY_TYPE().

/**
 * Implementation of hook_apachesolr_index_document_build_ENTITY_TYPE
 *
 * @param ApacheSolrDocument $document
 * @param $entity
 * @param $env_id
 */
function nb_image_search_apachesolr_index_document_build_node(ApacheSolrDocument $document, $entity, $env_id) {
  if ($entity->type == 'blog') {
    if (isset($entity->field_images['und'][0])) {
      $imagefield = entity_load('field_collection_item', ($entity->field_images['und'][0]));
      foreach($imagefield as $key => $image)  {
        if(isset($image->field_image[LANGUAGE_NONE])) {
           // Since they will be search on the image name, we don't want the file extension indexed, so
          // we remove it here.
          $uri  = $image->field_image[LANGUAGE_NONE][0]['uri'];
          // Index the entire image URL
          $document->addField('sm_field_image', $uri);
          $fid = $image->field_image[LANGUAGE_NONE][0]['fid'];
          $length = strrpos($uri, '.') - strrpos($uri, '/') - 1;
          $document->addfield('tom_image_name', substr($uri, strrpos($uri, '/') + 1, $length));
          // But we still need the file name with the extension, so we use a separate field for that.
          $document->addfield('sm_image_name_ext', substr($uri, strrpos($uri, '/') + 1));
          $document->addField('sm_image_path', substr($uri, 0, strrpos($uri, '/') + 1));
          $document->addfield('im_image_fid', $fid);
          // Also index person, organization, and year fields.
          foreach (array('person', 'organization') as $field) {
            $field_name = 'field_' . $field;
            if (isset($image->{$field_name}[LANGUAGE_NONE][0])) {
              $document->addField('tom_image_' . $field, $image->{$field_name}[LANGUAGE_NONE][0]['value']);
            }
            if (!empty($image->field_year)) {
              $document->addField('im_image_year', $image->field_year[LANGUAGE_NONE][0]['value']);
            }
          }
        }
      }
    }
    // There is no image for this node, so get the default image for field_image and index it.
    else{
      $default_file = nb_image_search_get_image_default();
      $default_image_uri = $default_file->uri;
      $document->addField('sm_field_image', $default_image_uri);
    }
  }
}

And here is the referenced helper function to get the default image.

/**
 * Helper function to get the path for the default image.
 *
 * @return default file uri
 */
function nb_image_search_get_image_default() {
  // First, see if the path is already cached.s
  $default_file = &drupal_static(__FUNCTION__);
  if (!isset($default_image_uri)) {
    if ($cache = cache_get('field_image_default')) {
      $default_file = $cache->data;
    }
    // Not cached, so we need to generate it this time through.
    else {
      $field = field_info_field('field_images');
      $instance = field_info_instance('field_collection_item', 'field_image', 'field_images');
      // Load the file object.
      $default_file = file_load($instance['settings']['default_image']);
      // Cache the file object.
      cache_set('field_image_default', $default_file, 'cache');
    }
  }

  return $default_file;
}

All we are doing here is splitting out the image information into smaller parts and indexing them into separate fields in the Solr index, and then also indexing data from the Person, Organization, and Year fields if present.

Searching Solr for images

And finally, we are to the code to actually search Solr. The Apache Solr Search module is currently a requirement, but only a very small portion of it is needed. The D7 version of the module includes a forked version of the Solr PHP Client library, and that's most of what we will be using. The majority of the Apache Solr Search module code handles the Drupal side of things (pages, blocks, facets, etc), and since we are just doing a quick search behind the scenes, we don't need all of that code; we just need the PHP client to directly access Solr (except for one function).

/**
 * Worker function to search for images.
 */
function nb_image_search_search($values, $page_num = 0) {
  // Call solr directly, since we don't need all of the other Drupal stuff
  // to be done once we get the results.
  $query = apachesolr_drupal_query('apachesolr', array(), '', '');

  if (isset($values['search_terms'])) {
    $keys = str_replace("%20", " ", $values['search_terms']);
  }
  else {
    $keys = '';
  }

  // Limit results to only records that have a value for tom_field_images.
  $query->addParam('fq', 'tom_image_name:[* TO *]');

  //Need to add fields to be searched to qf param.
  $query->addParam('qf', 'tom_image_name');

  foreach (array('person', 'organization') as $field) {
    $field_name = 'search_' . $field;
    if($values[$field_name] !== '') {
      $query->addFilter('sm_image_' . $field, $values['search_' . $field]);
    }
  }
  if ($values['search_year'] != '') {
    $query->addFilter('im_image_year', $values['search_year']);
  }

  // Get current params
  $rows = $query->getParam('rows');
  // Set $start param from pager
  $start = $rows * $page_num;
  $query->replaceParam('start', $start);

  // Specify the fields to be returned in the search results.
  $query->addParam('fl', 'sm_image_path');
  $query->addParam('fl', 'sm_image_name_ext');
  $query->addParam('fl', 'im_image_fid');
  $query->addParam('fl', 'sm_image_person');
  $query->addParam('fl', 'sm_image_organization');
  $query->addParam('fl', 'im_image_year');

  $response = $query->search($keys);
  $results = json_decode($response->data);

  // Loop through results and put them in an array for the modal form building function.
  if (count($results->response->docs)) {
    foreach ($results->response->docs as $result) {
      $filepath = $result->sm_image_path[0];
      $filename = $result->sm_image_name_ext[0];
      $fid = $result->im_image_fid[0];
      $images[] = array(
        'filename' => $filename,
        'filepath' => $filepath,
        'fid' => $fid,
        'person' => $result->sm_image_person[0],
        'organization' => $result->sm_image_organization[0],
        'year' => $result->im_image_year[0],
      );
    }
  }
  else {
    $images = 'No matching results found.';
  }
  $params = $query->getParams();
  $return['rows'] = $params['rows'];
  $return['images'] = $images;
  $return['total_found'] = $results->response->numFound;

  return $return;
}

The code above goes through the following steps:

  1. Build the default Solr query object.
  2. Get the search terms.
  3. Specify fields to be searched and values to search with.
  4. Specify the offset to be returned as determined by the pager value.
  5. Run the query.
  6. Put the results into an array, and return them to the page callback.

And that's it. You now have a functional custom image search.

A few notes about this module:

  • As it currently stands, it is fairly specific to my specific setup of four fields in a field collection. However, it should be fairly simple to modify it to fit your needs, including just using it with a simple image field by itself.
  • Since a Multifield uses an identical field structure as Field Collection, this also works with multifields.
  • It is pretty easy to see extending this to include admin settings where you can specify the fields(s) where you want to add this. Creating it as a Filefield Source already allows you to do that, but you also have to know the field structure to pass to the ajax code.

This post will be updated when I have put it on drupal.org as a project.

Sep 21 2015
Sep 21

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.

Sep 20 2015
Sep 20

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

Over the years I have been a big fan and user of Apache Solr for search on Drupal sites, in particular using the Apache Solr Search set of modules, mostly because of its speed and ability to create facets for drilling down into search results, along with the flexibilty provided by the module API to customize the functionality as needed. While re-building the NewsBusters site from scratch in D7 last year, one of the issues that the users wanted to address was finding existing images to re-use in blog posts. The existing functionality used a file browsing functionality in the WYSIWYG, and as the number of images added to the site grew, just loading the browser to search for an image could take a few minutes. Another change we were making in the D7 version was storing all of our images on Amazon S3 (using the S3FS module), so I figured that I would take this chance to create a custom image search that used Solr. This would address speed issues, and would also allow the users to index metadata about images that could be used later to search for images.

One module that I have used in the past that allows some image searching functionality is Filefield Sources. For instance, it allows you to get images from remote URLs, search the files directory with an autocomplete textfield, and attach from a server directory. However, I needed to do something custom, and fortunately, the module implements hooks that allows you to create your own custom source. Fortunately the Autocomplete reference field does what I already need to do once I get the image name and fid, so all I need to do is create my search functionality and write the approriate values to the text field.

As I mentioned above, I also wanted to index metadata about the image to allow for better searching, so I needed to find a way to store that data. I initially tried extending the image field in code (with no luck), so after looking at the avaialable options, I settled on the Field Collection module. This allowed me to create a group of four fields for each image:

  • Image
  • Person
  • Organization
  • Year

So to start out, I create a custom module (nb_image_search), and I declare my custom source using hook_filefield_sources_info():

/**
 * Implements hook_filefield_sources_info().
 */
function nb_image_search_filefield_sources_info() {
  $source = array();
  $source['imgsearch'] = array(
    'name' => t('Image search with Solr'),
    'label' => t('Image Search'),
    'description' => t('Search for an existing image using Apache Solr'),
    'process' => 'nb_image_search_image_search_process',
    'value' => 'nb_image_search_image_search_value',
    'weight' => 1,
    'file' => 'includes/image_search.inc',
  );

  return $source;
}

The items in this array are:

  • name - the name of the option displayed in the image field settings for File Sources
  • label - The name of the option that is displayed on the node create/edit form.
  • description - The description of the source
  • process - the name of the process function that does all the heavy-work of creating a form element for searching and populating a field.
  • value - This callback function then takes the value of that field and saves the  file locally.
  • weight - Used for ordering the enabled sources on the node create screen.
  • file - The path to the file where the process and value functions are stored.

A second hook implementation that is needed is hook_theme():

/**
 * Implements hook_theme().
 */
function nb_image_search_theme() {
  return array(
    'nb_image_search_image_search_element' => array(
      'render element' => 'element',
      'file' => 'includes/image_search.inc',
    ),
  );
}

This specifies the theme function that will be used to theme the custom element.

Next up is the process function. This is basically a form function that defines the element for searching.

define('FILEFIELD_SOURCE_IMGSEARCH_HINT_TEXT', 'example.png [fid:123]');

/**
 * A #process callback to extend the filefield_widget element type.
 */
function nb_image_search_image_search_process($element, &$form_state, $form) {
  $element['imgsearch'] = array(
    '#weight' => 100.5,
    '#theme' => 'nb_image_search_image_search_element',
    '#filefield_source' => TRUE, // Required for proper theming.
    '#filefield_sources_hint_text' => FILEFIELD_SOURCE_IMGSEARCH_HINT_TEXT,
  );

  $element['imgsearch']['file_url'] = array(
    '#type' => 'textfield',
    '#maxlength' => NULL,
  );

  // Handle this being a Field Collection entity.
  if (isset($element['#entity']->is_new) && $element['#entity']->is_new == TRUE) {
    $nid = 0;
  }
  else {
    if (isset($element['#entity']->nid)) {
      $nid = $element['#entity']->nid;
    }
    elseif(isset($form_state['node']->nid)) {
      $nid = $form_state['node']->nid;
    }
  }

  // Get id for field within the field collection. Use the position of the languge value since it's right before the number.
  $language = $element['#language'];
  $lang_pos = strpos($element['#id'], $language);
  $id = !is_object($element['#file']) ? substr($element['#id'], $lang_pos + strlen($language) + 1, 1) : 0;

  $element['imgsearch']['search'] = array(
    '#type' => 'markup',
    '#markup' => '<div id="imgsearch">' . l("Search for Image", 'imgsearch/nojs/' . $id . '/' . $element['#bundle'], array('attributes' => array('class' => 'ctools-use-modal ctools-modal-imgsearch-modal-style'))) . '</div>'
  );

  $element['imgsearch']['select'] = array(
    '#name' => implode('_', $element['#array_parents']) . '_imgsearch_select',
    '#type' => 'submit',
    '#value' => t('Select'),
    '#validate' => array(),
    '#submit' => array('filefield_sources_field_submit'),
    '#name' => $element['#name'] . '[imgsearch][button]',
    '#limit_validation_errors' => array($element['#parents']),
    '#ajax' => array(
      'path' => 'file/ajax/' . implode('/', $element['#array_parents']) . '/' . $form['form_build_id']['#value'],
      'wrapper' => $element['upload_button']['#ajax']['wrapper'],
      'effect' => 'fade',
    ),
  );

  return $element;
}

This creates the links and fields for the source as shown below.

Most of this is copied from the reference source, but there are a couple custom things going on here.

First, a Field Collection is a completely separate entity attached to the node, so it requires some custom code to get the node id.

Second, the $element['imgsearch']['search'] markup is a link to a custom page function (detailed in a later post) that generates the ctools modal window. There are two ctools classes applied to the link:

  • ctools-use-modal - opens the page callback being called in a modal window
  • ctools-modal-imgsearch-modal-style - a class to match custom settings for the modal that  defined in hook_node_prepare():
/**
 * Implementation of hook_node_prepare
 */
function nb_image_search_node_prepare($node) {
  if ($node->type == 'blog') {
    ctools_include('modal');
    ctools_modal_add_js();

    // Add custom settings for form size.
    drupal_add_js(array(
      'imgsearch-modal-style' => array(
        'modalSize' => array(
          'type' => 'fixed',
          'width' => 1000,
          'height' => 1200,
        ),
        'animation' => 'fadeIn',
        'closeText' => t('Close Search Window'),
        'loadingText' => t('Loading the Image Search window'),
      ),
    ), 'setting');
  }
}

When clicked on, this link will open up your modal window. Details of the window content will be covered in the next post.

Apr 16 2015
Apr 16

One of the major disadvantages of entities in Drupal 7, is the lack of support for built-in comments. This is due to the model of the core comments module, which is heavily tied to nodes -node comments are essentially settings of each content type-, and not available for other entity types. In Drupal 8 this has changed already, and comments are considered fields that can be attached to any entity type.

For Drupal 7, in the meantime, there’s been a similar solution for a while, which is the reply module. The reply module leverages the Entity API to define a “reply” entity type, and offers a UI to create “reply” bundles. Then, via the Field API, you can add a “Reply” field to any entity type, choosing the reply bundle that should be used by that particular field. The flexibility that gives is huge, since it means that you’re not restricted to just a comment type for your application.

I’ve been using it in a project for some time, and have to say the module works pretty well, although there’s not an official stable release yet. One of the problems I came across when I started to use it, was the lack of support for the panels module, so I decided to write a ctools “content_type” plugin to add that support myself. Here is the process I followed.

First, let ctools know where the ctools plugins live:

/** * Implements hook_ctools_plugin_directory() */ function reply_ctools_plugin_directory($module, $plugin) { if ($module == 'ctools') { return 'plugins/' . $plugin; } } * Implements hook_ctools_plugin_directory()functionreply_ctools_plugin_directory($module,$plugin){  if($module=='ctools'){    return'plugins/'.$plugin;

Then, create the plugin in the relevant folder. Let’s name it reply_add_form. Following the code above, in this case it would be under “{module_folder}/plugins/content_types/reply_add_form”. Let’s have a look at the $plugin declaration array:

/** * Plugins are described by creating a $plugin array which will be used * by the system that includes this file. */ $plugin = array( 'single' => TRUE, 'title' => t('Reply - Add form'), 'description' => t('Form to add a new reply to an Entity'), 'category' => t('Reply'), 'defaults' => array(), 'render callback' => 'reply_add_form_content_type_render', 'edit form' => array( 'reply_add_form_select_entity_type' => t('Reply form - Entity Type'), 'reply_add_form_select_reply_field' => t('Reply form - Reply Field'), ), 'required context' => array( new ctools_context_required(t('Entity being viewed'), 'any'), ), ); * Plugins are described by creating a $plugin array which will be used* by the system that includes this file.$plugin=array(  'single'=>TRUE,  'title'=>t('Reply - Add form'),  'description'=>t('Form to add a new reply to an Entity'),  'category'=>t('Reply'),  'defaults'=>array(),  'render callback'=>'reply_add_form_content_type_render',  'edit form'=>array(    'reply_add_form_select_entity_type'=>t('Reply form - Entity Type'),    'reply_add_form_select_reply_field'=>t('Reply form - Reply Field'),  'required context'=>array(    newctools_context_required(t('Entity being viewed'),'any'),

No magic in there if you’re familiar with ctools (if you aren’t, you can install the advanced_help module, which has plenty of documentation on how to create your own ctools plugins). One point to highlight, is the fact that the plugin has two edit forms instead of one: in the first one, we’ll choose the entity type for which we’re adding the form, and in the second one, we’ll choose the reply field used, from the available within the selected entity type (could be more than one).

Also, note that the required context bit is accepting “any” context available within the panels page. I had to do it that way because, unlike with nodes, it’s impossible to know in advance all the entity types that will be available in the system how the user will name the arguments and contexts in panels and offer just the relevant options. Instead, all contexts are accepted, and the user (which will be usually a developer or a site-builder, anyway), is responsible for choosing the right one in the settings form.

Let’s have a look at the settings forms:

/** * Returns an edit form for custom type settings. */ function reply_add_form_select_entity_type($form, &$form_state) { $entities = entity_get_info(); $options = array(); // Get all existing entities. foreach ($entities as $entity_type => $entity) { $options[$entity_type] = $entity['label']; } $form['config']['reply_entity_type'] = array( '#type' => 'select', '#title' => t('Entity Type'), '#options' => $options, '#default_value' => isset($form_state['conf']['reply_entity_type']) ? $form_state['conf']['reply_entity_type'] : NULL, ); return $form; } /** * Returns an edit form for custom type settings. */ function reply_add_form_select_reply_field($form, &$form_state) { $options = array(); // Get entity type chosen in previous step. $entity_type = $form_state['conf']['reply_entity_type']; // Get all the field instances for the given entity type, and add the 'reply' // ones as options. $field_map = field_info_field_map(); $reply_fields = array_filter($field_map, '_reply_add_form_filter_reply_fields'); foreach ($reply_fields as $field_name => $fields_info) { if (!empty($fields_info['bundles'][$entity_type])) { $options[$field_name] = $field_name; } } $form['config']['reply_field'] = array( '#type' => 'select', '#title' => t('Reply field'), '#options' => $options, '#default_value' => isset($form_state['conf']['reply_field']) ? $form_state['conf']['reply_field'] : NULL, ); return $form; } /** * Submit handler for the custom type settings form. */ function reply_add_form_select_entity_type_submit($form, &$form_state) { $form_state['conf'] = array_merge($form_state['conf'], array_filter($form_state['values'])); } /** * Submit handler for the custom type settings form. */ function reply_add_form_select_reply_field_submit($form, &$form_state) { reply_add_form_select_entity_type_submit($form, $form_state); } * Returns an edit form for custom type settings.functionreply_add_form_select_entity_type($form,&$form_state){  $entities=entity_get_info();  $options=array();  // Get all existing entities.  foreach($entitiesas$entity_type=>$entity){    $options[$entity_type]=$entity['label'];  $form['config']['reply_entity_type']=array(    '#type'=>'select',    '#title'=>t('Entity Type'),    '#options'=>$options,    '#default_value'=>isset($form_state['conf']['reply_entity_type'])?$form_state['conf']['reply_entity_type']:NULL,  return$form;* Returns an edit form for custom type settings.functionreply_add_form_select_reply_field($form,&$form_state){  $options=array();  // Get entity type chosen in previous step.  $entity_type=$form_state['conf']['reply_entity_type'];  // Get all the field instances for the given entity type, and add the 'reply'  // ones as options.  $field_map=field_info_field_map();  $reply_fields=array_filter($field_map,'_reply_add_form_filter_reply_fields');  foreach($reply_fieldsas$field_name=>$fields_info){    if(!empty($fields_info['bundles'][$entity_type])){      $options[$field_name]=$field_name;  $form['config']['reply_field']=array(    '#type'=>'select',    '#title'=>t('Reply field'),    '#options'=>$options,    '#default_value'=>isset($form_state['conf']['reply_field'])?$form_state['conf']['reply_field']:NULL,  return$form;* Submit handler for the custom type settings form.functionreply_add_form_select_entity_type_submit($form,&$form_state){  $form_state['conf']=array_merge($form_state['conf'],array_filter($form_state['values']));* Submit handler for the custom type settings form.functionreply_add_form_select_reply_field_submit($form,&$form_state){  reply_add_form_select_entity_type_submit($form,$form_state);

Pretty simple stuff. As mentioned, the first one allows to select the entity type for which the reply form will be added. The second one, gets a simple map containing all the fields in the system, filters them out to keep only reply fields, and then filters them out again to show only the ones available for the entity type selected in the first settings form. The forms look like this:

panels_reply_1panels_reply_2

Finally, the render function, which simply takes care of loading the entity passed in the plugin config, and calls the appropriate function of the reply module, to let it create the reply form.

/** * Output function for the 'reply_add_form' content type. */ function reply_add_form_content_type_render($subtype, $conf, $panel_args, $context) { if (!$context[0]->data) { return; } $entity = $context[0]->data; $reply_field_instance = field_info_instance($conf['reply_entity_type'], $conf['reply_field'], $conf['reply_entity_type']); $block = new stdClass(); $block->title = ''; $form_options = array( 'entity_id' => $entity->identifier(), 'instance_id' => $reply_field_instance['id'], ); $output = drupal_get_form('reply_form', (object) $form_options); $block->content = array( '#markup' => render($output), ); return $block; } * Output function for the 'reply_add_form' content type.functionreply_add_form_content_type_render($subtype,$conf,$panel_args,$context){  if(!$context[0]->data){    return;  $entity=$context[0]->data;  $reply_field_instance=field_info_instance($conf['reply_entity_type'],$conf['reply_field'],$conf['reply_entity_type']);  $block=newstdClass();  $block->title='';  $form_options=array(    'entity_id'=>$entity->identifier(),    'instance_id'=>$reply_field_instance['id'],  $output=drupal_get_form('reply_form',(object)$form_options);  $block->content=array(    '#markup'=>render($output),  return$block;

I’ve uploaded the plugin file as a gist to github, so you can download it from here. Also, it’s worth noting that there’s an issue in the issue queue of the reply module to get the panels support implemented, with this plugin supplied as a patch, so hopefully you won’t need to keep it as a separate plugin, as it should make it into the next alpha release soon, and in the dev branch in the next few days.

Jun 29 2014
Jun 29

The Drupal community web site has a profile field to list "My mentors"

For example, on my profile I say I was mentored by:
  • robbiethegeek - how to appreciate Drupal awesomeness and its limitations
  • Alex UA - how to run a business providing Drupal services
  • forestmars - how to be involved in the Drupal community
  • smerrill - how to be an engineer with platform tools like Jenkins, Vagrant, Redis
  • snugug - how to make web sites responsive
  • ericduran - how to experiment with new doodads like HTML5, Android
  • zroger - how to use Drupal hooks and APIs in code

I started thinking about my dumb luck picking Drupal as a tool about 9 years ago. I was looking for a Content Management System that made sense.

I was awfully interested in a project called PAWS (PHP Automatic Web Site) -- and it's a good thing I didn't ride that horse, which was long ago put out to pasture.

A client asked me to convert his static PHP site so that he could manage the content in the include files without editing code. I built my first Drupal 4.x site, with the crazy hack of creating a node for every include, and then printing the includes/nodes inside a main node (Panels, sort of, which did not exist in Drupal then). I also customized the front end of the TinyMCE wysiwyg editor to add buttons to apply his brand's pink and blue colors. The client smoked a lot of pot, drifted away, came back a year or two later for more work -- without a database. Oh well, not the first - or last - time the db was lost by a client.

That experience convinced me that a lot could be done with Drupal that I had not been able to do without a lot of custom coding just to build the base web application. Other projects with early versions of WordPress and Mambo (predecessor to Joomla) left me unimpressed with their extensibility. I have often said since then that "WordPress is like the smaller sibling of Drupal, but Joomla is the evil cousin."

Then Earl Miles conjured up his merlinofchaos wizardry for Sony Music, creating Views and Panels and Ctools, and that was around the time that a lot of developers took notice of Drupal. I was profoundly convinced that Drupal had outgrown being a CMS enabling writers to (more or less) easily edit content without (much) coding, and had become a Content Management Framework that could perform elegant and dynamic manipulations of the content in its database.

So I had to add dumbluck to my mentors - not just for my early experiment hacking the node system, but for each solution that I was able to implement afterwards, because my choice of Drupal provided me with an extensible framework allowing complex algorithms for presentation of content, and the Drupal project improves with every contributor's enhancements.

I think I'm dumb, maybe just happy

[embedded content]

I noticed in preparing this post that some Drupal user profiles are accessible by username, eg. https://www.drupal.org/u/decibel.places and https://www.drupal.org/u/robbiethegeek, while others, like merlinofchaos and smerrill, are only accessible by their UIDs https://www.drupal.org/user/26979 and https://www.drupal.org/user/77539 respectively.

Jan 23 2014
Jan 23

oEmbed is a simple and popular protocol for embedding external media in web pages. It supports many types of content, including images, videos, and even "rich" content (i.e., HTML snippets). Unfortunately, the current Facebook embed mechanism does not work so well when the embed code is loaded dynamically on a page, e.g. via AJAX instead of statically.

To see this in action, I've created a test page that shows the difference between static and dynamic embedding. The static embedding automatically renders the post correctly, whereas loading the very same embed code using AJAX (here, via the Load button) does not. It is necessary to call FB.XFBML.parse() manually (here, via Refresh button) to nudge the embed code into rendering the post.

In my Drupal app, I needed to show the Facebook embed on a CTools modal dialog. How to trigger the call to FB.XFBML.parse() when the modal opens? Reading the code for ctools_modal_render(), I found hook_ajax_render_alter() which allows to send arbitrary AJAX commands to be executed on the browser side, upon reception of the modal's content. Here's how I used it:

// @file my_module.module
/**
 * Implements hook_ajax_render_alter().
 * Add a `facebook_refresh` command to make sure FB embeds are shown correctly.
 */
function my_module_ajax_render_alter(&$commands) {
  foreach ($commands as $command) {
    if ($command['command'] == 'modal_display') {
      $commands[] = array(
        'command' => 'refreshFacebook',
      );
    }
  }
}
// @file my_module.js
  /**
   * Facebook initialization callback.
   */
  window.fbAsyncInit = function() {
    // Wait until FB object is loaded and initialized to refresh the embeds.
    FB.XFBML.parse();
  }

 /**
   * Command to refresh Facebook embeds.
   */
  Drupal.ajax.prototype.commands.refreshFacebook = function(ajax, response, status) {
    if (typeof(FB) != 'undefined') {
      window.fbAsyncInit();
    }
  }

The front-end logic goes as follows: the first time the modal dialog is opened, the embed code causes the FB script gets loaded, which in turn calls window.fbAsyncInit(), where I call FB.XFBML.parse() as needed. But on subsequent openings of the modal dialog, window.fbAsyncInit() is no longer called, so my custom AJAX command refreshFacebook() takes over in that case to do the same.

The proverbial astute reader would ask why I don't rely on refreshFacebook() to do all the work, since it gets called every time, including the first time. The problem with the first time is that this command callback gets called before the FB script has finished loading, so the FB object does not yet exist at this time. Yes, tricky.

This is where things get really complicated. Trying the code above, the FB embed did show on the CTools modal, only to disappear 45 seconds later with a console message saying fb:post failed to resize in 45s! A bit of googling revealed that this is a known behaviour - but it seems no one had yet analyzed the problem to its root causes. So I pulled up the debug version of Facebook's JavaScript SDK and proceeded to decipher the code there. It turns out that the FB embed code creates an IFRAME with default width and height of 1000px, and it expects the rendering process of the actual post to resize its dimensions according to the post's display area. The IFRAME handler kicks off a 45 seconds timeout, at the end of which it hides the post and logs the message above. Only when a resize event is received does the IFRAME handler cancel the timeout.

The only way I was able to trigger this resize event was by re-initializing the FB script each time the CTools modal is loaded. So my JavaScript code became:

// @file my_module.js
  /**
   * Facebook initialization callback.
   */
  window.fbAsyncInit = function() {
    // Wait until FB object is loaded and initialized to refresh the embeds.
    FB.init({ xfbml: true }); // added this to avoid "fb:post failed to resize in 45s" message
    FB.XFBML.parse();
  }

This will cause some warnings in the log, like FB.init has already been called - this could indicate a problem. So far, I haven't found a problem with this approach but I welcome your suggestions.

I've spent the best part of 3 days debugging this unexpected behaviour, so I hope this helps someone! I do hope Facebook would make their embed code more robust though - for example, the Twitter embed behaves much better.

Jun 04 2013
Jun 04

On a recent project I required a way to bind ctools handlers to their respective links dynamically. The project had the links being displayed using ajax based on user input, and so the handlers would have to be bound to the new links (and unbound for the links that disappeared) without the page being reloaded.

Searching online for a solution did not turn up any useful results. Digging into the ctools module code, there didn't seem to be any easy way to accomplish this. The handlers are bound on page load, using Drupal behaviours.

The behaviour that is executed is located in ctools/js/modal.js and is named Drupal.behaviors.ZZCToolsModal. Calling this method directly won't work because of the use of the jquery function once().But the useful code parts can be copied and added into a custom function, removing the once() function call. In addition, a call to unbind for each link must be made to prevent multiple handlers from being registered for the same link.

The following is an example of the a.ctools-use-modal handler code modified to work in a custom function: // Modified code to set handler

$('area.ctools-use-modal, a.ctools-use-modal').each( function() {
        var $this = $(this);
        $this.unbind(); // Note the unbind: Otherwise there are multiple bind events which causes issues
        $this.click(Drupal.CTools.Modal.clickAjaxLink);
        // Create a drupal ajax object
        var element_settings = {};
        if ($this.attr('href')) {
          element_settings.url = $this.attr('href');
          element_settings.event = 'click';
          element_settings.progress = {
            type: 'throbber'
          };
        }
        var base = $this.attr('href');
        Drupal.ajax[base] = new Drupal.ajax(base, this, element_settings);
      });

Aug 15 2012
Aug 15

In this lesson we cover how all over the configurations we have learned using Display Suite are able to be exported to code. This video shows how to do this using CTool but can also be done with Features as well.

May 05 2012
May 05

Over the years, I've accumulated a large collection of e-books and digital music albums, not to mention family pictures. Information overload is not a philosophical point of view, it's a real problem that forces me to devote time, effort and money to maintain that collection.

That's probably why so many media organizers exist. Because I believe that all applications should be delivered from the Web, and because no ready-made Web media organizer struck me as fulfilling my needs, I started to write my own using Drupal 6, dubbed Mediatheque. Here are the most important design goals I had in mind:

  • The main job of the organizer is to "ingest" media files by processing them to extract metadata, which is made available for searching and browsing.
  • I should be able to point the system to different "volumes" containing my media. These volumes are essentially folders that are present somewhere on the network.
  • No extra file space (beyond database growth) should be required to ingest media files.
  • The system should be able to store arbitrary metadata about the media.
  • I should be able to add new information handlers for media at any time - the system would silently re-process the files.
  • New version of media handlers should also be supported by re-processing the files.
  • The system should be smart about recognizing files across name changes and metadata updates.
  • The system should be robust in the face of plugin errors.
  • Media display should be dependent on its type.

Based on these goals, here are the significant implementation details of the current system:

  • Drupal Queue is used to process the files in the background. In fact, I am using 3 queues as a processing pipeline:

Starting with a volume root, the folders queue finds and enqueues the files, the files queue creates Drupal documents (nodes) and enqueues them for plugin processing, and the plugins queue applies the registered plugins to extract metadata.

  • Each processing step produces a log entry. This allows to track the errors that are produced during media processing. The log is a Drupal table that is integrated with Views via hook_views_data. Each log entry contains information about the processed document, the file hash, the plugin name and version, and the status code of the processing, allowing to detect the cases where re-processing should occur. Mediatheque log

  • Plugins are metadata extractors that are associated with MIME type patterns. For example, Mediatheque currently comes with an ID3 extractor that uses the getID3 library and an e-book metadata extractor that uses the Google Books API via Zend Gdata. Mediatheque plugins

  • Metadata extracted by the plugins is stored in the document node using my CCK Metadata module. This is a simple name/value CCK field and the document node includes this field with unlimited cardinality. The metadata pairs returned by each plugin are prefixed with a unique plugin prefix to be able to handle re-processing. CCK Metadata also allows custom formatting of specific metadata entries via hook_cck_metadata_fields:

/**
 * Implementation of hook_cck_metadata_fields().
 */
function mediatheque_cck_metadata_fields() {
  return array(
    'isbn:thumbnail' => array(
      'formatter' => 'mediatheque_formatter_thumbnail',
    ),
  );
}

Eventually, this metadata system should reuse RDF instead of using a custom design. Mediatheque plugins

  • Finally, the main mediatheque view is created as a regular view with filterable metadata. The metadata name exposed filter is converted to a drop-down via my Views Hacks' Views Selective Exposed Filters module whose job is to restrict the available filter values to those found in the view results. Mediatheque plugins

Mediatheque is still very much a work in progress. However, many conceptual challenges have already been solved, and I would love to hear your feedback!

AttachmentSize 132.92 KB 53.99 KB 50.21 KB 87.44 KB 163.26 KB
Jan 29 2012
Jan 29

Edit: Use the Inline entity form module that was created after this post was published.

This post will go over an example (yet fully functionally) module that shows how we can embed a commerce product form inside a node form, and have the node reference the commerce product - without horrible hacks.

The problem is obviously the fact that we don’t have commerce product ID nor a node ID, as none of those objects is saved. A second problem is how to actually embed the commerce product form inside the node form. Subform module solves both issues (with a little custom code help).

I won’t go over each line of code here, as the module is well documented, however I will explain the important steps.

In the node's form alter, we embed the commerce product, using Subform’s form element.

<?php
module_load_include
('inc', 'commerce_product', 'includes/commerce_product.forms');
$form['commerce_product_subform'] = array(
 
'#type' => 'subform',
 
'#subform_id' => 'commerce_product_ui_product_form',
 
'#subform_arguments' => array($commerce_product),
 
'#required' => TRUE,
 
'#weight' => 10,
);
?>

In the code above we embed the form, pass it a $product object, and say it is required - meaning Subform should do validation on the embedded form.
Next step, is adding submit handlers in the right order

<?php
// Set the first submit handler to be "subform_submit_all", so the
// commerce product will be created, before handing the submitted node
// se we can associate the node to the commerce product.
// The last submit handler is the original 'node_form_submit', which
// will get all the values already populated, and save the node.
if (empty($node->nid)) {
 
array_unshift($form['actions']['submit']['#submit'], 'subform_submit_all', 'commerce_product_subform_commerce_product_submit');
}
else {
 
// The node already exists, so we assume there is already a reference
  // to the commerce product, so just add "subform_submit_all" submit
  // handler, to make sure the commerce product can be edited.
 
array_unshift($form['actions']['submit']['#submit'], 'subform_submit_all');
}
?>

Noticed the emphasize on the “right order” above? That’s because we want Subform to first submit the commerce product form (thus the commerce product will be saved and have an ID), next we want our own submit handler to set the field reference from the node to the commerce product, and last we want the original node_form_submit() to save the node.

In the example module, you will notice that we also hide the commerce product title. The reason is that we can easily populate the title from the node's title (see commerce_product_subform_form_commerce_product_ui_validate()).

As bonus, the example module also has a CTools plugin, that allows you to add the subform to an node add/ edit page that was overriden by Page manager.

Jan 18 2012
Jan 18
Lullabot logo

Lullabot has trained thousands of Drupal developers & guided the development of some of the largest Drupal websites.

Jan 10 2012
Jan 10
Lullabot logo

Lullabot has trained thousands of Drupal developers & guided the development of some of the largest Drupal websites.

Dec 06 2011
Dec 06
Lullabot logo

Lullabot has trained thousands of Drupal developers & guided the development of some of the largest Drupal websites.

Oct 12 2011
Oct 12

CTools is a powerful toolbox for Drupal developers, but there are many features of this huge module that aren't well known or used. I would like to share some of my experience with using one of those lesser-known features: CTools Access Rulesets.

In this article you'll learn how to utilize the nice CTools Access Rulesets GUI in combination with some custom code to limit file downloads based on certain criteria. This promotes generic code and separates the access logic out to a place where it is customizable by a site administrator.

This gives you the ability to use flexible and powerful Panels selection rules outside of Panels.

Introduction

CTools Access Rulesets allow you to create access rules based on different contexts. It is widely used in page manager when we define rules for access to the page. When we enable the ctools_access_ruleset module, we use a UI to create our own access rules (admin/structure/ctools-rulesets/list).

One way we recently used this functionality was to limit private file downloads. I wanted to have a flexible rule that was customizible via UI, one that I would be able to extend if client's requirements changed (and I knew they would). To manage this, we wrote our own module and used hook_file_download to trigger our ctools access ruleset to determine whether we should allow the user to download the file or not.

We decided to base access control on taxonomy terms linked to the node that have the file attached. To do this we can create a ruleset that will have the context as nodes and there we check for the condition of whether or not the specified taxonomy terms are attached to these nodes.

Setting up the rule (in the GUI)

The screenshots below (click to enlarge) will allow us to see the UI interface to see the how to set it up.

First we need to open the list of access rulesets: admin/structure/ctools-rulesets/list

Each rule consists of three parts: basic infomation, contexts, and rules. Basic information is the name and description. I will skip this part.

For the Contexts we add the required context Node.

We also add the Relation to the taxonomy term we want to use. In my case, the term is from the Category vocabulary.


Now lets look at the settings of the Rules.


We have only one Rule based on a taxonomy term. The settings are as follows:


Creating the code

Now let's see how we can execute the ctools ruleset in our own module.

The key function is ctools_access($access, $context). $context is the array of contexts that we pass to the ruleset. $access is the configuration array. The machine name of my ruleset is document_download_rule.

<?php
 
// Prepare arguments for ctools_access().
  // Name of created ruleset is document_download_rule.
 
$access = array('plugins' => array(
    
0 => array(
      
'name' => 'ruleset:document_download_rule',
      
'settings' => NULL,
      
'context' => array('node_context'),
      
'not' => FALSE,
     ),
   ),
  
'logic' => 'and',
  
'type' => 'none',
  
'settings' => NULL,
  );  
ctools_include('context');
 
$node_context = ctools_context_create('entity:node', $node);
 
$contexts = array('node_context' => $node_context);   if (ctools_access($access, $contexts)) {
   
// Allow access.
 
}
  else {
   
// Deny access.
 
}
?>

$node is the fully loaded node object that we find based on the file $uri. I will skip this part as it is not too important here. However you can take a look at a full example that is attached to this post here: Download Example

If we will decide to extend the ruleset in the future, the only things we will need to do is add new rules and contexts via the UI.

In order to understand how to prepare the $access configuration array, I would recommend doing what I did: enable module page manager, play with access rules by creating rulesets, and debug the $access variable in ctools_access function.

Conclusion

By using CTools Access Rulesets we created a custom module, which allowed us to limit file downloads based on certain criteria. We showed that this method is a way to quickly write really flexible code that can adapt to changing needs.

It should give you a great perspective on what is possible using CTools Access Rulesets and stir your imagination. Have you used CTools Access Rulesets in a clever way in the past? Please share your experiences in the comments.

Thanks for reading!

AttachmentSize 38.52 KB 55.3 KB 50.13 KB 57.51 KB 53.14 KB 61.6 KB 1.06 KB
Sep 23 2011
Sep 23

Posted by John Ennew on Friday, 23 September 2011 at 12pm

CTools is a widely used Drupal API module which provides functional frameworks for modules such as Views, Panels, Context and more. In addition to its many uses, it has great JavaScript capabilities.

Tutorials on the web only tend to describe how to fire off a CTools AJAX call when clicking on a link. What we will show you is how to fire off a CTools call on other events, such as on page load.

To do this, you will need to create a custom module; our code assumes you've called your module 'mymodule'.

In your module code, start by defining a hook_menu path (if you haven't already), this will give us a URL to ping for updates via AJAX:

function mymodule_menu() {
  $items = array();
  $items['mymodule/%ctools_js/refresh-elements'] = array(
    'title' => 'AJAX callback - refresh the elements on the page',
    'page callback' => 'mymodule_refresh',
    'page arguments' => array(1),
    'access arguments' => array('access content'),
    'type' => MENU_CALLBACK,
  );
  return $items;
}

Note that the '%ctools_js' part of the path that gets replaced by JavaScript on page load if the client's browser has JavaScript enabled. If JavaScript is disabled it will stay as it is and fall back on nojs. As it's an argument we can pass it to our callback function. In our example this is more convention than a requirement as we won't be providing a no JavaScript fallback.

Next, define the callback function referenced in the menu item:

function mymodule_refresh($js = FALSE) {
  if (!$js) {
    // this is js only
    return;
  }
 
  ctools_include('ajax');
  $commands = array();
  $commands[] = ctools_ajax_command_html('.some-class', 'some-value');
  ctools_ajax_render($commands);
}

This function will only work when the menu is called via a CTools AJAX request. When it runs it will replace the inner HTML of all elements on the page with a class of 'some-class' with the string 'some-value'.

In order to perform this action on an event other than clicking on a link, we need to implement custom JavaScript which will be run on page load.

In your module make a directory called 'js' and within that, a file called 'mymodule.js' with the following contents:

Drupal.CTools.AJAX.refreshElements = function() {
  var url = '/mymodule/ajax/refresh-elements';
 
  try {
    $.ajax({
      type: "POST",
      url: url,
      data: { 'js': 1, 'ctools_ajax': 1},
      global: true,
      success: Drupal.CTools.AJAX.respond,
      error: function(xhr) {
        Drupal.CTools.AJAX.handleErrors(xhr, url);
      },
      complete: function() {
      },
      dataType: 'json'
    });
  }
  catch (err) {
    alert('An error occurred while attempting to process ' + url);
    return false;
  }
 
  return false;
};
$(document).ready(Drupal.CTools.AJAX.refreshElements);

This JavaScript code will attempt an AJAX call to the menu link you defined earlier and will pass the return value over to the CTools JavaScript handler. When passed to the CTools handler, it will perform the commands you specified in the callback 'mymodule_refresh'. The last line is the trigger point (document ready) where our JavaScript behaviour is called.

You need to include the JavaScript file on every page you want this to run. If this is every page on the site, then you can implement a hook_init function and use ctools_add_js. Note that the function ctools_add_js requires that your JavaScript file lives within a 'js' folder in your module, and must have a '.js' file extension.

function mymodule_init() {
  ctools_add_js('mymodule', 'mymodule'); // first arg is the javascript filename without the .js, second arg is the modulename
}

In the above code, the first argument for ctools_add_js is the name of the JavaScript file without the '.js' file extension, the second argument is the module name.

And that's it! CTools provides a lot of functions which you can add to the $commands array in the menu callback function to effect the page via ajax. For a full list of functions visit:
http://drupalcontrib.org/api/drupal/contributions--ctools--includes--ajax.inc/6

Jun 27 2011
Jun 27

The user modal is an interesting module we’ve been working lately in gizra for Medico.com, and I’d like to share our experience.

User modal example from Medico.com
From the README:

User modal module allows opening the Register/ Login/ Reset password menu items
as tabs. Since the tabs are shown via JS and not AJAX, this may lead to a better
user experience, as there is no time waiting for the selected tab to load.
Furthermore, the user modal form can be used by other implementing modules, and
for example allow a user to submit a node and register in the same time.

The idea is that we want to lower the barrier for a new user to participate in the site -- and the barrier is assumed to be too many clicks to just create a node or write a comment.

The final implementation is different from where we started:
1) We thought about creating our own custom forms and call the right validate and submit handlers, and suppress errors of non-submitted forms (e.g. the user has decided to login, instead of register so we don’t need to validate the register fields) using D7’s #limit_vaidation_errors property. The idea was to avoid form API complexity. It’s a no go. There are many advantages to re-using the current form. So we went on to the next step
2) Using subform module. Easier said then done, as by the time of writing user-modal subform was pretty broken. With a few patches from our side and a lot of help from the knowledgable casey, Subform maintainer, that provided lots if fix and support, we were able to re-use the forms.
3) So now it was just a matter of sticking it in the modal. Which modal? At first we used CTools modal (if you’ve been following my blog posts you know I’m a big fan of CTools), however in this case, we’ve replaced CTools modal with the overlay module that comes with core. Why? For several reasons (non of them is CTools modal fault).

CTools modal opens a modal dialog via ajax in the same page as the parent. The overlay opens the dialog in an IFrame, so you have "parent" and "child" pages. Silly things we love to hate, like IE, don’t allow (without hacks) to add new CSS to the page via AJAX. Another case was that Janrain (a module to connect to 3rd party providers like facebook) currently use JS to bind the click event to a link, but only to links it “sees” on page load. Links that are added via ajax are not binded. One final example, and again IE -- in our case we saw (and provided a patch) that when we post the form in the modal, $_POST returns empty. Thanks IE!
The advantage of using Overlay, is that the new content is in an IFrame, which means it’s actually loading the same way it would have in a separate window. Also, it should be mentioned that Overlay is a great piece of code, and I learned a lot from its JS (and a lot has went over my head).

We found that Overlay was easier to theme. CTools modal seems to have fixed width & height and requires a class to be added to the link that opens the modal with the name of the modal type that is required. With overlay it’s easier, as it’s a full page refresh , only inside the IFrame.

The disadvantage, or maybe better -- challenge is that unlike CTools modal, Overlay’s modal being in an IFrame means one has to do some “tricks” to pass information from the child to the parent window.

Another minor advantage is that overlay is a core module, so it’s considered a better practice to use it. It should be noted, that the Overlay gives an impression it can/ should be used only for admins - we think it’s just a matter of showing it could be used for other stuff as well.

Instead of going in details in the blog post, you should grab the module and enable the example module that comes with it. There’s some info in the README, and a lot of comments in the example module. The example shows a simple case of creating an article node and logging-in with a singe click!

May 11 2011
May 11

Why should you use panels node-preview-example module? You shouldn't, it's just an example ;)

This post covers how to build a CTools content-type plugin that has context. CTools "Context" should not be mistaken with the context module. Context is CTools way of saying, my plugin (read, block) shouldn't be dumb. If it requires a node or a user to extract data from, then it should let others know about it. "Others" in our case is Page manager module. It will load the context for the plugins and hand it over. In fact, if the context is not present it will not even bother loading the plugin.

I have created an example module called node-preview-example, that you can simply download and enable, create an Article page, and see how Panels is now showing the body field on the left and on the right a (silly) "Summary" of the node -- this is our plugin.

drush dl ctools panels features
drush en ctools page_manager panels features

And the example module is in my sandbox:

Let's go over the important parts in the code.

<?php
/**
* Plugin definition.
*/
$plugin = array(
  ...
'required context' => array(
    new
ctools_context_required(t('User'), 'user'),
    new
ctools_context_required(t('Node'), 'node'),
  ),
  ...
);
?>

The plugin definition is where we let the system know we need a user object and a node object to operate on.

Next the render callback can use those context, after making sure they are valid.

<?php
/**
* Render callback.
*/
function node_preview_example_summary_content_type_render($subtype, $conf, $args, $context) {
 
// Seperating the context to two different variables
 
list($user_context, $node_context) = $context; // Make sure that context variable arent empty.
 
if (empty($user_context) || empty($user_context->data)) {
    return;
  }

  if (empty(

$node_context) || empty($node_context->data)) {
    return;
  }
// Above we made sure we got the context.
 
$node = $node_context->data;

  ...
}

?>

As you can see, it didn't take much to get the context working, but it gives a lot of power:

  • Plugins without satisfied context will not appear in the plugins list, so a user can't add them by mistake
  • There is one single way for plugins to get a context, unlike "dumb" blocks that each one needs to find out where they are (e.g. menu_get_item() over and over again).

If you listen to talks about Butler module, and having a plugin and context system in D8 - it will probably very similar or at least learn a lot from CTools plugins and context system, so you know you are on the right path.

Apr 08 2011
Apr 08

I wanted to create a node view containing both the original node and its translation, sort of like this neat Meedan.net page. I decided to build this page as a Panels node view template, but to reach the translation(s) I had to write a new CTools relationship plugin that I am sharing here:

<?php
// @file mymodule.module/**
* Implementation of hook_ctools_plugin_directory().
*
* It simply tells panels where to find the .inc files that define various
* args, contexts, content_types.
*/
function mymodule_ctools_plugin_directory($module, $plugin) {
  if (
$module == 'ctools' && !empty($plugin)) {
    return
"plugins/$plugin";
  }
}
?>

Place the following file in a plugins/relationships subfolder of your module:

<?php
// @file node_translation.inc/**
* Plugins are described by creating a $plugin array which will be used
* by the system that includes this file.
*/
$plugin = array(
 
'title' => t('Node translation'),
 
'keyword' => 'translation',
 
'description' => t('Creates the translation of a node as a node context.'),
 
'required context' => new ctools_context_required(t('Node'), 'node'),
 
'context' => 'ctools_translation_from_node_context',
 
'settings form' => 'ctools_translation_from_node_settings_form',
 
'defaults' => array('language' => 'en', 'fallback' => FALSE),
);
/**
* Return a new context based on an existing context.
*/
function ctools_translation_from_node_context($context, $conf) {
 
// If unset it wants a generic, unfilled context, which is just NULL.
 
if (empty($context->data)) {
    return
ctools_context_create_empty('node', NULL);
  }  if (isset(
$context->data->nid)) {
   
$original = node_load($context->data->nid);
   
$tnids = translation_node_get_translations($original->tnid);
    if (empty(
$tnids[$conf['language']])) {
      return
$conf['fallback'] ?
       
ctools_context_create('node', $original) :
       
ctools_context_create_empty('node', NULL);
    }
   
$translation = node_load($tnids[$conf['language']]->nid);    // Send it to ctools.
   
return ctools_context_create('node', $translation);
  }
}function
ctools_translation_from_node_settings_form($conf) {
 
$form['language'] = array(
   
'#type' => 'select',
   
'#title' => t('Language'),
   
'#options' => locale_language_list(),
   
'#default_value' => $conf['language'],
  );
 
$form['fallback'] = array(
   
'#type' => 'checkbox',
   
'#title' => t('Fallback to original node'),
   
'#description' => t('Use original node if desired translation is not found.'),
   
'#default_value' => $conf['fallback'],
  );
  return
$form;
}
?>

To use this plugin, create a new variant of the Panels node view template. In the Contexts page, add a Node translation relationship for every language that you want to support. You can specify the desired language in the relationship settings. In your Content layout, you will then be able to use those relationships to access the node translations.

To go back to the original Meedan.net example above, we want the English version on the left and the Arabic on the right. To achieve this, I created two node translation relationships, one for English and the other for Arabic. I placed a node content panel in each of my two columns, and set the left panel to refer to the English translation and the right panel to the Arabic translation. Note that I didn't use the original "Node being viewed" content because there would be no way of telling if it's Arabic or English, given that this node template could be accessed via either versions.

What's missing is to replicate the bilingual comments view, and I'll share my solution once I implement it :-)

Mar 14 2011
Mar 14

I was using the brilliant context module in a project recently. The fact that it uses ctools means it has a few characteristics reminiscent of views (and panels). One of these is the import / export functionality, and the distinction between the different types of storage for the contexts you've set up - i.e.

  • normal
  • default
  • overridden

Seeing this, I was certain there must be a way of defining contexts in code in a module, similar to the way you can define default views hook_views_api() and hook_views_default_views(). However, I really struggled to find any documentation about the correct hooks and syntax to achieve this.

Of course, one way of finding out was to use the features module to package up a context and have a look at the code it produced.

It turns out this works in a very similar way to the views module (unsurprisingly given their shared heritage). I thought I'd document it here in case other people are struggling to find some clear instructions as to how to include your own default context objects in your module.

Just like with views, you need to implement an api hook, and then the actual context_default_contexts hook which returns the exported context object(s):

/**
 * Implementation of hook_ctools_plugin_api().
 */
function mymodule_ctools_plugin_api($module, $api) {
  if ($module == "context" && $api == "context") {
    return array("version" => 3); 
  }
}

and then in mymodule.context.inc something along the lines of this:

/**
 * Implementation of hook_context_default_contexts().
 */
function mymodule_context_default_contexts() {
  $export = array();
  $context = new stdClass;
  $context->disabled = FALSE; /* Edit this to true to make a default context disabled initially */
  $context->api_version = 3;
  $context->name = 'testy';
  $context->description = 'testing context';
  $context->tag = '';
  $context->conditions = array(
    'node' => array(
      'values' => array(
        'page' => 'page',
      ), 
      'options' => array(
        'node_form' => '1',
      ),
    ),
  );
  $context->reactions = array(
    'menu' => 'admin/help',
  );  
  $context->condition_mode = 0;
 
  // Translatables
  // Included for use with string extractors like potx.
  t('testing context');
 
  $export[$context->name] = $context;
  return $export;
}

Perhaps the reason I couldn't find easy documentation for this is that it's really the ctools api doing the work - I think I'll submit an issue for the context module though to suggest at least a hint is added to the README to point developers in the right direction.
[edit]I posted a documentation issue on drupal.org[/edit]

One of the posts I did find which helped was Stella Power's interesting write up on how to use ctools to create exportables in your own module.

Feb 24 2010
Feb 24

They will load faster, it's easier for deployment, and you'll be able have them under version control.

We all know that you can load views from code, and it is even recommended. But what about Panels pages? It is also possible. And actually, it's quite easy.

Let's say that you have reached a beta stage for these pages, and you are ready to start having these under version control so you can sleep better at night.

1. Create the module that will load these pages

panels_default_pages_file_structure.png
We'll call our module ctools_defaults. On the right we see the structure and files that we will need in our module.
Inside of the pages directory, we'll be inserting each of our exported pages.

2. Implement hook_ctools_plugin_api()

This is done in order to let ctools know that our module has something to say. We'll do this inside ctools_defaults.module.

<?php
/**
  * Implementation of hook_ctools_plugin_api().
  */
function ctools_defaults_ctools_plugin_api($module, $api) {
  if (
$module == 'page_manager' && $api == 'pages_default') {
    return array(
'version' => 1);
  }
}
?>

3. Implement hook_default_page_manager_pages()

The name of the file, very important, will be MODULENAME.pages_default.inc - and in our case ctools_defaults.pages_default.inc. Because we are clever, in it we'll do a little trick, to facilitate our lives, and have each page in its separate file. This will allow us to have an independent version control per page, and also make the re-exporting/editing of existing pages easier.

<?php
/**
* Implementation of hook_default_page_manager_pages().
*/
function ctools_defaults_default_page_manager_pages() {
 
$pages = array();
 
$path = drupal_get_path('module', 'ctools_defaults') . '/pages';
 
$files = drupal_system_listing('.inc$', $path, 'name', 0);
  foreach(
$files as $file) {
    include_once
$file->filename;
   
$pages[$page->name] = $page;
   }
  return
$pages;
}
?>


So what's special? not much, this code looks for .inc files in the directory called pages within our module, and for each of the files found, it tells panels that we have a default page. Each new file that we place in there will be loaded automatically.

4. Export our panel pages

The process is pretty simple, but in case someone gets lost, the button is right here when you are editing a panel page:
panel_page_export_button.png

Which will get you to a page with all the code ready to be copied, like:
panel_page_export_code.png

5. Create our PAGENAME.inc

We create a file called PAGENAME.inc inside our pages directory, within our module's directory.

<?php
// paste below the code exported from the panels UI
?>


Inside this file, we'll open php, and paste the code copied in the previous screen. Just like that, don't be shy and save the file.

6. Empty the cache

Panels caches (thankfully) the default pages that third party modules provide, so we must clear the cache when we create a new default page, and when we modify the code of an existing one.

Conclusion

This technique let's us sleep better at night. If someone ever touches the panel page, and breaks things, we can always revert to the default code. We'll be able to create pages based on existing ones if they are very similar, just by copying and modifying the original code, reducing our development time, and improving our personal relationships as an unexpected bonus.

If you have any comments, you know what to do. If you see a mistake in my technique, please do let me know and I'll fix it right away.

Attachment Size ctools_defaults.tar.gz 926 bytes
Oct 06 2009
Oct 06

Creating panels styles can be very powerful. You can define certain styles for your client to choose from, so they can choose what type of display the panel pane will be like. This way you keep the workflow clean, your code under revision control, your themer gets to keep his sanity, and your concious stays clear.

This article assumes you know about running panels, and more or less what the nomenclature is. You should know also that panels now uses ctools, which is is primarily a set of APIs and tools to improve the developer experience.

So, what we'll be doing here is actually creating a ctools plugin, to implement a new panels style. Sorry if I'm confusing you already, don't worry, it's actually quite straight forward, we want to be able to do this:

choose_style.png
... and then this:
choose_style2.png

OK, now to the meat of it. We'll call our module ctoolsplugins.

1. Create a new module, and tell ctools about our plugins

What you need is very basic, an info file and a module file. So far, nothing new.

1.1 Declare our dependencies

So we obviously need ctools module on our site, and well, the plugin wouldn't make much sense without panels and page_manager, so:

; $Id:
name = Ctools Plugins
description = Our custom ctools plugins
core = 6.x
dependencies[] = ctools
dependencies[] = panels
dependencies[] = page_manager
package = Chaos tool suite

1.2 Implement hook_ctools_plugin_directory to tell ctools about our plugins

In our module file, of course, we'll implement this function, that will check if ctools if looking for style plugins, and lets if so, it will let it know where ours are:

<?php
/**
  * Implementation of hook_ctools_plugin_directory().
  */
function ctoolsplugins_ctools_plugin_directory($module, $plugin) {
  if (
$module == 'panels' && $plugin == 'styles') {
    return
'plugins/' . $plugin;
  }
}
?>

2. Prepare our file structure for the plugins, and create our plugin file.

This image is pretty self explanatory:

ctoolsplugin_directories.png
We'll call our plugin 'collapsible', so we create inside ctoolsplugins/plugins/styles/ a file called collapsible.inc.

3. Implement our style plugin in collapsible.inc

3.1 Define your style goals and necessities.

OK, Here you should think about what you are going to do with the plugin.

  • Is it just for markup?
  • Will you be offering different options?
  • Will you be implementing javascript on it?

In our case, we'll take the opportunity, to teach you another thing that ctools has, the collapsible div utility. So our style will basically convert any panels pane, into a collapsible panels pane:
collapsed_pane.png

And because we are friendly developers (or like not to be ever bothered after developing it), we'll give the user a chance to configure if they want the pane to start opened or closed. That means an extra settings form, so we can have this:
ctools_style_options.png

3.2 Imlement hook_panels_style_info

The naming is very important here, it should be modulename_stylename_panels_style. You basically return an array, defining your style:

<?php
/**
* @file
* Definition of the 'collapsible' panel style.
*/

/**
* Implementation of hook_panels_style_info().
*/

function ctoolsplugins_collapsible_panels_styles() {
  return array(
     
'title' => t('Collapsible div'),
     
'description' => t('Display the pane in a collapsible div.'),
     
'render pane' => 'ctoolsplugins_collapsible_style_render_pane',
     
'pane settings form' => 'ctoolsplugins_collapsible_style_settings_form',
  );
}
?>

'title' and 'description' are pretty self explanatory.
'render pane' specifies the theme function we'll be providing for rendering the pane. Watch the naming convention.
'pane settings form' specifies the callback function which provides the extra settings form, that we'll be using for our start up options. Watch the naming convention.

3.3 Define the settings form callback.

The name of the function will be what you specified in 'pane settings form' earlier. Just provide a new array inside $form, for each configuration you want the user to specify. See the FAPI documentation for reference.

<?php
/**
* Settings form callback.
*/
function ctoolsplugins_collapsible_style_settings_form($style_settings) {
 
$form['collapsed'] = array(
   
'#type' => 'select',
   
'#title' => t('Startup behaviour'),
   
'#options' => array(
     
0 => t('Start opened'),
     
1 => t('Start collapsed'),
    ),
   
'#default_value' => (isset($style_settings['collapsed'])) ? $style_settings['collapsed'] : 'opened',
   
'#description' => t('Choose whether you want the pane to start collapsed or opened'),
  );

  return

$form;
}
?>

This is pretty straight forward, in our case we provide two options, one to start opened and one to start collapsed. If collapsed is chosen, the value will be 1.

3.4 Define the render callback function.

The name of the function will be what you previously specified in 'render pane'. This is just a theme function, and where you have the chance of altering what will be shown to the user when viewing the page.

<?php
/**
* Render callback.
*
* @ingroup themeable
*/
function theme_ctoolsplugins_collapsible_style_render_pane($content, $pane, $display) {
 
$style_settings = $pane->style['settings']; // good idea for readability of code if you have a ton of settings
 
$start_settings = $style_settings['collapsed']; // we can do this be cause the only values possible are 0 or 1 $pane_content = $content->content;

  if (

$content->title) {
   
$pane_title = '<h2 class="pane-title">'. $content->title .'</h2>'; // theme('ctools_collapsible', $handle, $content, $collapsed);
   
$result = theme('ctools_collapsible', $pane_title, $pane_content, $start_settings);
  }
// if we don't have a pane title, we just print out the content as normaly, since there's no handle
 
else {
   
$result = $pane_content;
  }

  return

$result;
}
?>

Important to note here, is that our user's specified settings are inside $pane->style['settings']. In this example we check if there's a title available, and if so we implement the ctools_collapsible theme function to get our collapsible panes. Other wise we don't have a handle, and we just return the content as normal.

And that is it. Hope you found the article useful, and if you'd like me to write up some more articles about writing plugins for panels/ctools, drop a comment with your question/suggestion!

UPDATE:You can also provide a style plugin in your theme, as shown in this fine tutorial.

Attachment Size ctoolsplugins.tar.gz 1.3 KB

About Drupal Sun

Drupal Sun is an Evolving Web project. It allows you to:

  • Do full-text search on all the articles in Drupal Planet (thanks to Apache Solr)
  • Facet based on tags, author, or feed
  • Flip through articles quickly (with j/k or arrow keys) to find what you're interested in
  • View the entire article text inline, or in the context of the site where it was created

See the blog post at Evolving Web

Evolving Web