Fix extensionCartUpdates not surfacing errors to cart and checkout (#49762)
* Add context to type * Clean up notifyerrors helpers * Notify and clear top level cart errors * changelog * Make extensionCartUpdate surface the errors * No automatic clearing of notice * Handle generic errors only, add docs * Rename param * Fix linting in readme * Update toc * test coverage * Unused import * We're only dealing with arrays of errors so simplify logic
This commit is contained in:
parent
f3a1281cd0
commit
a932ceb59f
|
@ -7,26 +7,47 @@ import { decodeEntities } from '@wordpress/html-entities';
|
|||
import { dispatch } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* This function is used to notify the user of cart item errors/conflicts
|
||||
* This function is used to normalize errors into an array of valid ApiErrorResponse objects.
|
||||
*/
|
||||
const filterValidErrors = ( errors: ApiErrorResponse[] ) => {
|
||||
return errors.filter( isApiErrorResponse );
|
||||
};
|
||||
|
||||
/**
|
||||
* This function is used to notify the user of errors/conflicts from an API error response object.
|
||||
*/
|
||||
const createNoticesFromErrors = ( errors: ApiErrorResponse[] ) => {
|
||||
errors.forEach( ( error ) => {
|
||||
createNotice( 'error', decodeEntities( error.message ), {
|
||||
id: error.code,
|
||||
context: error?.data?.context || 'wc/cart',
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* This function is used to dismiss old errors from the store.
|
||||
*/
|
||||
const dismissNoticesFromErrors = ( errors: ApiErrorResponse[] ) => {
|
||||
errors.forEach( ( error ) => {
|
||||
dispatch( 'core/notices' ).removeNotice(
|
||||
error.code,
|
||||
error?.data?.context || 'wc/cart'
|
||||
);
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* This function is used to notify the user of cart 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 ( oldErrors !== null ) {
|
||||
dismissNoticesFromErrors( filterValidErrors( oldErrors ) );
|
||||
}
|
||||
|
||||
if ( errors !== null ) {
|
||||
errors.forEach( ( error ) => {
|
||||
if ( isApiErrorResponse( error ) ) {
|
||||
createNotice( 'error', decodeEntities( error.message ), {
|
||||
id: error.code,
|
||||
context: 'wc/cart',
|
||||
} );
|
||||
}
|
||||
} );
|
||||
createNoticesFromErrors( filterValidErrors( errors ) );
|
||||
}
|
||||
};
|
||||
|
|
|
@ -41,6 +41,7 @@ export const receiveCart =
|
|||
cartItemsPendingDelete: select.getItemsPendingDelete(),
|
||||
} );
|
||||
dispatch.setCartData( newCart );
|
||||
dispatch.setErrorData( null );
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -65,13 +66,13 @@ export const receiveCartContents =
|
|||
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 );
|
||||
}
|
||||
if ( ! isApiErrorResponse( response ) ) {
|
||||
return;
|
||||
}
|
||||
if ( response.data?.cart ) {
|
||||
dispatch.receiveCart( response?.data?.cart );
|
||||
}
|
||||
dispatch.setErrorData( response );
|
||||
};
|
||||
|
||||
export type Thunks =
|
||||
|
|
|
@ -209,6 +209,9 @@ export const processErrorResponse = (
|
|||
|
||||
createNotice( 'error', errorMessage, {
|
||||
id: response.code,
|
||||
context: context || getErrorContextFromCode( response.code ),
|
||||
context:
|
||||
context ||
|
||||
response?.data?.context ||
|
||||
getErrorContextFromCode( response.code ),
|
||||
} );
|
||||
};
|
||||
|
|
|
@ -15,6 +15,7 @@ export type ApiErrorResponseData = {
|
|||
status: number;
|
||||
params: Record< string, string >;
|
||||
details: Record< string, ApiErrorResponseDataDetails >;
|
||||
context?: string;
|
||||
// Some endpoints return cart data to update the client.
|
||||
cart?: CartResponse | undefined;
|
||||
} | null;
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
ShippingRateItem,
|
||||
ExtensionsData,
|
||||
} from './cart-response';
|
||||
|
||||
import { ApiErrorResponse } from './api-error-response';
|
||||
import {
|
||||
ProductResponseItemData,
|
||||
ProductResponseItem,
|
||||
|
@ -182,11 +182,6 @@ export interface CartTotals extends CurrencyInfo {
|
|||
tax_lines: Array< CartTotalsTaxLineItem >;
|
||||
}
|
||||
|
||||
export interface CartErrorItem {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface Cart extends Record< string, unknown > {
|
||||
coupons: Array< CartCouponItem >;
|
||||
shippingRates: Array< CartShippingRate >;
|
||||
|
@ -201,7 +196,7 @@ export interface Cart extends Record< string, unknown > {
|
|||
hasCalculatedShipping: boolean;
|
||||
fees: Array< CartFeeItem >;
|
||||
totals: CartTotals;
|
||||
errors: Array< CartErrorItem >;
|
||||
errors: Array< ApiErrorResponse >;
|
||||
paymentMethods: Array< string >;
|
||||
paymentRequirements: Array< string >;
|
||||
extensions: ExtensionsData;
|
||||
|
|
|
@ -48,6 +48,7 @@ and on the client side:
|
|||
|
||||
```ts
|
||||
const { extensionCartUpdate } = wc.blocksCheckout;
|
||||
const { processErrorResponse } = wc.wcBlocksData;
|
||||
|
||||
extensionCartUpdate( {
|
||||
namespace: 'extension-unique-namespace',
|
||||
|
@ -58,6 +59,11 @@ extensionCartUpdate( {
|
|||
fourth_key: true,
|
||||
},
|
||||
},
|
||||
} ).then( () => {
|
||||
// Cart has been updated.
|
||||
} ).catch( ( error ) => {
|
||||
// Handle error.
|
||||
processErrorResponse(error);
|
||||
} );
|
||||
```
|
||||
|
||||
|
|
|
@ -2,23 +2,23 @@
|
|||
|
||||
## Table of Contents <!-- omit in toc -->
|
||||
|
||||
- [`extensionCartUpdate`](#extensioncartupdate)
|
||||
- [Usage](#usage)
|
||||
- [Options](#options)
|
||||
- [`args (object, required)`](#args-object-required)
|
||||
- [`mustContain`](#mustcontain)
|
||||
- [Usage](#usage-1)
|
||||
- [Options](#options-1)
|
||||
- [`value (string, required)`](#value-string-required)
|
||||
- [`requiredValue (string, required)`](#requiredvalue-string-required)
|
||||
- [`extensionCartUpdate`](#extensioncartupdate)
|
||||
- [`extensionCartUpdate` Usage](#extensioncartupdate-usage)
|
||||
- [`extensionCartUpdate` Options](#extensioncartupdate-options)
|
||||
- [`args (object, required)`](#args-object-required)
|
||||
- [`mustContain`](#mustcontain)
|
||||
- [`mustContain` Usage](#mustcontain-usage)
|
||||
- [`mustContain` Options](#mustcontain-options)
|
||||
- [`value (string, required)`](#value-string-required)
|
||||
- [`requiredValue (string, required)`](#requiredvalue-string-required)
|
||||
|
||||
Miscellaneous utility functions for dealing with checkout functionality.
|
||||
|
||||
## `extensionCartUpdate`
|
||||
|
||||
When executed, this will call the cart/extensions REST API endpoint. The new cart is then received into the client-side store.
|
||||
When executed, this will call the cart/extensions REST API endpoint. The new cart is then received into the client-side store. `extensionCartUpdate` returns a promise that resolves when the cart is updated which should also be used for error handling.
|
||||
|
||||
### Usage
|
||||
### `extensionCartUpdate` Usage
|
||||
|
||||
```ts
|
||||
// Aliased import
|
||||
|
@ -32,10 +32,14 @@ extensionCartUpdate( {
|
|||
data: {
|
||||
key: 'value',
|
||||
},
|
||||
} ).then( () => {
|
||||
// Cart has been updated.
|
||||
} ).catch( ( error ) => {
|
||||
// Handle error.
|
||||
} );
|
||||
```
|
||||
|
||||
### Options
|
||||
### `extensionCartUpdate` Options
|
||||
|
||||
The following options are available:
|
||||
|
||||
|
@ -47,7 +51,7 @@ Args to pass to the Rest API endpoint. This can contain data and a namespace to
|
|||
|
||||
Ensures that a given value contains a string, or throws an error.
|
||||
|
||||
### Usage
|
||||
### `mustContain` Usage
|
||||
|
||||
```js
|
||||
// Aliased import
|
||||
|
@ -60,7 +64,7 @@ mustContain( 'This is a string containing a <price />', '<price />' ); // This w
|
|||
mustContain( 'This is a string', '<price />' ); // This will throw an error
|
||||
```
|
||||
|
||||
### Options
|
||||
### `mustContain` Options
|
||||
|
||||
The following options are available:
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { CartResponse, ExtensionCartUpdateArgs } from '@woocommerce/types';
|
||||
import { processErrorResponse } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -19,5 +20,10 @@ export const extensionCartUpdate = (
|
|||
args: ExtensionCartUpdateArgs
|
||||
): Promise< CartResponse > => {
|
||||
const { applyExtensionCartUpdate } = dispatch( STORE_KEY );
|
||||
return applyExtensionCartUpdate( args );
|
||||
return applyExtensionCartUpdate( args ).catch( ( error ) => {
|
||||
if ( error?.code === 'woocommerce_rest_cart_extensions_error' ) {
|
||||
processErrorResponse( error );
|
||||
}
|
||||
return Promise.reject( error );
|
||||
} );
|
||||
};
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
/**
|
||||
* Plugin Name: WooCommerce Blocks Test Cart Extensions
|
||||
* Description: Adds callbacks for cart extensions.
|
||||
* Plugin URI: https://github.com/woocommerce/woocommerce
|
||||
* Author: WooCommerce
|
||||
*
|
||||
* @package woocommerce-blocks-test-cart-extensions
|
||||
*/
|
||||
|
||||
class Cart_Extensions_Test_Helper {
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
add_action( 'woocommerce_blocks_loaded', array( $this, 'register_update_callbacks' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callbacks.
|
||||
*/
|
||||
public function register_update_callbacks() {
|
||||
woocommerce_store_api_register_update_callback(
|
||||
array(
|
||||
'namespace' => 'cart-extensions-test-helper',
|
||||
'callback' => function () {
|
||||
throw new Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'test_error', 'This is an error with cart context.', 400, array( 'context' => 'wc/cart' ) );
|
||||
},
|
||||
)
|
||||
);
|
||||
woocommerce_store_api_register_update_callback(
|
||||
array(
|
||||
'namespace' => 'cart-extensions-test-helper-2',
|
||||
'callback' => function () {
|
||||
throw new Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'woocommerce_rest_cart_extensions_error', 'This is an error with cart context.', 400, array( 'context' => 'wc/cart' ) );
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
new Cart_Extensions_Test_Helper();
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { expect, test as base } from '@woocommerce/e2e-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { CheckoutPage } from '../checkout/checkout.page';
|
||||
import { REGULAR_PRICED_PRODUCT_NAME } from '../checkout/constants';
|
||||
|
||||
const test = base.extend< { checkoutPageObject: CheckoutPage } >( {
|
||||
checkoutPageObject: async ( { page }, use ) => {
|
||||
const pageObject = new CheckoutPage( {
|
||||
page,
|
||||
} );
|
||||
await use( pageObject );
|
||||
},
|
||||
} );
|
||||
|
||||
test.describe( 'Shopper → Cart Extension Callbacks', () => {
|
||||
test( 'Custom error code creates exception', async ( {
|
||||
frontendUtils,
|
||||
requestUtils,
|
||||
page,
|
||||
} ) => {
|
||||
await requestUtils.activatePlugin(
|
||||
'woocommerce-blocks-test-cart-extensions'
|
||||
);
|
||||
|
||||
await frontendUtils.goToShop();
|
||||
await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
|
||||
await frontendUtils.goToCart();
|
||||
|
||||
await expect(
|
||||
page.evaluate( () =>
|
||||
window.wc.blocksCheckout
|
||||
.extensionCartUpdate( {
|
||||
namespace: 'cart-extensions-test-helper',
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
throw new Error( error.message );
|
||||
} )
|
||||
)
|
||||
).rejects.toThrow( 'This is an error with cart context.' );
|
||||
} );
|
||||
|
||||
test( 'Error code `woocommerce_rest_cart_extensions_error` creates notice', async ( {
|
||||
frontendUtils,
|
||||
requestUtils,
|
||||
page,
|
||||
} ) => {
|
||||
await requestUtils.activatePlugin(
|
||||
'woocommerce-blocks-test-cart-extensions'
|
||||
);
|
||||
|
||||
await frontendUtils.goToShop();
|
||||
await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
|
||||
await frontendUtils.goToCart();
|
||||
|
||||
await page.evaluate( () => {
|
||||
window.wc.blocksCheckout.extensionCartUpdate( {
|
||||
namespace: 'cart-extensions-test-helper-2',
|
||||
} );
|
||||
} );
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator( '.wc-block-components-notice-banner__content' )
|
||||
.getByText( 'This is an error with cart context.' )
|
||||
).toBeVisible();
|
||||
} );
|
||||
|
||||
test( 'Invalid callback namespace creates notice', async ( {
|
||||
frontendUtils,
|
||||
page,
|
||||
} ) => {
|
||||
await frontendUtils.goToShop();
|
||||
await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
|
||||
await frontendUtils.goToCart();
|
||||
|
||||
await page.evaluate( () => {
|
||||
window.wc.blocksCheckout.extensionCartUpdate( {
|
||||
namespace: 'invalid-namespace',
|
||||
} );
|
||||
} );
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator( '.wc-block-components-notice-banner__content' )
|
||||
.getByText(
|
||||
'There is no such namespace registered: invalid-namespace.'
|
||||
)
|
||||
).toBeVisible();
|
||||
} );
|
||||
} );
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: fix
|
||||
|
||||
Fix extensionCartUpdates to surface generic error messages, and include documentation for the error handling.
|
|
@ -65,21 +65,27 @@ class CartExtensionsSchema extends AbstractSchema {
|
|||
} catch ( \Exception $e ) {
|
||||
throw new RouteException(
|
||||
'woocommerce_rest_cart_extensions_error',
|
||||
$e->getMessage(),
|
||||
esc_html( $e->getMessage() ),
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
$controller = new CartController();
|
||||
// Run the callback. Exceptions are not caught here.
|
||||
$callback( $request['data'] );
|
||||
|
||||
if ( is_callable( $callback ) ) {
|
||||
$callback( $request['data'] );
|
||||
try {
|
||||
// We recalculate the cart if we had something to run.
|
||||
$controller->calculate_totals();
|
||||
$controller = new CartController();
|
||||
$cart = $controller->calculate_totals();
|
||||
$response = $this->cart_schema->get_item_response( $cart );
|
||||
|
||||
return rest_ensure_response( $response );
|
||||
} catch ( \Exception $e ) {
|
||||
throw new RouteException(
|
||||
'woocommerce_rest_cart_extensions_error',
|
||||
esc_html( $e->getMessage() ),
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
$cart = $controller->get_cart_instance();
|
||||
|
||||
return rest_ensure_response( $this->cart_schema->get_item_response( $cart ) );
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue