Allow coupons to be applied and removed from the cart (https://github.com/woocommerce/woocommerce-blocks/pull/1790)

* useStoreCartCoupons hook

* Apply coupon w/ basic error handling for the fetch

* Basic store specifically for cart data

* Working on error states

* Show error on coupon fail

* removeCoupon action

* Added extra endpoints for more efficient cart queries

* Apply/remove coupons working

* Track applying/removing state

* StoreCartCoupon typedef

* Use coupon code on index

* Remove custom controls definition

* Adjust storecartcoupons mapper and remove ref

* Move cartData defaults and remove ref

* Call API directly, avoid schema lookup

* Improved selectors

* StoreCart typedef

* Split up cart state data and add more typedefs

* Add API tests for apply/remove coupon

* Jest tests

* Move default cart data to constant

* Comment indentation
This commit is contained in:
Mike Jolley 2020-02-25 11:36:53 +00:00 committed by GitHub
parent d803f6cc64
commit 6d7fdf50e0
26 changed files with 1348 additions and 107 deletions

View File

@ -70,7 +70,7 @@ const ShippingRatesControl = ( {
'Loading shipping rates…', 'Loading shipping rates…',
'woo-gutenberg-products-block' 'woo-gutenberg-products-block'
) } ) }
showSpinner={ true } showSpinner={ false }
> >
<Packages <Packages
className={ className } className={ className }

View File

@ -2,7 +2,7 @@
* External dependencies * External dependencies
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element'; import { useState, useEffect, useRef } from '@wordpress/element';
import { PanelBody, PanelRow } from 'wordpress-components'; import { PanelBody, PanelRow } from 'wordpress-components';
import Button from '@woocommerce/base-components/button'; import Button from '@woocommerce/base-components/button';
import TextInput from '@woocommerce/base-components/text-input'; import TextInput from '@woocommerce/base-components/text-input';
@ -14,9 +14,25 @@ import withComponentId from '@woocommerce/base-hocs/with-component-id';
* Internal dependencies * Internal dependencies
*/ */
import './style.scss'; import './style.scss';
import LoadingMask from '../../loading-mask';
const TotalsCouponCodeInput = ( { componentId, onSubmit } ) => { const TotalsCouponCodeInput = ( {
componentId,
isLoading = false,
onSubmit = () => {},
} ) => {
const [ couponValue, setCouponValue ] = useState( '' ); const [ couponValue, setCouponValue ] = useState( '' );
const currentIsLoading = useRef( false );
useEffect( () => {
if ( currentIsLoading.current !== isLoading ) {
if ( ! isLoading && couponValue ) {
setCouponValue( '' );
}
currentIsLoading.current = isLoading;
}
}, [ isLoading, couponValue ] );
return ( return (
<PanelBody <PanelBody
className="wc-block-coupon-code" className="wc-block-coupon-code"
@ -35,32 +51,46 @@ const TotalsCouponCodeInput = ( { componentId, onSubmit } ) => {
} }
initialOpen={ true } initialOpen={ true }
> >
<PanelRow className="wc-block-coupon-code__row"> <LoadingMask
<TextInput screenReaderLabel={ __(
id={ `wc-block-coupon-code__input-${ componentId }` } 'Applying coupon…',
className="wc-block-coupon-code__input" 'woo-gutenberg-products-block'
label={ __( 'Enter code', 'woo-gutenberg-products-block' ) } ) }
value={ couponValue } isLoading={ isLoading }
onChange={ ( newCouponValue ) => showSpinner={ false }
setCouponValue( newCouponValue ) >
} <PanelRow className="wc-block-coupon-code__row">
/> <TextInput
<Button id={ `wc-block-coupon-code__input-${ componentId }` }
className="wc-block-coupon-code__button" className="wc-block-coupon-code__input"
onClick={ () => { label={ __(
onSubmit( couponValue ); 'Enter code',
} } 'woo-gutenberg-products-block'
type="submit" ) }
> value={ couponValue }
{ __( 'Apply', 'woo-gutenberg-products-block' ) } onChange={ ( newCouponValue ) =>
</Button> setCouponValue( newCouponValue )
</PanelRow> }
/>
<Button
className="wc-block-coupon-code__button"
disabled={ isLoading }
onClick={ () => {
onSubmit( couponValue );
} }
type="submit"
>
{ __( 'Apply', 'woo-gutenberg-products-block' ) }
</Button>
</PanelRow>
</LoadingMask>
</PanelBody> </PanelBody>
); );
}; };
TotalsCouponCodeInput.propTypes = { TotalsCouponCodeInput.propTypes = {
onSubmit: PropTypes.func, onSubmit: PropTypes.func,
isLoading: PropTypes.bool,
}; };
export default withComponentId( TotalsCouponCodeInput ); export default withComponentId( TotalsCouponCodeInput );

View File

@ -1,6 +1,7 @@
export * from './use-query-state'; export * from './use-query-state';
export * from './use-shallow-equal'; export * from './use-shallow-equal';
export * from './use-store-cart'; export * from './use-store-cart';
export * from './use-store-cart-coupons';
export * from './use-store-products'; export * from './use-store-products';
export * from './use-collection'; export * from './use-collection';
export * from './use-collection-header'; export * from './use-collection-header';

View File

@ -0,0 +1,44 @@
/** @typedef { import('@woocommerce/type-defs/hooks').StoreCartCoupon } StoreCartCoupon */
/**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { useStoreCart } from './use-store-cart';
/**
* This is a custom hook for loading the Store API /cart/coupons endpoint and an
* action for adding a coupon _to_ the cart.
* See also: https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/master/src/RestApi/StoreApi
*
* @return {StoreCartCoupon} An object exposing data and actions from/for the
* store api /cart/coupons endpoint.
*/
export const useStoreCartCoupons = () => {
const { cartCoupons, cartIsLoading } = useStoreCart();
const results = useSelect( ( select, { dispatch } ) => {
const store = select( storeKey );
const isApplyingCoupon = store.isApplyingCoupon();
const isRemovingCoupon = store.isRemovingCoupon();
const { applyCoupon, removeCoupon } = dispatch( storeKey );
return {
applyCoupon,
removeCoupon,
isApplyingCoupon,
isRemovingCoupon,
};
}, [] );
return {
appliedCoupons: cartCoupons,
isLoading: cartIsLoading,
...results,
};
};

View File

