Display the link to add the shipping address when shipping address is not available (https://github.com/woocommerce/woocommerce-blocks/pull/8141)

This commit is contained in:
Tarun Vijwani 2023-03-22 11:15:13 +04:00 committed by GitHub
parent 6a629c60b2
commit 82c59ffb29
13 changed files with 570 additions and 98 deletions

View File

@ -2,56 +2,15 @@
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { ShippingAddress, getSetting } from '@woocommerce/settings';
import { decodeEntities } from '@wordpress/html-entities';
interface ShippingLocationProps {
address: ShippingAddress;
formattedLocation: string | null;
}
/**
* Shows a formatted shipping location.
*
* @param {Object} props Incoming props for the component.
* @param {Object} props.address Incoming address information.
*/
// Shows a formatted shipping location.
const ShippingLocation = ( {
address,
formattedLocation,
}: ShippingLocationProps ): JSX.Element | null => {
// we bail early if we don't have an address.
if ( Object.values( address ).length === 0 ) {
return null;
}
const shippingCountries = getSetting( 'shippingCountries', {} ) as Record<
string,
string
>;
const shippingStates = getSetting( 'shippingStates', {} ) as Record<
string,
Record< string, string >
>;
const formattedCountry =
typeof shippingCountries[ address.country ] === 'string'
? decodeEntities( shippingCountries[ address.country ] )
: '';
const formattedState =
typeof shippingStates[ address.country ] === 'object' &&
typeof shippingStates[ address.country ][ address.state ] === 'string'
? decodeEntities(
shippingStates[ address.country ][ address.state ]
)
: address.state;
const addressParts = [];
addressParts.push( address.postcode.toUpperCase() );
addressParts.push( address.city );
addressParts.push( formattedState );
addressParts.push( formattedCountry );
const formattedLocation = addressParts.filter( Boolean ).join( ', ' );
if ( ! formattedLocation ) {
return null;
}

View File

@ -15,15 +15,20 @@ export const CalculatorButton = ( {
setIsShippingCalculatorOpen,
}: CalculatorButtonProps ): JSX.Element => {
return (
<button
className="wc-block-components-totals-shipping__change-address-button"
onClick={ () => {
<a
role="button"
href="#wc-block-components-shipping-calculator-address__link"
className="wc-block-components-totals-shipping__change-address__link"
id="wc-block-components-totals-shipping__change-address__link"
onClick={ ( e ) => {
e.preventDefault();
setIsShippingCalculatorOpen( ! isShippingCalculatorOpen );
} }
aria-label={ label }
aria-expanded={ isShippingCalculatorOpen }
>
{ label }
</button>
</a>
);
};

View File

@ -10,6 +10,7 @@ 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 { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
import { isAddressComplete } from '@woocommerce/base-utils';
/**
* Internal dependencies
@ -67,6 +68,8 @@ export const TotalsShipping = ( {
}
);
const addressComplete = isAddressComplete( shippingAddress );
return (
<div
className={ classnames(
@ -77,23 +80,26 @@ export const TotalsShipping = ( {
<TotalsItem
label={ __( 'Shipping', 'woo-gutenberg-products-block' ) }
value={
hasRates && cartHasCalculatedShipping ? (
totalShippingValue
) : (
<ShippingPlaceholder
showCalculator={ showCalculator }
isCheckout={ isCheckout }
isShippingCalculatorOpen={
isShippingCalculatorOpen
}
setIsShippingCalculatorOpen={
setIsShippingCalculatorOpen
}
/>
)
hasRates && cartHasCalculatedShipping
? totalShippingValue
: // if address is not complete, display the link to add an address.
! addressComplete && (
<ShippingPlaceholder
showCalculator={ showCalculator }
isCheckout={ isCheckout }
isShippingCalculatorOpen={
isShippingCalculatorOpen
}
setIsShippingCalculatorOpen={
setIsShippingCalculatorOpen
}
/>
)
}
description={
hasRates && cartHasCalculatedShipping ? (
// If address is complete, display the shipping address.
( hasRates && cartHasCalculatedShipping ) ||
addressComplete ? (
<>
<ShippingVia
selectedShippingRates={ selectedShippingRates }
@ -132,6 +138,7 @@ export const TotalsShipping = ( {
hasRates={ hasRates }
shippingRates={ shippingRates }
isLoadingRates={ isLoadingRates }
isAddressComplete={ addressComplete }
/>
) }
</div>

View File

@ -3,6 +3,11 @@
*/
import { __ } from '@wordpress/i18n';
import { EnteredAddress } from '@woocommerce/settings';
import {
formatShippingAddress,
isAddressComplete,
} from '@woocommerce/base-utils';
import { useEditorContext } from '@woocommerce/base-context';
/**
* Internal dependencies
@ -23,13 +28,21 @@ export const ShippingAddress = ( {
setIsShippingCalculatorOpen,
shippingAddress,
}: ShippingAddressProps ): JSX.Element | null => {
const addressComplete = isAddressComplete( shippingAddress );
const { isEditor } = useEditorContext();
// If the address is incomplete, and we're not in the editor, don't show anything.
if ( ! addressComplete && ! isEditor ) {
return null;
}
const formattedLocation = formatShippingAddress( shippingAddress );
return (
<>
<ShippingLocation address={ shippingAddress } />
<ShippingLocation formattedLocation={ formattedLocation } />
{ showCalculator && (
<CalculatorButton
label={ __(
'(change address)',
'Change address',
'woo-gutenberg-products-block'
) }
isShippingCalculatorOpen={ isShippingCalculatorOpen }

View File

@ -39,6 +39,10 @@ export const ShippingPlaceholder = ( {
return (
<CalculatorButton
label={ __(
'Add an address for shipping options',
'woo-gutenberg-products-block'
) }
isShippingCalculatorOpen={ isShippingCalculatorOpen }
setIsShippingCalculatorOpen={ setIsShippingCalculatorOpen }
/>

View File

@ -2,8 +2,6 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Notice } from 'wordpress-components';
import classnames from 'classnames';
import type { CartResponseShippingRate } from '@woocommerce/types';
/**
@ -15,12 +13,14 @@ export interface ShippingRateSelectorProps {
hasRates: boolean;
shippingRates: CartResponseShippingRate[];
isLoadingRates: boolean;
isAddressComplete: boolean;
}
export const ShippingRateSelector = ( {
hasRates,
shippingRates,
isLoadingRates,
isAddressComplete,
}: ShippingRateSelectorProps ): JSX.Element => {
const legend = hasRates
? __( 'Shipping options', 'woo-gutenberg-products-block' )
@ -31,18 +31,13 @@ export const ShippingRateSelector = ( {
<ShippingRatesControl
className="wc-block-components-totals-shipping__options"
noResultsMessage={
<Notice
isDismissible={ false }
className={ classnames(
'wc-block-components-shipping-rates-control__no-results-notice',
'woocommerce-error'
) }
>
{ __(
'No shipping options were found.',
'woo-gutenberg-products-block'
) }
</Notice>
<>
{ isAddressComplete &&
__(
'There are no shipping options available. Please check your shipping address.',
'woo-gutenberg-products-block'
) }
</>
}
shippingRates={ shippingRates }
isLoadingRates={ isLoadingRates }

View File

@ -20,12 +20,16 @@
flex-basis: 100%;
text-align: left;
}
margin-top: ($gap-small);
}
.wc-block-components-shipping-rates-control__no-results-notice {
margin: 0 0 em($gap-small);
}
.wc-block-components-totals-shipping__change-address__link {
font-weight: normal;
}
.wc-block-components-totals-shipping__change-address-button {
@include link-button();

View File

@ -0,0 +1,282 @@
/**
* External dependencies
*/
import { screen, render } from '@testing-library/react';
import { SlotFillProvider } from '@woocommerce/blocks-checkout';
import { previewCart as mockPreviewCart } from '@woocommerce/resource-previews';
import * as wpData from '@wordpress/data';
import * as baseContextHooks from '@woocommerce/base-context/hooks';
/**
* Internal dependencies
*/
import { TotalsShipping } from '../index';
jest.mock( '@wordpress/data', () => ( {
__esModule: true,
...jest.requireActual( '@wordpress/data' ),
useSelect: jest.fn(),
} ) );
wpData.useSelect.mockImplementation( () => {
return { prefersCollection: false };
} );
const shippingAddress = {
first_name: 'John',
last_name: 'Doe',
company: 'Company',
address_1: '409 Main Street',
address_2: 'Apt 1',
city: 'London',
postcode: 'W1T 4JG',
country: 'GB',
state: '',
email: 'john.doe@company',
phone: '+1234567890',
};
jest.mock( '@woocommerce/base-context/hooks', () => {
return {
__esModule: true,
...jest.requireActual( '@woocommerce/base-context/hooks' ),
useShippingData: jest.fn(),
useStoreCart: jest.fn(),
};
} );
baseContextHooks.useShippingData.mockReturnValue( {
needsShipping: true,
shippingRates: [
{
package_id: 0,
name: 'Shipping method',
destination: {
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
},
items: [
{
key: 'fb0c0a746719a7596f296344b80cb2b6',
name: 'Hoodie - Blue, Yes',
quantity: 1,
},
{
key: '1f0e3dad99908345f7439f8ffabdffc4',
name: 'Beanie',
quantity: 1,
},
],
shipping_rates: [
{
rate_id: 'flat_rate:1',
name: 'Flat rate',
description: '',
delivery_time: '',
price: '500',
taxes: '0',
instance_id: 1,
method_id: 'flat_rate',
meta_data: [
{
key: 'Items',
value: 'Hoodie - Blue, Yes &times; 1, Beanie &times; 1',
},
],
selected: false,
currency_code: 'USD',
currency_symbol: '$',
currency_minor_unit: 2,
currency_decimal_separator: '.',
currency_thousand_separator: ',',
currency_prefix: '$',
currency_suffix: '',
},
{
rate_id: 'local_pickup:2',
name: 'Local pickup',
description: '',
delivery_time: '',
price: '0',
taxes: '0',
instance_id: 2,
method_id: 'local_pickup',
meta_data: [
{
key: 'Items',
value: 'Hoodie - Blue, Yes &times; 1, Beanie &times; 1',
},
],
selected: false,
currency_code: 'USD',
currency_symbol: '$',
currency_minor_unit: 2,
currency_decimal_separator: '.',
currency_thousand_separator: ',',
currency_prefix: '$',
currency_suffix: '',
},
{
rate_id: 'free_shipping:5',
name: 'Free shipping',
description: '',
delivery_time: '',
price: '0',
taxes: '0',
instance_id: 5,
method_id: 'free_shipping',
meta_data: [
{
key: 'Items',
value: 'Hoodie - Blue, Yes &times; 1, Beanie &times; 1',
},
],
selected: true,
currency_code: 'USD',
currency_symbol: '$',
currency_minor_unit: 2,
currency_decimal_separator: '.',
currency_thousand_separator: ',',
currency_prefix: '$',
currency_suffix: '',
},
],
},
],
} );
baseContextHooks.useStoreCart.mockReturnValue( {
cartItems: mockPreviewCart.items,
cartTotals: [ mockPreviewCart.totals ],
cartCoupons: mockPreviewCart.coupons,
cartFees: mockPreviewCart.fees,
cartNeedsShipping: mockPreviewCart.needs_shipping,
shippingRates: [],
shippingAddress,
billingAddress: mockPreviewCart.billing_address,
cartHasCalculatedShipping: mockPreviewCart.has_calculated_shipping,
isLoadingRates: false,
} );
describe( 'TotalsShipping', () => {
it( 'should show correct calculator button label if address is complete', () => {
render(
<SlotFillProvider>
<TotalsShipping
currency={ {
code: 'USD',
symbol: '$',
position: 'left',
precision: 2,
} }
values={ {
total_shipping: '0',
total_shipping_tax: '0',
} }
showCalculator={ true }
showRateSelector={ true }
isCheckout={ true }
className={ '' }
/>
</SlotFillProvider>
);
expect(
screen.getByText(
'Shipping to W1T 4JG, London, United Kingdom (UK)'
)
).toBeInTheDocument();
expect( screen.getByText( 'Change address' ) ).toBeInTheDocument();
} );
it( 'should show correct calculator button label if address is incomplete', () => {
baseContextHooks.useStoreCart.mockReturnValue( {
cartItems: mockPreviewCart.items,
cartTotals: [ mockPreviewCart.totals ],
cartCoupons: mockPreviewCart.coupons,
cartFees: mockPreviewCart.fees,
cartNeedsShipping: mockPreviewCart.needs_shipping,
shippingRates: [],
shippingAddress: {
...shippingAddress,
city: '',
country: '',
postcode: '',
},
billingAddress: mockPreviewCart.billing_address,
cartHasCalculatedShipping: mockPreviewCart.has_calculated_shipping,
isLoadingRates: false,
} );
render(
<SlotFillProvider>
<TotalsShipping
currency={ {
code: 'USD',
symbol: '$',
position: 'left',
precision: 2,
} }
values={ {
total_shipping: '0',
total_shipping_tax: '0',
} }
showCalculator={ true }
showRateSelector={ true }
isCheckout={ true }
className={ '' }
/>
</SlotFillProvider>
);
expect(
screen.queryByText( 'Change address' )
).not.toBeInTheDocument();
expect(
screen.getByText( 'Add an address for shipping options' )
).toBeInTheDocument();
} );
it( 'does not show the calculator button when default rates are available and no address has been entered', () => {
baseContextHooks.useStoreCart.mockReturnValue( {
cartItems: mockPreviewCart.items,
cartTotals: [ mockPreviewCart.totals ],
cartCoupons: mockPreviewCart.coupons,
cartFees: mockPreviewCart.fees,
cartNeedsShipping: mockPreviewCart.needs_shipping,
shippingRates: mockPreviewCart.shipping_rates,
shippingAddress: {
...shippingAddress,
city: '',
country: '',
postcode: '',
},
billingAddress: mockPreviewCart.billing_address,
cartHasCalculatedShipping: mockPreviewCart.has_calculated_shipping,
isLoadingRates: false,
} );
render(
<SlotFillProvider>
<TotalsShipping
currency={ {
code: 'USD',
symbol: '$',
position: 'left',
precision: 2,
} }
values={ {
total_shipping: '0',
total_shipping_tax: '0',
} }
showCalculator={ true }
showRateSelector={ true }
isCheckout={ true }
className={ '' }
/>
</SlotFillProvider>
);
expect(
screen.queryByText( 'Change address' )
).not.toBeInTheDocument();
expect(
screen.queryByText( 'Add an address for shipping options' )
).not.toBeInTheDocument();
} );
} );

View File

@ -0,0 +1,36 @@
/**
* External dependencies
*/
import { screen, render } from '@testing-library/react';
/**
* Internal dependencies
*/
import ShippingPlaceholder from '../shipping-placeholder';
describe( 'ShippingPlaceholder', () => {
it( 'should show correct text if showCalculator is false', () => {
const { rerender } = render(
<ShippingPlaceholder
showCalculator={ false }
isCheckout={ true }
isShippingCalculatorOpen={ false }
setIsShippingCalculatorOpen={ jest.fn() }
/>
);
expect(
screen.getByText( 'No shipping options available' )
).toBeInTheDocument();
rerender(
<ShippingPlaceholder
showCalculator={ false }
isCheckout={ false }
isShippingCalculatorOpen={ false }
setIsShippingCalculatorOpen={ jest.fn() }
/>
);
expect(
screen.getByText( 'Calculated during checkout' )
).toBeInTheDocument();
} );
} );

View File

@ -11,7 +11,9 @@ import {
defaultAddressFields,
ShippingAddress,
BillingAddress,
getSetting,
} from '@woocommerce/settings';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Compare two addresses and see if they are the same.
@ -100,3 +102,62 @@ export const emptyHiddenAddressFields = <
return newAddress;
};
/*
* Formats a shipping address for display.
*
* @param {Object} address The address to format.
* @return {string | null} The formatted address or null if no address is provided.
*/
export const formatShippingAddress = (
address: ShippingAddress | BillingAddress
): string | null => {
// We bail early if we don't have an address.
if ( Object.values( address ).length === 0 ) {
return null;
}
const shippingCountries = getSetting< Record< string, string > >(
'shippingCountries',
{}
);
const shippingStates = getSetting< Record< string, string > >(
'shippingStates',
{}
);
const formattedCountry =
typeof shippingCountries[ address.country ] === 'string'
? decodeEntities( shippingCountries[ address.country ] )
: '';
const formattedState =
typeof shippingStates[ address.country ] === 'object' &&
typeof shippingStates[ address.country ][ address.state ] === 'string'
? decodeEntities(
shippingStates[ address.country ][ address.state ]
)
: address.state;
const addressParts = [];
addressParts.push( address.postcode.toUpperCase() );
addressParts.push( address.city );
addressParts.push( formattedState );
addressParts.push( formattedCountry );
const formattedLocation = addressParts.filter( Boolean ).join( ', ' );
if ( ! formattedLocation ) {
return null;
}
return formattedLocation;
};
/**
* Returns true if the address has a city and country.
*/
export const isAddressComplete = (
address: ShippingAddress | BillingAddress
): boolean => {
return !! address.city && !! address.country;
};

View File

@ -1,7 +1,11 @@
/**
* External dependencies
*/
import { emptyHiddenAddressFields } from '@woocommerce/base-utils';
import {
emptyHiddenAddressFields,
isAddressComplete,
formatShippingAddress,
} from '@woocommerce/base-utils';
describe( 'emptyHiddenAddressFields', () => {
it( "Removes state from an address where the country doesn't use states", () => {
@ -22,3 +26,101 @@ describe( 'emptyHiddenAddressFields', () => {
expect( filteredAddress ).toHaveProperty( 'state', '' );
} );
} );
describe( 'isAddressComplete', () => {
it( 'correctly checks empty addresses', () => {
const address = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
postcode: '',
country: '',
state: '',
email: '',
phone: '',
};
expect( isAddressComplete( address ) ).toBe( false );
} );
it( 'correctly checks incomplete addresses', () => {
const address = {
first_name: 'John',
last_name: 'Doe',
company: 'Company',
address_1: '409 Main Street',
address_2: 'Apt 1',
city: '',
postcode: '',
country: '',
state: '',
email: 'john.doe@company',
phone: '+1234567890',
};
expect( isAddressComplete( address ) ).toBe( false );
address.city = 'London';
expect( isAddressComplete( address ) ).toBe( false );
address.postcode = 'W1T 4JG';
address.country = 'GB';
expect( isAddressComplete( address ) ).toBe( true );
} );
it( 'correctly checks complete addresses', () => {
const address = {
first_name: 'John',
last_name: 'Doe',
company: 'Company',
address_1: '409 Main Street',
address_2: 'Apt 1',
city: 'London',
postcode: 'W1T 4JG',
country: 'GB',
state: '',
email: 'john.doe@company',
phone: '+1234567890',
};
expect( isAddressComplete( address ) ).toBe( true );
} );
} );
describe( 'formatShippingAddress', () => {
it( 'returns null if address is empty', () => {
const address = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
postcode: '',
country: '',
state: '',
email: '',
phone: '',
};
expect( formatShippingAddress( address ) ).toBe( null );
} );
it( 'correctly returns the formatted address', () => {
const address = {
first_name: 'John',
last_name: 'Doe',
company: 'Company',
address_1: '409 Main Street',
address_2: 'Apt 1',
city: 'London',
postcode: 'W1T 4JG',
country: 'GB',
state: '',
email: 'john.doe@company',
phone: '+1234567890',
};
expect( formatShippingAddress( address ) ).toBe(
'W1T 4JG, London, United Kingdom (UK)'
);
} );
} );

View File

@ -26,6 +26,7 @@ const Block = ( {
showRateSelector={ false }
values={ cartTotals }
currency={ totalsCurrency }
isCheckout={ true }
/>
</TotalsWrapper>
);

View File

@ -7,22 +7,21 @@ import { ShippingRatesControl } from '@woocommerce/base-components/cart-checkout
import {
getShippingRatesPackageCount,
hasCollectableRate,
isAddressComplete,
} from '@woocommerce/base-utils';
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
import { useEditorContext, noticeContexts } from '@woocommerce/base-context';
import { StoreNoticesContainer } from '@woocommerce/blocks-checkout';
import { decodeEntities } from '@wordpress/html-entities';
import { Notice } from 'wordpress-components';
import classnames from 'classnames';
import { getSetting } from '@woocommerce/settings';
import type {
PackageRateOption,
CartShippingPackageShippingRate,
} from '@woocommerce/types';
import type { ReactElement } from 'react';
import { useSelect } from '@wordpress/data';
import { CART_STORE_KEY } from '@woocommerce/block-data';
import { useSelect } from '@wordpress/data';
import type { ReactElement } from 'react';
/**
* Internal dependencies
@ -87,11 +86,16 @@ const Block = ( {
} )
: shippingRates;
const shippingAddress = useSelect( ( select ) => {
return select( CART_STORE_KEY ).getCustomerData()?.shippingAddress;
} );
if ( ! needsShipping ) {
return null;
}
const shippingAddressIsComplete = ! shippingAddressHasValidationErrors();
const shippingAddressHasErrors = ! shippingAddressHasValidationErrors();
const addressComplete = isAddressComplete( shippingAddress );
const shippingRatesPackageCount =
getShippingRatesPackageCount( shippingRates );
@ -99,7 +103,7 @@ const Block = ( {
if (
( ! hasCalculatedShipping && ! shippingRatesPackageCount ) ||
( shippingCostRequiresAddress &&
( ! shippingAddressPushed || ! shippingAddressIsComplete ) )
( ! shippingAddressPushed || ! shippingAddressHasErrors ) )
) {
return (
<p>
@ -121,18 +125,17 @@ const Block = ( {
) : (
<ShippingRatesControl
noResultsMessage={
<Notice
isDismissible={ false }
className={ classnames(
'wc-block-components-shipping-rates-control__no-results-notice',
'woocommerce-error'
) }
>
{ __(
'There are no shipping options available. Please check your shipping address.',
'woo-gutenberg-products-block'
) }
</Notice>
<>
{ addressComplete
? __(
'There are no shipping options available. Please check your shipping address.',
'woo-gutenberg-products-block'
)
: __(
'Add a shipping address to view shipping options.',
'woo-gutenberg-products-block'
) }
</>
}
renderOption={ renderShippingRatesControlOption }
collapsible={ false }