Advertisement

Add Post Type Archive Links to Your Menu

by

A common request, particularly for those who have created custom post types like 'News' or 'Events', is to add a link to their post type's archive page on their navigation menu. Currently, however, this can only be done by manually entering the post type archive URL. Apart from being fairly inelegant, this solution has a few drawbacks: it doesn't always appear as 'current', if you change your permalink structure it could break the link, manually adding the URLs is tedious and the link does not appear as 'current' when on a post of that post type.

In this tutorial I will show you how to produce a plugin that creates a meta-box on your Appearance -> Menu page which allows you to add post type archive links. These links don't suffer from the drawbacks mentioned above.


Step 1 Creating a Plugin

This plugin will be called 'My Post Type Archive Links', and to that end first create a folder called my-post-type-archive-links under your /wp-content/plugins/ folder, and inside that create a file my-post-type-archive-links.php. This file is the main plugin file. We're going to wrap it in a class – this is simply so we don't have to worry about our function names clashing with WordPress or other plugins: we simply need to make sure that our class name is unique. Add the following to my-post-type-archive-links.php

<?php
/*
Plugin Name: My Post Type Archive Links
Version: 1.0
Description: Adds a metabox to the Appearance -> Menu page to add post type archive links
Author: Stephen Harris
Author URI: http://profiles.wordpress.org/users/stephenh1988/
*/

class My_Post_Type_Archive_Link {
	//Everything will go here
}
My_Post_Type_Archive_Link::load();
?>

Everything in this tutorial will sit inside that class.


Step 2 Loading the Plugin

When the plugin file is loaded, it will fire the class method load(). This method will be responsible for adding actions and filters onto various WordPress hooks. We'll go through each of them in the subsequent steps, but it also provides a useful summary. Add the following method to our class:

	public function load(){
		// Hook function to add the metabox to the Menu page
		add_action( 'admin_init', array(__CLASS__,'add_meta_box'));

		// Javascript for the meta box
		add_action( 'admin_enqueue_scripts', array(__CLASS__,'metabox_script') );

		// Ajax callback to create menu item and add it to menu
		add_action('wp_ajax_my-add-post-type-archive-links', array( __CLASS__, 'ajax_add_post_type'));

		// Assign menu item the appropriate url
		add_filter( 'wp_setup_nav_menu_item',  array(__CLASS__,'setup_archive_item') );

		// Make post type archive link 'current'
		add_filter( 'wp_nav_menu_objects', array(__CLASS__,'maybe_make_current'));
	}

Let's summarise what each of these parts are doing:

  1. Add a meta-box – Fairly self explanatory. The hooked function will be responsible for adding our meta box.
  2. Enqueue JavaScript – We use the admin_enqueue_scripts hook to enqueue our JavaScript file. Our JavaScript, when 'add to menu' is clicked, will trigger an AJAX request.
  3. AJAX callback – This function is responsible for handling the above AJAX request. It will create the menu items and add them onto the menu.
  4. Menu item set up – This ensures that when the archive link appears on your menu it correctly points to the post type's archive.
  5. Maybe make current – Whenever a menu appears its items are passed through a filter, we will ensure that the class 'current-menu-item' is added to the appropriate post type link.

Step 3 Adding the Metabox

First we define our add_meta_box method, which simply calls the WordPress function add_meta_box(). The details of this function have been covered many times before, but if you are unsure you can read up on it in the Codex pages.

	public function add_meta_box() {
		add_meta_box( 'post-type-archives', __('Post Types','my-post-type-archive-links'),array(__CLASS__,'metabox'),'nav-menus' ,'side','low');
	}

Next we define the meta-box callback function which is responsible for displaying the metabox's insides:

	public function metabox( ) {
		global $nav_menu_selected_id;

		//Get post types
		$post_types = get_post_types(array('public'=>true,'_builtin'=>false), 'object');?>

		<!-- Post type checkbox list -->
		<ul id="post-type-archive-checklist">
		<?php foreach ($post_types as $type):?>
			<li><label><input type="checkbox" value ="<?php echo esc_attr($type->name); ?>" /> <?php echo esc_attr($type->labels->name); ?> </label></li>
		<?php endforeach;?>
		</ul><!-- /#post-type-archive-checklist -->

		<!-- 'Add to Menu' button -->
		<p class="button-controls" >
			<span class="add-to-menu" >
				<input type="submit" id="submit-post-type-archives" <?php disabled( $nav_menu_selected_id, 0 ); ?> value="<?php esc_attr_e('Add to Menu'); ?>" name="add-post-type-menu-item"  class="button-secondary submit-add-to-menu" />
			</span>
		</p>
	<?php
	}

