Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Commit

Permalink
Product Query: Add support for filtering by attributes within the blo…
Browse files Browse the repository at this point in the history
…ck (#7743)
  • Loading branch information
sunyatasattva authored Nov 29, 2022
1 parent 76e615b commit 0d3e954
Show file tree
Hide file tree
Showing 6 changed files with 275 additions and 4 deletions.
2 changes: 2 additions & 0 deletions assets/js/blocks/product-query/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 ),
Expand Down
2 changes: 2 additions & 0 deletions assets/js/blocks/product-query/inspector-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) =>
Expand Down Expand Up @@ -84,6 +85,7 @@ function getStockStatusIdByLabel( statusLabel: FormTokenField.Value ) {
}

export const TOOLS_PANEL_CONTROLS = {
attributes: AttributesFilter,
onSale: ( props: ProductQueryBlock ) => {
const { query } = props.attributes;

Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
};
14 changes: 13 additions & 1 deletion assets/js/blocks/product-query/types.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,6 +27,7 @@ export interface ProductQueryArguments {
* the choice to those.
*/
orderBy: 'date' | 'popularity';
__woocommerceAttributes?: AttributeMetadata[];
/**
* Display only products on sale.
*
Expand Down
62 changes: 62 additions & 0 deletions assets/js/blocks/product-query/useProductAttributes.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
43 changes: 40 additions & 3 deletions src/BlockTypes/ProductQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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(),
);
Expand Down

0 comments on commit 0d3e954

Please sign in to comment.