* first cut - removing an item from cart:
- add actions to cart store for removing an item and keeping track of
pending removal API call
- add reducer logic for storing pending state on an item, and removing
an item
- expose removeCartItem on new useStoreCartItems hook
- hook it up to remove link / trashcan icon in row item

* disable cart quantity picker/remove link while API request in progress:
- expose cart item pending status from store using selector
- use selector to disable quantity related components in line item row

* fix jsdoc - getCartItem returns undefined if not found

* add typedef for cart items store object provided by hook

* fix rebase error - key prop went awol

* orient useStoreCartItem hook to single cart item:
- simplify interface for client component
  - isPending bool (was callback)
  - removeItem callback no need to specify item key
+ reinstate disabled prop on remove link when updating (lost in rebase)

* move cart item pending state out of cartItems, preserve API state shape:
- pending is now stored as array of keys
- fix isItemQuantityPending selector (now much simpler)

* ensure react knows that our useSelect depends on cartItemKey
This commit is contained in:
Rua Haszard 2020-02-28 15:05:10 +13:00 committed by GitHub
parent bb637bbaa3
commit 69ea94378b
8 changed files with 203 additions and 3 deletions

View File

@ -2,6 +2,7 @@ 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-cart-coupons';
export * from './use-store-cart-item';
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,47 @@
/** @typedef { import('@woocommerce/type-defs/hooks').StoreCartItems } StoreCartItems */
/**
* 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/ endpoint and
* actions for removing or changing item quantity.
* See also: https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/master/src/RestApi/StoreApi
*
* @param {string} cartItemKey Key for a cart item.
* @return {StoreCartItems} An object exposing data and actions relating to cart items.
*/
export const useStoreCartItem = ( cartItemKey ) => {
const { cartItems, cartIsLoading } = useStoreCart();
const cartItem = cartItems.filter( ( item ) => item.key === cartItemKey );
const results = useSelect(
( select, { dispatch } ) => {
const store = select( storeKey );
const isPending = store.isItemQuantityPending( cartItemKey );
const { removeItemFromCart } = dispatch( storeKey );
return {
isPending,
removeItem: () => {
removeItemFromCart( cartItemKey );
},
};
},
[ cartItemKey ]
);
return {
isLoading: cartIsLoading,
cartItem,
...results,
};
};

View File