This method simply gets all public custom post types with get_post_types() and then loops through them to create a list of checkboxes. Each checkbox has the name of the post type as its value. In the next step we add some javascript that will be triggered when a user clicks the 'Add to Menu' button.


Step 4 The JavaScript

We only want to enqueue our JavaScript on the Appearance -> Menu admin page. We've used the admin_enqueue_scripts hook which fires only on admin pages and passes the page's hook as an argument. The hook for the Appearance -> Menu page is nav-menus.php. After enqueueing our script we use wp_localize_script to make the nonce available in our JavaScript. We include it in the AJAX request to help verify that the action was intended.

	public function metabox_script($hook) {
		if( 'nav-menus.php' != $hook )
			return;

		//On Appearance>Menu page, enqueue script: 
		wp_enqueue_script( 'my-post-type-archive-links_metabox', plugins_url('/metabox.js', __FILE__), array('jquery'));

		//Add nonce variable
		wp_localize_script('my-post-type-archive-links_metabox','MyPostTypeArchiveLinks', array('nonce'=>wp_create_nonce('my-add-post-type-archive-links')));
	}

In the previous step the 'Add to Menu' button was given the ID submit-post-type-archives. We now use jQuery to target that button and, when clicked, send an AJAX request to create the menu item and append it to the menu. The following is the only part of this tutorial that lives outside our class. It should go in a file called metabox.js, inside our plug-in folder.

	jQuery(document).ready(function($) {
		$('#submit-post-type-archives').click(function(event) {
			event.preventDefault();

			/* Get checked boxes */
			var postTypes = [];
			$('#post-type-archive-checklist li :checked').each(function() {
				postTypes.push($(this).val());
			});

			/* Send checked post types with our action, and nonce */
			$.post( ajaxurl, {
					action: "my-add-post-type-archive-links",
					posttypearchive_nonce: MyPostTypeArchiveLinks.nonce,
					post_types: postTypes
				},

				/* AJAX returns html to add to the menu */
				function( response ) {
					$('#menu-to-edit').append(response);
				}
			);
		})
	});

Notice the URL we are sending the request to: ajaxurl. We haven't defined it anywhere. It's a global variable set by WordPress on the admin side only which points to the page that WordPress uses to handle AJAX requests. When the submit button is clicked, the names of the checked post types, a unique action and nonce are all sent to this URL. When WordPress receives the request it triggers the wp_ajax_my-add-post-type-archive-links hook. The nonce is a security precaution to help verify that the action was intended.


Step 5 The AJAX Callback

We now define the AJAX callback function ajax_add_post_type.

	public function ajax_add_post_type() {

		if ( ! current_user_can( 'edit_theme_options' ) )
			die('-1');

		check_ajax_referer('my-add-post-type-archive-links', 'posttypearchive_nonce');

		require_once ABSPATH . 'wp-admin/includes/nav-menu.php';

		if(empty($_POST['post_types']))
			exit;

		// Create menu items and store IDs in array
		$item_ids=array();
		foreach ( (array) $_POST['post_types'] as $post_type) {
			$post_type_obj = get_post_type_object($post_type);

			if(!$post_type_obj)
				continue;

			$menu_item_data= array(
				'menu-item-title' => esc_attr($post_type_obj->labels->name),
				'menu-item-type' => 'post_type_archive',
				'menu-item-object' => esc_attr($post_type),
				'menu-item-url' => get_post_type_archive_link($post_type)
			);

			//Collect the items' IDs.
			$item_ids[] = wp_update_nav_menu_item(0, 0, $menu_item_data );
		}

		// If there was an error die here
		if ( is_wp_error( $item_ids ) )
			die('-1');

		// Set up menu items
		foreach ( (array) $item_ids as $menu_item_id ) {
			$menu_obj = get_post( $menu_item_id );
			if ( ! empty( $menu_obj->ID ) ) {
				$menu_obj = wp_setup_nav_menu_item( $menu_obj );
				$menu_obj->label = $menu_obj->title; // don't show "(pending)" in ajax-added items
				$menu_items[] = $menu_obj;
			}
		}

		// This gets the HTML to returns it to the menu
		if ( ! empty( $menu_items ) ) {
			$args = array(
				'after' => '',
				'before' => '',
				'link_after' => '',
				'link_before' => '',
				'walker' => new Walker_Nav_Menu_Edit
			);
			echo walk_nav_menu_tree( $menu_items, 0, (object) $args );
		}

		// Finally don't forget to exit
		exit;
	}

