* Scroll to errors when interacting with radio buttons

* Fix full stop wrapping in checkout

* Make type guard for api response reusable

* Merge useShippingData  and useSelectShippingRate

Overlapping functionality and responsibility easily merged into a single hook.

* ShippingDataProvider Typescript

* Create errors when receiving errors via thunk

* Update DEFAULT_ERROR_MESSAGE

* Update tests since all errors are set via new action

* Correct SET_ERROR_DATA

* Update json error text and allow it to be dismissed

* Add back missing comment in types

* Put back typedef

* Allow Store Notice Containers to display subContexts without changing original context

* receiveError handles cart

* Update assets/js/data/cart/notify-errors.ts

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

* Update assets/js/base/context/hooks/shipping/types.ts

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

* Remove debug

* Revise type (remove as)

* rename to unregisteredSubContexts

* getNoticeContexts comment

* Add test for unregistered errors

* Update comment

Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>
This commit is contained in:
Mike Jolley 2023-01-19 16:40:52 +00:00 committed by GitHub
parent c86ea1aa66
commit 435e341fe6
31 changed files with 357 additions and 452 deletions

View File

@ -82,6 +82,7 @@
top: 0;
text-align: center;
transform: translateX(-50%);
white-space: nowrap;
.is-mobile &,
.is-small & {

View File

@ -7,7 +7,10 @@ import { decodeEntities } from '@wordpress/html-entities';
import type { ReactElement } from 'react';
import { Panel } from '@woocommerce/blocks-checkout';
import Label from '@woocommerce/base-components/label';
import { useShippingData } from '@woocommerce/base-context/hooks';
import {
useShippingData,
useStoreEvents,
} from '@woocommerce/base-context/hooks';
import { sanitizeHTML } from '@woocommerce/utils';
/**
@ -27,6 +30,7 @@ export const ShippingRatesControlPackage = ( {
showItems,
}: PackageProps ): ReactElement => {
const { selectShippingRate } = useShippingData();
const { dispatchCheckoutEvent } = useStoreEvents();
const multiplePackages =
document.querySelectorAll(
'.wc-block-components-shipping-rates-control__package'
@ -90,8 +94,12 @@ export const ShippingRatesControlPackage = ( {
className,
noResultsMessage,
rates: packageData.shipping_rates,
onSelectRate: ( newShippingRateId: string ) =>
selectShippingRate( newShippingRateId, packageId ),
onSelectRate: ( newShippingRateId: string ) => {
selectShippingRate( newShippingRateId, packageId );
dispatchCheckoutEvent( 'set-selected-shipping-rate', {
shippingRateId: newShippingRateId,
} );
},
selectedRate: packageData.shipping_rates.find(
( rate ) => rate.selected
),

View File

@ -2,23 +2,20 @@
* External dependencies
*/
import { Cart } from '@woocommerce/type-defs/cart';
import { SelectShippingRateType } from '@woocommerce/type-defs/shipping';
export interface ShippingData extends SelectShippingRateType {
export interface ShippingData {
needsShipping: Cart[ 'needsShipping' ];
hasCalculatedShipping: Cart[ 'hasCalculatedShipping' ];
shippingRates: Cart[ 'shippingRates' ];
isLoadingRates: boolean;
selectedRates: Record< string, string | unknown >;
/**
* The following values are used to determine if pickup methods are shown separately from shipping methods, or if
* those options should be hidden.
*/
// Returns a function that accepts a shipping rate ID and a package ID.
selectShippingRate: (
newShippingRateId: string,
packageId: string | number
) => void;
// Only true when ALL packages support local pickup. If true, we can show the collection/delivery toggle
isCollectable: boolean;
// True when at least one package has selected local pickup
hasSelectedLocalPickup: boolean;
// True when a rate is currently being selected and persisted to the server.
isSelectingRate: boolean;
}

View File

@ -1,63 +0,0 @@
/**
* External dependencies
*/
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useThrowError } from '@woocommerce/base-hooks';
import { SelectShippingRateType } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { useStoreEvents } from '../use-store-events';
/**
* This is a custom hook for selecting shipping rates for a shipping package.
*
* @return {Object} This hook will return an object with these properties:
* - selectShippingRate: A function that immediately returns the selected rate and dispatches an action generator.
* - isSelectingRate: True when rates are being resolved to the API.
*/
export const useSelectShippingRate = (): SelectShippingRateType => {
const throwError = useThrowError();
const { dispatchCheckoutEvent } = useStoreEvents();
const { selectShippingRate: dispatchSelectShippingRate } = useDispatch(
storeKey
) as {
selectShippingRate: unknown;
} as {
selectShippingRate: (
newShippingRateId: string,
packageId?: string | number
) => Promise< unknown >;
};
// Selects a shipping rate, fires an event, and catch any errors.
const selectShippingRate = useCallback(
( newShippingRateId, packageId ) => {
dispatchSelectShippingRate( newShippingRateId, packageId )
.then( () => {
dispatchCheckoutEvent( 'set-selected-shipping-rate', {
shippingRateId: newShippingRateId,
} );
} )
.catch( ( error ) => {
// Throw an error because an error when selecting a rate is problematic.
throwError( error );
} );
},
[ dispatchSelectShippingRate, dispatchCheckoutEvent, throwError ]
);
// See if rates are being selected.
const isSelectingRate = useSelect< boolean >( ( select ) => {
return select( storeKey ).isShippingRateBeingSelected();
}, [] );
return {
selectShippingRate,
isSelectingRate,
};
};

View File

@ -23,6 +23,7 @@ export const useShippingData = (): ShippingData => {
hasCalculatedShipping,
isLoadingRates,
isCollectable,
isSelectingRate,
} = useSelect( ( select ) => {
const isEditor = !! select( 'core/editor' );
const store = select( storeKey );
@ -45,14 +46,12 @@ export const useShippingData = (): ShippingData => {
methodId === 'pickup_location'
)
),
isSelectingRate: isEditor
? false
: store.isShippingRateBeingSelected(),
};
} );
// See if rates are being selected.
const isSelectingRate = useSelect< boolean >( ( select ) => {
return select( storeKey ).isShippingRateBeingSelected();
}, [] );
// set selected rates on ref so it's always current.
const selectedRates = useRef< Record< string, string > >( {} );
useEffect( () => {

View File

@ -1,60 +0,0 @@
/**
* @typedef {import('@woocommerce/type-defs/contexts').ShippingErrorTypes} ShippingErrorTypes
* @typedef {import('@woocommerce/type-defs/shipping').ShippingAddress} ShippingAddress
* @typedef {import('@woocommerce/type-defs/contexts').ShippingDataContext} ShippingDataContext
*/
/**
* @type {ShippingErrorTypes}
*/
export const ERROR_TYPES = {
NONE: 'none',
INVALID_ADDRESS: 'invalid_address',
UNKNOWN: 'unknown_error',
};
export const shippingErrorCodes = {
INVALID_COUNTRY: 'woocommerce_rest_cart_shipping_rates_invalid_country',
MISSING_COUNTRY: 'woocommerce_rest_cart_shipping_rates_missing_country',
INVALID_STATE: 'woocommerce_rest_cart_shipping_rates_invalid_state',
};
/**
* @type {ShippingAddress}
*/
export const DEFAULT_SHIPPING_ADDRESS = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
};
/**
* @type {ShippingDataContext}
*/
export const DEFAULT_SHIPPING_CONTEXT_DATA = {
shippingErrorStatus: {
isPristine: true,
isValid: false,
hasInvalidAddress: false,
hasError: false,
},
dispatchErrorStatus: () => null,
shippingErrorTypes: ERROR_TYPES,
shippingRates: [],
isLoadingRates: false,
selectedRates: [],
setSelectedRates: () => null,
shippingAddress: DEFAULT_SHIPPING_ADDRESS,
setShippingAddress: () => null,
onShippingRateSuccess: () => null,
onShippingRateFail: () => null,
onShippingRateSelectSuccess: () => null,
onShippingRateSelectFail: () => null,
needsShipping: false,
};

View File

@ -0,0 +1,48 @@
/**
* External dependencies
*/
import type { CartShippingAddress } from '@woocommerce/types';
/**
* Internal dependencies
*/
import type { ShippingDataContextType, ShippingErrorTypes } from './types';
export const ERROR_TYPES = {
NONE: 'none',
INVALID_ADDRESS: 'invalid_address',
UNKNOWN: 'unknown_error',
} as ShippingErrorTypes;
export const shippingErrorCodes = {
INVALID_COUNTRY: 'woocommerce_rest_cart_shipping_rates_invalid_country',
MISSING_COUNTRY: 'woocommerce_rest_cart_shipping_rates_missing_country',
INVALID_STATE: 'woocommerce_rest_cart_shipping_rates_invalid_state',
};
export const DEFAULT_SHIPPING_ADDRESS = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
} as CartShippingAddress;
export const DEFAULT_SHIPPING_CONTEXT_DATA = {
shippingErrorStatus: {
isPristine: true,
isValid: false,
hasInvalidAddress: false,
hasError: false,
},
dispatchErrorStatus: ( status ) => status,
shippingErrorTypes: ERROR_TYPES,
onShippingRateSuccess: () => () => void null,
onShippingRateFail: () => () => void null,
onShippingRateSelectSuccess: () => () => void null,
onShippingRateSelectFail: () => () => void null,
} as ShippingDataContextType;

