initial commit
This commit is contained in:
@ -0,0 +1,3 @@
|
||||
export * from './use-store-cart';
|
||||
export * from './use-store-cart-coupons';
|
||||
export * from './use-store-cart-item-quantity';
|
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import * as mockUseStoreCart from '../use-store-cart';
|
||||
import { useStoreCartItemQuantity } from '../use-store-cart-item-quantity';
|
||||
|
||||
jest.mock( '../use-store-cart', () => ( {
|
||||
useStoreCart: jest.fn(),
|
||||
} ) );
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
__esModule: true,
|
||||
CART_STORE_KEY: 'test/store',
|
||||
} ) );
|
||||
|
||||
// Make debounce instantaneous.
|
||||
jest.mock( 'use-debounce', () => ( {
|
||||
useDebounce: ( a ) => [ a ],
|
||||
} ) );
|
||||
|
||||
describe( 'useStoreCartItemQuantity', () => {
|
||||
let registry, renderer;
|
||||
|
||||
const getWrappedComponents = ( Component ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent = ( options ) => () => {
|
||||
const props = useStoreCartItemQuantity( options );
|
||||
return <div { ...props } />;
|
||||
};
|
||||
|
||||
let mockRemoveItemFromCart;
|
||||
let mockChangeCartItemQuantity;
|
||||
const setupMocks = ( { isPendingDelete, isPendingQuantity } ) => {
|
||||
mockRemoveItemFromCart = jest
|
||||
.fn()
|
||||
.mockReturnValue( { type: 'removeItemFromCartAction' } );
|
||||
mockChangeCartItemQuantity = jest
|
||||
.fn()
|
||||
.mockReturnValue( { type: 'changeCartItemQuantityAction' } );
|
||||
registry.registerStore( storeKey, {
|
||||
reducer: () => ( {} ),
|
||||
actions: {
|
||||
removeItemFromCart: mockRemoveItemFromCart,
|
||||
changeCartItemQuantity: mockChangeCartItemQuantity,
|
||||
},
|
||||
selectors: {
|
||||
isItemPendingDelete: jest
|
||||
.fn()
|
||||
.mockReturnValue( isPendingDelete ),
|
||||
isItemPendingQuantity: jest
|
||||
.fn()
|
||||
.mockReturnValue( isPendingQuantity ),
|
||||
},
|
||||
} );
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry();
|
||||
renderer = null;
|
||||
} );
|
||||
|
||||
afterEach( () => {
|
||||
mockRemoveItemFromCart.mockReset();
|
||||
mockChangeCartItemQuantity.mockReset();
|
||||
} );
|
||||
|
||||
describe( 'with no errors and not pending', () => {
|
||||
beforeEach( () => {
|
||||
setupMocks( { isPendingDelete: false, isPendingQuantity: false } );
|
||||
mockUseStoreCart.useStoreCart.mockReturnValue( {
|
||||
cartErrors: {},
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'update quantity value should happen instantly', () => {
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { setItemQuantity, quantity } = renderer.root.findByType(
|
||||
'div'
|
||||
).props;
|
||||
|
||||
expect( quantity ).toBe( 1 );
|
||||
|
||||
act( () => {
|
||||
setItemQuantity( 2 );
|
||||
} );
|
||||
|
||||
const { quantity: newQuantity } = renderer.root.findByType(
|
||||
'div'
|
||||
).props;
|
||||
|
||||
expect( newQuantity ).toBe( 2 );
|
||||
} );
|
||||
|
||||
it( 'removeItem should call the dispatch action', () => {
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { removeItem } = renderer.root.findByType( 'div' ).props;
|
||||
|
||||
act( () => {
|
||||
removeItem();
|
||||
} );
|
||||
|
||||
expect( mockRemoveItemFromCart ).toHaveBeenCalledWith( '123' );
|
||||
} );
|
||||
|
||||
it( 'setItemQuantity should call the dispatch action', () => {
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { setItemQuantity } = renderer.root.findByType( 'div' ).props;
|
||||
|
||||
act( () => {
|
||||
setItemQuantity( 2 );
|
||||
} );
|
||||
|
||||
expect( mockChangeCartItemQuantity.mock.calls ).toEqual( [
|
||||
[ '123', 2 ],
|
||||
] );
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'should expose store errors', () => {
|
||||
const mockCartErrors = [ { message: 'Test error' } ];
|
||||
setupMocks( { isPendingDelete: false, isPendingQuantity: false } );
|
||||
mockUseStoreCart.useStoreCart.mockReturnValue( {
|
||||
cartErrors: mockCartErrors,
|
||||
} );
|
||||
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { cartItemQuantityErrors } = renderer.root.findByType(
|
||||
'div'
|
||||
).props;
|
||||
|
||||
expect( cartItemQuantityErrors ).toEqual( mockCartErrors );
|
||||
} );
|
||||
|
||||
it( 'isPendingDelete should depend on the value provided by the store', () => {
|
||||
setupMocks( { isPendingDelete: true, isPendingQuantity: false } );
|
||||
mockUseStoreCart.useStoreCart.mockReturnValue( {
|
||||
cartErrors: {},
|
||||
} );
|
||||
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { isPendingDelete } = renderer.root.findByType( 'div' ).props;
|
||||
|
||||
expect( isPendingDelete ).toBe( true );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,235 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { previewCart } from '@woocommerce/resource-previews';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { defaultCartData, useStoreCart } from '../use-store-cart';
|
||||
import { useEditorContext } from '../../../providers/editor-context';
|
||||
|
||||
jest.mock( '../../../providers/editor-context', () => ( {
|
||||
useEditorContext: jest.fn(),
|
||||
} ) );
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
...jest.requireActual( '@woocommerce/block-data' ),
|
||||
__esModule: true,
|
||||
CART_STORE_KEY: 'test/store',
|
||||
} ) );
|
||||
|
||||
describe( 'useStoreCart', () => {
|
||||
let registry, renderer;
|
||||
|
||||
const receiveCartMock = () => {};
|
||||
|
||||
const previewCartData = {
|
||||
cartCoupons: previewCart.coupons,
|
||||
cartItems: previewCart.items,
|
||||
cartFees: previewCart.fees,
|
||||
cartItemsCount: previewCart.items_count,
|
||||
cartItemsWeight: previewCart.items_weight,
|
||||
cartNeedsPayment: previewCart.needs_payment,
|
||||
cartNeedsShipping: previewCart.needs_shipping,
|
||||
cartTotals: previewCart.totals,
|
||||
cartIsLoading: false,
|
||||
cartItemErrors: [],
|
||||
cartErrors: [],
|
||||
billingAddress: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
},
|
||||
shippingAddress: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
phone: '',
|
||||
},
|
||||
shippingRates: previewCart.shipping_rates,
|
||||
extensions: {},
|
||||
shippingRatesLoading: false,
|
||||
cartHasCalculatedShipping: true,
|
||||
};
|
||||
|
||||
const mockCartItems = [ { key: '1', id: 1, name: 'Lorem Ipsum' } ];
|
||||
const mockShippingAddress = {
|
||||
city: 'New York',
|
||||
};
|
||||
const mockCartData = {
|
||||
coupons: [],
|
||||
items: mockCartItems,
|
||||
fees: [],
|
||||
itemsCount: 1,
|
||||
itemsWeight: 10,
|
||||
needsPayment: true,
|
||||
needsShipping: true,
|
||||
billingAddress: {},
|
||||
shippingAddress: mockShippingAddress,
|
||||
shippingRates: [],
|
||||
hasCalculatedShipping: true,
|
||||
extensions: {},
|
||||
errors: [],
|
||||
receiveCart: undefined,
|
||||
paymentRequirements: [],
|
||||
};
|
||||
const mockCartTotals = {
|
||||
currency_code: 'USD',
|
||||
};
|
||||
const mockCartIsLoading = false;
|
||||
const mockCartErrors = [];
|
||||
const mockStoreCartData = {
|
||||
cartCoupons: [],
|
||||
cartItems: mockCartItems,
|
||||
cartItemErrors: [],
|
||||
cartItemsCount: 1,
|
||||
cartItemsWeight: 10,
|
||||
cartNeedsPayment: true,
|
||||
cartNeedsShipping: true,
|
||||
cartTotals: mockCartTotals,
|
||||
cartIsLoading: mockCartIsLoading,
|
||||
cartErrors: mockCartErrors,
|
||||
cartFees: [],
|
||||
billingAddress: {},
|
||||
shippingAddress: mockShippingAddress,
|
||||
shippingRates: [],
|
||||
extensions: {},
|
||||
shippingRatesLoading: false,
|
||||
cartHasCalculatedShipping: true,
|
||||
receiveCart: undefined,
|
||||
paymentRequirements: [],
|
||||
};
|
||||
|
||||
const getWrappedComponents = ( Component ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent = ( options ) => () => {
|
||||
const { receiveCart, ...results } = useStoreCart( options );
|
||||
return <div results={ results } receiveCart={ receiveCart } />;
|
||||
};
|
||||
|
||||
const setUpMocks = () => {
|
||||
const mocks = {
|
||||
selectors: {
|
||||
getCartData: jest.fn().mockReturnValue( mockCartData ),
|
||||
getCartErrors: jest.fn().mockReturnValue( mockCartErrors ),
|
||||
getCartTotals: jest.fn().mockReturnValue( mockCartTotals ),
|
||||
hasFinishedResolution: jest
|
||||
.fn()
|
||||
.mockReturnValue( ! mockCartIsLoading ),
|
||||
isCustomerDataUpdating: jest.fn().mockReturnValue( false ),
|
||||
},
|
||||
};
|
||||
registry.registerStore( storeKey, {
|
||||
reducer: () => ( {} ),
|
||||
selectors: mocks.selectors,
|
||||
} );
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry();
|
||||
renderer = null;
|
||||
setUpMocks();
|
||||
} );
|
||||
|
||||
afterEach( () => {
|
||||
useEditorContext.mockReset();
|
||||
} );
|
||||
|
||||
describe( 'in frontend', () => {
|
||||
beforeEach( () => {
|
||||
useEditorContext.mockReturnValue( {
|
||||
isEditor: false,
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'return default data when shouldSelect is false', () => {
|
||||
const TestComponent = getTestComponent( { shouldSelect: false } );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { results, receiveCart } = renderer.root.findByType(
|
||||
'div'
|
||||
).props;
|
||||
const {
|
||||
receiveCart: defaultReceiveCart,
|
||||
...remaining
|
||||
} = defaultCartData;
|
||||
expect( results ).toEqual( remaining );
|
||||
expect( receiveCart ).toEqual( defaultReceiveCart );
|
||||
} );
|
||||
|
||||
it( 'return store data when shouldSelect is true', () => {
|
||||
const TestComponent = getTestComponent( { shouldSelect: true } );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { results, receiveCart } = renderer.root.findByType(
|
||||
'div'
|
||||
).props;
|
||||
|
||||
expect( results ).toEqual( mockStoreCartData );
|
||||
expect( receiveCart ).toBeUndefined();
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'in editor', () => {
|
||||
beforeEach( () => {
|
||||
useEditorContext.mockReturnValue( {
|
||||
isEditor: true,
|
||||
previewData: {
|
||||
previewCart: {
|
||||
...previewCart,
|
||||
receiveCart: receiveCartMock,
|
||||
},
|
||||
},
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'return preview data in editor', () => {
|
||||
const TestComponent = getTestComponent();
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { results, receiveCart } = renderer.root.findByType(
|
||||
'div'
|
||||
).props;
|
||||
|
||||
expect( results ).toEqual( previewCartData );
|
||||
expect( receiveCart ).toEqual( receiveCartMock );
|
||||
} );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,126 @@
|
||||
/** @typedef { import('@woocommerce/type-defs/hooks').StoreCartCoupon } StoreCartCoupon */
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import type { StoreCartCoupon } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from './use-store-cart';
|
||||
import { useStoreSnackbarNotices } from '../use-store-snackbar-notices';
|
||||
import { useValidationContext } from '../../providers/validation';
|
||||
import { useStoreNotices } from '../use-store-notices';
|
||||
|
||||
/**
|
||||
* This is a custom hook for loading the Store API /cart/coupons endpoint and an
|
||||
* action for adding a coupon _to_ the cart.
|
||||
* See also: https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/trunk/src/RestApi/StoreApi
|
||||
*
|
||||
* @return {StoreCartCoupon} An object exposing data and actions from/for the
|
||||
* store api /cart/coupons endpoint.
|
||||
*/
|
||||
export const useStoreCartCoupons = (): StoreCartCoupon => {
|
||||
const { cartCoupons, cartIsLoading } = useStoreCart();
|
||||
const { addErrorNotice } = useStoreNotices();
|
||||
const { addSnackbarNotice } = useStoreSnackbarNotices();
|
||||
const { setValidationErrors } = useValidationContext();
|
||||
|
||||
const results: Pick<
|
||||
StoreCartCoupon,
|
||||
'applyCoupon' | 'removeCoupon' | 'isApplyingCoupon' | 'isRemovingCoupon'
|
||||
> = useSelect(
|
||||
( select, { dispatch } ) => {
|
||||
const store = select( storeKey );
|
||||
const isApplyingCoupon = store.isApplyingCoupon();
|
||||
const isRemovingCoupon = store.isRemovingCoupon();
|
||||
const {
|
||||
applyCoupon,
|
||||
removeCoupon,
|
||||
receiveApplyingCoupon,
|
||||
}: {
|
||||
applyCoupon: ( coupon: string ) => Promise< boolean >;
|
||||
removeCoupon: ( coupon: string ) => Promise< boolean >;
|
||||
receiveApplyingCoupon: ( coupon: string ) => void;
|
||||
} = dispatch( storeKey );
|
||||
|
||||
const applyCouponWithNotices = ( couponCode: string ) => {
|
||||
applyCoupon( couponCode )
|
||||
.then( ( result ) => {
|
||||
if ( result === true ) {
|
||||
addSnackbarNotice(
|
||||
sprintf(
|
||||
/* translators: %s coupon code. */
|
||||
__(
|
||||
'Coupon code "%s" has been applied to your cart.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
couponCode
|
||||
),
|
||||
{
|
||||
id: 'coupon-form',
|
||||
}
|
||||
);
|
||||
}
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
setValidationErrors( {
|
||||
coupon: {
|
||||
message: decodeEntities( error.message ),
|
||||
hidden: false,
|
||||
},
|
||||
} );
|
||||
// Finished handling the coupon.
|
||||
receiveApplyingCoupon( '' );
|
||||
} );
|
||||
};
|
||||
|
||||
const removeCouponWithNotices = ( couponCode: string ) => {
|
||||
removeCoupon( couponCode )
|
||||
.then( ( result ) => {
|
||||
if ( result === true ) {
|
||||
addSnackbarNotice(
|
||||
sprintf(
|
||||
/* translators: %s coupon code. */
|
||||
__(
|
||||
'Coupon code "%s" has been removed from your cart.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
couponCode
|
||||
),
|
||||
{
|
||||
id: 'coupon-form',
|
||||
}
|
||||
);
|
||||
}
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
addErrorNotice( error.message, {
|
||||
id: 'coupon-form',
|
||||
} );
|
||||
// Finished handling the coupon.
|
||||
receiveApplyingCoupon( '' );
|
||||
} );
|
||||
};
|
||||
|
||||
return {
|
||||
applyCoupon: applyCouponWithNotices,
|
||||
removeCoupon: removeCouponWithNotices,
|
||||
isApplyingCoupon,
|
||||
isRemovingCoupon,
|
||||
};
|
||||
},
|
||||
[ addErrorNotice, addSnackbarNotice ]
|
||||
);
|
||||
|
||||
return {
|
||||
appliedCoupons: cartCoupons,
|
||||
isLoading: cartIsLoading,
|
||||
...results,
|
||||
};
|
||||
};
|
@ -0,0 +1,146 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
import { useCallback, useState, useEffect } from '@wordpress/element';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { usePrevious } from '@woocommerce/base-hooks';
|
||||
import { triggerFragmentRefresh } from '@woocommerce/base-utils';
|
||||
import {
|
||||
CartItem,
|
||||
StoreCartItemQuantity,
|
||||
isNumber,
|
||||
isObject,
|
||||
isString,
|
||||
objectHasProp,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from './use-store-cart';
|
||||
import { useCheckoutContext } from '../../providers/cart-checkout';
|
||||
|
||||
/**
|
||||
* Ensures the object passed has props key: string and quantity: number
|
||||
*/
|
||||
const cartItemHasQuantityAndKey = (
|
||||
cartItem: unknown /* Object that may have quantity and key */
|
||||
): cartItem is Partial< CartItem > =>
|
||||
isObject( cartItem ) &&
|
||||
objectHasProp( cartItem, 'key' ) &&
|
||||
objectHasProp( cartItem, 'quantity' ) &&
|
||||
isString( cartItem.key ) &&
|
||||
isNumber( cartItem.quantity );
|
||||
|
||||
/**
|
||||
* This is a custom hook for loading the Store API /cart/ endpoint and actions for removing or changing item quantity.
|
||||
*
|
||||
* @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/trunk/src/RestApi/StoreApi
|
||||
*
|
||||
* @param {CartItem} cartItem The cartItem to get quantity info from and will have quantity updated on.
|
||||
* @return {StoreCartItemQuantity} An object exposing data and actions relating to cart items.
|
||||
*/
|
||||
export const useStoreCartItemQuantity = (
|
||||
cartItem: CartItem | Record< string, unknown >
|
||||
): StoreCartItemQuantity => {
|
||||
const verifiedCartItem = { key: '', quantity: 1 };
|
||||
|
||||
if ( cartItemHasQuantityAndKey( cartItem ) ) {
|
||||
verifiedCartItem.key = cartItem.key;
|
||||
verifiedCartItem.quantity = cartItem.quantity;
|
||||
}
|
||||
const {
|
||||
key: cartItemKey = '',
|
||||
quantity: cartItemQuantity = 1,
|
||||
} = verifiedCartItem;
|
||||
const { cartErrors } = useStoreCart();
|
||||
const { dispatchActions } = useCheckoutContext();
|
||||
|
||||
// Store quantity in hook state. This is used to keep the UI updated while server request is updated.
|
||||
const [ quantity, setQuantity ] = useState< number >( cartItemQuantity );
|
||||
const [ debouncedQuantity ] = useDebounce< number >( quantity, 400 );
|
||||
const previousDebouncedQuantity = usePrevious( debouncedQuantity );
|
||||
const { removeItemFromCart, changeCartItemQuantity } = useDispatch(
|
||||
storeKey
|
||||
);
|
||||
|
||||
// Track when things are already pending updates.
|
||||
const isPending = useSelect(
|
||||
( select ) => {
|
||||
if ( ! cartItemKey ) {
|
||||
return {
|
||||
quantity: false,
|
||||
delete: false,
|
||||
};
|
||||
}
|
||||
const store = select( storeKey );
|
||||
return {
|
||||
quantity: store.isItemPendingQuantity( cartItemKey ),
|
||||
delete: store.isItemPendingDelete( cartItemKey ),
|
||||
};
|
||||
},
|
||||
[ cartItemKey ]
|
||||
);
|
||||
|
||||
const removeItem = useCallback( () => {
|
||||
return cartItemKey
|
||||
? removeItemFromCart( cartItemKey ).then( () => {
|
||||
triggerFragmentRefresh();
|
||||
return true;
|
||||
} )
|
||||
: Promise.resolve( false );
|
||||
}, [ cartItemKey, removeItemFromCart ] );
|
||||
|
||||
// Observe debounced quantity value, fire action to update server on change.
|
||||
useEffect( () => {
|
||||
if (
|
||||
cartItemKey &&
|
||||
isNumber( previousDebouncedQuantity ) &&
|
||||
Number.isFinite( previousDebouncedQuantity ) &&
|
||||
previousDebouncedQuantity !== debouncedQuantity
|
||||
) {
|
||||
changeCartItemQuantity( cartItemKey, debouncedQuantity );
|
||||
}
|
||||
}, [
|
||||
cartItemKey,
|
||||
changeCartItemQuantity,
|
||||
debouncedQuantity,
|
||||
previousDebouncedQuantity,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( isPending.delete ) {
|
||||
dispatchActions.incrementCalculating();
|
||||
} else {
|
||||
dispatchActions.decrementCalculating();
|
||||
}
|
||||
return () => {
|
||||
if ( isPending.delete ) {
|
||||
dispatchActions.decrementCalculating();
|
||||
}
|
||||
};
|
||||
}, [ dispatchActions, isPending.delete ] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( isPending.quantity || debouncedQuantity !== quantity ) {
|
||||
dispatchActions.incrementCalculating();
|
||||
} else {
|
||||
dispatchActions.decrementCalculating();
|
||||
}
|
||||
return () => {
|
||||
if ( isPending.quantity || debouncedQuantity !== quantity ) {
|
||||
dispatchActions.decrementCalculating();
|
||||
}
|
||||
};
|
||||
}, [ dispatchActions, isPending.quantity, debouncedQuantity, quantity ] );
|
||||
|
||||
return {
|
||||
isPendingDelete: isPending.delete,
|
||||
quantity,
|
||||
setItemQuantity: setQuantity,
|
||||
removeItem,
|
||||
cartItemQuantityErrors: cartErrors,
|
||||
};
|
||||
};
|
@ -0,0 +1,240 @@
|
||||
/** @typedef { import('@woocommerce/type-defs/hooks').StoreCart } StoreCart */
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { isEqual } from 'lodash';
|
||||
import { useRef } from '@wordpress/element';
|
||||
import {
|
||||
CART_STORE_KEY as storeKey,
|
||||
EMPTY_CART_COUPONS,
|
||||
EMPTY_CART_ITEMS,
|
||||
EMPTY_CART_FEES,
|
||||
EMPTY_CART_ITEM_ERRORS,
|
||||
EMPTY_CART_ERRORS,
|
||||
EMPTY_SHIPPING_RATES,
|
||||
EMPTY_TAX_LINES,
|
||||
EMPTY_PAYMENT_REQUIREMENTS,
|
||||
EMPTY_EXTENSIONS,
|
||||
} from '@woocommerce/block-data';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import type {
|
||||
StoreCart,
|
||||
CartResponseTotals,
|
||||
CartResponseFeeItem,
|
||||
CartResponseBillingAddress,
|
||||
CartResponseShippingAddress,
|
||||
CartResponseCouponItem,
|
||||
CartResponseCoupons,
|
||||
} from '@woocommerce/types';
|
||||
import {
|
||||
emptyHiddenAddressFields,
|
||||
fromEntriesPolyfill,
|
||||
} from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useEditorContext } from '../../providers/editor-context';
|
||||
|
||||
declare module '@wordpress/html-entities' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
export function decodeEntities< T >( coupon: T ): T;
|
||||
}
|
||||
const defaultShippingAddress: CartResponseShippingAddress = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
phone: '',
|
||||
};
|
||||
|
||||
const defaultBillingAddress: CartResponseBillingAddress = {
|
||||
...defaultShippingAddress,
|
||||
email: '',
|
||||
};
|
||||
|
||||
const defaultCartTotals: CartResponseTotals = {
|
||||
total_items: '',
|
||||
total_items_tax: '',
|
||||
total_fees: '',
|
||||
total_fees_tax: '',
|
||||
total_discount: '',
|
||||
total_discount_tax: '',
|
||||
total_shipping: '',
|
||||
total_shipping_tax: '',
|
||||
total_price: '',
|
||||
total_tax: '',
|
||||
tax_lines: EMPTY_TAX_LINES,
|
||||
currency_code: '',
|
||||
currency_symbol: '',
|
||||
currency_minor_unit: 2,
|
||||
currency_decimal_separator: '',
|
||||
currency_thousand_separator: '',
|
||||
currency_prefix: '',
|
||||
currency_suffix: '',
|
||||
};
|
||||
|
||||
const decodeValues = (
|
||||
object: Record< string, unknown >
|
||||
): Record< string, unknown > =>
|
||||
fromEntriesPolyfill(
|
||||
Object.entries( object ).map( ( [ key, value ] ) => [
|
||||
key,
|
||||
decodeEntities( value ),
|
||||
] )
|
||||
);
|
||||
|
||||
/**
|
||||
* @constant
|
||||
* @type {StoreCart} Object containing cart data.
|
||||
*/
|
||||
export const defaultCartData: StoreCart = {
|
||||
cartCoupons: EMPTY_CART_COUPONS,
|
||||
cartItems: EMPTY_CART_ITEMS,
|
||||
cartFees: EMPTY_CART_FEES,
|
||||
cartItemsCount: 0,
|
||||
cartItemsWeight: 0,
|
||||
cartNeedsPayment: true,
|
||||
cartNeedsShipping: true,
|
||||
cartItemErrors: EMPTY_CART_ITEM_ERRORS,
|
||||
cartTotals: defaultCartTotals,
|
||||
cartIsLoading: true,
|
||||
cartErrors: EMPTY_CART_ERRORS,
|
||||
billingAddress: defaultBillingAddress,
|
||||
shippingAddress: defaultShippingAddress,
|
||||
shippingRates: EMPTY_SHIPPING_RATES,
|
||||
shippingRatesLoading: false,
|
||||
cartHasCalculatedShipping: false,
|
||||
paymentRequirements: EMPTY_PAYMENT_REQUIREMENTS,
|
||||
receiveCart: () => undefined,
|
||||
extensions: EMPTY_EXTENSIONS,
|
||||
};
|
||||
|
||||
/**
|
||||
* This is a custom hook that is wired up to the `wc/store/cart` data
|
||||
* store.
|
||||
*
|
||||
* @param {Object} options An object declaring the various
|
||||
* collection arguments.
|
||||
* @param {boolean} options.shouldSelect If false, the previous results will be
|
||||
* returned and internal selects will not
|
||||
* fire.
|
||||
*
|
||||
* @return {StoreCart} Object containing cart data.
|
||||
*/
|
||||
|
||||
export const useStoreCart = (
|
||||
options: { shouldSelect: boolean } = { shouldSelect: true }
|
||||
): StoreCart => {
|
||||
const { isEditor, previewData } = useEditorContext();
|
||||
const previewCart = previewData?.previewCart;
|
||||
const { shouldSelect } = options;
|
||||
const currentResults = useRef();
|
||||
const results: StoreCart = useSelect(
|
||||
( select, { dispatch } ) => {
|
||||
if ( ! shouldSelect ) {
|
||||
return defaultCartData;
|
||||
}
|
||||
|
||||
if ( isEditor ) {
|
||||
return {
|
||||
cartCoupons: previewCart.coupons,
|
||||
cartItems: previewCart.items,
|
||||
cartFees: previewCart.fees,
|
||||
cartItemsCount: previewCart.items_count,
|
||||
cartItemsWeight: previewCart.items_weight,
|
||||
cartNeedsPayment: previewCart.needs_payment,
|
||||
cartNeedsShipping: previewCart.needs_shipping,
|
||||
cartItemErrors: EMPTY_CART_ITEM_ERRORS,
|
||||
cartTotals: previewCart.totals,
|
||||
cartIsLoading: false,
|
||||
cartErrors: EMPTY_CART_ERRORS,
|
||||
billingAddress: defaultBillingAddress,
|
||||
shippingAddress: defaultShippingAddress,
|
||||
extensions: EMPTY_EXTENSIONS,
|
||||
shippingRates: previewCart.shipping_rates,
|
||||
shippingRatesLoading: false,
|
||||
cartHasCalculatedShipping:
|
||||
previewCart.has_calculated_shipping,
|
||||
paymentRequirements: previewCart.paymentRequirements,
|
||||
receiveCart:
|
||||
typeof previewCart?.receiveCart === 'function'
|
||||
? previewCart.receiveCart
|
||||
: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const store = select( storeKey );
|
||||
const cartData = store.getCartData();
|
||||
const cartErrors = store.getCartErrors();
|
||||
const cartTotals = store.getCartTotals();
|
||||
const cartIsLoading = ! store.hasFinishedResolution(
|
||||
'getCartData'
|
||||
);
|
||||
const shippingRatesLoading = store.isCustomerDataUpdating();
|
||||
const { receiveCart } = dispatch( storeKey );
|
||||
const billingAddress = decodeValues( cartData.billingAddress );
|
||||
const shippingAddress = cartData.needsShipping
|
||||
? decodeValues( cartData.shippingAddress )
|
||||
: billingAddress;
|
||||
const cartFees =
|
||||
cartData.fees.length > 0
|
||||
? cartData.fees.map( ( fee: CartResponseFeeItem ) =>
|
||||
decodeValues( fee )
|
||||
)
|
||||
: EMPTY_CART_FEES;
|
||||
|
||||
// Add a text property to the coupon to allow extensions to modify
|
||||
// the text used to display the coupon, without affecting the
|
||||
// functionality when it comes to removing the coupon.
|
||||
const cartCoupons: CartResponseCoupons =
|
||||
cartData.coupons.length > 0
|
||||
? cartData.coupons.map(
|
||||
( coupon: CartResponseCouponItem ) => ( {
|
||||
...coupon,
|
||||
label: coupon.code,
|
||||
} )
|
||||
)
|
||||
: EMPTY_CART_COUPONS;
|
||||
|
||||
return {
|
||||
cartCoupons,
|
||||
cartItems: cartData.items,
|
||||
cartFees,
|
||||
cartItemsCount: cartData.itemsCount,
|
||||
cartItemsWeight: cartData.itemsWeight,
|
||||
cartNeedsPayment: cartData.needsPayment,
|
||||
cartNeedsShipping: cartData.needsShipping,
|
||||
cartItemErrors: cartData.errors,
|
||||
cartTotals,
|
||||
cartIsLoading,
|
||||
cartErrors,
|
||||
billingAddress: emptyHiddenAddressFields( billingAddress ),
|
||||
shippingAddress: emptyHiddenAddressFields( shippingAddress ),
|
||||
extensions: cartData.extensions,
|
||||
shippingRates: cartData.shippingRates,
|
||||
shippingRatesLoading,
|
||||
cartHasCalculatedShipping: cartData.hasCalculatedShipping,
|
||||
paymentRequirements: cartData.paymentRequirements,
|
||||
receiveCart,
|
||||
};
|
||||
},
|
||||
[ shouldSelect ]
|
||||
);
|
||||
|
||||
if (
|
||||
! currentResults.current ||
|
||||
! isEqual( currentResults.current, results )
|
||||
) {
|
||||
currentResults.current = results;
|
||||
}
|
||||
|
||||
return currentResults.current;
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export * from './use-collection-data';
|
||||
export * from './use-collection-header';
|
||||
export * from './use-collection';
|
@ -0,0 +1,298 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { Component as ReactComponent } from '@wordpress/element';
|
||||
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCollection } from '../use-collection';
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
__esModule: true,
|
||||
COLLECTIONS_STORE_KEY: 'test/store',
|
||||
} ) );
|
||||
|
||||
class TestErrorBoundary extends ReactComponent {
|
||||
constructor( props ) {
|
||||
super( props );
|
||||
this.state = { hasError: false, error: {} };
|
||||
}
|
||||
static getDerivedStateFromError( error ) {
|
||||
// Update state so the next render will show the fallback UI.
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if ( this.state.hasError ) {
|
||||
return <div error={ this.state.error } />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
describe( 'useCollection', () => {
|
||||
let registry, mocks, renderer;
|
||||
const getProps = ( testRenderer ) => {
|
||||
const { results, isLoading } = testRenderer.root.findByType(
|
||||
'div'
|
||||
).props;
|
||||
return {
|
||||
results,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
|
||||
const getWrappedComponents = ( Component, props ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<TestErrorBoundary>
|
||||
<Component { ...props } />
|
||||
</TestErrorBoundary>
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent = () => ( { options } ) => {
|
||||
const items = useCollection( options );
|
||||
return <div { ...items } />;
|
||||
};
|
||||
|
||||
const setUpMocks = () => {
|
||||
mocks = {
|
||||
selectors: {
|
||||
getCollectionError: jest.fn().mockReturnValue( false ),
|
||||
getCollection: jest
|
||||
.fn()
|
||||
.mockImplementation( () => ( { foo: 'bar' } ) ),
|
||||
hasFinishedResolution: jest.fn().mockReturnValue( true ),
|
||||
},
|
||||
};
|
||||
registry.registerStore( storeKey, {
|
||||
reducer: () => ( {} ),
|
||||
selectors: mocks.selectors,
|
||||
} );
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry();
|
||||
mocks = {};
|
||||
renderer = null;
|
||||
setUpMocks();
|
||||
} );
|
||||
it(
|
||||
'should throw an error if an options object is provided without ' +
|
||||
'a namespace property',
|
||||
() => {
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
resourceName: 'products',
|
||||
query: { bar: 'foo' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const props = renderer.root.findByType( 'div' ).props;
|
||||
expect( props.error.message ).toMatch( /options object/ );
|
||||
expect( console ).toHaveErrored( /your React components:/ );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
it(
|
||||
'should throw an error if an options object is provided without ' +
|
||||
'a resourceName property',
|
||||
() => {
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
query: { bar: 'foo' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const props = renderer.root.findByType( 'div' ).props;
|
||||
expect( props.error.message ).toMatch( /options object/ );
|
||||
expect( console ).toHaveErrored( /your React components:/ );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
it(
|
||||
'should return expected behaviour for equivalent query on props ' +
|
||||
'across renders',
|
||||
() => {
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
query: { bar: 'foo' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results } = getProps( renderer );
|
||||
// rerender
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
query: { bar: 'foo' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
// re-render should result in same products object because although
|
||||
// query-state is a different instance, it's still equivalent.
|
||||
const { results: newResults } = getProps( renderer );
|
||||
expect( newResults ).toBe( results );
|
||||
// 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, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
query: { foo: 'bar' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results: resultsVerification } = getProps( renderer );
|
||||
expect( resultsVerification ).not.toBe( results );
|
||||
expect( resultsVerification ).toEqual( results );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
it(
|
||||
'should return expected behaviour for equivalent resourceValues on' +
|
||||
' props across renders',
|
||||
() => {
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
resourceValues: [ 10, 20 ],
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results } = getProps( renderer );
|
||||
// rerender
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
resourceValues: [ 10, 20 ],
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
// re-render should result in same products object because although
|
||||
// query-state is a different instance, it's still equivalent.
|
||||
const { results: newResults } = getProps( renderer );
|
||||
expect( newResults ).toBe( results );
|
||||
// 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, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
resourceValues: [ 20, 10 ],
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results: resultsVerification } = getProps( renderer );
|
||||
expect( resultsVerification ).not.toBe( results );
|
||||
expect( resultsVerification ).toEqual( results );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
it( 'should return previous query results if `shouldSelect` is false', () => {
|
||||
mocks.selectors.getCollection.mockImplementation(
|
||||
( state, ...args ) => {
|
||||
return args;
|
||||
}
|
||||
);
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
resourceValues: [ 10, 20 ],
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results } = getProps( renderer );
|
||||
// rerender but with shouldSelect to false
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'productsb',
|
||||
resourceValues: [ 10, 30 ],
|
||||
shouldSelect: false,
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results: results2 } = getProps( renderer );
|
||||
expect( results2 ).toBe( results );
|
||||
// expect 2 calls because internally, useSelect invokes callback twice
|
||||
// on mount.
|
||||
expect( mocks.selectors.getCollection ).toHaveBeenCalledTimes( 2 );
|
||||
|
||||
// rerender again but set shouldSelect to true again and we should see
|
||||
// new results
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'productsb',
|
||||
resourceValues: [ 10, 30 ],
|
||||
shouldSelect: true,
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results: results3 } = getProps( renderer );
|
||||
expect( results3 ).not.toEqual( results );
|
||||
expect( results3 ).toEqual( [
|
||||
'test/store',
|
||||
'productsb',
|
||||
{},
|
||||
[ 10, 30 ],
|
||||
] );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect, useMemo } from '@wordpress/element';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { sortBy } from 'lodash';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useQueryStateByContext, useQueryStateByKey } from '../use-query-state';
|
||||
import { useCollection } from './use-collection';
|
||||
import { useQueryStateContext } from '../../providers/query-state-context';
|
||||
|
||||
const buildCollectionDataQuery = ( collectionDataQueryState ) => {
|
||||
const query = collectionDataQueryState;
|
||||
|
||||
if ( collectionDataQueryState.calculate_attribute_counts ) {
|
||||
query.calculate_attribute_counts = sortBy(
|
||||
collectionDataQueryState.calculate_attribute_counts.map(
|
||||
( { taxonomy, queryType } ) => {
|
||||
return {
|
||||
taxonomy,
|
||||
query_type: queryType,
|
||||
};
|
||||
}
|
||||
),
|
||||
[ 'taxonomy', 'query_type' ]
|
||||
);
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const useCollectionData = ( {
|
||||
queryAttribute,
|
||||
queryPrices,
|
||||
queryStock,
|
||||
queryState,
|
||||
} ) => {
|
||||
let context = useQueryStateContext();
|
||||
context = `${ context }-collection-data`;
|
||||
|
||||
const [ collectionDataQueryState ] = useQueryStateByContext( context );
|
||||
const [
|
||||
calculateAttributesQueryState,
|
||||
setCalculateAttributesQueryState,
|
||||
] = useQueryStateByKey( 'calculate_attribute_counts', [], context );
|
||||
const [
|
||||
calculatePriceRangeQueryState,
|
||||
setCalculatePriceRangeQueryState,
|
||||
] = useQueryStateByKey( 'calculate_price_range', null, context );
|
||||
const [
|
||||
calculateStockStatusQueryState,
|
||||
setCalculateStockStatusQueryState,
|
||||
] = useQueryStateByKey( 'calculate_stock_status_counts', null, context );
|
||||
|
||||
const currentQueryAttribute = useShallowEqual( queryAttribute || {} );
|
||||
const currentQueryPrices = useShallowEqual( queryPrices );
|
||||
const currentQueryStock = useShallowEqual( queryStock );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
typeof currentQueryAttribute === 'object' &&
|
||||
Object.keys( currentQueryAttribute ).length
|
||||
) {
|
||||
const foundAttribute = calculateAttributesQueryState.find(
|
||||
( attribute ) => {
|
||||
return (
|
||||
attribute.taxonomy === currentQueryAttribute.taxonomy
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if ( ! foundAttribute ) {
|
||||
setCalculateAttributesQueryState( [
|
||||
...calculateAttributesQueryState,
|
||||
currentQueryAttribute,
|
||||
] );
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentQueryAttribute,
|
||||
calculateAttributesQueryState,
|
||||
setCalculateAttributesQueryState,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
calculatePriceRangeQueryState !== currentQueryPrices &&
|
||||
currentQueryPrices !== undefined
|
||||
) {
|
||||
setCalculatePriceRangeQueryState( currentQueryPrices );
|
||||
}
|
||||
}, [
|
||||
currentQueryPrices,
|
||||
setCalculatePriceRangeQueryState,
|
||||
calculatePriceRangeQueryState,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
calculateStockStatusQueryState !== currentQueryStock &&
|
||||
currentQueryStock !== undefined
|
||||
) {
|
||||
setCalculateStockStatusQueryState( currentQueryStock );
|
||||
}
|
||||
}, [
|
||||
currentQueryStock,
|
||||
setCalculateStockStatusQueryState,
|
||||
calculateStockStatusQueryState,
|
||||
] );
|
||||
|
||||
// Defer the select query so all collection-data query vars can be gathered.
|
||||
const [ shouldSelect, setShouldSelect ] = useState( false );
|
||||
const [ debouncedShouldSelect ] = useDebounce( shouldSelect, 200 );
|
||||
|
||||
if ( ! shouldSelect ) {
|
||||
setShouldSelect( true );
|
||||
}
|
||||
|
||||
const collectionDataQueryVars = useMemo( () => {
|
||||
return buildCollectionDataQuery( collectionDataQueryState );
|
||||
}, [ collectionDataQueryState ] );
|
||||
|
||||
return useCollection( {
|
||||
namespace: '/wc/store',
|
||||
resourceName: 'products/collection-data',
|
||||
query: {
|
||||
...queryState,
|
||||
page: undefined,
|
||||
per_page: undefined,
|
||||
orderby: undefined,
|
||||
order: undefined,
|
||||
...collectionDataQueryVars,
|
||||
},
|
||||
shouldSelect: debouncedShouldSelect,
|
||||
} );
|
||||
};
|
@ -0,0 +1,86 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* This is a custom hook that is wired up to the `wc/store/collections` data
|
||||
* store. Given a header key and a collections option object, this will ensure a
|
||||
* component is kept up to date with the collection header value matching that
|
||||
* query in the store state.
|
||||
*
|
||||
* @param {string} headerKey Used to indicate which header value to
|
||||
* return for the given collection query.
|
||||
* Example: `'x-wp-total'`
|
||||
* @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' }`
|
||||
*
|
||||
* @return {Object} This hook will return an object with two properties:
|
||||
* - value Whatever value is attached to the specified
|
||||
* header.
|
||||
* - isLoading A boolean indicating whether the header is
|
||||
* loading (true) or not.
|
||||
*/
|
||||
export const useCollectionHeader = ( headerKey, options ) => {
|
||||
const {
|
||||
namespace,
|
||||
resourceName,
|
||||
resourceValues = [],
|
||||
query = {},
|
||||
} = options;
|
||||
if ( ! namespace || ! resourceName ) {
|
||||
throw new Error(
|
||||
'The options object must have valid values for the namespace and ' +
|
||||
'the resource name properties.'
|
||||
);
|
||||
}
|
||||
// ensure we feed the previous reference if it's equivalent
|
||||
const currentQuery = useShallowEqual( query );
|
||||
const currentResourceValues = useShallowEqual( resourceValues );
|
||||
const { value, isLoading = true } = useSelect(
|
||||
( select ) => {
|
||||
const store = select( storeKey );
|
||||
// filter out query if it is undefined.
|
||||
const args = [
|
||||
headerKey,
|
||||
namespace,
|
||||
resourceName,
|
||||
currentQuery,
|
||||
currentResourceValues,
|
||||
];
|
||||
return {
|
||||
value: store.getCollectionHeader( ...args ),
|
||||
isLoading: store.hasFinishedResolution(
|
||||
'getCollectionHeader',
|
||||
args
|
||||
),
|
||||
};
|
||||
},
|
||||
[
|
||||
headerKey,
|
||||
namespace,
|
||||
resourceName,
|
||||
currentResourceValues,
|
||||
currentQuery,
|
||||
]
|
||||
);
|
||||
return {
|
||||
value,
|
||||
isLoading,
|
||||
};
|
||||
};
|
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { useRef } from '@wordpress/element';
|
||||
import { useShallowEqual, useThrowError } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* This is a custom hook that is wired up to the `wc/store/collections` data
|
||||
* store. Given a collections option object, this will ensure a component is
|
||||
* kept up to date with the collection matching that query in the store state.
|
||||
*
|
||||
* @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
|
||||
* 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.
|
||||
* - isLoading A boolean indicating whether the collection is
|
||||
* loading (true) or not.
|
||||
*/
|
||||
export const useCollection = ( options ) => {
|
||||
const {
|
||||
namespace,
|
||||
resourceName,
|
||||
resourceValues = [],
|
||||
query = {},
|
||||
shouldSelect = true,
|
||||
} = options;
|
||||
if ( ! namespace || ! resourceName ) {
|
||||
throw new Error(
|
||||
'The options object must have valid values for the namespace and ' +
|
||||
'the resource properties.'
|
||||
);
|
||||
}
|
||||
const currentResults = useRef( { results: [], isLoading: true } );
|
||||
// ensure we feed the previous reference if it's equivalent
|
||||
const currentQuery = useShallowEqual( query );
|
||||
const currentResourceValues = useShallowEqual( resourceValues );
|
||||
const throwError = useThrowError();
|
||||
const results = useSelect(
|
||||
( select ) => {
|
||||
if ( ! shouldSelect ) {
|
||||
return null;
|
||||
}
|
||||
const store = select( storeKey );
|
||||
const args = [
|
||||
namespace,
|
||||
resourceName,
|
||||
currentQuery,
|
||||
currentResourceValues,
|
||||
];
|
||||
const error = store.getCollectionError( ...args );
|
||||
|
||||
if ( error ) {
|
||||
throwError( error );
|
||||
}
|
||||
|
||||
return {
|
||||
results: store.getCollection( ...args ),
|
||||
isLoading: ! store.hasFinishedResolution(
|
||||
'getCollection',
|
||||
args
|
||||
),
|
||||
};
|
||||
},
|
||||
[
|
||||
namespace,
|
||||
resourceName,
|
||||
currentResourceValues,
|
||||
currentQuery,
|
||||
shouldSelect,
|
||||
]
|
||||
);
|
||||
// if selector was not bailed, then update current results. Otherwise return
|
||||
// previous results
|
||||
if ( results !== null ) {
|
||||
currentResults.current = results;
|
||||
}
|
||||
return currentResults.current;
|
||||
};
|
@ -0,0 +1,16 @@
|
||||
export * from './cart';
|
||||
export * from './collections';
|
||||
export * from './shipping';
|
||||
export * from './payment-methods';
|
||||
export * from './use-store-notices';
|
||||
export * from './use-store-events';
|
||||
export * from './use-query-state';
|
||||
export * from './use-store-products';
|
||||
export * from './use-store-add-to-cart';
|
||||
export * from './use-customer-data';
|
||||
export * from './use-checkout-address';
|
||||
export * from './use-checkout-notices';
|
||||
export * from './use-checkout-submit';
|
||||
export * from './use-emit-response';
|
||||
export * from './use-checkout-extension-data';
|
||||
export * from './use-validation';
|
@ -0,0 +1,2 @@
|
||||
export { usePaymentMethodInterface } from './use-payment-method-interface';
|
||||
export * from './use-payment-methods';
|
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { prepareTotalItems } from '../utils';
|
||||
|
||||
describe( 'prepareTotalItems', () => {
|
||||
const fixture = {
|
||||
total_items: '200',
|
||||
total_items_tax: '20',
|
||||
total_fees: '100',
|
||||
total_fees_tax: '10',
|
||||
total_discount: '350',
|
||||
total_discount_tax: '50',
|
||||
total_shipping: '50',
|
||||
total_shipping_tax: '5',
|
||||
total_tax: '30',
|
||||
};
|
||||
const expected = [
|
||||
{
|
||||
key: 'total_items',
|
||||
label: 'Subtotal:',
|
||||
value: 200,
|
||||
valueWithTax: 220,
|
||||
},
|
||||
{
|
||||
key: 'total_fees',
|
||||
label: 'Fees:',
|
||||
value: 100,
|
||||
valueWithTax: 110,
|
||||
},
|
||||
{
|
||||
key: 'total_discount',
|
||||
label: 'Discount:',
|
||||
value: 350,
|
||||
valueWithTax: 400,
|
||||
},
|
||||
{
|
||||
key: 'total_tax',
|
||||
label: 'Taxes:',
|
||||
value: 30,
|
||||
valueWithTax: 30,
|
||||
},
|
||||
];
|
||||
const expectedWithShipping = [
|
||||
...expected,
|
||||
{
|
||||
key: 'total_shipping',
|
||||
label: 'Shipping:',
|
||||
value: 50,
|
||||
valueWithTax: 55,
|
||||
},
|
||||
];
|
||||
it( 'returns expected values when needsShipping is false', () => {
|
||||
expect( prepareTotalItems( fixture, false ) ).toEqual( expected );
|
||||
} );
|
||||
it( 'returns expected values when needsShipping is true', () => {
|
||||
expect( prepareTotalItems( fixture, true ) ).toEqual(
|
||||
expectedWithShipping
|
||||
);
|
||||
} );
|
||||
} );
|
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
|
||||
import { useCallback, useEffect, useRef } from '@wordpress/element';
|
||||
import PaymentMethodLabel from '@woocommerce/base-components/cart-checkout/payment-method-label';
|
||||
import PaymentMethodIcons from '@woocommerce/base-components/cart-checkout/payment-method-icons';
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
import deprecated from '@wordpress/deprecated';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ValidationInputError } from '../../providers/validation';
|
||||
import { useStoreCart } from '../cart/use-store-cart';
|
||||
import { useStoreCartCoupons } from '../cart/use-store-cart-coupons';
|
||||
import { useEmitResponse } from '../use-emit-response';
|
||||
import { useCheckoutContext } from '../../providers/cart-checkout/checkout-state';
|
||||
import { usePaymentMethodDataContext } from '../../providers/cart-checkout/payment-methods';
|
||||
import { useShippingDataContext } from '../../providers/cart-checkout/shipping';
|
||||
import { useCustomerDataContext } from '../../providers/cart-checkout/customer';
|
||||
import { prepareTotalItems } from './utils';
|
||||
|
||||
/**
|
||||
* Returns am interface to use as payment method props.
|
||||
*/
|
||||
export const usePaymentMethodInterface = (): Record< string, unknown > => {
|
||||
const {
|
||||
isCalculating,
|
||||
isComplete,
|
||||
isIdle,
|
||||
isProcessing,
|
||||
onCheckoutBeforeProcessing,
|
||||
onCheckoutValidationBeforeProcessing,
|
||||
onCheckoutAfterProcessingWithSuccess,
|
||||
onCheckoutAfterProcessingWithError,
|
||||
onSubmit,
|
||||
customerId,
|
||||
} = useCheckoutContext();
|
||||
const {
|
||||
currentStatus,
|
||||
activePaymentMethod,
|
||||
onPaymentProcessing,
|
||||
setExpressPaymentError,
|
||||
shouldSavePayment,
|
||||
} = usePaymentMethodDataContext();
|
||||
const {
|
||||
shippingErrorStatus,
|
||||
shippingErrorTypes,
|
||||
shippingRates,
|
||||
shippingRatesLoading,
|
||||
selectedRates,
|
||||
setSelectedRates,
|
||||
isSelectingRate,
|
||||
onShippingRateSuccess,
|
||||
onShippingRateFail,
|
||||
onShippingRateSelectSuccess,
|
||||
onShippingRateSelectFail,
|
||||
needsShipping,
|
||||
} = useShippingDataContext();
|
||||
const {
|
||||
billingData,
|
||||
shippingAddress,
|
||||
setShippingAddress,
|
||||
} = useCustomerDataContext();
|
||||
const { cartTotals } = useStoreCart();
|
||||
const { appliedCoupons } = useStoreCartCoupons();
|
||||
const { noticeContexts, responseTypes } = useEmitResponse();
|
||||
const currentCartTotals = useRef(
|
||||
prepareTotalItems( cartTotals, needsShipping )
|
||||
);
|
||||
const currentCartTotal = useRef( {
|
||||
label: __( 'Total', 'woo-gutenberg-products-block' ),
|
||||
value: parseInt( cartTotals.total_price, 10 ),
|
||||
} );
|
||||
|
||||
useEffect( () => {
|
||||
currentCartTotals.current = prepareTotalItems(
|
||||
cartTotals,
|
||||
needsShipping
|
||||
);
|
||||
currentCartTotal.current = {
|
||||
label: __( 'Total', 'woo-gutenberg-products-block' ),
|
||||
value: parseInt( cartTotals.total_price, 10 ),
|
||||
};
|
||||
}, [ cartTotals, needsShipping ] );
|
||||
|
||||
const deprecatedSetExpressPaymentError = useCallback(
|
||||
( errorMessage = '' ) => {
|
||||
deprecated(
|
||||
'setExpressPaymentError should only be used by Express Payment Methods (using the provided onError handler).',
|
||||
{
|
||||
alternative: '',
|
||||
plugin: 'woocommerce-gutenberg-products-block',
|
||||
link:
|
||||
'https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4228',
|
||||
}
|
||||
);
|
||||
setExpressPaymentError( errorMessage );
|
||||
},
|
||||
[ setExpressPaymentError ]
|
||||
);
|
||||
|
||||
return {
|
||||
activePaymentMethod,
|
||||
billing: {
|
||||
billingData,
|
||||
cartTotal: currentCartTotal.current,
|
||||
currency: getCurrencyFromPriceResponse( cartTotals ),
|
||||
cartTotalItems: currentCartTotals.current,
|
||||
displayPricesIncludingTax: getSetting(
|
||||
'displayCartPricesIncludingTax',
|
||||
false
|
||||
) as boolean,
|
||||
appliedCoupons,
|
||||
customerId,
|
||||
},
|
||||
checkoutStatus: {
|
||||
isCalculating,
|
||||
isComplete,
|
||||
isIdle,
|
||||
isProcessing,
|
||||
},
|
||||
components: {
|
||||
ValidationInputError,
|
||||
PaymentMethodIcons,
|
||||
PaymentMethodLabel,
|
||||
},
|
||||
emitResponse: {
|
||||
noticeContexts,
|
||||
responseTypes,
|
||||
},
|
||||
eventRegistration: {
|
||||
onCheckoutBeforeProcessing,
|
||||
onCheckoutValidationBeforeProcessing,
|
||||
onCheckoutAfterProcessingWithSuccess,
|
||||
onCheckoutAfterProcessingWithError,
|
||||
onShippingRateSuccess,
|
||||
onShippingRateFail,
|
||||
onShippingRateSelectSuccess,
|
||||
onShippingRateSelectFail,
|
||||
onPaymentProcessing,
|
||||
},
|
||||
onSubmit,
|
||||
paymentStatus: currentStatus,
|
||||
setExpressPaymentError: deprecatedSetExpressPaymentError,
|
||||
shippingData: {
|
||||
shippingRates,
|
||||
shippingRatesLoading,
|
||||
selectedRates,
|
||||
setSelectedRates,
|
||||
isSelectingRate,
|
||||
shippingAddress,
|
||||
setShippingAddress,
|
||||
needsShipping,
|
||||
},
|
||||
shippingStatus: {
|
||||
shippingErrorStatus,
|
||||
shippingErrorTypes,
|
||||
},
|
||||
shouldSavePayment,
|
||||
};
|
||||
};
|
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
import type {
|
||||
PaymentMethods,
|
||||
ExpressPaymentMethods,
|
||||
} from '@woocommerce/type-defs/payments';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { usePaymentMethodDataContext } from '../../providers/cart-checkout/payment-methods';
|
||||
|
||||
interface PaymentMethodState {
|
||||
paymentMethods: PaymentMethods;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
interface ExpressPaymentMethodState {
|
||||
paymentMethods: ExpressPaymentMethods;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
|
||||
const usePaymentMethodState = (
|
||||
express = false
|
||||
): PaymentMethodState | ExpressPaymentMethodState => {
|
||||
const {
|
||||
paymentMethods,
|
||||
expressPaymentMethods,
|
||||
paymentMethodsInitialized,
|
||||
expressPaymentMethodsInitialized,
|
||||
} = usePaymentMethodDataContext();
|
||||
|
||||
const currentPaymentMethods = useShallowEqual( paymentMethods );
|
||||
const currentExpressPaymentMethods = useShallowEqual(
|
||||
expressPaymentMethods
|
||||
);
|
||||
|
||||
return {
|
||||
paymentMethods: express
|
||||
? currentExpressPaymentMethods
|
||||
: currentPaymentMethods,
|
||||
isInitialized: express
|
||||
? expressPaymentMethodsInitialized
|
||||
: paymentMethodsInitialized,
|
||||
};
|
||||
};
|
||||
|
||||
export const usePaymentMethods = ():
|
||||
| PaymentMethodState
|
||||
| ExpressPaymentMethodState => usePaymentMethodState( false );
|
||||
export const useExpressPaymentMethods = (): ExpressPaymentMethodState =>
|
||||
usePaymentMethodState( true );
|
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import {
|
||||
CartResponseTotals,
|
||||
objectHasProp,
|
||||
isString,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
export interface CartTotalItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
valueWithTax: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the total items into a shape usable for display as passed on to
|
||||
* registered payment methods.
|
||||
*
|
||||
* @param {Object} totals Current cart total items
|
||||
* @param {boolean} needsShipping Whether or not shipping is needed.
|
||||
*/
|
||||
export const prepareTotalItems = (
|
||||
totals: CartResponseTotals,
|
||||
needsShipping: boolean
|
||||
): CartTotalItem[] => {
|
||||
const newTotals = [];
|
||||
|
||||
const factory = ( label: string, property: string ): CartTotalItem => {
|
||||
const taxProperty = property + '_tax';
|
||||
const value =
|
||||
objectHasProp( totals, property ) && isString( totals[ property ] )
|
||||
? parseInt( totals[ property ] as string, 10 )
|
||||
: 0;
|
||||
const tax =
|
||||
objectHasProp( totals, taxProperty ) &&
|
||||
isString( totals[ taxProperty ] )
|
||||
? parseInt( totals[ taxProperty ] as string, 10 )
|
||||
: 0;
|
||||
return {
|
||||
key: property,
|
||||
label,
|
||||
value,
|
||||
valueWithTax: value + tax,
|
||||
};
|
||||
};
|
||||
|
||||
newTotals.push(
|
||||
factory(
|
||||
__( 'Subtotal:', 'woo-gutenberg-products-block' ),
|
||||
'total_items'
|
||||
)
|
||||
);
|
||||
|
||||
newTotals.push(
|
||||
factory( __( 'Fees:', 'woo-gutenberg-products-block' ), 'total_fees' )
|
||||
);
|
||||
|
||||
newTotals.push(
|
||||
factory(
|
||||
__( 'Discount:', 'woo-gutenberg-products-block' ),
|
||||
'total_discount'
|
||||
)
|
||||
);
|
||||
|
||||
newTotals.push( {
|
||||
key: 'total_tax',
|
||||
label: __( 'Taxes:', 'woo-gutenberg-products-block' ),
|
||||
value: parseInt( totals.total_tax, 10 ),
|
||||
valueWithTax: parseInt( totals.total_tax, 10 ),
|
||||
} );
|
||||
|
||||
if ( needsShipping ) {
|
||||
newTotals.push(
|
||||
factory(
|
||||
__( 'Shipping:', 'woo-gutenberg-products-block' ),
|
||||
'total_shipping'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return newTotals;
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export * from './use-select-shipping-rate';
|
||||
export * from './use-select-shipping-rates';
|
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback } from '@wordpress/element';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
import { CartShippingPackageShippingRate } from '@woocommerce/type-defs/cart';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useSelectShippingRates } from './use-select-shipping-rates';
|
||||
import { useStoreEvents } from '../use-store-events';
|
||||
|
||||
/**
|
||||
* Selected rates are derived by looping over the shipping rates.
|
||||
*
|
||||
* @param {Array} shippingRates Array of shipping rates.
|
||||
* @return {string} Selected rate id.
|
||||
*/
|
||||
// This will find the selected rate ID in an array of shipping rates.
|
||||
const deriveSelectedRateId = (
|
||||
shippingRates: CartShippingPackageShippingRate[]
|
||||
) => shippingRates.find( ( rate ) => rate.selected )?.rate_id;
|
||||
|
||||
/**
|
||||
* This is a custom hook for tracking selected shipping rates for a package and selecting a rate. State is used so
|
||||
* changes are reflected in the UI instantly.
|
||||
*
|
||||
* @param {string} packageId Package ID to select rates for.
|
||||
* @param {Array} shippingRates an array of packages with shipping rates.
|
||||
* @return {Object} This hook will return an object with these properties:
|
||||
* - selectShippingRate: A function that immediately returns the selected rate and dispatches an action generator.
|
||||
* - selectedShippingRate: The selected rate id.
|
||||
* - isSelectingRate: True when rates are being resolved to the API.
|
||||
*/
|
||||
export const useSelectShippingRate = (
|
||||
packageId: string | number,
|
||||
shippingRates: CartShippingPackageShippingRate[]
|
||||
): {
|
||||
selectShippingRate: ( newShippingRateId: string ) => unknown;
|
||||
selectedShippingRate: string | undefined;
|
||||
isSelectingRate: boolean;
|
||||
} => {
|
||||
const { dispatchCheckoutEvent } = useStoreEvents();
|
||||
|
||||
// Rates are selected via the shipping data context provider.
|
||||
const { selectShippingRate, isSelectingRate } = useSelectShippingRates();
|
||||
|
||||
// Selected rates are stored in state. This allows shipping rates changes to be shown in the UI instantly.
|
||||
// Defaults to the currently selected rate_id.
|
||||
const [ selectedShippingRate, setSelectedShippingRate ] = useState( () =>
|
||||
deriveSelectedRateId( shippingRates )
|
||||
);
|
||||
|
||||
// This ref is used to track when changes come in via the props. When the incoming shipping rates change, update our local state if there are changes to selected methods.
|
||||
const currentShippingRates = useRef( shippingRates );
|
||||
useEffect( () => {
|
||||
if ( ! isShallowEqual( currentShippingRates.current, shippingRates ) ) {
|
||||
currentShippingRates.current = shippingRates;
|
||||
setSelectedShippingRate( deriveSelectedRateId( shippingRates ) );
|
||||
}
|
||||
}, [ shippingRates ] );
|
||||
|
||||
// Sets a rate for a package in state (so changes are shown right away to consumers of the hook) and in the stores.
|
||||
const setPackageRateId = useCallback(
|
||||
( newShippingRateId ) => {
|
||||
setSelectedShippingRate( newShippingRateId );
|
||||
selectShippingRate( newShippingRateId, packageId );
|
||||
dispatchCheckoutEvent( 'set-selected-shipping-rate', {
|
||||
shippingRateId: newShippingRateId,
|
||||
} );
|
||||
},
|
||||
[ packageId, selectShippingRate, dispatchCheckoutEvent ]
|
||||
);
|
||||
|
||||
return {
|
||||
selectShippingRate: setPackageRateId,
|
||||
selectedShippingRate,
|
||||
isSelectingRate,
|
||||
};
|
||||
};
|
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { useCallback } from '@wordpress/element';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useThrowError } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* This is a custom hook for selecting shipping rates
|
||||
*
|
||||
* @return {Object} This hook will return an object with these properties:
|
||||
* - selectShippingRate: A function that immediately returns the selected rate and dispatches an action generator.
|
||||
* - isSelectingRate: True when rates are being resolved to the API.
|
||||
*/
|
||||
export const useSelectShippingRates = (): {
|
||||
selectShippingRate: (
|
||||
newShippingRateId: string,
|
||||
packageId: string | number
|
||||
) => unknown;
|
||||
isSelectingRate: boolean;
|
||||
} => {
|
||||
const throwError = useThrowError();
|
||||
const { selectShippingRate } = ( useDispatch( storeKey ) as {
|
||||
selectShippingRate: unknown;
|
||||
} ) as {
|
||||
selectShippingRate: (
|
||||
newShippingRateId: string,
|
||||
packageId: string | number
|
||||
) => Promise< unknown >;
|
||||
};
|
||||
|
||||
// Sets a rate for a package in state (so changes are shown right away to consumers of the hook) and in the stores.
|
||||
const setRate = useCallback(
|
||||
( newShippingRateId, packageId ) => {
|
||||
selectShippingRate( newShippingRateId, packageId ).catch(
|
||||
( error ) => {
|
||||
// we throw this error because an error on selecting a rate is problematic.
|
||||
throwError( error );
|
||||
}
|
||||
);
|
||||
},
|
||||
[ throwError, selectShippingRate ]
|
||||
);
|
||||
|
||||
// See if rates are being selected.
|
||||
const isSelectingRate = useSelect< boolean >( ( select ) => {
|
||||
return select( storeKey ).isShippingRateBeingSelected();
|
||||
}, [] );
|
||||
|
||||
return {
|
||||
selectShippingRate: setRate,
|
||||
isSelectingRate,
|
||||
};
|
||||
};
|
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCheckoutSubmit } from '../use-checkout-submit';
|
||||
|
||||
const mockUseCheckoutContext = {
|
||||
onSubmit: jest.fn(),
|
||||
};
|
||||
const mockUsePaymentMethodDataContext = {
|
||||
activePaymentMethod: '',
|
||||
currentStatus: {
|
||||
isDoingExpressPayment: false,
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock( '../../providers/cart-checkout/checkout-state', () => ( {
|
||||
useCheckoutContext: () => mockUseCheckoutContext,
|
||||
} ) );
|
||||
|
||||
jest.mock( '../../providers/cart-checkout/payment-methods', () => ( {
|
||||
usePaymentMethodDataContext: () => mockUsePaymentMethodDataContext,
|
||||
} ) );
|
||||
|
||||
describe( 'useCheckoutSubmit', () => {
|
||||
let registry, renderer;
|
||||
|
||||
const getWrappedComponents = ( Component ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent = () => () => {
|
||||
const data = useCheckoutSubmit();
|
||||
return <div { ...data } />;
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry();
|
||||
renderer = null;
|
||||
} );
|
||||
|
||||
it( 'onSubmit calls the correct action in the checkout context', () => {
|
||||
const TestComponent = getTestComponent();
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { onSubmit } = renderer.root.findByType( 'div' ).props;
|
||||
|
||||
onSubmit();
|
||||
|
||||
expect( mockUseCheckoutContext.onSubmit ).toHaveBeenCalledTimes( 1 );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,259 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
useQueryStateByContext,
|
||||
useQueryStateByKey,
|
||||
useSynchronizedQueryState,
|
||||
} from '../use-query-state';
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
__esModule: true,
|
||||
QUERY_STATE_STORE_KEY: 'test/store',
|
||||
} ) );
|
||||
|
||||
describe( 'Testing Query State Hooks', () => {
|
||||
let registry, mocks;
|
||||
beforeAll( () => {
|
||||
registry = createRegistry();
|
||||
mocks = {};
|
||||
} );
|
||||
/**
|
||||
* Test helper to return a tuple containing the expected query value and the
|
||||
* expected query state action creator from the given rendered test instance.
|
||||
*
|
||||
* @param {Object} testRenderer An instance of the created test component.
|
||||
*
|
||||
* @return {Array} A tuple containing the expected query value as the first
|
||||
* element and the expected query action creator as the
|
||||
* second argument.
|
||||
*/
|
||||
const getProps = ( testRenderer ) => {
|
||||
const props = testRenderer.root.findByType( 'div' ).props;
|
||||
return [ props.queryState, props.setQueryState ];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the given component wrapped in the registry provider for
|
||||
* instantiating using the TestRenderer using the current prepared registry
|
||||
* for the TestRenderer to instantiate with.
|
||||
*
|
||||
* @param {*} Component The test component to wrap.
|
||||
* @param {Object} props Props to feed the wrapped component.
|
||||
*
|
||||
* @return {*} Wrapped component.
|
||||
*/
|
||||
const getWrappedComponent = ( Component, props ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component { ...props } />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns a TestComponent for the provided hook to test with, and the
|
||||
* expected PropKeys for obtaining the values to be fed to the hook as
|
||||
* arguments.
|
||||
*
|
||||
* @param {Function} hookTested The hook being tested to use in the
|
||||
* test comopnent.
|
||||
* @param {Array} propKeysForArgs An array of keys for the props that
|
||||
* will be used on the test component that
|
||||
* will have values fed to the tested
|
||||
* hook.
|
||||
*
|
||||
* @return {*} A component ready for testing with!
|
||||
*/
|
||||
const getTestComponent = ( hookTested, propKeysForArgs ) => ( props ) => {
|
||||
const args = propKeysForArgs.map( ( key ) => props[ key ] );
|
||||
const [ queryValue, setQueryValue ] = hookTested( ...args );
|
||||
return (
|
||||
<div queryState={ queryValue } setQueryState={ setQueryValue } />
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A helper for setting up the `mocks` object and the `registry` mock before
|
||||
* each test.
|
||||
*
|
||||
* @param {string} actionMockName This should be the name of the action
|
||||
* that the hook returns. This will be
|
||||
* mocked using `mocks.action` when
|
||||
* registered in the mock registry.
|
||||
* @param {string} selectorMockName This should be the mame of the selector
|
||||
* that the hook uses. This will be mocked
|
||||
* using `mocks.selector` when registered
|
||||
* in the mock registry.
|
||||
*/
|
||||
const setupMocks = ( actionMockName, selectorMockName ) => {
|
||||
mocks.action = jest.fn().mockReturnValue( { type: 'testAction' } );
|
||||
mocks.selector = jest.fn().mockReturnValue( { foo: 'bar' } );
|
||||
registry.registerStore( storeKey, {
|
||||
reducer: () => ( {} ),
|
||||
actions: {
|
||||
[ actionMockName ]: mocks.action,
|
||||
},
|
||||
selectors: {
|
||||
[ selectorMockName ]: mocks.selector,
|
||||
},
|
||||
} );
|
||||
};
|
||||
describe( 'useQueryStateByContext', () => {
|
||||
const TestComponent = getTestComponent( useQueryStateByContext, [
|
||||
'context',
|
||||
] );
|
||||
let renderer;
|
||||
beforeEach( () => {
|
||||
renderer = null;
|
||||
setupMocks( 'setValueForQueryContext', 'getValueForQueryContext' );
|
||||
} );
|
||||
afterEach( () => {
|
||||
act( () => {
|
||||
renderer.unmount();
|
||||
} );
|
||||
} );
|
||||
it(
|
||||
'calls useSelect with the provided context and returns expected' +
|
||||
' values',
|
||||
() => {
|
||||
const { action, selector } = mocks;
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponent( TestComponent, {
|
||||
context: 'test-context',
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const [ queryState, setQueryState ] = getProps( renderer );
|
||||
// the {} is because all selectors are called internally in the
|
||||
// registry with the first argument being the state which is empty.
|
||||
expect( selector ).toHaveBeenLastCalledWith(
|
||||
{},
|
||||
'test-context',
|
||||
undefined
|
||||
);
|
||||
expect( queryState ).toEqual( { foo: 'bar' } );
|
||||
expect( action ).not.toHaveBeenCalled();
|
||||
|
||||
//execute dispatcher and make sure it's called.
|
||||
act( () => {
|
||||
setQueryState( { foo: 'bar' } );
|
||||
} );
|
||||
expect( action ).toHaveBeenCalledWith( 'test-context', {
|
||||
foo: 'bar',
|
||||
} );
|
||||
}
|
||||
);
|
||||
} );
|
||||
describe( 'useQueryStateByKey', () => {
|
||||
const TestComponent = getTestComponent( useQueryStateByKey, [
|
||||
'queryKey',
|
||||
undefined,
|
||||
'context',
|
||||
] );
|
||||
let renderer;
|
||||
beforeEach( () => {
|
||||
renderer = null;
|
||||
setupMocks( 'setQueryValue', 'getValueForQueryKey' );
|
||||
} );
|
||||
afterEach( () => {
|
||||
act( () => {
|
||||
renderer.unmount();
|
||||
} );
|
||||
} );
|
||||
it(
|
||||
'calls useSelect with the provided context and returns expected' +
|
||||
' values',
|
||||
() => {
|
||||
const { selector, action } = mocks;
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponent( TestComponent, {
|
||||
context: 'test-context',
|
||||
queryKey: 'someValue',
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const [ queryState, setQueryState ] = getProps( renderer );
|
||||
// the {} is because all selectors are called internally in the
|
||||
// registry with the first argument being the state which is empty.
|
||||
expect( selector ).toHaveBeenLastCalledWith(
|
||||
{},
|
||||
'test-context',
|
||||
'someValue',
|
||||
undefined
|
||||
);
|
||||
expect( queryState ).toEqual( { foo: 'bar' } );
|
||||
expect( action ).not.toHaveBeenCalled();
|
||||
|
||||
//execute dispatcher and make sure it's called.
|
||||
act( () => {
|
||||
setQueryState( { foo: 'bar' } );
|
||||
} );
|
||||
expect( action ).toHaveBeenCalledWith(
|
||||
'test-context',
|
||||
'someValue',
|
||||
{ foo: 'bar' }
|
||||
);
|
||||
}
|
||||
);
|
||||
} );
|
||||
// Note: these tests only add partial coverage because the state is not
|
||||
// actually updated by the action dispatch via our mocks.
|
||||
describe( 'useSynchronizedQueryState', () => {
|
||||
const TestComponent = getTestComponent( useSynchronizedQueryState, [
|
||||
'synchronizedQuery',
|
||||
'context',
|
||||
] );
|
||||
const initialQuery = { a: 'b' };
|
||||
let renderer;
|
||||
beforeEach( () => {
|
||||
setupMocks( 'setValueForQueryContext', 'getValueForQueryContext' );
|
||||
} );
|
||||
it( 'returns provided query state on initial render', () => {
|
||||
const { action, selector } = mocks;
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponent( TestComponent, {
|
||||
context: 'test-context',
|
||||
synchronizedQuery: initialQuery,
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const [ queryState ] = getProps( renderer );
|
||||
expect( queryState ).toBe( initialQuery );
|
||||
expect( selector ).toHaveBeenLastCalledWith(
|
||||
{},
|
||||
'test-context',
|
||||
undefined
|
||||
);
|
||||
expect( action ).toHaveBeenCalledWith( 'test-context', {
|
||||
foo: 'bar',
|
||||
a: 'b',
|
||||
} );
|
||||
} );
|
||||
it( 'returns merged queryState on subsequent render', () => {
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponent( TestComponent, {
|
||||
context: 'test-context',
|
||||
synchronizedQuery: initialQuery,
|
||||
} )
|
||||
);
|
||||
} );
|
||||
// note our test doesn't interact with an actual reducer so the
|
||||
// store state is not updated. Here we're just verifying that
|
||||
// what is is returned by the state selector mock is returned.
|
||||
// However we DO expect this to be a new object.
|
||||
const [ queryState ] = getProps( renderer );
|
||||
expect( queryState ).not.toBe( initialQuery );
|
||||
expect( queryState ).toEqual( { foo: 'bar' } );
|
||||
} );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { StoreNoticesProvider } from '@woocommerce/base-context';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreNotices } from '../use-store-notices';
|
||||
|
||||
describe( 'useStoreNotices', () => {
|
||||
function setup() {
|
||||
const returnVal = {};
|
||||
|
||||
function TestComponent() {
|
||||
Object.assign( returnVal, useStoreNotices() );
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render(
|
||||
<StoreNoticesProvider>
|
||||
<TestComponent />
|
||||
</StoreNoticesProvider>
|
||||
);
|
||||
|
||||
return returnVal;
|
||||
}
|
||||
|
||||
test( 'allows adding and removing notices and checking if there are notices of a specific type', () => {
|
||||
const storeNoticesData = setup();
|
||||
|
||||
// Assert initial state.
|
||||
expect( storeNoticesData.notices ).toEqual( [] );
|
||||
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( false );
|
||||
|
||||
// Add error notice.
|
||||
act( () => {
|
||||
storeNoticesData.addErrorNotice( 'Error notice' );
|
||||
} );
|
||||
|
||||
expect( storeNoticesData.notices.length ).toBe( 1 );
|
||||
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( true );
|
||||
|
||||
expect( storeNoticesData.notices.length ).toBe( 1 );
|
||||
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( true );
|
||||
|
||||
// Remove error notice.
|
||||
act( () => {
|
||||
storeNoticesData.removeNotices( 'error' );
|
||||
} );
|
||||
|
||||
expect( storeNoticesData.notices.length ).toBe( 0 );
|
||||
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( false );
|
||||
|
||||
// Remove all remaining notices.
|
||||
act( () => {
|
||||
storeNoticesData.removeNotices();
|
||||
} );
|
||||
|
||||
expect( storeNoticesData.notices.length ).toBe( 0 );
|
||||
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( false );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreProducts } from '../use-store-products';
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
__esModule: true,
|
||||
COLLECTIONS_STORE_KEY: 'test/store',
|
||||
} ) );
|
||||
|
||||
describe( 'useStoreProducts', () => {
|
||||
let registry, mocks, renderer;
|
||||
const getProps = ( testRenderer ) => {
|
||||
const {
|
||||
products,
|
||||
totalProducts,
|
||||
productsLoading,
|
||||
} = testRenderer.root.findByType( 'div' ).props;
|
||||
return {
|
||||
products,
|
||||
totalProducts,
|
||||
productsLoading,
|
||||
};
|
||||
};
|
||||
|
||||
const getWrappedComponents = ( Component, props ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component { ...props } />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent = () => ( { query } ) => {
|
||||
const items = useStoreProducts( query );
|
||||
return <div { ...items } />;
|
||||
};
|
||||
|
||||
const setUpMocks = () => {
|
||||
mocks = {
|
||||
selectors: {
|
||||
getCollectionError: jest.fn().mockReturnValue( false ),
|
||||
getCollection: jest
|
||||
.fn()
|
||||
.mockImplementation( () => ( { foo: 'bar' } ) ),
|
||||
getCollectionHeader: jest.fn().mockReturnValue( 22 ),
|
||||
hasFinishedResolution: jest.fn().mockReturnValue( true ),
|
||||
},
|
||||
};
|
||||
registry.registerStore( storeKey, {
|
||||
reducer: () => ( {} ),
|
||||
selectors: mocks.selectors,
|
||||
} );
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry();
|
||||
mocks = {};
|
||||
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 { products } = getProps( renderer );
|
||||
// rerender
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
query: { bar: 'foo' },
|
||||
} )
|
||||
);
|
||||
} );
|
||||
// re-render should result in same products object because although
|
||||
// query-state is a different instance, it's still equivalent.
|
||||
const { products: newProducts } = getProps( renderer );
|
||||
expect( newProducts ).toBe( products );
|
||||
// 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 { products: productsVerification } = getProps( renderer );
|
||||
expect( productsVerification ).not.toBe( products );
|
||||
expect( productsVerification ).toEqual( products );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
} );
|
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { StoreSnackbarNoticesProvider } from '@woocommerce/base-context/providers';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreSnackbarNotices } from '../use-store-snackbar-notices';
|
||||
|
||||
describe( 'useStoreNoticesWithSnackbar', () => {
|
||||
function setup() {
|
||||
const returnVal = {};
|
||||
|
||||
function TestComponent() {
|
||||
Object.assign( returnVal, useStoreSnackbarNotices() );
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render(
|
||||
<StoreSnackbarNoticesProvider>
|
||||
<TestComponent />
|
||||
</StoreSnackbarNoticesProvider>
|
||||
);
|
||||
|
||||
return returnVal;
|
||||
}
|
||||
|
||||
test( 'allows adding and removing notices and checking if there are notices of a specific type', () => {
|
||||
const storeNoticesData = setup();
|
||||
|
||||
// Assert initial state.
|
||||
expect( storeNoticesData.notices ).toEqual( [] );
|
||||
|
||||
// Add snackbar notice.
|
||||
act( () => {
|
||||
storeNoticesData.addSnackbarNotice( 'Snackbar notice' );
|
||||
} );
|
||||
|
||||
expect( storeNoticesData.notices.length ).toBe( 1 );
|
||||
|
||||
// Remove all remaining notices.
|
||||
act( () => {
|
||||
storeNoticesData.removeNotices();
|
||||
} );
|
||||
|
||||
expect( storeNoticesData.notices.length ).toBe( 0 );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { defaultAddressFields } from '@woocommerce/settings';
|
||||
import { useEffect, useCallback, useRef } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
useShippingDataContext,
|
||||
useCustomerDataContext,
|
||||
} from '../providers/cart-checkout';
|
||||
|
||||
/**
|
||||
* Custom hook for exposing address related functionality for the checkout address form.
|
||||
*/
|
||||
export const useCheckoutAddress = () => {
|
||||
const { needsShipping } = useShippingDataContext();
|
||||
const {
|
||||
billingData,
|
||||
setBillingData,
|
||||
shippingAddress,
|
||||
setShippingAddress,
|
||||
shippingAsBilling,
|
||||
setShippingAsBilling,
|
||||
} = useCustomerDataContext();
|
||||
|
||||
const currentShippingAsBilling = useRef( shippingAsBilling );
|
||||
const previousBillingData = useRef( billingData );
|
||||
|
||||
/**
|
||||
* Sets shipping address data, and also billing if using the same address.
|
||||
*/
|
||||
const setShippingFields = useCallback(
|
||||
( value ) => {
|
||||
setShippingAddress( value );
|
||||
|
||||
if ( shippingAsBilling ) {
|
||||
setBillingData( value );
|
||||
}
|
||||
},
|
||||
[ shippingAsBilling, setShippingAddress, setBillingData ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets billing address data, and also shipping if shipping is disabled.
|
||||
*/
|
||||
const setBillingFields = useCallback(
|
||||
( value ) => {
|
||||
setBillingData( value );
|
||||
|
||||
if ( ! needsShipping ) {
|
||||
setShippingAddress( value );
|
||||
}
|
||||
},
|
||||
[ needsShipping, setShippingAddress, setBillingData ]
|
||||
);
|
||||
|
||||
// When the "Use same address" checkbox is toggled we need to update the current billing address to reflect this.
|
||||
// This either sets the billing address to the shipping address, or restores the billing address to it's previous state.
|
||||
useEffect( () => {
|
||||
if ( currentShippingAsBilling.current !== shippingAsBilling ) {
|
||||
if ( shippingAsBilling ) {
|
||||
previousBillingData.current = billingData;
|
||||
setBillingData( shippingAddress );
|
||||
} else {
|
||||
const {
|
||||
// We need to pluck out email from previous billing data because they can be empty, causing the current email to get emptied. See issue #4155
|
||||
/* eslint-disable no-unused-vars */
|
||||
email,
|
||||
/* eslint-enable no-unused-vars */
|
||||
...billingAddress
|
||||
} = previousBillingData.current;
|
||||
|
||||
setBillingData( {
|
||||
...billingAddress,
|
||||
} );
|
||||
}
|
||||
currentShippingAsBilling.current = shippingAsBilling;
|
||||
}
|
||||
}, [ shippingAsBilling, setBillingData, shippingAddress, billingData ] );
|
||||
|
||||
const setEmail = useCallback(
|
||||
( value ) =>
|
||||
void setBillingData( {
|
||||
email: value,
|
||||
} ),
|
||||
[ setBillingData ]
|
||||
);
|
||||
|
||||
const setPhone = useCallback(
|
||||
( value ) =>
|
||||
void setBillingData( {
|
||||
phone: value,
|
||||
} ),
|
||||
[ setBillingData ]
|
||||
);
|
||||
|
||||
const setShippingPhone = useCallback(
|
||||
( value ) =>
|
||||
void setShippingFields( {
|
||||
phone: value,
|
||||
} ),
|
||||
[ setShippingFields ]
|
||||
);
|
||||
|
||||
// Note that currentShippingAsBilling is returned rather than the current state of shippingAsBilling--this is so that
|
||||
// the billing fields are not rendered before sync (billing field values are debounced and would be outdated)
|
||||
return {
|
||||
defaultAddressFields,
|
||||
shippingFields: shippingAddress,
|
||||
setShippingFields,
|
||||
billingFields: billingData,
|
||||
setBillingFields,
|
||||
setEmail,
|
||||
setPhone,
|
||||
setShippingPhone,
|
||||
shippingAsBilling,
|
||||
setShippingAsBilling,
|
||||
showShippingFields: needsShipping,
|
||||
showBillingFields:
|
||||
! needsShipping || ! currentShippingAsBilling.current,
|
||||
};
|
||||
};
|
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from '@wordpress/element';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCheckoutContext } from '../providers/cart-checkout/checkout-state';
|
||||
import type { CheckoutStateContextState } from '../providers/cart-checkout/checkout-state/types';
|
||||
|
||||
/**
|
||||
* Custom hook for setting custom checkout data which is passed to the wc/store/checkout endpoint when processing orders.
|
||||
*/
|
||||
export const useCheckoutExtensionData = (): {
|
||||
extensionData: CheckoutStateContextState[ 'extensionData' ];
|
||||
setExtensionData: (
|
||||
namespace: string,
|
||||
key: string,
|
||||
value: unknown
|
||||
) => void;
|
||||
} => {
|
||||
const { dispatchActions, extensionData } = useCheckoutContext();
|
||||
const extensionDataRef = useRef( extensionData );
|
||||
|
||||
useEffect( () => {
|
||||
if ( ! isShallowEqual( extensionData, extensionDataRef.current ) ) {
|
||||
extensionDataRef.current = extensionData;
|
||||
}
|
||||
}, [ extensionData ] );
|
||||
|
||||
const setExtensionDataWithNamespace = useCallback(
|
||||
( namespace, key, value ) => {
|
||||
const currentData = extensionDataRef.current[ namespace ] || {};
|
||||
dispatchActions.setExtensionData( {
|
||||
...extensionDataRef.current,
|
||||
[ namespace ]: {
|
||||
...currentData,
|
||||
[ key ]: value,
|
||||
},
|
||||
} );
|
||||
},
|
||||
[ dispatchActions ]
|
||||
);
|
||||
|
||||
return {
|
||||
extensionData: extensionDataRef.current,
|
||||
setExtensionData: setExtensionDataWithNamespace,
|
||||
};
|
||||
};
|
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useSelect } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useEmitResponse } from './use-emit-response';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').StoreNoticeObject} StoreNoticeObject
|
||||
* @typedef {import('@woocommerce/type-defs/hooks').CheckoutNotices} CheckoutNotices
|
||||
*/
|
||||
|
||||
/**
|
||||
* A hook that returns all notices visible in the Checkout block.
|
||||
*
|
||||
* @return {CheckoutNotices} Notices from the checkout form or payment methods.
|
||||
*/
|
||||
export const useCheckoutNotices = () => {
|
||||
const { noticeContexts } = useEmitResponse();
|
||||
|
||||
/**
|
||||
* @type {StoreNoticeObject[]}
|
||||
*/
|
||||
const checkoutNotices = useSelect(
|
||||
( select ) => select( 'core/notices' ).getNotices( 'wc/checkout' ),
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* @type {StoreNoticeObject[]}
|
||||
*/
|
||||
const expressPaymentNotices = useSelect(
|
||||
( select ) =>
|
||||
select( 'core/notices' ).getNotices(
|
||||
noticeContexts.EXPRESS_PAYMENTS
|
||||
),
|
||||
[ noticeContexts.EXPRESS_PAYMENTS ]
|
||||
);
|
||||
|
||||
/**
|
||||
* @type {StoreNoticeObject[]}
|
||||
*/
|
||||
const paymentNotices = useSelect(
|
||||
( select ) =>
|
||||
select( 'core/notices' ).getNotices( noticeContexts.PAYMENTS ),
|
||||
[ noticeContexts.PAYMENTS ]
|
||||
);
|
||||
|
||||
return {
|
||||
checkoutNotices,
|
||||
expressPaymentNotices,
|
||||
paymentNotices,
|
||||
};
|
||||
};
|
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCheckoutContext } from '../providers/cart-checkout/checkout-state';
|
||||
import { usePaymentMethodDataContext } from '../providers/cart-checkout/payment-methods';
|
||||
import { usePaymentMethods } from './payment-methods/use-payment-methods';
|
||||
|
||||
/**
|
||||
* Returns the submitButtonText, onSubmit interface from the checkout context,
|
||||
* and an indication of submission status.
|
||||
*/
|
||||
export const useCheckoutSubmit = () => {
|
||||
const {
|
||||
onSubmit,
|
||||
isCalculating,
|
||||
isBeforeProcessing,
|
||||
isProcessing,
|
||||
isAfterProcessing,
|
||||
isComplete,
|
||||
hasError,
|
||||
} = useCheckoutContext();
|
||||
const { paymentMethods = {} } = usePaymentMethods();
|
||||
const {
|
||||
activePaymentMethod,
|
||||
currentStatus: paymentStatus,
|
||||
} = usePaymentMethodDataContext();
|
||||
const paymentMethod = paymentMethods[ activePaymentMethod ] || {};
|
||||
const waitingForProcessing =
|
||||
isProcessing || isAfterProcessing || isBeforeProcessing;
|
||||
const waitingForRedirect = isComplete && ! hasError;
|
||||
|
||||
return {
|
||||
submitButtonText:
|
||||
paymentMethod?.placeOrderButtonLabel ||
|
||||
__( 'Place Order', 'woocommerce' ),
|
||||
onSubmit,
|
||||
isCalculating,
|
||||
isDisabled: isProcessing || paymentStatus.isDoingExpressPayment,
|
||||
waitingForProcessing,
|
||||
waitingForRedirect,
|
||||
};
|
||||
};
|
@ -0,0 +1,186 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useDispatch } from '@wordpress/data';
|
||||
import { useEffect, useState, useCallback, useRef } from '@wordpress/element';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
import {
|
||||
formatStoreApiErrorMessage,
|
||||
pluckAddress,
|
||||
pluckEmail,
|
||||
} from '@woocommerce/base-utils';
|
||||
import type {
|
||||
CartResponseBillingAddress,
|
||||
CartResponseShippingAddress,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
declare type CustomerData = {
|
||||
billingData: CartResponseBillingAddress;
|
||||
shippingAddress: CartResponseShippingAddress;
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from './cart/use-store-cart';
|
||||
import { useStoreNotices } from './use-store-notices';
|
||||
|
||||
function instanceOfCartResponseBillingAddress(
|
||||
address: CartResponseBillingAddress | CartResponseShippingAddress
|
||||
): address is CartResponseBillingAddress {
|
||||
return 'email' in address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a shallow compare of important address data to determine if the cart needs updating on the server.
|
||||
*
|
||||
* This takes the current and previous address into account, as well as the billing email field.
|
||||
*
|
||||
* @param {Object} previousAddress An object containing all previous address information
|
||||
* @param {Object} address An object containing all address information
|
||||
*
|
||||
* @return {boolean} True if the store needs updating due to changed data.
|
||||
*/
|
||||
const shouldUpdateAddressStore = <
|
||||
T extends CartResponseBillingAddress | CartResponseShippingAddress
|
||||
>(
|
||||
previousAddress: T,
|
||||
address: T
|
||||
): boolean => {
|
||||
if (
|
||||
instanceOfCartResponseBillingAddress( address ) &&
|
||||
pluckEmail( address ) !==
|
||||
pluckEmail( previousAddress as CartResponseBillingAddress )
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
!! address.country &&
|
||||
! isShallowEqual(
|
||||
pluckAddress( previousAddress ),
|
||||
pluckAddress( address )
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* This is a custom hook for syncing customer address data (billing and shipping) with the server.
|
||||
*/
|
||||
export const useCustomerData = (): {
|
||||
billingData: CartResponseBillingAddress;
|
||||
shippingAddress: CartResponseShippingAddress;
|
||||
setBillingData: ( data: CartResponseBillingAddress ) => void;
|
||||
setShippingAddress: ( data: CartResponseShippingAddress ) => void;
|
||||
} => {
|
||||
const { updateCustomerData } = useDispatch( storeKey );
|
||||
const { addErrorNotice, removeNotice } = useStoreNotices();
|
||||
|
||||
// Grab the initial values from the store cart hook.
|
||||
const {
|
||||
billingAddress: initialBillingAddress,
|
||||
shippingAddress: initialShippingAddress,
|
||||
}: Omit< CustomerData, 'billingData' > & {
|
||||
billingAddress: CartResponseBillingAddress;
|
||||
} = useStoreCart();
|
||||
|
||||
// State of customer data is tracked here from this point, using the initial values from the useStoreCart hook.
|
||||
const [ customerData, setCustomerData ] = useState< CustomerData >( {
|
||||
billingData: initialBillingAddress,
|
||||
shippingAddress: initialShippingAddress,
|
||||
} );
|
||||
|
||||
// Store values last sent to the server in a ref to avoid requests unless important fields are changed.
|
||||
const previousCustomerData = useRef< CustomerData >( customerData );
|
||||
|
||||
// Debounce updates to the customerData state so it's not triggered excessively.
|
||||
const [ debouncedCustomerData ] = useDebounce( customerData, 1000, {
|
||||
// Default equalityFn is prevData === newData.
|
||||
equalityFn: ( prevData, newData ) => {
|
||||
return (
|
||||
isShallowEqual( prevData.billingData, newData.billingData ) &&
|
||||
isShallowEqual(
|
||||
prevData.shippingAddress,
|
||||
newData.shippingAddress
|
||||
)
|
||||
);
|
||||
},
|
||||
} );
|
||||
|
||||
/**
|
||||
* Set billing data.
|
||||
*
|
||||
* Contains special handling for email so those fields are not overwritten if simply updating address.
|
||||
*/
|
||||
const setBillingData = useCallback( ( newData ) => {
|
||||
setCustomerData( ( prevState ) => {
|
||||
return {
|
||||
...prevState,
|
||||
billingData: {
|
||||
...prevState.billingData,
|
||||
...newData,
|
||||
},
|
||||
};
|
||||
} );
|
||||
}, [] );
|
||||
|
||||
/**
|
||||
* Set shipping data.
|
||||
*/
|
||||
const setShippingAddress = useCallback( ( newData ) => {
|
||||
setCustomerData( ( prevState ) => {
|
||||
return {
|
||||
...prevState,
|
||||
shippingAddress: {
|
||||
...prevState.shippingAddress,
|
||||
...newData,
|
||||
},
|
||||
};
|
||||
} );
|
||||
}, [] );
|
||||
|
||||
/**
|
||||
* This pushes changes to the API when the local state differs from the address in the cart.
|
||||
*/
|
||||
useEffect( () => {
|
||||
// Only push updates when enough fields are populated.
|
||||
if (
|
||||
! shouldUpdateAddressStore(
|
||||
previousCustomerData.current.billingData,
|
||||
debouncedCustomerData.billingData
|
||||
) &&
|
||||
! shouldUpdateAddressStore(
|
||||
previousCustomerData.current.shippingAddress,
|
||||
debouncedCustomerData.shippingAddress
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
previousCustomerData.current = debouncedCustomerData;
|
||||
updateCustomerData( {
|
||||
billing_address: debouncedCustomerData.billingData,
|
||||
shipping_address: debouncedCustomerData.shippingAddress,
|
||||
} )
|
||||
.then( () => {
|
||||
removeNotice( 'checkout' );
|
||||
} )
|
||||
.catch( ( response ) => {
|
||||
addErrorNotice( formatStoreApiErrorMessage( response ), {
|
||||
id: 'checkout',
|
||||
} );
|
||||
} );
|
||||
}, [
|
||||
debouncedCustomerData,
|
||||
addErrorNotice,
|
||||
removeNotice,
|
||||
updateCustomerData,
|
||||
] );
|
||||
return {
|
||||
billingData: customerData.billingData,
|
||||
shippingAddress: customerData.shippingAddress,
|
||||
setBillingData,
|
||||
setShippingAddress,
|
||||
};
|
||||
};
|
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { isObject } from '@woocommerce/types';
|
||||
|
||||
export enum responseTypes {
|
||||
SUCCESS = 'success',
|
||||
FAIL = 'failure',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export enum noticeContexts {
|
||||
PAYMENTS = 'wc/payment-area',
|
||||
EXPRESS_PAYMENTS = 'wc/express-payment-area',
|
||||
}
|
||||
|
||||
export interface ResponseType extends Record< string, unknown > {
|
||||
type: responseTypes;
|
||||
retry?: boolean;
|
||||
}
|
||||
|
||||
const isResponseOf = (
|
||||
response: unknown,
|
||||
type: string
|
||||
): response is ResponseType => {
|
||||
return isObject( response ) && 'type' in response && response.type === type;
|
||||
};
|
||||
|
||||
export const isSuccessResponse = (
|
||||
response: unknown
|
||||
): response is ResponseType => {
|
||||
return isResponseOf( response, responseTypes.SUCCESS );
|
||||
};
|
||||
|
||||
export const isErrorResponse = (
|
||||
response: unknown
|
||||
): response is ResponseType => {
|
||||
return isResponseOf( response, responseTypes.ERROR );
|
||||
};
|
||||
|
||||
export const isFailResponse = (
|
||||
response: unknown
|
||||
): response is ResponseType => {
|
||||
return isResponseOf( response, responseTypes.FAIL );
|
||||
};
|
||||
|
||||
export const shouldRetry = ( response: unknown ): boolean => {
|
||||
return (
|
||||
! isObject( response ) ||
|
||||
typeof response.retry === 'undefined' ||
|
||||
response.retry === true
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom hook exposing response utilities for emitters.
|
||||
*/
|
||||
export const useEmitResponse = () =>
|
||||
( {
|
||||
responseTypes,
|
||||
noticeContexts,
|
||||
shouldRetry,
|
||||
isSuccessResponse,
|
||||
isErrorResponse,
|
||||
isFailResponse,
|
||||
} as const );
|
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
import { useRef, useEffect, useCallback } from '@wordpress/element';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
import { useShallowEqual, usePrevious } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
|
||||
import { useQueryStateContext } from '../providers/query-state-context';
|
||||
|
||||
/**
|
||||
* A custom hook that exposes the current query state and a setter for the query
|
||||
* state store for the given context.
|
||||
*
|
||||
* "Query State" is a wp.data store that keeps track of an arbitrary object of
|
||||
* query keys and their values.
|
||||
*
|
||||
* @param {string} [context] What context to retrieve the query state for. If not
|
||||
* provided, this hook will attempt to get the context
|
||||
* from the query state context provided by the
|
||||
* QueryStateContextProvider
|
||||
*
|
||||
* @return {Array} An array that has two elements. The first element is the
|
||||
* query state value for the given context. The second element
|
||||
* is a dispatcher function for setting the query state.
|
||||
*/
|
||||
export const useQueryStateByContext = ( context ) => {
|
||||
const queryStateContext = useQueryStateContext();
|
||||
context = context || queryStateContext;
|
||||
const queryState = useSelect(
|
||||
( select ) => {
|
||||
const store = select( storeKey );
|
||||
return store.getValueForQueryContext( context, undefined );
|
||||
},
|
||||
[ context ]
|
||||
);
|
||||
const { setValueForQueryContext } = useDispatch( storeKey );
|
||||
const setQueryState = useCallback(
|
||||
( value ) => {
|
||||
setValueForQueryContext( context, value );
|
||||
},
|
||||
[ context, setValueForQueryContext ]
|
||||
);
|
||||
|
||||
return [ queryState, setQueryState ];
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom hook that exposes the current query state value and a setter for the
|
||||
* given context and query key.
|
||||
*
|
||||
* "Query State" is a wp.data store that keeps track of an arbitrary object of
|
||||
* query keys and their values.
|
||||
*
|
||||
* @param {*} queryKey The specific query key to retrieve the value for.
|
||||
* @param {*} [defaultValue] Default value if query does not exist.
|
||||
* @param {string} [context] What context to retrieve the query state for. If
|
||||
* not provided will attempt to use what is provided
|
||||
* by query state context.
|
||||
*
|
||||
* @return {*} Whatever value is set at the query state index using the
|
||||
* provided context and query key.
|
||||
*/
|
||||
export const useQueryStateByKey = ( queryKey, defaultValue, context ) => {
|
||||
const queryStateContext = useQueryStateContext();
|
||||
context = context || queryStateContext;
|
||||
const queryValue = useSelect(
|
||||
( select ) => {
|
||||
const store = select( storeKey );
|
||||
return store.getValueForQueryKey( context, queryKey, defaultValue );
|
||||
},
|
||||
[ context, queryKey ]
|
||||
);
|
||||
|
||||
const { setQueryValue } = useDispatch( storeKey );
|
||||
const setQueryValueByKey = useCallback(
|
||||
( value ) => {
|
||||
setQueryValue( context, queryKey, value );
|
||||
},
|
||||
[ context, queryKey, setQueryValue ]
|
||||
);
|
||||
|
||||
return [ queryValue, setQueryValueByKey ];
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom hook that works similarly to useQueryStateByContext. However, this
|
||||
* hook allows for synchronizing with a provided queryState object.
|
||||
*
|
||||
* This hook does the following things with the provided `synchronizedQuery`
|
||||
* object:
|
||||
*
|
||||
* - whenever synchronizedQuery varies between renders, the queryState will be
|
||||
* updated to a merged object of the internal queryState and the provided
|
||||
* object. Note, any values from the same properties between objects will
|
||||
* be set from synchronizedQuery.
|
||||
* - if there are no changes between renders, then the existing internal
|
||||
* queryState is always returned.
|
||||
* - on initial render, the synchronizedQuery value is returned.
|
||||
*
|
||||
* Typically, this hook would be used in a scenario where there may be external
|
||||
* triggers for updating the query state (i.e. initial population of query
|
||||
* state by hydration or component attributes, or routing url changes that
|
||||
* affect query state).
|
||||
*
|
||||
* @param {Object} synchronizedQuery A provided query state object to
|
||||
* synchronize internal query state with.
|
||||
* @param {string} [context] What context to retrieve the query state
|
||||
* for. If not provided, will be pulled from
|
||||
* the QueryStateContextProvider in the tree.
|
||||
*/
|
||||
export const useSynchronizedQueryState = ( synchronizedQuery, context ) => {
|
||||
const queryStateContext = useQueryStateContext();
|
||||
context = context || queryStateContext;
|
||||
const [ queryState, setQueryState ] = useQueryStateByContext( context );
|
||||
const currentQueryState = useShallowEqual( queryState );
|
||||
const currentSynchronizedQuery = useShallowEqual( synchronizedQuery );
|
||||
const previousSynchronizedQuery = usePrevious( currentSynchronizedQuery );
|
||||
// used to ensure we allow initial synchronization to occur before
|
||||
// returning non-synced state.
|
||||
const isInitialized = useRef( false );
|
||||
// update queryState anytime incoming synchronizedQuery changes
|
||||
useEffect( () => {
|
||||
if (
|
||||
! isShallowEqual(
|
||||
previousSynchronizedQuery,
|
||||
currentSynchronizedQuery
|
||||
)
|
||||
) {
|
||||
setQueryState(
|
||||
Object.assign( {}, currentQueryState, currentSynchronizedQuery )
|
||||
);
|
||||
isInitialized.current = true;
|
||||
}
|
||||
}, [
|
||||
currentQueryState,
|
||||
currentSynchronizedQuery,
|
||||
previousSynchronizedQuery,
|
||||
setQueryState,
|
||||
] );
|
||||
return isInitialized.current
|
||||
? [ queryState, setQueryState ]
|
||||
: [ synchronizedQuery, setQueryState ];
|
||||
};
|
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect, useRef } from '@wordpress/element';
|
||||
import { useDispatch } from '@wordpress/data';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import type { CartItem } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from './cart/use-store-cart';
|
||||
import { useStoreNotices } from './use-store-notices';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/hooks').StoreCartItemAddToCart} StoreCartItemAddToCart
|
||||
*/
|
||||
|
||||
interface StoreAddToCart {
|
||||
cartQuantity: number;
|
||||
addingToCart: boolean;
|
||||
cartIsLoading: boolean;
|
||||
addToCart: ( quantity?: number ) => Promise< boolean >;
|
||||
}
|
||||
/**
|
||||
* Get the quantity of a product in the cart.
|
||||
*
|
||||
* @param {Object} cartItems Array of items.
|
||||
* @param {number} productId The product id to look for.
|
||||
* @return {number} Quantity in the cart.
|
||||
*/
|
||||
const getQuantityFromCartItems = (
|
||||
cartItems: Array< CartItem >,
|
||||
productId: number
|
||||
): number => {
|
||||
const productItem = cartItems.find( ( { id } ) => id === productId );
|
||||
|
||||
return productItem ? productItem.quantity : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom hook for exposing cart related data for a given product id and an
|
||||
* action for adding a single quantity of the product _to_ the cart.
|
||||
*
|
||||
*
|
||||
* @param {number} productId The product id to be added to the cart.
|
||||
*
|
||||
* @return {StoreCartItemAddToCart} An object exposing data and actions relating
|
||||
* to add to cart functionality.
|
||||
*/
|
||||
export const useStoreAddToCart = ( productId: number ): StoreAddToCart => {
|
||||
const { addItemToCart } = useDispatch( storeKey );
|
||||
const { cartItems, cartIsLoading } = useStoreCart();
|
||||
const { addErrorNotice, removeNotice } = useStoreNotices();
|
||||
|
||||
const [ addingToCart, setAddingToCart ] = useState( false );
|
||||
const currentCartItemQuantity = useRef(
|
||||
getQuantityFromCartItems( cartItems, productId )
|
||||
);
|
||||
|
||||
const addToCart = ( quantity = 1 ) => {
|
||||
setAddingToCart( true );
|
||||
return addItemToCart( productId, quantity )
|
||||
.then( () => {
|
||||
removeNotice( 'add-to-cart' );
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
addErrorNotice( decodeEntities( error.message ), {
|
||||
context: 'wc/all-products',
|
||||
id: 'add-to-cart',
|
||||
isDismissible: true,
|
||||
} );
|
||||
} )
|
||||
.finally( () => {
|
||||
setAddingToCart( false );
|
||||
} );
|
||||
};
|
||||
|
||||
useEffect( () => {
|
||||
const quantity = getQuantityFromCartItems( cartItems, productId );
|
||||
|
||||
if ( quantity !== currentCartItemQuantity.current ) {
|
||||
currentCartItemQuantity.current = quantity;
|
||||
}
|
||||
}, [ cartItems, productId ] );
|
||||
|
||||
return {
|
||||
cartQuantity: Number.isFinite( currentCartItemQuantity.current )
|
||||
? currentCartItemQuantity.current
|
||||
: 0,
|
||||
addingToCart,
|
||||
cartIsLoading,
|
||||
addToCart,
|
||||
};
|
||||
};
|
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { doAction } from '@wordpress/hooks';
|
||||
import { useCallback, useRef, useEffect } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from './cart/use-store-cart';
|
||||
|
||||
type StoreEvent = (
|
||||
eventName: string,
|
||||
eventParams?: Partial< Record< string, unknown > >
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Abstraction on top of @wordpress/hooks for dispatching events via doAction for 3rd parties to hook into.
|
||||
*/
|
||||
export const useStoreEvents = (): {
|
||||
dispatchStoreEvent: StoreEvent;
|
||||
dispatchCheckoutEvent: StoreEvent;
|
||||
} => {
|
||||
const storeCart = useStoreCart();
|
||||
const currentStoreCart = useRef( storeCart );
|
||||
|
||||
// Track the latest version of the cart so we can use the current value in our callback function below without triggering
|
||||
// other useEffect hooks using dispatchCheckoutEvent as a dependency.
|
||||
useEffect( () => {
|
||||
currentStoreCart.current = storeCart;
|
||||
}, [ storeCart ] );
|
||||
|
||||
const dispatchStoreEvent = useCallback( ( eventName, eventParams = {} ) => {
|
||||
try {
|
||||
doAction(
|
||||
`experimental__woocommerce_blocks-${ eventName }`,
|
||||
eventParams
|
||||
);
|
||||
} catch ( e ) {
|
||||
// We don't handle thrown errors but just console.log for troubleshooting.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error( e );
|
||||
}
|
||||
}, [] );
|
||||
|
||||
const dispatchCheckoutEvent = useCallback(
|
||||
( eventName, eventParams = {} ) => {
|
||||
try {
|
||||
doAction(
|
||||
`experimental__woocommerce_blocks-checkout-${ eventName }`,
|
||||
{
|
||||
...eventParams,
|
||||
storeCart: currentStoreCart.current,
|
||||
}
|
||||
);
|
||||
} catch ( e ) {
|
||||
// We don't handle thrown errors but just console.log for troubleshooting.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error( e );
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { dispatchStoreEvent, dispatchCheckoutEvent };
|
||||
};
|
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo, useRef, useEffect } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreNoticesContext } from '../providers/store-notices/context';
|
||||
|
||||
type WPNoticeAction = {
|
||||
label: string;
|
||||
url?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type WPNotice = {
|
||||
id: string;
|
||||
status: 'success' | 'info' | 'error' | 'warning';
|
||||
content: string;
|
||||
spokenMessage: string;
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
__unstableHTML: string;
|
||||
isDismissible: boolean;
|
||||
type: 'default' | 'snackbar';
|
||||
speak: boolean;
|
||||
actions: WPNoticeAction[];
|
||||
};
|
||||
|
||||
type NoticeOptions = {
|
||||
id: string;
|
||||
type?: string;
|
||||
isDismissible: boolean;
|
||||
};
|
||||
|
||||
type NoticeCreator = ( text: string, noticeProps: NoticeOptions ) => void;
|
||||
|
||||
export const useStoreNotices = (): {
|
||||
notices: WPNotice[];
|
||||
hasNoticesOfType: ( type: string ) => boolean;
|
||||
removeNotices: ( status: string | null ) => void;
|
||||
removeNotice: ( id: string, context: string ) => void;
|
||||
addDefaultNotice: NoticeCreator;
|
||||
addErrorNotice: NoticeCreator;
|
||||
addWarningNotice: NoticeCreator;
|
||||
addInfoNotice: NoticeCreator;
|
||||
addSuccessNotice: NoticeCreator;
|
||||
setIsSuppressed: ( isSuppressed: boolean ) => void;
|
||||
} => {
|
||||
const {
|
||||
notices,
|
||||
createNotice,
|
||||
removeNotice,
|
||||
setIsSuppressed,
|
||||
} = useStoreNoticesContext();
|
||||
// Added to a ref so the surface for notices doesn't change frequently
|
||||
// and thus can be used as dependencies on effects.
|
||||
const currentNotices = useRef( notices );
|
||||
|
||||
// Update notices ref whenever they change
|
||||
useEffect( () => {
|
||||
currentNotices.current = notices;
|
||||
}, [ notices ] );
|
||||
|
||||
const noticesApi = useMemo(
|
||||
() => ( {
|
||||
hasNoticesOfType: ( type: 'default' | 'snackbar' ): boolean => {
|
||||
return currentNotices.current.some(
|
||||
( notice ) => notice.type === type
|
||||
);
|
||||
},
|
||||
removeNotices: ( status = null ) => {
|
||||
currentNotices.current.forEach( ( notice ) => {
|
||||
if ( status === null || notice.status === status ) {
|
||||
removeNotice( notice.id );
|
||||
}
|
||||
} );
|
||||
},
|
||||
removeNotice,
|
||||
} ),
|
||||
[ removeNotice ]
|
||||
);
|
||||
|
||||
const noticeCreators = useMemo(
|
||||
() => ( {
|
||||
addDefaultNotice: ( text: string, noticeProps = {} ) =>
|
||||
void createNotice( 'default', text, {
|
||||
...noticeProps,
|
||||
} ),
|
||||
addErrorNotice: ( text: string, noticeProps = {} ) =>
|
||||
void createNotice( 'error', text, {
|
||||
...noticeProps,
|
||||
} ),
|
||||
addWarningNotice: ( text: string, noticeProps = {} ) =>
|
||||
void createNotice( 'warning', text, {
|
||||
...noticeProps,
|
||||
} ),
|
||||
addInfoNotice: ( text: string, noticeProps = {} ) =>
|
||||
void createNotice( 'info', text, {
|
||||
...noticeProps,
|
||||
} ),
|
||||
addSuccessNotice: ( text: string, noticeProps = {} ) =>
|
||||
void createNotice( 'success', text, {
|
||||
...noticeProps,
|
||||
} ),
|
||||
} ),
|
||||
[ createNotice ]
|
||||
);
|
||||
|
||||
return {
|
||||
notices,
|
||||
...noticesApi,
|
||||
...noticeCreators,
|
||||
setIsSuppressed,
|
||||
};
|
||||
};
|
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCollectionHeader, useCollection } from './collections';
|
||||
|
||||
/**
|
||||
* This is a custom hook that is wired up to the `wc/store/collections` data
|
||||
* store for the `wc/store/products` route. Given a query object, this
|
||||
* will ensure a component is kept up to date with the products 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
|
||||
* products. Does not have to be included.
|
||||
*
|
||||
* @return {Object} This hook will return an object with three properties:
|
||||
* - products An array of product objects.
|
||||
* - totalProducts The total number of products that match
|
||||
* the given query parameters.
|
||||
* - productsLoading A boolean indicating whether the products
|
||||
* are still loading or not.
|
||||
*/
|
||||
export const useStoreProducts = ( query ) => {
|
||||
const collectionOptions = {
|
||||
namespace: '/wc/store',
|
||||
resourceName: 'products',
|
||||
};
|
||||
const { results: products, isLoading: productsLoading } = useCollection( {
|
||||
...collectionOptions,
|
||||
query,
|
||||
} );
|
||||
const { value: totalProducts } = useCollectionHeader( 'x-wp-total', {
|
||||
...collectionOptions,
|
||||
query,
|
||||
} );
|
||||
return {
|
||||
products,
|
||||
totalProducts: parseInt( totalProducts, 10 ),
|
||||
productsLoading,
|
||||
};
|
||||
};
|
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo, useRef, useEffect } from '@wordpress/element';
|
||||
import { useStoreSnackbarNoticesContext } from '@woocommerce/base-context/providers';
|
||||
|
||||
export const useStoreSnackbarNotices = () => {
|
||||
const {
|
||||
notices,
|
||||
createSnackbarNotice,
|
||||
removeSnackbarNotice,
|
||||
setIsSuppressed,
|
||||
} = useStoreSnackbarNoticesContext();
|
||||
// Added to a ref so the surface for notices doesn't change frequently
|
||||
// and thus can be used as dependencies on effects.
|
||||
const currentNotices = useRef( notices );
|
||||
|
||||
// Update notices ref whenever they change
|
||||
useEffect( () => {
|
||||
currentNotices.current = notices;
|
||||
}, [ notices ] );
|
||||
|
||||
const noticesApi = useMemo(
|
||||
() => ( {
|
||||
removeNotices: ( status = null ) => {
|
||||
currentNotices.current.forEach( ( notice ) => {
|
||||
if ( status === null || notice.status === status ) {
|
||||
removeSnackbarNotice( notice.id );
|
||||
}
|
||||
} );
|
||||
},
|
||||
removeSnackbarNotice,
|
||||
} ),
|
||||
[ removeSnackbarNotice ]
|
||||
);
|
||||
|
||||
const noticeCreators = useMemo(
|
||||
() => ( {
|
||||
addSnackbarNotice: ( text, noticeProps = {} ) => {
|
||||
createSnackbarNotice( text, noticeProps );
|
||||
},
|
||||
} ),
|
||||
[ createSnackbarNotice ]
|
||||
);
|
||||
|
||||
return {
|
||||
notices,
|
||||
...noticesApi,
|
||||
...noticeCreators,
|
||||
setIsSuppressed,
|
||||
};
|
||||
};
|
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useCallback } from '@wordpress/element';
|
||||
import type {
|
||||
ValidationData,
|
||||
ValidationContextError,
|
||||
} from '@woocommerce/type-defs/contexts';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useValidationContext } from '../providers/validation/';
|
||||
|
||||
/**
|
||||
* Custom hook for setting for adding errors to the validation system.
|
||||
*/
|
||||
export const useValidation = (): ValidationData => {
|
||||
const {
|
||||
hasValidationErrors,
|
||||
getValidationError,
|
||||
clearValidationError,
|
||||
hideValidationError,
|
||||
setValidationErrors,
|
||||
} = useValidationContext();
|
||||
const prefix = 'extensions-errors';
|
||||
|
||||
return {
|
||||
hasValidationErrors,
|
||||
getValidationError: useCallback(
|
||||
( validationErrorId: string ) =>
|
||||
getValidationError( `${ prefix }-${ validationErrorId }` ),
|
||||
[ getValidationError ]
|
||||
),
|
||||
clearValidationError: useCallback(
|
||||
( validationErrorId: string ) =>
|
||||
clearValidationError( `${ prefix }-${ validationErrorId }` ),
|
||||
[ clearValidationError ]
|
||||
),
|
||||
hideValidationError: useCallback(
|
||||
( validationErrorId: string ) =>
|
||||
hideValidationError( `${ prefix }-${ validationErrorId }` ),
|
||||
[ hideValidationError ]
|
||||
),
|
||||
setValidationErrors: useCallback(
|
||||
( errorsObject: Record< string, ValidationContextError > ) =>
|
||||
setValidationErrors(
|
||||
Object.fromEntries(
|
||||
Object.entries(
|
||||
errorsObject
|
||||
).map( ( [ validationErrorId, error ] ) => [
|
||||
`${ prefix }-${ validationErrorId }`,
|
||||
error,
|
||||
] )
|
||||
)
|
||||
),
|
||||
[ setValidationErrors ]
|
||||
),
|
||||
};
|
||||
};
|
Reference in New Issue
Block a user