Integrating a custom post type into a page hierarchy

I’m creating a theme with a custom post type for team members, I’ve also got the following page structure:

about  <-- this is a page
about/team-members  <-- this is a page, lists all the team members
about/team-members/joe-bloggs  <-- this is a custom post type (team member) entry

The third structure here uses the about and team member pages, but goes on to use the custom post type slug to make it look like it’s parents are team member and about. I’ve achieved this by setting the following options on the custom post type:

Read More
...
'rewrite' => array( 'slug' => 'about/team-members', 'with_front' => false)
...

This works great, however when I get down to the team member post level I no longer get the current-page, current-ancestor classes on the parent pages. I know why this is, because we’re not technically on a pagea parent of those pages, however is there a way I can trick/fix/bodge so the pages DO apear as parents?

I had achieved this nicely by using pages for team members, however a custom post type was chosen instead for easy of use for the administrator.

Thanks guys + girls!

Related posts

Leave a Reply

6 comments

  1. When Working with pages you can select a parent page and that value is saved as the parent page id number in the child page’s post_parent field in the database.

    In your case, you are using a custom post type so you would need to create your own metabox for the parent page; something like:

    /* Define the custom box */
    add_action('add_meta_boxes', 'child_cpt_add_custom_box');
    
    /* Adds a box to the main column on the custom post type edit screens */
    function child_cpt_add_custom_box() {
        add_meta_box('child_cpt', __( 'My child_cpt parent'),'team_member_inner_custom_box','team_member');
    }
    
    /* Prints the box content */
    function team_member_inner_custom_box() {
        global $post;
        // Use nonce for verification
        wp_nonce_field( plugin_basename(__FILE__), 'team_member_inner_custom_box' );
        echo 'Select the parent page';
        $mypages = get_pages();
        echo '<select name="cpt_parent">';
        foreach($mypages as $page){     
            echo '<option value="'.$page->ID.'"';
            if ($page->ID == $post->post_parent) {echo ' selected';}
            echo '>"'.$page->post_title.'</option>';
        }
        echo '</select>';
    }
    /* Do something with the data entered */
    add_action('wp_insert_post_data', 'myplugin_save_postdata');
    
    /* When the post is saved, saves our custom data */
    function myplugin_save_postdata( $data, $postarr ) {
        global $post;
          // verify this came from the our screen and with proper authorization,
          // because save_post can be triggered at other times
    
          if ( !wp_verify_nonce( $_POST['team_member_inner_custom_box'], plugin_basename(__FILE__) ) )
              return $data;
    
          // verify if this is an auto save routine. 
          // If it is our form has not been submitted, so we dont want to do anything
          if ( defined('DOING_AUTOSAVE') && DOING_AUTOSAVE ) 
              return $data;
          // OK, we're authenticated: we need to find and save the data
    
          if ($post->post_type == "team_member")
              $data['post_parent'] = $_POST['cpt_parent'];
    
         return $data;
    }
    

    It has nothing to do with register_post_type. You are tricking WordPress into thinking that it is a child page of another post type (page).

  2. I went with a custom walker to achieve something similar… avoids needs for custom fields, but all posts of a type have to sit below the same point in the page tree.

    class Walker_Page_CustomPostTypeHack extends Walker_Page {
        function walk($elements, $max_depth) {
            $called_with = func_get_args();
            // current page is arg 3... see walk_page_tree for why
            $current_page = $called_with[3];
    
            // if there's no parent - see if we can find one.
            // some ACF options would be an easy way to make this configurable instad of constants
            if ($current_page === 0) {
                global $wp_query;
                $current_post = $wp_query->get_queried_object();
                switch ($current_post->post_type) {
                    case 'course':
                        $current_page = POST_COURSES;
                        break;
                    case 'project':
                        $current_page = POST_PROJECTS;
                        break;
                    case 'story':
                        $current_page = POST_STORIES;
                        break;
                }
            }
    
            // now pass on into parent
            $called_with[3] = $current_page;
            return call_user_func_array(array('parent', 'walk'), $called_with);
        }
    
    }
    
  3. Disclaimer: After giving it a try this seems a not longer existing problem to me, because – at least for me – it just works on my WP 3.9.2 installation. Couldn’t find a according bug tracker though.


    I have out together a little plugin to test this, which might help someone. But like I said in above disclaimer, I couldn’t reproduce the problem in a current wordpress installation. I’ve separated the plugin into four files, they are going together into one directory inside the plugin directory.

    plugin-cpt_menu_hierarchy.php:

    <?php
    defined( 'ABSPATH' ) OR exit;
    /**
     * Plugin Name: CPT Menu Hierarchy Fix?
     * Description: CPT Menu Hierarchy Fix?
     * Author:      ialocin
     * Author URL:  http://wordpress.stackexchange.com/users/22534/ialocin
     * Plugin URL:  http://wordpress.stackexchange.com/q/13308/22534
     */
    
    // registering nonsense post type
    include 'include-register_post_type.php';
    
    // adding meta box to nosense custom post type
    include 'include-cpt_parent_meta_box.php';
    
    // menu highlighting fix
    include 'include-menu_highlighting.php';
    

    include-register_post_type.php:

    <?php
    defined( 'ABSPATH' ) OR exit;
    
    // See: http://codex.wordpress.org/Function_Reference/register_post_type
    add_action( 'init', 'wpse13308_basic_reigister_post_type');
    function wpse13308_basic_reigister_post_type() {
        $args = array(
            'public' => true,
            'label'  => 'Nonsense'
        );
        register_post_type( 'nonsense', $args );
    }
    

    include-cpt_parent_meta_box.php:

    <?php
    defined( 'ABSPATH' ) OR exit;
    
    // pretty much like @bainternet's answer
    
    // Add Meta Box
    add_action( 'add_meta_boxes', 'nonsense_add_meta_box' );
    function nonsense_add_meta_box() {
        add_meta_box(
            'nonsense',
            __( 'Nonsense parent' ),
            'nonsense_inner_meta_box',
            'nonsense'
        );
    }
    
    // Meta Box Content
    function nonsense_inner_meta_box() {
        global $post;
    
        wp_nonce_field(
            plugin_basename( __FILE__ ),
            'nonsense_inner_meta_box'
        );
        echo 'Parent Page:&nbsp;&nbsp;';
        $mypages = get_pages();
        echo '<select name="cpt_parent">';
        foreach($mypages as $page){     
            echo '<option value="'.$page->ID.'"';
            if ($page->ID == $post->post_parent) {echo ' selected';}
            echo '>'.$page->post_title.'</option>';
        }
        echo '</select>';
    }
    
    // Save Data From Meta Box
    add_action( 'wp_insert_post_data', 'nonsense_save_meta_box_data' );
    function nonsense_save_meta_box_data( $data, $postarr ) {
        global $post;
    
        if (
            ! wp_verify_nonce(
                $_POST['nonsense_inner_meta_box'],
                plugin_basename( __FILE__ )
            )
        ) {
            return $data;
        }
    
        if (
            defined('DOING_AUTOSAVE')
            && DOING_AUTOSAVE
        ) {
            return $data;
        }
    
        if ( $post->post_type == 'nonsense' ) {
            $data['post_parent'] = $_POST['cpt_parent'];
        }
        return $data;
    }
    

    include-menu_highlighting.php:

    <?php
    defined( 'ABSPATH' ) OR exit;
    
    // altering WordPress' nav menu classes via »nav_menu_css_class« filter
    add_filter( 'nav_menu_css_class', 'wpse13308_fix_nav_menu_highlighting', 10, 2 );
    function wpse13308_fix_nav_menu_highlighting( $classes, $item ) {
        // data of the current post
        global $post;
    
        // setting up some data from the current post
        $current_post_post_type = $post->post_type;
        $current_post_parent_id = $post->post_parent;
        // id of the post the current menu item represents
        $current_menu_item_id   = $item->object_id;
    
        // do this for a certain post type
        if( $current_post_post_type == 'nonsense' ) {
            // remove unwanted highlighting class via array_filter and callback
            // http://php.net/manual/de/function.array-filter.php
            $classes = array_filter(
                $classes,
                'wpse13308_remove_highlighting_classes'
            );
            // when the parents id equals the menu items id, we want to
            // highlight the parent menu item, so we check for:
            if( $current_post_parent_id == $current_menu_item_id ) {
                // use the css class used for highlighting
                $classes[] = 'replace-with-css-class';
            }
        }
        return $classes;
    }
    
    // callback to remove highlighting classes
    function wpse13308_remove_highlighting_classes( $class ) {
        return
            (
                // use the class(es) you need, overview over nav menu item css classes:
                // http://codex.wordpress.org/Function_Reference/wp_nav_menu#Menu_Item_CSS_Classes
                $class == 'highlight-class'
                // uncomment next line if you want to check for more then one class
                // repeat the line if you want to check for a third, fourth and so on
                // || $class == 'replace-with-css-class'
            ) 
            ? false
            : true
        ;
    }
    


    • This is a somewhat generalized code example.
    • It has to be fitted to the actual use case.
  4. A possible solution is whenever the custom post type is saved, you can set its’ parent to be about/team-members prgrammatically.

    Here are the steps:

    1. You can use the save_post hook to ‘catch’ whenever someone tries to save a post.
    2. If that post is the custom post type you are after, then proceed.
    3. Make sure to set the custom post’s parent to the page you want (you can hard-code the page ID as long as you do not delete it). You can use wp_update_post to save the parent (I haven’t tried this myself, but I don’t see why it shouldn’t work).
  5. I had some more time to dig into this myself (sorry if I wasted anyone’s time), and I figured that for me, the best way to solve the highlighting problem would be to kinda re-do what _wp_menu_item_classes_by_context() is doing, that is iterate over all parents and ancestors of the menu item that acts as the parent of my custom post type, and add classes appropriately.

    Since I also wanted to have the parent page for my custom post type fixed, and easily changeable without having to update all posts once the parent changes, I’ve decided to use an option instead of populating the post_parent field of my custom post type posts. I’ve used ACF for that since I’m using it in my theme anyways, but using the default WordPress option functionality would of course do it too.

    For my needs I could make use of the wp_nav_menu_objects filter. Additionally I had to filter the page_for_posts option so that it returns a falsely/empty value, this avoids the default posts page to be highlighted too.

    Note that I didn’t go all the way, the filter only adds the current-menu-ancestor and current-menu-parent classes, as this was enough for my needs!

    /**
     * Filters the `page_for_posts` option on specific custom post types in
     * order to avoid the wrong menu item being marked as
     * `current-page-parent`.
     *
     * @see _wp_menu_item_classes_by_context()
     */
    function wpse13308_pre_option_page_for_posts_filter()
    {
        $types = array
        (
            'my_custom_post_type_x',
            'my_custom_post_type_y',
            'my_custom_post_type_z'
        );
        if(in_array(get_post_type(), $types))
        {
            return 0;
        }
        return false;
    }
    add_filter('pre_option_page_for_posts', 'wpse13308_pre_option_page_for_posts_filter');
    
    
    /**
     * Returns the current posts parent page ID
     *
     * @return int
     */
    function wpse13308_get_parent_page_id()
    {
        $postType = get_post_type();
        $parentPageId = null;
        switch($postType)
        {
            case 'my_custom_post_type_x':
            case 'my_custom_post_type_y':
            case 'my_custom_post_type_z':
                $parentPageId = (int)get_field('page_for_' . $postType, 'options')->ID;
                break;
    
            case 'post':
                $parentPageId = (int)get_option('page_for_posts');
                break;
        }
        return $parentPageId;
    }
    
    /**
     * Adds proper context based classes so that the parent menu items are
     * being highlighted properly for custom post types and regular posts.
     *
     * @param array $menuItems
     * @return array
     *
     * @see _wp_menu_item_classes_by_context()
     */
    function wpse13308_wp_nav_menu_objects_filter(array $menuItems)
    {
        $parentPageId = wpse13308_get_parent_page_id();
    
        if($parentPageId !== null)
        {
            $activeAncestorItemIds = array();
            $activeParentItemIds = array();
            foreach($menuItems as $menuItem)
            {
                if((int)$parentPageId === (int)$menuItem->object_id)
                {
                    $ancestorId = (int)$menuItem->db_id;
    
                    while
                    (
                        ($ancestorId = (int)get_post_meta($ancestorId, '_menu_item_menu_item_parent', true)) &&
                        !in_array($ancestorId, $activeAncestorItemIds)
                    )
                    {
                        $activeAncestorItemIds[] = $ancestorId;
                    }
                    $activeParentItemIds[] = (int)$menuItem->db_id;
                }
            }
            $activeAncestorItemIds = array_filter(array_unique($activeAncestorItemIds));
            $activeParentItemIds = array_filter(array_unique($activeParentItemIds));
    
            foreach($menuItems as $key => $menuItem)
            {
                $classes = $menuItems[$key]->classes;
                if(in_array(intval($menuItem->db_id), $activeAncestorItemIds))
                {
                    $classes[] = 'current-menu-ancestor';
                    $menuItems[$key]->current_item_ancestor = true;
                }
    
                if(in_array($menuItem->db_id, $activeParentItemIds))
                {
                    $classes[] = 'current-menu-parent';
                    $menuItems[$key]->current_item_parent = true;
                }
    
                $menuItems[$key]->classes = array_unique($classes);
            }
        }
    
        return $menuItems;
    }
    add_filter('wp_nav_menu_objects', 'wpse13308_wp_nav_menu_objects_filter');
    

    For the sake of completeness, when populating post_parent (see @Bainternet’s answer) instead of using options, then retrieving the parent ID could look something like this:

    /**
     * Returns the current posts parent page ID
     *
     * @return int
     */
    function wpse13308_get_parent_page_id()
    {
        $parentPageId = null;
        $post = get_post();
        switch($post->post_type)
        {
            case 'my_custom_post_type_x':
            case 'my_custom_post_type_y':
            case 'my_custom_post_type_z':
                $parentPageId = (int)$post->post_parent;
                break;
    
            case 'post':
                $parentPageId = (int)get_option('page_for_posts');
                break;
        }
        return $parentPageId;
    }
    
  6. <?php
    the_post();
    
    // $postType holds all the information of the post type of the current post you are viewing
    $postType = get_post_type_object(get_post_type());
    
    // $postSlug is the slug you defined in the rewrite column: about/team-members
    $postSlug = $postType->rewrite['slug'];
    
    // $datas = { [0] => 'about', [1] => 'team-members' }
    $datas = explode('/', $postSlug);
    
    // $pageSlug = 'about'
    $pageSlug = $datas[0];
    
    // all the page information you require.
    $page = get_page_by_path($pageSlug, OBJECT, 'page');
    ?>
    

    http://codex.wordpress.org/Function_Reference/get_post_type_object
    http://codex.wordpress.org/Function_Reference/get_page_by_path

    EDIT 1:

    Since pointers do not work:

    add_filter('wp_nav_menu_objects', 'my_menu_class_edit');
    function my_menu_class_edit($items)
    {
        if (is_single()) {
            $postType = get_post_type_object(get_post_type());
            $postSlug = $postType->rewrite['slug'];
            if($postSlug  != 'about/team-members')
                return $items;
            $datas = explode('/', $postSlug);
            $pageAbout = get_page_by_path($datas[0], OBJECT, 'page');
            $pageTeamMembers = get_page_by_path($datas[1], OBJECT, 'page');
    
            foreach ($items as $item) {
                if ($item->title == $pageAbout->post_title) {
                    $item->classes[] = 'current-ancestor';
                } else if ($item->title == $pageTeamMembers->post_title) {
                    $item->classes[] = 'current-page';
                }
            }
       }
        return $items;
    }