Let's go through this callback a bit at a time. First we check the user's permissions, verify the nonce and load the nav-menu.php page (we need some of the functions).

		if ( ! current_user_can( 'edit_theme_options' ) )
			die('-1');

		check_ajax_referer('my-add-post-type-archive-links','posttypearchive_nonce');

		require_once ABSPATH . 'wp-admin/includes/nav-menu.php';

		if(empty($_POST['post_types']))
			exit;

We then create a menu item for each selected post type. We first check the post type we received exists by checking the value returned by get_post_type_object(). We can obtain the archive link with the function get_post_type_archive_link()

Menu items are in fact posts of post type 'nav_menu_item' with built-in post meta, including fields relating to 'url', 'type' and 'object'. The item's 'type' is normally 'custom', 'post_type' or 'taxonomy' – but we shall set its value to 'post_type_archive'. The item's 'object' meta value is normally only used for items of 'post_type' or 'taxonomy' type and refers to post type or taxonomy the link refers to. We'll use this to store the post type of the archive link.

		// Create menu items and store IDs in array
		$item_ids=array();
		foreach ( (array) $_POST['post_types'] as $post_type) {
			$post_type_obj = get_post_type_object($post_type);

			if(!$post_type_obj)
				continue;

			$menu_item_data= array(
				'menu-item-title' => esc_attr($post_type_obj->labels->name),
				'menu-item-type' => 'post_type_archive',
				'menu-item-object' => esc_attr($post_type),
				'menu-item-url' => get_post_type_archive_link($post_type)
			);

			// Collect the items' IDs. 
			$item_ids[] = wp_update_nav_menu_item(0, 0, $menu_item_data );
		}

		// If there was an error die here
		if ( is_wp_error( $item_ids ) )
			die('-1');

Next we simply generate the HTML which will be added to the menu. We use the $item_ids array to get an array of menu items and pass this to a WordPress walker class to do the hard work for us.

		//Set up menu items
		foreach ( (array) $item_ids as $menu_item_id ) {
			$menu_obj = get_post( $menu_item_id );
			if ( ! empty( $menu_obj->ID ) ) {
				$menu_obj = wp_setup_nav_menu_item( $menu_obj );
				$menu_obj->label = $menu_obj->title; // don't show "(pending)" in ajax-added items
				$menu_items[] = $menu_obj;
			}
		}

		//This gets the HTML to returns it to the menu
		if ( ! empty( $menu_items ) ) {
			$args = array(
				'after' => '',
				'before' => '',
				'link_after' => '',
				'link_before' => '',
				'walker' => new Walker_Nav_Menu_Edit
			);
			echo walk_nav_menu_tree( $menu_items, 0, (object) $args );
		}

		//Finally don't forget to exit
		exit;

Step 6 The Menu Item

Unfortunately, because of a bug with WordPress, if your item's type is not 'taxonomy', 'custom' or 'post_type' the URL gets removed. To counter this, when a 'post_type_archive' link is used in a menu, we manually re-add the URL. This also ensures the archive link is 'up to date' (in case your permalink structure has been changed).

	public function setup_archive_item($menu_item){
		if($menu_item->type !='post_type_archive')
		 	return $menu_item;

		$post_type = $menu_item->object;
		$menu_item->url =get_post_type_archive_link($post_type);

		return $menu_item;
	}

Step 7 Making the Link Current

