Product Collection: Trigger `wc-blocks_viewed_product` JS event (#51156)

* Define the event

* Add action sending an event in PC store

* Add directives and context to Product Template li element

* Use on--click directive in ProductImage

* Use on--click directive in Product Title

* Use on--click directive in Product Button

* Add changelog

* Add E2E tests

* Update docs

* Update blocks reference and docs manifest

* Update m,anifest

* Fix mistake in docs

* Regenerate docs manifest

* Fix lint

* Extractb new tests to a separate file
This commit is contained in:
Karol Manijak 2024-09-05 12:52:35 +02:00 committed by GitHub
parent 5a5b1f0478
commit a7231863c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 316 additions and 127 deletions

View File

@ -1109,16 +1109,16 @@ The contents of this block will display when there are no products found.
- **Supports:** align, color (background, gradients, link, text), typography (fontSize, lineHeight), ~~html~~, ~~reusable~~
- **Attributes:**
## Product Filter (Experimental) - woocommerce/product-filter
## Product Filters (Experimental) - woocommerce/product-filters
A block that adds product filters to the product collection.
Let shoppers filter products displayed on the page.
- **Name:** woocommerce/product-filter
- **Name:** woocommerce/product-filters
- **Category:** woocommerce
- **Ancestor:** woocommerce/product-filters
- **Ancestor:**
- **Parent:**
- **Supports:** ~~html~~, ~~inserter~~, ~~reusable~~
- **Attributes:** attributeId, filterType, heading, isPreview
- **Supports:** align, color (background, text), interactivity, layout (allowJustification, allowOrientation, allowVerticalAlignment, default, ~~allowInheriting~~), spacing (blockGap), typography (fontSize, textAlign), ~~inserter~~, ~~multiple~~
- **Attributes:** overlay, overlayButtonStyle, overlayIcon, overlayIconSize
## Filter Options - woocommerce/product-filter-active
@ -1153,50 +1153,6 @@ Allows shoppers to reset this filter.
- **Supports:** interactivity, ~~inserter~~
- **Attributes:**
## Filter Options - woocommerce/product-filter-price
Enable customers to filter the product collection by choosing a price range.
- **Name:** woocommerce/product-filter-price
- **Category:** woocommerce
- **Ancestor:** woocommerce/product-filter
- **Parent:**
- **Supports:** interactivity, ~~inserter~~
- **Attributes:** inlineInput, showInputFields
## Filter Options - woocommerce/product-filter-rating
Enable customers to filter the product collection by rating.
- **Name:** woocommerce/product-filter-rating
- **Category:** woocommerce
- **Ancestor:** woocommerce/product-filter
- **Parent:**
- **Supports:** color (text, ~~background~~), interactivity, ~~inserter~~
- **Attributes:** className, displayStyle, isPreview, selectType, showCounts
## Filter Options - woocommerce/product-filter-stock-status
Enable customers to filter the product collection by stock status.
- **Name:** woocommerce/product-filter-stock-status
- **Category:** woocommerce
- **Ancestor:** woocommerce/product-filter
- **Parent:**
- **Supports:** color (text, ~~background~~), interactivity, ~~html~~, ~~inserter~~, ~~multiple~~
- **Attributes:** className, displayStyle, isPreview, selectType, showCounts
## Product Filters (Experimental) - woocommerce/product-filters
Let shoppers filter products displayed on the page.
- **Name:** woocommerce/product-filters
- **Category:** woocommerce
- **Ancestor:**
- **Parent:**
- **Supports:** align, color (background, text), interactivity, layout (allowJustification, allowOrientation, allowVerticalAlignment, default, ~~allowInheriting~~), spacing (blockGap), typography (fontSize, textAlign), ~~inserter~~, ~~multiple~~
- **Attributes:** overlay, overlayButtonStyle, overlayIcon, overlayIconSize
## Product Filters Overlay (Experimental) - woocommerce/product-filters-overlay
Display product filters in an overlay on top of a page.
@ -1219,6 +1175,50 @@ Display overlay navigation controls.
- **Supports:** align (center, left, right), color (background, text), layout (default, ~~allowEditing~~), position (sticky), spacing (blockGap, margin, padding), typography (fontSize, lineHeight), ~~inserter~~
- **Attributes:** align, buttonStyle, iconSize, navigationStyle, overlayMode, style, triggerType
## Filter Options - woocommerce/product-filter-price
Enable customers to filter the product collection by choosing a price range.
- **Name:** woocommerce/product-filter-price
- **Category:** woocommerce
- **Ancestor:** woocommerce/product-filter
- **Parent:**
- **Supports:** interactivity, ~~inserter~~
- **Attributes:** inlineInput, showInputFields
## Product Filter (Experimental) - woocommerce/product-filter
A block that adds product filters to the product collection.
- **Name:** woocommerce/product-filter
- **Category:** woocommerce
- **Ancestor:** woocommerce/product-filters
- **Parent:**
- **Supports:** inserter, ~~html~~, ~~reusable~~
- **Attributes:** attributeId, filterType, heading, isPreview
## Filter Options - woocommerce/product-filter-rating
Enable customers to filter the product collection by rating.
- **Name:** woocommerce/product-filter-rating
- **Category:** woocommerce
- **Ancestor:** woocommerce/product-filter
- **Parent:**
- **Supports:** color (text, ~~background~~), interactivity, ~~inserter~~
- **Attributes:** className, displayStyle, isPreview, selectType, showCounts
## Filter Options - woocommerce/product-filter-stock-status
Enable customers to filter the product collection by stock status.
- **Name:** woocommerce/product-filter-stock-status
- **Category:** woocommerce
- **Ancestor:** woocommerce/product-filter
- **Parent:**
- **Supports:** color (text, ~~background~~), interactivity, ~~html~~, ~~inserter~~
- **Attributes:** className, displayStyle, isPreview, selectType, showCounts
## Product Gallery (Beta) - woocommerce/product-gallery
Showcase your products relevant images and media.

View File

@ -74,7 +74,7 @@
"post_title": "Blocks reference",
"menu_title": "Blocks Reference",
"edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/building-a-woo-store/block-references.md",
"hash": "329f17097ce67074a915d7814b2363e8b9e908910c1f7b196c8f4fd8594cc55c",
"hash": "9bbd3555641a70a0d7c24c818323a9270e437a6446998de9a6506e0c2ed6ddf5",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/building-a-woo-store/block-references.md",
"id": "1fbe91d7fa4fafaf35f0297e4cee1e7958756aed"
},
@ -1059,7 +1059,7 @@
"menu_title": "DOM Events",
"tags": "how-to",
"edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/product-collection-block/dom-events.md",
"hash": "fbad20bc55cc569161e80478c0789db3c34cf35513e669554af36db1de967a26",
"hash": "59a4b49eb146774d33229bc60ab7d8f74381493f6e7089ca8f0e2d0eb433a7a4",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/product-collection-block/dom-events.md",
"id": "c8d247b91472740075871e6b57a9583d893ac650"
}
@ -1804,5 +1804,5 @@
"categories": []
}
],
"hash": "212688b70a2dd0e70819746e6ffc033bc2279cb5b7b6350f377bbc3bc28c080f"
"hash": "12e9abfbcdbeae7dd5cc12dc3af3818332f272cb4b3ad12993cc010299009013"
}

View File

@ -10,13 +10,13 @@ tags: how-to
This event is triggered when Product Collection block was rendered or re-rendered (e.g. due to page change).
### `detail` parameters
### `wc-blocks_product_list_rendered` - `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
### `wc-blocks_product_list_rendered` - Example usage
```javascript
window.document.addEventListener(
@ -27,3 +27,27 @@ window.document.addEventListener(
}
);
```
## Event: `wc-blocks_viewed_product`
This event is triggered when some blocks are clicked in order to view product (redirect to product page).
### `wc-blocks_viewed_product` - `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. |
| `productId` | number | | Product ID |
### `wc-blocks_viewed_product` Example usage
```javascript
window.document.addEventListener(
'wc-blocks_viewed_product',
( e ) => {
const { collection, productId } = e.detail;
console.log( collection ) // -> collection name, e.g. "woocommerce/product-collection/featured" or undefined for default one
console.log( productId ) // -> product ID, e.g. 34
}
);
```

View File

@ -70,6 +70,17 @@ export const triggerProductListRenderedEvent = ( payload: {
} );
};
export const triggerViewedProductEvent = ( payload: {
collection?: CoreCollectionNames | string;
productId: number;
} ): void => {
dispatchEvent( 'wc-blocks_viewed_product', {
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.

View File

@ -8,7 +8,10 @@ import {
getElement,
getContext,
} from '@woocommerce/interactivity';
import { triggerProductListRenderedEvent } from '@woocommerce/base-utils';
import {
triggerProductListRenderedEvent,
triggerViewedProductEvent,
} from '@woocommerce/base-utils';
/**
* Internal dependencies
@ -17,6 +20,8 @@ import { CoreCollectionNames } from './types';
import './style.scss';
export type ProductCollectionStoreContext = {
// Available on the <li/> product element and deeper
productId?: number;
isPrefetchNextOrPreviousLink: boolean;
animation: 'start' | 'finish';
accessibilityMessage: string;
@ -164,6 +169,14 @@ const productCollectionStore = {
yield prefetch( ref.href );
}
},
*viewProduct() {
const { collection, productId } =
getContext< ProductCollectionStoreContext >();
if ( productId ) {
triggerViewedProductEvent( { collection, productId } );
}
},
},
callbacks: {
/**

View File

@ -0,0 +1,139 @@
/**
* External dependencies
*/
import { test as base, expect } from '@woocommerce/e2e-utils';
/**
* Internal dependencies
*/
import ProductCollectionPage from './product-collection.page';
const test = base.extend< { pageObject: ProductCollectionPage } >( {
pageObject: async ( { page, admin, editor }, use ) => {
const pageObject = new ProductCollectionPage( {
page,
admin,
editor,
} );
await use( pageObject );
},
} );
test.describe( 'Product Collection - 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 );
} );
test.describe( 'wc-blocks_viewed_product is emitted', () => {
let promise: Promise< { productId?: number; collection?: string } >;
test.beforeEach( async ( { page, pageObject } ) => {
await pageObject.createNewPostAndInsertBlock( 'featured' );
promise = new Promise( ( resolve ) => {
void page.exposeFunction( 'resolvePayload', resolve );
void page.addInitScript( () => {
window.document.addEventListener(
'wc-blocks_viewed_product',
( e ) => {
window.resolvePayload( e.detail );
}
);
} );
} );
await pageObject.publishAndGoToFrontend();
} );
test( 'when Product Image is clicked', async ( { page } ) => {
await page
.locator( '[data-block-name="woocommerce/product-image"]' )
.nth( 0 )
.click();
const { collection, productId } = await promise;
expect( collection ).toEqual(
'woocommerce/product-collection/featured'
);
expect( productId ).toEqual( expect.any( Number ) );
} );
test( 'when Product Title is clicked', async ( { page } ) => {
await page.locator( '.wp-block-post-title' ).nth( 0 ).click();
const { collection, productId } = await promise;
expect( collection ).toEqual(
'woocommerce/product-collection/featured'
);
expect( productId ).toEqual( expect.any( Number ) );
} );
test( 'when Add to Cart Anchor is clicked', async ( { page } ) => {
await page.getByLabel( 'Select options for “V-Neck T-' ).click();
const { collection, productId } = await promise;
expect( collection ).toEqual(
'woocommerce/product-collection/featured'
);
expect( productId ).toEqual( expect.any( Number ) );
} );
} );
} );

View File

@ -901,72 +901,6 @@ 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 );
} );
} );
} );
test.describe( 'Testing "usesReference" argument in "registerProductCollection"', () => {

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Product Elements send a JS event when user attempts to view a product

View File

@ -176,6 +176,10 @@ class ProductButton extends AbstractBlock {
data-wc-class--loading="context.isLoading"
';
$anchor_directive = '
data-wc-on--click="woocommerce/product-collection::actions.viewProduct"
';
$span_button_directives = '
data-wc-text="state.addToCartText"
data-wc-class--wc-block-slide-in="state.slideInAnimation"
@ -219,7 +223,7 @@ class ProductButton extends AbstractBlock {
'{attributes}' => isset( $args['attributes'] ) ? wc_implode_html_attributes( $args['attributes'] ) : '',
'{add_to_cart_text}' => esc_html( $initial_product_text ),
'{div_directives}' => $is_ajax_button ? $div_directives : '',
'{button_directives}' => $is_ajax_button ? $button_directives : '',
'{button_directives}' => $is_ajax_button ? $button_directives : $anchor_directive,
'{span_button_directives}' => $is_ajax_button ? $span_button_directives : '',
'{view_cart_html}' => $is_ajax_button ? $this->get_view_cart_html() : '',
)

View File

@ -117,6 +117,7 @@ class ProductCollection extends AbstractBlock {
// Interactivity API: Add navigation directives to the product collection block.
add_filter( 'render_block_woocommerce/product-collection', array( $this, 'handle_rendering' ), 10, 2 );
add_filter( 'render_block_core/query-pagination', array( $this, 'add_navigation_link_directives' ), 10, 3 );
add_filter( 'render_block_core/post-title', array( $this, 'add_product_title_click_event_directives' ), 10, 3 );
add_filter( 'posts_clauses', array( $this, 'add_price_range_filter_posts_clauses' ), 10, 2 );
@ -408,6 +409,36 @@ class ProductCollection extends AbstractBlock {
return $block_content;
}
/**
* 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-wc-on--click', 'woocommerce/product-collection::actions.viewProduct' );
$block_content = $p->get_updated_html();
}
}
return $block_content;
}
/**
* Process pagination links within the block content.
*

View File

@ -125,12 +125,15 @@ class ProductImage extends AbstractBlock {
private function render_anchor( $product, $on_sale_badge, $product_image, $attributes ) {
$product_permalink = $product->get_permalink();
$pointer_events = false === $attributes['showProductLink'] ? 'pointer-events: none;' : '';
$is_link = true === $attributes['showProductLink'];
$pointer_events = $is_link ? '' : 'pointer-events: none;';
$directive = $is_link ? 'data-wc-on--click="woocommerce/product-collection::actions.viewProduct"' : '';
return sprintf(
'<a href="%1$s" style="%2$s">%3$s %4$s</a>',
'<a href="%1$s" style="%2$s" %3$s>%4$s %5$s</a>',
$product_permalink,
$pointer_events,
$directive,
$on_sale_badge,
$product_image
);

View File

@ -85,6 +85,7 @@ class ProductTemplate extends AbstractBlock {
// Get an instance of the current Post Template block.
$block_instance = $block->parsed_block;
$product_id = get_the_ID();
// Set the block name to one that does not correspond to an existing registered block.
// This ensures that for the inner instances of the Post Template block, we do not render any block supports.
@ -97,14 +98,39 @@ class ProductTemplate extends AbstractBlock {
$block_instance,
array(
'postType' => get_post_type(),
'postId' => get_the_ID(),
'postId' => $product_id,
)
)
)->render( array( 'dynamic' => false ) );
$interactive = array(
'namespace' => 'woocommerce/product-collection',
);
$context = array(
'productId' => $product_id,
);
$li_directives = '
data-wc-interactive=\'' . wp_json_encode( $interactive, JSON_NUMERIC_CHECK | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) . '\'
data-wc-context=\'' . wp_json_encode( $context, JSON_NUMERIC_CHECK | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) . '\'
data-wc-key="product-item-' . $product_id . '"
';
// Wrap the render inner blocks in a `li` element with the appropriate post classes.
$post_classes = implode( ' ', get_post_class( 'wc-block-product' ) );
$content .= '<li data-wc-key="product-item-' . get_the_ID() . '" class="' . esc_attr( $post_classes ) . '">' . $block_content . '</li>';
$content .= strtr(
'<li class="{classes}"
{li_directives}
>
{content}
</li>',
array(
'{classes}' => esc_attr( $post_classes ),
'{li_directives}' => $li_directives,
'{content}' => $block_content,
)
);
}
/*