Use SharePoint Callouts to Create an In-page Tour

Web Design & Development

SharePoint 2013’s Callouts are used frequently throughout its UI to display contextual information. They also happen to be a great tool for creating in-page tours to guide users through learning about key features on a page. The first time I encountered one of these “tours” was after a Facebook redesign several years ago. Of course, there are other libraries that exist to create these tours, but I try to keep the UI consistent for users and take advantage of out-of-the-box features whenever possible. Here’s a quick video showing how it works:

https://www.youtube.com/watch?v=aJeIaR4VDT4&rel=0

Defining the Tour Behavior

Tours like this aren’t all the same–some are mandatory, some are really long, some have a ton of information, etc. I wanted to define exactly how the tour would behave before I started building it. Here’s what I came up with:

  • The user should be able to end the tour at any time. Maybe they feel that they are familiar enough with the app to get started without assistance, or maybe they are just super-impatient (I admit that I fall into one of these two categories often). Either way, the tour should not be an obstacle to using the app.
  • Each callout should indicate which step in the process the user is on (e.g. 3 of 5). That provides the user some context and allows him/her to make an informed decision on whether to view all of the steps or close the tour after a certain point.
  • The user should be able to move forward and backward in the tour. Sometimes a user might be too click-happy and realize they missed something and need to go back, or maybe the user will get to a step that he/she already understands and wants to immediately move on. Either way, I don’t want to restrict the user from viewing steps on their own terms.
  • The user should not be able to move forward if on the last step or move backwards if on the first step; there should be some visual indicator that those buttons/links are disabled (or they should be hidden altogether).
  • The page should automatically and smoothly scroll to bring the current step into view. This brings attention to the thing on the page that the tour is trying to highlight and provides some spatial context in relation to the other elements on the page.
  • The tour should be easily modified without code changes; a site owner or member should be able to make changes to the tour without asking for developer support.

Setup and Configuration

To make this work I used jQuery, the jQuery.scrollTo plugin, and a custom list to store the tour step information. jQuery is included in our environment’s master page, so it’s a no-brainer to use for us. The scrollTo plugin makes it simple to animate the page’s scroll position when showing each tour step. Storing the tour step information in a custom list makes it trivial to update the tour steps without having to modify any code (I recently used this along with Knockout as part of a new hire onboarding SPA that provided a tour for first-time users of the app, and all content in the app needed to be editable by the talent management team in HR without having to come to me for code updates).

I added references to the scrollTo plugin .js file and my own callout-tour.js file (I’ll get to that in a moment) in a Script Editor Web Part at the bottom of the page. You could upload those files to the Style Library, Site Assets library, a Document library, the _layouts folder, or wherever; it doesn’t really matter as long as you get them loaded onto the page.

Once the script references were on the page, I created a custom list called “Tour Steps” with the necessary Title, Content, Step, Beak Orientation, and Selector columns (all columns are required). I CamelCased the name of each column when creating it to avoid the “_x0020_” in the internal names so the column references in the REST calls are a little cleaner to read; then I edited the column and added spaces where appropriate so the display name reads better.

Title Column

This is a single line of text column that is used as the title of the callout. You may want to set a low character limit so the title isn’t truncated by the callout’s width.

Content Column

This is a multiple lines of text (Enhanced rich text) column that is used as the content of the callout. This can contain rich text, images, and even embedded video so your tour steps can be more engaging. I created an animated GIF to use in one of the tour steps to illustrate how a menu feature worked. Authors can use the familiar SharePoint ribbon to edit the content without having to understand HTML.

Step Column

This is a number column that determines which step in the tour the item should be used for. I also checked the “Ensure unique values” option (so there are no step conflicts) and set the decimal places to “0.”

Beak Orientation Column

This is a choice column with two choices–topBottom and leftRight–that correspond to the callout’s beakOrientation option. I chose to use radio buttons (don’t use checkboxes) and set “topBottom” as a default value, but you could leave the default value blank if you want to force authors to choose an orientation that works best for the tour step.

Selector Column

