Disallow selecting quantity of stock past what's available. (https://github.com/woocommerce/woocommerce-blocks/pull/1905)

- `useStoreCartItemQuantity` now receives the cartItem instead of the `cartItemKey` as an argument. I didn't notice in previous reviews how it's used in the context where we already have a cartItem so implementing this reduces complexity and makes the hook more precise for it's purpose.
- Add `backorders_allowed` to the CartItem schema for the API. This allows for client to have correct logic for maximum quantity when this value is true.
- Implement the above in the `CartLineItemRow` so that quantity picker is limited by the amount of stock remaining if that is available (`lowStockRemaining`) and the `backorders_allowed` is false.
- maximum quantity is currently hardcoded to a (filtered) value of `99` when other conditions don't apply (see related issue in woocommerce/woocommerce-blocks#1913)
This commit is contained in:
Darren Ethier 2020-03-10 07:43:57 -04:00 committed by GitHub
parent 2f58d86fa6
commit 0cacf5e0da
7 changed files with 134 additions and 87 deletions

View File

@ -6,11 +6,6 @@ import { useState, useEffect } from '@wordpress/element';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useDebounce } from 'use-debounce';
/**
* Internal dependencies
*/
import { useStoreCart } from './use-store-cart';
/**
* @typedef {import('@woocommerce/type-defs/hooks').StoreCartItemQuantity} StoreCartItemQuantity
* @typedef {import('@woocommerce/type-defs/cart').CartItem} CartItem
@ -22,18 +17,12 @@ import { useStoreCart } from './use-store-cart';
*
* @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/master/src/RestApi/StoreApi
*
* @param {string} cartItemKey Key for a cart item.
* @return {StoreCartItemQuantity} An object exposing data and actions relating to cart items.
* @param {CartItem} cartItem The cartItem to get quantity info from and
* will have quantity updated on.
* @return {StoreCartItemQuantity} An object exposing data and actions relating
* to cart items.
*/
export const useStoreCartItemQuantity = ( cartItemKey ) => {
const { cartItems, cartIsLoading } = useStoreCart();
/**
* @type {[CartItem, function( CartItem ):undefined]}
*/
const [ cartItem, setCartItem ] = useState( {
key: '',
quantity: 0,
} );
export const useStoreCartItemQuantity = ( cartItem ) => {
// Store quantity in hook state. This is used to keep the UI
// updated while server request is updated.
const [ quantity, changeQuantity ] = useState( cartItem.quantity );
@ -41,43 +30,28 @@ export const useStoreCartItemQuantity = ( cartItemKey ) => {
const isPending = useSelect(
( select ) => {
const store = select( storeKey );
return store.isItemQuantityPending( cartItemKey );
return store.isItemQuantityPending( cartItem.key );
},
[ cartItemKey ]
[ cartItem.key ]
);
useEffect( () => {
if ( ! cartIsLoading ) {
const foundCartItem = cartItems.find(
( item ) => item.key === cartItemKey
);
if ( foundCartItem ) {
setCartItem( foundCartItem );
}
}
}, [ cartItems, cartIsLoading, cartItemKey ] );
const { removeItemFromCart, changeCartItemQuantity } = useDispatch(
storeKey
);
const removeItem = () => {
removeItemFromCart( cartItemKey );
removeItemFromCart( cartItem.key );
};
// Observe debounced quantity value, fire action to update server when it
// changes.
useEffect( () => {
if ( debouncedQuantity === 0 ) {
changeQuantity( cartItem.quantity );
return;
}
changeCartItemQuantity( cartItemKey, debouncedQuantity );
}, [ debouncedQuantity, cartItemKey, cartItem.quantity ] );
changeCartItemQuantity( cartItem.key, debouncedQuantity );
}, [ debouncedQuantity, cartItem.key ] );
return {
isPending,
quantity,
changeQuantity,
removeItem,
isLoading: cartIsLoading,
};
};

View File

