Fix visibility of variable products with layered nav filtering.

The layered nav filtering doesn't work well with variable products
when some variations have stock and other don't. When a term is
selected in the widget, a variable product having no stock for
the variation corresponding to that term but having stock for
other variations will be displayed, but it shouldn't.

This commit fixes that by introducing two changes:

- A new override of "is_visible" for WC_Product_Variable that
  looks at the supplied filters, compares them against the corresponding
  available variations and calculates the visibility based on
  the query type (OR or AND).

- A hook on the "found_posts" filter in WC_Query, that adjusts
  the posts count based on the found products visibility
  when there are filters available; this is needed to sync the
  "displaying X posts" messages and the paging when variable
  products are hidden due to stock status.

Additionally, the visibility calculated in "found_posts" is cached
as loop variables so that it isn't calculated again when actually
displaying the products.
This commit is contained in:
Nestor Soriano 2020-04-23 15:07:09 +02:00
parent ff7884bdb6
commit 50e8f27bc7
8 changed files with 376 additions and 9 deletions

View File

@ -1501,6 +1501,16 @@ class WC_Product extends WC_Abstract_Legacy_Product {
* @return bool
*/
public function is_visible() {
$visible = $this->is_visible_core();
return apply_filters( 'woocommerce_product_is_visible', $visible, $this->get_id() );
}
/**
* Returns whether or not the product is visible in the catalog (doesn't trigger filters).
*
* @return bool
*/
protected function is_visible_core() {
$visible = 'visible' === $this->get_catalog_visibility() || ( is_search() && 'search' === $this->get_catalog_visibility() ) || ( ! is_search() && 'catalog' === $this->get_catalog_visibility() );
if ( 'trash' === $this->get_status() ) {
@ -1521,7 +1531,7 @@ class WC_Product extends WC_Abstract_Legacy_Product {
$visible = false;
}
return apply_filters( 'woocommerce_product_is_visible', $visible, $this->get_id() );
return $visible;
}
/**

View File

@ -572,6 +572,68 @@ class WC_Product_Variable extends WC_Product {
return true;
}
/**
* Returns whether or not the product is visible in the catalog (doesn't trigger filters).
*
* @return bool
*/
protected function is_visible_core() {
if ( ! $this->parent_is_visible_core() ) {
return false;
}
$query_filters = $this->get_layered_nav_chosen_attributes();
if ( empty( $query_filters ) ) {
return true;
}
/**
* If there are attribute filters in the request, a variable product will be visible
* only if at least one of the corresponding variations is visible (for OR filtering)
* or if all of them are (for AND filtering).
*
* Note that for "Any..." variations the attribute value will be empty, these must be
* always included in the result and hence the '' === $value check.
*/
$filter_attribute = array_keys( $query_filters )[0];
$temp = array_values( $query_filters )[0];
$filter_values = $temp['terms'];
$filter_type = $temp['query_type'];
$attributes = array();
foreach ( $this->get_available_variations() as $variation ) {
foreach ( $variation['attributes'] as $attribute => $value ) {
$attribute = substr( $attribute, strlen( 'attribute_' ) );
if ( $attribute === $filter_attribute && ( '' === $value || in_array( $value, $filter_values, true ) ) && ! in_array( $value, $attributes, true ) ) {
array_push( $attributes, $value );
}
}
}
return ( 'or' === $filter_type && count( $attributes ) > 0 ) || ( count( $attributes ) === count( $filter_values ) );
}
/**
* What does is_visible_core in the parent class say?
* This method exists to ease unit testing.
*
* @return bool
*/
protected function parent_is_visible_core() {
return parent::is_visible_core();
}
/**
* Get an array of attributes and terms selected with the layered nav widget.
* This method exists to ease unit testing.
*
* @return array
*/
protected function get_layered_nav_chosen_attributes() {
return WC()->query::get_layered_nav_chosen_attributes();
}
/*
|--------------------------------------------------------------------------
| Sync with child variations.

View File

@ -45,6 +45,7 @@ class WC_Query {
add_action( 'parse_request', array( $this, 'parse_request' ), 0 );
add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ) );
add_filter( 'the_posts', array( $this, 'remove_product_query_filters' ) );
add_filter( 'found_posts', array( $this, 'adjust_posts_count' ) );
add_filter( 'get_pagenum_link', array( $this, 'remove_add_to_cart_pagination' ), 10, 1 );
}
$this->init_query_vars();
@ -366,6 +367,59 @@ class WC_Query {
return $posts;
}
/**
* When the request is filtering by attributes via layered nav plugin we need to adjust the total posts count
* to account for variable products having stock in some variations but not in others.
* We do that by just checking if each product is visible.
*
* We also cache the post visibility so that it isn't checked again when displaying the posts list.
*
* @param int $count Original posts count, as supplied by the found_posts filter.
*
* @return int Adjusted posts count.
*/
public function adjust_posts_count( $count ) {
if ( empty( $this->get_layered_nav_chosen_attributes_inst() ) ) {
return $count;
}
$post_ids = $this->get_current_post_ids();
$count = 0;
foreach ( $post_ids as $id ) {
$product = wc_get_product( $id );
if ( ! is_object( $product ) ) {
continue;
}
if ( $product->is_visible() ) {
wc_set_loop_product_visibility( $id, true );
$count++;
} else {
wc_set_loop_product_visibility( $id, false );
}
}
wc_set_loop_prop( 'total', $count );
return $count;
}
/**
* Instance version of get_layered_nav_chosen_attributes, needed for unit tests.
*
* @return array
*/
protected function get_layered_nav_chosen_attributes_inst() {
return self::get_layered_nav_chosen_attributes();
}
/**
* Get the ids of the posts found in the current WP loop.
*
* @return array Array of post ids.
*/
protected function get_current_post_ids() {
return $GLOBALS['wp_query']->posts;
}
/**
* WP SEO meta description.
*

View File

@ -238,6 +238,27 @@ function wc_set_loop_prop( $prop, $value = '' ) {
$GLOBALS['woocommerce_loop'][ $prop ] = $value;
}
/**
* Set the current visbility for a product in the woocommerce_loop global.
*
* @param int $product_id Product it to cache visbiility for.
* @param bool $value The poduct visibility value to cache.
*/
function wc_set_loop_product_visibility( $product_id, $value ) {
wc_set_loop_prop( "product_visibility_$product_id", $value );
}
/**
* Gets the cached current visibility for a product from the woocommerce_loop global.
*
* @param int $product_id Product id to get the cached visibility for.
*
* @return bool|null The cached product visibility, or null if on visibility has been cached for that product.
*/
function wc_get_loop_product_visibility( $product_id ) {
return wc_get_loop_prop( "product_visibility_$product_id", null );
}
/**
* Should the WooCommerce loop be displayed?
*
@ -247,7 +268,7 @@ function wc_set_loop_prop( $prop, $value = '' ) {
* @return bool
*/
function woocommerce_product_loop() {
return have_posts() || 'products' !== woocommerce_get_loop_display_mode();
return wc_get_loop_prop( 'total' ) > 0 || 'products' !== woocommerce_get_loop_display_mode();
}
/**

View File

@ -20,7 +20,7 @@ defined( 'ABSPATH' ) || exit;
global $product;
// Ensure visibility.
if ( empty( $product ) || ! $product->is_visible() ) {
if ( empty( $product ) || false === wc_get_loop_product_visibility( $product->get_id() ) || ! $product->is_visible() ) {
return;
}
?>

View File

@ -103,14 +103,19 @@ class WC_Helper_Product {
}
/**
* Create a dummy variation product.
* Create a dummy variation product or configure an existing product object with dummy data.
*
*
* @since 2.3
*
* @param WC_Product_Variable|null $product Product object to configure, or null to create a new one.
* @return WC_Product_Variable
*/
public static function create_variation_product() {
$product = new WC_Product_Variable();
public static function create_variation_product( $product = null ) {
$is_new_product = is_null( $product );
if ( $is_new_product ) {
$product = new WC_Product_Variable();
}
$product->set_props(
array(
'name' => 'Dummy Variable Product',
@ -209,7 +214,12 @@ class WC_Helper_Product {
);
$variation_4->save();
return wc_get_product( $product->get_id() );
if ( $is_new_product ) {
return wc_get_product( $product->get_id() );
}
$product->set_children( array( $variation_1->get_id(), $variation_2->get_id(), $variation_3->get_id(), $variation_4->get_id() ) );
return $product;
}
/**

View File

@ -159,7 +159,7 @@ class WC_Tests_Product_Variable extends WC_Unit_Test_Case {
* @param string $expected_stock_status The expected stock status of the product after being saved.
*/
public function test_stock_status_on_save_when_managing_stock( $stock_quantity, $notify_no_stock_amount, $accepts_backorders, $expected_stock_status ) {
list($product, $child1, $child2) = $this->get_variable_product_with_children();
list( $product, $child1, $child2 ) = $this->get_variable_product_with_children();
update_option( 'woocommerce_notify_no_stock_amount', $notify_no_stock_amount );
@ -176,4 +176,142 @@ class WC_Tests_Product_Variable extends WC_Unit_Test_Case {
$this->assertEquals( $expected_stock_status, $product->get_stock_status() );
}
/**
* Setup for a test for is_visible.
*
* @param array $terms Terms for the "size" attribute that will be supplied as layered nav filtering.
* @param string $query_type Logical operation for the nav filtering, "or" or "and".
* @param bool $hide_out_of_stock_products Should the woocommerce_hide_out_of_stock_items option be set?.
* @param bool $is_visible_from_parent Return value of is_visible from base class.
*
* @return WC_Product_Variable A properly configured instance of WC_Product_Variable to test.
*/
private function prepare_visibility_test( $terms, $query_type, $hide_out_of_stock_products = true, $is_visible_from_parent = true ) {
if ( empty( $terms ) ) {
$layered_nav_chosen_attributes = array();
} else {
$layered_nav_chosen_attributes = array(
'pa_size' => array(
'terms' => $terms,
'query_type' => $query_type,
),
);
}
if ( $hide_out_of_stock_products ) {
update_option( 'woocommerce_hide_out_of_stock_items', 'yes' );
}
$sut = $this
->getMockBuilder( WC_Product_Variable::class )
->setMethods( array( 'parent_is_visible_core', 'get_layered_nav_chosen_attributes' ) )
->getMock();
$sut = WC_Helper_Product::create_variation_product( $sut );
$sut->save();
$sut->method( 'parent_is_visible_core' )->willReturn( $is_visible_from_parent );
$sut->method( 'get_layered_nav_chosen_attributes' )->willReturn( $layered_nav_chosen_attributes );
return $sut;
}
/**
* Configure the stock status for the "size" attribute-based variations of a product.
*
* @param WC_Product_Variable $product Product with the variations to configure.
* @param array $size_names Terms whose variations will have stock, all others won't have.
*/
private function set_size_variations_with_stock( $product, $size_names ) {
$variation_ids = $product->get_children();
foreach ( $variation_ids as $id ) {
$variation = wc_get_product( $id );
$size = $variation->get_attribute( 'pa_size' );
$variation->set_stock_status( in_array( $size, $size_names, true ) ? 'instock' : 'outofstock' );
$variation->save();
}
}
/**
* @testdox The product should be invisible when the parent 'is_visible' method returns false.
*/
public function test_is_invisible_when_parent_is_visible_returns_false() {
$sut = $this->prepare_visibility_test( array(), '', false, false );
$this->assertFalse( $sut->is_visible() );
}
/**
* @testdox The product should be visible when no nav filtering is supplied if at least one variation has stock.
*
* Note that if no variations have stock the base is_visible will already return false.
*/
public function test_is_visible_when_no_filtering_supplied_and_at_least_one_variation_has_stock() {
$sut = $this->prepare_visibility_test( array(), '' );
$this->set_size_variations_with_stock( $sut, array( 'small' ) );
$this->assertTrue( $sut->is_visible() );
}
/**
* @testdox Test product visibility when the variation requested in nav filtering has no stock, result depends on woocommerce_hide_out_of_stock_items option.
*
* @param bool $hide_out_of_stock Value for woocommerce_hide_out_of_stock_items.
* @param string $query_type "or" or "and".
* @param bool $expected_visibility Expected value of is_visible for the tested product.
*
* @testWith [true, "or", false]
* [false, "or", true]
* [true, "and", false]
* [false, "and", true]
*/
public function test_visibility_when_supplied_filter_has_no_stock( $hide_out_of_stock, $query_type, $expected_visibility ) {
$sut = $this->prepare_visibility_test( array( 'large' ), $query_type, $hide_out_of_stock );
$this->set_size_variations_with_stock( $sut, array( 'small' ) );
$this->assertEquals( $expected_visibility, $sut->is_visible() );
}
/**
* @testdox Test product visibility when only one of the variations requested in nav filtering has stock, result depends on woocommerce_hide_out_of_stock_items option and query type.
*
* @param bool $hide_out_of_stock Value for woocommerce_hide_out_of_stock_items.
* @param string $query_type "or" or "and".
* @param bool $expected_visibility Expected value of is_visible for the tested product.
*
* @testWith [true, "or", true]
* [false, "or", true]
* [true, "and", false]
* [false, "and", true]
*/
public function test_visibility_when_multiple_filters_supplied_and_only_one_has_stock( $hide_out_of_stock, $query_type, $expected_visibility ) {
$sut = $this->prepare_visibility_test( array( 'small', 'large' ), $query_type, $hide_out_of_stock );
$this->set_size_variations_with_stock( $sut, array( 'small' ) );
$this->assertEquals( $expected_visibility, $sut->is_visible() );
}
/**
* @testdox Product should always be visible when all of the variations requested in nav filtering have stock.
*
* @param bool $hide_out_of_stock Value for woocommerce_hide_out_of_stock_items.
* @param string $query_type "or" or "and".
* @param bool $expected_visibility Expected value of is_visible for the tested product.
*
* @testWith [true, "or", true]
* [false, "or", true]
* [true, "and", true]
* [false, "and", true]
*/
public function test_visibility_when_multiple_filters_supplied_and_all_of_them_have_stock( $hide_out_of_stock, $query_type, $expected_visibility ) {
$sut = $this->prepare_visibility_test( array( 'small', 'large' ), $query_type, $hide_out_of_stock );
$this->set_size_variations_with_stock( $sut, array( 'small', 'large' ) );
$this->assertEquals( $expected_visibility, $sut->is_visible() );
}
}

View File

@ -437,4 +437,76 @@ class WC_Tests_WC_Query extends WC_Unit_Test_Case {
WC()->query->remove_ordering_args();
}
/**
* Setup for a test for adjust_posts.
*
* @param bool $with_nav_filtering_data Should WC_Query::get_layered_nav_chosen_attributes return filtering data?.
*
* @return array An array where the first element is the instance of WC_Query, and the second is an array of sample products created.
*/
private function setup_adjust_posts_test( $with_nav_filtering_data ) {
update_option( 'woocommerce_hide_out_of_stock_items', 'yes' );
if ( $with_nav_filtering_data ) {
$nav_filtering_data = array( 'pa_something' => array( 'terms' => array( 'foo', 'bar' ) ) );
} else {
$nav_filtering_data = array();
}
$products = array();
$product_ids = array();
for ( $i = 0; $i < 5; $i++ ) {
$product = WC_Helper_Product::create_simple_product();
array_push( $products, $product );
array_push( $product_ids, $product->get_id() );
}
$products[0]->set_stock_status( 'outofstock' );
$sut = $this
->getMockBuilder( WC_Query::class )
->setMethods( array( 'get_current_post_ids', 'get_layered_nav_chosen_attributes_inst' ) )
->getMock();
$sut->method( 'get_current_post_ids' )->willReturn( $product_ids );
$sut->method( 'get_layered_nav_chosen_attributes_inst' )->willReturn( $nav_filtering_data );
return array( $sut, $products );
}
/**
* @testdox adjust_posts should do nothing when there are no nav filtering attributes in the request.
*/
public function test_adjust_posts_count_without_nav_filtering_attributes() {
list($sut, $products) = $this->setup_adjust_posts_test( false );
$products[0]->set_stock_status( 'outofstock' );
$products[0]->save();
$this->assertEquals( 34, $sut->adjust_posts_count( 34 ) );
foreach ( $products as $product ) {
$this->assertNull( wc_get_loop_product_visibility( $product->get_id() ) );
}
}
/**
* @testdox adjust_posts should return the number of visible products, and create product visibility loop variables, then there are nav filtering attributes in the request.
*/
public function test_adjust_posts_count_with_nav_filtering_attributes() {
list($sut, $products) = $this->setup_adjust_posts_test( true );
$products[0]->set_stock_status( 'outofstock' );
$products[0]->save();
$products[1]->set_stock_status( 'outofstock' );
$products[1]->save();
$this->assertEquals( 3, $sut->adjust_posts_count( 34 ) );
$this->assertEquals( 3, wc_get_loop_prop( 'total' ) );
$this->assertEquals( false, wc_get_loop_product_visibility( $products[0]->get_id() ) );
$this->assertEquals( false, wc_get_loop_product_visibility( $products[1]->get_id() ) );
foreach ( array_slice( $products, 2 ) as $product ) {
$this->assertEquals( true, wc_get_loop_product_visibility( $product->get_id() ) );
}
}
}