From 6d7fdf50e06cb16abc96973965bc0ba96ab03243 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Tue, 25 Feb 2020 11:36:53 +0000 Subject: [PATCH] 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 --- .../shipping-rates-control/index.js | 2 +- .../totals/totals-coupon-code-input/index.js | 74 +++++-- .../assets/js/base/hooks/index.js | 1 + .../js/base/hooks/use-store-cart-coupons.js | 44 ++++ .../assets/js/base/hooks/use-store-cart.js | 85 +++++-- .../js/blocks/cart-checkout/cart/frontend.js | 52 +++-- .../cart-checkout/cart/full-cart/index.js | 54 ++++- .../assets/js/data/cart/action-types.js | 7 + .../assets/js/data/cart/actions.js | 121 ++++++++++ .../assets/js/data/cart/constants.js | 16 ++ .../assets/js/data/cart/index.js | 24 ++ .../assets/js/data/cart/reducers.js | 71 ++++++ .../assets/js/data/cart/resolvers.js | 40 ++++ .../assets/js/data/cart/selectors.js | 108 +++++++++ .../assets/js/data/cart/test/reducers.js | 115 ++++++++++ .../assets/js/data/cart/test/resolvers.js | 59 +++++ .../assets/js/data/cart/test/selectors.js | 209 ++++++++++++++++++ .../assets/js/data/collections/actions.js | 44 ++-- .../assets/js/data/index.js | 1 + .../assets/js/type-defs/cart.js | 56 +++++ .../assets/js/type-defs/hooks.js | 26 +++ plugins/woocommerce-blocks/package-lock.json | 112 ++++++++-- plugins/woocommerce-blocks/package.json | 3 +- .../src/RestApi/StoreApi/Controllers/Cart.php | 101 +++++++++ .../StoreApi/Controllers/CartCoupons.php | 2 + .../php/RestApi/StoreApi/Controllers/Cart.php | 28 +++ 26 files changed, 1348 insertions(+), 107 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart-coupons.js create mode 100644 plugins/woocommerce-blocks/assets/js/data/cart/action-types.js create mode 100644 plugins/woocommerce-blocks/assets/js/data/cart/actions.js create mode 100644 plugins/woocommerce-blocks/assets/js/data/cart/constants.js create mode 100644 plugins/woocommerce-blocks/assets/js/data/cart/index.js create mode 100644 plugins/woocommerce-blocks/assets/js/data/cart/reducers.js create mode 100644 plugins/woocommerce-blocks/assets/js/data/cart/resolvers.js create mode 100644 plugins/woocommerce-blocks/assets/js/data/cart/selectors.js create mode 100644 plugins/woocommerce-blocks/assets/js/data/cart/test/reducers.js create mode 100644 plugins/woocommerce-blocks/assets/js/data/cart/test/resolvers.js create mode 100644 plugins/woocommerce-blocks/assets/js/data/cart/test/selectors.js create mode 100644 plugins/woocommerce-blocks/assets/js/type-defs/hooks.js diff --git a/plugins/woocommerce-blocks/assets/js/base/components/shipping-rates-control/index.js b/plugins/woocommerce-blocks/assets/js/base/components/shipping-rates-control/index.js index 089370b4c88..6fdab3e439d 100644 --- a/plugins/woocommerce-blocks/assets/js/base/components/shipping-rates-control/index.js +++ b/plugins/woocommerce-blocks/assets/js/base/components/shipping-rates-control/index.js @@ -70,7 +70,7 @@ const ShippingRatesControl = ( { 'Loading shipping rates…', 'woo-gutenberg-products-block' ) } - showSpinner={ true } + showSpinner={ false } > { +const TotalsCouponCodeInput = ( { + componentId, + isLoading = false, + onSubmit = () => {}, +} ) => { const [ couponValue, setCouponValue ] = useState( '' ); + const currentIsLoading = useRef( false ); + + useEffect( () => { + if ( currentIsLoading.current !== isLoading ) { + if ( ! isLoading && couponValue ) { + setCouponValue( '' ); + } + currentIsLoading.current = isLoading; + } + }, [ isLoading, couponValue ] ); + return ( { } initialOpen={ true } > - - - setCouponValue( newCouponValue ) - } - /> - - + + + + setCouponValue( newCouponValue ) + } + /> + + + ); }; TotalsCouponCodeInput.propTypes = { onSubmit: PropTypes.func, + isLoading: PropTypes.bool, }; export default withComponentId( TotalsCouponCodeInput ); diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/index.js b/plugins/woocommerce-blocks/assets/js/base/hooks/index.js index e04ff3c04c1..878f82391d3 100644 --- a/plugins/woocommerce-blocks/assets/js/base/hooks/index.js +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/index.js @@ -1,6 +1,7 @@ export * from './use-query-state'; export * from './use-shallow-equal'; export * from './use-store-cart'; +export * from './use-store-cart-coupons'; export * from './use-store-products'; export * from './use-collection'; export * from './use-collection-header'; diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart-coupons.js b/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart-coupons.js new file mode 100644 index 00000000000..80b6b6a53c6 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart-coupons.js @@ -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, + }; +}; diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart.js b/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart.js index 0a3c5033db9..1f37c1dcd6d 100644 --- a/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart.js +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart.js @@ -1,24 +1,69 @@ -/** - * Internal dependencies - */ -import { useCollection } from './use-collection'; +/** @typedef { import('@woocommerce/type-defs/hooks').StoreCart } StoreCart */ /** - * This is a custom hook for loading the Store API /cart endpoint. - * 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. + * External dependencies */ -export const useStoreCart = () => { - const collectionOptions = { - namespace: '/wc/store', - resourceName: 'cart', - }; - const { results: cartData, isLoading } = useCollection( collectionOptions ); - return { - cartData, - isLoading, - }; +import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data'; +import { useSelect } from '@wordpress/data'; + +/** + * @constant + * @type {StoreCart} Object containing cart data. + */ +const defaultCartData = { + 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; }; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/frontend.js b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/frontend.js index 231425a2931..63271c14d0a 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/frontend.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/frontend.js @@ -4,7 +4,7 @@ import { withRestApiHydration } from '@woocommerce/block-hocs'; import { useStoreCart } from '@woocommerce/base-hooks'; import { RawHTML } from '@wordpress/element'; - +import LoadingMask from '@woocommerce/base-components/loading-mask'; /** * Internal dependencies */ @@ -19,24 +19,46 @@ const CartFrontend = ( { isShippingCalculatorEnabled, 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; } - const cartItems = cartData.items; - const isCartEmpty = cartItems.length <= 0; - - return isCartEmpty ? ( - { emptyCart } - ) : ( - + return ( + <> +
+ { // @todo This is a placeholder for error messages - this needs refactoring. + cartErrors && + cartErrors.map( ( error = {}, i ) => ( +
+ { error.message } +
+ ) ) } +
+ { ! cartItems.length ? ( + { emptyCart } + ) : ( + + + + ) } + ); }; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/index.js b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/index.js index 807aed881af..6c688c12c3e 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/index.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/index.js @@ -13,6 +13,7 @@ import ShippingRatesControl, { } from '@woocommerce/base-components/shipping-rates-control'; import ShippingCalculator from '@woocommerce/base-components/shipping-calculator'; import ShippingLocation from '@woocommerce/base-components/shipping-location'; +import LoadingMask from '@woocommerce/base-components/loading-mask'; import { COUPONS_ENABLED, SHIPPING_ENABLED, @@ -22,6 +23,7 @@ import { getCurrencyFromPriceResponse } from '@woocommerce/base-utils'; import { Card, CardBody } from 'wordpress-components'; import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount'; import { decodeEntities } from '@wordpress/html-entities'; +import { useStoreCartCoupons } from '@woocommerce/base-hooks'; /** * Internal dependencies @@ -33,12 +35,6 @@ import CartLineItemsTable from './cart-line-items-table'; import './style.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 ) => ( { label: decodeEntities( option.name ), value: option.rate_id, @@ -62,6 +58,7 @@ const renderShippingRatesControlOption = ( option ) => ( { const Cart = ( { cartItems = [], cartTotals = {}, + cartCoupons = [], isShippingCalculatorEnabled, isShippingCostHidden, shippingRates, @@ -76,10 +73,16 @@ const Cart = ( { postcode: '', country: '', } ); - const [ showShippingCosts, setShowShippingCosts ] = useState( ! isShippingCostHidden ); + const { + applyCoupon, + removeCoupon, + isApplyingCoupon, + isRemovingCoupon, + } = useStoreCartCoupons(); + useEffect( () => { if ( ! SHIPPING_ENABLED ) { return setShowShippingCosts( false ); @@ -99,6 +102,7 @@ const Cart = ( { isShippingCostHidden, shippingCalculatorAddress, ] ); + /** * 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 ); - if ( totalDiscount > 0 ) { + if ( totalDiscount > 0 || cartCoupons.length !== 0 ) { const totalDiscountTax = parseInt( cartTotals.total_discount_tax, 10 ); + // @todo The remove coupon button is a placeholder - replace with new + // chip component. totalRowsConfig.push( { label: __( 'Discount:', 'woo-gutenberg-products-block' ), - value: DISPLAY_PRICES_INCLUDING_TAXES - ? totalDiscount + totalDiscountTax - : totalDiscount, + value: + ( DISPLAY_PRICES_INCLUDING_TAXES + ? totalDiscount + totalDiscountTax + : totalDiscount ) * -1, + description: ( + + { cartCoupons.map( ( cartCoupon ) => ( + + ) ) } + + ), } ); } if ( ! DISPLAY_PRICES_INCLUDING_TAXES ) { @@ -248,7 +277,8 @@ const Cart = ( { { showShippingCosts && } { COUPONS_ENABLED && ( ) } { + 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; diff --git a/plugins/woocommerce-blocks/assets/js/data/cart/resolvers.js b/plugins/woocommerce-blocks/assets/js/data/cart/resolvers.js new file mode 100644 index 00000000000..6d1e37a86f7 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/data/cart/resolvers.js @@ -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' ); +} diff --git a/plugins/woocommerce-blocks/assets/js/data/cart/selectors.js b/plugins/woocommerce-blocks/assets/js/data/cart/selectors.js new file mode 100644 index 00000000000..dce180b9fcd --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/data/cart/selectors.js @@ -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 || ''; +}; diff --git a/plugins/woocommerce-blocks/assets/js/data/cart/test/reducers.js b/plugins/woocommerce-blocks/assets/js/data/cart/test/reducers.js new file mode 100644 index 00000000000..28a6c177bac --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/data/cart/test/reducers.js @@ -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' ); + } ); +} ); diff --git a/plugins/woocommerce-blocks/assets/js/data/cart/test/resolvers.js b/plugins/woocommerce-blocks/assets/js/data/cart/test/resolvers.js new file mode 100644 index 00000000000..8c45ddb3944 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/data/cart/test/resolvers.js @@ -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 ); + } + ); + } ); +} ); diff --git a/plugins/woocommerce-blocks/assets/js/data/cart/test/selectors.js b/plugins/woocommerce-blocks/assets/js/data/cart/test/selectors.js new file mode 100644 index 00000000000..35bc444df36 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/data/cart/test/selectors.js @@ -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: '

This is a simple, virtual product.

', + short_description: '

This is a simple, virtual product.

', + description: + '

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.

', + 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: '

This is a simple product.

', + short_description: '

This is a simple product.

', + description: + '

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.

', + 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 + ); + } ); +} ); diff --git a/plugins/woocommerce-blocks/assets/js/data/collections/actions.js b/plugins/woocommerce-blocks/assets/js/data/collections/actions.js index da762623942..4b0a143f103 100644 --- a/plugins/woocommerce-blocks/assets/js/data/collections/actions.js +++ b/plugins/woocommerce-blocks/assets/js/data/collections/actions.js @@ -78,25 +78,31 @@ export function* __experimentalPersistItemToCollection( if ( ! route ) { return; } - const item = yield apiFetch( { - path: route, - method: 'POST', - data, - cache: 'no-store', - } ); - if ( item ) { - newCollection.push( item ); - yield receiveCollection( - namespace, - resourceName, - '', - [], - { - items: newCollection, - headers: Headers, - }, - true - ); + + try { + const item = yield apiFetch( { + path: route, + method: 'POST', + data, + cache: 'no-store', + } ); + + if ( item ) { + newCollection.push( item ); + yield receiveCollection( + namespace, + resourceName, + '', + [], + { + items: newCollection, + headers: Headers, + }, + true + ); + } + } catch ( error ) { + yield receiveCollectionError( namespace, resourceName, '', [], error ); } } diff --git a/plugins/woocommerce-blocks/assets/js/data/index.js b/plugins/woocommerce-blocks/assets/js/data/index.js index a9e040cdede..a31c17f1463 100644 --- a/plugins/woocommerce-blocks/assets/js/data/index.js +++ b/plugins/woocommerce-blocks/assets/js/data/index.js @@ -1,4 +1,5 @@ export { SCHEMA_STORE_KEY } from './schema'; export { COLLECTIONS_STORE_KEY } from './collections'; +export { CART_STORE_KEY } from './cart'; export { QUERY_STATE_STORE_KEY } from './query-state'; export { API_BLOCK_NAMESPACE } from './constants'; diff --git a/plugins/woocommerce-blocks/assets/js/type-defs/cart.js b/plugins/woocommerce-blocks/assets/js/type-defs/cart.js index d74c1179dce..09f88872631 100644 --- a/plugins/woocommerce-blocks/assets/js/type-defs/cart.js +++ b/plugins/woocommerce-blocks/assets/js/type-defs/cart.js @@ -45,4 +45,60 @@ * @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 {}; diff --git a/plugins/woocommerce-blocks/assets/js/type-defs/hooks.js b/plugins/woocommerce-blocks/assets/js/type-defs/hooks.js new file mode 100644 index 00000000000..3e42b427d0a --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/type-defs/hooks.js @@ -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 {}; diff --git a/plugins/woocommerce-blocks/package-lock.json b/plugins/woocommerce-blocks/package-lock.json index 64e0f03bef7..47896b881bb 100644 --- a/plugins/woocommerce-blocks/package-lock.json +++ b/plugins/woocommerce-blocks/package-lock.json @@ -3306,8 +3306,7 @@ "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", - "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", - "dev": true + "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" }, "@types/events": { "version": "3.0.0", @@ -3341,14 +3340,12 @@ "@types/istanbul-lib-coverage": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", - "integrity": "sha512-hRJD2ahnnpLgsj6KWMYSrmXkM3rm2Dl1qkx6IOFD5FnuNPXJIG5L0dhgKXCYTRMGzU4n0wImQ/xfmRc4POUFlg==", - "dev": true + "integrity": "sha512-hRJD2ahnnpLgsj6KWMYSrmXkM3rm2Dl1qkx6IOFD5FnuNPXJIG5L0dhgKXCYTRMGzU4n0wImQ/xfmRc4POUFlg==" }, "@types/istanbul-lib-report": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", - "dev": true, "requires": { "@types/istanbul-lib-coverage": "*" } @@ -3357,12 +3354,100 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.1.tgz", "integrity": "sha512-UpYjBi8xefVChsCoBpKShdxTllC9pwISirfoZsUa2AAdQg/Jd2KQGtSbw+ya7GPo7x/wAPlH6JBhKhAsXUEZNA==", - "dev": true, "requires": { "@types/istanbul-lib-coverage": "*", "@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": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.4.tgz", @@ -3589,8 +3674,7 @@ "@types/yargs-parser": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz", - "integrity": "sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw==", - "dev": true + "integrity": "sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw==" }, "@typescript-eslint/experimental-utils": { "version": "1.13.0", @@ -8719,7 +8803,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -8729,7 +8812,6 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", - "dev": true, "requires": { "@types/color-name": "^1.1.1", "color-convert": "^2.0.1" @@ -8739,7 +8821,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "requires": { "color-name": "~1.1.4" } @@ -8747,20 +8828,17 @@ "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==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, "supports-color": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", - "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -28404,7 +28482,7 @@ } }, "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", "dev": true, "requires": { diff --git a/plugins/woocommerce-blocks/package.json b/plugins/woocommerce-blocks/package.json index 29207154a16..7cf21bd27d6 100644 --- a/plugins/woocommerce-blocks/package.json +++ b/plugins/woocommerce-blocks/package.json @@ -139,11 +139,12 @@ "npm": "6.13.7" }, "dependencies": { + "@types/jest": "^25.1.3", "@woocommerce/components": "4.0.0", "classnames": "2.2.6", "compare-versions": "3.6.0", - "downshift": "4.1.0", "config": "3.2.5", + "downshift": "4.1.0", "react-number-format": "4.3.1", "trim-html": "0.1.9", "use-debounce": "3.3.0", diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/Cart.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/Cart.php index 63f68683c70..ea6e9bb3c34 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/Cart.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/Cart.php @@ -13,6 +13,7 @@ defined( 'ABSPATH' ) || exit; use \WP_Error as RestError; use \WP_REST_Server as RestServer; 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\Utilities\CartController; @@ -68,6 +69,106 @@ class Cart extends RestController { 'schema' => [ $this, 'get_public_item_schema' ], ] ); + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/apply-coupon/(?P[\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[\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; } /** diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/CartCoupons.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/CartCoupons.php index 37542c4e2c4..c4820078d15 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/CartCoupons.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/CartCoupons.php @@ -193,6 +193,7 @@ class CartCoupons extends RestController { $cart = $controller->get_cart_instance(); $cart->remove_coupon( $request['code'] ); + $cart->calculate_totals(); return new RestResponse( null, 204 ); } @@ -207,6 +208,7 @@ class CartCoupons extends RestController { $controller = new CartController(); $cart = $controller->get_cart_instance(); $cart->remove_coupons(); + $cart->calculate_totals(); return new RestResponse( [], 200 ); } diff --git a/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/Cart.php b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/Cart.php index 5a4dbfe8c37..d946852548a 100644 --- a/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/Cart.php +++ b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/Cart.php @@ -85,6 +85,34 @@ class Cart extends TestCase { $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. */