Product Query: Add support for filtering by attributes within the block (https://github.com/woocommerce/woocommerce-blocks/pull/7743)
This commit is contained in:
parent
0517c06495
commit
181838bfdf
|
@ -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 ),
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -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(),
|
||||
);
|
||||
|
|
Loading…
Reference in New Issue