Product Gallery Thumbnails: Interactivity API directives (https://github.com/woocommerce/woocommerce-blocks/pull/10776)

* Fix "On sale" badge class for shop

* Add class to sale badge

* Move the thumbnails featching logic to an utils file. Add context directive with thumbnails data to the Product Gallery block. Add on-click directives to the Thumbnails block

* Product Gallery Thumbnails: Remove the legacy thumbnail markup

* Product Gallery Thumbnails: Add Large Image replacing

* update the main image when the thumbnail is clicked

* add E2E tests

* fix typo

* fix warning on the frontend

* address feedback

* update E2E test

* improve comment

* fix indentation

* improve E2E test

* improve flaky test

* improve E2E test

* improve comments

* improve E2E test

* try now

* add comment

* skip test

* reset script

* update todo comment

---------

Co-authored-by: Alba Rincón <alba.rincon@automattic.com>
Co-authored-by: Alba Rincón <albarin@users.noreply.github.com>
Co-authored-by: Luigi <gigitux@gmail.com>
This commit is contained in:
Daniel Dudzic 2023-09-12 09:36:44 +02:00 committed by GitHub
parent 468a0d0da6
commit 9db927de30
10 changed files with 369 additions and 74 deletions

View File

@ -21,6 +21,7 @@
"pagerDisplayMode": "pagerDisplayMode",
"hoverZoom": "hoverZoom"
},
"usesContext": [ "postId" ],
"attributes": {
"thumbnailsPosition": {
"type": "string",
@ -55,5 +56,5 @@
"default": "insideTheImage"
}
},
"viewScript": "wc-product-gallery-interactivity-frontend"
"viewScript": "wc-product-gallery-frontend"
}

View File

@ -9,15 +9,20 @@ interface State {
interface Context {
woocommerce: {
productGallery: { numberOfThumbnails: number };
selectedImage: string;
imageId: string;
};
}
interface Selectors {
woocommerce: {
productGallery: {
numberOfPages: ( store: unknown ) => number;
};
isSelected: ( store: unknown ) => boolean;
};
}
interface Actions {
woocommerce: {
handleClick: ( context: Context ) => void;
};
}
@ -25,22 +30,27 @@ interface Store {
state: State;
context: Context;
selectors: Selectors;
ref: HTMLElement;
actions: Actions;
ref?: HTMLElement;
}
type SelectorsStore = Pick< Store, 'context' | 'selectors' >;
interactivityApiStore( {
state: {},
selectors: {
woocommerce: {
productGallery: {
numberOfPages: ( store: SelectorsStore ) => {
const { context } = store;
return context.woocommerce.productGallery
.numberOfThumbnails;
},
isSelected: ( { context }: Store ) => {
return (
context?.woocommerce.selectedImage ===
context?.woocommerce.imageId
);
},
},
},
} as Store );
actions: {
woocommerce: {
handleClick: ( { context }: Store ) => {
context.woocommerce.selectedImage = context.woocommerce.imageId;
},
},
},
} );

View File

@ -13,6 +13,10 @@
}
}
img[hidden] {
display: none;
}
.wc-block-product-gallery-large-image__inner-blocks {
pointer-events: none;
display: flex;

View File

@ -1,4 +1,8 @@
.woocommerce {
.wp-block-woocommerce-product-gallery-thumbnails {
img {
cursor: pointer;
}
.is-vertical .wc-block-components-product-gallery-thumbnails {
display: flex;
flex-direction: row;

View File

@ -1,6 +1,8 @@
<?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\ProductGalleryUtils;
/**
* ProductGallery class.
*/
@ -12,15 +14,6 @@ class ProductGallery extends AbstractBlock {
*/
protected $block_name = 'product-gallery';
/**
* Get the frontend style handle for this block type.
*
* @return null
*/
protected function get_block_type_style() {
return null;
}
/**
* Include and render the block.
*
@ -30,6 +23,8 @@ class ProductGallery extends AbstractBlock {
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
// This is a temporary solution. We have to refactor this code when the block will have to be addable on every page/post https://github.com/woocommerce/woocommerce-blocks/issues/10882.
global $product;
$classname = $attributes['className'] ?? '';
$wrapper_attributes = get_block_wrapper_attributes( array( 'class' => trim( sprintf( 'woocommerce %1$s', $classname ) ) ) );
$html = sprintf(
@ -39,13 +34,20 @@ class ProductGallery extends AbstractBlock {
$wrapper_attributes,
$content
);
$p = new \WP_HTML_Tag_Processor( $content );
$p = new \WP_HTML_Tag_Processor( $content );
if ( $p->next_tag() ) {
$p->set_attribute( 'data-wc-interactive', true );
$p->set_attribute(
'data-wc-context',
wp_json_encode( array( 'woocommerce' => array( 'productGallery' => array( 'numberOfThumbnails' => 0 ) ) ) )
wp_json_encode(
array(
'woocommerce' => array(
'selectedImage' => $product->get_image_id(),
),
)
)
);
$html = $p->get_updated_html();
}

View File

@ -1,6 +1,8 @@
<?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\ProductGalleryUtils;
/**
* ProductGalleryLargeImage class.
*/
@ -69,28 +71,23 @@ class ProductGalleryLargeImage extends AbstractBlock {
$processor->remove_class( 'wp-block-woocommerce-product-gallery-large-image' );
$content = $processor->get_updated_html();
$image_html = wp_get_attachment_image(
$product->get_image_id(),
'full',
false,
array(
'class' => 'wc-block-woocommerce-product-gallery-large-image__image',
)
);
[ $visible_main_image, $main_images ] = $this->get_main_images_html( $block->context, $post_id );
[$directives, $image_html] = $block->context['hoverZoom'] ? $this->get_html_with_interactivity( $image_html ) : array( array(), $image_html );
$directives = $this->get_directives( $block->context );
return strtr(
'<div class="wp-block-woocommerce-product-gallery-large-image" {directives}>
{image}
{visible_main_image}
{main_images}
<div class="wc-block-woocommerce-product-gallery-large-image__content">
{content}
</div>
</div>',
array(
'{image}' => $image_html,
'{content}' => $content,
'{directives}' => array_reduce(
'{visible_main_image}' => $visible_main_image,
'{main_images}' => implode( ' ', $main_images ),
'{content}' => $content,
'{directives}' => array_reduce(
array_keys( $directives ),
function( $carry, $key ) use ( $directives ) {
return $carry . ' ' . $key . '="' . esc_attr( $directives[ $key ] ) . '"';
@ -102,12 +99,54 @@ class ProductGalleryLargeImage extends AbstractBlock {
}
/**
* Get the HTML that adds interactivity to the image. This is used for the hover zoom effect.
* Get the main images html code. The first element of the array contains the HTML of the first image that is visible, the second element contains the HTML of the other images that are hidden.
*
* @param array $context The block context.
* @param int $product_id The product id.
*
* @param string $image_html The image HTML.
* @return array
*/
private function get_html_with_interactivity( $image_html ) {
private function get_main_images_html( $context, $product_id ) {
$attributes = array(
'data-wc-bind--hidden' => '!selectors.woocommerce.isSelected',
'hidden' => true,
'class' => 'wc-block-woocommerce-product-gallery-large-image__image',
);
if ( $context['hoverZoom'] ) {
$attributes['class'] .= ' wc-block-woocommerce-product-gallery-large-image__image--hoverZoom';
$attributes['data-wc-bind--style'] = 'selectors.woocommerce.styles';
}
$main_images = ProductGalleryUtils::get_product_gallery_images(
$product_id,
'full',
$attributes
);
$visible_main_image = array_shift( $main_images );
$visible_main_image_processor = new \WP_HTML_Tag_Processor( $visible_main_image );
$visible_main_image_processor->next_tag();
$visible_main_image_processor->remove_attribute( 'hidden' );
$visible_main_image = $visible_main_image_processor->get_updated_html();
return array( $visible_main_image, $main_images );
}
/**
* Get directives for the hover zoom.
*
* @param array $block_context The block context.
*
* @return array
*/
private function get_directives( $block_context ) {
if ( ! $block_context['hoverZoom'] ) {
return array();
}
$context = array(
'woocommerce' => array(
'styles' => array(
@ -117,21 +156,10 @@ class ProductGalleryLargeImage extends AbstractBlock {
),
);
$directives = array(
return array(
'data-wc-on--mousemove' => 'actions.woocommerce.handleMouseMove',
'data-wc-on--mouseleave' => 'actions.woocommerce.handleMouseLeave',
'data-wc-context' => wp_json_encode( $context, JSON_NUMERIC_CHECK ),
);
$image_html_processor = new \WP_HTML_Tag_Processor( $image_html );
$image_html_processor->next_tag( 'img' );
$image_html_processor->add_class( 'wc-block-woocommerce-product-gallery-large-image__image--hoverZoom' );
$image_html_processor->set_attribute( 'data-wc-bind--style', 'selectors.woocommerce.styles' );
$image_html = $image_html_processor->get_updated_html();
return array(
$directives,
$image_html,
);
}
}

View File

@ -2,6 +2,7 @@
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
use Automattic\WooCommerce\Blocks\Utils\ProductGalleryUtils;
/**
* ProductGalleryLargeImage class.
@ -54,33 +55,38 @@ class ProductGalleryThumbnails extends AbstractBlock {
$html = '';
if ( $product ) {
$attachment_ids = $product->get_gallery_image_ids();
if ( $attachment_ids && $post_thumbnail_id ) {
$html .= wc_get_gallery_image_html( $post_thumbnail_id, true );
$post_thumbnail_id = $product->get_image_id();
$product_gallery_images = ProductGalleryUtils::get_product_gallery_images( $post_id, 'thumbnail', array() );
if ( $product_gallery_images && $post_thumbnail_id ) {
$number_of_thumbnails = isset( $block->context['thumbnailsNumberOfThumbnails'] ) ? $block->context['thumbnailsNumberOfThumbnails'] : 3;
$thumbnails_count = 1;
foreach ( $attachment_ids as $attachment_id ) {
if ( $thumbnails_count >= $number_of_thumbnails ) {
foreach ( $product_gallery_images as $product_gallery_image_html ) {
if ( $thumbnails_count > $number_of_thumbnails ) {
break;
}
/**
* Filter the HTML markup for a single product image thumbnail in the gallery.
*
* @param string $thumbnail_html The HTML markup for the thumbnail.
* @param int $attachment_id The attachment ID of the thumbnail.
*
* @since 7.9.0
*/
$html .= apply_filters( 'woocommerce_single_product_image_thumbnail_html', wc_get_gallery_image_html( $attachment_id ), $attachment_id ); // phpcs:disable WordPress.XSS.EscapeOutput.OutputNotEscaped
$html .= '<div class="wp-block-woocommerce-product-gallery-thumbnails__thumbnail">';
$processor = new \WP_HTML_Tag_Processor( $product_gallery_image_html );
if ( $processor->next_tag() ) {
$processor->set_attribute(
'data-wc-on--click',
'actions.woocommerce.handleClick'
);
$html .= $processor->get_updated_html();
}
$html .= '</div>';
$thumbnails_count++;
}
}
return sprintf(
'<div class="wc-block-components-product-gallery-thumbnails %1$s" style="%2$s">
'<div class="wc-block-components-product-gallery-thumbnails wp-block-woocommerce-product-gallery-thumbnails %1$s" style="%2$s">
%3$s
</div>',
esc_attr( $classes_and_styles['classes'] ),

View File

@ -0,0 +1,69 @@
<?php
namespace Automattic\WooCommerce\Blocks\Utils;
/**
* Utility methods used for the Product Gallery block.
* {@internal This class and its methods are not intended for public use.}
*/
class ProductGalleryUtils {
/**
* When requesting a full-size image, this function may return an array with a single image.
* However, when requesting a non-full-size image, it will always return an array with multiple images.
* This distinction is based on the image size needed for rendering purposes:
* - "Full" size is used for the main product featured image.
* - Non-full sizes are used for rendering thumbnails.
*
* @param int $post_id Post ID.
* @param string $size Image size.
* @param array $attributes Attributes.
* @return array
*/
public static function get_product_gallery_images( $post_id, $size = 'full', $attributes = array() ) {
$product_gallery_images = array();
$product = wc_get_product( $post_id );
if ( $product ) {
// Main product featured image.
$featured_image_id = $product->get_image_id();
// All other product gallery images.
$product_gallery_image_ids = $product->get_gallery_image_ids();
// We don't want to show the same image twice, so we have to remove the featured image from the gallery if it's there.
$all_product_gallery_image_ids = array_unique(
array_merge(
array( $featured_image_id ),
$product_gallery_image_ids
)
);
if ( 'full' === $size || 'full' !== $size && count( $all_product_gallery_image_ids ) > 1 ) {
foreach ( $all_product_gallery_image_ids as $product_gallery_image_id ) {
$product_image_html = wp_get_attachment_image(
$product_gallery_image_id,
$size,
false,
$attributes
);
$product_image_html_processor = new \WP_HTML_Tag_Processor( $product_image_html );
$product_image_html_processor->next_tag();
$product_image_html_processor->set_attribute(
'data-wc-context',
wp_json_encode(
array(
'woocommerce' => array(
'imageId' => strval( $product_gallery_image_id ),
),
)
)
);
$product_gallery_images[] = $product_image_html_processor->get_updated_html();
}
}
}
return $product_gallery_images;
}
}

View File

@ -97,7 +97,9 @@ test.describe( `${ blockData.name }`, () => {
);
// img[style] is the selector because the style attribute is Interactivity API.
const imgElement = blockFrontend.locator( 'img[style]' );
const imgElement = blockFrontend.locator(
'img[style]:not([hidden])'
);
const style = await imgElement.evaluate( ( el ) => el.style );
await expect( style.transform ).toBe( 'scale(1)' );
@ -135,9 +137,13 @@ test.describe( `${ blockData.name }`, () => {
);
// img[style] is the selector because the style attribute is added by Interactivity API. In this case, the style attribute should not be added.
const imgElement = blockFrontend.locator( 'img[style]' );
const imgElement = blockFrontend.locator(
'img[style]:not([hidden])'
);
await expect( imgElement ).toBeHidden();
await expect( blockFrontend.locator( 'img' ) ).toBeVisible();
await expect(
blockFrontend.locator( 'img:not([hidden])' )
).toBeVisible();
} );
} );
} );

View File

@ -0,0 +1,165 @@
/**
* External dependencies
*/
import { test as base, expect } from '@woocommerce/e2e-playwright-utils';
import { Locator, Page } from '@playwright/test';
import { FrontendUtils } from '@woocommerce/e2e-utils';
/**
* Internal dependencies
*/
import { ProductGalleryPage } from './product-gallery.page';
const blockData = {
name: 'woocommerce/product-gallery',
selectors: {
frontend: {},
editor: {},
},
slug: 'single-product',
productPage: '/product/logo-collection/',
};
const test = base.extend< { pageObject: ProductGalleryPage } >( {
pageObject: async ( { page, editor }, use ) => {
const pageObject = new ProductGalleryPage( {
page,
editor,
} );
await use( pageObject );
},
} );
export const getVisibleLargeImageId = async (
frontendUtils: FrontendUtils
) => {
const mainImageBlock = await frontendUtils.getBlockByName(
'woocommerce/product-gallery-large-image'
);
const mainImage = mainImageBlock.locator( 'img:not([hidden])' ) as Locator;
const mainImageContext = ( await mainImage.getAttribute(
'data-wc-context'
) ) as string;
const mainImageParsedContext = JSON.parse( mainImageContext );
return mainImageParsedContext.woocommerce.imageId;
};
export const waitForJavascriptFrontendFileIsLoaded = async ( page: Page ) => {
await page.waitForResponse(
( response ) =>
response.url().includes( 'product-gallery-frontend' ) &&
response.status() === 200
);
};
export const getThumbnailImageIdByNth = async (
nth: number,
frontendUtils: FrontendUtils
) => {
const thumbnailsBlock = await frontendUtils.getBlockByName(
'woocommerce/product-gallery-thumbnails'
);
const image = thumbnailsBlock.locator( 'img' ).nth( nth );
const imageContext = ( await image.getAttribute(
'data-wc-context'
) ) as string;
const imageId = JSON.parse( imageContext ).woocommerce.imageId;
return imageId;
};
test.describe( `${ blockData.name }`, () => {
test.beforeEach( async ( { requestUtils, admin, editorUtils } ) => {
await requestUtils.deleteAllTemplates( 'wp_template' );
await requestUtils.deleteAllTemplates( 'wp_template_part' );
await admin.visitSiteEditor( {
postId: `woocommerce/woocommerce//${ blockData.slug }`,
postType: 'wp_template',
} );
await editorUtils.enterEditMode();
} );
test.afterEach( async ( { requestUtils } ) => {
await requestUtils.deleteAllTemplates( 'wp_template' );
await requestUtils.deleteAllTemplates( 'wp_template_part' );
} );
test( 'should have as first thumbnail, the same image that it is visible in the Large Image block', async ( {
page,
editorUtils,
frontendUtils,
pageObject,
} ) => {
await pageObject.addProductGalleryBlock( { cleanContent: true } );
await editorUtils.saveTemplate();
await page.goto( blockData.productPage, {
waitUntil: 'commit',
} );
const visibleLargeImageId = await getVisibleLargeImageId(
frontendUtils
);
const firstImageThumbnailId = await getThumbnailImageIdByNth(
0,
frontendUtils
);
expect( visibleLargeImageId ).toBe( firstImageThumbnailId );
} );
// @todo: Fix this test. It's failing because the thumbnail images aren't generated correctly when the products are imported via .xml: https://github.com/woocommerce/woocommerce/issues/31646
// eslint-disable-next-line playwright/no-skipped-test
test.skip( 'should change the image when the user click on a thumbnail image', async ( {
page,
editorUtils,
frontendUtils,
pageObject,
} ) => {
await pageObject.addProductGalleryBlock( { cleanContent: true } );
await editorUtils.saveTemplate();
await Promise.all( [
page.goto( blockData.productPage, {
waitUntil: 'load',
} ),
waitForJavascriptFrontendFileIsLoaded( page ),
] );
const visibleLargeImageId = await getVisibleLargeImageId(
frontendUtils
);
const secondImageThumbnailId = await getThumbnailImageIdByNth(
1,
frontendUtils
);
expect( visibleLargeImageId ).not.toBe( secondImageThumbnailId );
await (
await frontendUtils.getBlockByName(
'woocommerce/product-gallery-thumbnails'
)
)
.locator( 'img' )
.nth( 1 )
.click();
const newVisibleLargeImageId = await getVisibleLargeImageId(
frontendUtils
);
expect( newVisibleLargeImageId ).toBe( secondImageThumbnailId );
} );
} );