Controller.php
<?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection;
use Automattic\WooCommerce\Blocks\BlockTypes\AbstractBlock;
use WP_Query;
/**
* Controller class.
*/
class Controller extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'product-collection';
/**
* Instance of HandlerRegistry.
*
* @var HandlerRegistry
*/
protected $collection_handler_registry;
/**
* Instance of QueryBuilder.
*
* @var QueryBuilder
*/
protected $query_builder;
/**
* Instance of Renderer.
*
* @var Renderer
*/
protected $renderer;
/**
* Initialize this block type.
*
* - Register hooks and filters.
* - Set up QueryBuilder, Renderer and HandlerRegistry.
*/
protected function initialize() {
parent::initialize();
$this->query_builder = new QueryBuilder();
$this->renderer = new Renderer();
$this->collection_handler_registry = new HandlerRegistry();
// Update query for frontend rendering.
add_filter(
'query_loop_block_query_vars',
array( $this, 'build_frontend_query' ),
10,
3
);
add_filter(
'pre_render_block',
array( $this, 'add_support_for_filter_blocks' ),
10,
2
);
// Register the backend settings so they can be used in the editor.
add_action( 'rest_api_init', array( $this, 'register_settings' ) );
// Update the query for Editor.
add_filter( 'rest_product_query', array( $this, 'update_rest_query_in_editor' ), 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 );
add_filter( 'render_block_core/post-title', array( $this, 'add_product_title_click_event_directives' ), 10, 3 );
// Disable client-side-navigation if incompatible blocks are detected.
add_filter( 'render_block_data', array( $this, 'disable_enhanced_pagination' ), 10, 1 );
$this->register_core_collections_and_set_handler_store();
}
/**
* Add interactivity to the Product Title block within Product Collection.
* This enables the triggering of a custom event when the product title is clicked.
*
* @param string $block_content The block content.
* @param array $block The full block, including name and attributes.
* @param \WP_Block $instance The block instance.
* @return string Modified block content with added interactivity.
*/
public function add_product_title_click_event_directives( $block_content, $block, $instance ) {
$namespace = $instance->attributes['__woocommerceNamespace'] ?? '';
$is_product_title_block = 'woocommerce/product-collection/product-title' === $namespace;
$is_link = $instance->attributes['isLink'] ?? false;
// Only proceed if the block is a Product Title (Post Title variation) block.
if ( $is_product_title_block && $is_link ) {
$p = new \WP_HTML_Tag_Processor( $block_content );
$p->next_tag( array( 'class_name' => 'wp-block-post-title' ) );
$is_anchor = $p->next_tag( array( 'tag_name' => 'a' ) );
if ( $is_anchor ) {
$p->set_attribute( 'data-wp-on--click', 'woocommerce/product-collection::actions.viewProduct' );
$block_content = $p->get_updated_html();
}
}
return $block_content;
}
/**
* Verifies if the inner block is compatible with Interactivity API.
*
* @param string $block_name Name of the block to verify.
* @return boolean
*/
private function is_block_compatible( $block_name ) {
// Check for explicitly unsupported blocks.
$unsupported_blocks = array(
'core/post-content',
'woocommerce/mini-cart',
'woocommerce/featured-product',
'woocommerce/active-filters',
'woocommerce/price-filter',
'woocommerce/stock-filter',
'woocommerce/attribute-filter',
'woocommerce/rating-filter',
);
if ( in_array( $block_name, $unsupported_blocks, true ) ) {
return false;
}
// Check for supported prefixes.
if (
str_starts_with( $block_name, 'core/' ) ||
str_starts_with( $block_name, 'woocommerce/' )
) {
return true;
}
// Otherwise block is unsupported.
return false;
}
/**
* Check inner blocks of Product Collection block if there's one
* incompatible with the Interactivity API and if so, disable client-side
* navigation.
*
* @param array $parsed_block The block being rendered.
* @return string Returns the parsed block, unmodified.
*/
public function disable_enhanced_pagination( $parsed_block ) {
static $enhanced_query_stack = array();
static $dirty_enhanced_queries = array();
static $render_product_collection_callback = null;
$block_name = $parsed_block['blockName'];
$is_product_collection_block = $parsed_block['attrs']['query']['isProductCollectionBlock'] ?? false;
$force_page_reload_global =
$parsed_block['attrs']['forcePageReload'] ?? false &&
isset( $parsed_block['attrs']['queryId'] );
if (
$is_product_collection_block &&
'woocommerce/product-collection' === $block_name &&
! $force_page_reload_global
) {
$enhanced_query_stack[] = $parsed_block['attrs']['queryId'];
if ( ! isset( $render_product_collection_callback ) ) {
/**
* Filter that disables the enhanced pagination feature during block
* rendering when a plugin block has been found inside. It does so
* by adding an attribute called `data-wp-navigation-disabled` which
* is later handled by the front-end logic.
*
* @param string $content The block content.
* @param array $block The full block, including name and attributes.
* @return string Returns the modified output of the query block.
*/
$render_product_collection_callback = static function ( $content, $block ) use ( &$enhanced_query_stack, &$dirty_enhanced_queries, &$render_product_collection_callback ) {
$force_page_reload =
$parsed_block['attrs']['forcePageReload'] ?? false &&
isset( $block['attrs']['queryId'] );
if ( $force_page_reload ) {
return $content;
}
if ( isset( $dirty_enhanced_queries[ $block['attrs']['queryId'] ] ) ) {
wp_interactivity_config( 'core/router', array( 'clientNavigationDisabled' => true ) );
$dirty_enhanced_queries[ $block['attrs']['queryId'] ] = null;
}
array_pop( $enhanced_query_stack );
if ( empty( $enhanced_query_stack ) ) {
remove_filter( 'render_block_woocommerce/product-collection', $render_product_collection_callback );
$render_product_collection_callback = null;
}
return $content;
};
add_filter( 'render_block_woocommerce/product-collection', $render_product_collection_callback, 10, 2 );
}
} elseif (
! empty( $enhanced_query_stack ) &&
isset( $block_name ) &&
! $this->is_block_compatible( $block_name )
) {
foreach ( $enhanced_query_stack as $query_id ) {
$dirty_enhanced_queries[ $query_id ] = true;
}
}
return $parsed_block;
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = array() ) {
parent::enqueue_data( $attributes );
// The `loop_shop_per_page` filter can be found in WC_Query::product_query().
// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
$this->asset_data_registry->add( 'loopShopPerPage', apply_filters( 'loop_shop_per_page', wc_get_default_products_per_row() * wc_get_default_product_rows_per_page() ) );
}
/**
* Exposes settings used by the Product Collection block when manipulating
* the default query.
*/
public function register_settings() {
register_setting(
'options',
'woocommerce_default_catalog_orderby',
array(
'type' => 'object',
'description' => __( 'How should products be sorted in the catalog by default?', 'woocommerce' ),
'label' => __( 'Default product sorting', 'woocommerce' ),
'show_in_rest' => array(
'name' => 'woocommerce_default_catalog_orderby',
'schema' => array(
'type' => 'string',
'enum' => array( 'menu_order', 'popularity', 'rating', 'date', 'price', 'price-desc' ),
),
),
'default' => 'menu_order',
)
);
}
/**
* Update the query for the product query block in Editor.
*
* @param array $query Query args.
* @param WP_REST_Request $request Request.
*/
public function update_rest_query_in_editor( $query, $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 $query;
}
$product_collection_query_context = $request->get_param( 'productCollectionQueryContext' );
$collection_args = array(
'name' => $product_collection_query_context['collection'] ?? '',
// The editor uses a REST query to grab product post types. This means we don't have a block
// instance to work with and the client needs to provide the location context.
'productCollectionLocation' => $request->get_param( 'productCollectionLocation' ),
);
// Allow collections to modify the collection arguments passed to the query builder.
$handlers = $this->collection_handler_registry->get_collection_handler( $collection_args['name'] );
if ( isset( $handlers['editor_args'] ) ) {
$collection_args = call_user_func( $handlers['editor_args'], $collection_args, $query, $request );
}
$orderby = $request->get_param( 'orderby' );
// When requested, short-circuit the query and return the preview query args.
$preview_state = $request->get_param( 'previewState' );
if ( isset( $preview_state['isPreview'] ) && 'true' === $preview_state['isPreview'] ) {
return $this->query_builder->get_preview_query_args( $collection_args, array_merge( $query, array( 'orderby' => $orderby ) ), $request );
}
$on_sale = $request->get_param( 'woocommerceOnSale' ) === 'true';
$stock_status = $request->get_param( 'woocommerceStockStatus' );
$product_attributes = $request->get_param( 'woocommerceAttributes' );
$handpicked_products = $request->get_param( 'woocommerceHandPickedProducts' );
$featured = $request->get_param( 'featured' );
$time_frame = $request->get_param( 'timeFrame' );
$price_range = $request->get_param( 'priceRange' );
// This argument is required for the tests to PHP Unit Tests to run correctly.
// Most likely this argument is being accessed in the test environment image.
$query['author'] = '';
// Use QueryBuilder to get the final query args.
return $this->query_builder->get_final_query_args(
$collection_args,
$query,
array(
'orderby' => $orderby,
'on_sale' => $on_sale,
'stock_status' => $stock_status,
'product_attributes' => $product_attributes,
'handpicked_products' => $handpicked_products,
'featured' => $featured,
'timeFrame' => $time_frame,
'priceRange' => $price_range,
)
);
}
/**
* Add support for filter blocks:
* - Price filter block
* - Attributes filter block
* - Rating filter block
* - In stock filter block etc.
*
* @param array $pre_render The pre-rendered block.
* @param array $parsed_block The parsed block.
*/
public function add_support_for_filter_blocks( $pre_render, $parsed_block ) {
$is_product_collection_block = $parsed_block['attrs']['query']['isProductCollectionBlock'] ?? false;
if ( ! $is_product_collection_block ) {
return $pre_render;
}
$this->renderer->set_parsed_block( $parsed_block );
$this->asset_data_registry->add( 'hasFilterableProducts', true );
/**
* It enables the page to refresh when a filter is applied, ensuring that the product collection block,
* which is a server-side rendered (SSR) block, retrieves the products that match the filters.
*/
$this->asset_data_registry->add( 'isRenderingPhpTemplate', true );
return $pre_render;
}
/**
* 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.
* @param int $page The page number.
*
* @return array
*/
public function build_frontend_query( $query, $block, $page ) {
// 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;
}
$block_context_query = $block->context['query'];
// phpcs:ignore WordPress.DB.SlowDBQuery
$block_context_query['tax_query'] = ! empty( $query['tax_query'] ) ? $query['tax_query'] : array();
$inherit = $block->context['query']['inherit'] ?? false;
$filterable = $block->context['query']['filterable'] ?? false;
$is_exclude_applied_filters = ! ( $inherit || $filterable );
$collection_args = array(
'name' => $block->context['collection'] ?? '',
'productCollectionLocation' => $block->context['productCollectionLocation'] ?? null,
);
// Use QueryBuilder to construct the query.
return $this->query_builder->get_final_frontend_query(
$collection_args,
$block_context_query,
$page,
$is_exclude_applied_filters
);
}
/**
* 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->query_builder->get_custom_order_opts() ) );
return $params;
}
/**
* Registers core collections and sets the handler store.
*/
protected function register_core_collections_and_set_handler_store() {
// Use HandlerRegistry to register collections.
$collection_handler_store = $this->collection_handler_registry->register_core_collections();
$this->query_builder->set_collection_handler_store( $collection_handler_store );
}
/**
* Disable the block type script, this block uses script modules.
*
* @param string|null $key The key of the script.
*
* @return null
*/
protected function get_block_type_script( $key = null ) {
return null;
}
}