Expose cart errors as notices (https://github.com/woocommerce/woocommerce-blocks/pull/8162)
* 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:
parent
c86ea1aa66
commit
435e341fe6
|
@ -82,6 +82,7 @@
|
||||||
top: 0;
|
top: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
.is-mobile &,
|
.is-mobile &,
|
||||||
.is-small & {
|
.is-small & {
|
||||||
|
|
|
@ -7,7 +7,10 @@ import { decodeEntities } from '@wordpress/html-entities';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { Panel } from '@woocommerce/blocks-checkout';
|
import { Panel } from '@woocommerce/blocks-checkout';
|
||||||
import Label from '@woocommerce/base-components/label';
|
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';
|
import { sanitizeHTML } from '@woocommerce/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -27,6 +30,7 @@ export const ShippingRatesControlPackage = ( {
|
||||||
showItems,
|
showItems,
|
||||||
}: PackageProps ): ReactElement => {
|
}: PackageProps ): ReactElement => {
|
||||||
const { selectShippingRate } = useShippingData();
|
const { selectShippingRate } = useShippingData();
|
||||||
|
const { dispatchCheckoutEvent } = useStoreEvents();
|
||||||
const multiplePackages =
|
const multiplePackages =
|
||||||
document.querySelectorAll(
|
document.querySelectorAll(
|
||||||
'.wc-block-components-shipping-rates-control__package'
|
'.wc-block-components-shipping-rates-control__package'
|
||||||
|
@ -90,8 +94,12 @@ export const ShippingRatesControlPackage = ( {
|
||||||
className,
|
className,
|
||||||
noResultsMessage,
|
noResultsMessage,
|
||||||
rates: packageData.shipping_rates,
|
rates: packageData.shipping_rates,
|
||||||
onSelectRate: ( newShippingRateId: string ) =>
|
onSelectRate: ( newShippingRateId: string ) => {
|
||||||
selectShippingRate( newShippingRateId, packageId ),
|
selectShippingRate( newShippingRateId, packageId );
|
||||||
|
dispatchCheckoutEvent( 'set-selected-shipping-rate', {
|
||||||
|
shippingRateId: newShippingRateId,
|
||||||
|
} );
|
||||||
|
},
|
||||||
selectedRate: packageData.shipping_rates.find(
|
selectedRate: packageData.shipping_rates.find(
|
||||||
( rate ) => rate.selected
|
( rate ) => rate.selected
|
||||||
),
|
),
|
||||||
|
|
|
@ -2,23 +2,20 @@
|
||||||
* External dependencies
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
import { Cart } from '@woocommerce/type-defs/cart';
|
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' ];
|
needsShipping: Cart[ 'needsShipping' ];
|
||||||
hasCalculatedShipping: Cart[ 'hasCalculatedShipping' ];
|
hasCalculatedShipping: Cart[ 'hasCalculatedShipping' ];
|
||||||
shippingRates: Cart[ 'shippingRates' ];
|
shippingRates: Cart[ 'shippingRates' ];
|
||||||
isLoadingRates: boolean;
|
isLoadingRates: boolean;
|
||||||
selectedRates: Record< string, string | unknown >;
|
selectedRates: Record< string, string | unknown >;
|
||||||
|
// Returns a function that accepts a shipping rate ID and a package ID.
|
||||||
/**
|
selectShippingRate: (
|
||||||
* The following values are used to determine if pickup methods are shown separately from shipping methods, or if
|
newShippingRateId: string,
|
||||||
* those options should be hidden.
|
packageId: string | number
|
||||||
*/
|
) => void;
|
||||||
|
|
||||||
// Only true when ALL packages support local pickup. If true, we can show the collection/delivery toggle
|
// Only true when ALL packages support local pickup. If true, we can show the collection/delivery toggle
|
||||||
isCollectable: boolean;
|
isCollectable: boolean;
|
||||||
|
// True when a rate is currently being selected and persisted to the server.
|
||||||
// True when at least one package has selected local pickup
|
isSelectingRate: boolean;
|
||||||
hasSelectedLocalPickup: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -23,6 +23,7 @@ export const useShippingData = (): ShippingData => {
|
||||||
hasCalculatedShipping,
|
hasCalculatedShipping,
|
||||||
isLoadingRates,
|
isLoadingRates,
|
||||||
isCollectable,
|
isCollectable,
|
||||||
|
isSelectingRate,
|
||||||
} = useSelect( ( select ) => {
|
} = useSelect( ( select ) => {
|
||||||
const isEditor = !! select( 'core/editor' );
|
const isEditor = !! select( 'core/editor' );
|
||||||
const store = select( storeKey );
|
const store = select( storeKey );
|
||||||
|
@ -45,14 +46,12 @@ export const useShippingData = (): ShippingData => {
|
||||||
methodId === 'pickup_location'
|
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.
|
// set selected rates on ref so it's always current.
|
||||||
const selectedRates = useRef< Record< string, string > >( {} );
|
const selectedRates = useRef< Record< string, string > >( {} );
|
||||||
useEffect( () => {
|
useEffect( () => {
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
|
@ -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;
|
|
@ -15,6 +15,10 @@ import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
|
import type {
|
||||||
|
ShippingDataContextType,
|
||||||
|
ShippingDataProviderProps,
|
||||||
|
} from './types';
|
||||||
import { ERROR_TYPES, DEFAULT_SHIPPING_CONTEXT_DATA } from './constants';
|
import { ERROR_TYPES, DEFAULT_SHIPPING_CONTEXT_DATA } from './constants';
|
||||||
import { hasInvalidShippingAddress } from './utils';
|
import { hasInvalidShippingAddress } from './utils';
|
||||||
import { errorStatusReducer } from './reducers';
|
import { errorStatusReducer } from './reducers';
|
||||||
|
@ -27,28 +31,19 @@ import {
|
||||||
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
|
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
|
||||||
import { useShippingData } from '../../../hooks/shipping/use-shipping-data';
|
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 { NONE, INVALID_ADDRESS, UNKNOWN } = ERROR_TYPES;
|
||||||
const ShippingDataContext = createContext( DEFAULT_SHIPPING_CONTEXT_DATA );
|
const ShippingDataContext = createContext( DEFAULT_SHIPPING_CONTEXT_DATA );
|
||||||
|
|
||||||
/**
|
export const useShippingDataContext = (): ShippingDataContextType => {
|
||||||
* @return {ShippingDataContext} Returns data and functions related to shipping methods.
|
|
||||||
*/
|
|
||||||
export const useShippingDataContext = () => {
|
|
||||||
return useContext( ShippingDataContext );
|
return useContext( ShippingDataContext );
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shipping data provider exposes the interface for shipping in the checkout/cart.
|
* 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 } =
|
const { __internalIncrementCalculating, __internalDecrementCalculating } =
|
||||||
useDispatch( CHECKOUT_STORE_KEY );
|
useDispatch( CHECKOUT_STORE_KEY );
|
||||||
const { shippingRates, isLoadingRates, cartErrors } = useStoreCart();
|
const { shippingRates, isLoadingRates, cartErrors } = useStoreCart();
|
||||||
|
@ -191,10 +186,7 @@ export const ShippingDataProvider = ( { children } ) => {
|
||||||
currentErrorStatus.hasInvalidAddress,
|
currentErrorStatus.hasInvalidAddress,
|
||||||
] );
|
] );
|
||||||
|
|
||||||
/**
|
const ShippingData: ShippingDataContextType = {
|
||||||
* @type {ShippingDataContext}
|
|
||||||
*/
|
|
||||||
const ShippingData = {
|
|
||||||
shippingErrorStatus: currentErrorStatus,
|
shippingErrorStatus: currentErrorStatus,
|
||||||
dispatchErrorStatus,
|
dispatchErrorStatus,
|
||||||
shippingErrorTypes: ERROR_TYPES,
|
shippingErrorTypes: ERROR_TYPES,
|
|
@ -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[];
|
||||||
|
}
|
|
@ -15,28 +15,24 @@ export const DEFAULT_ERROR_MESSAGE = __(
|
||||||
'woo-gutenberg-products-block'
|
'woo-gutenberg-products-block'
|
||||||
);
|
);
|
||||||
|
|
||||||
export const hasStoreNoticesContainer = ( container: string ): boolean => {
|
/**
|
||||||
const containers = select( 'wc/store/store-notices' ).getContainers();
|
* Returns a list of all notice contexts defined by Blocks.
|
||||||
return containers.includes( container );
|
*
|
||||||
|
* 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 => {
|
const hasStoreNoticesContainer = ( container: string ): boolean => {
|
||||||
if ( container.includes( noticeContexts.CHECKOUT + '/' ) ) {
|
const containers = select(
|
||||||
return noticeContexts.CHECKOUT;
|
'wc/store/store-notices'
|
||||||
}
|
).getRegisteredContainers();
|
||||||
if ( container.includes( noticeContexts.CART + '/' ) ) {
|
return containers.includes( container );
|
||||||
return hasStoreNoticesContainer( noticeContexts.CART )
|
|
||||||
? noticeContexts.CART
|
|
||||||
: noticeContexts.CHECKOUT;
|
|
||||||
}
|
|
||||||
return container;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper for @wordpress/notices createNotice.
|
* 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 = (
|
export const createNotice = (
|
||||||
status: 'error' | 'warning' | 'info' | 'success',
|
status: 'error' | 'warning' | 'info' | 'success',
|
||||||
|
@ -51,14 +47,10 @@ export const createNotice = (
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { createNotice: dispatchCreateNotice } = dispatch( 'core/notices' );
|
dispatch( 'core/notices' ).createNotice( status, message, {
|
||||||
|
|
||||||
dispatchCreateNotice( status, message, {
|
|
||||||
isDismissible: true,
|
isDismissible: true,
|
||||||
...options,
|
...options,
|
||||||
context: hasStoreNoticesContainer( noticeContext )
|
context: noticeContext,
|
||||||
? noticeContext
|
|
||||||
: findParentContainer( noticeContext ),
|
|
||||||
} );
|
} );
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -82,7 +74,9 @@ export const createNoticeIfVisible = (
|
||||||
* @see https://github.com/WordPress/gutenberg/pull/44059
|
* @see https://github.com/WordPress/gutenberg/pull/44059
|
||||||
*/
|
*/
|
||||||
export const removeAllNotices = () => {
|
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 { removeNotice } = dispatch( 'core/notices' );
|
||||||
const { getNotices } = select( 'core/notices' );
|
const { getNotices } = select( 'core/notices' );
|
||||||
|
|
||||||
|
|
|
@ -4,9 +4,6 @@
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { SidebarLayout } from '@woocommerce/base-components/sidebar-layout';
|
import { SidebarLayout } from '@woocommerce/base-components/sidebar-layout';
|
||||||
import { useStoreCart } from '@woocommerce/base-context/hooks';
|
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
|
* Internal dependencies
|
||||||
|
@ -20,44 +17,8 @@ const FrontendBlock = ( {
|
||||||
children: JSX.Element | JSX.Element[];
|
children: JSX.Element | JSX.Element[];
|
||||||
className: string;
|
className: string;
|
||||||
} ): JSX.Element | null => {
|
} ): JSX.Element | null => {
|
||||||
const { cartItems, cartIsLoading, cartItemErrors } = useStoreCart();
|
const { cartItems, cartIsLoading } = useStoreCart();
|
||||||
const { hasDarkControls } = useCartBlockContext();
|
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 ) {
|
if ( cartIsLoading || cartItems.length >= 1 ) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -184,6 +184,7 @@ const Block = ( {
|
||||||
showErrorMessage={ CURRENT_USER_IS_ADMIN }
|
showErrorMessage={ CURRENT_USER_IS_ADMIN }
|
||||||
>
|
>
|
||||||
<StoreNoticesContainer context={ noticeContexts.CHECKOUT } />
|
<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 need to be defined before CheckoutProvider so fills have the SlotFill context ready when they mount. */ }
|
||||||
<SlotFillProvider>
|
<SlotFillProvider>
|
||||||
<CheckoutProvider>
|
<CheckoutProvider>
|
||||||
|
|
|
@ -4,10 +4,6 @@
|
||||||
import { StoreNoticesContainer } from '@woocommerce/blocks-checkout';
|
import { StoreNoticesContainer } from '@woocommerce/blocks-checkout';
|
||||||
import { useStoreCart } from '@woocommerce/base-context/hooks';
|
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 = {
|
type FilledMiniCartContentsBlockProps = {
|
||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
className: string;
|
className: string;
|
||||||
|
@ -17,44 +13,7 @@ const FilledMiniCartContentsBlock = ( {
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: FilledMiniCartContentsBlockProps ): JSX.Element | null => {
|
}: FilledMiniCartContentsBlockProps ): JSX.Element | null => {
|
||||||
const { cartItems, cartItemErrors } = useStoreCart();
|
const { cartItems } = 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,
|
|
||||||
] );
|
|
||||||
|
|
||||||
if ( cartItems.length === 0 ) {
|
if ( cartItems.length === 0 ) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
export const ACTION_TYPES = {
|
export const ACTION_TYPES = {
|
||||||
SET_CART_DATA: 'SET_CART_DATA',
|
SET_CART_DATA: 'SET_CART_DATA',
|
||||||
RECEIVE_ERROR: 'RECEIVE_ERROR',
|
SET_ERROR_DATA: 'SET_ERROR_DATA',
|
||||||
REPLACE_ERRORS: 'REPLACE_ERRORS',
|
|
||||||
APPLYING_COUPON: 'APPLYING_COUPON',
|
APPLYING_COUPON: 'APPLYING_COUPON',
|
||||||
REMOVING_COUPON: 'REMOVING_COUPON',
|
REMOVING_COUPON: 'REMOVING_COUPON',
|
||||||
RECEIVE_CART_ITEM: 'RECEIVE_CART_ITEM',
|
RECEIVE_CART_ITEM: 'RECEIVE_CART_ITEM',
|
||||||
|
|
|
@ -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.
|
* 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.
|
* Returns an action object used to track when a coupon is applying.
|
||||||
*
|
*
|
||||||
|
@ -195,13 +197,6 @@ export const applyExtensionCartUpdate =
|
||||||
return response;
|
return response;
|
||||||
} catch ( error ) {
|
} catch ( error ) {
|
||||||
dispatch.receiveError( 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 );
|
dispatch.receiveCart( response );
|
||||||
} catch ( error ) {
|
} catch ( error ) {
|
||||||
dispatch.receiveError( 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;
|
return true;
|
||||||
|
@ -268,14 +255,6 @@ export const removeCoupon =
|
||||||
dispatch.receiveCart( response );
|
dispatch.receiveCart( response );
|
||||||
} catch ( error ) {
|
} catch ( error ) {
|
||||||
dispatch.receiveError( 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 {
|
} finally {
|
||||||
dispatch.receiveRemovingCoupon( '' );
|
dispatch.receiveRemovingCoupon( '' );
|
||||||
}
|
}
|
||||||
|
@ -312,14 +291,6 @@ export const addItemToCart =
|
||||||
triggerAddedToCartEvent( { preserveCartData: true } );
|
triggerAddedToCartEvent( { preserveCartData: true } );
|
||||||
} catch ( error ) {
|
} catch ( error ) {
|
||||||
dispatch.receiveError( 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 );
|
dispatch.receiveCart( response );
|
||||||
} catch ( error ) {
|
} catch ( error ) {
|
||||||
dispatch.receiveError( error );
|
dispatch.receiveError( error );
|
||||||
|
|
||||||
// If updated cart state was returned, also update that.
|
|
||||||
if ( error.data?.cart ) {
|
|
||||||
dispatch.receiveCart( error.data.cart );
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
dispatch.itemIsPendingDelete( cartItemKey, false );
|
dispatch.itemIsPendingDelete( cartItemKey, false );
|
||||||
}
|
}
|
||||||
|
@ -401,11 +367,6 @@ export const changeCartItemQuantity =
|
||||||
dispatch.receiveCart( response );
|
dispatch.receiveCart( response );
|
||||||
} catch ( error ) {
|
} catch ( error ) {
|
||||||
dispatch.receiveError( error );
|
dispatch.receiveError( error );
|
||||||
|
|
||||||
// If updated cart state was returned, also update that.
|
|
||||||
if ( error.data?.cart ) {
|
|
||||||
dispatch.receiveCart( error.data.cart );
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
dispatch.itemIsPendingQuantity( cartItemKey, false );
|
dispatch.itemIsPendingQuantity( cartItemKey, false );
|
||||||
}
|
}
|
||||||
|
@ -436,14 +397,6 @@ export const selectShippingRate =
|
||||||
dispatch.receiveCart( response );
|
dispatch.receiveCart( response );
|
||||||
} catch ( error ) {
|
} catch ( error ) {
|
||||||
dispatch.receiveError( 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 {
|
} finally {
|
||||||
dispatch.shippingRatesBeingSelected( false );
|
dispatch.shippingRatesBeingSelected( false );
|
||||||
}
|
}
|
||||||
|
@ -494,11 +447,6 @@ export const updateCustomerData =
|
||||||
dispatch.receiveError( error );
|
dispatch.receiveError( error );
|
||||||
dispatch.updatingCustomerData( false );
|
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.reject( error );
|
||||||
}
|
}
|
||||||
return Promise.resolve( true );
|
return Promise.resolve( true );
|
||||||
|
@ -508,7 +456,7 @@ export type CartAction = ReturnOrGeneratorYieldUnion<
|
||||||
| typeof receiveCartContents
|
| typeof receiveCartContents
|
||||||
| typeof setBillingAddress
|
| typeof setBillingAddress
|
||||||
| typeof setShippingAddress
|
| typeof setShippingAddress
|
||||||
| typeof receiveError
|
| typeof setErrorData
|
||||||
| typeof receiveApplyingCoupon
|
| typeof receiveApplyingCoupon
|
||||||
| typeof receiveRemovingCoupon
|
| typeof receiveRemovingCoupon
|
||||||
| typeof receiveCartItem
|
| typeof receiveCartItem
|
||||||
|
|
|
@ -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,
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
};
|
|
@ -48,15 +48,7 @@ const reducer: Reducer< CartState > = (
|
||||||
action: Partial< CartAction >
|
action: Partial< CartAction >
|
||||||
) => {
|
) => {
|
||||||
switch ( action.type ) {
|
switch ( action.type ) {
|
||||||
case types.RECEIVE_ERROR:
|
case types.SET_ERROR_DATA:
|
||||||
if ( action.error ) {
|
|
||||||
state = {
|
|
||||||
...state,
|
|
||||||
errors: state.errors.concat( action.error ),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case types.REPLACE_ERRORS:
|
|
||||||
if ( action.error ) {
|
if ( action.error ) {
|
||||||
state = {
|
state = {
|
||||||
...state,
|
...state,
|
||||||
|
|
|
@ -54,9 +54,9 @@ describe( 'cartReducer', () => {
|
||||||
totals: {},
|
totals: {},
|
||||||
} );
|
} );
|
||||||
} );
|
} );
|
||||||
it( 'sets expected state when errors are replaced', () => {
|
it( 'sets expected state when errors are set', () => {
|
||||||
const testAction = {
|
const testAction = {
|
||||||
type: types.REPLACE_ERRORS,
|
type: types.SET_ERROR_DATA,
|
||||||
error: {
|
error: {
|
||||||
code: '101',
|
code: '101',
|
||||||
message: 'Test Error',
|
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', () => {
|
it( 'sets expected state when a coupon is applied', () => {
|
||||||
const testAction = {
|
const testAction = {
|
||||||
type: types.APPLYING_COUPON,
|
type: types.APPLYING_COUPON,
|
||||||
|
|
|
@ -1,13 +1,19 @@
|
||||||
/**
|
/**
|
||||||
* External dependencies
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
import { CartResponse, Cart } from '@woocommerce/types';
|
import {
|
||||||
|
CartResponse,
|
||||||
|
Cart,
|
||||||
|
ApiErrorResponse,
|
||||||
|
isApiErrorResponse,
|
||||||
|
} from '@woocommerce/types';
|
||||||
import { camelCase, mapKeys } from 'lodash';
|
import { camelCase, mapKeys } from 'lodash';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import { notifyQuantityChanges } from './notify-quantity-changes';
|
import { notifyQuantityChanges } from './notify-quantity-changes';
|
||||||
|
import { notifyErrors, notifyCartErrors } from './notify-errors';
|
||||||
import { CartDispatchFromMap, CartSelectFromMap } from './index';
|
import { CartDispatchFromMap, CartSelectFromMap } from './index';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -25,14 +31,33 @@ export const receiveCart =
|
||||||
dispatch: CartDispatchFromMap;
|
dispatch: CartDispatchFromMap;
|
||||||
select: CartSelectFromMap;
|
select: CartSelectFromMap;
|
||||||
} ) => {
|
} ) => {
|
||||||
const cart = mapKeys( response, ( _, key ) =>
|
const newCart = mapKeys( response, ( _, key ) =>
|
||||||
camelCase( key )
|
camelCase( key )
|
||||||
) as unknown as Cart;
|
) as unknown as Cart;
|
||||||
|
const oldCart = select.getCartData();
|
||||||
|
notifyCartErrors( newCart.errors, oldCart.errors );
|
||||||
notifyQuantityChanges( {
|
notifyQuantityChanges( {
|
||||||
oldCart: select.getCartData(),
|
oldCart,
|
||||||
newCart: cart,
|
newCart,
|
||||||
cartItemsPendingQuantity: select.getItemsPendingQuantityUpdate(),
|
cartItemsPendingQuantity: select.getItemsPendingQuantityUpdate(),
|
||||||
cartItemsPendingDelete: select.getItemsPendingDelete(),
|
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 );
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,6 +3,6 @@
|
||||||
*/
|
*/
|
||||||
import { StoreNoticesState } from './default-state';
|
import { StoreNoticesState } from './default-state';
|
||||||
|
|
||||||
export const getContainers = (
|
export const getRegisteredContainers = (
|
||||||
state: StoreNoticesState
|
state: StoreNoticesState
|
||||||
): StoreNoticesState[ 'containers' ] => state.containers;
|
): StoreNoticesState[ 'containers' ] => state.containers;
|
||||||
|
|
|
@ -7,7 +7,11 @@ import {
|
||||||
DEFAULT_ERROR_MESSAGE,
|
DEFAULT_ERROR_MESSAGE,
|
||||||
} from '@woocommerce/base-utils';
|
} from '@woocommerce/base-utils';
|
||||||
import { decodeEntities } from '@wordpress/html-entities';
|
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';
|
import { noticeContexts } from '@woocommerce/base-context/event-emit/utils';
|
||||||
|
|
||||||
type ApiParamError = {
|
type ApiParamError = {
|
||||||
|
@ -17,15 +21,6 @@ type ApiParamError = {
|
||||||
message: 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.
|
* Flattens error details which are returned from the API when multiple params are not valid.
|
||||||
*
|
*
|
||||||
|
@ -131,7 +126,7 @@ export const processErrorResponse = (
|
||||||
response: ApiErrorResponse,
|
response: ApiErrorResponse,
|
||||||
context: string | undefined
|
context: string | undefined
|
||||||
) => {
|
) => {
|
||||||
if ( ! isApiResponse( response ) ) {
|
if ( ! isApiErrorResponse( response ) ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
switch ( response.code ) {
|
switch ( response.code ) {
|
||||||
|
|
|
@ -7,7 +7,7 @@ import type { CartResponse } from './cart-response';
|
||||||
export type ApiErrorResponse = {
|
export type ApiErrorResponse = {
|
||||||
code: string;
|
code: string;
|
||||||
message: 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.
|
// API errors contain data with the status, and more in-depth error details. This may be null.
|
||||||
|
|
|
@ -11,16 +11,6 @@
|
||||||
* included (in subunits).
|
* 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
|
* @typedef {Object} CartItemImage
|
||||||
*
|
*
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
/* eslint-disable jsdoc/valid-types */
|
/* eslint-disable jsdoc/valid-types */
|
||||||
/**
|
/**
|
||||||
* @typedef {import('./billing').BillingData} BillingData
|
* @typedef {import('./billing').BillingData} BillingData
|
||||||
* @typedef {import('./cart').CartShippingOption} CartShippingOption
|
|
||||||
* @typedef {import('./shipping').ShippingAddress} CartShippingAddress
|
* @typedef {import('./shipping').ShippingAddress} CartShippingAddress
|
||||||
* @typedef {import('./cart').CartData} CartData
|
* @typedef {import('./cart').CartData} CartData
|
||||||
* @typedef {import('./checkout').CheckoutDispatchActions} CheckoutDispatchActions
|
* @typedef {import('./checkout').CheckoutDispatchActions} CheckoutDispatchActions
|
||||||
|
@ -10,32 +9,6 @@
|
||||||
* @typedef {import('./add-to-cart-form').AddToCartFormEventRegistration} AddToCartFormEventRegistration
|
* @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
|
* @typedef {Object} ShippingErrorStatus
|
||||||
*
|
*
|
||||||
|
|
|
@ -11,13 +11,3 @@ export interface PackageRateOption {
|
||||||
secondaryDescription?: string | ReactElement | undefined;
|
secondaryDescription?: string | ReactElement | undefined;
|
||||||
id?: string | 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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -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' )
|
||||||
|
);
|
||||||
|
};
|
|
@ -9,3 +9,4 @@ export * from './string';
|
||||||
export * from './attributes';
|
export * from './attributes';
|
||||||
export * from './ratings';
|
export * from './ratings';
|
||||||
export * from './stock-status';
|
export * from './stock-status';
|
||||||
|
export * from './api-error-response';
|
||||||
|
|
|
@ -2,7 +2,12 @@
|
||||||
* External dependencies
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
import { useSelect } from '@wordpress/data';
|
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
|
* Internal dependencies
|
||||||
|
@ -12,14 +17,11 @@ import StoreNotices from './store-notices';
|
||||||
import SnackbarNotices from './snackbar-notices';
|
import SnackbarNotices from './snackbar-notices';
|
||||||
import type { StoreNoticesContainerProps, StoreNotice } from './types';
|
import type { StoreNoticesContainerProps, StoreNotice } from './types';
|
||||||
|
|
||||||
const formatNotices = (
|
const formatNotices = ( notices: Notice[], context: string ): StoreNotice[] => {
|
||||||
notices: StoreNotice[],
|
|
||||||
context: string
|
|
||||||
): StoreNotice[] => {
|
|
||||||
return notices.map( ( notice ) => ( {
|
return notices.map( ( notice ) => ( {
|
||||||
...notice,
|
...notice,
|
||||||
context,
|
context,
|
||||||
} ) );
|
} ) ) as StoreNotice[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const StoreNoticesContainer = ( {
|
const StoreNoticesContainer = ( {
|
||||||
|
@ -27,22 +29,41 @@ const StoreNoticesContainer = ( {
|
||||||
context,
|
context,
|
||||||
additionalNotices = [],
|
additionalNotices = [],
|
||||||
}: StoreNoticesContainerProps ): JSX.Element | null => {
|
}: StoreNoticesContainerProps ): JSX.Element | null => {
|
||||||
const suppressNotices = useSelect( ( select ) =>
|
const { suppressNotices, registeredContainers } = useSelect(
|
||||||
select( PAYMENT_STORE_KEY ).isExpressPaymentMethodActive()
|
( 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 notices = useSelect< StoreNotice[] >( ( select ) => {
|
||||||
const { getNotices } = select( 'core/notices' );
|
const { getNotices } = select( 'core/notices' );
|
||||||
|
|
||||||
return formatNotices(
|
return [
|
||||||
( getNotices( context ) as StoreNotice[] ).concat(
|
...unregisteredSubContexts.flatMap( ( subContext: string ) =>
|
||||||
additionalNotices
|
formatNotices( getNotices( subContext ), subContext )
|
||||||
),
|
),
|
||||||
context
|
...formatNotices(
|
||||||
).filter( Boolean ) as StoreNotice[];
|
getNotices( context ).concat( additionalNotices ),
|
||||||
|
context
|
||||||
|
),
|
||||||
|
].filter( Boolean ) as StoreNotice[];
|
||||||
} );
|
} );
|
||||||
|
|
||||||
if ( suppressNotices ) {
|
if ( suppressNotices || ! notices.length ) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -47,7 +47,8 @@ const StoreNotices = ( {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
activeElement &&
|
activeElement &&
|
||||||
inputs.indexOf( activeElement.tagName.toLowerCase() ) !== -1
|
inputs.indexOf( activeElement.tagName.toLowerCase() ) !== -1 &&
|
||||||
|
activeElement.getAttribute( 'type' ) !== 'radio'
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -72,7 +73,7 @@ const StoreNotices = ( {
|
||||||
};
|
};
|
||||||
}, [ context, registerContainer, unregisterContainer ] );
|
}, [ 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(
|
const dismissibleNotices = notices.filter(
|
||||||
( { isDismissible } ) => !! isDismissible
|
( { isDismissible } ) => !! isDismissible
|
||||||
);
|
);
|
||||||
|
@ -101,7 +102,7 @@ const StoreNotices = ( {
|
||||||
>
|
>
|
||||||
{ nonDismissibleNotices.map( ( notice ) => (
|
{ nonDismissibleNotices.map( ( notice ) => (
|
||||||
<Notice
|
<Notice
|
||||||
key={ notice.id }
|
key={ notice.id + '-' + notice.context }
|
||||||
className={ classnames(
|
className={ classnames(
|
||||||
'wc-block-components-notices__notice',
|
'wc-block-components-notices__notice',
|
||||||
getClassNameFromStatus( notice.status )
|
getClassNameFromStatus( notice.status )
|
||||||
|
@ -140,7 +141,11 @@ const StoreNotices = ( {
|
||||||
) : (
|
) : (
|
||||||
<ul>
|
<ul>
|
||||||
{ noticeGroup.map( ( notice ) => (
|
{ noticeGroup.map( ( notice ) => (
|
||||||
<li key={ notice.id }>
|
<li
|
||||||
|
key={
|
||||||
|
notice.id + '-' + notice.context
|
||||||
|
}
|
||||||
|
>
|
||||||
{ sanitizeHTML(
|
{ sanitizeHTML(
|
||||||
decodeEntities( notice.content )
|
decodeEntities( notice.content )
|
||||||
) }
|
) }
|
||||||
|
|
|
@ -97,8 +97,44 @@ describe( 'StoreNoticesContainer', () => {
|
||||||
] }
|
] }
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
// Also counts the spokenMessage.
|
||||||
expect( screen.getAllByText( /Additional test error/i ) ).toHaveLength(
|
expect( screen.getAllByText( /Additional test error/i ) ).toHaveLength(
|
||||||
2
|
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'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} );
|
||||||
} );
|
} );
|
||||||
|
|
|
@ -36,5 +36,4 @@ export const useShippingData = () => ( {
|
||||||
hasCalculatedShipping: previewCart.has_calculated_shipping,
|
hasCalculatedShipping: previewCart.has_calculated_shipping,
|
||||||
isLoadingRates: false,
|
isLoadingRates: false,
|
||||||
isCollectable: false,
|
isCollectable: false,
|
||||||
hasSelectedLocalPickup: false,
|
|
||||||
} );
|
} );
|
||||||
|
|
Loading…
Reference in New Issue