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:
parent
2f58d86fa6
commit
0cacf5e0da
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {};
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 );
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue