New contexts for `StoreNoticesContainer` and notice grouping (https://github.com/woocommerce/woocommerce-blocks/pull/7711)

* Refactor Store Notices

Move snackbar hiding filter before notice creation

Implements showApplyCouponNotice

Refactor context providers

Use STORE_NOTICE_CONTEXTS

use refs to track notice containers

Refactor ref usage

Use existing noticeContexts

* Move new notice code to checkout package

* Combine store and snackbars

* Update noticeContexts imports

* Remove context provider

* Update data store

* Fix 502

* Add new error contexts

* Force types

* Unnecessary reorder of imports

* Fix global handling

* Document forceType

* Optional props are undefined

* Remove function name

* Missing condition

* Remove context prop

* Define ACTION_TYPES

* Remove controls

* Update assets/js/base/context/event-emit/utils.ts

Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>

* CONTACT_INFORMATION

* Remove ref from registerContainer

* Abstract container locating methods

* pass context correctly when displaying notices

* Remove debugging buttons

* Update filter usage - remove useMemo so filter can work inline

* Refactor existing error notices from the API (https://github.com/woocommerce/woocommerce-blocks/pull/7728)

* Update API type defs

* Move create notice utils

* Replace useCheckoutNotices with new contexts

* processCheckoutResponseHeaders should check headers are defined

* Scroll to error notices only if we're not editing a field

* Error handling utils

* processErrorResponse when pushing changes

* processErrorResponse when processing checkout

* remove formatStoreApiErrorMessage

* Add todo for cart errors

* Remove unused deps

* unused imports

* Fix linting warnings

* Unused dep

* Update assets/js/types/type-defs/api-response.ts

Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>

* Add todo

* Use generic

* remove const

* Update array types

* Phone should be in address blocks

Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>

* Update store name to wc/store/store-notices

* Fix assertResponseIsValid

* Funnel woocommerce_rest_invalid_email_address to the correct place

* woocommerce_rest_missing_email_address

* Move comments around

* Move data back into const

* Spacing

* Remove spacing

* Remove forced snack bar and styling

* Move notices within wrapper

* Remove type

* hasStoreNoticesContainer rename

* Group by status/context

* Remove global context

* Remove white space

* remove changes to simplify diff

* white space

* Move comment to typescript

* List style

* showApplyCouponNotice docs

* See if scrollIntoView exists

* fix notice tests

Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>
This commit is contained in:
Mike Jolley 2022-12-19 15:30:13 +00:00 committed by GitHub
parent 5b095eb1a2
commit 9e00b015fc
55 changed files with 966 additions and 606 deletions

View File

@ -26,8 +26,15 @@ export enum responseTypes {
}
export enum noticeContexts {
PAYMENTS = 'wc/payment-area',
EXPRESS_PAYMENTS = 'wc/express-payment-area',
CART = 'wc/cart',
CHECKOUT = 'wc/checkout',
PAYMENTS = 'wc/checkout/payments',
EXPRESS_PAYMENTS = 'wc/checkout/express-payments',
CONTACT_INFORMATION = 'wc/checkout/contact-information',
SHIPPING_ADDRESS = 'wc/checkout/shipping-address',
BILLING_ADDRESS = 'wc/checkout/billing-address',
SHIPPING_METHODS = 'wc/checkout/shipping-methods',
CHECKOUT_ACTIONS = 'wc/checkout/checkout-actions',
}
export interface ResponseType extends Record< string, unknown > {

View File

@ -3,12 +3,10 @@
*/
import { __, sprintf } from '@wordpress/i18n';
import { useDispatch, useSelect } from '@wordpress/data';
import {
CART_STORE_KEY as storeKey,
VALIDATION_STORE_KEY,
} from '@woocommerce/block-data';
import { CART_STORE_KEY, VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import { decodeEntities } from '@wordpress/html-entities';
import type { StoreCartCoupon } from '@woocommerce/types';
import { __experimentalApplyCheckoutFilter } from '@woocommerce/blocks-checkout';
/**
* Internal dependencies
@ -19,9 +17,6 @@ import { useStoreCart } from './use-store-cart';
* This is a custom hook for loading the Store API /cart/coupons endpoint and an
* action for adding a coupon _to_ the cart.
* See also: https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/trunk/src/RestApi/StoreApi
*
* @return {StoreCartCoupon} An object exposing data and actions from/for the
* store api /cart/coupons endpoint.
*/
export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => {
const { cartCoupons, cartIsLoading } = useStoreCart();
@ -35,7 +30,7 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => {
}: Pick< StoreCartCoupon, 'isApplyingCoupon' | 'isRemovingCoupon' > =
useSelect(
( select ) => {
const store = select( storeKey );
const store = select( CART_STORE_KEY );
return {
isApplyingCoupon: store.isApplyingCoupon(),
@ -46,12 +41,19 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => {
);
const { applyCoupon, removeCoupon, receiveApplyingCoupon } =
useDispatch( storeKey );
useDispatch( CART_STORE_KEY );
const applyCouponWithNotices = ( couponCode: string ) => {
applyCoupon( couponCode )
.then( ( result ) => {
if ( result === true ) {
if (
result === true &&
__experimentalApplyCheckoutFilter( {
filterName: 'showApplyCouponNotice',
defaultValue: true,
arg: { couponCode, context },
} )
) {
createNotice(
'info',
sprintf(
@ -85,7 +87,14 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => {
const removeCouponWithNotices = ( couponCode: string ) => {
removeCoupon( couponCode )
.then( ( result ) => {
if ( result === true ) {
if (
result === true &&
__experimentalApplyCheckoutFilter( {
filterName: 'showRemoveCouponNotice',
defaultValue: true,
arg: { couponCode, context },
} )
) {
createNotice(
'info',
sprintf(

View File

@ -8,7 +8,6 @@ export * from './use-store-products';
export * from './use-store-add-to-cart';
export * from './use-customer-data';
export * from './use-checkout-address';
export * from './use-checkout-notices';
export * from './use-checkout-submit';
export * from './use-checkout-extension-data';
export * from './use-validation';

View File

@ -1,55 +0,0 @@
/**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { noticeContexts } from '../event-emit';
/**
* @typedef {import('@woocommerce/type-defs/contexts').StoreNoticeObject} StoreNoticeObject
* @typedef {import('@woocommerce/type-defs/hooks').CheckoutNotices} CheckoutNotices
*/
/**
* A hook that returns all notices visible in the Checkout block.
*
* @return {CheckoutNotices} Notices from the checkout form or payment methods.
*/
export const useCheckoutNotices = () => {
/**
* @type {StoreNoticeObject[]}
*/
const checkoutNotices = useSelect(
( select ) => select( 'core/notices' ).getNotices( 'wc/checkout' ),
[]
);
/**
* @type {StoreNoticeObject[]}
*/
const expressPaymentNotices = useSelect(
( select ) =>
select( 'core/notices' ).getNotices(
noticeContexts.EXPRESS_PAYMENTS
),
[ noticeContexts.EXPRESS_PAYMENTS ]
);
/**
* @type {StoreNoticeObject[]}
*/
const paymentNotices = useSelect(
( select ) =>
select( 'core/notices' ).getNotices( noticeContexts.PAYMENTS ),
[ noticeContexts.PAYMENTS ]
);
return {
checkoutNotices,
expressPaymentNotices,
paymentNotices,
};
};

View File

@ -24,9 +24,8 @@ import {
* Internal dependencies
*/
import { useEventEmitters, reducer as emitReducer } from './event-emit';
import type { emitterCallback } from '../../../event-emit';
import { emitterCallback, noticeContexts } from '../../../event-emit';
import { useStoreEvents } from '../../../hooks/use-store-events';
import { useCheckoutNotices } from '../../../hooks/use-checkout-notices';
import {
getExpressPaymentMethods,
getPaymentMethods,
@ -134,11 +133,29 @@ export const CheckoutEventsProvider = ( {
}
const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
const { createErrorNotice } = useDispatch( 'core/notices' );
const { dispatchCheckoutEvent } = useStoreEvents();
const { checkoutNotices, paymentNotices, expressPaymentNotices } =
useCheckoutNotices();
useSelect( ( select ) => {
const { getNotices } = select( 'core/notices' );
const checkoutContexts = Object.values( noticeContexts ).filter(
( context ) =>
context !== noticeContexts.PAYMENTS &&
context !== noticeContexts.EXPRESS_PAYMENTS
);
const allCheckoutNotices = checkoutContexts.reduce(
( acc, context ) => {
return [ ...acc, ...getNotices( context ) ];
},
[]
);
return {
checkoutNotices: allCheckoutNotices,
paymentNotices: getNotices( noticeContexts.PAYMENTS ),
expressPaymentNotices: getNotices(
noticeContexts.EXPRESS_PAYMENTS
),
};
}, [] );
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
const currentObservers = useRef( observers );
@ -186,7 +203,6 @@ export const CheckoutEventsProvider = ( {
}, [
isCheckoutBeforeProcessing,
setValidationErrors,
createErrorNotice,
__internalEmitValidateEvent,
] );
@ -224,7 +240,6 @@ export const CheckoutEventsProvider = ( {
isCheckoutBeforeProcessing,
previousStatus,
previousHasError,
createErrorNotice,
checkoutNotices,
expressPaymentNotices,
paymentNotices,

View File

@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { __ } from '@wordpress/i18n';
import triggerFetch from '@wordpress/api-fetch';
import {
useEffect,
@ -12,7 +12,7 @@ import {
} from '@wordpress/element';
import {
emptyHiddenAddressFields,
formatStoreApiErrorMessage,
removeAllNotices,
} from '@woocommerce/base-utils';
import { useDispatch, useSelect } from '@wordpress/data';
import {
@ -20,11 +20,18 @@ import {
PAYMENT_STORE_KEY,
VALIDATION_STORE_KEY,
CART_STORE_KEY,
processErrorResponse,
} from '@woocommerce/block-data';
import {
getPaymentMethods,
getExpressPaymentMethods,
} from '@woocommerce/blocks-registry';
import {
ApiResponse,
CheckoutResponseSuccess,
CheckoutResponseError,
assertResponseIsValid,
} from '@woocommerce/types';
/**
* Internal dependencies
@ -78,7 +85,6 @@ const CheckoutProcessor = () => {
);
const { cartNeedsPayment, cartNeedsShipping, receiveCart } = useStoreCart();
const { createErrorNotice, removeNotice } = useDispatch( 'core/notices' );
const {
activePaymentMethod,
@ -177,7 +183,7 @@ const CheckoutProcessor = () => {
// Validate the checkout using the CHECKOUT_VALIDATION_BEFORE_PROCESSING event
useEffect( () => {
let unsubscribeProcessing;
let unsubscribeProcessing: () => void;
if ( ! isExpressPaymentMethodActive ) {
unsubscribeProcessing = onCheckoutValidationBeforeProcessing(
checkValidation,
@ -185,7 +191,10 @@ const CheckoutProcessor = () => {
);
}
return () => {
if ( ! isExpressPaymentMethodActive ) {
if (
! isExpressPaymentMethodActive &&
typeof unsubscribeProcessing === 'function'
) {
unsubscribeProcessing();
}
};
@ -208,7 +217,7 @@ const CheckoutProcessor = () => {
return;
}
setIsProcessingOrder( true );
removeNotice( 'checkout' );
removeAllNotices();
const paymentData = cartNeedsPayment
? {
@ -222,6 +231,9 @@ const CheckoutProcessor = () => {
: {};
const data = {
shipping_address: cartNeedsShipping
? emptyHiddenAddressFields( currentShippingAddress.current )
: undefined,
billing_address: emptyHiddenAddressFields(
currentBillingAddress.current
),
@ -231,12 +243,6 @@ const CheckoutProcessor = () => {
extensions: { ...extensionData },
};
if ( cartNeedsShipping ) {
data.shipping_address = emptyHiddenAddressFields(
currentShippingAddress.current
);
}
triggerFetch( {
path: '/wc/store/v1/checkout',
method: 'POST',
@ -244,74 +250,49 @@ const CheckoutProcessor = () => {
cache: 'no-store',
parse: false,
} )
.then( ( response ) => {
.then( ( response: unknown ) => {
assertResponseIsValid< CheckoutResponseSuccess >( response );
processCheckoutResponseHeaders( response.headers );
if ( ! response.ok ) {
throw new Error( response );
throw response;
}
return response.json();
} )
.then( ( responseJson ) => {
.then( ( responseJson: CheckoutResponseSuccess ) => {
__internalProcessCheckoutResponse( responseJson );
setIsProcessingOrder( false );
} )
.catch( ( errorResponse ) => {
.catch( ( errorResponse: ApiResponse< CheckoutResponseError > ) => {
processCheckoutResponseHeaders( errorResponse?.headers );
try {
if ( errorResponse?.headers ) {
processCheckoutResponseHeaders( errorResponse.headers );
}
// This attempts to parse a JSON error response where the status code was 4xx/5xx.
errorResponse.json().then( ( response ) => {
// If updated cart state was returned, update the store.
if ( response.data?.cart ) {
receiveCart( response.data.cart );
}
createErrorNotice(
formatStoreApiErrorMessage( response ),
{
id: 'checkout',
context: 'wc/checkout',
__unstableHTML: true,
errorResponse
.json()
.then(
( response ) => response as CheckoutResponseError
)
.then( ( response: CheckoutResponseError ) => {
if ( response.data?.cart ) {
receiveCart( response.data.cart );
}
);
response?.additional_errors?.forEach?.(
( additionalError ) => {
createErrorNotice( additionalError.message, {
id: additionalError.error_code,
context: 'wc/checkout',
__unstableHTML: true,
} );
}
);
__internalProcessCheckoutResponse( response );
} );
processErrorResponse( response );
__internalProcessCheckoutResponse( response );
} );
} catch {
createErrorNotice(
sprintf(
// Translators: %s Error text.
__(
'%s Please try placing your order again.',
'woo-gutenberg-products-block'
),
errorResponse?.message ??
__(
'Something went wrong. Please contact us for assistance.',
'woo-gutenberg-products-block'
)
processErrorResponse( {
code: 'unknown_error',
message: __(
'Something went wrong. Please try placing your order again.',
'woo-gutenberg-products-block'
),
{
id: 'checkout',
context: 'wc/checkout',
__unstableHTML: true,
}
);
data: null,
} );
}
__internalSetHasError( true );
setIsProcessingOrder( false );
} );
}, [
isProcessingOrder,
removeNotice,
cartNeedsPayment,
paymentMethodId,
paymentMethodData,
@ -321,7 +302,6 @@ const CheckoutProcessor = () => {
shouldCreateAccount,
extensionData,
cartNeedsShipping,
createErrorNotice,
receiveCart,
__internalSetHasError,
__internalProcessCheckoutResponse,

View File

@ -19,7 +19,6 @@ import {
* Internal dependencies
*/
import { useEventEmitters, reducer as emitReducer } from './event-emit';
import { useCustomerData } from '../../../hooks/use-customer-data';
import { emitterCallback } from '../../../event-emit';
type PaymentEventsContextType = {
@ -73,7 +72,6 @@ export const PaymentEventsProvider = ( {
};
} );
const { createErrorNotice, removeNotice } = useDispatch( 'core/notices' );
const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
const { onPaymentProcessing } = useEventEmitters( observerDispatch );
@ -87,10 +85,8 @@ export const PaymentEventsProvider = ( {
const {
__internalSetPaymentProcessing,
__internalSetPaymentPristine,
__internalSetPaymentMethodData,
__internalEmitPaymentProcessingEvent,
} = useDispatch( PAYMENT_STORE_KEY );
const { setBillingAddress, setShippingAddress } = useCustomerData();
// flip payment to processing if checkout processing is complete, there are no errors, and payment status is started.
useEffect( () => {
@ -139,11 +135,6 @@ export const PaymentEventsProvider = ( {
}, [
isPaymentProcessing,
setValidationErrors,
removeNotice,
createErrorNotice,
setBillingAddress,
__internalSetPaymentMethodData,
setShippingAddress,
__internalEmitPaymentProcessingEvent,
] );

View File

@ -31,7 +31,12 @@ export const preparePaymentData = (
/**
* Process headers from an API response an dispatch updates.
*/
export const processCheckoutResponseHeaders = ( headers: Headers ): void => {
export const processCheckoutResponseHeaders = (
headers: Headers | undefined
): void => {
if ( ! headers ) {
return;
}
const { __internalSetCustomerId } = dispatch( CHECKOUT_STORE_KEY );
if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment

View File

@ -1,7 +1,6 @@
export * from './editor-context';
export * from './add-to-cart-form';
export * from './cart-checkout';
export * from './store-snackbar-notices';
export * from './container-width-context';
export * from './editor-context';
export * from './query-state-context';

View File

@ -1,85 +0,0 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { SnackbarList } from 'wordpress-components';
import classnames from 'classnames';
import { __experimentalApplyCheckoutFilter } from '@woocommerce/blocks-checkout';
import { useDispatch, useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { useEditorContext } from '../../editor-context';
const EMPTY_SNACKBAR_NOTICES = {};
export const SnackbarNoticesContainer = ( {
className,
context = 'default',
} ) => {
const { isEditor } = useEditorContext();
const { notices } = useSelect( ( select ) => {
const store = select( 'core/notices' );
return {
notices: store.getNotices( context ),
};
} );
const { removeNotice } = useDispatch( 'core/notices' );
if ( isEditor ) {
return null;
}
const snackbarNotices = notices.filter(
( notice ) => notice.type === 'snackbar'
);
const noticeVisibility =
snackbarNotices.length > 0
? snackbarNotices.reduce( ( acc, { content } ) => {
acc[ content ] = true;
return acc;
}, {} )
: EMPTY_SNACKBAR_NOTICES;
const filteredNotices = __experimentalApplyCheckoutFilter( {
filterName: 'snackbarNoticeVisibility',
defaultValue: noticeVisibility,
} );
const visibleNotices = snackbarNotices.filter(
( notice ) => filteredNotices[ notice.content ] === true
);
const wrapperClass = classnames(
className,
'wc-block-components-notices__snackbar'
);
return (
<SnackbarList
notices={ visibleNotices }
className={ wrapperClass }
onRemove={ () => {
visibleNotices.forEach( ( notice ) =>
removeNotice( notice.id, context )
);
} }
/>
);
};
SnackbarNoticesContainer.propTypes = {
className: PropTypes.string,
notices: PropTypes.arrayOf(
PropTypes.shape( {
content: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
isDismissible: PropTypes.bool,
type: PropTypes.oneOf( [ 'default', 'snackbar' ] ),
} )
),
};

View File

@ -1,20 +0,0 @@
.wc-block-components-notices__snackbar {
position: fixed;
bottom: 20px;
left: 16px;
width: auto;
@include breakpoint("<782px") {
position: fixed;
top: 10px;
left: 0;
bottom: auto;
}
.components-snackbar-list__notice-container {
@include breakpoint("<782px") {
margin-left: 10px;
margin-right: 10px;
}
}
}

View File

@ -1 +0,0 @@
export * from './components/snackbar-notices-container';

View File

@ -0,0 +1,94 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import type { Options as NoticeOptions } from '@wordpress/notices';
import { select, dispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import { noticeContexts } from '../context/event-emit/utils';
export const DEFAULT_ERROR_MESSAGE = __(
'Something went wrong. Please contact us to get assistance.',
'woo-gutenberg-products-block'
);
export const hasStoreNoticesContainer = ( container: string ): boolean => {
const containers = select( 'wc/store/store-notices' ).getContainers();
return containers.includes( container );
};
const findParentContainer = ( container: string ): string => {
if ( container.includes( noticeContexts.CHECKOUT + '/' ) ) {
return noticeContexts.CHECKOUT;
}
if ( container.includes( noticeContexts.CART + '/' ) ) {
return hasStoreNoticesContainer( noticeContexts.CART )
? noticeContexts.CART
: noticeContexts.CHECKOUT;
}
return container;
};
/**
* Wrapper for @wordpress/notices createNotice.
*
* This is used to create the correct type of notice based on the provided context, and to ensure the notice container
* exists first, otherwise it uses the default context instead.
*/
export const createNotice = (
status: 'error' | 'warning' | 'info' | 'success',
message: string,
options: Partial< NoticeOptions >
) => {
const noticeContext = options?.context;
const suppressNotices =
select( 'wc/store/payment' ).isExpressPaymentMethodActive();
if ( suppressNotices || noticeContext === undefined ) {
return;
}
const { createNotice: dispatchCreateNotice } = dispatch( 'core/notices' );
dispatchCreateNotice( status, message, {
isDismissible: true,
...options,
context: hasStoreNoticesContainer( noticeContext )
? noticeContext
: findParentContainer( noticeContext ),
} );
};
/**
* Creates a notice only if the Store Notice Container is visible.
*/
export const createNoticeIfVisible = (
status: 'error' | 'warning' | 'info' | 'success',
message: string,
options: Partial< NoticeOptions >
) => {
if ( options?.context && hasStoreNoticesContainer( options.context ) ) {
createNotice( status, message, options );
}
};
/**
* Remove notices from all contexts.
*
* @todo Remove this when supported in Gutenberg.
* @see https://github.com/WordPress/gutenberg/pull/44059
*/
export const removeAllNotices = () => {
const containers = select( 'wc/store/store-notices' ).getContainers();
const { removeNotice } = dispatch( 'core/notices' );
const { getNotices } = select( 'core/notices' );
containers.forEach( ( container ) => {
getNotices( container ).forEach( ( notice ) => {
removeNotice( notice.id, container );
} );
} );
};

View File

@ -1,9 +1,3 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Given a JS error or a fetch response error, parse and format it, so it can be displayed to the user.
*
@ -34,27 +28,3 @@ export const formatError = async ( error ) => {
type: error.type || 'general',
};
};
/**
* Given an API response object, formats the error message into something more human-readable.
*
* @param {Object} response Response object.
* @return {string} Error message.
*/
export const formatStoreApiErrorMessage = ( response ) => {
if ( response.data && response.code === 'rest_invalid_param' ) {
const invalidParams = Object.values( response.data.params );
if ( invalidParams[ 0 ] ) {
return invalidParams[ 0 ];
}
}
if ( ! response?.message ) {
return __(
'Something went wrong. Please contact us to get assistance.',
'woo-gutenberg-products-block'
);
}
return decodeEntities( response.message );
};

View File

@ -8,3 +8,4 @@ export * from './product-data';
export * from './derive-selected-shipping-rates';
export * from './get-icons-from-payment-methods';
export * from './parse-style';
export * from './create-notice';

View File

@ -6,7 +6,7 @@ import { Component } from 'react';
import PropTypes from 'prop-types';
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
import { StoreNoticesContainer } from '@woocommerce/blocks-checkout';
import { noticeContexts } from '@woocommerce/base-context/hooks';
import { noticeContexts } from '@woocommerce/base-context';
class PaymentMethodErrorBoundary extends Component {
state = { errorMessage: '', hasError: false };

View File

@ -5,12 +5,11 @@ import { __ } from '@wordpress/i18n';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { useEffect } from '@wordpress/element';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import { SnackbarNoticesContainer } from '@woocommerce/base-context';
import { CartProvider, noticeContexts } from '@woocommerce/base-context';
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 { CartProvider } from '@woocommerce/base-context/providers';
import {
SlotFillProvider,
StoreNoticesContainer,
@ -83,8 +82,7 @@ const Block = ( { attributes, children, scrollToTop } ) => (
}
showErrorMessage={ CURRENT_USER_IS_ADMIN }
>
<SnackbarNoticesContainer context="wc/cart" />
<StoreNoticesContainer context="wc/cart" />
<StoreNoticesContainer context={ noticeContexts.CART } />
<SlotFillProvider>
<CartProvider>
<Cart attributes={ attributes }>{ children }</Cart>

View File

@ -5,11 +5,7 @@ import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import { createInterpolateElement, useEffect } from '@wordpress/element';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import {
CheckoutProvider,
SnackbarNoticesContainer,
} from '@woocommerce/base-context';
import { CheckoutProvider, noticeContexts } from '@woocommerce/base-context';
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
import { SidebarLayout } from '@woocommerce/base-components/sidebar-layout';
import { CURRENT_USER_IS_ADMIN, getSetting } from '@woocommerce/settings';
@ -33,7 +29,6 @@ import CheckoutOrderError from './checkout-order-error';
import { LOGIN_TO_CHECKOUT_URL, isLoginRequired, reloadPage } from './utils';
import type { Attributes } from './types';
import { CheckoutBlockContext } from './context';
import { hasNoticesOfType } from '../../utils/notices';
const MustLoginPrompt = () => {
return (
@ -136,9 +131,7 @@ const ScrollOnError = ( {
const { showAllValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
const hasErrorsToDisplay =
checkoutIsIdle &&
checkoutHasError &&
( hasValidationErrors || hasNoticesOfType( 'wc/checkout', 'default' ) );
checkoutIsIdle && checkoutHasError && hasValidationErrors;
useEffect( () => {
let scrollToTopTimeout: number;
@ -190,8 +183,7 @@ const Block = ( {
) }
showErrorMessage={ CURRENT_USER_IS_ADMIN }
>
<SnackbarNoticesContainer context="wc/checkout" />
<StoreNoticesContainer context="wc/checkout" />
<StoreNoticesContainer context={ noticeContexts.CHECKOUT } />
{ /* SlotFillProvider need to be defined before CheckoutProvider so fills have the SlotFill context ready when they mount. */ }
<SlotFillProvider>
<CheckoutProvider>

View File

@ -8,7 +8,11 @@ import {
ReturnToCartButton,
} from '@woocommerce/base-components/cart-checkout';
import { useCheckoutSubmit } from '@woocommerce/base-context/hooks';
import { __experimentalApplyCheckoutFilter } from '@woocommerce/blocks-checkout';
import { noticeContexts } from '@woocommerce/base-context';
import {
StoreNoticesContainer,
__experimentalApplyCheckoutFilter,
} from '@woocommerce/blocks-checkout';
/**
* Internal dependencies
@ -40,12 +44,17 @@ const Block = ( {
<div
className={ classnames( 'wc-block-checkout__actions', className ) }
>
{ showReturnToCart && (
<ReturnToCartButton
link={ getSetting( 'page-' + cartPageId, false ) }
/>
) }
<PlaceOrderButton label={ label } />
<StoreNoticesContainer
context={ noticeContexts.CHECKOUT_ACTIONS }
/>
<div className="wc-block-checkout__actions_row">
{ showReturnToCart && (
<ReturnToCartButton
link={ getSetting( 'page-' + cartPageId, false ) }
/>
) }
<PlaceOrderButton label={ label } />
</div>
</div>
);
};

View File

@ -1,19 +1,21 @@
.wc-block-checkout__actions {
display: flex;
justify-content: space-between;
align-items: center;
&_row {
display: flex;
justify-content: space-between;
align-items: center;
.wc-block-components-checkout-place-order-button {
width: 50%;
padding: 1em;
height: auto;
.wc-block-components-checkout-place-order-button {
width: 50%;
padding: 1em;
height: auto;
.wc-block-components-button__text {
line-height: 24px;
.wc-block-components-button__text {
line-height: 24px;
> svg {
fill: $white;
vertical-align: top;
> svg {
fill: $white;
vertical-align: top;
}
}
}
}

View File

@ -6,6 +6,7 @@ import {
useCheckoutAddress,
useStoreEvents,
useEditorContext,
noticeContexts,
} from '@woocommerce/base-context';
import { AddressForm } from '@woocommerce/base-components/cart-checkout';
import Noninteractive from '@woocommerce/base-components/noninteractive';
@ -14,6 +15,7 @@ import type {
AddressField,
AddressFields,
} from '@woocommerce/settings';
import { StoreNoticesContainer } from '@woocommerce/blocks-checkout';
/**
* Internal dependencies
@ -39,7 +41,6 @@ const Block = ( {
setBillingAddress,
setShippingAddress,
setBillingPhone,
setShippingPhone,
forcedBillingAddress,
} = useCheckoutAddress();
const { dispatchCheckoutEvent } = useStoreEvents();
@ -89,6 +90,7 @@ const Block = ( {
return (
<AddressFormWrapperComponent>
<StoreNoticesContainer context={ noticeContexts.BILLING_ADDRESS } />
<AddressForm
id="billing"
type="billing"
@ -117,12 +119,6 @@ const Block = ( {
dispatchCheckoutEvent( 'set-phone-number', {
step: 'billing',
} );
if ( forcedBillingAddress ) {
setShippingPhone( value );
dispatchCheckoutEvent( 'set-phone-number', {
step: 'shipping',
} );
}
} }
/>
) }

View File

@ -2,11 +2,16 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useCheckoutAddress, useStoreEvents } from '@woocommerce/base-context';
import {
useCheckoutAddress,
useStoreEvents,
noticeContexts,
} from '@woocommerce/base-context';
import { getSetting } from '@woocommerce/settings';
import {
CheckboxControl,
ValidatedTextInput,
StoreNoticesContainer,
} from '@woocommerce/blocks-checkout';
import { useDispatch, useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
@ -53,6 +58,9 @@ const Block = (): JSX.Element => {
return (
<>
<StoreNoticesContainer
context={ noticeContexts.CONTACT_INFORMATION }
/>
<ValidatedTextInput
id="email"
type="email"

View File

@ -8,13 +8,13 @@ import { FormStep } from '@woocommerce/base-components/cart-checkout';
import { useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
import { StoreNoticesContainer } from '@woocommerce/blocks-checkout';
import { noticeContexts } from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import Block from './block';
import attributes from './attributes';
import { noticeContexts } from '../../../../base/context/event-emit';
const FrontendBlock = ( {
title,

View File

@ -8,8 +8,12 @@ import {
useCheckoutAddress,
useStoreEvents,
useEditorContext,
noticeContexts,
} from '@woocommerce/base-context';
import { CheckboxControl } from '@woocommerce/blocks-checkout';
import {
CheckboxControl,
StoreNoticesContainer,
} from '@woocommerce/blocks-checkout';
import Noninteractive from '@woocommerce/base-components/noninteractive';
import type {
BillingAddress,
@ -97,6 +101,9 @@ const Block = ( {
return (
<>
<AddressFormWrapperComponent>
<StoreNoticesContainer
context={ noticeContexts.SHIPPING_ADDRESS }
/>
<AddressForm
id="shipping"
type="shipping"

View File

@ -7,7 +7,8 @@ import { ShippingRatesControl } from '@woocommerce/base-components/cart-checkout
import { getShippingRatesPackageCount } from '@woocommerce/base-utils';
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
import { useEditorContext } from '@woocommerce/base-context';
import { useEditorContext, noticeContexts } from '@woocommerce/base-context';
import { StoreNoticesContainer } from '@woocommerce/blocks-checkout';
import { decodeEntities } from '@wordpress/html-entities';
import { Notice } from 'wordpress-components';
import classnames from 'classnames';
@ -80,6 +81,9 @@ const Block = (): JSX.Element | null => {
return (
<>
<StoreNoticesContainer
context={ noticeContexts.SHIPPING_METHODS }
/>
{ isEditor && ! shippingRatesPackageCount ? (
<NoShippingPlaceholder />
) : (

View File

@ -7,6 +7,7 @@ import type {
CartResponseItem,
ExtensionCartUpdateArgs,
BillingAddressShippingAddress,
ApiErrorResponse,
} from '@woocommerce/types';
import { camelCase, mapKeys } from 'lodash';
import { BillingAddress, ShippingAddress } from '@woocommerce/settings';
@ -20,7 +21,6 @@ import {
*/
import { ACTION_TYPES as types } from './action-types';
import { apiFetchWithHeaders } from '../shared-controls';
import type { ResponseError } from '../types';
import { ReturnOrGeneratorYieldUnion } from '../mapped-types';
import { CartDispatchFromMap, CartResolveSelectFromMap } from './index';
@ -64,14 +64,9 @@ export const receiveCartContents = (
/**
* Returns an action object used for receiving customer facing errors from the API.
*
* @param {ResponseError|null} [error=null] An error object containing the error
* message and response code.
* @param {boolean} [replace=true] Should existing errors be replaced,
* or should the error be appended.
*/
export const receiveError = (
error: ResponseError | null = null,
error: ApiErrorResponse | null = null,
replace = true
) =>
( {

View File

@ -1,7 +1,7 @@
/**
* External dependencies
*/
import type { Cart, CartMeta } from '@woocommerce/types';
import type { Cart, CartMeta, ApiErrorResponse } from '@woocommerce/types';
/**
* Internal dependencies
@ -18,17 +18,16 @@ import {
EMPTY_PAYMENT_REQUIREMENTS,
EMPTY_EXTENSIONS,
} from '../constants';
import type { ResponseError } from '../types';
const EMPTY_PENDING_QUANTITY: [] = [];
const EMPTY_PENDING_DELETE: [] = [];
export interface CartState {
cartItemsPendingQuantity: Array< string >;
cartItemsPendingDelete: Array< string >;
cartItemsPendingQuantity: string[];
cartItemsPendingDelete: string[];
cartData: Cart;
metaData: CartMeta;
errors: Array< ResponseError >;
errors: ApiErrorResponse[];
}
export const defaultCartState: CartState = {
cartItemsPendingQuantity: EMPTY_PENDING_QUANTITY,

View File

@ -4,9 +4,9 @@
import { debounce } from 'lodash';
import { select, dispatch } from '@wordpress/data';
import {
formatStoreApiErrorMessage,
pluckAddress,
pluckEmail,
removeAllNotices,
} from '@woocommerce/base-utils';
import {
CartResponseBillingAddress,
@ -20,6 +20,7 @@ import { BillingAddressShippingAddress } from '@woocommerce/type-defs/cart';
*/
import { STORE_KEY } from './constants';
import { VALIDATION_STORE_KEY } from '../validation';
import { processErrorResponse } from '../utils';
declare type CustomerData = {
billingAddress: CartResponseBillingAddress;
@ -103,20 +104,10 @@ const updateCustomerData = debounce( (): void => {
dispatch( STORE_KEY )
.updateCustomerData( customerDataToUpdate )
.then( () => {
dispatch( 'core/notices' ).removeNotice(
'checkout',
'wc/checkout'
);
removeAllNotices();
} )
.catch( ( response ) => {
dispatch( 'core/notices' ).createNotice(
'error',
formatStoreApiErrorMessage( response ),
{
id: 'checkout',
context: 'wc/checkout',
}
);
processErrorResponse( response );
} );
}
}, 1000 );

View File

@ -7,6 +7,7 @@ import type {
CartMeta,
CartItem,
CartShippingRate,
ApiErrorResponse,
} from '@woocommerce/types';
import { BillingAddress, ShippingAddress } from '@woocommerce/settings';
@ -14,7 +15,6 @@ import { BillingAddress, ShippingAddress } from '@woocommerce/settings';
* Internal dependencies
*/
import { CartState, defaultCartState } from './default-state';
import type { ResponseError } from '../types';
/**
* Retrieves cart data from state.
@ -90,11 +90,8 @@ export const getCartMeta = ( state: CartState ): CartMeta => {
/**
* Retrieves cart errors from state.
*
* @param {CartState} state The current state.
* @return {Array<ResponseError>} Array of errors.
*/
export const getCartErrors = ( state: CartState ): Array< ResponseError > => {
export const getCartErrors = ( state: CartState ): ApiErrorResponse[] => {
return state.errors;
};

View File

@ -13,5 +13,7 @@ export { CHECKOUT_STORE_KEY } from './checkout';
export { PAYMENT_STORE_KEY } from './payment';
export { VALIDATION_STORE_KEY } from './validation';
export { QUERY_STATE_STORE_KEY } from './query-state';
export { STORE_NOTICES_STORE_KEY } from './store-notices';
export * from './constants';
export * from './types';
export * from './utils';

View File

@ -4,15 +4,11 @@
import { __ } from '@wordpress/i18n';
import triggerFetch, { APIFetchOptions } from '@wordpress/api-fetch';
import DataLoader from 'dataloader';
/**
* Internal dependencies
*/
import {
ApiResponse,
assertBatchResponseIsValid,
assertResponseIsValid,
ApiResponse,
} from './types';
} from '@woocommerce/types';
const EMPTY_OBJECT = {};

View File

@ -0,0 +1,4 @@
export enum ACTION_TYPES {
REGISTER_CONTAINER = 'REGISTER_CONTAINER',
UNREGISTER_CONTAINER = 'UNREGISTER_CONTAINER',
}

View File

@ -0,0 +1,18 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES } from './action-types';
export const registerContainer = ( containerContext: string ) => {
return {
type: ACTION_TYPES.REGISTER_CONTAINER,
containerContext,
};
};
export const unregisterContainer = ( containerContext: string ) => {
return {
type: ACTION_TYPES.UNREGISTER_CONTAINER,
containerContext,
};
};

View File

@ -0,0 +1,7 @@
export interface StoreNoticesState {
containers: string[];
}
export const defaultStoreNoticesState: StoreNoticesState = {
containers: [],
};

View File

@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { createReduxStore, register } from '@wordpress/data';
/**
* Internal dependencies
*/
import * as actions from './actions';
import * as selectors from './selectors';
import reducer from './reducers';
import { DispatchFromMap, SelectFromMap } from '../mapped-types';
const STORE_KEY = 'wc/store/store-notices';
const config = {
reducer,
actions,
selectors,
};
const store = createReduxStore( STORE_KEY, config );
register( store );
export const STORE_NOTICES_STORE_KEY = STORE_KEY;
declare module '@wordpress/data' {
function dispatch(
key: typeof STORE_KEY
): DispatchFromMap< typeof actions >;
function select( key: typeof STORE_KEY ): SelectFromMap<
typeof selectors
> & {
hasFinishedResolution: ( selector: string ) => boolean;
};
}

View File

@ -0,0 +1,34 @@
/**
* External dependencies
*/
import type { Reducer } from 'redux';
/**
* Internal dependencies
*/
import { defaultStoreNoticesState, StoreNoticesState } from './default-state';
import { ACTION_TYPES } from './action-types';
const reducer: Reducer< StoreNoticesState > = (
state = defaultStoreNoticesState,
action
) => {
switch ( action.type ) {
case ACTION_TYPES.REGISTER_CONTAINER:
return {
...state,
containers: [ ...state.containers, action.containerContext ],
};
case ACTION_TYPES.UNREGISTER_CONTAINER:
const newContainers = state.containers.filter(
( container ) => container !== action.containerContext
);
return {
...state,
containers: newContainers,
};
}
return state;
};
export default reducer;

View File

@ -0,0 +1,8 @@
/**
* Internal dependencies
*/
import { StoreNoticesState } from './default-state';
export const getContainers = (
state: StoreNoticesState
): StoreNoticesState[ 'containers' ] => state.containers;

View File

@ -1,49 +0,0 @@
export interface ResponseError {
code: string;
message: string;
data: {
status: number;
[ key: string ]: unknown;
};
}
export interface ApiResponse {
body: Record< string, unknown >;
headers: Headers;
status: number;
}
export function assertBatchResponseIsValid(
response: unknown
): asserts response is {
responses: ApiResponse[];
headers: Headers;
} {
if (
typeof response === 'object' &&
response !== null &&
response.hasOwnProperty( 'responses' )
) {
return;
}
throw new Error( 'Response not valid' );
}
export function assertResponseIsValid(
response: unknown
): asserts response is ApiResponse {
if (
typeof response === 'object' &&
response !== null &&
response.hasOwnProperty( 'body' ) &&
response.hasOwnProperty( 'headers' )
) {
return;
}
throw new Error( 'Response not valid' );
}
export interface FieldValidationStatus {
message: string;
hidden: boolean;
}

View File

@ -1,2 +1,3 @@
export { default as hasInState } from './has-in-state';
export { default as updateState } from './update-state';
export { default as processErrorResponse } from './process-error-response';

View File

@ -0,0 +1,148 @@
/**
* External dependencies
*/
import {
createNotice,
createNoticeIfVisible,
DEFAULT_ERROR_MESSAGE,
} from '@woocommerce/base-utils';
import { decodeEntities } from '@wordpress/html-entities';
import { isObject, objectHasProp, ApiErrorResponse } from '@woocommerce/types';
import { noticeContexts } from '@woocommerce/base-context/event-emit/utils';
type ApiParamError = {
param: string;
id: string;
code: string;
message: string;
};
const isApiResponse = ( response: unknown ): response is ApiErrorResponse => {
return (
isObject( response ) &&
objectHasProp( response, 'code' ) &&
objectHasProp( response, 'message' ) &&
objectHasProp( response, 'data' )
);
};
/**
* Flattens error details which are returned from the API when multiple params are not valid.
*
* - Codes will be prefixed with the param. For example, `invalid_email` becomes `billing_address_invalid_email`.
* - Additional error messages will be flattened alongside the main error message.
* - Supports 1 level of nesting.
* - Decodes HTML entities in error messages.
*/
const getErrorDetails = ( response: ApiErrorResponse ): ApiParamError[] => {
const errorDetails = objectHasProp( response.data, 'details' )
? Object.entries( response.data.details )
: null;
if ( ! errorDetails ) {
return [];
}
return errorDetails.reduce(
(
acc,
[
param,
{ code, message, additional_errors: additionalErrors = [] },
]
) => {
return [
...acc,
{
param,
id: `${ param }_${ code }`,
code,
message: decodeEntities( message ),
},
...( Array.isArray( additionalErrors )
? additionalErrors.flatMap( ( additionalError ) => {
if (
! objectHasProp( additionalError, 'code' ) ||
! objectHasProp( additionalError, 'message' )
) {
return [];
}
return [
{
param,
id: `${ param }_${ additionalError.code }`,
code: additionalError.code,
message: decodeEntities(
additionalError.message
),
},
];
} )
: [] ),
];
},
[] as ApiParamError[]
);
};
/**
* Processes the response for an invalid param error, with response code rest_invalid_param.
*/
const processInvalidParamResponse = ( response: ApiErrorResponse ) => {
const errorDetails = getErrorDetails( response );
errorDetails.forEach( ( { code, message, id, param } ) => {
switch ( code ) {
case 'invalid_email':
createNotice( 'error', message, {
id,
context: noticeContexts.CONTACT_INFORMATION,
} );
return;
}
switch ( param ) {
case 'billing_address':
createNoticeIfVisible( 'error', message, {
id,
context: noticeContexts.BILLING_ADDRESS,
} );
break;
case 'shipping_address':
createNoticeIfVisible( 'error', message, {
id,
context: noticeContexts.SHIPPING_ADDRESS,
} );
break;
}
} );
};
/**
* Takes an API response object and creates error notices to display to the customer.
*
* This is where we can handle specific error codes and display notices in specific contexts.
*/
const processErrorResponse = ( response: ApiErrorResponse ) => {
if ( ! isApiResponse( response ) ) {
return;
}
switch ( response.code ) {
case 'woocommerce_rest_missing_email_address':
case 'woocommerce_rest_invalid_email_address':
createNotice( 'error', response.message, {
id: response.code,
context: noticeContexts.CONTACT_INFORMATION,
} );
break;
case 'rest_invalid_param':
processInvalidParamResponse( response );
break;
default:
createNotice( 'error', response.message || DEFAULT_ERROR_MESSAGE, {
id: response.code,
context: noticeContexts.CHECKOUT,
} );
}
};
export default processErrorResponse;

View File

@ -0,0 +1,28 @@
/**
* Internal dependencies
*/
import type { CartResponse } from './cart-response';
// This is the standard API response data when an error is returned.
export type ApiErrorResponse = {
code: string;
message: string;
data: ApiErrorResponseData;
};
// API errors contain data with the status, and more in-depth error details. This may be null.
export type ApiErrorResponseData = {
status: number;
params: Record< string, string >;
details: Record< string, ApiErrorResponseDataDetails >;
// Some endpoints return cart data to update the client.
cart?: CartResponse | undefined;
} | null;
// The details object lists individual errors for each field.
export type ApiErrorResponseDataDetails = {
code: string;
message: string;
data: ApiErrorResponseData;
additional_errors: ApiErrorResponse[];
};

View File

@ -0,0 +1,37 @@
export interface ApiResponse< T > {
body: Record< string, unknown >;
headers: Headers;
status: number;
ok: boolean;
json: () => Promise< T >;
}
export function assertBatchResponseIsValid(
response: unknown
): asserts response is {
responses: ApiResponse< unknown >[];
headers: Headers;
} {
if (
typeof response === 'object' &&
response !== null &&
response.hasOwnProperty( 'responses' )
) {
return;
}
throw new Error( 'Response not valid' );
}
export function assertResponseIsValid< T >(
response: unknown
): asserts response is ApiResponse< T > {
if (
typeof response === 'object' &&
response !== null &&
'body' in response &&
'headers' in response
) {
return;
}
throw new Error( 'Response not valid' );
}

View File

@ -3,6 +3,11 @@
*/
import { ShippingAddress, BillingAddress } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import type { ApiErrorResponse } from './api-error-response';
export interface CheckoutResponseSuccess {
billing_address: BillingAddress;
customer_id: number;
@ -20,12 +25,6 @@ export interface CheckoutResponseSuccess {
status: string;
}
export interface CheckoutResponseError {
code: string;
message: string;
data: {
status: number;
};
}
export type CheckoutResponseError = ApiErrorResponse;
export type CheckoutResponse = CheckoutResponseSuccess | CheckoutResponseError;

View File

@ -18,17 +18,18 @@ import type {
CartResponse,
CartResponseCoupons,
} from './cart-response';
import type { ResponseError } from '../../data/types';
import type { ApiErrorResponse } from './api-error-response';
export interface StoreCartItemQuantity {
isPendingDelete: boolean;
quantity: number;
setItemQuantity: React.Dispatch< React.SetStateAction< number > >;
removeItem: () => Promise< boolean >;
cartItemQuantityErrors: Array< CartResponseErrorItem >;
cartItemQuantityErrors: CartResponseErrorItem[];
}
// An object exposing data and actions from/for the store api /cart/coupons endpoint.
export interface StoreCartCoupon {
appliedCoupons: Array< CartResponseCouponItem >;
appliedCoupons: CartResponseCouponItem[];
isLoading: boolean;
applyCoupon: ( coupon: string ) => void;
removeCoupon: ( coupon: string ) => void;
@ -38,24 +39,24 @@ export interface StoreCartCoupon {
export interface StoreCart {
cartCoupons: CartResponseCoupons;
cartItems: Array< CartResponseItem >;
crossSellsProducts: Array< ProductResponseItem >;
cartFees: Array< CartResponseFeeItem >;
cartItems: CartResponseItem[];
crossSellsProducts: ProductResponseItem[];
cartFees: CartResponseFeeItem[];
cartItemsCount: number;
cartItemsWeight: number;
cartNeedsPayment: boolean;
cartNeedsShipping: boolean;
cartItemErrors: Array< CartResponseErrorItem >;
cartItemErrors: CartResponseErrorItem[];
cartTotals: CartResponseTotals;
cartIsLoading: boolean;
cartErrors: Array< ResponseError >;
cartErrors: ApiErrorResponse[];
billingAddress: CartResponseBillingAddress;
shippingAddress: CartResponseShippingAddress;
shippingRates: Array< CartResponseShippingRate >;
shippingRates: CartResponseShippingRate[];
extensions: Record< string, unknown >;
isLoadingRates: boolean;
cartHasCalculatedShipping: boolean;
paymentRequirements: Array< string >;
paymentRequirements: string[];
receiveCart: ( cart: CartResponse ) => void;
}

View File

@ -1,3 +1,5 @@
export * from './api-response';
export * from './api-error-response';
export * from './blocks';
export * from './cart';
export * from './cart-response';

View File

@ -5,15 +5,19 @@ import { dispatch, select } from '@wordpress/data';
import type { Notice } from '@wordpress/notices';
export const hasNoticesOfType = (
context = '',
type: 'default' | 'snackbar'
type: 'default' | 'snackbar',
context?: string | undefined
): boolean => {
const notices: Notice[] = select( 'core/notices' ).getNotices( context );
return notices.some( ( notice: Notice ) => notice.type === type );
};
export const removeNoticesByStatus = ( status: string, context = '' ): void => {
const notices = select( 'core/notices' ).getNotices();
// Note, if context is blank, the default context is used.
export const removeNoticesByStatus = (
status: string,
context?: string | undefined
): void => {
const notices = select( 'core/notices' ).getNotices( context );
const { removeNotice } = dispatch( 'core/notices' );
const noticesOfType = notices.filter(
( notice ) => notice.status === status

View File

@ -34,10 +34,10 @@ describe( 'Notice utils', () => {
] ),
} );
const hasSnackbarNotices = hasNoticesOfType(
'wc/cart',
'snackbar'
'snackbar',
'wc/cart'
);
const hasDefaultNotices = hasNoticesOfType( 'wc/cart', 'default' );
const hasDefaultNotices = hasNoticesOfType( 'default', 'wc/cart' );
expect( hasDefaultNotices ).toBe( true );
expect( hasSnackbarNotices ).toBe( false );
} );
@ -46,7 +46,7 @@ describe( 'Notice utils', () => {
select.mockReturnValue( {
getNotices: jest.fn().mockReturnValue( [] ),
} );
const hasDefaultNotices = hasNoticesOfType( 'wc/cart', 'default' );
const hasDefaultNotices = hasNoticesOfType( 'default', 'wc/cart' );
expect( hasDefaultNotices ).toBe( false );
} );
} );
@ -98,12 +98,12 @@ describe( 'Notice utils', () => {
expect( dispatch().removeNotice ).toHaveBeenNthCalledWith(
1,
'coupon-form',
''
undefined
);
expect( dispatch().removeNotice ).toHaveBeenNthCalledWith(
2,
'address-form',
''
undefined
);
} );

View File

@ -6,13 +6,12 @@
- [Order Summary Items](#order-summary-items)
- [Totals footer item (in Mini Cart, Cart and Checkout)](#totals-footer-item-in-mini-cart-cart-and-checkout)
- [Coupons](#coupons)
- [Snackbar notices](#snackbar-notices)
- [Place Order Button Label](#place-order-button-label)
- [Examples](#examples)
- [Changing the wording of the Totals label in the Mini Cart, Cart and Checkout](#changing-the-wording-of-the-totals-label-in-the-mini-cart-cart-and-checkout)
- [Changing the format of the item's single price](#changing-the-format-of-the-items-single-price)
- [Change the name of a coupon](#change-the-name-of-a-coupon)
- [Hide a snackbar notice containing a certain string](#hide-a-snackbar-notice-containing-a-certain-string)
- [Hide the "Remove item" link on a cart item](#hide-the-remove-item-link-on-a-cart-item)
- [Change the label of the Place Order button](#change-the-label-of-the-place-order-button)
- [Troubleshooting](#troubleshooting)
@ -27,7 +26,7 @@ Line items refer to each item listed in the cart or checkout. For instance, the
The following filters are available for line items:
| Filter name | Description | Return type |
| ---------------------- |----------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------|
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `itemName` | Used to change the name of the item before it is rendered onto the page | `string` |
| `cartItemPrice` | This is the price of the item, multiplied by the number of items in the cart. | `string` and **must** contain the substring `<price/>` where the price should appear. |
| `cartItemClass` | This is the className of the item cell. | `string` |
@ -91,31 +90,6 @@ CartCoupon {
}
```
## Snackbar notices
There is a snackbar at the bottom of the page used to display notices to the customer, it looks like this:
![Snackbar notices](https://user-images.githubusercontent.com/5656702/120882329-d573c100-c5ce-11eb-901b-d7f206f74a66.png)
It may be desirable to hide this if there's a notice you don't want the shopper to see.
| Filter name | Description | Return type |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `snackbarNoticeVisibility` | An object keyed by the content of the notices slated to be displayed. The value of each member of this object will initially be true. | `object` |
The filter passes an object whose keys are the `content` of each notice.
If there are two notices slated to be displayed ('Coupon code "10off" has been applied to your basket.', and 'Coupon code "50off" has been removed from your basket.'), the value passed to the filter would look like so:
```js
{
'Coupon code "10off" has been applied to your basket.': true,
'Coupon code "50off" has been removed from your basket.': true
}
```
To reiterate, the _value_ here will determine whether this notice gets displayed or not. It will display if true.
## Place Order Button Label
The Checkout block contains a button which is labelled 'Place Order' by default, but can be changed using the following filter.
@ -212,21 +186,22 @@ __experimentalRegisterCheckoutFilters( 'automatic-coupon-extension', {
| -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| ![image](https://user-images.githubusercontent.com/5656702/123768988-bc55eb80-d8c0-11eb-9262-5d629837706d.png) | ![image](https://user-images.githubusercontent.com/5656702/124126048-2c57a380-da72-11eb-9b45-b2cae0cffc37.png) |
### Hide a snackbar notice containing a certain string
### Prevent a snackbar notice from appearing for coupons
Let's say we want to hide all notices that contain the string `auto-generated-coupon`. We would do this by setting the value of the `snackbarNoticeVisibility` to false for the notices we would like to hide.
If you want to prevent a coupon apply notice from appearing, you can use the `showApplyCouponNotice` filter. If it returns `false` then the notice will not be created.
The same can be done with the `showRemoveCouponNotice` filter to prevent a notice when a coupon is removed from the cart.
```ts
import { __experimentalRegisterCheckoutFilters } from '@woocommerce/blocks-checkout';
__experimentalRegisterCheckoutFilters( 'automatic-coupon-extension', {
snackbarNoticeVisibility: ( value ) => {
// Copy the value so we don't mutate what is being passed by the filter.
const valueCopy = Object.assign( {}, value );
Object.keys( value ).forEach( ( key ) => {
valueCopy[ key ] = key.indexOf( 'auto-generated-coupon' ) === -1;
} );
return valueCopy;
__experimentalRegisterCheckoutFilters( 'example-extension', {
showApplyCouponNotice: ( value, extensions, { couponCode } ) => {
// Prevent a couponCode called '10off' from creating a notice.
return couponCode === '10off' ? false : value;
},
showRemoveCouponNotice: ( value, _, { couponCode } ) => {
return couponCode === '10off' ? false : value;
},
} );
```
@ -234,7 +209,7 @@ __experimentalRegisterCheckoutFilters( 'automatic-coupon-extension', {
### Hide the "Remove item" link on a cart item
If you want to stop customers from being able to remove a specific item from their cart **on the front end**, you can do
this by using the `showRemoveItemLink` filter. If it returns `false` for that line item the link will not show.
this by using the `showRemoveItemLink` filter. If it returns `false` for that line item the link will not show.
An important caveat to note is this does _not_ prevent the item from being removed from the cart using StoreAPI or by
removing it in the Mini Cart, or traditional shortcode cart.
@ -291,4 +266,3 @@ The error will also be shown in your console.
🐞 Found a mistake, or have a suggestion? [Leave feedback about this document here.](https://github.com/woocommerce/woocommerce-blocks/issues/new?assignees=&labels=type%3A+documentation&template=--doc-feedback.md&title=Feedback%20on%20./docs/third-party-developers/extensibility/checkout-block/available-filters.md)
<!-- /FEEDBACK -->

View File

@ -1,95 +1,67 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { Notice } from 'wordpress-components';
import { sanitizeHTML } from '@woocommerce/utils';
import { useDispatch, useSelect } from '@wordpress/data';
import { useSelect } from '@wordpress/data';
import { PAYMENT_STORE_KEY } from '@woocommerce/block-data';
import type { Notice as NoticeType } from '@wordpress/notices';
/**
* Internal dependencies
*/
import './style.scss';
import StoreNotices from './store-notices';
import SnackbarNotices from './snackbar-notices';
import type { StoreNoticesContainerProps, StoreNotice } from './types';
const getWooClassName = ( { status = 'default' } ) => {
switch ( status ) {
case 'error':
return 'woocommerce-error';
case 'success':
return 'woocommerce-message';
case 'info':
case 'warning':
return 'woocommerce-info';
}
return '';
const formatNotices = (
notices: StoreNotice[],
context: string
): StoreNotice[] => {
return notices.map( ( notice ) => ( {
...notice,
context,
} ) );
};
interface StoreNoticesContainerProps {
className?: string;
context?: string;
additionalNotices?: NoticeType[];
}
/**
* Component that displays notices from the core/notices data store. See
* https://developer.wordpress.org/block-editor/reference-guides/data/data-core-notices/ for more information on this
* data store.
*
* @param props
* @param props.className Class name to add to the container.
* @param props.context Context to show notices from.
* @param props.additionalNotices Additional notices to display.
* @function Object() { [native code] }
*/
export const StoreNoticesContainer = ( {
className,
context = 'default',
const StoreNoticesContainer = ( {
className = '',
context,
additionalNotices = [],
}: StoreNoticesContainerProps ): JSX.Element | null => {
const isExpressPaymentMethodActive = useSelect( ( select ) =>
const suppressNotices = useSelect( ( select ) =>
select( PAYMENT_STORE_KEY ).isExpressPaymentMethodActive()
);
const { notices } = useSelect( ( select ) => {
const store = select( 'core/notices' );
return {
notices: store.getNotices( context ),
};
} );
const { removeNotice } = useDispatch( 'core/notices' );
const regularNotices = notices
.filter( ( notice ) => notice.type !== 'snackbar' )
.concat( additionalNotices );
const notices = useSelect< StoreNotice[] >( ( select ) => {
const { getNotices } = select( 'core/notices' );
if ( ! regularNotices.length ) {
return formatNotices(
( getNotices( context ) as StoreNotice[] ).concat(
additionalNotices
),
context
).filter( Boolean ) as StoreNotice[];
} );
if ( suppressNotices ) {
return null;
}
const wrapperClass = classnames( className, 'wc-block-components-notices' );
// We suppress the notices when the express payment method is active
return isExpressPaymentMethodActive ? null : (
<div className={ wrapperClass }>
{ regularNotices.map( ( props ) => (
<Notice
key={ `store-notice-${ props.id }` }
{ ...props }
className={ classnames(
'wc-block-components-notices__notice',
getWooClassName( props )
) }
onRemove={ () => {
if ( props.isDismissible ) {
removeNotice( props.id, context );
}
} }
>
{ sanitizeHTML( props.content ) }
</Notice>
) ) }
</div>
return (
<>
<StoreNotices
className={ className }
context={ context }
notices={ notices.filter(
( notice ) => notice.type === 'default'
) }
/>
<SnackbarNotices
className={ className }
notices={ notices.filter(
( notice ) => notice.type === 'snackbar'
) }
/>
</>
);
};

View File

@ -0,0 +1,51 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { SnackbarList } from 'wordpress-components';
import { useDispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import type { StoreNotice } from './types';
const SnackbarNotices = ( {
className,
notices,
}: {
className: string;
notices: StoreNotice[];
} ): JSX.Element | null => {
const { removeNotice } = useDispatch( 'core/notices' );
if ( ! notices.length ) {
return null;
}
return (
<SnackbarList
className={ classnames(
className,
'wc-block-components-notices__snackbar'
) }
notices={ notices.map( ( notice ) => {
return {
...notice,
className: 'components-snackbar--status-' + notice.status,
};
} ) }
onRemove={ ( noticeId: string ) => {
notices.forEach( ( notice ) => {
if ( notice.explicitDismiss && notice.id === noticeId ) {
removeNotice( notice.id, notice.context );
} else if ( ! notice.explicitDismiss ) {
removeNotice( notice.id, notice.context );
}
} );
} }
/>
);
};
export default SnackbarNotices;

View File

@ -0,0 +1,133 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { useRef, useEffect } from '@wordpress/element';
import { Notice } from 'wordpress-components';
import { sanitizeHTML } from '@woocommerce/utils';
import { useDispatch } from '@wordpress/data';
import { usePrevious } from '@woocommerce/base-hooks';
import { decodeEntities } from '@wordpress/html-entities';
import { STORE_NOTICES_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { getClassNameFromStatus } from './utils';
import type { StoreNotice } from './types';
const StoreNotices = ( {
context,
className,
notices,
}: {
context: string;
className: string;
notices: StoreNotice[];
} ): JSX.Element => {
const ref = useRef< HTMLDivElement >( null );
const { removeNotice } = useDispatch( 'core/notices' );
const { registerContainer, unregisterContainer } = useDispatch(
STORE_NOTICES_STORE_KEY
);
const noticeIds = notices.map( ( notice ) => notice.id );
const previousNoticeIds = usePrevious( noticeIds );
useEffect( () => {
// Scroll to container when an error is added here.
const containerRef = ref.current;
if ( ! containerRef ) {
return;
}
// Do not scroll if input has focus.
const activeElement = containerRef.ownerDocument.activeElement;
const inputs = [ 'input', 'select', 'button', 'textarea' ];
if (
activeElement &&
inputs.indexOf( activeElement.tagName.toLowerCase() ) !== -1
) {
return;
}
const newNoticeIds = noticeIds.filter(
( value ) =>
! previousNoticeIds || ! previousNoticeIds.includes( value )
);
if ( newNoticeIds.length && containerRef?.scrollIntoView ) {
containerRef.scrollIntoView( {
behavior: 'smooth',
} );
}
}, [ noticeIds, previousNoticeIds, ref ] );
// Register the container context with the parent.
useEffect( () => {
registerContainer( context );
return () => {
unregisterContainer( context );
};
}, [ context, registerContainer, unregisterContainer ] );
// Group notices by status. Do not group notices that are not dismissable.
const noticesByStatus = {
error: notices.filter( ( { status } ) => status === 'error' ),
success: notices.filter( ( { status } ) => status === 'success' ),
warning: notices.filter( ( { status } ) => status === 'warning' ),
info: notices.filter( ( { status } ) => status === 'info' ),
};
return (
<div
ref={ ref }
className={ classnames( className, 'wc-block-components-notices' ) }
>
{ Object.entries( noticesByStatus ).map(
( [ status, noticeGroup ] ) => {
if ( ! noticeGroup.length ) {
return null;
}
return (
<Notice
key={ `store-notice-${ status }` }
className={ classnames(
'wc-block-components-notices__notice',
getClassNameFromStatus( status )
) }
onRemove={ () => {
noticeGroup.forEach( ( notice ) => {
removeNotice( notice.id, notice.context );
} );
} }
>
{ noticeGroup.length === 1 ? (
<>
{ sanitizeHTML(
decodeEntities(
noticeGroup[ 0 ].content
)
) }
</>
) : (
<ul>
{ noticeGroup.map( ( notice ) => (
<li key={ notice.id }>
{ sanitizeHTML(
decodeEntities( notice.content )
) }
</li>
) ) }
</ul>
) }
</Notice>
);
}
) }
</div>
);
};
export default StoreNotices;

View File

@ -1,6 +1,12 @@
.wc-block-components-notices {
display: block;
margin-bottom: 2em;
margin: 1.5em 0;
&:first-child {
margin-top: 0;
}
&:empty {
margin: 0;
}
.wc-block-components-notices__notice {
margin: 0;
display: flex;
@ -28,6 +34,16 @@
margin-bottom: 0;
}
}
.components-notice__content {
ul {
margin: 0;
padding: 0;
list-style: none;
}
li + li {
margin: 0.25em 0 0 0;
}
}
}
.wc-block-components-notices__notice + .wc-block-components-notices__notice {
margin-top: 1em;
@ -41,3 +57,24 @@
padding: 1.5rem 3rem;
}
}
.wc-block-components-notices__snackbar {
position: fixed;
bottom: 20px;
left: 16px;
width: auto;
@include breakpoint("<782px") {
position: fixed;
top: 10px;
left: 0;
bottom: auto;
}
.components-snackbar-list__notice-container {
@include breakpoint("<782px") {
margin-left: 10px;
margin-right: 10px;
}
}
}

View File

@ -0,0 +1,16 @@
/**
* External dependencies
*/
import type {
Notice as NoticeType,
Options as NoticeOptions,
} from '@wordpress/notices';
export interface StoreNoticesContainerProps {
className?: string | undefined;
context: string;
// List of additional notices that were added inline and not stored in the `core/notices` store.
additionalNotices?: ( NoticeType & NoticeOptions )[];
}
export type StoreNotice = NoticeType & NoticeOptions;

View File

@ -0,0 +1,12 @@
export const getClassNameFromStatus = ( status = 'default' ): string => {
switch ( status ) {
case 'error':
return 'woocommerce-error';
case 'success':
return 'woocommerce-message';
case 'info':
case 'warning':
return 'woocommerce-info';
}
return '';
};

View File

@ -1,7 +1,6 @@
/**
* External dependencies
*/
import { useMemo } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
import deprecated from '@wordpress/deprecated';
@ -41,19 +40,6 @@ export const __experimentalRegisterCheckoutFilters = (
namespace: string,
filters: Record< string, CheckoutFilterFunction >
): void => {
/**
* Let developers know snackbarNotices is no longer available as a filter.
*
* See: https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4417
*/
if ( Object.keys( filters ).includes( 'couponName' ) ) {
deprecated( 'snackbarNotices', {
alternative: 'snackbarNoticeVisibility',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4417',
} );
}
/**
* Let the user know couponName is no longer available as a filter.
*
@ -211,42 +197,40 @@ export const __experimentalApplyCheckoutFilter = < T >( {
/** Function that needs to return true when the filtered value is passed in order for the filter to be applied. */
validation?: ( value: T ) => true | Error;
} ): T => {
return useMemo( () => {
if (
! shouldReRunFilters( filterName, arg, extensions, defaultValue ) &&
cachedValues[ filterName ] !== undefined
) {
return cachedValues[ filterName ];
}
const filters = getCheckoutFilters( filterName );
let value = defaultValue;
filters.forEach( ( filter ) => {
try {
const newValue = filter( value, extensions || {}, arg );
if ( typeof newValue !== typeof value ) {
throw new Error(
sprintf(
/* translators: %1$s is the type of the variable passed to the filter function, %2$s is the type of the value returned by the filter function. */
__(
'The type returned by checkout filters must be the same as the type they receive. The function received %1$s but returned %2$s.',
'woo-gutenberg-products-block'
),
typeof value,
typeof newValue
)
);
}
value = validation( newValue ) ? newValue : value;
} catch ( e ) {
if ( CURRENT_USER_IS_ADMIN ) {
throw e;
} else {
// eslint-disable-next-line no-console
console.error( e );
}
if (
! shouldReRunFilters( filterName, arg, extensions, defaultValue ) &&
cachedValues[ filterName ] !== undefined
) {
return cachedValues[ filterName ];
}
const filters = getCheckoutFilters( filterName );
let value = defaultValue;
filters.forEach( ( filter ) => {
try {
const newValue = filter( value, extensions || {}, arg );
if ( typeof newValue !== typeof value ) {
throw new Error(
sprintf(
/* translators: %1$s is the type of the variable passed to the filter function, %2$s is the type of the value returned by the filter function. */
__(
'The type returned by checkout filters must be the same as the type they receive. The function received %1$s but returned %2$s.',
'woo-gutenberg-products-block'
),
typeof value,
typeof newValue
)
);
}
} );
cachedValues[ filterName ] = value;
return value;
}, [ arg, defaultValue, extensions, filterName, validation ] );
value = validation( newValue ) ? newValue : value;
} catch ( e ) {
if ( CURRENT_USER_IS_ADMIN ) {
throw e;
} else {
// eslint-disable-next-line no-console
console.error( e );
}
}
} );
cachedValues[ filterName ] = value;
return value;
};