* Add validation reducers, actions, and action types

* Add selector for getValidationErrors

* Export store key and register store

* Export validation store key

* Move TextInput files to checkout package

* Export ValidatedTextInput from blocks-checkout package

* Update imports of ValidatedTextInput to reflect new location

* Use the validation wp-data store for showing error messages

* Export getValidationError in checkout package

* Move validation store to checkout package

* Move ValidationInputError to blocks-checkout package

* Only export "exposedSelectors" from validation

* Convert validation context to data store

* Fixed linting error

* Fixed linting error

* Change the validation selectors to return a function

* Convert reducer and selectors to TS

* Remove superfluous comments and improve test titles

* Test to ensure visible errors remain visible

* Make test for hasValidationErrors more robust

* Augment the wp-data module to include our selectors and actions

* Removed unused `exposedSelectors` variable

* Remove TS error because of `instanceId` on props

* Remove unnecessary as const

* Use function returned by getValidationError

* Use correct selector/action names now context has been decoupled

* hide validation error when input value changes

* Add correct aria-describedBy now we can get error id from store

* Clear validation error from store when component unmounts

* Clear validation error if input is valid

* convert ValidationInputError to TS and get correct id/error from store

* Ensure checkout block doesn't break when there are no errors

* Get validation data from the store instead of context

* Update country input to remove validation context

* Move validation store out of checkout package

* Move TextInput and ValidationInputError back out of the checkout package

* Remove duplicate internal styles comment

* Remove exports that no longer exist

* Get validation store key from block-data

* Make attribute-select-control use validation data store

* Export FieldValidationStatus type

* Make combobox use validation store not context

* Make Address use validation store not context

* Make Address use validation store not context

* Use hasValidationErrors selector as a function in shipping calculator

* Remove validation context from coupon story

* Import VALIDATION_STORE_KEY from correct location

* Stop coupon story from erroring

* Update useStoreCartCoupons to use validation store not context

* Make TotalsCoupon use validation store instead of context

* Make AddToCartFormContext use validation store not context

* Remove ValidationContext

* Import FieldValidationStatus from correct location

* Import ValidatedTextInput and ValidatedTextInput from correct location

* Remove ValidationContextProvider

* Update components to use validation store not context

* Update useValidation to use the data store

* Replace the validation context in checkout-events file

* Use the re-mapped path for the store key import

* Use "register" instead of the deprecated "registerStore"

* Fix import error of the "FieldValidationStatus" type

* Use TS instead of React's "PropTypes"

* Fix the type of "ValidationInputError" in the "payment-method-interface"

* Fix error not showing on the first place order click bug

We were mutating the state in the reducer, which prevented re-rendering
on state change

* Fix state mutation issue in the Validation reducer

Co-authored-by: Thomas Roberts <thomas.roberts@automattic.com>
Co-authored-by: Saad Tarhi <saad.trh@gmail.com>
This commit is contained in:
Tarun Vijwani 2022-07-02 03:06:25 +04:00 committed by Alex Florisca
parent 22f61cf4d9
commit 4de3bdb0f1
40 changed files with 749 additions and 462 deletions

View File

@ -6,10 +6,9 @@ import { decodeEntities } from '@wordpress/html-entities';
import { SelectControl } from 'wordpress-components'; import { SelectControl } from 'wordpress-components';
import { useEffect } from 'react'; import { useEffect } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { import { ValidationInputError } from '@woocommerce/base-components/validation-input-error';
ValidationInputError, import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
useValidationContext, import { useDispatch, useSelect } from '@wordpress/data';
} from '@woocommerce/base-context';
// Default option for select boxes. // Default option for select boxes.
const selectAnOption = { const selectAnOption = {
@ -32,8 +31,16 @@ const AttributeSelectControl = ( {
'woo-gutenberg-products-block' 'woo-gutenberg-products-block'
), ),
} ) => { } ) => {
const { getValidationError, setValidationErrors, clearValidationError } = const { setValidationErrors, clearValidationError } = useDispatch(
useValidationContext(); VALIDATION_STORE_KEY
);
const { getValidationError } = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY );
return {
getValidationError: store.getValidationError(),
};
} );
const errorId = attributeName; const errorId = attributeName;
const error = getValidationError( errorId ) || {}; const error = getValidationError( errorId ) || {};

View File

@ -10,7 +10,6 @@ import {
BillingStateInput, BillingStateInput,
ShippingStateInput, ShippingStateInput,
} from '@woocommerce/base-components/state-input'; } from '@woocommerce/base-components/state-input';
import { useValidationContext } from '@woocommerce/base-context';
import { useEffect, useMemo } from '@wordpress/element'; import { useEffect, useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { withInstanceId } from '@wordpress/compose'; import { withInstanceId } from '@wordpress/compose';
@ -22,6 +21,11 @@ import {
defaultAddressFields, defaultAddressFields,
EnteredAddress, EnteredAddress,
} from '@woocommerce/settings'; } from '@woocommerce/settings';
import { useSelect, useDispatch } from '@wordpress/data';
import {
VALIDATION_STORE_KEY,
FieldValidationStatus,
} from '@woocommerce/block-data';
/** /**
* Internal dependencies * Internal dependencies
@ -32,7 +36,9 @@ import prepareAddressFields from './prepare-address-fields';
// values without having set the country first, show an error. // values without having set the country first, show an error.
const validateShippingCountry = ( const validateShippingCountry = (
values: EnteredAddress, values: EnteredAddress,
setValidationErrors: ( errors: Record< string, unknown > ) => void, setValidationErrors: (
errors: Record< string, FieldValidationStatus >
) => void,
clearValidationError: ( error: string ) => void, clearValidationError: ( error: string ) => void,
hasValidationError: boolean hasValidationError: boolean
): void => { ): void => {
@ -87,8 +93,14 @@ const AddressForm = ( {
type = 'shipping', type = 'shipping',
values, values,
}: AddressFormProps ): JSX.Element => { }: AddressFormProps ): JSX.Element => {
const { getValidationError, setValidationErrors, clearValidationError } = const { setValidationErrors, clearValidationError } = useDispatch(
useValidationContext(); VALIDATION_STORE_KEY
);
const getValidationError = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY );
return store.getValidationError();
} );
const currentFields = useShallowEqual( fields ); const currentFields = useShallowEqual( fields );

View File

@ -5,8 +5,9 @@ import { __ } from '@wordpress/i18n';
import Button from '@woocommerce/base-components/button'; import Button from '@woocommerce/base-components/button';
import { useState } from '@wordpress/element'; import { useState } from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal'; import isShallowEqual from '@wordpress/is-shallow-equal';
import { useValidationContext } from '@woocommerce/base-context';
import type { EnteredAddress, AddressFields } from '@woocommerce/settings'; import type { EnteredAddress, AddressFields } from '@woocommerce/settings';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import { useDispatch, useSelect } from '@wordpress/data';
/** /**
* Internal dependencies * Internal dependencies
@ -25,12 +26,18 @@ const ShippingCalculatorAddress = ( {
addressFields, addressFields,
}: ShippingCalculatorAddressProps ): JSX.Element => { }: ShippingCalculatorAddressProps ): JSX.Element => {
const [ address, setAddress ] = useState( initialAddress ); const [ address, setAddress ] = useState( initialAddress );
const { hasValidationErrors, showAllValidationErrors } = const { showAllValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
useValidationContext();
const { hasValidationErrors } = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY );
return {
hasValidationErrors: store.hasValidationErrors,
};
} );
const validateSubmit = () => { const validateSubmit = () => {
showAllValidationErrors(); showAllValidationErrors();
return ! hasValidationErrors; return ! hasValidationErrors();
}; };
return ( return (

View File

@ -4,15 +4,14 @@
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { useState, useEffect, useRef } from '@wordpress/element'; import { useState, useEffect, useRef } from '@wordpress/element';
import Button from '@woocommerce/base-components/button'; import Button from '@woocommerce/base-components/button';
import { ValidatedTextInput } from '@woocommerce/base-components/text-input'; import { Panel } from '@woocommerce/blocks-checkout';
import Label from '@woocommerce/base-components/label'; import Label from '@woocommerce/base-components/label';
import LoadingMask from '@woocommerce/base-components/loading-mask'; import LoadingMask from '@woocommerce/base-components/loading-mask';
import { withInstanceId } from '@wordpress/compose'; import { withInstanceId } from '@wordpress/compose';
import { import { ValidatedTextInput } from '@woocommerce/base-components/text-input';
ValidationInputError, import ValidationInputError from '@woocommerce/base-components/validation-input-error';
useValidationContext, import { useSelect } from '@wordpress/data';
} from '@woocommerce/base-context'; import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import { Panel } from '@woocommerce/blocks-checkout';
/** /**
* Internal dependencies * Internal dependencies
@ -46,7 +45,16 @@ export const TotalsCoupon = ( {
}: TotalsCouponProps ): JSX.Element => { }: TotalsCouponProps ): JSX.Element => {
const [ couponValue, setCouponValue ] = useState( '' ); const [ couponValue, setCouponValue ] = useState( '' );
const currentIsLoading = useRef( false ); const currentIsLoading = useRef( false );
const { getValidationError, getValidationErrorId } = useValidationContext(); const { getValidationError, getValidationErrorId } = useSelect(
( select ) => {
const store = select( VALIDATION_STORE_KEY );
return {
getValidationError: store.getValidationError(),
getValidationErrorId: store.getValidationErrorId(),
};
}
);
const validationError = getValidationError( 'coupon' ); const validationError = getValidationError( 'coupon' );
useEffect( () => { useEffect( () => {

View File

@ -3,11 +3,9 @@
*/ */
import { useArgs } from '@storybook/client-api'; import { useArgs } from '@storybook/client-api';
import { Story, Meta } from '@storybook/react'; import { Story, Meta } from '@storybook/react';
import {
useValidationContext,
ValidationContextProvider,
} from '@woocommerce/base-context';
import { INTERACTION_TIMEOUT } from '@woocommerce/storybook-controls'; import { INTERACTION_TIMEOUT } from '@woocommerce/storybook-controls';
import { useDispatch } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
/** /**
* Internal dependencies * Internal dependencies
@ -52,7 +50,7 @@ LoadingState.args = {
}; };
export const ErrorState: Story< TotalsCouponProps > = ( args ) => { export const ErrorState: Story< TotalsCouponProps > = ( args ) => {
const { setValidationErrors } = useValidationContext(); const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
setValidationErrors( { coupon: INVALID_COUPON_ERROR } ); setValidationErrors( { coupon: INVALID_COUPON_ERROR } );
@ -61,10 +59,6 @@ export const ErrorState: Story< TotalsCouponProps > = ( args ) => {
ErrorState.decorators = [ ErrorState.decorators = [
( StoryComponent ) => { ( StoryComponent ) => {
return ( return <StoryComponent />;
<ValidationContextProvider>
<StoryComponent />
</ValidationContextProvider>
);
}, },
]; ];

View File

@ -6,11 +6,10 @@ import { __ } from '@wordpress/i18n';
import { useEffect, useRef } from '@wordpress/element'; import { useEffect, useRef } from '@wordpress/element';
import { withInstanceId } from '@wordpress/compose'; import { withInstanceId } from '@wordpress/compose';
import { ComboboxControl } from 'wordpress-components'; import { ComboboxControl } from 'wordpress-components';
import { import { ValidationInputError } from '@woocommerce/base-components/validation-input-error';
ValidationInputError,
useValidationContext,
} from '@woocommerce/base-context';
import { isObject } from '@woocommerce/types'; import { isObject } from '@woocommerce/types';
import { useDispatch, useSelect } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
/** /**
* Internal dependencies * Internal dependencies
@ -55,8 +54,13 @@ const Combobox = ( {
instanceId = '0', instanceId = '0',
autoComplete = 'off', autoComplete = 'off',
}: ComboboxProps ): JSX.Element => { }: ComboboxProps ): JSX.Element => {
const { getValidationError, setValidationErrors, clearValidationError } = const { setValidationErrors, clearValidationError } = useDispatch(
useValidationContext(); VALIDATION_STORE_KEY
);
const getValidationError = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY );
return store.getValidationError();
} );
const controlRef = useRef< HTMLDivElement >( null ); const controlRef = useRef< HTMLDivElement >( null );
const controlId = id || 'control-' + instanceId; const controlId = id || 'control-' + instanceId;

View File

@ -2,11 +2,9 @@
* External dependencies * External dependencies
*/ */
import { Story, Meta } from '@storybook/react'; import { Story, Meta } from '@storybook/react';
import { import { useDispatch } from '@wordpress/data';
useValidationContext,
ValidationContextProvider,
} from '@woocommerce/base-context';
import { useState, useEffect } from '@wordpress/element'; import { useState, useEffect } from '@wordpress/element';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
/** /**
* Internal dependencies * Internal dependencies
@ -31,21 +29,16 @@ export default {
options: { table: { disable: true } }, options: { table: { disable: true } },
value: { control: false }, value: { control: false },
}, },
decorators: [ decorators: [ ( StoryComponent ) => <StoryComponent /> ],
( StoryComponent ) => (
<ValidationContextProvider>
<StoryComponent />
</ValidationContextProvider>
),
],
} as Meta< CountryInputWithCountriesProps >; } as Meta< CountryInputWithCountriesProps >;
const Template: Story< CountryInputWithCountriesProps > = ( args ) => { const Template: Story< CountryInputWithCountriesProps > = ( args ) => {
const [ selectedCountry, selectCountry ] = useState< CountryCode | '' >( const [ selectedCountry, selectCountry ] = useState< CountryCode | '' >(
'' ''
); );
const { clearValidationError, showValidationError } = const { clearValidationError, showValidationError } = useDispatch(
useValidationContext(); VALIDATION_STORE_KEY
);
useEffect( () => { useEffect( () => {
showValidationError( 'country' ); showValidationError( 'country' );

View File

@ -5,11 +5,11 @@ import { __ } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities'; import { decodeEntities } from '@wordpress/html-entities';
import { useCallback, useMemo, useEffect, useRef } from '@wordpress/element'; import { useCallback, useMemo, useEffect, useRef } from '@wordpress/element';
import classnames from 'classnames'; import classnames from 'classnames';
import { ValidatedTextInput } from '@woocommerce/base-components/text-input';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { ValidatedTextInput } from '../text-input';
import Combobox from '../combobox'; import Combobox from '../combobox';
import './style.scss'; import './style.scss';
import type { StateInputWithStatesProps } from './StateInputProps'; import type { StateInputWithStatesProps } from './StateInputProps';

View File

@ -4,33 +4,21 @@
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { useCallback, useRef, useEffect, useState } from 'react'; import { useCallback, useRef, useEffect, useState } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import {
ValidationInputError,
useValidationContext,
} from '@woocommerce/base-context';
import { withInstanceId } from '@wordpress/compose'; import { withInstanceId } from '@wordpress/compose';
import { isString } from '@woocommerce/types'; import { isString } from '@woocommerce/types';
import { dispatch, useSelect } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import TextInput from './text-input'; import TextInput from './text-input';
import './style.scss'; import './style.scss';
import { ValidationInputError } from '../validation-input-error';
interface ValidatedTextInputPropsWithId { interface ValidatedTextInputProps {
instanceId?: string;
id: string;
}
interface ValidatedTextInputPropsWithInstanceId {
instanceId: string;
id?: string; id?: string;
} instanceId: string;
type ValidatedTextInputProps = (
| ValidatedTextInputPropsWithId
| ValidatedTextInputPropsWithInstanceId
) & {
className?: string; className?: string;
ariaDescribedBy?: string; ariaDescribedBy?: string;
errorId?: string; errorId?: string;
@ -39,7 +27,7 @@ type ValidatedTextInputProps = (
errorMessage?: string; errorMessage?: string;
onChange: ( newValue: string ) => void; onChange: ( newValue: string ) => void;
value: string; value: string;
}; }
const ValidatedTextInput = ( { const ValidatedTextInput = ( {
className, className,
@ -53,20 +41,26 @@ const ValidatedTextInput = ( {
errorMessage: passedErrorMessage = '', errorMessage: passedErrorMessage = '',
value = '', value = '',
...rest ...rest
}: ValidatedTextInputProps ) => { }: ValidatedTextInputProps ): JSX.Element => {
const [ isPristine, setIsPristine ] = useState( true ); const [ isPristine, setIsPristine ] = useState( true );
const inputRef = useRef< HTMLInputElement >( null ); const inputRef = useRef< HTMLInputElement >( null );
const {
getValidationError, const { setValidationErrors, hideValidationError, clearValidationError } =
hideValidationError, dispatch( VALIDATION_STORE_KEY );
setValidationErrors,
clearValidationError,
getValidationErrorId,
} = useValidationContext();
const textInputId = const textInputId =
typeof id !== 'undefined' ? id : 'textinput-' + instanceId; typeof id !== 'undefined' ? id : 'textinput-' + instanceId;
const errorIdString = errorId !== undefined ? errorId : textInputId; const errorIdString = errorId !== undefined ? errorId : textInputId;
const { getValidationError, getValidationErrorId } = useSelect(
( select ) => {
const store = select( VALIDATION_STORE_KEY );
return {
getValidationError: store.getValidationError(),
getValidationErrorId: store.getValidationErrorId(),
};
}
);
const validateInput = useCallback( const validateInput = useCallback(
( errorsHidden = true ) => { ( errorsHidden = true ) => {
const inputObject = inputRef.current || null; const inputObject = inputRef.current || null;
@ -79,7 +73,7 @@ const ValidatedTextInput = ( {
if ( inputIsValid ) { if ( inputIsValid ) {
clearValidationError( errorIdString ); clearValidationError( errorIdString );
} else { } else {
setValidationErrors( { const validationErrors = {
[ errorIdString ]: { [ errorIdString ]: {
message: message:
inputObject.validationMessage || inputObject.validationMessage ||
@ -89,7 +83,8 @@ const ValidatedTextInput = ( {
), ),
hidden: errorsHidden, hidden: errorsHidden,
}, },
} ); };
setValidationErrors( validationErrors );
} }
}, },
[ clearValidationError, errorIdString, setValidationErrors ] [ clearValidationError, errorIdString, setValidationErrors ]
@ -129,17 +124,13 @@ const ValidatedTextInput = ( {
}; };
}, [ clearValidationError, errorIdString ] ); }, [ clearValidationError, errorIdString ] );
// @todo - When useValidationContext is converted to TypeScript, remove this cast and use the correct type. const errorMessage = getValidationError( errorIdString );
const errorMessage = ( getValidationError( errorIdString ) || {} ) as {
message?: string;
hidden?: boolean;
};
if ( isString( passedErrorMessage ) && passedErrorMessage !== '' ) { if ( isString( passedErrorMessage ) && passedErrorMessage !== '' ) {
errorMessage.message = passedErrorMessage; errorMessage.message = passedErrorMessage;
} }
const hasError = errorMessage.message && ! errorMessage.hidden; const hasError = errorMessage?.message && ! errorMessage?.hidden;
const describedBy = const describedBy =
showError && hasError && getValidationErrorId( errorIdString ) showError && hasError && getValidationErrorId( errorIdString )
? getValidationErrorId( errorIdString ) ? getValidationErrorId( errorIdString )

View File

@ -12,6 +12,7 @@
"../hocs", "../hocs",
"../../atomic/utils", "../../atomic/utils",
"../../atomic/blocks/component-init.js", "../../atomic/blocks/component-init.js",
"../../data",
"../../shared/context" "../../shared/context"
], ],
"exclude": [ "**/test/**" ] "exclude": [ "**/test/**" ]

View File

@ -0,0 +1,50 @@
/**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import './style.scss';
interface ValidationInputErrorProps {
errorMessage?: string;
propertyName?: string;
elementId?: string;
}
export const ValidationInputError = ( {
errorMessage = '',
propertyName = '',
elementId = '',
}: ValidationInputErrorProps ): JSX.Element | null => {
const { getValidationError, getValidationErrorId } = useSelect(
( select ) => {
const store = select( VALIDATION_STORE_KEY );
return {
getValidationError: store.getValidationError(),
getValidationErrorId: store.getValidationErrorId(),
};
}
);
const validationError = getValidationError( propertyName );
if ( ! errorMessage || typeof errorMessage !== 'string' ) {
const error = validationError || {};
if ( error.message && ! error.hidden ) {
errorMessage = error.message;
} else {
return null;
}
}
return (
<div className="wc-block-components-validation-error" role="alert">
<p id={ getValidationErrorId( elementId ) }>{ errorMessage }</p>
</div>
);
};
export default ValidationInputError;

View File

@ -5,7 +5,10 @@
*/ */
import { __, sprintf } from '@wordpress/i18n'; import { __, sprintf } from '@wordpress/i18n';
import { useDispatch, useSelect } from '@wordpress/data'; import { useDispatch, useSelect } from '@wordpress/data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data'; import {
CART_STORE_KEY as storeKey,
VALIDATION_STORE_KEY,
} from '@woocommerce/block-data';
import { decodeEntities } from '@wordpress/html-entities'; import { decodeEntities } from '@wordpress/html-entities';
import type { StoreCartCoupon } from '@woocommerce/types'; import type { StoreCartCoupon } from '@woocommerce/types';
@ -13,7 +16,6 @@ import type { StoreCartCoupon } from '@woocommerce/types';
* Internal dependencies * Internal dependencies
*/ */
import { useStoreCart } from './use-store-cart'; import { useStoreCart } from './use-store-cart';
import { useValidationContext } from '../../providers/validation';
/** /**
* This is a custom hook for loading the Store API /cart/coupons endpoint and an * This is a custom hook for loading the Store API /cart/coupons endpoint and an
@ -27,7 +29,7 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => {
const { cartCoupons, cartIsLoading } = useStoreCart(); const { cartCoupons, cartIsLoading } = useStoreCart();
const { createErrorNotice } = useDispatch( 'core/notices' ); const { createErrorNotice } = useDispatch( 'core/notices' );
const { createNotice } = useDispatch( 'core/notices' ); const { createNotice } = useDispatch( 'core/notices' );
const { setValidationErrors } = useValidationContext(); const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
const { const {
applyCoupon, applyCoupon,

View File

@ -12,11 +12,11 @@ import LoadingMask from '@woocommerce/base-components/loading-mask';
import type { PaymentMethodInterface } from '@woocommerce/types'; import type { PaymentMethodInterface } from '@woocommerce/types';
import { useSelect } from '@wordpress/data'; import { useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
import { ValidationInputError } from '@woocommerce/base-components/validation-input-error';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { ValidationInputError } from '../../providers/validation';
import { useStoreCart } from '../cart/use-store-cart'; import { useStoreCart } from '../cart/use-store-cart';
import { useStoreCartCoupons } from '../cart/use-store-cart-coupons'; import { useStoreCartCoupons } from '../cart/use-store-cart-coupons';
import { useEmitResponse } from '../use-emit-response'; import { useEmitResponse } from '../use-emit-response';

View File

@ -6,27 +6,31 @@ import type {
ValidationData, ValidationData,
ValidationContextError, ValidationContextError,
} from '@woocommerce/type-defs/contexts'; } from '@woocommerce/type-defs/contexts';
import { useDispatch, useSelect } from '@wordpress/data';
/** import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
* Internal dependencies
*/
import { useValidationContext } from '../providers/validation/';
/** /**
* Custom hook for setting for adding errors to the validation system. * Custom hook for setting for adding errors to the validation system.
*/ */
export const useValidation = (): ValidationData => { export const useValidation = (): ValidationData => {
const { const {
hasValidationErrors,
getValidationError,
clearValidationError, clearValidationError,
hideValidationError, hideValidationError,
setValidationErrors, setValidationErrors,
} = useValidationContext(); } = useDispatch( VALIDATION_STORE_KEY );
const { hasValidationErrors, getValidationError } = useSelect(
( select ) => {
const store = select( VALIDATION_STORE_KEY );
return {
hasValidationErrors: store.hasValidationErrors,
getValidationError: store.getValidationError(),
};
}
);
const prefix = 'extensions-errors'; const prefix = 'extensions-errors';
return { return {
hasValidationErrors, hasValidationErrors: hasValidationErrors(),
getValidationError: useCallback( getValidationError: useCallback(
( validationErrorId: string ) => ( validationErrorId: string ) =>
getValidationError( `${ prefix }-${ validationErrorId }` ), getValidationError( `${ prefix }-${ validationErrorId }` ),

View File

@ -15,6 +15,8 @@ import {
productSupportsAddToCartForm, productSupportsAddToCartForm,
} from '@woocommerce/base-utils'; } from '@woocommerce/base-utils';
import { useDispatch } from '@wordpress/data'; import { useDispatch } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
/** /**
* Internal dependencies * Internal dependencies
*/ */
@ -28,7 +30,6 @@ import {
emitEventWithAbort, emitEventWithAbort,
reducer as emitReducer, reducer as emitReducer,
} from './event-emit'; } from './event-emit';
import { useValidationContext } from '../../validation';
import { useEmitResponse } from '../../../hooks/use-emit-response'; import { useEmitResponse } from '../../../hooks/use-emit-response';
import { removeNoticesByStatus } from '../../../../../utils/notices'; import { removeNoticesByStatus } from '../../../../../utils/notices';
@ -100,9 +101,12 @@ export const AddToCartFormStateContextProvider = ( {
const [ observers, observerDispatch ] = useReducer( emitReducer, {} ); const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
const currentObservers = useShallowEqual( observers ); const currentObservers = useShallowEqual( observers );
const { createErrorNotice } = useDispatch( 'core/notices' ); const { createErrorNotice } = useDispatch( 'core/notices' );
const { setValidationErrors } = useValidationContext(); const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
const { isSuccessResponse, isErrorResponse, isFailResponse } = const {
useEmitResponse(); isSuccessResponse,
isErrorResponse,
isFailResponse,
} = useEmitResponse();
/** /**
* @type {AddToCartFormEventRegistration} * @type {AddToCartFormEventRegistration}

View File

@ -2,7 +2,6 @@
* Internal dependencies * Internal dependencies
*/ */
import { AddToCartFormStateContextProvider } from '../form-state'; import { AddToCartFormStateContextProvider } from '../form-state';
import { ValidationContextProvider } from '../../validation';
import FormSubmit from './submit'; import FormSubmit from './submit';
/** /**
@ -21,14 +20,12 @@ export const AddToCartFormContextProvider = ( {
showFormElements, showFormElements,
} ) => { } ) => {
return ( return (
<ValidationContextProvider> <AddToCartFormStateContextProvider
<AddToCartFormStateContextProvider product={ product }
product={ product } showFormElements={ showFormElements }
showFormElements={ showFormElements } >
> { children }
{ children } <FormSubmit />
<FormSubmit /> </AddToCartFormStateContextProvider>
</AddToCartFormStateContextProvider>
</ValidationContextProvider>
); );
}; };

View File

@ -6,13 +6,13 @@ import triggerFetch from '@wordpress/api-fetch';
import { useEffect, useCallback, useState } from '@wordpress/element'; import { useEffect, useCallback, useState } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities'; import { decodeEntities } from '@wordpress/html-entities';
import { triggerAddedToCartEvent } from '@woocommerce/base-utils'; import { triggerAddedToCartEvent } from '@woocommerce/base-utils';
import { useDispatch } from '@wordpress/data'; import { useDispatch, useSelect } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { useAddToCartFormContext } from '../../form-state'; import { useAddToCartFormContext } from '../../form-state';
import { useValidationContext } from '../../../validation';
import { useStoreCart } from '../../../../hooks/cart/use-store-cart'; import { useStoreCart } from '../../../../hooks/cart/use-store-cart';
/** /**
@ -30,15 +30,18 @@ const FormSubmit = () => {
isProcessing, isProcessing,
requestParams, requestParams,
} = useAddToCartFormContext(); } = useAddToCartFormContext();
const { hasValidationErrors, showAllValidationErrors } = const { showAllValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
useValidationContext(); const hasValidationErrors = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY );
return store.hasValidationErrors;
} );
const { createErrorNotice, removeNotice } = useDispatch( 'core/notices' ); const { createErrorNotice, removeNotice } = useDispatch( 'core/notices' );
const { receiveCart } = useStoreCart(); const { receiveCart } = useStoreCart();
const [ isSubmitting, setIsSubmitting ] = useState( false ); const [ isSubmitting, setIsSubmitting ] = useState( false );
const doSubmit = ! hasError && isProcessing; const doSubmit = ! hasError && isProcessing;
const checkValidationContext = useCallback( () => { const checkValidationContext = useCallback( () => {
if ( hasValidationErrors ) { if ( hasValidationErrors() ) {
showAllValidationErrors(); showAllValidationErrors();
return { return {
type: 'error', type: 'error',

View File

@ -14,7 +14,10 @@ import {
import { usePrevious } from '@woocommerce/base-hooks'; import { usePrevious } from '@woocommerce/base-hooks';
import deprecated from '@wordpress/deprecated'; import deprecated from '@wordpress/deprecated';
import { useDispatch, useSelect } from '@wordpress/data'; import { useDispatch, useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; import {
CHECKOUT_STORE_KEY,
VALIDATION_STORE_KEY,
} from '@woocommerce/block-data';
/** /**
* Internal dependencies * Internal dependencies
@ -22,7 +25,6 @@ import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
import type { CheckoutEventsContextType } from './types'; import type { CheckoutEventsContextType } from './types';
import { useEventEmitters, reducer as emitReducer } from './event-emit'; import { useEventEmitters, reducer as emitReducer } from './event-emit';
import { STATUS } from '../../../../../data/checkout/constants'; import { STATUS } from '../../../../../data/checkout/constants';
import { useValidationContext } from '../../validation';
import { useStoreEvents } from '../../../hooks/use-store-events'; import { useStoreEvents } from '../../../hooks/use-store-events';
import { useCheckoutNotices } from '../../../hooks/use-checkout-notices'; import { useCheckoutNotices } from '../../../hooks/use-checkout-notices';
import { useEmitResponse } from '../../../hooks/use-emit-response'; import { useEmitResponse } from '../../../hooks/use-emit-response';
@ -64,7 +66,7 @@ export const CheckoutEventsProvider = ( {
checkoutActions.setRedirectUrl( redirectUrl ); checkoutActions.setRedirectUrl( redirectUrl );
} }
const { setValidationErrors } = useValidationContext(); const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
const { createErrorNotice } = useDispatch( 'core/notices' ); const { createErrorNotice } = useDispatch( 'core/notices' );
const { dispatchCheckoutEvent } = useStoreEvents(); const { dispatchCheckoutEvent } = useStoreEvents();

View File

@ -15,7 +15,10 @@ import {
formatStoreApiErrorMessage, formatStoreApiErrorMessage,
} from '@woocommerce/base-utils'; } from '@woocommerce/base-utils';
import { useDispatch, useSelect } from '@wordpress/data'; import { useDispatch, useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; import {
CHECKOUT_STORE_KEY,
VALIDATION_STORE_KEY,
} from '@woocommerce/block-data';
/** /**
* Internal dependencies * Internal dependencies
@ -25,9 +28,9 @@ import { useCheckoutEventsContext } from './checkout-events';
import { useShippingDataContext } from './shipping'; import { useShippingDataContext } from './shipping';
import { useCustomerDataContext } from './customer'; import { useCustomerDataContext } from './customer';
import { usePaymentMethodDataContext } from './payment-methods'; import { usePaymentMethodDataContext } from './payment-methods';
import { useValidationContext } from '../validation';
import { useStoreCart } from '../../hooks/cart/use-store-cart'; import { useStoreCart } from '../../hooks/cart/use-store-cart';
import { useStoreNoticesContext } from '../store-notices'; import { useStoreNoticesContext } from '../store-notices';
/** /**
* CheckoutProcessor component. * CheckoutProcessor component.
* *
@ -58,7 +61,9 @@ const CheckoutProcessor = () => {
const { setHasError, processCheckoutResponse } = const { setHasError, processCheckoutResponse } =
useDispatch( CHECKOUT_STORE_KEY ); useDispatch( CHECKOUT_STORE_KEY );
const { hasValidationErrors } = useValidationContext(); const hasValidationErrors = useSelect(
( select ) => select( VALIDATION_STORE_KEY ).hasValidationErrors
);
const { shippingErrorStatus } = useShippingDataContext(); const { shippingErrorStatus } = useShippingDataContext();
const { billingAddress, shippingAddress } = useCustomerDataContext(); const { billingAddress, shippingAddress } = useCustomerDataContext();
const { cartNeedsPayment, cartNeedsShipping, receiveCart } = useStoreCart(); const { cartNeedsPayment, cartNeedsShipping, receiveCart } = useStoreCart();
@ -87,7 +92,7 @@ const CheckoutProcessor = () => {
}, [ activePaymentMethod, expressPaymentMethods, paymentMethods ] ); }, [ activePaymentMethod, expressPaymentMethods, paymentMethods ] );
const checkoutWillHaveError = const checkoutWillHaveError =
( hasValidationErrors && ! isExpressPaymentMethodActive ) || ( hasValidationErrors() && ! isExpressPaymentMethodActive ) ||
currentPaymentStatus.hasError || currentPaymentStatus.hasError ||
shippingErrorStatus.hasError; shippingErrorStatus.hasError;
@ -128,7 +133,7 @@ const CheckoutProcessor = () => {
}, [ billingAddress, shippingAddress, redirectUrl ] ); }, [ billingAddress, shippingAddress, redirectUrl ] );
const checkValidation = useCallback( () => { const checkValidation = useCallback( () => {
if ( hasValidationErrors ) { if ( hasValidationErrors() ) {
return false; return false;
} }
if ( currentPaymentStatus.hasError ) { if ( currentPaymentStatus.hasError ) {

View File

@ -12,7 +12,10 @@ import {
} from '@wordpress/element'; } from '@wordpress/element';
import { objectHasProp } from '@woocommerce/types'; import { objectHasProp } from '@woocommerce/types';
import { useDispatch, useSelect } from '@wordpress/data'; import { useDispatch, useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; import {
CHECKOUT_STORE_KEY,
VALIDATION_STORE_KEY,
} from '@woocommerce/block-data';
/** /**
* Internal dependencies * Internal dependencies
@ -39,7 +42,6 @@ import {
emitEventWithAbort, emitEventWithAbort,
reducer as emitReducer, reducer as emitReducer,
} from './event-emit'; } from './event-emit';
import { useValidationContext } from '../../validation';
import { useEmitResponse } from '../../../hooks/use-emit-response'; import { useEmitResponse } from '../../../hooks/use-emit-response';
import { getCustomerPaymentMethods } from './utils'; import { getCustomerPaymentMethods } from './utils';
@ -77,9 +79,10 @@ export const PaymentMethodDataProvider = ( {
}; };
} ); } );
const { isEditor, getPreviewData } = useEditorContext(); const { isEditor, getPreviewData } = useEditorContext();
const { setValidationErrors } = useValidationContext(); const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
const { createErrorNotice: addErrorNotice, removeNotice } = const { createErrorNotice: addErrorNotice, removeNotice } = useDispatch(
useDispatch( 'core/notices' ); 'core/notices'
);
const { const {
isSuccessResponse, isSuccessResponse,
isErrorResponse, isErrorResponse,

View File

@ -3,7 +3,6 @@ export * from './add-to-cart-form';
export * from './cart-checkout'; export * from './cart-checkout';
export * from './store-notices'; export * from './store-notices';
export * from './store-snackbar-notices'; export * from './store-snackbar-notices';
export * from './validation';
export * from './container-width-context'; export * from './container-width-context';
export * from './editor-context'; export * from './editor-context';
export * from './query-state-context'; export * from './query-state-context';

View File

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

View File

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

View File

@ -1,257 +0,0 @@
/**
* External dependencies
*/
import {
createContext,
useCallback,
useContext,
useState,
} from '@wordpress/element';
import { pickBy } from 'lodash';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* @typedef { import('@woocommerce/type-defs/contexts').ValidationContext } ValidationContext
* @typedef {import('react')} React
*/
const ValidationContext = createContext( {
getValidationError: () => '',
setValidationErrors: ( errors ) => void errors,
clearValidationError: ( property ) => void property,
clearAllValidationErrors: () => void null,
hideValidationError: () => void null,
showValidationError: () => void null,
showAllValidationErrors: () => void null,
hasValidationErrors: false,
getValidationErrorId: ( errorId ) => errorId,
} );
/**
* @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.
*
* @param {Object} props Incoming props for the component.
* @param {JSX.Element} props.children What react elements are wrapped by this component.
*/
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 {Object} The error object for the given property.
*/
const getValidationError = useCallback(
( property ) => validationErrors[ property ],
[ validationErrors ]
);
/**
* Provides an id for the validation error that can be used to fill out
* aria-describedby attribute values.
*
* @param {string} errorId The input css id the validation error is related
* to.
* @return {string} The id to use for the validation error container.
*/
const getValidationErrorId = useCallback(
( errorId ) => {
const error = validationErrors[ errorId ];
if ( ! error || error.hidden ) {
return '';
}
return `validate-error-${ errorId }`;
},
[ validationErrors ]
);
/**
* 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 = useCallback(
/**
* Callback that is memoized.
*
* @param {string} property
*/
( property ) => {
updateValidationErrors(
/**
* Callback for validation Errors handling.
*
* @param {Object} prevErrors
*/
( prevErrors ) => {
if ( ! prevErrors[ property ] ) {
return prevErrors;
}
const {
// eslint-disable-next-line no-unused-vars -- this is intentional to omit the dynamic property from the returned object.
[ property ]: clearedProperty,
...newErrors
} = prevErrors;
return newErrors;
}
);
},
[]
);
/**
* Clears the entire validation error state.
*/
const clearAllValidationErrors = useCallback(
() => 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 = useCallback( ( newErrors ) => {
if ( ! newErrors ) {
return;
}
updateValidationErrors( ( prevErrors ) => {
newErrors = pickBy( newErrors, ( error, property ) => {
if ( typeof error.message !== 'string' ) {
return false;
}
if ( prevErrors.hasOwnProperty( property ) ) {
return ! isShallowEqual( prevErrors[ property ], error );
}
return true;
} );
if ( Object.values( newErrors ).length === 0 ) {
return prevErrors;
}
return {
...prevErrors,
...newErrors,
};
} );
}, [] );
/**
* Used to update a validation error.
*
* @param {string} property The name of the property to update.
* @param {Object} newError New validation error object.
*/
const updateValidationError = useCallback( ( property, newError ) => {
updateValidationErrors( ( prevErrors ) => {
if ( ! prevErrors.hasOwnProperty( property ) ) {
return prevErrors;
}
const updatedError = {
...prevErrors[ property ],
...newError,
};
return isShallowEqual( prevErrors[ property ], updatedError )
? prevErrors
: {
...prevErrors,
[ property ]: updatedError,
};
} );
}, [] );
/**
* Given a property name and if an associated error exists, it sets its
* `hidden` value to true.
*
* @param {string} property The name of the property to set the `hidden`
* value to true.
*/
const hideValidationError = useCallback(
( property ) =>
void updateValidationError( property, {
hidden: true,
} ),
[ updateValidationError ]
);
/**
* Given a property name and if an associated error exists, it sets its
* `hidden` value to false.
*
* @param {string} property The name of the property to set the `hidden`
* value to false.
*/
const showValidationError = useCallback(
( property ) =>
void updateValidationError( property, {
hidden: false,
} ),
[ updateValidationError ]
);
/**
* Sets the `hidden` value of all errors to `false`.
*/
const showAllValidationErrors = useCallback(
() =>
void updateValidationErrors( ( prevErrors ) => {
const updatedErrors = {};
Object.keys( prevErrors ).forEach( ( property ) => {
if ( prevErrors[ property ].hidden ) {
updatedErrors[ property ] = {
...prevErrors[ property ],
hidden: false,
};
}
} );
if ( Object.values( updatedErrors ).length === 0 ) {
return prevErrors;
}
return {
...prevErrors,
...updatedErrors,
};
} ),
[]
);
const context = {
getValidationError,
setValidationErrors,
clearValidationError,
clearAllValidationErrors,
hideValidationError,
showValidationError,
showAllValidationErrors,
hasValidationErrors: Object.keys( validationErrors ).length > 0,
getValidationErrorId,
};
return (
<ValidationContext.Provider value={ context }>
{ children }
</ValidationContext.Provider>
);
};

View File

@ -1,2 +0,0 @@
export * from './context';
export * from './components';

View File

@ -6,7 +6,6 @@ import { useStoreCart } from '@woocommerce/base-context/hooks';
import { useEffect } from '@wordpress/element'; import { useEffect } from '@wordpress/element';
import LoadingMask from '@woocommerce/base-components/loading-mask'; import LoadingMask from '@woocommerce/base-components/loading-mask';
import { import {
ValidationContextProvider,
StoreNoticesContainer, StoreNoticesContainer,
SnackbarNoticesContainer, SnackbarNoticesContainer,
} from '@woocommerce/base-context'; } from '@woocommerce/base-context';
@ -39,9 +38,7 @@ const Cart = ( { children, attributes = {} } ) => {
hasDarkControls, hasDarkControls,
} } } }
> >
<ValidationContextProvider> { children }
{ children }
</ValidationContextProvider>
</CartBlockContext.Provider> </CartBlockContext.Provider>
</LoadingMask> </LoadingMask>
); );

View File

@ -6,8 +6,6 @@ import classnames from 'classnames';
import { createInterpolateElement, useEffect } from '@wordpress/element'; import { createInterpolateElement, useEffect } from '@wordpress/element';
import { useStoreCart } from '@woocommerce/base-context/hooks'; import { useStoreCart } from '@woocommerce/base-context/hooks';
import { import {
useValidationContext,
ValidationContextProvider,
CheckoutProvider, CheckoutProvider,
SnackbarNoticesContainer, SnackbarNoticesContainer,
} from '@woocommerce/base-context'; } from '@woocommerce/base-context';
@ -17,8 +15,11 @@ import { SidebarLayout } from '@woocommerce/base-components/sidebar-layout';
import { CURRENT_USER_IS_ADMIN, getSetting } from '@woocommerce/settings'; import { CURRENT_USER_IS_ADMIN, getSetting } from '@woocommerce/settings';
import { SlotFillProvider } from '@woocommerce/blocks-checkout'; import { SlotFillProvider } from '@woocommerce/blocks-checkout';
import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top'; import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top';
import { useSelect } from '@wordpress/data'; import { useDispatch, useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; import {
CHECKOUT_STORE_KEY,
VALIDATION_STORE_KEY,
} from '@woocommerce/block-data';
/** /**
* Internal dependencies * Internal dependencies
@ -120,8 +121,13 @@ const ScrollOnError = ( {
}; };
} }
); );
const { hasValidationErrors, showAllValidationErrors } = const { hasValidationErrors } = useSelect( ( select ) => {
useValidationContext(); const store = select( VALIDATION_STORE_KEY );
return {
hasValidationErrors: store.hasValidationErrors(),
};
} );
const { showAllValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
const hasErrorsToDisplay = const hasErrorsToDisplay =
checkoutIsIdle && checkoutIsIdle &&
@ -181,24 +187,21 @@ const Block = ( {
<SnackbarNoticesContainer context="wc/checkout" /> <SnackbarNoticesContainer context="wc/checkout" />
<StoreNoticesProvider> <StoreNoticesProvider>
<StoreNoticesContainer context="wc/checkout" /> <StoreNoticesContainer context="wc/checkout" />
<ValidationContextProvider> { /* SlotFillProvider need to be defined before CheckoutProvider so fills have the SlotFill context ready when they mount. */ }
{ /* SlotFillProvider need to be defined before CheckoutProvider so fills have the SlotFill context ready when they mount. */ } <SlotFillProvider>
<SlotFillProvider> <CheckoutProvider>
<CheckoutProvider> <SidebarLayout
<SidebarLayout className={ classnames( 'wc-block-checkout', {
className={ classnames( 'wc-block-checkout', { 'has-dark-controls': attributes.hasDarkControls,
'has-dark-controls': } ) }
attributes.hasDarkControls, >
} ) } <Checkout attributes={ attributes }>
> { children }
<Checkout attributes={ attributes }> </Checkout>
{ children } <ScrollOnError scrollToTop={ scrollToTop } />
</Checkout> </SidebarLayout>
<ScrollOnError scrollToTop={ scrollToTop } /> </CheckoutProvider>
</SidebarLayout> </SlotFillProvider>
</CheckoutProvider>
</SlotFillProvider>
</ValidationContextProvider>
</StoreNoticesProvider> </StoreNoticesProvider>
</BlockErrorBoundary> </BlockErrorBoundary>
); );