@ -7,6 +7,7 @@ import PropTypes from 'prop-types';
import QuantitySelector from '@woocommerce/base-components/quantity-selector'; import QuantitySelector from '@woocommerce/base-components/quantity-selector';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount'; import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
import { getCurrency, formatPrice } from '@woocommerce/base-utils'; import { getCurrency, formatPrice } from '@woocommerce/base-utils';
import { useStoreCartItem } from '@woocommerce/base-hooks';
import { Icon, trash } from '@woocommerce/icons'; import { Icon, trash } from '@woocommerce/icons';
/** /**
@ -21,6 +22,7 @@ import ProductLowStockBadge from './product-low-stock-badge';
*/ */
const CartLineItemRow = ( { lineItem = {} } ) => { const CartLineItemRow = ( { lineItem = {} } ) => {
const { const {
key = '',
name = '', name = '',
summary = '', summary = '',
permalink = '', permalink = '',
@ -36,6 +38,10 @@ const CartLineItemRow = ( { lineItem = {} } ) => {
const purchasePrice = parseInt( prices.price, 10 ) * lineQuantity; const purchasePrice = parseInt( prices.price, 10 ) * lineQuantity;
const saleAmount = regularPrice - purchasePrice; const saleAmount = regularPrice - purchasePrice;
const { removeItem, isPending: itemQuantityDisabled } = useStoreCartItem(
key
);
return ( return (
<tr className="wc-block-cart-items__row"> <tr className="wc-block-cart-items__row">
<td className="wc-block-cart-item__image"> <td className="wc-block-cart-item__image">
@ -60,14 +66,23 @@ const CartLineItemRow = ( { lineItem = {} } ) => {
</td> </td>
<td className="wc-block-cart-item__quantity"> <td className="wc-block-cart-item__quantity">
<QuantitySelector <QuantitySelector
disabled={ itemQuantityDisabled }
quantity={ lineQuantity } quantity={ lineQuantity }
onChange={ setLineQuantity } onChange={ setLineQuantity }
itemName={ name } itemName={ name }
/> />
<button className="wc-block-cart-item__remove-link"> <button
disabled={ itemQuantityDisabled }
className="wc-block-cart-item__remove-link"
onClick={ removeItem }
>
{ __( 'Remove item', 'woo-gutenberg-products-block' ) } { __( 'Remove item', 'woo-gutenberg-products-block' ) }
</button> </button>
<button className="wc-block-cart-item__remove-icon"> <button
disabled={ itemQuantityDisabled }
className="wc-block-cart-item__remove-icon"
onClick={ removeItem }
>
<Icon srcElement={ trash } /> <Icon srcElement={ trash } />
</button> </button>
</td> </td>
@ -100,6 +115,7 @@ const CartLineItemRow = ( { lineItem = {} } ) => {
CartLineItemRow.propTypes = { CartLineItemRow.propTypes = {
lineItem: PropTypes.shape( { lineItem: PropTypes.shape( {
key: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
summary: PropTypes.string.isRequired, summary: PropTypes.string.isRequired,
images: PropTypes.array.isRequired, images: PropTypes.array.isRequired,

View File

@ -4,4 +4,6 @@ export const ACTION_TYPES = {
REPLACE_ERRORS: 'REPLACE_ERRORS', REPLACE_ERRORS: 'REPLACE_ERRORS',
APPLYING_COUPON: 'APPLYING_COUPON', APPLYING_COUPON: 'APPLYING_COUPON',
REMOVING_COUPON: 'REMOVING_COUPON', REMOVING_COUPON: 'REMOVING_COUPON',
ITEM_QUANTITY_PENDING: 'ITEM_QUANTITY_PENDING',
RECEIVE_REMOVED_ITEM: 'RECEIVE_REMOVED_ITEM',
}; };

View File

@ -125,3 +125,59 @@ export function* removeCoupon( couponCode ) {
yield receiveRemovingCoupon( '' ); yield receiveRemovingCoupon( '' );
} }
/**
* Returns an action object to indicate if the specified cart item
* is being updated; i.e. removing, or changing quantity.
*
* @param {string} cartItemKey Cart item being updated.
* @param {boolean} isQuantityPending Flag for update state; true if API request is pending.
* @return {Object} Object for action.
*/
export function itemQuantityPending( cartItemKey, isQuantityPending ) {
return {
type: types.ITEM_QUANTITY_PENDING,
cartItemKey,
isQuantityPending,
};
}
/**
* Returns an action object to remove a cart item from the store.
*
* @param {string} cartItemKey Cart item to remove.
* @return {Object} Object for action.
*/
export function receiveRemovedItem( cartItemKey ) {
return {
type: types.RECEIVE_REMOVED_ITEM,
cartItemKey,
};
}
/**
* Removes specified item from the cart:
* - Calls API to remove item.
* - If successful, yields action to remove item from store.
* - If error, yields action to store error.
* - Sets cart item as pending while API request is in progress.
*
* @param {string} cartItemKey Cart item being updated.
*/
export function* removeItemFromCart( cartItemKey ) {
yield itemQuantityPending( cartItemKey, true );
try {
yield apiFetch( {
path: `/wc/store/cart/items/${ cartItemKey }`,
method: 'DELETE',
cache: 'no-store',
} );
yield receiveRemovedItem( cartItemKey );
} catch ( error ) {
yield receiveError( error );
}
yield itemQuantityPending( cartItemKey, false );
}

View File

@ -3,6 +3,22 @@
*/ */
import { ACTION_TYPES as types } from './action-types'; import { ACTION_TYPES as types } from './action-types';
/**
* Sub-reducer for cart items array.
*
* @param {Array} state cartData.items state slice.
* @param {Object} action Action object.
*/
const cartItemsReducer = ( state = [], action ) => {
switch ( action.type ) {
case types.RECEIVE_REMOVED_ITEM:
return state.filter( ( cartItem ) => {
return cartItem.key !== action.cartItemKey;
} );
}
return state;
};
/** /**
* Reducer for receiving items related to the cart. * Reducer for receiving items related to the cart.
* *
@ -13,6 +29,7 @@ import { ACTION_TYPES as types } from './action-types';
*/ */
const reducer = ( const reducer = (
state = { state = {
cartItemsQuantityPending: [],
cartData: { cartData: {
coupons: [], coupons: [],
items: [], items: [],
@ -64,6 +81,32 @@ const reducer = (
}, },
}; };
break; break;
case types.ITEM_QUANTITY_PENDING:
// Remove key by default - handles isQuantityPending==false
// and prevents duplicates when isQuantityPending===true.
const newPendingKeys = state.cartItemsQuantityPending.filter(
( key ) => key !== action.cartItemKey
);
if ( action.isQuantityPending ) {
newPendingKeys.push( action.cartItemKey );
}
state = {
...state,
cartItemsQuantityPending: newPendingKeys,
};
break;
// Delegate to cartItemsReducer.
case types.RECEIVE_REMOVED_ITEM:
state = {
...state,
cartData: {
...state.cartData,
items: cartItemsReducer( state.cartData.items, action ),
},
};
break;
} }
return state; return state;
}; };

View File

@ -106,3 +106,27 @@ export const isRemovingCoupon = ( state ) => {
export const getCouponBeingRemoved = ( state ) => { export const getCouponBeingRemoved = ( state ) => {
return state.metaData.removingCoupon || ''; return state.metaData.removingCoupon || '';
}; };
/**
* Returns cart item matching specified key.
*
* @param {Object} state The current state.
* @param {string} cartItemKey Key for a cart item.
* @return {Object} Cart item object, or undefined if not found.
*/
export const getCartItem = ( state, cartItemKey ) => {
return state.cartData.items.find(
( cartItem ) => cartItem.key === cartItemKey
);
};
/**
* Returns true if the quantity is being updated for the specified cart item.
*
* @param {Object} state The current state.
* @param {string} cartItemKey Key for a cart item.
* @return {boolean} True if a item has a pending request to delete / update quantity.
*/
export const isItemQuantityPending = ( state, cartItemKey ) => {
return state.cartItemsQuantityPending.includes( cartItemKey );
};

View File

@ -23,4 +23,15 @@
* @property {boolean} isRemovingCoupon True when a coupon is being removed. * @property {boolean} isRemovingCoupon True when a coupon is being removed.
*/ */
export {}; /**
* @typedef {Object} StoreCartItems
*
* @property {boolean} isLoading True when cart items are being loaded.
* @property {Array} cartItems An array of items in the cart.
* @property {Function} isItemQuantityPending Callback for determining if a cart item
* is currently updating (i.e. remoe / change
* quantity).
* @property {Function} removeItemFromCart Callback for removing a cart item.
*/
export {};