From da191209e9ed5d6707132b168db7dacd427bdd7e Mon Sep 17 00:00:00 2001 From: Lucio Giannotta <lucio.giannotta@a8c.com> Date: Mon, 8 May 2023 14:50:10 +0200 Subject: [PATCH] Implement Hand-Picked Products block (#7925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the ProductSelector advanced filter within the “Products (Beta)” block. The filter allows the merchant to narrow down the exact products to which all subsequent filters will be applied, mirroring the functionality of the existing “Hand-picked Products” plus all the other functionalities available from the “Products (Beta)” block. --- assets/js/blocks/product-query/constants.ts | 11 +- .../product-query/inspector-controls.tsx | 4 +- .../inspector-controls/product-selector.tsx | 108 ++++++++++++++++++ assets/js/blocks/product-query/types.ts | 1 + assets/js/utils/index.ts | 1 + assets/js/utils/object-operations.ts | 8 ++ src/BlockTypes/ProductQuery.php | 32 +++++- 7 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 assets/js/blocks/product-query/inspector-controls/product-selector.tsx create mode 100644 assets/js/utils/object-operations.ts diff --git a/assets/js/blocks/product-query/constants.ts b/assets/js/blocks/product-query/constants.ts index e8ae8e33159..61e0b9771cc 100644 --- a/assets/js/blocks/product-query/constants.ts +++ b/assets/js/blocks/product-query/constants.ts @@ -2,6 +2,7 @@ * External dependencies */ import { getSetting } from '@woocommerce/settings'; +import { objectOmit } from '@woocommerce/utils'; import type { InnerBlockTemplate } from '@wordpress/blocks'; /** @@ -12,15 +13,6 @@ import { VARIATION_NAME as PRODUCT_TITLE_ID } from './variations/elements/produc import { VARIATION_NAME as PRODUCT_TEMPLATE_ID } from './variations/elements/product-template'; import { ImageSizing } from '../../atomic/blocks/product-elements/image/types'; -/** - * Returns an object without a key. - */ -function objectOmit< T, K extends keyof T >( obj: T, key: K ) { - const { [ key ]: omit, ...rest } = obj; - - return rest; -} - export const EDIT_ATTRIBUTES_URL = '/wp-admin/edit.php?post_type=product&page=product_attributes'; @@ -31,6 +23,7 @@ export const DEFAULT_CORE_ALLOWED_CONTROLS = [ 'taxQuery', 'search' ]; export const ALL_PRODUCT_QUERY_CONTROLS = [ 'attributes', 'presets', + 'productSelector', 'onSale', 'stockStatus', 'wooInherit', diff --git a/assets/js/blocks/product-query/inspector-controls.tsx b/assets/js/blocks/product-query/inspector-controls.tsx index 87ec22f422c..1099a6015b5 100644 --- a/assets/js/blocks/product-query/inspector-controls.tsx +++ b/assets/js/blocks/product-query/inspector-controls.tsx @@ -38,8 +38,9 @@ import { QUERY_LOOP_ID, STOCK_STATUS_OPTIONS, } from './constants'; -import { PopularPresets } from './inspector-controls/popular-presets'; import { AttributesFilter } from './inspector-controls/attributes-filter'; +import { PopularPresets } from './inspector-controls/popular-presets'; +import { ProductSelector } from './inspector-controls/product-selector'; import './editor.scss'; @@ -168,6 +169,7 @@ export const TOOLS_PANEL_CONTROLS = { </ToolsPanelItem> ); }, + productSelector: ProductSelector, stockStatus: ( props: ProductQueryBlock ) => { const { query } = props.attributes; diff --git a/assets/js/blocks/product-query/inspector-controls/product-selector.tsx b/assets/js/blocks/product-query/inspector-controls/product-selector.tsx new file mode 100644 index 00000000000..a49da3d0354 --- /dev/null +++ b/assets/js/blocks/product-query/inspector-controls/product-selector.tsx @@ -0,0 +1,108 @@ +/** + * External dependencies + */ +import { getProducts } from '@woocommerce/editor-components/utils'; +import { ProductResponseItem } from '@woocommerce/types'; +import { objectOmit } from '@woocommerce/utils'; +import { useState, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { + FormTokenField, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToolsPanelItem as ToolsPanelItem, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { ProductQueryBlock } from '../types'; +import { setQueryAttribute } from '../utils'; + +function useProductsList() { + const [ productsList, setProductsList ] = useState< ProductResponseItem[] >( + [] + ); + + useEffect( () => { + getProducts( { selected: [] } ).then( ( results ) => { + setProductsList( results as ProductResponseItem[] ); + } ); + }, [] ); + + return productsList; +} + +export const ProductSelector = ( props: ProductQueryBlock ) => { + const { query } = props.attributes; + + const productsList = useProductsList(); + + const onTokenChange = ( values: FormTokenField.Value[] ) => { + const ids = values + .map( + ( nameOrId ) => + productsList.find( + ( product ) => + product.name === nameOrId || + product.id === Number( nameOrId ) + )?.id + ) + .filter( Boolean ) + .map( String ); + + if ( ! ids.length && props.attributes.query.include ) { + const prunedQuery = objectOmit( props.attributes.query, 'include' ); + + setQueryAttribute( + { + ...props, + attributes: { + ...props.attributes, + query: prunedQuery, + }, + }, + {} + ); + } else { + setQueryAttribute( props, { + include: ids, + } ); + } + }; + + return ( + <ToolsPanelItem + label={ __( + 'Hand-picked Products', + 'woo-gutenberg-products-block' + ) } + hasValue={ () => query.include?.length } + > + <FormTokenField + disabled={ ! productsList.length } + displayTransform={ ( token: string ) => + Number.isNaN( Number( token ) ) + ? token + : productsList.find( + ( product ) => product.id === Number( token ) + )?.name || '' + } + label={ __( + 'Pick some products', + 'woo-gutenberg-products-block' + ) } + onChange={ onTokenChange } + suggestions={ productsList.map( ( product ) => product.name ) } + validateInput={ ( value: string ) => + productsList.find( ( product ) => product.name === value ) + } + value={ + ! productsList.length + ? [ __( 'Loading…', 'woo-gutenberg-products-block' ) ] + : query?.include || [] + } + __experimentalExpandOnFocus={ true } + /> + </ToolsPanelItem> + ); +}; diff --git a/assets/js/blocks/product-query/types.ts b/assets/js/blocks/product-query/types.ts index 0cfbf6fcfe1..39d84ffe891 100644 --- a/assets/js/blocks/product-query/types.ts +++ b/assets/js/blocks/product-query/types.ts @@ -80,6 +80,7 @@ export interface QueryBlockAttributes { export interface QueryBlockQuery { author?: string; exclude?: string[]; + include?: string[]; inherit: boolean; offset?: number; order: 'asc' | 'desc'; diff --git a/assets/js/utils/index.ts b/assets/js/utils/index.ts index c86184973fb..d869a64aadc 100644 --- a/assets/js/utils/index.ts +++ b/assets/js/utils/index.ts @@ -3,6 +3,7 @@ export * from './attributes-query'; export * from './attributes'; export * from './filters'; export * from './notices'; +export * from './object-operations'; export * from './products'; export * from './shared-attributes'; export * from './sanitize-html'; diff --git a/assets/js/utils/object-operations.ts b/assets/js/utils/object-operations.ts new file mode 100644 index 00000000000..2eb89fa55a4 --- /dev/null +++ b/assets/js/utils/object-operations.ts @@ -0,0 +1,8 @@ +/** + * Returns an object without a key. + */ +export function objectOmit< T, K extends keyof T >( obj: T, key: K ) { + const { [ key ]: omit, ...rest } = obj; + + return rest; +} diff --git a/src/BlockTypes/ProductQuery.php b/src/BlockTypes/ProductQuery.php index 6420019e97e..2edd0a5a956 100644 --- a/src/BlockTypes/ProductQuery.php +++ b/src/BlockTypes/ProductQuery.php @@ -165,18 +165,21 @@ public function build_query( $query ) { } $common_query_values = array( - 'post_type' => 'product', - 'post__in' => array(), - 'post_status' => 'publish', + 'meta_query' => array(), 'posts_per_page' => $query['posts_per_page'], 'orderby' => $query['orderby'], 'order' => $query['order'], 'offset' => $query['offset'], - 'meta_query' => array(), + 'post__in' => array(), + 'post_status' => 'publish', + 'post_type' => 'product', 'tax_query' => array(), ); - return $this->merge_queries( + $handpicked_products = isset( $parsed_block['attrs']['query']['include'] ) ? + $parsed_block['attrs']['query']['include'] : $common_query_values['post__in']; + + $merged_query = $this->merge_queries( $common_query_values, $this->get_global_query( $parsed_block ), $this->get_custom_orderby_query( $query['orderby'] ), @@ -185,6 +188,8 @@ public function build_query( $query ) { $this->get_filter_by_taxonomies_query( $query ), $this->get_filter_by_keyword_query( $query ) ); + + return $this->filter_query_to_only_include_ids( $merged_query, $handpicked_products ); } /** @@ -307,6 +312,23 @@ private function get_custom_orderby_query( $orderby ) { ); } + /** + * 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; + } + /** * Return the `tax_query` for the requested attributes *