diff --git a/includes/classes/Feature/WooCommerce/WooCommerce.php b/includes/classes/Feature/WooCommerce/WooCommerce.php index e3d6e204f..c33cc29fa 100644 --- a/includes/classes/Feature/WooCommerce/WooCommerce.php +++ b/includes/classes/Feature/WooCommerce/WooCommerce.php @@ -511,14 +511,14 @@ public function translate_args( $query ) { * Also make sure the orderby param affects only the main query */ if ( ! empty( $_GET['orderby'] ) && $query->is_main_query() ) { // phpcs:ignore WordPress.Security.NonceVerification - - switch ( $_GET['orderby'] ) { // phpcs:ignore WordPress.Security.NonceVerification + $orderby = sanitize_text_field( $_GET['orderby'] ); // phpcs:ignore WordPress.Security.NonceVerification + switch ( $orderby ) { // phpcs:ignore WordPress.Security.NonceVerification case 'popularity': $query->set( 'orderby', $this->get_orderby_meta_mapping( 'total_sales' ) ); $query->set( 'order', 'DESC' ); break; case 'price': - $query->set( 'order', 'ASC' ); + $query->set( 'order', $query->get( 'order', 'ASC' ) ); $query->set( 'orderby', $this->get_orderby_meta_mapping( '_price' ) ); break; case 'price-desc': @@ -530,10 +530,12 @@ public function translate_args( $query ) { $query->set( 'order', 'DESC' ); break; case 'date': - $query->set( 'orderby', $this->get_orderby_meta_mapping( 'date' ) ); - break; + case 'title': case 'ID': - $query->set( 'orderby', $this->get_orderby_meta_mapping( 'ID' ) ); + $query->set( 'orderby', $this->get_orderby_meta_mapping( $orderby ) ); + break; + case 'sku': + $query->set( 'orderby', $this->get_orderby_meta_mapping( '_sku' ) ); break; default: $query->set( 'orderby', $this->get_orderby_meta_mapping( 'menu_order' ) ); // Order by menu and title. @@ -560,11 +562,13 @@ public function get_orderby_meta_mapping( $meta_key ) { 'orderby_meta_mapping', array( 'ID' => 'ID', + 'title' => 'title date', 'menu_order' => 'menu_order title date', 'menu_order title' => 'menu_order title date', 'total_sales' => 'meta.total_sales.double date', '_wc_average_rating' => 'meta._wc_average_rating.double date', '_price' => 'meta._price.double date', + '_sku' => 'meta._sku.value.sortable date', ) ); @@ -807,6 +811,7 @@ public function setup() { add_filter( 'ep_weighting_fields_for_post_type', [ $this, 'add_product_attributes_to_weighting' ], 10, 2 ); add_filter( 'ep_weighting_default_post_type_weights', [ $this, 'add_product_default_post_type_weights' ], 10, 2 ); add_filter( 'ep_prepare_meta_data', [ $this, 'add_variations_skus_meta' ], 10, 2 ); + add_filter( 'request', [ $this, 'admin_product_list_request_query' ], 9 ); } /** @@ -973,6 +978,108 @@ function ( $variations_skus, $current_id ) { return $post_meta; } + /** + * Integrate ElasticPress with the WooCommerce Admin Product List. + * + * WooCommerce uses its `WC_Admin_List_Table_Products` class to control that screen. This + * function adds all necessary hooks to bypass the default behavior and integrate with ElasticPress. + * By default, WC runs a SQL query to get the Product IDs that match the list criteria and passes + * that list of IDs to the main WP_Query. This integration changes that process to a single query, run + * by ElasticPress. + * + * @since 4.2.0 + * @param array $query_vars Query vars. + * @return array + */ + public function admin_product_list_request_query( $query_vars ) { + global $typenow, $wc_list_table; + + // Return if not in the correct screen. + if ( ! is_a( $wc_list_table, 'WC_Admin_List_Table_Products' ) || 'product' !== $typenow ) { + return $query_vars; + } + + // Return if admin WP_Query integration is not turned on, i.e., Protect Content is not enabled. + if ( ! has_filter( 'ep_admin_wp_query_integration', '__return_true' ) ) { + return $query_vars; + } + + /** + * Filter to skip integration with WooCommerce Admin Product List. + * + * @hook ep_woocommerce_integrate_admin_products_list + * @since 4.2.0 + * @param {bool} $integrate True to integrate, false to preserve original behavior. Defaults to true. + * @param {array} $query_vars Query vars. + * @return {bool} New integrate value + */ + if ( ! apply_filters( 'ep_woocommerce_integrate_admin_products_list', true, $query_vars ) ) { + return $query_vars; + } + + add_action( 'pre_get_posts', [ $this, 'translate_args_admin_products_list' ], 12 ); + + // This short-circuits WooCommerce search for product IDs. + add_filter( 'woocommerce_product_pre_search_products', '__return_empty_array' ); + + return $query_vars; + } + + /** + * Apply the necessary changes to WP_Query in WooCommerce Admin Product List. + * + * @param WP_Query $query The WP Query being executed. + */ + public function translate_args_admin_products_list( $query ) { + // The `translate_args()` method sets it to `true` if we should integrate it. + if ( ! $query->get( 'ep_integrate', false ) ) { + return; + } + + // WooCommerce unsets the search term right after using it to fetch product IDs. Here we add it back. + $search_term = ! empty( $_GET['s'] ) ? sanitize_text_field( $_GET['s'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification + if ( ! empty( $search_term ) ) { + $query->set( 's', sanitize_text_field( $search_term ) ); // phpcs:ignore WordPress.Security.NonceVerification + + /** + * Filter to skip integration with WooCommerce Admin Product List. + * + * @hook ep_woocommerce_admin_products_list_search_fields + * @since 4.2.0 + * @param {bool} $integrate True to integrate, false to preserve original behavior. Defaults to true. + * @param {array} $query_vars Query vars. + * @return {bool} New integrate value + */ + $search_fields = apply_filters( + 'ep_woocommerce_admin_products_list_search_fields', + [ + 'post_title', + 'post_content', + 'post_excerpt', + 'meta' => [ + '_sku', + '_variations_skus', + ], + ] + ); + + $query->set( 'search_fields', $search_fields ); + } + + // Sets the meta query for `product_type` if needed. Also removed from the WP_Query by WC in `WC_Admin_List_Table_Products::query_filters()`. + $product_type_query = $query->get( 'product_type', '' ); + $product_type_url = ! empty( $_GET['product_type'] ) ? sanitize_text_field( $_GET['product_type'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification + $allowed_prod_types = [ 'virtual', 'downloadable' ]; + if ( empty( $product_type_query ) && ! empty( $product_type_url ) && in_array( $product_type_url, $allowed_prod_types, true ) ) { + $meta_query = $query->get( 'meta_query', [] ); + $meta_query[] = [ + 'key' => "_{$product_type_url}", + 'value' => 'yes', + ]; + $query->set( 'meta_query', $meta_query ); + } + } + /** * Determines whether or not ES should be integrating with the provided query * diff --git a/tests/php/features/TestWooCommerce.php b/tests/php/features/TestWooCommerce.php index 807e9271a..37ae7fe2d 100644 --- a/tests/php/features/TestWooCommerce.php +++ b/tests/php/features/TestWooCommerce.php @@ -284,4 +284,78 @@ public function testAddVariationsSkusMeta() { $this->assertContains( 'child-sku-1', $product_meta_to_index['_variations_skus'] ); $this->assertContains( 'child-sku-2', $product_meta_to_index['_variations_skus'] ); } + + /** + * Test the translate_args_admin_products_list method + * + * @since 4.2.0 + * @group woocommerce + */ + public function testTranslateArgsAdminProductsList() { + ElasticPress\Features::factory()->activate_feature( 'protected_content' ); + ElasticPress\Features::factory()->activate_feature( 'woocommerce' ); + ElasticPress\Features::factory()->setup_features(); + + parse_str( 'post_type=product&s=product&product_type=downloadable', $_GET ); + + $query_args = [ + 'ep_integrate' => true, + ]; + + $woocommerce_feature = ElasticPress\Features::factory()->get_registered_feature( 'woocommerce' ); + add_action( 'pre_get_posts', [ $woocommerce_feature, 'translate_args_admin_products_list' ] ); + + $query = new \WP_Query( $query_args ); + + $this->assertTrue( $query->elasticsearch_success ); + $this->assertEquals( $query->query_vars['s'], 'product' ); + $this->assertEquals( $query->query_vars['meta_query'][0]['key'], '_downloadable' ); + $this->assertEquals( $query->query_vars['meta_query'][0]['value'], 'yes' ); + $this->assertEquals( + $query->query_vars['search_fields'], + [ + 'post_title', + 'post_content', + 'post_excerpt', + 'meta' => [ + '_sku', + '_variations_skus', + ], + ] + ); + } + + /** + * Test the ep_woocommerce_admin_products_list_search_fields filter + * + * @since 4.2.0 + * @group woocommerce + */ + public function testEPWoocommerceAdminProductsListSearchFields() { + ElasticPress\Features::factory()->activate_feature( 'protected_content' ); + ElasticPress\Features::factory()->activate_feature( 'woocommerce' ); + ElasticPress\Features::factory()->setup_features(); + + parse_str( 'post_type=product&s=product&product_type=downloadable', $_GET ); + + $query_args = [ + 'ep_integrate' => true, + ]; + + $woocommerce_feature = ElasticPress\Features::factory()->get_registered_feature( 'woocommerce' ); + add_action( 'pre_get_posts', [ $woocommerce_feature, 'translate_args_admin_products_list' ] ); + + $search_fields_function = function() { + return [ 'post_title', 'post_content' ]; + }; + add_filter( 'ep_woocommerce_admin_products_list_search_fields', $search_fields_function ); + + $query = new \WP_Query( $query_args ); + $this->assertEquals( + $query->query_vars['search_fields'], + [ 'post_title', 'post_content' ] + ); + + remove_filter( 'ep_woocommerce_admin_products_list_search_fields', $search_fields_function ); + } }