* Create MiniCart block prototype

* Use window.onload instead of DOMContentLoaded

* Don't load translations for scripts without localized strings

* Don't try to load cart instance in API requests

* Remove PHP pre-rendering

* Fix some typos

* Move Mini Cart block files under 'cart-checkout' directory

* Update selectors to follow guidelines

* Only enable the MiniCart block in experimental builds

* Fix wrong translations element selector

* Improve lazyLoadScript and preloadScript documentation

* Move lazy-load-script and preload-script to base-utils

* Add function to check if script tag is already in the DOM
This commit is contained in:
Albert Juhé Lluveras 2021-08-25 17:42:55 +02:00 committed by GitHub
parent ea55000792
commit 2b2631d7ab
13 changed files with 650 additions and 64 deletions

View File

@ -0,0 +1,82 @@
interface lazyLoadScriptParams {
handle: string;
src: string;
version?: string;
after?: string;
before?: string;
translations?: string;
}
/**
* In WP, registered scripts are loaded into the page with an element like this:
* `<script src='...' id='[SCRIPT_ID]'></script>`
* This function checks whether an element matching that selector exists.
* Useful to know if a script has already been appended to the page.
*/
const isScriptTagInDOM = ( scriptId: string ): boolean => {
const scriptElements = document.querySelectorAll( `script#${ scriptId }` );
return scriptElements.length > 0;
};
/**
* Appends a `<script>` tag to the document body based on the src and handle
* parameters. In addition, it appends additional script tags to load the code
* needed for translations and any before and after inline scripts. See these
* documentation pages for more information:
*
* https://developer.wordpress.org/reference/functions/wp_set_script_translations/
* https://developer.wordpress.org/reference/functions/wp_add_inline_script/
*/
const lazyLoadScript = ( {
handle,
src,
version,
after,
before,
translations,
}: lazyLoadScriptParams ): Promise< void > => {
return new Promise( ( resolve, reject ) => {
// Append script translations if they doesn't exist yet in the page.
if (
translations &&
! isScriptTagInDOM( `${ handle }-js-translations` )
) {
const handleTranslations = document.createElement( 'script' );
handleTranslations.innerHTML = translations;
handleTranslations.id = `${ handle }-js-translations`;
document.body.appendChild( handleTranslations );
}
// Append before inline script if it doesn't exist yet in the page.
if ( before && ! isScriptTagInDOM( `${ handle }-js-before` ) ) {
const handleBeforeScript = document.createElement( 'script' );
handleBeforeScript.innerHTML = before;
handleBeforeScript.id = `${ handle }-js-before`;
document.body.appendChild( handleBeforeScript );
}
if ( isScriptTagInDOM( `${ handle }-js` ) ) {
resolve();
} else {
// Append script.
const handleScript = document.createElement( 'script' );
handleScript.src = version ? `${ src }?${ version }` : src;
handleScript.id = `${ handle }-js`;
handleScript.onerror = reject;
handleScript.onload = () => {
// Append after inline script if it doesn't exist yet in the page.
if ( after && ! isScriptTagInDOM( `${ handle }-js-after` ) ) {
const handleAfterScript = document.createElement(
'script'
);
handleAfterScript.innerHTML = after;
handleAfterScript.id = `${ handle }-js-after`;
document.body.appendChild( handleAfterScript );
}
resolve();
};
document.body.appendChild( handleScript );
}
} );
};
export default lazyLoadScript;

View File

@ -0,0 +1,30 @@
interface preloadScriptParams {
handle: string;
src: string;
version?: string;
}
/**
* Appends a `<link>` tag to the document head to preload a script based on the
* src and handle parameters.
*/
const preloadScript = ( {
handle,
src,
version,
}: preloadScriptParams ): void => {
const handleScriptElements = document.querySelectorAll(
`#${ handle }-js, #${ handle }-js-prefetch`
);
if ( handleScriptElements.length === 0 ) {
const prefetchLink = document.createElement( 'link' );
prefetchLink.href = version ? `${ src }?${ version }` : src;
prefetchLink.rel = 'preload';
prefetchLink.as = 'script';
prefetchLink.id = `${ handle }-js-prefetch`;
document.head.appendChild( prefetchLink );
}
};
export default preloadScript;

