From d91385e3b91b8e44dc9028109c1afa45c2df473e Mon Sep 17 00:00:00 2001 From: Manish Menaria Date: Thu, 8 Jun 2023 11:33:01 +0530 Subject: [PATCH] Product Collection: Hand picked products control in sidebar settings (https://github.com/woocommerce/woocommerce-blocks/pull/9733) * Add support for hand-picked products in Product Collection block This commit introduces the ability to manually select specific products in the Product Collection block. Changes include: - Added `woocommerceHandPickedProducts` to the `ProductCollectionQuery` and `DEFAULT_FILTERS` in `constants.ts`. - Created a new control file `hand-picked-products-control.tsx` which introduces a token field where the user can search for and select specific products. - Included `HandPickedProductsControl` in the Product Collection block's inspector controls in `index.tsx`. - Updated `ProductCollectionQuery` in `types.ts` to accommodate handpicked products. - Updated the PHP `ProductCollection` class to handle the hand-picked products query parameters. These changes allow users to hand-pick products to be displayed in the Product Collection block. This allows for greater customization of the products shown in the block. * Enhance handling of hand-picked products - A Set data structure is now used to store 'newHandPickedProducts' instead of an Array, which will help prevent duplicate entries. - Additionally, the suggestions for products to be hand-picked now excludes already selected products, enhancing the user experience by avoiding redundancy in the suggestions list. - Lastly, the function name 'displayTransform' has been changed to 'transformTokenIntoProductName' to more accurately reflect its purpose, which is to transform a token into a product name. * Update import & export of HandPickedProductsControl --- .../js/blocks/product-collection/constants.ts | 4 +- .../hand-picked-products-control.tsx | 146 ++++++++++++++++++ .../inspector-controls/index.tsx | 7 + .../js/blocks/product-collection/types.ts | 1 + .../src/BlockTypes/ProductCollection.php | 72 ++++++--- 5 files changed, 205 insertions(+), 25 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/js/blocks/product-collection/inspector-controls/hand-picked-products-control.tsx 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 2876e73f837..057ce3234f9 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/constants.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/constants.ts @@ -49,6 +49,7 @@ export const DEFAULT_QUERY: ProductCollectionQuery = { woocommerceOnSale: false, woocommerceStockStatus: getDefaultStockStatuses(), woocommerceAttributes: [], + woocommerceHandPickedProducts: [], }; export const DEFAULT_ATTRIBUTES: Partial< ProductCollectionAttributes > = { @@ -73,9 +74,10 @@ export const getDefaultSettings = ( }, } ); -export const DEFAULT_FILTERS = { +export const DEFAULT_FILTERS: Partial< ProductCollectionQuery > = { woocommerceOnSale: DEFAULT_QUERY.woocommerceOnSale, woocommerceStockStatus: getDefaultStockStatuses(), woocommerceAttributes: [], taxQuery: DEFAULT_QUERY.taxQuery, + woocommerceHandPickedProducts: [], }; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/inspector-controls/hand-picked-products-control.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/inspector-controls/hand-picked-products-control.tsx new file mode 100644 index 00000000000..b14ca82b2a0 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/inspector-controls/hand-picked-products-control.tsx @@ -0,0 +1,146 @@ +/** + * External dependencies + */ +import { getProducts } from '@woocommerce/editor-components/utils'; +import { ProductResponseItem } from '@woocommerce/types'; +import { useState, useEffect, useCallback, useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { + FormTokenField, + // @ts-expect-error Using experimental features + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToolsPanelItem as ToolsPanelItem, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { ProductCollectionQuery } from '../types'; + +interface HandPickedProductsControlProps { + setQueryAttribute: ( value: Partial< ProductCollectionQuery > ) => void; + selectedProductIds?: string[] | undefined; +} + +/** + * Returns: + * - productsMap: Map of products by id and name. + * - productsList: List of products retrieved. + */ +function useProducts() { + // Creating a map for fast lookup of products by id or name. + const [ productsMap, setProductsMap ] = useState< + Map< number | string, ProductResponseItem > + >( new Map() ); + + // List of products retrieved + const [ productsList, setProductsList ] = useState< ProductResponseItem[] >( + [] + ); + + useEffect( () => { + getProducts( { selected: [] } ).then( ( results ) => { + const newProductsMap = new Map(); + ( results as ProductResponseItem[] ).forEach( ( product ) => { + newProductsMap.set( product.id, product ); + newProductsMap.set( product.name, product ); + } ); + + setProductsList( results as ProductResponseItem[] ); + setProductsMap( newProductsMap ); + } ); + }, [] ); + + return { productsMap, productsList }; +} + +const HandPickedProductsControl = ( { + selectedProductIds, + setQueryAttribute, +}: HandPickedProductsControlProps ) => { + const { productsMap, productsList } = useProducts(); + + const onTokenChange = useCallback( + ( values: string[] ) => { + // Map the tokens to product ids. + const newHandPickedProductsSet = values.reduce( + ( acc, nameOrId ) => { + const product = + productsMap.get( nameOrId ) || + productsMap.get( Number( nameOrId ) ); + if ( product ) acc.add( String( product.id ) ); + return acc; + }, + new Set< string >() + ); + + setQueryAttribute( { + woocommerceHandPickedProducts: Array.from( + newHandPickedProductsSet + ), + } ); + }, + [ setQueryAttribute, productsMap ] + ); + + const suggestions = useMemo( () => { + return ( + productsList + // Filter out products that are already selected. + .filter( + ( product ) => + ! selectedProductIds?.includes( String( product.id ) ) + ) + .map( ( product ) => product.name ) + ); + }, [ productsList, selectedProductIds ] ); + + const transformTokenIntoProductName = ( token: string ) => { + const parsedToken = Number( token ); + + if ( Number.isNaN( parsedToken ) ) { + return token; + } + + const product = productsMap.get( parsedToken ); + + return product?.name || ''; + }; + + return ( + !! selectedProductIds?.length } + onDeselect={ () => { + setQueryAttribute( { + woocommerceHandPickedProducts: [], + } ); + } } + > + + productsMap.has( value ) + } + value={ + ! productsMap.size + ? [ __( 'Loading…', 'woo-gutenberg-products-block' ) ] + : selectedProductIds || [] + } + /> + + ); +}; + +export default HandPickedProductsControl; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/inspector-controls/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/inspector-controls/index.tsx index a0743cbcb99..7f4425cc9af 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/inspector-controls/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/inspector-controls/index.tsx @@ -25,6 +25,7 @@ import StockStatusControl from './stock-status-control'; import KeywordControl from './keyword-control'; import AttributesControl from './attributes-control'; import TaxonomyControls from './taxonomy-controls'; +import HandPickedProductsControl from './hand-picked-products-control'; import AuthorControl from './author-control'; const ProductCollectionInspectorControls = ( @@ -73,6 +74,12 @@ const ProductCollectionInspectorControls = ( > + get_param( 'orderBy' ); - $on_sale = $request->get_param( 'woocommerceOnSale' ) === 'true'; - $stock_status = $request->get_param( 'woocommerceStockStatus' ); - $product_attributes = $request->get_param( 'woocommerceAttributes' ); - $args['author'] = $request->get_param( 'author' ) ?? ''; + $orderby = $request->get_param( 'orderBy' ); + $on_sale = $request->get_param( 'woocommerceOnSale' ) === 'true'; + $stock_status = $request->get_param( 'woocommerceStockStatus' ); + $product_attributes = $request->get_param( 'woocommerceAttributes' ); + $handpicked_products = $request->get_param( 'woocommerceHandPickedProducts' ); + $args['author'] = $request->get_param( 'author' ) ?? ''; return $this->get_final_query_args( $args, array( - 'orderby' => $orderby, - 'on_sale' => $on_sale, - 'stock_status' => $stock_status, - 'product_attributes' => $product_attributes, + 'orderby' => $orderby, + 'on_sale' => $on_sale, + 'stock_status' => $stock_status, + 'product_attributes' => $product_attributes, + 'handpicked_products' => $handpicked_products, ) ); } @@ -100,15 +102,18 @@ class ProductCollection extends AbstractBlock { * @param array $query Query from block context. */ private function get_final_query_args( $common_query_values, $query ) { - $orderby_query = $query['orderby'] ? $this->get_custom_orderby_query( $query['orderby'] ) : []; - $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 ) : []; - $attributes_query = $this->get_product_attributes_query( $query['product_attributes'] ); - $taxonomies_query = $query['taxonomies_query'] ?? []; - $tax_query = $this->merge_tax_queries( $visibility_query, $attributes_query, $taxonomies_query ); + $handpicked_products = $query['handpicked_products'] ?? []; + $orderby_query = $query['orderby'] ? $this->get_custom_orderby_query( $query['orderby'] ) : []; + $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 ) : []; + $attributes_query = $this->get_product_attributes_query( $query['product_attributes'] ); + $taxonomies_query = $query['taxonomies_query'] ?? []; + $tax_query = $this->merge_tax_queries( $visibility_query, $attributes_query, $taxonomies_query ); - return $this->merge_queries( $common_query_values, $orderby_query, $on_sale_query, $stock_query, $tax_query ); + $merged_query = $this->merge_queries( $common_query_values, $orderby_query, $on_sale_query, $stock_query, $tax_query ); + + return $this->filter_query_to_only_include_ids( $merged_query, $handpicked_products ); } /** @@ -147,17 +152,19 @@ class ProductCollection extends AbstractBlock { 'author' => $block_context_query['author'] ?? '', ); - $is_on_sale = $block_context_query['woocommerceOnSale'] ?? false; - $taxonomies_query = $this->get_filter_by_taxonomies_query( $query['tax_query'] ?? [] ); + $is_on_sale = $block_context_query['woocommerceOnSale'] ?? false; + $taxonomies_query = $this->get_filter_by_taxonomies_query( $query['tax_query'] ?? [] ); + $handpicked_products = $block_context_query['woocommerceHandPickedProducts'] ?? []; return $this->get_final_query_args( $common_query_values, array( - 'on_sale' => $is_on_sale, - 'stock_status' => $block_context_query['woocommerceStockStatus'], - 'orderby' => $block_context_query['orderBy'], - 'product_attributes' => $block_context_query['woocommerceAttributes'], - 'taxonomies_query' => $taxonomies_query, + 'on_sale' => $is_on_sale, + 'stock_status' => $block_context_query['woocommerceStockStatus'], + 'orderby' => $block_context_query['orderBy'], + 'product_attributes' => $block_context_query['woocommerceAttributes'], + 'taxonomies_query' => $taxonomies_query, + 'handpicked_products' => $handpicked_products, ) ); } @@ -541,4 +548,21 @@ class ProductCollection extends AbstractBlock { // phpcs:ignore WordPress.DB.SlowDBQuery return ! empty( $result ) ? [ 'tax_query' => $result ] : []; } + + /** + * Apply the query only to a subset of products + * + * @param array $query The query. + * @param array $ids Array of selected product ids. + * + * @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'] ); + } + + return $query; + } }