diff --git a/assets/js/base/components/cart-checkout/pickup-location/index.tsx b/assets/js/base/components/cart-checkout/pickup-location/index.tsx new file mode 100644 index 00000000000..a213f795f71 --- /dev/null +++ b/assets/js/base/components/cart-checkout/pickup-location/index.tsx @@ -0,0 +1,78 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { isObject, objectHasProp } from '@woocommerce/types'; +import { isPackageRateCollectable } from '@woocommerce/base-utils'; + +/** + * Shows a formatted pickup location. + */ +const PickupLocation = (): JSX.Element | null => { + const { pickupAddress, pickupMethod } = useSelect( ( select ) => { + const cartShippingRates = select( 'wc/store/cart' ).getShippingRates(); + + const flattenedRates = cartShippingRates.flatMap( + ( cartShippingRate ) => cartShippingRate.shipping_rates + ); + const selectedCollectableRate = flattenedRates.find( + ( rate ) => rate.selected && isPackageRateCollectable( rate ) + ); + + // If the rate has an address specified in its metadata. + if ( + isObject( selectedCollectableRate ) && + objectHasProp( selectedCollectableRate, 'meta_data' ) + ) { + const selectedRateMetaData = selectedCollectableRate.meta_data.find( + ( meta ) => meta.key === 'pickup_address' + ); + if ( + isObject( selectedRateMetaData ) && + objectHasProp( selectedRateMetaData, 'value' ) && + selectedRateMetaData.value + ) { + const selectedRatePickupAddress = selectedRateMetaData.value; + return { + pickupAddress: selectedRatePickupAddress, + pickupMethod: selectedCollectableRate.name, + }; + } + } + + if ( isObject( selectedCollectableRate ) ) { + return { + pickupAddress: undefined, + pickupMethod: selectedCollectableRate.name, + }; + } + return { + pickupAddress: undefined, + pickupMethod: undefined, + }; + } ); + + // If the method does not contain an address, or the method supporting collection was not found, return early. + if ( + typeof pickupAddress === 'undefined' && + typeof pickupMethod === 'undefined' + ) { + return null; + } + + // Show the pickup method's name if we don't have an address to show. + return ( + + { sprintf( + /* translators: %s: shipping method name, e.g. "Amazon Locker" */ + __( 'Collection from %s', 'woo-gutenberg-products-block' ), + typeof pickupAddress === 'undefined' + ? pickupMethod + : pickupAddress + ) + ' ' } + + ); +}; + +export default PickupLocation; diff --git a/assets/js/base/components/cart-checkout/pickup-location/test/index.tsx b/assets/js/base/components/cart-checkout/pickup-location/test/index.tsx new file mode 100644 index 00000000000..603bb951931 --- /dev/null +++ b/assets/js/base/components/cart-checkout/pickup-location/test/index.tsx @@ -0,0 +1,93 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import { CART_STORE_KEY, CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; +import { dispatch } from '@wordpress/data'; +import { previewCart } from '@woocommerce/resource-previews'; +import PickupLocation from '@woocommerce/base-components/cart-checkout/pickup-location'; + +jest.mock( '@woocommerce/settings', () => { + const originalModule = jest.requireActual( '@woocommerce/settings' ); + + return { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore We know @woocommerce/settings is an object. + ...originalModule, + getSetting: ( setting: string, ...rest: unknown[] ) => { + if ( setting === 'localPickupEnabled' ) { + return true; + } + if ( setting === 'collectableMethodIds' ) { + return [ 'pickup_location' ]; + } + return originalModule.getSetting( setting, ...rest ); + }, + }; +} ); +describe( 'PickupLocation', () => { + it( `renders an address if one is set in the method's metadata`, async () => { + dispatch( CHECKOUT_STORE_KEY ).setPrefersCollection( true ); + + // Deselect the default selected rate and select pickup_location:1 rate. + const currentlySelectedIndex = + previewCart.shipping_rates[ 0 ].shipping_rates.findIndex( + ( rate ) => rate.selected + ); + previewCart.shipping_rates[ 0 ].shipping_rates[ + currentlySelectedIndex + ].selected = false; + const pickupRateIndex = + previewCart.shipping_rates[ 0 ].shipping_rates.findIndex( + ( rate ) => rate.method_id === 'pickup_location' + ); + previewCart.shipping_rates[ 0 ].shipping_rates[ + pickupRateIndex + ].selected = true; + + dispatch( CART_STORE_KEY ).receiveCart( previewCart ); + + render( ); + expect( + screen.getByText( + /Collection from 123 Easy Street, New York, 12345/ + ) + ).toBeInTheDocument(); + } ); + it( 'renders the method name if address is not in metadata', async () => { + dispatch( CHECKOUT_STORE_KEY ).setPrefersCollection( true ); + + // Deselect the default selected rate and select pickup_location:1 rate. + const currentlySelectedIndex = + previewCart.shipping_rates[ 0 ].shipping_rates.findIndex( + ( rate ) => rate.selected + ); + previewCart.shipping_rates[ 0 ].shipping_rates[ + currentlySelectedIndex + ].selected = false; + const pickupRateIndex = + previewCart.shipping_rates[ 0 ].shipping_rates.findIndex( + ( rate ) => rate.rate_id === 'pickup_location:2' + ); + previewCart.shipping_rates[ 0 ].shipping_rates[ + pickupRateIndex + ].selected = true; + + // Set the pickup_location metadata value to an empty string in the selected pickup rate. + const addressKeyIndex = previewCart.shipping_rates[ 0 ].shipping_rates[ + pickupRateIndex + ].meta_data.findIndex( + ( metaData ) => metaData.key === 'pickup_address' + ); + previewCart.shipping_rates[ 0 ].shipping_rates[ + pickupRateIndex + ].meta_data[ addressKeyIndex ].value = ''; + + dispatch( CART_STORE_KEY ).receiveCart( previewCart ); + + render( ); + expect( + screen.getByText( /Collection from Local pickup/ ) + ).toBeInTheDocument(); + } ); +} ); diff --git a/assets/js/base/components/cart-checkout/totals/shipping/index.tsx b/assets/js/base/components/cart-checkout/totals/shipping/index.tsx index 5b2fade1a9e..6085c4d15d6 100644 --- a/assets/js/base/components/cart-checkout/totals/shipping/index.tsx +++ b/assets/js/base/components/cart-checkout/totals/shipping/index.tsx @@ -8,9 +8,12 @@ import { useStoreCart } from '@woocommerce/base-context/hooks'; import { TotalsItem } from '@woocommerce/blocks-checkout'; import type { Currency } from '@woocommerce/price-format'; import { ShippingVia } from '@woocommerce/base-components/cart-checkout/totals/shipping/shipping-via'; -import { useSelect } from '@wordpress/data'; +import { + isAddressComplete, + isPackageRateCollectable, +} from '@woocommerce/base-utils'; import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; -import { isAddressComplete } from '@woocommerce/base-utils'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -33,7 +36,6 @@ export interface TotalShippingProps { className?: string; isCheckout?: boolean; } - export const TotalsShipping = ( { currency, values, @@ -50,20 +52,25 @@ export const TotalsShipping = ( { shippingRates, isLoadingRates, } = useStoreCart(); - const { prefersCollection } = useSelect( ( select ) => { - const checkoutStore = select( CHECKOUT_STORE_KEY ); - return { - prefersCollection: checkoutStore.prefersCollection(), - }; - } ); const totalShippingValue = getTotalShippingValue( values ); const hasRates = hasShippingRate( shippingRates ) || totalShippingValue > 0; const showShippingCalculatorForm = showCalculator && isShippingCalculatorOpen; + const prefersCollection = useSelect( ( select ) => { + return select( CHECKOUT_STORE_KEY ).prefersCollection(); + } ); const selectedShippingRates = shippingRates.flatMap( ( shippingPackage ) => { return shippingPackage.shipping_rates - .filter( ( rate ) => rate.selected ) + .filter( + ( rate ) => + // If the shopper prefers collection, the rate is collectable AND selected. + ( prefersCollection && + isPackageRateCollectable( rate ) && + rate.selected ) || + // Or the shopper does not prefer collection and the rate is selected + ( ! prefersCollection && rate.selected ) + ) .flatMap( ( rate ) => rate.name ); } ); @@ -104,18 +111,16 @@ export const TotalsShipping = ( { - { ! prefersCollection && ( - - ) } + ) : null } diff --git a/assets/js/base/components/cart-checkout/totals/shipping/shipping-address.tsx b/assets/js/base/components/cart-checkout/totals/shipping/shipping-address.tsx index c2be8f15954..2d95810aa88 100644 --- a/assets/js/base/components/cart-checkout/totals/shipping/shipping-address.tsx +++ b/assets/js/base/components/cart-checkout/totals/shipping/shipping-address.tsx @@ -2,12 +2,15 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { EnteredAddress } from '@woocommerce/settings'; import { formatShippingAddress, isAddressComplete, } from '@woocommerce/base-utils'; import { useEditorContext } from '@woocommerce/base-context'; +import { ShippingAddress as ShippingAddressType } from '@woocommerce/settings'; +import PickupLocation from '@woocommerce/base-components/cart-checkout/pickup-location'; +import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -19,7 +22,7 @@ export interface ShippingAddressProps { showCalculator: boolean; isShippingCalculatorOpen: boolean; setIsShippingCalculatorOpen: CalculatorButtonProps[ 'setIsShippingCalculatorOpen' ]; - shippingAddress: EnteredAddress; + shippingAddress: ShippingAddressType; } export const ShippingAddress = ( { @@ -30,7 +33,9 @@ export const ShippingAddress = ( { }: ShippingAddressProps ): JSX.Element | null => { const addressComplete = isAddressComplete( shippingAddress ); const { isEditor } = useEditorContext(); - + const prefersCollection = useSelect( ( select ) => + select( CHECKOUT_STORE_KEY ).prefersCollection() + ); // If the address is incomplete, and we're not in the editor, don't show anything. if ( ! addressComplete && ! isEditor ) { return null; @@ -38,8 +43,12 @@ export const ShippingAddress = ( { const formattedLocation = formatShippingAddress( shippingAddress ); return ( <> - - { showCalculator && ( + { prefersCollection ? ( + + ) : ( + + ) } + { showCalculator && ! prefersCollection ? ( - ) } + ) : null } ); }; diff --git a/assets/js/base/components/cart-checkout/totals/shipping/test/index.tsx b/assets/js/base/components/cart-checkout/totals/shipping/test/index.tsx index daa43f4a967..ed04a2eb123 100644 --- a/assets/js/base/components/cart-checkout/totals/shipping/test/index.tsx +++ b/assets/js/base/components/cart-checkout/totals/shipping/test/index.tsx @@ -18,9 +18,24 @@ jest.mock( '@wordpress/data', () => ( { useSelect: jest.fn(), } ) ); -wpData.useSelect.mockImplementation( () => { - return { prefersCollection: false }; -} ); +// Mock use select so we can override it when wc/store/checkout is accessed, but return the original select function if any other store is accessed. +wpData.useSelect.mockImplementation( + jest.fn().mockImplementation( ( passedMapSelect ) => { + const mockedSelect = jest.fn().mockImplementation( ( storeName ) => { + if ( storeName === 'wc/store/checkout' ) { + return { + prefersCollection() { + return false; + }, + }; + } + return jest.requireActual( '@wordpress/data' ).select( storeName ); + } ); + passedMapSelect( mockedSelect, { + dispatch: jest.requireActual( '@wordpress/data' ).dispatch, + } ); + } ) +); const shippingAddress = { first_name: 'John', diff --git a/assets/js/base/components/cart-checkout/totals/shipping/test/shipping-address.tsx b/assets/js/base/components/cart-checkout/totals/shipping/test/shipping-address.tsx new file mode 100644 index 00000000000..c3be82cf4cd --- /dev/null +++ b/assets/js/base/components/cart-checkout/totals/shipping/test/shipping-address.tsx @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import ShippingAddress from '@woocommerce/base-components/cart-checkout/totals/shipping/shipping-address'; +import { CART_STORE_KEY, CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; +import { dispatch } from '@wordpress/data'; +import { previewCart } from '@woocommerce/resource-previews'; + +jest.mock( '@woocommerce/settings', () => { + const originalModule = jest.requireActual( '@woocommerce/settings' ); + + return { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore We know @woocommerce/settings is an object. + ...originalModule, + getSetting: ( setting: string, ...rest: unknown[] ) => { + if ( setting === 'localPickupEnabled' ) { + return true; + } + if ( setting === 'collectableMethodIds' ) { + return [ 'pickup_location' ]; + } + return originalModule.getSetting( setting, ...rest ); + }, + }; +} ); +describe( 'ShippingAddress', () => { + const testShippingAddress = { + first_name: 'John', + last_name: 'Doe', + company: 'Automattic', + address_1: '123 Main St', + address_2: '', + city: 'San Francisco', + state: 'CA', + postcode: '94107', + country: 'US', + phone: '555-555-5555', + }; + + it( 'renders ShippingLocation if user does not prefer collection', () => { + render( + + ); + expect( screen.getByText( /Shipping to 94107/ ) ).toBeInTheDocument(); + expect( + screen.queryByText( /Collection from/ ) + ).not.toBeInTheDocument(); + } ); + it( 'renders PickupLocation if shopper prefers collection', async () => { + dispatch( CHECKOUT_STORE_KEY ).setPrefersCollection( true ); + + // Deselect the default selected rate and select pickup_location:1 rate. + const currentlySelectedIndex = + previewCart.shipping_rates[ 0 ].shipping_rates.findIndex( + ( rate ) => rate.selected + ); + previewCart.shipping_rates[ 0 ].shipping_rates[ + currentlySelectedIndex + ].selected = false; + const pickupRateIndex = + previewCart.shipping_rates[ 0 ].shipping_rates.findIndex( + ( rate ) => rate.method_id === 'pickup_location' + ); + previewCart.shipping_rates[ 0 ].shipping_rates[ + pickupRateIndex + ].selected = true; + + dispatch( CART_STORE_KEY ).receiveCart( previewCart ); + + render( + + ); + expect( + screen.getByText( + /Collection from 123 Easy Street, New York, 12345/ + ) + ).toBeInTheDocument(); + } ); +} ); diff --git a/assets/js/base/context/hooks/shipping/types.ts b/assets/js/base/context/hooks/shipping/types.ts index 5a132d516df..d191ada754c 100644 --- a/assets/js/base/context/hooks/shipping/types.ts +++ b/assets/js/base/context/hooks/shipping/types.ts @@ -18,6 +18,6 @@ export interface ShippingData { isCollectable: boolean; // True when a rate is currently being selected and persisted to the server. isSelectingRate: boolean; - + // True when the user has chosen a local pickup method. hasSelectedLocalPickup: boolean; } diff --git a/assets/js/base/context/hooks/shipping/use-shipping-data.ts b/assets/js/base/context/hooks/shipping/use-shipping-data.ts index c27c6dc52c5..539226abd54 100644 --- a/assets/js/base/context/hooks/shipping/use-shipping-data.ts +++ b/assets/js/base/context/hooks/shipping/use-shipping-data.ts @@ -85,7 +85,6 @@ export const useShippingData = (): ShippingData => { ( rate ) => rate.split( ':' )[ 0 ] ) ); - // Selects a shipping rate, fires an event, and catch any errors. const { dispatchCheckoutEvent } = useStoreEvents(); const selectShippingRate = useCallback( diff --git a/assets/js/data/checkout/selectors.ts b/assets/js/data/checkout/selectors.ts index 7cf19cb6745..9982215995e 100644 --- a/assets/js/data/checkout/selectors.ts +++ b/assets/js/data/checkout/selectors.ts @@ -77,7 +77,7 @@ export const isCalculating = ( state: CheckoutState ) => { }; export const prefersCollection = ( state: CheckoutState ) => { - if ( state.prefersCollection === undefined ) { + if ( typeof state.prefersCollection === 'undefined' ) { const shippingRates = select( cartStoreKey ).getShippingRates(); if ( ! shippingRates || ! shippingRates.length ) { return false; @@ -85,6 +85,7 @@ export const prefersCollection = ( state: CheckoutState ) => { const selectedRate = shippingRates[ 0 ].shipping_rates.find( ( rate ) => rate.selected ); + if ( objectHasProp( selectedRate, 'method_id' ) && isString( selectedRate.method_id ) diff --git a/src/Shipping/ShippingController.php b/src/Shipping/ShippingController.php index 6e313d99a41..e6be896593f 100644 --- a/src/Shipping/ShippingController.php +++ b/src/Shipping/ShippingController.php @@ -51,6 +51,7 @@ function() { true ); } + $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' ] );