Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Add minimum quantity, maximum quantity, and step (multiple_of) to the Cart Block and Store API #5406

Merged
merged 17 commits into from
Jan 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const Simple = () => {
quantity,
minQuantity,
maxQuantity,
multipleOf,
dispatchActions,
isDisabled,
} = useAddToCartFormContext();
Expand All @@ -43,6 +44,7 @@ const Simple = () => {
value={ quantity }
min={ minQuantity }
max={ maxQuantity }
step={ multipleOf }
mikejolley marked this conversation as resolved.
Show resolved Hide resolved
disabled={ isDisabled }
onChange={ dispatchActions.setQuantity }
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const Variable = () => {
quantity,
minQuantity,
maxQuantity,
multipleOf,
dispatchActions,
isDisabled,
} = useAddToCartFormContext();
Expand Down Expand Up @@ -52,6 +53,7 @@ const Variable = () => {
value={ quantity }
min={ minQuantity }
max={ maxQuantity }
step={ multipleOf }
disabled={ isDisabled }
onChange={ dispatchActions.setQuantity }
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,71 @@
/**
* External dependencies
*/
import { useDebouncedCallback } from 'use-debounce';

/**
* Quantity Input Component.
*
* @param {Object} props Incoming props for component
* @param {boolean} props.disabled Whether input is disabled or not.
* @param {number} props.min Minimum value for input.
* @param {number} props.max Maximum value for input.
* @param {number} props.step Step attribute for input.
* @param {number} props.value Value for input.
* @param {function():any} props.onChange Function to call on input change event.
*/
const QuantityInput = ( { disabled, min, max, value, onChange } ) => {
const QuantityInput = ( { disabled, min, max, step = 1, value, onChange } ) => {
const hasMaximum = typeof max !== 'undefined';

/**
* The goal of this function is to normalize what was inserted,
* but after the customer has stopped typing.
*
* It's important to wait before normalizing or we end up with
* a frustrating experience, for example, if the minimum is 2 and
* the customer is trying to type "10", premature normalizing would
* always kick in at "1" and turn that into 2.
*
* Copied from <QuantitySelector>
*/
const normalizeQuantity = useDebouncedCallback( ( initialValue ) => {
// We copy the starting value.
let newValue = initialValue;

// We check if we have a maximum value, and select the lowest between what was inserted and the maximum.
if ( hasMaximum ) {
newValue = Math.min(
newValue,
// the maximum possible value in step increments.
Math.floor( max / step ) * step
);
}

// Select the biggest between what's inserted, the the minimum value in steps.
newValue = Math.max( newValue, Math.ceil( min / step ) * step );

// We round off the value to our steps.
newValue = Math.floor( newValue / step ) * step;

// Only commit if the value has changed
if ( newValue !== initialValue ) {
onChange( newValue );
}
}, 300 );

Comment on lines +17 to +55
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we used this in a lot of places that it might be worth abstracting up?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should use the same component in both places (follow up) rather than abstracting this one function.

return (
<input
className="wc-block-components-product-add-to-cart-quantity"
type="number"
value={ value }
min={ min }
max={ max }
step={ step }
hidden={ max === 1 }
disabled={ disabled }
onChange={ ( e ) => {
onChange( e.target.value );
normalizeQuantity( e.target.value );
} }
/>
);
Expand Down
98 changes: 79 additions & 19 deletions assets/js/base/components/quantity-selector/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
import { __, sprintf } from '@wordpress/i18n';
import { speak } from '@wordpress/a11y';
import classNames from 'classnames';
import { useCallback } from '@wordpress/element';
import { useCallback, useLayoutEffect } from '@wordpress/element';
import { DOWN, UP } from '@wordpress/keycodes';
import { useDebouncedCallback } from 'use-debounce';

/**
* Internal dependencies
Expand All @@ -31,6 +32,10 @@ export interface QuantitySelectorProps {
* Maximum quantity
*/
maximum: number;
/**
* Input step attribute.
*/
step?: number;
/**
* Event handler triggered when the quantity is changed
*/
Expand All @@ -53,6 +58,7 @@ const QuantitySelector = ( {
minimum = 1,
maximum,
onChange = () => void 0,
step = 1,
itemName = '',
disabled,
}: QuantitySelectorProps ): JSX.Element => {
Expand All @@ -62,8 +68,59 @@ const QuantitySelector = ( {
);

const hasMaximum = typeof maximum !== 'undefined';
const canDecrease = quantity > minimum;
const canIncrease = ! hasMaximum || quantity < maximum;
const canDecrease = quantity - step >= minimum;
const canIncrease = ! hasMaximum || quantity + step <= maximum;

/**
* The goal of this function is to normalize what was inserted,
* but after the customer has stopped typing.
*/
const normalizeQuantity = useCallback(
( initialValue: number ) => {
// We copy the starting value.
let value = initialValue;

// We check if we have a maximum value, and select the lowest between what was inserted and the maximum.
if ( hasMaximum ) {
value = Math.min(
value,
// the maximum possible value in step increments.
Math.floor( maximum / step ) * step
);
}

// Select the biggest between what's inserted, the the minimum value in steps.
value = Math.max( value, Math.ceil( minimum / step ) * step );

// We round off the value to our steps.
value = Math.floor( value / step ) * step;

// Only commit if the value has changed
if ( value !== initialValue ) {
onChange( value );
}
},
[ hasMaximum, maximum, minimum, onChange, step ]
);

/*
* It's important to wait before normalizing or we end up with
* a frustrating experience, for example, if the minimum is 2 and
* the customer is trying to type "10", premature normalizing would
* always kick in at "1" and turn that into 2.
*/
const debouncedNormalizeQuantity = useDebouncedCallback(
normalizeQuantity,
// This value is deliberately smaller than what's in useStoreCartItemQuantity so we don't end up with two requests.
300
);

/**
* Normalize qty on mount before render.
*/
useLayoutEffect( () => {
normalizeQuantity( quantity );
}, [ quantity, normalizeQuantity ] );

/**
* Handles keyboard up and down keys to change quantity value.
Expand All @@ -83,15 +140,15 @@ const QuantitySelector = ( {

if ( isArrowDown && canDecrease ) {
event.preventDefault();
onChange( quantity - 1 );
onChange( quantity - step );
}

if ( isArrowUp && canIncrease ) {
event.preventDefault();
onChange( quantity + 1 );
onChange( quantity + step );
}
},
[ quantity, onChange, canIncrease, canDecrease ]
[ quantity, onChange, canIncrease, canDecrease, step ]
);

return (
Expand All @@ -100,22 +157,23 @@ const QuantitySelector = ( {
className="wc-block-components-quantity-selector__input"
disabled={ disabled }
type="number"
step="1"
min="0"
step={ step }
min={ minimum }
max={ maximum }
value={ quantity }
onKeyDown={ quantityInputOnKeyDown }
onChange={ ( event ) => {
let value =
Number.isNaN( event.target.value ) ||
! event.target.value
? 0
: parseInt( event.target.value, 10 );
if ( hasMaximum ) {
value = Math.min( value, maximum );
}
value = Math.max( value, minimum );
// Inputs values are strings, we parse them here.
let value = parseInt( event.target.value, 10 );
// parseInt would throw NaN for anything not a number,
// so we revert value to the quantity value.
value = isNaN( value ) ? quantity : value;

if ( value !== quantity ) {
// we commit this value immediately.
onChange( value );
// but once the customer has stopped typing, we make sure his value is respecting the bounds (maximum value, minimum value, step value), and commit the normalized value.
debouncedNormalizeQuantity( value );
}
} }
aria-label={ sprintf(
Expand All @@ -135,7 +193,7 @@ const QuantitySelector = ( {
className="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus"
disabled={ disabled || ! canDecrease }
onClick={ () => {
const newQuantity = quantity - 1;
const newQuantity = quantity - step;
onChange( newQuantity );
speak(
sprintf(
Expand All @@ -147,6 +205,7 @@ const QuantitySelector = ( {
newQuantity
)
);
normalizeQuantity( newQuantity );
} }
>
&#65293;
Expand All @@ -159,7 +218,7 @@ const QuantitySelector = ( {
disabled={ disabled || ! canIncrease }
className="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus"
onClick={ () => {
const newQuantity = quantity + 1;
const newQuantity = quantity + step;
onChange( newQuantity );
speak(
sprintf(
Expand All @@ -171,6 +230,7 @@ const QuantitySelector = ( {
newQuantity
)
);
normalizeQuantity( newQuantity );
} }
>
&#65291;
Expand Down
6 changes: 4 additions & 2 deletions assets/js/base/components/quantity-selector/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
line-height: 1;
vertical-align: middle;
-moz-appearance: textfield;
font-weight: 600;

&:focus {
background: $gray-100;
Expand Down Expand Up @@ -70,11 +71,12 @@

.wc-block-components-quantity-selector__button {
@include reset-button;
@include font-size(regular);
@include font-size(regular, 0.9em);
min-width: 30px;
cursor: pointer;
color: $gray-900;
color: $gray-600;
font-style: normal;
font-weight: normal;
text-align: center;
text-decoration: none;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const STATUS = {
export const DEFAULT_STATE = {
status: STATUS.PRISTINE,
hasError: false,
quantity: 1,
quantity: 0,
processingResponse: null,
requestParams: {},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,9 +296,11 @@ export const AddToCartFormStateContextProvider = ( {
productHasOptions: product.has_options || false,
supportsFormElements,
showFormElements: showFormElements && supportsFormElements,
quantity: addToCartFormState.quantity,
minQuantity: 1,
maxQuantity: product.quantity_limit || 99,
quantity:
addToCartFormState.quantity || product?.add_to_cart?.minimum || 1,
minQuantity: product?.add_to_cart?.minimum || 1,
maxQuantity: product?.add_to_cart?.maximum || 99,
multipleOf: product?.add_to_cart?.multiple_of || 1,
requestParams: addToCartFormState.requestParams,
isIdle: addToCartFormState.status === STATUS.IDLE,
isDisabled: addToCartFormState.status === STATUS.DISABLED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ const CartLineItemRow = forwardRef< HTMLTableRowElement, CartLineItemRowProps >(
description: fullDescription = '',
low_stock_remaining: lowStockRemaining = null,
show_backorder_badge: showBackorderBadge = false,
quantity_limit: quantityLimit = 99,
quantity_limits: quantityLimits = {
minimum: 1,
maximum: 99,
multiple_of: 1,
editable: true,
},
sold_individually: soldIndividually = false,
mikejolley marked this conversation as resolved.
Show resolved Hide resolved
permalink = '',
images = [],
variation = [],
Expand Down Expand Up @@ -278,19 +284,26 @@ const CartLineItemRow = forwardRef< HTMLTableRowElement, CartLineItemRowProps >(
/>

<div className="wc-block-cart-item__quantity">
<QuantitySelector
disabled={ isPendingDelete }
quantity={ quantity }
maximum={ quantityLimit }
onChange={ ( newQuantity ) => {
setItemQuantity( newQuantity );
dispatchStoreEvent( 'cart-set-item-quantity', {
product: lineItem,
quantity: newQuantity,
} );
} }
itemName={ name }
/>
{ ! soldIndividually && !! quantityLimits.editable && (
<QuantitySelector
disabled={ isPendingDelete }
quantity={ quantity }
minimum={ quantityLimits.minimum }
maximum={ quantityLimits.maximum }
step={ quantityLimits.multiple_of }
onChange={ ( newQuantity ) => {
setItemQuantity( newQuantity );
dispatchStoreEvent(
'cart-set-item-quantity',
{
product: lineItem,
quantity: newQuantity,
}
);
} }
itemName={ name }
/>
) }
<button
className="wc-block-cart-item__remove-link"
onClick={ () => {
Expand Down
Loading