From 0d3e954c5766bc8e00eb75a1b0a9ccff5495ce81 Mon Sep 17 00:00:00 2001 From: Lucio Giannotta Date: Tue, 29 Nov 2022 14:44:52 +0100 Subject: [PATCH] Product Query: Add support for filtering by attributes within the block (#7743) --- assets/js/blocks/product-query/constants.ts | 2 + .../product-query/inspector-controls.tsx | 2 + .../inspector-controls/attributes-filter.tsx | 156 ++++++++++++++++++ assets/js/blocks/product-query/types.ts | 14 +- .../product-query/useProductAttributes.ts | 62 +++++++ src/BlockTypes/ProductQuery.php | 43 ++++- 6 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 assets/js/blocks/product-query/inspector-controls/attributes-filter.tsx create mode 100644 assets/js/blocks/product-query/useProductAttributes.ts diff --git a/assets/js/blocks/product-query/constants.ts b/assets/js/blocks/product-query/constants.ts index c5732551805..7f11e826604 100644 --- a/assets/js/blocks/product-query/constants.ts +++ b/assets/js/blocks/product-query/constants.ts @@ -23,6 +23,7 @@ export const QUERY_LOOP_ID = 'core/query'; export const DEFAULT_CORE_ALLOWED_CONTROLS = [ 'taxQuery', 'search' ]; export const ALL_PRODUCT_QUERY_CONTROLS = [ + 'attributes', 'presets', 'onSale', 'stockStatus', @@ -61,6 +62,7 @@ export const QUERY_DEFAULT_ATTRIBUTES: QueryBlockAttributes = { exclude: [], sticky: '', inherit: false, + __woocommerceAttributes: [], __woocommerceStockStatus: GLOBAL_HIDE_OUT_OF_STOCK ? Object.keys( objectOmit( STOCK_STATUS_OPTIONS, 'outofstock' ) ) : Object.keys( STOCK_STATUS_OPTIONS ), diff --git a/assets/js/blocks/product-query/inspector-controls.tsx b/assets/js/blocks/product-query/inspector-controls.tsx index 75374d17d59..1816f22ff01 100644 --- a/assets/js/blocks/product-query/inspector-controls.tsx +++ b/assets/js/blocks/product-query/inspector-controls.tsx @@ -35,6 +35,7 @@ import { STOCK_STATUS_OPTIONS, } from './constants'; import { PopularPresets } from './inspector-controls/popular-presets'; +import { AttributesFilter } from './inspector-controls/attributes-filter'; const NAMESPACED_CONTROLS = ALL_PRODUCT_QUERY_CONTROLS.map( ( id ) => @@ -84,6 +85,7 @@ function getStockStatusIdByLabel( statusLabel: FormTokenField.Value ) { } export const TOOLS_PANEL_CONTROLS = { + attributes: AttributesFilter, onSale: ( props: ProductQueryBlock ) => { const { query } = props.attributes; diff --git a/assets/js/blocks/product-query/inspector-controls/attributes-filter.tsx b/assets/js/blocks/product-query/inspector-controls/attributes-filter.tsx new file mode 100644 index 00000000000..2ef1b645465 --- /dev/null +++ b/assets/js/blocks/product-query/inspector-controls/attributes-filter.tsx @@ -0,0 +1,156 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + FormTokenField, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToolsPanelItem as ToolsPanelItem, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { + AttributeMetadata, + AttributeWithTerms, + ProductQueryBlock, +} from '../types'; +import useProductAttributes from '../useProductAttributes'; +import { setQueryAttribute } from '../utils'; + +function getAttributeMetadataFromToken( + token: string, + productsAttributes: AttributeWithTerms[] +) { + const [ attributeLabel, termName ] = token.split( ': ' ); + const taxonomy = productsAttributes.find( + ( attribute ) => attribute.attribute_label === attributeLabel + ); + + if ( ! taxonomy ) + throw new Error( 'Product Query Filter: Invalid attribute label' ); + + const term = taxonomy.terms.find( + ( currentTerm ) => currentTerm.name === termName + ); + + if ( ! term ) throw new Error( 'Product Query Filter: Invalid term name' ); + + return { + taxonomy: `pa_${ taxonomy.attribute_name }`, + termId: term.id, + }; +} + +function getAttributeFromMetadata( + metadata: AttributeMetadata, + productsAttributes: AttributeWithTerms[] +) { + const taxonomy = productsAttributes.find( + ( attribute ) => + attribute.attribute_name === metadata.taxonomy.slice( 3 ) + ); + + return { + taxonomy, + term: taxonomy?.terms.find( ( term ) => term.id === metadata.termId ), + }; +} + +function getInputValueFromQueryParam( + queryParam: AttributeMetadata[] | undefined, + productAttributes: AttributeWithTerms[] +): FormTokenField.Value[] { + return ( + queryParam?.map( ( metadata ) => { + const { taxonomy, term } = getAttributeFromMetadata( + metadata, + productAttributes + ); + + return ! taxonomy || ! term + ? { + title: __( + 'Saved taxonomy was perhaps deleted or the slug was changed.', + 'woo-gutenberg-products-block' + ), + value: __( + `Error with saved taxonomy`, + 'woo-gutenberg-products-block' + ), + status: 'error', + } + : `${ taxonomy.attribute_label }: ${ term.name }`; + } ) || [] + ); +} + +export const AttributesFilter = ( props: ProductQueryBlock ) => { + const { query } = props.attributes; + const { isLoadingAttributes, productsAttributes } = + useProductAttributes( true ); + + const attributesSuggestions = productsAttributes.reduce( ( acc, curr ) => { + const namespacedTerms = curr.terms.map( + ( term ) => `${ curr.attribute_label }: ${ term.name }` + ); + + return [ ...acc, ...namespacedTerms ]; + }, [] as string[] ); + + return ( + query.__woocommerceAttributes?.length } + > + { + let __woocommerceAttributes; + + try { + __woocommerceAttributes = attributes.map( + ( attribute ) => { + attribute = + typeof attribute === 'string' + ? attribute + : attribute.value; + + return getAttributeMetadataFromToken( + attribute, + productsAttributes + ); + } + ); + + setQueryAttribute( props, { + __woocommerceAttributes, + } ); + } catch ( ok ) { + // Not required to do anything here + // Input validation is handled by the `validateInput` + // below, and we don't need to save anything. + } + } } + suggestions={ attributesSuggestions } + validateInput={ ( value: string ) => + attributesSuggestions.includes( value ) + } + value={ + isLoadingAttributes + ? [ __( 'Loading…', 'woo-gutenberg-products-block' ) ] + : getInputValueFromQueryParam( + query.__woocommerceAttributes, + productsAttributes + ) + } + __experimentalExpandOnFocus={ true } + /> + + ); +}; diff --git a/assets/js/blocks/product-query/types.ts b/assets/js/blocks/product-query/types.ts index 901ccbbada1..4e5067eed7c 100644 --- a/assets/js/blocks/product-query/types.ts +++ b/assets/js/blocks/product-query/types.ts @@ -1,7 +1,18 @@ /** * External dependencies */ -import type { EditorBlock } from '@woocommerce/types'; +import type { + AttributeSetting, + AttributeTerm, + EditorBlock, +} from '@woocommerce/types'; + +export interface AttributeMetadata { + taxonomy: string; + termId: number; +} + +export type AttributeWithTerms = AttributeSetting & { terms: AttributeTerm[] }; // The interface below disables the forbidden underscores // naming convention because we are namespacing our @@ -16,6 +27,7 @@ export interface ProductQueryArguments { * the choice to those. */ orderBy: 'date' | 'popularity'; + __woocommerceAttributes?: AttributeMetadata[]; /** * Display only products on sale. * diff --git a/assets/js/blocks/product-query/useProductAttributes.ts b/assets/js/blocks/product-query/useProductAttributes.ts new file mode 100644 index 00000000000..b2da1786109 --- /dev/null +++ b/assets/js/blocks/product-query/useProductAttributes.ts @@ -0,0 +1,62 @@ +/** + * External dependencies + */ +import { useEffect, useRef, useState } from '@wordpress/element'; +import { getTerms } from '@woocommerce/editor-components/utils'; +import { getSetting } from '@woocommerce/settings'; +import { AttributeSetting } from '@woocommerce/types'; + +/** + * Internal dependencies + */ +import { AttributeWithTerms } from './types'; + +export default function useProductAttributes( shouldLoadAttributes: boolean ) { + const STORE_ATTRIBUTES = getSetting< AttributeSetting[] >( + 'attributes', + [] + ); + const [ isLoadingAttributes, setIsLoadingAttributes ] = useState( false ); + const [ productsAttributes, setProductsAttributes ] = useState< + AttributeWithTerms[] + >( [] ); + const hasLoadedAttributes = useRef( false ); + + useEffect( () => { + if ( + ! shouldLoadAttributes || + isLoadingAttributes || + hasLoadedAttributes.current + ) + return; + + async function fetchTerms() { + setIsLoadingAttributes( true ); + + for ( const attribute of STORE_ATTRIBUTES ) { + const terms = await getTerms( + Number( attribute.attribute_id ) + ); + + setProductsAttributes( ( oldAttributes ) => [ + ...oldAttributes, + { + ...attribute, + terms, + }, + ] ); + } + + hasLoadedAttributes.current = true; + setIsLoadingAttributes( false ); + } + + fetchTerms(); + + return () => { + hasLoadedAttributes.current = true; + }; + }, [ STORE_ATTRIBUTES, isLoadingAttributes, shouldLoadAttributes ] ); + + return { isLoadingAttributes, productsAttributes }; +} diff --git a/src/BlockTypes/ProductQuery.php b/src/BlockTypes/ProductQuery.php index 4ba07717b56..0a928e02513 100644 --- a/src/BlockTypes/ProductQuery.php +++ b/src/BlockTypes/ProductQuery.php @@ -122,8 +122,9 @@ public function update_query( $pre_render, $parsed_block ) { public function update_rest_query( $args, $request ) { $on_sale_query = $request->get_param( '__woocommerceOnSale' ) !== 'true' ? array() : $this->get_on_sale_products_query(); $orderby_query = $this->get_custom_orderby_query( $request->get_param( 'orderby' ) ); + $tax_query = $this->get_product_attributes_query( $request->get_param( '__woocommerceAttributes' ) ); - return array_merge( $args, $on_sale_query, $orderby_query ); + return array_merge( $args, $on_sale_query, $orderby_query, $tax_query ); } /** @@ -279,6 +280,40 @@ private function get_custom_orderby_query( $orderby ) { ); } + /** + * Return the `tax_query` for the requested attributes + * + * @param array $attributes Attributes and their terms. + * + * @return array + */ + private function get_product_attributes_query( $attributes ) { + $grouped_attributes = array_reduce( + $attributes, + function ( $carry, $item ) { + $taxonomy = sanitize_title( $item['taxonomy'] ); + + if ( ! key_exists( $taxonomy, $carry ) ) { + $carry[ $taxonomy ] = array( + 'field' => 'term_id', + 'operator' => 'IN', + 'taxonomy' => $taxonomy, + 'terms' => array( $item['termId'] ), + ); + } else { + $carry[ $taxonomy ]['terms'][] = $item['termId']; + } + + return $carry; + }, + array() + ); + + return array( + 'tax_query' => array_values( $grouped_attributes ), + ); + } + /** * Return a query for products depending on their stock status. * @@ -394,10 +429,12 @@ private function get_queries_by_applied_filters() { * @return array */ private function get_queries_by_attributes( $parsed_block ) { - $query = $parsed_block['attrs']['query']; - $on_sale_enabled = isset( $query['__woocommerceOnSale'] ) && true === $query['__woocommerceOnSale']; + $query = $parsed_block['attrs']['query']; + $on_sale_enabled = isset( $query['__woocommerceOnSale'] ) && true === $query['__woocommerceOnSale']; + $attributes_query = isset( $query['__woocommerceAttributes'] ) ? $this->get_product_attributes_query( $query['__woocommerceAttributes'] ) : array(); return array( + 'attributes' => $attributes_query, 'on_sale' => ( $on_sale_enabled ? $this->get_on_sale_products_query() : array() ), 'stock_status' => isset( $query['__woocommerceStockStatus'] ) ? $this->get_stock_status_query( $query['__woocommerceStockStatus'] ) : array(), );