View File

@ -12,6 +12,7 @@ import type { CheckoutState } from './default-state';
import type { DispatchFromMap, SelectFromMap } from '../mapped-types'; import type { DispatchFromMap, SelectFromMap } from '../mapped-types';
import * as selectors from './selectors'; import * as selectors from './selectors';
import * as actions from './actions'; import * as actions from './actions';
import { FieldValidationStatus } from '../types';
export type CheckoutAfterProcessingWithErrorEventData = { export type CheckoutAfterProcessingWithErrorEventData = {
redirectUrl: CheckoutState[ 'redirectUrl' ]; redirectUrl: CheckoutState[ 'redirectUrl' ];
@ -53,7 +54,9 @@ export type emitValidateEventType = ( {
setValidationErrors, setValidationErrors,
}: { }: {
observers: EventObserversType; observers: EventObserversType;
setValidationErrors: ( errors: Array< unknown > ) => void; setValidationErrors: (
errors: Record< string, FieldValidationStatus >
) => void;
} ) => ( { } ) => ( {
dispatch, dispatch,
registry, registry,

View File

@ -10,6 +10,7 @@ export { SCHEMA_STORE_KEY } from './schema';
export { COLLECTIONS_STORE_KEY } from './collections'; export { COLLECTIONS_STORE_KEY } from './collections';
export { CART_STORE_KEY } from './cart'; export { CART_STORE_KEY } from './cart';
export { CHECKOUT_STORE_KEY } from './checkout'; export { CHECKOUT_STORE_KEY } from './checkout';
export { VALIDATION_STORE_KEY } from './validation';
export { QUERY_STATE_STORE_KEY } from './query-state'; export { QUERY_STATE_STORE_KEY } from './query-state';
export * from './constants'; export * from './constants';
export * from './types'; export * from './types';

View File

@ -42,3 +42,8 @@ export function assertResponseIsValid(
} }
throw new Error( 'Response not valid' ); throw new Error( 'Response not valid' );
} }
export interface FieldValidationStatus {
message: string;
hidden: boolean;
}

View File

@ -0,0 +1,8 @@
export const ACTION_TYPES = {
SET_VALIDATION_ERRORS: 'SET_VALIDATION_ERRORS',
CLEAR_ALL_VALIDATION_ERRORS: 'CLEAR_ALL_VALIDATION_ERRORS',
CLEAR_VALIDATION_ERROR: 'CLEAR_VALIDATION_ERROR',
HIDE_VALIDATION_ERROR: 'HIDE_VALIDATION_ERROR',
SHOW_VALIDATION_ERROR: 'SHOW_VALIDATION_ERROR',
SHOW_ALL_VALIDATION_ERRORS: 'SHOW_ALL_VALIDATION_ERRORS',
} as const;

View File

@ -0,0 +1,41 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { ReturnOrGeneratorYieldUnion } from '../mapped-types';
import { FieldValidationStatus } from '../types';
export const setValidationErrors = (
errors: Record< string, FieldValidationStatus >
) => ( {
type: types.SET_VALIDATION_ERRORS,
errors,
} );
export const clearAllValidationErrors = () => ( {
type: types.CLEAR_ALL_VALIDATION_ERRORS,
} );
export const clearValidationError = ( error: string ) => ( {
type: types.CLEAR_VALIDATION_ERROR,
error,
} );
export const hideValidationError = ( error: string ) => ( {
type: types.HIDE_VALIDATION_ERROR,
error,
} );
export const showValidationError = ( error: string ) => ( {
type: types.SHOW_VALIDATION_ERROR,
error,
} );
export const showAllValidationErrors = () => ( {
type: types.SHOW_ALL_VALIDATION_ERRORS,
} );
export type ValidationAction = ReturnOrGeneratorYieldUnion<
| typeof setValidationErrors
| typeof clearAllValidationErrors
| typeof clearValidationError
| typeof hideValidationError
| typeof showValidationError
| typeof showAllValidationErrors
>;

View File

@ -0,0 +1 @@
export const STORE_KEY = 'wc/store/validation';

View File

@ -0,0 +1,39 @@
/**
* External dependencies
*/
import { createReduxStore, register } from '@wordpress/data';
/**
* Internal dependencies
*/
import reducer from './reducers';
import { STORE_KEY } from './constants';
import * as actions from './actions';
import * as selectors from './selectors';
import { DispatchFromMap, SelectFromMap } from '../mapped-types';
export const config = {
reducer,
selectors,
actions,
// TODO: Gutenberg with Thunks was released in WP 6.0. Once 6.1 is released, remove the experimental flag here
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore We pass this in case there is an older version of Gutenberg running.
__experimentalUseThunks: true,
};
const store = createReduxStore( STORE_KEY, config );
register( store );
export const VALIDATION_STORE_KEY = STORE_KEY;
declare module '@wordpress/data' {
function dispatch(
key: typeof VALIDATION_STORE_KEY
): DispatchFromMap< typeof actions >;
function select( key: typeof VALIDATION_STORE_KEY ): SelectFromMap<
typeof selectors
> & {
hasFinishedResolution: ( selector: string ) => boolean;
};
}

View File

@ -0,0 +1,80 @@
/**
* External dependencies
*/
import type { Reducer } from 'redux';
import { pickBy } from 'lodash';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { isString } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { ValidationAction } from './actions';
import { ACTION_TYPES as types } from './action-types';
import { FieldValidationStatus } from '../types';
const reducer: Reducer< Record< string, FieldValidationStatus > > = (
state: Record< string, FieldValidationStatus > = {},
action: Partial< ValidationAction >
) => {
const newState = { ...state };
switch ( action.type ) {
case types.SET_VALIDATION_ERRORS:
const newErrors = pickBy( action.errors, ( error, property ) => {
if ( typeof error.message !== 'string' ) {
return false;
}
if ( state.hasOwnProperty( property ) ) {
return ! isShallowEqual( state[ property ], error );
}
return true;
} );
if ( Object.values( newErrors ).length === 0 ) {
return state;
}
return { ...state, ...action.errors };
case types.CLEAR_ALL_VALIDATION_ERRORS:
return {};
case types.CLEAR_VALIDATION_ERROR:
if (
! isString( action.error ) ||
! newState.hasOwnProperty( action.error )
) {
return newState;
}
delete newState[ action.error ];
return newState;
case types.HIDE_VALIDATION_ERROR:
if (
! isString( action.error ) ||
! newState.hasOwnProperty( action.error )
) {
return newState;
}
newState[ action.error ].hidden = true;
return newState;
case types.SHOW_VALIDATION_ERROR:
if (
! isString( action.error ) ||
! newState.hasOwnProperty( action.error )
) {
return newState;
}
newState[ action.error ].hidden = false;
return newState;
case types.SHOW_ALL_VALIDATION_ERRORS:
Object.keys( newState ).forEach( ( property ) => {
if ( newState[ property ].hidden ) {
newState[ property ].hidden = false;
}
} );
return { ...newState };
default:
return state;
}
};
export type State = ReturnType< typeof reducer >;
export default reducer;

View File

@ -0,0 +1,19 @@
/**
* Internal dependencies
*/
import type { State } from './reducers';
export const getValidationError = ( state: State ) => {
return ( errorId: string ) => state[ errorId ];
};
export const getValidationErrorId = ( state: State ) => {
return ( errorId: string ) => {
if ( ! state.hasOwnProperty( errorId ) || state[ errorId ].hidden ) {
return;
}
return `validate-error-${ errorId }`;
};
};
export const hasValidationErrors = ( state: State ) => {
return Object.keys( state ).length > 0;
};

View File

@ -0,0 +1,248 @@
/**
* Internal dependencies
*/
import reducer from '../reducers';
import { FieldValidationStatus } from '../../types';
import { ACTION_TYPES as types } from '.././action-types';
import { ValidationAction } from '../actions';
describe( 'Validation reducer', () => {
it( 'Sets a single validation error', () => {
const singleValidationAction: ValidationAction = {
type: types.SET_VALIDATION_ERRORS,
errors: {
singleValidationError: {
message: 'This is a single validation error message',
hidden: false,
},
},
};
const nextState = reducer( {}, singleValidationAction );
expect( nextState ).toEqual( {
singleValidationError: {
message: 'This is a single validation error message',
hidden: false,
},
} );
} );
it( 'Does not add new errors if the same error already exists in state', () => {
const state: Record< string, FieldValidationStatus > = {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
};
const existingErrorValidation: ValidationAction = {
type: types.SET_VALIDATION_ERRORS,
errors: {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
},
};
const nextState = reducer( state, existingErrorValidation );
expect( nextState ).toEqual( {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
} );
} );
it( 'Does not add new errors if error message is not string, but keeps existing errors', () => {
const integerErrorAction: ValidationAction = {
type: types.SET_VALIDATION_ERRORS,
errors: {
integerError: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ignoring because we're testing runtime errors with integers.
message: 1234,
hidden: false,
},
},
};
const nextState = reducer( {}, integerErrorAction );
expect( nextState ).not.toHaveProperty( 'integerError' );
} );
it( 'Updates existing error if message or hidden property changes', () => {
const state: Record< string, FieldValidationStatus > = {
existingValidationError: {
message: 'This is an existing error message',
hidden: false,
},
};
const updateExistingErrorAction: ValidationAction = {
type: types.SET_VALIDATION_ERRORS,
errors: {
existingValidationError: {
message: 'This is an existing error message',
hidden: true,
},
},
};
const nextState = reducer( state, updateExistingErrorAction );
expect( nextState ).toEqual( {
existingValidationError: {
message: 'This is an existing error message',
hidden: true,
},
} );
} );
it( 'Appends new errors to list of existing errors', () => {
const state: Record< string, FieldValidationStatus > = {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
};
const addNewError: ValidationAction = {
type: types.SET_VALIDATION_ERRORS,
errors: {
newError: {
message: 'This is a new error',
hidden: false,
},
},
};
const nextState = reducer( state, addNewError );
expect( nextState ).toEqual( {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
newError: {
message: 'This is a new error',
hidden: false,
},
} );
} );
it( 'Clears all validation errors', () => {
const state: Record< string, FieldValidationStatus > = {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
};
const clearAllErrors: ValidationAction = {
type: types.CLEAR_ALL_VALIDATION_ERRORS,
};
const nextState = reducer( state, clearAllErrors );
expect( nextState ).toEqual( {} );
} );
it( 'Clears a single validation error', () => {
const state: Record< string, FieldValidationStatus > = {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
testError: {
message: 'This is error should not be removed',
hidden: false,
},
};
const clearError: ValidationAction = {
type: types.CLEAR_VALIDATION_ERROR,
error: 'existingError',
};
const nextState = reducer( state, clearError );
expect( nextState ).not.toHaveProperty( 'existingError' );
expect( nextState ).toHaveProperty( 'testError' );
} );
it( 'Hides a single validation error', () => {
const state: Record< string, FieldValidationStatus > = {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
testError: {
message: 'This is error should not be removed',
hidden: false,
},
};
const testAction: ValidationAction = {
type: types.HIDE_VALIDATION_ERROR,
error: 'existingError',
};
const nextState = reducer( state, testAction );
expect( nextState ).toEqual( {
existingError: {
message: 'This is an existing error message',
hidden: true,
},
testError: {
message: 'This is error should not be removed',
hidden: false,
},
} );
} );
it( 'Shows a single validation error', () => {
const state: Record< string, FieldValidationStatus > = {
existingError: {
message: 'This is an existing error message',
hidden: true,
},
testError: {
message: 'This is error should not be removed',
hidden: true,
},
visibleError: {
message: 'This is error should remain visible',
hidden: false,
},
};
const testAction: ValidationAction = {
type: types.SHOW_VALIDATION_ERROR,
error: 'existingError',
};
const nextState = reducer( state, testAction );
expect( nextState ).toEqual( {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
testError: {
message: 'This is error should not be removed',
hidden: true,
},
visibleError: {
message: 'This is error should remain visible',
hidden: false,
},
} );
} );
it( 'Shows all validation errors', () => {
const state: Record< string, FieldValidationStatus > = {
firstExistingError: {
message: 'This is first existing error message',
hidden: true,
},
secondExistingError: {
message: 'This is the second existing error message',
hidden: true,
},
};
const showAllErrors: ValidationAction = {
type: types.SHOW_ALL_VALIDATION_ERRORS,
};
const nextState = reducer( state, showAllErrors );
expect( nextState ).toEqual( {
firstExistingError: {
message: 'This is first existing error message',
hidden: false,
},
secondExistingError: {
message: 'This is the second existing error message',
hidden: false,
},
} );
} );
} );

View File

@ -0,0 +1,56 @@
/**
* Internal dependencies
*/
import {
getValidationErrorId,
getValidationError,
hasValidationErrors,
} from '../selectors';
import { FieldValidationStatus } from '../../types';
describe( 'Validation selectors', () => {
it( 'Gets the validation error', () => {
const state: Record< string, FieldValidationStatus > = {
validationError: {
message: 'This is a test message',
hidden: false,
},
};
const validationError = getValidationError( state )(
'validationError'
);
expect( validationError ).toEqual( {
message: 'This is a test message',
hidden: false,
} );
} );
it( 'Gets the generated validation error ID', () => {
const state: Record< string, FieldValidationStatus > = {
validationError: {
message: 'This is a test message',
hidden: false,
},
};
const validationErrorID = getValidationErrorId( state )(
'validationError'
);
expect( validationErrorID ).toEqual( `validate-error-validationError` );
} );
it( 'Checks if state has any validation errors', () => {
const state: Record< string, FieldValidationStatus > = {
validationError: {
message: 'This is a test message',
hidden: false,
},
};
const validationErrors = hasValidationErrors( state );
expect( validationErrors ).toEqual( true );
const stateWithNoErrors: Record< string, FieldValidationStatus > = {};
const stateWithNoErrorsCheckResult = hasValidationErrors(
stateWithNoErrors
);
expect( stateWithNoErrorsCheckResult ).toEqual( false );
} );
} );

View File

@ -5,6 +5,7 @@
import type PaymentMethodLabel from '@woocommerce/base-components/cart-checkout/payment-method-label'; import type PaymentMethodLabel from '@woocommerce/base-components/cart-checkout/payment-method-label';
import type PaymentMethodIcons from '@woocommerce/base-components/cart-checkout/payment-method-icons'; import type PaymentMethodIcons from '@woocommerce/base-components/cart-checkout/payment-method-icons';
import type LoadingMask from '@woocommerce/base-components/loading-mask'; import type LoadingMask from '@woocommerce/base-components/loading-mask';
import type ValidationInputError from '@woocommerce/base-components/validation-input-error';
/** /**
* Internal dependencies * Internal dependencies
@ -76,7 +77,7 @@ export interface ComponentProps {
// A component used for displaying payment method labels, including an icon. // A component used for displaying payment method labels, including an icon.
PaymentMethodLabel: typeof PaymentMethodLabel; PaymentMethodLabel: typeof PaymentMethodLabel;
// A container for holding validation errors // A container for holding validation errors
ValidationInputError: () => JSX.Element | null; ValidationInputError: typeof ValidationInputError;
} }
export interface EmitResponseProps { export interface EmitResponseProps {