Product Collection: Disable client side navigation if blocks incompatible with Interactivity API are detected (#45435)

* Add dummy Force Page Reload control to Inspector Advanced Control

* Add enhancedPagination attribute that decides if client side navigation is enabled

* Consume the enhancedPagination attribute

* Force client side navigation when incompatible blocks detected

* Dummy util to detect incompatible blocks

* Detect incompatible blocks in the Editor

* Switch to WordPress Interactivity package in Product Collection

* Add initial implementation of the incompatible blocks detection in frontend

* Remove leftover

* Revert to using internal version of interactivity API

* There's no Interactivity store config available in the internal Interactivity implementation so remove it

* Disable client side navigation if the incompatible block is detected

* Add default attribute value

* Switch from enmhancedPagination attribute to forcePageReload

* Fixed some misclicked line order change

* Switch from enhancedPagination to forcePageReload in PHP code

* Apply the correct filter

* Fix the incorrect condition to detect incompatible block

* Initial implementation of orange dot to bring attention

* Cleanup

* Remove the orange dot indicator

* Refactor checking for unsupported blocks

* Add changelog

* Fix PHP lint errors

* Bring back empty line at the end of pnpm-lock

* Bring pnpm-lock.yaml file to original state

* Fix incorrect function call

* Add visibility description to function

* Switch private method to public

* More linted fixes
This commit is contained in:
Karol Manijak 2024-03-18 08:24:03 +01:00 committed by GitHub
parent 52d300f2a9
commit 5a54dd6527
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 303 additions and 10 deletions

View File

@ -34,6 +34,10 @@
}, },
"queryContextIncludes": { "queryContextIncludes": {
"type": "array" "type": "array"
},
"forcePageReload": {
"type": "boolean",
"default": false
} }
}, },
"providesContext": { "providesContext": {

View File

@ -72,6 +72,7 @@ export const DEFAULT_ATTRIBUTES: Partial< ProductCollectionAttributes > = {
shrinkColumns: true, shrinkColumns: true,
}, },
queryContextIncludes: [ 'collection' ], queryContextIncludes: [ 'collection' ],
forcePageReload: false,
}; };
export const getDefaultQuery = ( export const getDefaultQuery = (

View File

@ -0,0 +1,56 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useEffect } from '@wordpress/element';
import { ToggleControl } from '@wordpress/components';
/**
* Internal dependencies
*/
import { useHasUnsupportedBlocks } from './utils';
import type { ProductCollectionSetAttributes } from '../../types';
type ForcePageReloadControlProps = {
clientId: string;
forcePageReload: boolean;
setAttributes: ProductCollectionSetAttributes;
};
const helpTextIfEnabled = __(
'Enforce full page reload on certain interactions, like using paginations controls.',
'woocommerce'
);
const helpTextIfDisabled = __(
"Force page reload can't be disabled because there are non-compatible blocks inside the Product Collection block.",
'woocommerce'
);
const ForcePageReloadControl = ( props: ForcePageReloadControlProps ) => {
const { clientId, forcePageReload, setAttributes } = props;
const hasUnsupportedBlocks = useHasUnsupportedBlocks( clientId );
useEffect( () => {
if ( ! forcePageReload && hasUnsupportedBlocks ) {
setAttributes( { forcePageReload: true } );
}
}, [ forcePageReload, hasUnsupportedBlocks, setAttributes ] );
const helpText = hasUnsupportedBlocks
? helpTextIfDisabled
: helpTextIfEnabled;
return (
<ToggleControl
label={ __( 'Force Page Reload', 'woocommerce' ) }
help={ helpText }
checked={ forcePageReload }
onChange={ () =>
setAttributes( { forcePageReload: ! forcePageReload } )
}
disabled={ hasUnsupportedBlocks }
/>
);
};
export default ForcePageReloadControl;

View File

@ -0,0 +1,27 @@
/**
* External dependencies
*/
import { InspectorAdvancedControls } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import ForcePageReloadControl from './force-page-reload-control';
import type { ProductCollectionEditComponentProps } from '../../types';
export default function ProductCollectionAdvancedInspectorControls(
props: ProductCollectionEditComponentProps
) {
const { clientId, attributes, setAttributes } = props;
const { forcePageReload } = attributes;
return (
<InspectorAdvancedControls>
<ForcePageReloadControl
clientId={ clientId }
forcePageReload={ forcePageReload }
setAttributes={ setAttributes }
/>
</InspectorAdvancedControls>
);
}

View File

@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
import { store as blockEditorStore } from '@wordpress/block-editor';
const unsupportedBlocks = [
'core/post-content',
'woocommerce/mini-cart',
'woocommerce/featured-product',
];
const supportedPrefixes = [ 'core/', 'woocommerce/' ];
const isBlockSupported = ( blockName: string ) => {
// Check for explicitly unsupported blocks
if ( unsupportedBlocks.includes( blockName ) ) {
return false;
}
// Check for supported prefixes
if (
supportedPrefixes.find( ( prefix ) => blockName.startsWith( prefix ) )
) {
return true;
}
// Otherwise block is unsupported
return false;
};
export const useHasUnsupportedBlocks = ( clientId: string ): boolean =>
useSelect(
( select ) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet
const { getClientIdsOfDescendants, getBlockName } =
select( blockEditorStore );
const hasUnsupportedBlocks =
getClientIdsOfDescendants( clientId ).find(
( blockId: string ) => {
const blockName = getBlockName( blockId );
const supported = isBlockSupported( blockName );
return ! supported;
}
) || false;
return hasUnsupportedBlocks;
},
[ clientId ]
);

