initial commit
This commit is contained in:
@ -0,0 +1,16 @@
|
||||
export const ACTION_TYPES = {
|
||||
RECEIVE_CART: 'RECEIVE_CART',
|
||||
RECEIVE_ERROR: 'RECEIVE_ERROR',
|
||||
REPLACE_ERRORS: 'REPLACE_ERRORS',
|
||||
APPLYING_COUPON: 'APPLYING_COUPON',
|
||||
REMOVING_COUPON: 'REMOVING_COUPON',
|
||||
RECEIVE_CART_ITEM: 'RECEIVE_CART_ITEM',
|
||||
ITEM_PENDING_QUANTITY: 'ITEM_PENDING_QUANTITY',
|
||||
SET_IS_CART_DATA_STALE: 'SET_IS_CART_DATA_STALE',
|
||||
RECEIVE_REMOVED_ITEM: 'RECEIVE_REMOVED_ITEM',
|
||||
UPDATING_CUSTOMER_DATA: 'UPDATING_CUSTOMER_DATA',
|
||||
UPDATING_SELECTED_SHIPPING_RATE: 'UPDATING_SELECTED_SHIPPING_RATE',
|
||||
UPDATE_LEGACY_CART_FRAGMENTS: 'UPDATE_LEGACY_CART_FRAGMENTS',
|
||||
TRIGGER_ADDING_TO_CART_EVENT: 'TRIGGER_ADDING_TO_CART_EVENT',
|
||||
TRIGGER_ADDED_TO_CART_EVENT: 'TRIGGER_ADDED_TO_CART_EVENT',
|
||||
} as const;
|
525
packages/woocommerce-blocks/assets/js/data/cart/actions.ts
Normal file
525
packages/woocommerce-blocks/assets/js/data/cart/actions.ts
Normal file
@ -0,0 +1,525 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { select } from '@wordpress/data-controls';
|
||||
import type {
|
||||
Cart,
|
||||
CartResponse,
|
||||
CartResponseItem,
|
||||
CartBillingAddress,
|
||||
CartShippingAddress,
|
||||
ExtensionCartUpdateArgs,
|
||||
} from '@woocommerce/types';
|
||||
import { ReturnOrGeneratorYieldUnion } from '@automattic/data-stores';
|
||||
import { camelCase, mapKeys } from 'lodash';
|
||||
import type { AddToCartEventDetail } from '@woocommerce/type-defs/events';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION_TYPES as types } from './action-types';
|
||||
import { STORE_KEY as CART_STORE_KEY } from './constants';
|
||||
import { apiFetchWithHeaders } from '../shared-controls';
|
||||
import type { ResponseError } from '../types';
|
||||
|
||||
/**
|
||||
* Returns an action object used in updating the store with the provided items
|
||||
* retrieved from a request using the given querystring.
|
||||
*
|
||||
* This is a generic response action.
|
||||
*
|
||||
* @param {CartResponse} response
|
||||
*/
|
||||
export const receiveCart = (
|
||||
response: CartResponse
|
||||
): { type: string; response: Cart } => {
|
||||
const cart = ( mapKeys( response, ( _, key ) =>
|
||||
camelCase( key )
|
||||
) as unknown ) as Cart;
|
||||
return {
|
||||
type: types.RECEIVE_CART,
|
||||
response: cart,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an action object used for receiving customer facing errors from the API.
|
||||
*
|
||||
* @param {ResponseError|null} [error=null] An error object containing the error
|
||||
* message and response code.
|
||||
* @param {boolean} [replace=true] Should existing errors be replaced,
|
||||
* or should the error be appended.
|
||||
*/
|
||||
export const receiveError = (
|
||||
error: ResponseError | null = null,
|
||||
replace = true
|
||||
) =>
|
||||
( {
|
||||
type: replace ? types.REPLACE_ERRORS : types.RECEIVE_ERROR,
|
||||
error,
|
||||
} as const );
|
||||
|
||||
/**
|
||||
* Returns an action object used to track when a coupon is applying.
|
||||
*
|
||||
* @param {string} [couponCode] Coupon being added.
|
||||
*/
|
||||
export const receiveApplyingCoupon = ( couponCode: string ) =>
|
||||
( {
|
||||
type: types.APPLYING_COUPON,
|
||||
couponCode,
|
||||
} as const );
|
||||
|
||||
/**
|
||||
* Returns an action object used to track when a coupon is removing.
|
||||
*
|
||||
* @param {string} [couponCode] Coupon being removed..
|
||||
*/
|
||||
export const receiveRemovingCoupon = ( couponCode: string ) =>
|
||||
( {
|
||||
type: types.REMOVING_COUPON,
|
||||
couponCode,
|
||||
} as const );
|
||||
|
||||
/**
|
||||
* Returns an action object for updating a single cart item in the store.
|
||||
*
|
||||
* @param {CartResponseItem} [response=null] A cart item API response.
|
||||
*/
|
||||
export const receiveCartItem = ( response: CartResponseItem | null = null ) =>
|
||||
( {
|
||||
type: types.RECEIVE_CART_ITEM,
|
||||
cartItem: response,
|
||||
} as const );
|
||||
|
||||
/**
|
||||
* Returns an action object to indicate if the specified cart item quantity is
|
||||
* being updated.
|
||||
*
|
||||
* @param {string} cartItemKey Cart item being updated.
|
||||
* @param {boolean} [isPendingQuantity=true] Flag for update state; true if API
|
||||
* request is pending.
|
||||
*/
|
||||
export const itemIsPendingQuantity = (
|
||||
cartItemKey: string,
|
||||
isPendingQuantity = true
|
||||
) =>
|
||||
( {
|
||||
type: types.ITEM_PENDING_QUANTITY,
|
||||
cartItemKey,
|
||||
isPendingQuantity,
|
||||
} as const );
|
||||
|
||||
/**
|
||||
* Returns an action object to remove a cart item from the store.
|
||||
*
|
||||
* @param {string} cartItemKey Cart item to remove.
|
||||
* @param {boolean} [isPendingDelete=true] Flag for update state; true if API
|
||||
* request is pending.
|
||||
*/
|
||||
export const itemIsPendingDelete = (
|
||||
cartItemKey: string,
|
||||
isPendingDelete = true
|
||||
) =>
|
||||
( {
|
||||
type: types.RECEIVE_REMOVED_ITEM,
|
||||
cartItemKey,
|
||||
isPendingDelete,
|
||||
} as const );
|
||||
/**
|
||||
* Returns an action object to mark the cart data in the store as stale.
|
||||
*
|
||||
* @param {boolean} [isCartDataStale=true] Flag to mark cart data as stale; true if
|
||||
* lastCartUpdate timestamp is newer than the
|
||||
* one in wcSettings.
|
||||
*/
|
||||
export const setIsCartDataStale = ( isCartDataStale = true ) =>
|
||||
( {
|
||||
type: types.SET_IS_CART_DATA_STALE,
|
||||
isCartDataStale,
|
||||
} as const );
|
||||
|
||||
/**
|
||||
* Returns an action object used to track when customer data is being updated
|
||||
* (billing and/or shipping).
|
||||
*/
|
||||
export const updatingCustomerData = ( isResolving: boolean ) =>
|
||||
( {
|
||||
type: types.UPDATING_CUSTOMER_DATA,
|
||||
isResolving,
|
||||
} as const );
|
||||
|
||||
/**
|
||||
* Returns an action object used to track whether the shipping rate is being
|
||||
* selected or not.
|
||||
*
|
||||
* @param {boolean} isResolving True if shipping rate is being selected.
|
||||
*/
|
||||
export const shippingRatesBeingSelected = ( isResolving: boolean ) =>
|
||||
( {
|
||||
type: types.UPDATING_SELECTED_SHIPPING_RATE,
|
||||
isResolving,
|
||||
} as const );
|
||||
|
||||
/**
|
||||
* Returns an action object for updating legacy cart fragments.
|
||||
*/
|
||||
export const updateCartFragments = () =>
|
||||
( {
|
||||
type: types.UPDATE_LEGACY_CART_FRAGMENTS,
|
||||
} as const );
|
||||
|
||||
/**
|
||||
* Triggers an adding to cart event so other blocks can update accordingly.
|
||||
*/
|
||||
export const triggerAddingToCartEvent = () =>
|
||||
( {
|
||||
type: types.TRIGGER_ADDING_TO_CART_EVENT,
|
||||
} as const );
|
||||
|
||||
/**
|
||||
* Triggers an added to cart event so other blocks can update accordingly.
|
||||
*/
|
||||
export const triggerAddedToCartEvent = ( {
|
||||
preserveCartData,
|
||||
}: AddToCartEventDetail ) =>
|
||||
( {
|
||||
type: types.TRIGGER_ADDED_TO_CART_EVENT,
|
||||
preserveCartData,
|
||||
} as const );
|
||||
|
||||
/**
|
||||
* POSTs to the /cart/extensions endpoint with the data supplied by the extension.
|
||||
*
|
||||
* @param {Object} args The data to be posted to the endpoint
|
||||
*/
|
||||
export function* applyExtensionCartUpdate(
|
||||
args: ExtensionCartUpdateArgs
|
||||
): Generator< unknown, CartResponse, { response: CartResponse } > {
|
||||
try {
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: '/wc/store/cart/extensions',
|
||||
method: 'POST',
|
||||
data: { namespace: args.namespace, data: args.data },
|
||||
cache: 'no-store',
|
||||
} );
|
||||
yield receiveCart( response );
|
||||
yield updateCartFragments();
|
||||
return response;
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
}
|
||||
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a coupon code and either invalidates caches, or receives an error if
|
||||
* the coupon cannot be applied.
|
||||
*
|
||||
* @param {string} couponCode The coupon code to apply to the cart.
|
||||
* @throws Will throw an error if there is an API problem.
|
||||
*/
|
||||
export function* applyCoupon(
|
||||
couponCode: string
|
||||
): Generator< unknown, boolean, { response: CartResponse } > {
|
||||
yield receiveApplyingCoupon( couponCode );
|
||||
|
||||
try {
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: '/wc/store/cart/apply-coupon',
|
||||
method: 'POST',
|
||||
data: {
|
||||
code: couponCode,
|
||||
},
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
yield receiveCart( response );
|
||||
yield receiveApplyingCoupon( '' );
|
||||
yield updateCartFragments();
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
yield receiveApplyingCoupon( '' );
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
}
|
||||
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a coupon code and either invalidates caches, or receives an error if
|
||||
* the coupon cannot be removed.
|
||||
*
|
||||
* @param {string} couponCode The coupon code to remove from the cart.
|
||||
* @throws Will throw an error if there is an API problem.
|
||||
*/
|
||||
export function* removeCoupon(
|
||||
couponCode: string
|
||||
): Generator< unknown, boolean, { response: CartResponse } > {
|
||||
yield receiveRemovingCoupon( couponCode );
|
||||
|
||||
try {
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: '/wc/store/cart/remove-coupon',
|
||||
method: 'POST',
|
||||
data: {
|
||||
code: couponCode,
|
||||
},
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
yield receiveCart( response );
|
||||
yield receiveRemovingCoupon( '' );
|
||||
yield updateCartFragments();
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
yield receiveRemovingCoupon( '' );
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
}
|
||||
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an item to the cart:
|
||||
* - Calls API to add item.
|
||||
* - If successful, yields action to add item from store.
|
||||
* - If error, yields action to store error.
|
||||
*
|
||||
* @param {number} productId Product ID to add to cart.
|
||||
* @param {number} [quantity=1] Number of product ID being added to cart.
|
||||
* @throws Will throw an error if there is an API problem.
|
||||
*/
|
||||
export function* addItemToCart(
|
||||
productId: number,
|
||||
quantity = 1
|
||||
): Generator< unknown, void, { response: CartResponse } > {
|
||||
try {
|
||||
yield triggerAddingToCartEvent();
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: `/wc/store/cart/add-item`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
id: productId,
|
||||
quantity,
|
||||
},
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
yield receiveCart( response );
|
||||
yield triggerAddedToCartEvent( { preserveCartData: true } );
|
||||
yield updateCartFragments();
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
}
|
||||
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes specified item from the cart:
|
||||
* - Calls API to remove item.
|
||||
* - If successful, yields action to remove item from store.
|
||||
* - If error, yields action to store error.
|
||||
* - Sets cart item as pending while API request is in progress.
|
||||
*
|
||||
* @param {string} cartItemKey Cart item being updated.
|
||||
*/
|
||||
export function* removeItemFromCart(
|
||||
cartItemKey: string
|
||||
): Generator< unknown, void, { response: CartResponse } > {
|
||||
yield itemIsPendingDelete( cartItemKey );
|
||||
|
||||
try {
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: `/wc/store/cart/remove-item`,
|
||||
data: {
|
||||
key: cartItemKey,
|
||||
},
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
yield receiveCart( response );
|
||||
yield updateCartFragments();
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
}
|
||||
}
|
||||
yield itemIsPendingDelete( cartItemKey, false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a quantity change the for specified cart item:
|
||||
* - Calls API to set quantity.
|
||||
* - If successful, yields action to update store.
|
||||
* - If error, yields action to store error.
|
||||
*
|
||||
* @param {string} cartItemKey Cart item being updated.
|
||||
* @param {number} quantity Specified (new) quantity.
|
||||
*/
|
||||
export function* changeCartItemQuantity(
|
||||
cartItemKey: string,
|
||||
quantity: number
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- unclear how to represent multiple different yields as type
|
||||
): Generator< unknown, void, any > {
|
||||
const cartItem = yield select( CART_STORE_KEY, 'getCartItem', cartItemKey );
|
||||
yield itemIsPendingQuantity( cartItemKey );
|
||||
|
||||
if ( cartItem?.quantity === quantity ) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: '/wc/store/cart/update-item',
|
||||
method: 'POST',
|
||||
data: {
|
||||
key: cartItemKey,
|
||||
quantity,
|
||||
},
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
yield receiveCart( response );
|
||||
yield updateCartFragments();
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
}
|
||||
}
|
||||
yield itemIsPendingQuantity( cartItemKey, false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a shipping rate.
|
||||
*
|
||||
* @param {string} rateId The id of the rate being selected.
|
||||
* @param {number | string} [packageId] The key of the packages that we will
|
||||
* select within.
|
||||
*/
|
||||
export function* selectShippingRate(
|
||||
rateId: string,
|
||||
packageId = 0
|
||||
): Generator< unknown, boolean, { response: CartResponse } > {
|
||||
try {
|
||||
yield shippingRatesBeingSelected( true );
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: `/wc/store/cart/select-shipping-rate`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
package_id: packageId,
|
||||
rate_id: rateId,
|
||||
},
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
yield receiveCart( response );
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
yield shippingRatesBeingSelected( false );
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
}
|
||||
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
}
|
||||
yield shippingRatesBeingSelected( false );
|
||||
return true;
|
||||
}
|
||||
|
||||
type BillingAddressShippingAddress = {
|
||||
billing_address: CartBillingAddress;
|
||||
shipping_address: CartShippingAddress;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the shipping and/or billing address for the customer and returns an
|
||||
* updated cart.
|
||||
*
|
||||
* @param {BillingAddressShippingAddress} customerData Address data to be updated; can contain both
|
||||
* billing_address and shipping_address.
|
||||
*/
|
||||
export function* updateCustomerData(
|
||||
customerData: BillingAddressShippingAddress
|
||||
): Generator< unknown, boolean, { response: CartResponse } > {
|
||||
yield updatingCustomerData( true );
|
||||
|
||||
try {
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: '/wc/store/cart/update-customer',
|
||||
method: 'POST',
|
||||
data: customerData,
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
yield receiveCart( response );
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
yield updatingCustomerData( false );
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
}
|
||||
|
||||
// rethrow error.
|
||||
throw error;
|
||||
}
|
||||
|
||||
yield updatingCustomerData( false );
|
||||
return true;
|
||||
}
|
||||
|
||||
export type CartAction = ReturnOrGeneratorYieldUnion<
|
||||
| typeof receiveCart
|
||||
| typeof receiveError
|
||||
| typeof receiveApplyingCoupon
|
||||
| typeof receiveRemovingCoupon
|
||||
| typeof receiveCartItem
|
||||
| typeof itemIsPendingQuantity
|
||||
| typeof itemIsPendingDelete
|
||||
| typeof updatingCustomerData
|
||||
| typeof shippingRatesBeingSelected
|
||||
| typeof setIsCartDataStale
|
||||
| typeof updateCustomerData
|
||||
| typeof removeItemFromCart
|
||||
| typeof changeCartItemQuantity
|
||||
| typeof addItemToCart
|
||||
| typeof updateCartFragments
|
||||
>;
|
17
packages/woocommerce-blocks/assets/js/data/cart/constants.ts
Normal file
17
packages/woocommerce-blocks/assets/js/data/cart/constants.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
export const STORE_KEY = 'wc/store/cart';
|
||||
export const CART_API_ERROR = {
|
||||
code: 'cart_api_error',
|
||||
message: __(
|
||||
'Unable to get cart data from the API.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
data: {
|
||||
status: 500,
|
||||
},
|
||||
};
|
||||
export const LAST_CART_UPDATE_TIMESTAMP_KEY = 'wc-blocks_cart_update_timestamp';
|
25
packages/woocommerce-blocks/assets/js/data/cart/controls.js
vendored
Normal file
25
packages/woocommerce-blocks/assets/js/data/cart/controls.js
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
triggerFragmentRefresh,
|
||||
triggerAddedToCartEvent,
|
||||
triggerAddingToCartEvent,
|
||||
} from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Default export for registering the controls with the store.
|
||||
*
|
||||
* @return {Object} An object with the controls to register with the store on the controls property of the registration object.
|
||||
*/
|
||||
export const controls = {
|
||||
UPDATE_LEGACY_CART_FRAGMENTS() {
|
||||
triggerFragmentRefresh();
|
||||
},
|
||||
TRIGGER_ADDING_TO_CART_EVENT() {
|
||||
triggerAddingToCartEvent();
|
||||
},
|
||||
TRIGGER_ADDED_TO_CART_EVENT( preserveCartData ) {
|
||||
triggerAddedToCartEvent( preserveCartData );
|
||||
},
|
||||
};
|
36
packages/woocommerce-blocks/assets/js/data/cart/index.ts
Normal file
36
packages/woocommerce-blocks/assets/js/data/cart/index.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { registerStore } from '@wordpress/data';
|
||||
import { controls as dataControls } from '@wordpress/data-controls';
|
||||
import type { SelectFromMap, DispatchFromMap } from '@automattic/data-stores';
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { STORE_KEY } from './constants';
|
||||
import * as selectors from './selectors';
|
||||
import * as actions from './actions';
|
||||
import * as resolvers from './resolvers';
|
||||
import reducer, { State } from './reducers';
|
||||
import { controls as sharedControls } from '../shared-controls';
|
||||
import { controls } from './controls';
|
||||
|
||||
registerStore< State >( STORE_KEY, {
|
||||
reducer,
|
||||
actions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
controls: { ...dataControls, ...sharedControls, ...controls } as any,
|
||||
selectors,
|
||||
resolvers,
|
||||
} );
|
||||
|
||||
export const CART_STORE_KEY = STORE_KEY;
|
||||
|
||||
declare module '@wordpress/data' {
|
||||
function dispatch(
|
||||
key: typeof CART_STORE_KEY
|
||||
): DispatchFromMap< typeof actions >;
|
||||
function select(
|
||||
key: typeof CART_STORE_KEY
|
||||
): SelectFromMap< typeof selectors >;
|
||||
}
|
169
packages/woocommerce-blocks/assets/js/data/cart/reducers.ts
Normal file
169
packages/woocommerce-blocks/assets/js/data/cart/reducers.ts
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { CartItem } from '@woocommerce/types';
|
||||
import type { Reducer } from 'redux';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION_TYPES as types } from './action-types';
|
||||
import { defaultCartState, CartState } from '../default-states';
|
||||
import { EMPTY_CART_ERRORS } from '../constants';
|
||||
import type { CartAction } from './actions';
|
||||
|
||||
/**
|
||||
* Sub-reducer for cart items array.
|
||||
*
|
||||
* @param {Array<CartItem>} state cartData.items state slice.
|
||||
* @param {CartAction} action Action object.
|
||||
*/
|
||||
const cartItemsReducer = (
|
||||
state: Array< CartItem > = [],
|
||||
action: Partial< CartAction >
|
||||
) => {
|
||||
switch ( action.type ) {
|
||||
case types.RECEIVE_CART_ITEM:
|
||||
// Replace specified cart element with the new data from server.
|
||||
return state.map( ( cartItem ) => {
|
||||
if ( cartItem.key === action.cartItem?.key ) {
|
||||
return action.cartItem;
|
||||
}
|
||||
return cartItem;
|
||||
} );
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reducer for receiving items related to the cart.
|
||||
*
|
||||
* @param {CartState} state The current state in the store.
|
||||
* @param {CartAction} action Action object.
|
||||
*
|
||||
* @return {CartState} New or existing state.
|
||||
*/
|
||||
const reducer: Reducer< CartState > = (
|
||||
state = defaultCartState,
|
||||
action: Partial< CartAction >
|
||||
) => {
|
||||
switch ( action.type ) {
|
||||
case types.RECEIVE_ERROR:
|
||||
if ( action.error ) {
|
||||
state = {
|
||||
...state,
|
||||
errors: state.errors.concat( action.error ),
|
||||
};
|
||||
}
|
||||
break;
|
||||
case types.REPLACE_ERRORS:
|
||||
if ( action.error ) {
|
||||
state = {
|
||||
...state,
|
||||
errors: [ action.error ],
|
||||
};
|
||||
}
|
||||
break;
|
||||
case types.RECEIVE_CART:
|
||||
if ( action.response ) {
|
||||
state = {
|
||||
...state,
|
||||
errors: EMPTY_CART_ERRORS,
|
||||
cartData: action.response,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case types.APPLYING_COUPON:
|
||||
if ( action.couponCode || action.couponCode === '' ) {
|
||||
state = {
|
||||
...state,
|
||||
metaData: {
|
||||
...state.metaData,
|
||||
applyingCoupon: action.couponCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
break;
|
||||
case types.REMOVING_COUPON:
|
||||
if ( action.couponCode || action.couponCode === '' ) {
|
||||
state = {
|
||||
...state,
|
||||
metaData: {
|
||||
...state.metaData,
|
||||
removingCoupon: action.couponCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case types.ITEM_PENDING_QUANTITY:
|
||||
// Remove key by default - handles isQuantityPending==false
|
||||
// and prevents duplicates when isQuantityPending===true.
|
||||
const keysPendingQuantity = state.cartItemsPendingQuantity.filter(
|
||||
( key ) => key !== action.cartItemKey
|
||||
);
|
||||
if ( action.isPendingQuantity && action.cartItemKey ) {
|
||||
keysPendingQuantity.push( action.cartItemKey );
|
||||
}
|
||||
state = {
|
||||
...state,
|
||||
cartItemsPendingQuantity: keysPendingQuantity,
|
||||
};
|
||||
break;
|
||||
case types.RECEIVE_REMOVED_ITEM:
|
||||
const keysPendingDelete = state.cartItemsPendingDelete.filter(
|
||||
( key ) => key !== action.cartItemKey
|
||||
);
|
||||
if ( action.isPendingDelete && action.cartItemKey ) {
|
||||
keysPendingDelete.push( action.cartItemKey );
|
||||
}
|
||||
state = {
|
||||
...state,
|
||||
cartItemsPendingDelete: keysPendingDelete,
|
||||
};
|
||||
break;
|
||||
// Delegate to cartItemsReducer.
|
||||
case types.RECEIVE_CART_ITEM:
|
||||
state = {
|
||||
...state,
|
||||
errors: EMPTY_CART_ERRORS,
|
||||
cartData: {
|
||||
...state.cartData,
|
||||
items: cartItemsReducer( state.cartData.items, action ),
|
||||
},
|
||||
};
|
||||
break;
|
||||
case types.UPDATING_CUSTOMER_DATA:
|
||||
state = {
|
||||
...state,
|
||||
metaData: {
|
||||
...state.metaData,
|
||||
updatingCustomerData: !! action.isResolving,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case types.UPDATING_SELECTED_SHIPPING_RATE:
|
||||
state = {
|
||||
...state,
|
||||
metaData: {
|
||||
...state.metaData,
|
||||
updatingSelectedRate: !! action.isResolving,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case types.SET_IS_CART_DATA_STALE:
|
||||
state = {
|
||||
...state,
|
||||
metaData: {
|
||||
...state.metaData,
|
||||
isCartDataStale: action.isCartDataStale,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export type State = ReturnType< typeof reducer >;
|
||||
|
||||
export default reducer;
|
36
packages/woocommerce-blocks/assets/js/data/cart/resolvers.ts
Normal file
36
packages/woocommerce-blocks/assets/js/data/cart/resolvers.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { select, apiFetch } from '@wordpress/data-controls';
|
||||
import { CartResponse, Cart } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { receiveCart, receiveError } from './actions';
|
||||
import { STORE_KEY, CART_API_ERROR } from './constants';
|
||||
|
||||
/**
|
||||
* Resolver for retrieving all cart data.
|
||||
*/
|
||||
export function* getCartData(): Generator< unknown, void, CartResponse > {
|
||||
const cartData = yield apiFetch( {
|
||||
path: '/wc/store/cart',
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
if ( ! cartData ) {
|
||||
yield receiveError( CART_API_ERROR );
|
||||
return;
|
||||
}
|
||||
|
||||
yield receiveCart( cartData );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolver for retrieving cart totals.
|
||||
*/
|
||||
export function* getCartTotals(): Generator< unknown, void, Cart > {
|
||||
yield select( STORE_KEY, 'getCartData' );
|
||||
}
|
165
packages/woocommerce-blocks/assets/js/data/cart/selectors.ts
Normal file
165
packages/woocommerce-blocks/assets/js/data/cart/selectors.ts
Normal file
@ -0,0 +1,165 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { Cart, CartTotals, CartMeta, CartItem } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { CartState, defaultCartState } from '../default-states';
|
||||
import type { ResponseError } from '../types';
|
||||
|
||||
/**
|
||||
* Retrieves cart data from state.
|
||||
*
|
||||
* @param {CartState} state The current state.
|
||||
* @return {Cart} The data to return.
|
||||
*/
|
||||
export const getCartData = ( state: CartState ): Cart => {
|
||||
return state.cartData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves cart totals from state.
|
||||
*
|
||||
* @param {CartState} state The current state.
|
||||
* @return {CartTotals} The data to return.
|
||||
*/
|
||||
export const getCartTotals = ( state: CartState ): CartTotals => {
|
||||
return state.cartData.totals || defaultCartState.cartData.totals;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves cart meta from state.
|
||||
*
|
||||
* @param {CartState} state The current state.
|
||||
* @return {CartMeta} The data to return.
|
||||
*/
|
||||
export const getCartMeta = ( state: CartState ): CartMeta => {
|
||||
return state.metaData || defaultCartState.metaData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves cart errors from state.
|
||||
*
|
||||
* @param {CartState} state The current state.
|
||||
* @return {Array<ResponseError>} Array of errors.
|
||||
*/
|
||||
export const getCartErrors = ( state: CartState ): Array< ResponseError > => {
|
||||
return state.errors;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if any coupon is being applied.
|
||||
*
|
||||
* @param {CartState} state The current state.
|
||||
* @return {boolean} True if a coupon is being applied.
|
||||
*/
|
||||
export const isApplyingCoupon = ( state: CartState ): boolean => {
|
||||
return !! state.metaData.applyingCoupon;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if cart is stale, false if it is not.
|
||||
*
|
||||
* @param {CartState} state The current state.
|
||||
* @return {boolean} True if the cart data is stale.
|
||||
*/
|
||||
export const isCartDataStale = ( state: CartState ): boolean => {
|
||||
return state.metaData.isCartDataStale;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the coupon code currently being applied.
|
||||
*
|
||||
* @param {CartState} state The current state.
|
||||
* @return {string} The data to return.
|
||||
*/
|
||||
export const getCouponBeingApplied = ( state: CartState ): string => {
|
||||
return state.metaData.applyingCoupon || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if any coupon is being removed.
|
||||
*
|
||||
* @param {CartState} state The current state.
|
||||
* @return {boolean} True if a coupon is being removed.
|
||||
*/
|
||||
export const isRemovingCoupon = ( state: CartState ): boolean => {
|
||||
return !! state.metaData.removingCoupon;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the coupon code currently being removed.
|
||||
*
|
||||
* @param {CartState} state The current state.
|
||||
* @return {string} The data to return.
|
||||
*/
|
||||
export const getCouponBeingRemoved = ( state: CartState ): string => {
|
||||
return state.metaData.removingCoupon || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns cart item matching specified key.
|
||||
*
|
||||
* @param {CartState} state The current state.
|
||||
* @param {string} cartItemKey Key for a cart item.
|
||||
* @return {CartItem | void} Cart item object, or undefined if not found.
|
||||
*/
|
||||
export const getCartItem = (
|
||||
state: CartState,
|
||||
cartItemKey: string
|
||||
): CartItem | void => {
|
||||
return state.cartData.items.find(
|
||||
( cartItem ) => cartItem.key === cartItemKey
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if the specified cart item quantity is being updated.
|
||||
*
|
||||
* @param {CartState} state The current state.
|
||||
* @param {string} cartItemKey Key for a cart item.
|
||||
* @return {boolean} True if a item has a pending request to be updated.
|
||||
*/
|
||||
export const isItemPendingQuantity = (
|
||||
state: CartState,
|
||||
cartItemKey: string
|
||||
): boolean => {
|
||||
return state.cartItemsPendingQuantity.includes( cartItemKey );
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if the specified cart item quantity is being updated.
|
||||
*
|
||||
* @param {CartState} state The current state.
|
||||
* @param {string} cartItemKey Key for a cart item.
|
||||
* @return {boolean} True if a item has a pending request to be updated.
|
||||
*/
|
||||
export const isItemPendingDelete = (
|
||||
state: CartState,
|
||||
cartItemKey: string
|
||||
): boolean => {
|
||||
return state.cartItemsPendingDelete.includes( cartItemKey );
|
||||
};
|
||||
/**
|
||||
* Retrieves if the address is being applied for shipping.
|
||||
*
|
||||
* @param {CartState} state The current state.
|
||||
* @return {boolean} are shipping rates loading.
|
||||
*/
|
||||
export const isCustomerDataUpdating = ( state: CartState ): boolean => {
|
||||
return !! state.metaData.updatingCustomerData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves if the shipping rate selection is being persisted.
|
||||
*
|
||||
* @param {CartState} state The current state.
|
||||
*
|
||||
* @return {boolean} True if the shipping rate selection is being persisted to
|
||||
* the server.
|
||||
*/
|
||||
export const isShippingRateBeingSelected = ( state: CartState ): boolean => {
|
||||
return !! state.metaData.updatingSelectedRate;
|
||||
};
|
118
packages/woocommerce-blocks/assets/js/data/cart/test/reducers.js
Normal file
118
packages/woocommerce-blocks/assets/js/data/cart/test/reducers.js
Normal file
@ -0,0 +1,118 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import deepFreeze from 'deep-freeze';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import cartReducer from '../reducers';
|
||||
import { ACTION_TYPES as types } from '../action-types';
|
||||
|
||||
describe( 'cartReducer', () => {
|
||||
const originalState = deepFreeze( {
|
||||
cartData: {
|
||||
coupons: [],
|
||||
items: [],
|
||||
fees: [],
|
||||
itemsCount: 0,
|
||||
itemsWeight: 0,
|
||||
needsShipping: true,
|
||||
totals: {},
|
||||
},
|
||||
metaData: {},
|
||||
errors: [
|
||||
{
|
||||
code: '100',
|
||||
message: 'Test Error',
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
} );
|
||||
it( 'sets expected state when a cart is received', () => {
|
||||
const testAction = {
|
||||
type: types.RECEIVE_CART,
|
||||
response: {
|
||||
coupons: [],
|
||||
items: [],
|
||||
fees: [],
|
||||
itemsCount: 0,
|
||||
itemsWeight: 0,
|
||||
needsShipping: true,
|
||||
totals: {},
|
||||
},
|
||||
};
|
||||
const newState = cartReducer( originalState, testAction );
|
||||
expect( newState ).not.toBe( originalState );
|
||||
expect( newState.cartData ).toEqual( {
|
||||
coupons: [],
|
||||
items: [],
|
||||
fees: [],
|
||||
itemsCount: 0,
|
||||
itemsWeight: 0,
|
||||
needsShipping: true,
|
||||
totals: {},
|
||||
} );
|
||||
} );
|
||||
it( 'sets expected state when errors are replaced', () => {
|
||||
const testAction = {
|
||||
type: types.REPLACE_ERRORS,
|
||||
error: {
|
||||
code: '101',
|
||||
message: 'Test Error',
|
||||
data: {},
|
||||
},
|
||||
};
|
||||
const newState = cartReducer( originalState, testAction );
|
||||
expect( newState ).not.toBe( originalState );
|
||||
expect( newState.errors ).toEqual( [
|
||||
{
|
||||
code: '101',
|
||||
message: 'Test Error',
|
||||
data: {},
|
||||
},
|
||||
] );
|
||||
} );
|
||||
it( 'sets expected state when an error is added', () => {
|
||||
const testAction = {
|
||||
type: types.RECEIVE_ERROR,
|
||||
error: {
|
||||
code: '101',
|
||||
message: 'Test Error',
|
||||
data: {},
|
||||
},
|
||||
};
|
||||
const newState = cartReducer( originalState, testAction );
|
||||
expect( newState ).not.toBe( originalState );
|
||||
expect( newState.errors ).toEqual( [
|
||||
{
|
||||
code: '100',
|
||||
message: 'Test Error',
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
code: '101',
|
||||
message: 'Test Error',
|
||||
data: {},
|
||||
},
|
||||
] );
|
||||
} );
|
||||
it( 'sets expected state when a coupon is applied', () => {
|
||||
const testAction = {
|
||||
type: types.APPLYING_COUPON,
|
||||
couponCode: 'APPLYME',
|
||||
};
|
||||
const newState = cartReducer( originalState, testAction );
|
||||
expect( newState ).not.toBe( originalState );
|
||||
expect( newState.metaData.applyingCoupon ).toEqual( 'APPLYME' );
|
||||
} );
|
||||
it( 'sets expected state when a coupon is removed', () => {
|
||||
const testAction = {
|
||||
type: types.REMOVING_COUPON,
|
||||
couponCode: 'REMOVEME',
|
||||
};
|
||||
const newState = cartReducer( originalState, testAction );
|
||||
expect( newState ).not.toBe( originalState );
|
||||
expect( newState.metaData.removingCoupon ).toEqual( 'REMOVEME' );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getCartData } from '../resolvers';
|
||||
import { receiveCart, receiveError } from '../actions';
|
||||
import { CART_API_ERROR } from '../constants';
|
||||
|
||||
jest.mock( '@wordpress/data-controls' );
|
||||
|
||||
describe( 'getCartData', () => {
|
||||
describe( 'yields with expected responses', () => {
|
||||
let fulfillment;
|
||||
const rewind = () => ( fulfillment = getCartData() );
|
||||
test(
|
||||
'when apiFetch returns a valid response, yields expected ' +
|
||||
'action',
|
||||
() => {
|
||||
rewind();
|
||||
fulfillment.next( 'https://example.org' );
|
||||
const { value } = fulfillment.next( {
|
||||
coupons: [],
|
||||
items: [],
|
||||
fees: [],
|
||||
itemsCount: 0,
|
||||
itemsWeight: 0,
|
||||
needsShipping: true,
|
||||
totals: {},
|
||||
} );
|
||||
expect( value ).toEqual(
|
||||
receiveCart( {
|
||||
coupons: [],
|
||||
items: [],
|
||||
fees: [],
|
||||
itemsCount: 0,
|
||||
itemsWeight: 0,
|
||||
needsShipping: true,
|
||||
totals: {},
|
||||
} )
|
||||
);
|
||||
const { done } = fulfillment.next();
|
||||
expect( done ).toBe( true );
|
||||
}
|
||||
);
|
||||
} );
|
||||
describe( 'yields with expected response when there is an error', () => {
|
||||
let fulfillment;
|
||||
const rewind = () => ( fulfillment = getCartData() );
|
||||
test(
|
||||
'when apiFetch returns a valid response, yields expected ' +
|
||||
'action',
|
||||
() => {
|
||||
rewind();
|
||||
fulfillment.next( 'https://example.org' );
|
||||
const { value } = fulfillment.next( undefined );
|
||||
expect( value ).toEqual( receiveError( CART_API_ERROR ) );
|
||||
const { done } = fulfillment.next();
|
||||
expect( done ).toBe( true );
|
||||
}
|
||||
);
|
||||
} );
|
||||
} );
|
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
getCartData,
|
||||
getCartTotals,
|
||||
getCartMeta,
|
||||
getCartErrors,
|
||||
isApplyingCoupon,
|
||||
getCouponBeingApplied,
|
||||
isRemovingCoupon,
|
||||
getCouponBeingRemoved,
|
||||
} from '../selectors';
|
||||
|
||||
const state = {
|
||||
cartData: {
|
||||
coupons: [
|
||||
{
|
||||
code: 'test',
|
||||
totals: {
|
||||
currency_code: 'GBP',
|
||||
currency_symbol: '£',
|
||||
currency_minor_unit: 2,
|
||||
currency_decimal_separator: '.',
|
||||
currency_thousand_separator: ',',
|
||||
currency_prefix: '£',
|
||||
currency_suffix: '',
|
||||
total_discount: '583',
|
||||
total_discount_tax: '117',
|
||||
},
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
key: '1f0e3dad99908345f7439f8ffabdffc4',
|
||||
id: 19,
|
||||
quantity: 1,
|
||||
name: 'Album',
|
||||
short_description: '<p>This is a simple, virtual product.</p>',
|
||||
description:
|
||||
'<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum sagittis orci ac odio dictum tincidunt. Donec ut metus leo. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Sed luctus, dui eu sagittis sodales, nulla nibh sagittis augue, vel porttitor diam enim non metus. Vestibulum aliquam augue neque. Phasellus tincidunt odio eget ullamcorper efficitur. Cras placerat ut turpis pellentesque vulputate. Nam sed consequat tortor. Curabitur finibus sapien dolor. Ut eleifend tellus nec erat pulvinar dignissim. Nam non arcu purus. Vivamus et massa massa.</p>',
|
||||
sku: 'woo-album',
|
||||
low_stock_remaining: null,
|
||||
permalink: 'http://local.wordpress.test/product/album/',
|
||||
images: [
|
||||
{
|
||||
id: 48,
|
||||
src:
|
||||
'http://local.wordpress.test/wp-content/uploads/2019/12/album-1.jpg',
|
||||
thumbnail:
|
||||
'http://local.wordpress.test/wp-content/uploads/2019/12/album-1-324x324.jpg',
|
||||
srcset:
|
||||
'http://local.wordpress.test/wp-content/uploads/2019/12/album-1.jpg 800w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-324x324.jpg 324w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-100x100.jpg 100w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-416x416.jpg 416w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-300x300.jpg 300w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-150x150.jpg 150w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-768x768.jpg 768w',
|
||||
sizes: '(max-width: 800px) 100vw, 800px',
|
||||
name: 'album-1.jpg',
|
||||
alt: '',
|
||||
},
|
||||
],
|
||||
variation: [],
|
||||
totals: {
|
||||
currency_code: 'GBP',
|
||||
currency_symbol: '£',
|
||||
currency_minor_unit: 2,
|
||||
currency_decimal_separator: '.',
|
||||
currency_thousand_separator: ',',
|
||||
currency_prefix: '£',
|
||||
currency_suffix: '',
|
||||
line_subtotal: '1250',
|
||||
line_subtotal_tax: '250',
|
||||
line_total: '1000',
|
||||
line_total_tax: '200',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '6512bd43d9caa6e02c990b0a82652dca',
|
||||
id: 11,
|
||||
quantity: 1,
|
||||
name: 'Beanie',
|
||||
short_description: '<p>This is a simple product.</p>',
|
||||
description:
|
||||
'<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>',
|
||||
sku: 'woo-beanie',
|
||||
low_stock_remaining: null,
|
||||
permalink: 'http://local.wordpress.test/product/beanie/',
|
||||
images: [
|
||||
{
|
||||
id: 40,
|
||||
src:
|
||||
'http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2.jpg',
|
||||
thumbnail:
|
||||
'http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-324x324.jpg',
|
||||
srcset:
|
||||
'http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2.jpg 801w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-324x324.jpg 324w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-100x100.jpg 100w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-416x416.jpg 416w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-300x300.jpg 300w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-150x150.jpg 150w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-768x768.jpg 768w',
|
||||
sizes: '(max-width: 801px) 100vw, 801px',
|
||||
name: 'beanie-2.jpg',
|
||||
alt: '',
|
||||
},
|
||||
],
|
||||
variation: [],
|
||||
totals: {
|
||||
currency_code: 'GBP',
|
||||
currency_symbol: '£',
|
||||
currency_minor_unit: 2,
|
||||
currency_decimal_separator: '.',
|
||||
currency_thousand_separator: ',',
|
||||
currency_prefix: '£',
|
||||
currency_suffix: '',
|
||||
line_subtotal: '1667',
|
||||
line_subtotal_tax: '333',
|
||||
line_total: '1333',
|
||||
line_total_tax: '267',
|
||||
},
|
||||
},
|
||||
],
|
||||
items_count: 2,
|
||||
items_weight: 0,
|
||||
needs_payment: true,
|
||||
needs_shipping: true,
|
||||
totals: {
|
||||
currency_code: 'GBP',
|
||||
currency_symbol: '£',
|
||||
currency_minor_unit: 2,
|
||||
currency_decimal_separator: '.',
|
||||
currency_thousand_separator: ',',
|
||||
currency_prefix: '£',
|
||||
currency_suffix: '',
|
||||
total_items: '2917',
|
||||
total_items_tax: '583',
|
||||
total_fees: '0',
|
||||
total_fees_tax: '0',
|
||||
total_discount: '583',
|
||||
total_discount_tax: '117',
|
||||
total_shipping: '2000',
|
||||
total_shipping_tax: '400',
|
||||
total_price: '5200',
|
||||
total_tax: '867',
|
||||
tax_lines: [
|
||||
{
|
||||
name: 'Tax',
|
||||
price: '867',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
metaData: {
|
||||
applyingCoupon: 'test-coupon',
|
||||
removingCoupon: 'test-coupon2',
|
||||
},
|
||||
errors: [
|
||||
{
|
||||
code: '100',
|
||||
message: 'Test Error',
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe( 'getCartData', () => {
|
||||
it( 'returns expected values for items existing in state', () => {
|
||||
expect( getCartData( state ) ).toEqual( state.cartData );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'getCartTotals', () => {
|
||||
it( 'returns expected values for items existing in state', () => {
|
||||
expect( getCartTotals( state ) ).toEqual( state.cartData.totals );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'getCartMeta', () => {
|
||||
it( 'returns expected values for items existing in state', () => {
|
||||
expect( getCartMeta( state ) ).toEqual( state.metaData );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'getCartErrors', () => {
|
||||
it( 'returns expected values for items existing in state', () => {
|
||||
expect( getCartErrors( state ) ).toEqual( state.errors );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'isApplyingCoupon', () => {
|
||||
it( 'returns expected values for items existing in state', () => {
|
||||
expect( isApplyingCoupon( state ) ).toEqual( true );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'getCouponBeingApplied', () => {
|
||||
it( 'returns expected values for items existing in state', () => {
|
||||
expect( getCouponBeingApplied( state ) ).toEqual(
|
||||
state.metaData.applyingCoupon
|
||||
);
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'isRemovingCoupon', () => {
|
||||
it( 'returns expected values for items existing in state', () => {
|
||||
expect( isRemovingCoupon( state ) ).toEqual( true );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'getCouponBeingRemoved', () => {
|
||||
it( 'returns expected values for items existing in state', () => {
|
||||
expect( getCouponBeingRemoved( state ) ).toEqual(
|
||||
state.metaData.removingCoupon
|
||||
);
|
||||
} );
|
||||
} );
|
@ -0,0 +1,7 @@
|
||||
export const ACTION_TYPES = {
|
||||
RECEIVE_COLLECTION: 'RECEIVE_COLLECTION',
|
||||
RESET_COLLECTION: 'RESET_COLLECTION',
|
||||
ERROR: 'ERROR',
|
||||
RECEIVE_LAST_MODIFIED: 'RECEIVE_LAST_MODIFIED',
|
||||
INVALIDATE_RESOLUTION_FOR_STORE: 'INVALIDATE_RESOLUTION_FOR_STORE',
|
||||
};
|
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION_TYPES as types } from './action-types';
|
||||
|
||||
let Headers = window.Headers || null;
|
||||
Headers = Headers
|
||||
? new Headers()
|
||||
: { get: () => undefined, has: () => undefined };
|
||||
|
||||
/**
|
||||
* Returns an action object used in updating the store with the provided items
|
||||
* retrieved from a request using the given querystring.
|
||||
*
|
||||
* This is a generic response action.
|
||||
*
|
||||
* @param {string} namespace The namespace for the collection route.
|
||||
* @param {string} resourceName The resource name for the collection route.
|
||||
* @param {string} [queryString=''] The query string for the collection
|
||||
* @param {Array} [ids=[]] An array of ids (in correct order) for the
|
||||
* model.
|
||||
* @param {Object} [response={}] An object containing the response from the
|
||||
* collection request.
|
||||
* @param {Array<*>} response.items An array of items for the given collection.
|
||||
* @param {Headers} response.headers A Headers object from the response
|
||||
* link https://developer.mozilla.org/en-US/docs/Web/API/Headers
|
||||
* @param {boolean} [replace=false] If true, signals to replace the current
|
||||
* items in the state with the provided
|
||||
* items.
|
||||
* @return {
|
||||
* {
|
||||
* type: string,
|
||||
* namespace: string,
|
||||
* resourceName: string,
|
||||
* queryString: string,
|
||||
* ids: Array<*>,
|
||||
* items: Array<*>,
|
||||
* }
|
||||
* } Object for action.
|
||||
*/
|
||||
export function receiveCollection(
|
||||
namespace,
|
||||
resourceName,
|
||||
queryString = '',
|
||||
ids = [],
|
||||
response = { items: [], headers: Headers },
|
||||
replace = false
|
||||
) {
|
||||
return {
|
||||
type: replace ? types.RESET_COLLECTION : types.RECEIVE_COLLECTION,
|
||||
namespace,
|
||||
resourceName,
|
||||
queryString,
|
||||
ids,
|
||||
response,
|
||||
};
|
||||
}
|
||||
|
||||
export function receiveCollectionError(
|
||||
namespace,
|
||||
resourceName,
|
||||
queryString,
|
||||
ids,
|
||||
error
|
||||
) {
|
||||
return {
|
||||
type: 'ERROR',
|
||||
namespace,
|
||||
resourceName,
|
||||
queryString,
|
||||
ids,
|
||||
response: {
|
||||
items: [],
|
||||
headers: Headers,
|
||||
error,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function receiveLastModified( timestamp ) {
|
||||
return {
|
||||
type: types.RECEIVE_LAST_MODIFIED,
|
||||
timestamp,
|
||||
};
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export const STORE_KEY = 'wc/store/collections';
|
||||
export const DEFAULT_EMPTY_ARRAY = [];
|
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { registerStore } from '@wordpress/data';
|
||||
import { controls as dataControls } from '@wordpress/data-controls';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { STORE_KEY } from './constants';
|
||||
import * as selectors from './selectors';
|
||||
import * as actions from './actions';
|
||||
import * as resolvers from './resolvers';
|
||||
import reducer from './reducers';
|
||||
import { controls } from '../shared-controls';
|
||||
|
||||
registerStore( STORE_KEY, {
|
||||
reducer,
|
||||
actions,
|
||||
controls: { ...dataControls, ...controls },
|
||||
selectors,
|
||||
resolvers,
|
||||
} );
|
||||
|
||||
export const COLLECTIONS_STORE_KEY = STORE_KEY;
|
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION_TYPES as types } from './action-types';
|
||||
import { hasInState, updateState } from '../utils';
|
||||
|
||||
/**
|
||||
* Reducer for receiving items to a collection.
|
||||
*
|
||||
* @param {Object} state The current state in the store.
|
||||
* @param {Object} action Action object.
|
||||
*
|
||||
* @return {Object} New or existing state depending on if there are
|
||||
* any changes.
|
||||
*/
|
||||
const receiveCollection = ( state = {}, action ) => {
|
||||
// Update last modified and previous last modified values.
|
||||
if ( action.type === types.RECEIVE_LAST_MODIFIED ) {
|
||||
if ( action.timestamp === state.lastModified ) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
lastModified: action.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// When invalidating data, remove stored values from state.
|
||||
if ( action.type === types.INVALIDATE_RESOLUTION_FOR_STORE ) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { type, namespace, resourceName, queryString, response } = action;
|
||||
// ids are stringified so they can be used as an index.
|
||||
const ids = action.ids ? JSON.stringify( action.ids ) : '[]';
|
||||
switch ( type ) {
|
||||
case types.RECEIVE_COLLECTION:
|
||||
if (
|
||||
hasInState( state, [
|
||||
namespace,
|
||||
resourceName,
|
||||
ids,
|
||||
queryString,
|
||||
] )
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
state = updateState(
|
||||
state,
|
||||
[ namespace, resourceName, ids, queryString ],
|
||||
response
|
||||
);
|
||||
break;
|
||||
case types.RESET_COLLECTION:
|
||||
state = updateState(
|
||||
state,
|
||||
[ namespace, resourceName, ids, queryString ],
|
||||
response
|
||||
);
|
||||
break;
|
||||
case types.ERROR:
|
||||
state = updateState(
|
||||
state,
|
||||
[ namespace, resourceName, ids, queryString ],
|
||||
response
|
||||
);
|
||||
break;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export default receiveCollection;
|
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { select, dispatch } from '@wordpress/data-controls';
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { receiveCollection, receiveCollectionError } from './actions';
|
||||
import { STORE_KEY as SCHEMA_STORE_KEY } from '../schema/constants';
|
||||
import { STORE_KEY, DEFAULT_EMPTY_ARRAY } from './constants';
|
||||
import { apiFetchWithHeaders } from '../shared-controls';
|
||||
|
||||
/**
|
||||
* Check if the store needs invalidating due to a change in last modified headers.
|
||||
*
|
||||
* @param {number} timestamp Last update timestamp.
|
||||
*/
|
||||
function* invalidateModifiedCollection( timestamp ) {
|
||||
const lastModified = yield select( STORE_KEY, 'getCollectionLastModified' );
|
||||
|
||||
if ( ! lastModified ) {
|
||||
yield dispatch( STORE_KEY, 'receiveLastModified', timestamp );
|
||||
} else if ( timestamp > lastModified ) {
|
||||
yield dispatch( STORE_KEY, 'invalidateResolutionForStore' );
|
||||
yield dispatch( STORE_KEY, 'receiveLastModified', timestamp );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolver for retrieving a collection via a api route.
|
||||
*
|
||||
* @param {string} namespace
|
||||
* @param {string} resourceName
|
||||
* @param {Object} query
|
||||
* @param {Array} ids
|
||||
*/
|
||||
export function* getCollection( namespace, resourceName, query, ids ) {
|
||||
const route = yield select(
|
||||
SCHEMA_STORE_KEY,
|
||||
'getRoute',
|
||||
namespace,
|
||||
resourceName,
|
||||
ids
|
||||
);
|
||||
const queryString = addQueryArgs( '', query );
|
||||
if ( ! route ) {
|
||||
yield receiveCollection( namespace, resourceName, queryString, ids );
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
response = DEFAULT_EMPTY_ARRAY,
|
||||
headers,
|
||||
} = yield apiFetchWithHeaders( { path: route + queryString } );
|
||||
|
||||
if ( headers && headers.get && headers.has( 'last-modified' ) ) {
|
||||
// Do any invalidation before the collection is received to prevent
|
||||
// this query running again.
|
||||
yield invalidateModifiedCollection(
|
||||
parseInt( headers.get( 'last-modified' ), 10 )
|
||||
);
|
||||
}
|
||||
|
||||
yield receiveCollection( namespace, resourceName, queryString, ids, {
|
||||
items: response,
|
||||
headers,
|
||||
} );
|
||||
} catch ( error ) {
|
||||
yield receiveCollectionError(
|
||||
namespace,
|
||||
resourceName,
|
||||
queryString,
|
||||
ids,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolver for retrieving a specific collection header for the given arguments
|
||||
*
|
||||
* Note: This triggers the `getCollection` resolver if it hasn't been resolved
|
||||
* yet.
|
||||
*
|
||||
* @param {string} header
|
||||
* @param {string} namespace
|
||||
* @param {string} resourceName
|
||||
* @param {Object} query
|
||||
* @param {Array} ids
|
||||
*/
|
||||
export function* getCollectionHeader(
|
||||
header,
|
||||
namespace,
|
||||
resourceName,
|
||||
query,
|
||||
ids
|
||||
) {
|
||||
// feed the correct number of args in for the select so we don't resolve
|
||||
// unnecessarily. Any undefined args will be excluded. This is important
|
||||
// because resolver resolution is cached by both number and value of args.
|
||||
const args = [ namespace, resourceName, query, ids ].filter(
|
||||
( arg ) => typeof arg !== 'undefined'
|
||||
);
|
||||
//we call this simply to do any resolution of the collection if necessary.
|
||||
yield select( STORE_KEY, 'getCollection', ...args );
|
||||
}
|
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { hasInState } from '../utils';
|
||||
import { DEFAULT_EMPTY_ARRAY } from './constants';
|
||||
|
||||
const getFromState = ( {
|
||||
state,
|
||||
namespace,
|
||||
resourceName,
|
||||
query,
|
||||
ids,
|
||||
type = 'items',
|
||||
fallback = DEFAULT_EMPTY_ARRAY,
|
||||
} ) => {
|
||||
// prep ids and query for state retrieval
|
||||
ids = JSON.stringify( ids );
|
||||
query = query !== null ? addQueryArgs( '', query ) : '';
|
||||
if ( hasInState( state, [ namespace, resourceName, ids, query, type ] ) ) {
|
||||
return state[ namespace ][ resourceName ][ ids ][ query ][ type ];
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const getCollectionHeaders = (
|
||||
state,
|
||||
namespace,
|
||||
resourceName,
|
||||
query = null,
|
||||
ids = DEFAULT_EMPTY_ARRAY
|
||||
) => {
|
||||
return getFromState( {
|
||||
state,
|
||||
namespace,
|
||||
resourceName,
|
||||
query,
|
||||
ids,
|
||||
type: 'headers',
|
||||
fallback: undefined,
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the collection items from the state for the given arguments.
|
||||
*
|
||||
* @param {Object} state The current collections state.
|
||||
* @param {string} namespace The namespace for the collection.
|
||||
* @param {string} resourceName The resource name for the collection.
|
||||
* @param {Object} [query=null] The query for the collection request.
|
||||
* @param {Array} [ids=[]] Any ids for the collection request (these are
|
||||
* values that would be added to the route for a
|
||||
* route with id placeholders)
|
||||
* @return {Array} an array of items stored in the collection.
|
||||
*/
|
||||
export const getCollection = (
|
||||
state,
|
||||
namespace,
|
||||
resourceName,
|
||||
query = null,
|
||||
ids = DEFAULT_EMPTY_ARRAY
|
||||
) => {
|
||||
return getFromState( { state, namespace, resourceName, query, ids } );
|
||||
};
|
||||
|
||||
export const getCollectionError = (
|
||||
state,
|
||||
namespace,
|
||||
resourceName,
|
||||
query = null,
|
||||
ids = DEFAULT_EMPTY_ARRAY
|
||||
) => {
|
||||
return getFromState( {
|
||||
state,
|
||||
namespace,
|
||||
resourceName,
|
||||
query,
|
||||
ids,
|
||||
type: 'error',
|
||||
fallback: null,
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* This selector enables retrieving a specific header value from a given
|
||||
* collection request.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```js
|
||||
* const totalProducts = wp.data.select( COLLECTION_STORE_KEY )
|
||||
* .getCollectionHeader( '/wc/blocks', 'products', 'x-wp-total' )
|
||||
* ```
|
||||
*
|
||||
* @param {string} state The current collection state.
|
||||
* @param {string} header The header to retrieve.
|
||||
* @param {string} namespace The namespace for the collection.
|
||||
* @param {string} resourceName The model name for the collection.
|
||||
* @param {Object} [query=null] The query object on the collection request.
|
||||
* @param {Array} [ids=[]] Any ids for the collection request (these are
|
||||
* values that would be added to the route for a
|
||||
* route with id placeholders)
|
||||
*
|
||||
* @return {*|null} The value for the specified header, null if there are no
|
||||
* headers available and undefined if the header does not exist for the
|
||||
* collection.
|
||||
*/
|
||||
export const getCollectionHeader = (
|
||||
state,
|
||||
header,
|
||||
namespace,
|
||||
resourceName,
|
||||
query = null,
|
||||
ids = DEFAULT_EMPTY_ARRAY
|
||||
) => {
|
||||
const headers = getCollectionHeaders(
|
||||
state,
|
||||
namespace,
|
||||
resourceName,
|
||||
query,
|
||||
ids
|
||||
);
|
||||
// Can't just do a truthy check because `getCollectionHeaders` resolver
|
||||
// invokes the `getCollection` selector to trigger the resolution of the
|
||||
// collection request. Its fallback is an empty array.
|
||||
if ( headers && headers.get ) {
|
||||
return headers.has( header ) ? headers.get( header ) : undefined;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the last modified header for the collection.
|
||||
*
|
||||
* @param {string} state The current collection state.
|
||||
* @return {number} Timestamp.
|
||||
*/
|
||||
export const getCollectionLastModified = ( state ) => {
|
||||
return state.lastModified || 0;
|
||||
};
|
@ -0,0 +1,99 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import deepFreeze from 'deep-freeze';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import receiveCollection from '../reducers';
|
||||
import { ACTION_TYPES as types } from '../action-types';
|
||||
|
||||
describe( 'receiveCollection', () => {
|
||||
const originalState = deepFreeze( {
|
||||
'wc/blocks': {
|
||||
products: {
|
||||
'[]': {
|
||||
'?someQuery=2': {
|
||||
items: [ 'foo' ],
|
||||
headers: { 'x-wp-total': 22 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} );
|
||||
it(
|
||||
'returns original state when there is already an entry in the state ' +
|
||||
'for the given arguments',
|
||||
() => {
|
||||
const testAction = {
|
||||
type: types.RECEIVE_COLLECTION,
|
||||
namespace: 'wc/blocks',
|
||||
resourceName: 'products',
|
||||
queryString: '?someQuery=2',
|
||||
response: {
|
||||
items: [ 'bar' ],
|
||||
headers: { foo: 'bar' },
|
||||
},
|
||||
};
|
||||
expect( receiveCollection( originalState, testAction ) ).toBe(
|
||||
originalState
|
||||
);
|
||||
}
|
||||
);
|
||||
it(
|
||||
'returns new state when items exist in collection but the type is ' +
|
||||
'for a reset',
|
||||
() => {
|
||||
const testAction = {
|
||||
type: types.RESET_COLLECTION,
|
||||
namespace: 'wc/blocks',
|
||||
resourceName: 'products',
|
||||
queryString: '?someQuery=2',
|
||||
response: {
|
||||
items: [ 'cheeseburger' ],
|
||||
headers: { foo: 'bar' },
|
||||
},
|
||||
};
|
||||
const newState = receiveCollection( originalState, testAction );
|
||||
expect( newState ).not.toBe( originalState );
|
||||
expect(
|
||||
newState[ 'wc/blocks' ].products[ '[]' ][ '?someQuery=2' ]
|
||||
).toEqual( {
|
||||
items: [ 'cheeseburger' ],
|
||||
headers: { foo: 'bar' },
|
||||
} );
|
||||
}
|
||||
);
|
||||
it( 'returns new state when items do not exist in collection yet', () => {
|
||||
const testAction = {
|
||||
type: types.RECEIVE_COLLECTION,
|
||||
namespace: 'wc/blocks',
|
||||
resourceName: 'products',
|
||||
queryString: '?someQuery=3',
|
||||
response: { items: [ 'cheeseburger' ], headers: { foo: 'bar' } },
|
||||
};
|
||||
const newState = receiveCollection( originalState, testAction );
|
||||
expect( newState ).not.toBe( originalState );
|
||||
expect(
|
||||
newState[ 'wc/blocks' ].products[ '[]' ][ '?someQuery=3' ]
|
||||
).toEqual( { items: [ 'cheeseburger' ], headers: { foo: 'bar' } } );
|
||||
} );
|
||||
it( 'sets expected state when ids are passed in', () => {
|
||||
const testAction = {
|
||||
type: types.RECEIVE_COLLECTION,
|
||||
namespace: 'wc/blocks',
|
||||
resourceName: 'products/attributes',
|
||||
queryString: '?something',
|
||||
response: { items: [ 10, 20 ], headers: { foo: 'bar' } },
|
||||
ids: [ 30, 42 ],
|
||||
};
|
||||
const newState = receiveCollection( originalState, testAction );
|
||||
expect( newState ).not.toBe( originalState );
|
||||
expect(
|
||||
newState[ 'wc/blocks' ][ 'products/attributes' ][ '[30,42]' ][
|
||||
'?something'
|
||||
]
|
||||
).toEqual( { items: [ 10, 20 ], headers: { foo: 'bar' } } );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,161 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { select } from '@wordpress/data-controls';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getCollection, getCollectionHeader } from '../resolvers';
|
||||
import { receiveCollection } from '../actions';
|
||||
import { STORE_KEY as SCHEMA_STORE_KEY } from '../../schema/constants';
|
||||
import { STORE_KEY } from '../constants';
|
||||
import { apiFetchWithHeaders } from '../../shared-controls';
|
||||
|
||||
jest.mock( '@wordpress/data-controls' );
|
||||
|
||||
describe( 'getCollection', () => {
|
||||
describe( 'yields with expected responses', () => {
|
||||
let fulfillment;
|
||||
const testArgs = [
|
||||
'wc/blocks',
|
||||
'products',
|
||||
{ foo: 'bar' },
|
||||
[ 20, 30 ],
|
||||
];
|
||||
const rewind = () => ( fulfillment = getCollection( ...testArgs ) );
|
||||
test( 'with getRoute call invoked to retrieve route', () => {
|
||||
rewind();
|
||||
fulfillment.next();
|
||||
expect( select ).toHaveBeenCalledWith(
|
||||
SCHEMA_STORE_KEY,
|
||||
'getRoute',
|
||||
testArgs[ 0 ],
|
||||
testArgs[ 1 ],
|
||||
testArgs[ 3 ]
|
||||
);
|
||||
} );
|
||||
test(
|
||||
'when no route is retrieved, yields receiveCollection and ' +
|
||||
'returns',
|
||||
() => {
|
||||
const { value } = fulfillment.next();
|
||||
const expected = receiveCollection(
|
||||
'wc/blocks',
|
||||
'products',
|
||||
'?foo=bar',
|
||||
[ 20, 30 ],
|
||||
{
|
||||
items: [],
|
||||
headers: {
|
||||
get: () => undefined,
|
||||
has: () => undefined,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect( value.type ).toBe( expected.type );
|
||||
expect( value.namespace ).toBe( expected.namespace );
|
||||
expect( value.resourceName ).toBe( expected.resourceName );
|
||||
expect( value.queryString ).toBe( expected.queryString );
|
||||
expect( value.ids ).toEqual( expected.ids );
|
||||
expect( Object.keys( value.response ) ).toEqual(
|
||||
Object.keys( expected.response )
|
||||
);
|
||||
const { done } = fulfillment.next();
|
||||
expect( done ).toBe( true );
|
||||
}
|
||||
);
|
||||
test(
|
||||
'when route is retrieved, yields apiFetchWithHeaders control action with ' +
|
||||
'expected route',
|
||||
() => {
|
||||
rewind();
|
||||
fulfillment.next();
|
||||
const { value } = fulfillment.next( 'https://example.org' );
|
||||
expect( value ).toEqual(
|
||||
apiFetchWithHeaders( {
|
||||
path: 'https://example.org?foo=bar',
|
||||
} )
|
||||
);
|
||||
}
|
||||
);
|
||||
test(
|
||||
'when apiFetchWithHeaders does not return a valid response, ' +
|
||||
'yields expected action',
|
||||
() => {
|
||||
const { value } = fulfillment.next( {} );
|
||||
expect( value ).toEqual(
|
||||
receiveCollection(
|
||||
'wc/blocks',
|
||||
'products',
|
||||
'?foo=bar',
|
||||
[ 20, 30 ],
|
||||
{ items: [], headers: undefined }
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
test(
|
||||
'when apiFetch returns a valid response, yields expected ' +
|
||||
'action',
|
||||
() => {
|
||||
rewind();
|
||||
fulfillment.next();
|
||||
fulfillment.next( 'https://example.org' );
|
||||
const { value } = fulfillment.next( {
|
||||
response: [ '42', 'cheeseburgers' ],
|
||||
headers: { foo: 'bar' },
|
||||
} );
|
||||
expect( value ).toEqual(
|
||||
receiveCollection(
|
||||
'wc/blocks',
|
||||
'products',
|
||||
'?foo=bar',
|
||||
[ 20, 30 ],
|
||||
{
|
||||
items: [ '42', 'cheeseburgers' ],
|
||||
headers: { foo: 'bar' },
|
||||
}
|
||||
)
|
||||
);
|
||||
const { done } = fulfillment.next();
|
||||
expect( done ).toBe( true );
|
||||
}
|
||||
);
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'getCollectionHeader', () => {
|
||||
let fulfillment;
|
||||
const rewind = ( ...testArgs ) =>
|
||||
( fulfillment = getCollectionHeader( ...testArgs ) );
|
||||
it( 'yields expected select control when called with less args', () => {
|
||||
rewind( 'x-wp-total', '/wc/blocks', 'products' );
|
||||
const { value } = fulfillment.next();
|
||||
expect( value ).toEqual(
|
||||
select( STORE_KEY, 'getCollection', '/wc/blocks', 'products' )
|
||||
);
|
||||
} );
|
||||
it( 'yields expected select control when called with all args', () => {
|
||||
const args = [
|
||||
'x-wp-total',
|
||||
'/wc/blocks',
|
||||
'products/attributes',
|
||||
{ sort: 'ASC' },
|
||||
[ 10 ],
|
||||
];
|
||||
rewind( ...args );
|
||||
const { value } = fulfillment.next();
|
||||
expect( value ).toEqual(
|
||||
select(
|
||||
STORE_KEY,
|
||||
'/wc/blocks',
|
||||
'products/attributes',
|
||||
{ sort: 'ASC' },
|
||||
[ 10 ]
|
||||
)
|
||||
);
|
||||
const { done } = fulfillment.next();
|
||||
expect( done ).toBe( true );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getCollection, getCollectionHeader } from '../selectors';
|
||||
|
||||
const getHeaderMock = ( total ) => {
|
||||
const headers = { total };
|
||||
return {
|
||||
get: ( key ) => headers[ key ] || null,
|
||||
has: ( key ) => !! headers[ key ],
|
||||
};
|
||||
};
|
||||
|
||||
const state = {
|
||||
'wc/blocks': {
|
||||
products: {
|
||||
'[]': {
|
||||
'?someQuery=2': {
|
||||
items: [ 'foo' ],
|
||||
headers: getHeaderMock( 22 ),
|
||||
},
|
||||
},
|
||||
},
|
||||
'products/attributes': {
|
||||
'[10]': {
|
||||
'?someQuery=2': {
|
||||
items: [ 'bar' ],
|
||||
headers: getHeaderMock( 42 ),
|
||||
},
|
||||
},
|
||||
},
|
||||
'products/attributes/terms': {
|
||||
'[10,20]': {
|
||||
'?someQuery=10': {
|
||||
items: [ 42 ],
|
||||
headers: getHeaderMock( 12 ),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe( 'getCollection', () => {
|
||||
it( 'returns empty array when namespace does not exist in state', () => {
|
||||
expect( getCollection( state, 'invalid', 'products' ) ).toEqual( [] );
|
||||
} );
|
||||
it( 'returns empty array when resourceName does not exist in state', () => {
|
||||
expect( getCollection( state, 'wc/blocks', 'invalid' ) ).toEqual( [] );
|
||||
} );
|
||||
it( 'returns empty array when query does not exist in state', () => {
|
||||
expect( getCollection( state, 'wc/blocks', 'products' ) ).toEqual( [] );
|
||||
} );
|
||||
it( 'returns empty array when ids do not exist in state', () => {
|
||||
expect(
|
||||
getCollection(
|
||||
state,
|
||||
'wc/blocks',
|
||||
'products/attributes',
|
||||
'?someQuery=2',
|
||||
[ 20 ]
|
||||
)
|
||||
).toEqual( [] );
|
||||
} );
|
||||
describe( 'returns expected values for items existing in state', () => {
|
||||
test.each`
|
||||
resourceName | ids | query | expected
|
||||
${ 'products' } | ${ [] } | ${ { someQuery: 2 } } | ${ [ 'foo' ] }
|
||||
${ 'products/attributes' } | ${ [ 10 ] } | ${ { someQuery: 2 } } | ${ [ 'bar' ] }
|
||||
${ 'products/attributes/terms' } | ${ [ 10, 20 ] } | ${ { someQuery: 10 } } | ${ [ 42 ] }
|
||||
`(
|
||||
'for "$resourceName", "$ids", and "$query"',
|
||||
( { resourceName, ids, query, expected } ) => {
|
||||
expect(
|
||||
getCollection(
|
||||
state,
|
||||
'wc/blocks',
|
||||
resourceName,
|
||||
query,
|
||||
ids
|
||||
)
|
||||
).toEqual( expected );
|
||||
}
|
||||
);
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'getCollectionHeader', () => {
|
||||
it(
|
||||
'returns undefined when there are headers but the specific header ' +
|
||||
'does not exist',
|
||||
() => {
|
||||
expect(
|
||||
getCollectionHeader(
|
||||
state,
|
||||
'invalid',
|
||||
'wc/blocks',
|
||||
'products',
|
||||
{
|
||||
someQuery: 2,
|
||||
}
|
||||
)
|
||||
).toBeUndefined();
|
||||
}
|
||||
);
|
||||
it( 'returns null when there are no headers for the given arguments', () => {
|
||||
expect( getCollectionHeader( state, 'wc/blocks', 'invalid' ) ).toBe(
|
||||
null
|
||||
);
|
||||
} );
|
||||
it( 'returns expected header when it exists', () => {
|
||||
expect(
|
||||
getCollectionHeader( state, 'total', 'wc/blocks', 'products', {
|
||||
someQuery: 2,
|
||||
} )
|
||||
).toBe( 22 );
|
||||
} );
|
||||
} );
|
16
packages/woocommerce-blocks/assets/js/data/constants.ts
Normal file
16
packages/woocommerce-blocks/assets/js/data/constants.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* REST API namespace for rest requests against blocks namespace.
|
||||
*
|
||||
* @member {string}
|
||||
*/
|
||||
export const API_BLOCK_NAMESPACE = 'wc/blocks';
|
||||
|
||||
export const EMPTY_CART_COUPONS: [ ] = [];
|
||||
export const EMPTY_CART_ITEMS: [ ] = [];
|
||||
export const EMPTY_CART_FEES: [ ] = [];
|
||||
export const EMPTY_CART_ITEM_ERRORS: [ ] = [];
|
||||
export const EMPTY_CART_ERRORS: [ ] = [];
|
||||
export const EMPTY_SHIPPING_RATES: [ ] = [];
|
||||
export const EMPTY_PAYMENT_REQUIREMENTS: [ ] = [];
|
||||
export const EMPTY_EXTENSIONS: Record< string, unknown > = {};
|
||||
export const EMPTY_TAX_LINES: [ ] = [];
|
103
packages/woocommerce-blocks/assets/js/data/default-states.ts
Normal file
103
packages/woocommerce-blocks/assets/js/data/default-states.ts
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { Cart, CartMeta } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
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 './constants';
|
||||
import type { ResponseError } from './types';
|
||||
|
||||
export interface CartState {
|
||||
cartItemsPendingQuantity: Array< string >;
|
||||
cartItemsPendingDelete: Array< string >;
|
||||
cartData: Cart;
|
||||
metaData: CartMeta;
|
||||
errors: Array< ResponseError >;
|
||||
}
|
||||
|
||||
export const EMPTY_PENDING_QUANTITY: [ ] = [];
|
||||
export const EMPTY_PENDING_DELETE: [ ] = [];
|
||||
|
||||
export const defaultCartState: CartState = {
|
||||
cartItemsPendingQuantity: EMPTY_PENDING_QUANTITY,
|
||||
cartItemsPendingDelete: EMPTY_PENDING_DELETE,
|
||||
cartData: {
|
||||
coupons: EMPTY_CART_COUPONS,
|
||||
shippingRates: EMPTY_SHIPPING_RATES,
|
||||
shippingAddress: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
phone: '',
|
||||
},
|
||||
billingAddress: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
},
|
||||
items: EMPTY_CART_ITEMS,
|
||||
itemsCount: 0,
|
||||
itemsWeight: 0,
|
||||
needsShipping: true,
|
||||
needsPayment: false,
|
||||
hasCalculatedShipping: true,
|
||||
fees: EMPTY_CART_FEES,
|
||||
totals: {
|
||||
currency_code: '',
|
||||
currency_symbol: '',
|
||||
currency_minor_unit: 2,
|
||||
currency_decimal_separator: '.',
|
||||
currency_thousand_separator: ',',
|
||||
currency_prefix: '',
|
||||
currency_suffix: '',
|
||||
total_items: '0',
|
||||
total_items_tax: '0',
|
||||
total_fees: '0',
|
||||
total_fees_tax: '0',
|
||||
total_discount: '0',
|
||||
total_discount_tax: '0',
|
||||
total_shipping: '0',
|
||||
total_shipping_tax: '0',
|
||||
total_price: '0',
|
||||
total_tax: '0',
|
||||
tax_lines: EMPTY_TAX_LINES,
|
||||
},
|
||||
errors: EMPTY_CART_ITEM_ERRORS,
|
||||
paymentRequirements: EMPTY_PAYMENT_REQUIREMENTS,
|
||||
extensions: EMPTY_EXTENSIONS,
|
||||
},
|
||||
metaData: {
|
||||
updatingCustomerData: false,
|
||||
updatingSelectedRate: false,
|
||||
applyingCoupon: '',
|
||||
removingCoupon: '',
|
||||
isCartDataStale: false,
|
||||
},
|
||||
errors: EMPTY_CART_ERRORS,
|
||||
};
|
13
packages/woocommerce-blocks/assets/js/data/index.ts
Normal file
13
packages/woocommerce-blocks/assets/js/data/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import '@wordpress/notices';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
export { SCHEMA_STORE_KEY } from './schema';
|
||||
export { COLLECTIONS_STORE_KEY } from './collections';
|
||||
export { CART_STORE_KEY } from './cart';
|
||||
export { QUERY_STATE_STORE_KEY } from './query-state';
|
||||
export * from './constants';
|
@ -0,0 +1,4 @@
|
||||
export const ACTION_TYPES = {
|
||||
SET_QUERY_KEY_VALUE: 'SET_QUERY_KEY_VALUE',
|
||||
SET_QUERY_CONTEXT_VALUE: 'SET_QUERY_CONTEXT_VALUE',
|
||||
};
|
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION_TYPES as types } from './action-types';
|
||||
|
||||
/**
|
||||
* Action creator for setting a single query-state value for a given context.
|
||||
*
|
||||
* @param {string} context Context for query state being stored.
|
||||
* @param {string} queryKey Key for query item.
|
||||
* @param {*} value The value for the query item.
|
||||
*
|
||||
* @return {Object} The action object.
|
||||
*/
|
||||
export const setQueryValue = ( context, queryKey, value ) => {
|
||||
return {
|
||||
type: types.SET_QUERY_KEY_VALUE,
|
||||
context,
|
||||
queryKey,
|
||||
value,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Action creator for setting query-state for a given context.
|
||||
*
|
||||
* @param {string} context Context for query state being stored.
|
||||
* @param {*} value Query state being stored for the given context.
|
||||
*
|
||||
* @return {Object} The action object.
|
||||
*/
|
||||
export const setValueForQueryContext = ( context, value ) => {
|
||||
return {
|
||||
type: types.SET_QUERY_CONTEXT_VALUE,
|
||||
context,
|
||||
value,
|
||||
};
|
||||
};
|
@ -0,0 +1 @@
|
||||
export const STORE_KEY = 'wc/store/query-state';
|
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { registerStore } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { STORE_KEY } from './constants';
|
||||
import * as selectors from './selectors';
|
||||
import * as actions from './actions';
|
||||
import reducer from './reducers';
|
||||
|
||||
registerStore( STORE_KEY, {
|
||||
reducer,
|
||||
actions,
|
||||
selectors,
|
||||
} );
|
||||
|
||||
export const QUERY_STATE_STORE_KEY = STORE_KEY;
|
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION_TYPES as types } from './action-types';
|
||||
import { getStateForContext } from './utils';
|
||||
|
||||
/**
|
||||
* Reducer for processing actions related to the query state store.
|
||||
*
|
||||
* @param {Object} state Current state in store.
|
||||
* @param {Object} action Action being processed.
|
||||
*/
|
||||
const queryStateReducer = ( state = {}, action ) => {
|
||||
const { type, context, queryKey, value } = action;
|
||||
const prevState = getStateForContext( state, context );
|
||||
let newState;
|
||||
switch ( type ) {
|
||||
case types.SET_QUERY_KEY_VALUE:
|
||||
const prevStateObject =
|
||||
prevState !== null ? JSON.parse( prevState ) : {};
|
||||
|
||||
// mutate it and JSON.stringify to compare
|
||||
prevStateObject[ queryKey ] = value;
|
||||
newState = JSON.stringify( prevStateObject );
|
||||
|
||||
if ( prevState !== newState ) {
|
||||
state = {
|
||||
...state,
|
||||
[ context ]: newState,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case types.SET_QUERY_CONTEXT_VALUE:
|
||||
newState = JSON.stringify( value );
|
||||
if ( prevState !== newState ) {
|
||||
state = {
|
||||
...state,
|
||||
[ context ]: newState,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export default queryStateReducer;
|
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getStateForContext } from './utils';
|
||||
|
||||
/**
|
||||
* Selector for retrieving a specific query-state for the given context.
|
||||
*
|
||||
* @param {Object} state Current state.
|
||||
* @param {string} context Context for the query-state being retrieved.
|
||||
* @param {string} queryKey Key for the specific query-state item.
|
||||
* @param {*} defaultValue Default value for the query-state key if it doesn't
|
||||
* currently exist in state.
|
||||
*
|
||||
* @return {*} The currently stored value or the defaultValue if not present.
|
||||
*/
|
||||
export const getValueForQueryKey = (
|
||||
state,
|
||||
context,
|
||||
queryKey,
|
||||
defaultValue = {}
|
||||
) => {
|
||||
let stateContext = getStateForContext( state, context );
|
||||
if ( stateContext === null ) {
|
||||
return defaultValue;
|
||||
}
|
||||
stateContext = JSON.parse( stateContext );
|
||||
return typeof stateContext[ queryKey ] !== 'undefined'
|
||||
? stateContext[ queryKey ]
|
||||
: defaultValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Selector for retrieving the query-state for the given context.
|
||||
*
|
||||
* @param {Object} state The current state.
|
||||
* @param {string} context The context for the query-state being retrieved.
|
||||
* @param {*} defaultValue The default value to return if there is no state for
|
||||
* the given context.
|
||||
*
|
||||
* @return {*} The currently stored query-state for the given context or
|
||||
* defaultValue if not present in state.
|
||||
*/
|
||||
export const getValueForQueryContext = (
|
||||
state,
|
||||
context,
|
||||
defaultValue = {}
|
||||
) => {
|
||||
const stateContext = getStateForContext( state, context );
|
||||
return stateContext === null ? defaultValue : JSON.parse( stateContext );
|
||||
};
|
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import deepFreeze from 'deep-freeze';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import queryStateReducer from '../reducers';
|
||||
import { setQueryValue, setValueForQueryContext } from '../actions';
|
||||
|
||||
describe( 'queryStateReducer', () => {
|
||||
const originalState = deepFreeze( {
|
||||
contexta: JSON.stringify( {
|
||||
foo: 'bar',
|
||||
cheese: 'pizza',
|
||||
} ),
|
||||
} );
|
||||
it(
|
||||
'returns original state when the action is not of the type being ' +
|
||||
'processed',
|
||||
() => {
|
||||
expect(
|
||||
queryStateReducer( originalState, { type: 'invalid' } )
|
||||
).toBe( originalState );
|
||||
}
|
||||
);
|
||||
describe( 'SET_QUERY_KEY_VALUE action', () => {
|
||||
it(
|
||||
'returns original state when incoming query-state key value ' +
|
||||
'matches what is already in the state',
|
||||
() => {
|
||||
expect(
|
||||
queryStateReducer(
|
||||
originalState,
|
||||
setQueryValue( 'contexta', 'foo', 'bar' )
|
||||
)
|
||||
).toBe( originalState );
|
||||
}
|
||||
);
|
||||
it(
|
||||
'returns new state when incoming query-state key exist ' +
|
||||
'but the value is a new value',
|
||||
() => {
|
||||
const newState = queryStateReducer(
|
||||
originalState,
|
||||
setQueryValue( 'contexta', 'foo', 'zed' )
|
||||
);
|
||||
expect( newState ).not.toBe( originalState );
|
||||
expect( newState ).toEqual( {
|
||||
contexta: JSON.stringify( {
|
||||
foo: 'zed',
|
||||
cheese: 'pizza',
|
||||
} ),
|
||||
} );
|
||||
}
|
||||
);
|
||||
it(
|
||||
'returns new state when incoming query-state key does not ' +
|
||||
'exist',
|
||||
() => {
|
||||
const newState = queryStateReducer(
|
||||
originalState,
|
||||
setQueryValue( 'contexta', 'burger', 'pizza' )
|
||||
);
|
||||
expect( newState ).not.toBe( originalState );
|
||||
expect( newState ).toEqual( {
|
||||
contexta: JSON.stringify( {
|
||||
foo: 'bar',
|
||||
cheese: 'pizza',
|
||||
burger: 'pizza',
|
||||
} ),
|
||||
} );
|
||||
}
|
||||
);
|
||||
} );
|
||||
describe( 'SET_QUERY_CONTEXT_VALUE action', () => {
|
||||
it(
|
||||
'returns original state when incoming context value matches ' +
|
||||
'what is already in the state',
|
||||
() => {
|
||||
expect(
|
||||
queryStateReducer(
|
||||
originalState,
|
||||
setValueForQueryContext( 'contexta', {
|
||||
foo: 'bar',
|
||||
cheese: 'pizza',
|
||||
} )
|
||||
)
|
||||
).toBe( originalState );
|
||||
}
|
||||
);
|
||||
it(
|
||||
'returns new state when incoming context value is different ' +
|
||||
'than what is already in the state',
|
||||
() => {
|
||||
const newState = queryStateReducer(
|
||||
originalState,
|
||||
setValueForQueryContext( 'contexta', {
|
||||
bar: 'foo',
|
||||
pizza: 'cheese',
|
||||
} )
|
||||
);
|
||||
expect( newState ).not.toBe( originalState );
|
||||
expect( newState ).toEqual( {
|
||||
contexta: JSON.stringify( {
|
||||
bar: 'foo',
|
||||
pizza: 'cheese',
|
||||
} ),
|
||||
} );
|
||||
}
|
||||
);
|
||||
it(
|
||||
'returns new state when incoming context does not exist in the ' +
|
||||
'state',
|
||||
() => {
|
||||
const newState = queryStateReducer(
|
||||
originalState,
|
||||
setValueForQueryContext( 'contextb', {
|
||||
foo: 'bar',
|
||||
} )
|
||||
);
|
||||
expect( newState ).not.toBe( originalState );
|
||||
expect( newState ).toEqual( {
|
||||
contexta: JSON.stringify( {
|
||||
foo: 'bar',
|
||||
cheese: 'pizza',
|
||||
} ),
|
||||
contextb: JSON.stringify( {
|
||||
foo: 'bar',
|
||||
} ),
|
||||
} );
|
||||
}
|
||||
);
|
||||
} );
|
||||
} );
|
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import deepFreeze from 'deep-freeze';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getValueForQueryKey, getValueForQueryContext } from '../selectors';
|
||||
|
||||
const testState = deepFreeze( {
|
||||
contexta: JSON.stringify( {
|
||||
foo: 'bar',
|
||||
cheese: 'pizza',
|
||||
} ),
|
||||
} );
|
||||
|
||||
describe( 'getValueForQueryKey', () => {
|
||||
it(
|
||||
'returns provided default value when there is no state for the ' +
|
||||
'given context',
|
||||
() => {
|
||||
expect(
|
||||
getValueForQueryKey( testState, 'invalid', 'foo', 42 )
|
||||
).toBe( 42 );
|
||||
}
|
||||
);
|
||||
it(
|
||||
'returns provided default value when there is no value for the ' +
|
||||
'given context and queryKey',
|
||||
() => {
|
||||
expect(
|
||||
getValueForQueryKey( testState, 'contexta', 'pizza', 42 )
|
||||
).toBe( 42 );
|
||||
}
|
||||
);
|
||||
it( 'returns expected value when context and queryKey exist', () => {
|
||||
expect( getValueForQueryKey( testState, 'contexta', 'foo', 42 ) ).toBe(
|
||||
'bar'
|
||||
);
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'getValueForQueryContext', () => {
|
||||
it(
|
||||
'returns provided default value when there is no state for the ' +
|
||||
'given context',
|
||||
() => {
|
||||
expect( getValueForQueryContext( testState, 'invalid', 42 ) ).toBe(
|
||||
42
|
||||
);
|
||||
}
|
||||
);
|
||||
it(
|
||||
'returns expected value when selecting a context that exists in ' +
|
||||
'state',
|
||||
() => {
|
||||
expect(
|
||||
getValueForQueryContext( testState, 'contexta', 42 )
|
||||
).toEqual( JSON.parse( testState.contexta ) );
|
||||
}
|
||||
);
|
||||
} );
|
@ -0,0 +1,3 @@
|
||||
export const getStateForContext = ( state, context ) => {
|
||||
return typeof state[ context ] === 'undefined' ? null : state[ context ];
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export const ACTION_TYPES = {
|
||||
RECEIVE_MODEL_ROUTES: 'RECEIVE_MODEL_ROUTES',
|
||||
};
|
22
packages/woocommerce-blocks/assets/js/data/schema/actions.js
Normal file
22
packages/woocommerce-blocks/assets/js/data/schema/actions.js
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION_TYPES as types } from './action-types.js';
|
||||
import { API_BLOCK_NAMESPACE } from '../constants';
|
||||
|
||||
/**
|
||||
* Returns an action object used to update the store with the provided list
|
||||
* of model routes.
|
||||
*
|
||||
* @param {Object} routes An array of routes to add to the store state.
|
||||
* @param {string} namespace
|
||||
*
|
||||
* @return {Object} The action object.
|
||||
*/
|
||||
export function receiveRoutes( routes, namespace = API_BLOCK_NAMESPACE ) {
|
||||
return {
|
||||
type: types.RECEIVE_MODEL_ROUTES,
|
||||
routes,
|
||||
namespace,
|
||||
};
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Identifier key for this store reducer.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const STORE_KEY = 'wc/store/schema';
|
24
packages/woocommerce-blocks/assets/js/data/schema/index.js
Normal file
24
packages/woocommerce-blocks/assets/js/data/schema/index.js
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { registerStore } from '@wordpress/data';
|
||||
import { controls } from '@wordpress/data-controls';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { STORE_KEY } from './constants';
|
||||
import * as selectors from './selectors';
|
||||
import * as actions from './actions';
|
||||
import * as resolvers from './resolvers';
|
||||
import reducer from './reducers';
|
||||
|
||||
registerStore( STORE_KEY, {
|
||||
reducer,
|
||||
actions,
|
||||
controls,
|
||||
selectors,
|
||||
resolvers,
|
||||
} );
|
||||
|
||||
export const SCHEMA_STORE_KEY = STORE_KEY;
|
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { combineReducers } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION_TYPES as types } from './action-types';
|
||||
import {
|
||||
extractResourceNameFromRoute,
|
||||
getRouteIds,
|
||||
simplifyRouteWithId,
|
||||
} from './utils';
|
||||
import { hasInState, updateState } from '../utils';
|
||||
|
||||
/**
|
||||
* Reducer for routes
|
||||
*
|
||||
* @param {Object} state The current state.
|
||||
* @param {Object} action The action object for parsing.
|
||||
*
|
||||
* @return {Object} The new (or original) state.
|
||||
*/
|
||||
export const receiveRoutes = ( state = {}, action ) => {
|
||||
const { type, routes, namespace } = action;
|
||||
if ( type === types.RECEIVE_MODEL_ROUTES ) {
|
||||
routes.forEach( ( route ) => {
|
||||
const resourceName = extractResourceNameFromRoute(
|
||||
namespace,
|
||||
route
|
||||
);
|
||||
if ( resourceName && resourceName !== namespace ) {
|
||||
const routeIdNames = getRouteIds( route );
|
||||
const savedRoute = simplifyRouteWithId( route, routeIdNames );
|
||||
if (
|
||||
! hasInState( state, [
|
||||
namespace,
|
||||
resourceName,
|
||||
savedRoute,
|
||||
] )
|
||||
) {
|
||||
state = updateState(
|
||||
state,
|
||||
[ namespace, resourceName, savedRoute ],
|
||||
routeIdNames
|
||||
);
|
||||
}
|
||||
}
|
||||
} );
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export default combineReducers( {
|
||||
routes: receiveRoutes,
|
||||
} );
|
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { select, apiFetch } from '@wordpress/data-controls';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { receiveRoutes } from './actions';
|
||||
import { STORE_KEY } from './constants';
|
||||
|
||||
/**
|
||||
* Resolver for the getRoute selector.
|
||||
*
|
||||
* Note: All this essentially does is ensure the routes for the given namespace
|
||||
* have been resolved.
|
||||
*
|
||||
* @param {string} namespace The namespace of the route being resolved.
|
||||
*/
|
||||
export function* getRoute( namespace ) {
|
||||
// we call this simply to do any resolution of all endpoints if necessary.
|
||||
// allows for jit population of routes for a given namespace.
|
||||
yield select( STORE_KEY, 'getRoutes', namespace );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolver for the getRoutes selector.
|
||||
*
|
||||
* @param {string} namespace The namespace of the routes being resolved.
|
||||
*/
|
||||
export function* getRoutes( namespace ) {
|
||||
const routeResponse = yield apiFetch( { path: namespace } );
|
||||
const routes =
|
||||
routeResponse && routeResponse.routes
|
||||
? Object.keys( routeResponse.routes )
|
||||
: [];
|
||||
yield receiveRoutes( routes, namespace );
|
||||
}
|
158
packages/woocommerce-blocks/assets/js/data/schema/selectors.js
Normal file
158
packages/woocommerce-blocks/assets/js/data/schema/selectors.js
Normal file
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { sprintf } from '@wordpress/i18n';
|
||||
import { createRegistrySelector } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { STORE_KEY } from './constants';
|
||||
|
||||
/**
|
||||
* Returns the requested route for the given arguments.
|
||||
*
|
||||
* @param {Object} state The original state.
|
||||
* @param {string} namespace The namespace for the route.
|
||||
* @param {string} resourceName The resource being requested
|
||||
* (eg. products/attributes)
|
||||
* @param {Array} [ids] This is for any ids that might be implemented in
|
||||
* the route request. It is not for any query
|
||||
* parameters.
|
||||
*
|
||||
* Ids example:
|
||||
* If you are looking for the route for a single product on the `wc/blocks`
|
||||
* namespace, then you'd have `[ 20 ]` as the ids. This would produce something
|
||||
* like `/wc/blocks/products/20`
|
||||
*
|
||||
*
|
||||
* @throws {Error} If there is no route for the given arguments, then this will
|
||||
* throw
|
||||
*
|
||||
* @return {string} The route if it is available.
|
||||
*/
|
||||
export const getRoute = createRegistrySelector(
|
||||
( select ) => ( state, namespace, resourceName, ids = [] ) => {
|
||||
const hasResolved = select(
|
||||
STORE_KEY
|
||||
).hasFinishedResolution( 'getRoutes', [ namespace ] );
|
||||
state = state.routes;
|
||||
let error = '';
|
||||
if ( ! state[ namespace ] ) {
|
||||
error = sprintf(
|
||||
'There is no route for the given namespace (%s) in the store',
|
||||
namespace
|
||||
);
|
||||
} else if ( ! state[ namespace ][ resourceName ] ) {
|
||||
error = sprintf(
|
||||
'There is no route for the given resource name (%s) in the store',
|
||||
resourceName
|
||||
);
|
||||
}
|
||||
if ( error !== '' ) {
|
||||
if ( hasResolved ) {
|
||||
throw new Error( error );
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const route = getRouteFromResourceEntries(
|
||||
state[ namespace ][ resourceName ],
|
||||
ids
|
||||
);
|
||||
if ( route === '' ) {
|
||||
if ( hasResolved ) {
|
||||
throw new Error(
|
||||
sprintf(
|
||||
'While there is a route for the given namespace (%1$s) and resource name (%2$s), there is no route utilizing the number of ids you included in the select arguments. The available routes are: (%3$s)',
|
||||
namespace,
|
||||
resourceName,
|
||||
JSON.stringify( state[ namespace ][ resourceName ] )
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
return route;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Return all the routes for a given namespace.
|
||||
*
|
||||
* @param {Object} state The current state.
|
||||
* @param {string} namespace The namespace to return routes for.
|
||||
*
|
||||
* @return {Array} An array of all routes for the given namespace.
|
||||
*/
|
||||
export const getRoutes = createRegistrySelector(
|
||||
( select ) => ( state, namespace ) => {
|
||||
const hasResolved = select(
|
||||
STORE_KEY
|
||||
).hasFinishedResolution( 'getRoutes', [ namespace ] );
|
||||
const routes = state.routes[ namespace ];
|
||||
if ( ! routes ) {
|
||||
if ( hasResolved ) {
|
||||
throw new Error(
|
||||
sprintf(
|
||||
'There is no route for the given namespace (%s) in the store',
|
||||
namespace
|
||||
)
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
let namespaceRoutes = [];
|
||||
for ( const resourceName in routes ) {
|
||||
namespaceRoutes = [
|
||||
...namespaceRoutes,
|
||||
...Object.keys( routes[ resourceName ] ),
|
||||
];
|
||||
}
|
||||
return namespaceRoutes;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the route from the given slice of the route state.
|
||||
*
|
||||
* @param {Object} stateSlice This will be a slice of the route state from a
|
||||
* given namespace and resource name.
|
||||
* @param {Array} [ids=[]] Any id references that are to be replaced in
|
||||
* route placeholders.
|
||||
*
|
||||
* @return {string} The route or an empty string if nothing found.
|
||||
*/
|
||||
const getRouteFromResourceEntries = ( stateSlice, ids = [] ) => {
|
||||
// convert to array for easier discovery
|
||||
stateSlice = Object.entries( stateSlice );
|
||||
const match = stateSlice.find( ( [ , idNames ] ) => {
|
||||
return ids.length === idNames.length;
|
||||
} );
|
||||
const [ matchingRoute, routePlaceholders ] = match || [];
|
||||
// if we have a matching route, let's return it.
|
||||
if ( matchingRoute ) {
|
||||
return ids.length === 0
|
||||
? matchingRoute
|
||||
: assembleRouteWithPlaceholders(
|
||||
matchingRoute,
|
||||
routePlaceholders,
|
||||
ids
|
||||
);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* For a given route, route parts and ids,
|
||||
*
|
||||
* @param {string} route
|
||||
* @param {Array} routePlaceholders
|
||||
* @param {Array} ids
|
||||
*
|
||||
* @return {string} Assembled route.
|
||||
*/
|
||||
const assembleRouteWithPlaceholders = ( route, routePlaceholders, ids ) => {
|
||||
routePlaceholders.forEach( ( part, index ) => {
|
||||
route = route.replace( `{${ part }}`, ids[ index ] );
|
||||
} );
|
||||
return route;
|
||||
};
|
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import deepFreeze from 'deep-freeze';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { receiveRoutes } from '../reducers';
|
||||
import { ACTION_TYPES as types } from '../action-types';
|
||||
|
||||
describe( 'receiveRoutes', () => {
|
||||
it( 'returns original state when action type is not a match', () => {
|
||||
expect( receiveRoutes( undefined, { type: 'invalid' } ) ).toEqual( {} );
|
||||
} );
|
||||
it( 'returns original state when the given endpoints already exists', () => {
|
||||
const routes = [
|
||||
'wc/blocks/products/attributes',
|
||||
'wc/blocks/products/attributes/(?P<attribute_id>[d]+)/terms/(?P<id>[d]+)',
|
||||
];
|
||||
const originalState = deepFreeze( {
|
||||
'wc/blocks': {
|
||||
'products/attributes': {
|
||||
'wc/blocks/products/attributes': [],
|
||||
},
|
||||
'products/attributes/terms': {
|
||||
'wc/blocks/products/attributes/{attribute_id}/terms/{id}': [
|
||||
'attribute_id',
|
||||
'id',
|
||||
],
|
||||
},
|
||||
},
|
||||
} );
|
||||
const newState = receiveRoutes( originalState, {
|
||||
type: types.RECEIVE_MODEL_ROUTES,
|
||||
namespace: 'wc/blocks',
|
||||
routes,
|
||||
} );
|
||||
expect( newState ).toBe( originalState );
|
||||
} );
|
||||
it( 'returns expected state when new route added', () => {
|
||||
const action = {
|
||||
type: types.RECEIVE_MODEL_ROUTES,
|
||||
namespace: 'wc/blocks',
|
||||
routes: [ 'wc/blocks/products/attributes' ],
|
||||
};
|
||||
const originalState = deepFreeze( {
|
||||
'wc/blocks': {
|
||||
'products/attributes/terms': {
|
||||
'wc/blocks/products/attributes/{attribute_id}/terms/{id}': [
|
||||
'attribute_id',
|
||||
'id',
|
||||
],
|
||||
},
|
||||
},
|
||||
} );
|
||||
const newState = receiveRoutes( originalState, action );
|
||||
expect( newState ).not.toBe( originalState );
|
||||
expect( newState ).toEqual( {
|
||||
'wc/blocks': {
|
||||
'products/attributes': {
|
||||
'wc/blocks/products/attributes': [],
|
||||
},
|
||||
'products/attributes/terms': {
|
||||
'wc/blocks/products/attributes/{attribute_id}/terms/{id}': [
|
||||
'attribute_id',
|
||||
'id',
|
||||
],
|
||||
},
|
||||
},
|
||||
} );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { select, apiFetch } from '@wordpress/data-controls';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getRoute, getRoutes } from '../resolvers';
|
||||
import { receiveRoutes } from '../actions';
|
||||
import { STORE_KEY } from '../constants';
|
||||
|
||||
jest.mock( '@wordpress/data-controls' );
|
||||
|
||||
describe( 'getRoute', () => {
|
||||
it( 'yields select control response', () => {
|
||||
const fulfillment = getRoute( 'wc/blocks' );
|
||||
fulfillment.next();
|
||||
expect( select ).toHaveBeenCalledWith(
|
||||
STORE_KEY,
|
||||
'getRoutes',
|
||||
'wc/blocks'
|
||||
);
|
||||
const { done } = fulfillment.next();
|
||||
expect( done ).toBe( true );
|
||||
} );
|
||||
} );
|
||||
describe( 'getRoutes', () => {
|
||||
describe( 'yields with expected responses', () => {
|
||||
let fulfillment;
|
||||
const rewind = () => ( fulfillment = getRoutes( 'wc/blocks' ) );
|
||||
test( 'with apiFetch control invoked', () => {
|
||||
rewind();
|
||||
fulfillment.next();
|
||||
expect( apiFetch ).toHaveBeenCalledWith( { path: 'wc/blocks' } );
|
||||
} );
|
||||
test( 'with receiveRoutes action with valid response', () => {
|
||||
const testResponse = {
|
||||
routes: {
|
||||
'/wc/blocks/products/attributes': [],
|
||||
},
|
||||
};
|
||||
const { value } = fulfillment.next( testResponse );
|
||||
expect( value ).toEqual(
|
||||
receiveRoutes( Object.keys( testResponse.routes ), 'wc/blocks' )
|
||||
);
|
||||
} );
|
||||
test( 'with receiveRoutesAction with invalid response', () => {
|
||||
rewind();
|
||||
fulfillment.next();
|
||||
const { value } = fulfillment.next( {} );
|
||||
expect( value ).toEqual( receiveRoutes( [], 'wc/blocks' ) );
|
||||
} );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,104 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import deepFreeze from 'deep-freeze';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getRoute, getRoutes } from '../selectors';
|
||||
|
||||
const mockHasFinishedResolution = jest.fn().mockReturnValue( false );
|
||||
jest.mock( '@wordpress/data', () => ( {
|
||||
__esModule: true,
|
||||
createRegistrySelector: ( callback ) =>
|
||||
callback( () => ( {
|
||||
hasFinishedResolution: mockHasFinishedResolution,
|
||||
} ) ),
|
||||
} ) );
|
||||
|
||||
const testState = deepFreeze( {
|
||||
routes: {
|
||||
'wc/blocks': {
|
||||
'products/attributes': {
|
||||
'wc/blocks/products/attributes': [],
|
||||
},
|
||||
'products/attributes/terms': {
|
||||
'wc/blocks/products/attributes/{attribute_id}/terms/{id}': [
|
||||
'attribute_id',
|
||||
'id',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} );
|
||||
|
||||
describe( 'getRoute', () => {
|
||||
const invokeTest = ( namespace, resourceName, ids = [] ) => () => {
|
||||
return getRoute( testState, namespace, resourceName, ids );
|
||||
};
|
||||
describe( 'with throwing errors', () => {
|
||||
beforeEach( () => mockHasFinishedResolution.mockReturnValue( true ) );
|
||||
it( 'throws an error if there is no route for the given namespace', () => {
|
||||
expect( invokeTest( 'invalid' ) ).toThrowError( /given namespace/ );
|
||||
} );
|
||||
it(
|
||||
'throws an error if there are routes for the given namespace, but no ' +
|
||||
'route for the given resource',
|
||||
() => {
|
||||
expect( invokeTest( 'wc/blocks', 'invalid' ) ).toThrowError();
|
||||
}
|
||||
);
|
||||
it(
|
||||
'throws an error if there are routes for the given namespace and ' +
|
||||
'resource name, but no routes for the given ids',
|
||||
() => {
|
||||
expect(
|
||||
invokeTest( 'wc/blocks', 'products/attributes', [ 10 ] )
|
||||
).toThrowError( /number of ids you included/ );
|
||||
}
|
||||
);
|
||||
} );
|
||||
describe( 'with no throwing of errors if resolution has not finished', () => {
|
||||
beforeEach( () => mockHasFinishedResolution.mockReturnValue( false ) );
|
||||
it.each`
|
||||
description | args
|
||||
${ 'is no route for the given namespace' } | ${ [ 'invalid' ] }
|
||||
${ 'are no routes for the given namespace, but no route for the given resource' } | ${ [ 'wc/blocks', 'invalid' ] }
|
||||
${ 'are routes for the given namespace and resource name, but no routes for the given ids' } | ${ [ 'wc/blocks', 'products/attributes', [ 10 ] ] }
|
||||
`( 'does not throw an error if there $description', ( { args } ) => {
|
||||
expect( invokeTest( ...args ) ).not.toThrowError();
|
||||
} );
|
||||
} );
|
||||
describe( 'returns expected value for given valid arguments', () => {
|
||||
test( 'when there is a route with no placeholders', () => {
|
||||
expect( invokeTest( 'wc/blocks', 'products/attributes' )() ).toBe(
|
||||
'wc/blocks/products/attributes'
|
||||
);
|
||||
} );
|
||||
test( 'when there is a route with placeholders', () => {
|
||||
expect(
|
||||
invokeTest( 'wc/blocks', 'products/attributes/terms', [
|
||||
10,
|
||||
20,
|
||||
] )()
|
||||
).toBe( 'wc/blocks/products/attributes/10/terms/20' );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'getRoutes', () => {
|
||||
const invokeTest = ( namespace ) => () => {
|
||||
return getRoutes( testState, namespace );
|
||||
};
|
||||
it( 'throws an error if there is no route for the given namespace', () => {
|
||||
mockHasFinishedResolution.mockReturnValue( true );
|
||||
expect( invokeTest( 'invalid' ) ).toThrowError( /given namespace/ );
|
||||
} );
|
||||
it( 'returns expected routes for given namespace', () => {
|
||||
expect( invokeTest( 'wc/blocks' )() ).toEqual( [
|
||||
'wc/blocks/products/attributes',
|
||||
'wc/blocks/products/attributes/{attribute_id}/terms/{id}',
|
||||
] );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
extractResourceNameFromRoute,
|
||||
getRouteIds,
|
||||
simplifyRouteWithId,
|
||||
} from '../utils';
|
||||
|
||||
describe( 'extractResourceNameFromRoute', () => {
|
||||
it.each`
|
||||
namespace | route | expected
|
||||
${ 'wc/blocks' } | ${ 'wc/blocks/products' } | ${ 'products' }
|
||||
${ 'wc/other' } | ${ 'wc/blocks/product' } | ${ 'wc/blocks/product' }
|
||||
${ 'wc/blocks' } | ${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)' } | ${ 'products/attributes' }
|
||||
${ 'wc/blocks' } | ${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms' } | ${ 'products/attributes/terms' }
|
||||
${ 'wc/blocks' } | ${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms/(?P<id>[d]+)' } | ${ 'products/attributes/terms' }
|
||||
`(
|
||||
'returns "$expected" when namespace is "$namespace" and route is "$route"',
|
||||
( { namespace, route, expected } ) => {
|
||||
expect( extractResourceNameFromRoute( namespace, route ) ).toBe(
|
||||
expected
|
||||
);
|
||||
}
|
||||
);
|
||||
} );
|
||||
|
||||
describe( 'getRouteIds', () => {
|
||||
it.each`
|
||||
route | expected
|
||||
${ 'wc/blocks/products' } | ${ [] }
|
||||
${ 'wc/blocks/products/(?P<id>[\\d]+)' } | ${ [ 'id' ] }
|
||||
${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms/(?P<id>[\\d]+)' } | ${ [ 'attribute_id', 'id' ] }
|
||||
`(
|
||||
'returns "$expected" when route is "$route"',
|
||||
( { route, expected } ) => {
|
||||
expect( getRouteIds( route ) ).toEqual( expected );
|
||||
}
|
||||
);
|
||||
} );
|
||||
|
||||
describe( 'simplifyRouteWithId', () => {
|
||||
it.each`
|
||||
route | matchIds | expected
|
||||
${ 'wc/blocks/products' } | ${ [] } | ${ 'wc/blocks/products' }
|
||||
${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)' } | ${ [ 'attribute_id' ] } | ${ 'wc/blocks/products/attributes/{attribute_id}' }
|
||||
${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms' } | ${ [ 'attribute_id' ] } | ${ 'wc/blocks/products/attributes/{attribute_id}/terms' }
|
||||
${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms/(?P<id>[\\d]+)' } | ${ [ 'attribute_id', 'id' ] } | ${ 'wc/blocks/products/attributes/{attribute_id}/terms/{id}' }
|
||||
${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms/(?P<id>[\\d]+)' } | ${ [ 'id', 'attribute_id' ] } | ${ 'wc/blocks/products/attributes/{attribute_id}/terms/{id}' }
|
||||
`(
|
||||
'returns "$expected" when route is "$route" and matchIds is "$matchIds"',
|
||||
( { route, matchIds, expected } ) => {
|
||||
expect( simplifyRouteWithId( route, matchIds ) ).toBe( expected );
|
||||
}
|
||||
);
|
||||
} );
|
65
packages/woocommerce-blocks/assets/js/data/schema/utils.js
Normal file
65
packages/woocommerce-blocks/assets/js/data/schema/utils.js
Normal file
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* This returns a resource name string as an index for a given route.
|
||||
*
|
||||
* For example:
|
||||
* /wc/blocks/products/attributes/(?P<id>[\d]+)/terms
|
||||
* returns
|
||||
* /products/attributes/terms
|
||||
*
|
||||
* @param {string} namespace
|
||||
* @param {string} route
|
||||
*
|
||||
* @return {string} The resource name extracted from the route.
|
||||
*/
|
||||
export const extractResourceNameFromRoute = ( namespace, route ) => {
|
||||
route = route.replace( `${ namespace }/`, '' );
|
||||
return route.replace( /\/\(\?P\<[a-z_]*\>\[\\*[a-z]\]\+\)/g, '' );
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an array of the identifier for the named capture groups in a given
|
||||
* route.
|
||||
*
|
||||
* For example, if the route was this:
|
||||
* /wc/blocks/products/attributes/(?P<attribute_id>[\d]+)/terms/(?P<id>[\d]+)
|
||||
*
|
||||
* ...then the following would get returned
|
||||
* [ 'attribute_id', 'id' ]
|
||||
*
|
||||
* @param {string} route - The route to extract identifier names from.
|
||||
*
|
||||
* @return {Array} An array of named route identifier names.
|
||||
*/
|
||||
export const getRouteIds = ( route ) => {
|
||||
const matches = route.match( /\<[a-z_]*\>/g );
|
||||
if ( ! Array.isArray( matches ) || matches.length === 0 ) {
|
||||
return [];
|
||||
}
|
||||
return matches.map( ( match ) => match.replace( /<|>/g, '' ) );
|
||||
};
|
||||
|
||||
/**
|
||||
* This replaces regex placeholders in routes with the relevant named string
|
||||
* found in the matchIds.
|
||||
*
|
||||
* Something like:
|
||||
* /wc/blocks/products/attributes/(?P<attribute_id>[\d]+)/terms/(?P<id>[\d]+)
|
||||
*
|
||||
* ..ends up as:
|
||||
* /wc/blocks/products/attributes/{attribute_id}/terms/{id}
|
||||
*
|
||||
* @param {string} route The route to manipulate
|
||||
* @param {Array} matchIds An array of named ids ( [ attribute_id, id ] )
|
||||
*
|
||||
* @return {string} The route with new id placeholders
|
||||
*/
|
||||
export const simplifyRouteWithId = ( route, matchIds ) => {
|
||||
if ( ! Array.isArray( matchIds ) || matchIds.length === 0 ) {
|
||||
return route;
|
||||
}
|
||||
matchIds.forEach( ( matchId ) => {
|
||||
const expression = `\\(\\?P<${ matchId }>.*?\\)`;
|
||||
route = route.replace( new RegExp( expression ), `{${ matchId }}` );
|
||||
} );
|
||||
return route;
|
||||
};
|
197
packages/woocommerce-blocks/assets/js/data/shared-controls.ts
Normal file
197
packages/woocommerce-blocks/assets/js/data/shared-controls.ts
Normal file
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import triggerFetch, { APIFetchOptions } from '@wordpress/api-fetch';
|
||||
import DataLoader from 'dataloader';
|
||||
import { isWpVersion } from '@woocommerce/settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
assertBatchResponseIsValid,
|
||||
assertResponseIsValid,
|
||||
ApiResponse,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Dispatched a control action for triggering an api fetch call with no parsing.
|
||||
* Typically this would be used in scenarios where headers are needed.
|
||||
*
|
||||
* @param {APIFetchOptions} options The options for the API request.
|
||||
*/
|
||||
export const apiFetchWithHeaders = ( options: APIFetchOptions ) =>
|
||||
( {
|
||||
type: 'API_FETCH_WITH_HEADERS',
|
||||
options,
|
||||
} as const );
|
||||
|
||||
const EMPTY_OBJECT = {};
|
||||
|
||||
/**
|
||||
* Error thrown when JSON cannot be parsed.
|
||||
*/
|
||||
const invalidJsonError = {
|
||||
code: 'invalid_json',
|
||||
message: __(
|
||||
'The response is not a valid JSON response.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
};
|
||||
|
||||
const setNonceOnFetch = ( headers: Headers ): void => {
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore -- this does exist because it's monkey patched in
|
||||
// middleware/store-api-nonce.
|
||||
triggerFetch.setNonce &&
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore -- this does exist because it's monkey patched in
|
||||
// middleware/store-api-nonce.
|
||||
typeof triggerFetch.setNonce === 'function'
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore -- this does exist because it's monkey patched in
|
||||
// middleware/store-api-nonce.
|
||||
triggerFetch.setNonce( headers );
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
'The monkey patched function on APIFetch, "setNonce", is not present, likely another plugin or some other code has removed this augmentation'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger a fetch from the API using the batch endpoint.
|
||||
*/
|
||||
const triggerBatchFetch = ( keys: readonly APIFetchOptions[] ) => {
|
||||
return triggerFetch( {
|
||||
path: `/wc/store/batch`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
requests: keys.map( ( request: APIFetchOptions ) => {
|
||||
return {
|
||||
...request,
|
||||
body: request?.data,
|
||||
};
|
||||
} ),
|
||||
},
|
||||
} ).then( ( response: unknown ) => {
|
||||
assertBatchResponseIsValid( response );
|
||||
return keys.map(
|
||||
( key, index: number ) =>
|
||||
response.responses[ index ] || EMPTY_OBJECT
|
||||
);
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* In ms, how long we should wait for requests to batch.
|
||||
*
|
||||
* DataLoader collects all requests over this window of time (and as a consequence, adds this amount of latency).
|
||||
*/
|
||||
const triggerBatchFetchDelay = 300;
|
||||
|
||||
/**
|
||||
* DataLoader instance for triggerBatchFetch.
|
||||
*/
|
||||
const triggerBatchFetchLoader = new DataLoader( triggerBatchFetch, {
|
||||
batchScheduleFn: ( callback: () => void ) =>
|
||||
setTimeout( callback, triggerBatchFetchDelay ),
|
||||
cache: false,
|
||||
maxBatchSize: 25,
|
||||
} );
|
||||
|
||||
/**
|
||||
* Trigger a fetch from the API using the batch endpoint.
|
||||
*
|
||||
* @param {APIFetchOptions} request Request object containing API request.
|
||||
*/
|
||||
const batchFetch = async ( request: APIFetchOptions ) => {
|
||||
return await triggerBatchFetchLoader.load( request );
|
||||
};
|
||||
|
||||
/**
|
||||
* Default export for registering the controls with the store.
|
||||
*
|
||||
* @return {Object} An object with the controls to register with the store on
|
||||
* the controls property of the registration object.
|
||||
*/
|
||||
export const controls = {
|
||||
API_FETCH_WITH_HEADERS: ( {
|
||||
options,
|
||||
}: ReturnType< typeof apiFetchWithHeaders > ): Promise< unknown > => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
// GET Requests cannot be batched.
|
||||
if (
|
||||
! options.method ||
|
||||
options.method === 'GET' ||
|
||||
isWpVersion( '5.6', '<' )
|
||||
) {
|
||||
// Parse is disabled here to avoid returning just the body--we also need headers.
|
||||
triggerFetch( {
|
||||
...options,
|
||||
parse: false,
|
||||
} )
|
||||
.then( ( fetchResponse ) => {
|
||||
fetchResponse
|
||||
.json()
|
||||
.then( ( response ) => {
|
||||
resolve( {
|
||||
response,
|
||||
headers: fetchResponse.headers,
|
||||
} );
|
||||
setNonceOnFetch( fetchResponse.headers );
|
||||
} )
|
||||
.catch( () => {
|
||||
reject( invalidJsonError );
|
||||
} );
|
||||
} )
|
||||
.catch( ( errorResponse ) => {
|
||||
setNonceOnFetch( errorResponse.headers );
|
||||
if ( typeof errorResponse.json === 'function' ) {
|
||||
// Parse error response before rejecting it.
|
||||
errorResponse
|
||||
.json()
|
||||
.then( ( error: unknown ) => {
|
||||
reject( error );
|
||||
} )
|
||||
.catch( () => {
|
||||
reject( invalidJsonError );
|
||||
} );
|
||||
} else {
|
||||
reject( errorResponse.message );
|
||||
}
|
||||
} );
|
||||
} else {
|
||||
batchFetch( options )
|
||||
.then( ( response: ApiResponse ) => {
|
||||
assertResponseIsValid( response );
|
||||
|
||||
if ( response.status >= 200 && response.status < 300 ) {
|
||||
resolve( {
|
||||
response: response.body,
|
||||
headers: response.headers,
|
||||
} );
|
||||
setNonceOnFetch( response.headers );
|
||||
}
|
||||
|
||||
// Status code indicates error.
|
||||
throw response;
|
||||
} )
|
||||
.catch( ( errorResponse: ApiResponse ) => {
|
||||
if ( errorResponse.headers ) {
|
||||
setNonceOnFetch( errorResponse.headers );
|
||||
}
|
||||
if ( errorResponse.body ) {
|
||||
reject( errorResponse.body );
|
||||
} else {
|
||||
reject();
|
||||
}
|
||||
} );
|
||||
}
|
||||
} );
|
||||
},
|
||||
};
|
44
packages/woocommerce-blocks/assets/js/data/types.ts
Normal file
44
packages/woocommerce-blocks/assets/js/data/types.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export interface ResponseError {
|
||||
code: string;
|
||||
message: string;
|
||||
data: {
|
||||
status: number;
|
||||
[ key: string ]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
body: Record< string, unknown >;
|
||||
headers: Headers;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export function assertBatchResponseIsValid(
|
||||
response: unknown
|
||||
): asserts response is {
|
||||
responses: ApiResponse[];
|
||||
headers: Headers;
|
||||
} {
|
||||
if (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
response.hasOwnProperty( 'responses' )
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new Error( 'Response not valid' );
|
||||
}
|
||||
|
||||
export function assertResponseIsValid(
|
||||
response: unknown
|
||||
): asserts response is ApiResponse {
|
||||
if (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
response.hasOwnProperty( 'body' ) &&
|
||||
response.hasOwnProperty( 'headers' )
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new Error( 'Response not valid' );
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { has } from 'lodash';
|
||||
|
||||
/**
|
||||
* Utility for returning whether the given path exists in the state.
|
||||
*
|
||||
* @param {Object} state The state being checked
|
||||
* @param {Array} path The path to check
|
||||
*
|
||||
* @return {boolean} True means this exists in the state.
|
||||
*/
|
||||
export default function hasInState( state, path ) {
|
||||
return has( state, path );
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export { default as hasInState } from './has-in-state';
|
||||
export { default as updateState } from './update-state';
|
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { setWith, clone } from 'lodash';
|
||||
|
||||
/**
|
||||
* Utility for updating state and only cloning objects in the path that changed.
|
||||
*
|
||||
* @param {Object} state The state being updated
|
||||
* @param {Array} path The path being updated
|
||||
* @param {*} value The value to update for the path
|
||||
*
|
||||
* @return {Object} The new state
|
||||
*/
|
||||
export default function updateState( state, path, value ) {
|
||||
return setWith( clone( state ), path, value, clone );
|
||||
}
|
Reference in New Issue
Block a user