Updating Panelizer content programmatically
Panelizer is a great module for being able to modify the layout of a page on a per-node basis. However, its greatest strength can sometimes be its greatest weakness. We found this out the hard way when a client asked us to help them add a block on every single page of their site directly beneath the h1 page title. Read on for how we approached this issue.
Introduction
One of our clients with a pretty content-heavy site asked us to help them place a new block on every single page of their site. A pretty straightforward request, sure. But they wanted it to appear directly beneath the h1 page title on every page. At first glance, this looked like something core’s Block module could handle. Except that the page content is grouped together, so there was no way in the UI to place the new Block directly beneath the page title.
There are many possible approaches to this, but we had to work within the existing structure of the site and there are also multiple modules handling the layout of pages. For instance the Panelizer module is being used for some content types and standard nodes/templates for others. They are also using Views pages and one or two Page manager pages. So we needed a solution that would cover all these bases.
The trickiest part, and the focus of this article was the Panelizer pages. The Panelizer module is much like its parent, Panels, except Panelizer allows you to “Panelize” a page on a per-node basis. So with Panels we might create a Panels page, and apply the use of that page to a content-type. This means that we need only change the layout of the Panels page and it automatically applies to any page based on that.
Panelizer, however, works by doing this on a per node basis. So, a user could modify the layout of a single node page. For example, let’s say node/56 is a page called “Company Expenses”. The user wants to show some content taken from the company report, but we wants it laid out in a very custom and specific way. The layout is a little too complex or cumbersome for just using the WYSIWYG editor, and perhaps it is not intended to be reused anywhere else. This is where Panelizer is great. It gives users the flexibility to make changes to the layout AND content of a page, without having to touch any code. But...
“its greatest strength can sometimes be its greatest weakness.”
When a user does change the layout or the content, the page is then in an “Overridden” state. This means that any changes to the default Panelizer template, will not be reflected on overridden pages. In this case of our client, there were hundreds of pages where their layout/content was overridden. We could have just given them instructions on how to manually place the block using the Panelizer interface, but it would have been a lot of work to manually edit hundreds of pages, so we decided to look at a way to automate it.
Option 1: Preprocess page
The first place I looked was preprocessing of the page. I hoped that by using a general hook_preprocess_page I could simply add an additional element to the page array that would get rendered. Unfortunately, the Panels content of the page is already rendered to markup by the time it gets to hook_preprocess_page, so that wasn't going to work for us.
Option 2: Pre render/alter hook
The next road I went down was trying to do the same thing as above, but with a more targeted hook. Sometimes modules will provide the pre render or alter hook for you. I did some Googling and looked through the Panelizer documentation, the first function I came across was hook_preprocess_panels_pane(). I tried it out but found that it was preprocessing the individual panes within a region. I needed to actually provide a new pane, so it wasn't going to help us.
I then started to dig around in the actual Panelizer code. That is when I found calls to panelizer_pre_render(). This seemed to be perfect as it specifically targeted the exact thing I was trying to modify. One of the arguments that is passed to this function is the $display object of the entity. The display has a $content property which is an array of "panes". I thought this was going to be perfect. I could insert my own pane into this array and it should just work. It did work to some degree in that I could insert a pane, but for some reason I could not modify the order of the panes. The requirement was to have this new block appear directly beneath the h1 page title. I tried re-ordering the array elements. I tried using the 'position' property but nothing I did could change the order of elements at this point. At first I was just adding in an array but later I found the function panels_add_pane() function. I hoped this would help but unfortunately I still could not modify the order of the panes.
Option 3: Update hook/programmatic load/save
After some more research I came across the interesting approach outlined in this blog post. Basically it outlines how you can programmatically load a Panelizer entity and add in a new pane. It does in code what you would do through the Panels interface by clicking the gear icon then clicking 'Add Content', placing it in a region and then clicking 'Save'. I decided to write an update hook that would do something very similar. Here is my code:
First I wanted to grab all the Panelized nodes that are overridden. This function handles querying for them:
function example_block_pane_update() {
// Load all overridden nodes.
$panelizer_nodes = db_select('panelizer_entity', 'e')->fields('e', array('entity_id'))->condition('entity_type', 'node')->condition('did', 0, '<>')->groupBy('e.entity_id')->execute()->fetchCol('entity_id');
// Pass to function which will add my_block block.
example_add_block_pane_nodes($panelizer_nodes);
}
Notice that I am querying the panelizer_entity table looking for any entities that have the column “did” set to zero. This “did” column is a foreign key to an entry in the panels_display table, or the "display object" essentially. What I found was that all of our overridden nodes had their did set to zero. So this was how I queried the database for the entity id’s that I wanted to update. Note: I did find some anomalies with this approach. There were a handful of entities which had zero for did but were not listed as overridden. I was unable to determine why this was the case. It could be that I am making the wrong assumption about the did in this case but I wasn't able to find a more definitive answer at this stage. If you are aware of the answer please comment to let me know.
Now that I have queried and found all the overridden Panelizer nodes, I want to loop through these and add in my new block. Here is my function that handles it.
function example_add_block_pane_nodes($nids) {
$nodes = node_load_multiple($nids);
foreach ($nodes as $nid => $node) {
// Set region based on layout.
switch ($node->panelizer['page_manager']->display->layout) {
case 'landing_page':
$region = 'primary';
break;
case 'sub_landing_page':
$region = 'main';
break;
case 'onecol':
$region = 'middle';
break;
}
// Check if matching type.
if ($region) {
$panes = array();
$my_block_pane_exists = FALSE;
// Load the display.
if ($display = panels_load_display($node->panelizer['page_manager']->display->did)) {
// Get this display's panes.
$panes = $display->content;
// Reset the panes.
$display->content = array();
$display->panels[$region] = array();
// Loop through and add in our new pane.
foreach ($panes as $pid => $pane) {
// If my_block pane already exists we can skip this.
if ($pane->type == 'block' && $pane->subtype == 'my_block') {
$my_block_pane_exists = TRUE;
break;
}
// Set content.
$display->panels[$region][] = $pid;
$display->content[$pid] = $pane;
// Add a new pane after the node_title pane.
if ($pane->type == 'node_title' || $pane->type == 'page_title') {
$new_pane = panels_new_pane('block', 'my_block', TRUE);
$new_pane->panel = $region;
$display->panels[$region][] = $new_pane->pid;
$display->content[$new_pane->pid] = $new_pane;
}
}
// Finished reordering pane's, now save.
if (!$my_block_pane_exists) {
panels_save_display($display);
}
}
}
}
}
Some things to take note of here:
- I set the $region value based on the layout for this entity. This is because the regions within the layouts can have different names.
- The main foreach loop was modelled after the submit function panels_edit_display_form_submit() which is in panels/includes/display-edit.inc
- Since we want this new pane to appear directly beneath the page title, we simply check as we loop through each pane if it is the page/node title and if it is we create the new pane using panels_new_pane() and apply it as the next element in the panels and content arrays on the display object.
The above functions were placed into an update hook and so we updated a large number of Panelized nodes in one go. I then wrote some tests that would test creating a new Panelizer node (using the default) and to look at some of the existing to ensure that my_block was present.
Conclusion
Panelizer is a great module, so long as you are aware of this kind of potential issue. It can be particularly confusing to users who probably wouldn't consider the issue that even though you have the flexibility to change the layout/structure/content of a page, it then means that it can be incredibly difficult to automatically make changes to the layout/structure/content of those pages in the future. I think this kind of issue can be mitigated by careful design and site architecture. Perhaps instead of allowing the entire page to be Panelized, only certain parts? During the planning phase of a project you should consider whether users really need the flexibility to change the layout of every single page and again this should be weighed against the ability to later maintain those pages.