Cart i2: Render filled and empty Carts on frontend (https://github.com/woocommerce/woocommerce-blocks/pull/4802)

* WIP getting to work on frontend

* restore frontend.tsx

* fix layout

* remove unit tests living where they shouldn't be living

* remove skeleton

* support emtpy cart in frontend

* remove extra todo

* use fragment instead of div

* Add empty cart event

* Remove extra fragment
This commit is contained in:
Seghir Nadir 2021-09-23 16:38:30 +01:00 committed by GitHub
parent 27600b3309
commit b6167bc179
10 changed files with 237 additions and 2369 deletions

View File

@ -13,7 +13,7 @@ interface DispatchedEventProperties {
// See https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/detail
detail?: unknown;
// Element that dispatches the event. By default, the body.
element?: HTMLElement;
element?: Element | null;
}
/**

View File

@ -1,37 +1,37 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { dispatch } from '@wordpress/data';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { useEffect, RawHTML } from '@wordpress/element';
import { useEffect } from '@wordpress/element';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import { ValidationContextProvider } from '@woocommerce/base-context';
import {
dispatchEvent,
translateJQueryEventToNative,
} from '@woocommerce/base-utils';
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
import { translateJQueryEventToNative } from '@woocommerce/base-utils';
import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top';
import {
StoreNoticesProvider,
StoreSnackbarNoticesProvider,
CartProvider,
} from '@woocommerce/base-context/providers';
import { SlotFillProvider } from '@woocommerce/blocks-checkout';
/**
* Internal dependencies
*/
import FullCart from './full-cart';
const reloadPage = () => void window.location.reload( true );
const EmptyCart = ( { content } ) => {
useEffect( () => {
dispatchEvent( 'wc-blocks_render_blocks_frontend', {
element: document.body.querySelector(
'.wp-block-woocommerce-cart'
),
} );
}, [] );
return <RawHTML>{ content }</RawHTML>;
const Cart = ( { children } ) => {
const { cartIsLoading } = useStoreCart();
return (
<LoadingMask showSpinner={ true } isLoading={ cartIsLoading }>
<ValidationContextProvider>{ children }</ValidationContextProvider>
</LoadingMask>
);
};
const Block = ( { emptyCart, attributes, scrollToTop } ) => {
const { cartItems, cartIsLoading } = useStoreCart();
const ScrollOnError = ( { scrollToTop } ) => {
useEffect( () => {
const invalidateCartData = () => {
dispatch( storeKey ).invalidateResolutionForStore();
@ -72,19 +72,32 @@ const Block = ( { emptyCart, attributes, scrollToTop } ) => {
};
}, [ scrollToTop ] );
return (
<>
{ ! cartIsLoading && cartItems.length === 0 ? (
<EmptyCart content={ emptyCart } />
) : (
<LoadingMask showSpinner={ true } isLoading={ cartIsLoading }>
<ValidationContextProvider>
<FullCart attributes={ attributes } />
</ValidationContextProvider>
</LoadingMask>
) }
</>
);
return null;
};
const Block = ( { attributes, children, scrollToTop } ) => (
<BlockErrorBoundary
header={ __( 'Something went wrong…', 'woo-gutenberg-products-block' ) }
text={ __(
'The cart has encountered an unexpected error. If the error persists, please get in touch with us for help.',
'woo-gutenberg-products-block'
) }
button={
<button className="wc-block-button" onClick={ reloadPage }>
{ __( 'Reload the page', 'woo-gutenberg-products-block' ) }
</button>
}
showErrorMessage={ CURRENT_USER_IS_ADMIN }
>
<StoreSnackbarNoticesProvider context="wc/cart">
<StoreNoticesProvider context="wc/cart">
<SlotFillProvider>
<CartProvider>
<Cart attributes={ attributes }>{ children }</Cart>
<ScrollOnError scrollToTop={ scrollToTop } />
</CartProvider>
</SlotFillProvider>
</StoreNoticesProvider>
</StoreSnackbarNoticesProvider>
</BlockErrorBoundary>
);
export default withScrollToTop( Block );

View File

@ -5,25 +5,27 @@ import {
withStoreCartApiHydration,
withRestApiHydration,
} from '@woocommerce/block-hocs';
import { __ } from '@wordpress/i18n';
import {
StoreNoticesProvider,
StoreSnackbarNoticesProvider,
CartProvider,
} from '@woocommerce/base-context/providers';
import { SlotFillProvider } from '@woocommerce/blocks-checkout';
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
import {
renderFrontend,
getValidBlockAttributes,
} from '@woocommerce/base-utils';
import { getValidBlockAttributes } from '@woocommerce/base-utils';
import { Children, cloneElement, isValidElement } from '@wordpress/element';
import { useStoreCart } from '@woocommerce/base-context';
import { useValidation } from '@woocommerce/base-context/hooks';
import { getRegisteredBlockComponents } from '@woocommerce/blocks-registry';
import { renderParentBlock } from '@woocommerce/atomic-utils';
/**
* Internal dependencies
*/
import Block from './block.js';
import { blockAttributes } from './attributes';
import './inner-blocks/register-components';
import Block from './block';
import { blockName, blockAttributes } from './attributes';
const reloadPage = () => void window.location.reload( true );
/**
* Wrapper component to supply API data and show empty cart view as needed.
*
@ -45,30 +47,36 @@ const CartFrontend = ( props ) => {
const getProps = ( el ) => {
return {
emptyCart: el.innerHTML,
attributes: getValidBlockAttributes( blockAttributes, el.dataset ),
};
};
const getErrorBoundaryProps = () => {
return {
header: __( 'Something went wrong…', 'woo-gutenberg-products-block' ),
text: __(
'The cart has encountered an unexpected error. If the error persists, please get in touch with us for help.',
'woo-gutenberg-products-block'
),
showErrorMessage: CURRENT_USER_IS_ADMIN,
button: (
<button className="wc-block-button" onClick={ reloadPage }>
{ __( 'Reload the page', 'woo-gutenberg-products-block' ) }
</button>
attributes: getValidBlockAttributes(
blockAttributes,
!! el ? el.dataset : {}
),
};
};
renderFrontend( {
selector: '.wp-block-woocommerce-cart-i2',
const Wrapper = ( { children } ) => {
// we need to pluck out receiveCart.
// eslint-disable-next-line no-unused-vars
const { extensions, receiveCart, ...cart } = useStoreCart();
const validation = useValidation();
return Children.map( children, ( child ) => {
if ( isValidElement( child ) ) {
const componentProps = {
extensions,
cart,
validation,
};
return cloneElement( child, componentProps );
}
return child;
} );
};
renderParentBlock( {
Block: withStoreCartApiHydration( withRestApiHydration( CartFrontend ) ),
blockName,
selector: '.wp-block-woocommerce-cart-i2',
getProps,
getErrorBoundaryProps,
blockMap: getRegisteredBlockComponents( blockName ),
blockWrapper: Wrapper,
} );

View File

@ -0,0 +1,27 @@
/**
* External dependencies
*/
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { useEffect } from '@wordpress/element';
import { dispatchEvent } from '@woocommerce/base-utils';
const FrontendBlock = ( {
children,
}: {
children: JSX.Element;
} ): JSX.Element | null => {
const { cartItems, cartIsLoading } = useStoreCart();
useEffect( () => {
dispatchEvent( 'wc-blocks_render_blocks_frontend', {
element: document.body.querySelector(
'.wp-block-woocommerce-cart'
),
} );
}, [] );
if ( ! cartIsLoading && cartItems.length === 0 ) {
return <>{ children }</>;
}
return null;
};
export default FrontendBlock;

View File

@ -0,0 +1,30 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { SidebarLayout } from '@woocommerce/base-components/sidebar-layout';
import { useStoreCart } from '@woocommerce/base-context/hooks';
const FrontendBlock = ( {
children,
}: {
children: JSX.Element;
} ): JSX.Element | null => {
const { cartItems, cartIsLoading } = useStoreCart();
// @todo pass attributes to inner most blocks.
const hasDarkControls = false;
if ( cartIsLoading || cartItems.length >= 1 ) {
return (
<SidebarLayout
className={ classnames( 'wc-block-cart', {
'has-dark-controls': hasDarkControls,
} ) }
>
{ children }
</SidebarLayout>
);
}
return null;
};
export default FrontendBlock;

View File

@ -0,0 +1,92 @@
/**
* External dependencies
*/
import { lazy } from '@wordpress/element';
import { WC_BLOCKS_BUILD_URL } from '@woocommerce/block-settings';
import { registerCheckoutBlock } from '@woocommerce/blocks-checkout';
// Modify webpack publicPath at runtime based on location of WordPress Plugin.
// eslint-disable-next-line no-undef,camelcase
__webpack_public_path__ = WC_BLOCKS_BUILD_URL;
/**
* Internal dependencies
*/
import filledCartMetadata from './filled-cart-block/block.json';
import emptyCartMetadata from './empty-cart-block/block.json';
import cartItemsMetadata from './cart-items-block/block.json';
import cartExpressPaymentMetadata from './cart-express-payment-block/block.json';
import cartLineItemsMetadata from './cart-line-items-block/block.json';
import cartOrderSummaryMetadata from './cart-order-summary-block/block.json';
import cartTotalsMetadata from './cart-totals-block/block.json';
import cartProceedToCheckoutMetadata from './proceed-to-checkout-block/block.json';
registerCheckoutBlock( {
metadata: filledCartMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/filled-cart" */ './filled-cart-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: emptyCartMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/empty-cart" */ './empty-cart-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: cartItemsMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/items" */ './cart-items-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: cartLineItemsMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/line-items" */ './cart-line-items-block/block'
)
),
} );
registerCheckoutBlock( {
metadata: cartTotalsMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/totals" */ './cart-totals-block/frontend'
)
),
} );
registerCheckoutBlock( {
metadata: cartOrderSummaryMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/order-summary" */ './cart-order-summary-block/block'
)
),
} );
registerCheckoutBlock( {
metadata: cartExpressPaymentMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/express-payment" */ './cart-express-payment-block/block'
)
),
} );
registerCheckoutBlock( {
metadata: cartProceedToCheckoutMetadata,
component: lazy( () =>
import(
/* webpackChunkName: "cart-blocks/checkout-button" */ './proceed-to-checkout-block/frontend'
)
),
} );