View File

@ -36,8 +36,8 @@ const isElementInsideWrappers = ( el, wrappers ) => {
const renderBlockInContainers = ( {
Block,
containers,
getProps = () => {},
getErrorBoundaryProps = () => {},
getProps = () => ( {} ),
getErrorBoundaryProps = () => ( {} ),
} ) => {
if ( containers.length === 0 ) {
return;
@ -49,7 +49,7 @@ const renderBlockInContainers = ( {
const errorBoundaryProps = getErrorBoundaryProps( el, i );
const attributes = {
...el.dataset,
...props.attributes,
...( props.attributes || {} ),
};
el.classList.remove( 'is-loading' );

View File

@ -86,7 +86,8 @@ table.wc-block-cart-items {
}
// Loading placeholder state.
.wc-block-cart--is-loading {
.wc-block-cart--is-loading,
.wc-block-mini-cart-items--is-loading {
th span,
h2 span {
@include placeholder();
@ -97,47 +98,43 @@ table.wc-block-cart-items {
h2 span {
min-width: 33%;
}
.wc-block-cart-items {
.wc-block-cart-items__row {
.wc-block-cart-item__price,
.wc-block-cart-item__individual-price,
.wc-block-cart-item__product-metadata,
.wc-block-cart-item__image > *,
.wc-block-components-quantity-selector {
@include placeholder();
}
.wc-block-cart-item__product-name {
@include placeholder();
@include force-content();
min-width: 84px;
display: inline-block;
}
.wc-block-cart-item__product-metadata {
margin-top: 0.25em;
min-width: 8em;
}
.wc-block-cart-item__remove-link {
visibility: hidden;
}
.wc-block-cart-item__image a {
display: block;
}
.wc-block-cart-item__individual-price {
@include force-content();
max-width: 3em;
display: block;
margin-top: 0.25em;
}
.wc-block-cart-item__total {
> span,
> div {
display: none;
}
.wc-block-cart-item__price {
@include force-content();
display: block;
}
}
.wc-block-cart-item__price,
.wc-block-cart-item__individual-price,
.wc-block-cart-item__product-metadata,
.wc-block-cart-item__image > *,
.wc-block-components-quantity-selector {
@include placeholder();
}
.wc-block-cart-item__product-name {
@include placeholder();
@include force-content();
min-width: 84px;
display: inline-block;
}
.wc-block-cart-item__product-metadata {
margin-top: 0.25em;
min-width: 8em;
}
.wc-block-cart-item__remove-link {
visibility: hidden;
}
.wc-block-cart-item__image a {
display: block;
}
.wc-block-cart-item__individual-price {
@include force-content();
max-width: 3em;
display: block;
margin-top: 0.25em;
}
.wc-block-cart-item__total {
> span,
> div {
display: none;
}
.wc-block-cart-item__price {
@include force-content();
display: block;
}
}
.wc-block-cart__sidebar .components-card {

View File

@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { renderFrontend } from '@woocommerce/base-utils';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import {
withStoreCartApiHydration,
withRestApiHydration,
} from '@woocommerce/block-hocs';
/**
* Internal dependencies
*/
import CartLineItemsTable from '../cart/full-cart/cart-line-items-table';
const MiniCartContents = () => {
const { cartItems, cartIsLoading } = useStoreCart();
if ( cartItems.length === 0 ) {
return <>{ __( 'Cart is empty', 'woo-gutenberg-products-block' ) }</>;
}
return (
<CartLineItemsTable
lineItems={ cartItems }
isLoading={ cartIsLoading }
/>
);
};
renderFrontend( {
selector: '.wc-block-mini-cart__contents',
Block: withStoreCartApiHydration(
withRestApiHydration( MiniCartContents )
),
} );

View File

@ -0,0 +1,33 @@
/**
* External dependencies
*/
import { _n, sprintf } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import type { ReactElement } from 'react';
const MiniCartBlock = (): ReactElement => {
const blockProps = useBlockProps( {
className: 'wc-block-mini-cart',
} );
const productCount = 0;
return (
<div { ...blockProps }>
<button className="wc-block-mini-cart__button">
{ sprintf(
/* translators: %d is the number of products in the cart. */
_n(
'%d product',
'%d products',
productCount,
'woo-gutenberg-products-block'
),
productCount
) }
</button>
</div>
);
};
export default MiniCartBlock;

View File

@ -0,0 +1,75 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
import preloadScript from '@woocommerce/base-utils/preload-script';
import lazyLoadScript from '@woocommerce/base-utils/lazy-load-script';
interface dependencyData {
src: string;
version?: string;
after?: string;
before?: string;
translations?: string;
}
// eslint-disable-next-line @wordpress/no-global-event-listener
window.onload = () => {
const miniCartBlocks = document.querySelectorAll( '.wc-block-mini-cart' );
if ( miniCartBlocks.length === 0 ) {
return;
}
const dependencies = getSetting(
'mini_cart_block_frontend_dependencies',
{}
) as Record< string, dependencyData >;
// Preload scripts
for ( const dependencyHandle in dependencies ) {
const dependency = dependencies[ dependencyHandle ];
preloadScript( {
handle: dependencyHandle,
...dependency,
} );
}
miniCartBlocks.forEach( ( miniCartBlock ) => {
const miniCartButton = miniCartBlock.querySelector(
'.wc-block-mini-cart__button'
);
const miniCartContents = miniCartBlock.querySelector(
'.wc-block-mini-cart__contents'
);
if ( ! miniCartButton || ! miniCartContents ) {
// Markup is not correct, abort.
return;
}
const showContents = async () => {
miniCartContents.removeAttribute( 'hidden' );
// Load scripts
for ( const dependencyHandle in dependencies ) {
const dependency = dependencies[ dependencyHandle ];
await lazyLoadScript( {
handle: dependencyHandle,
...dependency,
} );
}
};
const hideContents = () =>
miniCartContents.setAttribute( 'hidden', 'true' );
miniCartButton.addEventListener( 'mouseover', showContents );
miniCartButton.addEventListener( 'mouseleave', hideContents );
miniCartContents.addEventListener( 'mouseover', showContents );
miniCartContents.addEventListener( 'mouseleave', hideContents );
miniCartButton.addEventListener( 'focus', showContents );
miniCartButton.addEventListener( 'blur', hideContents );
} );
};

View File

@ -0,0 +1,50 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Icon, cart } from '@woocommerce/icons';
import { registerFeaturePluginBlockType } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import edit from './edit';
const settings = {
apiVersion: 2,
title: __( 'Mini Cart', 'woo-gutenberg-products-block' ),
icon: {
src: <Icon srcElement={ cart } />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woo-gutenberg-products-block' ) ],
description: __(
'Display a mini cart widget.',
'woo-gutenberg-products-block'
),
supports: {
html: false,
multiple: false,
},
example: {
attributes: {
isPreview: true,
},
},
attributes: {
isPreview: {
type: 'boolean',
default: false,
save: false,
},
},
edit,
save() {
return null;
},
};
registerFeaturePluginBlockType( 'woocommerce/mini-cart', settings );

View File

@ -50,6 +50,9 @@ const blocks = {
customDir: 'cart-checkout/checkout-i2',
isExperimental: true,
},
'mini-cart': {
customDir: 'cart-checkout/mini-cart',
},
'single-product': {
isExperimental: true,
},
@ -127,6 +130,8 @@ const entries = {
frontend: {
reviews: './assets/js/blocks/reviews/frontend.js',
...getBlockEntries( 'frontend.{t,j}s{,x}' ),
'mini-cart-component':
'./assets/js/blocks/cart-checkout/mini-cart/component-frontend.tsx',
},
payments: {
'wc-payment-method-stripe':

View File

@ -61,23 +61,14 @@ class Api {
}
/**
* Registers a script according to `wp_register_script`, adding the correct prefix, and additionally loading translations.
* Get src, version and dependencies given a script relative src.
*
* When creating script assets, the following rules should be followed:
* 1. All asset handles should have a `wc-` prefix.
* 2. If the asset handle is for a Block (in editor context) use the `-block` suffix.
* 3. If the asset handle is for a Block (in frontend context) use the `-block-frontend` suffix.
* 4. If the asset is for any other script being consumed or enqueued by the blocks plugin, use the `wc-blocks-` prefix.
* @param string $relative_src Relative src to the script.
* @param array $dependencies Optional. An array of registered script handles this script depends on. Default empty array.
*
* @since 2.5.0
* @throws Exception If the registered script has a dependency on itself.
*
* @param string $handle Unique name of the script.
* @param string $relative_src Relative url for the script to the path from plugin root.
* @param array $dependencies Optional. An array of registered script handles this script depends on. Default empty array.
* @param bool $has_i18n Optional. Whether to add a script translation call to this file. Default: true.
* @return array src, version and dependencies of the script.
*/
public function register_script( $handle, $relative_src, $dependencies = [], $has_i18n = true ) {
public function get_script_data( $relative_src, $dependencies = [] ) {
$src = '';
$version = '1';
@ -96,9 +87,36 @@ class Api {
}
}
if ( in_array( $handle, $dependencies, true ) ) {
return array(
'src' => $src,
'version' => $version,
'dependencies' => $dependencies,
);
}
/**
* Registers a script according to `wp_register_script`, adding the correct prefix, and additionally loading translations.
*
* When creating script assets, the following rules should be followed:
* 1. All asset handles should have a `wc-` prefix.
* 2. If the asset handle is for a Block (in editor context) use the `-block` suffix.
* 3. If the asset handle is for a Block (in frontend context) use the `-block-frontend` suffix.
* 4. If the asset is for any other script being consumed or enqueued by the blocks plugin, use the `wc-blocks-` prefix.
*
* @since 2.5.0
* @throws Exception If the registered script has a dependency on itself.
*
* @param string $handle Unique name of the script.
* @param string $relative_src Relative url for the script to the path from plugin root.
* @param array $dependencies Optional. An array of registered script handles this script depends on. Default empty array.
* @param bool $has_i18n Optional. Whether to add a script translation call to this file. Default: true.
*/
public function register_script( $handle, $relative_src, $dependencies = [], $has_i18n = true ) {
$script_data = $this->get_script_data( $relative_src, $dependencies );
if ( in_array( $handle, $script_data['dependencies'], true ) ) {
if ( $this->package->feature()->is_development_environment() ) {
$dependencies = array_diff( $dependencies, [ $handle ] );
$dependencies = array_diff( $script_data['dependencies'], [ $handle ] );
add_action(
'admin_notices',
function() use ( $handle ) {
@ -113,7 +131,7 @@ class Api {
}
}
wp_register_script( $handle, $src, apply_filters( 'woocommerce_blocks_register_script_dependencies', $dependencies, $handle ), $version, true );
wp_register_script( $handle, $script_data['src'], apply_filters( 'woocommerce_blocks_register_script_dependencies', $script_data['dependencies'], $handle ), $script_data['version'], true );
if ( $has_i18n && function_exists( 'wp_set_script_translations' ) ) {
wp_set_script_translations( $handle, 'woo-gutenberg-products-block', $this->package->get_path( 'languages' ) );

View File

@ -121,23 +121,31 @@ abstract class AbstractBlock {
*/
protected function register_block_type_assets() {
if ( null !== $this->get_block_type_editor_script() ) {
$data = $this->asset_api->get_script_data( $this->get_block_type_editor_script( 'path' ) );
$has_i18n = in_array( 'wp-i18n', $data['dependencies'], true );
$this->asset_api->register_script(
$this->get_block_type_editor_script( 'handle' ),
$this->get_block_type_editor_script( 'path' ),
array_merge(
$this->get_block_type_editor_script( 'dependencies' ),
$this->integration_registry->get_all_registered_editor_script_handles()
)
),
$has_i18n
);
}
if ( null !== $this->get_block_type_script() ) {
$data = $this->asset_api->get_script_data( $this->get_block_type_script( 'path' ) );
$has_i18n = in_array( 'wp-i18n', $data['dependencies'], true );
$this->asset_api->register_script(
$this->get_block_type_script( 'handle' ),
$this->get_block_type_script( 'path' ),
array_merge(
$this->get_block_type_script( 'dependencies' ),
$this->integration_registry->get_all_registered_script_handles()
)
),
$has_i18n
);
}
}

View File

@ -0,0 +1,250 @@
<?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Assets;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\CartController;
/**
* Mini Cart class.
*
* @internal
*/
class MiniCart extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'mini-cart';
/**
* Array of scripts that will be lazy loaded when interacting with the block.
*
* @var string[]
*/
protected $scripts_to_lazy_load = array();
/**
* Get the editor script handle for this block type.
*
* @param string $key Data to get, or default to everything.
* @return array|string;
*/
protected function get_block_type_editor_script( $key = null ) {
$script = [
'handle' => 'wc-' . $this->block_name . '-block',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name ),
'dependencies' => [ 'wc-blocks' ],
];
return $key ? $script[ $key ] : $script;
}
/**
* Get the frontend script handle for this block type.
*
* @see $this->register_block_type()
* @param string $key Data to get, or default to everything.
* @return array|string
*/
protected function get_block_type_script( $key = null ) {
$script = [
'handle' => 'wc-' . $this->block_name . '-block-frontend',
'path' => $this->asset_api->get_block_asset_build_path( $this->block_name . '-frontend' ),
'dependencies' => [],
];
return $key ? $script[ $key ] : $script;
}
/**
* Extra data passed through from server to client for block.
*
* @param array $attributes Any attributes that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $attributes = [] ) {
parent::enqueue_data( $attributes );
// Hydrate the following data depending on admin or frontend context.
if ( ! is_admin() && ! WC()->is_rest_api_request() ) {
$this->hydrate_from_api();
}
$script_data = $this->asset_api->get_script_data( 'build/mini-cart-component-frontend.js' );
$num_dependencies = count( $script_data['dependencies'] );
$wp_scripts = wp_scripts();
for ( $i = 0; $i < $num_dependencies; $i++ ) {
$dependency = $script_data['dependencies'][ $i ];
foreach ( $wp_scripts->registered as $script ) {
if ( $script->handle === $dependency ) {
$this->append_script_and_deps_src( $script );
break;
}
}
}
$this->scripts_to_lazy_load['wc-block-mini-cart-component-frontend'] = array(
'src' => $script_data['src'],
'version' => $script_data['version'],
);
$this->asset_data_registry->add(
'mini_cart_block_frontend_dependencies',
$this->scripts_to_lazy_load,
true
);
do_action( 'woocommerce_blocks_cart_enqueue_data' );
}
/**
* Hydrate the cart block with data from the API.
*/
protected function hydrate_from_api() {
$this->asset_data_registry->hydrate_api_request( '/wc/store/cart' );
}
/**
* Returns the script data given its handle.
*
* @param string $handle Handle of the script.
*
* @return array Array containing the script data.
*/
protected function get_script_from_handle( $handle ) {
$wp_scripts = wp_scripts();
foreach ( $wp_scripts->registered as $script ) {
if ( $script->handle === $handle ) {
return $script;
}
}
return '';
}
/**
* Recursively appends a scripts and its dependencies into the
* scripts_to_lazy_load array.
*
* @param string $script Array containing script data.
*/
protected function append_script_and_deps_src( $script ) {
$wp_scripts = wp_scripts();
// This script and its dependencies have already been appended.
if ( array_key_exists( $script->handle, $this->scripts_to_lazy_load ) ) {
return;
}
if ( count( $script->deps ) > 0 ) {
foreach ( $script->deps as $dep ) {
if ( ! array_key_exists( $dep, $this->scripts_to_lazy_load ) ) {
$dep_script = $this->get_script_from_handle( $dep );
$this->append_script_and_deps_src( $dep_script );
}
}
}
$this->scripts_to_lazy_load[ $script->handle ] = array(
'src' => $script->src,
'version' => $script->ver,
'before' => $wp_scripts->print_inline_script( $script->handle, 'before', false ),
'after' => $wp_scripts->print_inline_script( $script->handle, 'after', false ),
'translations' => $wp_scripts->print_translations( $script->handle, false ),
);
}
/**
* Append frontend scripts when rendering the Mini Cart block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
*
* @return string Rendered block type output.
*/
protected function render( $attributes, $content ) {
return $this->inject_html_data_attributes( $content . $this->get_markup(), $attributes );
}
/**
* Render the markup for the Mini Cart block.
*
* @return string The HTML markup.
*/
protected function get_markup() {
if ( is_admin() || WC()->is_rest_api_request() ) {
// In the editor we will display the placeholder, so no need to load
// real cart data and to print the markup.
return '';
}
$cart_controller = new CartController();
$cart = $cart_controller->get_cart_instance();
$cart_contents_count = $cart->get_cart_contents_count();
$cart_contents = $cart->get_cart();
// Force mobile styles.
return '<div class="wc-block-mini-cart is-mobile">
<button class="wc-block-mini-cart__button">' .
sprintf(
/* translators: %d is the number of products in the cart. */
_n(
'%d product',
'%d products',
$cart_contents_count,
'woo-gutenberg-products-block'
),
$cart_contents_count
) . '</button>
<div class="wc-block-mini-cart__contents" hidden>' . $this->get_cart_contents_markup( $cart_contents ) . '</div>
</div>';
}
/**
* Render the markup of the Cart contents.
*
* @param array $cart_contents Array of contents in the cart.
*
* @return string The HTML markup.
*/
protected function get_cart_contents_markup( $cart_contents ) {
return '<table class="wc-block-cart-items wc-block-mini-cart-items--is-loading" aria-hidden="true">
<thead>
<tr class="wc-block-cart-items__header">
<th class="wc-block-cart-items__header-image"><span /></th>
<th class="wc-block-cart-items__header-product"><span /></th>
<th class="wc-block-cart-items__header-total"><span /></th>
</tr>
</thead>
<tbody>' . implode( array_map( array( $this, 'get_cart_item_markup' ), $cart_contents ) ) . '</tbody>
</table>';
}
/**
* Render the skeleton of a Cart item.
*
* @return string The skeleton HTML markup.
*/
protected function get_cart_item_markup() {
return '<tr class="wc-block-cart-items__row">
<td class="wc-block-cart-item__image">
<div><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" width="1" height="1" /></div>
</td>
<td class="wc-block-cart-item__product">
<div class="wc-block-cart-item__product-name"></div>
<div class="wc-block-cart-item__individual-price"></div>
<div class="wc-block-cart-item__product-metadata"></div>
<div class="wc-block-components-quantity-selector">
<input class="wc-block-components-quantity-selector__input" type="number" step="1" min="0" value="1" />
<button class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus"></button>
<button class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus"></button>
</div>
</td>
<td class="wc-block-cart-item__total">
<div class="wc-block-cart-item__price"></div>
</td>
</tr>';
}
}

View File

@ -128,6 +128,7 @@ final class BlockTypesController {
if ( Package::feature()->is_experimental_build() ) {
$block_types[] = 'SingleProduct';
$block_types[] = 'CheckoutI2';
$block_types[] = 'MiniCart';
}
/**