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

Commit

Permalink
Add minimum quantity, maximum quantity, and step (multiple_of) to the…
Browse files Browse the repository at this point in the history
… Cart Block and Store API (#5406)

* add min and step to Store API

* add min and step support

* typo

* Update assets/js/base/components/quantity-selector/index.tsx

* Update assets/js/base/components/quantity-selector/index.tsx

* Fix debounce callback

* Style qty input to show steps

* Implement quantity_limits in API

* Quantity validation

* Update product API

* Normalize on + -

* Separate add to cart events from cart item events in regards to limits

* Prevent qty change for editable line items

* Unify filters

* Remove step number indicator from buttons ¯\_(ツ)_/¯

* Normalize on mount

* Update docs

Co-authored-by: Nadir Seghir <[email protected]>
  • Loading branch information
mikejolley and senadir authored Jan 11, 2022
1 parent a5353c1 commit aa78099
Show file tree
Hide file tree
Showing 23 changed files with 815 additions and 396 deletions.
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 }
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 );

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,
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

0 comments on commit aa78099

Please sign in to comment.