diff --git a/assets/js/base/components/state-input/StateInputProps.ts b/assets/js/base/components/state-input/StateInputProps.ts index bf66f2cfba4..341dbe34c27 100644 --- a/assets/js/base/components/state-input/StateInputProps.ts +++ b/assets/js/base/components/state-input/StateInputProps.ts @@ -8,6 +8,7 @@ export interface StateInputProps { onChange: ( value: string ) => void; required?: boolean; errorMessage?: string; + errorId?: string; } export type StateInputWithStatesProps = StateInputProps & { diff --git a/assets/js/base/components/state-input/state-input.tsx b/assets/js/base/components/state-input/state-input.tsx index 15d8b6f61b9..7bb069d5999 100644 --- a/assets/js/base/components/state-input/state-input.tsx +++ b/assets/js/base/components/state-input/state-input.tsx @@ -36,6 +36,7 @@ const StateInput = ( { autoComplete = 'off', value = '', required = false, + errorId = '', }: StateInputWithStatesProps ): JSX.Element => { const countryStates = states[ country ]; const options = useMemo( @@ -102,6 +103,7 @@ const StateInput = ( { 'Please select a state.', 'woo-gutenberg-products-block' ) } + errorId={ errorId } required={ required } autoComplete={ autoComplete } /> diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/attributes.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/attributes.tsx index 42dfb4696fa..9c21e4de517 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/attributes.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/attributes.tsx @@ -44,4 +44,8 @@ export default { remove: true, }, }, + shippingCostRequiresAddress: { + type: 'boolean', + default: false, + }, }; diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.json b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.json index b080adafec9..b3255985c2d 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.json +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.json @@ -19,6 +19,10 @@ "remove": true, "move": true } + }, + "shippingCostRequiresAddress": { + "type": "boolean", + "default": false } }, "parent": [ "woocommerce/checkout-fields-block" ], diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx index e59b5e91725..d6bbbfac0de 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx @@ -17,6 +17,7 @@ import './style.scss'; import { RatePrice, getLocalPickupPrices, getShippingPrices } from './shared'; import type { minMaxPrices } from './shared'; import { defaultLocalPickupText, defaultShippingText } from './constants'; +import { shippingAddressHasValidationErrors } from '../../../../data/cart/utils'; const LocalPickupSelector = ( { checked, @@ -71,15 +72,19 @@ const ShippingSelector = ( { showPrice, showIcon, toggleText, + shippingCostRequiresAddress = false, }: { checked: string; rate: minMaxPrices; showPrice: boolean; showIcon: boolean; + shippingCostRequiresAddress: boolean; toggleText: string; } ) => { + const rateShouldBeHidden = + shippingCostRequiresAddress && shippingAddressHasValidationErrors(); const Price = - rate.min === undefined ? ( + rate.min === undefined || rateShouldBeHidden ? ( { __( 'calculated with an address', @@ -122,11 +127,13 @@ const Block = ( { showIcon, localPickupText, shippingText, + shippingCostRequiresAddress = false, }: { checked: string; onChange: ( value: string ) => void; showPrice: boolean; showIcon: boolean; + shippingCostRequiresAddress: boolean; localPickupText: string; shippingText: string; } ): JSX.Element | null => { @@ -145,6 +152,7 @@ const Block = ( { rate={ getShippingPrices( shippingRates[ 0 ]?.shipping_rates ) } showPrice={ showPrice } showIcon={ showIcon } + shippingCostRequiresAddress={ shippingCostRequiresAddress } toggleText={ shippingText || defaultShippingText } /> ) => void; } ): JSX.Element | null => { + const toggleAttribute = ( key: keyof Attributes ): void => { + const newAttributes = {} as Partial< Attributes >; + newAttributes[ key ] = ! ( attributes[ key ] as boolean ); + setAttributes( newAttributes ); + }; + const { setPrefersCollection } = useDispatch( CHECKOUT_STORE_KEY ); const { prefersCollection } = useSelect( ( select ) => { const checkoutStore = select( CHECKOUT_STORE_KEY ); @@ -210,6 +221,30 @@ export const Edit = ( { ) } > + + { + updateAttributeInSiblingBlock( + clientId, + 'shippingCostRequiresAddress', + selected, + 'woocommerce/checkout-shipping-methods-block' + ); + + toggleAttribute( 'shippingCostRequiresAddress' ); + } } + /> + diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/frontend.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/frontend.tsx index 74ae2cc83f8..86f5154a87b 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/frontend.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/frontend.tsx @@ -25,10 +25,12 @@ const FrontendBlock = ( { showIcon, shippingText, localPickupText, + shippingCostRequiresAddress, }: { title: string; description: string; showStepNumber: boolean; + shippingCostRequiresAddress: boolean; children: JSX.Element; className?: string; showPrice: boolean; @@ -90,6 +92,7 @@ const FrontendBlock = ( { showIcon={ showIcon } localPickupText={ localPickupText } shippingText={ shippingText } + shippingCostRequiresAddress={ shippingCostRequiresAddress } /> { children } diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/attributes.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/attributes.tsx index b36f320660d..17cb4f6bf2f 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/attributes.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/attributes.tsx @@ -24,4 +24,8 @@ export default { remove: true, }, }, + shippingCostRequiresAddress: { + type: 'boolean', + default: false, + }, }; diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.json b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.json index df53c0117c8..b4c5dc5192f 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.json +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.json @@ -19,6 +19,10 @@ "remove": true, "move": true } + }, + "shippingCostRequiresAddress": { + "type": "boolean", + "default": false } }, "parent": [ "woocommerce/checkout-fields-block" ], diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx index fb241284d8a..89e67fb766e 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx @@ -21,11 +21,14 @@ import type { CartShippingPackageShippingRate, } from '@woocommerce/types'; import type { ReactElement } from 'react'; +import { useSelect } from '@wordpress/data'; +import { CART_STORE_KEY } from '@woocommerce/block-data'; /** * Internal dependencies */ import './style.scss'; +import { shippingAddressHasValidationErrors } from '../../../../data/cart/utils'; /** * Renders a shipping rate control option. @@ -52,7 +55,10 @@ const renderShippingRatesControlOption = ( }; }; -const Block = ( { noShippingPlaceholder = null } ): ReactElement | null => { +const Block = ( { + noShippingPlaceholder = null, + shippingCostRequiresAddress = false, +} ): ReactElement | null => { const { isEditor } = useEditorContext(); const { @@ -63,6 +69,10 @@ const Block = ( { noShippingPlaceholder = null } ): ReactElement | null => { isCollectable, } = useShippingData(); + const shippingAddressPushed = useSelect( ( select ) => { + return select( CART_STORE_KEY ).getFullShippingAddressPushed(); + } ); + const filteredShippingRates = isCollectable ? shippingRates.map( ( shippingRatesPackage ) => { return { @@ -81,13 +91,15 @@ const Block = ( { noShippingPlaceholder = null } ): ReactElement | null => { return null; } + const shippingAddressIsComplete = ! shippingAddressHasValidationErrors(); + const shippingRatesPackageCount = getShippingRatesPackageCount( shippingRates ); if ( - ! isEditor && - ! hasCalculatedShipping && - ! shippingRatesPackageCount + ( ! hasCalculatedShipping && ! shippingRatesPackageCount ) || + ( shippingCostRequiresAddress && + ( ! shippingAddressPushed || ! shippingAddressIsComplete ) ) ) { return (

diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/edit.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/edit.tsx index ac1d3bbd9bf..0edb15d412a 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/edit.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/edit.tsx @@ -4,12 +4,14 @@ import classnames from 'classnames'; import { __ } from '@wordpress/i18n'; import { InspectorControls, useBlockProps } from '@wordpress/block-editor'; -import { PanelBody, ExternalLink } from '@wordpress/components'; +import { PanelBody, ExternalLink, ToggleControl } from '@wordpress/components'; import { ADMIN_URL, getSetting } from '@woocommerce/settings'; import ExternalLinkCard from '@woocommerce/editor-components/external-link-card'; import { innerBlockAreas } from '@woocommerce/blocks-checkout'; import { useCheckoutAddress } from '@woocommerce/base-context/hooks'; import Noninteractive from '@woocommerce/base-components/noninteractive'; +import { Attributes } from '@woocommerce/blocks/checkout/types'; +import { updateAttributeInSiblingBlock } from '@woocommerce/utils'; /** * Internal dependencies @@ -32,12 +34,15 @@ type shippingAdminLink = { export const Edit = ( { attributes, setAttributes, + clientId, }: { + clientId: string; attributes: { title: string; description: string; showStepNumber: boolean; className: string; + shippingCostRequiresAddress: boolean; }; setAttributes: ( attributes: Record< string, unknown > ) => void; } ): JSX.Element | null => { @@ -54,6 +59,12 @@ export const Edit = ( { return null; } + const toggleAttribute = ( key: keyof Attributes ): void => { + const newAttributes = {} as Partial< Attributes >; + newAttributes[ key ] = ! ( attributes[ key ] as boolean ); + setAttributes( newAttributes ); + }; + return ( + + { + updateAttributeInSiblingBlock( + clientId, + 'shippingCostRequiresAddress', + selected, + 'woocommerce/checkout-shipping-method-block' + ); + toggleAttribute( 'shippingCostRequiresAddress' ); + } } + /> + { globalShippingMethods.length > 0 && ( - } /> + } + shippingCostRequiresAddress={ + attributes.shippingCostRequiresAddress + } + /> diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/frontend.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/frontend.tsx index dd67d39fba5..9fced3fa2e7 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/frontend.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/frontend.tsx @@ -20,6 +20,7 @@ const FrontendBlock = ( { showStepNumber, children, className, + shippingCostRequiresAddress = false, }: { title: string; description: string; @@ -31,6 +32,7 @@ const FrontendBlock = ( { showStepNumber: boolean; children: JSX.Element; className?: string; + shippingCostRequiresAddress: boolean; } ) => { const checkoutIsProcessing = useSelect( ( select ) => select( CHECKOUT_STORE_KEY ).isProcessing() @@ -53,7 +55,9 @@ const FrontendBlock = ( { description={ description } showStepNumber={ showStepNumber } > - + { children } ); diff --git a/assets/js/data/cart/action-types.ts b/assets/js/data/cart/action-types.ts index ce5ffa64032..6ee20f94551 100644 --- a/assets/js/data/cart/action-types.ts +++ b/assets/js/data/cart/action-types.ts @@ -1,5 +1,6 @@ export const ACTION_TYPES = { SET_CART_DATA: 'SET_CART_DATA', + SET_FULL_SHIPPING_ADDRESS_PUSHED: 'SET_FULL_SHIPPING_ADDRESS_PUSHED', SET_ERROR_DATA: 'SET_ERROR_DATA', APPLYING_COUPON: 'APPLYING_COUPON', REMOVING_COUPON: 'REMOVING_COUPON', diff --git a/assets/js/data/cart/actions.ts b/assets/js/data/cart/actions.ts index c7cc97261a1..393680c7cbf 100644 --- a/assets/js/data/cart/actions.ts +++ b/assets/js/data/cart/actions.ts @@ -417,7 +417,14 @@ export const selectShippingRate = }, cache: 'no-store', } ); - dispatch.receiveCart( response ); + // Remove shipping and billing address from the response, so we don't overwrite what the shopper is + // entering in the form if rates suddenly appear mid-edit. + const { + shipping_address: shippingAddress, + billing_address: billingAddress, + ...rest + } = response; + dispatch.receiveCart( rest ); return response as CartResponse; } catch ( error ) { dispatch.receiveError( error ); @@ -474,6 +481,13 @@ export const updateCustomerData = } }; +export const setFullShippingAddressPushed = ( + fullShippingAddressPushed: boolean +) => ( { + type: types.SET_FULL_SHIPPING_ADDRESS_PUSHED, + fullShippingAddressPushed, +} ); + type Actions = | typeof addItemToCart | typeof applyCoupon @@ -494,6 +508,7 @@ 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 f7f1d65fdd2..f0411989fb8 100644 --- a/assets/js/data/cart/default-state.ts +++ b/assets/js/data/cart/default-state.ts @@ -98,6 +98,7 @@ 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 8410a3a0a70..2fd720de047 100644 --- a/assets/js/data/cart/push-changes.ts +++ b/assets/js/data/cart/push-changes.ts @@ -17,6 +17,7 @@ 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; @@ -192,6 +193,11 @@ 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 67a9dcfe471..3ab809ec8c6 100644 --- a/assets/js/data/cart/reducers.ts +++ b/assets/js/data/cart/reducers.ts @@ -48,6 +48,15 @@ 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 71b4c386d18..080b021d0db 100644 --- a/assets/js/data/cart/resolvers.ts +++ b/assets/js/data/cart/resolvers.ts @@ -9,6 +9,7 @@ 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. @@ -27,6 +28,10 @@ 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 f4644a4b2d2..59041eb804e 100644 --- a/assets/js/data/cart/selectors.ts +++ b/assets/js/data/cart/selectors.ts @@ -222,3 +222,10 @@ 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/data/cart/test/push-changes.ts b/assets/js/data/cart/test/push-changes.ts index fd4ee9b827f..8bf726c2aa5 100644 --- a/assets/js/data/cart/test/push-changes.ts +++ b/assets/js/data/cart/test/push-changes.ts @@ -64,6 +64,7 @@ jest.mock( '../utils', () => ( { // need to update payment methods, they are not relevant to the tests in this file. jest.mock( '../update-payment-methods', () => ( { debouncedUpdatePaymentMethods: jest.fn(), + updatePaymentMethods: jest.fn(), } ) ); describe( 'pushChanges', () => { diff --git a/assets/js/data/cart/utils.ts b/assets/js/data/cart/utils.ts index 1130198a750..19afead5670 100644 --- a/assets/js/data/cart/utils.ts +++ b/assets/js/data/cart/utils.ts @@ -3,9 +3,38 @@ */ import { camelCase, mapKeys } from 'lodash'; import { Cart, CartResponse } from '@woocommerce/types'; +import { select } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_KEY as VALIDATION_STORE_KEY } from '../validation/constants'; export const mapCartResponseToCart = ( responseCart: CartResponse ): Cart => { return mapKeys( responseCart, ( _, key ) => camelCase( key ) ) as unknown as Cart; }; + +export const shippingAddressHasValidationErrors = () => { + const validationStore = select( VALIDATION_STORE_KEY ); + // Check if the shipping address form has validation errors - if not then we know the full required + // address has been pushed to the server. + const stateValidationErrors = + validationStore.getValidationError( 'shipping_state' ); + const address1ValidationErrors = + validationStore.getValidationError( 'shipping_address_1' ); + const countryValidationErrors = + validationStore.getValidationError( 'shipping_country' ); + const postcodeValidationErrors = + validationStore.getValidationError( 'shipping_postcode' ); + const cityValidationErrors = + validationStore.getValidationError( 'shipping_city' ); + return [ + cityValidationErrors, + stateValidationErrors, + address1ValidationErrors, + countryValidationErrors, + postcodeValidationErrors, + ].some( ( entry ) => typeof entry !== 'undefined' ); +}; diff --git a/assets/js/types/type-defs/cart.ts b/assets/js/types/type-defs/cart.ts index 3765f2698c6..7fd2761fbf5 100644 --- a/assets/js/types/type-defs/cart.ts +++ b/assets/js/types/type-defs/cart.ts @@ -209,6 +209,8 @@ 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/assets/js/utils/attributes.ts b/assets/js/utils/attributes.ts index 3815d5a3aec..ec0b871ff0d 100644 --- a/assets/js/utils/attributes.ts +++ b/assets/js/utils/attributes.ts @@ -3,6 +3,7 @@ */ import { getSetting } from '@woocommerce/settings'; import { AttributeObject, AttributeSetting } from '@woocommerce/types'; +import { dispatch, select } from '@wordpress/data'; const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] ); @@ -82,3 +83,35 @@ export const getTaxonomyFromAttributeId = ( attributeId: number ) => { const attribute = getAttributeFromID( attributeId ); return attribute ? attribute.taxonomy : null; }; + +/** + * Updates an attribute in a sibling block. Useful if two settings control the same attribute, but you don't want to + * have this attribute exist on a parent block. + */ +export const updateAttributeInSiblingBlock = ( + clientId: string, + attribute: string, + newValue: unknown, + siblingBlockName: string +) => { + const store = select( 'core/block-editor' ); + const actions = dispatch( 'core/block-editor' ); + const parentBlocks = store.getBlockParents( clientId ); + + let shippingMethodsBlockClientId = ''; + + // Loop through parent block's children until we find woocommerce/checkout-shipping-methods-block. + // Also set this attribute in the woocommerce/checkout-shipping-methods-block. + parentBlocks.forEach( ( parent ) => { + const childBlock = store + .getBlock( parent ) + .innerBlocks.find( ( child ) => child.name === siblingBlockName ); + if ( ! childBlock ) { + return; + } + shippingMethodsBlockClientId = childBlock.clientId; + } ); + actions.updateBlockAttributes( shippingMethodsBlockClientId, { + [ attribute ]: newValue, + } ); +}; diff --git a/src/Shipping/ShippingController.php b/src/Shipping/ShippingController.php index ba4cc3ad623..d0bdae7e910 100644 --- a/src/Shipping/ShippingController.php +++ b/src/Shipping/ShippingController.php @@ -3,6 +3,7 @@ use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi; use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry; +use Automattic\WooCommerce\Blocks\Tests\BlockTypes\Cart; use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils; use Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils; use Automattic\WooCommerce\Utilities\ArrayUtil; @@ -52,6 +53,7 @@ function() { ); } $this->asset_data_registry->add( 'collectableMethodIds', array( 'Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils', 'get_local_pickup_method_ids' ), true ); + $this->asset_data_registry->add( 'shippingCostRequiresAddress', get_option( 'woocommerce_shipping_cost_requires_address', false ) === 'yes' ); add_action( 'rest_api_init', [ $this, 'register_settings' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'admin_scripts' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'hydrate_client_settings' ] ); @@ -62,6 +64,38 @@ function() { add_filter( 'pre_update_option_woocommerce_pickup_location_settings', array( $this, 'flush_cache' ) ); add_filter( 'pre_update_option_pickup_location_pickup_locations', array( $this, 'flush_cache' ) ); add_filter( 'woocommerce_shipping_settings', array( $this, 'remove_shipping_settings' ) ); + add_filter( 'wc_shipping_enabled', array( $this, 'force_shipping_enabled' ), 100, 1 ); + + // This is required to short circuit `show_shipping` from class-wc-cart.php - without it, that function + // returns based on the option's value in the DB and we can't override it any other way. + add_filter( 'option_woocommerce_shipping_cost_requires_address', array( $this, 'override_cost_requires_address_option' ) ); + } + + /** + * Overrides the option to force shipping calculations NOT to wait until an address is entered, but only if the + * Checkout page contains the Checkout Block. + * + * @param boolean $value Whether shipping cost calculation requires address to be entered. + * @return boolean Whether shipping cost calculation should require an address to be entered before calculating. + */ + public function override_cost_requires_address_option( $value ) { + if ( CartCheckoutUtils::is_checkout_block_default() ) { + return 'no'; + } + return $value; + } + + /** + * Force shipping to be enabled if the Checkout block is in use on the Checkout page. + * + * @param boolean $enabled Whether shipping is currently enabled. + * @return boolean Whether shipping should continue to be enabled/disabled. + */ + public function force_shipping_enabled( $enabled ) { + if ( CartCheckoutUtils::is_checkout_block_default() ) { + return true; + } + return $enabled; } /** diff --git a/tests/e2e/specs/backend/__fixtures__/checkout.fixture.json b/tests/e2e/specs/backend/__fixtures__/checkout.fixture.json index b0be2edc810..2d9631d676c 100644 --- a/tests/e2e/specs/backend/__fixtures__/checkout.fixture.json +++ b/tests/e2e/specs/backend/__fixtures__/checkout.fixture.json @@ -1 +1 @@ -{"title":"Checkout Block","pageContent":"\n

\n
\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n
\n\n\n\n
\n
\n
\n
\n"} +{"title":"Checkout Block","pageContent":"\n
\n
\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n
\n\n\n\n
\n
\n
\n
\n"} diff --git a/tests/e2e/specs/backend/checkout.test.js b/tests/e2e/specs/backend/checkout.test.js index bc905565682..cf661d8e429 100644 --- a/tests/e2e/specs/backend/checkout.test.js +++ b/tests/e2e/specs/backend/checkout.test.js @@ -22,6 +22,7 @@ import { openWidgetEditor, closeModalIfExists, } from '../../utils.js'; +import { merchant as merchantUtils } from '../../../utils/merchant'; const block = { name: 'Checkout', @@ -64,6 +65,79 @@ describe( `${ block.name } Block`, () => { await selectBlockByName( block.slug ); } ); + it( 'can toggle "hide shipping costs until an address is entered"', async () => { + await selectBlockByName( + 'woocommerce/checkout-shipping-methods-block' + ); + const toggleLabel = await findLabelWithText( + 'Hide shipping costs until an address is entered' + ); + await toggleLabel.click(); + const shippingOptionsRequireAddressText = await page.$x( + '//p[contains(text(), "Shipping options will be displayed here after entering your full shipping address.")]' + ); + await expect( shippingOptionsRequireAddressText ).toHaveLength( + 1 + ); + + await toggleLabel.click(); + await expect( page ).toMatchElement( + '.wc-block-components-shipping-rates-control' + ); + } ); + + it( 'toggles the same setting in shipping method and shipping methods blocks', async () => { + await merchantUtils.goToLocalPickupSettingsPage(); + await merchantUtils.enableLocalPickup(); + await merchantUtils.saveLocalPickupSettingsPageWithRefresh(); + + await visitBlockPage( `${ block.name } Block` ); + await expect( page ).toClick( + '.wc-block-checkout__shipping-method button', + { text: 'Shipping' } + ); + await openDocumentSettingsSidebar(); + const toggleLabel = await findLabelWithText( + 'Hide shipping costs until an address is entered' + ); + await toggleLabel.click(); + const [ label ] = await page.$x( + '//label[contains(., "Hide shipping costs until an address is entered")]' + ); + const shippingMethodForValue = await page.evaluate( + ( passedLabel ) => passedLabel.getAttribute( 'for' ), + label + ); + const shippingMethodSettingIsChecked = await page.evaluate( + ( passedShippingMethodForValue ) => + document.getElementById( passedShippingMethodForValue ) + .checked, + shippingMethodForValue + ); + await expect( shippingMethodSettingIsChecked ).toBe( true ); + await selectBlockByName( + 'woocommerce/checkout-shipping-methods-block' + ); + const [ shippingMethodsLabel ] = await page.$x( + '//label[contains(., "Hide shipping costs until an address is entered")]' + ); + const shippingMethodsLabelForValue = await page.evaluate( + ( passedShippingMethodsLabel ) => + passedShippingMethodsLabel.getAttribute( 'for' ), + shippingMethodsLabel + ); + const shippingMethodLabelIsChecked = await page.evaluate( + ( passedShippingMethodsLabelForValue ) => + document.getElementById( + passedShippingMethodsLabelForValue + ).checked, + shippingMethodsLabelForValue + ); + expect( shippingMethodSettingIsChecked ).toBe( + shippingMethodLabelIsChecked + ); + } ); + it( 'can enable dark mode inputs', async () => { const toggleLabel = await findLabelWithText( 'Dark mode inputs' diff --git a/tests/e2e/specs/merchant/checkout-terms.test.js b/tests/e2e/specs/merchant/checkout-terms.test.js index c27733f85cc..70824729214 100644 --- a/tests/e2e/specs/merchant/checkout-terms.test.js +++ b/tests/e2e/specs/merchant/checkout-terms.test.js @@ -83,6 +83,11 @@ describe( 'Merchant → Checkout → Can adjust T&S and Privacy Policy options', await shopper.block.goToCheckout(); await shopper.block.fillBillingDetails( BILLING_DETAILS ); + // Wait for the "Place Order" button to avoid flakey tests. + await page.waitForSelector( + '.wc-block-components-checkout-place-order-button:not([disabled])' + ); + // Placing an order now, must lead to an error. await page.click( '.wc-block-components-checkout-place-order-button' ); diff --git a/tests/e2e/specs/merchant/local-pickup.test.ts b/tests/e2e/specs/merchant/local-pickup.test.ts index 196273cf6b5..cc845c53789 100644 --- a/tests/e2e/specs/merchant/local-pickup.test.ts +++ b/tests/e2e/specs/merchant/local-pickup.test.ts @@ -6,51 +6,14 @@ import { findLabelWithText } from '@woocommerce/blocks-test-utils'; import WooCommerceRestApi from '@woocommerce/woocommerce-rest-api'; import { default as axios } from 'axios'; -const goToSettingsPage = async () => { - await visitAdminPage( - 'admin.php', - 'page=wc-settings&tab=shipping§ion=pickup_location' - ); - await page.waitForSelector( - '#wc-shipping-method-pickup-location-settings-container' - ); -}; - -const saveSettingsPageWithRefresh = async () => { - await expect( page ).toClick( 'button', { - text: 'Save changes', - } ); - await expect( page ).toMatchElement( '.components-snackbar__content', { - text: 'Local Pickup settings have been saved.', - } ); - await goToSettingsPage(); -}; +/** + * Internal dependencies + */ +import { merchant } from '../../../utils'; const setDefaults = async () => { - const enabledLabel = await findLabelWithText( 'Enable local pickup' ); - const enabledChecked = await page.$eval( - '#inspector-checkbox-control-1', - ( el ) => ( el as HTMLInputElement ).checked - ); - if ( enabledChecked ) { - await enabledLabel.click(); - } - - await expect( page ).toFill( - 'input[name="local_pickup_title"]', - 'Local Pickup' - ); - - const costLabel = await findLabelWithText( - 'Add a price for customers who choose local pickup' - ); - const costChecked = await page.$eval( - '#inspector-checkbox-control-1', - ( el ) => ( el as HTMLInputElement ).checked - ); - if ( costChecked ) { - await costLabel.click(); - } + await merchant.enableLocalPickup(); + await merchant.removeCostForLocalPickup(); }; const clearLocations = async () => { @@ -113,23 +76,23 @@ const setCartCheckoutPages = async ( { describe( `Local Pickup Settings`, () => { beforeAll( async () => { await switchUserToAdmin(); - await goToSettingsPage(); + await merchant.goToLocalPickupSettingsPage(); await setDefaults(); await clearLocations(); - await saveSettingsPageWithRefresh(); + await merchant.saveLocalPickupSettingsPageWithRefresh(); } ); afterAll( async () => { await switchUserToAdmin(); - await goToSettingsPage(); + await merchant.goToLocalPickupSettingsPage(); await setDefaults(); await clearLocations(); - await saveSettingsPageWithRefresh(); + await merchant.saveLocalPickupSettingsPageWithRefresh(); } ); beforeEach( async () => { await switchUserToAdmin(); - await goToSettingsPage(); + await merchant.goToLocalPickupSettingsPage(); } ); it( 'renders without crashing', async () => { @@ -195,7 +158,7 @@ describe( `Local Pickup Settings`, () => { 'Enable local pickup' ); await toggleLabel.click(); - await saveSettingsPageWithRefresh(); + await merchant.saveLocalPickupSettingsPageWithRefresh(); expect( await page.$eval( @@ -211,7 +174,7 @@ describe( `Local Pickup Settings`, () => { 'Local Pickup Test' ); - await saveSettingsPageWithRefresh(); + await merchant.saveLocalPickupSettingsPageWithRefresh(); expect( await page.$eval( @@ -251,7 +214,7 @@ describe( `Local Pickup Settings`, () => { 'none' ); - await saveSettingsPageWithRefresh(); + await merchant.saveLocalPickupSettingsPageWithRefresh(); const refreshChecked = await page.$eval( '#inspector-checkbox-control-1', @@ -311,7 +274,7 @@ describe( `Local Pickup Settings`, () => { text: 'Done', } ); - await saveSettingsPageWithRefresh(); + await merchant.saveLocalPickupSettingsPageWithRefresh(); await expect( page ).toMatchElement( '.pickup-locations tbody tr td', @@ -340,7 +303,7 @@ describe( `Local Pickup Settings`, () => { text: 'Delete location', } ); - await saveSettingsPageWithRefresh(); + await merchant.saveLocalPickupSettingsPageWithRefresh(); await expect( page ).not.toMatchElement( '.pickup-locations tbody tr td', { diff --git a/tests/e2e/specs/shopper/cart-checkout/checkout.test.js b/tests/e2e/specs/shopper/cart-checkout/checkout.test.js index 0a3eb12f26f..f1854b25137 100644 --- a/tests/e2e/specs/shopper/cart-checkout/checkout.test.js +++ b/tests/e2e/specs/shopper/cart-checkout/checkout.test.js @@ -28,7 +28,7 @@ import { SIMPLE_PHYSICAL_PRODUCT_NAME, SIMPLE_VIRTUAL_PRODUCT_NAME, } from '../../../../utils'; - +import { merchant as merchantUtils } from '../../../../utils/merchant'; import { createCoupon } from '../../../utils'; let coupon; @@ -209,6 +209,44 @@ describe( 'Shopper → Checkout', () => { const NORMAL_SHIPPING_NAME = 'Normal Shipping'; const NORMAL_SHIPPING_PRICE = '$20.00'; + afterAll( async () => { + await merchant.login(); + await visitBlockPage( 'Checkout Block' ); + await openDocumentSettingsSidebar(); + await switchBlockInspectorTabWhenGutenbergIsInstalled( 'Settings' ); + await selectBlockByName( + 'woocommerce/checkout-shipping-methods-block' + ); + const [ label ] = await page.$x( + '//label[contains(., "Hide shipping costs until an address is entered")]' + ); + const shippingMethodForValue = await page.evaluate( + ( passedLabel ) => passedLabel.getAttribute( 'for' ), + label + ); + let shippingMethodSettingIsChecked = await page.evaluate( + ( passedShippingMethodForValue ) => + document.getElementById( passedShippingMethodForValue ) + .checked, + shippingMethodForValue + ); + if ( ! shippingMethodSettingIsChecked ) { + await setCheckbox( + await getToggleIdByLabel( + 'Hide shipping costs until an address is entered' + ) + ); + } + shippingMethodSettingIsChecked = await page.evaluate( + ( passedShippingMethodForValue ) => + document.getElementById( passedShippingMethodForValue ) + .checked, + shippingMethodForValue + ); + + await merchantUtils.disableLocalPickup(); + } ); + it( 'User can choose free shipping', async () => { await shopper.block.goToShop(); await shopper.addToCartFromShopPage( SIMPLE_PHYSICAL_PRODUCT_NAME ); @@ -238,11 +276,175 @@ describe( 'Shopper → Checkout', () => { await expect( page ).toMatch( 'Order received' ); await expect( page ).toMatch( NORMAL_SHIPPING_NAME ); } ); + + it( 'User sees the correct shipping options based on block settings', async () => { + await preventCompatibilityNotice(); + await merchant.login(); + await visitBlockPage( 'Checkout Block' ); + await openDocumentSettingsSidebar(); + await switchBlockInspectorTabWhenGutenbergIsInstalled( 'Settings' ); + await selectBlockByName( + 'woocommerce/checkout-shipping-methods-block' + ); + + const [ label ] = await page.$x( + '//label[contains(., "Hide shipping costs until an address is entered")]' + ); + const shippingMethodForValue = await page.evaluate( + ( passedLabel ) => passedLabel.getAttribute( 'for' ), + label + ); + let shippingMethodSettingIsChecked = await page.evaluate( + ( passedShippingMethodForValue ) => + document.getElementById( passedShippingMethodForValue ) + .checked, + shippingMethodForValue + ); + if ( ! shippingMethodSettingIsChecked ) { + await setCheckbox( + await getToggleIdByLabel( + 'Hide shipping costs until an address is entered' + ) + ); + } + shippingMethodSettingIsChecked = await page.evaluate( + ( passedShippingMethodForValue ) => + document.getElementById( passedShippingMethodForValue ) + .checked, + shippingMethodForValue + ); + await expect( shippingMethodSettingIsChecked ).toBe( true ); + await saveOrPublish(); + await shopper.block.emptyCart(); + // Log out to have a fresh empty cart. + await shopper.logout(); + await shopper.block.goToShop(); + await shopper.addToCartFromShopPage( SIMPLE_PHYSICAL_PRODUCT_NAME ); + await shopper.block.goToCheckout(); + // Expect no shipping options to be shown, but with a friendly message. + const shippingOptionsRequireAddressText = await page.$x( + '//p[contains(text(), "Shipping options will be displayed here after entering your full shipping address.")]' + ); + expect( shippingOptionsRequireAddressText ).toHaveLength( 1 ); + + // Enter the address and expect shipping options to be shown. + await shopper.block.fillInCheckoutWithTestData(); + await expect( page ).toMatchElement( + '.wc-block-components-shipping-rates-control' + ); + + // This sequence will reset the checkout form. + await shopper.login(); + await shopper.logout(); + + await preventCompatibilityNotice(); + await merchant.login(); + await visitBlockPage( 'Checkout Block' ); + await openDocumentSettingsSidebar(); + await switchBlockInspectorTabWhenGutenbergIsInstalled( 'Settings' ); + await selectBlockByName( + 'woocommerce/checkout-shipping-methods-block' + ); + + await unsetCheckbox( + await getToggleIdByLabel( + 'Hide shipping costs until an address is entered' + ) + ); + await saveOrPublish(); + await shopper.block.emptyCart(); + + await shopper.block.goToShop(); + await shopper.addToCartFromShopPage( SIMPLE_PHYSICAL_PRODUCT_NAME ); + await shopper.block.goToCheckout(); + + // Expect the shipping options to be displayed without entering an address. + await expect( page ).toMatchElement( + '.wc-block-components-shipping-rates-control' + ); + } ); + + it( 'User does not see shipping rates until full address is entered', async () => { + await preventCompatibilityNotice(); + await merchant.login(); + + await merchantUtils.enableLocalPickup(); + await merchantUtils.addLocalPickupLocation(); + await visitBlockPage( 'Checkout Block' ); + await openDocumentSettingsSidebar(); + await switchBlockInspectorTabWhenGutenbergIsInstalled( 'Settings' ); + await selectBlockByName( + 'woocommerce/checkout-shipping-methods-block' + ); + + await setCheckbox( + await getToggleIdByLabel( + 'Hide shipping costs until an address is entered' + ) + ); + await saveOrPublish(); + await shopper.block.emptyCart(); + // Log out to have a fresh empty cart. + await shopper.logout(); + await shopper.block.goToShop(); + await shopper.addToCartFromShopPage( SIMPLE_PHYSICAL_PRODUCT_NAME ); + await shopper.block.goToCheckout(); + + // Expect no shipping options to be shown, but with a friendly message. + const shippingOptionsRequireAddressText = await page.$x( + '//p[contains(text(), "Shipping options will be displayed here after entering your full shipping address.")]' + ); + + expect( shippingOptionsRequireAddressText ).toHaveLength( 1 ); + + await expect( page ).toClick( + '.wc-block-checkout__shipping-method button', + { text: 'Shipping' } + ); + + // Enter the address but not city and expect shipping options not to be shown. + await shopper.block.fillInCheckoutWithTestData( { city: '' } ); + + await expect( page ).not.toMatchElement( + '.wc-block-components-shipping-rates-control' + ); + + // This sequence will reset the checkout form. + await shopper.login(); + await shopper.logout(); + + await preventCompatibilityNotice(); + await merchant.login(); + await visitBlockPage( 'Checkout Block' ); + await openDocumentSettingsSidebar(); + await switchBlockInspectorTabWhenGutenbergIsInstalled( 'Settings' ); + await selectBlockByName( + 'woocommerce/checkout-shipping-methods-block' + ); + + await unsetCheckbox( + await getToggleIdByLabel( + 'Hide shipping costs until an address is entered' + ) + ); + await saveOrPublish(); + await shopper.block.emptyCart(); + + await shopper.block.goToShop(); + await shopper.addToCartFromShopPage( SIMPLE_PHYSICAL_PRODUCT_NAME ); + await shopper.block.goToCheckout(); + + // Expect the shipping options to be displayed without entering an address. + await expect( page ).toMatchElement( + '.wc-block-components-shipping-rates-control' + ); + } ); } ); describe( 'Coupons', () => { beforeAll( async () => { coupon = await createCoupon( { usageLimit: 1 } ); + await shopper.logout(); await shopper.login(); } ); diff --git a/tests/utils/merchant.js b/tests/utils/merchant.js deleted file mode 100644 index 21b86cfaeda..00000000000 --- a/tests/utils/merchant.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * External dependencies - */ -import { merchant as wcMerchant } from '@woocommerce/e2e-utils'; -import { visitAdminPage } from '@wordpress/e2e-test-utils'; - -export const merchant = { - ...wcMerchant, - changeLanguage: async ( language ) => { - await visitAdminPage( 'options-general.php' ); - await page.select( 'select#WPLANG', language ); - await page.click( 'input[type="submit"]' ); - await page.waitForSelector( '#setting-error-settings_updated', { - visible: true, - } ); - }, -}; diff --git a/tests/utils/merchant.ts b/tests/utils/merchant.ts new file mode 100644 index 00000000000..344170a69a4 --- /dev/null +++ b/tests/utils/merchant.ts @@ -0,0 +1,110 @@ +/** + * External dependencies + */ +import { merchant as wcMerchant } from '@woocommerce/e2e-utils'; +import { visitAdminPage } from '@wordpress/e2e-test-utils'; +import { findLabelWithText } from '@woocommerce/blocks-test-utils'; + +export const merchant = { + ...wcMerchant, + changeLanguage: async ( language ) => { + await visitAdminPage( 'options-general.php' ); + await page.select( 'select#WPLANG', language ); + await page.click( 'input[type="submit"]' ); + await page.waitForSelector( '#setting-error-settings_updated', { + visible: true, + } ); + }, + goToLocalPickupSettingsPage: async () => { + await visitAdminPage( + 'admin.php', + 'page=wc-settings&tab=shipping§ion=pickup_location' + ); + await page.waitForSelector( + '#wc-shipping-method-pickup-location-settings-container' + ); + }, + saveLocalPickupSettingsPageWithRefresh: async () => { + await expect( page ).toClick( 'button', { + text: 'Save changes', + } ); + await expect( page ).toMatchElement( '.components-snackbar__content', { + text: 'Local Pickup settings have been saved.', + } ); + await merchant.goToLocalPickupSettingsPage(); + }, + enableLocalPickup: async () => { + await merchant.goToLocalPickupSettingsPage(); + const enabledLabel = await findLabelWithText( 'Enable local pickup' ); + const enabledChecked = await page.$eval( + '#inspector-checkbox-control-1', + ( el ) => ( el as HTMLInputElement ).checked + ); + if ( ! enabledChecked ) { + await enabledLabel.click(); + } + + await expect( page ).toFill( + 'input[name="local_pickup_title"]', + 'Local Pickup' + ); + await merchant.saveLocalPickupSettingsPageWithRefresh(); + }, + disableLocalPickup: async () => { + await merchant.goToLocalPickupSettingsPage(); + const enabledLabel = await findLabelWithText( 'Enable local pickup' ); + const enabledChecked = await page.$eval( + '#inspector-checkbox-control-1', + ( el ) => ( el as HTMLInputElement ).checked + ); + if ( enabledChecked ) { + await enabledLabel.click(); + } + await merchant.saveLocalPickupSettingsPageWithRefresh(); + }, + removeCostForLocalPickup: async () => { + const costLabel = await findLabelWithText( + 'Add a price for customers who choose local pickup' + ); + const costChecked = await page.$eval( + '#inspector-checkbox-control-1', + ( el ) => ( el as HTMLInputElement ).checked + ); + if ( costChecked ) { + await costLabel.click(); + } + }, + addLocalPickupLocation: async () => { + await merchant.goToLocalPickupSettingsPage(); + await expect( page ).toClick( 'button', { + text: 'Add pickup location', + } ); + await expect( page ).toFill( + 'input[name="location_name"]', + 'Test Location' + ); + await expect( page ).toFill( + 'input[name="location_address"]', + 'Test Address 1' + ); + await expect( page ).toFill( + 'input[name="location_city"]', + 'Test City' + ); + await expect( page ).toFill( + 'input[name="location_postcode"]', + '90210' + ); + await expect( page ).toFill( + 'input[name="pickup_details"]', + 'Collect from store' + ); + await expect( page ).toSelect( + 'select[name="location_country"]', + 'US' + ); + await expect( page ).toSelect( 'select[name="location_state"]', 'CA' ); + await expect( page ).toClick( 'button', { text: 'Done' } ); + await merchant.saveLocalPickupSettingsPageWithRefresh(); + }, +};