This is part three of a five part series Drupal 7 Dynamic Content Carousel. If you haven't read part one yet, it may help to read it before moving forward - Part One - Introduction.
AJAX Basics
In the last article, we created the majority of the AJAX needed for our Dynamic Content Carousel...but there were still a few issues:
- The URL doesn't update.
- Browser history doesn't record dynamically loaded pages.
- Page HTML meta title doesn't update.
- The AJAX link hrefs look ugly on hover (in browsers that display hrefs on hover...like Chrome).
Let's fix this now!
AJAX Improvements - Setup
To do this, we'll use the ajax_command_invoke() function, this allows us to execute a JavaScript file while processing our AJAX commands.
Step 1 - JavaScript Setup
Before we add this command, let's add a 'js' directory to our module and an ajax_content_loader.js file inside that.
Next we'll create a Drupal behaviour (ajaxContentLoaderInit) in our new ajax_content_loader.js file. The idea is that we'll invoke ajaxContentLoaderInit using ajax_command_invoke() from our ajax_content_loader.module file before and after the AJAX commands have executed.
(function($){
Drupal.behaviors.ajaxContentLoaderInit = {
attach: function (context, settings){
// Code goes here
}
};
})(jQuery);
js/ajax_content_loader.js
With the current setup, all code within the // Code goes here section will execute as soon as the page loads. This isn't what we want, we want some code to execute before our AJAX commands have started and different code to execute after the commands. To do this, we'll wrap our two units of code in event handlers. The event handler will execute and register on page load but will only trigger when the specified event is triggered. We can use the ajax_command_invoke() command from our ajax_content_loader.module file to trigger the event.
(function($){
Drupal.behaviors.ajaxContentLoaderInit = {
attach: function (context, settings){
// Create a custom event that will trigger additional AJAX processing before loading content.
$(document).on('beforeAjaxProcess', function(event, selector) {
// Code goes here
});
// Create a custom event that will trigger additional AJAX processing after loading content.
$(document).on('afterAjaxProcess', function(event, newPage) {
// Code goes here
});
}
};
})(jQuery);
js/ajax_content_loader.js
We've just created two event handlers, beforeAjaxProcess will be triggered at the start of the AJAX update process and afterAjaxProcess will be triggered at the end.
Here we use the jQuery on() method to attach a custom event (beforeAjaxProcess / afterAjaxProcess) onto the root document in the DOM. We can then add our code within the on() method's callback. We pass two parameters into the callback, event and a variable, the variable will contain variables passed from ajax_content_loader.module such as the new page's title and URL.
We're almost ready to begin resolving our list of issues, however there is one more thing we need prepare. In jQuery, events can be attached multiple times (even the same event). As it stands, if we ran the above code, every time ajaxContentLoaderInit executes, new beforeAjaxProcess and afterAjaxProcess events would be attached to document. This means when the custom event is triggered, any code inside the on() callback (currently // Code goes here) would be executed multiple times.
Let's prevent this.
There's two ways that we can achieve this, the quick way or the recommended Drupal way. The quick way is to always detatch the event before attaching it. This way we guarantee that only one event is attached to document. To do this, you simply need to add .off('beforeAjaxProcess') as follows:
$(document).off('beforeAjaxProcess').on('beforeAjaxProcess', function(event, selector) {
This way works but there is a more efficient way to achieve the same result (instead of always detatching and reattaching the event handler, it's better to just ensure the event handler is only run once). Drupal comes pre-packaged with the jQuery Once plugin, we can use this (in addition to specifying a jQuery selector context) to achieve our goal.
(function($){
Drupal.behaviors.ajaxContentLoaderInit = {
attach: function (context, settings){
// Add the class 'ajaxloader-processed' to the 'body' element after first invocation
// and prevent further invocations.
$('body', context).once('ajaxloader', function(){
// Create a custom event that will trigger additional AJAX processing before loading content.
$(document).on('beforeAjaxProcess', function(event, selector) {
// Code goes here
});
// Create a custom event that will trigger additional AJAX processing after loading content.
$(document).on('afterAjaxProcess', function(event, newPage) {
// Code goes here
});
});
}
};
})(jQuery);
js/ajax_content_loader.js
By wrapping our event handlers with the .once callback function, we can ensure each event is only attached (and therefore executed) once.
Before moving on, let's complete our beforeAjaxProcess custom event. This is going to be a simple event that manipulates the classes on the content wrapper (this will help us track the state of out AJAX loader). Update the callback function as follows:
// Create a custom event that will trigger additional AJAX processing before loading content.
$(document).on('beforeAjaxProcess', function(event, selector) {
$(selector).addClass('js-ajax-loading');
$(selector).removeClass('js-ajax-loaded');
});
js/ajax_content_loader.js fragment
Step 2 - JavaScript Execution
Now we've got ajax_content_loader.js ready, in order to test as we go along, let's hook it up to the ajax_content_loader.module file. To execute our new JavaScript file, we need to let our module know it exists. We'll do this in the ajax_content_loader_init() function of ajax_content_loader.module.
function ajax_content_loader_init(){
drupal_add_js('misc/jquery.form.js');
drupal_add_library('system','drupal.ajax');
$path = drupal_get_path('module', 'ajax_content_loader');
drupal_add_js($path . '/js/ajax_content_loader.js');
}
ajax_content_loader.module fragment - initial version.
Now that we're adding JavaScript, to prevent possible future conflicts in the admin theme, we can wrap our new code in an if statement to ensure it is only executed on the front-end theme.
function ajax_content_loader_init(){
drupal_add_js('misc/jquery.form.js');
drupal_add_library('system','drupal.ajax');
// If we're not in the admin theme, load JavaScript.
global $theme;
if(variable_get('admin_theme', FALSE) != $theme){
$path = drupal_get_path('module', 'ajax_content_loader');
drupal_add_js($path . '/js/ajax_content_loader.js');
}
}
ajax_content_loader.module fragment - improved version.
Now, whenever our module is enabled, ajax_content_loader.js will be executed.
Step 3 - Trigger Custom Event
In our JavaScript file, we created event handlers to respond to our custom beforeAjaxProcess and afterAjaxProcess events. In order to trigger these events, we'll add ajax_command_invoke() to the ajax_link_response() function in ajax_content_loader.module so that before and after the AJAX commands are complete, our custom events are triggered.
Above the existing $commands[] assignments, add the following:
// Run custom javascript, trigger a custom event and pass parameters.
$commands[] = ajax_command_invoke('html', 'trigger', array('beforeAjaxProcess', $content));
ajax_content_loader.module fragment - ajax_command_invoke().
$contentwill contain the selector of the main content wrapper. Since we're creating an AJAX carousel, we're eventually going to wrap the content in a .main-carousel-wrapper element. Let's create the $content variable, at the start of the ajax_link_response() function, add the following code:
function ajax_link_response($type = 'ajax', $nid = 0){
$content = '.main-carousel-wrapper';
// Remainder of code
ajax_content_loader.module fragment - ajax_link_response() fragment.
Now let's prepare to trigger the second custom event, the one that triggers after the AJAX update.
Under the existing $commands[] assignments, add the following:
// Run custom javascript, trigger a custom event.
$commands[] = ajax_command_invoke('html', 'trigger', array('afterAjaxProcess'));
ajax_content_loader.module fragment - ajax_command_invoke(), initial version.
Here we're using the html element as a selector (we can't use document since there is no document HTML element), calling the jQuery trigger() method and passing an array of parameters to trigger(). This array contains the event type afterAjaxProcess.
ajax_command_invoke() allows us to do just that.
// Run custom javascript, trigger a custom event and pass variables.
$commands[] = ajax_command_invoke('html', 'trigger', array('afterAjaxProcess',
array(
'title' => $node_title,
'selector' => $content,
'url' => $new_url,
)
));
ajax_content_loader.module fragment - ajax_command_invoke(), improved version.
The 'title' property $node_title and 'selector' property $content are reused from earlier, however we haven't created a $new_url variable yet...let's change that.
Under the helper function executions, near the top of ajax_link_response, add the following:
$new_url = _ajax_loader_get_new_url($nid);
Now let's create _ajax_loader_get_new_url(). Under the other helper function declarations, add the following:
function _ajax_loader_get_new_url($nid = 0){
$options = array('absolute' => TRUE);
return url('node/' . $nid, $options);
}
Here we simply use the built-in Drupal function url() in order to return the aliased (pretty) URL of a given 'node/<nid>' URL
Step 4 - Test Custom Event
To test that the title, selector and url parameters are correctly passed along with our afterAjaxProcess event, we can simply alert() or console.log() the newPage variable in our JavaScript event handler (in ajax_content_loader.js).
// Create a custom event that will trigger additional AJAX processing
$(document).on('afterAjaxProcess', function(event, newPage) {
// Code goes here
console.log(newPage.title);
console.log(newPage.selector);
console.log(newPage.url);
});
ajax_content_loader.js fragment - testing.
Step 5 - Non-JavaScript Fallback
The ajax_content_loader.module file is now almost complete, let's finish it off before moving back to our JavaScript file. The only thing remaining is to add fallback content in case JavaScript is disabled on the user's browser. If this is the case, the user will navigate to a URL ending 'nojs/<nid>' (in our example, because the menu callback was created on $items['blog/ajax'], the URL will be '/blog/ajax/nojs/<nid>').
For my particular situation, the fallback content is not too important (I plan to handle non-JavaScript users in a different way) but it's still good practice to include one. In my project, since I don't want users navigating to the '/nojs/<nid>' pages, I intend to create two 'Next' links and two 'Previous' links, one AJAX and one non-AJAX. Only one link will be displayed on screen depending on the whether the user has JavaScript enabled.
To include fallback content, inside the ajax_link_response() function, we simply return the content in the }else{ statement belonging to if($type == 'ajax'){.
}else{
// Else, JavaScript not enabled, return fallback content.
$content_fallback = "Please enable JavaScript to view the page.";
return $content_fallback;
}
ajax_content_loader.module fragment.
If a user does visit a 'nojs/<nid>' page, the layout may appear broken after the page refresh since only the node content is displayed (no page title or additional views depending on how they were added to the theme).
We've finished working with ajax_content_loader.module for now.
AJAX Improvements - Implementation
Step 6 - Update URL & Browser History
Now we've completed ajax_content_loader.module, we can begin addressing the remaining issues solvable via JavaScript.
To recap, these issues are:
- The URL doesn't update when AJAX loads new content.
- Browser history doesn't record dynamically loaded pages.
- Page HTML meta title doesn't update.
- The AJAX link hrefs look ugly on hover (in browsers that display hrefs on hover...like Chrome).
Let's switch back to our JavaScript file and add the code to update the URL. It's good practice to separate code so we'll create a helper function setUrl() that does the actual work.
(function($){
Drupal.behaviors.ajaxContentLoaderInit = {
attach: function (context, settings){
// Add the class 'ajaxloader-processed' to the 'body' element after first invocation
// and prevent further invocations.
$('body', context).once('ajaxloader', function(){
// Create a custom event that will trigger additional AJAX processing
$(document).on('afterAjaxProcess', function(event, newPage) {
// Update URL to new node URL
setUrl(newPage.url);
/**
* Helper function
* Update URL and add new URL to browser history
*/
function setUrl(newUrl){
window.history.pushState({path:newUrl},'',newUrl);
}
});
});
}
};
})(jQuery);
ajax_content_loader.js
window.history.pushState()is supported on all HTML5 compatible browsers (that means IE9 and lower do not support it). More info on the History API introduced with HTML5 can be found on MDN: Manipulating the browser history.
Using pushState() not only updates the URL but also adds an entry into browser history, solving our first two issues...almost.
On testing, you'll notice that after clicking the AJAX link a few times, when you press the browser back (and forward) button, although the page URL updates correctly, the content does not. The page does not refresh to display the correct content.
To correct this, let's add a new Drupal behavior that forces the page to reload when the back/forward buttons are clicked. Place this behaviour under the original ajaxContentLoaderInit behaviour.
/**
* Force a page refresh when browser history is navigated
*/
Drupal.behaviors.ajaxContentLoaderForceRefresh = {
attach: function (context, settings){
$(window, context).on('popstate', function() {
window.location.reload(true);
});
}
};
ajax_content_loader.js fragment
This function uses the HTML5 'popstate' event to check when a history event is triggered from a browser. When such an event is triggered, we force the page to refresh. Now the back/forward buttons should work as expected.
Step 7 - HTML Meta Title
Next, let's tackle the HTML meta title. For this, we'll use plain and simple jQuery string manipulation. My meta-titles are formatted as <page title>|<website> so I want to extract the current title and update everything before the '|' character. Depending on your format, this may vary on your site.
Under the existing setUrl(newPage.url); add the following:
//Update title
setTitleMeta(newPage.title);
ajax_content_loader.js fragment
Yep, another helper function...let's create this function below our other helper function.
/**
* Update HTML Meta title
*/
function setTitleMeta(newTitle){
// Get title and split it based on '|' separator.
var $titleText = $('title').text();
var currentTitle = $titleText.split('|');
//replace old title with new title
$('title').text($titleText.replace(currentTitle[0], newTitle + ' '));
}
ajax_content_loader.js fragment
Step 8 - AJAX Link Appearance
So that's the first three items on the list complete. For the final item, it is more of a preference than an issue. I usually check the URLs of links via the small preview window on Chrome and I hate seeing the '/nojs/<nid>' URL. Furthermore, it may also confuse users (although if a user is savy enough to be checking link hrefs, they may understand the differences between link types).
Currently our static test link looks like this (with a NID of 2):
<a class="use-ajax" href="/blog/ajax/nojs/2">Test AJAX Link</a>
Static test AJAX link.
If we change it to include data attributes containing the aliased URL and the node id, we have the following:
<a class="use-ajax" href="/blog/ajax/nojs/2" data-url="/blog/my-second-article" data-node="2">Test AJAX Link</a>
Static test AJAX link.
Using this structure, we can add JavaScript which on hover, updates the link href to the aliased version. When the user stops hovering over the link, we can replace the href with its original destination.
To assist with this, let's wrap our static link in a list and give the list a class of js-ajax-loader
<ul class="js-ajax-loader">
<li>
<a class="use-ajax" href="/blog/ajax/nojs/2" data-url="/blog/my-second-article" data-node="2">Test AJAX Link</a>
</li>
</ul>
Static test AJAX link.
Now the link is ready, let's add a new behaviour that updates the link on hover:
/**
* On hover, display the aliased href of the AJAX link,
* hide the '.../nojs/...' href
*/
Drupal.behaviors.ajaxContentLoaderAliasedLinks = {
attach: function (context, settings){
var $linkList = $('.js-ajax-loader');
$('.js-ajax-loader', context).once('ajaxloader-links', function(){
$linkList.on({
mouseenter: function () {
// Get URL and replace link href
var aliasedUrl = $(this).attr('data-url');
$(this).attr('href', aliasedUrl);
},
mouseleave: function () {
// Get nid and replace href
var nid = $(this).attr('data-node');
$(this).attr('href', '/blog/ajax/nojs/'+nid);
}
}, 'a');
});
}
};
ajax_content_loader.js fragment - 'ajaxContentLoaderAliasedLinks' behaviour.
Now when we hover over the link, the correct aliased URL is displayed.
Step 9 - Reinitialise state tracking classes
Remember earlier in our beforeAjaxProcess event (the one that gets executed before the AJAX update), we updated the .js-ajax-loaded and .js-ajax-loading classes. Now that we've dealt with the remaining AJAX issues, before ending the AJAX update, let's reset those classes. At the bottom of the afterAjaxProcess callback function, add the following:
$(newPage.selector).removeClass('js-ajax-loading');
$(newPage.selector).addClass('js-ajax-loaded');
ajax_content_loader.js fragment - 'afterAjaxProcess' callback function.
Step 10 - Extra - Non-Javascript Fallback Links
We've almost finished with the Ajax Loader module part of this series, there's only one thing left to add. Remember earlier I said I intended to use two links for every item, an AJAX and non-AJAX link. Let's create static versions of the links so that we can style them appropriately. The AJAX link remains the same but we've added a new non-AJAX link.
<a class="no-js" href="/blog/my-second-article">Test non-AJAX Link</a>
<a class="use-ajax" href="/blog/ajax/nojs/2" data-url="/blog/my-second-article" data-node="2">Test AJAX Link</a>
Static test AJAX links.
Let's add this to our static link list (and also format them as 'Next' and 'Previous' links).
(If you're following along, update the test NIDs and hrefs below to real ones from your site.)
<ul class="js-ajax-loader">
<li class="prev">
<a class="no-js" href="/blog/my-first-article">Previous - Test non-AJAX Link</a>
<a class="use-ajax" href="/blog/ajax/nojs/1" data-url="/blog/my-first-article" data-node="1">Previous - Test AJAX Link</a>
</li>
<li class="next">
<a class="no-js" href="/blog/my-second-article">Next - Test non-AJAX Link</a>
<a class="use-ajax" href="/blog/ajax/nojs/2" data-url="/blog/my-second-article" data-node="2">Next - Test AJAX Link</a>
</li>
</ul>
Static test AJAX links.
Now we just need to:
- Add CSS to hide the AJAX link by default.
- Add JavaScript to display AJAX link and hide non-AJAX link.
a) Let's add CSS to hide the AJAX link. To do this we should add a stylesheet to our module (allowing us to keep the code modular). In the module, create a 'css' directory and inside that, create 'ajax_content_loader.css'.
Now let's register the stylesheet with the module by adding it in the ajax_content_loader.info file.
name = Ajax Content Loader
description = "A custom module which dynamically loads node content."
core = 7.x
package = Website Customisations
stylesheets[all][] = css/ajax_content_loader.css
ajax_content_loader.info.
(Always clear your cache after editing a .info file to ensure the changes are reflected.)
After that, we simply add the CSS to our new ajax_content_loader.css file.
.js-ajax-loader a.use-ajax{
/* Hide AJAX links by default, let JavaScript show them if enabled. */
display:none;
}
ajax_content_loader.css
Now, when you refresh your test page, you'll notice the AJAX links are hidden.
b) Finally, we need to use JavaScript to display the AJAX link and hide the non-AJAX link. The following code will achieve this:
$('.js-ajax-loader a').show();
$('.js-ajax-loader a.no-js').hide();
We'll add a variation of this code to our pre-existing ajaxContentLoaderAliasedLinks behavior inside ajax_content_loader.js. We'll update the selectors to use our $linkList variable.
/**
* On hover, display the aliased href of the AJAX link,
* hide the '.../nojs/...' href
*/
Drupal.behaviors.ajaxloaderAliasedLinks = {
attach: function (context, settings){
var $linkList = $('.js-ajax-loader');
// Display AJAX links, hide non-AJAX links
$linkList.find('a').show();
$linkList.find('a.no-js').hide();
$('.js-ajax-loader', context).once('ajaxloader-links', function(){
$linkList.on({
mouseenter: function () {
// Get URL and replace link href
var aliasedUrl = $(this).attr('data-url');
$(this).attr('href', aliasedUrl);
},
mouseleave: function () {
// Get nid and replace href
var nid = $(this).attr('data-node');
$(this).attr('href', '/blog/ajax/nojs/'+nid);
}
}, 'a');
});
}
};
ajax_content_loader.js fragment - ajaxloaderAliasedLinks behaviour
That's it, we've finished working on ajax_content_loader.js and should now have a fully functioning 'Ajax Content Loader' module.
By creating the 'Ajax Content Loader' module we've added functionality which allows us to dynamically insert content from any node. This is only the first (and by far most complicated) phase of creating the content carousel.
In the next part of this series, we'll look at how to dynamically create the 'Next' and 'Previous' links.