This is a single line of text column that is used as a jQuery selector to get the DOM element to use for the callout’s launchPoint. Authors will need to understand how jQuery selectors work (which is essentially how CSS selectors work) but I couldn’t think of a better/friendlier way to determine the launchPoint for each tour step. The value of this column will be used as a jQuery selector. So if the value for the column is “#sideNavBox” then the jQuery selector would be $('#sideNavBox'). This allows for a good deal of flexibility thanks to jQuery’s Sizzle selector engine. If you want to highlight individual web parts, you could try using the web part ID or the web part title as a selector; this worked OK for me as long as the web part title doesn’t change: .ms-webpart-chrome-title > span[title="Getting Started with your Team site"]. I also checked the “Ensure unique values” option (so there are no callout launchPoint conflicts).

Create the Tour Steps

Before you can see if the solution is working, you’ll need to add a few tour steps. Here’s what I came up with for the demo video:

Callout Tour Steps

The JavaScript

The code below is commented to explain what each function is doing. At a high level, the JavaScript performs the following steps:

  1. Queries the “Tour Steps” list via the REST API
  2. Gets the DOM element for each launchPoint using the “Selector” field’s value, and verifies that the launchPoint exists on the page (or skips over the step if it doesn’t)
  3. Creates each callout using the column values from the list items
  4. Creates the “Previous,” “Next,” and “End Tour” actions for each callout
  5. Shows the first callout
/**
 * Create an in-page tour using SharePoint callouts.
 *
 * @author  Josh McCarty
 */

