From c55a387657945c94f3000760cab9fbde69691e70 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Sat, 23 Nov 2019 17:04:30 +0000 Subject: [PATCH] Prevent multiple collection data requests using debounce (https://github.com/woocommerce/woocommerce-blocks/pull/1233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add context for collection data query * Introduce useCollectionData hook * Implement hook in filter blocks * Update API to handle nuances of counts instead of client side * Clone requests so original is untouched * Prevent dupe requests is working * Cleanup * Update assets/js/base/hooks/use-collection-data.js Co-Authored-By: Albert Juhé Lluveras * Update assets/js/base/hooks/use-collection-data.js Co-Authored-By: Albert Juhé Lluveras * Feedback * Remove context --- .../assets/js/base/hooks/index.js | 1 + .../js/base/hooks/use-collection-data.js | 124 ++++++++++++++++++ .../js/blocks/attribute-filter/block.js | 38 +----- .../assets/js/blocks/price-filter/block.js | 18 +-- .../Controllers/ProductCollectionData.php | 79 +++++++++-- .../src/RestApi/StoreApi/README.md | 12 +- .../StoreApi/Utilities/ProductFiltering.php | 6 + .../Controllers/ProductCollectionData.php | 10 +- 8 files changed, 226 insertions(+), 62 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/js/base/hooks/use-collection-data.js diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/index.js b/plugins/woocommerce-blocks/assets/js/base/hooks/index.js index 5745d288c87..13c0ae28153 100644 --- a/plugins/woocommerce-blocks/assets/js/base/hooks/index.js +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/index.js @@ -3,4 +3,5 @@ export * from './use-shallow-equal'; export * from './use-store-products'; export * from './use-collection'; export * from './use-collection-header'; +export * from './use-collection-data'; export * from './use-previous'; diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/use-collection-data.js b/plugins/woocommerce-blocks/assets/js/base/hooks/use-collection-data.js new file mode 100644 index 00000000000..6a4c6dc7ad0 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/use-collection-data.js @@ -0,0 +1,124 @@ +/** + * External dependencies + */ +import { useState, useEffect, useMemo } from '@wordpress/element'; +import { + useQueryStateByContext, + useQueryStateByKey, + useCollection, +} from '@woocommerce/base-hooks'; +import { useQueryStateContext } from '@woocommerce/base-context/query-state-context'; +import { useDebounce } from 'use-debounce'; +import { sortBy } from 'lodash'; + +/** + * Internal dependencies + */ +import { useShallowEqual } from './use-shallow-equal'; + +const buildCollectionDataQuery = ( collectionDataQueryState ) => { + const query = collectionDataQueryState; + + if ( collectionDataQueryState.calculate_attribute_counts ) { + query.calculate_attribute_counts = sortBy( + collectionDataQueryState.calculate_attribute_counts.map( + ( { taxonomy, queryType } ) => { + return { + taxonomy, + query_type: queryType, + }; + } + ), + [ 'taxonomy', 'query_type' ] + ); + } + + return query; +}; + +export const useCollectionData = ( { + queryAttribute, + queryPrices, + queryState, +} ) => { + let context = useQueryStateContext(); + context = `${ context }-collection-data`; + + const [ collectionDataQueryState ] = useQueryStateByContext( context ); + const [ + calculateAttributesQueryState, + setCalculateAttributesQueryState, + ] = useQueryStateByKey( 'calculate_attribute_counts', [], context ); + const [ + calculatePriceRangeQueryState, + setCalculatePriceRangeQueryState, + ] = useQueryStateByKey( 'calculate_price_range', null, context ); + + const currentQueryAttribute = useShallowEqual( queryAttribute || {} ); + const currentQueryPrices = useShallowEqual( queryPrices ); + + useEffect( () => { + if ( + typeof currentQueryAttribute === 'object' && + Object.keys( currentQueryAttribute ).length + ) { + const foundAttribute = calculateAttributesQueryState.find( + ( attribute ) => { + return ( + attribute.taxonomy === currentQueryAttribute.taxonomy + ); + } + ); + + if ( ! foundAttribute ) { + setCalculateAttributesQueryState( [ + ...calculateAttributesQueryState, + currentQueryAttribute, + ] ); + } + } + }, [ + currentQueryAttribute, + calculateAttributesQueryState, + setCalculateAttributesQueryState, + ] ); + + useEffect( () => { + if ( + calculatePriceRangeQueryState !== currentQueryPrices && + currentQueryPrices !== undefined + ) { + setCalculatePriceRangeQueryState( currentQueryPrices ); + } + }, [ + currentQueryPrices, + setCalculatePriceRangeQueryState, + calculatePriceRangeQueryState, + ] ); + + // Defer the select query so all collection-data query vars can be gathered. + const [ shouldSelect, setShouldSelect ] = useState( false ); + const [ debouncedShouldSelect ] = useDebounce( shouldSelect, 200 ); + + if ( ! shouldSelect ) { + setShouldSelect( true ); + } + + const collectionDataQueryVars = useMemo( () => { + return buildCollectionDataQuery( collectionDataQueryState ); + }, [ collectionDataQueryState ] ); + + return useCollection( { + namespace: '/wc/store', + resourceName: 'products/collection-data', + query: { + ...queryState, + page: undefined, + per_page: undefined, + orderby: undefined, + order: undefined, + ...collectionDataQueryVars, + }, + shouldSelect: debouncedShouldSelect, + } ); +}; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/block.js b/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/block.js index f61bc6e159a..9f3178f1e6d 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/block.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/block.js @@ -5,6 +5,7 @@ import { useCollection, useQueryStateByKey, useQueryStateByContext, + useCollectionData, } from '@woocommerce/base-hooks'; import { useCallback, @@ -91,32 +92,6 @@ const AttributeFilterBlock = ( { .flatMap( ( attribute ) => attribute.slug ); }, [ productAttributesQuery, attributeObject ] ); - const filteredCountsQueryState = useMemo( () => { - // If doing an "AND" query, we need to remove current taxonomy query so counts are not affected. - const modifiedQueryState = - blockAttributes.queryType === 'or' - ? productAttributesQuery.filter( - ( item ) => item.attribute !== attributeObject.taxonomy - ) - : productAttributesQuery; - - // Take current query and remove paging args. - return { - ...queryState, - orderby: undefined, - order: undefined, - per_page: undefined, - page: undefined, - attributes: modifiedQueryState, - calculate_attribute_counts: [ attributeObject.taxonomy ], - }; - }, [ - queryState, - attributeObject, - blockAttributes, - productAttributesQuery, - ] ); - const { results: attributeTerms, isLoading: attributeTermsLoading, @@ -130,11 +105,12 @@ const AttributeFilterBlock = ( { const { results: filteredCounts, isLoading: filteredCountsLoading, - } = useCollection( { - namespace: '/wc/store', - resourceName: 'products/collection-data', - query: filteredCountsQueryState, - shouldSelect: blockAttributes.attributeId > 0, + } = useCollectionData( { + queryAttribute: { + taxonomy: attributeObject.taxonomy, + queryType: blockAttributes.queryType, + }, + queryState, } ); /** diff --git a/plugins/woocommerce-blocks/assets/js/blocks/price-filter/block.js b/plugins/woocommerce-blocks/assets/js/blocks/price-filter/block.js index 184e6df5ea6..39afa4d0490 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/price-filter/block.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/price-filter/block.js @@ -2,9 +2,9 @@ * External dependencies */ import { - useCollection, useQueryStateByKey, useQueryStateByContext, + useCollectionData, } from '@woocommerce/base-hooks'; import { Fragment, useCallback, useState, useEffect } from '@wordpress/element'; import PriceSlider from '@woocommerce/base-components/price-slider'; @@ -22,19 +22,9 @@ const PriceFilterBlock = ( { attributes, isPreview = false } ) => { 'max_price' ); const [ queryState ] = useQueryStateByContext(); - const { results, isLoading } = useCollection( { - namespace: '/wc/store', - resourceName: 'products/collection-data', - query: { - ...queryState, - min_price: undefined, - max_price: undefined, - orderby: undefined, - order: undefined, - per_page: undefined, - page: undefined, - calculate_price_range: true, - }, + const { results, isLoading } = useCollectionData( { + queryPrices: true, + queryState, } ); const [ minPrice, setMinPrice ] = useState(); diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/ProductCollectionData.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/ProductCollectionData.php index c9a1f6655ac..36f65b0af17 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/ProductCollectionData.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/ProductCollectionData.php @@ -143,27 +143,71 @@ class ProductCollectionData extends RestContoller { $filters = new ProductQueryFilters(); if ( ! empty( $request['calculate_price_range'] ) ) { - $price_results = $filters->get_filtered_price( $request ); + $filter_request = clone $request; + $filter_request->set_param( 'min_price', null ); + $filter_request->set_param( 'max_price', null ); + $price_results = $filters->get_filtered_price( $filter_request ); $return['min_price'] = $price_results->min_price; $return['max_price'] = $price_results->max_price; } if ( ! empty( $request['calculate_attribute_counts'] ) ) { - $return['attribute_counts'] = []; - $counts = $filters->get_attribute_counts( $request, $request['calculate_attribute_counts'] ); + $taxonomy__or_queries = []; + $taxonomy__and_queries = []; - foreach ( $counts as $key => $value ) { - $return['attribute_counts'][] = [ - 'term' => $key, - 'count' => $value, - ]; + foreach ( $request['calculate_attribute_counts'] as $attributes_to_count ) { + if ( 'or' === $attributes_to_count['query_type'] ) { + $taxonomy__or_queries[] = $attributes_to_count['taxonomy']; + } else { + $taxonomy__and_queries[] = $attributes_to_count['taxonomy']; + } + } + + $return['attribute_counts'] = []; + + // Or type queries need special handling because the attribute, if set, needs removing from the query first otherwise counts would not be correct. + if ( $taxonomy__or_queries ) { + foreach ( $taxonomy__or_queries as $taxonomy ) { + $filter_request = clone $request; + $filter_attributes = $filter_request->get_param( 'attributes' ); + + if ( ! empty( $filter_attributes ) ) { + $filter_attributes = array_filter( + $filter_attributes, + function( $query ) { + return $query['attribute'] !== $taxonomy; + } + ); + } + + $filter_request->set_param( 'attributes', $filter_attributes ); + $counts = $filters->get_attribute_counts( $filter_request, [ $taxonomy ] ); + + foreach ( $counts as $key => $value ) { + $return['attribute_counts'][] = [ + 'term' => $key, + 'count' => $value, + ]; + } + } + } + + if ( $taxonomy__and_queries ) { + $counts = $filters->get_attribute_counts( $request, $taxonomy__and_queries ); + + foreach ( $counts as $key => $value ) { + $return['attribute_counts'][] = [ + 'term' => $key, + 'count' => $value, + ]; + } } } if ( ! empty( $request['calculate_rating_counts'] ) ) { - $return['rating_counts'] = []; $counts = $filters->get_rating_counts( $request ); + $return['rating_counts'] = []; foreach ( $counts as $key => $value ) { $return['rating_counts'][] = [ @@ -194,7 +238,22 @@ class ProductCollectionData extends RestContoller { 'description' => __( 'If requested, calculates attribute term counts for products in the collection.', 'woo-gutenberg-products-block' ), 'type' => 'array', 'items' => array( - 'type' => 'string', + 'type' => 'object', + 'properties' => array( + 'taxonomy' => array( + 'description' => __( 'Taxonomy name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'query_type' => array( + 'description' => __( 'Query type being performed which may affect counts. Valid values include "and" and "or".', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'enum' => array( 'and', 'or' ), + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + ), ), 'default' => array(), ); diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/README.md b/plugins/woocommerce-blocks/src/RestApi/StoreApi/README.md index 71a9fabadec..0fb27acdee4 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/README.md +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/README.md @@ -100,15 +100,15 @@ This endpoint allows you to get aggregate data from a collection of products, fo ```http GET /products/collection-data GET /products/collection-data?calculate_price_range=true -GET /products/collection-data?calculate_attribute_counts=pa_size,pa_color +GET /products/collection-data?calculate_attribute_counts[0][query_type]=or&calculate_attribute_counts[0][taxonomy]=pa_color GET /products/collection-data?calculate_rating_counts=true ``` -| Attribute | Type | Required | Description | -| :--------------------------- | :----- | :------: | :--------------------------------------------------------------------------------------------------------------------------------------- | -| `calculate_price_range` | bool | No | Returns the min and max price for the product collection. If false, only `null` will be returned. | -| `calculate_attribute_counts` | string | No | Returns attribute counts for a list of attribute (taxonomy) names you pass in via the parameter. If empty, only `null` will be returned. | -| `calculate_rating_counts` | bool | No | Returns the counts of products with a certain average rating, 1-5. If false, only `null` will be returned. | +| Attribute | Type | Required | Description | +| :--------------------------- | :----- | :------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `calculate_price_range` | bool | No | Returns the min and max price for the product collection. If false, only `null` will be returned. | +| `calculate_attribute_counts` | object | No | Returns attribute counts for a list of attribute taxonomies you pass in via this parameter. Each should be provided as an object with keys "taxonomy" and "query_type". If empty, `null` will be returned. | +| `calculate_rating_counts` | bool | No | Returns the counts of products with a certain average rating, 1-5. If false, only `null` will be returned. | **In addition to the above attributes**, all product list attributes are supported. This allows you to get data for a certain subset of products. See [the products API list products section](#list-products) for the full list. diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/ProductFiltering.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/ProductFiltering.php index f485f32dcf6..c4d9f4daae8 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/ProductFiltering.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/ProductFiltering.php @@ -64,6 +64,12 @@ class ProductQueryFilters { public function get_attribute_counts( $request, $attributes = [] ) { global $wpdb; + // Remove paging and sorting params from the request. + $request->set_param( 'page', null ); + $request->set_param( 'per_page', null ); + $request->set_param( 'order', null ); + $request->set_param( 'orderby', null ); + // Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products. $product_query = new ProductQuery(); diff --git a/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/ProductCollectionData.php b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/ProductCollectionData.php index aa92461d1fe..01f1739d3cd 100644 --- a/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/ProductCollectionData.php +++ b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/ProductCollectionData.php @@ -107,7 +107,15 @@ class ProductCollectionData extends TestCase { ProductHelper::create_variation_product(); $request = new WP_REST_Request( 'GET', '/wc/store/products/collection-data' ); - $request->set_param( 'calculate_attribute_counts', 'pa_size' ); + $request->set_param( + 'calculate_attribute_counts', + [ + [ + 'taxonomy' => 'pa_size', + 'query_type' => 'and', + ] + ] + ); $response = $this->server->dispatch( $request ); $data = $response->get_data();