* add select shipping endpoint to router

* add select shipping method

* add selected rates to cart

* better select rates

* move schema function to seperate function

* move validation to Cart Controller

* fix wrong session key

* Update shipping/cart endpoints (https://github.com/woocommerce/woocommerce-blocks/pull/1833)

* Items should not have keys in API response

* Include package ID in response (this is just a basic index)

* /cart/select-shipping-rate/package_id

* Add package_id to package array

* Update responses and add shipping-rates to main cart endpoint

* update-shipping endpoint

* Add querying selected shipping rate to the store (https://github.com/woocommerce/woocommerce-blocks/pull/1829)

* add selecting shipping to store

* directly call useSelectShippingRate

* refactor cart keys transformation to reducer

* remove selecting first result and accept selecting

* move update shipping to new endpoint

* pass selected rates down

* select shipping right directly and fix editor issues

* fix some broken prop types

* key -> package id

* Update and fix cart/shipping-rate tests

* fix case for when rates are set

* Update useShippingRates test

* add args to rest endpoint

* move selecting shipping rate logic to hook

* fix some naming issues

* update propTypes

* update action call

* fully watch cart state

* address review issues

* fix prop type issues

* fix issue with rates not loading in checkout

* remove extra package for shipping

* move ShippingCalculatorOptions to outside

Co-authored-by: Mike Jolley <mike.jolley@me.com>
Co-authored-by: Albert Juhé Lluveras <aljullu@gmail.com>
This commit is contained in:
Seghir Nadir 2020-03-05 20:54:05 +01:00 committed by GitHub
parent 5a26d2708e
commit 2c8388f0a8
30 changed files with 893 additions and 397 deletions

View File

@ -26,7 +26,10 @@ const ShippingCalculatorAddress = ( { address: initialAddress, onUpdate } ) => {
<Button
className="wc-block-shipping-calculator-address__button"
disabled={ isShallowEqual( address, initialAddress ) }
onClick={ () => onUpdate( address ) }
onClick={ ( e ) => {
e.preventDefault();
return onUpdate( address );
} }
type="submit"
>
{ __( 'Update', 'woo-gutenberg-products-block' ) }

View File

@ -13,6 +13,10 @@ import { decodeEntities } from '@wordpress/html-entities';
* Shows a formatted shipping location.
*/
const ShippingLocation = ( { address } ) => {
// we bail early if we don't have an address.
if ( Object.values( address ).length === 0 ) {
return null;
}
const formattedCountry =
typeof SHIPPING_COUNTRIES[ address.country ] === 'string'
? decodeEntities( SHIPPING_COUNTRIES[ address.country ] )

View File

@ -3,8 +3,7 @@
*/
import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import { usePrevious, useShippingRates } from '@woocommerce/base-hooks';
import { useEffect } from '@wordpress/element';
import { usePrevious } from '@woocommerce/base-hooks';
/**
* Internal dependencies
@ -14,55 +13,17 @@ import LoadingMask from '../loading-mask';
import './style.scss';
const ShippingRatesControl = ( {
address,
shippingRates,
shippingRatesLoading,
className,
noResultsMessage,
onChange,
renderOption,
selected = [],
} ) => {
const { shippingRates, shippingRatesLoading } = useShippingRates( address );
const previousShippingRates = usePrevious(
shippingRates,
( newRates ) => newRates.length > 0
);
// Select first item when shipping rates are loaded.
useEffect(
() => {
if ( shippingRates.length === 0 ) {
return;
}
const isSelectedValid =
selected.length === shippingRates.length &&
selected.every( ( selectedId, i ) => {
const rates = shippingRates[ i ].shipping_rates;
return rates.some(
( { rate_id: rateId } ) => rateId === selectedId
);
} );
if ( isSelectedValid ) {
return;
}
const newShippingRates = shippingRates.map( ( shippingRate ) => {
if ( shippingRate.shipping_rates.length > 0 ) {
return shippingRate.shipping_rates[ 0 ].rate_id;
}
return null;
} );
if ( newShippingRates.length > 0 ) {
onChange( newShippingRates );
}
},
// We only want to run this when `shippingRates` changes,
// so there is no need to add `selected` to the effect dependencies.
[ shippingRates ]
);
return (
<LoadingMask
isLoading={ shippingRatesLoading }
@ -75,9 +36,7 @@ const ShippingRatesControl = ( {
<Packages
className={ className }
noResultsMessage={ noResultsMessage }
onChange={ onChange }
renderOption={ renderOption }
selected={ selected }
shippingRates={ previousShippingRates || shippingRates }
/>
</LoadingMask>
@ -94,10 +53,8 @@ ShippingRatesControl.propTypes = {
country: PropTypes.string,
} ),
noResultsMessage: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
renderOption: PropTypes.func.isRequired,
className: PropTypes.string,
selected: PropTypes.arrayOf( PropTypes.string ),
};
export default ShippingRatesControl;

View File

@ -81,9 +81,10 @@ Package.propTypes = {
renderOption: PropTypes.func.isRequired,
shippingRate: PropTypes.shape( {
shipping_rates: PropTypes.arrayOf( PropTypes.object ).isRequired,
items: PropTypes.objectOf(
items: PropTypes.arrayOf(
PropTypes.shape( {
name: PropTypes.string.isRequired,
key: PropTypes.string.isRequired,
quantity: PropTypes.number.isRequired,
} ).isRequired
).isRequired,

View File

@ -2,6 +2,7 @@
* External dependencies
*/
import PropTypes from 'prop-types';
import { useSelectShippingRate } from '@woocommerce/base-hooks';
/**
* Internal dependencies
@ -12,23 +13,22 @@ import './style.scss';
const Packages = ( {
className,
noResultsMessage,
onChange = () => {},
renderOption,
selected = [],
shippingRates,
shippingRates = [],
} ) => {
const { selectShippingRate, selectedShippingRates } = useSelectShippingRate(
shippingRates
);
return shippingRates.map( ( shippingRate, i ) => (
<Package
key={ Object.keys( shippingRate.items ).join() }
key={ shippingRate.package_id }
className={ className }
noResultsMessage={ noResultsMessage }
onChange={ ( newShippingRate ) => {
const newSelected = [ ...selected ];
newSelected[ i ] = newShippingRate;
onChange( newSelected );
selectShippingRate( newShippingRate, i );
} }
renderOption={ renderOption }
selected={ selected[ i ] }
selected={ selectedShippingRates[ i ] }
shippingRate={ shippingRate }
showItems={ shippingRates.length > 1 }
title={ shippingRates.length > 1 ? shippingRate.name : null }
@ -40,11 +40,19 @@ Packages.propTypes = {
renderOption: PropTypes.func.isRequired,
className: PropTypes.string,
noResultsMessage: PropTypes.string,
onChange: PropTypes.func,
selected: PropTypes.arrayOf( PropTypes.string ),
shippingRates: PropTypes.arrayOf(
PropTypes.shape( {
items: PropTypes.object.isRequired,
items: PropTypes.arrayOf(
PropTypes.shape( {
key: PropTypes.string,
name: PropTypes.string,
quantity: PropTypes.number,
} )
).isRequired,
package_id: PropTypes.number,
name: PropTypes.string,
destination: PropTypes.object,
shipping_rates: PropTypes.arrayOf( PropTypes.object ),
} ).isRequired
).isRequired,
};

View File

@ -12,3 +12,4 @@ export * from './use-previous';
export * from './checkout';
export * from './payment-methods';
export * from './use-shipping-rates';
export * from './use-select-shipping-rate';

View File

@ -3,7 +3,7 @@
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* Internal dependencies
@ -12,12 +12,7 @@ import { useShippingRates } from '../use-shipping-rates';
jest.mock( '@woocommerce/block-data', () => ( {
__esModule: true,
COLLECTIONS_STORE_KEY: 'test/store',
} ) );
// Make debounce instantaneous.
jest.mock( 'use-debounce', () => ( {
useDebounce: ( a ) => [ a ],
CART_STORE_KEY: 'test/store',
} ) );
describe( 'useShippingRates', () => {
@ -33,24 +28,33 @@ describe( 'useShippingRates', () => {
};
};
const getWrappedComponents = ( Component, props ) => (
const getWrappedComponents = ( Component ) => (
<RegistryProvider value={ registry }>
<Component { ...props } />
<Component />
</RegistryProvider>
);
const getTestComponent = () => ( { query } ) => {
const items = useShippingRates( query );
const getTestComponent = () => () => {
const items = useShippingRates();
return <div { ...items } />;
};
const mockCartData = {
coupons: [],
items: [ { foo: 'bar' } ],
itemsCount: 123,
itemsWeight: 123,
needsShipping: false,
shippingRates: { foo: 'bar' },
};
const setUpMocks = () => {
mocks = {
selectors: {
getCollectionError: jest.fn().mockReturnValue( false ),
getCollection: jest
.fn()
.mockImplementation( () => ( { foo: 'bar' } ) ),
getCartData: jest.fn().mockReturnValue( mockCartData ),
getCartErrors: jest.fn().mockReturnValue( false ),
getCartTotals: jest.fn().mockReturnValue( 123 ),
areShippingRatesLoading: jest.fn().mockReturnValue( false ),
hasFinishedResolution: jest.fn().mockReturnValue( true ),
},
};
@ -66,49 +70,22 @@ describe( 'useShippingRates', () => {
renderer = null;
setUpMocks();
} );
it(
'should return expected behaviour for equivalent query on props ' +
'across renders',
() => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
query: { bar: 'foo' },
} )
);
} );
const { shippingRates } = getProps( renderer );
// rerender
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
query: { bar: 'foo' },
} )
);
} );
// re-render should result in same shippingRates object because although
// query-state is a different instance, it's still equivalent.
const { shippingRates: newShippingRates } = getProps( renderer );
expect( newShippingRates ).toBe( shippingRates );
// now let's change the query passed through to verify new object
// is created.
// remember this won't actually change the results because the mock
// selector is returning an equivalent object when it is called,
// however it SHOULD be a new object instance.
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
query: { foo: 'bar' },
} )
);
} );
const { shippingRates: shippingRatesVerification } = getProps(
renderer
it( 'should return expected shipping rates provided by the store', () => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent )
);
expect( shippingRatesVerification ).not.toBe( shippingRates );
expect( shippingRatesVerification ).toEqual( shippingRates );
renderer.unmount();
}
);
} );
const { shippingRates } = getProps( renderer );
expect( shippingRates ).toBe( mockCartData.shippingRates );
// rerender
act( () => {
renderer.update( getWrappedComponents( TestComponent ) );
} );
// re-render should result in same shippingRates object.
const { shippingRates: newShippingRates } = getProps( renderer );
expect( newShippingRates ).toBe( shippingRates );
renderer.unmount();
} );
} );

View File

@ -18,24 +18,24 @@ import { useShallowEqual } from './use-shallow-equal';
* @throws {Object} Throws an exception object if there was a problem with the
* API request, to be picked up by BlockErrorBoundry.
*
* @param {Object} options An object declaring the various
* collection arguments.
* @param {string} options.namespace The namespace for the collection.
* Example: `'/wc/blocks'`
* @param {string} options.resourceName The name of the resource for the
* collection. Example:
* `'products/attributes'`
* @param {Array} options.resourceValues An array of values (in correct order)
* that are substituted in the route
* placeholders for the collection route.
* Example: `[10, 20]`
* @param {Object} options.query An object of key value pairs for the
* query to execute on the collection
* (optional). Example:
* `{ order: 'ASC', order_by: 'price' }`
* @param {boolean} options.shouldSelect If false, the previous results will be
* returned and internal selects will not
* fire.
* @param {Object} options An object declaring the various
* collection arguments.
* @param {string} options.namespace The namespace for the collection.
* Example: `'/wc/blocks'`
* @param {string} options.resourceName The name of the resource for the
* collection. Example:
* `'products/attributes'`
* @param {Array} [options.resourceValues] An array of values (in correct order)
* that are substituted in the route
* placeholders for the collection route.
* Example: `[10, 20]`
* @param {Object} [options.query] An object of key value pairs for the
* query to execute on the collection
* Example:
* `{ order: 'ASC', order_by: 'price' }`
* @param {boolean} [options.shouldSelect] If false, the previous results will be
* returned and internal selects will not
* fire.
*
* @return {Object} This hook will return an object with two properties:
* - results An array of collection items returned.

View File

@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { useDispatch } from '@wordpress/data';
import { useState } from '@wordpress/element';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* This is a custom hook for loading the selected shipping rate from the cart store and actions for selecting a rate.
See also: https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/master/src/RestApi/StoreApi
*
* @param {Array} shippingRates an array of packages with shipping rates.
* @return {Object} This hook will return an object with two properties:
* - selectShippingRate A function that immediately returns the selected
* rate and dispatches an action generator.
* - selectedShippingRates An array of selected shipping rates, maintained
* locally by a state and updated optimistically.
*/
export const useSelectShippingRate = ( shippingRates ) => {
const initiallySelectedRates = shippingRates.map(
// the API responds with those keys.
// eslint-disable-next-line camelcase
( p ) => p.shipping_rates.find( ( rate ) => rate.selected )?.rate_id
);
const [ selectedShippingRates, setSelectedShipping ] = useState(
initiallySelectedRates
);
const { selectShippingRate } = useDispatch( storeKey );
const setRate = ( newShippingRate, packageId ) => {
setSelectedShipping( [ newShippingRate ] );
selectShippingRate( newShippingRate, packageId );
};
return {
selectShippingRate: setRate,
selectedShippingRates,
};
};

View File

@ -1,12 +1,12 @@
/**
* External dependencies
*/
import { useDebounce } from 'use-debounce';
import { useSelect } from '@wordpress/data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { useCollection } from './use-collection';
import { useStoreCart } from './use-store-cart';
/**
* This is a custom hook that is wired up to the `wc/store/collections` data
@ -14,29 +14,27 @@ import { useCollection } from './use-collection';
* will ensure a component is kept up to date with the shipping rates matching that
* query in the store state.
*
* @param {Object} query An object containing any query arguments to be
* included with the collection request for the
* shipping rates. Does not have to be included.
*
* @return {Object} This hook will return an object with three properties:
* - shippingRates An array of shipping rate objects.
* - shippingRatesLoading A boolean indicating whether the shipping
* rates are still loading or not.
* - updateShipping An action dispatcher to update the shipping address.
*/
export const useShippingRates = ( query ) => {
const [ debouncedQuery ] = useDebounce( query, 300 );
export const useShippingRates = () => {
const { shippingRates } = useStoreCart();
const results = useSelect( ( select, { dispatch } ) => {
const store = select( storeKey );
const shippingRatesLoading = store.areShippingRatesLoading();
const { updateShippingAddress } = dispatch( storeKey );
const {
results: shippingRates,
isLoading: shippingRatesLoading,
} = useCollection( {
namespace: '/wc/store',
resourceName: 'cart/shipping-rates',
query: debouncedQuery,
} );
return {
shippingRatesLoading,
updateShippingAddress,
};
}, [] );
return {
shippingRates,
shippingRatesLoading,
...results,
};
};

View File

@ -51,6 +51,7 @@ export const useStoreCart = ( options = { shouldSelect: true } ) => {
return {
cartCoupons: cartData.coupons,
shippingRates: cartData.shippingRates,
cartItems: cartData.items,
cartItemsCount: cartData.itemsCount,
cartItemsWeight: cartData.itemsWeight,

View File

@ -29,6 +29,7 @@ const CartFrontend = ( {
cartTotals,
cartIsLoading,
cartCoupons,
shippingRates,
} = useStoreCart();
return (
@ -46,6 +47,7 @@ const CartFrontend = ( {
}
isShippingCostHidden={ isShippingCostHidden }
isLoading={ cartIsLoading }
shippingRates={ shippingRates }
/>
</LoadingMask>
) }

View File

@ -1,3 +1,4 @@
// @ts-nocheck
/**
* External dependencies
*/
@ -8,9 +9,7 @@ import {
TotalsCouponCodeInput,
TotalsItem,
} from '@woocommerce/base-components/totals';
import ShippingRatesControl, {
Packages,
} from '@woocommerce/base-components/shipping-rates-control';
import ShippingRatesControl from '@woocommerce/base-components/shipping-rates-control';
import ShippingCalculator from '@woocommerce/base-components/shipping-calculator';
import ShippingLocation from '@woocommerce/base-components/shipping-location';
import LoadingMask from '@woocommerce/base-components/loading-mask';
@ -24,7 +23,7 @@ import { getCurrencyFromPriceResponse } from '@woocommerce/base-utils';
import { Card, CardBody } from 'wordpress-components';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
import { decodeEntities } from '@wordpress/html-entities';
import { useStoreCartCoupons } from '@woocommerce/base-hooks';
import { useStoreCartCoupons, useShippingRates } from '@woocommerce/base-hooks';
import classnames from 'classnames';
import { __experimentalCreateInterpolateElement } from 'wordpress-element';
@ -55,6 +54,43 @@ const renderShippingRatesControlOption = ( option ) => ( {
),
} );
const ShippingCalculatorOptions = ( {
shippingRates,
shippingRatesLoading,
shippingAddress,
} ) => {
return (
<fieldset className="wc-block-cart__shipping-options-fieldset">
<legend className="screen-reader-text">
{ __(
'Choose the shipping method.',
'woo-gutenberg-products-block'
) }
</legend>
<ShippingRatesControl
className="wc-block-cart__shipping-options"
address={
shippingAddress
? {
city: shippingAddress.city,
state: shippingAddress.state,
postcode: shippingAddress.postcode,
country: shippingAddress.country,
}
: null
}
noResultsMessage={ __(
'No shipping options were found.',
'woo-gutenberg-products-block'
) }
renderOption={ renderShippingRatesControlOption }
shippingRates={ shippingRates }
shippingRatesLoading={ shippingRatesLoading }
/>
</fieldset>
);
};
/**
* Component that renders the Cart block when user has something in cart aka "full".
*/
@ -67,16 +103,8 @@ const Cart = ( {
shippingRates,
isLoading = false,
} ) => {
const [ selectedShippingRate, setSelectedShippingRate ] = useState();
const [
shippingCalculatorAddress,
setShippingCalculatorAddress,
] = useState( {
city: '',
state: '',
postcode: '',
country: '',
} );
const { updateShippingAddress, shippingRatesLoading } = useShippingRates();
const shippingAddress = shippingRates[ 0 ]?.destination;
const [ showShippingCosts, setShowShippingCosts ] = useState(
! isShippingCostHidden
);
@ -93,7 +121,7 @@ const Cart = ( {
}
if ( isShippingCalculatorEnabled ) {
if ( isShippingCostHidden ) {
if ( shippingCalculatorAddress.country ) {
if ( shippingAddress?.country ) {
return setShowShippingCosts( true );
}
} else {
@ -101,11 +129,7 @@ const Cart = ( {
}
}
return setShowShippingCosts( false );
}, [
isShippingCalculatorEnabled,
isShippingCostHidden,
shippingCalculatorAddress,
] );
}, [ isShippingCalculatorEnabled, isShippingCostHidden, shippingAddress ] );
/**
* Given an API response with cart totals, generates an array of rows to display in the Cart block.
@ -196,12 +220,10 @@ const Cart = ( {
: totalShipping,
description: (
<>
<ShippingLocation
address={ shippingCalculatorAddress }
/>
<ShippingLocation address={ shippingAddress } />
<ShippingCalculator
address={ shippingCalculatorAddress }
setAddress={ setShippingCalculatorAddress }
address={ shippingAddress }
setAddress={ updateShippingAddress }
/>
</>
),
@ -254,56 +276,13 @@ const Cart = ( {
'woo-gutenberg-products-block'
) }
</legend>
{ shippingRates ? (
<Packages
className="wc-block-cart__shipping-options"
renderOption={
renderShippingRatesControlOption
}
shippingRates={ shippingRates }
selected={ [
// This is only rendered in the editor, with placeholder
// shippingRates, so we can safely fallback to set the
// first shipping rate as selected and ignore setting
// an onChange prop.
shippingRates[ 0 ]
.shipping_rates[ 0 ].rate_id,
] }
/>
) : (
<ShippingRatesControl
className="wc-block-cart__shipping-options"
address={
shippingCalculatorAddress.country
? {
city:
shippingCalculatorAddress.city,
state:
shippingCalculatorAddress.state,
postcode:
shippingCalculatorAddress.postcode,
country:
shippingCalculatorAddress.country,
}
: null
}
noResultsMessage={ __(
'No shipping options were found.',
'woo-gutenberg-products-block'
) }
selected={ selectedShippingRate }
renderOption={
renderShippingRatesControlOption
}
onChange={ (
newSelectedShippingOption
) =>
setSelectedShippingRate(
newSelectedShippingOption
)
}
/>
) }
<ShippingCalculatorOptions
shippingRates={ shippingRates }
shippingRatesLoading={
shippingRatesLoading
}
shippingAddress={ shippingAddress }
/>
</fieldset>
) }
{ ! DISPLAY_CART_PRICES_INCLUDING_TAX && (

View File

@ -10,9 +10,7 @@ import FormStep from '@woocommerce/base-components/checkout/form-step';
import CheckoutForm from '@woocommerce/base-components/checkout/form';
import NoShipping from '@woocommerce/base-components/checkout/no-shipping';
import TextInput from '@woocommerce/base-components/text-input';
import ShippingRatesControl, {
Packages,
} from '@woocommerce/base-components/shipping-rates-control';
import ShippingRatesControl from '@woocommerce/base-components/shipping-rates-control';
import { CheckboxControl } from '@wordpress/components';
import { getCurrencyFromPriceResponse } from '@woocommerce/base-utils';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
@ -23,6 +21,7 @@ import {
} from '@woocommerce/base-components/payment-methods';
import { SHIPPING_ENABLED } from '@woocommerce/block-settings';
import { decodeEntities } from '@wordpress/html-entities';
import { useShippingRates } from '@woocommerce/base-hooks';
/**
* Internal dependencies
@ -31,6 +30,7 @@ import './style.scss';
import '../../../payment-methods-demo';
const Block = ( { attributes, isEditor = false, shippingRates = [] } ) => {
const { shippingRatesLoading } = useShippingRates();
const [ selectedShippingRate, setSelectedShippingRate ] = useState( {} );
const [ contactFields, setContactFields ] = useState( {} );
const [ shouldSavePayment, setShouldSavePayment ] = useState( true );
@ -228,55 +228,33 @@ const Block = ( { attributes, isEditor = false, shippingRates = [] } ) => {
'woo-gutenberg-products-block'
) }
>
{ shippingRates.length > 0 ? (
<Packages
renderOption={
renderShippingRatesControlOption
}
shippingRates={ shippingRates }
selected={ [
// This is only rendered in the editor, with placeholder
// shippingRates, so we can safely fallback to set the
// first shipping rate as selected and ignore setting
// an onChange prop.
shippingRates[ 0 ].shipping_rates[ 0 ]
.rate_id,
] }
/>
) : (
<ShippingRatesControl
address={
shippingFields.country
? {
address_1:
shippingFields.address_1,
address_2:
shippingFields.apartment,
city: shippingFields.city,
state: shippingFields.state,
postcode:
shippingFields.postcode,
country:
shippingFields.country,
}
: null
}
noResultsMessage={ __(
'There are no shipping options available. Please ensure that your address has been entered correctly, or contact us if you need any help.',
'woo-gutenberg-products-block'
) }
selected={ selectedShippingRate.methods }
renderOption={
renderShippingRatesControlOption
}
onChange={ ( newMethods ) =>
setSelectedShippingRate( {
...selectedShippingRate,
methods: newMethods,
} )
}
/>
) }
<ShippingRatesControl
address={
shippingFields.country
? {
address_1:
shippingFields.address_1,
address_2:
shippingFields.apartment,
city: shippingFields.city,
state: shippingFields.state,
postcode:
shippingFields.postcode,
country: shippingFields.country,
}
: null
}
noResultsMessage={ __(
'There are no shipping options available. Please ensure that your address has been entered correctly, or contact us if you need any help.',
'woo-gutenberg-products-block'
) }
renderOption={
renderShippingRatesControlOption
}
shippingRates={ shippingRates }
shippingRatesLoading={ shippingRatesLoading }
/>
<CheckboxControl
className="wc-block-checkout__add-note"
label="Add order notes?"

View File

@ -2,6 +2,7 @@
* External dependencies
*/
import { withRestApiHydration } from '@woocommerce/block-hocs';
import { useStoreCart } from '@woocommerce/base-hooks';
/**
* Internal dependencies
@ -10,6 +11,16 @@ import Block from './block.js';
import blockAttributes from './attributes';
import renderFrontend from '../../../utils/render-frontend.js';
/**
* Wrapper component to supply API data.
*
* @param {Object} attributes object of key value attributes passed to block.
*/
const CheckoutFrontend = ( attributes ) => {
const { shippingRates } = useStoreCart();
return <Block attributes={ attributes } shippingRates={ shippingRates } />;
};
const getProps = ( el ) => {
const attributes = {};
@ -33,6 +44,6 @@ const getProps = ( el ) => {
renderFrontend(
'.wp-block-woocommerce-checkout',
withRestApiHydration( Block ),
withRestApiHydration( CheckoutFrontend ),
getProps
);

View File

@ -7,4 +7,5 @@ export const ACTION_TYPES = {
RECEIVE_CART_ITEM: 'RECEIVE_CART_ITEM',
ITEM_QUANTITY_PENDING: 'ITEM_QUANTITY_PENDING',
RECEIVE_REMOVED_ITEM: 'RECEIVE_REMOVED_ITEM',
UPDATING_SHIPPING_ADDRESS: 'UPDATING_SHIPPING_ADDRESS',
};

View File

@ -244,3 +244,66 @@ export function* changeCartItemQuantity( cartItemKey, quantity ) {
yield itemQuantityPending( cartItemKey, false );
}
/**
* Selects a shipping rate.
*
* @param {string} rateId the id of the rate being selected.
* @param {number} [packageId] the key of the packages that we will select within.
*/
export function* selectShippingRate( rateId, packageId = 0 ) {
try {
const result = yield apiFetch( {
path: `/wc/store/cart/select-shipping-rate/${ packageId }`,
method: 'POST',
data: {
rate_id: rateId,
},
cache: 'no-store',
} );
if ( result ) {
yield receiveCart( result );
}
} catch ( error ) {
yield receiveError( error );
}
}
/**
* Returns an action object used to track what shipping address are we updating to.
*
* @param {boolean} isResolving if we're loading shipping address or not.
* @return {Object} Object for action.
*/
export function shippingRatesAreResolving( isResolving ) {
return {
type: types.UPDATING_SHIPPING_ADDRESS,
isResolving,
};
}
/**
* Applies a coupon code and either invalidates caches, or receives an error if
the coupon cannot be applied.
*
* @param {Object} address shipping address to be updated
*/
export function* updateShippingAddress( address ) {
yield shippingRatesAreResolving( true );
try {
const result = yield apiFetch( {
path: '/wc/store/cart/update-shipping',
method: 'POST',
data: address,
cache: 'no-store',
} );
if ( result ) {
yield receiveCart( result );
}
} catch ( error ) {
yield receiveError( error );
}
yield shippingRatesAreResolving( false );
}

View File

@ -1,3 +1,8 @@
/**
* External dependencies
*/
import { camelCase, mapKeys } from 'lodash';
/**
* Internal dependencies
*/
@ -41,6 +46,7 @@ const reducer = (
cartItemsQuantityPending: [],
cartData: {
coupons: [],
shippingRates: [],
items: [],
itemsCount: 0,
itemsWeight: 0,
@ -69,7 +75,9 @@ const reducer = (
state = {
...state,
errors: [],
cartData: action.response,
cartData: mapKeys( action.response, ( _, key ) =>
camelCase( key )
),
};
break;
case types.APPLYING_COUPON:
@ -117,6 +125,15 @@ const reducer = (
},
};
break;
case types.UPDATING_SHIPPING_ADDRESS:
state = {
...state,
metaData: {
...state.metaData,
updatingShipping: action.isResolving,
},
};
break;
}
return state;
};

View File

@ -2,7 +2,6 @@
* External dependencies
*/
import { select, apiFetch } from '@wordpress/data-controls';
import { camelCase, mapKeys } from 'lodash';
/**
* Internal dependencies
@ -25,11 +24,7 @@ export function* getCartData() {
return;
}
yield receiveCart(
mapKeys( cartData, ( _value, key ) => {
return camelCase( key );
} )
);
yield receiveCart( cartData );
}
/**

View File

@ -1,6 +1,16 @@
/** @typedef { import('@woocommerce/type-defs/cart').CartData } CartData */
/** @typedef { import('@woocommerce/type-defs/cart').CartTotals } CartTotals */
/**
* External dependencies
*/
import { createRegistrySelector } from '@wordpress/data';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
/**
* Retrieves cart data from state.
*
@ -130,3 +140,18 @@ export const getCartItem = ( state, cartItemKey ) => {
export const isItemQuantityPending = ( state, cartItemKey ) => {
return state.cartItemsQuantityPending.includes( cartItemKey );
};
/**
* Retrieves if the address is being applied for shipping.
*
* @param {Object} state The current state.
* @return {boolean} are shipping rates loading.
*/
export const areShippingRatesLoading = createRegistrySelector(
( select ) => ( state ) => {
return !! (
state.metaData.updatingShipping ||
select( STORE_KEY ).isResolving( 'getCartData', [] )
);
}
);

View File

@ -1,12 +1,33 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { __, _x } from '@wordpress/i18n';
export const previewShippingRates = [
{
destination: {},
items: {},
package_id: 0,
name: __( 'Shipping', 'woo-gutenberg-products-block' ),
items: [
{
key: '33e75ff09dd601bbe69f351039152189',
name: _x(
'Beanie with Logo',
'example product in Cart Block',
'woo-gutenberg-products-block'
),
quantity: 2,
},
{
key: '6512bd43d9caa6e02c990b0a82652dca',
name: _x(
'Beanie',
'example product in Cart Block',
'woo-gutenberg-products-block'
),
quantity: 1,
},
],
shipping_rates: [
{
currency_code: 'USD',
@ -21,6 +42,8 @@ export const previewShippingRates = [
delivery_time: '',
price: '200',
rate_id: 'free_shipping:1',
method_id: 'flat_rate',
selected: true,
},
{
currency_code: 'USD',
@ -35,6 +58,8 @@ export const previewShippingRates = [
delivery_time: '',
price: '0',
rate_id: 'local_pickup:1',
method_id: 'local_pickup',
selected: false,
},
],
},

View File

@ -49,6 +49,7 @@
* @typedef {Object} CartData
*
* @property {Array} coupons Coupons applied to cart.
* @property {Array} shippingRates array of selected shipping rates
* @property {Array} items Items in the cart.
* @property {number} itemsCount Number of items in the cart.
* @property {number} itemsWeight Weight of items in the cart.

View File

@ -6,6 +6,7 @@
* @typedef {Object} StoreCart
*
* @property {Array} cartCoupons An array of coupons applied to the cart.
* @property {Array} shippingRates array of selected shipping rates
* @property {Array} cartItems An array of items in the cart.
* @property {number} cartItemsCount The number of items in the cart.
* @property {number} cartItemsWeight The weight of all items in the cart.
@ -36,7 +37,7 @@
* @property {Function} isPending Callback for determining if a cart
* item is currently updating (i.e.
* remove / change quantity).
* @property {Function} changeQuantity Callback for changing quantity of item
* @property {Function} changeQuantity Callback for changing quantity of item
* in cart.
* @property {Function} removeItem Callback for removing a cart item.
*/

View File

@ -14,6 +14,7 @@ use \WP_Error as RestError;
use \WP_REST_Server as RestServer;
use \WP_REST_Controller as RestController;
use \WC_REST_Exception as RestException;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Controllers\CartShippingRates;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Schemas\CartSchema;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\CartController;
@ -141,8 +142,124 @@ class Cart extends RestController {
'schema' => [ $this, 'get_public_item_schema' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/update-shipping',
[
[
'methods' => 'POST',
'callback' => [ $this, 'update_shipping' ],
'args' => [
'address_1' => array(
'description' => __( 'First line of the address being shipped to.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'default' => '',
'sanitize_callback' => 'wc_clean',
'validate_callback' => 'rest_validate_request_arg',
),
'address_2' => [
'description' => __( 'Second line of the address being shipped to.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'default' => '',
'sanitize_callback' => 'wc_clean',
'validate_callback' => 'rest_validate_request_arg',
],
'city' => [
'description' => __( 'City of the address being shipped to.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'default' => '',
'sanitize_callback' => 'wc_clean',
'validate_callback' => 'rest_validate_request_arg',
],
'state' => [
'description' => __( 'ISO code, or name, for the state, province, or district of the address being shipped to.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'default' => '',
'sanitize_callback' => 'wc_clean',
'validate_callback' => 'rest_validate_request_arg',
],
'postcode' => [
'description' => __( 'Zip or Postcode of the address being shipped to.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'default' => '',
'sanitize_callback' => 'wc_clean',
'validate_callback' => 'rest_validate_request_arg',
],
'country' => [
'description' => __( 'ISO code for the country of the address being shipped to.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'default' => '',
'sanitize_callback' => 'wc_clean',
'validate_callback' => 'rest_validate_request_arg',
],
],
],
'schema' => [ $this, 'get_public_item_schema' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/select-shipping-rate/(?P<package_id>[\d]+)',
[
'args' => [
'package_id' => array(
'description' => __( 'The ID of the package being shipped.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'required' => true,
),
'rate_id' => [
'description' => __( 'The chosen rate ID for the package.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'required' => true,
],
],
[
'methods' => 'POST',
'callback' => [ $this, 'select_shipping_rate_for_package' ],
],
'schema' => [ $this, 'get_public_item_schema' ],
]
);
}
/**
* Select a shipping rate for the cart.
*
* This selects a shipping rate for a package and adds it to an array of selected shipping rates.
*
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_Error|\WP_REST_Response
*/
public function select_shipping_rate_for_package( $request ) {
if ( ! wc_shipping_enabled() ) {
return new RestError( 'woocommerce_rest_shipping_disabled', __( 'Shipping is disabled.', 'woo-gutenberg-products-block' ), array( 'status' => 404 ) );
}
if ( ! isset( $request['package_id'] ) || ! is_numeric( $request['package_id'] ) ) {
return new RestError( 'woocommerce_rest_cart_missing_package_id', __( 'Invalid Package ID.', 'woo-gutenberg-products-block' ), array( 'status' => 403 ) );
}
$controller = new CartController();
$cart = $controller->get_cart_instance();
if ( ! $cart || ! $cart instanceof \WC_Cart ) {
return new RestError( 'woocommerce_rest_cart_error', __( 'Unable to retrieve cart.', 'woo-gutenberg-products-block' ), array( 'status' => 500 ) );
}
if ( $cart->needs_shipping() ) {
$package_id = absint( $request['package_id'] );
$rate_id = wc_clean( wp_unslash( $request['rate_id'] ) );
try {
$controller->select_shipping_rate( $package_id, $rate_id );
} catch ( RestException $e ) {
return new RestError( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
}
}
$cart->calculate_totals();
return rest_ensure_response( $this->prepare_item_for_response( $cart, $request ) );
}
/**
* Apply a coupon to the cart.
*
@ -169,7 +286,6 @@ class Cart extends RestController {
} catch ( RestException $e ) {
return new RestError( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
}
$data = $this->prepare_item_for_response( $cart, $request );
$response = rest_ensure_response( $data );
@ -211,6 +327,52 @@ class Cart extends RestController {
return $response;
}
/**
* Given an address, gets updated shipping rates for the cart.
*
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_Error|\WP_REST_Response
*/
public function update_shipping( $request ) {
if ( ! wc_shipping_enabled() ) {
return new RestError( 'woocommerce_rest_shipping_disabled', __( 'Shipping is disabled.', 'woo-gutenberg-products-block' ), array( 'status' => 404 ) );
}
$controller = new CartController();
$cart = $controller->get_cart_instance();
if ( ! $cart || ! $cart instanceof \WC_Cart ) {
return new RestError( 'woocommerce_rest_cart_error', __( 'Unable to retrieve cart.', 'woo-gutenberg-products-block' ), array( 'status' => 500 ) );
}
$cart_shipping_rate_controller = new CartShippingRates();
$request = $cart_shipping_rate_controller->validate_shipping_address( $request );
if ( is_wp_error( $request ) ) {
return $request;
}
// Update customer session.
WC()->customer->set_props(
array(
'shipping_country' => isset( $request['country'] ) ? $request['country'] : null,
'shipping_state' => isset( $request['state'] ) ? $request['state'] : null,
'shipping_postcode' => isset( $request['postcode'] ) ? $request['postcode'] : null,
'shipping_city' => isset( $request['city'] ) ? $request['city'] : null,
'shipping_address_1' => isset( $request['address_1'] ) ? $request['address_1'] : null,
'shipping_address_2' => isset( $request['address_2'] ) ? $request['address_2'] : null,
)
);
WC()->customer->save();
$cart->calculate_shipping();
$cart->calculate_totals();
$data = $this->prepare_item_for_response( $cart, $request );
return rest_ensure_response( $data );
}
/**
* Get the cart.
*
@ -296,8 +458,6 @@ class Cart extends RestController {
* @return \WP_REST_Response Response object.
*/
public function prepare_item_for_response( $cart, $request ) {
$data = $this->schema->get_item_response( $cart );
return rest_ensure_response( $data );
return rest_ensure_response( $this->schema->get_item_response( $cart ) );
}
}

View File

@ -60,7 +60,49 @@ class CartShippingRates extends RestController {
'methods' => RestServer::READABLE,
'callback' => [ $this, 'get_items' ],
'args' => [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
'address_1' => [
'description' => __( 'First line of the address being shipped to.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'default' => '',
'sanitize_callback' => 'wc_clean',
'validate_callback' => 'rest_validate_request_arg',
],
'address_2' => [
'description' => __( 'Second line of the address being shipped to.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'default' => '',
'sanitize_callback' => 'wc_clean',
'validate_callback' => 'rest_validate_request_arg',
],
'city' => [
'description' => __( 'City of the address being shipped to.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'default' => '',
'sanitize_callback' => 'wc_clean',
'validate_callback' => 'rest_validate_request_arg',
],
'state' => [
'description' => __( 'ISO code, or name, for the state, province, or district of the address being shipped to.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'default' => '',
'sanitize_callback' => 'wc_clean',
'validate_callback' => 'rest_validate_request_arg',
],
'postcode' => [
'description' => __( 'Zip or Postcode of the address being shipped to.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'default' => '',
'sanitize_callback' => 'wc_clean',
'validate_callback' => 'rest_validate_request_arg',
],
'country' => [
'description' => __( 'ISO code for the country of the address being shipped to.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'default' => '',
'sanitize_callback' => 'wc_clean',
'validate_callback' => 'rest_validate_request_arg',
],
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
],
],
'schema' => [ $this, 'get_public_item_schema' ],
@ -75,6 +117,10 @@ class CartShippingRates extends RestController {
* @return \WP_Error|\WP_REST_Response
*/
public function get_items( $request ) {
if ( ! wc_shipping_enabled() ) {
return new RestError( 'woocommerce_rest_shipping_disabled', __( 'Shipping is disabled.', 'woo-gutenberg-products-block' ), array( 'status' => 404 ) );
}
$controller = new CartController();
$cart = $controller->get_cart_instance();
@ -82,24 +128,8 @@ class CartShippingRates extends RestController {
return new RestError( 'woocommerce_rest_cart_error', __( 'Unable to retrieve cart.', 'woo-gutenberg-products-block' ), array( 'status' => 500 ) );
}
if ( ! empty( $request['country'] ) ) {
$valid_countries = WC()->countries->get_shipping_countries();
if (
is_array( $valid_countries ) &&
count( $valid_countries ) > 0 &&
! array_key_exists( $request['country'], $valid_countries )
) {
return new RestError(
'woocommerce_rest_cart_shipping_rates_invalid_country',
sprintf(
/* translators: 1: valid country codes */
__( 'Destination country code is not valid. Please enter one of the following: %s', 'woo-gutenberg-products-block' ),
implode( ', ', array_keys( $valid_countries ) )
),
[ 'status' => 400 ]
);
}
if ( ! $cart->needs_shipping() ) {
return rest_ensure_response( [] );
}
$request = $this->validate_shipping_address( $request );
@ -108,22 +138,21 @@ class CartShippingRates extends RestController {
return $request;
}
$cart_items = $controller->get_cart_items(
function( $item ) {
return ! empty( $item['data'] ) && $item['data']->needs_shipping();
}
$packages = $controller->get_shipping_packages(
true,
[
'address_1' => $request['address_1'],
'address_2' => $request['address_2'],
'city' => $request['city'],
'state' => $request['state'],
'postcode' => $request['postcode'],
'country' => $request['country'],
]
);
if ( empty( $cart_items ) ) {
return rest_ensure_response( [] );
}
$packages = $this->get_shipping_packages( $request );
$response = [];
foreach ( $packages as $key => $package ) {
$package['key'] = $key;
$response[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $package, $request ) );
foreach ( $packages as $package ) {
$response[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $package, $request ) );
}
return rest_ensure_response( $response );
@ -135,8 +164,39 @@ class CartShippingRates extends RestController {
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_Error|\WP_REST_Response
*/
protected function validate_shipping_address( $request ) {
$request['country'] = wc_strtoupper( $request['country'] );
public function validate_shipping_address( $request ) {
$valid_countries = WC()->countries->get_shipping_countries();
if ( empty( $request['country'] ) ) {
return new RestError(
'woocommerce_rest_cart_shipping_rates_missing_country',
sprintf(
/* translators: 1: valid country codes */
__( 'No destination country code was given. Please provide one of the following: %s', 'woo-gutenberg-products-block' ),
implode( ', ', array_keys( $valid_countries ) )
),
[ 'status' => 400 ]
);
}
$request['country'] = wc_strtoupper( $request['country'] );
if (
is_array( $valid_countries ) &&
count( $valid_countries ) > 0 &&
! array_key_exists( $request['country'], $valid_countries )
) {
return new RestError(
'woocommerce_rest_cart_shipping_rates_invalid_country',
sprintf(
/* translators: 1: valid country codes */
__( 'Destination country code is not valid. Please enter one of the following: %s', 'woo-gutenberg-products-block' ),
implode( ', ', array_keys( $valid_countries ) )
),
[ 'status' => 400 ]
);
}
$request['postcode'] = $request['postcode'] ? wc_format_postcode( $request['postcode'], $request['country'] ) : null;
if ( ! empty( $request['state'] ) ) {
@ -249,34 +309,4 @@ class CartShippingRates extends RestController {
return $params;
}
/**
* Get packages with calculated shipping.
*
* Based on WC_Cart::get_shipping_packages but allows the destination to be
* customised based on passed params.
*
* @param \WP_REST_Request $request Request object.
* @return array of cart items
*/
protected function get_shipping_packages( $request ) {
$packages = WC()->cart->get_shipping_packages();
if ( $request['country'] ) {
foreach ( $packages as $key => $package ) {
$packages[ $key ]['destination'] = [
'address_1' => $request['address_1'],
'address_2' => $request['address_2'],
'city' => $request['city'],
'state' => $request['state'],
'postcode' => $request['postcode'],
'country' => $request['country'],
];
}
}
$packages = WC()->shipping()->calculate_shipping( $packages );
return $packages;
}
}

View File

@ -7,6 +7,8 @@
namespace Automattic\WooCommerce\Blocks\RestApi\StoreApi\Schemas;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\CartController;
defined( 'ABSPATH' ) || exit;
/**
@ -39,6 +41,16 @@ class CartSchema extends AbstractSchema {
'properties' => $this->force_schema_readonly( ( new CartCouponSchema() )->get_properties() ),
],
],
'shipping_rates' => [
'description' => __( 'List of available shipping rates for the cart.', 'woo-gutenberg-products-block' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => $this->force_schema_readonly( ( new CartShippingRateSchema() )->get_properties() ),
],
],
'items' => [
'description' => __( 'List of cart items.', 'woo-gutenberg-products-block' ),
'type' => 'array',
@ -171,12 +183,15 @@ class CartSchema extends AbstractSchema {
* @return array
*/
public function get_item_response( $cart ) {
$cart_coupon_schema = new CartCouponSchema();
$cart_item_schema = new CartItemSchema();
$context = 'edit';
$controller = new CartController();
$cart_coupon_schema = new CartCouponSchema();
$cart_item_schema = new CartItemSchema();
$shipping_rate_schema = new CartShippingRateSchema();
$context = 'edit';
return [
'coupons' => array_values( array_map( [ $cart_coupon_schema, 'get_item_response' ], array_filter( $cart->get_applied_coupons() ) ) ),
'shipping_rates' => array_values( array_map( [ $shipping_rate_schema, 'get_item_response' ], $controller->get_shipping_packages() ) ),
'items' => array_values( array_map( [ $cart_item_schema, 'get_item_response' ], array_filter( $cart->get_cart() ) ) ),
'items_count' => $cart->get_cart_contents_count(),
'items_weight' => wc_get_weight( $cart->get_cart_contents_weight(), 'g' ),

View File

@ -29,6 +29,18 @@ class CartShippingRateSchema extends AbstractSchema {
*/
protected function get_properties() {
return [
'package_id' => [
'description' => __( 'The ID of the package the shipping rates belong to.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Name of the package.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'destination' => [
'description' => __( 'Shipping destination address.', 'woo-gutenberg-products-block' ),
'type' => 'object',
@ -74,13 +86,19 @@ class CartShippingRateSchema extends AbstractSchema {
],
],
'items' => [
'description' => __( 'List of cart items (keys) the returned shipping rates apply to.', 'woo-gutenberg-products-block' ),
'description' => __( 'List of cart items the returned shipping rates apply to.', 'woo-gutenberg-products-block' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => [
'type' => 'object',
'properties' => [
'key' => [
'description' => __( 'Unique identifier for the item within the cart.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Name of the item.', 'woo-gutenberg-products-block' ),
'type' => 'string',
@ -96,12 +114,6 @@ class CartShippingRateSchema extends AbstractSchema {
],
],
],
'name' => [
'description' => __( 'Name of the package.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'shipping_rates' => [
'description' => __( 'List of shipping rates.', 'woo-gutenberg-products-block' ),
'type' => 'array',
@ -122,8 +134,13 @@ class CartShippingRateSchema extends AbstractSchema {
*/
protected function get_rate_properties() {
return array_merge(
$this->get_store_currency_properties(),
[
'rate_id' => [
'description' => __( 'ID of the shipping rate.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Name of the shipping rate, e.g. Express shipping.', 'woo-gutenberg-products-block' ),
'type' => 'string',
@ -148,12 +165,6 @@ class CartShippingRateSchema extends AbstractSchema {
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'rate_id' => [
'description' => __( 'ID of the shipping rate.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'method_id' => [
'description' => __( 'ID of the shipping method that provided the rate.', 'woo-gutenberg-products-block' ),
'type' => 'string',
@ -188,7 +199,14 @@ class CartShippingRateSchema extends AbstractSchema {
],
],
],
]
'selected' => [
'description' => __( 'True if this is the rate currently selected by the customer for the cart.', 'woo-gutenberg-products-block' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
$this->get_store_currency_properties()
);
}
@ -202,13 +220,31 @@ class CartShippingRateSchema extends AbstractSchema {
// Add product names and quantities.
$items = array();
foreach ( $package['contents'] as $item_id => $values ) {
$items[ $item_id ] = [
$items[] = [
'key' => $item_id,
'name' => $values['data']->get_name(),
'quantity' => $values['quantity'],
];
}
// Generate package name.
$package_number = absint( $package['package_id'] ) + 1;
$package_display_name = apply_filters(
'woocommerce_shipping_package_name',
$package_number > 1 ?
sprintf(
/* translators: %d: shipping package number */
_x( 'Shipping %d', 'shipping packages', 'woo-gutenberg-products-block' ),
$package_number
) :
_x( 'Shipping', 'shipping packages', 'woo-gutenberg-products-block' ),
$package['package_id'],
$package
);
return [
'package_id' => $package['package_id'],
'name' => $package_display_name,
'destination' => (object) $this->prepare_html_response(
[
'address_1' => $package['destination']['address_1'],
@ -220,38 +256,55 @@ class CartShippingRateSchema extends AbstractSchema {
]
),
'items' => $items,
'name' => apply_filters(
'woocommerce_shipping_package_name',
( $package['key'] > 0 ) ?
/* translators: %d: shipping package number */
sprintf( _x( 'Shipping %d', 'shipping packages', 'woo-gutenberg-products-block' ), ( $package['key'] + 1 ) ) :
_x( 'Shipping', 'shipping packages', 'woo-gutenberg-products-block' ),
$package['key'],
$package
),
'shipping_rates' => array_values( array_map( [ $this, 'get_rate_response' ], $package['rates'] ) ),
'shipping_rates' => $this->prepare_rates_response( $package ),
];
}
/**
* Prepare an array of rates from a package for the response.
*
* @param array $package Shipping package complete with rates from WooCommerce.
* @return array
*/
protected function prepare_rates_response( $package ) {
$rates = $package['rates'];
$selected_rates = WC()->session->get( 'chosen_shipping_methods', array() );
$selected_rate = isset( $chosen_shipping_methods[ $package['package_id'] ] ) ? $chosen_shipping_methods[ $package['package_id'] ] : '';
if ( empty( $selected_rate ) && ! empty( $package['rates'] ) ) {
$selected_rate = wc_get_chosen_shipping_method_for_package( $package['package_id'], $package );
}
$response = [];
foreach ( $package['rates'] as $rate ) {
$response[] = $this->get_rate_response( $rate, $selected_rate );
}
return $response;
}
/**
* Response for a single rate.
*
* @param WC_Shipping_Rate $rate Rate object.
* @param string $selected_rate Selected rate.
* @return array
*/
protected function get_rate_response( $rate ) {
protected function get_rate_response( $rate, $selected_rate = '' ) {
return array_merge(
$this->get_store_currency_response(),
[
'rate_id' => $this->get_rate_prop( $rate, 'id' ),
'name' => $this->prepare_html_response( $this->get_rate_prop( $rate, 'label' ) ),
'description' => $this->prepare_html_response( $this->get_rate_prop( $rate, 'description' ) ),
'delivery_time' => $this->prepare_html_response( $this->get_rate_prop( $rate, 'delivery_time' ) ),
'price' => $this->prepare_money_response( $this->get_rate_prop( $rate, 'cost' ), wc_get_price_decimals() ),
'rate_id' => $this->get_rate_prop( $rate, 'id' ),
'instance_id' => $this->get_rate_prop( $rate, 'instance_id' ),
'method_id' => $this->get_rate_prop( $rate, 'method_id' ),
'meta_data' => $this->get_rate_meta_data( $rate ),
]
'selected' => $selected_rate === $this->get_rate_prop( $rate, 'id' ),
],
$this->get_store_currency_response()
);
}

View File

@ -209,6 +209,43 @@ class CartController {
return $callback ? array_filter( wc()->cart->get_applied_coupons(), $callback ) : array_filter( wc()->cart->get_applied_coupons() );
}
/**
* Get shipping packages from the cart and format as required.
*
* @param bool $calculate_rates Should rates for the packages also be returned.
* @param array $destination Pass an address to override the package destination before calculation.
* @return array
*/
public function get_shipping_packages( $calculate_rates = true, $destination = [] ) {
$cart = $this->get_cart_instance();
$packages = $cart->get_shipping_packages();
// Add package ID to array.
foreach ( $packages as $key => $package ) {
$packages[ $key ]['package_id'] = $key;
if ( ! empty( $destination ) ) {
$packages[ $key ]['destination'] = $destination;
}
}
return $calculate_rates ? WC()->shipping()->calculate_shipping( $packages ) : $packages;
}
/**
* Selects a shipping rate.
*
* @param int $package_id ID of the package to choose a rate for.
* @param string $rate_id ID of the rate being chosen.
*/
public function select_shipping_rate( $package_id, $rate_id ) {
$cart = $this->get_cart_instance();
$session_data = WC()->session->get( 'chosen_shipping_methods' ) ? WC()->session->get( 'chosen_shipping_methods' ) : [];
$session_data[ $package_id ] = $rate_id;
WC()->session->set( 'chosen_shipping_methods', $session_data );
}
/**
* Based on the core cart class but returns errors rather than rendering notices directly.
*

View File

@ -11,6 +11,7 @@ use \WP_REST_Request;
use \WC_REST_Unit_Test_Case as TestCase;
use \WC_Helper_Product as ProductHelper;
use \WC_Helper_Coupon as CouponHelper;
use \WC_Helper_Shipping;
use Automattic\WooCommerce\Blocks\Tests\Helpers\ValidateSchema;
/**
@ -48,6 +49,8 @@ class Cart extends TestCase {
$this->keys[] = wc()->cart->add_to_cart( $this->products[0]->get_id(), 2 );
$this->keys[] = wc()->cart->add_to_cart( $this->products[1]->get_id(), 1 );
wc()->cart->apply_coupon( $this->coupon->get_code() );
WC_Helper_Shipping::create_simple_flat_rate();
}
/**
@ -56,6 +59,10 @@ class Cart extends TestCase {
public function test_register_routes() {
$routes = $this->server->get_routes();
$this->assertArrayHasKey( '/wc/store/cart', $routes );
$this->assertArrayHasKey( '/wc/store/cart/apply-coupon', $routes );
$this->assertArrayHasKey( '/wc/store/cart/remove-coupon', $routes );
$this->assertArrayHasKey( '/wc/store/cart/update-shipping', $routes );
$this->assertArrayHasKey( '/wc/store/cart/select-shipping-rate/(?P<package_id>[\d]+)', $routes );
}
/**
@ -68,7 +75,7 @@ class Cart extends TestCase {
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 3, $data['items_count'] );
$this->assertEquals( 2, count( $data['items'] ) );
$this->assertEquals( false, $data['needs_shipping'] );
$this->assertEquals( true, $data['needs_shipping'] );
$this->assertEquals( '30', $data['items_weight'] );
$this->assertEquals( 'GBP', $data['totals']->currency_code );
@ -151,6 +158,111 @@ class Cart extends TestCase {
$this->assertEquals( '11000', $data['totals']->total_items );
}
/**
* Test getting updated shipping.
*/
public function test_update_shipping() {
$request = new WP_REST_Request( 'POST', '/wc/store/cart/update-shipping' );
$request->set_body_params(
array(
'country' => 'US',
)
);
$response = $this->server->dispatch( $request );
$data = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertArrayHasKey( 'shipping_rates', $data );
$this->assertEquals( null, $data['shipping_rates'][0]['destination']->address_1 );
$this->assertEquals( null, $data['shipping_rates'][0]['destination']->address_2 );
$this->assertEquals( null, $data['shipping_rates'][0]['destination']->city );
$this->assertEquals( null, $data['shipping_rates'][0]['destination']->state );
$this->assertEquals( null, $data['shipping_rates'][0]['destination']->postcode );
$this->assertEquals( 'US', $data['shipping_rates'][0]['destination']->country );
}
/**
* Test shipping address validation.
*/
public function test_get_items_address_validation() {
// US address.
$request = new WP_REST_Request( 'POST', '/wc/store/cart/update-shipping' );
$request->set_body_params(
array(
'address_1' => 'Test address 1',
'address_2' => 'Test address 2',
'city' => 'Test City',
'state' => 'AL',
'postcode' => '90210',
'country' => 'US'
)
);
$response = $this->server->dispatch( $request );
$data = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 'Test address 1', $data['shipping_rates'][0]['destination']->address_1 );
$this->assertEquals( 'Test address 2', $data['shipping_rates'][0]['destination']->address_2 );
$this->assertEquals( 'Test City', $data['shipping_rates'][0]['destination']->city );
$this->assertEquals( 'AL', $data['shipping_rates'][0]['destination']->state );
$this->assertEquals( '90210', $data['shipping_rates'][0]['destination']->postcode );
$this->assertEquals( 'US', $data['shipping_rates'][0]['destination']->country );
// Address with empty country.
$request = new WP_REST_Request( 'POST', '/wc/store/cart/update-shipping' );
$request->set_body_params(
array(
'country' => ''
)
);
$response = $this->server->dispatch( $request );
$data = $response->get_data();
$this->assertEquals( 400, $response->get_status() );
// Address with invalid country.
$request = new WP_REST_Request( 'POST', '/wc/store/cart/update-shipping' );
$request->set_body_params(
array(
'country' => 'ZZZZZZZZ'
)
);
$response = $this->server->dispatch( $request );
$data = $response->get_data();
$this->assertEquals( 400, $response->get_status() );
// US address with named state.
$request = new WP_REST_Request( 'POST', '/wc/store/cart/update-shipping' );
$request->set_body_params(
array(
'state' =>'Alabama',
'country' => 'US'
)
);
$response = $this->server->dispatch( $request );
$data = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 'AL', $data['shipping_rates'][0]['destination']->state );
$this->assertEquals( 'US', $data['shipping_rates'][0]['destination']->country );
// US address with invalid state.
$request = new WP_REST_Request( 'POST', '/wc/store/cart/update-shipping' );
$request->set_body_params(
array(
'state' =>'ZZZZZZZZ',
'country' => 'US'
)
);
$response = $this->server->dispatch( $request );
$data = $response->get_data();
$this->assertEquals( 400, $response->get_status() );
}
/**
* Test applying coupon to cart.
*/
@ -230,6 +342,8 @@ class Cart extends TestCase {
$this->assertArrayHasKey( 'items_count', $schema['properties'] );
$this->assertArrayHasKey( 'items', $schema['properties'] );
$this->assertArrayHasKey( 'shipping_rates', $schema['properties'] );
$this->assertArrayHasKey( 'coupons', $schema['properties'] );
$this->assertArrayHasKey( 'needs_shipping', $schema['properties'] );
$this->assertArrayHasKey( 'items_weight', $schema['properties'] );
$this->assertArrayHasKey( 'totals', $schema['properties'] );
@ -246,6 +360,8 @@ class Cart extends TestCase {
$this->assertArrayHasKey( 'items_count', $data );
$this->assertArrayHasKey( 'items', $data );
$this->assertArrayHasKey( 'shipping_rates', $data );
$this->assertArrayHasKey( 'coupons', $data );
$this->assertArrayHasKey( 'needs_shipping', $data );
$this->assertArrayHasKey( 'items_weight', $data );
$this->assertArrayHasKey( 'totals', $data );

View File

@ -103,7 +103,7 @@ class CartShippingRates extends TestCase {
$response = $this->server->dispatch( $request );
$data = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 400, $response->get_status() );
// Address with invalid country.
$request = new WP_REST_Request( 'GET', '/wc/store/cart/shipping-rates' );
@ -171,8 +171,8 @@ class CartShippingRates extends TestCase {
*/
public function test_prepare_item_for_response() {
$controller = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Controllers\CartShippingRates();
$packages = wc()->shipping->calculate_shipping( wc()->cart->get_shipping_packages() );
$packages[0]['key'] = 0;
$cart_controller = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\CartController();
$packages = $cart_controller->get_shipping_packages();
$response = $controller->prepare_item_for_response( current( $packages ), [] );
$data = $response->get_data();