View File

@ -15,6 +15,10 @@ import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import type {
ShippingDataContextType,
ShippingDataProviderProps,
} from './types';
import { ERROR_TYPES, DEFAULT_SHIPPING_CONTEXT_DATA } from './constants';
import { hasInvalidShippingAddress } from './utils';
import { errorStatusReducer } from './reducers';
@ -27,28 +31,19 @@ import {
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
import { useShippingData } from '../../../hooks/shipping/use-shipping-data';
/**
* @typedef {import('@woocommerce/type-defs/contexts').ShippingDataContext} ShippingDataContext
* @typedef {import('react')} React
*/
const { NONE, INVALID_ADDRESS, UNKNOWN } = ERROR_TYPES;
const ShippingDataContext = createContext( DEFAULT_SHIPPING_CONTEXT_DATA );
/**
* @return {ShippingDataContext} Returns data and functions related to shipping methods.
*/
export const useShippingDataContext = () => {
export const useShippingDataContext = (): ShippingDataContextType => {
return useContext( ShippingDataContext );
};
/**
* The shipping data provider exposes the interface for shipping in the checkout/cart.
*
* @param {Object} props Incoming props for provider
* @param {React.ReactElement} props.children
*/
export const ShippingDataProvider = ( { children } ) => {
export const ShippingDataProvider = ( {
children,
}: ShippingDataProviderProps ) => {
const { __internalIncrementCalculating, __internalDecrementCalculating } =
useDispatch( CHECKOUT_STORE_KEY );
const { shippingRates, isLoadingRates, cartErrors } = useStoreCart();
@ -191,10 +186,7 @@ export const ShippingDataProvider = ( { children } ) => {
currentErrorStatus.hasInvalidAddress,
] );
/**
* @type {ShippingDataContext}
*/
const ShippingData = {
const ShippingData: ShippingDataContextType = {
shippingErrorStatus: currentErrorStatus,
dispatchErrorStatus,
shippingErrorTypes: ERROR_TYPES,

View File

@ -0,0 +1,42 @@
/**
* Internal dependencies
*/
import type { emitterCallback } from '../../../event-emit';
export type ShippingErrorStatus = {
isPristine: boolean;
isValid: boolean;
hasInvalidAddress: boolean;
hasError: boolean;
};
export type ShippingErrorTypes = {
// eslint-disable-next-line @typescript-eslint/naming-convention
NONE: 'none';
// eslint-disable-next-line @typescript-eslint/naming-convention
INVALID_ADDRESS: 'invalid_address';
// eslint-disable-next-line @typescript-eslint/naming-convention
UNKNOWN: 'unknown_error';
};
export type ShippingDataContextType = {
// A function for dispatching a shipping rate error status.
dispatchErrorStatus: React.Dispatch< {
type: string;
} >;
onShippingRateFail: ReturnType< typeof emitterCallback >;
// Used to register a callback to be invoked when shipping rate is selected unsuccessfully
onShippingRateSelectFail: ReturnType< typeof emitterCallback >;
// Used to register a callback to be invoked when shipping rate is selected.
onShippingRateSelectSuccess: ReturnType< typeof emitterCallback >;
// Used to register a callback to be invoked when shipping rates are retrieved.
onShippingRateSuccess: ReturnType< typeof emitterCallback >;
// The current shipping error status.
shippingErrorStatus: ShippingErrorStatus;
// The error type constants for the shipping rate error status.
shippingErrorTypes: ShippingErrorTypes;
};
export interface ShippingDataProviderProps {
children: JSX.Element | JSX.Element[];
}

View File

@ -15,28 +15,24 @@ export const DEFAULT_ERROR_MESSAGE = __(
'woo-gutenberg-products-block'
);
export const hasStoreNoticesContainer = ( container: string ): boolean => {
const containers = select( 'wc/store/store-notices' ).getContainers();
return containers.includes( container );
/**
* Returns a list of all notice contexts defined by Blocks.
*
* Contexts are defined in enum format, but this returns an array of strings instead.
*/
export const getNoticeContexts = () => {
return Object.values( noticeContexts );
};
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;
const hasStoreNoticesContainer = ( container: string ): boolean => {
const containers = select(
'wc/store/store-notices'
).getRegisteredContainers();
return containers.includes( 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',
@ -51,14 +47,10 @@ export const createNotice = (
return;
}
const { createNotice: dispatchCreateNotice } = dispatch( 'core/notices' );
dispatchCreateNotice( status, message, {
dispatch( 'core/notices' ).createNotice( status, message, {
isDismissible: true,
...options,
context: hasStoreNoticesContainer( noticeContext )
? noticeContext
: findParentContainer( noticeContext ),
context: noticeContext,
} );
};
@ -82,7 +74,9 @@ export const createNoticeIfVisible = (
* @see https://github.com/WordPress/gutenberg/pull/44059
*/
export const removeAllNotices = () => {
const containers = select( 'wc/store/store-notices' ).getContainers();
const containers = select(
'wc/store/store-notices'
).getRegisteredContainers();
const { removeNotice } = dispatch( 'core/notices' );
const { getNotices } = select( 'core/notices' );

View File

@ -4,9 +4,6 @@
import classnames from 'classnames';
import { SidebarLayout } from '@woocommerce/base-components/sidebar-layout';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { useEffect } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
import { useDispatch, useSelect } from '@wordpress/data';
/**
* Internal dependencies
@ -20,44 +17,8 @@ const FrontendBlock = ( {
children: JSX.Element | JSX.Element[];
className: string;
} ): JSX.Element | null => {
const { cartItems, cartIsLoading, cartItemErrors } = useStoreCart();
const { cartItems, cartIsLoading } = useStoreCart();
const { hasDarkControls } = useCartBlockContext();
const { createErrorNotice, removeNotice } = useDispatch( 'core/notices' );
/*
* The code for removing old notices is also present in the filled-mini-cart-contents-block/frontend.tsx file and
* will take care of removing outdated errors in the Mini Cart block.
*/
const currentlyDisplayedErrorNoticeCodes = useSelect( ( select ) => {
return select( 'core/notices' )
.getNotices( 'wc/cart' )
.filter(
( notice ) =>
notice.status === 'error' && notice.type === 'default'
)
.map( ( notice ) => notice.id );
} );
useEffect( () => {
// Clear errors out of the store before adding the new ones from the response.
currentlyDisplayedErrorNoticeCodes.forEach( ( id ) => {
removeNotice( id, 'wc/cart' );
} );
// Ensures any cart errors listed in the API response get shown.
cartItemErrors.forEach( ( error ) => {
createErrorNotice( decodeEntities( error.message ), {
isDismissible: true,
id: error.code,
context: 'wc/cart',
} );
} );
}, [
createErrorNotice,
cartItemErrors,
currentlyDisplayedErrorNoticeCodes,
removeNotice,
] );
if ( cartIsLoading || cartItems.length >= 1 ) {
return (

View File

@ -184,6 +184,7 @@ const Block = ( {
showErrorMessage={ CURRENT_USER_IS_ADMIN }
>
<StoreNoticesContainer context={ noticeContexts.CHECKOUT } />
<StoreNoticesContainer context={ noticeContexts.CART } />
{ /* SlotFillProvider need to be defined before CheckoutProvider so fills have the SlotFill context ready when they mount. */ }
<SlotFillProvider>
<CheckoutProvider>

View File

@ -4,10 +4,6 @@
import { StoreNoticesContainer } from '@woocommerce/blocks-checkout';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { useDispatch, useSelect } from '@wordpress/data';
import { useEffect } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
type FilledMiniCartContentsBlockProps = {
children: JSX.Element;
className: string;
@ -17,44 +13,7 @@ const FilledMiniCartContentsBlock = ( {
children,
className,
}: FilledMiniCartContentsBlockProps ): JSX.Element | null => {
const { cartItems, cartItemErrors } = useStoreCart();
const { createErrorNotice, removeNotice } = useDispatch( 'core/notices' );
/*
* The code for removing old notices is also present in the filled-cart-block/frontend.tsx file and will take care
* of removing outdated errors in the Cart block.
*/
const currentlyDisplayedErrorNoticeCodes = useSelect( ( select ) => {
return select( 'core/notices' )
.getNotices( 'wc/cart' )
.filter(
( notice ) =>
notice.status === 'error' && notice.type === 'default'
)
.map( ( notice ) => notice.id );
} );
// Ensures any cart errors listed in the API response get shown.
useEffect( () => {
// Clear errors out of the store before adding the new ones from the response.
currentlyDisplayedErrorNoticeCodes.forEach( ( id ) => {
removeNotice( id, 'wc/cart' );
} );
cartItemErrors.forEach( ( error ) => {
createErrorNotice( decodeEntities( error.message ), {
isDismissible: false,
id: error.code,
context: 'wc/cart',
} );
} );
}, [
createErrorNotice,
cartItemErrors,
currentlyDisplayedErrorNoticeCodes,
removeNotice,
] );
const { cartItems } = useStoreCart();
if ( cartItems.length === 0 ) {
return null;

View File

@ -1,7 +1,6 @@
export const ACTION_TYPES = {
SET_CART_DATA: 'SET_CART_DATA',
RECEIVE_ERROR: 'RECEIVE_ERROR',
REPLACE_ERRORS: 'REPLACE_ERRORS',
SET_ERROR_DATA: 'SET_ERROR_DATA',
APPLYING_COUPON: 'APPLYING_COUPON',
REMOVING_COUPON: 'REMOVING_COUPON',
RECEIVE_CART_ITEM: 'RECEIVE_CART_ITEM',

View File

@ -39,6 +39,20 @@ export const setCartData = ( cart: Cart ): { type: string; response: Cart } => {
};
};
/**
* An action creator that dispatches the plain action responsible for setting the cart error data in the store.
*
* @param error the parsed error object (Parsed into camelCase).
*/
export const setErrorData = (
error: ApiErrorResponse | null
): { type: string; response: ApiErrorResponse | null } => {
return {
type: types.SET_ERROR_DATA,
error,
};
};
/**
* Returns an action object used in updating the store with the provided cart.
*
@ -62,18 +76,6 @@ export const receiveCartContents = (
};
};
/**
* Returns an action object used for receiving customer facing errors from the API.
*/
export const receiveError = (
error: ApiErrorResponse | null = null,
replace = true
) =>
( {
type: replace ? types.REPLACE_ERRORS : types.RECEIVE_ERROR,
error,
} as const );
/**
* Returns an action object used to track when a coupon is applying.
*
@ -195,13 +197,6 @@ export const applyExtensionCartUpdate =
return response;
} catch ( error ) {
dispatch.receiveError( error );
// If updated cart state was returned, also update that.
if ( error.data?.cart ) {
dispatch.receiveCart( error.data.cart );
}
// Re-throw the error.
throw error;
}
};
@ -230,14 +225,6 @@ export const applyCoupon =
dispatch.receiveCart( response );
} catch ( error ) {
dispatch.receiveError( error );
// If updated cart state was returned, also update that.
if ( error.data?.cart ) {
dispatch.receiveCart( error.data.cart );
}
// Re-throw the error.
throw error;
}
return true;
@ -268,14 +255,6 @@ export const removeCoupon =
dispatch.receiveCart( response );
} catch ( error ) {
dispatch.receiveError( error );
// If updated cart state was returned, also update that.
if ( error.data?.cart ) {
dispatch.receiveCart( error.data.cart );
}
// Re-throw the error.
throw error;
} finally {
dispatch.receiveRemovingCoupon( '' );
}
@ -312,14 +291,6 @@ export const addItemToCart =
triggerAddedToCartEvent( { preserveCartData: true } );
} catch ( error ) {
dispatch.receiveError( error );
// If updated cart state was returned, also update that.
if ( error.data?.cart ) {
dispatch.receiveCart( error.data.cart );
}
// Re-throw the error.
throw error;
}
};
@ -350,11 +321,6 @@ export const removeItemFromCart =
dispatch.receiveCart( response );
} catch ( error ) {
dispatch.receiveError( error );
// If updated cart state was returned, also update that.
if ( error.data?.cart ) {
dispatch.receiveCart( error.data.cart );
}
} finally {
dispatch.itemIsPendingDelete( cartItemKey, false );
}
@ -401,11 +367,6 @@ export const changeCartItemQuantity =
dispatch.receiveCart( response );
} catch ( error ) {
dispatch.receiveError( error );
// If updated cart state was returned, also update that.
if ( error.data?.cart ) {
dispatch.receiveCart( error.data.cart );
}
} finally {
dispatch.itemIsPendingQuantity( cartItemKey, false );
}
@ -436,14 +397,6 @@ export const selectShippingRate =
dispatch.receiveCart( response );
} catch ( error ) {
dispatch.receiveError( error );
// If updated cart state was returned, also update that.
if ( error.data?.cart ) {
dispatch.receiveCart( error.data.cart );
}
// Re-throw the error.
throw error;
} finally {
dispatch.shippingRatesBeingSelected( false );
}
@ -494,11 +447,6 @@ export const updateCustomerData =
dispatch.receiveError( error );
dispatch.updatingCustomerData( false );
// If updated cart state was returned, also update that.
if ( error.data?.cart ) {
dispatch.receiveCart( error.data.cart );
}
return Promise.reject( error );
}
return Promise.resolve( true );
@ -508,7 +456,7 @@ export type CartAction = ReturnOrGeneratorYieldUnion<
| typeof receiveCartContents
| typeof setBillingAddress
| typeof setShippingAddress
| typeof receiveError
| typeof setErrorData
| typeof receiveApplyingCoupon
| typeof receiveRemovingCoupon
| typeof receiveCartItem

View File

@ -0,0 +1,60 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { ApiErrorResponse, isApiErrorResponse } from '@woocommerce/types';
import { createNotice, DEFAULT_ERROR_MESSAGE } from '@woocommerce/base-utils';
import { decodeEntities } from '@wordpress/html-entities';
import { dispatch } from '@wordpress/data';
/**
* This function is used to notify the user of cart errors.
*/
export const notifyErrors = ( error: ApiErrorResponse | null = null ) => {
if ( error === null || ! isApiErrorResponse( error ) ) {
return;
}
let errorMessage = error.message || DEFAULT_ERROR_MESSAGE;
// Replace the generic invalid JSON message with something more user friendly.
if ( error.code === 'invalid_json' ) {
errorMessage = __(
'Something went wrong. Please contact us for assistance.',
'woo-gutenberg-products-block'
);
}
// Create a new notice with a consistent error ID.
createNotice( 'error', errorMessage, {
id: 'woocommerce_cart_data_request_error',
context: 'wc/cart',
isDismissible: true,
} );
};
/**
* This function is used to notify the user of cart item errors/conflicts
*/
export const notifyCartErrors = (
errors: ApiErrorResponse[] | null = null,
oldErrors: ApiErrorResponse[] | null = null
) => {
if ( oldErrors ) {
oldErrors.forEach( ( error ) => {
dispatch( 'core/notices' ).removeNotice( error.code, 'wc/cart' );
} );
}
if ( errors !== null ) {
errors.forEach( ( error ) => {
if ( isApiErrorResponse( error ) ) {
createNotice( 'error', decodeEntities( error.message ), {
id: error.code,
context: 'wc/cart',
isDismissible: true,
} );
}
} );
}
};

View File

@ -48,15 +48,7 @@ const reducer: Reducer< CartState > = (
action: Partial< CartAction >
) => {
switch ( action.type ) {
case types.RECEIVE_ERROR:
if ( action.error ) {
state = {
...state,
errors: state.errors.concat( action.error ),
};
}
break;
case types.REPLACE_ERRORS:
case types.SET_ERROR_DATA:
if ( action.error ) {
state = {
...state,

View File

@ -54,9 +54,9 @@ describe( 'cartReducer', () => {
totals: {},
} );
} );
it( 'sets expected state when errors are replaced', () => {
it( 'sets expected state when errors are set', () => {
const testAction = {
type: types.REPLACE_ERRORS,
type: types.SET_ERROR_DATA,
error: {
code: '101',
message: 'Test Error',
@ -73,30 +73,6 @@ describe( 'cartReducer', () => {
},
] );
} );
it( 'sets expected state when an error is added', () => {
const testAction = {
type: types.RECEIVE_ERROR,
error: {
code: '101',
message: 'Test Error',
data: {},
},
};
const newState = cartReducer( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect( newState.errors ).toEqual( [
{
code: '100',
message: 'Test Error',
data: {},
},
{
code: '101',
message: 'Test Error',
data: {},
},
] );
} );
it( 'sets expected state when a coupon is applied', () => {
const testAction = {
type: types.APPLYING_COUPON,

View File

@ -1,13 +1,19 @@
/**
* External dependencies
*/
import { CartResponse, Cart } from '@woocommerce/types';
import {
CartResponse,
Cart,
ApiErrorResponse,
isApiErrorResponse,
} from '@woocommerce/types';
import { camelCase, mapKeys } from 'lodash';
/**
* Internal dependencies
*/
import { notifyQuantityChanges } from './notify-quantity-changes';
import { notifyErrors, notifyCartErrors } from './notify-errors';
import { CartDispatchFromMap, CartSelectFromMap } from './index';
/**
@ -25,14 +31,33 @@ export const receiveCart =
dispatch: CartDispatchFromMap;
select: CartSelectFromMap;
} ) => {
const cart = mapKeys( response, ( _, key ) =>
const newCart = mapKeys( response, ( _, key ) =>
camelCase( key )
) as unknown as Cart;
const oldCart = select.getCartData();
notifyCartErrors( newCart.errors, oldCart.errors );
notifyQuantityChanges( {
oldCart: select.getCartData(),
newCart: cart,
oldCart,
newCart,
cartItemsPendingQuantity: select.getItemsPendingQuantityUpdate(),
cartItemsPendingDelete: select.getItemsPendingDelete(),
} );
dispatch.setCartData( cart );
dispatch.setCartData( newCart );
};
/**
* A thunk used in updating the store with cart errors retrieved from a request. This also notifies the shopper of any errors that occur.
*/
export const receiveError =
( response: ApiErrorResponse | null = null ) =>
( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
if ( isApiErrorResponse( response ) ) {
dispatch.setErrorData( response );
if ( response.data?.cart ) {
dispatch.receiveCart( response?.data?.cart );
}
notifyErrors( response );
}
};

View File

@ -3,6 +3,6 @@
*/
import { StoreNoticesState } from './default-state';
export const getContainers = (
export const getRegisteredContainers = (
state: StoreNoticesState
): StoreNoticesState[ 'containers' ] => state.containers;

View File

@ -7,7 +7,11 @@ import {
DEFAULT_ERROR_MESSAGE,
} from '@woocommerce/base-utils';
import { decodeEntities } from '@wordpress/html-entities';
import { isObject, objectHasProp, ApiErrorResponse } from '@woocommerce/types';
import {
objectHasProp,
ApiErrorResponse,
isApiErrorResponse,
} from '@woocommerce/types';
import { noticeContexts } from '@woocommerce/base-context/event-emit/utils';
type ApiParamError = {
@ -17,15 +21,6 @@ type ApiParamError = {
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.
*
@ -131,7 +126,7 @@ export const processErrorResponse = (
response: ApiErrorResponse,
context: string | undefined
) => {
if ( ! isApiResponse( response ) ) {
if ( ! isApiErrorResponse( response ) ) {
return;
}
switch ( response.code ) {

View File

@ -7,7 +7,7 @@ import type { CartResponse } from './cart-response';
export type ApiErrorResponse = {
code: string;
message: string;
data: ApiErrorResponseData;
data?: ApiErrorResponseData | undefined;
};
// API errors contain data with the status, and more in-depth error details. This may be null.

View File

@ -11,16 +11,6 @@
* included (in subunits).
*/
/**
* @typedef {Object} CartShippingOption
*
* @property {string} name Name of the shipping rate
* @property {string} description Description of the shipping rate.
* @property {string} price Price of the shipping rate (in subunits)
* @property {string} rate_id The ID of the shipping rate.
* @property {string} delivery_time The delivery time of the shipping rate
*/
/**
* @typedef {Object} CartItemImage
*

View File

@ -2,7 +2,6 @@
/* eslint-disable jsdoc/valid-types */
/**
* @typedef {import('./billing').BillingData} BillingData
* @typedef {import('./cart').CartShippingOption} CartShippingOption
* @typedef {import('./shipping').ShippingAddress} CartShippingAddress
* @typedef {import('./cart').CartData} CartData
* @typedef {import('./checkout').CheckoutDispatchActions} CheckoutDispatchActions
@ -10,32 +9,6 @@
* @typedef {import('./add-to-cart-form').AddToCartFormEventRegistration} AddToCartFormEventRegistration
*/
/**
* @typedef {Object} ShippingDataContext
*
* @property {ShippingErrorStatus} shippingErrorStatus The current shipping error status.
* @property {Function} dispatchErrorStatus A function for dispatching a shipping rate error status.
* @property {ShippingErrorTypes} shippingErrorTypes The error type constants for the shipping rate error
* status.
* @property {CartShippingOption[]} shippingRates An array of available shipping rates.
* @property {boolean} shippingRatesLoading Whether or not the shipping rates are being loaded.
* @property {string[]} selectedRates The ids of the rates that are selected.
* @property {function()} setSelectedRates Function for setting the selected rates.
* @property {boolean} isSelectingRate True when rate is being selected.
* @property {CartShippingAddress} shippingAddress The current set address for shipping.
* @property {function(Object)} setShippingAddress Function for setting the shipping address.
* @property {function()} onShippingRateSuccess Used to register a callback to be invoked when shipping
* rates are retrieved.
* @property {function()} onShippingRateSelectSuccess Used to register a callback to be invoked when shipping
* rate is selected.
* @property {function()} onShippingRateSelectFail Used to register a callback to be invoked when shipping
* rate is selected unsuccessfully
* @property {function()} onShippingRateFail Used to register a callback to be invoked when there is
* an error with retrieving shipping rates.
* @property {boolean} needsShipping True if the cart has items requiring shipping.
* @property {boolean} hasCalculatedShipping True if the cart has calculated shipping costs.
*/
/**
* @typedef {Object} ShippingErrorStatus
*

View File

@ -11,13 +11,3 @@ export interface PackageRateOption {
secondaryDescription?: string | ReactElement | undefined;
id?: string | undefined;
}
export interface SelectShippingRateType {
// Returns a function that accepts a shipping rate ID and a package ID.
selectShippingRate: (
newShippingRateId: string,
packageId?: string | number
) => unknown;
// True when a rate is currently being selected and persisted to the server.
isSelectingRate: boolean;
}

View File

@ -0,0 +1,16 @@
/**
* Internal dependencies
*/
import { isObject, objectHasProp } from './object';
import type { ApiErrorResponse } from '../type-defs';
// Type guard for ApiErrorResponse.
export const isApiErrorResponse = (
response: unknown
): response is ApiErrorResponse => {
return (
isObject( response ) &&
objectHasProp( response, 'code' ) &&
objectHasProp( response, 'message' )
);
};

View File

@ -9,3 +9,4 @@ export * from './string';
export * from './attributes';
export * from './ratings';
export * from './stock-status';
export * from './api-error-response';

View File

@ -2,7 +2,12 @@
* External dependencies
*/
import { useSelect } from '@wordpress/data';
import { PAYMENT_STORE_KEY } from '@woocommerce/block-data';
import {
PAYMENT_STORE_KEY,
STORE_NOTICES_STORE_KEY,
} from '@woocommerce/block-data';
import { getNoticeContexts } from '@woocommerce/base-utils';
import type { Notice } from '@wordpress/notices';
/**
* Internal dependencies
@ -12,14 +17,11 @@ import StoreNotices from './store-notices';
import SnackbarNotices from './snackbar-notices';
import type { StoreNoticesContainerProps, StoreNotice } from './types';
const formatNotices = (
notices: StoreNotice[],
context: string
): StoreNotice[] => {
const formatNotices = ( notices: Notice[], context: string ): StoreNotice[] => {
return notices.map( ( notice ) => ( {
...notice,
context,
} ) );
} ) ) as StoreNotice[];
};
const StoreNoticesContainer = ( {
@ -27,22 +29,41 @@ const StoreNoticesContainer = ( {
context,
additionalNotices = [],
}: StoreNoticesContainerProps ): JSX.Element | null => {
const suppressNotices = useSelect( ( select ) =>
select( PAYMENT_STORE_KEY ).isExpressPaymentMethodActive()
const { suppressNotices, registeredContainers } = useSelect(
( select ) => ( {
suppressNotices:
select( PAYMENT_STORE_KEY ).isExpressPaymentMethodActive(),
registeredContainers: select(
STORE_NOTICES_STORE_KEY
).getRegisteredContainers(),
} )
);
// Find sub-contexts that have not been registered. We will show notices from those contexts here too.
const allContexts = getNoticeContexts();
const unregisteredSubContexts = allContexts.filter(
( subContext: string ) =>
subContext.includes( context + '/' ) &&
! registeredContainers.includes( subContext )
);
// Get notices from the current context and any sub-contexts and append the name of the context to the notice
// objects for later reference.
const notices = useSelect< StoreNotice[] >( ( select ) => {
const { getNotices } = select( 'core/notices' );
return formatNotices(
( getNotices( context ) as StoreNotice[] ).concat(
additionalNotices
return [
...unregisteredSubContexts.flatMap( ( subContext: string ) =>
formatNotices( getNotices( subContext ), subContext )
),
context
).filter( Boolean ) as StoreNotice[];
...formatNotices(
getNotices( context ).concat( additionalNotices ),
context
),
].filter( Boolean ) as StoreNotice[];
} );
if ( suppressNotices ) {
if ( suppressNotices || ! notices.length ) {
return null;
}

View File

@ -47,7 +47,8 @@ const StoreNotices = ( {
if (
activeElement &&
inputs.indexOf( activeElement.tagName.toLowerCase() ) !== -1
inputs.indexOf( activeElement.tagName.toLowerCase() ) !== -1 &&
activeElement.getAttribute( 'type' ) !== 'radio'
) {
return;
}
@ -72,7 +73,7 @@ const StoreNotices = ( {
};
}, [ context, registerContainer, unregisterContainer ] );
// Group notices by whether or not they are dismissable. Dismissable notices can be grouped.
// Group notices by whether or not they are dismissible. Dismissible notices can be grouped.
const dismissibleNotices = notices.filter(
( { isDismissible } ) => !! isDismissible
);
@ -101,7 +102,7 @@ const StoreNotices = ( {
>
{ nonDismissibleNotices.map( ( notice ) => (
<Notice
key={ notice.id }
key={ notice.id + '-' + notice.context }
className={ classnames(
'wc-block-components-notices__notice',
getClassNameFromStatus( notice.status )
@ -140,7 +141,11 @@ const StoreNotices = ( {
) : (
<ul>
{ noticeGroup.map( ( notice ) => (
<li key={ notice.id }>
<li
key={
notice.id + '-' + notice.context
}
>
{ sanitizeHTML(
decodeEntities( notice.content )
) }

View File

@ -97,8 +97,44 @@ describe( 'StoreNoticesContainer', () => {
] }
/>
);
// Also counts the spokenMessage.
expect( screen.getAllByText( /Additional test error/i ) ).toHaveLength(
2
);
} );
it( 'Shows notices from unregistered sub-contexts', async () => {
dispatch( noticesStore ).createErrorNotice(
'Custom sub-context error',
{
id: 'custom-subcontext-test-error',
context: 'wc/checkout/shipping-address',
}
);
dispatch( noticesStore ).createErrorNotice(
'Custom sub-context error',
{
id: 'custom-subcontext-test-error',
context: 'wc/checkout/billing-address',
}
);
render( <StoreNoticesContainer context="wc/checkout" /> );
// This should match against 3 elements; 2 error messages, and the spoken message where they are combined into one element.
expect(
screen.getAllByText( /Custom sub-context error/i )
).toHaveLength( 3 );
// Clean up notices.
await act( () =>
dispatch( noticesStore ).removeNotice(
'custom-subcontext-test-error',
'wc/checkout/shipping-address'
)
);
await act( () =>
dispatch( noticesStore ).removeNotice(
'custom-subcontext-test-error',
'wc/checkout/billing-address'
)
);
} );
} );

View File

@ -36,5 +36,4 @@ export const useShippingData = () => ( {
hasCalculatedShipping: previewCart.has_calculated_shipping,
isLoadingRates: false,
isCollectable: false,
hasSelectedLocalPickup: false,
} );