Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Implement the Block Hooks API to automatically inject the Mini-Cart block #11745

Merged
merged 12 commits into from
Dec 4, 2023
2 changes: 1 addition & 1 deletion assets/js/blocks/mini-cart/block.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
},
"hasHiddenPrice": {
"type": "boolean",
"default": false
"default": true
},
"cartAndCheckoutRenderStyle": {
"type": "string",
Expand Down
2 changes: 1 addition & 1 deletion assets/js/blocks/mini-cart/block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const MiniCartBlock = ( attributes: Props ): JSX.Element => {
contents = '',
miniCartIcon,
addToCartBehaviour = 'none',
hasHiddenPrice = false,
hasHiddenPrice = true,
priceColor = defaultColorItem,
iconColor = defaultColorItem,
productCountColor = defaultColorItem,
Expand Down
6 changes: 2 additions & 4 deletions assets/js/blocks/mini-cart/test/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ describe( 'Testing Mini-Cart', () => {

it( 'renders cart price if "Hide Cart Price" setting is not enabled', async () => {
mockEmptyCart();
render( <MiniCartBlock /> );
render( <MiniCartBlock hasHiddenPrice={ false } /> );
await waitFor( () => expect( fetchMock ).toHaveBeenCalled() );

await waitFor( () =>
Expand All @@ -212,9 +212,7 @@ describe( 'Testing Mini-Cart', () => {

it( 'does not render cart price if "Hide Cart Price" setting is enabled', async () => {
mockEmptyCart();
const { container } = render(
<MiniCartBlock hasHiddenPrice={ true } />
);
const { container } = render( <MiniCartBlock /> );
await waitFor( () => expect( fetchMock ).toHaveBeenCalled() );

await waitFor( () =>
Expand Down
99 changes: 99 additions & 0 deletions src/BlockTypes/MiniCart.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public function __construct( AssetApi $asset_api, AssetDataRegistry $asset_data_
protected function initialize() {
parent::initialize();
add_action( 'wp_loaded', array( $this, 'register_empty_cart_message_block_pattern' ) );
add_action( 'hooked_block_types', array( $this, 'register_auto_insert' ), 10, 4 );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know how/why this worked but the hook is a filter according to the docs and I've only just realised this was added as an action.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little known fact that add_action is actually a wrapper around add_filter. To make the intent clear, it would be better to use the appropriate function though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL

add_action( 'wp_print_footer_scripts', array( $this, 'print_lazy_load_scripts' ), 2 );
}

Expand Down Expand Up @@ -577,6 +578,104 @@ public function register_empty_cart_message_block_pattern() {
);
}

/**
* Callback for `hooked_block_types` to auto-inject the mini-cart block into headers after navigation.
*
* @param array $hooked_blocks An array of block slugs hooked into a given context.
* @param string $position Position of the block insertion point.
* @param string $anchor_block The block acting as the anchor for the inserted block.
* @param \WP_Block_Template|array $context Where the block is embedded.
* @since $VID:$
* @return array An array of block slugs hooked into a given context.
*/
public function register_auto_insert( $hooked_blocks, $position, $anchor_block, $context ) {
// Cache for active theme.
static $active_theme_name = null;
if ( is_null( $active_theme_name ) ) {
$active_theme_name = wp_get_theme()->get( 'Name' );
}
/**
* A list of pattern slugs to exclude from auto-insert (useful when
* there are patterns that have a very specific location for the block)
*
* @since $VID:$
*/
$pattern_exclude_list = apply_filters( 'woocommerce_blocks_mini_cart_auto_insert_pattern_exclude_list', [] );

/**
* A list of theme slugs to execute this with. This is a temporary
* measure until improvements to the Block Hooks API allow for exposing
* to all block themes.
*
* @since $VID:$
*/
$theme_include_list = apply_filters( 'woocommerce_blocks_mini_cart_auto_insert_theme_include_list', [ 'Twenty Twenty-Four' ] );

if ( $context && in_array( $active_theme_name, $theme_include_list, true ) ) {
if (
'after' === $position &&
'core/navigation' === $anchor_block &&
$this->is_header_part_or_pattern( $context ) &&
! $this->pattern_is_excluded( $context, $pattern_exclude_list ) &&
! $this->has_mini_cart_block( $context )
) {
$hooked_blocks[] = 'woocommerce/' . $this->block_name;
}
}

return $hooked_blocks;
}

/**
* Returns whether the pattern is excluded or not
*
* @param array|\WP_Block_Template $context Where the block is embedded.
* @param array $pattern_exclude_list List of pattern slugs to exclude.
* @since $VID:$
* @return boolean
*/
private function pattern_is_excluded( $context, $pattern_exclude_list ) {
$pattern_slug = is_array( $context ) && isset( $context['slug'] ) ? $context['slug'] : '';
return in_array( $pattern_slug, $pattern_exclude_list, true );
}

/**
* Checks if the provided context contains a mini-cart block.
*
* @param array|\WP_Block_Template $context Where the block is embedded.
* @since $VID:$
* @return boolean
*/
private function has_mini_cart_block( $context ) {
/**
* Note: this won't work for parsing WP_Block_Template instance until it's fixed in core
* because $context->content is set as the result of `traverse_and_serialize_blocks` so
* the filter callback doesn't get the original content.
*
* @see https://core.trac.wordpress.org/ticket/59882
*/
$content = is_array( $context ) && isset( $context['content'] ) ? $context['content'] : '';
$content = '' === $content && $context instanceof \WP_Block_Template ? $context->content : $content;
return strpos( $content, 'wp:woocommerce/mini-cart' ) !== false;
}

/**
* Given a provided context, returns whether the context refers to header content.
*
* @param array|\WP_Block_Template $context Where the block is embedded.
* @since $VID:$
* @return boolean
*/
private function is_header_part_or_pattern( $context ) {
$is_header_pattern = is_array( $context ) &&
(
( isset( $context['blockTypes'] ) && in_array( 'core/template-part/header', $context['blockTypes'], true ) ) ||
( isset( $context['categories'] ) && in_array( 'header', $context['categories'], true ) )
);
$is_header_part = $context instanceof \WP_Block_Template && 'header' === $context->area;
return ( $is_header_pattern || $is_header_part );
}

/**
* Returns whether the Mini-Cart should be rendered or not.
*
Expand Down
8 changes: 0 additions & 8 deletions tests/e2e-jest/specs/shopper/mini-cart.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,6 @@ describe( 'Shopper → Mini-Cart', () => {
await expect( page ).toMatchElement(
'.wc-block-mini-cart__quantity-badge'
);

// Make sure the initial quantity is 0.
await expect( page ).toMatchElement(
'.wc-block-mini-cart__amount',
{
text: '$0',
}
);
await expect( page ).toMatchElement( '.wc-block-mini-cart__badge', {
text: '',
} );
Expand Down
5 changes: 0 additions & 5 deletions tests/e2e/bin/posts/mini-cart.html

This file was deleted.

53 changes: 37 additions & 16 deletions tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
* External dependencies
*/
import { test, expect } from '@woocommerce/e2e-playwright-utils';
import { Page } from '@playwright/test';
import { FrontendUtils } from '@woocommerce/e2e-utils';

const openMiniCart = async ( page: Page ) => {
await page.getByLabel( 'items in cart,' ).hover();
await page.getByLabel( 'items in cart,' ).click();
const blockName = 'woocommerce/mini-cart';

const openMiniCart = async ( frontendUtils: FrontendUtils ) => {
const block = await frontendUtils.getBlockByName( blockName );
await block.click();
};

test.describe( `Mini Cart Block`, () => {
Expand All @@ -24,11 +26,29 @@ test.describe( `Mini Cart Block`, () => {
} );

test.beforeEach( async ( { page } ) => {
await page.goto( `/mini-cart-block`, { waitUntil: 'commit' } );
await page.goto( `/shop`, { waitUntil: 'commit' } );
} );

test( 'should open the empty cart drawer', async ( { page } ) => {
await openMiniCart( page );
test( 'should the Mini Cart block be present near the navigation block', async ( {
page,
frontendUtils,
} ) => {
const block = await frontendUtils.getBlockByName( blockName );

// The Mini Cart block should be present near the navigation block.
const navigationBlock = page.locator(
`//div[@data-block-name='${ blockName }']/preceding-sibling::nav[contains(@class, 'wp-block-navigation')]`
);

await expect( navigationBlock ).toBeVisible();
await expect( block ).toBeVisible();
} );

test( 'should open the empty cart drawer', async ( {
page,
frontendUtils,
} ) => {
await openMiniCart( frontendUtils );

await expect( page.getByRole( 'dialog' ) ).toContainText(
'Your cart is currently empty!'
Expand All @@ -37,8 +57,9 @@ test.describe( `Mini Cart Block`, () => {

test( 'should close the drawer when clicking on the close button', async ( {
page,
frontendUtils,
} ) => {
await openMiniCart( page );
await openMiniCart( frontendUtils );

await expect( page.getByRole( 'dialog' ) ).toContainText(
'Your cart is currently empty!'
Expand All @@ -51,26 +72,26 @@ test.describe( `Mini Cart Block`, () => {

test( 'should close the drawer when clicking outside the drawer', async ( {
page,
frontendUtils,
} ) => {
await openMiniCart( page );
await openMiniCart( frontendUtils );

await expect( page.getByRole( 'dialog' ) ).toContainText(
'Your cart is currently empty!'
);

await expect(
page.getByRole( 'button', { name: 'Close' } )
).toBeInViewport();

await page.mouse.click( 50, 200 );
await page.mouse.click( 0, 0 );

await expect( page.getByRole( 'dialog' ) ).toHaveCount( 0 );
} );

test( 'should open the filled cart drawer', async ( { page } ) => {
test( 'should open the filled cart drawer', async ( {
page,
frontendUtils,
} ) => {
await page.click( 'text=Add to cart' );

await openMiniCart( page );
await openMiniCart( frontendUtils );

await expect( page.getByRole( 'dialog' ) ).toContainText(
'Your cart (1 item)'
Expand Down
12 changes: 8 additions & 4 deletions tests/e2e/tests/product-collection/product-collection.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ class ProductCollectionPage {
* Private methods to be used by the class.
*/
private async refreshLocators( currentUI: 'editor' | 'frontend' ) {
await this.waitForProductsToLoad();
await this.waitForProductsToLoad( currentUI );

if ( currentUI === 'editor' ) {
await this.initializeLocatorsForEditor();
Expand Down Expand Up @@ -417,11 +417,15 @@ class ProductCollectionPage {
this.pagination = this.page.locator( SELECTORS.pagination.onFrontend );
}

private async waitForProductsToLoad() {
private async waitForProductsToLoad( currentUI: 'editor' | 'frontend' ) {
// Wait for the product blocks to be loaded.
await this.page.waitForSelector( SELECTORS.product );
// Wait for the loading spinner to be detached.
await this.page.waitForSelector( '.is-loading', { state: 'detached' } );
if ( currentUI === 'editor' ) {
// Wait for the loading spinner to be detached.
await this.page.waitForSelector( '.is-loading', {
state: 'detached',
} );
}
}
}

Expand Down
Loading