@ -1,24 +1,69 @@
/** /** @typedef { import('@woocommerce/type-defs/hooks').StoreCart } StoreCart */
* Internal dependencies
*/
import { useCollection } from './use-collection';
/** /**
* This is a custom hook for loading the Store API /cart endpoint. * External dependencies
* See also: https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/master/src/RestApi/StoreApi
*
* @return {Object} This hook will return an object with these properties:
* - cartData Cart data (see cart API for details).
* - cartIsLoading True when cart data has completed loading.
*/ */
export const useStoreCart = () => { import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
const collectionOptions = { import { useSelect } from '@wordpress/data';
namespace: '/wc/store',
resourceName: 'cart', /**
}; * @constant
const { results: cartData, isLoading } = useCollection( collectionOptions ); * @type {StoreCart} Object containing cart data.
return { */
cartData, const defaultCartData = {
isLoading, cartCoupons: [],
}; cartItems: [],
cartItemsCount: 0,
cartItemsWeight: 0,
cartNeedsShipping: true,
cartTotals: {},
cartIsLoading: true,
cartErrors: [],
};
/**
* This is a custom hook that is wired up to the `wc/store/cart` data
* store.
*
* @param {Object} options An object declaring the various
* collection arguments.
* @param {boolean} options.shouldSelect If false, the previous results will be
* returned and internal selects will not
* fire.
*
* @return {StoreCart} Object containing cart data.
*/
export const useStoreCart = ( options = { shouldSelect: true } ) => {
const { shouldSelect } = options;
const results = useSelect(
( select ) => {
if ( ! shouldSelect ) {
return null;
}
const store = select( storeKey );
const cartData = store.getCartData();
const cartErrors = store.getCartErrors();
const cartTotals = store.getCartTotals();
const cartIsLoading = ! store.hasFinishedResolution(
'getCartData'
);
return {
cartCoupons: cartData.coupons,
cartItems: cartData.items,
cartItemsCount: cartData.itemsCount,
cartItemsWeight: cartData.itemsWeight,
cartNeedsShipping: cartData.needsShipping,
cartTotals,
cartIsLoading,
cartErrors,
};
},
[ shouldSelect ]
);
if ( results === null ) {
return defaultCartData;
}
return results;
}; };

View File

@ -4,7 +4,7 @@
import { withRestApiHydration } from '@woocommerce/block-hocs'; import { withRestApiHydration } from '@woocommerce/block-hocs';
import { useStoreCart } from '@woocommerce/base-hooks'; import { useStoreCart } from '@woocommerce/base-hooks';
import { RawHTML } from '@wordpress/element'; import { RawHTML } from '@wordpress/element';
import LoadingMask from '@woocommerce/base-components/loading-mask';
/** /**
* Internal dependencies * Internal dependencies
*/ */
@ -19,24 +19,46 @@ const CartFrontend = ( {
isShippingCalculatorEnabled, isShippingCalculatorEnabled,
isShippingCostHidden, isShippingCostHidden,
} ) => { } ) => {
const { cartData, isLoading } = useStoreCart(); const {
cartItems,
cartTotals,
cartIsLoading,
cartErrors,
cartCoupons,
} = useStoreCart();
if ( isLoading ) { // Blank state on first load.
if ( cartIsLoading && ! cartItems.length ) {
return null; return null;
} }
const cartItems = cartData.items; return (
const isCartEmpty = cartItems.length <= 0; <>
<div className="errors">
return isCartEmpty ? ( { // @todo This is a placeholder for error messages - this needs refactoring.
<RawHTML>{ emptyCart }</RawHTML> cartErrors &&
) : ( cartErrors.map( ( error = {}, i ) => (
<FullCart <div className="woocommerce-info" key={ 'notice-' + i }>
cartItems={ cartItems } { error.message }
cartTotals={ cartData.totals } </div>
isShippingCalculatorEnabled={ isShippingCalculatorEnabled } ) ) }
isShippingCostHidden={ isShippingCostHidden } </div>
/> { ! cartItems.length ? (
<RawHTML>{ emptyCart }</RawHTML>
) : (
<LoadingMask showSpinner={ true } isLoading={ cartIsLoading }>
<FullCart
cartItems={ cartItems }
cartTotals={ cartTotals }
cartCoupons={ cartCoupons }
isShippingCalculatorEnabled={
isShippingCalculatorEnabled
}
isShippingCostHidden={ isShippingCostHidden }
/>
</LoadingMask>
) }
</>
); );
}; };

View File

