Wordpress - Create WP tutorial for users with admin pointer using next button for navigation

You are calling .pointer( 'open' ); javascript function on all the pointers objects, so is not a surprise that all pointers appear on same time...

That said, I don't understand why do you returns all pointers (even non-active ones) from custom_admin_pointers() and then add an additional function to check if there are some active pointers and a check inside pointers loop (if ( $array['active'] ) {) to choose to add javascript pointer or not. Is not simpler just returning only active pointers?

Moreover, you are adding that javascript on all admin pages, is not too much? Also consider that some elements like "#save-post" are available only on new post page, so is not better add the pointers only in new pot page?

Finally, how messy are that javascript mixed up with PHP, I think you should consider to use wp_localize_script to pass data to javascript.

The plan:

  1. Move the pointers definitions in PHP to a separate file, in this way it's easy to edit and also removing markup from PHP code, everything result more readable and maintenable
  2. In the pointers configuration add a property "where" that will be used to set in which admin page a popup should appear: post-new.php, index.php...
  3. Write a class that will handle the loading, the parsing and the filtering of pointers info
  4. Write some js goodness that will help us to change the default "Remove" button to "Next"

The #4 can (probably) done easily knowing the pointer plugin well, but it's not my case. So I'll use general jQuery code to obtain the result, if someone can improve my code I'll appreciate.


Edit

I edited the code (mainly js) because there are different things I had not considered: some pointers may be added to same anchor, or same pointers can be added to non-existing or non-visible anchors. In all that case previous code did'nt work, the new version seems to address that issues nicely.

I've also setup a Gist with all the code I used to test.


Let's start with points #1 and #2: create a file named pointers.php and write there:

<?php
$pointers = array();

$pointers['new-items'] = array(
  'title'     => sprintf( '<h3>%s</h3>', esc_html__( 'Add New Item' ) ),
  'content'   => sprintf( '<p>%s</p>', esc_html__( 'Easily add a new post..' ) ),
  'anchor_id' => '#wp-admin-bar-new-content',
  'edge'      => 'top',
  'align'     => 'left',
  'where'     => array( 'index.php', 'post-new.php' ) // <-- Please note this
);

$pointers['story_cover_help'] = array(
  'title'     => sprintf( '<h3>%s</h3>', esc_html__( 'Another info' ) ),
  'content'   => sprintf( '<p>%s</p>', esc_html__( 'Lore ipsum....' ) ),
  'anchor_id' => '#save-post',
  'edge'      => 'top',
  'align'     => 'right',
  'where'     => array( 'post-new.php' ) // <-- Please note this
);

// more pointers here...

return $pointers; 

All pointers configuration is here. When you need to change something, just open this file and edit it.

Note the "where" property that is an array of pages where the pointer should be available.

