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:
parent
5b095eb1a2
commit
9e00b015fc
|
@ -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 > {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
errorResponse
|
||||
.json()
|
||||
.then(
|
||||
( response ) => response as CheckoutResponseError
|
||||
)
|
||||
.then( ( response: CheckoutResponseError ) => {
|
||||
if ( response.data?.cart ) {
|
||||
receiveCart( response.data.cart );
|
||||
}
|
||||
createErrorNotice(
|
||||
formatStoreApiErrorMessage( response ),
|
||||
{
|
||||
id: 'checkout',
|
||||
context: 'wc/checkout',
|
||||
__unstableHTML: true,
|
||||
}
|
||||
);
|
||||
response?.additional_errors?.forEach?.(
|
||||
( additionalError ) => {
|
||||
createErrorNotice( additionalError.message, {
|
||||
id: additionalError.error_code,
|
||||
context: 'wc/checkout',
|
||||
__unstableHTML: true,
|
||||
} );
|
||||
}
|
||||
);
|
||||
processErrorResponse( response );
|
||||
__internalProcessCheckoutResponse( response );
|
||||
} );
|
||||
} catch {
|
||||
createErrorNotice(
|
||||
sprintf(
|
||||
// Translators: %s Error text.
|
||||
__(
|
||||
'%s Please try placing your order again.',
|
||||
processErrorResponse( {
|
||||
code: 'unknown_error',
|
||||
message: __(
|
||||
'Something went wrong. Please try placing your order again.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
errorResponse?.message ??
|
||||
__(
|
||||
'Something went wrong. Please contact us for assistance.',
|
||||
'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,
|
|
@ -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,
|
||||
] );
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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' ] ),
|
||||
} )
|
||||
),
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export * from './components/snackbar-notices-container';
|
|
@ -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 );
|
||||
} );
|
||||
} );
|
||||
};
|
|
@ -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 );
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,6 +44,10 @@ const Block = ( {
|
|||
<div
|
||||
className={ classnames( 'wc-block-checkout__actions', className ) }
|
||||
>
|
||||
<StoreNoticesContainer
|
||||
context={ noticeContexts.CHECKOUT_ACTIONS }
|
||||
/>
|
||||
<div className="wc-block-checkout__actions_row">
|
||||
{ showReturnToCart && (
|
||||
<ReturnToCartButton
|
||||
link={ getSetting( 'page-' + cartPageId, false ) }
|
||||
|
@ -47,6 +55,7 @@ const Block = ( {
|
|||
) }
|
||||
<PlaceOrderButton label={ label } />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
.wc-block-checkout__actions {
|
||||
&_row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
@ -17,6 +18,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-mobile {
|
||||
|
|
|
@ -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',
|
||||
} );
|
||||
}
|
||||
} }
|
||||
/>
|
||||
) }
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 />
|
||||
) : (
|
||||
|
|
|
@ -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
|
||||
) =>
|
||||
( {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 );
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 = {};
|
||||
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
export enum ACTION_TYPES {
|
||||
REGISTER_CONTAINER = 'REGISTER_CONTAINER',
|
||||
UNREGISTER_CONTAINER = 'UNREGISTER_CONTAINER',
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
export interface StoreNoticesState {
|
||||
containers: string[];
|
||||
}
|
||||
|
||||
export const defaultStoreNoticesState: StoreNoticesState = {
|
||||
containers: [],
|
||||
};
|
|
@ -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;
|
||||
};
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { StoreNoticesState } from './default-state';
|
||||
|
||||
export const getContainers = (
|
||||
state: StoreNoticesState
|
||||
): StoreNoticesState[ 'containers' ] => state.containers;
|
|
@ -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;
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
|
@ -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[];
|
||||
};
|
|
@ -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' );
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
export * from './api-response';
|
||||
export * from './api-error-response';
|
||||
export * from './blocks';
|
||||
export * from './cart';
|
||||
export * from './cart-response';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
);
|
||||
} );
|
||||
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
} );
|
||||
```
|
||||
|
@ -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 -->
|
||||
|
||||
|
|
|
@ -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 )
|
||||
return (
|
||||
<>
|
||||
<StoreNotices
|
||||
className={ className }
|
||||
context={ context }
|
||||
notices={ notices.filter(
|
||||
( notice ) => notice.type === 'default'
|
||||
) }
|
||||
onRemove={ () => {
|
||||
if ( props.isDismissible ) {
|
||||
removeNotice( props.id, context );
|
||||
}
|
||||
} }
|
||||
>
|
||||
{ sanitizeHTML( props.content ) }
|
||||
</Notice>
|
||||
) ) }
|
||||
</div>
|
||||
/>
|
||||
<SnackbarNotices
|
||||
className={ className }
|
||||
notices={ notices.filter(
|
||||
( notice ) => notice.type === 'snackbar'
|
||||
) }
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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 '';
|
||||
};
|
|
@ -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,7 +197,6 @@ 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
|
||||
|
@ -248,5 +233,4 @@ export const __experimentalApplyCheckoutFilter = < T >( {
|
|||
} );
|
||||
cachedValues[ filterName ] = value;
|
||||
return value;
|
||||
}, [ arg, defaultValue, extensions, filterName, validation ] );
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue