Here’s what I wanted to do: give clients the ability to add conditional items to menus and be clear about which users will see which items on the front end menu. For example, I want some menu items which only show for logged in users and some which only show for users who have bought a subscription. The clients need to be able to look at the menu in the admin area and easily see which menu items are conditional, who will see them and which will show for everyone.
One way to do this is with conditional *menus*, ie make a menu for every possible combination of conditionals, so one for logged out users, one for logged in users who have not bought a subscription and one for logged in users who have bought a subscription. However this gets unmaintainable, especially because we already have different menus for various css breakpoints. Making a simple change to the order would require remembering to update multiple menus.
So I wanted conditional menu items – the individual items which make up a menu. Where to start? Putting the menu items in the sidebar so they could be chosen seemed like a good place. Luckily I learned it’s possible to make a custom menu metabox using add_meta_box
like so:
/**
* Adds the metabox to the nav menu list.
*
* @return void
*/
function add_menu_metabox() {
add_meta_box( 'mjj_course_conditional_nav_links', __( 'MJJ Conditionals', 'mjjmenu' ), array( $this, 'nav_menu_links' ), 'nav-menus', 'side', 'low' );
}
add_action( 'admin_head-nav-menus.php', 'add_menu_metabox' );
I’m not going to tell you how to make a metabox here, it’s along the same lines as making other metaboxes. I, as usual, cheated by copying and editing someone else’s. In particular I started from WooCommerce’s menu metaboxes in add_nav_menu_meta_boxes()
.
But I will explain which fields are available and automatically saved in menu metaboxes. It helps to know that menu items are saved as individual posts with the post type of nav_menu_item
. (The menus themselves are generated with all their associated menu items using terms but I’m not going into that here!)
Each menu item has (or can have) these default entries:
// from wp_update_nav_menu_item() in wp-includes/nav-menu.php
// (comments on the items are mine)
$defaults = array(
'menu-item-db-id' => $menu_item_db_id, // The post ID of the menu item.
'menu-item-object-id' => 0, // The ID of the object to which the menu item links. (postmeta)
'menu-item-object' => '', // The *type* of the object to which the menu item links. (postmeta)
'menu-item-parent-id' => 0, // Is this a sub item of another menu item? (in posts, post_parent)
'menu-item-position' => 0, // The position. (in posts, menu_order)
'menu-item-type' => 'custom', // The menu item type. (postmeta)
'menu-item-title' => '', // The title. (posts, post_title)
'menu-item-url' => '', // The url. This is *stripped* in this function if it's not a standard type. (postmeta)
'menu-item-description' => '', // The desciption (posts, post_content)
'menu-item-attr-title' => '', // The title attribute. (posts, post_excerpt)
'menu-item-target' => '', // The target of the link. (postmeta)
'menu-item-classes' => '', // The classes to be applied to the link. (postmeta)
'menu-item-xfn' => '', // xfn, if you have friends or something. (postmeta)
'menu-item-status' => '', // The post status. (posts, post_status)
);
If those input field names are used in the menu item metabox, then wp_update_nav_menu_item()
will automatically pick them up and save them where they should go. For the most part, I’ll get to the notable exception in a moment.
But first, notice what’s not in there. There’s no place to put the “item type label” – the label on the menu which says “Category” or “Custom Link”.
The function which renders those menu items is wp_setup_nav_menu_item()
.
wp_setup_nav_menu_item()
sets up the menu item in the admin area and the front end. For the item type label in the admin, it looks for a few known menu item types, then defaults to set the label “Custom Link” (boo) but it sets the link as
(corresponding to $menu_item->url
menu-item-url
) and the title as $menu_item->title
(menu-item-title
) so we’re golden, right?
It has a filter, so I can filter in the custom item type label by setting $item->type_label
to be what I want. So far, so good. To figure out which label should be used, I’ll set a custom item type (menu-item-type
in the metabox) and look for that, then use it to set the correct label. This will also make it easier on the front end to pick up which items should be conditionally rendered and when they should show. Sounds great, doesn’t it?
Let me try setting up the rest of my metabox so that menu-item-url
links where I want, menu_item_title
is the default title, maybe add some classes in menu-item-classes
and see what happens.
Hmmm. This isn’t working as expected. The item type label is filtered in, no problem, but $menu_item->url
has been unset so the front end menu item does not link to anything.
/**
* This filters the label on the nav item in the list so it says something other than the default "Custom link" for certain item types.
*
* @param object $item The nav menu item object.
* @return object The nav menu item object.
*/
function nav_type_labels( $item ) {
$types = [
'mjj-logged-in-page' => 'Shows for logged in users',
'mjj-subs-cat' => 'Shows for subscribers',
'mjj-subs-page' => 'Shows for subscribers',
];
if ( ! empty( $types[ $item->type ] ) ) {
$item->type_label = $types[ $item->type ];
}
return $item;
}
add_filter( 'wp_setup_nav_menu_item', 'nav_type_labels' );
(Above is “the item type label is filtered in no problem” code.)
The issue with bumping up against older parts of WordPress core is that functions aren’t as flexible as one might expect. And that’s true here. The problem with wp_update_nav_menu_item()
is that, if menu-item-type
is *not* set as custom
it strips the link in menu-item-url
and tries to use menu-item-type
to create a new url, leaving the created $menu_item->url
blank if the menu item type is not in the hardcoded list. Then it saves the postmeta entry for the url as an empty string.
Luckily there’s an action at the end of the function where we update the postmeta row to the proper url. How to know what it should be though? Where can I put data that won’t get overwritten?
[Insert flailing about for a couple of hours. Really, I should have said to myself, “Self, stop working, it’s Friday afternoon and you’re done. Take a break and come back later.” Because when I came back to it, it was all much clearer.]
The short answer? Use menu-item-object-id
for the id of the object and menu-item-object
for the object type of the item to which the menu item links (ie not the menu item itself).
Why is the object type of the linked item necessary? That will tell me which function to use to get the link. It’ll be get_permalink
for post objects, get_term_link
for terms, etc. Then hook it all to 'wp_update_nav_menu_item'
and update the postmeta which stores the url ('_menu_item_url'
) using $menu_item_db_id
as the menu item’s post id. (Which is what it is.)
/**
* Updates the menu item's url to the correct one.
*
* @param int $menu_id The id of the menu.
* @param int $menu_item_db_id The id of the menu item.
* @param array $args The values which were saved as postmeta and in the menu item's posts entry.
* @return void
*/
function add_back_url( $menu_id, $menu_item_db_id, $args ) {
$mjj_types = $this->mjj_menu_types;
$item_type = $args['menu-item-type'];
if ( ! empty( $mjj_types[ $item_type ] ) ) {
$object_id = $args['menu-item-object-id'];
$object = $args['menu-item-object'];
$url = $this->get_link( $object_id, $object ); // Private function which gets the correct link depending on the object type and object id.
update_post_meta( $menu_item_db_id, '_menu_item_url', esc_url_raw( $url ) );
}
}
add_action( 'wp_update_nav_menu_item', 'add_back_url', 10, 3 );
It works! Now all we have left to do is filter the menu items on the front end for our users.
/**
* Maybe add menu items. Currently adding "Back to subscription" for subscription users on 12 week course pages.
*
* @param array $menu_items The menu items, sorted by each menu item's menu order.
* @param object $menu_args The menu object.
* @return array The final html for the menu.
*/
function dynamic_menu_items( $menu_items, $menu_args ) {
foreach ( $menu_items as $key => $item ) {
$type = $item->type;
// If a user is not logged in, unset the "mjj-logged-in" type menu items so they don't show in the menu.
if ( strpos( $type, 'mjj-logged-in' ) === 0 ) {
$is_logged_in = is_user_logged_in();
if ( ! $is_logged_in ) {
unset( $menu_items[ $key ] ); // If the user is not logged in, remove the link.
continue;
}
}
// If a user has not bought a subscription, unset the "mjj-subscription-member" type menu items so they don't show in the menu.
if ( strpos( $type, 'mjj-subs-' ) === 0 ) {
$user_id = get_current_user_id();
$is_subscription_user = mjj_user_has_subscription( $user_id ); // Made up function for the purpose of this post.
if ( ! $is_subscription_user ) {
unset( $menu_items[ $key ] );
continue;
}
}
}
return $menu_items;
}
// This is what filters the menu items shown on the public facing site.
add_filter( 'wp_nav_menu_objects', 'dynamic_menu_items' , 10, 2 );
And that’s it, I’m golden.
Leave a Reply