If you want to display pointers in a page generated by a plugin, look for this line outlined below public function filter( $page ) { and add die($page); immediately underneath it. Then open the respective plugin page and use that string in the where property.

Ok, now the point #3.

Before writing the class I just want to code an interface: there I'll put comments so that you can better understand what class will do.

<?php
interface PointersManagerInterface {

  /**
  * Load pointers from file and setup id with prefix and version.
  * Cast pointers to objects.
  */
  public function parse();
  
  /**
  * Remove from parse pointers dismissed ones and pointers
  * that should not be shown on given page
  *
  * @param string $page Current admin page file
  */
  public function filter( $page );

}

I think should be pretty clear. Now let's write the class, it will contain the 2 methods from interface plus the constructor.

<?php namespace GM;
 
class PointersManager implements PointersManagerInterface {
 
  private $pfile;
  private $version;
  private $prefix;
  private $pointers = array();
 
  public function __construct( $file, $version, $prefix ) {
    $this->pfile = file_exists( $file ) ? $file : FALSE;
    $this->version = str_replace( '.', '_', $version );
    $this->prefix = $prefix;
  }
 
  public function parse() {
    if ( empty( $this->pfile ) ) return;
    $pointers = (array) require_once $this->pfile;
    if ( empty($pointers) ) return;
    foreach ( $pointers as $i => $pointer ) {
      $pointer['id'] = "{$this->prefix}{$this->version}_{$i}";
      $this->pointers[$pointer['id']] = (object) $pointer;
    }
  }
 
  public function filter( $page ) {
    if ( empty( $this->pointers ) ) return array();
    $uid = get_current_user_id();
    $no = explode( ',', (string) get_user_meta( $uid, 'dismissed_wp_pointers', TRUE ) );
    $active_ids = array_diff( array_keys( $this->pointers ), $no );
    $good = array();
    foreach( $this->pointers as $i => $pointer ) {
      if (
        in_array( $i, $active_ids, TRUE ) // is active
        && isset( $pointer->where ) // has where
        && in_array( $page, (array) $pointer->where, TRUE ) // current page is in where
      ) {
       $good[] = $pointer;
      }
    }
    $count = count( $good );
    if ( $good === 0 ) return array();
    foreach( array_values( $good ) as $i => $pointer ) {
      $good[$i]->next = $i+1 < $count ? $good[$i+1]->id : '';
    }
    return $good;
  }
}

Code is very simple, and does exactly what interface expects.

However, the class does nothing by itself, we need an hook where to instantiate the class nad launch the 2 methods passing proper arguments.

The 'admin_enqueue_scripts' is perfect for our scope: there we will have access to current admin page and we can also enqueue scripts and styles needed.

add_action( 'admin_enqueue_scripts', function( $page ) {
  $file = plugin_dir_path( __FILE__ ) . 'pointers.php';
  // Arguments: pointers php file, version (dots will be replaced), prefix
  $manager = new PointersManager( $file, '5.0', 'custom_admin_pointers' );
  $manager->parse();
  $pointers = $manager->filter( $page );
  if ( empty( $pointers ) ) { // nothing to do if no pointers pass the filter
    return;
  }
  wp_enqueue_style( 'wp-pointer' );
  $js_url = plugins_url( 'pointers.js', __FILE__ );
  wp_enqueue_script( 'custom_admin_pointers', $js_url, array('wp-pointer'), NULL, TRUE );
  // data to pass to javascript
  $data = array(
    'next_label' => __( 'Next' ),
    'close_label' => __('Close'),
    'pointers' => $pointers
  );
  wp_localize_script( 'custom_admin_pointers', 'MyAdminPointers', $data );
} );

Nothing special: just using the class to get pointers data and if some pointers pass the filters enqueue styles and scripts. Then pass pointers data to script along to the localized "Next" label for the button.

Ok, now the "hardest" part: the js. Again I want to highlight that I don't know the pointer plugin WordPress uses, so what I do in my code can be done better if someone knows it, however my code do its work and -genarally speaking- it's not so bad.

( function($, MAP) {

  $(document).on( 'MyAdminPointers.setup_done', function( e, data ) {
    e.stopImmediatePropagation();
    MAP.setPlugin( data ); // open first popup
  } );
  
  $(document).on( 'MyAdminPointers.current_ready', function( e ) {
    e.stopImmediatePropagation();
    MAP.openPointer(); // open a popup
  } );
  
  MAP.js_pointers = {};        // contain js-parsed pointer objects
  MAP.first_pointer = false;   // contain first pointer anchor jQuery object
  MAP.current_pointer = false; // contain current pointer jQuery object
  MAP.last_pointer = false;    // contain last pointer jQuery object
  MAP.visible_pointers = [];   // contain ids of pointers whose anchors are visible
  
  MAP.hasNext = function( data ) { // check if a given pointer has valid next property
    return typeof data.next === 'string'
      && data.next !== ''
      && typeof MAP.js_pointers[data.next].data !== 'undefined'
      && typeof MAP.js_pointers[data.next].data.id === 'string';
  };
  
  MAP.isVisible = function( data ) { // check if anchor for given pointer is visible
    return $.inArray( data.id, MAP.visible_pointers ) !== -1;
  };
  
  // given a pointer object, return its the anchor jQuery object if available
  // otherwise return first available, lookin at next property of subsequent pointers
  MAP.getPointerData = function( data ) { 
    var $target = $( data.anchor_id );
    if ( $.inArray(data.id, MAP.visible_pointers) !== -1 ) {
      return { target: $target, data: data };
    }
    $target = false;
    while( MAP.hasNext( data ) && ! MAP.isVisible( data ) ) {
      data = MAP.js_pointers[data.next].data;
      if ( MAP.isVisible( data ) ) {
        $target = $(data.anchor_id);
      }
    }
    return MAP.isVisible( data )
      ? { target: $target, data: data }
      : { target: false, data: false };
  };
  
  // take pointer data and setup pointer plugin for anchor element
  MAP.setPlugin = function( data ) {
    if ( typeof MAP.last_pointer === 'object') {
      MAP.last_pointer.pointer('destroy');
      MAP.last_pointer = false;
    }
    MAP.current_pointer = false;
    var pointer_data = MAP.getPointerData( data );
      if ( ! pointer_data.target || ! pointer_data.data ) {
      return;
    }
    $target = pointer_data.target;
    data = pointer_data.data;
    $pointer = $target.pointer({
      content: data.title + data.content,
      position: { edge: data.edge, align: data.align },
      close: function() {
        // open next pointer if it exists
        if ( MAP.hasNext( data ) ) {
          MAP.setPlugin( MAP.js_pointers[data.next].data );
        }
        $.post( ajaxurl, { pointer: data.id, action: 'dismiss-wp-pointer' } );
      }
    });
    MAP.current_pointer = { pointer: $pointer, data: data };
    $(document).trigger( 'MyAdminPointers.current_ready' );
  };
  
  // scroll the page to current pointer then open it
  MAP.openPointer = function() {          
    var $pointer = MAP.current_pointer.pointer;
    if ( ! typeof $pointer === 'object' ) {
      return;
    }
    $('html, body').animate({ // scroll page to pointer
      scrollTop: $pointer.offset().top - 30
    }, 300, function() { // when scroll complete
      MAP.last_pointer = $pointer;
        var $widget = $pointer.pointer('widget');
        MAP.setNext( $widget, MAP.current_pointer.data );
        $pointer.pointer( 'open' ); // open
    });
  };
  
  // if there is a next pointer set button label to "Next", to "Close" otherwise
  MAP.setNext = function( $widget, data ) {
    if ( typeof $widget === 'object' ) {
      var $buttons = $widget.find('.wp-pointer-buttons').eq(0);        
      var $close = $buttons.find('a.close').eq(0);
      $button = $close.clone(true, true).removeClass('close');
      $buttons.find('a.close').remove();
      $button.addClass('button').addClass('button-primary');
      has_next = false;
      if ( MAP.hasNext( data ) ) {
        has_next_data = MAP.getPointerData(MAP.js_pointers[data.next].data);
        has_next = has_next_data.target && has_next_data.data;
      }
      var label = has_next ? MAP.next_label : MAP.close_label;
      $button.html(label).appendTo($buttons);
    }
  };
  
  $(MAP.pointers).each(function(index, pointer) { // loop pointers data
    if( ! $().pointer ) return;      // do nothing if pointer plugin isn't available
    MAP.js_pointers[pointer.id] = { data: pointer };
    var $target = $(pointer.anchor_id);
    if ( $target.length && $target.is(':visible') ) { // anchor exists and is visible?
      MAP.visible_pointers.push(pointer.id);
      if ( ! MAP.first_pointer ) {
        MAP.first_pointer = pointer;
      }
    }
    if ( index === ( MAP.pointers.length - 1 ) && MAP.first_pointer ) {
      $(document).trigger( 'MyAdminPointers.setup_done', MAP.first_pointer );
    }
  });

} )(jQuery, MyAdminPointers); // MyAdminPointers is passed by `wp_localize_script`

With help of comments the code should be pretty clear, at least, I hope so.

Ok we are done. Our PHP is simpler and better organized, our javascript more readable, pointers are easier to edit and, more important, everything works.


Ahhh.. yes. WordPress pointers. You know, there are quite a lot of mixed feelings when it comes to using pointers ;)

