2

I've setup a custom query, I'm using Woocommerce and I am aware that it has it's own functionality, but have been tasked with creating custom filtering with a traditionally paged product/category archive pages

What have I done so far and what is happening?

Issue: The first page does not return the correct number of posts

This is my first real exposure with Woocom and custom filtering. I am getting some strange results and I have obviously made an error in my queries and filtering.

I have setup my query as normal using WP_Query along with associated $args

// archive-product.php

// Set some basic defaults here
$paged = (get_query_var('paged')) ? get_query_var('paged') : 1;
$post_type = 'product';
$post_num = 16;
$orderby = array(
  '_stock_status' => 'ASC',
  '_price' => 'DESC',
);

$tax_query = $meta_query = null;

 $args = array(
    'post_type' => $post_type,
    'posts_per_page' => $post_num,
    'post_status' => 'publish',
    'paged' => $paged,
    'tax_query' => $tax_query,
    'meta_query' => $meta_query,
    // Order by stock status first, then by price
    'orderby' => $orderby
  );

$products = new WP_Query($args);

This snippet here will give some context to the first issue I am encountering. On page 1, the query returns 15, not 16 items. The following pages appear to display the correct number, but the first page misses one off.

I did add the following to my functions file (and removed posts_per_page from $args) but it do not seem to resolve it.

// functions.php

function custom_posts_per_page( $query ) {
  if ( !$query->is_main_query() && !is_admin() ) {
    if( $query->get('post_type') == 'product' ) {
      $query->set( 'posts_per_page', 16 );
    }
  }
}

add_action( 'pre_get_posts', 'custom_posts_per_page' );

In the admin area, under Reading, the number of posts is also set to 16.

Issue: When filtered, pagination still shows as if all products are being queried, but the last few pages are completely empty

As I mentioned, this is my first real exposure to custom queries in WP. No doubt there is an issue in how I have set this up, but I will attempt to explain what I am doing

$meta_query = array(
  'relation' => 'AND', array(
    '_stock_status' => array(
      'key' => '_stock_status',
      'compare' => 'EXISTS',
    ),
    '_price' => array(
      'key' => '_price',
      'compare' => 'EXISTS',
      'type' => 'NUMERIC'
    ),
  ),
);

The$meta_query is being setup in this way as the products need to first sort by _stock_status then by _price.

If someone selects a product price, its value is added to the $meta_query

// If a selection to product price has been made, add this to the meta query
if( isset($_GET['product-price']) && $_GET['product-price'] != '' ) {
  $arr = explode(':', $_GET['product-price']);

  $meta_query[] = array(
    'relation' => 'AND', array(
      'key' => '_price',
      'value' => $arr,
      'type' => 'numeric',
      'compare' => 'BETWEEN'
    ),
  );
};

And similar is happening for product-type and product-brand, except they modify the $tax_query

// If a selection to product type has been made, add this to the tax query
if( isset($_GET['product-type']) && $_GET['product-type'] != '' ) {
  $tax_query[] = array(
    'relation' => 'AND', array(
      'taxonomy' => 'product_cat',
      'field' => 'slug',
      'terms' => $_GET['product-type']
    )
  );
};

// If a selection to product brand has been made, add this to the tax query
if(  isset($_GET['product-brand']) && $_GET['product-brand'] != '' ) {
  $tax_query[] = array(
    'relation' => 'AND', array(
      'taxonomy' => 'pa_brands',
      'field' => 'slug',
      'terms' => array($_GET['product-brand'])
    )
  );
};

I appear to be getting the correct filtering, so based on the arguments passed to $_GET the query is being modified accordingly. Apart from the pagination - which is a custom implementation (could this be the cause?):


function wordpress_numeric_post_nav() {
    if( is_singular() )
        return;
    global $wp_query;
    /* Stop the code if there is only a single page page */
    if( $wp_query->max_num_pages <= 1 )
        return;
    $paged = get_query_var( 'paged' ) ? absint( get_query_var( 'paged' ) ) : 1;
    $max   = intval( $wp_query->max_num_pages );
    /*Add current page into the array */
    if ( $paged >= 1 )
        $links[] = $paged;
    /*Add the pages around the current page to the array */
    if ( $paged >= 3 ) {
        $links[] = $paged - 1;
        $links[] = $paged - 2;
    }
    if ( ( $paged + 2 ) <= $max ) {
        $links[] = $paged + 2;
        $links[] = $paged + 1;
    }

    $arrow = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 51 37.875">
                    <path d="M52.373,30.011,36.643,14.731a3.871,3.871,0,0,0-5.315,0,3.609,3.609,0,0,0,0,5.163l9.287,9.074H6.258a3.652,3.652,0,1,0,0,7.3H40.616l-9.287,9.022a3.609,3.609,0,0,0,0,5.163,3.837,3.837,0,0,0,5.368,0L52.373,35.227A3.661,3.661,0,0,0,53.5,32.619,3.457,3.457,0,0,0,52.373,30.011Z" transform="translate(53.5 51.55) rotate(180)"/>
                </svg>';

    echo '<div class="e-pagination pagination-navigation">
            <div class="e-pagination__inner">
                <div class="e-pagination__container">
                    <ul class="e-pagination__list">' . "\n";
    /*Display Previous Post Link */
    if ( get_previous_posts_link() )
        printf( '<li class="e-pagination__prev">%s</li>' . "\n", get_previous_posts_link($arrow) );
    /*Display Link to first page*/
    if ( ! in_array( 1, $links ) ) {
        $class = 1 == $paged ? ' class="active"' : '';
        printf( '<li%s><a href="%s">%s</a></li>' . "\n", $class, esc_url( get_pagenum_link( 1 ) ), '1' );
        if ( ! in_array( 2, $links ) )
            echo '<li>…</li>';
    }
    /* Link to current page */
    sort( $links );
    foreach ( (array) $links as $link ) {
        $class = $paged == $link ? ' class="active"' : '';
        printf( '<li%s><a href="%s">%s</a></li>' . "\n", $class, esc_url( get_pagenum_link( $link ) ), $link );
    }
    /* Link to last page, plus ellipses if necessary */
    if ( ! in_array( $max, $links ) ) {
        if ( ! in_array( $max - 1, $links ) )
            echo '<li>…</li>' . "\n";
        $class = $paged == $max ? ' class="active"' : '';
        printf( '<li%s><a href="%s">%s</a></li>' . "\n", $class, esc_url( get_pagenum_link( $max ) ), $max );
    }
    /** Next Post Link */
    if ( get_next_posts_link() )
        printf( '<li class="e-pagination__next">%s</li>' . "\n", get_next_posts_link($arrow) );
        echo '</ul>
            </div>
        </div>
    </div>' . "\n";
}

That seems to be a complete outline of the issue I am having, any help would be appreciated

// complete archive-product.php


  <form class="filter" method="GET">
      <div class="container">
          Filters: 
          <?php 
          
          /**
           * Product Type Selection
          */
          $product_types = get_terms([
            'taxonomy' => 'product_cat',
            'hide_empty' => false,
            'parent' => $product_cat_term_id,
          ]);
          
          ?>
          <select class="select" name="product-type" id="product-type" onchange="this.form.submit();">
            <option value="">Product Type</option>
            <?php foreach($product_types as $product_type) : ?>
            <option value="<?= $product_type->slug ?>" <?= (isset($_GET['product-type'])) ? selected($_GET['product-type'], $product_type->slug) : null ?>><?= $product_type->name ?></option>
            <?php endforeach; ?>
          </select>

          <?php 
          /**
           * Product Brand Selection
          */
          $product_brands = get_terms( [
            'taxonomy'    => 'pa_brands', 
            'hide_empty'  => true,
            'parent'      => 0
          ]); 

          ?>
          <select class="select" name="product-brand" id="product-brand" onchange="this.form.submit();">
            <option value="">Brand</option>
            <?php foreach($product_brands as $product_brand) : ?>
            <option value="<?= $product_brand->slug ?>" <?= (isset($_GET['product-brand'])) ? selected($_GET['product-brand'], $product_brand->slug) : null ?>><?= $product_brand->name ?></option>
            <?php endforeach; ?>
          </select>

          <?php 
          /**
           * Product Price Selection
          */
          ?>
          <select class="select" name="product-price" id="product-price" onchange="this.form.submit();">
            <option value="">Price</option>
            <option value="0:99" <?= (isset($_GET['product-price'])) ? selected($_GET['product-price'], '0:99') : null ?>>Under £100</option>
            <option value="100:250" <?= (isset($_GET['product-price'])) ? selected($_GET['product-price'], '100:250') : null ?>>£100 - £250</option>
            <option value="251:10000" <?= (isset($_GET['product-price'])) ? selected($_GET['product-price'], '251:10000') : null ?>>Over £250</option>
          </select>
    </div>
  </form>

  <div class="list">
    <div class="b-product-list__items">
        <?php

        if($products->have_posts()) :
          while($products->have_posts()) : $products->the_post();
            wc_get_template_part( 'content', 'product' );
          endwhile; wp_reset_postdata();
        endif;

        ?>
        <?php echo wordpress_numeric_post_nav(); ?>
    </div>
  </div>

1 Answer 1

1
+100

Your query syntax looked good, although you could instead add a direct meta/taxonomy query clause array such as $meta_query[] = array( 'key' => '_price', ... ) instead of $meta_query[] = array( 'relation' => 'AND', array( 'key' => '_price', ... ) ).

However, I spotted 2 main issues with your code:

Issue 1: Your custom pagination function will not work correctly with secondary WP_Query queries.

Because the function (wordpress_numeric_post_nav) was intended only for the main WordPress query which is referenced using the global $wp_query object.

So if you want that function to work with secondary or custom WP_Query queries, then you need to adjust your function so that it supports custom WP_Query instances like your $products object.

