From 0cacf5e0da2b8e711f10db1a3a16d4d3eafe09b3 Mon Sep 17 00:00:00 2001 From: Darren Ethier Date: Tue, 10 Mar 2020 07:43:57 -0400 Subject: [PATCH] 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) --- .../hooks/use-store-cart-item-quantity.js | 46 ++++----------- .../cart/full-cart/cart-line-item-row.js | 59 ++++++++++++++----- .../assets/js/type-defs/cart.js | 57 +++++++++++------- .../assets/js/type-defs/hooks.js | 13 ++-- .../src/BlockTypes/Cart.php | 10 ++++ .../StoreApi/Schemas/CartItemSchema.php | 34 +++++++++-- .../StoreApi/Controllers/CartItems.php | 2 + 7 files changed, 134 insertions(+), 87 deletions(-) diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart-item-quantity.js b/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart-item-quantity.js index 09af702e6ec..79babe0bafa 100644 --- a/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart-item-quantity.js +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart-item-quantity.js @@ -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, }; }; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/cart-line-item-row.js b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/cart-line-item-row.js index a36acc839d8..f60413b303a 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/cart-line-item-row.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/cart-line-item-row.js @@ -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 } - +
{ summary } @@ -69,7 +92,10 @@ const CartLineItemRow = ( { lineItem = {} } ) => { @@ -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, diff --git a/plugins/woocommerce-blocks/assets/js/type-defs/cart.js b/plugins/woocommerce-blocks/assets/js/type-defs/cart.js index 9a24ab16251..e219c58af79 100644 --- a/plugins/woocommerce-blocks/assets/js/type-defs/cart.js +++ b/plugins/woocommerce-blocks/assets/js/type-defs/cart.js @@ -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 diff --git a/plugins/woocommerce-blocks/assets/js/type-defs/hooks.js b/plugins/woocommerce-blocks/assets/js/type-defs/hooks.js index f70f54eb1f1..09aed04ff71 100644 --- a/plugins/woocommerce-blocks/assets/js/type-defs/hooks.js +++ b/plugins/woocommerce-blocks/assets/js/type-defs/hooks.js @@ -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 {}; diff --git a/plugins/woocommerce-blocks/src/BlockTypes/Cart.php b/plugins/woocommerce-blocks/src/BlockTypes/Cart.php index 421ae14e719..ee2a58fa8f1 100644 --- a/plugins/woocommerce-blocks/src/BlockTypes/Cart.php +++ b/plugins/woocommerce-blocks/src/BlockTypes/Cart.php @@ -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' diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php index 2a217fb5df7..7b103489336 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php @@ -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; } diff --git a/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/CartItems.php b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/CartItems.php index 91e8b924f88..273215e8a82 100644 --- a/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/CartItems.php +++ b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/CartItems.php @@ -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 ); }