Overriding URLs with Drupal panel pages

Hi, My name's Marcus, and I'm addicted to Drupal panels.
(For those of you who haven't come across this wonderdrug before, panels is a layout-engine for Drupal: it lets you add and lay out content, blocks, views and other panels on the page. And it's awesome!)

Panels has a UI which lets you setup new panel pages: e.g. you could add the URL /jobs for your jobs listing page.

A set of system URLs can also be overridden: e.g. node/%node can be overridden to use a panel-page instead of the core node_page_view function.

The full list of system URLs which can be overridden by panels are:
  • /node/%node
  • /node/%node/edit
  • /poll
  • /taxonomy/term/%term
  • /user/%user
So it's not a long list, but covers most of the public-facing pages of a core Drupal site. But when you add a new module, you get new URLs: say you add the notifications module, you'll get a new page at user/%user/notifications. What if you want this in a panel too? You can override existing Drupal pages with panel pages by adding a page manager task plugin. First, you need to create a module, and tell ctools where to find the plugins:
/**
 * Implementation of hook_ctools_plugin_directory() to let the system know
 * where our task plugins are.
 */
function foo_ctools_plugin_directory($owner, $plugin_type) {
  return 'plugins/' . $plugin_type;
}
Storing ctools plugins in a subdirectory called plugins is a good pattern to follow - apply the principal of least surprise! Within plugins, create a new subdirectory called tasks. Next, you want to add a task plugin for notifications - create a file called notifications.inc, and enter:
<?php
// $Id$
 
$plugin = array(
  // This is a 'page' task and will fall under the page admin UI
  'task type' => 'page',
 
  'title' => t('Notifications'),
  'admin title' => t('Notifications'),
  'admin description' => t('When enabled, this overrides the default Drupal behavior for the notifications page at <em>/user/%user/notifications</em>.'),
  'admin path' => 'user/%user/notifications',
 
  // Menu hooks so that we can alter the default entry
  // foo is the name of the module; notifications is the name of the task.
  //   (this is arbitrary and the name-structure is a convention 
  //    to avoid function-name collisions with other modules.)
  'hook menu alter' => 'foo_notifications_menu_alter',
 
  // This task provides the 'user' context to content-types on the panel
  'handler type' => 'context',
  'get arguments' => 'foo_notifications_get_arguments',
  'get context placeholders' => 'foo_notifications_get_contexts',
 
  // Allow this panel-page to be enabled or disabled:
  'disabled' => variable_get('foo_notifications_disabled', TRUE),
  'enable callback' => 'foo_notifications_enable',
);
Next, add the callbacks which are declared in the plugin (simply add these to the same notifications.inc file). First, the menu-alter, which replaces the default user/%user/notifications callback with our panel page.:
function foo_notifications_menu_alter(&$items, $task) {
  // the enable/disable callback will set a variable to control whether the panel is enabled.
  if (variable_get('foo_notifications_disabled', TRUE)) {
    // the panel is disabled: don't run the menu-alter, leave the default menu entry.
    return;
  }
 
  // Check whether the callbacks at user/%user/notifications match those
  // provided by the notifications module.  If not, then the URL has been overridden already.
  $page_callback = $items['user/%user/notifications']['page callback'];
  $page_arguments = $items['user/%user/notifications']['page arguments'];
 
  $is_using_default = ($page_callback == 'drupal_get_form' && page_arguments == array('notifications_user_overview', 1));
 
  // check that it either matches the callback provided by notifications,
  // or if not, it's already been overridden by another module, so check
  // whether page-manager is configured to override already-overridden pages.
  if ($is_using_default || variable_get('page_manager_override_anyway', FALSE)) {
    $items['user/%user/notifications']['page callback'] = 'foo_notifications_page';
    $items['user/%user/notifications']['file path'] = $task['path'];
    $items['user/%user/notifications']['file'] = $task['file'];
  }
  else {
    // disable the panel page
    variable_set('foo_notifications_disabled', TRUE);
    // the enable-function sets a global, so the message is only displayed when trying to enable the panel, not on every cache-clear.
    if (!empty($GLOBALS['foo_enabling_notifications'])) {
      drupal_set_message(t('Foo module is unable to enable the notifications panel page because some other module already has overridden with %callback.', array('%callback' => $callback)), 'warning');
    }
    return;
  }
}
Next, the code to enable/disable the panel-page:
function foo_notifications_enable($cache, $status) {
  variable_set('foo_notifications_disabled', $status);
  // Set a global flag so that the menu routine knows it needs
  // to set a message if enabling cannot be done.
  if (!$status) {
    $GLOBALS['foo_enabling_notifications'] = TRUE;
  }
}
And the panel page needs to pass the user-context:
function foo_notifications_get_arguments($task, $subtask_id) {
  return array(
    array(
      'keyword' => 'user',
      'identifier' => t('User'),
      'id' => 1,
      'name' => 'uid',
      'settings' => array(),
    ),
  );
}
 