Here's a simple modification you can try:

  1. Change the function wordpress_numeric_post_nav() to:

    function wordpress_numeric_post_nav( $wp_query = null )
    
  2. Then in that function, change the global $wp_query; to:

    if ( ! is_a( $wp_query, 'WP_Query' ) ) {
        global $wp_query;
    }
    

So with that, you'll display the pagination like so:

echo wordpress_numeric_post_nav( $products );

Issue 2: You should use the pre_get_posts action and not creating a secondary WP_Query query and loop.

The main query also uses paged as the pagination query var (which is read from the URL of, or matching rewrite rule for the current page), hence it will conflict with the pagination for secondary WP_Query queries on archive pages like your product post type archive, because the main query could have less number of pages than secondary queries, leading to 404 errors.

So although 404 error isn't or may not be the problem here, I strongly suggest you to use the pre_get_posts action to apply your custom meta and taxonomy filters.

So just remove that $products = new WP_Query($args); (and the variables, etc. used for that piece of code) from your template (archive-product.php) and then:

  1. In the theme functions file, add this snippet:

    add_action( 'pre_get_posts', 'my_product_archive_pre_get_posts' );
    function my_product_archive_pre_get_posts( $query ) {
        // Checks whether we are on the product archive page and that the current
        // WP_Query instance is the main query. We also don't want to affect admin
        // posts queries, hence we do `! is_admin()` check.
        if ( ! is_admin() && is_post_type_archive( 'product' ) &&
            $query->is_main_query()
        ) {
            $tax_query  = array( 'relation' => 'AND' );
            $meta_query = array( 'relation' => 'AND' );
    
            // Sorts the posts by the stock status and then price.
            $meta_query[] = array(
                'relation'      => 'AND',
                '_stock_status' => array(
                    'key'     => '_stock_status',
                    'compare' => 'EXISTS',
                ),
                '_price'        => array(
                    'key'     => '_price',
                    'compare' => 'EXISTS',
                ),
            );
    
            // Filter by a specific price range.
            if ( ! empty( $_GET['product-price'] ) ) {
                $range  = sanitize_text_field( $_GET['product-price'] );
                $prices = array_map( 'intval', explode( ':', $range ) );
    
                $meta_query[] = array(
                    'key'     => '_price',
                    'value'   => $prices,
                    'type'    => 'NUMERIC',
                    'compare' => 'BETWEEN',
                );
            }
    
            // Filter by a specific product type.
            if ( ! empty( $_GET['product-type'] ) ) {
                $tax_query[] = array(
                    'taxonomy' => 'product_cat',
                    'field'    => 'slug',
                    'terms'    => sanitize_text_field( $_GET['product-type'] ),
                );
            }
    
            // Filter by a specific product brand.
            if ( ! empty( $_GET['product-brand'] ) ) {
                $tax_query[] = array(
                    'taxonomy' => 'pa_brands',
                    'field'    => 'slug',
                    'terms'    => sanitize_text_field( $_GET['product-brand'] ),
                );
            }
    
            // Now modify the main query's arguments.
            $query->set( 'posts_per_page', 16 );
            $query->set( 'tax_query', $tax_query );
            $query->set( 'meta_query', $meta_query );
            $query->set(
                'orderby',
                array(
                    '_stock_status' => 'ASC',
                    '_price'        => 'DESC',
                )
            );
        }
    }
    

    Hint: You can actually just use the '<taxonomy>' => '<term slug>' format, hence you can remove the $tax_query parts and do something like:

    // Filter by a specific product type.
    if ( ! empty( $_GET['product-type'] ) ) {
        $query->set( 'product_cat', sanitize_text_field( $_GET['product-type'] ) );
    }
    
    // Filter by a specific product brand.
    if ( ! empty( $_GET['product-brand'] ) ) {
        $query->set( 'pa_brands', sanitize_text_field( $_GET['product-brand'] ) );
    }
    
  2. In your template, replace the $products->have_posts() with have_posts() and then the $products->the_post() with the_post(), i.e. just use the main loop to display the posts.

That way, you would not need to modify your pagination function and simply use echo wordpress_numeric_post_nav(); to display the pagination.

Additional Notes

  1. On singular pages like a Page (post type page), your secondary WP_Query query and loop, as well as the pagination, would work properly. (Because the main query does not use paged)

  2. WordPress core has built-in pagination functions like the_posts_pagination() and paginate_links().

  3. You can learn more about the main (WordPress) query at https://codex.wordpress.org/Query_Overview.

    Sorry, I could not find an updated version of the above article on the WordPress Developer Resources site..

1
  • 1
    That was very useful, and great in-depth explanations of where I was going wrong. For the past few years my go-to for custom queries has been WP_Query, in all situations, other than the main loop so its a bit of an eye-opener to see that it may have been potentially causing issues in other builds. However, its reassuring to see that I have the basics of tax and meta queries understood.
    – Bagseye
    Commented Mar 13, 2023 at 22:13

Your Answer

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

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