You were on the right track with your code above. But there are a couple issues.

@G.M. is correct about the pointer('open') command opening all your pointers at once. Furthermore, you are not providing a method to advance through pointers.

I fought this same issue.. and came up with my own approach. I use a query variable in the url, reload the page to the admin page where I want to display the next pointer, and let jQuery handle the rest.

WP Pointers Class

I decided to write this as a class. But I'm going to show it in increments at first to help you better understand what is happening.

Beginning the Class

// Create as a class
class testWPpointers {

    // Define pointer version
    const DISPLAY_VERSION = 'v1.0';

    // Initiate construct
    function __construct () {
        add_action('admin_enqueue_scripts', array($this, 'admin_enqueue_scripts'));  // Hook to admin_enqueue_scripts
    }

    function admin_enqueue_scripts () {
    
        // Check to see if user has already dismissed the pointer tour
        $dismissed = explode (',', get_user_meta (wp_get_current_user ()->ID, 'dismissed_wp_pointers', true));
        $do_tour = !in_array ('test_wp_pointer', $dismissed);
    
        // If not, we are good to continue
        if ($do_tour) {
        
            // Enqueue necessary WP scripts and styles
            wp_enqueue_style ('wp-pointer');
            wp_enqueue_script ('wp-pointer');
        
            // Finish hooking to WP admin areas
            add_action('admin_print_footer_scripts', array($this, 'admin_print_footer_scripts'));  // Hook to admin footer scripts
            add_action('admin_head', array($this, 'admin_head'));  // Hook to admin head
        }
    }