View File

@ -16,6 +16,7 @@ import type {
import { DEFAULT_ATTRIBUTES, INNER_BLOCKS_TEMPLATE } from '../constants'; import { DEFAULT_ATTRIBUTES, INNER_BLOCKS_TEMPLATE } from '../constants';
import { getDefaultValueOfInheritQueryFromTemplate } from '../utils'; import { getDefaultValueOfInheritQueryFromTemplate } from '../utils';
import InspectorControls from './inspector-controls'; import InspectorControls from './inspector-controls';
import InspectorAdvancedControls from './inspector-advanced-controls';
import ToolbarControls from './toolbar-controls'; import ToolbarControls from './toolbar-controls';
const ProductCollectionContent = ( const ProductCollectionContent = (
@ -69,6 +70,7 @@ const ProductCollectionContent = (
return ( return (
<div { ...blockProps }> <div { ...blockProps }>
<InspectorControls { ...props } /> <InspectorControls { ...props } />
<InspectorAdvancedControls { ...props } />
<ToolbarControls { ...props } /> <ToolbarControls { ...props } />
<div { ...innerBlocksProps } /> <div { ...innerBlocksProps } />
</div> </div>

View File

@ -37,6 +37,13 @@ const isValidEvent = ( event: MouseEvent ) =>
! event.shiftKey && ! event.shiftKey &&
! event.defaultPrevented; ! event.defaultPrevented;
const forcePageReload = ( href: string ) => {
window.location.assign( href );
// It's function called in generator expecting asyncFunc return.
// eslint-disable-next-line @typescript-eslint/no-empty-function
return new Promise( () => {} );
};
/** /**
* Ensures the visibility of the first product in the collection. * Ensures the visibility of the first product in the collection.
* Scrolls the page to the first product if it's not in the viewport. * Scrolls the page to the first product if it's not in the viewport.
@ -93,6 +100,13 @@ const productCollectionStore = {
const wcNavigationId = ( const wcNavigationId = (
ref?.closest( '[data-wc-navigation-id]' ) as HTMLDivElement ref?.closest( '[data-wc-navigation-id]' ) as HTMLDivElement
)?.dataset?.wcNavigationId; )?.dataset?.wcNavigationId;
const isDisabled = (
ref?.closest( '[data-wc-navigation-id]' ) as HTMLDivElement
)?.dataset.wcNavigationDisabled;
if ( isDisabled ) {
yield forcePageReload( ref.href );
}
if ( isValidLink( ref ) && isValidEvent( event ) ) { if ( isValidLink( ref ) && isValidEvent( event ) ) {
event.preventDefault(); event.preventDefault();
@ -130,6 +144,15 @@ const productCollectionStore = {
*/ */
*prefetchOnHover() { *prefetchOnHover() {
const { ref } = getElement(); const { ref } = getElement();
const isDisabled = (
ref?.closest( '[data-wc-navigation-id]' ) as HTMLDivElement
)?.dataset.wcNavigationDisabled;
if ( isDisabled ) {
return;
}
if ( isValidLink( ref ) ) { if ( isValidLink( ref ) ) {
yield prefetch( ref.href ); yield prefetch( ref.href );
} }
@ -141,8 +164,17 @@ const productCollectionStore = {
* Reduces perceived load times for subsequent page navigations. * Reduces perceived load times for subsequent page navigations.
*/ */
*prefetch() { *prefetch() {
const context = getContext< ProductCollectionStoreContext >();
const { ref } = getElement(); const { ref } = getElement();
const isDisabled = (
ref?.closest( '[data-wc-navigation-id]' ) as HTMLDivElement
)?.dataset.wcNavigationDisabled;
if ( isDisabled ) {
return;
}
const context = getContext< ProductCollectionStoreContext >();
if ( context?.isPrefetchNextOrPreviousLink && isValidLink( ref ) ) { if ( context?.isPrefetchNextOrPreviousLink && isValidLink( ref ) ) {
yield prefetch( ref.href ); yield prefetch( ref.href );
} }

View File

@ -22,6 +22,7 @@ export interface ProductCollectionAttributes {
* Contain the list of attributes that should be included in the queryContext * Contain the list of attributes that should be included in the queryContext
*/ */
queryContextIncludes: string[]; queryContextIncludes: string[];
forcePageReload: boolean;
} }
export enum LayoutOptions { export enum LayoutOptions {
@ -99,9 +100,13 @@ export type TProductCollectionOrderBy =
| 'popularity' | 'popularity'
| 'rating'; | 'rating';
export type ProductCollectionSetAttributes = (
attrs: Partial< ProductCollectionAttributes >
) => void;
export type DisplayLayoutControlProps = { export type DisplayLayoutControlProps = {
displayLayout: ProductCollectionDisplayLayout; displayLayout: ProductCollectionDisplayLayout;
setAttributes: ( attrs: Partial< ProductCollectionAttributes > ) => void; setAttributes: ProductCollectionSetAttributes;
}; };
export type QueryControlProps = { export type QueryControlProps = {
query: ProductCollectionQuery; query: ProductCollectionQuery;

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Product Collection: disable client-side navigation if incompatible blocks are detected inside

View File

@ -81,6 +81,10 @@ class ProductCollection extends AbstractBlock {
add_filter( 'render_block_core/query-pagination', array( $this, 'add_navigation_link_directives' ), 10, 3 ); add_filter( 'render_block_core/query-pagination', array( $this, 'add_navigation_link_directives' ), 10, 3 );
add_filter( 'posts_clauses', array( $this, 'add_price_range_filter_posts_clauses' ), 10, 2 ); add_filter( 'posts_clauses', array( $this, 'add_price_range_filter_posts_clauses' ), 10, 2 );
// Disable client-side-navigation if incompatible blocks are detected.
add_filter( 'render_block_data', array( $this, 'disable_enhanced_pagination' ), 10, 1 );
} }
/** /**
@ -95,8 +99,9 @@ class ProductCollection extends AbstractBlock {
* @return string Updated block content with added interactivity attributes. * @return string Updated block content with added interactivity attributes.
*/ */
public function enhance_product_collection_with_interactivity( $block_content, $block ) { public function enhance_product_collection_with_interactivity( $block_content, $block ) {
$is_product_collection_block = $block['attrs']['query']['isProductCollectionBlock'] ?? false; $is_product_collection_block = $block['attrs']['query']['isProductCollectionBlock'] ?? false;
if ( $is_product_collection_block ) { $is_enhanced_pagination_enabled = ! ( $block['attrs']['forcePageReload'] ?? false );
if ( $is_product_collection_block && $is_enhanced_pagination_enabled ) {
// Enqueue the Interactivity API runtime. // Enqueue the Interactivity API runtime.
wp_enqueue_script( 'wc-interactivity' ); wp_enqueue_script( 'wc-interactivity' );
@ -166,13 +171,15 @@ class ProductCollection extends AbstractBlock {
* @param \WP_Block $instance The block instance. * @param \WP_Block $instance The block instance.
*/ */
public function add_navigation_link_directives( $block_content, $block, $instance ) { public function add_navigation_link_directives( $block_content, $block, $instance ) {
$query_context = $instance->context['query'] ?? array(); $query_context = $instance->context['query'] ?? array();
$is_product_collection_block = $query_context['isProductCollectionBlock'] ?? false; $is_product_collection_block = $query_context['isProductCollectionBlock'] ?? false;
$query_id = $instance->context['queryId'] ?? null; $query_id = $instance->context['queryId'] ?? null;
$parsed_query_id = $this->parsed_block['attrs']['queryId'] ?? null; $parsed_query_id = $this->parsed_block['attrs']['queryId'] ?? null;
$is_enhanced_pagination_enabled = ! ( $this->parsed_block['attrs']['forcePageReload'] ?? false );
// Only proceed if the block is a product collection block and query IDs match. // Only proceed if the block is a product collection block,
if ( $is_product_collection_block && $query_id === $parsed_query_id ) { // enhaced pagination is enabled and query IDs match.
if ( $is_product_collection_block && $is_enhanced_pagination_enabled && $query_id === $parsed_query_id ) {
$block_content = $this->process_pagination_links( $block_content ); $block_content = $this->process_pagination_links( $block_content );
} }
@ -231,6 +238,110 @@ class ProductCollection extends AbstractBlock {
} }
} }
/**
* 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.
if ( 'core/post-content' === $block_name ||
'woocommerce/mini-cart' === $block_name ||
'woocommerce/featured-product' === $block_name ) {
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 Interactivity API and if so, disable client-side
* naviagtion.
*
* @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'];
$force_page_reload_global =
$parsed_block['attrs']['forcePageReload'] ?? false &&
isset( $block['attrs']['queryId'] );
if (
'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'] ] ) ) {
$p = new \WP_HTML_Tag_Processor( $content );
if ( $p->next_tag() ) {
$p->set_attribute( 'data-wc-navigation-disabled', 'true' );
}
$content = $p->get_updated_html();
$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. * Extra data passed through from server to client for block.
* *