From 2099008a72a6bfac73ce79c16c7b2c4ededdc373 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Fri, 30 Aug 2024 16:15:17 -0700 Subject: [PATCH 01/26] Fixed PHP 7.4 Compatibility The splat operator does not support associative arrays until PHP 8.1. --- .../Blocks/BlockTypes/ProductCollection.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index f2f9f72328a..09fe61976fa 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -304,14 +304,16 @@ class ProductCollection extends AbstractBlock { $p->set_attribute( 'data-wc-context', wp_json_encode( - array( - ...$current_context, - // The message to be announced by the screen reader when the page is loading or loaded. - 'accessibilityLoadingMessage' => __( 'Loading page, please wait.', 'woocommerce' ), - 'accessibilityLoadedMessage' => __( 'Page Loaded.', 'woocommerce' ), - // We don't prefetch the links if user haven't clicked on pagination links yet. - // This way we avoid prefetching when the page loads. - 'isPrefetchNextOrPreviousLink' => false, + array_merge( + $current_context, + array( + // The message to be announced by the screen reader when the page is loading or loaded. + 'accessibilityLoadingMessage' => __( 'Loading page, please wait.', 'woocommerce' ), + 'accessibilityLoadedMessage' => __( 'Page Loaded.', 'woocommerce' ), + // We don't prefetch the links if user haven't clicked on pagination links yet. + // This way we avoid prefetching when the page loads. + 'isPrefetchNextOrPreviousLink' => false, + ), ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) From 674475eaaec22f47bbba996f4d70cf007845046a Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Fri, 30 Aug 2024 16:37:03 -0700 Subject: [PATCH 02/26] Added Related Products Collection Boilerplate --- .../product-collection/collections/index.tsx | 2 + .../collections/related.tsx | 64 +++++++++++++++++++ .../js/blocks/product-collection/types.ts | 1 + 3 files changed, 67 insertions(+) create mode 100644 plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related.tsx diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx index 869b7275469..2a24183d705 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx @@ -20,6 +20,7 @@ import topRated from './top-rated'; import bestSellers from './best-sellers'; import onSale from './on-sale'; import featured from './featured'; +import related from './related'; const collections: BlockVariation[] = [ productCollection, @@ -28,6 +29,7 @@ const collections: BlockVariation[] = [ onSale, bestSellers, newArrivals, + related, ]; export const registerCollections = () => { diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related.tsx new file mode 100644 index 00000000000..114e7e115c2 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related.tsx @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import type { InnerBlockTemplate } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; +import { Icon, loop } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { INNER_BLOCKS_PRODUCT_TEMPLATE } from '../constants'; +import { CoreCollectionNames, CoreFilterNames, LayoutOptions } from '../types'; + +const collection = { + name: CoreCollectionNames.RELATED, + title: __( 'Related Products', 'woocommerce' ), + icon: , + description: __( 'Recommend products like this one.', 'woocommerce' ), + keywords: [ 'related', 'product collection' ], + scope: [], + preview: { + initialPreviewState: { + isPreview: true, + previewMessage: __( + 'Actual products will vary depending on the product being viewed.', + 'woocommerce' + ), + }, + }, +}; + +const attributes = { + displayLayout: { + type: LayoutOptions.GRID, + columns: 4, + shrinkColumns: true, + }, + query: { + perPage: 4, + pages: 1, + }, + hideControls: [ CoreFilterNames.FILTERABLE ], +}; + +const heading: InnerBlockTemplate = [ + 'core/heading', + { + textAlign: 'center', + level: 2, + content: __( 'You may also like', 'woocommerce' ), + style: { spacing: { margin: { bottom: '1rem' } } }, + }, +]; + +const innerBlocks: InnerBlockTemplate[] = [ + heading, + INNER_BLOCKS_PRODUCT_TEMPLATE, +]; + +export default { + ...collection, + attributes, + innerBlocks, +}; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts index 4407c682abe..9e67c249445 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts @@ -140,6 +140,7 @@ export enum CoreCollectionNames { NEW_ARRIVALS = 'woocommerce/product-collection/new-arrivals', ON_SALE = 'woocommerce/product-collection/on-sale', TOP_RATED = 'woocommerce/product-collection/top-rated', + RELATED = 'woocommerce/product-collection/related', } export enum CoreFilterNames { From c5e783606a1dc2e2ea4f261568316d8657d4b827 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Sat, 31 Aug 2024 11:23:30 -0700 Subject: [PATCH 03/26] Added `woocommerceRelatedTo` Collection Parameter This parameter allows the block to query for products that are related to the given product IDs. --- .../register-product-collection.tsx | 12 ++++ .../product-collection/collections/index.tsx | 4 +- .../{related.tsx => related-to.tsx} | 2 +- .../js/blocks/product-collection/constants.ts | 3 + .../js/blocks/product-collection/types.ts | 6 +- ...s-wc-product-collection-block-tracking.php | 5 ++ .../Blocks/BlockTypes/ProductCollection.php | 71 ++++++++++++++----- .../Blocks/BlockTypes/ProductCollection.php | 63 ++++++++++++++++ 8 files changed, 144 insertions(+), 22 deletions(-) rename plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/{related.tsx => related-to.tsx} (97%) diff --git a/plugins/woocommerce-blocks/assets/js/blocks-registry/product-collection/register-product-collection.tsx b/plugins/woocommerce-blocks/assets/js/blocks-registry/product-collection/register-product-collection.tsx index 516fb64e48d..05b15355c84 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks-registry/product-collection/register-product-collection.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks-registry/product-collection/register-product-collection.tsx @@ -222,6 +222,15 @@ const isValidCollectionConfig = ( config: ProductCollectionConfig ) => { 'Invalid woocommerceHandPickedProducts: woocommerceHandPickedProducts must be an array.' ); } + // attributes.query.woocommerceRelatedTo + if ( + config.attributes?.query?.woocommerceRelatedTo !== undefined && + ! Array.isArray( config.attributes.query.woocommerceRelatedTo ) + ) { + console.warn( + 'Invalid woocommerceRelatedTo: woocommerceRelatedTo must be an array.' + ); + } // attributes.query.priceRange if ( config.attributes?.query?.priceRange !== undefined && @@ -402,6 +411,9 @@ export const __experimentalRegisterProductCollection = ( woocommerceHandPickedProducts: query.woocommerceHandPickedProducts, } ), + ...( query.woocommerceRelatedTo !== undefined && { + woocommerceRelatedTo: query.woocommerceRelatedTo, + } ), ...( query.priceRange !== undefined && { priceRange: query.priceRange, } ), diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx index 2a24183d705..708d2b2224b 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx @@ -20,7 +20,7 @@ import topRated from './top-rated'; import bestSellers from './best-sellers'; import onSale from './on-sale'; import featured from './featured'; -import related from './related'; +import relatedTo from './related-to'; const collections: BlockVariation[] = [ productCollection, @@ -29,7 +29,7 @@ const collections: BlockVariation[] = [ onSale, bestSellers, newArrivals, - related, + relatedTo, ]; export const registerCollections = () => { diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx similarity index 97% rename from plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related.tsx rename to plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx index 114e7e115c2..a8ca8022023 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx @@ -12,7 +12,7 @@ import { INNER_BLOCKS_PRODUCT_TEMPLATE } from '../constants'; import { CoreCollectionNames, CoreFilterNames, LayoutOptions } from '../types'; const collection = { - name: CoreCollectionNames.RELATED, + name: CoreCollectionNames.RELATED_TO, title: __( 'Related Products', 'woocommerce' ), icon: , description: __( 'Recommend products like this one.', 'woocommerce' ), diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/constants.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/constants.ts index 98a1d9be2f6..96d34f5b949 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/constants.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/constants.ts @@ -60,6 +60,7 @@ export const DEFAULT_QUERY: ProductCollectionQuery = { woocommerceStockStatus: getDefaultStockStatuses(), woocommerceAttributes: [], woocommerceHandPickedProducts: [], + woocommerceRelatedTo: [], timeFrame: undefined, priceRange: undefined, filterable: false, @@ -90,6 +91,7 @@ export const DEFAULT_FILTERS: Pick< | 'woocommerceStockStatus' | 'woocommerceAttributes' | 'woocommerceHandPickedProducts' + | 'woocommerceRelatedTo' | 'taxQuery' | 'featured' | 'timeFrame' @@ -99,6 +101,7 @@ export const DEFAULT_FILTERS: Pick< woocommerceStockStatus: DEFAULT_QUERY.woocommerceStockStatus, woocommerceAttributes: DEFAULT_QUERY.woocommerceAttributes, woocommerceHandPickedProducts: DEFAULT_QUERY.woocommerceHandPickedProducts, + woocommerceRelatedTo: DEFAULT_QUERY.woocommerceRelatedTo, taxQuery: DEFAULT_QUERY.taxQuery, featured: DEFAULT_QUERY.featured, timeFrame: DEFAULT_QUERY.timeFrame, diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts index 9e67c249445..22f9af2b8d7 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts @@ -93,6 +93,10 @@ export interface ProductCollectionQuery { woocommerceAttributes: AttributeMetadata[]; isProductCollectionBlock: boolean; woocommerceHandPickedProducts: string[]; + /** + * Filter for products related to the given list of product IDs. + */ + woocommerceRelatedTo: string[]; priceRange: undefined | PriceRange; filterable: boolean; } @@ -140,7 +144,7 @@ export enum CoreCollectionNames { NEW_ARRIVALS = 'woocommerce/product-collection/new-arrivals', ON_SALE = 'woocommerce/product-collection/on-sale', TOP_RATED = 'woocommerce/product-collection/top-rated', - RELATED = 'woocommerce/product-collection/related', + RELATED_TO = 'woocommerce/product-collection/related', } export enum CoreFilterNames { diff --git a/plugins/woocommerce/includes/tracks/events/class-wc-product-collection-block-tracking.php b/plugins/woocommerce/includes/tracks/events/class-wc-product-collection-block-tracking.php index 092be4f81ee..948c20175ae 100644 --- a/plugins/woocommerce/includes/tracks/events/class-wc-product-collection-block-tracking.php +++ b/plugins/woocommerce/includes/tracks/events/class-wc-product-collection-block-tracking.php @@ -247,6 +247,7 @@ class WC_Product_Collection_Block_Tracking { $filters = array( 'inherit' => 'no', 'order-by' => 'no', + 'related-to' => 'no', 'on-sale' => 'no', 'stock-status' => 'no', 'handpicked' => 'no', @@ -303,6 +304,10 @@ class WC_Product_Collection_Block_Tracking { $filters['handpicked'] = 'yes'; } + if ( ! empty( $query_attrs['woocommerceRelatedTo'] ) ) { + $filters['related-to'] = 'yes'; + } + if ( ! empty( $query_attrs['search'] ) ) { $filters['keyword'] = 'yes'; } diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index 09fe61976fa..fdea9df3cd1 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -609,6 +609,7 @@ class ProductCollection extends AbstractBlock { $stock_status = $request->get_param( 'woocommerceStockStatus' ); $product_attributes = $request->get_param( 'woocommerceAttributes' ); $handpicked_products = $request->get_param( 'woocommerceHandPickedProducts' ); + $related_to = $request->get_param( 'woocommerceRelatedTo' ); $featured = $request->get_param( 'featured' ); $time_frame = $request->get_param( 'timeFrame' ); $price_range = $request->get_param( 'priceRange' ); @@ -624,6 +625,7 @@ class ProductCollection extends AbstractBlock { 'stock_status' => $stock_status, 'product_attributes' => $product_attributes, 'handpicked_products' => $handpicked_products, + 'related_to' => $related_to, 'featured' => $featured, 'timeFrame' => $time_frame, 'priceRange' => $price_range, @@ -749,24 +751,35 @@ class ProductCollection extends AbstractBlock { * @param bool $is_exclude_applied_filters Whether to exclude the applied filters or not. */ private function get_final_query_args( $common_query_values, $query, $is_exclude_applied_filters = false ) { - $handpicked_products = $query['handpicked_products'] ?? array(); - $orderby_query = $query['orderby'] ? $this->get_custom_orderby_query( $query['orderby'] ) : array(); - $on_sale_query = $this->get_on_sale_products_query( $query['on_sale'] ); - $stock_query = $this->get_stock_status_query( $query['stock_status'] ); - $visibility_query = is_array( $query['stock_status'] ) ? $this->get_product_visibility_query( $stock_query, $query['stock_status'] ) : array(); - $featured_query = $this->get_featured_query( $query['featured'] ?? false ); - $attributes_query = $this->get_product_attributes_query( $query['product_attributes'] ); - $taxonomies_query = $query['taxonomies_query'] ?? array(); - $tax_query = $this->merge_tax_queries( $visibility_query, $attributes_query, $taxonomies_query, $featured_query ); - $date_query = $this->get_date_query( $query['timeFrame'] ?? array() ); - $price_query_args = $this->get_price_range_query_args( $query['priceRange'] ?? array() ); + $orderby_query = $query['orderby'] ? $this->get_custom_orderby_query( $query['orderby'] ) : array(); + $on_sale_query = $this->get_on_sale_products_query( $query['on_sale'] ); + $stock_query = $this->get_stock_status_query( $query['stock_status'] ); + $visibility_query = is_array( $query['stock_status'] ) ? $this->get_product_visibility_query( $stock_query, $query['stock_status'] ) : array(); + $featured_query = $this->get_featured_query( $query['featured'] ?? false ); + $attributes_query = $this->get_product_attributes_query( $query['product_attributes'] ); + $taxonomies_query = $query['taxonomies_query'] ?? array(); + $tax_query = $this->merge_tax_queries( $visibility_query, $attributes_query, $taxonomies_query, $featured_query ); + $date_query = $this->get_date_query( $query['timeFrame'] ?? array() ); + $price_query_args = $this->get_price_range_query_args( $query['priceRange'] ?? array() ); // We exclude applied filters to generate product ids for the filter blocks. $applied_filters_query = $is_exclude_applied_filters ? array() : $this->get_queries_by_applied_filters(); - $merged_query = $this->merge_queries( $common_query_values, $orderby_query, $on_sale_query, $stock_query, $tax_query, $applied_filters_query, $date_query, $price_query_args ); + $merged_query = $this->merge_queries( + $common_query_values, + $orderby_query, + $on_sale_query, + $stock_query, + $tax_query, + $applied_filters_query, + $date_query, + $price_query_args + ); - $result = $this->filter_query_to_only_include_ids( $merged_query, $handpicked_products ); + $handpicked_products = $query['handpicked_products'] ?? array(); + $related_to = $this->get_related_to_query( $query['related_to'] ); + + $result = $this->filter_query_to_only_include_ids( $merged_query, $handpicked_products, $related_to ); return $result; } @@ -1077,6 +1090,20 @@ class ProductCollection extends AbstractBlock { ); } + /** + * Returns a query for filtering products that are related to the ones given. + * + * @param array $related_to The IDs pf products that we're looking for a relationship to. + * @return array The query for related products. + */ + private function get_related_to_query( $related_to ) { + if ( empty( $related_to ) ) { + return array(); + } + + return array(); + } + /** * Generates a tax query to filter products based on their "featured" status. * If the `$featured` parameter is true, the function will return a tax query @@ -1209,16 +1236,24 @@ class ProductCollection extends AbstractBlock { * Apply the query only to a subset of products * * @param array $query The query. - * @param array $ids Array of selected product ids. + * @param array ...$ids The product IDs to filter. * * @return array */ - private function filter_query_to_only_include_ids( $query, $ids ) { - if ( ! empty( $ids ) ) { - $query['post__in'] = empty( $query['post__in'] ) ? - $ids : array_intersect( $ids, $query['post__in'] ); + private function filter_query_to_only_include_ids( $query, ...$ids ) { + if ( empty( $ids ) ) { + return $query; } + // Since this is an exclusive filter, we will only show products that are present in all of the ID filters. + $post_in_filter = ! empty( $query['post__in'] ) ? $query['post__in'] : array(); + foreach ( $ids as $i ) { + if ( is_array( $i ) && ! empty( $i ) ) { + $post_in_filter = array_intersect( $i, $post_in_filter ); + } + } + $query['post__in'] = $post_in_filter; + return $query; } diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php index aba17ef74c3..6e2cf07d10f 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php @@ -90,6 +90,7 @@ class ProductCollection extends \WP_UnitTestCase { 'woocommerceOnSale' => false, 'woocommerceAttributes' => array(), 'woocommerceStockStatus' => array(), + 'woocommerceRelatedTo' => array(), 'timeFrame' => array(), 'priceRange' => array(), ) @@ -678,6 +679,7 @@ class ProductCollection extends \WP_UnitTestCase { ), ), 'woocommerceStockStatus' => array( 'instock', 'outofstock' ), + 'woocommerceRelatedTo' => array(), 'timeFrame' => array( 'operator' => 'in', 'value' => $time_frame_date, @@ -937,4 +939,65 @@ class ProductCollection extends \WP_UnitTestCase { $this->assertStringContainsString( "( wc_product_meta_lookup.tax_class = 'collection-test' AND wc_product_meta_lookup.`min_price` >= 1.", $query->request ); $this->assertStringContainsString( "( wc_product_meta_lookup.tax_class = 'collection-test' AND wc_product_meta_lookup.`max_price` <= 2.", $query->request ); } + + /** + * Test handpicked products queries. + */ + public function test_handpicked_producs_queries() { + $handpicked_product_ids = array( 1, 2, 3, 4 ); + + $parsed_block = $this->get_base_parsed_block(); + $parsed_block['attrs']['query']['woocommerceHandPickedProducts'] = $handpicked_product_ids; + + $merged_query = $this->initialize_merged_query( $parsed_block ); + + foreach ( $handpicked_product_ids as $id ) { + $this->assertContainsEquals( $id, $merged_query['post__in'] ); + } + + $this->assertCount( count( $handpicked_product_ids ), $merged_query['post__in'] ); + } + + /** + * Test related to queries. + */ + public function test_related_to_queries() { + $related_to_product_ids = array( 1, 2, 3, 4 ); + + $parsed_block = $this->get_base_parsed_block(); + $parsed_block['attrs']['query']['woocommerceRelatedTo'] = array(); + + $merged_query = $this->initialize_merged_query( $parsed_block ); + + foreach ( $related_to_product_ids as $id ) { + $this->assertContainsEquals( $id, $merged_query['post__in'] ); + } + + $this->assertCount( 4, $merged_query['post__in'] ); + } + + /** + * Test merging exclusive id filters. + */ + public function test_merges_exclusive_id_filters() { + $handpicked_product_ids = array( 3, 4, 5, 6 ); + $related_to_product_ids = array( 1, 2, 3, 4 ); + + $parsed_block = $this->get_base_parsed_block(); + $parsed_block['attrs']['query']['woocommerceHandPickedProducts'] = $handpicked_product_ids; + $parsed_block['attrs']['query']['woocommerceRelatedTo'] = array(); + + $merged_query = $this->initialize_merged_query( $parsed_block ); + + // Since these are exclusive filters, we expect the insersection of the ID filters. + $expected_product_ids = array_intersect( + $handpicked_product_ids, + $related_to_product_ids + ); + foreach ( $expected_product_ids as $id ) { + $this->assertContainsEquals( $id, $merged_query['post__in'] ); + } + + $this->assertCount( count( $expected_product_ids ), $merged_query['post__in'] ); + } } From 40b167fb419befba416f1b850ff3f36530295ef4 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Sat, 31 Aug 2024 13:03:07 -0700 Subject: [PATCH 04/26] Added Collection Related Product Filter Using `woocommerceRelatedTo`, queries may restrict the products returned to those that are related to the given product ID(s). --- .../Blocks/BlockTypes/ProductCollection.php | 63 ++++++++++++++----- .../Blocks/BlockTypes/ProductCollection.php | 46 ++++++++++---- 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index fdea9df3cd1..e6c1873f2d0 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -699,8 +699,9 @@ class ProductCollection extends AbstractBlock { * @param bool $is_exclude_applied_filters Whether to exclude the applied filters or not. */ private function get_final_frontend_query( $query, $page = 1, $is_exclude_applied_filters = false ) { - $offset = $query['offset'] ?? 0; - $per_page = $query['perPage'] ?? 9; + $product_ids = $query['post__in'] ?? array(); + $offset = $query['offset'] ?? 0; + $per_page = $query['perPage'] ?? 9; $common_query_values = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query @@ -708,7 +709,7 @@ class ProductCollection extends AbstractBlock { 'posts_per_page' => $query['perPage'], 'order' => $query['order'], 'offset' => ( $per_page * ( $page - 1 ) ) + $offset, - 'post__in' => array(), + 'post__in' => $product_ids, 'post_status' => 'publish', 'post_type' => 'product', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query @@ -721,6 +722,7 @@ class ProductCollection extends AbstractBlock { $product_attributes = $query['woocommerceAttributes'] ?? array(); $taxonomies_query = $this->get_filter_by_taxonomies_query( $query['tax_query'] ?? array() ); $handpicked_products = $query['woocommerceHandPickedProducts'] ?? array(); + $related_to = $query['woocommerceRelatedTo'] ?? array(); $time_frame = $query['timeFrame'] ?? null; $price_range = $query['priceRange'] ?? null; @@ -733,6 +735,7 @@ class ProductCollection extends AbstractBlock { 'product_attributes' => $product_attributes, 'taxonomies_query' => $taxonomies_query, 'handpicked_products' => $handpicked_products, + 'related_to' => $related_to, 'featured' => $query['featured'] ?? false, 'timeFrame' => $time_frame, 'priceRange' => $price_range, @@ -777,9 +780,9 @@ class ProductCollection extends AbstractBlock { ); $handpicked_products = $query['handpicked_products'] ?? array(); - $related_to = $this->get_related_to_query( $query['related_to'] ); + $related_products = $this->get_related_products( $query['related_to'], $common_query_values['posts_per_page'] ); - $result = $this->filter_query_to_only_include_ids( $merged_query, $handpicked_products, $related_to ); + $result = $this->filter_query_to_only_include_ids( $merged_query, $handpicked_products, $related_products ); return $result; } @@ -1093,15 +1096,24 @@ class ProductCollection extends AbstractBlock { /** * Returns a query for filtering products that are related to the ones given. * - * @param array $related_to The IDs pf products that we're looking for a relationship to. - * @return array The query for related products. + * @param array $product_ids The IDs pf products that we're looking for a relationship to. + * @param int $per_page The number of related products to fetch. + * @return array The IDs of the related products. */ - private function get_related_to_query( $related_to ) { - if ( empty( $related_to ) ) { + private function get_related_products( $product_ids, $per_page ) { + if ( empty( $product_ids ) ) { return array(); } - return array(); + $related_product_ids = array(); + foreach ( $product_ids as $id ) { + $related_product_ids = array_merge( + $related_product_ids, + wc_get_related_products( $id, $per_page ) + ); + } + + return $related_product_ids; } /** @@ -1245,14 +1257,33 @@ class ProductCollection extends AbstractBlock { return $query; } - // Since this is an exclusive filter, we will only show products that are present in all of the ID filters. - $post_in_filter = ! empty( $query['post__in'] ) ? $query['post__in'] : array(); - foreach ( $ids as $i ) { - if ( is_array( $i ) && ! empty( $i ) ) { - $post_in_filter = array_intersect( $i, $post_in_filter ); + // Since we're using array_intersect, any array that is empty will result + // in an empty array. To avoid this, we need to make sure every + // argument is a non-empty array. + $i = 0; + $len = count( $ids ); + $post_in_filter = null; + + // Make sure to filter any product IDs that have already been set in the query too. + if ( ! empty( $query['post__in'] ) ) { + $post_in_filter = $query['post__in']; + } else { + // Find the first non-empty array to serve as the base for the intersection. + while ( empty( $post_in_filter ) && $i < $len ) { + $post_in_filter = $ids[ $i ]; + ++$i; } } - $query['post__in'] = $post_in_filter; + + for ( ; $i < $len; ++$i ) { + $arr = $ids[ $i ]; + if ( is_array( $arr ) && ! empty( $arr ) ) { + $post_in_filter = array_intersect( $arr, $post_in_filter ); + } + } + + // Clean up the output since there will be duplicates and possibly some weird keys. + $query['post__in'] = array_values( array_unique( $post_in_filter, SORT_NUMERIC ) ); return $query; } diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php index 6e2cf07d10f..5add2f42bf7 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php @@ -943,7 +943,7 @@ class ProductCollection extends \WP_UnitTestCase { /** * Test handpicked products queries. */ - public function test_handpicked_producs_queries() { + public function test_handpicked_products_queries() { $handpicked_product_ids = array( 1, 2, 3, 4 ); $parsed_block = $this->get_base_parsed_block(); @@ -955,49 +955,69 @@ class ProductCollection extends \WP_UnitTestCase { $this->assertContainsEquals( $id, $merged_query['post__in'] ); } - $this->assertCount( count( $handpicked_product_ids ), $merged_query['post__in'] ); + $this->assertCount( 4, $merged_query['post__in'] ); } /** * Test related to queries. */ public function test_related_to_queries() { - $related_to_product_ids = array( 1, 2, 3, 4 ); + $related_to_product_ids = array( 1, 2, 3 ); + // This filter will turn off the data store so we don't need dummy products. + add_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); + $related_filter = function ( $related_posts, $product_id ) use ( $related_to_product_ids ) { + $this->assertEquals( 4, $product_id ); + return $related_to_product_ids; + }; + add_filter( 'woocommerce_related_products', $related_filter, 10, 2 ); $parsed_block = $this->get_base_parsed_block(); - $parsed_block['attrs']['query']['woocommerceRelatedTo'] = array(); + $parsed_block['attrs']['query']['woocommerceRelatedTo'] = array( 4 ); $merged_query = $this->initialize_merged_query( $parsed_block ); + remove_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); + remove_filter( 'woocommerce_related_products', $related_filter ); + foreach ( $related_to_product_ids as $id ) { $this->assertContainsEquals( $id, $merged_query['post__in'] ); } - $this->assertCount( 4, $merged_query['post__in'] ); + $this->assertCount( 3, $merged_query['post__in'] ); } /** * Test merging exclusive id filters. */ public function test_merges_exclusive_id_filters() { + $existing_id_filter = array( 1, 4 ); $handpicked_product_ids = array( 3, 4, 5, 6 ); $related_to_product_ids = array( 1, 2, 3, 4 ); + // The only ID present in ALL of the exclusive filters is 4. + $expected_product_ids = array( 4 ); - $parsed_block = $this->get_base_parsed_block(); + // This filter will turn off the data store so we don't need dummy products. + add_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); + $related_filter = function ( $related_posts, $product_id ) use ( $related_to_product_ids ) { + $this->assertEquals( 10, $product_id ); + return $related_to_product_ids; + }; + add_filter( 'woocommerce_related_products', $related_filter, 10, 2 ); + + $parsed_block = $this->get_base_parsed_block(); + $parsed_block['attrs']['query']['post__in'] = $existing_id_filter; $parsed_block['attrs']['query']['woocommerceHandPickedProducts'] = $handpicked_product_ids; - $parsed_block['attrs']['query']['woocommerceRelatedTo'] = array(); + $parsed_block['attrs']['query']['woocommerceRelatedTo'] = array( 10 ); $merged_query = $this->initialize_merged_query( $parsed_block ); - // Since these are exclusive filters, we expect the insersection of the ID filters. - $expected_product_ids = array_intersect( - $handpicked_product_ids, - $related_to_product_ids - ); + remove_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); + remove_filter( 'woocommerce_related_products', $related_filter ); + foreach ( $expected_product_ids as $id ) { $this->assertContainsEquals( $id, $merged_query['post__in'] ); } - $this->assertCount( count( $expected_product_ids ), $merged_query['post__in'] ); + $this->assertCount( 1, $merged_query['post__in'] ); } } From 2dff8ec5746e13db58e39e42f26f09c7e62eb0b7 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Sat, 31 Aug 2024 14:28:32 -0700 Subject: [PATCH 05/26] Removed Unnecessary Tracking --- .../events/class-wc-product-collection-block-tracking.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/plugins/woocommerce/includes/tracks/events/class-wc-product-collection-block-tracking.php b/plugins/woocommerce/includes/tracks/events/class-wc-product-collection-block-tracking.php index 948c20175ae..092be4f81ee 100644 --- a/plugins/woocommerce/includes/tracks/events/class-wc-product-collection-block-tracking.php +++ b/plugins/woocommerce/includes/tracks/events/class-wc-product-collection-block-tracking.php @@ -247,7 +247,6 @@ class WC_Product_Collection_Block_Tracking { $filters = array( 'inherit' => 'no', 'order-by' => 'no', - 'related-to' => 'no', 'on-sale' => 'no', 'stock-status' => 'no', 'handpicked' => 'no', @@ -304,10 +303,6 @@ class WC_Product_Collection_Block_Tracking { $filters['handpicked'] = 'yes'; } - if ( ! empty( $query_attrs['woocommerceRelatedTo'] ) ) { - $filters['related-to'] = 'yes'; - } - if ( ! empty( $query_attrs['search'] ) ) { $filters['keyword'] = 'yes'; } From 5f370b02aa48cb6953f36252884fae446f3d7ebc Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 31 Aug 2024 21:40:47 +0000 Subject: [PATCH 06/26] Add changefile(s) from automation for the following project(s): woocommerce-blocks, woocommerce --- .../changelog/51076-add-49335-related-products-collection | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 plugins/woocommerce/changelog/51076-add-49335-related-products-collection diff --git a/plugins/woocommerce/changelog/51076-add-49335-related-products-collection b/plugins/woocommerce/changelog/51076-add-49335-related-products-collection new file mode 100644 index 00000000000..16e94f62a5c --- /dev/null +++ b/plugins/woocommerce/changelog/51076-add-49335-related-products-collection @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +New "Related Products" Product Collection type. \ No newline at end of file From eed87239166afa91045fc38f292a73bef3697359 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Sat, 31 Aug 2024 14:56:29 -0700 Subject: [PATCH 07/26] Removing Misunderstood Option --- .../js/blocks/product-collection/collections/related-to.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx index a8ca8022023..3c38767c81d 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx @@ -39,7 +39,6 @@ const attributes = { perPage: 4, pages: 1, }, - hideControls: [ CoreFilterNames.FILTERABLE ], }; const heading: InnerBlockTemplate = [ From 0936af5529bf3fe82ac619ee01632c4cdb98c54d Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Sat, 31 Aug 2024 15:14:16 -0700 Subject: [PATCH 08/26] Linting Fix --- .../js/blocks/product-collection/collections/related-to.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx index 3c38767c81d..aecf3aff62b 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx @@ -9,7 +9,7 @@ import { Icon, loop } from '@wordpress/icons'; * Internal dependencies */ import { INNER_BLOCKS_PRODUCT_TEMPLATE } from '../constants'; -import { CoreCollectionNames, CoreFilterNames, LayoutOptions } from '../types'; +import { CoreCollectionNames, LayoutOptions } from '../types'; const collection = { name: CoreCollectionNames.RELATED_TO, From 71ed41c2736ea3fefea00609c4bc66d05a5ae06e Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Sat, 31 Aug 2024 15:32:06 -0700 Subject: [PATCH 09/26] Fixed Test --- .../src/Blocks/BlockTypes/ProductCollection.php | 2 +- .../tests/php/src/Blocks/BlockTypes/ProductCollection.php | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index e6c1873f2d0..fa91cd0b82b 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -706,7 +706,7 @@ class ProductCollection extends AbstractBlock { $common_query_values = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array(), - 'posts_per_page' => $query['perPage'], + 'posts_per_page' => $per_page, 'order' => $query['order'], 'offset' => ( $per_page * ( $page - 1 ) ) + $offset, 'post__in' => $product_ids, diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php index 5add2f42bf7..df08c01476a 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php @@ -633,7 +633,9 @@ class ProductCollection extends \WP_UnitTestCase { $product_visibility_terms = wc_get_product_visibility_term_ids(); $product_visibility_not_in = array( is_search() ? $product_visibility_terms['exclude-from-search'] : $product_visibility_terms['exclude-from-catalog'] ); - $args = array(); + $args = array( + 'posts_per_page' => 9, + ); $request = $this->build_request(); $updated_query = $this->block_instance->update_rest_query_in_editor( $args, $request ); @@ -667,7 +669,9 @@ class ProductCollection extends \WP_UnitTestCase { $product_visibility_terms = wc_get_product_visibility_term_ids(); $product_visibility_not_in = array( is_search() ? $product_visibility_terms['exclude-from-search'] : $product_visibility_terms['exclude-from-catalog'] ); - $args = array(); + $args = array( + 'posts_per_page' => 9, + ); $time_frame_date = gmdate( 'Y-m-d H:i:s' ); $params = array( 'featured' => 'true', From 7aeb4052d208ffcd3ae810a466692087600e4a7f Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Thu, 5 Sep 2024 09:27:09 -0700 Subject: [PATCH 10/26] Removed Unnecessary Keyword --- .../js/blocks/product-collection/collections/related-to.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx index aecf3aff62b..62c6b4bfcf0 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx @@ -16,7 +16,7 @@ const collection = { title: __( 'Related Products', 'woocommerce' ), icon: , description: __( 'Recommend products like this one.', 'woocommerce' ), - keywords: [ 'related', 'product collection' ], + keywords: [ 'product collection' ], scope: [], preview: { initialPreviewState: { From 41c837202ebad27031b82f4227cba0b307bc7905 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Thu, 5 Sep 2024 10:28:30 -0700 Subject: [PATCH 11/26] Support Unlimited `wc_get_related_products()` This changes related product fetching so that `-1` will return all related products. I also removed a second +10 offset that seems to have been accidentally added eight years ago. --- .../class-wc-product-data-store-cpt.php | 24 ++- .../class-wc-product-data-store-interface.php | 2 +- .../includes/wc-product-functions.php | 20 ++- .../Blocks/BlockTypes/ProductCollection.php | 12 +- .../includes/wc-product-functions-test.php | 147 ++++++++++++++++++ 5 files changed, 187 insertions(+), 18 deletions(-) diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php index b5a611d6f8a..68234967684 100644 --- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php +++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php @@ -1453,7 +1453,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da * @param array $cats_array List of categories IDs. * @param array $tags_array List of tags IDs. * @param array $exclude_ids Excluded IDs. - * @param int $limit Limit of results. + * @param int $limit Limit of results, -1 for no limit. * @param int $product_id Product ID. * @return array */ @@ -1464,13 +1464,20 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da 'categories' => $cats_array, 'tags' => $tags_array, 'exclude_ids' => $exclude_ids, - 'limit' => $limit + 10, + 'limit' => $limit, ); - $related_product_query = (array) apply_filters( 'woocommerce_product_related_posts_query', $this->get_related_products_query( $cats_array, $tags_array, $exclude_ids, $limit + 10 ), $product_id, $args ); + $related_product_query = (array) apply_filters( + 'woocommerce_product_related_posts_query', + $this->get_related_products_query( $cats_array, $tags_array, $exclude_ids, $limit ), + $product_id, + $args + ); // phpcs:ignore WordPress.VIP.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared - return $wpdb->get_col( implode( ' ', $related_product_query ) ); + $products = $wpdb->get_col( implode( ' ', $related_product_query ) ); + + return array_map( 'intval', $products ); } /** @@ -1481,7 +1488,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da * @param array $cats_array List of categories IDs. * @param array $tags_array List of tags IDs. * @param array $exclude_ids Excluded IDs. - * @param int $limit Limit of results. + * @param int $limit Limit of results, -1 for no limit. * * @return array */ @@ -1511,11 +1518,12 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da AND p.post_type = 'product' ", - 'limits' => ' - LIMIT ' . absint( $limit ) . ' - ', ); + if ( $limit > 0 ) { + $query['limits'] = ' LIMIT ' . absint( $limit ); + } + if ( count( $exclude_term_ids ) ) { $query['join'] .= " LEFT JOIN ( SELECT object_id FROM {$wpdb->term_relationships} WHERE term_taxonomy_id IN ( " . implode( ',', array_map( 'absint', $exclude_term_ids ) ) . ' ) ) AS exclude_join ON exclude_join.object_id = p.ID'; $query['where'] .= ' AND exclude_join.object_id IS NULL'; diff --git a/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php b/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php index ed9244ae56a..4199fefa857 100644 --- a/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php +++ b/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php @@ -86,7 +86,7 @@ interface WC_Product_Data_Store_Interface { * @param array $cats_array List of categories IDs. * @param array $tags_array List of tags IDs. * @param array $exclude_ids Excluded IDs. - * @param int $limit Limit of results. + * @param int $limit Limit of results, -1 for no limit. * @param int $product_id Product ID. * @return array */ diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php index 7232ef7ccc7..7f2450df9f5 100644 --- a/plugins/woocommerce/includes/wc-product-functions.php +++ b/plugins/woocommerce/includes/wc-product-functions.php @@ -976,14 +976,15 @@ function wc_get_product_backorder_options() { * * @since 3.0.0 * @param int $product_id Product ID. - * @param int $limit Limit of results. + * @param int $limit Limit of results, -1 for no limit. * @param array $exclude_ids Exclude IDs from the results. * @return array */ function wc_get_related_products( $product_id, $limit = 5, $exclude_ids = array() ) { $product_id = absint( $product_id ); - $limit = $limit >= -1 ? $limit : 5; + $limit = intval( $limit ); + $limit = $limit < -1 ? -1 : $limit; // Any negative number will default to no limit. $exclude_ids = array_merge( array( 0, $product_id ), $exclude_ids ); $transient_name = 'wc_related_' . $product_id; $query_args = http_build_query( @@ -997,7 +998,7 @@ function wc_get_related_products( $product_id, $limit = 5, $exclude_ids = array( $related_posts = $transient && is_array( $transient ) && isset( $transient[ $query_args ] ) ? $transient[ $query_args ] : false; // We want to query related posts if they are not cached, or we don't have enough. - if ( false === $related_posts || count( $related_posts ) < $limit ) { + if ( false === $related_posts || ( $limit > 0 && count( $related_posts ) < $limit ) ) { $cats_array = apply_filters( 'woocommerce_product_related_posts_relate_by_category', true, $product_id ) ? apply_filters( 'woocommerce_get_related_product_cat_terms', wc_get_product_term_ids( $product_id, 'product_cat' ), $product_id ) : array(); $tags_array = apply_filters( 'woocommerce_product_related_posts_relate_by_tag', true, $product_id ) ? apply_filters( 'woocommerce_get_related_product_tag_terms', wc_get_product_term_ids( $product_id, 'product_tag' ), $product_id ) : array(); @@ -1006,8 +1007,13 @@ function wc_get_related_products( $product_id, $limit = 5, $exclude_ids = array( if ( empty( $cats_array ) && empty( $tags_array ) && ! apply_filters( 'woocommerce_product_related_posts_force_display', false, $product_id ) ) { $related_posts = array(); } else { + // For backward compatibility we need to grab 10 extra products. This was done so that when we shuffle the + // output below it will appear to be random by including different products rather than the same ones + // in a different order. + $query_limit = $limit > 0 ? $limit + 10 : -1; + $data_store = WC_Data_Store::load( 'product' ); - $related_posts = $data_store->get_related_products( $cats_array, $tags_array, $exclude_ids, $limit + 10, $product_id ); + $related_posts = $data_store->get_related_products( $cats_array, $tags_array, $exclude_ids, $query_limit, $product_id ); } if ( $transient && is_array( $transient ) ) { @@ -1033,7 +1039,11 @@ function wc_get_related_products( $product_id, $limit = 5, $exclude_ids = array( shuffle( $related_posts ); } - return array_slice( $related_posts, 0, $limit ); + if ( $limit > 0 ) { + return array_slice( $related_posts, 0, $limit ); + } + + return $related_posts; } /** diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index fa91cd0b82b..43c13e941f6 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -780,7 +780,7 @@ class ProductCollection extends AbstractBlock { ); $handpicked_products = $query['handpicked_products'] ?? array(); - $related_products = $this->get_related_products( $query['related_to'], $common_query_values['posts_per_page'] ); + $related_products = $this->get_related_products( $query['related_to'] ); $result = $this->filter_query_to_only_include_ids( $merged_query, $handpicked_products, $related_products ); @@ -1097,10 +1097,9 @@ class ProductCollection extends AbstractBlock { * Returns a query for filtering products that are related to the ones given. * * @param array $product_ids The IDs pf products that we're looking for a relationship to. - * @param int $per_page The number of related products to fetch. * @return array The IDs of the related products. */ - private function get_related_products( $product_ids, $per_page ) { + private function get_related_products( $product_ids ) { if ( empty( $product_ids ) ) { return array(); } @@ -1109,7 +1108,12 @@ class ProductCollection extends AbstractBlock { foreach ( $product_ids as $id ) { $related_product_ids = array_merge( $related_product_ids, - wc_get_related_products( $id, $per_page ) + // Since this is a secondary query we want to make sure to + // grab enough products so that filtering in the primary + // query will have a subset to work with. We ALSO need + // to make sure that we aren't harming performance by + // fetching and caching too many related product IDs. + wc_get_related_products( $id, 100 ) ); } diff --git a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php index 247c74e4fe7..30f4b47b980 100644 --- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php +++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php @@ -240,4 +240,151 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case { $this->assertEquals( 100, wc_get_product( $product->get_id() )->get_price() ); } + + /** + * @testDox Products related by tag or category should be returned. + */ + public function test_wc_get_related_products() { + add_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_true', 100 ); + add_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_true', 100 ); + + $related_tag = wp_insert_term( 'Related Tag', 'product_tag' ); + $related_cat = wp_insert_term( 'Related Category', 'product_cat' ); + + $product = WC_Helper_Product::create_simple_product(); + $product->set_tag_ids( array( $related_tag['term_id'] ) ); + $product->set_category_ids( array( $related_cat['term_id'] ) ); + $product->save(); + + $related_product_1 = WC_Helper_Product::create_simple_product(); + $related_product_1->set_category_ids( array( $related_cat['term_id'] ) ); + $related_product_1->save(); + + $related_product_2 = WC_Helper_Product::create_simple_product(); + $related_product_2->set_tag_ids( array( $related_tag['term_id'] ) ); + $related_product_2->save(); + + $related = wc_get_related_products( $product->get_id() ); + + remove_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_true', 100 ); + remove_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_true', 100 ); + + $this->assertEquals( 2, count( $related ) ); + $this->assertContains( $related_product_1->get_id(), $related ); + $this->assertContains( $related_product_2->get_id(), $related ); + } + + /** + * @testDox Only products related by tag should be returned when the appropriate filters are set. + */ + public function test_wc_get_related_products_by_tag() { + add_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_false', 100 ); + add_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_true', 100 ); + + $related_tag = wp_insert_term( 'Related Tag', 'product_tag' ); + $related_cat = wp_insert_term( 'Related Category', 'product_cat' ); + + $product = WC_Helper_Product::create_simple_product(); + $product->set_tag_ids( array( $related_tag['term_id'] ) ); + $product->set_category_ids( array( $related_cat['term_id'] ) ); + $product->save(); + + $related_product_1 = WC_Helper_Product::create_simple_product(); + $related_product_1->set_tag_ids( array( $related_tag['term_id'] ) ); + $related_product_1->set_category_ids( array( $related_cat['term_id'] ) ); + $related_product_1->save(); + + $related_product_2 = WC_Helper_Product::create_simple_product(); + $related_product_2->set_tag_ids( array( $related_tag['term_id'] ) ); + $related_product_2->set_category_ids( array( $related_cat['term_id'] ) ); + $related_product_2->save(); + + $unrelated_product = WC_Helper_Product::create_simple_product(); + $unrelated_product->set_category_ids( array( $related_cat['term_id'] ) ); + $unrelated_product->save(); + + $related = wc_get_related_products( $product->get_id() ); + + remove_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_false', 100 ); + remove_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_true', 100 ); + + $this->assertEquals( 2, count( $related ) ); + $this->assertContains( $related_product_1->get_id(), $related ); + $this->assertContains( $related_product_2->get_id(), $related ); + } + + /** + * @testDox Only products related by category should be returned when the appropriate filters are set. + */ + public function test_wc_get_related_products_by_category() { + add_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_true', 100 ); + add_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_false', 100 ); + + $related_tag = wp_insert_term( 'Related Tag', 'product_tag' ); + $related_cat = wp_insert_term( 'Related Category', 'product_cat' ); + + $product = WC_Helper_Product::create_simple_product(); + $product->set_tag_ids( array( $related_tag['term_id'] ) ); + $product->set_category_ids( array( $related_cat['term_id'] ) ); + $product->save(); + + $related_product_1 = WC_Helper_Product::create_simple_product(); + $related_product_1->set_tag_ids( array( $related_tag['term_id'] ) ); + $related_product_1->set_category_ids( array( $related_cat['term_id'] ) ); + $related_product_1->save(); + + $related_product_2 = WC_Helper_Product::create_simple_product(); + $related_product_2->set_tag_ids( array( $related_tag['term_id'] ) ); + $related_product_2->set_category_ids( array( $related_cat['term_id'] ) ); + $related_product_2->save(); + + $unrelated_product = WC_Helper_Product::create_simple_product(); + $unrelated_product->set_tag_ids( array( $related_tag['term_id'] ) ); + $unrelated_product->save(); + + $related = wc_get_related_products( $product->get_id() ); + + remove_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_true', 100 ); + remove_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_false', 100 ); + + $this->assertEquals( 2, count( $related ) ); + $this->assertContains( $related_product_1->get_id(), $related ); + $this->assertContains( $related_product_2->get_id(), $related ); + } + + /** + * @testDox Products related by tag or category should apply a limit to the results. + */ + public function test_wc_get_related_products_limit() { + add_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_true', 100 ); + add_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_true', 100 ); + + $related_tag = wp_insert_term( 'Related Tag', 'product_tag' ); + $related_cat = wp_insert_term( 'Related Category', 'product_cat' ); + + $product = WC_Helper_Product::create_simple_product(); + $product->set_tag_ids( array( $related_tag['term_id'] ) ); + $product->set_category_ids( array( $related_cat['term_id'] ) ); + $product->save(); + + $related_product_1 = WC_Helper_Product::create_simple_product(); + $related_product_1->set_category_ids( array( $related_cat['term_id'] ) ); + $related_product_1->save(); + + $related_product_2 = WC_Helper_Product::create_simple_product(); + $related_product_2->set_tag_ids( array( $related_tag['term_id'] ) ); + $related_product_2->save(); + + $related = wc_get_related_products( $product->get_id(), 1 ); + $unlimited_related = wc_get_related_products( $product->get_id(), -1 ); + + remove_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_true', 100 ); + remove_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_true', 100 ); + + $this->assertEquals( 1, count( $related ) ); + $this->assertContains( $related[0], array( $related_product_1->get_id(), $related_product_2->get_id() ) ); + $this->assertEquals( 2, count( $unlimited_related ) ); + $this->assertContains( $related_product_1->get_id(), $unlimited_related ); + $this->assertContains( $related_product_2->get_id(), $unlimited_related ); + } } From aa1ffdcecd9111bfb7326ab987542dae0b3bbc38 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Tue, 10 Sep 2024 20:49:30 -0700 Subject: [PATCH 12/26] Removed `woocommerceRelatedTo` Param Since we're going to use the collection name, we don't need this functionality any longer. --- .../register-product-collection.tsx | 12 ------ .../js/blocks/product-collection/constants.ts | 3 -- .../js/blocks/product-collection/types.ts | 4 -- .../Blocks/BlockTypes/ProductCollection.php | 7 +-- .../Blocks/BlockTypes/ProductCollection.php | 43 ------------------- 5 files changed, 1 insertion(+), 68 deletions(-) diff --git a/plugins/woocommerce-blocks/assets/js/blocks-registry/product-collection/register-product-collection.tsx b/plugins/woocommerce-blocks/assets/js/blocks-registry/product-collection/register-product-collection.tsx index 05b15355c84..516fb64e48d 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks-registry/product-collection/register-product-collection.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks-registry/product-collection/register-product-collection.tsx @@ -222,15 +222,6 @@ const isValidCollectionConfig = ( config: ProductCollectionConfig ) => { 'Invalid woocommerceHandPickedProducts: woocommerceHandPickedProducts must be an array.' ); } - // attributes.query.woocommerceRelatedTo - if ( - config.attributes?.query?.woocommerceRelatedTo !== undefined && - ! Array.isArray( config.attributes.query.woocommerceRelatedTo ) - ) { - console.warn( - 'Invalid woocommerceRelatedTo: woocommerceRelatedTo must be an array.' - ); - } // attributes.query.priceRange if ( config.attributes?.query?.priceRange !== undefined && @@ -411,9 +402,6 @@ export const __experimentalRegisterProductCollection = ( woocommerceHandPickedProducts: query.woocommerceHandPickedProducts, } ), - ...( query.woocommerceRelatedTo !== undefined && { - woocommerceRelatedTo: query.woocommerceRelatedTo, - } ), ...( query.priceRange !== undefined && { priceRange: query.priceRange, } ), diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/constants.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/constants.ts index 96d34f5b949..98a1d9be2f6 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/constants.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/constants.ts @@ -60,7 +60,6 @@ export const DEFAULT_QUERY: ProductCollectionQuery = { woocommerceStockStatus: getDefaultStockStatuses(), woocommerceAttributes: [], woocommerceHandPickedProducts: [], - woocommerceRelatedTo: [], timeFrame: undefined, priceRange: undefined, filterable: false, @@ -91,7 +90,6 @@ export const DEFAULT_FILTERS: Pick< | 'woocommerceStockStatus' | 'woocommerceAttributes' | 'woocommerceHandPickedProducts' - | 'woocommerceRelatedTo' | 'taxQuery' | 'featured' | 'timeFrame' @@ -101,7 +99,6 @@ export const DEFAULT_FILTERS: Pick< woocommerceStockStatus: DEFAULT_QUERY.woocommerceStockStatus, woocommerceAttributes: DEFAULT_QUERY.woocommerceAttributes, woocommerceHandPickedProducts: DEFAULT_QUERY.woocommerceHandPickedProducts, - woocommerceRelatedTo: DEFAULT_QUERY.woocommerceRelatedTo, taxQuery: DEFAULT_QUERY.taxQuery, featured: DEFAULT_QUERY.featured, timeFrame: DEFAULT_QUERY.timeFrame, diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts index 95d55186da7..04bc42f22ca 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts @@ -103,10 +103,6 @@ export interface ProductCollectionQuery { woocommerceAttributes: AttributeMetadata[]; isProductCollectionBlock: boolean; woocommerceHandPickedProducts: string[]; - /** - * Filter for products related to the given list of product IDs. - */ - woocommerceRelatedTo: string[]; priceRange: undefined | PriceRange; filterable: boolean; productReference?: number; diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index 43c13e941f6..5f2ff50e4f1 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -609,7 +609,6 @@ class ProductCollection extends AbstractBlock { $stock_status = $request->get_param( 'woocommerceStockStatus' ); $product_attributes = $request->get_param( 'woocommerceAttributes' ); $handpicked_products = $request->get_param( 'woocommerceHandPickedProducts' ); - $related_to = $request->get_param( 'woocommerceRelatedTo' ); $featured = $request->get_param( 'featured' ); $time_frame = $request->get_param( 'timeFrame' ); $price_range = $request->get_param( 'priceRange' ); @@ -625,7 +624,6 @@ class ProductCollection extends AbstractBlock { 'stock_status' => $stock_status, 'product_attributes' => $product_attributes, 'handpicked_products' => $handpicked_products, - 'related_to' => $related_to, 'featured' => $featured, 'timeFrame' => $time_frame, 'priceRange' => $price_range, @@ -722,7 +720,6 @@ class ProductCollection extends AbstractBlock { $product_attributes = $query['woocommerceAttributes'] ?? array(); $taxonomies_query = $this->get_filter_by_taxonomies_query( $query['tax_query'] ?? array() ); $handpicked_products = $query['woocommerceHandPickedProducts'] ?? array(); - $related_to = $query['woocommerceRelatedTo'] ?? array(); $time_frame = $query['timeFrame'] ?? null; $price_range = $query['priceRange'] ?? null; @@ -735,7 +732,6 @@ class ProductCollection extends AbstractBlock { 'product_attributes' => $product_attributes, 'taxonomies_query' => $taxonomies_query, 'handpicked_products' => $handpicked_products, - 'related_to' => $related_to, 'featured' => $query['featured'] ?? false, 'timeFrame' => $time_frame, 'priceRange' => $price_range, @@ -780,9 +776,8 @@ class ProductCollection extends AbstractBlock { ); $handpicked_products = $query['handpicked_products'] ?? array(); - $related_products = $this->get_related_products( $query['related_to'] ); - $result = $this->filter_query_to_only_include_ids( $merged_query, $handpicked_products, $related_products ); + $result = $this->filter_query_to_only_include_ids( $merged_query, $handpicked_products ); return $result; } diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php index df08c01476a..643b906859c 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php @@ -90,7 +90,6 @@ class ProductCollection extends \WP_UnitTestCase { 'woocommerceOnSale' => false, 'woocommerceAttributes' => array(), 'woocommerceStockStatus' => array(), - 'woocommerceRelatedTo' => array(), 'timeFrame' => array(), 'priceRange' => array(), ) @@ -683,7 +682,6 @@ class ProductCollection extends \WP_UnitTestCase { ), ), 'woocommerceStockStatus' => array( 'instock', 'outofstock' ), - 'woocommerceRelatedTo' => array(), 'timeFrame' => array( 'operator' => 'in', 'value' => $time_frame_date, @@ -962,62 +960,21 @@ class ProductCollection extends \WP_UnitTestCase { $this->assertCount( 4, $merged_query['post__in'] ); } - /** - * Test related to queries. - */ - public function test_related_to_queries() { - $related_to_product_ids = array( 1, 2, 3 ); - // This filter will turn off the data store so we don't need dummy products. - add_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); - $related_filter = function ( $related_posts, $product_id ) use ( $related_to_product_ids ) { - $this->assertEquals( 4, $product_id ); - return $related_to_product_ids; - }; - add_filter( 'woocommerce_related_products', $related_filter, 10, 2 ); - - $parsed_block = $this->get_base_parsed_block(); - $parsed_block['attrs']['query']['woocommerceRelatedTo'] = array( 4 ); - - $merged_query = $this->initialize_merged_query( $parsed_block ); - - remove_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); - remove_filter( 'woocommerce_related_products', $related_filter ); - - foreach ( $related_to_product_ids as $id ) { - $this->assertContainsEquals( $id, $merged_query['post__in'] ); - } - - $this->assertCount( 3, $merged_query['post__in'] ); - } - /** * Test merging exclusive id filters. */ public function test_merges_exclusive_id_filters() { $existing_id_filter = array( 1, 4 ); $handpicked_product_ids = array( 3, 4, 5, 6 ); - $related_to_product_ids = array( 1, 2, 3, 4 ); // The only ID present in ALL of the exclusive filters is 4. $expected_product_ids = array( 4 ); - // This filter will turn off the data store so we don't need dummy products. - add_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); - $related_filter = function ( $related_posts, $product_id ) use ( $related_to_product_ids ) { - $this->assertEquals( 10, $product_id ); - return $related_to_product_ids; - }; - add_filter( 'woocommerce_related_products', $related_filter, 10, 2 ); - $parsed_block = $this->get_base_parsed_block(); $parsed_block['attrs']['query']['post__in'] = $existing_id_filter; $parsed_block['attrs']['query']['woocommerceHandPickedProducts'] = $handpicked_product_ids; - $parsed_block['attrs']['query']['woocommerceRelatedTo'] = array( 10 ); $merged_query = $this->initialize_merged_query( $parsed_block ); - remove_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); - remove_filter( 'woocommerce_related_products', $related_filter ); - foreach ( $expected_product_ids as $id ) { $this->assertContainsEquals( $id, $merged_query['post__in'] ); } From e34a24b195783b99e0592ea5dc36187645bc9f57 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Tue, 10 Sep 2024 23:52:51 -0700 Subject: [PATCH 13/26] Simplified `post__in` Filtering Since `merge_queries` was already using an intersection merge for `post__in`, this second function is unnecessary. I've removed it as well as refactored the merging logic. --- .../Blocks/BlockTypes/ProductCollection.php | 147 +++++++++--------- 1 file changed, 77 insertions(+), 70 deletions(-) diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index 5f2ff50e4f1..885a0f14fb1 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -760,11 +760,12 @@ class ProductCollection extends AbstractBlock { $tax_query = $this->merge_tax_queries( $visibility_query, $attributes_query, $taxonomies_query, $featured_query ); $date_query = $this->get_date_query( $query['timeFrame'] ?? array() ); $price_query_args = $this->get_price_range_query_args( $query['priceRange'] ?? array() ); + $handpicked_query = $this->get_handpicked_query( $query['handpicked_products'] ?? false ); // We exclude applied filters to generate product ids for the filter blocks. $applied_filters_query = $is_exclude_applied_filters ? array() : $this->get_queries_by_applied_filters(); - $merged_query = $this->merge_queries( + return $this->merge_queries( $common_query_values, $orderby_query, $on_sale_query, @@ -772,14 +773,9 @@ class ProductCollection extends AbstractBlock { $tax_query, $applied_filters_query, $date_query, - $price_query_args + $price_query_args, + $handpicked_query ); - - $handpicked_products = $query['handpicked_products'] ?? array(); - - $result = $this->filter_query_to_only_include_ids( $merged_query, $handpicked_products ); - - return $result; } /** @@ -828,42 +824,80 @@ class ProductCollection extends AbstractBlock { * @return array */ private function merge_queries( ...$queries ) { + // Rather than a simple merge, some query vars should be held aside and merged differently. + $special_query_vars = array( + 'post__in' => array(), + ); + $special_query_keys = array_keys( $special_query_vars ); + $merged_query = array_reduce( $queries, - function ( $acc, $query ) { + function ( $acc, $query ) use ( $special_query_keys, &$special_query_vars ) { if ( ! is_array( $query ) ) { return $acc; } + // If the $query doesn't contain any valid query keys, we unpack/spread it then merge. if ( empty( array_intersect( $this->get_valid_query_vars(), array_keys( $query ) ) ) ) { return $this->merge_queries( $acc, ...array_values( $query ) ); } + + // Pull out the special query vars so we can merge them separately. + foreach ( $special_query_keys as $query_var ) { + if ( isset( $query[ $query_var ] ) ) { + $special_query_vars[ $query_var ][] = $query[ $query_var ]; + unset( $query[ $query_var ] ); + } + } + return $this->array_merge_recursive_replace_non_array_properties( $acc, $query ); }, array() ); - /** - * If there are duplicated items in post__in, it means that we need to - * use the intersection of the results, which in this case, are the - * duplicated items. - */ - if ( - ! empty( $merged_query['post__in'] ) && - is_array( $merged_query['post__in'] ) && - count( $merged_query['post__in'] ) > count( array_unique( $merged_query['post__in'] ) ) - ) { - $merged_query['post__in'] = array_unique( - array_diff( - $merged_query['post__in'], - array_unique( $merged_query['post__in'] ) - ) - ); - } + // Perform any necessary special merges. + $merged_query['post__in'] = $this->merge_post__in( ...$special_query_vars['post__in'] ); return $merged_query; } + /** + * Merge all of the 'post__in' values and return an array containing only values that are present in all arrays. + * + * @param int[][] ...$post__in The 'post__in' values to be merged. + * + * @return int[] The merged 'post__in' values. + */ + private function merge_post__in( ...$post__in ) { + if ( empty( $post__in ) ) { + return array(); + } + + // Since we're using array_intersect, any array that is empty will result + // in an empty output array. To avoid this we need to make sure every + // argument is a non-empty array. + $post__in = array_filter( + $post__in, + function ( $val ) { + return is_array( $val ) && ! empty( $val ); + } + ); + if ( empty( $post__in ) ) { + return array(); + } + + // Since the 'post__in' filter is exclusionary we need to use an intersection of + // all of the arrays. This ensures one query doesn't add options that another + // has otherwise excluded from the results. + if ( count( $post__in ) > 1 ) { + $post__in = array_intersect( ...$post__in ); + } else { + $post__in = reset( $post__in ); + } + + return array_values( array_unique( $post__in, SORT_NUMERIC ) ); + } + /** * Return query params to support custom sort values * @@ -1143,6 +1177,23 @@ class ProductCollection extends AbstractBlock { ); } + /** + * Generates a post__in query to filter products to the set of provided IDs. + * + * @param int[]|false $handpicked_products The products to filter. + * + * @return array The post__in query. + */ + private function get_handpicked_query( $handpicked_products ) { + if ( false === $handpicked_products ) { + return array(); + } + + return array( + 'post__in' => $handpicked_products, + ); + } + /** * Merge tax_queries from various queries. @@ -1243,50 +1294,6 @@ class ProductCollection extends AbstractBlock { return ! empty( $result ) ? array( 'tax_query' => $result ) : array(); } - /** - * Apply the query only to a subset of products - * - * @param array $query The query. - * @param array ...$ids The product IDs to filter. - * - * @return array - */ - private function filter_query_to_only_include_ids( $query, ...$ids ) { - if ( empty( $ids ) ) { - return $query; - } - - // Since we're using array_intersect, any array that is empty will result - // in an empty array. To avoid this, we need to make sure every - // argument is a non-empty array. - $i = 0; - $len = count( $ids ); - $post_in_filter = null; - - // Make sure to filter any product IDs that have already been set in the query too. - if ( ! empty( $query['post__in'] ) ) { - $post_in_filter = $query['post__in']; - } else { - // Find the first non-empty array to serve as the base for the intersection. - while ( empty( $post_in_filter ) && $i < $len ) { - $post_in_filter = $ids[ $i ]; - ++$i; - } - } - - for ( ; $i < $len; ++$i ) { - $arr = $ids[ $i ]; - if ( is_array( $arr ) && ! empty( $arr ) ) { - $post_in_filter = array_intersect( $arr, $post_in_filter ); - } - } - - // Clean up the output since there will be duplicates and possibly some weird keys. - $query['post__in'] = array_values( array_unique( $post_in_filter, SORT_NUMERIC ) ); - - return $query; - } - /** * Return queries that are generated by query args. * From 440cdba53eeb88450246bf368dc28ceac53a556d Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Wed, 11 Sep 2024 01:24:10 -0700 Subject: [PATCH 14/26] Pass Collection Name To Final Query --- .../product-collection/collections/index.tsx | 4 +- .../{related-to.tsx => related.tsx} | 12 +--- .../js/blocks/product-collection/types.ts | 2 +- .../Blocks/BlockTypes/ProductCollection.php | 63 +++++++++---------- 4 files changed, 35 insertions(+), 46 deletions(-) rename plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/{related-to.tsx => related.tsx} (82%) diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx index 708d2b2224b..2a24183d705 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/index.tsx @@ -20,7 +20,7 @@ import topRated from './top-rated'; import bestSellers from './best-sellers'; import onSale from './on-sale'; import featured from './featured'; -import relatedTo from './related-to'; +import related from './related'; const collections: BlockVariation[] = [ productCollection, @@ -29,7 +29,7 @@ const collections: BlockVariation[] = [ onSale, bestSellers, newArrivals, - relatedTo, + related, ]; export const registerCollections = () => { diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related.tsx similarity index 82% rename from plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx rename to plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related.tsx index 62c6b4bfcf0..ec530da03dc 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related-to.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related.tsx @@ -12,21 +12,13 @@ import { INNER_BLOCKS_PRODUCT_TEMPLATE } from '../constants'; import { CoreCollectionNames, LayoutOptions } from '../types'; const collection = { - name: CoreCollectionNames.RELATED_TO, + name: CoreCollectionNames.RELATED, title: __( 'Related Products', 'woocommerce' ), icon: , description: __( 'Recommend products like this one.', 'woocommerce' ), keywords: [ 'product collection' ], scope: [], - preview: { - initialPreviewState: { - isPreview: true, - previewMessage: __( - 'Actual products will vary depending on the product being viewed.', - 'woocommerce' - ), - }, - }, + usesReference: [ 'product' ], }; const attributes = { diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts index 04bc42f22ca..5be0fe0a4e7 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts @@ -153,7 +153,7 @@ export enum CoreCollectionNames { NEW_ARRIVALS = 'woocommerce/product-collection/new-arrivals', ON_SALE = 'woocommerce/product-collection/on-sale', TOP_RATED = 'woocommerce/product-collection/top-rated', - RELATED_TO = 'woocommerce/product-collection/related', + RELATED = 'woocommerce/product-collection/related', } export enum CoreFilterNames { diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index 885a0f14fb1..10e8f1acb89 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -616,7 +616,8 @@ class ProductCollection extends AbstractBlock { // Most likely this argument is being accessed in the test environment image. $args['author'] = ''; - return $this->get_final_query_args( + $final_query = $this->get_final_query_args( + $product_collection_query_context['collection'] ?? '', $args, array( 'orderby' => $orderby, @@ -629,6 +630,8 @@ class ProductCollection extends AbstractBlock { 'priceRange' => $price_range, ) ); + + return $final_query; } /** @@ -724,6 +727,7 @@ class ProductCollection extends AbstractBlock { $price_range = $query['priceRange'] ?? null; $final_query = $this->get_final_query_args( + $this->parsed_block['attrs']['collection'] ?? '', $common_query_values, array( 'on_sale' => $is_on_sale, @@ -745,11 +749,12 @@ class ProductCollection extends AbstractBlock { /** * Get final query args based on provided values * - * @param array $common_query_values Common query values. - * @param array $query Query from block context. - * @param bool $is_exclude_applied_filters Whether to exclude the applied filters or not. + * @param string $collection The name of the collection. + * @param array $common_query_values Common query values. + * @param array $query Query from block context. + * @param bool $is_exclude_applied_filters Whether to exclude the applied filters or not. */ - private function get_final_query_args( $common_query_values, $query, $is_exclude_applied_filters = false ) { + private function get_final_query_args( $collection, $common_query_values, $query, $is_exclude_applied_filters = false ) { $orderby_query = $query['orderby'] ? $this->get_custom_orderby_query( $query['orderby'] ) : array(); $on_sale_query = $this->get_on_sale_products_query( $query['on_sale'] ); $stock_query = $this->get_stock_status_query( $query['stock_status'] ); @@ -761,6 +766,7 @@ class ProductCollection extends AbstractBlock { $date_query = $this->get_date_query( $query['timeFrame'] ?? array() ); $price_query_args = $this->get_price_range_query_args( $query['priceRange'] ?? array() ); $handpicked_query = $this->get_handpicked_query( $query['handpicked_products'] ?? false ); + $collection_query = $this->get_core_collection_query( $collection, $query ); // We exclude applied filters to generate product ids for the filter blocks. $applied_filters_query = $is_exclude_applied_filters ? array() : $this->get_queries_by_applied_filters(); @@ -774,10 +780,28 @@ class ProductCollection extends AbstractBlock { $applied_filters_query, $date_query, $price_query_args, - $handpicked_query + $handpicked_query, + $collection_query ); } + /** + * Get any collection-specific query args to merge. + * + * @param string $collection The name of the collection. + * @param array $query Query from block context. + * + * @return array The collection-specific query to merge. + */ + private function get_core_collection_query( $collection, $query ) { + $collection = preg_match( '/^woocommerce\/product-collection\/(.*)/', $collection, $matches ) ? $matches[1] : ''; + if ( '' === $collection ) { + return array(); + } + + return array(); + } + /** * Get query args for preview mode. These query args will be used with WP_Query to fetch the products. * @@ -1122,33 +1146,6 @@ class ProductCollection extends AbstractBlock { ); } - /** - * Returns a query for filtering products that are related to the ones given. - * - * @param array $product_ids The IDs pf products that we're looking for a relationship to. - * @return array The IDs of the related products. - */ - private function get_related_products( $product_ids ) { - if ( empty( $product_ids ) ) { - return array(); - } - - $related_product_ids = array(); - foreach ( $product_ids as $id ) { - $related_product_ids = array_merge( - $related_product_ids, - // Since this is a secondary query we want to make sure to - // grab enough products so that filtering in the primary - // query will have a subset to work with. We ALSO need - // to make sure that we aren't harming performance by - // fetching and caching too many related product IDs. - wc_get_related_products( $id, 100 ) - ); - } - - return $related_product_ids; - } - /** * Generates a tax query to filter products based on their "featured" status. * If the `$featured` parameter is true, the function will return a tax query From b47b0f638f851d769918ed3987a17f7c4a59b670 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Wed, 11 Sep 2024 14:30:07 -0700 Subject: [PATCH 15/26] Added Custom Collection Handlers Developers can register collections along with handlers that implement the custom behavior. --- .../Blocks/BlockTypes/ProductCollection.php | 143 ++++++++++++------ .../Blocks/BlockTypes/ProductCollection.php | 134 ++++++++++++++++ .../Blocks/Mocks/ProductCollectionMock.php | 24 +++ 3 files changed, 254 insertions(+), 47 deletions(-) diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index 94583e54ec4..652609bf4e9 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -18,6 +18,13 @@ class ProductCollection extends AbstractBlock { */ protected $block_name = 'product-collection'; + /** + * An array keyed by the name of the collection containing handlers for implementing custom collection behavior. + * + * @var array + */ + protected $collection_handler_store = array(); + /** * The Block with its attributes before it gets rendered * @@ -617,22 +624,32 @@ class ProductCollection extends AbstractBlock { /** * Update the query for the product query block in Editor. * - * @param array $args Query args. + * @param array $query Query args. * @param WP_REST_Request $request Request. */ - public function update_rest_query_in_editor( $args, $request ): array { + public function update_rest_query_in_editor( $query, $request ): array { // Only update the query if this is a product collection block. $is_product_collection_block = $request->get_param( 'isProductCollectionBlock' ); if ( ! $is_product_collection_block ) { - return $args; + return $query; + } + + $product_collection_query_context = $request->get_param( 'productCollectionQueryContext' ); + $collection_args = array( + 'name' => $product_collection_query_context['collection'] ?? '', + ); + + // Allow collections to modify the collection arguments passed to the query builder. + $handlers = $this->collection_handler_store[ $collection_args['name'] ] ?? null; + if ( isset( $handlers['editor_args'] ) ) { + $collection_args = call_user_func( $handlers['editor_args'], $collection_args, $query ); } // Is this a preview mode request? // If yes, short-circuit the query and return the preview query args. - $product_collection_query_context = $request->get_param( 'productCollectionQueryContext' ); - $is_preview = $product_collection_query_context['previewState']['isPreview'] ?? false; + $is_preview = $product_collection_query_context['previewState']['isPreview'] ?? false; if ( 'true' === $is_preview ) { - return $this->get_preview_query_args( $args, $request ); + return $this->get_preview_query_args( $collection_args, $query, $request ); } $orderby = $request->get_param( 'orderBy' ); @@ -645,11 +662,11 @@ class ProductCollection extends AbstractBlock { $price_range = $request->get_param( 'priceRange' ); // This argument is required for the tests to PHP Unit Tests to run correctly. // Most likely this argument is being accessed in the test environment image. - $args['author'] = ''; + $query['author'] = ''; $final_query = $this->get_final_query_args( - $product_collection_query_context['collection'] ?? '', - $args, + $collection_args, + $query, array( 'orderby' => $orderby, 'on_sale' => $on_sale, @@ -757,8 +774,18 @@ class ProductCollection extends AbstractBlock { $time_frame = $query['timeFrame'] ?? null; $price_range = $query['priceRange'] ?? null; + $collection_args = array( + 'name' => $this->parsed_block['attrs']['collection'] ?? '', + ); + + // Allow collections to modify the collection arguments passed to the query builder. + $handlers = $this->collection_handler_store[ $collection_args['name'] ] ?? null; + if ( isset( $handlers['frontend_args'] ) ) { + $collection_args = call_user_func( $handlers['frontend_args'], $collection_args, $query ); + } + $final_query = $this->get_final_query_args( - $this->parsed_block['attrs']['collection'] ?? '', + $collection_args, $common_query_values, array( 'on_sale' => $is_on_sale, @@ -780,12 +807,17 @@ class ProductCollection extends AbstractBlock { /** * Get final query args based on provided values * - * @param string $collection The name of the collection. - * @param array $common_query_values Common query values. - * @param array $query Query from block context. - * @param bool $is_exclude_applied_filters Whether to exclude the applied filters or not. + * @param array $collection_args Any special arguments that should change the behavior of the query. + * @param array $common_query_values Common query values. + * @param array $query Query from block context. + * @param bool $is_exclude_applied_filters Whether to exclude the applied filters or not. */ - private function get_final_query_args( $collection, $common_query_values, $query, $is_exclude_applied_filters = false ) { + private function get_final_query_args( + $collection_args, + $common_query_values, + $query, + $is_exclude_applied_filters = false + ) { $orderby_query = $query['orderby'] ? $this->get_custom_orderby_query( $query['orderby'] ) : array(); $on_sale_query = $this->get_on_sale_products_query( $query['on_sale'] ); $stock_query = $this->get_stock_status_query( $query['stock_status'] ); @@ -797,11 +829,24 @@ class ProductCollection extends AbstractBlock { $date_query = $this->get_date_query( $query['timeFrame'] ?? array() ); $price_query_args = $this->get_price_range_query_args( $query['priceRange'] ?? array() ); $handpicked_query = $this->get_handpicked_query( $query['handpicked_products'] ?? false ); - $collection_query = $this->get_core_collection_query( $collection, $query ); // We exclude applied filters to generate product ids for the filter blocks. $applied_filters_query = $is_exclude_applied_filters ? array() : $this->get_queries_by_applied_filters(); + // Allow collections to provide their own query parameters. + $handlers = $this->collection_handler_store[ $collection_args['name'] ] ?? null; + if ( isset( $handlers['build_query'] ) ) { + $collection_query = call_user_func( + $handlers['build_query'], + $collection_args, + $common_query_values, + $query, + $is_exclude_applied_filters + ); + } else { + $collection_query = array(); + } + return $this->merge_queries( $common_query_values, $orderby_query, @@ -816,41 +861,21 @@ class ProductCollection extends AbstractBlock { ); } - /** - * Get any collection-specific query args to merge. - * - * @param string $collection The name of the collection. - * @param array $query Query from block context. - * - * @return array The collection-specific query to merge. - */ - private function get_core_collection_query( $collection, $query ) { - $collection = preg_match( '/^woocommerce\/product-collection\/(.*)/', $collection, $matches ) ? $matches[1] : ''; - if ( '' === $collection ) { - return array(); - } - - return array(); - } - /** * Get query args for preview mode. These query args will be used with WP_Query to fetch the products. * - * @param array $args Query args. - * @param WP_REST_Request $request Request. + * @param array $collection_args Any collection-specific arguments. + * @param array $args Query args. + * @param WP_REST_Request $request Request. */ - private function get_preview_query_args( $args, $request ) { + private function get_preview_query_args( $collection_args, $args, $request ) { $collection_query = array(); - /** - * In future, Here we will modify the preview query based on the collection name. For example: - * - * $product_collection_query_context = $request->get_param( 'productCollectionQueryContext' ); - * $collection_name = $product_collection_query_context['collection'] ?? ''; - * if ( 'woocommerce/product-collection/on-sale' === $collection_name ) { - * $collection_query = $this->get_on_sale_products_query( true ); - * }. - */ + // Allow collections to override the preview mode behavior. + $handlers = $this->collection_handler_store[ $collection_args['name'] ] ?? null; + if ( isset( $handlers['preview_query'] ) ) { + $collection_query = call_user_func( $handlers['preview_query'], $collection_args, $args, $request ); + } $args = $this->merge_queries( $args, $collection_query ); return $args; @@ -892,8 +917,8 @@ class ProductCollection extends AbstractBlock { return $acc; } - // If the $query doesn't contain any valid query keys, we unpack/spread it then merge. - if ( empty( array_intersect( $this->get_valid_query_vars(), array_keys( $query ) ) ) ) { + // When the $query has keys but doesn't contain any valid query keys, we unpack/spread it then merge. + if ( ! empty( $query ) && empty( array_intersect( $this->get_valid_query_vars(), array_keys( $query ) ) ) ) { return $this->merge_queries( $acc, ...array_values( $query ) ); } @@ -1750,4 +1775,28 @@ class ProductCollection extends AbstractBlock { return $price_filter + array_sum( $taxes ); } + + /** + * Registers handlers for a collection. + * + * @param string $collection_name The name of the custom collection. + * @param callable $build_query A hook returning any custom query arguments to merge with the collection's query. + * @param callable|null $frontend_args An optional hook that returns any frontend collection arguments to pass to the query builder. + * @param callable|null $editor_args An optional hook that returns any REST collection arguments to pass to the query builder. + * @param callable|null $preview_query An optional hook that returns a query to use in preview mode. + * + * @throws \InvalidArgumentException If collection handlers are already registered for the given collection name. + */ + protected function register_collection_handlers( $collection_name, $build_query, $frontend_args = null, $editor_args = null, $preview_query = null ) { + if ( isset( $this->collection_handler_store[ $collection_name ] ) ) { + throw new \InvalidArgumentException( 'Collection handlers already registered for ' . esc_html( $collection_name ) ); + } + + $this->collection_handler_store[ $collection_name ] = array( + 'build_query' => $build_query, + 'frontend_args' => $frontend_args, + 'editor_args' => $editor_args, + 'preview_query' => $preview_query, + ); + } } diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php index 643b906859c..49cf1db1a27 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php @@ -981,4 +981,138 @@ class ProductCollection extends \WP_UnitTestCase { $this->assertCount( 1, $merged_query['post__in'] ); } + + /** + * Test for frontend collection handlers. + */ + public function test_frontend_collection_handlers() { + $build_query = $this->getMockBuilder( \stdClass::class ) + ->setMethods( [ '__invoke' ] ) + ->getMock(); + $frontend_args = $this->getMockBuilder( \stdClass::class ) + ->setMethods( [ '__invoke' ] ) + ->getMock(); + $this->block_instance->register_collection_handlers( 'test-collection', $build_query, $frontend_args ); + + $frontend_args->expects( $this->once() ) + ->method( '__invoke' ) + ->willReturnCallback( + function ( $collection_args ) { + $collection_args['test'] = 'test-arg'; + return $collection_args; + } + ); + $build_query->expects( $this->once() ) + ->method( '__invoke' ) + ->willReturnCallback( + function ( $collection_args ) { + $this->assertArrayHasKey( 'test', $collection_args ); + $this->assertEquals( 'test-arg', $collection_args['test'] ); + return array( + 'post__in' => array( 111 ), + ); + } + ); + + $parsed_block = $this->get_base_parsed_block(); + $parsed_block['attrs']['collection'] = 'test-collection'; + + $merged_query = $this->initialize_merged_query( $parsed_block ); + + $this->block_instance->unregister_collection_handlers( 'test-collection' ); + + $this->assertContains( 111, $merged_query['post__in'] ); + } + + /** + * Test for editor collection handlers. + */ + public function test_editor_collection_handlers() { + $build_query = $this->getMockBuilder( \stdClass::class ) + ->setMethods( [ '__invoke' ] ) + ->getMock(); + $editor_args = $this->getMockBuilder( \stdClass::class ) + ->setMethods( [ '__invoke' ] ) + ->getMock(); + $this->block_instance->register_collection_handlers( 'test-collection', $build_query, null, $editor_args ); + + $editor_args->expects( $this->once() ) + ->method( '__invoke' ) + ->willReturnCallback( + function ( $collection_args ) { + $collection_args['test'] = 'test-arg'; + return $collection_args; + } + ); + $build_query->expects( $this->once() ) + ->method( '__invoke' ) + ->willReturnCallback( + function ( $collection_args ) { + $this->assertArrayHasKey( 'test', $collection_args ); + $this->assertEquals( 'test-arg', $collection_args['test'] ); + return array( + 'post__in' => array( 111 ), + ); + } + ); + + $args = array(); + $request = $this->build_request(); + $request->set_param( + 'productCollectionQueryContext', + array( + 'collection' => 'test-collection', + ) + ); + + $updated_query = $this->block_instance->update_rest_query_in_editor( $args, $request ); + + $this->block_instance->unregister_collection_handlers( 'test-collection' ); + + $this->assertContains( 111, $updated_query['post__in'] ); + } + + /** + * Test for the editor preview collection handler. + */ + public function test_editor_preview_collection_handler() { + $preview_query = $this->getMockBuilder( \stdClass::class ) + ->setMethods( [ '__invoke' ] ) + ->getMock(); + $this->block_instance->register_collection_handlers( + 'test-collection', + function () { + return array(); + }, + null, + null, + $preview_query + ); + + $preview_query->expects( $this->once() ) + ->method( '__invoke' ) + ->willReturn( + array( + 'post__in' => array( 123 ), + ) + ); + + $args = array(); + $request = $this->build_request(); + $request->set_param( + 'productCollectionQueryContext', + array( + 'collection' => 'test-collection', + 'previewState' => array( + 'isPreview' => 'true', + ), + ) + ); + + $updated_query = $this->block_instance->update_rest_query_in_editor( $args, $request ); + + $this->block_instance->unregister_collection_handlers( 'test-collection' ); + + $this->assertContains( 123, $updated_query['post__in'] ); + } } diff --git a/plugins/woocommerce/tests/php/src/Blocks/Mocks/ProductCollectionMock.php b/plugins/woocommerce/tests/php/src/Blocks/Mocks/ProductCollectionMock.php index 59e9bae0b4f..71a74e1ca40 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/Mocks/ProductCollectionMock.php +++ b/plugins/woocommerce/tests/php/src/Blocks/Mocks/ProductCollectionMock.php @@ -9,6 +9,8 @@ use Automattic\WooCommerce\Blocks\Assets\Api; use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry; use Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry; +// phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod.Found + /** * ProductCollectionMock used to test Product Query block functions. */ @@ -49,4 +51,26 @@ class ProductCollectionMock extends ProductCollection { public function set_attributes_filter_query_args( $data ) { $this->attributes_filter_query_args = $data; } + + /** + * Makes a protected method public so that it can be used in tests. + * + * @param string $collection_name The name of the custom collection. + * @param callable $build_query A hook returning any custom query arguments to merge with the collection's query. + * @param callable|null $frontend_args An optional hook that returns any frontend collection arguments to pass to the query builder. + * @param callable|null $editor_args An optional hook that returns any REST collection arguments to pass to the query builder. + * @param callable|null $preview_query An optional hook that returns a query to use in preview mode. + */ + public function register_collection_handlers( $collection_name, $build_query, $frontend_args = null, $editor_args = null, $preview_query = null ) { + parent::register_collection_handlers( $collection_name, $build_query, $frontend_args, $editor_args, $preview_query ); + } + + /** + * Removes any custom collection handlers for the given collection. + * + * @param string $collection_name The name of the collection to unregister. + */ + public function unregister_collection_handlers( $collection_name ) { + unset( $this->collection_handlers[ $collection_name ] ); + } } From bd29f481c5d54e65587dd95c255e355f4e83e93b Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Wed, 11 Sep 2024 23:01:49 -0700 Subject: [PATCH 16/26] Removed Second Test Query Construction As it was, `build_query_vars_from_query_block()` was triggering the ProductCollection instance hooked into WordPress. After that, we called `build_frontend_query()` on our test instance. This caused some weird behavior in tests. As a result, however, the tax_query merge test couldn't rely on the tax_query transformation done by WordPress. --- .../Blocks/BlockTypes/ProductCollection.php | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php index 49cf1db1a27..84a2cf12a4a 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php @@ -60,8 +60,9 @@ class ProductCollection extends \WP_UnitTestCase { * Build the merged_query for testing * * @param array $parsed_block Parsed block data. + * @param array $query Query data. */ - private function initialize_merged_query( $parsed_block = array() ) { + private function initialize_merged_query( $parsed_block = array(), $query = array() ) { if ( empty( $parsed_block ) ) { $parsed_block = $this->get_base_parsed_block(); } @@ -71,8 +72,6 @@ class ProductCollection extends \WP_UnitTestCase { $block = new \stdClass(); $block->context = $parsed_block['attrs']; - $query = build_query_vars_from_query_block( $block, 1 ); - return $this->block_instance->build_frontend_query( $query, $block, 1 ); } @@ -598,14 +597,27 @@ class ProductCollection extends \WP_UnitTestCase { * - Product tags */ public function test_merging_taxonomies_query() { - $parsed_block = $this->get_base_parsed_block(); - $parsed_block['attrs']['query']['taxQuery'] = array( - 'product_cat' => array( 1, 2 ), - 'product_tag' => array( 3, 4 ), + $merged_query = $this->initialize_merged_query( + null, + // Since we aren't calling the Query Loop build function, we need to provide + // a tax_query rather than relying on it generating one from the input. + array( + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query + 'tax_query' => array( + array( + 'taxonomy' => 'product_cat', + 'terms' => array( 1, 2 ), + 'include_children' => false, + ), + array( + 'taxonomy' => 'product_tag', + 'terms' => array( 3, 4 ), + 'include_children' => false, + ), + ), + ) ); - $merged_query = $this->initialize_merged_query( $parsed_block ); - $this->assertContains( array( 'taxonomy' => 'product_cat', From 9926d9d20b1efe98c47d260a6e8cabd9ffdddf1c Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Wed, 11 Sep 2024 23:02:19 -0700 Subject: [PATCH 17/26] Added Related Product Collection Handlers --- .../Blocks/BlockTypes/ProductCollection.php | 89 +++++++++++++++--- .../Blocks/BlockTypes/ProductCollection.php | 91 +++++++++++++++++++ .../Blocks/Mocks/ProductCollectionMock.php | 4 +- 3 files changed, 171 insertions(+), 13 deletions(-) diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index 652609bf4e9..1efe9ceecd7 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -3,6 +3,7 @@ namespace Automattic\WooCommerce\Blocks\BlockTypes; use Automattic\WooCommerce\Blocks\Utils\ProductCollectionUtils; +use InvalidArgumentException; use WP_Query; use WC_Tax; @@ -130,6 +131,8 @@ class ProductCollection extends AbstractBlock { // Disable client-side-navigation if incompatible blocks are detected. add_filter( 'render_block_data', array( $this, 'disable_enhanced_pagination' ), 10, 1 ); + + $this->register_core_collections(); } /** @@ -636,13 +639,16 @@ class ProductCollection extends AbstractBlock { $product_collection_query_context = $request->get_param( 'productCollectionQueryContext' ); $collection_args = array( - 'name' => $product_collection_query_context['collection'] ?? '', + 'name' => $product_collection_query_context['collection'] ?? '', + // The editor uses a REST query to grab product post types. This means we don't have a block + // instance to work with and the client needs to provide the location context. + 'productCollectionLocation' => $request->get_param( 'productCollectionLocation' ), ); // Allow collections to modify the collection arguments passed to the query builder. $handlers = $this->collection_handler_store[ $collection_args['name'] ] ?? null; if ( isset( $handlers['editor_args'] ) ) { - $collection_args = call_user_func( $handlers['editor_args'], $collection_args, $query ); + $collection_args = call_user_func( $handlers['editor_args'], $collection_args, $query, $request ); } // Is this a preview mode request? @@ -736,18 +742,29 @@ class ProductCollection extends AbstractBlock { $is_exclude_applied_filters = ! ( $inherit || $filterable ); - return $this->get_final_frontend_query( $block_context_query, $page, $is_exclude_applied_filters ); + $collection_args = array( + 'name' => $block->context['collection'] ?? '', + 'productCollectionLocation' => $block->context['productCollectionLocation'] ?? null, + ); + + return $this->get_final_frontend_query( + $collection_args, + $block_context_query, + $page, + $is_exclude_applied_filters + ); } /** * Get the final query arguments for the frontend. * - * @param array $query The query arguments. - * @param int $page The page number. + * @param array $collection_args Any special arguments that should change the behavior of the query. + * @param array $query The query arguments. + * @param int $page The page number. * @param bool $is_exclude_applied_filters Whether to exclude the applied filters or not. */ - private function get_final_frontend_query( $query, $page = 1, $is_exclude_applied_filters = false ) { + private function get_final_frontend_query( $collection_args, $query, $page = 1, $is_exclude_applied_filters = false ) { $product_ids = $query['post__in'] ?? array(); $offset = $query['offset'] ?? 0; $per_page = $query['perPage'] ?? 9; @@ -774,10 +791,6 @@ class ProductCollection extends AbstractBlock { $time_frame = $query['timeFrame'] ?? null; $price_range = $query['priceRange'] ?? null; - $collection_args = array( - 'name' => $this->parsed_block['attrs']['collection'] ?? '', - ); - // Allow collections to modify the collection arguments passed to the query builder. $handlers = $this->collection_handler_store[ $collection_args['name'] ] ?? null; if ( isset( $handlers['frontend_args'] ) ) { @@ -847,7 +860,7 @@ class ProductCollection extends AbstractBlock { $collection_query = array(); } - return $this->merge_queries( + $merged = $this->merge_queries( $common_query_values, $orderby_query, $on_sale_query, @@ -859,6 +872,8 @@ class ProductCollection extends AbstractBlock { $handpicked_query, $collection_query ); + + return $merged; } /** @@ -1799,4 +1814,56 @@ class ProductCollection extends AbstractBlock { 'preview_query' => $preview_query, ); } + + /** + * Registers any handlers for the core collections. + */ + protected function register_core_collections() { + $this->register_collection_handlers( + 'woocommerce/product-collection/related', + function ( $collection_args ) { + // No products should be shown if no related product reference is set. + if ( empty( $collection_args['relatedProductReference'] ) ) { + return array( + 'post__in' => array( -1 ), + ); + } + + // Have it filter the results to products related to the one provided. + return array( + 'post__in' => wc_get_related_products( + $collection_args['relatedProductReference'], + // Use a higher limit so that the result set contains enough products for the collection to subsequently filter. + 100 + ), + ); + }, + function ( $collection_args, $query ) { + $product_reference = $query['productReference'] ?? null; + // Infer the product reference from the location if an explicit product is not set. + if ( empty( $product_reference ) ) { + $location = $collection_args['productCollectionLocation']; + if ( isset( $location['type'] ) && 'product' === $location['type'] ) { + $product_reference = $location['sourceData']['productId']; + } + } + + $collection_args['relatedProductReference'] = $product_reference; + return $collection_args; + }, + function ( $collection_args, $query, $request ) { + $product_reference = $request->get_param( 'productReference' ); + // In some cases the editor will send along block location context that we can infer the product reference from. + if ( empty( $product_reference ) ) { + $location = $collection_args['productCollectionLocation']; + if ( isset( $location['type'] ) && 'product' === $location['type'] ) { + $product_reference = $location['sourceData']['productId']; + } + } + + $collection_args['relatedProductReference'] = $product_reference; + return $collection_args; + } + ); + } } diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php index 84a2cf12a4a..77946863ff1 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php @@ -1127,4 +1127,95 @@ class ProductCollection extends \WP_UnitTestCase { $this->assertContains( 123, $updated_query['post__in'] ); } + + /** + * Provides the test input and expected output for the collection handler tests. + */ + public function core_collection_handler_test_provider() { + $related_filter = $this->getMockBuilder( \stdClass::class ) + ->setMethods( [ '__invoke' ] ) + ->getMock(); + + return array( + array( + 'woocommerce/product-collection/related', + function () use ( $related_filter ) { + // This filter will turn off the data store so we don't need dummy products. + add_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); + $related_filter->expects( $this->exactly( 2 ) ) + ->method( '__invoke' ) + ->with( array(), 1 ) + ->willReturn( array( 2, 3, 4 ) ); + add_filter( 'woocommerce_related_products', array( $related_filter, '__invoke' ), 10, 2 ); + }, + function () use ( $related_filter ) { + remove_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); + remove_filter( 'woocommerce_related_products', array( $related_filter, '__invoke' ) ); + }, + array( + 'productReference' => 1, + ), + array( + 'productReference' => 1, + ), + array( + 'post__in' => array( 2, 3, 4 ), + ), + ), + ); + } + + /** + * Tests that the core collection handlers behave correctly. + * + * @dataProvider core_collection_handler_test_provider + * + * @param string $collection_name The name of the collection under test. + * @param callable|null $set_up An optional function to call before the test. + * @param callable|null $tear_down An optional function to call after the test. + * @param array $frontend_query The query arguments to use for the frontend queries. + * @param array $editor_query The query arguments to use for the editor queries. + * @param array $expected_query Any query arguments we expect to be present. + */ + public function test_core_collection_handlers( + $collection_name, + $set_up, + $tear_down, + $frontend_query, + $editor_query, + $expected_query + ) { + if ( isset( $set_up ) ) { + $set_up(); + } + + // Frontend. + $parsed_block = $this->get_base_parsed_block(); + $parsed_block['attrs']['collection'] = $collection_name; + foreach ( $frontend_query as $key => $value ) { + $parsed_block['attrs']['query'][ $key ] = $value; + } + $result_frontend = $this->initialize_merged_query( $parsed_block ); + + // Editor. + $request = $this->build_request( $editor_query ); + $request->set_param( + 'productCollectionQueryContext', + array( + 'collection' => $collection_name, + ) + ); + $result_editor = $this->block_instance->update_rest_query_in_editor( array(), $request ); + + if ( isset( $tear_down ) ) { + $tear_down(); + } + + foreach ( $expected_query as $key => $value ) { + $this->assertEqualsCanonicalizing( $value, $result_frontend[ $key ] ); + } + foreach ( $expected_query as $key => $value ) { + $this->assertEqualsCanonicalizing( $value, $result_editor[ $key ] ); + } + } } diff --git a/plugins/woocommerce/tests/php/src/Blocks/Mocks/ProductCollectionMock.php b/plugins/woocommerce/tests/php/src/Blocks/Mocks/ProductCollectionMock.php index 71a74e1ca40..1f9c95582b6 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/Mocks/ProductCollectionMock.php +++ b/plugins/woocommerce/tests/php/src/Blocks/Mocks/ProductCollectionMock.php @@ -28,10 +28,10 @@ class ProductCollectionMock extends ProductCollection { } /** - * For now don't need to initialize anything in tests so let's - * just override the default behaviour. + * Override the normal initialization behavior to prevent registering the block with WordPress filters. */ protected function initialize() { + $this->register_core_collections(); } /** From 3c5b47e2b7ee11fec4fbc745c44f660461d4dec9 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Wed, 11 Sep 2024 23:15:03 -0700 Subject: [PATCH 18/26] Revert "Support Unlimited `wc_get_related_products()`" This reverts commit 41c837202ebad27031b82f4227cba0b307bc7905. --- .../class-wc-product-data-store-cpt.php | 24 +-- .../class-wc-product-data-store-interface.php | 2 +- .../includes/wc-product-functions.php | 20 +-- .../includes/wc-product-functions-test.php | 147 ------------------ 4 files changed, 14 insertions(+), 179 deletions(-) diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php index 68234967684..b5a611d6f8a 100644 --- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php +++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php @@ -1453,7 +1453,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da * @param array $cats_array List of categories IDs. * @param array $tags_array List of tags IDs. * @param array $exclude_ids Excluded IDs. - * @param int $limit Limit of results, -1 for no limit. + * @param int $limit Limit of results. * @param int $product_id Product ID. * @return array */ @@ -1464,20 +1464,13 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da 'categories' => $cats_array, 'tags' => $tags_array, 'exclude_ids' => $exclude_ids, - 'limit' => $limit, + 'limit' => $limit + 10, ); - $related_product_query = (array) apply_filters( - 'woocommerce_product_related_posts_query', - $this->get_related_products_query( $cats_array, $tags_array, $exclude_ids, $limit ), - $product_id, - $args - ); + $related_product_query = (array) apply_filters( 'woocommerce_product_related_posts_query', $this->get_related_products_query( $cats_array, $tags_array, $exclude_ids, $limit + 10 ), $product_id, $args ); // phpcs:ignore WordPress.VIP.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared - $products = $wpdb->get_col( implode( ' ', $related_product_query ) ); - - return array_map( 'intval', $products ); + return $wpdb->get_col( implode( ' ', $related_product_query ) ); } /** @@ -1488,7 +1481,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da * @param array $cats_array List of categories IDs. * @param array $tags_array List of tags IDs. * @param array $exclude_ids Excluded IDs. - * @param int $limit Limit of results, -1 for no limit. + * @param int $limit Limit of results. * * @return array */ @@ -1518,12 +1511,11 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da AND p.post_type = 'product' ", + 'limits' => ' + LIMIT ' . absint( $limit ) . ' + ', ); - if ( $limit > 0 ) { - $query['limits'] = ' LIMIT ' . absint( $limit ); - } - if ( count( $exclude_term_ids ) ) { $query['join'] .= " LEFT JOIN ( SELECT object_id FROM {$wpdb->term_relationships} WHERE term_taxonomy_id IN ( " . implode( ',', array_map( 'absint', $exclude_term_ids ) ) . ' ) ) AS exclude_join ON exclude_join.object_id = p.ID'; $query['where'] .= ' AND exclude_join.object_id IS NULL'; diff --git a/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php b/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php index 4199fefa857..ed9244ae56a 100644 --- a/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php +++ b/plugins/woocommerce/includes/interfaces/class-wc-product-data-store-interface.php @@ -86,7 +86,7 @@ interface WC_Product_Data_Store_Interface { * @param array $cats_array List of categories IDs. * @param array $tags_array List of tags IDs. * @param array $exclude_ids Excluded IDs. - * @param int $limit Limit of results, -1 for no limit. + * @param int $limit Limit of results. * @param int $product_id Product ID. * @return array */ diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php index 7f2450df9f5..7232ef7ccc7 100644 --- a/plugins/woocommerce/includes/wc-product-functions.php +++ b/plugins/woocommerce/includes/wc-product-functions.php @@ -976,15 +976,14 @@ function wc_get_product_backorder_options() { * * @since 3.0.0 * @param int $product_id Product ID. - * @param int $limit Limit of results, -1 for no limit. + * @param int $limit Limit of results. * @param array $exclude_ids Exclude IDs from the results. * @return array */ function wc_get_related_products( $product_id, $limit = 5, $exclude_ids = array() ) { $product_id = absint( $product_id ); - $limit = intval( $limit ); - $limit = $limit < -1 ? -1 : $limit; // Any negative number will default to no limit. + $limit = $limit >= -1 ? $limit : 5; $exclude_ids = array_merge( array( 0, $product_id ), $exclude_ids ); $transient_name = 'wc_related_' . $product_id; $query_args = http_build_query( @@ -998,7 +997,7 @@ function wc_get_related_products( $product_id, $limit = 5, $exclude_ids = array( $related_posts = $transient && is_array( $transient ) && isset( $transient[ $query_args ] ) ? $transient[ $query_args ] : false; // We want to query related posts if they are not cached, or we don't have enough. - if ( false === $related_posts || ( $limit > 0 && count( $related_posts ) < $limit ) ) { + if ( false === $related_posts || count( $related_posts ) < $limit ) { $cats_array = apply_filters( 'woocommerce_product_related_posts_relate_by_category', true, $product_id ) ? apply_filters( 'woocommerce_get_related_product_cat_terms', wc_get_product_term_ids( $product_id, 'product_cat' ), $product_id ) : array(); $tags_array = apply_filters( 'woocommerce_product_related_posts_relate_by_tag', true, $product_id ) ? apply_filters( 'woocommerce_get_related_product_tag_terms', wc_get_product_term_ids( $product_id, 'product_tag' ), $product_id ) : array(); @@ -1007,13 +1006,8 @@ function wc_get_related_products( $product_id, $limit = 5, $exclude_ids = array( if ( empty( $cats_array ) && empty( $tags_array ) && ! apply_filters( 'woocommerce_product_related_posts_force_display', false, $product_id ) ) { $related_posts = array(); } else { - // For backward compatibility we need to grab 10 extra products. This was done so that when we shuffle the - // output below it will appear to be random by including different products rather than the same ones - // in a different order. - $query_limit = $limit > 0 ? $limit + 10 : -1; - $data_store = WC_Data_Store::load( 'product' ); - $related_posts = $data_store->get_related_products( $cats_array, $tags_array, $exclude_ids, $query_limit, $product_id ); + $related_posts = $data_store->get_related_products( $cats_array, $tags_array, $exclude_ids, $limit + 10, $product_id ); } if ( $transient && is_array( $transient ) ) { @@ -1039,11 +1033,7 @@ function wc_get_related_products( $product_id, $limit = 5, $exclude_ids = array( shuffle( $related_posts ); } - if ( $limit > 0 ) { - return array_slice( $related_posts, 0, $limit ); - } - - return $related_posts; + return array_slice( $related_posts, 0, $limit ); } /** diff --git a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php index 30f4b47b980..247c74e4fe7 100644 --- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php +++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php @@ -240,151 +240,4 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case { $this->assertEquals( 100, wc_get_product( $product->get_id() )->get_price() ); } - - /** - * @testDox Products related by tag or category should be returned. - */ - public function test_wc_get_related_products() { - add_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_true', 100 ); - add_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_true', 100 ); - - $related_tag = wp_insert_term( 'Related Tag', 'product_tag' ); - $related_cat = wp_insert_term( 'Related Category', 'product_cat' ); - - $product = WC_Helper_Product::create_simple_product(); - $product->set_tag_ids( array( $related_tag['term_id'] ) ); - $product->set_category_ids( array( $related_cat['term_id'] ) ); - $product->save(); - - $related_product_1 = WC_Helper_Product::create_simple_product(); - $related_product_1->set_category_ids( array( $related_cat['term_id'] ) ); - $related_product_1->save(); - - $related_product_2 = WC_Helper_Product::create_simple_product(); - $related_product_2->set_tag_ids( array( $related_tag['term_id'] ) ); - $related_product_2->save(); - - $related = wc_get_related_products( $product->get_id() ); - - remove_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_true', 100 ); - remove_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_true', 100 ); - - $this->assertEquals( 2, count( $related ) ); - $this->assertContains( $related_product_1->get_id(), $related ); - $this->assertContains( $related_product_2->get_id(), $related ); - } - - /** - * @testDox Only products related by tag should be returned when the appropriate filters are set. - */ - public function test_wc_get_related_products_by_tag() { - add_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_false', 100 ); - add_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_true', 100 ); - - $related_tag = wp_insert_term( 'Related Tag', 'product_tag' ); - $related_cat = wp_insert_term( 'Related Category', 'product_cat' ); - - $product = WC_Helper_Product::create_simple_product(); - $product->set_tag_ids( array( $related_tag['term_id'] ) ); - $product->set_category_ids( array( $related_cat['term_id'] ) ); - $product->save(); - - $related_product_1 = WC_Helper_Product::create_simple_product(); - $related_product_1->set_tag_ids( array( $related_tag['term_id'] ) ); - $related_product_1->set_category_ids( array( $related_cat['term_id'] ) ); - $related_product_1->save(); - - $related_product_2 = WC_Helper_Product::create_simple_product(); - $related_product_2->set_tag_ids( array( $related_tag['term_id'] ) ); - $related_product_2->set_category_ids( array( $related_cat['term_id'] ) ); - $related_product_2->save(); - - $unrelated_product = WC_Helper_Product::create_simple_product(); - $unrelated_product->set_category_ids( array( $related_cat['term_id'] ) ); - $unrelated_product->save(); - - $related = wc_get_related_products( $product->get_id() ); - - remove_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_false', 100 ); - remove_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_true', 100 ); - - $this->assertEquals( 2, count( $related ) ); - $this->assertContains( $related_product_1->get_id(), $related ); - $this->assertContains( $related_product_2->get_id(), $related ); - } - - /** - * @testDox Only products related by category should be returned when the appropriate filters are set. - */ - public function test_wc_get_related_products_by_category() { - add_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_true', 100 ); - add_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_false', 100 ); - - $related_tag = wp_insert_term( 'Related Tag', 'product_tag' ); - $related_cat = wp_insert_term( 'Related Category', 'product_cat' ); - - $product = WC_Helper_Product::create_simple_product(); - $product->set_tag_ids( array( $related_tag['term_id'] ) ); - $product->set_category_ids( array( $related_cat['term_id'] ) ); - $product->save(); - - $related_product_1 = WC_Helper_Product::create_simple_product(); - $related_product_1->set_tag_ids( array( $related_tag['term_id'] ) ); - $related_product_1->set_category_ids( array( $related_cat['term_id'] ) ); - $related_product_1->save(); - - $related_product_2 = WC_Helper_Product::create_simple_product(); - $related_product_2->set_tag_ids( array( $related_tag['term_id'] ) ); - $related_product_2->set_category_ids( array( $related_cat['term_id'] ) ); - $related_product_2->save(); - - $unrelated_product = WC_Helper_Product::create_simple_product(); - $unrelated_product->set_tag_ids( array( $related_tag['term_id'] ) ); - $unrelated_product->save(); - - $related = wc_get_related_products( $product->get_id() ); - - remove_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_true', 100 ); - remove_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_false', 100 ); - - $this->assertEquals( 2, count( $related ) ); - $this->assertContains( $related_product_1->get_id(), $related ); - $this->assertContains( $related_product_2->get_id(), $related ); - } - - /** - * @testDox Products related by tag or category should apply a limit to the results. - */ - public function test_wc_get_related_products_limit() { - add_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_true', 100 ); - add_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_true', 100 ); - - $related_tag = wp_insert_term( 'Related Tag', 'product_tag' ); - $related_cat = wp_insert_term( 'Related Category', 'product_cat' ); - - $product = WC_Helper_Product::create_simple_product(); - $product->set_tag_ids( array( $related_tag['term_id'] ) ); - $product->set_category_ids( array( $related_cat['term_id'] ) ); - $product->save(); - - $related_product_1 = WC_Helper_Product::create_simple_product(); - $related_product_1->set_category_ids( array( $related_cat['term_id'] ) ); - $related_product_1->save(); - - $related_product_2 = WC_Helper_Product::create_simple_product(); - $related_product_2->set_tag_ids( array( $related_tag['term_id'] ) ); - $related_product_2->save(); - - $related = wc_get_related_products( $product->get_id(), 1 ); - $unlimited_related = wc_get_related_products( $product->get_id(), -1 ); - - remove_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_true', 100 ); - remove_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_true', 100 ); - - $this->assertEquals( 1, count( $related ) ); - $this->assertContains( $related[0], array( $related_product_1->get_id(), $related_product_2->get_id() ) ); - $this->assertEquals( 2, count( $unlimited_related ) ); - $this->assertContains( $related_product_1->get_id(), $unlimited_related ); - $this->assertContains( $related_product_2->get_id(), $unlimited_related ); - } } From cea25124123b9bb99f011717376e862977fbc89a Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Wed, 11 Sep 2024 23:47:14 -0700 Subject: [PATCH 19/26] Fixed Preview Mode Query It looks like we were checking the wrong place for the preview state. --- .../src/Blocks/BlockTypes/ProductCollection.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index 1efe9ceecd7..2a9b7660fe2 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -651,10 +651,9 @@ class ProductCollection extends AbstractBlock { $collection_args = call_user_func( $handlers['editor_args'], $collection_args, $query, $request ); } - // Is this a preview mode request? - // If yes, short-circuit the query and return the preview query args. - $is_preview = $product_collection_query_context['previewState']['isPreview'] ?? false; - if ( 'true' === $is_preview ) { + // When requested, short-circuit the query and return the preview query args. + $preview_state = $request->get_param( 'previewState' ); + if ( isset( $preview_state['isPreview'] ) && 'true' === $preview_state['isPreview'] ) { return $this->get_preview_query_args( $collection_args, $query, $request ); } @@ -860,7 +859,7 @@ class ProductCollection extends AbstractBlock { $collection_query = array(); } - $merged = $this->merge_queries( + return $this->merge_queries( $common_query_values, $orderby_query, $on_sale_query, @@ -872,8 +871,6 @@ class ProductCollection extends AbstractBlock { $handpicked_query, $collection_query ); - - return $merged; } /** From c35a64721e2c79dcdb17ba060da5a5a0ea09c8bb Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Thu, 12 Sep 2024 06:54:54 -0700 Subject: [PATCH 20/26] Fixed Test --- .../php/src/Blocks/BlockTypes/ProductCollection.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php index 77946863ff1..6430a680b1f 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php @@ -1114,10 +1114,13 @@ class ProductCollection extends \WP_UnitTestCase { $request->set_param( 'productCollectionQueryContext', array( - 'collection' => 'test-collection', - 'previewState' => array( - 'isPreview' => 'true', - ), + 'collection' => 'test-collection', + ) + ); + $request->set_param( + 'previewState', + array( + 'isPreview' => 'true', ) ); From 750e4723a101776f2a48329d534eb61c37531189 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Thu, 12 Sep 2024 09:49:26 -0700 Subject: [PATCH 21/26] Updated Collection Heading --- .../assets/js/blocks/product-collection/collections/related.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related.tsx index ec530da03dc..3de8cd441e5 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/collections/related.tsx @@ -38,7 +38,7 @@ const heading: InnerBlockTemplate = [ { textAlign: 'center', level: 2, - content: __( 'You may also like', 'woocommerce' ), + content: __( 'Related Products', 'woocommerce' ), style: { spacing: { margin: { bottom: '1rem' } } }, }, ]; From 32449f5fd0d3c2b896e69024c5c599725918ccc6 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Mon, 16 Sep 2024 13:06:16 -0700 Subject: [PATCH 22/26] Better Collection Test Teardown Passing a callable back means that we can use variables in the setUp and then the tearDown without any confusion. --- .../Blocks/BlockTypes/ProductCollection.php | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php index 6430a680b1f..a1386defe53 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php @@ -1135,14 +1135,14 @@ class ProductCollection extends \WP_UnitTestCase { * Provides the test input and expected output for the collection handler tests. */ public function core_collection_handler_test_provider() { - $related_filter = $this->getMockBuilder( \stdClass::class ) - ->setMethods( [ '__invoke' ] ) - ->getMock(); - return array( array( 'woocommerce/product-collection/related', - function () use ( $related_filter ) { + function () { + $related_filter = $this->getMockBuilder( \stdClass::class ) + ->setMethods( [ '__invoke' ] ) + ->getMock(); + // This filter will turn off the data store so we don't need dummy products. add_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); $related_filter->expects( $this->exactly( 2 ) ) @@ -1150,10 +1150,11 @@ class ProductCollection extends \WP_UnitTestCase { ->with( array(), 1 ) ->willReturn( array( 2, 3, 4 ) ); add_filter( 'woocommerce_related_products', array( $related_filter, '__invoke' ), 10, 2 ); - }, - function () use ( $related_filter ) { - remove_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); - remove_filter( 'woocommerce_related_products', array( $related_filter, '__invoke' ) ); + + return function () use ( $related_filter ) { + remove_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); + remove_filter( 'woocommerce_related_products', array( $related_filter, '__invoke' ) ); + }; }, array( 'productReference' => 1, @@ -1174,8 +1175,7 @@ class ProductCollection extends \WP_UnitTestCase { * @dataProvider core_collection_handler_test_provider * * @param string $collection_name The name of the collection under test. - * @param callable|null $set_up An optional function to call before the test. - * @param callable|null $tear_down An optional function to call after the test. + * @param callable|null $set_up An optional function to call before the test. This function can return a callable to clean up after the test. * @param array $frontend_query The query arguments to use for the frontend queries. * @param array $editor_query The query arguments to use for the editor queries. * @param array $expected_query Any query arguments we expect to be present. @@ -1183,13 +1183,13 @@ class ProductCollection extends \WP_UnitTestCase { public function test_core_collection_handlers( $collection_name, $set_up, - $tear_down, $frontend_query, $editor_query, $expected_query ) { + $tear_down = null; if ( isset( $set_up ) ) { - $set_up(); + $tear_down = call_user_func( $set_up ); } // Frontend. @@ -1210,8 +1210,8 @@ class ProductCollection extends \WP_UnitTestCase { ); $result_editor = $this->block_instance->update_rest_query_in_editor( array(), $request ); - if ( isset( $tear_down ) ) { - $tear_down(); + if ( is_callable( $tear_down ) ) { + call_user_func( $tear_down ); } foreach ( $expected_query as $key => $value ) { From 1c055f4d282bd80af44a489945e6cbfdc7a76981 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Mon, 16 Sep 2024 13:24:56 -0700 Subject: [PATCH 23/26] Removed Unnecessary Test Provider --- .../Blocks/BlockTypes/ProductCollection.php | 98 +++++-------------- 1 file changed, 25 insertions(+), 73 deletions(-) diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php index a1386defe53..3f0bf143ed5 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php @@ -1132,93 +1132,45 @@ class ProductCollection extends \WP_UnitTestCase { } /** - * Provides the test input and expected output for the collection handler tests. + * Tests that the related products collection handler works as expected. */ - public function core_collection_handler_test_provider() { - return array( - array( - 'woocommerce/product-collection/related', - function () { - $related_filter = $this->getMockBuilder( \stdClass::class ) - ->setMethods( [ '__invoke' ] ) - ->getMock(); + public function test_collection_related_products() { + $related_filter = $this->getMockBuilder( \stdClass::class ) + ->setMethods( [ '__invoke' ] ) + ->getMock(); - // This filter will turn off the data store so we don't need dummy products. - add_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); - $related_filter->expects( $this->exactly( 2 ) ) - ->method( '__invoke' ) - ->with( array(), 1 ) - ->willReturn( array( 2, 3, 4 ) ); - add_filter( 'woocommerce_related_products', array( $related_filter, '__invoke' ), 10, 2 ); + $expected_product_ids = array( 2, 3, 4 ); - return function () use ( $related_filter ) { - remove_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); - remove_filter( 'woocommerce_related_products', array( $related_filter, '__invoke' ) ); - }; - }, - array( - 'productReference' => 1, - ), - array( - 'productReference' => 1, - ), - array( - 'post__in' => array( 2, 3, 4 ), - ), - ), - ); - } - - /** - * Tests that the core collection handlers behave correctly. - * - * @dataProvider core_collection_handler_test_provider - * - * @param string $collection_name The name of the collection under test. - * @param callable|null $set_up An optional function to call before the test. This function can return a callable to clean up after the test. - * @param array $frontend_query The query arguments to use for the frontend queries. - * @param array $editor_query The query arguments to use for the editor queries. - * @param array $expected_query Any query arguments we expect to be present. - */ - public function test_core_collection_handlers( - $collection_name, - $set_up, - $frontend_query, - $editor_query, - $expected_query - ) { - $tear_down = null; - if ( isset( $set_up ) ) { - $tear_down = call_user_func( $set_up ); - } + // This filter will turn off the data store so we don't need dummy products. + add_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); + $related_filter->expects( $this->exactly( 2 ) ) + ->method( '__invoke' ) + ->with( array(), 1 ) + ->willReturn( $expected_product_ids ); + add_filter( 'woocommerce_related_products', array( $related_filter, '__invoke' ), 10, 2 ); // Frontend. - $parsed_block = $this->get_base_parsed_block(); - $parsed_block['attrs']['collection'] = $collection_name; - foreach ( $frontend_query as $key => $value ) { - $parsed_block['attrs']['query'][ $key ] = $value; - } - $result_frontend = $this->initialize_merged_query( $parsed_block ); + $parsed_block = $this->get_base_parsed_block(); + $parsed_block['attrs']['collection'] = 'woocommerce/product-collection/related'; + $parsed_block['attrs']['query']['productReference'] = 1; + $result_frontend = $this->initialize_merged_query( $parsed_block ); // Editor. - $request = $this->build_request( $editor_query ); + $request = $this->build_request( + array( 'productReference' => 1 ) + ); $request->set_param( 'productCollectionQueryContext', array( - 'collection' => $collection_name, + 'collection' => 'woocommerce/product-collection/related', ) ); $result_editor = $this->block_instance->update_rest_query_in_editor( array(), $request ); - if ( is_callable( $tear_down ) ) { - call_user_func( $tear_down ); - } + remove_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 ); + remove_filter( 'woocommerce_related_products', array( $related_filter, '__invoke' ) ); - foreach ( $expected_query as $key => $value ) { - $this->assertEqualsCanonicalizing( $value, $result_frontend[ $key ] ); - } - foreach ( $expected_query as $key => $value ) { - $this->assertEqualsCanonicalizing( $value, $result_editor[ $key ] ); - } + $this->assertEqualsCanonicalizing( $expected_product_ids, $result_frontend['post__in'] ); + $this->assertEqualsCanonicalizing( $expected_product_ids, $result_editor['post__in'] ); } } From 1fa3ea32d585af84c7ec60cb15381eb53bcda1fd Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Mon, 16 Sep 2024 22:12:16 -0700 Subject: [PATCH 24/26] Fixed `$post__in` Merging When the intersection is empty it was returning all products. This makes it so that it returns nothing when there's no results. --- .../Blocks/BlockTypes/ProductCollection.php | 5 +++++ .../Blocks/BlockTypes/ProductCollection.php | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index 72285fd79d1..66618f333f3 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -990,6 +990,11 @@ class ProductCollection extends AbstractBlock { // has otherwise excluded from the results. if ( count( $post__in ) > 1 ) { $post__in = array_intersect( ...$post__in ); + // An empty array means that there was no overlap between the filters and so + // the query should return no results. + if ( empty( $post__in ) ) { + return array( -1 ); + } } else { $post__in = reset( $post__in ); } diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php index 3f0bf143ed5..f3ee91ab08a 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection.php @@ -975,7 +975,7 @@ class ProductCollection extends \WP_UnitTestCase { /** * Test merging exclusive id filters. */ - public function test_merges_exclusive_id_filters() { + public function test_merges_post__in() { $existing_id_filter = array( 1, 4 ); $handpicked_product_ids = array( 3, 4, 5, 6 ); // The only ID present in ALL of the exclusive filters is 4. @@ -994,6 +994,22 @@ class ProductCollection extends \WP_UnitTestCase { $this->assertCount( 1, $merged_query['post__in'] ); } + /** + * Test merging exclusive id filters with no intersection. + */ + public function test_merges_post__in_empty_result_without_intersection() { + $existing_id_filter = array( 1, 4 ); + $handpicked_product_ids = array( 2, 3 ); + + $parsed_block = $this->get_base_parsed_block(); + $parsed_block['attrs']['query']['post__in'] = $existing_id_filter; + $parsed_block['attrs']['query']['woocommerceHandPickedProducts'] = $handpicked_product_ids; + + $merged_query = $this->initialize_merged_query( $parsed_block ); + + $this->assertEquals( array( -1 ), $merged_query['post__in'] ); + } + /** * Test for frontend collection handlers. */ From f7a83ee05360a098b5b0cccbf491dc655d403c2c Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Wed, 18 Sep 2024 07:12:32 -0700 Subject: [PATCH 25/26] Fixed PHPDoc Co-authored-by: Manish Menaria --- .../src/Blocks/BlockTypes/ProductCollection.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index 66618f333f3..502178a051d 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -19,10 +19,11 @@ class ProductCollection extends AbstractBlock { */ protected $block_name = 'product-collection'; - /** - * An array keyed by the name of the collection containing handlers for implementing custom collection behavior. + /** + * An associative array of collection handlers. * - * @var array + * @var array $collection_handler_store + * Keys are collection names, values are callable handlers for custom collection behavior. */ protected $collection_handler_store = array(); From 376a1b5223899c808702f424e156e03d7b6a9a16 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Wed, 18 Sep 2024 08:11:07 -0700 Subject: [PATCH 26/26] Hide Empty Related Product Collections --- .../src/Blocks/BlockTypes/ProductCollection.php | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index 502178a051d..83eb74be35d 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -1839,13 +1839,20 @@ class ProductCollection extends AbstractBlock { ); } + $related_products = wc_get_related_products( + $collection_args['relatedProductReference'], + // Use a higher limit so that the result set contains enough products for the collection to subsequently filter. + 100 + ); + if ( empty( $related_products ) ) { + return array( + 'post__in' => array( -1 ), + ); + } + // Have it filter the results to products related to the one provided. return array( - 'post__in' => wc_get_related_products( - $collection_args['relatedProductReference'], - // Use a higher limit so that the result set contains enough products for the collection to subsequently filter. - 100 - ), + 'post__in' => $related_products, ); }, function ( $collection_args, $query ) {