    // Used to add spacing between the two buttons in the pointer overlay window.
    function admin_head () {
        ?>
        <style type="text/css" media="screen">
            #pointer-primary {
                margin: 0 5px 0 0;
            }
        </style>
        <?php
    }
  1. We have defined the class.
  2. We constructed the class, and added an action to admin_enqueue_scripts.
  3. We determined if our pointers have already been dismissed.
  4. If not, we continue to enqueue the necessary scripts.

You do NOT need to change anything in these first functions.

Setting up the array of Pointer Items

The next step is defining each of the pointers. There are five items we need to define (excpept for the last pointer). We will do this using arrays. Let's take a look at the function:

// Define footer scripts
function admin_print_footer_scripts () {
    
    // Define global variables
    global $pagenow;
    global $current_user;
    
    //*****************************************************************************************************
    // This is our array of individual pointers.
    // -- The array key should be unique.  It is what will be used to 'advance' to the next pointer.
    // -- The 'id' should correspond to an html element id on the page.
    // -- The 'content' will be displayed inside the pointer overlay window.
    // -- The 'button2' is the text to show for the 'action' button in the pointer overlay window.
    // -- The 'function' is the method used to reload the window (or relocate to a new window).
    //    This also creates a query variable to add to the end of the url.
    //    The query variable is used to determine which pointer to display.
    //*****************************************************************************************************
    $tour = array (
        'quick_press' => array (
            'id' => '#dashboard_quick_press',
            'content' => '<h3>' . __('Congratulations!', 'test_lang') . '</h3>'
                . '<p><strong>' . __('WP Pointers is working properly.', 'test_lang') . '</strong></p>'
                . '<p>' . __('This pointer is attached to the "Quick Draft" admin widget.', 'test_lang') . '</p>'
                . '<p>' . __('Our next pointer will take us to the "Settings" admin menu.', 'test_lang') . '</p>',
            'button2' => __('Next', 'test_lang'),
            'function' => 'window.location="' . $this->get_admin_url('options-general.php', 'site_title') . '"'  // We are relocating to "Settings" page with the 'site_title' query var
            ),
        'site_title' => array (
            'id' => '#blogname',
            'content' => '<h3>' . __('Moving along to Site Title.', 'test_lang') . '</h3>'
            . '<p><strong>' . __('Another WP Pointer.', 'test_lang') . '</strong></p>'
            . '<p>' . __('This pointer is attached to the "Blog Title" input field.', 'test_lang') . '</p>',
            'button2' => __('Next', 'test_lang'),
            'function' => 'window.location="' . $this->get_admin_url('index.php', 'quick_press_last') . '"'  // We are relocating back to "Dashboard" with 'quick_press_last' query var
            ),
        'quick_press_last' => array (
            'id' => '#dashboard_quick_press',
            'content' => '<h3>' . __('This concludes our WP Pointers tour.', 'test_lang') . '</h3>'
            . '<p><strong>' . __('Last WP Pointer.', 'test_lang') . '</strong></p>'
            . '<p>' . __('When closing the pointer tour; it will be saved in the users custom meta.  The tour will NOT be shown to that user again.', 'test_lang') . '</p>'
            )
        );
    
    // Determine which tab is set in the query variable
    $tab = isset($_GET['tab']) ? $_GET['tab'] : '';
    // Define other variables
    $function = '';
    $button2 = '';
    $options = array ();
    $show_pointer = false;

    // *******************************************************************************************************
    // This will be the first pointer shown to the user.
    // If no query variable is set in the url.. then the 'tab' cannot be determined... and we start with this pointer.
    // *******************************************************************************************************
    if (!array_key_exists($tab, $tour)) {
        
        $show_pointer = true;
        $file_error = true;
        
        $id = '#dashboard_right_now';  // Define ID used on page html element where we want to display pointer
        $content = '<h3>' . sprintf (__('Test WP Pointers %s', 'test_lang'), self::DISPLAY_VERSION) . '</h3>';
        $content .= __('<p>Welcome to Test WP Pointers admin tour!</p>', 'test_lang');
        $content .= __('<p>This pointer is attached to the "At a Glance" dashboard widget.</p>', 'test_lang');
        $content .= '<p>' . __('Click the <em>Begin Tour</em> button to get started.', 'test_lang' ) . '</p>';

        $options = array (
            'content' => $content,
            'position' => array ('edge' => 'top', 'align' => 'left')
            );
        $button2 = __('Begin Tour', 'test_lang' );
        $function = 'document.location="' . $this->get_admin_url('index.php', 'quick_press') . '";';
    }
    // Else if the 'tab' is set in the query variable.. then we can determine which pointer to display
    else {
        
        if ($tab != '' && in_array ($tab, array_keys ($tour))) {
            
            $show_pointer = true;
            
            if (isset ($tour[$tab]['id'])) {
                $id = $tour[$tab]['id'];
            }
            
            $options = array (
                'content' => $tour[$tab]['content'],
                'position' => array ('edge' => 'top', 'align' => 'left')
            );
                
            $button2 = false;
            $function = '';
            
            if (isset ($tour[$tab]['button2'])) {
                $button2 = $tour[$tab]['button2'];
            }
            if (isset ($tour[$tab]['function'])) {
                $function = $tour[$tab]['function'];
            }
        }
    }
    
    // If we are showing a pointer... let's load the jQuery.
    if ($show_pointer) {
        $this->make_pointer_script ($id, $options, __('Close', 'test_lang'), $button2, $function);
    }
}