View File

@ -1,165 +0,0 @@
/**
* External dependencies
*/
import { render, screen, waitFor } from '@testing-library/react';
import { previewCart } from '@woocommerce/resource-previews';
import { dispatch } from '@wordpress/data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { SlotFillProvider } from '@woocommerce/blocks-checkout';
import { default as fetchMock } from 'jest-fetch-mock';
/**
* Internal dependencies
*/
import Block from '../block';
import { defaultCartState } from '../../../../data/default-states';
import { allSettings } from '../../../../settings/shared/settings-init';
const CartBlock = ( props ) => (
<SlotFillProvider>
<Block { ...props } />
</SlotFillProvider>
);
describe( 'Testing cart', () => {
beforeEach( async () => {
fetchMock.mockResponse( ( req ) => {
if ( req.url.match( /wc\/store\/cart/ ) ) {
return Promise.resolve( JSON.stringify( previewCart ) );
}
return Promise.resolve( '' );
} );
// need to clear the store resolution state between tests.
await dispatch( storeKey ).invalidateResolutionForStore();
await dispatch( storeKey ).receiveCart( defaultCartState.cartData );
} );
afterEach( () => {
fetchMock.resetMocks();
} );
it( 'renders cart if there are items in the cart', async () => {
render(
<CartBlock
emptyCart={ null }
attributes={ {
isShippingCalculatorEnabled: false,
} }
/>
);
await waitFor( () => expect( fetchMock ).toHaveBeenCalled() );
expect(
screen.getByText( /Proceed to Checkout/i )
).toBeInTheDocument();
expect( fetchMock ).toHaveBeenCalledTimes( 1 );
// ["`select` control in `@wordpress/data-controls` is deprecated. Please use built-in `resolveSelect` control in `@wordpress/data` instead."]
expect( console ).toHaveWarned();
} );
it( 'Contains a Taxes section if Core options are set to show it', async () => {
allSettings.displayCartPricesIncludingTax = false;
// The criteria for showing the Taxes section is:
// Display prices during basket and checkout: 'Excluding tax'.
const { container } = render(
<CartBlock
emptyCart={ null }
attributes={ {
isShippingCalculatorEnabled: false,
} }
/>
);
await waitFor( () => expect( fetchMock ).toHaveBeenCalled() );
expect( container ).toMatchSnapshot();
} );
it( 'Shows individual tax lines if the store is set to do so', async () => {
allSettings.displayCartPricesIncludingTax = false;
allSettings.displayItemizedTaxes = true;
// The criteria for showing the lines in the Taxes section is:
// Display prices during basket and checkout: 'Excluding tax'.
// Display tax totals: 'Itemized';
const { container } = render(
<CartBlock
emptyCart={ null }
attributes={ {
isShippingCalculatorEnabled: false,
} }
/>
);
await waitFor( () => expect( fetchMock ).toHaveBeenCalled() );
expect( container ).toMatchSnapshot();
} );
it( 'Shows rate percentages after tax lines if the block is set to do so', async () => {
allSettings.displayCartPricesIncludingTax = false;
allSettings.displayItemizedTaxes = true;
// The criteria for showing the lines in the Taxes section is:
// Display prices during basket and checkout: 'Excluding tax'.
// Display tax totals: 'Itemized';
const { container } = render(
<CartBlock
emptyCart={ null }
attributes={ {
showRateAfterTaxName: true,
isShippingCalculatorEnabled: false,
} }
/>
);
await waitFor( () => expect( fetchMock ).toHaveBeenCalled() );
expect( container ).toMatchSnapshot();
} );
it( 'renders empty cart if there are no items in the cart', async () => {
fetchMock.mockResponse( ( req ) => {
if ( req.url.match( /wc\/store\/cart/ ) ) {
return Promise.resolve(
JSON.stringify( defaultCartState.cartData )
);
}
return Promise.resolve( '' );
} );
render(
<CartBlock
emptyCart={ '<div>Empty Cart</div>' }
attributes={ {
isShippingCalculatorEnabled: false,
} }
/>
);
await waitFor( () => expect( fetchMock ).toHaveBeenCalled() );
expect( screen.getByText( /Empty Cart/i ) ).toBeInTheDocument();
expect( fetchMock ).toHaveBeenCalledTimes( 1 );
} );
it( 'renders correct cart line subtotal when currency has 0 decimals', async () => {
fetchMock.mockResponse( ( req ) => {
if ( req.url.match( /wc\/store\/cart/ ) ) {
const cart = {
...previewCart,
// Make it so there is only one item to simplify things.
items: [
{
...previewCart.items[ 0 ],
totals: {
...previewCart.items[ 0 ].totals,
// Change price format so there are no decimals.
currency_minor_unit: 0,
currency_prefix: '',
currency_suffix: '€',
line_subtotal: '16',
line_total: '18',
},
},
],
};
return Promise.resolve( JSON.stringify( cart ) );
}
} );
render( <CartBlock emptyCart={ null } attributes={ {} } /> );
await waitFor( () => expect( fetchMock ).toHaveBeenCalled() );
expect( screen.getAllByRole( 'cell' )[ 1 ] ).toHaveTextContent( '16€' );
} );
} );

