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 { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useDebounce } from 'use-debounce'; 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/hooks').StoreCartItemQuantity} StoreCartItemQuantity
* @typedef {import('@woocommerce/type-defs/cart').CartItem} CartItem * @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 * @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/master/src/RestApi/StoreApi
* *
* @param {string} cartItemKey Key for a cart item. * @param {CartItem} cartItem The cartItem to get quantity info from and
* @return {StoreCartItemQuantity} An object exposing data and actions relating to cart items. * will have quantity updated on.
* @return {StoreCartItemQuantity} An object exposing data and actions relating
* to cart items.
*/ */
export const useStoreCartItemQuantity = ( cartItemKey ) => { export const useStoreCartItemQuantity = ( cartItem ) => {
const { cartItems, cartIsLoading } = useStoreCart();
/**
* @type {[CartItem, function( CartItem ):undefined]}
*/
const [ cartItem, setCartItem ] = useState( {
key: '',
quantity: 0,
} );
// Store quantity in hook state. This is used to keep the UI // Store quantity in hook state. This is used to keep the UI
// updated while server request is updated. // updated while server request is updated.
const [ quantity, changeQuantity ] = useState( cartItem.quantity ); const [ quantity, changeQuantity ] = useState( cartItem.quantity );
@ -41,43 +30,28 @@ export const useStoreCartItemQuantity = ( cartItemKey ) => {
const isPending = useSelect( const isPending = useSelect(
( select ) => { ( select ) => {
const store = select( storeKey ); 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( const { removeItemFromCart, changeCartItemQuantity } = useDispatch(
storeKey storeKey
); );
const removeItem = () => { const removeItem = () => {
removeItemFromCart( cartItemKey ); removeItemFromCart( cartItem.key );
}; };
// Observe debounced quantity value, fire action to update server when it // Observe debounced quantity value, fire action to update server when it
// changes. // changes.
useEffect( () => { useEffect( () => {
if ( debouncedQuantity === 0 ) { changeCartItemQuantity( cartItem.key, debouncedQuantity );
changeQuantity( cartItem.quantity ); }, [ debouncedQuantity, cartItem.key ] );
return;
}
changeCartItemQuantity( cartItemKey, debouncedQuantity );
}, [ debouncedQuantity, cartItemKey, cartItem.quantity ] );
return { return {
isPending, isPending,
quantity, quantity,
changeQuantity, changeQuantity,
removeItem, 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 { getCurrency, formatPrice } from '@woocommerce/base-utils';
import { useStoreCartItemQuantity } from '@woocommerce/base-hooks'; import { useStoreCartItemQuantity } from '@woocommerce/base-hooks';
import { Icon, trash } from '@woocommerce/icons'; import { Icon, trash } from '@woocommerce/icons';
import { getSetting } from '@woocommerce/settings';
/** /**
* Internal dependencies * Internal dependencies
@ -17,18 +18,42 @@ import ProductVariationData from './product-variation-data';
import ProductImage from './product-image'; import ProductImage from './product-image';
import ProductLowStockBadge from './product-low-stock-badge'; 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. * Cart line item table row component.
*/ */
const CartLineItemRow = ( { lineItem = {} } ) => { const CartLineItemRow = ( { lineItem } ) => {
/**
* @type {CartItem}
*/
const { const {
key = '', name,
name = '', summary,
summary = '', low_stock_remaining: lowStockRemaining,
permalink = '', backorders_allowed: backOrdersAllowed,
images = [], permalink,
variation = [], images,
prices = {}, variation,
prices,
} = lineItem; } = lineItem;
const { const {
@ -36,9 +61,9 @@ const CartLineItemRow = ( { lineItem = {} } ) => {
changeQuantity, changeQuantity,
removeItem, removeItem,
isPending: itemQuantityDisabled, isPending: itemQuantityDisabled,
} = useStoreCartItemQuantity( key ); } = useStoreCartItemQuantity( lineItem );
const currency = getCurrency(); const currency = getCurrency( prices );
const regularPrice = parseInt( prices.regular_price, 10 ) * quantity; const regularPrice = parseInt( prices.regular_price, 10 ) * quantity;
const purchasePrice = parseInt( prices.price, 10 ) * quantity; const purchasePrice = parseInt( prices.price, 10 ) * quantity;
const saleAmount = regularPrice - purchasePrice; const saleAmount = regularPrice - purchasePrice;
@ -57,9 +82,7 @@ const CartLineItemRow = ( { lineItem = {} } ) => {
> >
{ name } { name }
</a> </a>
<ProductLowStockBadge <ProductLowStockBadge lowStockRemaining={ lowStockRemaining } />
lowStockRemaining={ lineItem.low_stock_remaining }
/>
<div className="wc-block-cart-item__product-metadata"> <div className="wc-block-cart-item__product-metadata">
<RawHTML>{ summary }</RawHTML> <RawHTML>{ summary }</RawHTML>
<ProductVariationData variation={ variation } /> <ProductVariationData variation={ variation } />
@ -69,7 +92,10 @@ const CartLineItemRow = ( { lineItem = {} } ) => {
<QuantitySelector <QuantitySelector
disabled={ itemQuantityDisabled } disabled={ itemQuantityDisabled }
quantity={ quantity } quantity={ quantity }
maximum={ lineItem.sold_individually ? 1 : undefined } maximum={ getMaximumQuantity(
backOrdersAllowed,
lowStockRemaining
) }
onChange={ changeQuantity } onChange={ changeQuantity }
itemName={ name } itemName={ name }
/> />
@ -121,8 +147,9 @@ CartLineItemRow.propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
summary: PropTypes.string.isRequired, summary: PropTypes.string.isRequired,
images: PropTypes.array.isRequired, images: PropTypes.array.isRequired,
low_stock_remaining: PropTypes.number, low_stock_remaining: PropTypes.number.isRequired,
sold_individually: PropTypes.bool, backorders_allowed: PropTypes.bool.isRequired,
sold_individually: PropTypes.bool.isRequired,
variation: PropTypes.arrayOf( variation: PropTypes.arrayOf(
PropTypes.shape( { PropTypes.shape( {
attribute: PropTypes.string.isRequired, attribute: PropTypes.string.isRequired,

View File

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

View File

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

View File

@ -58,6 +58,16 @@ class Cart extends AbstractBlock {
if ( ! $data_registry->exists( 'cartData' ) ) { if ( ! $data_registry->exists( 'cartData' ) ) {
$data_registry->add( 'cartData', WC()->api->get_endpoint_data( '/wc/store/cart' ) ); $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( \Automattic\WooCommerce\Blocks\Assets::register_block_script(
$this->block_name . '-frontend', $this->block_name . '-frontend',
$this->block_name . '-block-frontend' $this->block_name . '-block-frontend'

View File

@ -104,6 +104,12 @@ class CartItemSchema extends AbstractSchema {
'context' => [ 'view', 'edit' ], 'context' => [ 'view', 'edit' ],
'readonly' => true, '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' => [ 'sold_individually' => [
'description' => __( 'If true, only one item of this product is allowed for purchase in a single order.', 'woo-gutenberg-products-block' ), 'description' => __( 'If true, only one item of this product is allowed for purchase in a single order.', 'woo-gutenberg-products-block' ),
'type' => 'boolean', 'type' => 'boolean',
@ -304,6 +310,7 @@ class CartItemSchema extends AbstractSchema {
'description' => $this->prepare_html_response( wc_format_content( $product->get_description() ) ), 'description' => $this->prepare_html_response( wc_format_content( $product->get_description() ) ),
'sku' => $this->prepare_html_response( $product->get_sku() ), 'sku' => $this->prepare_html_response( $product->get_sku() ),
'low_stock_remaining' => $this->get_low_stock_remaining( $product ), 'low_stock_remaining' => $this->get_low_stock_remaining( $product ),
'backorders_allowed' => $product->backorders_allowed(),
'sold_individually' => $product->is_sold_individually(), 'sold_individually' => $product->is_sold_individually(),
'permalink' => $product->get_permalink(), 'permalink' => $product->get_permalink(),
'images' => ( new ProductImages() )->images_to_array( $product ), '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. * @param \WC_Product $product Product instance.
* @return integer|null * @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() ) ) { if ( is_null( $product->get_stock_quantity() ) ) {
return null; return null;
} }
@ -343,10 +350,25 @@ class CartItemSchema extends AbstractSchema {
$reserve_stock = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock(); $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 ); $reserved_stock = $reserve_stock->get_reserved_stock( $product, isset( $draft_order['id'] ) ? $draft_order['id'] : 0 );
$remaining_stock = $product->get_stock_quantity() - $reserved_stock; 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; return $remaining_stock;
} }

View File

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