@ -9,6 +9,7 @@ import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-mone
import { getCurrency, formatPrice } from '@woocommerce/base-utils';
import { useStoreCartItemQuantity } from '@woocommerce/base-hooks';
import { Icon, trash } from '@woocommerce/icons';
import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
@ -17,18 +18,42 @@ import ProductVariationData from './product-variation-data';
import ProductImage from './product-image';
import ProductLowStockBadge from './product-low-stock-badge';
/**
* @typedef {import('@woocommerce/type-defs/cart').CartItem} CartItem
*/
/**
*
* @param {boolean} backOrdersAllowed Whether to allow backorders or not.
* @param {number|null} lowStockAmount If present the number of stock
* remaining.
*
* @return {number} The maximum number value for the quantity input.
*/
const getMaximumQuantity = ( backOrdersAllowed, lowStockAmount ) => {
const maxQuantityLimit = getSetting( 'quantitySelectLimit', 99 );
if ( backOrdersAllowed || ! lowStockAmount ) {
return maxQuantityLimit;
}
return Math.min( lowStockAmount, maxQuantityLimit );
};
/**
* Cart line item table row component.
*/
const CartLineItemRow = ( { lineItem = {} } ) => {
const CartLineItemRow = ( { lineItem } ) => {
/**
* @type {CartItem}
*/
const {
key = '',
name = '',
summary = '',
permalink = '',
images = [],
variation = [],
prices = {},
name,
summary,
low_stock_remaining: lowStockRemaining,
backorders_allowed: backOrdersAllowed,
permalink,
images,
variation,
prices,
} = lineItem;
const {
@ -36,9 +61,9 @@ const CartLineItemRow = ( { lineItem = {} } ) => {
changeQuantity,
removeItem,
isPending: itemQuantityDisabled,
} = useStoreCartItemQuantity( key );
} = useStoreCartItemQuantity( lineItem );
const currency = getCurrency();
const currency = getCurrency( prices );
const regularPrice = parseInt( prices.regular_price, 10 ) * quantity;
const purchasePrice = parseInt( prices.price, 10 ) * quantity;
const saleAmount = regularPrice - purchasePrice;
@ -57,9 +82,7 @@ const CartLineItemRow = ( { lineItem = {} } ) => {
>
{ name }
</a>
<ProductLowStockBadge
lowStockRemaining={ lineItem.low_stock_remaining }
/>
<ProductLowStockBadge lowStockRemaining={ lowStockRemaining } />
<div className="wc-block-cart-item__product-metadata">
<RawHTML>{ summary }</RawHTML>
<ProductVariationData variation={ variation } />
@ -69,7 +92,10 @@ const CartLineItemRow = ( { lineItem = {} } ) => {
<QuantitySelector
disabled={ itemQuantityDisabled }
quantity={ quantity }
maximum={ lineItem.sold_individually ? 1 : undefined }
maximum={ getMaximumQuantity(
backOrdersAllowed,
lowStockRemaining
) }
onChange={ changeQuantity }
itemName={ name }
/>
@ -121,8 +147,9 @@ CartLineItemRow.propTypes = {
name: PropTypes.string.isRequired,
summary: PropTypes.string.isRequired,
images: PropTypes.array.isRequired,
low_stock_remaining: PropTypes.number,
sold_individually: PropTypes.bool,
low_stock_remaining: PropTypes.number.isRequired,
backorders_allowed: PropTypes.bool.isRequired,
sold_individually: PropTypes.bool.isRequired,
variation: PropTypes.arrayOf(
PropTypes.shape( {
attribute: PropTypes.string.isRequired,

View File

@ -92,31 +92,40 @@
* @property {string} line_total_tax Line total tax.
*/
/**
* @typedef {Object} CartItemPriceRange
*
* @property {string} min_amount Price min amount in range.
* @property {string} max_amount Price max amount in range.
*/
/**
* @typedef {Object} CartItemPrices
*
* @property {string} currency_code The ISO code for the currency.
* @property {number} currency_minor_unit The precision (decimal
* places).
* @property {string} currency_symbol The symbol for the currency
* (eg '$')
* @property {string} currency_prefix Price prefix for the currency
* which can be used to format
* returned prices.
* @property {string} currency_suffix Price suffix for the currency
* which can be used to format
* returned prices.
* @property {string} currency_decimal_separator The string used for the
* decimal separator.
* @property {string} currency_thousand_separator The string used for the
* thousands separator.
* @property {string} price Current product price.
* @property {string} regular_price Regular product price.
* @property {string} sale_price Sale product price, if
* applicable.
* @property {Object} price_range Price range, if applicable.
* @property {string} price_range.min_amount Price min amount in range.
* @property {string} price_range.max_amount Price max amount in range.
* @property {string} currency_code The ISO code for the
* currency.
* @property {number} currency_minor_unit The precision (decimal
* places).
* @property {string} currency_symbol The symbol for the
* currency (eg '$')
* @property {string} currency_prefix Price prefix for the
* currency which can be
* used to format returned
* prices.
* @property {string} currency_suffix Price suffix for the
* currency which can be
* used to format returned
* prices.
* @property {string} currency_decimal_separator The string used for the
* decimal separator.
* @property {string} currency_thousand_separator The string used for the
* thousands separator.
* @property {string} price Current product price.
* @property {string} regular_price Regular product price.
* @property {string} sale_price Sale product price, if
* applicable.
* @property {CartItemPriceRange|null} price_range Price range, if
* applicable.
*
*/
@ -140,6 +149,9 @@
* @property {number|null} low_stock_remaining Quantity left in stock if
* stock is low, or null if
* not applicable.
* @property {boolean} backorders_allowed True if backorders are
* allowed past stock
* availability.
* @property {boolean} sold_individually If true, only one item of
* this product is allowed
* for purchase in a single
@ -150,6 +162,7 @@
* product/variation.
* @property {CartItemVariation[]} variation Chosen attributes (for
* variations).
* @property {CartItemPrices} prices Item prices.
* @property {CartItemTotals} totals Item total amounts
* provided using the
* smallest unit of the

View File

@ -31,13 +31,12 @@
/**
* @typedef {Object} StoreCartItemQuantity
*
* @property {boolean} isLoading True when cart items are being
* loaded.
* @property {number} quantity The quantity of the item in the cart.
* @property {boolean} isPending Whether the cart item is updating or not.
* @property {Function} changeQuantity Callback for changing quantity of item
* in cart.
* @property {Function} removeItem Callback for removing a cart item.
* @property {number} quantity The quantity of the item in the cart.
* @property {boolean} isPending Whether the cart item is updating or
* not.
* @property {Function} changeQuantity Callback for changing quantity of
* item in cart.
* @property {Function} removeItem Callback for removing a cart item.
*/
export {};

View File

@ -58,6 +58,16 @@ class Cart extends AbstractBlock {
if ( ! $data_registry->exists( 'cartData' ) ) {
$data_registry->add( 'cartData', WC()->api->get_endpoint_data( '/wc/store/cart' ) );
}
if ( ! $data_registry->exists( 'quantitySelectLimit' ) ) {
/**
* Note: this filter will be deprecated if/when quantity select limits
* are added at the product level.
*
* @return {integer} $max_quantity_limit Maximum quantity of products that can be selected in the cart.
*/
$max_quantity_limit = apply_filters( 'woocommerce_maximum_quantity_selected_cart', 99 );
$data_registry->add( 'quantitySelectLimit', $max_quantity_limit );
}
\Automattic\WooCommerce\Blocks\Assets::register_block_script(
$this->block_name . '-frontend',
$this->block_name . '-block-frontend'

View File

@ -104,6 +104,12 @@ class CartItemSchema extends AbstractSchema {
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'backorders_allowed' => [
'description' => __( 'True if backorders are allowed past stock availability.', 'woo-gutenberg-products-block' ),
'type' => [ 'boolean' ],
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'sold_individually' => [
'description' => __( 'If true, only one item of this product is allowed for purchase in a single order.', 'woo-gutenberg-products-block' ),
'type' => 'boolean',
@ -304,6 +310,7 @@ class CartItemSchema extends AbstractSchema {
'description' => $this->prepare_html_response( wc_format_content( $product->get_description() ) ),
'sku' => $this->prepare_html_response( $product->get_sku() ),
'low_stock_remaining' => $this->get_low_stock_remaining( $product ),
'backorders_allowed' => $product->backorders_allowed(),
'sold_individually' => $product->is_sold_individually(),
'permalink' => $product->get_permalink(),
'images' => ( new ProductImages() )->images_to_array( $product ),
@ -322,14 +329,14 @@ class CartItemSchema extends AbstractSchema {
}
/**
* If a product has low stock, return the remaining stock amount for display.
* Returns the remaining stock for a product if it has stock.
*
* Note; unlike the products API, this also factors in draft orders so the results are more up to date.
* This also factors in draft orders.
*
* @param \WC_Product $product Product instance.
* @return integer|null
*/
protected function get_low_stock_remaining( \WC_Product $product ) {
protected function get_remaining_stock( \WC_Product $product ) {
if ( is_null( $product->get_stock_quantity() ) ) {
return null;
}
@ -343,10 +350,25 @@ class CartItemSchema extends AbstractSchema {
$reserve_stock = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock();
}
$reserved_stock = $reserve_stock->get_reserved_stock( $product, isset( $draft_order['id'] ) ? $draft_order['id'] : 0 );
$remaining_stock = $product->get_stock_quantity() - $reserved_stock;
$reserved_stock = $reserve_stock->get_reserved_stock( $product, isset( $draft_order['id'] ) ? $draft_order['id'] : 0 );
return $product->get_stock_quantity() - $reserved_stock;
}
if ( $remaining_stock <= wc_get_low_stock_amount( $product ) ) {
/**
* If a product has low stock, return the remaining stock amount for display.
*
* Note; unlike the products API, this also factors in draft orders so the results are more up to date.
*
* @param \WC_Product $product Product instance.
* @return integer|null
*/
protected function get_low_stock_remaining( \WC_Product $product ) {
$remaining_stock = $this->get_remaining_stock( $product );
if (
null !== $remaining_stock
&& $remaining_stock <= wc_get_low_stock_amount( $product )
) {
return $remaining_stock;
}

View File

@ -215,6 +215,7 @@ class CartItems extends TestCase {
$this->assertArrayHasKey( 'short_description', $schema['properties'] );
$this->assertArrayHasKey( 'sku', $schema['properties'] );
$this->assertArrayHasKey( 'low_stock_remaining', $schema['properties'] );
$this->assertArrayHasKey( 'backorders_allowed', $schema['properties'] );
$this->assertArrayHasKey( 'permalink', $schema['properties'] );
$this->assertArrayHasKey( 'images', $schema['properties'] );
$this->assertArrayHasKey( 'totals', $schema['properties'] );
@ -240,6 +241,7 @@ class CartItems extends TestCase {
$this->assertArrayHasKey( 'totals', $data );
$this->assertArrayHasKey( 'variation', $data );
$this->assertArrayHasKey( 'low_stock_remaining', $data );
$this->assertArrayHasKey( 'backorders_allowed', $data );
$this->assertArrayHasKey( 'short_description', $data );
}