From fc95e40fcc591eec0904604f2514216c69b56852 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 23 Nov 2022 16:00:09 +0000 Subject: [PATCH] Refactor existing error notices from the API (#7728) * Update API type defs * Move create notice utils * Replace useCheckoutNotices with new contexts * processCheckoutResponseHeaders should check headers are defined * Scroll to error notices only if we're not editing a field * Error handling utils * processErrorResponse when pushing changes * processErrorResponse when processing checkout * remove formatStoreApiErrorMessage * Add todo for cart errors * Remove unused deps * unused imports * Fix linting warnings * Unused dep * Update assets/js/types/type-defs/api-response.ts Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com> * Add todo * Use generic * remove const * Update array types * Phone should be in address blocks Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com> --- assets/js/base/context/hooks/index.js | 1 - .../context/hooks/use-checkout-notices.js | 55 ------- .../cart-checkout/checkout-events/index.tsx | 29 +++- ...out-processor.js => checkout-processor.ts} | 135 +++++++---------- .../cart-checkout/payment-events/index.tsx | 9 -- .../context/providers/cart-checkout/utils.ts | 7 +- assets/js/base/utils/create-notice.ts | 101 +++++++++++++ assets/js/base/utils/errors.js | 30 ---- assets/js/base/utils/index.js | 1 + .../filled-cart-block/frontend.tsx | 2 +- assets/js/blocks/checkout/block.tsx | 1 - assets/js/data/cart/actions.ts | 9 +- assets/js/data/cart/default-state.ts | 9 +- assets/js/data/cart/push-changes.ts | 17 +-- assets/js/data/cart/selectors.ts | 7 +- assets/js/data/checkout/types.ts | 2 +- assets/js/data/index.ts | 1 + assets/js/data/shared-controls.ts | 8 +- assets/js/data/types.ts | 49 ------ assets/js/data/utils/index.js | 1 + .../js/data/utils/process-error-response.ts | 139 ++++++++++++++++++ .../js/types/type-defs/api-error-response.ts | 28 ++++ assets/js/types/type-defs/api-response.ts | 37 +++++ assets/js/types/type-defs/checkout.ts | 13 +- assets/js/types/type-defs/hooks.ts | 20 +-- assets/js/types/type-defs/index.ts | 2 + .../store-notices-container/store-notices.tsx | 19 ++- packages/checkout/filter-registry/index.ts | 1 - packages/checkout/utils/create-notice.ts | 66 --------- packages/checkout/utils/index.js | 1 - 30 files changed, 443 insertions(+), 357 deletions(-) delete mode 100644 assets/js/base/context/hooks/use-checkout-notices.js rename assets/js/base/context/providers/cart-checkout/{checkout-processor.js => checkout-processor.ts} (74%) create mode 100644 assets/js/base/utils/create-notice.ts create mode 100644 assets/js/data/utils/process-error-response.ts create mode 100644 assets/js/types/type-defs/api-error-response.ts create mode 100644 assets/js/types/type-defs/api-response.ts delete mode 100644 packages/checkout/utils/create-notice.ts diff --git a/assets/js/base/context/hooks/index.js b/assets/js/base/context/hooks/index.js index b47f97613ff..7190de32465 100644 --- a/assets/js/base/context/hooks/index.js +++ b/assets/js/base/context/hooks/index.js @@ -8,7 +8,6 @@ export * from './use-store-products'; export * from './use-store-add-to-cart'; export * from './use-customer-data'; export * from './use-checkout-address'; -export * from './use-checkout-notices'; export * from './use-checkout-submit'; export * from './use-checkout-extension-data'; export * from './use-validation'; diff --git a/assets/js/base/context/hooks/use-checkout-notices.js b/assets/js/base/context/hooks/use-checkout-notices.js deleted file mode 100644 index c211bcabc14..00000000000 --- a/assets/js/base/context/hooks/use-checkout-notices.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * External dependencies - */ -import { useSelect } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import { noticeContexts } from '../event-emit'; - -/** - * @typedef {import('@woocommerce/type-defs/contexts').StoreNoticeObject} StoreNoticeObject - * @typedef {import('@woocommerce/type-defs/hooks').CheckoutNotices} CheckoutNotices - */ - -/** - * A hook that returns all notices visible in the Checkout block. - * - * @return {CheckoutNotices} Notices from the checkout form or payment methods. - */ -export const useCheckoutNotices = () => { - /** - * @type {StoreNoticeObject[]} - */ - const checkoutNotices = useSelect( - ( select ) => select( 'core/notices' ).getNotices( 'wc/checkout' ), - [] - ); - - /** - * @type {StoreNoticeObject[]} - */ - const expressPaymentNotices = useSelect( - ( select ) => - select( 'core/notices' ).getNotices( - noticeContexts.EXPRESS_PAYMENTS - ), - [ noticeContexts.EXPRESS_PAYMENTS ] - ); - - /** - * @type {StoreNoticeObject[]} - */ - const paymentNotices = useSelect( - ( select ) => - select( 'core/notices' ).getNotices( noticeContexts.PAYMENTS ), - [ noticeContexts.PAYMENTS ] - ); - - return { - checkoutNotices, - expressPaymentNotices, - paymentNotices, - }; -}; diff --git a/assets/js/base/context/providers/cart-checkout/checkout-events/index.tsx b/assets/js/base/context/providers/cart-checkout/checkout-events/index.tsx index 0525be93980..16fc2484166 100644 --- a/assets/js/base/context/providers/cart-checkout/checkout-events/index.tsx +++ b/assets/js/base/context/providers/cart-checkout/checkout-events/index.tsx @@ -24,9 +24,8 @@ import { * Internal dependencies */ import { useEventEmitters, reducer as emitReducer } from './event-emit'; -import type { emitterCallback } from '../../../event-emit'; +import { emitterCallback, noticeContexts } from '../../../event-emit'; import { useStoreEvents } from '../../../hooks/use-store-events'; -import { useCheckoutNotices } from '../../../hooks/use-checkout-notices'; import { getExpressPaymentMethods, getPaymentMethods, @@ -134,11 +133,29 @@ export const CheckoutEventsProvider = ( { } const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY ); - const { createErrorNotice } = useDispatch( 'core/notices' ); - const { dispatchCheckoutEvent } = useStoreEvents(); const { checkoutNotices, paymentNotices, expressPaymentNotices } = - useCheckoutNotices(); + useSelect( ( select ) => { + const { getNotices } = select( 'core/notices' ); + const checkoutContexts = Object.values( noticeContexts ).filter( + ( context ) => + context !== noticeContexts.PAYMENTS && + context !== noticeContexts.EXPRESS_PAYMENTS + ); + const allCheckoutNotices = checkoutContexts.reduce( + ( acc, context ) => { + return [ ...acc, ...getNotices( context ) ]; + }, + [] + ); + return { + checkoutNotices: allCheckoutNotices, + paymentNotices: getNotices( noticeContexts.PAYMENTS ), + expressPaymentNotices: getNotices( + noticeContexts.EXPRESS_PAYMENTS + ), + }; + }, [] ); const [ observers, observerDispatch ] = useReducer( emitReducer, {} ); const currentObservers = useRef( observers ); @@ -186,7 +203,6 @@ export const CheckoutEventsProvider = ( { }, [ isCheckoutBeforeProcessing, setValidationErrors, - createErrorNotice, __internalEmitValidateEvent, ] ); @@ -224,7 +240,6 @@ export const CheckoutEventsProvider = ( { isCheckoutBeforeProcessing, previousStatus, previousHasError, - createErrorNotice, checkoutNotices, expressPaymentNotices, paymentNotices, diff --git a/assets/js/base/context/providers/cart-checkout/checkout-processor.js b/assets/js/base/context/providers/cart-checkout/checkout-processor.ts similarity index 74% rename from assets/js/base/context/providers/cart-checkout/checkout-processor.js rename to assets/js/base/context/providers/cart-checkout/checkout-processor.ts index 01844818aac..8b333d15c29 100644 --- a/assets/js/base/context/providers/cart-checkout/checkout-processor.js +++ b/assets/js/base/context/providers/cart-checkout/checkout-processor.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import triggerFetch from '@wordpress/api-fetch'; import { useEffect, @@ -12,7 +12,7 @@ import { } from '@wordpress/element'; import { emptyHiddenAddressFields, - formatStoreApiErrorMessage, + removeAllNotices, } from '@woocommerce/base-utils'; import { useDispatch, useSelect } from '@wordpress/data'; import { @@ -20,11 +20,18 @@ import { PAYMENT_STORE_KEY, VALIDATION_STORE_KEY, CART_STORE_KEY, + processErrorResponse, } from '@woocommerce/block-data'; import { getPaymentMethods, getExpressPaymentMethods, } from '@woocommerce/blocks-registry'; +import { + ApiResponse, + CheckoutResponseSuccess, + CheckoutResponseError, + assertResponseIsValid, +} from '@woocommerce/types'; /** * Internal dependencies @@ -41,7 +48,6 @@ import { useStoreCart } from '../../hooks/cart/use-store-cart'; */ const CheckoutProcessor = () => { const { onCheckoutValidationBeforeProcessing } = useCheckoutEventsContext(); - const { hasError: checkoutHasError, redirectUrl, @@ -64,10 +70,8 @@ const CheckoutProcessor = () => { extensionData: store.getExtensionData(), }; } ); - const { __internalSetHasError, __internalProcessCheckoutResponse } = useDispatch( CHECKOUT_STORE_KEY ); - const hasValidationErrors = useSelect( ( select ) => select( VALIDATION_STORE_KEY ).hasValidationErrors ); @@ -78,7 +82,6 @@ const CheckoutProcessor = () => { ); const { cartNeedsPayment, cartNeedsShipping, receiveCart } = useStoreCart(); - const { createErrorNotice, removeNotice } = useDispatch( 'core/notices' ); const { activePaymentMethod, @@ -126,8 +129,8 @@ const CheckoutProcessor = () => { ( isPaymentSuccess || ! cartNeedsPayment ) && checkoutIsProcessing; - // Determine if checkout has an error. useEffect( () => { + // Determine if checkout has an error. if ( checkoutWillHaveError !== checkoutHasError && ( checkoutIsProcessing || checkoutIsBeforeProcessing ) && @@ -144,8 +147,8 @@ const CheckoutProcessor = () => { __internalSetHasError, ] ); - // Keep the billing, shipping and redirectUrl current useEffect( () => { + // Keep the billing, shipping and redirectUrl current currentBillingAddress.current = billingAddress; currentShippingAddress.current = shippingAddress; currentRedirectUrl.current = redirectUrl; @@ -175,9 +178,9 @@ const CheckoutProcessor = () => { return true; }, [ hasValidationErrors, hasPaymentError, shippingErrorStatus.hasError ] ); - // Validate the checkout using the CHECKOUT_VALIDATION_BEFORE_PROCESSING event useEffect( () => { - let unsubscribeProcessing; + // Validate the checkout using the CHECKOUT_VALIDATION_BEFORE_PROCESSING event. + let unsubscribeProcessing: () => void; if ( ! isExpressPaymentMethodActive ) { unsubscribeProcessing = onCheckoutValidationBeforeProcessing( checkValidation, @@ -185,7 +188,10 @@ const CheckoutProcessor = () => { ); } return () => { - if ( ! isExpressPaymentMethodActive ) { + if ( + ! isExpressPaymentMethodActive && + typeof unsubscribeProcessing === 'function' + ) { unsubscribeProcessing(); } }; @@ -195,8 +201,8 @@ const CheckoutProcessor = () => { isExpressPaymentMethodActive, ] ); - // Redirect when checkout is complete and there is a redirect url. useEffect( () => { + // Redirect when checkout is complete and there is a redirect url. if ( currentRedirectUrl.current ) { window.location.href = currentRedirectUrl.current; } @@ -207,8 +213,8 @@ const CheckoutProcessor = () => { if ( isProcessingOrder ) { return; } + removeAllNotices(); setIsProcessingOrder( true ); - removeNotice( 'checkout' ); const paymentData = cartNeedsPayment ? { @@ -221,97 +227,67 @@ const CheckoutProcessor = () => { } : {}; - const data = { - billing_address: emptyHiddenAddressFields( - currentBillingAddress.current - ), - customer_note: orderNotes, - create_account: shouldCreateAccount, - ...paymentData, - extensions: { ...extensionData }, - }; - - if ( cartNeedsShipping ) { - data.shipping_address = emptyHiddenAddressFields( - currentShippingAddress.current - ); - } - triggerFetch( { path: '/wc/store/v1/checkout', method: 'POST', - data, + data: { + shipping_address: cartNeedsShipping + ? emptyHiddenAddressFields( currentShippingAddress.current ) + : undefined, + billing_address: emptyHiddenAddressFields( + currentBillingAddress.current + ), + customer_note: orderNotes, + create_account: shouldCreateAccount, + ...paymentData, + extensions: { ...extensionData }, + }, cache: 'no-store', parse: false, } ) - .then( ( response ) => { + .then( ( response: unknown ) => { + assertResponseIsValid< CheckoutResponseSuccess >( response ); processCheckoutResponseHeaders( response.headers ); if ( ! response.ok ) { - throw new Error( response ); + throw response; } return response.json(); } ) - .then( ( responseJson ) => { + .then( ( responseJson: CheckoutResponseSuccess ) => { __internalProcessCheckoutResponse( responseJson ); setIsProcessingOrder( false ); } ) - .catch( ( errorResponse ) => { + .catch( ( errorResponse: ApiResponse< CheckoutResponseError > ) => { + processCheckoutResponseHeaders( errorResponse?.headers ); try { - if ( errorResponse?.headers ) { - processCheckoutResponseHeaders( errorResponse.headers ); - } // This attempts to parse a JSON error response where the status code was 4xx/5xx. - errorResponse.json().then( ( response ) => { - // If updated cart state was returned, update the store. - if ( response.data?.cart ) { - receiveCart( response.data.cart ); - } - createErrorNotice( - formatStoreApiErrorMessage( response ), - { - id: 'checkout', - context: 'wc/checkout', - __unstableHTML: true, + errorResponse + .json() + .then( + ( response ) => response as CheckoutResponseError + ) + .then( ( response: CheckoutResponseError ) => { + if ( response.data?.cart ) { + receiveCart( response.data.cart ); } - ); - response?.additional_errors?.forEach?.( - ( additionalError ) => { - createErrorNotice( additionalError.message, { - id: additionalError.error_code, - context: 'wc/checkout', - __unstableHTML: true, - } ); - } - ); - __internalProcessCheckoutResponse( response ); - } ); + processErrorResponse( response ); + __internalProcessCheckoutResponse( response ); + } ); } catch { - createErrorNotice( - sprintf( - // Translators: %s Error text. - __( - '%s Please try placing your order again.', - 'woo-gutenberg-products-block' - ), - errorResponse?.message ?? - __( - 'Something went wrong. Please contact us for assistance.', - 'woo-gutenberg-products-block' - ) + processErrorResponse( { + code: 'unknown_error', + message: __( + 'Something went wrong. Please try placing your order again.', + 'woo-gutenberg-products-block' ), - { - id: 'checkout', - context: 'wc/checkout', - __unstableHTML: true, - } - ); + data: null, + } ); } __internalSetHasError( true ); setIsProcessingOrder( false ); } ); }, [ isProcessingOrder, - removeNotice, cartNeedsPayment, paymentMethodId, paymentMethodData, @@ -321,7 +297,6 @@ const CheckoutProcessor = () => { shouldCreateAccount, extensionData, cartNeedsShipping, - createErrorNotice, receiveCart, __internalSetHasError, __internalProcessCheckoutResponse, diff --git a/assets/js/base/context/providers/cart-checkout/payment-events/index.tsx b/assets/js/base/context/providers/cart-checkout/payment-events/index.tsx index 085514f2855..10a38b2cea2 100644 --- a/assets/js/base/context/providers/cart-checkout/payment-events/index.tsx +++ b/assets/js/base/context/providers/cart-checkout/payment-events/index.tsx @@ -19,7 +19,6 @@ import { * Internal dependencies */ import { useEventEmitters, reducer as emitReducer } from './event-emit'; -import { useCustomerData } from '../../../hooks/use-customer-data'; import { emitterCallback } from '../../../event-emit'; type PaymentEventsContextType = { @@ -73,7 +72,6 @@ export const PaymentEventsProvider = ( { }; } ); - const { createErrorNotice, removeNotice } = useDispatch( 'core/notices' ); const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY ); const [ observers, observerDispatch ] = useReducer( emitReducer, {} ); const { onPaymentProcessing } = useEventEmitters( observerDispatch ); @@ -87,10 +85,8 @@ export const PaymentEventsProvider = ( { const { __internalSetPaymentProcessing, __internalSetPaymentPristine, - __internalSetPaymentMethodData, __internalEmitPaymentProcessingEvent, } = useDispatch( PAYMENT_STORE_KEY ); - const { setBillingAddress, setShippingAddress } = useCustomerData(); // flip payment to processing if checkout processing is complete, there are no errors, and payment status is started. useEffect( () => { @@ -139,11 +135,6 @@ export const PaymentEventsProvider = ( { }, [ isPaymentProcessing, setValidationErrors, - removeNotice, - createErrorNotice, - setBillingAddress, - __internalSetPaymentMethodData, - setShippingAddress, __internalEmitPaymentProcessingEvent, ] ); diff --git a/assets/js/base/context/providers/cart-checkout/utils.ts b/assets/js/base/context/providers/cart-checkout/utils.ts index 5151a987824..da9044f111a 100644 --- a/assets/js/base/context/providers/cart-checkout/utils.ts +++ b/assets/js/base/context/providers/cart-checkout/utils.ts @@ -31,7 +31,12 @@ export const preparePaymentData = ( /** * Process headers from an API response an dispatch updates. */ -export const processCheckoutResponseHeaders = ( headers: Headers ): void => { +export const processCheckoutResponseHeaders = ( + headers: Headers | undefined +): void => { + if ( ! headers ) { + return; + } const { __internalSetCustomerId } = dispatch( CHECKOUT_STORE_KEY ); if ( // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/assets/js/base/utils/create-notice.ts b/assets/js/base/utils/create-notice.ts new file mode 100644 index 00000000000..feeeb5a6cd5 --- /dev/null +++ b/assets/js/base/utils/create-notice.ts @@ -0,0 +1,101 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import type { Options as NoticeOptions } from '@wordpress/notices'; +import { select, dispatch } from '@wordpress/data'; + +export const GLOBAL_CONTEXT = 'wc/global'; +export const DEFAULT_ERROR_MESSAGE = __( + 'Something went wrong. Please contact us to get assistance.', + 'woo-gutenberg-products-block' +); + +export const hasStoreNoticeContainer = ( container: string ): boolean => { + const containers = select( 'wc/store/notices' ).getContainers(); + return containers.includes( container ); +}; + +const findParentContainer = ( container: string ): string => { + let parentContainer = GLOBAL_CONTEXT; + if ( + container.includes( 'wc/checkout/' ) && + hasStoreNoticeContainer( 'wc/checkout' ) + ) { + parentContainer = 'wc/checkout'; + } else if ( + container.includes( 'wc/cart/' ) && + hasStoreNoticeContainer( 'wc/cart' ) + ) { + parentContainer = 'wc/cart'; + } + return parentContainer; +}; + +/** + * Wrapper for @wordpress/notices createNotice. + * + * This is used to create the correct type of notice based on the provided context, and to ensure the notice container + * exists first, otherwise it uses the default context instead. + */ +export const createNotice = ( + status: 'error' | 'warning' | 'info' | 'success', + message: string, + options: Partial< NoticeOptions > +) => { + let noticeContext = options?.context || GLOBAL_CONTEXT; + + const suppressNotices = + select( 'wc/store/payment' ).isExpressPaymentMethodActive(); + + if ( suppressNotices ) { + return; + } + + if ( ! hasStoreNoticeContainer( noticeContext ) ) { + // If the container ref was not registered, use the parent context instead. + noticeContext = findParentContainer( noticeContext ); + } + + const { createNotice: dispatchCreateNotice } = dispatch( 'core/notices' ); + + dispatchCreateNotice( status, message, { + isDismissible: true, + __unstableHTML: true, + ...options, + context: noticeContext, + } ); +}; + +/** + * Creates a notice only if the Store Notice Container is visible. + */ +export const createNoticeIfVisible = ( + status: 'error' | 'warning' | 'info' | 'success', + message: string, + options: Partial< NoticeOptions > +) => { + const noticeContext = options?.context || GLOBAL_CONTEXT; + + if ( hasStoreNoticeContainer( noticeContext ) ) { + createNotice( status, message, options ); + } +}; + +/** + * Remove notices from all contexts. + * + * @todo Remove this when supported in Gutenberg. + * @see https://github.com/WordPress/gutenberg/pull/44059 + */ +export const removeAllNotices = () => { + const containers = select( 'wc/store/notices' ).getContainers(); + const { removeNotice } = dispatch( 'core/notices' ); + const { getNotices } = select( 'core/notices' ); + + containers.forEach( ( container ) => { + getNotices( container ).forEach( ( notice ) => { + removeNotice( notice.id, container ); + } ); + } ); +}; diff --git a/assets/js/base/utils/errors.js b/assets/js/base/utils/errors.js index dd8c5020851..a2bbb56b926 100644 --- a/assets/js/base/utils/errors.js +++ b/assets/js/base/utils/errors.js @@ -1,9 +1,3 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import { decodeEntities } from '@wordpress/html-entities'; - /** * Given a JS error or a fetch response error, parse and format it, so it can be displayed to the user. * @@ -34,27 +28,3 @@ export const formatError = async ( error ) => { type: error.type || 'general', }; }; - -/** - * Given an API response object, formats the error message into something more human-readable. - * - * @param {Object} response Response object. - * @return {string} Error message. - */ -export const formatStoreApiErrorMessage = ( response ) => { - if ( response.data && response.code === 'rest_invalid_param' ) { - const invalidParams = Object.values( response.data.params ); - if ( invalidParams[ 0 ] ) { - return invalidParams[ 0 ]; - } - } - - if ( ! response?.message ) { - return __( - 'Something went wrong. Please contact us to get assistance.', - 'woo-gutenberg-products-block' - ); - } - - return decodeEntities( response.message ); -}; diff --git a/assets/js/base/utils/index.js b/assets/js/base/utils/index.js index c81fa33c4b2..3439277d829 100644 --- a/assets/js/base/utils/index.js +++ b/assets/js/base/utils/index.js @@ -8,3 +8,4 @@ export * from './product-data'; export * from './derive-selected-shipping-rates'; export * from './get-icons-from-payment-methods'; export * from './parse-style'; +export * from './create-notice'; diff --git a/assets/js/blocks/cart/inner-blocks/filled-cart-block/frontend.tsx b/assets/js/blocks/cart/inner-blocks/filled-cart-block/frontend.tsx index 03ef72c9e34..e34d7b9d69f 100644 --- a/assets/js/blocks/cart/inner-blocks/filled-cart-block/frontend.tsx +++ b/assets/js/blocks/cart/inner-blocks/filled-cart-block/frontend.tsx @@ -24,7 +24,7 @@ const FrontendBlock = ( { const { hasDarkControls } = useCartBlockContext(); const { createErrorNotice } = useDispatch( 'core/notices' ); - // Ensures any cart errors listed in the API response get shown. + // @todo Cart errors need to be watched for and created as notices elsewhere. useEffect( () => { cartItemErrors.forEach( ( error ) => { createErrorNotice( decodeEntities( error.message ), { diff --git a/assets/js/blocks/checkout/block.tsx b/assets/js/blocks/checkout/block.tsx index f2a4de004e9..0a323fe7391 100644 --- a/assets/js/blocks/checkout/block.tsx +++ b/assets/js/blocks/checkout/block.tsx @@ -29,7 +29,6 @@ import CheckoutOrderError from './checkout-order-error'; import { LOGIN_TO_CHECKOUT_URL, isLoginRequired, reloadPage } from './utils'; import type { Attributes } from './types'; import { CheckoutBlockContext } from './context'; -import { hasNoticesOfType } from '../../utils/notices'; const LoginPrompt = () => { return ( diff --git a/assets/js/data/cart/actions.ts b/assets/js/data/cart/actions.ts index 8a1fdb037ce..c8928261dc9 100644 --- a/assets/js/data/cart/actions.ts +++ b/assets/js/data/cart/actions.ts @@ -7,6 +7,7 @@ import type { CartResponseItem, ExtensionCartUpdateArgs, BillingAddressShippingAddress, + ApiErrorResponse, } from '@woocommerce/types'; import { camelCase, mapKeys } from 'lodash'; import type { AddToCartEventDetail } from '@woocommerce/type-defs/events'; @@ -19,7 +20,6 @@ import { controls } from '@wordpress/data'; import { ACTION_TYPES as types } from './action-types'; import { STORE_KEY as CART_STORE_KEY } from './constants'; import { apiFetchWithHeaders } from '../shared-controls'; -import type { ResponseError } from '../types'; import { ReturnOrGeneratorYieldUnion } from '../mapped-types'; /** @@ -67,14 +67,9 @@ export const receiveCartContents = ( /** * Returns an action object used for receiving customer facing errors from the API. - * - * @param {ResponseError|null} [error=null] An error object containing the error - * message and response code. - * @param {boolean} [replace=true] Should existing errors be replaced, - * or should the error be appended. */ export const receiveError = ( - error: ResponseError | null = null, + error: ApiErrorResponse | null = null, replace = true ) => ( { diff --git a/assets/js/data/cart/default-state.ts b/assets/js/data/cart/default-state.ts index 066a1a2e889..f7f1d65fdd2 100644 --- a/assets/js/data/cart/default-state.ts +++ b/assets/js/data/cart/default-state.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { Cart, CartMeta } from '@woocommerce/types'; +import type { Cart, CartMeta, ApiErrorResponse } from '@woocommerce/types'; /** * Internal dependencies @@ -18,17 +18,16 @@ import { EMPTY_PAYMENT_REQUIREMENTS, EMPTY_EXTENSIONS, } from '../constants'; -import type { ResponseError } from '../types'; const EMPTY_PENDING_QUANTITY: [] = []; const EMPTY_PENDING_DELETE: [] = []; export interface CartState { - cartItemsPendingQuantity: Array< string >; - cartItemsPendingDelete: Array< string >; + cartItemsPendingQuantity: string[]; + cartItemsPendingDelete: string[]; cartData: Cart; metaData: CartMeta; - errors: Array< ResponseError >; + errors: ApiErrorResponse[]; } export const defaultCartState: CartState = { cartItemsPendingQuantity: EMPTY_PENDING_QUANTITY, diff --git a/assets/js/data/cart/push-changes.ts b/assets/js/data/cart/push-changes.ts index b55bc0bbdf6..d49680ea95c 100644 --- a/assets/js/data/cart/push-changes.ts +++ b/assets/js/data/cart/push-changes.ts @@ -4,9 +4,9 @@ import { debounce } from 'lodash'; import { select, dispatch } from '@wordpress/data'; import { - formatStoreApiErrorMessage, pluckAddress, pluckEmail, + removeAllNotices, } from '@woocommerce/base-utils'; import { CartResponseBillingAddress, @@ -19,6 +19,7 @@ import { BillingAddressShippingAddress } from '@woocommerce/type-defs/cart'; * Internal dependencies */ import { STORE_KEY } from './constants'; +import { processErrorResponse } from '../utils'; declare type CustomerData = { billingAddress: CartResponseBillingAddress; @@ -102,20 +103,10 @@ const updateCustomerData = debounce( (): void => { dispatch( STORE_KEY ) .updateCustomerData( customerDataToUpdate ) .then( () => { - dispatch( 'core/notices' ).removeNotice( - 'checkout', - 'wc/checkout' - ); + removeAllNotices(); } ) .catch( ( response ) => { - dispatch( 'core/notices' ).createNotice( - 'error', - formatStoreApiErrorMessage( response ), - { - id: 'checkout', - context: 'wc/checkout', - } - ); + processErrorResponse( response ); } ); } }, 1000 ); diff --git a/assets/js/data/cart/selectors.ts b/assets/js/data/cart/selectors.ts index 73b88c13d22..a466a9950ae 100644 --- a/assets/js/data/cart/selectors.ts +++ b/assets/js/data/cart/selectors.ts @@ -7,6 +7,7 @@ import type { CartMeta, CartItem, CartShippingRate, + ApiErrorResponse, } from '@woocommerce/types'; import { BillingAddress, ShippingAddress } from '@woocommerce/settings'; @@ -14,7 +15,6 @@ import { BillingAddress, ShippingAddress } from '@woocommerce/settings'; * Internal dependencies */ import { CartState, defaultCartState } from './default-state'; -import type { ResponseError } from '../types'; /** * Retrieves cart data from state. @@ -90,11 +90,8 @@ export const getCartMeta = ( state: CartState ): CartMeta => { /** * Retrieves cart errors from state. - * - * @param {CartState} state The current state. - * @return {Array} Array of errors. */ -export const getCartErrors = ( state: CartState ): Array< ResponseError > => { +export const getCartErrors = ( state: CartState ): ApiErrorResponse[] => { return state.errors; }; diff --git a/assets/js/data/checkout/types.ts b/assets/js/data/checkout/types.ts index 6057407e1da..448844f5757 100644 --- a/assets/js/data/checkout/types.ts +++ b/assets/js/data/checkout/types.ts @@ -13,7 +13,7 @@ import type { PaymentState } from '../payment/default-state'; import type { DispatchFromMap, SelectFromMap } from '../mapped-types'; import * as selectors from './selectors'; import * as actions from './actions'; -import { FieldValidationStatus } from '../types'; +import type { FieldValidationStatus } from '../types'; export type CheckoutAfterProcessingWithErrorEventData = { redirectUrl: CheckoutState[ 'redirectUrl' ]; diff --git a/assets/js/data/index.ts b/assets/js/data/index.ts index 8be09cf717f..5367e06bb06 100644 --- a/assets/js/data/index.ts +++ b/assets/js/data/index.ts @@ -16,3 +16,4 @@ export { QUERY_STATE_STORE_KEY } from './query-state'; export { STORE_NOTICES_STORE_KEY } from './store-notices'; export * from './constants'; export * from './types'; +export * from './utils'; diff --git a/assets/js/data/shared-controls.ts b/assets/js/data/shared-controls.ts index 6631f86b9d5..b2176112ca4 100644 --- a/assets/js/data/shared-controls.ts +++ b/assets/js/data/shared-controls.ts @@ -5,15 +5,11 @@ import { __ } from '@wordpress/i18n'; import triggerFetch, { APIFetchOptions } from '@wordpress/api-fetch'; import DataLoader from 'dataloader'; import { isWpVersion } from '@woocommerce/settings'; - -/** - * Internal dependencies - */ import { + ApiResponse, assertBatchResponseIsValid, assertResponseIsValid, - ApiResponse, -} from './types'; +} from '@woocommerce/types'; /** * Dispatched a control action for triggering an api fetch call with no parsing. diff --git a/assets/js/data/types.ts b/assets/js/data/types.ts index 491ff5ed4ed..e69de29bb2d 100644 --- a/assets/js/data/types.ts +++ b/assets/js/data/types.ts @@ -1,49 +0,0 @@ -export interface ResponseError { - code: string; - message: string; - data: { - status: number; - [ key: string ]: unknown; - }; -} - -export interface ApiResponse { - body: Record< string, unknown >; - headers: Headers; - status: number; -} - -export function assertBatchResponseIsValid( - response: unknown -): asserts response is { - responses: ApiResponse[]; - headers: Headers; -} { - if ( - typeof response === 'object' && - response !== null && - response.hasOwnProperty( 'responses' ) - ) { - return; - } - throw new Error( 'Response not valid' ); -} - -export function assertResponseIsValid( - response: unknown -): asserts response is ApiResponse { - if ( - typeof response === 'object' && - response !== null && - response.hasOwnProperty( 'body' ) && - response.hasOwnProperty( 'headers' ) - ) { - return; - } - throw new Error( 'Response not valid' ); -} - -export interface FieldValidationStatus { - message: string; - hidden: boolean; -} diff --git a/assets/js/data/utils/index.js b/assets/js/data/utils/index.js index c8d47bd6f98..19a8b886967 100644 --- a/assets/js/data/utils/index.js +++ b/assets/js/data/utils/index.js @@ -1,2 +1,3 @@ export { default as hasInState } from './has-in-state'; export { default as updateState } from './update-state'; +export { default as processErrorResponse } from './process-error-response'; diff --git a/assets/js/data/utils/process-error-response.ts b/assets/js/data/utils/process-error-response.ts new file mode 100644 index 00000000000..d7044d82163 --- /dev/null +++ b/assets/js/data/utils/process-error-response.ts @@ -0,0 +1,139 @@ +/** + * External dependencies + */ +import { + createNotice, + createNoticeIfVisible, + DEFAULT_ERROR_MESSAGE, +} from '@woocommerce/base-utils'; +import { decodeEntities } from '@wordpress/html-entities'; +import { isObject, objectHasProp, ApiErrorResponse } from '@woocommerce/types'; +import { noticeContexts } from '@woocommerce/base-context/event-emit/utils'; + +type ApiParamError = { + param: string; + id: string; + code: string; + message: string; +}; + +const isApiResponse = ( response: unknown ): response is ApiErrorResponse => { + return ( + isObject( response ) && + objectHasProp( response, 'code' ) && + objectHasProp( response, 'message' ) && + objectHasProp( response, 'data' ) + ); +}; + +/** + * Flattens error details which are returned from the API when multiple params are not valid. + * + * - Codes will be prefixed with the param. For example, `invalid_email` becomes `billing_address_invalid_email`. + * - Additional error messages will be flattened alongside the main error message. + * - Supports 1 level of nesting. + * - Decodes HTML entities in error messages. + */ +const getErrorDetails = ( response: ApiErrorResponse ): ApiParamError[] => { + const errorDetails = objectHasProp( response.data, 'details' ) + ? Object.entries( response.data.details ) + : null; + + if ( ! errorDetails ) { + return []; + } + + return errorDetails.reduce( + ( + acc, + [ + param, + { code, message, additional_errors: additionalErrors = [] }, + ] + ) => { + return [ + ...acc, + { + param, + id: `${ param }_${ code }`, + code, + message: decodeEntities( message ), + }, + ...( Array.isArray( additionalErrors ) + ? additionalErrors.flatMap( ( additionalError ) => { + if ( + ! objectHasProp( additionalError, 'code' ) || + ! objectHasProp( additionalError, 'message' ) + ) { + return []; + } + return [ + { + param, + id: `${ param }_${ additionalError.code }`, + code: additionalError.code, + message: decodeEntities( + additionalError.message + ), + }, + ]; + } ) + : [] ), + ]; + }, + [] as ApiParamError[] + ); +}; + +/** + * Processes the response for an invalid param error, with response code rest_invalid_param. + */ +const processInvalidParamResponse = ( response: ApiErrorResponse ) => { + const errorDetails = getErrorDetails( response ); + + errorDetails.forEach( ( { code, message, id, param } ) => { + switch ( code ) { + case 'invalid_email': + createNotice( 'error', message, { + id, + context: noticeContexts.CONTACT_INFORMATION, + } ); + return; + } + switch ( param ) { + case 'billing_address': + createNoticeIfVisible( 'error', message, { + id, + context: noticeContexts.BILLING_ADDRESS, + } ); + break; + case 'shipping_address': + createNoticeIfVisible( 'error', message, { + id, + context: noticeContexts.SHIPPING_ADDRESS, + } ); + break; + } + } ); +}; + +/** + * Takes an API response object and creates error notices to display to the customer. + */ +const processErrorResponse = ( response: ApiErrorResponse ) => { + if ( ! isApiResponse( response ) ) { + return; + } + switch ( response.code ) { + case 'rest_invalid_param': + processInvalidParamResponse( response ); + break; + default: + createNotice( 'error', response.message || DEFAULT_ERROR_MESSAGE, { + id: response.code, + context: noticeContexts.CHECKOUT, + } ); + } +}; + +export default processErrorResponse; diff --git a/assets/js/types/type-defs/api-error-response.ts b/assets/js/types/type-defs/api-error-response.ts new file mode 100644 index 00000000000..e7b64dc5afa --- /dev/null +++ b/assets/js/types/type-defs/api-error-response.ts @@ -0,0 +1,28 @@ +/** + * Internal dependencies + */ +import type { CartResponse } from './cart-response'; + +// This is the standard API response data when an error is returned. +export type ApiErrorResponse = { + code: string; + message: string; + data: ApiErrorResponseData; +}; + +// API errors contain data with the status, and more in-depth error details. This may be null. +export type ApiErrorResponseData = { + status: number; + params: Record< string, string >; + details: Record< string, ApiErrorResponseDataDetails >; + // Some endpoints return cart data to update the client. + cart?: CartResponse | undefined; +} | null; + +// The details object lists individual errors for each field. +export type ApiErrorResponseDataDetails = { + code: string; + message: string; + data: ApiErrorResponseData; + additional_errors: ApiErrorResponse[]; +}; diff --git a/assets/js/types/type-defs/api-response.ts b/assets/js/types/type-defs/api-response.ts new file mode 100644 index 00000000000..7a8e5d9e543 --- /dev/null +++ b/assets/js/types/type-defs/api-response.ts @@ -0,0 +1,37 @@ +export interface ApiResponse< T > { + body: Record< string, unknown >; + headers: Headers; + status: number; + ok: boolean; + json: () => Promise< T >; +} + +export function assertBatchResponseIsValid( + response: unknown +): asserts response is { + responses: ApiResponse< unknown >[]; + headers: Headers; +} { + if ( + typeof response === 'object' && + response !== null && + response.hasOwnProperty( 'responses' ) + ) { + return; + } + throw new Error( 'Response not valid' ); +} + +export function assertResponseIsValid< T >( + response: unknown +): asserts response is ApiResponse< T > { + if ( + typeof response === 'object' && + response !== null && + response.hasOwnProperty( 'body' ) && + response.hasOwnProperty( 'headers' ) + ) { + return; + } + throw new Error( 'Response not valid' ); +} diff --git a/assets/js/types/type-defs/checkout.ts b/assets/js/types/type-defs/checkout.ts index 45c038dba8d..41a49eef0c3 100644 --- a/assets/js/types/type-defs/checkout.ts +++ b/assets/js/types/type-defs/checkout.ts @@ -3,6 +3,11 @@ */ import { ShippingAddress, BillingAddress } from '@woocommerce/settings'; +/** + * Internal dependencies + */ +import type { ApiErrorResponse } from './api-error-response'; + export interface CheckoutResponseSuccess { billing_address: BillingAddress; customer_id: number; @@ -20,12 +25,6 @@ export interface CheckoutResponseSuccess { status: string; } -export interface CheckoutResponseError { - code: string; - message: string; - data: { - status: number; - }; -} +export type CheckoutResponseError = ApiErrorResponse; export type CheckoutResponse = CheckoutResponseSuccess | CheckoutResponseError; diff --git a/assets/js/types/type-defs/hooks.ts b/assets/js/types/type-defs/hooks.ts index 09cead7e6c8..9d1b51c3f7c 100644 --- a/assets/js/types/type-defs/hooks.ts +++ b/assets/js/types/type-defs/hooks.ts @@ -18,17 +18,17 @@ import type { CartResponse, CartResponseCoupons, } from './cart-response'; -import type { ResponseError } from '../../data/types'; +import type { ApiErrorResponse } from './api-error-response'; export interface StoreCartItemQuantity { isPendingDelete: boolean; quantity: number; setItemQuantity: React.Dispatch< React.SetStateAction< number > >; removeItem: () => Promise< boolean >; - cartItemQuantityErrors: Array< CartResponseErrorItem >; + cartItemQuantityErrors: CartResponseErrorItem[]; } export interface StoreCartCoupon { - appliedCoupons: Array< CartResponseCouponItem >; + appliedCoupons: CartResponseCouponItem[]; isLoading: boolean; applyCoupon: ( coupon: string ) => void; removeCoupon: ( coupon: string ) => void; @@ -38,24 +38,24 @@ export interface StoreCartCoupon { export interface StoreCart { cartCoupons: CartResponseCoupons; - cartItems: Array< CartResponseItem >; - crossSellsProducts: Array< ProductResponseItem >; - cartFees: Array< CartResponseFeeItem >; + cartItems: CartResponseItem[]; + crossSellsProducts: ProductResponseItem[]; + cartFees: CartResponseFeeItem[]; cartItemsCount: number; cartItemsWeight: number; cartNeedsPayment: boolean; cartNeedsShipping: boolean; - cartItemErrors: Array< CartResponseErrorItem >; + cartItemErrors: CartResponseErrorItem[]; cartTotals: CartResponseTotals; cartIsLoading: boolean; - cartErrors: Array< ResponseError >; + cartErrors: ApiErrorResponse[]; billingAddress: CartResponseBillingAddress; shippingAddress: CartResponseShippingAddress; - shippingRates: Array< CartResponseShippingRate >; + shippingRates: CartResponseShippingRate[]; extensions: Record< string, unknown >; isLoadingRates: boolean; cartHasCalculatedShipping: boolean; - paymentRequirements: Array< string >; + paymentRequirements: string[]; receiveCart: ( cart: CartResponse ) => void; } diff --git a/assets/js/types/type-defs/index.ts b/assets/js/types/type-defs/index.ts index 4b2fe4bc849..3f8d79018bf 100644 --- a/assets/js/types/type-defs/index.ts +++ b/assets/js/types/type-defs/index.ts @@ -1,3 +1,5 @@ +export * from './api-response'; +export * from './api-error-response'; export * from './blocks'; export * from './cart'; export * from './cart-response'; diff --git a/packages/checkout/components/store-notices-container/store-notices.tsx b/packages/checkout/components/store-notices-container/store-notices.tsx index 07e99d70b5c..b8dddd70c09 100644 --- a/packages/checkout/components/store-notices-container/store-notices.tsx +++ b/packages/checkout/components/store-notices-container/store-notices.tsx @@ -35,13 +35,30 @@ const StoreNotices = ( { useEffect( () => { // Scroll to container when an error is added here. + const containerRef = ref.current; + + if ( ! containerRef ) { + return; + } + + // Do not scroll if input has focus. + const activeElement = containerRef.ownerDocument.activeElement; + const inputs = [ 'input', 'select', 'button', 'textarea' ]; + + if ( + activeElement && + inputs.indexOf( activeElement.tagName.toLowerCase() ) !== -1 + ) { + return; + } + const newNoticeIds = noticeIds.filter( ( value ) => ! previousNoticeIds || ! previousNoticeIds.includes( value ) ); if ( newNoticeIds.length ) { - ref.current?.scrollIntoView( { + containerRef.scrollIntoView( { behavior: 'smooth', } ); } diff --git a/packages/checkout/filter-registry/index.ts b/packages/checkout/filter-registry/index.ts index 058b7d70cc8..91efcbb66a8 100644 --- a/packages/checkout/filter-registry/index.ts +++ b/packages/checkout/filter-registry/index.ts @@ -1,7 +1,6 @@ /** * External dependencies */ -import { useMemo } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings'; import deprecated from '@wordpress/deprecated'; diff --git a/packages/checkout/utils/create-notice.ts b/packages/checkout/utils/create-notice.ts deleted file mode 100644 index 4237d6bded0..00000000000 --- a/packages/checkout/utils/create-notice.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * External dependencies - */ -import type { Options as NoticeOptions } from '@wordpress/notices'; -import { - STORE_NOTICES_STORE_KEY, - PAYMENT_STORE_KEY, -} from '@woocommerce/block-data'; -import { select, dispatch } from '@wordpress/data'; - -const DEFAULT_CONTEXT = 'wc/global'; - -const hasContainer = ( container: string ): boolean => { - const containers = select( STORE_NOTICES_STORE_KEY ).getContainers(); - return containers.includes( container ); -}; - -const findParentContainer = ( container: string ): string => { - let parentContainer = DEFAULT_CONTEXT; - if ( - container.includes( 'wc/checkout/' ) && - hasContainer( 'wc/checkout' ) - ) { - parentContainer = 'wc/checkout'; - } else if ( - container.includes( 'wc/cart/' ) && - hasContainer( 'wc/cart' ) - ) { - parentContainer = 'wc/cart'; - } - return parentContainer; -}; - -/** - * Wrapper for @wordpress/notices createNotice. - * - * This is used to create the correct type of notice based on the provided context, and to ensure the notice container - * exists first, otherwise it uses the default context instead. - */ -export const createNotice = ( - status: 'error' | 'warning' | 'info' | 'success', - message: string, - options: Partial< NoticeOptions > -) => { - let noticeContext = options?.context || DEFAULT_CONTEXT; - - const suppressNotices = - select( PAYMENT_STORE_KEY ).isExpressPaymentMethodActive(); - - if ( suppressNotices ) { - return; - } - - if ( ! hasContainer( noticeContext ) ) { - // If the container ref was not registered, use the parent context instead. - noticeContext = findParentContainer( noticeContext ); - } - - const { createNotice: dispatchCreateNotice } = dispatch( 'core/notices' ); - - dispatchCreateNotice( status, message, { - isDismissible: true, - ...options, - context: noticeContext, - } ); -}; diff --git a/packages/checkout/utils/index.js b/packages/checkout/utils/index.js index 24c2979de55..c48b5ad34b3 100644 --- a/packages/checkout/utils/index.js +++ b/packages/checkout/utils/index.js @@ -1,3 +1,2 @@ export * from './validation'; -export * from './create-notice'; export { extensionCartUpdate } from './extension-cart-update';