Add validation context provider and implement validation for shipping country and coupons. (https://github.com/woocommerce/woocommerce-blocks/pull/1972)

* add errormessage handling to countryinput (along with storybook)

* add types for react

* Add validation context and implement

* implement validation context for country field validation

* tweak ValidationInputError so that it can receive property name for getting error from

* improve storybook webpack config to pull from tsconfig.json

* update storybook story to cover changes with context

* Wrap Checkout Provider with Validation Context Provider

* add screen-reader-text style to storybook

* add styles for input error validation to text input

* improve styling for ValidationInputError component

* add validation error handling to TotalsCouponCode component

And story

* make sure errors are cleared on successful receive/remove item

* dispatch loading cancellation on catching errors

This is needed because loading would be cancelled before the error is thrown so any error handling after the thrown error will not be able to rely on loading.

* implement validation setting for coupon errors

* add error color to labels on inputs too

* fix borders back and force border color

* remove extra structure and improve validation error with alignment for coupon code

* add aria-describedby for text inputs

* add back in validation context provider to fix rebase issue

* rework validation so it works for both checkout and cart

* Some styling tweaks

* more style fixes

* remove unnecessary method

* make sure new function is included in context defaults

* package.lock update? seems harmless so rolling with it.
This commit is contained in:
Darren Ethier 2020-03-17 07:45:33 -04:00 committed by GitHub
parent bd6c7f657c
commit ad4c981793
29 changed files with 958 additions and 254 deletions

View File

@ -11,6 +11,9 @@ import {
BillingStateInput,
ShippingStateInput,
} from '@woocommerce/base-components/state-input';
import { useValidationContext } from '@woocommerce/base-context';
import { useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
@ -18,6 +21,29 @@ import {
import defaultAddressFields from './default-address-fields';
import countryAddressFields from './country-address-fields';
const validateCountry = (
values,
setValidationErrors,
clearValidationError,
hasValidationError
) => {
if (
! hasValidationError &&
! values.country &&
Object.values( values ).some( ( value ) => value !== '' )
) {
setValidationErrors( {
country: __(
'Please select a country to calculate rates.',
'woo-gutenberg-products-block'
),
} );
}
if ( hasValidationError && values.country ) {
clearValidationError( 'country' );
}
};
/**
* Checkout address form.
*/
@ -28,6 +54,11 @@ const AddressForm = ( {
type = 'shipping',
values,
} ) => {
const {
getValidationError,
setValidationErrors,
clearValidationError,
} = useValidationContext();
const countryLocale = countryAddressFields[ values.country ] || {};
const addressFields = fields.map( ( field ) => ( {
key: field,
@ -38,7 +69,20 @@ const AddressForm = ( {
const sortedAddressFields = addressFields.sort(
( a, b ) => a.index - b.index
);
const countryValidationError = getValidationError( 'country' );
useEffect( () => {
validateCountry(
values,
setValidationErrors,
clearValidationError,
!! countryValidationError
);
}, [
values,
countryValidationError,
setValidationErrors,
clearValidationError,
] );
return (
<div className="wc-block-address-form">
{ sortedAddressFields.map( ( field ) => {

View File

@ -3,11 +3,14 @@
*/
import PropTypes from 'prop-types';
import { decodeEntities } from '@wordpress/html-entities';
import { useValidationContext } from '@woocommerce/base-context';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import Select from '../select';
import { ValidationInputError } from '../validation';
const CountryInput = ( {
className,
@ -22,16 +25,18 @@ const CountryInput = ( {
key,
name: decodeEntities( countries[ key ] ),
} ) );
const { getValidationError } = useValidationContext();
const errorMessage = getValidationError( 'country' );
return (
<>
<div className={ classnames( className, 'wc-block-country-input' ) }>
<Select
className={ className }
label={ label }
onChange={ onChange }
options={ options }
value={ options.find( ( option ) => option.key === value ) }
required={ required }
hasError={ !! errorMessage }
/>
{ autoComplete !== 'off' && (
<input
@ -56,7 +61,8 @@ const CountryInput = ( {
tabIndex={ -1 }
/>
) }
</>
<ValidationInputError errorMessage={ errorMessage } />
</div>
);
};

View File

@ -0,0 +1,251 @@
export const countries = {
AX: '&#197;land Islands',
AF: 'Afghanistan',
AL: 'Albania',
DZ: 'Algeria',
AS: 'American Samoa',
AD: 'Andorra',
AO: 'Angola',
AI: 'Anguilla',
AQ: 'Antarctica',
AG: 'Antigua and Barbuda',
AR: 'Argentina',
AM: 'Armenia',
AW: 'Aruba',
AU: 'Australia',
AT: 'Austria',
AZ: 'Azerbaijan',
BS: 'Bahamas',
BH: 'Bahrain',
BD: 'Bangladesh',
BB: 'Barbados',
BY: 'Belarus',
PW: 'Belau',
BE: 'Belgium',
BZ: 'Belize',
BJ: 'Benin',
BM: 'Bermuda',
BT: 'Bhutan',
BO: 'Bolivia',
BQ: 'Bonaire, Saint Eustatius and Saba',
BA: 'Bosnia and Herzegovina',
BW: 'Botswana',
BV: 'Bouvet Island',
BR: 'Brazil',
IO: 'British Indian Ocean Territory',
BN: 'Brunei',
BG: 'Bulgaria',
BF: 'Burkina Faso',
BI: 'Burundi',
KH: 'Cambodia',
CM: 'Cameroon',
CA: 'Canada',
CV: 'Cape Verde',
KY: 'Cayman Islands',
CF: 'Central African Republic',
TD: 'Chad',
CL: 'Chile',
CN: 'China',
CX: 'Christmas Island',
CC: 'Cocos (Keeling) Islands',
CO: 'Colombia',
KM: 'Comoros',
CG: 'Congo (Brazzaville)',
CD: 'Congo (Kinshasa)',
CK: 'Cook Islands',
CR: 'Costa Rica',
HR: 'Croatia',
CU: 'Cuba',
CW: 'Cura&ccedil;ao',
CY: 'Cyprus',
CZ: 'Czech Republic',
DK: 'Denmark',
DJ: 'Djibouti',
DM: 'Dominica',
DO: 'Dominican Republic',
EC: 'Ecuador',
EG: 'Egypt',
SV: 'El Salvador',
GQ: 'Equatorial Guinea',
ER: 'Eritrea',
EE: 'Estonia',
ET: 'Ethiopia',
FK: 'Falkland Islands',
FO: 'Faroe Islands',
FJ: 'Fiji',
FI: 'Finland',
FR: 'France',
GF: 'French Guiana',
PF: 'French Polynesia',
TF: 'French Southern Territories',
GA: 'Gabon',
GM: 'Gambia',
GE: 'Georgia',
DE: 'Germany',
GH: 'Ghana',
GI: 'Gibraltar',
GR: 'Greece',
GL: 'Greenland',
GD: 'Grenada',
GP: 'Guadeloupe',
GU: 'Guam',
GT: 'Guatemala',
GG: 'Guernsey',
GN: 'Guinea',
GW: 'Guinea-Bissau',
GY: 'Guyana',
HT: 'Haiti',
HM: 'Heard Island and McDonald Islands',
HN: 'Honduras',
HK: 'Hong Kong',
HU: 'Hungary',
IS: 'Iceland',
IN: 'India',
ID: 'Indonesia',
IR: 'Iran',
IQ: 'Iraq',
IE: 'Ireland',
IM: 'Isle of Man',
IL: 'Israel',
IT: 'Italy',
CI: 'Ivory Coast',
JM: 'Jamaica',
JP: 'Japan',
JE: 'Jersey',
JO: 'Jordan',
KZ: 'Kazakhstan',
KE: 'Kenya',
KI: 'Kiribati',
KW: 'Kuwait',
KG: 'Kyrgyzstan',
LA: 'Laos',
LV: 'Latvia',
LB: 'Lebanon',
LS: 'Lesotho',
LR: 'Liberia',
LY: 'Libya',
LI: 'Liechtenstein',
LT: 'Lithuania',
LU: 'Luxembourg',
MO: 'Macao',
MG: 'Madagascar',
MW: 'Malawi',
MY: 'Malaysia',
MV: 'Maldives',
ML: 'Mali',
MT: 'Malta',
MH: 'Marshall Islands',
MQ: 'Martinique',
MR: 'Mauritania',
MU: 'Mauritius',
YT: 'Mayotte',
MX: 'Mexico',
FM: 'Micronesia',
MD: 'Moldova',
MC: 'Monaco',
MN: 'Mongolia',
ME: 'Montenegro',
MS: 'Montserrat',
MA: 'Morocco',
MZ: 'Mozambique',
MM: 'Myanmar',
NA: 'Namibia',
NR: 'Nauru',
NP: 'Nepal',
NL: 'Netherlands',
NC: 'New Caledonia',
NZ: 'New Zealand',
NI: 'Nicaragua',
NE: 'Niger',
NG: 'Nigeria',
NU: 'Niue',
NF: 'Norfolk Island',
KP: 'North Korea',
MK: 'North Macedonia',
MP: 'Northern Mariana Islands',
NO: 'Norway',
OM: 'Oman',
PK: 'Pakistan',
PS: 'Palestinian Territory',
PA: 'Panama',
PG: 'Papua New Guinea',
PY: 'Paraguay',
PE: 'Peru',
PH: 'Philippines',
PN: 'Pitcairn',
PL: 'Poland',
PT: 'Portugal',
PR: 'Puerto Rico',
QA: 'Qatar',
RE: 'Reunion',
RO: 'Romania',
RU: 'Russia',
RW: 'Rwanda',
ST: 'S&atilde;o Tom&eacute; and Pr&iacute;ncipe',
BL: 'Saint Barth&eacute;lemy',
SH: 'Saint Helena',
KN: 'Saint Kitts and Nevis',
LC: 'Saint Lucia',
SX: 'Saint Martin (Dutch part)',
MF: 'Saint Martin (French part)',
PM: 'Saint Pierre and Miquelon',
VC: 'Saint Vincent and the Grenadines',
WS: 'Samoa',
SM: 'San Marino',
SA: 'Saudi Arabia',
SN: 'Senegal',
RS: 'Serbia',
SC: 'Seychelles',
SL: 'Sierra Leone',
SG: 'Singapore',
SK: 'Slovakia',
SI: 'Slovenia',
SB: 'Solomon Islands',
SO: 'Somalia',
ZA: 'South Africa',
GS: 'South Georgia/Sandwich Islands',
KR: 'South Korea',
SS: 'South Sudan',
ES: 'Spain',
LK: 'Sri Lanka',
SD: 'Sudan',
SR: 'Suriname',
SJ: 'Svalbard and Jan Mayen',
SZ: 'Swaziland',
SE: 'Sweden',
CH: 'Switzerland',
SY: 'Syria',
TW: 'Taiwan',
TJ: 'Tajikistan',
TZ: 'Tanzania',
TH: 'Thailand',
TL: 'Timor-Leste',
TG: 'Togo',
TK: 'Tokelau',
TO: 'Tonga',
TT: 'Trinidad and Tobago',
TN: 'Tunisia',
TR: 'Turkey',
TM: 'Turkmenistan',
TC: 'Turks and Caicos Islands',
TV: 'Tuvalu',
UG: 'Uganda',
UA: 'Ukraine',
AE: 'United Arab Emirates',
GB: 'United Kingdom (UK)',
US: 'United States (US)',
UM: 'United States (US) Minor Outlying Islands',
UY: 'Uruguay',
UZ: 'Uzbekistan',
VU: 'Vanuatu',
VA: 'Vatican',
VE: 'Venezuela',
VN: 'Vietnam',
VG: 'Virgin Islands (British)',
VI: 'Virgin Islands (US)',
WF: 'Wallis and Futuna',
EH: 'Western Sahara',
YE: 'Yemen',
ZM: 'Zambia',
ZW: 'Zimbabwe',
};

View File

@ -0,0 +1,53 @@
/**
* External dependencies
*/
import { text } from '@storybook/addon-knobs';
import { useState, useEffect } from '@wordpress/element';
import {
ValidationContextProvider,
useValidationContext,
} from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import { CountryInput } from '../';
import { countries as exampleCountries } from './countries-filler';
export default {
title: 'WooCommerce Blocks/@base-components/CountryInput',
component: CountryInput,
};
const StoryComponent = ( { label, errorMessage } ) => {
const [ selectedCountry, selectCountry ] = useState();
const {
setValidationErrors,
clearValidationError,
} = useValidationContext();
useEffect( () => {
setValidationErrors( { country: errorMessage } );
}, [ errorMessage ] );
const updateCountry = ( country ) => {
clearValidationError( 'country' );
selectCountry( country );
};
return (
<CountryInput
countries={ exampleCountries }
label={ label }
value={ selectedCountry }
onChange={ updateCountry }
/>
);
};
export const Default = () => {
const label = text( 'Input Label', 'Countries:' );
const errorMessage = text( 'Error Message', '' );
return (
<ValidationContextProvider>
<StoryComponent label={ label } errorMessage={ errorMessage } />
</ValidationContextProvider>
);
};

View File

@ -10,11 +10,12 @@ import { CustomSelectControl } from 'wordpress-components';
*/
import './style.scss';
const Select = ( { className, label, onChange, options, value } ) => {
const Select = ( { className, label, onChange, options, value, hasError } ) => {
return (
<CustomSelectControl
className={ classnames( 'wc-block-select', className, {
'is-active': value,
'has-error': hasError,
} ) }
label={ label }
onChange={ ( { selectedItem } ) => {

View File

@ -1,7 +1,7 @@
.wc-block-select {
height: 48px;
position: relative;
margin-top: $gap;
margin-bottom: $gap-large;
label {
position: absolute;
@ -26,6 +26,16 @@
transform: translateY(#{$gap-smallest}) scale(0.75);
}
&.has-error {
.components-custom-select-control__button {
border-color: $error-red;
}
}
&.has-error label {
color: $error-red;
}
.components-custom-select-control__button {
background-color: #fff;
box-shadow: none;

View File

@ -6,6 +6,7 @@ import { __ } from '@wordpress/i18n';
import { Button } from '@woocommerce/base-components/cart-checkout';
import { useState } from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { useValidationContext } from '@woocommerce/base-context';
/**
* Internal dependencies
@ -19,6 +20,7 @@ const ShippingCalculatorAddress = ( {
addressFields,
} ) => {
const [ address, setAddress ] = useState( initialAddress );
const { getValidationError } = useValidationContext();
return (
<form className="wc-block-shipping-calculator-address">
@ -29,7 +31,10 @@ const ShippingCalculatorAddress = ( {
/>
<Button
className="wc-block-shipping-calculator-address__button"
disabled={ isShallowEqual( address, initialAddress ) }
disabled={
isShallowEqual( address, initialAddress ) ||
getValidationError( 'country' )
}
onClick={ ( e ) => {
e.preventDefault();
return onUpdate( address );

View File

@ -6,3 +6,7 @@
margin-top: $gap;
width: 100%;
}
.wc-block-cart__shipping-calculator {
padding-top: $gap-large;
}

View File

@ -18,6 +18,7 @@ const TextInput = ( {
id,
type = 'text',
ariaLabel,
ariaDescribedBy,
label,
screenReaderLabel,
disabled,
@ -48,7 +49,9 @@ const TextInput = ( {
aria-label={ ariaLabel || label }
disabled={ disabled }
aria-describedby={
!! help ? textInputId + '__help' : undefined
!! help && ! ariaDescribedBy
? textInputId + '__help'
: ariaDescribedBy
}
required={ required }
/>
@ -78,6 +81,7 @@ TextInput.propTypes = {
id: PropTypes.string,
value: PropTypes.string,
ariaLabel: PropTypes.string,
ariaDescribedBy: PropTypes.string,
label: PropTypes.string,
screenReaderLabel: PropTypes.string,
disabled: PropTypes.bool,

View File

@ -1,7 +1,23 @@
.wc-block-text-input {
position: relative;
margin-top: $gap;
margin-bottom: $gap-large;
white-space: nowrap;
&.has-error input {
/**
* This is needed because of the input[type="*"] rules later in this
* file (and in themes like storefront) have higher specificity for
* border
*/
border-color: $error-red !important;
&:focus {
outline-color: $error-red;
}
}
&.has-error label {
color: $error-red;
}
}
.wc-block-text-input label {

View File

@ -7,8 +7,11 @@ import { PanelBody, PanelRow } from 'wordpress-components';
import { Button } from '@woocommerce/base-components/cart-checkout';
import TextInput from '@woocommerce/base-components/text-input';
import Label from '@woocommerce/base-components/label';
import { ValidationInputError } from '@woocommerce/base-components/validation';
import PropTypes from 'prop-types';
import { withInstanceId } from 'wordpress-compose';
import classnames from 'classnames';
import { useValidationContext } from '@woocommerce/base-context';
/**
* Internal dependencies
@ -24,15 +27,23 @@ const TotalsCouponCodeInput = ( {
} ) => {
const [ couponValue, setCouponValue ] = useState( '' );
const currentIsLoading = useRef( false );
const {
getValidationError,
clearValidationError,
getValidationErrorId,
} = useValidationContext();
const validationMessage = getValidationError( 'coupon' );
useEffect( () => {
if ( currentIsLoading.current !== isLoading ) {
if ( ! isLoading && couponValue ) {
if ( ! isLoading && couponValue && ! validationMessage ) {
setCouponValue( '' );
}
currentIsLoading.current = isLoading;
}
}, [ isLoading, couponValue ] );
}, [ isLoading, couponValue, validationMessage ] );
const textInputId = `wc-block-coupon-code__input-${ instanceId }`;
return (
<PanelBody
@ -47,7 +58,7 @@ const TotalsCouponCodeInput = ( {
'Introduce Coupon Code',
'woo-gutenberg-products-block'
) }
htmlFor={ `wc-block-coupon-code__input-${ instanceId }` }
htmlFor={ textInputId }
/>
}
initialOpen={ initialOpen }
@ -63,16 +74,25 @@ const TotalsCouponCodeInput = ( {
<PanelRow className="wc-block-coupon-code__row">
<form className="wc-block-coupon-code__form">
<TextInput
id={ `wc-block-coupon-code__input-${ instanceId }` }
className="wc-block-coupon-code__input"
id={ textInputId }
className={ classnames(
'wc-block-coupon-code__input',
{
'has-error': !! validationMessage,
}
) }
label={ __(
'Enter code',
'woo-gutenberg-products-block'
) }
value={ couponValue }
onChange={ ( newCouponValue ) =>
setCouponValue( newCouponValue )
}
ariaDescribedBy={ getValidationErrorId(
textInputId
) }
onChange={ ( newCouponValue ) => {
setCouponValue( newCouponValue );
clearValidationError( 'coupon' );
} }
/>
<Button
className="wc-block-coupon-code__button"
@ -87,6 +107,10 @@ const TotalsCouponCodeInput = ( {
</Button>
</form>
</PanelRow>
<ValidationInputError
errorMessage={ validationMessage }
elementId={ textInputId }
/>
</LoadingMask>
</PanelBody>
);

View File

@ -0,0 +1,46 @@
/**
* External dependencies
*/
import { text, boolean } from '@storybook/addon-knobs';
import {
useValidationContext,
ValidationContextProvider,
} from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import CouponInput from '../';
export default {
title: 'WooCommerce Blocks/@base-components/CouponInput',
component: CouponInput,
};
const StoryComponent = ( { validCoupon, isLoading, invalidCouponText } ) => {
const { setValidationErrors } = useValidationContext();
const onSubmit = ( coupon ) => {
if ( coupon !== validCoupon ) {
setValidationErrors( { coupon: invalidCouponText } );
}
};
return <CouponInput isLoading={ isLoading } onSubmit={ onSubmit } />;
};
export const Default = () => {
const validCoupon = text( 'A valid coupon code', 'validcoupon' );
const invalidCouponText = text(
'Error message for invalid code',
'Invalid coupon code.'
);
const isLoading = boolean( 'Toggle isLoading state', false );
return (
<ValidationContextProvider>
<StoryComponent
validCoupon={ validCoupon }
isLoading={ isLoading }
invalidCouponText={ invalidCouponText }
/>
</ValidationContextProvider>
);
};

View File

@ -0,0 +1 @@
export * from './validation-input-error';

View File

@ -0,0 +1,16 @@
.wc-block-form-input-validation-error {
color: $error-red;
@include font-size(12);
margin-top: -$gap-large;
padding-top: $gap-smallest;
display: block;
> p {
padding: 0;
margin: 0;
}
}
.wc-block-select + .wc-block-form-input-validation-error {
margin-bottom: $gap-large;
}

View File

@ -0,0 +1,33 @@
/**
* External dependencies
*/
import { useValidationContext } from '@woocommerce/base-context';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import './style.scss';
export const ValidationInputError = ( {
errorMessage = '',
propertyName = '',
elementId = '',
} ) => {
const { getValidationError, getValidationErrorId } = useValidationContext();
if ( ! errorMessage && ! propertyName ) {
return null;
}
errorMessage = errorMessage || getValidationError( propertyName );
return (
<div className="wc-block-form-input-validation-error" role="alert">
<p id={ getValidationErrorId( elementId ) }>{ errorMessage }</p>
</div>
);
};
ValidationInputError.propTypes = {
errorMessage: PropTypes.string,
propertyName: PropTypes.string,
elementId: PropTypes.string,
};

View File

@ -1,3 +1,4 @@
export * from './payment-methods';
export * from './shipping';
export * from './checkout';
export * from './validation';

View File

@ -0,0 +1,107 @@
/**
* External dependencies
*/
import { createContext, useContext, useState } from '@wordpress/element';
import { omit, pickBy } from 'lodash';
/**
* @typedef { import('@woocommerce/type-defs/contexts').ValidationContext } ValidationContext
*/
const ValidationContext = createContext( {
getValidationError: () => '',
setValidationErrors: ( errors ) => void errors,
clearValidationError: ( property ) => void property,
clearAllValidationErrors: () => void null,
getValidationErrorId: ( inputId ) => void inputId,
} );
/**
* @return {ValidationContext} The context values for the validation context.
*/
export const useValidationContext = () => {
return useContext( ValidationContext );
};
/**
* Validation context provider
*
* Any children of this context will be exposed to validation state and helpers
* for tracking validation.
*/
export const ValidationContextProvider = ( { children } ) => {
const [ validationErrors, updateValidationErrors ] = useState( {} );
/**
* This retrieves any validation error message that exists in state for the
* given property name.
*
* @param {string} property The property the error message is for.
*
* @return {string} Either the error message for the given property or an
* empty string.
*/
const getValidationError = ( property ) =>
validationErrors[ property ] || '';
/**
* Clears any validation error that exists in state for the given property
* name.
*
* @param {string} property The name of the property to clear if exists in
* validation error state.
*/
const clearValidationError = ( property ) => {
if ( validationErrors[ property ] ) {
updateValidationErrors( omit( validationErrors, [ property ] ) );
}
};
/**
* Clears the entire validation error state.
*/
const clearAllValidationErrors = () => void updateValidationErrors( {} );
/**
* Used to record new validation errors in the state.
*
* @param {Object} newErrors An object where keys are the property names the
* validation error is for and values are the
* validation error message displayed to the user.
*/
const setValidationErrors = ( newErrors ) => {
// all values must be a string.
newErrors = pickBy(
newErrors,
( message ) => typeof message === 'string'
);
if ( Object.values( newErrors ).length > 0 ) {
updateValidationErrors( { ...validationErrors, ...newErrors } );
}
};
/**
* Provides an id for the validation error that can be used to fill out
* aria-describedby attribute values.
*
* @param {string} inputId The input css id the validation error is related
* to.
* @return {string} The id to use for the validation error container.
*/
const getValidationErrorId = ( inputId ) => {
return inputId ? `validate-error-${ inputId }` : '';
};
const context = {
getValidationError,
setValidationErrors,
clearValidationError,
clearAllValidationErrors,
getValidationErrorId,
};
return (
<ValidationContext.Provider value={ context }>
{ children }
</ValidationContext.Provider>
);
};

View File

@ -6,12 +6,13 @@
import { __, sprintf } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useStoreNotices } from '@woocommerce/base-hooks';
import { useValidationContext } from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import { useStoreCart } from './use-store-cart';
import { useStoreNotices } from '../use-store-notices';
/**
* This is a custom hook for loading the Store API /cart/coupons endpoint and an
@ -24,13 +25,18 @@ import { useStoreCart } from './use-store-cart';
export const useStoreCartCoupons = () => {
const { cartCoupons, cartErrors, cartIsLoading } = useStoreCart();
const { addErrorNotice, addSnackbarNotice } = useStoreNotices();
const { setValidationErrors } = useValidationContext();
const results = useSelect(
( select, { dispatch } ) => {
const store = select( storeKey );
const isApplyingCoupon = store.isApplyingCoupon();
const isRemovingCoupon = store.isRemovingCoupon();
const { applyCoupon, removeCoupon } = dispatch( storeKey );
const {
applyCoupon,
removeCoupon,
receiveApplyingCoupon,
} = dispatch( storeKey );
const applyCouponWithNotices = ( couponCode ) => {
applyCoupon( couponCode )
@ -52,9 +58,9 @@ export const useStoreCartCoupons = () => {
}
} )
.catch( ( error ) => {
addErrorNotice( error.message, {
id: 'coupon-form',
} );
setValidationErrors( { coupon: error.message } );
// Finished handling the coupon.
receiveApplyingCoupon( '' );
} );
};
@ -81,6 +87,8 @@ export const useStoreCartCoupons = () => {
addErrorNotice( error.message, {
id: 'coupon-form',
} );
// Finished handling the coupon.
receiveApplyingCoupon( '' );
} );
};

View File

@ -9,7 +9,10 @@ import { __ } from '@wordpress/i18n';
import { useStoreCart } from '@woocommerce/base-hooks';
import { RawHTML } from '@wordpress/element';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import { StoreNoticesProvider } from '@woocommerce/base-context';
import {
StoreNoticesProvider,
ValidationContextProvider,
} from '@woocommerce/base-context';
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/block-settings';
import { __experimentalCreateInterpolateElement } from 'wordpress-element';
@ -32,7 +35,9 @@ const Block = ( { emptyCart, attributes } ) => {
<RawHTML>{ emptyCart }</RawHTML>
) : (
<LoadingMask showSpinner={ true } isLoading={ cartIsLoading }>
<FullCart attributes={ attributes } />
<ValidationContextProvider>
<FullCart attributes={ attributes } />
</ValidationContextProvider>
</LoadingMask>
) }
</>

View File

@ -20,7 +20,10 @@ import ShippingRatesControl from '@woocommerce/base-components/shipping-rates-co
import CheckboxControl from '@woocommerce/base-components/checkbox-control';
import { getCurrencyFromPriceResponse } from '@woocommerce/base-utils';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
import { CheckoutProvider } from '@woocommerce/base-context';
import {
CheckoutProvider,
ValidationContextProvider,
} from '@woocommerce/base-context';
import {
ExpressCheckoutFormControl,
PaymentMethods,
@ -97,261 +100,265 @@ const Block = ( {
} = useShippingRates();
return (
<CheckoutProvider isEditor={ isEditor }>
<SidebarLayout className="wc-block-checkout">
<Main>
<ExpressCheckoutFormControl />
<CheckoutForm>
<FormStep
id="contact-fields"
className="wc-block-checkout__contact-fields"
title={ __(
'Contact information',
'woo-gutenberg-products-block'
) }
description={ __(
"We'll use this email to send you details and updates about your order.",
'woo-gutenberg-products-block'
) }
stepHeadingContent={ () => (
<Fragment>
{ __(
'Already have an account? ',
'woo-gutenberg-products-block'
) }
<a href="/wp-login.php">
{ __(
'Log in.',
'woo-gutenberg-products-block'
) }
</a>
</Fragment>
) }
>
<TextInput
type="email"
label={ __(
'Email address',
'woo-gutenberg-products-block'
) }
value={ contactFields.email }
autoComplete="email"
onChange={ ( newValue ) =>
setContactFields( {
...contactFields,
email: newValue,
} )
}
required={ true }
/>
<CheckboxControl
className="wc-block-checkout__keep-updated"
label={ __(
'Keep me up to date on news and exclusive offers',
'woo-gutenberg-products-block'
) }
checked={ contactFields.keepUpdated }
onChange={ () =>
setContactFields( {
...contactFields,
keepUpdated: ! contactFields.keepUpdated,
} )
}
/>
</FormStep>
{ SHIPPING_ENABLED && (
<ValidationContextProvider>
<CheckoutProvider isEditor={ isEditor }>
<SidebarLayout className="wc-block-checkout">
<Main>
<ExpressCheckoutFormControl />
<CheckoutForm>
<FormStep
id="shipping-fields"
className="wc-block-checkout__shipping-fields"
id="contact-fields"
className="wc-block-checkout__contact-fields"
title={ __(
'Shipping address',
'Contact information',
'woo-gutenberg-products-block'
) }
description={ __(
'Enter the physical address where you want us to deliver your order.',
"We'll use this email to send you details and updates about your order.",
'woo-gutenberg-products-block'
) }
>
<AddressForm
onChange={ setShippingFields }
values={ shippingFields }
fields={ Object.keys( addressFields ) }
fieldConfig={ addressFields }
/>
{ attributes.showPhoneField && (
<TextInput
type="tel"
label={
attributes.requirePhoneField
? __(
'Phone',
'woo-gutenberg-products-block'
)
: __(
'Phone (optional)',
'woo-gutenberg-products-block'
)
}
value={ shippingFields.phone }
autoComplete="tel"
onChange={ ( newValue ) =>
setShippingFields( {
...shippingFields,
phone: newValue,
} )
}
required={
attributes.requirePhoneField
}
/>
stepHeadingContent={ () => (
<Fragment>
{ __(
'Already have an account? ',
'woo-gutenberg-products-block'
) }
<a href="/wp-login.php">
{ __(
'Log in.',
'woo-gutenberg-products-block'
) }
</a>
</Fragment>
) }
<CheckboxControl
className="wc-block-checkout__use-address-for-billing"
>
<TextInput
type="email"
label={ __(
'Use same address for billing',
'Email address',
'woo-gutenberg-products-block'
) }
checked={ useShippingAddressAsBilling }
onChange={ ( isChecked ) =>
setUseShippingAsBilling( isChecked )
value={ contactFields.email }
autoComplete="email"
onChange={ ( newValue ) =>
setContactFields( {
...contactFields,
email: newValue,
} )
}
required={ true }
/>
<CheckboxControl
className="wc-block-checkout__keep-updated"
label={ __(
'Keep me up to date on news and exclusive offers',
'woo-gutenberg-products-block'
) }
checked={ contactFields.keepUpdated }
onChange={ () =>
setContactFields( {
...contactFields,
keepUpdated: ! contactFields.keepUpdated,
} )
}
/>
</FormStep>
) }
{ showBillingFields && (
<FormStep
id="billing-fields"
className="wc-block-checkout__billing-fields"
title={ __(
'Billing address',
'woo-gutenberg-products-block'
) }
description={ __(
'Enter the address that matches your card or payment method.',
'woo-gutenberg-products-block'
) }
>
<AddressForm
onChange={ setBillingFields }
type="billing"
values={ billingFields }
fields={ Object.keys( addressFields ) }
fieldConfig={ addressFields }
/>
</FormStep>
) }
{ SHIPPING_ENABLED &&
( shippingRates.length === 0 && isEditor ? (
<NoShipping />
) : (
{ SHIPPING_ENABLED && (
<FormStep
id="shipping-option"
className="wc-block-checkout__shipping-option"
id="shipping-fields"
className="wc-block-checkout__shipping-fields"
title={ __(
'Shipping options',
'Shipping address',
'woo-gutenberg-products-block'
) }
description={ __(
'Select your shipping method below.',
'Enter the physical address where you want us to deliver your order.',
'woo-gutenberg-products-block'
) }
>
<ShippingRatesControl
address={
shippingFields.country
? {
address_1:
shippingFields.address_1,
address_2:
shippingFields.apartment,
city:
shippingFields.city,
state:
shippingFields.state,
postcode:
shippingFields.postcode,
country:
shippingFields.country,
}
: null
}
noResultsMessage={ __(
'There are no shipping options available. Please ensure that your address has been entered correctly, or contact us if you need any help.',
<AddressForm
onChange={ setShippingFields }
values={ shippingFields }
fields={ Object.keys( addressFields ) }
fieldConfig={ addressFields }
/>
{ attributes.showPhoneField && (
<TextInput
type="tel"
label={
attributes.requirePhoneField
? __(
'Phone',
'woo-gutenberg-products-block'
)
: __(
'Phone (optional)',
'woo-gutenberg-products-block'
)
}
value={ shippingFields.phone }
autoComplete="tel"
onChange={ ( newValue ) =>
setShippingFields( {
...shippingFields,
phone: newValue,
} )
}
required={
attributes.requirePhoneField
}
/>
) }
<CheckboxControl
className="wc-block-checkout__use-address-for-billing"
label={ __(
'Use same address for billing',
'woo-gutenberg-products-block'
) }
renderOption={
renderShippingRatesControlOption
}
shippingRates={ shippingRates }
shippingRatesLoading={
shippingRatesLoading
}
/>
<CheckboxControl
className="wc-block-checkout__add-note"
label="Add order notes?"
checked={
selectedShippingRate.orderNote
}
onChange={ () =>
setSelectedShippingRate( {
...selectedShippingRate,
orderNote: ! selectedShippingRate.orderNote,
} )
checked={ useShippingAddressAsBilling }
onChange={ ( isChecked ) =>
setUseShippingAsBilling( isChecked )
}
/>
</FormStep>
) ) }
<FormStep
id="payment-method"
className="wc-block-checkout__payment-method"
title={ __(
'Payment method',
'woo-gutenberg-products-block'
) }
description={ __(
'Select a payment method below.',
'woo-gutenberg-products-block'
{ showBillingFields && (
<FormStep
id="billing-fields"
className="wc-block-checkout__billing-fields"
title={ __(
'Billing address',
'woo-gutenberg-products-block'
) }
description={ __(
'Enter the address that matches your card or payment method.',
'woo-gutenberg-products-block'
) }
>
<AddressForm
onChange={ setBillingFields }
type="billing"
values={ billingFields }
fields={ Object.keys( addressFields ) }
fieldConfig={ addressFields }
/>
</FormStep>
) }
>
<PaymentMethods />
{ /*@todo this should be something the payment method controls*/ }
<CheckboxControl
className="wc-block-checkout__save-card-info"
label={ __(
'Save payment information to my account for future purchases.',
{ SHIPPING_ENABLED &&
( shippingRates.length === 0 && isEditor ? (
<NoShipping />
) : (
<FormStep
id="shipping-option"
className="wc-block-checkout__shipping-option"
title={ __(
'Shipping options',
'woo-gutenberg-products-block'
) }
description={ __(
'Select your shipping method below.',
'woo-gutenberg-products-block'
) }
>
<ShippingRatesControl
address={
shippingFields.country
? {
address_1:
shippingFields.address_1,
address_2:
shippingFields.apartment,
city:
shippingFields.city,
state:
shippingFields.state,
postcode:
shippingFields.postcode,
country:
shippingFields.country,
}
: null
}
noResultsMessage={ __(
'There are no shipping options available. Please ensure that your address has been entered correctly, or contact us if you need any help.',
'woo-gutenberg-products-block'
) }
renderOption={
renderShippingRatesControlOption
}
shippingRates={ shippingRates }
shippingRatesLoading={
shippingRatesLoading
}
/>
<CheckboxControl
className="wc-block-checkout__add-note"
label="Add order notes?"
checked={
selectedShippingRate.orderNote
}
onChange={ () =>
setSelectedShippingRate( {
...selectedShippingRate,
orderNote: ! selectedShippingRate.orderNote,
} )
}
/>
</FormStep>
) ) }
<FormStep
id="payment-method"
className="wc-block-checkout__payment-method"
title={ __(
'Payment method',
'woo-gutenberg-products-block'
) }
checked={ shouldSavePayment }
onChange={ () =>
setShouldSavePayment( ! shouldSavePayment )
}
/>
</FormStep>
<div className="wc-block-checkout__actions">
{ attributes.showReturnToCart && (
<ReturnToCartButton
link={ getSetting(
'page-' + attributes?.cartPageId,
false
description={ __(
'Select a payment method below.',
'woo-gutenberg-products-block'
) }
>
<PaymentMethods />
{ /*@todo this should be something the payment method controls*/ }
<CheckboxControl
className="wc-block-checkout__save-card-info"
label={ __(
'Save payment information to my account for future purchases.',
'woo-gutenberg-products-block'
) }
checked={ shouldSavePayment }
onChange={ () =>
setShouldSavePayment(
! shouldSavePayment
)
}
/>
) }
<PlaceOrderButton />
</div>
{ attributes.showPolicyLinks && <Policies /> }
</CheckoutForm>
</Main>
<Sidebar className="wc-block-checkout__sidebar">
<CheckoutSidebar
cartCoupons={ cartCoupons }
cartItems={ cartItems }
cartTotals={ cartTotals }
/>
</Sidebar>
</SidebarLayout>
</CheckoutProvider>
</FormStep>
<div className="wc-block-checkout__actions">
{ attributes.showReturnToCart && (
<ReturnToCartButton
link={ getSetting(
'page-' + attributes?.cartPageId,
false
) }
/>
) }
<PlaceOrderButton />
</div>
{ attributes.showPolicyLinks && <Policies /> }
</CheckoutForm>
</Main>
<Sidebar className="wc-block-checkout__sidebar">
<CheckoutSidebar
cartCoupons={ cartCoupons }
cartItems={ cartItems }
cartTotals={ cartTotals }
/>
</Sidebar>
</SidebarLayout>
</CheckoutProvider>
</ValidationContextProvider>
);
};

View File

@ -113,12 +113,20 @@
}
.wc-block-text-input,
.wc-block-country-input,
.wc-block-select {
float: left;
margin-left: #{$gap-small / 2};
margin-right: #{$gap-small / 2};
position: relative;
width: calc(50% - #{$gap-small});
.wc-block-select {
float: none;
width: 100%;
margin-left: 0;
margin-right: 0;
}
}
.wc-block-address-form__company,

View File

@ -98,8 +98,6 @@ export function* applyCoupon( couponCode ) {
} catch ( error ) {
// Store the error message in state.
yield receiveError( error );
// Finished handling the coupon.
yield receiveApplyingCoupon( '' );
// Re-throw the error.
throw error;
}

View File

@ -149,6 +149,7 @@ const reducer = (
case types.RECEIVE_REMOVED_ITEM:
state = {
...state,
errors: [],
cartData: {
...state.cartData,
items: cartItemsReducer( state.cartData.items, action ),

View File

@ -1,7 +1,7 @@
/**
* External dependencies
* Internal dependencies
*/
import { getSetting } from '@woocommerce/settings';
import { getSetting } from '../shared';
export const CURRENT_USER_IS_ADMIN = getSetting( 'currentUserIsAdmin', false );
export const REVIEW_RATINGS_ENABLED = getSetting(

View File

@ -215,4 +215,22 @@
* @property {number} currentPostId The post ID being edited.
*/
/**
* @typedef {Object} ValidationContext
*
* @property {function(string):string} getValidationError Return validation error or empty
* string if it doesn't exist for the
* given property.
* @property {function(Object<string>)} setValidationErrors Receive an object of properties and
* error messages as strings and adds
* to the validation error state.
* @property {function(string)} clearValidationError Clears a validation error for the
* given property name.
* @property {function()} clearAllValidationErrors Clears all validation errors
* currently in state.
* @property {function(string)} getValidationErrorId Returns the css id for the
* validation error using the given
* inputId string.
*/
export {};

View File

@ -28580,7 +28580,7 @@
}
},
"woocommerce": {
"version": "git+https://github.com/woocommerce/woocommerce.git#6a8d8b27d98fe3036f2240d14be2b7a5e4ecf10e",
"version": "git+https://github.com/woocommerce/woocommerce.git#6f2b232fc7fa53417691a742a419147bb0134a5c",
"from": "git+https://github.com/woocommerce/woocommerce.git",
"dev": true,
"requires": {

View File

@ -75,6 +75,7 @@
"@storybook/addons": "5.3.12",
"@storybook/react": "5.3.12",
"@types/jest": "25.1.4",
"@types/react": "^16.9.23",
"@wordpress/babel-preset-default": "4.10.0",
"@wordpress/base-styles": "1.4.0",
"@wordpress/blocks": "6.12.0",

View File

@ -7,3 +7,26 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
}
// screen-reader-text applied in WP environment
/* Hide visually but not from screen readers */
.screen-reader-text,
.screen-reader-text span,
.ui-helper-hidden-accessible {
border: 0;
clip: rect(1px, 1px, 1px, 1px);
-webkit-clip-path: inset(50%);
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
word-wrap: normal !important; /* many screen reader and browser combinations announce broken words as they would appear visually */
}
.button .screen-reader-text {
height: auto; /* Fixes a Safari+VoiceOver bug, see ticket #42006 */
}

View File

@ -2,18 +2,31 @@
* External dependencies
*/
const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' );
const path = require( 'path' );
/**
* Internal dependencies
*/
const { getAlias, getMainConfig } = require( '../bin/webpack-helpers.js' );
const tsConfig = require( '../tsconfig.json' );
const aliases = Object.keys( tsConfig.compilerOptions.paths ).reduce(
( acc, key ) => {
const currentPath = tsConfig.compilerOptions.paths[ key ][ 0 ];
acc[ key.replace( '/*', '' ) ] = path.resolve(
__dirname,
'../' + currentPath.replace( '/*', '/' )
);
return acc;
},
{}
);
module.exports = ( { config: storybookConfig } ) => {
const wooBlocksConfig = getMainConfig( { alias: getAlias() } );
storybookConfig.resolve.alias = {
...storybookConfig.resolve.alias,
...wooBlocksConfig.resolve.alias,
...aliases,
};
storybookConfig.module.rules.push(
{