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:
Mike Jolley 2024-08-14 15:24:44 +01:00 committed by GitHub
parent f3a1281cd0
commit a932ceb59f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 237 additions and 52 deletions

View File

@ -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 ) );
}
};

View File

@ -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 =

View File

@ -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 ),
} );
};

View File

@ -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;

View File

@ -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;

View File

@ -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);
} );
```

View File

@ -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:

View File

@ -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 );
} );
};

View File

@ -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();

View File

@ -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();
} );
} );

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix extensionCartUpdates to surface generic error messages, and include documentation for the error handling.

View File

@ -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 ) );
}
}