7

I'm displaying a simple sitemap with wp_list_pages();

$args = array(
    'sort_column' => 'menu_order',
    'title_li' => '',
    'post_status'  => 'publish'
);

wp_list_pages( $args );

The problem is that by default this also shows the published children of draft pages, like so :

Page 1 (published) -> displayed

--- Page 2 (draft) -> not displayed

------ Page 3 (published) -> displayed

What I would like to achieve is :

Page 1 (published) -> displayed

--- Page 2 (draft) -> not displayed

------ Page 3 (published) -> not displayed

I suspect a custom Walker would do the trick, but I could never really understand how those work..

Is there a way to hide those child pages without having to set them all to draft ?

Edit:

To clarify, let's try some imagery. So you have a tree with the complete hierarchy of your pages. We are climbing up the tree. The moment we encounter a a draft branch, we cut it down. Naturally all the other branches attached to it further along are also discarded (no matter if they are drafts or not). I hope that explains it better.

Here is an example with a somewhat deep hierarchy :

Page 1 (published) -> displayed

--- Page 2 (draft) -> not displayed <- Cut here and exclude all further children

------ Page 3 (published) -> not displayed

--------- Page 4 (published) -> not displayed

------------ Page 5 (draft) -> not displayed

--------------- Page 6 (published) -> not displayed

4 Answers 4

4

Great answers above. I took on the challenge trying to find yet another way to solve this.

The exclude parameter:

We could try:

'exclude' => wpse_exclude_drafts_branches()

where:

function wpse_exclude_drafts_branches()
{
    global $wpdb;
    $exclude = array();
    $results = $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} where post_status = 'draft' AND post_type = 'page' " );
    $exclude = array_merge( $exclude, $results) ;
    while ( $results ):
        $results = $wpdb->get_col( "SELECT DISTINCT ID FROM {$wpdb->posts} WHERE post_type = 'page' AND post_status = 'publish' AND post_parent > 0 AND post_parent IN (" .  join( ',', $results ) . ") " );
        $exclude = array_merge( $exclude, $results) ;
    endwhile;
    return join( ',', $exclude );
}

and the number of queries depends on the tree depth.

Update:

The exclude_tree parameter:

I just noticed the exclude_tree parameter mentioned on the Codex page, so I wonder if this would work (untested) to exclude the whole of the draft nodes branches:

$exclude = get_posts(
    array( 
        'post_type'      => 'page',
        'fields'         => 'ids',
        'post_status'    => 'draft',
        'posts_per_page' => -1,
   )
);

and then use:

'exclude_tree' => join( ',', $exclude ),

with wp_list_pages().

10
  • 1
    I wonder if we can simplify this much more with the exclude_tree parameter, to exclude the whole draft branches.
    – birgire
    Aug 31, 2014 at 15:27
  • 1
    I can confirm that exclude_tree option you suggested in your update does work. Which really is kind of funny, considering the amount of code that has been produced to answer this question. I feel this should be the accepted answer. Aug 31, 2014 at 15:54
  • 1
    @ialocin oh, it's maybe not as elegant and deep as the other solutions you and G.M. provided, but I'm glad to hear it works ;-) I'm not sure why I didn't notice this parameter before on the Codex page, but I guess that can happen when putting the nose too deep into the source code instead ;-)
    – birgire
    Aug 31, 2014 at 16:03
  • 1
    Ok, maybe its not as fancy of a solution :) But its built-in and only needs about 8 extra lines code, so thats actually not that bad. I guess you are right, that is what happens.. ;) Aug 31, 2014 at 16:07
  • 2
    I removed my prev comment because now this works. And +1. What exclude_tree does is what @ialocin did in his code: to call get_page_children inside a loop. Now that function [recursively call itself].(developer.wordpress.org/reference/functions/wp_parse_id_list). Inside a for loop of possibly hundrends of posts. Result: to do this task 8 lines are required, but is possible that dozen of hundrends functions are called. I have no doubt that this is the right WordPress way, but as someone said in The Loop: "nobody does things the f*king WordPress way... and thank God for that!"*
    – gmazzap
    Sep 1, 2014 at 17:07
4

Sincerly I found custom walkers annoying: sometimes what can be done with a simple filter require an entire class to coded and, but probably it's me, I don't really like logic behind WordPress walkers.

This is the reason why I often use a trick to filter elements before they are walked. It is a really simple Walker class:

class FilterableWalker extends Walker {

  private $walker;

  function __construct( Walker $walker ) {
    $this->walker = $walker;
  }

  function walk( array $elements = null, $max_depth = null ) {
    $args = func_get_arg( 2 );
    $filtered = apply_filters( 'filterable_walker_elements', $elements, $args, $this );
    if ( is_array( $filtered ) ) {
      $walk_args = func_get_args();
      $walk_args[0] = $filtered ;
      return call_user_func_array( array( $this->walker, 'walk' ), $walk_args );
    }
    return call_user_func_array( array( $this->walker, 'walk' ), func_get_args() );
  }

  function getWalker() {
    return $this->walker;
  }

  function getWalkerClass() {
    return get_class( $this->getWalker() );
  }
}

This is an all-purpose, reusable walker that enable filtering items before they are passed to real walker that must be passed in constructor.

In your case, you should do something like:

$args = array(
  'sort_column' => 'menu_order',
  'title_li' => '',
  'post_status'  => 'publish',
  'skip_draft_children' => 1, // <- custom argument we will use in filter callback
  'walker' => new FilterableWalker( new Walker_Page ) // <-- our walker
);

$pages = wp_list_pages( $args );

Now you can code a filter callback to filter pages using 'filterable_walker_elements' hook fired by FilterableWalker class:

add_filter( 'filterable_walker_elements', function( $elements, $args, $filterable ) {

  $walker = $filterable->getWalkerClass();

  if (
    $walker === 'Walker_Page'
    && isset( $args['skip_draft_children'] )
    && $args['skip_draft_children'] // <-- our custom argument
  ) {
    $ids = array_filter( array_unique( wp_list_pluck( $elements, 'post_parent' ) ) );
    $parents = get_posts(
      array(
        'post__in' => $ids,  'post_status' => 'publish',
        'fields'   => 'ids', 'post_type'   => 'page',
        'nopaging' => true
      )
    );
    $pages = $elements;
    foreach( $pages as $i => $page ) {
      if ( $page->post_parent !== 0 && ! in_array( $page->post_parent, $parents, true ) ) {
        unset($elements[$i]);
        $self_i = array_search( $page->ID, $parents, true );
        if ( $self_i !== FALSE ) unset( $parents[$self_i] );
      }
    }
  }
  return $elements;

}, 10, 3 );
4
  • 1
    Thanks for your detailed answer ! Although it doesn't seem to do what I'm after. I am displaying a sitemap of pages (like a tree), whenever we arrive at a page that is in draft, I want to stop showing any other page further down in the hierarchy of that particular branch. I'll try to fiddle with your code to see what I can find.
    – mike23
    Aug 30, 2014 at 23:48
  • 1
    @mike23 I edited the answer, now it should work. Before editing only direct children of a draft pages were removed, now any page down in the hierarchy of a draft page should be removed.
    – gmazzap
    Aug 31, 2014 at 2:56
  • @G.M. Does this work no matter at which depth the first draft page is ?
    – mike23
    Aug 31, 2014 at 13:17
  • @mike23 yes, absolutely
    – gmazzap
    Aug 31, 2014 at 14:12
3

Making use of a custom Walker is actually not that hard, it basically goes like this:

  • Create a class;

    A class is a collection of variables and functions working with these variables.

  • By extending another one;

    The extended or derived class has all variables and functions of the base class [...] and what you add in the extended definition.

  • Like this:

    class Extended_Class extends Base_Class {
       // code
    }
  • Which gives you the possibility to change/extend the methods aka functions of the base class that has been extended. Additionally you can/could extend by adding methods or variables to the extended class.

  • To fully understand and make use of the possibilities it is necessary to get deeper into OOP: Classes and Objects aspects of PHP. But that would be too much here and not the rightplace anyway.

So lets get back to WordPress and wp_list_pages(). The class we want to extend to make use of with wp_list_pages(), the Walker_Page class - source -, itself has been derived by extending the class Walker - source.

Following the above explained schema we are going to do the same:

class Wpse159627_Walker_Page extends Walker_Page {
    // code
}

Now Walker_Page has two variables - $tree_type and $db_fields - and four methods - start_lvl(), end_lvl(), start_el() and end_el(). The variables won't concern us, regarding the methods we are at least have to take a closer look at start_el() and end_el().

The first thing to see is that those two methods have the parameter $page:

@param object $page Page data object.

Which contains all the relevant data we need, like the post_parent, and is pretty much a WP_Post/$post/"$page" object. Given back by the get_pages() return

An array containing all the Pages matching the request, or false on failure. The returned array is an array of "page" objects.

inside the wp_list_pages() function.

What we need to check is the post status of the current page parent, for doing this the function get_post_status() is available. Like determined we can use the $page object available to do so.

$page_parent_id     = $page->post_parent;
$page_parent_status = get_post_status( $page_parent_id );

Now we can use this to check against the status of the currents page parent:

if ( $page_parent_status != 'draft' ) {
    // code
}

Lets implement it in our extended Walker class:

class Wpse159627_Walker_Page extends Walker_Page {
    function start_el( &$output, $page, $depth = 0, $args = array(), $current_page = 0 ) {
        $page_parent_id     = $page->post_parent;
        $page_parent_status = get_post_status( $page_parent_id );
        if ( $page_parent_status != 'draft' ) {
            if ( $depth )
                $indent = str_repeat("\t", $depth);
            else
                $indent = '';

            extract($args, EXTR_SKIP);
            $css_class = array('page_item', 'page-item-'.$page->ID);

            if( isset( $args['pages_with_children'][ $page->ID ] ) )
                $css_class[] = 'page_item_has_children';

            if ( !empty($current_page) ) {
                $_current_page = get_post( $current_page );
                if ( in_array( $page->ID, $_current_page->ancestors ) )
                    $css_class[] = 'current_page_ancestor';
                if ( $page->ID == $current_page )
                    $css_class[] = 'current_page_item';
                elseif ( $_current_page && $page->ID == $_current_page->post_parent )
                    $css_class[] = 'current_page_parent';
            } elseif ( $page->ID == get_option('page_for_posts') ) {
                $css_class[] = 'current_page_parent';
            }

            $css_class = implode( ' ', apply_filters( 'page_css_class', $css_class, $page, $depth, $args, $current_page ) );

            if ( '' === $page->post_title )
                $page->post_title = sprintf( __( '#%d (no title)' ), $page->ID );

            $output .= $indent . '<li class="' . $css_class . '"><a href="' . get_permalink($page->ID) . '">' . $link_before . apply_filters( 'the_title', $page->post_title, $page->ID ) . $link_after . '</a>';

            if ( !empty($show_date) ) {
                if ( 'modified' == $show_date )
                    $time = $page->post_modified;
                else
                    $time = $page->post_date;

                $output .= " " . mysql2date($date_format, $time);
            }
        }
    }
    function end_el( &$output, $page, $depth = 0, $args = array() ) {
        $page_parent_id     = $page->post_parent;
        $page_parent_status = get_post_status( $page_parent_id );
        if ( $page_parent_status != 'draft' ) {
            $output .= "</li>\n";
        }
    }
}

The new class can be used with wp_list_pages() like this:

$args = array(
    'sort_column' => 'menu_order',
    'title_li'    => '',
    'post_status' => 'publish',
    'walker'      => new Wpse159627_Walker_Page
);
wp_list_pages( $args );



Edit:

Adding this for completeness reasons, so to make this work for trees, all descendants, not just children. It is not the optimal way to do it though, enough other suggestion have been made.

Because WordPress' get_ancestors() and get_post_ancestors() functions aren't made to get drafts too, I constructed a function to get every ancestor:

function wpse159627_get_all_post_ancestors( $post_id ) {
    $post_type = get_post_type( $post_id );
    $post = new WP_Query(
        array(
            'page_id'                => $post_id,
            'include'                => $post_id,
            'post_type'              => $post_type,
            'post_status'            => 'any',
            'cache_results'          => false,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false
        )
    );
    $post = $post->posts[0];

    if (
        ! $post
        || empty( $post->post_parent )
        || $post->post_parent == $post->ID
    ) {
        return array();
    }

    $ancestors = array();

    $id = $ancestors[] = $post->post_parent;

    while (
        $ancestor = new WP_Query(
            array(
                'page_id'                => $id,
                'include'                => $id,
                'post_type'              => $post_type,
                'post_status'            => 'any',
                'cache_results'          => false,
                'update_post_meta_cache' => false,
                'update_post_term_cache' => false
            )
        )
    ) {
    $ancestor = $ancestor->posts[0];
            if ( 
                empty( $ancestor->post_parent )
                || ( $ancestor->post_parent == $post->ID )
                || in_array( $ancestor->post_parent, $ancestors ) 
            ) {
                break;
            }

            $id = $ancestors[] = $ancestor->post_parent;
    }

    return $ancestors;
}

Additionally it is necessary to get the status of those ancestors. Which can be done with the following function:

function wpse159627_get_all_status( $ids ) {
    $status_arr = array();
    foreach ( $ids as $id ) {
        $post_type = get_post_type( $id );
        $post = new WP_Query(
            array(
                'page_id'                => $id,
                'include'                => $id,
                'post_type'              => $post_type,
                'post_status'            => 'any',
                'cache_results'          => false,
                'update_post_meta_cache' => false,
                'update_post_term_cache' => false
            )
        );
        $post = $post->posts[0];
        $status_arr[] = $post->post_status;
        }
    return $status_arr;
}

This can be used to replace above explained conditional:

$ancestors = wpse159627_get_all_post_ancestors( $page->ID );
$ancestors_status = wpse159627_get_all_status( $ancestors );
if ( ! in_array( 'draft', $ancestors_status ) ) {
    // code
}
6
  • Problem with this approach is that get_post_status called for each element will trigger a DB query. And a DB query for every page is not a great thing if there are a lot of pages...
    – gmazzap
    Aug 30, 2014 at 19:43
  • Sure, its not quite optimal, but believe me if I would be as knowledgeable as you are I would have suggested a solution as smart as yours. @G.M. Aug 30, 2014 at 19:54
  • I just hate walkers, and you don't, so I had to find a solution to don't use them :) And believe me, you are enough knowledgeable to answer questions far better than me :)
    – gmazzap
    Aug 30, 2014 at 19:58
  • Ok, lets agree we are both somewhat capable :) My feeling towards walkers just indifferent :) Aside from that reading through source plus documentation and answering questions is just another way for me to learn a bit more about the matter. @G.M. Aug 30, 2014 at 20:10
  • That's a whole tutorial you wrote ! :) I'll see if I can make it work, for now just copypasta doesn't solve the problem.. I still see published pages whose parents are in draft.
    – mike23
    Aug 30, 2014 at 23:52
2

This answer is offering another way of doing this. The code is pretty much self-explaining, I named everything pretty literal to make it better understandable. What I did is constructing a function that determines the draft pages and their descendants, which than can be used with the exclude parameter of wp_list_pages().

Helper Function:

function wpse159627_exclude_draft_sub_trees() {
    $pages_any_status = get_posts(
        array(
            'post_type'              => 'page',
            'post_status'            => 'any',
            'posts_per_page'         => -1,
            // make this as inexpensive as possible
            'cache_results'          => false,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false
        )
    );
    $draft_posts_ids = array_filter(
        array_map(
            function ( $array_to_map ) {
                if( $array_to_map->post_status == 'draft' ) {
                    return $array_to_map->ID;
                } else {
                    return null;
                }
            },
            $pages_any_status
        )
    );
    $children_of_draft_posts_arr_of_obj = array();
    foreach ( $draft_posts_ids as $draft_id ) {
        $children_of_draft_posts_arr_of_obj[] = get_page_children(
            $draft_id,
            $pages_any_status
        );
    }
    $children_of_draft_posts = array();
    foreach ( $children_of_draft_posts_arr_of_obj as $object ) {
        foreach ( $object as $key => $value ) {
            $children_of_draft_posts[] = $value;
        }
    }
    $children_of_draft_posts_ids = array_map(
        function ( $array_to_map ) {
            return $array_to_map->ID;
        },
        $children_of_draft_posts
    );
    $exclude_from_list_pages = array_merge(
        $draft_posts_ids,
        $children_of_draft_posts_ids
    );
    $exclude_comma_sep_list = implode(',',$exclude_from_list_pages);
    return $exclude_comma_sep_list;
}

Usage:

$args = array(
    'sort_column' => 'menu_order',
    'title_li'    => '',
    'post_status' => 'publish',
    'exclude'     => wpse159627_exclude_draft_sub_trees()
);
wp_list_pages( $args );

If you are on a PHP version smaller than 5.3 you need a version without closures. In my book, to say that clearly, it is a mistake to operate on anything below 5.4. But I'm very well aware of the WordPress requirements, PHP 5.2.4, so here you go:

function wpse159627_extract_ids( $array_to_map ) {
    return $array_to_map->ID;
}
function wpse159627_extract_ids_of_drafts( $array_to_map ) {
    if( $array_to_map->post_status == 'draft' ) {
        return $array_to_map->ID;
    } else {
        return null;
    }
}
function wpse159627_exclude_draft_sub_trees_old_php() {
    $pages_any_status = get_posts(
        array(
            'post_type'              => 'page',
            'post_status'            => 'any',
            'posts_per_page'         => -1,
            // make this as inexpensive as possible
            'cache_results'          => false,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false
        )
    );
    $draft_posts_ids = array_filter(
        array_map(
            'wpse159627_extract_ids_of_drafts',
            $pages_any_status
        )
    );
    $children_of_draft_posts_arr_of_obj = array();
    foreach ( $draft_posts_ids as $draft_id ) {
        $children_of_draft_posts_arr_of_obj[] = get_page_children(
            $draft_id,
            $pages_any_status
        );
    }
    $children_of_draft_posts = array();
    foreach ( $children_of_draft_posts_arr_of_obj as $object ) {
        foreach ( $object as $key => $value ) {
            $children_of_draft_posts[] = $value;
        }
    }
    $exclude_from_list_pages = array_merge(
        $draft_posts_ids,
        $children_of_draft_posts_ids
    );
    $exclude_comma_sep_list = implode(',',$exclude_from_list_pages);
    return $exclude_comma_sep_list;
}

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.