Finally we need to make the item 'current' when we are on the appropriate page. I want the post type archive link to be highlighted as current if we are on that archive page, or viewing a single post of that type. To do this I check:

To make the item current, we simply need to add current-menu-item to the item's classes which are stored in $item->classes. We then have to loop through its parents in the menu and add the classes current_item_parent and current_item_ancestor. Let's look at each bit individually:

We loop through each of the items in the menu:

	public function maybe_make_current($items) {
		foreach ($items as $item) {
			// This is where we check the item
		}
		return $items;
	}

If the item is not of 'post_type_archive' or if it is, but we don't want it to make it 'current', we simply skip on to the next item. Recall that for our archive links, the post type is stored as the item's object. So inside the foreach loop:

			if('post_type_archive' != $item->type)
				continue;

			$post_type = $item->object;
			if(!is_post_type_archive($post_type)&& !is_singular($post_type))
				continue;

If we do want to make it current we give it the appropriate class and then grab its parents in the menu. A menu item's parent are stored as post meta with meta key _menu_item_menu_item_parent.

		//Make item current
		$item->current = true;
		$item->classes[] = 'current-menu-item';

		//Get menu item's ancestors:
		$_anc_id = (int) $item->db_id;
		$active_ancestor_item_ids=array();

		while(( $_anc_id = get_post_meta( $_anc_id, '_menu_item_menu_item_parent', true ) ) && ! in_array( $_anc_id, $active_ancestor_item_ids )  ) {
			$active_ancestor_item_ids[] = $_anc_id;
		}

We then loop through the menu items and give the 'current' item's parents and ancestors the appropriate classes.

		// Loop through the items and give ancestors and parents the appropriate class
		foreach ($items as $key=>$parent_item) {
			$classes = (array) $parent_item->classes;

			// If menu item is the parent
			if ($parent_item->db_id == $item->menu_item_parent ) {
				$classes[] = 'current-menu-parent';
				$items[$key]->current_item_parent = true;
			}

			// If menu item is an ancestor
			if ( in_array(  intval( $parent_item->db_id ), $active_ancestor_item_ids ) ) {
				$classes[] = 'current-menu-ancestor';
				$items[$key]->current_item_ancestor = true;
			}

			$items[$key]->classes = array_unique( $classes );
		}

Putting that function together:

	public function maybe_make_current($items) {
		foreach ($items as $item) {
			if('post_type_archive' != $item->type)
				continue;

			$post_type = $item->object;
			if(!is_post_type_archive($post_type)&& !is_singular($post_type))
				continue;

			// Make item current
			$item->current = true;
			$item->classes[] = 'current-menu-item';

			// Get menu item's ancestors:
			$_anc_id = (int) $item->db_id;
			$active_ancestor_item_ids=array();

			while(( $_anc_id = get_post_meta( $_anc_id, '_menu_item_menu_item_parent', true ) ) && ! in_array( $_anc_id, $active_ancestor_item_ids )  ) {
				$active_ancestor_item_ids[] = $_anc_id;
			}

			// Loop through ancestors and give them 'ancestor' or 'parent' class
			foreach ($items as $key=>$parent_item) {
				$classes = (array) $parent_item->classes;

				// If menu item is the parent
				if ($parent_item->db_id == $item->menu_item_parent ) {
					$classes[] = 'current-menu-parent';
					$items[$key]->current_item_parent = true;
				}

				// If menu item is an ancestor
				if ( in_array(  intval( $parent_item->db_id ), $active_ancestor_item_ids ) ) {
					$classes[] = 'current-menu-ancestor';
					$items[$key]->current_item_ancestor = true;
				}

				$items[$key]->classes = array_unique( $classes );
			}

		}
		return $items;
	}

All that remains is to go to your plugins admin page and activate the plugin.


Conclusion

There is always room for improvement. For instance, with a bit of jQuery you could add a 'Select All' link underneath the checkboxes or display a 'loading' symbol while the AJAX is processing. Now this plugin isn't the simplest solution – but it does work well and avoids the pitfalls of simply adding a custom link. The above plugin in it's entirety can be found on my GitHub. If you have any comments or suggestions, feel free to leave a comment or contact me via Twitter.

Advertisement