;(function($) {

    var self = this;

    self.options = {
        scrollTime: 500, // Milliseconds for scroll animation
        scrollOffset: -150 // Leave 150px of space above the element that is scrolled to so it isn't right at the top of the browser
    };

    // Create an array to store the tour step information
    var tourSteps = [];

    /**
     * Finds the element on the page to use as the launchPoint for the callout
     * @param  {String} selector The jQuery selector to use to select the DOM element
     * @return {Object}          The DOM element that will be used as the callout's launchPoint
     */
    self.getLaunchPoint = function(selector) {
        return $(selector).first()[0];
    };

    /**
     * Queries the Tour Steps list to retrieve data about all of the tour steps
     * @return {Object} jQuery jqXHR object with the item data in JSON format
     */
    self.getTourSteps = function() {
        return $.ajax({
            url: _spPageContextInfo.webServerRelativeUrl + "/_api/web/lists/getbytitle('Tour Steps')/items?$select=Title,ID,Content,Selector,Step,BeakOrientation&$orderby=Step asc",
            method: "GET",
            headers: { "accept": "application/json; odata=verbose" }
        });
    };

    /**
     * Remove all tour callouts on the page
     */
    self.endTour = function() {
        $.each(tourSteps, function(index, step) {
            var callout = CalloutManager.getFromLaunchPointIfExists(step.launchPoint);
            CalloutManager.remove(callout);
        });
    };

    /**
     * Displays the callout
     * @param  {Number} index The index of the step to show from the `tourSteps` array
     */
    self.showCallout = function(index) {

        // Don't show callouts if there aren't any tour steps
        if (tourSteps.length === 0) {
            return;
        }

        var step = tourSteps[index];

        // If the launchPoint is inside #s4-workspace, scroll to it
        if ($(step.launchPoint).closest('#s4-workspace').length !== 0) {
            $('#s4-workspace').scrollTo(step.launchPoint, self.options.scrollTime, {
                offset: self.options.scrollOffset
            });
        }

        // Otherwise scroll to the top of the page (because the launchPoint should be in the ribbon)
        else {
            $('#s4-workspace').animate({
                scrollTop: 0
            }, self.options.scrollTime);
        }

        // TODO: Find a way to reliably open subsequent callouts without setTimout
        // Show the callout after scrolling has finished
        setTimeout(function() {
            CalloutManager.getFromLaunchPointIfExists(step.launchPoint).open();
        }, self.options.scrollTime);
    };

    /**
     * Creates the callout
     * @param  {Number} index The index of the step to create from the `tourSteps` array
     */
    self.createCallout = function(index) {

        // Get the step to create the callout for from the tourSteps array
        var step = tourSteps[index];

        // Create the callout options
        var options = {
            ID: "callout-tour=" + step.ID,
            title: step.Title + " <span class='ms-core-defaultFont ms-soften'>(" + (index+1) + " of " + tourSteps.length + ")</span>", // Show current step of total steps in title
            launchPoint: step.launchPoint,
            content: step.Content,
            contentWidth: 600, // Set this to your desired width; you could also make this a column in the Tour Steps list
            beakOrientation: step.BeakOrientation,
            openOptions: {
                showCloseButton: false, // This is important so we can control when/how the tour ends using callout actions
                event: "none"
            }
        };

        // Create the callout (note that this does not actually show the callout)
        var callout = CalloutManager.createNew(options);

        // Create the callout action to go to the previous step in the tour
        var previousStep = new CalloutAction({
            text: "Previous",
            onClickCallback: function() {

                // Close the current callout
                callout.close();

                // Show the previous callout
                self.showCallout(index - 1);
            },
            isEnabledCallback: function() {

                // Don't enable previousStep for the first step in the tour
                return index !== 0;
            }
        });
        callout.addAction(previousStep);

        // Create the callout action to go to the next step in the tour
        var nextStep = new CalloutAction({
            text: "Next",
            onClickCallback: function() {

                // Close the current callout
                callout.close();

                // Show the next callout
                self.showCallout(index + 1);
            },
            isEnabledCallback: function() {

                // Don't enable nextStep for the last step in the tour
                return index !== tourSteps.length - 1;
            }
        });
        callout.addAction(nextStep);

        // Create the callout action to end the tour
        var endTour = new CalloutAction({
            text: "End Tour",
            onClickCallback: function() {

                // Removes all callouts from the page
                self.endTour();
            }
        });
        callout.addAction(endTour);
    };

    /**
     * Load the tour steps, create the callouts, and start the tour
     */
    self.init = function() {
        self.getTourSteps().then(function(data) {

            // Add each step to the `tourSteps` array
            $.each(data.d.results, function(index, step) {

                // Set the launchpoint for the step
                step.launchPoint = self.getLaunchPoint(step.Selector);
                if (step.launchPoint) {
                    tourSteps.push(step);
                }
            });

            // Once the `tourSteps` array is created, create each of the callouts.
            // The `tourSteps` array must be created first so that when creating each
            // callout we know the total # of callouts and can reference the
            // next/previous callout for the callout actions
            $.each(tourSteps, function(index) {
                self.createCallout(index);
            });

            // Show the first callout
            self.showCallout(0);
        });
    };

    SP.SOD.loadMultiple(['strings.js', 'sp.js', 'callout.js'], self.init);

})(jQuery);

When to Display the Tour

In the app I used this for, I’m already tracking every user’s progress in another custom list, so I just included a Yes/No column in that list to indicate whether they’ve seen the tour yet. When the tour is ended, I make a REST call to update that column value to “Yes” for the current user. This is convenient because whenever a user loads the page I have to do a bunch of other status checks for them anyway, so I’m not making any extra AJAX requests to see if I need to display the tour or not.

However, you may want to use this on an intranet portal home page or other high-traffic page when you redesign or roll out a new feature. In that case you probably aren’t already tracking every user’s interaction on the page, so using a custom list to keep track of whether they’ve viewed the tour already might be overkill (especially for a large company with tens of thousands of intranet users). An alternative could be to set a cookie or save a value in local storage once the tour is viewed; on future visits you could check whether the cookie/storage value is set . This option works fine unless the user clears cookies/storage or uses another browser. That risk could be mitigated if you only display the tour for a certain period of time–say for one week after launching a new feature. Or you might decide that the tour is manually triggered by a button and it’s up to the user to decide to view it. There are a lot of other ways you can create the logic to trigger the tour; you’ll have to decide what works best for your situation.


Comments

Comments are closed