/**
 * Callback to get context placeholders provided by this handler.
 */
function foo_notifications_get_contexts($task, $subtask_id) {
  return ctools_context_get_placeholders_from_argument(foo_notifications_get_arguments($task, $subtask_id));
}
So this example shows a single context, but multiple/custom contexts can be handled too. Finally, we need the code that actually handles that URL:
/**
 * Entry point for our overridden notifications URL.
 *
 * This function asks its assigned handlers who, if anyone, would like
 * to run with it. If no one does, it passes through to notifications's
 * handler.
 */
function foo_notifications_page($user) {
  // Load my task plugin
  $task = page_manager_get_task('notifications');
 
  // Load the user into a context.
  ctools_include('context');
  ctools_include('context-task-handler');
  $contexts = ctools_context_handler_get_task_contexts($task, '', array($user));
  $args = array($user->uid);
 
  $output = ctools_context_handler_render($task, '', $contexts, $args);
  if ($output !== FALSE) {
    return $output;
  }
 
  // fallback to the default notifications handler:
 
  module_load_include('inc', 'notifications', 'notifications.pages');
 
  $function = 'drupal_get_form';
  $args = array('notifications_user_overview', $user);
 
  foreach (module_implements('page_manager_override') as $module) {
    $callback = $module . '_page_manager_override';
    if (($rc = $callback('notifications')) && function_exists($rc)) {
      $function = $rc;
      break;
    }
  }
 
  // Otherwise, fall back.
  return $function($args);
}
Et voila: you should now have a panel-page which can be enabled and take over the notifications page. Your next problem is that there's nothing on the page: to reproduce the content that was originally on the notifications page, you'll need to create a custom ctools content-type...which, for now, you'll have to search for; I'll save that story for another blog! Bootnote: most of this code is derived from the task handlers provided by page-manager module, part of ctools.

Comments

Excellent posting. Undoubtedly you are an expert of such writing topics. This is absolutely the first time I visited your site and frankly speaking it succeeds in making me visit here now and then.And yes i have book mark your site deglos.com .

Hi,

thank you for article. It helped me a lot.
One thing I had to change. I had to implment plugin with hook. Now, I am not exactly sure why, but it helped.

SO I have somesthing like:

function MODULENAME_PLUGINAME_page_manager_tasks() {
  return array(
    // This is a 'page' task and will fall under the page admin UI
    'task type' => 'page',
 
    'title' => t('Forum'),
    'admin title' => t('Forum'),
    'admin description' => t('Override forum path.'),
    'admin path' => 'forum/!forum',
 
    // Menu hooks so that we can alter the default entry
    'hook menu alter' => 'rw_ratolesti_forum_menu_alter',
 
    // This task provides the 'forum' context to content-types on the panel
    'handler type' => 'context',
    'get arguments' => 'rw_ratolesti_forum_get_arguments',
    'get context placeholders' => 'rw_ratolesti_forum_get_contexts',
 
    // Allow this panel-page to be enabled or disabled:
    'disabled' => variable_get('rw_ratolesti_forum_disabled', TRUE),
    'enable callback' => 'rw_ratolesti_forum_enable',
  );
}

Cheers.

Tried to adapt this to override a Profile2 page (/profile-main/%user) using D7, but failed on several points.

  • Wouldn't enable unless I forced it to enable.
  • Error when enabling-> Undefined index: profile-main/%user in panelsuser_profile2_menu_alter()
  • Message->module is unable to enable the profile2 panel page because some other module already has overridden with .
  • Several other subsequent errors.

Add new comment

Filtered HTML

  • Web page addresses and e-mail addresses turn into links automatically.
  • Allowed HTML tags: <a> <em> <strong> <cite> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd>
  • Lines and paragraphs break automatically.
  • You can enable syntax highlighting of source code with the following tags: <code>, <blockcode>, <c>, <cpp>, <drupal5>, <drupal6>, <java>, <javascript>, <php>, <python>, <ruby>. The supported tag styles are: <foo>, [foo]. PHP source code can also be enclosed in <?php ... ?> or <% ... %>.

Plain text

  • No HTML tags allowed.
  • Web page addresses and e-mail addresses turn into links automatically.
  • Lines and paragraphs break automatically.
By submitting this form, you accept the Mollom privacy policy.