Okay.. let's take a look at a few things here.

First, our $tour array. This is the array that holds all the pointers EXCEPT the first pointer that is displayed to the user (more on this later). So, you'll want to start with the second pointer you intend to show.. and continue through to the last pointer.

Next, we have a few items which are very important.

  1. The $tour array keys must be unique (quick_press, site_title, quick_press_last; as examples above).
  2. The 'id' command MUST match the html element id of the item you wish to attach to the pointer.
  3. The function command will reload/relocate the window. This is what is used to show the next pointer. We have to either reload the window, or relocate it to the next admin page where a pointer will be displayed.
  4. We run the get_admin_url() function with two variables; the first is the admin page where we want to go next; and the second is the unique array key of the pointer we wish to display.

Further down, you'll see the code that begins if (!array_key_exists($tab, $tour)) {. This is where we determine if a url query variable has been set. If it has NOT, then we need to define the first pointer to display.

This pointer uses the exact same id, content, button2, and function items as used in our $tour array above. Remember, the second argument of the get_admin_url() function MUST be the exact same as the array key in the $tour variable. This is what tells the script to go to the next pointer.

The rest of the function is used if a query variable is already set in the url. There is no need to adjust any more of the function.

Getting Admin Url The next function is actually a helper function... used to get the admin url and advance the pointer.