View File

@ -13,6 +13,7 @@ export enum innerBlockAreas {
BILLING_ADDRESS = 'woocommerce/checkout-billing-address-block',
SHIPPING_METHODS = 'woocommerce/checkout-shipping-methods-block',
PAYMENT_METHODS = 'woocommerce/checkout-payment-methods-block',
CART = 'woocommerce/cart-i2',
EMPTY_CART = 'woocommerce/empty-cart-block',
FILLED_CART = 'woocommerce/filled-cart-block',
CART_ITEMS = 'woocommerce/cart-items-block',

View File

@ -74,7 +74,7 @@ class CartI2 extends AbstractBlock {
wp_dequeue_script( 'selectWoo' );
wp_dequeue_style( 'select2' );
return $this->inject_html_data_attributes( $content . $this->get_skeleton(), $attributes );
return $this->inject_html_data_attributes( $content, $attributes );
}
/**
@ -162,85 +162,4 @@ class CartI2 extends AbstractBlock {
protected function hydrate_from_api() {
$this->asset_data_registry->hydrate_api_request( '/wc/store/cart' );
}
/**
* Render skeleton markup for the cart block.
*/
protected function get_skeleton() {
return '
<div class="wc-block-skeleton wc-block-components-sidebar-layout wc-block-cart wc-block-cart--is-loading wc-block-cart--skeleton hidden" aria-hidden="true">
<div class="wc-block-components-main wc-block-cart__main">
<h2 class="wc-block-components-title"><span></span></h2>
<table class="wc-block-cart-items">
<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>
<tr class="wc-block-cart-items__row">
<td class="wc-block-cart-item__image">
<a href=""><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" width="1" height="1" /></a>
</td>
<td class="wc-block-cart-item__product">
<div class="wc-block-components-product-name"></div>
<div class="wc-block-components-product-price"></div>
<div class="wc-block-components-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-components-product-price"></div>
</td>
</tr>
<tr class="wc-block-cart-items__row">
<td class="wc-block-cart-item__image">
<a href=""><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" width="1" height="1" /></a>
</td>
<td class="wc-block-cart-item__product">
<div class="wc-block-components-product-name"></div>
<div class="wc-block-components-product-price"></div>
<div class="wc-block-components-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-components-product-price"></div>
</td>
</tr>
<tr class="wc-block-cart-items__row">
<td class="wc-block-cart-item__image">
<a href=""><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" width="1" height="1" /></a>
</td>
<td class="wc-block-cart-item__product">
<div class="wc-block-components-product-name"></div>
<div class="wc-block-components-product-price"></div>
<div class="wc-block-components-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-components-product-price"></div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="wc-block-components-sidebar wc-block-cart__sidebar">
<div class="components-card"></div>
</div>
</div>
' . $this->get_skeleton_inline_script();
}
}