Overriding URLs with Drupal panel pages

This blog post is more than 6 years old, so the content may be out of date.

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.

Oh man. I have been banging my head against a wall trying to figure out why my task wasn't loading. I saw this reply and sure enough, putting it in the function rather than a self-standing plugin array did the trick.

Thanks for the catch. And did you ever figure out why that is?

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.

I had to remove these lines and place the callback in my .module file.
Remove:

$items['user/%user/notifications']['file path'] = $task['path'];
$items['user/%user/notifications']['file'] = $task['file'];

Move:

/**
* 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);
}

When ctools ran the with it as it was in the post ctools didn't load the plugin again because it was already being required. The require_once in ctools was seeing that the file with the plugin (notifications.inc) was already loaded.

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>, <apache>, <bash>, <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.