Product Query: Add support for filtering by attributes within the block (https://github.com/woocommerce/woocommerce-blocks/pull/7743)

This commit is contained in:
Lucio Giannotta 2022-11-29 14:44:52 +01:00 committed by GitHub
parent 0517c06495
commit 181838bfdf
6 changed files with 275 additions and 4 deletions

View File

@ -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 ),

View File

@ -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;

View File

@ -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 (
<ToolsPanelItem
label={ __( 'Product Attributes', 'woo-gutenberg-products-block' ) }
hasValue={ () => query.__woocommerceAttributes?.length }
>
<FormTokenField
disabled={ isLoadingAttributes }
label={ __(
'Product Attributes',
'woo-gutenberg-products-block'
) }
onChange={ ( attributes ) => {
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 }
/>
</ToolsPanelItem>
);
};

View File

@ -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.
*

View File

@ -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 };
}

View File

@ -122,8 +122,9 @@ class ProductQuery extends AbstractBlock {
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 @@ class ProductQuery extends AbstractBlock {
);
}
/**
* 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 @@ class ProductQuery extends AbstractBlock {
* @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(),
);