Product Collection: Add 'Order By' Control to Product Collection Inspector (https://github.com/woocommerce/woocommerce-blocks/pull/9480)

* Add columns control to product collection block editor settings

- `InspectorControls` from './inspector-controls' is now imported in `edit.tsx` and used in the returned JSX of `Edit` function.
- A new file `columns-control.tsx` is added under 'product-collection' block's 'inspector-controls' directory which exports a `ColumnsControl` component. This component uses `RangeControl` from '@wordpress/components' to control the number of columns in the product collection display layout when the layout type is 'flex'.
- The types file (`types.ts`) for 'product-collection' block is updated. The `Attributes` interface is renamed to `ProductCollectionAttributes` and the `ProductCollectionContext` interface is removed. The `ProductCollectionAttributes` now includes 'queryContext', 'templateSlug', and 'displayLayout' properties.

* Refactor: Simplify Fallback Return in ColumnsControl Component

This commit simplifies the fallback return value of the ColumnsControl component. Instead of returning an empty fragment (<> </>), it now returns null when the condition isn't met. This change improves readability and aligns with best practices for conditional rendering in React.

* Feature: Add 'Order By' Control to Product Collection Inspector

This commit adds a new 'Order By' control to the product collection inspector. The control allows users to specify the order of products in a collection by various attributes such as title and date. To support this, a new component 'OrderByControl' has been created and included in the product collection inspector. Additionally, the types for 'order' and 'orderBy' attributes have been updated and exported for reuse.

* Add more options to OrderBy type

* Add orderby handling on frontend & editor

The main changes include:
1. Added a new property 'isProductCollectionBlock' in the block.json to denote if a block is a product collection block.
2. In the ProductCollection PHP class, a new initialization function has been defined to hook into the WordPress lifecycle, register the block, and update the query based on this block.
3. Added methods to manage query parameters for both frontend rendering and the Editor.
4. Expanded allowed 'collection_params' for the REST API to include custom 'orderby' values.
5. Defined a function to build the query based on block attributes, filters, and global WP_Query.
6. Created utility functions to handle complex query operations such as merging queries, handling custom sort values, and merging arrays recursively.

These improvements allow for more flexible and robust handling of product collections in both the front-end display and the WordPress editor. It also extends support for custom 'orderby' values in the REST API, which allows for more advanced sorting options in product collections.

* fix: handle undefined index for isProductCollectionBlock

This commit addresses a potential issue where the 'isProductCollectionBlock' index might not be defined in certain situations within the 'build_query' method of the ProductCollection class.

Previously, we directly accessed 'isProductCollectionBlock' from the 'query' context of the block. Now, we use the null coalescing operator (??) to ensure that we assign a default value of false if 'isProductCollectionBlock' is not set.

This change provides a safer way to handle the scenario when the 'isProductCollectionBlock' is not defined in the block context and helps prevent undefined index warnings.
This commit is contained in:
Manish Menaria 2023-05-18 16:24:08 +05:30 committed by GitHub
parent 715e63fbfd
commit 57d4ac529e
5 changed files with 362 additions and 3 deletions

View File

@ -27,7 +27,8 @@
"sticky": "",
"inherit": false,
"taxQuery": null,
"parents": []
"parents": [],
"isProductCollectionBlock": true
}
},
"tagName": {

View File

@ -11,6 +11,7 @@ import { __ } from '@wordpress/i18n';
*/
import { ProductCollectionAttributes } from '../types';
import ColumnsControl from './columns-control';
import OrderByControl from './order-by-control';
const ProductCollectionInspectorControls = (
props: BlockEditProps< ProductCollectionAttributes >
@ -21,6 +22,7 @@ const ProductCollectionInspectorControls = (
title={ __( 'Settings', 'woo-gutenberg-products-block' ) }
>
<ColumnsControl { ...props } />
<OrderByControl { ...props } />
</PanelBody>
</InspectorControls>
);

View File

@ -0,0 +1,67 @@
/**
* External dependencies
*/
import { SelectControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import {
ProductCollectionAttributes,
TProductCollectionOrder,
TProductCollectionOrderBy,
} from '../types';
const orderOptions = [
{
label: __( 'A → Z', 'woo-gutenberg-products-block' ),
value: 'title/asc',
},
{
label: __( 'Z → A', 'woo-gutenberg-products-block' ),
value: 'title/desc',
},
{
label: __( 'Newest to oldest', 'woo-gutenberg-products-block' ),
value: 'date/desc',
},
{
label: __( 'Oldest to newest', 'woo-gutenberg-products-block' ),
value: 'date/asc',
},
{
value: 'popularity/desc',
label: __( 'Best Selling', 'woo-gutenberg-products-block' ),
},
{
value: 'rating/desc',
label: __( 'Top Rated', 'woo-gutenberg-products-block' ),
},
];
const OrderByControl = (
props: BlockEditProps< ProductCollectionAttributes >
) => {
const { order, orderBy } = props.attributes.query;
return (
<SelectControl
label={ __( 'Order by', 'woo-gutenberg-products-block' ) }
value={ `${ orderBy }/${ order }` }
options={ orderOptions }
onChange={ ( value ) => {
const [ newOrderBy, newOrder ] = value.split( '/' );
props.setAttributes( {
query: {
...props.attributes.query,
order: newOrder as TProductCollectionOrder,
orderBy: newOrderBy as TProductCollectionOrderBy,
},
} );
} }
/>
);
};
export default OrderByControl;

View File

@ -18,8 +18,8 @@ export interface ProductCollectionQuery {
exclude: string[];
inherit: boolean;
offset: number;
order: 'asc' | 'desc';
orderBy: 'date' | 'relevance' | 'title';
order: TProductCollectionOrder;
orderBy: TProductCollectionOrderBy;
pages: number;
parents: number[];
perPage: number;
@ -28,3 +28,10 @@ export interface ProductCollectionQuery {
sticky: string;
taxQuery: string;
}
export type TProductCollectionOrder = 'asc' | 'desc';
export type TProductCollectionOrderBy =
| 'date'
| 'title'
| 'popularity'
| 'rating';

View File

@ -2,6 +2,8 @@
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use WP_Query;
/**
* ProductCollection class.
*/
@ -14,6 +16,20 @@ class ProductCollection extends AbstractBlock {
*/
protected $block_name = 'product-collection';
/**
* All query args from WP_Query.
*
* @var array
*/
protected $valid_query_vars;
/**
* Orderby options not natively supported by WordPress REST API
*
* @var array
*/
protected $custom_order_opts = array( 'popularity', 'rating' );
/**
* Get the frontend script handle for this block type.
*
@ -22,4 +38,270 @@ class ProductCollection extends AbstractBlock {
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Initialize this block type.
*
* - Hook into WP lifecycle.
* - Register the block with WordPress.
* - Hook into pre_render_block to update the query.
*/
protected function initialize() {
parent::initialize();
// Update query for frontend rendering.
add_filter(
'query_loop_block_query_vars',
array( $this, 'build_query' ),
10,
2
);
// Update the query for Editor.
add_filter( 'rest_product_query', array( $this, 'update_rest_query' ), 10, 2 );
// Extend allowed `collection_params` for the REST API.
add_filter( 'rest_product_collection_params', array( $this, 'extend_rest_query_allowed_params' ), 10, 1 );
}
/**
* Update the query for the product query block in Editor.
*
* @param array $args Query args.
* @param WP_REST_Request $request Request.
*/
public function update_rest_query( $args, $request ): array {
// Only update the query if this is a product collection block.
$is_product_collection_block = $request->get_param( 'isProductCollectionBlock' );
if ( ! $is_product_collection_block ) {
return $args;
}
$orderby = $request->get_param( 'orderby' );
$orderby_query = $orderby ? $this->get_custom_orderby_query( $orderby ) : [];
return array_merge( $args, $orderby_query );
}
/**
* Extends allowed `collection_params` for the REST API
*
* By itself, the REST API doesn't accept custom `orderby` values,
* even if they are supported by a custom post type.
*
* @param array $params A list of allowed `orderby` values.
*
* @return array
*/
public function extend_rest_query_allowed_params( $params ) {
$original_enum = isset( $params['orderby']['enum'] ) ? $params['orderby']['enum'] : array();
$params['orderby']['enum'] = array_unique( array_merge( $original_enum, $this->custom_order_opts ) );
return $params;
}
/**
* Return a custom query based on attributes, filters and global WP_Query.
*
* @param WP_Query $query The WordPress Query.
* @param WP_Block $block The block being rendered.
*
* @return array
*/
public function build_query( $query, $block ) {
// If not in context of product collection block, return the query as is.
$is_product_collection_block = $block->context['query']['isProductCollectionBlock'] ?? false;
if ( ! $is_product_collection_block ) {
return $query;
}
$common_query_values = array(
'meta_query' => array(),
'posts_per_page' => $query['posts_per_page'],
'orderby' => $query['orderby'],
'order' => $query['order'],
'offset' => $query['offset'],
'post__in' => array(),
'post_status' => 'publish',
'post_type' => 'product',
'tax_query' => array(),
);
$merged_query = $this->merge_queries(
$common_query_values,
$this->get_custom_orderby_query( $query['orderby'] )
);
return $merged_query;
}
/**
* Merge in the first parameter the keys "post_in", "meta_query" and "tax_query" of the second parameter.
*
* @param array[] ...$queries Query arrays to be merged.
* @return array
*/
private function merge_queries( ...$queries ) {
$merged_query = array_reduce(
$queries,
function( $acc, $query ) {
if ( ! is_array( $query ) ) {
return $acc;
}
// If the $query doesn't contain any valid query keys, we unpack/spread it then merge.
if ( empty( array_intersect( $this->get_valid_query_vars(), array_keys( $query ) ) ) ) {
return $this->merge_queries( $acc, ...array_values( $query ) );
}
return $this->array_merge_recursive_replace_non_array_properties( $acc, $query );
},
array()
);
/**
* If there are duplicated items in post__in, it means that we need to
* use the intersection of the results, which in this case, are the
* duplicated items.
*/
if (
! empty( $merged_query['post__in'] ) &&
count( $merged_query['post__in'] ) > count( array_unique( $merged_query['post__in'] ) )
) {
$merged_query['post__in'] = array_unique(
array_diff(
$merged_query['post__in'],
array_unique( $merged_query['post__in'] )
)
);
}
return $merged_query;
}
/**
* Return query params to support custom sort values
*
* @param string $orderby Sort order option.
*
* @return array
*/
private function get_custom_orderby_query( $orderby ) {
if ( ! in_array( $orderby, $this->custom_order_opts, true ) ) {
return array( 'orderby' => $orderby );
}
$meta_keys = array(
'popularity' => 'total_sales',
'rating' => '_wc_average_rating',
);
return array(
'meta_key' => $meta_keys[ $orderby ],
'orderby' => 'meta_value_num',
);
}
/**
* Return or initialize $valid_query_vars.
*
* @return array
*/
private function get_valid_query_vars() {
if ( ! empty( $this->valid_query_vars ) ) {
return $this->valid_query_vars;
}
$valid_query_vars = array_keys( ( new WP_Query() )->fill_query_vars( array() ) );
$this->valid_query_vars = array_merge(
$valid_query_vars,
// fill_query_vars doesn't include these vars so we need to add them manually.
array(
'date_query',
'exact',
'ignore_sticky_posts',
'lazy_load_term_meta',
'meta_compare_key',
'meta_compare',
'meta_query',
'meta_type_key',
'meta_type',
'nopaging',
'offset',
'order',
'orderby',
'page',
'post_type',
'posts_per_page',
'suppress_filters',
'tax_query',
)
);
return $this->valid_query_vars;
}
/**
* Merge two array recursively but replace the non-array values instead of
* merging them. The merging strategy:
*
* - If keys from merge array doesn't exist in the base array, create them.
* - For array items with numeric keys, we merge them as normal.
* - For array items with string keys:
*
* - If the value isn't array, we'll use the value comming from the merge array.
* $base = ['orderby' => 'date']
* $new = ['orderby' => 'meta_value_num']
* Result: ['orderby' => 'meta_value_num']
*
* - If the value is array, we'll use recursion to merge each key.
* $base = ['meta_query' => [
* [
* 'key' => '_stock_status',
* 'compare' => 'IN'
* 'value' => ['instock', 'onbackorder']
* ]
* ]]
* $new = ['meta_query' => [
* [
* 'relation' => 'AND',
* [...<max_price_query>],
* [...<min_price_query>],
* ]
* ]]
* Result: ['meta_query' => [
* [
* 'key' => '_stock_status',
* 'compare' => 'IN'
* 'value' => ['instock', 'onbackorder']
* ],
* [
* 'relation' => 'AND',
* [...<max_price_query>],
* [...<min_price_query>],
* ]
* ]]
*
* $base = ['post__in' => [1, 2, 3, 4, 5]]
* $new = ['post__in' => [3, 4, 5, 6, 7]]
* Result: ['post__in' => [1, 2, 3, 4, 5, 3, 4, 5, 6, 7]]
*
* @param array $base First array.
* @param array $new Second array.
*/
private function array_merge_recursive_replace_non_array_properties( $base, $new ) {
foreach ( $new as $key => $value ) {
if ( is_numeric( $key ) ) {
$base[] = $value;
} else {
if ( is_array( $value ) ) {
if ( ! isset( $base[ $key ] ) ) {
$base[ $key ] = array();
}
$base[ $key ] = $this->array_merge_recursive_replace_non_array_properties( $base[ $key ], $value );
} else {
$base[ $key ] = $value;
}
}
}
return $base;
}
}