From 8bdc78c77732a88af77562b4fabf94e7879098b6 Mon Sep 17 00:00:00 2001 From: Karol Manijak <20098064+kmanijak@users.noreply.github.com> Date: Tue, 13 Aug 2024 12:29:04 +0200 Subject: [PATCH] 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 * 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 --- docs/docs-manifest.json | 13 +- docs/product-collection-block/dom-events.md | 29 +++ .../assets/js/base/utils/legacy-events.ts | 11 ++ .../js/blocks/product-collection/frontend.tsx | 13 ++ .../js/blocks/product-collection/types.ts | 1 - .../js/blocks/product-collection/utils.tsx | 14 +- .../product-collection.block_theme.spec.ts | 66 +++++++ ...r-wc-blocks_product_list_rendered-js-event | 4 + .../Blocks/BlockTypes/ProductCollection.php | 168 +++++++++++++----- 9 files changed, 265 insertions(+), 54 deletions(-) create mode 100644 docs/product-collection-block/dom-events.md create mode 100644 plugins/woocommerce/changelog/48862-product-collection-trigger-wc-blocks_product_list_rendered-js-event diff --git a/docs/docs-manifest.json b/docs/docs-manifest.json index a994f533f1d..61146429a4d 100644 --- a/docs/docs-manifest.json +++ b/docs/docs-manifest.json @@ -1007,9 +1007,18 @@ "menu_title": "Registering custom collections", "tags": "how-to", "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", "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": [] @@ -1697,5 +1706,5 @@ "categories": [] } ], - "hash": "c24136612fe16fc7dee435a2ad2198ce242ab63dbba483fd0a18efd88dc3a289" + "hash": "945d1eb884a52c8c0dbc4c8852760e332ab40030de82403d1a7103b2517c36a1" } \ No newline at end of file diff --git a/docs/product-collection-block/dom-events.md b/docs/product-collection-block/dom-events.md new file mode 100644 index 00000000000..cc49b4bb8b9 --- /dev/null +++ b/docs/product-collection-block/dom-events.md @@ -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 + } +); +``` diff --git a/plugins/woocommerce-blocks/assets/js/base/utils/legacy-events.ts b/plugins/woocommerce-blocks/assets/js/base/utils/legacy-events.ts index 322974c8db5..c2607db8c6d 100644 --- a/plugins/woocommerce-blocks/assets/js/base/utils/legacy-events.ts +++ b/plugins/woocommerce-blocks/assets/js/base/utils/legacy-events.ts @@ -2,6 +2,7 @@ * External dependencies */ import type { AddToCartEventDetail } from '@woocommerce/types'; +import type { CoreCollectionNames } from '@woocommerce/blocks/product-collection/types'; 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. * Useful to convert WC Core events into events that can be read by blocks. diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/frontend.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/frontend.tsx index cebf7594018..12b5f386209 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/frontend.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/frontend.tsx @@ -8,10 +8,12 @@ import { getElement, getContext, } from '@woocommerce/interactivity'; +import { triggerProductListRenderedEvent } from '@woocommerce/base-utils'; /** * Internal dependencies */ +import { CoreCollectionNames } from './types'; import './style.scss'; export type ProductCollectionStoreContext = { @@ -20,6 +22,7 @@ export type ProductCollectionStoreContext = { accessibilityMessage: string; accessibilityLoadingMessage: string; accessibilityLoadedMessage: string; + collection: CoreCollectionNames; }; const isValidLink = ( ref: HTMLAnchorElement ) => @@ -136,6 +139,10 @@ const productCollectionStore = { ctx.isPrefetchNextOrPreviousLink = !! ref.href; scrollToFirstProductIfNotVisible( wcNavigationId ); + + triggerProductListRenderedEvent( { + collection: ctx.collection, + } ); } }, /** @@ -179,6 +186,12 @@ const productCollectionStore = { yield prefetch( ref.href ); } }, + *onRender() { + const { collection } = + getContext< ProductCollectionStoreContext >(); + + triggerProductListRenderedEvent( { collection } ); + }, }, }; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts index 5d5a766f50c..4407c682abe 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts @@ -135,7 +135,6 @@ export type QueryControlProps = { export enum CoreCollectionNames { PRODUCT_CATALOG = 'woocommerce/product-collection/product-catalog', - CUSTOM = 'woocommerce/product-collection/custom', BEST_SELLERS = 'woocommerce/product-collection/best-sellers', FEATURED = 'woocommerce/product-collection/featured', NEW_ARRIVALS = 'woocommerce/product-collection/new-arrivals', diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/utils.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/utils.tsx index 1958d15e4de..bdbd882e88a 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/utils.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/utils.tsx @@ -18,13 +18,13 @@ import { * Internal dependencies */ import { - ProductCollectionAttributes, - TProductCollectionOrder, - TProductCollectionOrderBy, - ProductCollectionQuery, - ProductCollectionDisplayLayout, - PreviewState, - SetPreviewState, + type ProductCollectionAttributes, + type TProductCollectionOrder, + type TProductCollectionOrderBy, + type ProductCollectionQuery, + type ProductCollectionDisplayLayout, + type PreviewState, + type SetPreviewState, } from './types'; import { coreQueryPaginationBlockName, diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts index fc5aeaaaa86..cffadc5246c 100644 --- a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts +++ b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts @@ -1627,6 +1627,72 @@ test.describe( 'Product Collection', () => { 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 ); + } ); + } ); } ); /** diff --git a/plugins/woocommerce/changelog/48862-product-collection-trigger-wc-blocks_product_list_rendered-js-event b/plugins/woocommerce/changelog/48862-product-collection-trigger-wc-blocks_product_list_rendered-js-event new file mode 100644 index 00000000000..6f4d379a7a3 --- /dev/null +++ b/plugins/woocommerce/changelog/48862-product-collection-trigger-wc-blocks_product_list_rendered-js-event @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Product Collection: emit the JS event when PC block is rendered diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php index 4b9372e35f6..6b94f719973 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection.php @@ -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 - * to enable client-side navigation and animation effects. It also enqueues the Interactivity API runtime. + * @param WP_HTML_Tag_processor $p Initial tag processor. + * + * @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 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 ) { - $is_product_collection_block = $block['attrs']['query']['isProductCollectionBlock'] ?? false; - $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' ); + private function add_rendering_callback( $block_content, $collection ) { + $p = new \WP_HTML_Tag_Processor( $block_content ); - $p = new \WP_HTML_Tag_Processor( $block_content ); - - // Add `data-wc-navigation-id to the product collection block. - if ( $p->next_tag( array( 'class_name' => 'wp-block-woocommerce-product-collection' ) ) ) { - $p->set_attribute( - 'data-wc-navigation-id', - 'wc-product-collection-' . $this->parsed_block['attrs']['queryId'] - ); - $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 ) ); + // Add `data-init to the product collection block so we trigger JS event on render. + if ( $this->is_next_tag_product_collection( $p ) ) { + $p->set_attribute( + 'data-wc-init', + 'callbacks.onRender' + ); + if ( $collection ) { $p->set_attribute( 'data-wc-context', wp_json_encode( array( - // 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, + 'collection' => $collection, ), 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, '' ); - $accessibility_and_animation_html = ' + return $p->get_updated_html(); + } + + /** + * Attach all the Interactivity API directives responsible + * for client-side navigation. + * + * @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, '' ); + $accessibility_and_animation_html = '
'; - $block_content = substr_replace( - $block_content, - $accessibility_and_animation_html, - $last_tag_position, - 0 - ); + return substr_replace( + $block_content, + $accessibility_and_animation_html, + $last_tag_position, + 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; @@ -295,7 +373,7 @@ class ProductCollection extends AbstractBlock { '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-key', $key_prefix . '--' . esc_attr( wp_rand() ) ); @@ -314,9 +392,11 @@ class ProductCollection extends AbstractBlock { */ private function is_block_compatible( $block_name ) { // Check for explicitly unsupported blocks. - if ( 'core/post-content' === $block_name || + if ( + 'core/post-content' === $block_name || 'woocommerce/mini-cart' === $block_name || - 'woocommerce/featured-product' === $block_name ) { + 'woocommerce/featured-product' === $block_name + ) { return false; } @@ -835,7 +915,7 @@ class ProductCollection extends AbstractBlock { if ( ! isset( $base[ $key ] ) ) { $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 { $base[ $key ] = $value; }