// This function is used to reload the admin page.
// -- $page = the admin page we are passing (index.php or options-general.php)
// -- $tab = the NEXT pointer array key we want to display
function get_admin_url($page, $tab) {
    
    $url = admin_url();
    $url .= $page.'?tab='.$tab;

    return $url;
}

Remember, there are two arguments; the admin page we are going to.. and the tab. The tab will be the $tour array key we want to go to next. THESE MUST MATCH.

So, when we call the function get_admin_url() and pass the two variables; the first variable determines the next admin page.. and the second variable determines which pointer to display.

Lastly... we can finally print the admin script to the footer.

// Print footer scripts
function make_pointer_script ($id, $options, $button1, $button2=false, $function='') {
    
    ?>
    <script type="text/javascript">
        
        (function ($) {
            
            // Define pointer options
            var wp_pointers_tour_opts = <?php echo json_encode ($options); ?>, setup;
        
            wp_pointers_tour_opts = $.extend (wp_pointers_tour_opts, {
                
                // Add 'Close' button
                buttons: function (event, t) {
                    
                    button = jQuery ('<a id="pointer-close" class="button-secondary">' + '<?php echo $button1; ?>' + '</a>');
                    button.bind ('click.pointer', function () {
                        t.element.pointer ('close');
                    });
                    return button;
                },
                close: function () {
                    
                    // Post to admin ajax to disable pointers when user clicks "Close"
                    $.post (ajaxurl, {
                        pointer: 'test_wp_pointer',
                        action: 'dismiss-wp-pointer'
                    });
                }
            });
            
            // This is used for our "button2" value above (advances the pointers)
            setup = function () {
                
                $('<?php echo $id; ?>').pointer(wp_pointers_tour_opts).pointer('open');
                
                <?php if ($button2) { ?>
                    
                    jQuery ('#pointer-close').after ('<a id="pointer-primary" class="button-primary">' + '<?php echo $button2; ?>' + '</a>');
                    jQuery ('#pointer-primary').click (function () {
                        <?php echo $function; ?>  // Execute button2 function
                    });
                    jQuery ('#pointer-close').click (function () {
                        
                        // Post to admin ajax to disable pointers when user clicks "Close"
                        $.post (ajaxurl, {
                            pointer: 'test_wp_pointer',
                            action: 'dismiss-wp-pointer'
                        });
                    })
                <?php } ?>
            };
        
            if (wp_pointers_tour_opts.position && wp_pointers_tour_opts.position.defer_loading) {
                
                $(window).bind('load.wp-pointers', setup);
            }
            else {
                setup ();
            }
        }) (jQuery);
    </script>
    <?php
}
} 
$testWPpointers = new testWPpointers();

Again, there is no need to change anything above. This script will define and output the two buttons in the pointer overlay window. One will always be the "Close" button; and will update the current user meta dismissed_pointers option.

The second button (the action button) will execute the function (our window relocation method).

And we close the class.

Here is the code in it's entirety. WP Pointer Class

You can copy/paste that into your dev site and visit the "Dashboard" page. It will guide you through the tour.

Remember, it's a little confusing that the first pointer is defined last in the code. That is the way it is supposed to work. The array will hold all the rest of the pointers you wish to use.

Remember, the 'id' array item MUST match the second argument of the get_admin_url() function from the previous array item 'function' command. This is how the pointers 'talk' to each other and know how to advance.

Enjoy!! :)

Tags:

Wp Admin