@ -13,6 +13,7 @@ import ShippingRatesControl, {
} from '@woocommerce/base-components/shipping-rates-control'; } from '@woocommerce/base-components/shipping-rates-control';
import ShippingCalculator from '@woocommerce/base-components/shipping-calculator'; import ShippingCalculator from '@woocommerce/base-components/shipping-calculator';
import ShippingLocation from '@woocommerce/base-components/shipping-location'; import ShippingLocation from '@woocommerce/base-components/shipping-location';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import { import {
COUPONS_ENABLED, COUPONS_ENABLED,
SHIPPING_ENABLED, SHIPPING_ENABLED,
@ -22,6 +23,7 @@ import { getCurrencyFromPriceResponse } from '@woocommerce/base-utils';
import { Card, CardBody } from 'wordpress-components'; import { Card, CardBody } from 'wordpress-components';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount'; import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
import { decodeEntities } from '@wordpress/html-entities'; import { decodeEntities } from '@wordpress/html-entities';
import { useStoreCartCoupons } from '@woocommerce/base-hooks';
/** /**
* Internal dependencies * Internal dependencies
@ -33,12 +35,6 @@ import CartLineItemsTable from './cart-line-items-table';
import './style.scss'; import './style.scss';
import './editor.scss'; import './editor.scss';
// @todo this are placeholders
const onActivateCoupon = ( couponCode ) => {
// eslint-disable-next-line no-console
console.log( 'coupon activated: ' + couponCode );
};
const renderShippingRatesControlOption = ( option ) => ( { const renderShippingRatesControlOption = ( option ) => ( {
label: decodeEntities( option.name ), label: decodeEntities( option.name ),
value: option.rate_id, value: option.rate_id,
@ -62,6 +58,7 @@ const renderShippingRatesControlOption = ( option ) => ( {
const Cart = ( { const Cart = ( {
cartItems = [], cartItems = [],
cartTotals = {}, cartTotals = {},
cartCoupons = [],
isShippingCalculatorEnabled, isShippingCalculatorEnabled,
isShippingCostHidden, isShippingCostHidden,
shippingRates, shippingRates,
@ -76,10 +73,16 @@ const Cart = ( {
postcode: '', postcode: '',
country: '', country: '',
} ); } );
const [ showShippingCosts, setShowShippingCosts ] = useState( const [ showShippingCosts, setShowShippingCosts ] = useState(
! isShippingCostHidden ! isShippingCostHidden
); );
const {
applyCoupon,
removeCoupon,
isApplyingCoupon,
isRemovingCoupon,
} = useStoreCartCoupons();
useEffect( () => { useEffect( () => {
if ( ! SHIPPING_ENABLED ) { if ( ! SHIPPING_ENABLED ) {
return setShowShippingCosts( false ); return setShowShippingCosts( false );
@ -99,6 +102,7 @@ const Cart = ( {
isShippingCostHidden, isShippingCostHidden,
shippingCalculatorAddress, shippingCalculatorAddress,
] ); ] );
/** /**
* Given an API response with cart totals, generates an array of rows to display in the Cart block. * Given an API response with cart totals, generates an array of rows to display in the Cart block.
* *
@ -126,16 +130,41 @@ const Cart = ( {
} ); } );
} }
const totalDiscount = parseInt( cartTotals.total_discount, 10 ); const totalDiscount = parseInt( cartTotals.total_discount, 10 );
if ( totalDiscount > 0 ) { if ( totalDiscount > 0 || cartCoupons.length !== 0 ) {
const totalDiscountTax = parseInt( const totalDiscountTax = parseInt(
cartTotals.total_discount_tax, cartTotals.total_discount_tax,
10 10
); );
// @todo The remove coupon button is a placeholder - replace with new
// chip component.
totalRowsConfig.push( { totalRowsConfig.push( {
label: __( 'Discount:', 'woo-gutenberg-products-block' ), label: __( 'Discount:', 'woo-gutenberg-products-block' ),
value: DISPLAY_PRICES_INCLUDING_TAXES value:
? totalDiscount + totalDiscountTax ( DISPLAY_PRICES_INCLUDING_TAXES
: totalDiscount, ? totalDiscount + totalDiscountTax
: totalDiscount ) * -1,
description: (
<LoadingMask
screenReaderLabel={ __(
'Removing coupon…',
'woo-gutenberg-products-block'
) }
isLoading={ isRemovingCoupon }
showSpinner={ false }
>
{ cartCoupons.map( ( cartCoupon ) => (
<button
key={ 'coupon-' + cartCoupon.code }
disabled={ isRemovingCoupon }
onClick={ () => {
removeCoupon( cartCoupon.code );
} }
>
{ cartCoupon.code }
</button>
) ) }
</LoadingMask>
),
} ); } );
} }
if ( ! DISPLAY_PRICES_INCLUDING_TAXES ) { if ( ! DISPLAY_PRICES_INCLUDING_TAXES ) {
@ -248,7 +277,8 @@ const Cart = ( {
{ showShippingCosts && <ShippingCalculatorOptions /> } { showShippingCosts && <ShippingCalculatorOptions /> }
{ COUPONS_ENABLED && ( { COUPONS_ENABLED && (
<TotalsCouponCodeInput <TotalsCouponCodeInput
onSubmit={ onActivateCoupon } onSubmit={ applyCoupon }
isLoading={ isApplyingCoupon }
/> />
) } ) }
<TotalsItem <TotalsItem

View File

@ -0,0 +1,7 @@
export const ACTION_TYPES = {
RECEIVE_CART: 'RECEIVE_CART',
RECEIVE_ERROR: 'RECEIVE_ERROR',
REPLACE_ERRORS: 'REPLACE_ERRORS',
APPLYING_COUPON: 'APPLYING_COUPON',
REMOVING_COUPON: 'REMOVING_COUPON',
};

View File

@ -0,0 +1,121 @@
/**
* External dependencies
*/
import { apiFetch } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
/**
* Returns an action object used in updating the store with the provided items
* retrieved from a request using the given querystring.
*
* This is a generic response action.
*
* @param {Object} [response={}] An object containing the response from the
* request.
* @return {Object} Object for action.
*/
export function receiveCart( response = {} ) {
return {
type: types.RECEIVE_CART,
response,
};
}
/**
* Returns an action object used for receiving customer facing errors from the
* API.
*
* @param {Object} [error={}] 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.
* @return {Object} Object for action.
*/
export function receiveError( error = {}, replace = true ) {
return {
type: replace ? types.REPLACE_ERRORS : types.RECEIVE_ERROR,
error,
};
}
/**
* Returns an action object used to track when a coupon is applying.
*
* @param {string} [couponCode] Coupon being added.
* @return {Object} Object for action.
*/
export function receiveApplyingCoupon( couponCode ) {
return {
type: types.APPLYING_COUPON,
couponCode,
};
}
/**
* Returns an action object used to track when a coupon is removing.
*
* @param {string} [couponCode] Coupon being removed.
* @return {Object} Object for action.
*/
export function receiveRemovingCoupon( couponCode ) {
return {
type: types.REMOVING_COUPON,
couponCode,
};
}
/**
* Applies a coupon code and either invalidates caches, or receives an error if
* the coupon cannot be applied.
*
* @param {string} couponCode The coupon code to apply to the cart.
*/
export function* applyCoupon( couponCode ) {
yield receiveApplyingCoupon( couponCode );
try {
const result = yield apiFetch( {
path: '/wc/store/cart/apply-coupon/' + couponCode,
method: 'POST',
cache: 'no-store',
} );
if ( result ) {
yield receiveCart( result );
}
} catch ( error ) {
yield receiveError( error );
}
yield receiveApplyingCoupon( '' );
}
/**
* Removes a coupon code and either invalidates caches, or receives an error if
* the coupon cannot be removed.
*
* @param {string} couponCode The coupon code to remove from the cart.
*/
export function* removeCoupon( couponCode ) {
yield receiveRemovingCoupon( couponCode );
try {
const result = yield apiFetch( {
path: '/wc/store/cart/remove-coupon/' + couponCode,
method: 'POST',
cache: 'no-store',
} );
if ( result ) {
yield receiveCart( result );
}
} catch ( error ) {
yield receiveError( error );
}
yield receiveRemovingCoupon( '' );
}

View File

@ -0,0 +1,16 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
export const STORE_KEY = 'wc/store/cart';
export const CART_API_ERROR = {
code: 'cart_api_error',
message: __(
'Unable to get cart data from the API.',
'woo-gutenberg-products-block'
),
data: {
status: 500,
},
};

View File

@ -0,0 +1,24 @@
/**
* External dependencies
*/
import { registerStore } from '@wordpress/data';
import { controls } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
import * as selectors from './selectors';
import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer from './reducers';
registerStore( STORE_KEY, {
reducer,
actions,
controls,
selectors,
resolvers,
} );
export const CART_STORE_KEY = STORE_KEY;

View File

@ -0,0 +1,71 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
/**
* Reducer for receiving items related to the cart.
*
* @param {Object} state The current state in the store.
* @param {Object} action Action object.
*
* @return {Object} New or existing state.
*/
const reducer = (
state = {
cartData: {
coupons: [],
items: [],
itemsCount: 0,
itemsWeight: 0,
needsShipping: true,
totals: {},
},
metaData: {},
errors: [],
},
action
) => {
switch ( action.type ) {
case types.RECEIVE_ERROR:
state = {
...state,
errors: state.errors.concat( action.error ),
};
break;
case types.REPLACE_ERRORS:
state = {
...state,
errors: [ action.error ],
};
break;
case types.RECEIVE_CART:
state = {
...state,
errors: [],
cartData: action.response,
};
break;
case types.APPLYING_COUPON:
state = {
...state,
metaData: {
...state.metaData,
applyingCoupon: action.couponCode,
},
};
break;
case types.REMOVING_COUPON:
state = {
...state,
metaData: {
...state.metaData,
removingCoupon: action.couponCode,
},
};
break;
}
return state;
};
export default reducer;

View File

@ -0,0 +1,40 @@
/**
* External dependencies
*/
import { select, apiFetch } from '@wordpress/data-controls';
import { camelCase, mapKeys } from 'lodash';
/**
* Internal dependencies
*/
import { receiveCart, receiveError } from './actions';
import { STORE_KEY, CART_API_ERROR } from './constants';
/**
* Resolver for retrieving all cart data.
*/
export function* getCartData() {
const cartData = yield apiFetch( {
path: '/wc/store/cart',
method: 'GET',
cache: 'no-store',
} );
if ( ! cartData ) {
yield receiveError( CART_API_ERROR );
return;
}
yield receiveCart(
mapKeys( cartData, ( _value, key ) => {
return camelCase( key );
} )
);
}
/**
* Resolver for retrieving cart totals.
*/
export function* getCartTotals() {
yield select( STORE_KEY, 'getCartData' );
}

View File

@ -0,0 +1,108 @@
/** @typedef { import('@woocommerce/type-defs/cart').CartData } CartData */
/** @typedef { import('@woocommerce/type-defs/cart').CartTotals } CartTotals */
/**
* Retrieves cart data from state.
*
* @param {Object} state The current state.
* @return {CartData} The data to return.
*/
export const getCartData = ( state ) => {
return state.cartData;
};
/**
* Retrieves cart totals from state.
*
* @param {Object} state The current state.
* @return {CartTotals} The data to return.
*/
export const getCartTotals = ( state ) => {
return (
state.cartData.totals || {
currency_code: '',
currency_symbol: '',
currency_minor_unit: 2,
currency_decimal_separator: '.',
currency_thousand_separator: ',',
currency_prefix: '',
currency_suffix: '',
total_items: 0,
total_items_tax: 0,
total_fees: 0,
total_fees_tax: 0,
total_discount: 0,
total_discount_tax: 0,
total_shipping: 0,
total_shipping_tax: 0,
total_price: 0,
total_tax: 0,
tax_lines: [],
}
);
};
/**
* Retrieves cart meta from state.
*
* @param {Object} state The current state.
* @return {Object} The data to return.
*/
export const getCartMeta = ( state ) => {
return (
state.metaData || {
applyingCoupon: '',
removingCoupon: '',
}
);
};
/**
* Retrieves cart errors from state.
*
* @param {Object} state The current state.
* @return {Array} Array of errors.
*/
export const getCartErrors = ( state ) => {
return state.errors || [];
};
/**
* Returns true if any coupon is being applied.
*
* @param {Object} state The current state.
* @return {boolean} True if a coupon is being applied.
*/
export const isApplyingCoupon = ( state ) => {
return !! state.metaData.applyingCoupon;
};
/**
* Retrieves the coupon code currently being applied.
*
* @param {Object} state The current state.
* @return {string} The data to return.
*/
export const getCouponBeingApplied = ( state ) => {
return state.metaData.applyingCoupon || '';
};
/**
* Returns true if any coupon is being removed.
*
* @param {Object} state The current state.
* @return {boolean} True if a coupon is being removed.
*/
export const isRemovingCoupon = ( state ) => {
return !! state.metaData.removingCoupon;
};
/**
* Retrieves the coupon code currently being removed.
*
* @param {Object} state The current state.
* @return {string} The data to return.
*/
export const getCouponBeingRemoved = ( state ) => {
return state.metaData.removingCoupon || '';
};

View File

@ -0,0 +1,115 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import cartReducer from '../reducers';
import { ACTION_TYPES as types } from '../action-types';
describe( 'cartReducer', () => {
const originalState = deepFreeze( {
cartData: {
coupons: [],
items: [],
itemsCount: 0,
itemsWeight: 0,
needsShipping: true,
totals: {},
},
metaData: {},
errors: [
{
code: '100',
message: 'Test Error',
data: {},
},
],
} );
it( 'sets expected state when a cart is received', () => {
const testAction = {
type: types.RECEIVE_CART,
response: {
coupons: [],
items: [],
itemsCount: 0,
itemsWeight: 0,
needsShipping: true,
totals: {},
},
};
const newState = cartReducer( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect( newState.cartData ).toEqual( {
coupons: [],
items: [],
itemsCount: 0,
itemsWeight: 0,
needsShipping: true,
totals: {},
} );
} );
it( 'sets expected state when errors are replaced', () => {
const testAction = {
type: types.REPLACE_ERRORS,
error: {
code: '101',
message: 'Test Error',
data: {},
},
};
const newState = cartReducer( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect( newState.errors ).toEqual( [
{
code: '101',
message: 'Test Error',
data: {},
},
] );
} );
it( 'sets expected state when an error is added', () => {
const testAction = {
type: types.RECEIVE_ERROR,
error: {
code: '101',
message: 'Test Error',
data: {},
},
};
const newState = cartReducer( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect( newState.errors ).toEqual( [
{
code: '100',
message: 'Test Error',
data: {},
},
{
code: '101',
message: 'Test Error',
data: {},
},
] );
} );
it( 'sets expected state when a coupon is applied', () => {
const testAction = {
type: types.APPLYING_COUPON,
couponCode: 'APPLYME',
};
const newState = cartReducer( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect( newState.metaData.applyingCoupon ).toEqual( 'APPLYME' );
} );
it( 'sets expected state when a coupon is removed', () => {
const testAction = {
type: types.REMOVING_COUPON,
couponCode: 'REMOVEME',
};
const newState = cartReducer( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect( newState.metaData.removingCoupon ).toEqual( 'REMOVEME' );
} );
} );

View File

@ -0,0 +1,59 @@
/**
* Internal dependencies
*/
import { getCartData } from '../resolvers';
import { receiveCart, receiveError } from '../actions';
import { CART_API_ERROR } from '../constants';
jest.mock( '@wordpress/data-controls' );
describe( 'getCartData', () => {
describe( 'yields with expected responses', () => {
let fulfillment;
const rewind = () => ( fulfillment = getCartData() );
test(
'when apiFetch returns a valid response, yields expected ' +
'action',
() => {
rewind();
fulfillment.next( 'https://example.org' );
const { value } = fulfillment.next( {
coupons: [],
items: [],
itemsCount: 0,
itemsWeight: 0,
needsShipping: true,
totals: {},
} );
expect( value ).toEqual(
receiveCart( {
coupons: [],
items: [],
itemsCount: 0,
itemsWeight: 0,
needsShipping: true,
totals: {},
} )
);
const { done } = fulfillment.next();
expect( done ).toBe( true );
}
);
} );
describe( 'yields with expected response when there is an error', () => {
let fulfillment;
const rewind = () => ( fulfillment = getCartData() );
test(
'when apiFetch returns a valid response, yields expected ' +
'action',
() => {
rewind();
fulfillment.next( 'https://example.org' );
const { value } = fulfillment.next( undefined );
expect( value ).toEqual( receiveError( CART_API_ERROR ) );
const { done } = fulfillment.next();
expect( done ).toBe( true );
}
);
} );
} );

View File

@ -0,0 +1,209 @@
/**
* Internal dependencies
*/
import {
getCartData,
getCartTotals,
getCartMeta,
getCartErrors,
isApplyingCoupon,
getCouponBeingApplied,
isRemovingCoupon,
getCouponBeingRemoved,
} from '../selectors';
const state = {
cartData: {
coupons: [
{
code: 'test',
totals: {
currency_code: 'GBP',
currency_symbol: '£',
currency_minor_unit: 2,
currency_decimal_separator: '.',
currency_thousand_separator: ',',
currency_prefix: '£',
currency_suffix: '',
total_discount: '583',
total_discount_tax: '117',
},
},
],
items: [
{
key: '1f0e3dad99908345f7439f8ffabdffc4',
id: 19,
quantity: 1,
name: 'Album',
summary: '<p>This is a simple, virtual product.</p>',
short_description: '<p>This is a simple, virtual product.</p>',
description:
'<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum sagittis orci ac odio dictum tincidunt. Donec ut metus leo. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Sed luctus, dui eu sagittis sodales, nulla nibh sagittis augue, vel porttitor diam enim non metus. Vestibulum aliquam augue neque. Phasellus tincidunt odio eget ullamcorper efficitur. Cras placerat ut turpis pellentesque vulputate. Nam sed consequat tortor. Curabitur finibus sapien dolor. Ut eleifend tellus nec erat pulvinar dignissim. Nam non arcu purus. Vivamus et massa massa.</p>',
sku: 'woo-album',
low_stock_remaining: null,
permalink: 'http://local.wordpress.test/product/album/',
images: [
{
id: 48,
src:
'http://local.wordpress.test/wp-content/uploads/2019/12/album-1.jpg',
thumbnail:
'http://local.wordpress.test/wp-content/uploads/2019/12/album-1-324x324.jpg',
srcset:
'http://local.wordpress.test/wp-content/uploads/2019/12/album-1.jpg 800w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-324x324.jpg 324w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-100x100.jpg 100w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-416x416.jpg 416w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-300x300.jpg 300w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-150x150.jpg 150w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-768x768.jpg 768w',
sizes: '(max-width: 800px) 100vw, 800px',
name: 'album-1.jpg',
alt: '',
},
],
variation: [],
totals: {
currency_code: 'GBP',
currency_symbol: '£',
currency_minor_unit: 2,
currency_decimal_separator: '.',
currency_thousand_separator: ',',
currency_prefix: '£',
currency_suffix: '',
line_subtotal: '1250',
line_subtotal_tax: '250',
line_total: '1000',
line_total_tax: '200',
},
},
{
key: '6512bd43d9caa6e02c990b0a82652dca',
id: 11,
quantity: 1,
name: 'Beanie',
summary: '<p>This is a simple product.</p>',
short_description: '<p>This is a simple product.</p>',
description:
'<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>',
sku: 'woo-beanie',
low_stock_remaining: null,
permalink: 'http://local.wordpress.test/product/beanie/',
images: [
{
id: 40,
src:
'http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2.jpg',
thumbnail:
'http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-324x324.jpg',
srcset:
'http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2.jpg 801w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-324x324.jpg 324w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-100x100.jpg 100w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-416x416.jpg 416w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-300x300.jpg 300w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-150x150.jpg 150w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-768x768.jpg 768w',
sizes: '(max-width: 801px) 100vw, 801px',
name: 'beanie-2.jpg',
alt: '',
},
],
variation: [],
totals: {
currency_code: 'GBP',
currency_symbol: '£',
currency_minor_unit: 2,
currency_decimal_separator: '.',
currency_thousand_separator: ',',
currency_prefix: '£',
currency_suffix: '',
line_subtotal: '1667',
line_subtotal_tax: '333',
line_total: '1333',
line_total_tax: '267',
},
},
],
items_count: 2,
items_weight: 0,
needs_shipping: true,
totals: {
currency_code: 'GBP',
currency_symbol: '£',
currency_minor_unit: 2,
currency_decimal_separator: '.',
currency_thousand_separator: ',',
currency_prefix: '£',
currency_suffix: '',
total_items: '2917',
total_items_tax: '583',
total_fees: '0',
total_fees_tax: '0',
total_discount: '583',
total_discount_tax: '117',
total_shipping: '2000',
total_shipping_tax: '400',
total_price: '5200',
total_tax: '867',
tax_lines: [
{
name: 'Tax',
price: '867',
},
],
},
},
metaData: {
applyingCoupon: 'test-coupon',
removingCoupon: 'test-coupon2',
},
errors: [
{
code: '100',
message: 'Test Error',
data: {},
},
],
};
describe( 'getCartData', () => {
it( 'returns expected values for items existing in state', () => {
expect( getCartData( state ) ).toEqual( state.cartData );
} );
} );
describe( 'getCartTotals', () => {
it( 'returns expected values for items existing in state', () => {
expect( getCartTotals( state ) ).toEqual( state.cartData.totals );
} );
} );
describe( 'getCartMeta', () => {
it( 'returns expected values for items existing in state', () => {
expect( getCartMeta( state ) ).toEqual( state.metaData );
} );
} );
describe( 'getCartErrors', () => {
it( 'returns expected values for items existing in state', () => {
expect( getCartErrors( state ) ).toEqual( state.errors );
} );
} );
describe( 'isApplyingCoupon', () => {
it( 'returns expected values for items existing in state', () => {
expect( isApplyingCoupon( state ) ).toEqual( true );
} );
} );
describe( 'getCouponBeingApplied', () => {
it( 'returns expected values for items existing in state', () => {
expect( getCouponBeingApplied( state ) ).toEqual(
state.metaData.applyingCoupon
);
} );
} );
describe( 'isRemovingCoupon', () => {
it( 'returns expected values for items existing in state', () => {
expect( isRemovingCoupon( state ) ).toEqual( true );
} );
} );
describe( 'getCouponBeingRemoved', () => {
it( 'returns expected values for items existing in state', () => {
expect( getCouponBeingRemoved( state ) ).toEqual(
state.metaData.removingCoupon
);
} );
} );

View File

@ -78,25 +78,31 @@ export function* __experimentalPersistItemToCollection(
if ( ! route ) { if ( ! route ) {
return; return;
} }
const item = yield apiFetch( {
path: route, try {
method: 'POST', const item = yield apiFetch( {
data, path: route,
cache: 'no-store', method: 'POST',
} ); data,
if ( item ) { cache: 'no-store',
newCollection.push( item ); } );
yield receiveCollection(
namespace, if ( item ) {
resourceName, newCollection.push( item );
'', yield receiveCollection(
[], namespace,
{ resourceName,
items: newCollection, '',
headers: Headers, [],
}, {
true items: newCollection,
); headers: Headers,
},
true
);
}
} catch ( error ) {
yield receiveCollectionError( namespace, resourceName, '', [], error );
} }
} }

View File

@ -1,4 +1,5 @@
export { SCHEMA_STORE_KEY } from './schema'; export { SCHEMA_STORE_KEY } from './schema';
export { COLLECTIONS_STORE_KEY } from './collections'; export { COLLECTIONS_STORE_KEY } from './collections';
export { CART_STORE_KEY } from './cart';
export { QUERY_STATE_STORE_KEY } from './query-state'; export { QUERY_STATE_STORE_KEY } from './query-state';
export { API_BLOCK_NAMESPACE } from './constants'; export { API_BLOCK_NAMESPACE } from './constants';

View File

@ -45,4 +45,60 @@
* @property {string} email The email for the billing address * @property {string} email The email for the billing address
*/ */
/**
* @typedef {Object} CartData
*
* @property {Array} coupons Coupons applied to cart.
* @property {Array} items Items in the cart.
* @property {number} itemsCount Number of items in the cart.
* @property {number} itemsWeight Weight of items in the cart.
* @property {boolean} needsShipping True if the cart needs shipping.
* @property {CartTotals} totals Cart total amounts.
*/
/**
* @typedef {Object} CartTotals
*
* @property {string} currency_code Currency code (in ISO format)
* for returned prices.
* @property {string} currency_symbol Currency symbol for the
* currency which can be used to
* format returned prices.
* @property {number} currency_minor_unit Currency minor unit (number of
* digits after the decimal
* separator) for returned
* prices.
* @property {string} currency_decimal_separator Decimal separator for the
* currency which can be used to
* format returned prices.
* @property {string} currency_thousand_separator Thousand separator for the
* currency which can be used to
* format returned prices.
* @property {string} currency_prefix Price prefix for the currency
* which can be used to format
* returned prices.
* @property {string} currency_suffix Price prefix for the currency
* which can be used to format
* returned prices.
* @property {number} total_items Total price of items in the
* cart.
* @property {number} total_items_tax Total tax on items in the
* cart.
* @property {number} total_fees Total price of any applied
* fees.
* @property {number} total_fees_tax Total tax on fees.
* @property {number} total_discount Total discount from applied
* coupons.
* @property {number} total_discount_tax Total tax removed due to
* discount from applied coupons.
* @property {number} total_shipping Total price of shipping.
* @property {number} total_shipping_tax Total tax on shipping.
* @property {number} total_price Total price the customer will
* pay.
* @property {number} total_tax Total tax applied to items and
* shipping.
* @property {Array} tax_lines Lines of taxes applied to
* items and shipping.
*/
export {}; export {};

View File

@ -0,0 +1,26 @@
/**
* @typedef {Object} StoreCart
*
* @property {Array} cartCoupons An array of coupons applied to the cart.
* @property {Array} cartItems An array of items in the cart.
* @property {number} cartItemsCount The number of items in the cart.
* @property {number} cartItemsWeight The weight of all items in the cart.
* @property {boolean} cartNeedsShipping True when the cart will require shipping.
* @property {Object} cartTotals Cart and line total amounts.
* @property {boolean} cartIsLoading True when cart data is being loaded.
* @property {Array} cartErrors An array of errors thrown by the cart.
*/
/**
* @typedef {Object} StoreCartCoupon
*
* @property {Array} appliedCoupons Collection of applied coupons from the
* API.
* @property {boolean} isLoading True when coupon data is being loaded.
* @property {Function} applyCoupon Callback for applying a coupon by code.
* @property {Function} removeCoupon Callback for removing a coupon by code.
* @property {boolean} isApplyingCoupon True when a coupon is being applied.
* @property {boolean} isRemovingCoupon True when a coupon is being removed.
*/
export {};

View File

@ -3306,8 +3306,7 @@
"@types/color-name": { "@types/color-name": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ=="
"dev": true
}, },
"@types/events": { "@types/events": {
"version": "3.0.0", "version": "3.0.0",
@ -3341,14 +3340,12 @@
"@types/istanbul-lib-coverage": { "@types/istanbul-lib-coverage": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz",
"integrity": "sha512-hRJD2ahnnpLgsj6KWMYSrmXkM3rm2Dl1qkx6IOFD5FnuNPXJIG5L0dhgKXCYTRMGzU4n0wImQ/xfmRc4POUFlg==", "integrity": "sha512-hRJD2ahnnpLgsj6KWMYSrmXkM3rm2Dl1qkx6IOFD5FnuNPXJIG5L0dhgKXCYTRMGzU4n0wImQ/xfmRc4POUFlg=="
"dev": true
}, },
"@types/istanbul-lib-report": { "@types/istanbul-lib-report": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
"integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==",
"dev": true,
"requires": { "requires": {
"@types/istanbul-lib-coverage": "*" "@types/istanbul-lib-coverage": "*"
} }
@ -3357,12 +3354,100 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.1.tgz",
"integrity": "sha512-UpYjBi8xefVChsCoBpKShdxTllC9pwISirfoZsUa2AAdQg/Jd2KQGtSbw+ya7GPo7x/wAPlH6JBhKhAsXUEZNA==", "integrity": "sha512-UpYjBi8xefVChsCoBpKShdxTllC9pwISirfoZsUa2AAdQg/Jd2KQGtSbw+ya7GPo7x/wAPlH6JBhKhAsXUEZNA==",
"dev": true,
"requires": { "requires": {
"@types/istanbul-lib-coverage": "*", "@types/istanbul-lib-coverage": "*",
"@types/istanbul-lib-report": "*" "@types/istanbul-lib-report": "*"
} }
}, },
"@types/jest": {
"version": "25.1.3",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-25.1.3.tgz",
"integrity": "sha512-jqargqzyJWgWAJCXX96LBGR/Ei7wQcZBvRv0PLEu9ZByMfcs23keUJrKv9FMR6YZf9YCbfqDqgmY+JUBsnqhrg==",
"requires": {
"jest-diff": "^25.1.0",
"pretty-format": "^25.1.0"
},
"dependencies": {
"@jest/types": {
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-25.1.0.tgz",
"integrity": "sha512-VpOtt7tCrgvamWZh1reVsGADujKigBUFTi19mlRjqEGsE8qH4r3s+skY33dNdXOwyZIvuftZ5tqdF1IgsMejMA==",
"requires": {
"@types/istanbul-lib-coverage": "^2.0.0",
"@types/istanbul-reports": "^1.1.1",
"@types/yargs": "^15.0.0",
"chalk": "^3.0.0"
}
},
"@types/yargs": {
"version": "15.0.3",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.3.tgz",
"integrity": "sha512-XCMQRK6kfpNBixHLyHUsGmXrpEmFFxzMrcnSXFMziHd8CoNJo8l16FkHyQq4x+xbM7E2XL83/O78OD8u+iZTdQ==",
"requires": {
"@types/yargs-parser": "*"
}
},
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg=="
},
"ansi-styles": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
"integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
"requires": {
"@types/color-name": "^1.1.1",
"color-convert": "^2.0.1"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"diff-sequences": {
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.1.0.tgz",
"integrity": "sha512-nFIfVk5B/NStCsJ+zaPO4vYuLjlzQ6uFvPxzYyHlejNZ/UGa7G/n7peOXVrVNvRuyfstt+mZQYGpjxg9Z6N8Kw=="
},
"jest-diff": {
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.1.0.tgz",
"integrity": "sha512-nepXgajT+h017APJTreSieh4zCqnSHEJ1iT8HDlewu630lSJ4Kjjr9KNzm+kzGwwcpsDE6Snx1GJGzzsefaEHw==",
"requires": {
"chalk": "^3.0.0",
"diff-sequences": "^25.1.0",
"jest-get-type": "^25.1.0",
"pretty-format": "^25.1.0"
}
},
"jest-get-type": {
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.1.0.tgz",
"integrity": "sha512-yWkBnT+5tMr8ANB6V+OjmrIJufHtCAqI5ic2H40v+tRqxDmE0PGnIiTyvRWFOMtmVHYpwRqyazDbTnhpjsGvLw=="
},
"pretty-format": {
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-25.1.0.tgz",
"integrity": "sha512-46zLRSGLd02Rp+Lhad9zzuNZ+swunitn8zIpfD2B4OPCRLXbM87RJT2aBLBWYOznNUML/2l/ReMyWNC80PJBUQ==",
"requires": {
"@jest/types": "^25.1.0",
"ansi-regex": "^5.0.0",
"ansi-styles": "^4.0.0",
"react-is": "^16.12.0"
}
}
}
},
"@types/json-schema": { "@types/json-schema": {
"version": "7.0.4", "version": "7.0.4",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.4.tgz",
@ -3589,8 +3674,7 @@
"@types/yargs-parser": { "@types/yargs-parser": {
"version": "15.0.0", "version": "15.0.0",
"resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz",
"integrity": "sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw==", "integrity": "sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw=="
"dev": true
}, },
"@typescript-eslint/experimental-utils": { "@typescript-eslint/experimental-utils": {
"version": "1.13.0", "version": "1.13.0",
@ -8719,7 +8803,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
"integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
"dev": true,
"requires": { "requires": {
"ansi-styles": "^4.1.0", "ansi-styles": "^4.1.0",
"supports-color": "^7.1.0" "supports-color": "^7.1.0"
@ -8729,7 +8812,6 @@
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
"integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
"dev": true,
"requires": { "requires": {
"@types/color-name": "^1.1.1", "@types/color-name": "^1.1.1",
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@ -8739,7 +8821,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": { "requires": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
} }
@ -8747,20 +8828,17 @@
"color-name": { "color-name": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
"dev": true
}, },
"has-flag": { "has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
"dev": true
}, },
"supports-color": { "supports-color": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
"integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==",
"dev": true,
"requires": { "requires": {
"has-flag": "^4.0.0" "has-flag": "^4.0.0"
} }
@ -28404,7 +28482,7 @@
} }
}, },
"woocommerce": { "woocommerce": {
"version": "git+https://github.com/woocommerce/woocommerce.git#bfac625cdcda4d7d14e85ca3cf5534e86081e7bc", "version": "git+https://github.com/woocommerce/woocommerce.git#33e6aee1af515adccfeda43181d33c3ad39309ce",
"from": "git+https://github.com/woocommerce/woocommerce.git", "from": "git+https://github.com/woocommerce/woocommerce.git",
"dev": true, "dev": true,
"requires": { "requires": {

View File

@ -139,11 +139,12 @@
"npm": "6.13.7" "npm": "6.13.7"
}, },
"dependencies": { "dependencies": {
"@types/jest": "^25.1.3",
"@woocommerce/components": "4.0.0", "@woocommerce/components": "4.0.0",
"classnames": "2.2.6", "classnames": "2.2.6",
"compare-versions": "3.6.0", "compare-versions": "3.6.0",
"downshift": "4.1.0",
"config": "3.2.5", "config": "3.2.5",
"downshift": "4.1.0",
"react-number-format": "4.3.1", "react-number-format": "4.3.1",
"trim-html": "0.1.9", "trim-html": "0.1.9",
"use-debounce": "3.3.0", "use-debounce": "3.3.0",

View File

@ -13,6 +13,7 @@ defined( 'ABSPATH' ) || exit;
use \WP_Error as RestError; use \WP_Error as RestError;
use \WP_REST_Server as RestServer; use \WP_REST_Server as RestServer;
use \WP_REST_Controller as RestController; use \WP_REST_Controller as RestController;
use \WC_REST_Exception as RestException;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Schemas\CartSchema; use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Schemas\CartSchema;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\CartController; use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\CartController;
@ -68,6 +69,106 @@ class Cart extends RestController {
'schema' => [ $this, 'get_public_item_schema' ], 'schema' => [ $this, 'get_public_item_schema' ],
] ]
); );
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/apply-coupon/(?P<code>[\w-]+)',
[
'args' => [
'code' => [
'description' => __( 'Unique identifier for the coupon within the cart.', 'woo-gutenberg-products-block' ),
'type' => 'string',
],
],
[
'methods' => 'POST',
'callback' => [ $this, 'apply_coupon' ],
],
'schema' => [ $this, 'get_public_item_schema' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/remove-coupon/(?P<code>[\w-]+)',
[
'args' => [
'code' => [
'description' => __( 'Unique identifier for the coupon within the cart.', 'woo-gutenberg-products-block' ),
'type' => 'string',
],
],
[
'methods' => 'POST',
'callback' => [ $this, 'remove_coupon' ],
],
'schema' => [ $this, 'get_public_item_schema' ],
]
);
}
/**
* Apply a coupon to the cart.
*
* This works like the CartCoupons endpoint, but returns the entire cart when finished to avoid multiple requests.
*
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_Error|\WP_REST_Response
*/
public function apply_coupon( $request ) {
if ( ! wc_coupons_enabled() ) {
return new RestError( 'woocommerce_rest_cart_coupon_disabled', __( 'Coupons are disabled.', 'woo-gutenberg-products-block' ), array( 'status' => 404 ) );
}
$controller = new CartController();
$cart = $controller->get_cart_instance();
if ( ! $cart || ! $cart instanceof \WC_Cart ) {
return new RestError( 'woocommerce_rest_cart_error', __( 'Unable to retrieve cart.', 'woo-gutenberg-products-block' ), array( 'status' => 500 ) );
}
try {
$controller->apply_coupon( $request['code'] );
} catch ( RestException $e ) {
return new RestError( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
}
$data = $this->prepare_item_for_response( $cart, $request );
$response = rest_ensure_response( $data );
return $response;
}
/**
* Remove a coupon from the cart.
*
* This works like the CartCoupons endpoint, but returns the entire cart when finished to avoid multiple requests.
*
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_Error|\WP_REST_Response
*/
public function remove_coupon( $request ) {
if ( ! wc_coupons_enabled() ) {
return new RestError( 'woocommerce_rest_cart_coupon_disabled', __( 'Coupons are disabled.', 'woo-gutenberg-products-block' ), array( 'status' => 404 ) );
}
$controller = new CartController();
$cart = $controller->get_cart_instance();
if ( ! $cart || ! $cart instanceof \WC_Cart ) {
return new RestError( 'woocommerce_rest_cart_error', __( 'Unable to retrieve cart.', 'woo-gutenberg-products-block' ), array( 'status' => 500 ) );
}
if ( ! $controller->has_coupon( $request['code'] ) ) {
return new RestError( 'woocommerce_rest_cart_coupon_invalid_code', __( 'Coupon does not exist in the cart.', 'woo-gutenberg-products-block' ), array( 'status' => 404 ) );
}
$cart = $controller->get_cart_instance();
$cart->remove_coupon( $request['code'] );
$cart->calculate_totals();
$data = $this->prepare_item_for_response( $cart, $request );
$response = rest_ensure_response( $data );
return $response;
} }
/** /**

View File

@ -193,6 +193,7 @@ class CartCoupons extends RestController {
$cart = $controller->get_cart_instance(); $cart = $controller->get_cart_instance();
$cart->remove_coupon( $request['code'] ); $cart->remove_coupon( $request['code'] );
$cart->calculate_totals();
return new RestResponse( null, 204 ); return new RestResponse( null, 204 );
} }
@ -207,6 +208,7 @@ class CartCoupons extends RestController {
$controller = new CartController(); $controller = new CartController();
$cart = $controller->get_cart_instance(); $cart = $controller->get_cart_instance();
$cart->remove_coupons(); $cart->remove_coupons();
$cart->calculate_totals();
return new RestResponse( [], 200 ); return new RestResponse( [], 200 );
} }

View File

@ -85,6 +85,34 @@ class Cart extends TestCase {
$this->assertEquals( '2900', $data['totals']->total_price ); $this->assertEquals( '2900', $data['totals']->total_price );
} }
/**
* Test applying coupon to cart.
*/
public function test_apply_coupon() {
wc()->cart->remove_coupon( $this->coupon->get_code() );
$response = $this->server->dispatch( new WP_REST_Request( 'POST', '/wc/store/cart/apply-coupon/' . $this->coupon->get_code() ) );
$data = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( '100', $data['totals']->total_discount );
}
/**
* Test removing coupon from cart.
*/
public function test_remove_coupon() {
// Invalid coupon.
$response = $this->server->dispatch( new WP_REST_Request( 'POST', '/wc/store/cart/remove-coupon/doesnotexist' ) );
$data = $response->get_data();
$this->assertEquals( 404, $response->get_status() );
// Applied coupon.
$response = $this->server->dispatch( new WP_REST_Request( 'POST', '/wc/store/cart/remove-coupon/' . $this->coupon->get_code() ) );
$data = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( '0', $data['totals']->total_discount );
}
/** /**
* Test schema retrieval. * Test schema retrieval.
*/ */