Product Collection: Trigger `wc-blocks_product_list_rendered` JS event (#50166)

* Dispatch JS event about PC being rendered

* Revert format changes

* Write the callback

* Add functions descriptions

* Add changelog

* Remove empty line

* Add tests

* Rename test cases

* Replace waiting for page load with more reliable expect.poll

* Remove leftover step

Co-authored-by: Bart Kalisz <bartlomiej.kalisz@gmail.com>

* Fix typo in function name

* Add collection name to default Product Collection block

* Expect collection name in the event

* Expose the collection name through IAPI context

* Send the collection name with the event

* Trigger event also on page change

* Remove unused CUSTOM collection type

* Provide documentation

* Update TOC

* Update tests that verify the event payload

* Improve E2E tests further

* Don't add a Product Catalog collection type to default collection

* Avoid repeating the same piece of code by extracting some function on tag processor

* Rename function to better depict its purpose

* Move the documentation to the right place

* Remove the unused variable

* Add example to dom-events doc

* Update documentation

* Update docs manifest

* Attach default collection name

* Add the default collection context in PHP so it covers all the cases

* Prevent exposing product catalog collection name in event

* Update docs

* Update test

---------

Co-authored-by: Bart Kalisz <bartlomiej.kalisz@gmail.com>
This commit is contained in:
Karol Manijak 2024-08-13 12:29:04 +02:00 committed by GitHub
parent 7802209887
commit 8bdc78c777
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 265 additions and 54 deletions

View File

@ -1007,9 +1007,18 @@
"menu_title": "Registering custom collections", "menu_title": "Registering custom collections",
"tags": "how-to", "tags": "how-to",
"edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/product-collection-block/register-product-collection.md", "edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/product-collection-block/register-product-collection.md",
"hash": "88445929a9f76512e1e8ff60be7beff7e912f31fbad552abf18862ed85f00585", "hash": "e3df65c5eec52e4bb797e34c040dbb8f820ea6571e9ce50b1d518e95ca6cb169",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/product-collection-block/register-product-collection.md", "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/product-collection-block/register-product-collection.md",
"id": "3bf26fc7c56ae6e6a56e1171f750f5204fcfcece" "id": "3bf26fc7c56ae6e6a56e1171f750f5204fcfcece"
},
{
"post_title": "DOM Events sent from product collection block",
"menu_title": "DOM Events",
"tags": "how-to",
"edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/product-collection-block/dom-events.md",
"hash": "78bce4ab5b5e902232b5ff73fd7a7c197e4f4417a490ccb45c9a27400d003787",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/product-collection-block/dom-events.md",
"id": "c8d247b91472740075871e6b57a9583d893ac650"
} }
], ],
"categories": [] "categories": []
@ -1697,5 +1706,5 @@
"categories": [] "categories": []
} }
], ],
"hash": "c24136612fe16fc7dee435a2ad2198ce242ab63dbba483fd0a18efd88dc3a289" "hash": "945d1eb884a52c8c0dbc4c8852760e332ab40030de82403d1a7103b2517c36a1"
} }

View File

@ -0,0 +1,29 @@
---
post_title: DOM Events sent from product collection block
menu_title: DOM Events
tags: how-to
---
# Product Collection - DOM Events
## `wc-blocks_product_list_rendered`
This event is triggered when Product Collection block was rendered or re-rendered (e.g. due to page change).
### `detail` parameters
| Parameter | Type | Default value | Description |
| ------------------ | ------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `collection` | string | `undefined` | Collection type. It's `undefined` for "create your own" collections as the type is not specified. For other Core collections it can be one of type: `woocommerce/product-collection/best-sellers`, `woocommerce/product-collection/featured`, `woocommerce/product-collection/new-arrivals`, `woocommerce/product-collection/on-sale`, `woocommerce/product-collection/top-rated`. For custom collections it will hold their name. |
### Example usage
```javascript
window.document.addEventListener(
'wc-blocks_product_list_rendered',
( e ) => {
const { collection } = e.detail;
console.log( collection ) // -> collection name, e.g. woocommerce/product-collection/on-sale
}
);
```

View File

@ -2,6 +2,7 @@
* External dependencies * External dependencies
*/ */
import type { AddToCartEventDetail } from '@woocommerce/types'; import type { AddToCartEventDetail } from '@woocommerce/types';
import type { CoreCollectionNames } from '@woocommerce/blocks/product-collection/types';
const CustomEvent = window.CustomEvent || null; const CustomEvent = window.CustomEvent || null;
@ -59,6 +60,16 @@ export const triggerAddedToCartEvent = ( {
} ); } );
}; };
export const triggerProductListRenderedEvent = ( payload: {
collection?: CoreCollectionNames | string;
} ) => {
dispatchEvent( 'wc-blocks_product_list_rendered', {
bubbles: true,
cancelable: true,
detail: payload,
} );
};
/** /**
* Function that listens to a jQuery event and dispatches a native JS event. * Function that listens to a jQuery event and dispatches a native JS event.
* Useful to convert WC Core events into events that can be read by blocks. * Useful to convert WC Core events into events that can be read by blocks.

View File

@ -8,10 +8,12 @@ import {
getElement, getElement,
getContext, getContext,
} from '@woocommerce/interactivity'; } from '@woocommerce/interactivity';
import { triggerProductListRenderedEvent } from '@woocommerce/base-utils';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { CoreCollectionNames } from './types';
import './style.scss'; import './style.scss';
export type ProductCollectionStoreContext = { export type ProductCollectionStoreContext = {
@ -20,6 +22,7 @@ export type ProductCollectionStoreContext = {
accessibilityMessage: string; accessibilityMessage: string;
accessibilityLoadingMessage: string; accessibilityLoadingMessage: string;
accessibilityLoadedMessage: string; accessibilityLoadedMessage: string;
collection: CoreCollectionNames;
}; };
const isValidLink = ( ref: HTMLAnchorElement ) => const isValidLink = ( ref: HTMLAnchorElement ) =>
@ -136,6 +139,10 @@ const productCollectionStore = {
ctx.isPrefetchNextOrPreviousLink = !! ref.href; ctx.isPrefetchNextOrPreviousLink = !! ref.href;
scrollToFirstProductIfNotVisible( wcNavigationId ); scrollToFirstProductIfNotVisible( wcNavigationId );
triggerProductListRenderedEvent( {
collection: ctx.collection,
} );
} }
}, },
/** /**
@ -179,6 +186,12 @@ const productCollectionStore = {
yield prefetch( ref.href ); yield prefetch( ref.href );
} }
}, },
*onRender() {
const { collection } =
getContext< ProductCollectionStoreContext >();
triggerProductListRenderedEvent( { collection } );
},
}, },
}; };

View File

@ -135,7 +135,6 @@ export type QueryControlProps = {
export enum CoreCollectionNames { export enum CoreCollectionNames {
PRODUCT_CATALOG = 'woocommerce/product-collection/product-catalog', PRODUCT_CATALOG = 'woocommerce/product-collection/product-catalog',
CUSTOM = 'woocommerce/product-collection/custom',
BEST_SELLERS = 'woocommerce/product-collection/best-sellers', BEST_SELLERS = 'woocommerce/product-collection/best-sellers',
FEATURED = 'woocommerce/product-collection/featured', FEATURED = 'woocommerce/product-collection/featured',
NEW_ARRIVALS = 'woocommerce/product-collection/new-arrivals', NEW_ARRIVALS = 'woocommerce/product-collection/new-arrivals',

View File

@ -18,13 +18,13 @@ import {
* Internal dependencies * Internal dependencies
*/ */
import { import {
ProductCollectionAttributes, type ProductCollectionAttributes,
TProductCollectionOrder, type TProductCollectionOrder,
TProductCollectionOrderBy, type TProductCollectionOrderBy,
ProductCollectionQuery, type ProductCollectionQuery,
ProductCollectionDisplayLayout, type ProductCollectionDisplayLayout,
PreviewState, type PreviewState,
SetPreviewState, type SetPreviewState,
} from './types'; } from './types';
import { import {
coreQueryPaginationBlockName, coreQueryPaginationBlockName,

View File

@ -1627,6 +1627,72 @@ test.describe( 'Product Collection', () => {
await expect( products ).toHaveText( expectedProducts ); await expect( products ).toHaveText( expectedProducts );
} ); } );
} ); } );
test.describe( 'Extensibility - JS events', () => {
test( 'emits wc-blocks_product_list_rendered event on init and on page change', async ( {
pageObject,
page,
} ) => {
await pageObject.createNewPostAndInsertBlock();
await page.addInitScript( () => {
let eventFired = 0;
window.document.addEventListener(
'wc-blocks_product_list_rendered',
( e ) => {
const { collection } = e.detail;
window.eventPayload = collection;
window.eventFired = ++eventFired;
}
);
} );
await pageObject.publishAndGoToFrontend();
await expect
.poll(
async () => await page.evaluate( 'window.eventPayload' )
)
.toBe( undefined );
await expect
.poll( async () => await page.evaluate( 'window.eventFired' ) )
.toBe( 1 );
await page.getByRole( 'link', { name: 'Next Page' } ).click();
await expect
.poll( async () => await page.evaluate( 'window.eventFired' ) )
.toBe( 2 );
} );
test( 'emits one wc-blocks_product_list_rendered event per block', async ( {
pageObject,
page,
} ) => {
// Adding three blocks in total
await pageObject.createNewPostAndInsertBlock();
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost();
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost();
await page.addInitScript( () => {
let eventFired = 0;
window.document.addEventListener(
'wc-blocks_product_list_rendered',
() => {
window.eventFired = ++eventFired;
}
);
} );
await pageObject.publishAndGoToFrontend();
await expect
.poll( async () => await page.evaluate( 'window.eventFired' ) )
.toBe( 3 );
} );
} );
} ); } );
/** /**

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Product Collection: emit the JS event when PC block is rendered

View File

@ -156,56 +156,102 @@ class ProductCollection extends AbstractBlock {
} }
/** /**
* Enhances the Product Collection block with client-side pagination. * Check if next tag is a PC block.
* *
* This function identifies Product Collection blocks and adds necessary data attributes * @param WP_HTML_Tag_processor $p Initial tag processor.
* to enable client-side navigation and animation effects. It also enqueues the Interactivity API runtime. *
* @return bool Answer if PC block is available.
*/
private function is_next_tag_product_collection( $p ) {
return $p->next_tag( array( 'class_name' => 'wp-block-woocommerce-product-collection' ) );
}
/**
* Set PC block namespace for Interactivity API.
*
* @param WP_HTML_Tag_processor $p Initial tag processor.
*/
private function set_product_collection_namespace( $p ) {
$p->set_attribute( 'data-wc-interactive', wp_json_encode( array( 'namespace' => 'woocommerce/product-collection' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) );
}
/**
* Attach the init directive to Product Collection block to call
* the onRender callback.
* *
* @param string $block_content The HTML content of the block. * @param string $block_content The HTML content of the block.
* @param array $block Block details, including its attributes. * @param string $collection Collection type.
* *
* @return string Updated block content with added interactivity attributes. * @return string Updated HTML content.
*/ */
public function enhance_product_collection_with_interactivity( $block_content, $block ) { private function add_rendering_callback( $block_content, $collection ) {
$is_product_collection_block = $block['attrs']['query']['isProductCollectionBlock'] ?? false; $p = new \WP_HTML_Tag_Processor( $block_content );
$is_enhanced_pagination_enabled = ! ( $block['attrs']['forcePageReload'] ?? false );
if ( $is_product_collection_block && $is_enhanced_pagination_enabled ) {
// Enqueue the Interactivity API runtime.
wp_enqueue_script( 'wc-interactivity' );
$p = new \WP_HTML_Tag_Processor( $block_content ); // Add `data-init to the product collection block so we trigger JS event on render.
if ( $this->is_next_tag_product_collection( $p ) ) {
// Add `data-wc-navigation-id to the product collection block. $p->set_attribute(
if ( $p->next_tag( array( 'class_name' => 'wp-block-woocommerce-product-collection' ) ) ) { 'data-wc-init',
$p->set_attribute( 'callbacks.onRender'
'data-wc-navigation-id', );
'wc-product-collection-' . $this->parsed_block['attrs']['queryId'] if ( $collection ) {
);
$p->set_attribute( 'data-wc-interactive', wp_json_encode( array( 'namespace' => 'woocommerce/product-collection' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) );
$p->set_attribute( $p->set_attribute(
'data-wc-context', 'data-wc-context',
wp_json_encode( wp_json_encode(
array( array(
// The message to be announced by the screen reader when the page is loading or loaded. 'collection' => $collection,
'accessibilityLoadingMessage' => __( 'Loading page, please wait.', 'woocommerce' ),
'accessibilityLoadedMessage' => __( 'Page Loaded.', 'woocommerce' ),
// We don't prefetch the links if user haven't clicked on pagination links yet.
// This way we avoid prefetching when the page loads.
'isPrefetchNextOrPreviousLink' => false,
), ),
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
) )
); );
$block_content = $p->get_updated_html();
} }
}
/** return $p->get_updated_html();
* Add two div's: }
* 1. Pagination animation for visual users.
* 2. Accessibility div for screen readers, to announce page load states. /**
*/ * Attach all the Interactivity API directives responsible
$last_tag_position = strripos( $block_content, '</div>' ); * for client-side navigation.
$accessibility_and_animation_html = ' *
* @param string $block_content The HTML content of the block.
*
* @return string Updated HTML content.
*/
private function enable_client_side_navigation( $block_content ) {
$p = new \WP_HTML_Tag_Processor( $block_content );
// Add `data-wc-navigation-id to the product collection block.
if ( $this->is_next_tag_product_collection( $p ) ) {
$p->set_attribute(
'data-wc-navigation-id',
'wc-product-collection-' . $this->parsed_block['attrs']['queryId']
);
$current_context = json_decode( $p->get_attribute( 'data-wc-context' ), true ) ?? [];
$p->set_attribute(
'data-wc-context',
wp_json_encode(
array(
...$current_context,
// The message to be announced by the screen reader when the page is loading or loaded.
'accessibilityLoadingMessage' => __( 'Loading page, please wait.', 'woocommerce' ),
'accessibilityLoadedMessage' => __( 'Page Loaded.', 'woocommerce' ),
// We don't prefetch the links if user haven't clicked on pagination links yet.
// This way we avoid prefetching when the page loads.
'isPrefetchNextOrPreviousLink' => false,
),
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
)
);
$block_content = $p->get_updated_html();
}
/**
* Add two div's:
* 1. Pagination animation for visual users.
* 2. Accessibility div for screen readers, to announce page load states.
*/
$last_tag_position = strripos( $block_content, '</div>' );
$accessibility_and_animation_html = '
<div <div
data-wc-interactive="{&quot;namespace&quot;:&quot;woocommerce/product-collection&quot;}" data-wc-interactive="{&quot;namespace&quot;:&quot;woocommerce/product-collection&quot;}"
class="wc-block-product-collection__pagination-animation" class="wc-block-product-collection__pagination-animation"
@ -219,12 +265,44 @@ class ProductCollection extends AbstractBlock {
data-wc-text="context.accessibilityMessage"> data-wc-text="context.accessibilityMessage">
</div> </div>
'; ';
$block_content = substr_replace( return substr_replace(
$block_content, $block_content,
$accessibility_and_animation_html, $accessibility_and_animation_html,
$last_tag_position, $last_tag_position,
0 0
); );
}
/**
* Enhances the Product Collection block with client-side pagination.
*
* This function identifies Product Collection blocks and adds necessary data attributes
* to enable client-side navigation and animation effects. It also enqueues the Interactivity API runtime.
*
* @param string $block_content The HTML content of the block.
* @param array $block Block details, including its attributes.
*
* @return string Updated block content with added interactivity attributes.
*/
public function enhance_product_collection_with_interactivity( $block_content, $block ) {
$is_product_collection_block = $block['attrs']['query']['isProductCollectionBlock'] ?? false;
if ( $is_product_collection_block ) {
// Enqueue the Interactivity API runtime and set the namespace.
wp_enqueue_script( 'wc-interactivity' );
$p = new \WP_HTML_Tag_Processor( $block_content );
if ( $this->is_next_tag_product_collection( $p ) ) {
$this->set_product_collection_namespace( $p );
}
$block_content = $p->get_updated_html();
$collection = $block['attrs']['collection'] ?? '';
$block_content = $this->add_rendering_callback( $block_content, $collection );
$is_enhanced_pagination_enabled = ! ( $block['attrs']['forcePageReload'] ?? false );
if ( $is_enhanced_pagination_enabled ) {
$block_content = $this->enable_client_side_navigation( $block_content );
}
} }
return $block_content; return $block_content;
@ -295,7 +373,7 @@ class ProductCollection extends AbstractBlock {
'class_name' => $class_name, 'class_name' => $class_name,
) )
) ) { ) ) {
$processor->set_attribute( 'data-wc-interactive', wp_json_encode( array( 'namespace' => 'woocommerce/product-collection' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ); $this->set_product_collection_namespace( $processor );
$processor->set_attribute( 'data-wc-on--click', 'actions.navigate' ); $processor->set_attribute( 'data-wc-on--click', 'actions.navigate' );
$processor->set_attribute( 'data-wc-key', $key_prefix . '--' . esc_attr( wp_rand() ) ); $processor->set_attribute( 'data-wc-key', $key_prefix . '--' . esc_attr( wp_rand() ) );
@ -314,9 +392,11 @@ class ProductCollection extends AbstractBlock {
*/ */
private function is_block_compatible( $block_name ) { private function is_block_compatible( $block_name ) {
// Check for explicitly unsupported blocks. // Check for explicitly unsupported blocks.
if ( 'core/post-content' === $block_name || if (
'core/post-content' === $block_name ||
'woocommerce/mini-cart' === $block_name || 'woocommerce/mini-cart' === $block_name ||
'woocommerce/featured-product' === $block_name ) { 'woocommerce/featured-product' === $block_name
) {
return false; return false;
} }
@ -835,7 +915,7 @@ class ProductCollection extends AbstractBlock {
if ( ! isset( $base[ $key ] ) ) { if ( ! isset( $base[ $key ] ) ) {
$base[ $key ] = array(); $base[ $key ] = array();
} }
$base[ $key ] = $this->array_merge_recursive_replace_non_array_properties( $base[ $key ], $value ); $base[ $key ] = $this->array_merge_recursive_replace_non_array_properties( $base[ $key ], $value );
} else { } else {
$base[ $key ] = $value; $base[ $key ] = $value;
} }