diff --git a/assets/js/base/components/cart-checkout/address-form/address-form.tsx b/assets/js/base/components/cart-checkout/address-form/address-form.tsx index 28fb22b0a27..4f7d9f189af 100644 --- a/assets/js/base/components/cart-checkout/address-form/address-form.tsx +++ b/assets/js/base/components/cart-checkout/address-form/address-form.tsx @@ -22,7 +22,7 @@ import { ShippingAddress, } from '@woocommerce/settings'; import { useSelect, useDispatch, dispatch } from '@wordpress/data'; -import { VALIDATION_STORE_KEY } from '@woocommerce/block-data'; +import { CART_STORE_KEY, VALIDATION_STORE_KEY } from '@woocommerce/block-data'; import { FieldValidationStatus } from '@woocommerce/types'; /** @@ -96,11 +96,26 @@ const AddressForm = ( { const { setValidationErrors, clearValidationError } = useDispatch( VALIDATION_STORE_KEY ); - const countryValidationError = useSelect( ( select ) => { - const store = select( VALIDATION_STORE_KEY ); - return store.getValidationError( validationErrorId ); + const { country, countryValidationError } = useSelect( ( select ) => { + const validationStore = select( VALIDATION_STORE_KEY ); + const cartStore = select( CART_STORE_KEY ); + return { + countryValidationError: + validationStore.getValidationError( validationErrorId ), + country: + cartStore.getCartData()?.[ + type === 'shipping' ? 'shippingAddress' : 'billingAddress' + ]?.country, + }; } ); + // This object is used to revalidate the specified fields when any dependency changes. + const revalidateDependencies: { + [ K in keyof AddressFields ]?: unknown[]; + } = { + postcode: [ country ], + }; + const currentFields = useShallowEqual( fields ); const addressFormFields = useMemo( () => { @@ -269,6 +284,9 @@ const AddressForm = ( { label={ field.required ? field.label : field.optionalLabel } + revalidateDependencies={ + revalidateDependencies[ field.key ] + } value={ values[ field.key ] } autoCapitalize={ field.autocapitalize } autoComplete={ field.autocomplete } diff --git a/assets/js/data/cart/actions.ts b/assets/js/data/cart/actions.ts index 1f09020e1ff..41b9b8de857 100644 --- a/assets/js/data/cart/actions.ts +++ b/assets/js/data/cart/actions.ts @@ -479,13 +479,6 @@ export const updateCustomerData = } }; -export const setFullShippingAddressPushed = ( - fullShippingAddressPushed: boolean -) => ( { - type: types.SET_FULL_SHIPPING_ADDRESS_PUSHED, - fullShippingAddressPushed, -} ); - type Actions = | typeof addItemToCart | typeof applyCoupon @@ -506,7 +499,6 @@ type Actions = | typeof setShippingAddress | typeof shippingRatesBeingSelected | typeof updateCustomerData - | typeof setFullShippingAddressPushed | typeof updatingCustomerData; export type CartAction = ReturnOrGeneratorYieldUnion< Actions | Thunks >; diff --git a/assets/js/data/cart/default-state.ts b/assets/js/data/cart/default-state.ts index 112f5eeb94f..8ce98387a77 100644 --- a/assets/js/data/cart/default-state.ts +++ b/assets/js/data/cart/default-state.ts @@ -100,7 +100,6 @@ export const defaultCartState: CartState = { applyingCoupon: '', removingCoupon: '', isCartDataStale: false, - fullShippingAddressPushed: false, }, errors: EMPTY_CART_ERRORS, }; diff --git a/assets/js/data/cart/push-changes.ts b/assets/js/data/cart/push-changes.ts index 784a2b12686..a1cabfa3a23 100644 --- a/assets/js/data/cart/push-changes.ts +++ b/assets/js/data/cart/push-changes.ts @@ -20,7 +20,6 @@ import isShallowEqual from '@wordpress/is-shallow-equal'; import { STORE_KEY } from './constants'; import { VALIDATION_STORE_KEY } from '../validation'; import { processErrorResponse } from '../utils'; -import { shippingAddressHasValidationErrors } from './utils'; type CustomerData = { billingAddress: CartBillingAddress; @@ -212,11 +211,6 @@ const updateCustomerData = debounce( (): void => { ) as BaseAddressKey[] ), ]; } - } ) - .finally( () => { - if ( ! shippingAddressHasValidationErrors() ) { - dispatch( STORE_KEY ).setFullShippingAddressPushed( true ); - } } ); } }, 1000 ); diff --git a/assets/js/data/cart/reducers.ts b/assets/js/data/cart/reducers.ts index 3ab809ec8c6..67a9dcfe471 100644 --- a/assets/js/data/cart/reducers.ts +++ b/assets/js/data/cart/reducers.ts @@ -48,15 +48,6 @@ const reducer: Reducer< CartState > = ( action: Partial< CartAction > ) => { switch ( action.type ) { - case types.SET_FULL_SHIPPING_ADDRESS_PUSHED: - state = { - ...state, - metaData: { - ...state.metaData, - fullShippingAddressPushed: action.fullShippingAddressPushed, - }, - }; - break; case types.SET_ERROR_DATA: if ( action.error ) { state = { diff --git a/assets/js/data/cart/resolvers.ts b/assets/js/data/cart/resolvers.ts index 080b021d0db..71b4c386d18 100644 --- a/assets/js/data/cart/resolvers.ts +++ b/assets/js/data/cart/resolvers.ts @@ -9,7 +9,6 @@ import { CartResponse } from '@woocommerce/types'; */ import { CART_API_ERROR } from './constants'; import type { CartDispatchFromMap, CartResolveSelectFromMap } from './index'; -import { shippingAddressHasValidationErrors } from './utils'; /** * Resolver for retrieving all cart data. @@ -28,10 +27,6 @@ export const getCartData = receiveError( CART_API_ERROR ); return; } - - if ( ! shippingAddressHasValidationErrors() ) { - dispatch.setFullShippingAddressPushed( true ); - } receiveCart( cartData ); }; diff --git a/assets/js/data/cart/selectors.ts b/assets/js/data/cart/selectors.ts index 59041eb804e..f4644a4b2d2 100644 --- a/assets/js/data/cart/selectors.ts +++ b/assets/js/data/cart/selectors.ts @@ -222,10 +222,3 @@ export const getItemsPendingQuantityUpdate = ( state: CartState ): string[] => { export const getItemsPendingDelete = ( state: CartState ): string[] => { return state.cartItemsPendingDelete; }; - -/** - * Whether the address has changes that have not been synced with the server. - */ -export const getFullShippingAddressPushed = ( state: CartState ): boolean => { - return state.metaData.fullShippingAddressPushed; -}; diff --git a/assets/js/types/type-defs/cart.ts b/assets/js/types/type-defs/cart.ts index b1fd1cf7b4f..d0ecf0095b8 100644 --- a/assets/js/types/type-defs/cart.ts +++ b/assets/js/types/type-defs/cart.ts @@ -210,8 +210,6 @@ export interface CartMeta { isCartDataStale: boolean; applyingCoupon: string; removingCoupon: string; - /* Whether the full address has been previously pushed to the server */ - fullShippingAddressPushed: boolean; } export interface ExtensionCartUpdateArgs { data: Record< string, unknown >; diff --git a/packages/checkout/components/text-input/test/validated-text-input.tsx b/packages/checkout/components/text-input/test/validated-text-input.tsx index e3e081a9cbc..6ba83cbd486 100644 --- a/packages/checkout/components/text-input/test/validated-text-input.tsx +++ b/packages/checkout/components/text-input/test/validated-text-input.tsx @@ -303,5 +303,61 @@ describe( 'ValidatedTextInput', () => { await expect( textInputElement ).toHaveFocus(); await expect( setValidationErrors ).not.toHaveBeenCalled(); } ); + + it( 'revalidates when revalidateDependencies value changes', async () => { + const setValidationErrors = jest.fn(); + wpData.useDispatch.mockImplementation( ( storeName: string ) => { + if ( storeName === VALIDATION_STORE_KEY ) { + return { + ...jest + .requireActual( '@wordpress/data' ) + .useDispatch( storeName ), + setValidationErrors, + }; + } + return jest + .requireActual( '@wordpress/data' ) + .useDispatch( storeName ); + } ); + + const TestComponent = ( { + dependencies, + }: { + dependencies: unknown[]; + } ) => { + const [ inputValue, setInputValue ] = useState( '' ); + return ( + setInputValue( value ) } + value={ inputValue } + label={ 'Test Input' } + required={ true } + customValidation={ ( inputObject ) => { + return inputObject.value === 'Valid Value'; + } } + focusOnMount={ true } + revalidateDependencies={ dependencies } + validateOnMount={ false } + /> + ); + }; + let dependencyToTrack = 'Test'; + const { rerender } = await render( + + ); + await expect( setValidationErrors ).not.toHaveBeenCalled(); + dependencyToTrack = 'Changed'; + await rerender( + + ); + await expect( setValidationErrors ).toHaveBeenCalled(); + dependencyToTrack = 'Changed again'; + await rerender( + + ); + await expect( setValidationErrors ).toHaveBeenCalledTimes( 2 ); + } ); } ); } ); diff --git a/packages/checkout/components/text-input/validated-text-input.tsx b/packages/checkout/components/text-input/validated-text-input.tsx index 9fcec057f0c..6fbe26152ec 100644 --- a/packages/checkout/components/text-input/validated-text-input.tsx +++ b/packages/checkout/components/text-input/validated-text-input.tsx @@ -51,6 +51,8 @@ interface ValidatedTextInputProps | undefined; // Whether validation should run when focused - only has an effect when focusOnMount is also true. validateOnMount?: boolean | undefined; + // A set of dependencies to watch, and revalidate if they change. + revalidateDependencies?: unknown[] | undefined; } const ValidatedTextInput = ( { @@ -67,6 +69,7 @@ const ValidatedTextInput = ( { customValidation, label, validateOnMount = true, + revalidateDependencies = [], ...rest }: ValidatedTextInputProps ): JSX.Element => { const [ isPristine, setIsPristine ] = useState( true ); @@ -99,10 +102,6 @@ const ValidatedTextInput = ( { inputObject.value = inputObject.value.trim(); inputObject.setCustomValidity( '' ); - if ( previousValue === inputObject.value ) { - return; - } - const inputIsValid = customValidation ? inputObject.checkValidity() && customValidation( inputObject ) : inputObject.checkValidity(); @@ -122,7 +121,6 @@ const ValidatedTextInput = ( { } ); }, [ - previousValue, clearValidationError, customValidation, errorIdString, @@ -131,6 +129,15 @@ const ValidatedTextInput = ( { ] ); + useEffect( () => { + if ( isPristine ) { + return; + } + validateInput( value === '' ); + // Purposely skip running this unless any of the revalidateDependencies change. Also don't run it on mount (isPristine). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ ...revalidateDependencies ] ); + /** * Handle browser autofill / changes via data store. * @@ -170,7 +177,7 @@ const ValidatedTextInput = ( { // if validateOnMount is false, only validate input if focusOnMount is also false if ( validateOnMount || ! focusOnMount ) { - validateInput( true ); + validateInput(); } setIsPristine( false ); @@ -220,12 +227,23 @@ const ValidatedTextInput = ( { hideValidationError( errorIdString ); // Revalidate on user input so we know if the value is valid. - validateInput( true ); + validateInput(); // Push the changes up to the parent component if the value is valid. onChange( val ); } } onBlur={ () => { + // Don't validate on blur if the value is unchanged and the field is not required. + const inputObject = inputRef.current || null; + + if ( + inputObject && + inputObject.value === previousValue && + ! inputObject.required && + inputObject.value !== '' + ) { + return; + } validateInput( false ); } } ariaDescribedBy={ describedBy }