rebase from live enviornment
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { actions } from './reducer';
|
||||
import type { ActionType, ActionCallbackType } from './types';
|
||||
|
||||
export const emitterCallback =
|
||||
( type: string, observerDispatch: React.Dispatch< ActionType > ) =>
|
||||
( callback: ActionCallbackType, priority = 10 ): ( () => void ) => {
|
||||
const action = actions.addEventCallback( type, callback, priority );
|
||||
observerDispatch( action );
|
||||
return () => {
|
||||
observerDispatch( actions.removeEventCallback( type, action.id ) );
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
getObserversByPriority,
|
||||
isErrorResponse,
|
||||
isFailResponse,
|
||||
ObserverResponse,
|
||||
responseTypes,
|
||||
} from './utils';
|
||||
import type { EventObserversType } from './types';
|
||||
import { isObserverResponse } from '../../../types/type-guards/observers';
|
||||
|
||||
/**
|
||||
* Emits events on registered observers for the provided type and passes along
|
||||
* the provided data.
|
||||
*
|
||||
* This event emitter will silently catch promise errors, but doesn't care
|
||||
* otherwise if any errors are caused by observers. So events that do care
|
||||
* should use `emitEventWithAbort` instead.
|
||||
*
|
||||
* @param {Object} observers The registered observers to omit to.
|
||||
* @param {string} eventType The event type being emitted.
|
||||
* @param {*} data Data passed along to the observer when it is invoked.
|
||||
*
|
||||
* @return {Promise} A promise that resolves to true after all observers have executed.
|
||||
*/
|
||||
export const emitEvent = async (
|
||||
observers: EventObserversType,
|
||||
eventType: string,
|
||||
data: unknown
|
||||
): Promise< unknown > => {
|
||||
const observersByType = getObserversByPriority( observers, eventType );
|
||||
const observerResponses = [];
|
||||
for ( const observer of observersByType ) {
|
||||
try {
|
||||
const observerResponse = await Promise.resolve(
|
||||
observer.callback( data )
|
||||
);
|
||||
if ( typeof observerResponse === 'object' ) {
|
||||
observerResponses.push( observerResponse );
|
||||
}
|
||||
} catch ( e ) {
|
||||
// we don't care about errors blocking execution, but will console.error for troubleshooting.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error( e );
|
||||
}
|
||||
}
|
||||
return observerResponses.length ? observerResponses : true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Emits events on registered observers for the provided type and passes along
|
||||
* the provided data. This event emitter will abort if an observer throws an
|
||||
* error or if the response includes an object with an error type property.
|
||||
*
|
||||
* Any successful observer responses before abort will be included in the returned package.
|
||||
*
|
||||
* @param {Object} observers The registered observers to omit to.
|
||||
* @param {string} eventType The event type being emitted.
|
||||
* @param {*} data Data passed along to the observer when it is invoked.
|
||||
*
|
||||
* @return {Promise} Returns a promise that resolves to either boolean, or an array of responses
|
||||
* from registered observers that were invoked up to the point of an error.
|
||||
*/
|
||||
export const emitEventWithAbort = async (
|
||||
observers: EventObserversType,
|
||||
eventType: string,
|
||||
data: unknown
|
||||
): Promise< ObserverResponse[] > => {
|
||||
const observerResponses: ObserverResponse[] = [];
|
||||
const observersByType = getObserversByPriority( observers, eventType );
|
||||
for ( const observer of observersByType ) {
|
||||
try {
|
||||
const response = await Promise.resolve( observer.callback( data ) );
|
||||
if ( ! isObserverResponse( response ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( ! response.hasOwnProperty( 'type' ) ) {
|
||||
throw new Error(
|
||||
'Returned objects from event emitter observers must return an object with a type property'
|
||||
);
|
||||
}
|
||||
if ( isErrorResponse( response ) || isFailResponse( response ) ) {
|
||||
observerResponses.push( response );
|
||||
// early abort.
|
||||
return observerResponses;
|
||||
}
|
||||
// all potential abort conditions have been considered push the
|
||||
// response to the array.
|
||||
observerResponses.push( response );
|
||||
} catch ( e ) {
|
||||
// We don't handle thrown errors but just console.log for troubleshooting.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error( e );
|
||||
observerResponses.push( { type: responseTypes.ERROR } );
|
||||
return observerResponses;
|
||||
}
|
||||
}
|
||||
return observerResponses;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './reducer';
|
||||
export * from './emitters';
|
||||
export * from './emitter-callback';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
ACTION,
|
||||
ActionType,
|
||||
ActionCallbackType,
|
||||
EventObserversType,
|
||||
} from './types';
|
||||
|
||||
export function generateUniqueId() {
|
||||
return Math.floor( Math.random() * Date.now() ).toString();
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
addEventCallback: (
|
||||
eventType: string,
|
||||
callback: ActionCallbackType,
|
||||
priority = 10
|
||||
): ActionType => {
|
||||
return {
|
||||
id: generateUniqueId(),
|
||||
type: ACTION.ADD_EVENT_CALLBACK,
|
||||
eventType,
|
||||
callback,
|
||||
priority,
|
||||
};
|
||||
},
|
||||
removeEventCallback: ( eventType: string, id: string ): ActionType => {
|
||||
return {
|
||||
id,
|
||||
type: ACTION.REMOVE_EVENT_CALLBACK,
|
||||
eventType,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const initialState = {} as EventObserversType;
|
||||
|
||||
/**
|
||||
* Handles actions for emitters
|
||||
*/
|
||||
export const reducer = (
|
||||
state = initialState,
|
||||
{ type, eventType, id, callback, priority }: ActionType
|
||||
): typeof initialState => {
|
||||
const newEvents = state.hasOwnProperty( eventType )
|
||||
? new Map( state[ eventType ] )
|
||||
: new Map();
|
||||
switch ( type ) {
|
||||
case ACTION.ADD_EVENT_CALLBACK:
|
||||
newEvents.set( id, { priority, callback } );
|
||||
return {
|
||||
...state,
|
||||
[ eventType ]: newEvents,
|
||||
};
|
||||
case ACTION.REMOVE_EVENT_CALLBACK:
|
||||
newEvents.delete( id );
|
||||
return {
|
||||
...state,
|
||||
[ eventType ]: newEvents,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { emitEvent, emitEventWithAbort } from '../emitters';
|
||||
|
||||
describe( 'Testing emitters', () => {
|
||||
let observerMocks = {};
|
||||
let observerA;
|
||||
let observerB;
|
||||
let observerPromiseWithResolvedValue;
|
||||
beforeEach( () => {
|
||||
observerA = jest.fn().mockReturnValue( true );
|
||||
observerB = jest.fn().mockReturnValue( true );
|
||||
observerPromiseWithResolvedValue = jest.fn().mockResolvedValue( 10 );
|
||||
observerMocks = new Map( [
|
||||
[ 'observerA', { priority: 10, callback: observerA } ],
|
||||
[ 'observerB', { priority: 10, callback: observerB } ],
|
||||
[
|
||||
'observerReturnValue',
|
||||
{ priority: 10, callback: jest.fn().mockReturnValue( 10 ) },
|
||||
],
|
||||
[
|
||||
'observerPromiseWithReject',
|
||||
{
|
||||
priority: 10,
|
||||
callback: jest.fn().mockRejectedValue( 'an error' ),
|
||||
},
|
||||
],
|
||||
[
|
||||
'observerPromiseWithResolvedValue',
|
||||
{ priority: 10, callback: observerPromiseWithResolvedValue },
|
||||
],
|
||||
[
|
||||
'observerSuccessType',
|
||||
{
|
||||
priority: 10,
|
||||
callback: jest.fn().mockReturnValue( { type: 'success' } ),
|
||||
},
|
||||
],
|
||||
] );
|
||||
} );
|
||||
describe( 'Testing emitEvent()', () => {
|
||||
it( 'invokes all observers', async () => {
|
||||
const observers = { test: observerMocks };
|
||||
const response = await emitEvent( observers, 'test', 'foo' );
|
||||
expect( console ).toHaveErroredWith( 'an error' );
|
||||
expect( observerA ).toHaveBeenCalledTimes( 1 );
|
||||
expect( observerB ).toHaveBeenCalledWith( 'foo' );
|
||||
expect( response ).toEqual( [ { type: 'success' } ] );
|
||||
} );
|
||||
} );
|
||||
describe( 'Testing emitEventWithAbort()', () => {
|
||||
it( 'does not abort on any return value other than an object with an error or fail type property', async () => {
|
||||
observerMocks.delete( 'observerPromiseWithReject' );
|
||||
const observers = { test: observerMocks };
|
||||
const response = await emitEventWithAbort(
|
||||
observers,
|
||||
'test',
|
||||
'foo'
|
||||
);
|
||||
expect( console ).not.toHaveErrored();
|
||||
expect( observerB ).toHaveBeenCalledTimes( 1 );
|
||||
expect( observerPromiseWithResolvedValue ).toHaveBeenCalled();
|
||||
expect( response ).toEqual( [ { type: 'success' } ] );
|
||||
} );
|
||||
it( 'Aborts on a return value with an object that has a a fail type property', async () => {
|
||||
const validObjectResponse = jest
|
||||
.fn()
|
||||
.mockReturnValue( { type: 'failure' } );
|
||||
observerMocks.set( 'observerValidObject', {
|
||||
priority: 5,
|
||||
callback: validObjectResponse,
|
||||
} );
|
||||
const observers = { test: observerMocks };
|
||||
const response = await emitEventWithAbort(
|
||||
observers,
|
||||
'test',
|
||||
'foo'
|
||||
);
|
||||
expect( console ).not.toHaveErrored();
|
||||
expect( validObjectResponse ).toHaveBeenCalledTimes( 1 );
|
||||
expect( observerPromiseWithResolvedValue ).not.toHaveBeenCalled();
|
||||
expect( response ).toEqual( [ { type: 'failure' } ] );
|
||||
} );
|
||||
it( 'throws an error on an object returned from observer without a type property', async () => {
|
||||
const failingObjectResponse = jest.fn().mockReturnValue( {} );
|
||||
observerMocks.set( 'observerInvalidObject', {
|
||||
priority: 5,
|
||||
callback: failingObjectResponse,
|
||||
} );
|
||||
const observers = { test: observerMocks };
|
||||
const response = await emitEventWithAbort(
|
||||
observers,
|
||||
'test',
|
||||
'foo'
|
||||
);
|
||||
expect( console ).toHaveErrored();
|
||||
expect( failingObjectResponse ).toHaveBeenCalledTimes( 1 );
|
||||
expect( observerPromiseWithResolvedValue ).not.toHaveBeenCalled();
|
||||
expect( response ).toEqual( [ { type: 'error' } ] );
|
||||
} );
|
||||
} );
|
||||
describe( 'Test Priority', () => {
|
||||
it( 'executes observers in expected order by priority', async () => {
|
||||
const a = jest.fn();
|
||||
const b = jest.fn().mockReturnValue( { type: 'error' } );
|
||||
const observers = {
|
||||
test: new Map( [
|
||||
[ 'observerA', { priority: 200, callback: a } ],
|
||||
[ 'observerB', { priority: 10, callback: b } ],
|
||||
] ),
|
||||
};
|
||||
await emitEventWithAbort( observers, 'test', 'foo' );
|
||||
expect( console ).not.toHaveErrored();
|
||||
expect( b ).toHaveBeenCalledTimes( 1 );
|
||||
expect( a ).not.toHaveBeenCalled();
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,18 @@
|
||||
export enum ACTION {
|
||||
ADD_EVENT_CALLBACK = 'add_event_callback',
|
||||
REMOVE_EVENT_CALLBACK = 'remove_event_callback',
|
||||
}
|
||||
|
||||
export type ActionCallbackType = ( ...args: unknown[] ) => unknown;
|
||||
|
||||
export type ActionType = {
|
||||
type: ACTION;
|
||||
eventType: string;
|
||||
id: string;
|
||||
callback?: ActionCallbackType;
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
export type ObserverType = { priority: number; callback: ActionCallbackType };
|
||||
export type ObserversType = Map< string, ObserverType >;
|
||||
export type EventObserversType = Record< string, ObserversType >;
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { FieldValidationStatus, isObject } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { EventObserversType, ObserverType } from './types';
|
||||
|
||||
export const getObserversByPriority = (
|
||||
observers: EventObserversType,
|
||||
eventType: string
|
||||
): ObserverType[] => {
|
||||
return observers[ eventType ]
|
||||
? Array.from( observers[ eventType ].values() ).sort( ( a, b ) => {
|
||||
return a.priority - b.priority;
|
||||
} )
|
||||
: [];
|
||||
};
|
||||
|
||||
export enum responseTypes {
|
||||
SUCCESS = 'success',
|
||||
FAIL = 'failure',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export enum noticeContexts {
|
||||
CART = 'wc/cart',
|
||||
CHECKOUT = 'wc/checkout',
|
||||
PAYMENTS = 'wc/checkout/payments',
|
||||
EXPRESS_PAYMENTS = 'wc/checkout/express-payments',
|
||||
CONTACT_INFORMATION = 'wc/checkout/contact-information',
|
||||
SHIPPING_ADDRESS = 'wc/checkout/shipping-address',
|
||||
BILLING_ADDRESS = 'wc/checkout/billing-address',
|
||||
SHIPPING_METHODS = 'wc/checkout/shipping-methods',
|
||||
CHECKOUT_ACTIONS = 'wc/checkout/checkout-actions',
|
||||
}
|
||||
|
||||
export interface ResponseType extends Record< string, unknown > {
|
||||
type: responseTypes;
|
||||
retry?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observers of checkout/cart events can return a response object to indicate success/error/failure. They may also
|
||||
* optionally pass metadata.
|
||||
*/
|
||||
export interface ObserverResponse {
|
||||
type: responseTypes;
|
||||
meta?: Record< string, unknown > | undefined;
|
||||
validationErrors?: Record< string, FieldValidationStatus > | undefined;
|
||||
}
|
||||
|
||||
const isResponseOf = (
|
||||
response: unknown,
|
||||
type: string
|
||||
): response is ResponseType => {
|
||||
return isObject( response ) && 'type' in response && response.type === type;
|
||||
};
|
||||
|
||||
export const isSuccessResponse = (
|
||||
response: unknown
|
||||
): response is ObserverFailResponse => {
|
||||
return isResponseOf( response, responseTypes.SUCCESS );
|
||||
};
|
||||
interface ObserverSuccessResponse extends ObserverResponse {
|
||||
type: responseTypes.SUCCESS;
|
||||
}
|
||||
export const isErrorResponse = (
|
||||
response: unknown
|
||||
): response is ObserverSuccessResponse => {
|
||||
return isResponseOf( response, responseTypes.ERROR );
|
||||
};
|
||||
interface ObserverErrorResponse extends ObserverResponse {
|
||||
type: responseTypes.ERROR;
|
||||
}
|
||||
|
||||
interface ObserverFailResponse extends ObserverResponse {
|
||||
type: responseTypes.FAIL;
|
||||
}
|
||||
export const isFailResponse = (
|
||||
response: unknown
|
||||
): response is ObserverErrorResponse => {
|
||||
return isResponseOf( response, responseTypes.FAIL );
|
||||
};
|
||||
|
||||
export const shouldRetry = ( response: unknown ): boolean => {
|
||||
return (
|
||||
! isObject( response ) ||
|
||||
typeof response.retry === 'undefined' ||
|
||||
response.retry === true
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './use-store-cart';
|
||||
export * from './use-store-cart-coupons';
|
||||
export * from './use-store-cart-item-quantity';
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { CART_STORE_KEY, CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import * as mockUseStoreCart from '../use-store-cart';
|
||||
import { useStoreCartItemQuantity } from '../use-store-cart-item-quantity';
|
||||
import { config as checkoutStoreConfig } from '../../../../../data/checkout';
|
||||
|
||||
jest.mock( '../use-store-cart', () => ( {
|
||||
useStoreCart: jest.fn(),
|
||||
} ) );
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
__esModule: true,
|
||||
CART_STORE_KEY: 'test/cart/store',
|
||||
CHECKOUT_STORE_KEY: 'test/checkout/store',
|
||||
} ) );
|
||||
|
||||
// Make debounce instantaneous.
|
||||
jest.mock( 'use-debounce', () => ( {
|
||||
useDebounce: ( a ) => [ a ],
|
||||
} ) );
|
||||
|
||||
describe( 'useStoreCartItemQuantity', () => {
|
||||
let registry, renderer;
|
||||
|
||||
const getWrappedComponents = ( Component ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent = ( options ) => () => {
|
||||
const props = useStoreCartItemQuantity( options );
|
||||
return <div { ...props } />;
|
||||
};
|
||||
|
||||
let mockRemoveItemFromCart;
|
||||
let mockChangeCartItemQuantity;
|
||||
const setupMocks = ( { isPendingDelete, isPendingQuantity } ) => {
|
||||
// Register mock cart store
|
||||
mockRemoveItemFromCart = jest
|
||||
.fn()
|
||||
.mockReturnValue( { type: 'removeItemFromCartAction' } );
|
||||
mockChangeCartItemQuantity = jest
|
||||
.fn()
|
||||
.mockReturnValue( { type: 'changeCartItemQuantityAction' } );
|
||||
|
||||
registry.registerStore( CART_STORE_KEY, {
|
||||
reducer: () => ( {} ),
|
||||
actions: {
|
||||
removeItemFromCart: mockRemoveItemFromCart,
|
||||
changeCartItemQuantity: mockChangeCartItemQuantity,
|
||||
},
|
||||
selectors: {
|
||||
isItemPendingDelete: jest
|
||||
.fn()
|
||||
.mockReturnValue( isPendingDelete ),
|
||||
isItemPendingQuantity: jest
|
||||
.fn()
|
||||
.mockReturnValue( isPendingQuantity ),
|
||||
},
|
||||
} );
|
||||
|
||||
// Register actual checkout store
|
||||
registry.registerStore( CHECKOUT_STORE_KEY, checkoutStoreConfig );
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry();
|
||||
renderer = null;
|
||||
} );
|
||||
|
||||
afterEach( () => {
|
||||
mockRemoveItemFromCart.mockReset();
|
||||
mockChangeCartItemQuantity.mockReset();
|
||||
} );
|
||||
|
||||
describe( 'with no errors and not pending', () => {
|
||||
beforeEach( () => {
|
||||
setupMocks( { isPendingDelete: false, isPendingQuantity: false } );
|
||||
mockUseStoreCart.useStoreCart.mockReturnValue( {
|
||||
cartErrors: {},
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'update quantity value should happen instantly', () => {
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { setItemQuantity, quantity } =
|
||||
renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
|
||||
|
||||
expect( quantity ).toBe( 1 );
|
||||
|
||||
act( () => {
|
||||
setItemQuantity( 2 );
|
||||
} );
|
||||
|
||||
const { quantity: newQuantity } =
|
||||
renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
|
||||
|
||||
expect( newQuantity ).toBe( 2 );
|
||||
} );
|
||||
|
||||
it( 'removeItem should call the dispatch action', () => {
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { removeItem } = renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
|
||||
|
||||
act( () => {
|
||||
removeItem();
|
||||
} );
|
||||
|
||||
expect( mockRemoveItemFromCart ).toHaveBeenCalledWith( '123' );
|
||||
} );
|
||||
|
||||
it( 'setItemQuantity should call the dispatch action', () => {
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { setItemQuantity } = renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
|
||||
|
||||
act( () => {
|
||||
setItemQuantity( 2 );
|
||||
} );
|
||||
|
||||
expect( mockChangeCartItemQuantity.mock.calls ).toEqual( [
|
||||
[ '123', 2 ],
|
||||
] );
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'should expose store errors', () => {
|
||||
const mockCartErrors = [ { message: 'Test error' } ];
|
||||
setupMocks( {
|
||||
isPendingDelete: false,
|
||||
isPendingQuantity: false,
|
||||
} );
|
||||
mockUseStoreCart.useStoreCart.mockReturnValue( {
|
||||
cartErrors: mockCartErrors,
|
||||
} );
|
||||
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { cartItemQuantityErrors } =
|
||||
renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
|
||||
|
||||
expect( cartItemQuantityErrors ).toEqual( mockCartErrors );
|
||||
} );
|
||||
|
||||
it( 'isPendingDelete should depend on the value provided by the store', () => {
|
||||
setupMocks( {
|
||||
isPendingDelete: true,
|
||||
isPendingQuantity: false,
|
||||
} );
|
||||
mockUseStoreCart.useStoreCart.mockReturnValue( {
|
||||
cartErrors: {},
|
||||
} );
|
||||
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { isPendingDelete } = renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
|
||||
|
||||
expect( isPendingDelete ).toBe( true );
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { previewCart } from '@woocommerce/resource-previews';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { defaultCartData, useStoreCart } from '../use-store-cart';
|
||||
import { useEditorContext } from '../../../providers/editor-context';
|
||||
|
||||
jest.mock( '../../../providers/editor-context', () => ( {
|
||||
useEditorContext: jest.fn(),
|
||||
} ) );
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
...jest.requireActual( '@woocommerce/block-data' ),
|
||||
__esModule: true,
|
||||
CART_STORE_KEY: 'test/store',
|
||||
} ) );
|
||||
|
||||
describe( 'useStoreCart', () => {
|
||||
let registry, renderer;
|
||||
|
||||
const receiveCartMock = () => {};
|
||||
const receiveCartContentsMock = () => {};
|
||||
|
||||
const previewCartData = {
|
||||
cartCoupons: previewCart.coupons,
|
||||
cartItems: previewCart.items,
|
||||
crossSellsProducts: previewCart.cross_sells,
|
||||
cartFees: previewCart.fees,
|
||||
cartItemsCount: previewCart.items_count,
|
||||
cartItemsWeight: previewCart.items_weight,
|
||||
cartNeedsPayment: previewCart.needs_payment,
|
||||
cartNeedsShipping: previewCart.needs_shipping,
|
||||
cartTotals: previewCart.totals,
|
||||
cartIsLoading: false,
|
||||
cartItemErrors: [],
|
||||
cartErrors: [],
|
||||
billingData: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
},
|
||||
billingAddress: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
},
|
||||
shippingAddress: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
phone: '',
|
||||
},
|
||||
shippingRates: previewCart.shipping_rates,
|
||||
extensions: {},
|
||||
isLoadingRates: false,
|
||||
cartHasCalculatedShipping: true,
|
||||
};
|
||||
|
||||
const mockCartItems = [ { key: '1', id: 1, name: 'Lorem Ipsum' } ];
|
||||
const mockShippingAddress = {
|
||||
city: 'New York',
|
||||
};
|
||||
const mockCartData = {
|
||||
coupons: [],
|
||||
items: mockCartItems,
|
||||
fees: [],
|
||||
itemsCount: 1,
|
||||
itemsWeight: 10,
|
||||
needsPayment: true,
|
||||
needsShipping: true,
|
||||
billingAddress: {},
|
||||
shippingAddress: mockShippingAddress,
|
||||
shippingRates: [],
|
||||
hasCalculatedShipping: true,
|
||||
extensions: {},
|
||||
errors: [],
|
||||
paymentRequirements: [],
|
||||
receiveCart: undefined,
|
||||
receiveCartContents: undefined,
|
||||
};
|
||||
const mockCartTotals = {
|
||||
currency_code: 'USD',
|
||||
};
|
||||
const mockCartIsLoading = false;
|
||||
const mockCartErrors = [];
|
||||
const mockStoreCartData = {
|
||||
cartCoupons: [],
|
||||
cartItems: mockCartItems,
|
||||
cartItemErrors: [],
|
||||
cartItemsCount: 1,
|
||||
cartItemsWeight: 10,
|
||||
cartNeedsPayment: true,
|
||||
cartNeedsShipping: true,
|
||||
cartTotals: mockCartTotals,
|
||||
cartIsLoading: mockCartIsLoading,
|
||||
cartErrors: mockCartErrors,
|
||||
cartFees: [],
|
||||
billingData: {},
|
||||
billingAddress: {},
|
||||
shippingAddress: mockShippingAddress,
|
||||
shippingRates: [],
|
||||
extensions: {},
|
||||
isLoadingRates: false,
|
||||
cartHasCalculatedShipping: true,
|
||||
paymentRequirements: [],
|
||||
receiveCart: undefined,
|
||||
receiveCartContents: undefined,
|
||||
};
|
||||
|
||||
const getWrappedComponents = ( Component ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent = ( options ) => () => {
|
||||
const { receiveCart, receiveCartContents, ...results } =
|
||||
useStoreCart( options );
|
||||
return (
|
||||
<div
|
||||
data-results={ results }
|
||||
data-receiveCart={ receiveCart }
|
||||
data-receiveCartContents={ receiveCartContents }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const setUpMocks = () => {
|
||||
const mocks = {
|
||||
selectors: {
|
||||
getCartData: jest.fn().mockReturnValue( mockCartData ),
|
||||
getCartErrors: jest.fn().mockReturnValue( mockCartErrors ),
|
||||
getCartTotals: jest.fn().mockReturnValue( mockCartTotals ),
|
||||
hasFinishedResolution: jest
|
||||
.fn()
|
||||
.mockReturnValue( ! mockCartIsLoading ),
|
||||
isCustomerDataUpdating: jest.fn().mockReturnValue( false ),
|
||||
},
|
||||
};
|
||||
registry.registerStore( storeKey, {
|
||||
reducer: () => ( {} ),
|
||||
selectors: mocks.selectors,
|
||||
} );
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry();
|
||||
renderer = null;
|
||||
setUpMocks();
|
||||
} );
|
||||
|
||||
afterEach( () => {
|
||||
useEditorContext.mockReset();
|
||||
} );
|
||||
|
||||
describe( 'in frontend', () => {
|
||||
beforeEach( () => {
|
||||
useEditorContext.mockReturnValue( {
|
||||
isEditor: false,
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'return default data when shouldSelect is false', () => {
|
||||
const TestComponent = getTestComponent( {
|
||||
shouldSelect: false,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const props = renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
|
||||
const results = props[ 'data-results' ];
|
||||
const receiveCart = props[ 'data-receiveCart' ];
|
||||
const receiveCartContents = props[ 'data-receiveCartContents' ];
|
||||
|
||||
const {
|
||||
receiveCart: defaultReceiveCart,
|
||||
receiveCartContents: defaultReceiveCartContents,
|
||||
...remaining
|
||||
} = defaultCartData;
|
||||
expect( results ).toEqual( remaining );
|
||||
expect( receiveCart ).toEqual( defaultReceiveCart );
|
||||
expect( receiveCartContents ).toEqual( defaultReceiveCartContents );
|
||||
} );
|
||||
|
||||
it( 'return store data when shouldSelect is true', () => {
|
||||
const TestComponent = getTestComponent( {
|
||||
shouldSelect: true,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const props = renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
|
||||
const results = props[ 'data-results' ];
|
||||
const receiveCart = props[ 'data-receiveCart' ];
|
||||
const receiveCartContents = props[ 'data-receiveCartContents' ];
|
||||
|
||||
expect( results ).toEqual( mockStoreCartData );
|
||||
expect( receiveCart ).toBeUndefined();
|
||||
expect( receiveCartContents ).toBeUndefined();
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'in editor', () => {
|
||||
beforeEach( () => {
|
||||
useEditorContext.mockReturnValue( {
|
||||
isEditor: true,
|
||||
previewData: {
|
||||
previewCart: {
|
||||
...previewCart,
|
||||
receiveCart: receiveCartMock,
|
||||
receiveCartContents: receiveCartContentsMock,
|
||||
},
|
||||
},
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'return preview data in editor', () => {
|
||||
const TestComponent = getTestComponent();
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const props = renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
|
||||
const results = props[ 'data-results' ];
|
||||
const receiveCart = props[ 'data-receiveCart' ];
|
||||
const receiveCartContents = props[ 'data-receiveCartContents' ];
|
||||
|
||||
expect( results ).toEqual( previewCartData );
|
||||
expect( receiveCart ).toEqual( receiveCartMock );
|
||||
expect( receiveCartContents ).toEqual( receiveCartContentsMock );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { CART_STORE_KEY, VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import type { StoreCartCoupon } from '@woocommerce/types';
|
||||
import { applyCheckoutFilter } from '@woocommerce/blocks-checkout';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from './use-store-cart';
|
||||
|
||||
/**
|
||||
* This is a custom hook for loading the Store API /cart/coupons endpoint and an
|
||||
* action for adding a coupon _to_ the cart.
|
||||
* See also: https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/trunk/src/RestApi/StoreApi
|
||||
*/
|
||||
export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => {
|
||||
const { cartCoupons, cartIsLoading } = useStoreCart();
|
||||
const { createErrorNotice } = useDispatch( 'core/notices' );
|
||||
const { createNotice } = useDispatch( 'core/notices' );
|
||||
const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
|
||||
|
||||
const {
|
||||
isApplyingCoupon,
|
||||
isRemovingCoupon,
|
||||
}: Pick< StoreCartCoupon, 'isApplyingCoupon' | 'isRemovingCoupon' > =
|
||||
useSelect(
|
||||
( select ) => {
|
||||
const store = select( CART_STORE_KEY );
|
||||
|
||||
return {
|
||||
isApplyingCoupon: store.isApplyingCoupon(),
|
||||
isRemovingCoupon: store.isRemovingCoupon(),
|
||||
};
|
||||
},
|
||||
[ createErrorNotice, createNotice ]
|
||||
);
|
||||
|
||||
const { applyCoupon, removeCoupon } = useDispatch( CART_STORE_KEY );
|
||||
|
||||
const applyCouponWithNotices = ( couponCode: string ) => {
|
||||
return applyCoupon( couponCode )
|
||||
.then( () => {
|
||||
if (
|
||||
applyCheckoutFilter( {
|
||||
filterName: 'showApplyCouponNotice',
|
||||
defaultValue: true,
|
||||
arg: { couponCode, context },
|
||||
} )
|
||||
) {
|
||||
createNotice(
|
||||
'info',
|
||||
sprintf(
|
||||
/* translators: %s coupon code. */
|
||||
__(
|
||||
'Coupon code "%s" has been applied to your cart.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
couponCode
|
||||
),
|
||||
{
|
||||
id: 'coupon-form',
|
||||
type: 'snackbar',
|
||||
context,
|
||||
}
|
||||
);
|
||||
}
|
||||
return Promise.resolve( true );
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
setValidationErrors( {
|
||||
coupon: {
|
||||
message: decodeEntities( error.message ),
|
||||
hidden: false,
|
||||
},
|
||||
} );
|
||||
return Promise.resolve( false );
|
||||
} );
|
||||
};
|
||||
|
||||
const removeCouponWithNotices = ( couponCode: string ) => {
|
||||
return removeCoupon( couponCode )
|
||||
.then( () => {
|
||||
if (
|
||||
applyCheckoutFilter( {
|
||||
filterName: 'showRemoveCouponNotice',
|
||||
defaultValue: true,
|
||||
arg: { couponCode, context },
|
||||
} )
|
||||
) {
|
||||
createNotice(
|
||||
'info',
|
||||
sprintf(
|
||||
/* translators: %s coupon code. */
|
||||
__(
|
||||
'Coupon code "%s" has been removed from your cart.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
couponCode
|
||||
),
|
||||
{
|
||||
id: 'coupon-form',
|
||||
type: 'snackbar',
|
||||
context,
|
||||
}
|
||||
);
|
||||
}
|
||||
return Promise.resolve( true );
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
createErrorNotice( error.message, {
|
||||
id: 'coupon-form',
|
||||
context,
|
||||
} );
|
||||
return Promise.resolve( false );
|
||||
} );
|
||||
};
|
||||
|
||||
return {
|
||||
appliedCoupons: cartCoupons,
|
||||
isLoading: cartIsLoading,
|
||||
applyCoupon: applyCouponWithNotices,
|
||||
removeCoupon: removeCouponWithNotices,
|
||||
isApplyingCoupon,
|
||||
isRemovingCoupon,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useEffect } from '@wordpress/element';
|
||||
import { CART_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import {
|
||||
translateJQueryEventToNative,
|
||||
getNavigationType,
|
||||
} from '@woocommerce/base-utils';
|
||||
|
||||
interface StoreCartListenersType {
|
||||
// Counts the number of consumers of this hook so we can remove listeners when no longer needed.
|
||||
count: number;
|
||||
// Function to remove all registered listeners.
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
interface CartDataCustomEvent extends Event {
|
||||
detail?:
|
||||
| {
|
||||
preserveCartData?: boolean | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wcBlocksStoreCartListeners: StoreCartListenersType;
|
||||
}
|
||||
}
|
||||
|
||||
const refreshData = ( event: CartDataCustomEvent ): void => {
|
||||
const eventDetail = event?.detail;
|
||||
if ( ! eventDetail || ! eventDetail.preserveCartData ) {
|
||||
dispatch( CART_STORE_KEY ).invalidateResolutionForStore();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Refreshes data if the pageshow event is triggered by the browser history.
|
||||
*
|
||||
* - In Chrome, `back_forward` will be returned by getNavigationType() when the browser history is used.
|
||||
* - In safari we instead need to use `event.persisted` which is true when page cache is used.
|
||||
*/
|
||||
const refreshCachedCartData = ( event: PageTransitionEvent ): void => {
|
||||
if ( event?.persisted || getNavigationType() === 'back_forward' ) {
|
||||
dispatch( CART_STORE_KEY ).invalidateResolutionForStore();
|
||||
}
|
||||
};
|
||||
|
||||
const setUp = (): void => {
|
||||
if ( ! window.wcBlocksStoreCartListeners ) {
|
||||
window.wcBlocksStoreCartListeners = {
|
||||
count: 0,
|
||||
remove: () => void null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Checks if there are any listeners registered.
|
||||
const hasListeners = (): boolean => {
|
||||
return window.wcBlocksStoreCartListeners?.count > 0;
|
||||
};
|
||||
|
||||
// Add listeners if there are none, otherwise just increment the count.
|
||||
const addListeners = (): void => {
|
||||
setUp();
|
||||
|
||||
if ( hasListeners() ) {
|
||||
window.wcBlocksStoreCartListeners.count++;
|
||||
return;
|
||||
}
|
||||
document.body.addEventListener( 'wc-blocks_added_to_cart', refreshData );
|
||||
document.body.addEventListener(
|
||||
'wc-blocks_removed_from_cart',
|
||||
refreshData
|
||||
);
|
||||
window.addEventListener( 'pageshow', refreshCachedCartData );
|
||||
|
||||
const removeJQueryAddedToCartEvent = translateJQueryEventToNative(
|
||||
'added_to_cart',
|
||||
`wc-blocks_added_to_cart`
|
||||
) as () => () => void;
|
||||
const removeJQueryRemovedFromCartEvent = translateJQueryEventToNative(
|
||||
'removed_from_cart',
|
||||
`wc-blocks_removed_from_cart`
|
||||
) as () => () => void;
|
||||
|
||||
window.wcBlocksStoreCartListeners.count = 1;
|
||||
window.wcBlocksStoreCartListeners.remove = () => {
|
||||
document.body.removeEventListener(
|
||||
'wc-blocks_added_to_cart',
|
||||
refreshData
|
||||
);
|
||||
document.body.removeEventListener(
|
||||
'wc-blocks_removed_from_cart',
|
||||
refreshData
|
||||
);
|
||||
window.removeEventListener( 'pageshow', refreshCachedCartData );
|
||||
removeJQueryAddedToCartEvent();
|
||||
removeJQueryRemovedFromCartEvent();
|
||||
};
|
||||
};
|
||||
|
||||
const removeListeners = (): void => {
|
||||
if ( window.wcBlocksStoreCartListeners.count === 1 ) {
|
||||
window.wcBlocksStoreCartListeners.remove();
|
||||
}
|
||||
window.wcBlocksStoreCartListeners.count--;
|
||||
};
|
||||
|
||||
/**
|
||||
* This will keep track of jQuery and DOM events that invalidate the store resolution.
|
||||
*/
|
||||
export const useStoreCartEventListeners = (): void => {
|
||||
useEffect( () => {
|
||||
addListeners();
|
||||
return removeListeners;
|
||||
}, [] );
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
import { useCallback, useState, useEffect } from '@wordpress/element';
|
||||
import {
|
||||
CART_STORE_KEY,
|
||||
CHECKOUT_STORE_KEY,
|
||||
processErrorResponse,
|
||||
} from '@woocommerce/block-data';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { usePrevious } from '@woocommerce/base-hooks';
|
||||
import {
|
||||
CartItem,
|
||||
StoreCartItemQuantity,
|
||||
isNumber,
|
||||
isObject,
|
||||
isString,
|
||||
objectHasProp,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from './use-store-cart';
|
||||
|
||||
/**
|
||||
* Ensures the object passed has props key: string and quantity: number
|
||||
*/
|
||||
const cartItemHasQuantityAndKey = (
|
||||
cartItem: unknown /* Object that may have quantity and key */
|
||||
): cartItem is Partial< CartItem > =>
|
||||
isObject( cartItem ) &&
|
||||
objectHasProp( cartItem, 'key' ) &&
|
||||
objectHasProp( cartItem, 'quantity' ) &&
|
||||
isString( cartItem.key ) &&
|
||||
isNumber( cartItem.quantity );
|
||||
|
||||
/**
|
||||
* This is a custom hook for loading the Store API /cart/ endpoint and actions for removing or changing item quantity.
|
||||
*
|
||||
* @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/trunk/src/RestApi/StoreApi
|
||||
*
|
||||
* @param {CartItem} cartItem The cartItem to get quantity info from and will have quantity updated on.
|
||||
* @return {StoreCartItemQuantity} An object exposing data and actions relating to cart items.
|
||||
*/
|
||||
export const useStoreCartItemQuantity = (
|
||||
cartItem: CartItem | Record< string, unknown >
|
||||
): StoreCartItemQuantity => {
|
||||
const verifiedCartItem = { key: '', quantity: 1 };
|
||||
|
||||
if ( cartItemHasQuantityAndKey( cartItem ) ) {
|
||||
verifiedCartItem.key = cartItem.key;
|
||||
verifiedCartItem.quantity = cartItem.quantity;
|
||||
}
|
||||
const { key: cartItemKey = '', quantity: cartItemQuantity = 1 } =
|
||||
verifiedCartItem;
|
||||
const { cartErrors } = useStoreCart();
|
||||
const { __internalIncrementCalculating, __internalDecrementCalculating } =
|
||||
useDispatch( CHECKOUT_STORE_KEY );
|
||||
|
||||
// Store quantity in hook state. This is used to keep the UI updated while server request is updated.
|
||||
const [ quantity, setQuantity ] = useState< number >( cartItemQuantity );
|
||||
const [ debouncedQuantity ] = useDebounce< number >( quantity, 400 );
|
||||
const previousDebouncedQuantity = usePrevious( debouncedQuantity );
|
||||
const { removeItemFromCart, changeCartItemQuantity } =
|
||||
useDispatch( CART_STORE_KEY );
|
||||
|
||||
// Update local state when server updates.
|
||||
useEffect( () => setQuantity( cartItemQuantity ), [ cartItemQuantity ] );
|
||||
|
||||
// Track when things are already pending updates.
|
||||
const isPending = useSelect(
|
||||
( select ) => {
|
||||
if ( ! cartItemKey ) {
|
||||
return {
|
||||
quantity: false,
|
||||
delete: false,
|
||||
};
|
||||
}
|
||||
const store = select( CART_STORE_KEY );
|
||||
return {
|
||||
quantity: store.isItemPendingQuantity( cartItemKey ),
|
||||
delete: store.isItemPendingDelete( cartItemKey ),
|
||||
};
|
||||
},
|
||||
[ cartItemKey ]
|
||||
);
|
||||
|
||||
const removeItem = useCallback( () => {
|
||||
if ( cartItemKey ) {
|
||||
return removeItemFromCart( cartItemKey ).catch( ( error ) => {
|
||||
processErrorResponse( error );
|
||||
} );
|
||||
}
|
||||
return Promise.resolve( false );
|
||||
}, [ cartItemKey, removeItemFromCart ] );
|
||||
|
||||
// Observe debounced quantity value, fire action to update server on change.
|
||||
useEffect( () => {
|
||||
if (
|
||||
cartItemKey &&
|
||||
isNumber( previousDebouncedQuantity ) &&
|
||||
Number.isFinite( previousDebouncedQuantity ) &&
|
||||
previousDebouncedQuantity !== debouncedQuantity
|
||||
) {
|
||||
changeCartItemQuantity( cartItemKey, debouncedQuantity ).catch(
|
||||
( error ) => {
|
||||
processErrorResponse( error );
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [
|
||||
cartItemKey,
|
||||
changeCartItemQuantity,
|
||||
debouncedQuantity,
|
||||
previousDebouncedQuantity,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( isPending.delete ) {
|
||||
__internalIncrementCalculating();
|
||||
} else {
|
||||
__internalDecrementCalculating();
|
||||
}
|
||||
return () => {
|
||||
if ( isPending.delete ) {
|
||||
__internalDecrementCalculating();
|
||||
}
|
||||
};
|
||||
}, [
|
||||
__internalDecrementCalculating,
|
||||
__internalIncrementCalculating,
|
||||
isPending.delete,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( isPending.quantity || debouncedQuantity !== quantity ) {
|
||||
__internalIncrementCalculating();
|
||||
} else {
|
||||
__internalDecrementCalculating();
|
||||
}
|
||||
return () => {
|
||||
if ( isPending.quantity || debouncedQuantity !== quantity ) {
|
||||
__internalDecrementCalculating();
|
||||
}
|
||||
};
|
||||
}, [
|
||||
__internalIncrementCalculating,
|
||||
__internalDecrementCalculating,
|
||||
isPending.quantity,
|
||||
debouncedQuantity,
|
||||
quantity,
|
||||
] );
|
||||
|
||||
return {
|
||||
isPendingDelete: isPending.delete,
|
||||
quantity,
|
||||
setItemQuantity: setQuantity,
|
||||
removeItem,
|
||||
cartItemQuantityErrors: cartErrors,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
/** @typedef { import('@woocommerce/type-defs/hooks').StoreCart } StoreCart */
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import fastDeepEqual from 'fast-deep-equal/es6';
|
||||
import { useRef } from '@wordpress/element';
|
||||
import {
|
||||
CART_STORE_KEY as storeKey,
|
||||
EMPTY_CART_COUPONS,
|
||||
EMPTY_CART_ITEMS,
|
||||
EMPTY_CART_CROSS_SELLS,
|
||||
EMPTY_CART_FEES,
|
||||
EMPTY_CART_ITEM_ERRORS,
|
||||
EMPTY_CART_ERRORS,
|
||||
EMPTY_SHIPPING_RATES,
|
||||
EMPTY_TAX_LINES,
|
||||
EMPTY_PAYMENT_METHODS,
|
||||
EMPTY_PAYMENT_REQUIREMENTS,
|
||||
EMPTY_EXTENSIONS,
|
||||
} from '@woocommerce/block-data';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import type {
|
||||
StoreCart,
|
||||
CartResponseTotals,
|
||||
CartResponseFeeItem,
|
||||
CartResponseBillingAddress,
|
||||
CartResponseShippingAddress,
|
||||
CartResponseCouponItem,
|
||||
CartResponseCoupons,
|
||||
} from '@woocommerce/types';
|
||||
import { emptyHiddenAddressFields } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useEditorContext } from '../../providers/editor-context';
|
||||
import { useStoreCartEventListeners } from './use-store-cart-event-listeners';
|
||||
|
||||
declare module '@wordpress/html-entities' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
export function decodeEntities< T >( coupon: T ): T;
|
||||
}
|
||||
const defaultShippingAddress: CartResponseShippingAddress = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
phone: '',
|
||||
};
|
||||
|
||||
const defaultBillingAddress: CartResponseBillingAddress = {
|
||||
...defaultShippingAddress,
|
||||
email: '',
|
||||
};
|
||||
|
||||
const defaultCartTotals: CartResponseTotals = {
|
||||
total_items: '',
|
||||
total_items_tax: '',
|
||||
total_fees: '',
|
||||
total_fees_tax: '',
|
||||
total_discount: '',
|
||||
total_discount_tax: '',
|
||||
total_shipping: '',
|
||||
total_shipping_tax: '',
|
||||
total_price: '',
|
||||
total_tax: '',
|
||||
tax_lines: EMPTY_TAX_LINES,
|
||||
currency_code: '',
|
||||
currency_symbol: '',
|
||||
currency_minor_unit: 2,
|
||||
currency_decimal_separator: '',
|
||||
currency_thousand_separator: '',
|
||||
currency_prefix: '',
|
||||
currency_suffix: '',
|
||||
};
|
||||
|
||||
const decodeValues = (
|
||||
object: Record< string, unknown >
|
||||
): Record< string, unknown > =>
|
||||
Object.fromEntries(
|
||||
Object.entries( object ).map( ( [ key, value ] ) => [
|
||||
key,
|
||||
decodeEntities( value ),
|
||||
] )
|
||||
);
|
||||
|
||||
/**
|
||||
* @constant
|
||||
* @type {StoreCart} Object containing cart data.
|
||||
*/
|
||||
export const defaultCartData: StoreCart = {
|
||||
cartCoupons: EMPTY_CART_COUPONS,
|
||||
cartItems: EMPTY_CART_ITEMS,
|
||||
cartFees: EMPTY_CART_FEES,
|
||||
cartItemsCount: 0,
|
||||
cartItemsWeight: 0,
|
||||
crossSellsProducts: EMPTY_CART_CROSS_SELLS,
|
||||
cartNeedsPayment: true,
|
||||
cartNeedsShipping: true,
|
||||
cartItemErrors: EMPTY_CART_ITEM_ERRORS,
|
||||
cartTotals: defaultCartTotals,
|
||||
cartIsLoading: true,
|
||||
cartErrors: EMPTY_CART_ERRORS,
|
||||
billingAddress: defaultBillingAddress,
|
||||
shippingAddress: defaultShippingAddress,
|
||||
shippingRates: EMPTY_SHIPPING_RATES,
|
||||
isLoadingRates: false,
|
||||
cartHasCalculatedShipping: false,
|
||||
paymentMethods: EMPTY_PAYMENT_METHODS,
|
||||
paymentRequirements: EMPTY_PAYMENT_REQUIREMENTS,
|
||||
receiveCart: () => undefined,
|
||||
receiveCartContents: () => undefined,
|
||||
extensions: EMPTY_EXTENSIONS,
|
||||
};
|
||||
|
||||
/**
|
||||
* This is a custom hook that is wired up to the `wc/store/cart` data
|
||||
* store.
|
||||
*
|
||||
* @param {Object} options An object declaring the various
|
||||
* collection arguments.
|
||||
* @param {boolean} options.shouldSelect If false, the previous results will be
|
||||
* returned and internal selects will not
|
||||
* fire.
|
||||
*
|
||||
* @return {StoreCart} Object containing cart data.
|
||||
*/
|
||||
|
||||
export const useStoreCart = (
|
||||
options: { shouldSelect: boolean } = { shouldSelect: true }
|
||||
): StoreCart => {
|
||||
const { isEditor, previewData } = useEditorContext();
|
||||
const previewCart = previewData?.previewCart;
|
||||
const { shouldSelect } = options;
|
||||
const currentResults = useRef();
|
||||
|
||||
// This will keep track of jQuery and DOM events that invalidate the store resolution.
|
||||
useStoreCartEventListeners();
|
||||
|
||||
const results: StoreCart = useSelect(
|
||||
( select, { dispatch } ) => {
|
||||
if ( ! shouldSelect ) {
|
||||
return defaultCartData;
|
||||
}
|
||||
|
||||
if ( isEditor ) {
|
||||
return {
|
||||
cartCoupons: previewCart.coupons,
|
||||
cartItems: previewCart.items,
|
||||
crossSellsProducts: previewCart.cross_sells,
|
||||
cartFees: previewCart.fees,
|
||||
cartItemsCount: previewCart.items_count,
|
||||
cartItemsWeight: previewCart.items_weight,
|
||||
cartNeedsPayment: previewCart.needs_payment,
|
||||
cartNeedsShipping: previewCart.needs_shipping,
|
||||
cartItemErrors: EMPTY_CART_ITEM_ERRORS,
|
||||
cartTotals: previewCart.totals,
|
||||
cartIsLoading: false,
|
||||
cartErrors: EMPTY_CART_ERRORS,
|
||||
billingData: defaultBillingAddress,
|
||||
billingAddress: defaultBillingAddress,
|
||||
shippingAddress: defaultShippingAddress,
|
||||
extensions: EMPTY_EXTENSIONS,
|
||||
shippingRates: previewCart.shipping_rates,
|
||||
isLoadingRates: false,
|
||||
cartHasCalculatedShipping:
|
||||
previewCart.has_calculated_shipping,
|
||||
paymentRequirements: previewCart.paymentRequirements,
|
||||
receiveCart:
|
||||
typeof previewCart?.receiveCart === 'function'
|
||||
? previewCart.receiveCart
|
||||
: () => undefined,
|
||||
receiveCartContents:
|
||||
typeof previewCart?.receiveCartContents === 'function'
|
||||
? previewCart.receiveCartContents
|
||||
: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const store = select( storeKey );
|
||||
const cartData = store.getCartData();
|
||||
const cartErrors = store.getCartErrors();
|
||||
const cartTotals = store.getCartTotals();
|
||||
const cartIsLoading =
|
||||
! store.hasFinishedResolution( 'getCartData' );
|
||||
|
||||
const isLoadingRates = store.isCustomerDataUpdating();
|
||||
const { receiveCart, receiveCartContents } = dispatch( storeKey );
|
||||
const billingAddress = decodeValues( cartData.billingAddress );
|
||||
const shippingAddress = cartData.needsShipping
|
||||
? decodeValues( cartData.shippingAddress )
|
||||
: billingAddress;
|
||||
const cartFees =
|
||||
cartData.fees.length > 0
|
||||
? cartData.fees.map( ( fee: CartResponseFeeItem ) =>
|
||||
decodeValues( fee )
|
||||
)
|
||||
: EMPTY_CART_FEES;
|
||||
|
||||
// Add a text property to the coupon to allow extensions to modify
|
||||
// the text used to display the coupon, without affecting the
|
||||
// functionality when it comes to removing the coupon.
|
||||
const cartCoupons: CartResponseCoupons =
|
||||
cartData.coupons.length > 0
|
||||
? cartData.coupons.map(
|
||||
( coupon: CartResponseCouponItem ) => ( {
|
||||
...coupon,
|
||||
label: coupon.code,
|
||||
} )
|
||||
)
|
||||
: EMPTY_CART_COUPONS;
|
||||
|
||||
return {
|
||||
cartCoupons,
|
||||
cartItems: cartData.items,
|
||||
crossSellsProducts: cartData.crossSells,
|
||||
cartFees,
|
||||
cartItemsCount: cartData.itemsCount,
|
||||
cartItemsWeight: cartData.itemsWeight,
|
||||
cartNeedsPayment: cartData.needsPayment,
|
||||
cartNeedsShipping: cartData.needsShipping,
|
||||
cartItemErrors: cartData.errors,
|
||||
cartTotals,
|
||||
cartIsLoading,
|
||||
cartErrors,
|
||||
billingData: emptyHiddenAddressFields( billingAddress ),
|
||||
billingAddress: emptyHiddenAddressFields( billingAddress ),
|
||||
shippingAddress: emptyHiddenAddressFields( shippingAddress ),
|
||||
extensions: cartData.extensions,
|
||||
shippingRates: cartData.shippingRates,
|
||||
isLoadingRates,
|
||||
cartHasCalculatedShipping: cartData.hasCalculatedShipping,
|
||||
paymentRequirements: cartData.paymentRequirements,
|
||||
receiveCart,
|
||||
receiveCartContents,
|
||||
};
|
||||
},
|
||||
[ shouldSelect ]
|
||||
);
|
||||
|
||||
if (
|
||||
! currentResults.current ||
|
||||
! fastDeepEqual( currentResults.current, results )
|
||||
) {
|
||||
currentResults.current = results;
|
||||
}
|
||||
|
||||
return currentResults.current;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './use-collection-data';
|
||||
export * from './use-collection-header';
|
||||
export * from './use-collection';
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { Component as ReactComponent } from '@wordpress/element';
|
||||
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCollection } from '../use-collection';
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
__esModule: true,
|
||||
COLLECTIONS_STORE_KEY: 'test/store',
|
||||
} ) );
|
||||
|
||||
class TestErrorBoundary extends ReactComponent {
|
||||
constructor( props ) {
|
||||
super( props );
|
||||
this.state = { hasError: false, error: {} };
|
||||
}
|
||||
static getDerivedStateFromError( error ) {
|
||||
// Update state so the next render will show the fallback UI.
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if ( this.state.hasError ) {
|
||||
return <div data-error={ this.state.error } />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
describe( 'useCollection', () => {
|
||||
let registry, mocks, renderer;
|
||||
const getProps = ( testRenderer ) => {
|
||||
const { results, isLoading } =
|
||||
testRenderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
|
||||
return {
|
||||
results,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
|
||||
const getWrappedComponents = ( Component, props ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<TestErrorBoundary>
|
||||
<Component { ...props } />
|
||||
</TestErrorBoundary>
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent =
|
||||
() =>
|
||||
( { options } ) => {
|
||||
const items = useCollection( options );
|
||||
return <div { ...items } />;
|
||||
};
|
||||
|
||||
const setUpMocks = () => {
|
||||
mocks = {
|
||||
selectors: {
|
||||
getCollectionError: jest.fn().mockReturnValue( false ),
|
||||
getCollection: jest
|
||||
.fn()
|
||||
.mockImplementation( () => ( { foo: 'bar' } ) ),
|
||||
hasFinishedResolution: jest.fn().mockReturnValue( true ),
|
||||
},
|
||||
};
|
||||
registry.registerStore( storeKey, {
|
||||
reducer: () => ( {} ),
|
||||
selectors: mocks.selectors,
|
||||
} );
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry();
|
||||
mocks = {};
|
||||
renderer = null;
|
||||
setUpMocks();
|
||||
} );
|
||||
it(
|
||||
'should throw an error if an options object is provided without ' +
|
||||
'a namespace property',
|
||||
() => {
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
resourceName: 'products',
|
||||
query: { bar: 'foo' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
//eslint-disable-next-line testing-library/await-async-query
|
||||
const props = renderer.root.findByType( 'div' ).props;
|
||||
expect( props[ 'data-error' ].message ).toMatch( /options object/ );
|
||||
expect( console ).toHaveErrored( /your React components:/ );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
it(
|
||||
'should throw an error if an options object is provided without ' +
|
||||
'a resourceName property',
|
||||
() => {
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
query: { bar: 'foo' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
//eslint-disable-next-line testing-library/await-async-query
|
||||
const props = renderer.root.findByType( 'div' ).props;
|
||||
expect( props[ 'data-error' ].message ).toMatch( /options object/ );
|
||||
expect( console ).toHaveErrored( /your React components:/ );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
it(
|
||||
'should return expected behaviour for equivalent query on props ' +
|
||||
'across renders',
|
||||
() => {
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
query: { bar: 'foo' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results } = getProps( renderer );
|
||||
// rerender
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
query: { bar: 'foo' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
// re-render should result in same products object because although
|
||||
// query-state is a different instance, it's still equivalent.
|
||||
const { results: newResults } = getProps( renderer );
|
||||
expect( newResults ).toBe( results );
|
||||
// now let's change the query passed through to verify new object
|
||||
// is created.
|
||||
// remember this won't actually change the results because the mock
|
||||
// selector is returning an equivalent object when it is called,
|
||||
// however it SHOULD be a new object instance.
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
query: { foo: 'bar' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results: resultsVerification } = getProps( renderer );
|
||||
expect( resultsVerification ).not.toBe( results );
|
||||
expect( resultsVerification ).toEqual( results );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
it(
|
||||
'should return expected behaviour for equivalent resourceValues on' +
|
||||
' props across renders',
|
||||
() => {
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
resourceValues: [ 10, 20 ],
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results } = getProps( renderer );
|
||||
// rerender
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
resourceValues: [ 10, 20 ],
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
// re-render should result in same products object because although
|
||||
// query-state is a different instance, it's still equivalent.
|
||||
const { results: newResults } = getProps( renderer );
|
||||
expect( newResults ).toBe( results );
|
||||
// now let's change the query passed through to verify new object
|
||||
// is created.
|
||||
// remember this won't actually change the results because the mock
|
||||
// selector is returning an equivalent object when it is called,
|
||||
// however it SHOULD be a new object instance.
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
resourceValues: [ 20, 10 ],
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results: resultsVerification } = getProps( renderer );
|
||||
expect( resultsVerification ).not.toBe( results );
|
||||
expect( resultsVerification ).toEqual( results );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
it( 'should return previous query results if `shouldSelect` is false', () => {
|
||||
mocks.selectors.getCollection.mockImplementation(
|
||||
( state, ...args ) => {
|
||||
return args;
|
||||
}
|
||||
);
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
resourceValues: [ 10, 20 ],
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results } = getProps( renderer );
|
||||
// rerender but with shouldSelect to false
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'productsb',
|
||||
resourceValues: [ 10, 30 ],
|
||||
shouldSelect: false,
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results: results2 } = getProps( renderer );
|
||||
expect( results2 ).toBe( results );
|
||||
// expect 2 calls because internally, useSelect invokes callback twice
|
||||
// on mount.
|
||||
expect( mocks.selectors.getCollection ).toHaveBeenCalledTimes( 2 );
|
||||
|
||||
// rerender again but set shouldSelect to true again and we should see
|
||||
// new results
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'productsb',
|
||||
resourceValues: [ 10, 30 ],
|
||||
shouldSelect: true,
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results: results3 } = getProps( renderer );
|
||||
expect( results3 ).not.toEqual( results );
|
||||
expect( results3 ).toEqual( [
|
||||
'test/store',
|
||||
'productsb',
|
||||
{},
|
||||
[ 10, 30 ],
|
||||
] );
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect, useMemo } from '@wordpress/element';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { objectHasProp } from '@woocommerce/types';
|
||||
import { sort } from 'fast-sort';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useQueryStateByContext, useQueryStateByKey } from '../use-query-state';
|
||||
import { useCollection } from './use-collection';
|
||||
import { useQueryStateContext } from '../../providers/query-state-context';
|
||||
|
||||
const buildCollectionDataQuery = (
|
||||
collectionDataQueryState: Record< string, unknown >
|
||||
) => {
|
||||
const query = collectionDataQueryState;
|
||||
|
||||
if (
|
||||
Array.isArray( collectionDataQueryState.calculate_attribute_counts )
|
||||
) {
|
||||
query.calculate_attribute_counts = sort(
|
||||
collectionDataQueryState.calculate_attribute_counts.map(
|
||||
( { taxonomy, queryType } ) => {
|
||||
return {
|
||||
taxonomy,
|
||||
query_type: queryType,
|
||||
};
|
||||
}
|
||||
)
|
||||
).asc( [ 'taxonomy', 'query_type' ] );
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
interface UseCollectionDataProps {
|
||||
queryAttribute?: {
|
||||
taxonomy: string;
|
||||
queryType: string;
|
||||
};
|
||||
queryPrices?: boolean;
|
||||
queryStock?: boolean;
|
||||
queryRating?: boolean;
|
||||
queryState: Record< string, unknown >;
|
||||
isEditor?: boolean;
|
||||
}
|
||||
|
||||
export const useCollectionData = ( {
|
||||
queryAttribute,
|
||||
queryPrices,
|
||||
queryStock,
|
||||
queryRating,
|
||||
queryState,
|
||||
isEditor = false,
|
||||
}: UseCollectionDataProps ) => {
|
||||
let context = useQueryStateContext();
|
||||
context = `${ context }-collection-data`;
|
||||
|
||||
const [ collectionDataQueryState ] = useQueryStateByContext( context );
|
||||
const [ calculateAttributesQueryState, setCalculateAttributesQueryState ] =
|
||||
useQueryStateByKey( 'calculate_attribute_counts', [], context );
|
||||
const [ calculatePriceRangeQueryState, setCalculatePriceRangeQueryState ] =
|
||||
useQueryStateByKey( 'calculate_price_range', null, context );
|
||||
const [
|
||||
calculateStockStatusQueryState,
|
||||
setCalculateStockStatusQueryState,
|
||||
] = useQueryStateByKey( 'calculate_stock_status_counts', null, context );
|
||||
const [ calculateRatingQueryState, setCalculateRatingQueryState ] =
|
||||
useQueryStateByKey( 'calculate_rating_counts', null, context );
|
||||
|
||||
const currentQueryAttribute = useShallowEqual( queryAttribute || {} );
|
||||
const currentQueryPrices = useShallowEqual( queryPrices );
|
||||
const currentQueryStock = useShallowEqual( queryStock );
|
||||
const currentQueryRating = useShallowEqual( queryRating );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
typeof currentQueryAttribute === 'object' &&
|
||||
Object.keys( currentQueryAttribute ).length
|
||||
) {
|
||||
const foundAttribute = calculateAttributesQueryState.find(
|
||||
( attribute ) => {
|
||||
return (
|
||||
objectHasProp( currentQueryAttribute, 'taxonomy' ) &&
|
||||
attribute.taxonomy === currentQueryAttribute.taxonomy
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if ( ! foundAttribute ) {
|
||||
setCalculateAttributesQueryState( [
|
||||
...calculateAttributesQueryState,
|
||||
currentQueryAttribute,
|
||||
] );
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentQueryAttribute,
|
||||
calculateAttributesQueryState,
|
||||
setCalculateAttributesQueryState,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
calculatePriceRangeQueryState !== currentQueryPrices &&
|
||||
currentQueryPrices !== undefined
|
||||
) {
|
||||
setCalculatePriceRangeQueryState( currentQueryPrices );
|
||||
}
|
||||
}, [
|
||||
currentQueryPrices,
|
||||
setCalculatePriceRangeQueryState,
|
||||
calculatePriceRangeQueryState,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
calculateStockStatusQueryState !== currentQueryStock &&
|
||||
currentQueryStock !== undefined
|
||||
) {
|
||||
setCalculateStockStatusQueryState( currentQueryStock );
|
||||
}
|
||||
}, [
|
||||
currentQueryStock,
|
||||
setCalculateStockStatusQueryState,
|
||||
calculateStockStatusQueryState,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
calculateRatingQueryState !== currentQueryRating &&
|
||||
currentQueryRating !== undefined
|
||||
) {
|
||||
setCalculateRatingQueryState( currentQueryRating );
|
||||
}
|
||||
}, [
|
||||
currentQueryRating,
|
||||
setCalculateRatingQueryState,
|
||||
calculateRatingQueryState,
|
||||
] );
|
||||
|
||||
// Defer the select query so all collection-data query vars can be gathered.
|
||||
const [ shouldSelect, setShouldSelect ] = useState( isEditor );
|
||||
const [ debouncedShouldSelect ] = useDebounce( shouldSelect, 200 );
|
||||
|
||||
if ( ! shouldSelect ) {
|
||||
setShouldSelect( true );
|
||||
}
|
||||
|
||||
const collectionDataQueryVars = useMemo( () => {
|
||||
return buildCollectionDataQuery( collectionDataQueryState );
|
||||
}, [ collectionDataQueryState ] );
|
||||
|
||||
return useCollection( {
|
||||
namespace: '/wc/store/v1',
|
||||
resourceName: 'products/collection-data',
|
||||
query: {
|
||||
...queryState,
|
||||
page: undefined,
|
||||
per_page: undefined,
|
||||
orderby: undefined,
|
||||
order: undefined,
|
||||
...collectionDataQueryVars,
|
||||
},
|
||||
shouldSelect: debouncedShouldSelect,
|
||||
} );
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCollectionOptions } from '.';
|
||||
|
||||
/**
|
||||
* This is a custom hook that is wired up to the `wc/store/collections` data
|
||||
* store. Given a header key and a collections option object, this will ensure a
|
||||
* component is kept up to date with the collection header value matching that
|
||||
* query in the store state.
|
||||
*
|
||||
* @param {string} headerKey Used to indicate which header value to
|
||||
* return for the given collection query.
|
||||
* Example: `'x-wp-total'`
|
||||
* @param {Object} options An object declaring the various
|
||||
* collection arguments.
|
||||
* @param {string} options.namespace The namespace for the collection.
|
||||
* Example: `'/wc/blocks'`
|
||||
* @param {string} options.resourceName The name of the resource for the
|
||||
* collection. Example:
|
||||
* `'products/attributes'`
|
||||
* @param {Array} options.resourceValues An array of values (in correct order)
|
||||
* that are substituted in the route
|
||||
* placeholders for the collection route.
|
||||
* Example: `[10, 20]`
|
||||
* @param {Object} options.query An object of key value pairs for the
|
||||
* query to execute on the collection
|
||||
* (optional). Example:
|
||||
* `{ order: 'ASC', order_by: 'price' }`
|
||||
*
|
||||
* @return {Object} This hook will return an object with two properties:
|
||||
* - value Whatever value is attached to the specified
|
||||
* header.
|
||||
* - isLoading A boolean indicating whether the header is
|
||||
* loading (true) or not.
|
||||
*/
|
||||
|
||||
export const useCollectionHeader = (
|
||||
headerKey: string,
|
||||
options: Omit< useCollectionOptions, 'shouldSelect' >
|
||||
): {
|
||||
value: unknown;
|
||||
isLoading: boolean;
|
||||
} => {
|
||||
const {
|
||||
namespace,
|
||||
resourceName,
|
||||
resourceValues = [],
|
||||
query = {},
|
||||
} = options;
|
||||
if ( ! namespace || ! resourceName ) {
|
||||
throw new Error(
|
||||
'The options object must have valid values for the namespace and ' +
|
||||
'the resource name properties.'
|
||||
);
|
||||
}
|
||||
// ensure we feed the previous reference if it's equivalent
|
||||
const currentQuery = useShallowEqual( query );
|
||||
const currentResourceValues = useShallowEqual( resourceValues );
|
||||
const { value, isLoading = true } = useSelect(
|
||||
( select ) => {
|
||||
const store = select( storeKey );
|
||||
// filter out query if it is undefined.
|
||||
const args = [
|
||||
headerKey,
|
||||
namespace,
|
||||
resourceName,
|
||||
currentQuery,
|
||||
currentResourceValues,
|
||||
];
|
||||
return {
|
||||
value: store.getCollectionHeader( ...args ),
|
||||
isLoading: store.hasFinishedResolution(
|
||||
'getCollectionHeader',
|
||||
args
|
||||
),
|
||||
};
|
||||
},
|
||||
[
|
||||
headerKey,
|
||||
namespace,
|
||||
resourceName,
|
||||
currentResourceValues,
|
||||
currentQuery,
|
||||
]
|
||||
);
|
||||
return {
|
||||
value,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { useRef } from '@wordpress/element';
|
||||
import { useShallowEqual, useThrowError } from '@woocommerce/base-hooks';
|
||||
import { isError } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* This is a custom hook that is wired up to the `wc/store/collections` data
|
||||
* store. Given a collections option object, this will ensure a component is
|
||||
* kept up to date with the collection matching that query in the store state.
|
||||
*
|
||||
* @throws {Object} Throws an exception object if there was a problem with the
|
||||
* API request, to be picked up by BlockErrorBoundry.
|
||||
*
|
||||
* @param {Object} options An object declaring the various
|
||||
* collection arguments.
|
||||
* @param {string} options.namespace The namespace for the collection.
|
||||
* Example: `'/wc/blocks'`
|
||||
* @param {string} options.resourceName The name of the resource for the
|
||||
* collection. Example:
|
||||
* `'products/attributes'`
|
||||
* @param {Array} [options.resourceValues] An array of values (in correct order)
|
||||
* that are substituted in the route
|
||||
* placeholders for the collection route.
|
||||
* Example: `[10, 20]`
|
||||
* @param {Object} [options.query] An object of key value pairs for the
|
||||
* query to execute on the collection
|
||||
* Example:
|
||||
* `{ order: 'ASC', order_by: 'price' }`
|
||||
* @param {boolean} [options.shouldSelect] If false, the previous results will be
|
||||
* returned and internal selects will not
|
||||
* fire.
|
||||
*
|
||||
* @return {Object} This hook will return an object with two properties:
|
||||
* - results An array of collection items returned.
|
||||
* - isLoading A boolean indicating whether the collection is
|
||||
* loading (true) or not.
|
||||
*/
|
||||
|
||||
export interface useCollectionOptions {
|
||||
namespace: string;
|
||||
resourceName: string;
|
||||
resourceValues?: number[];
|
||||
query?: Record< string, unknown >;
|
||||
shouldSelect?: boolean;
|
||||
isEditor?: boolean;
|
||||
}
|
||||
|
||||
export const useCollection = < T >(
|
||||
options: useCollectionOptions
|
||||
): {
|
||||
results: T[];
|
||||
isLoading: boolean;
|
||||
} => {
|
||||
const {
|
||||
namespace,
|
||||
resourceName,
|
||||
resourceValues = [],
|
||||
query = {},
|
||||
shouldSelect = true,
|
||||
} = options;
|
||||
if ( ! namespace || ! resourceName ) {
|
||||
throw new Error(
|
||||
'The options object must have valid values for the namespace and ' +
|
||||
'the resource properties.'
|
||||
);
|
||||
}
|
||||
const currentResults = useRef< { results: T[]; isLoading: boolean } >( {
|
||||
results: [],
|
||||
isLoading: true,
|
||||
} );
|
||||
// ensure we feed the previous reference if it's equivalent
|
||||
const currentQuery = useShallowEqual( query );
|
||||
const currentResourceValues = useShallowEqual( resourceValues );
|
||||
const throwError = useThrowError();
|
||||
const results = useSelect(
|
||||
( select ) => {
|
||||
if ( ! shouldSelect ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const store = select( storeKey );
|
||||
const args = [
|
||||
namespace,
|
||||
resourceName,
|
||||
currentQuery,
|
||||
currentResourceValues,
|
||||
];
|
||||
const error = store.getCollectionError( ...args );
|
||||
|
||||
if ( error ) {
|
||||
if ( isError( error ) ) {
|
||||
throwError( error );
|
||||
} else {
|
||||
throw new Error(
|
||||
'TypeError: `error` object is not an instance of Error constructor'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
results: store.getCollection< T[] >( ...args ),
|
||||
isLoading: ! store.hasFinishedResolution(
|
||||
'getCollection',
|
||||
args
|
||||
),
|
||||
};
|
||||
},
|
||||
[
|
||||
namespace,
|
||||
resourceName,
|
||||
currentResourceValues,
|
||||
currentQuery,
|
||||
shouldSelect,
|
||||
]
|
||||
);
|
||||
// if selector was not bailed, then update current results. Otherwise return
|
||||
// previous results
|
||||
if ( results !== null ) {
|
||||
currentResults.current = results;
|
||||
}
|
||||
return currentResults.current;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
export * from './cart';
|
||||
export * from './collections';
|
||||
export * from './shipping';
|
||||
export * from './payment-methods';
|
||||
export * from './use-store-events';
|
||||
export * from './use-query-state';
|
||||
export * from './use-store-products';
|
||||
export * from './use-store-add-to-cart';
|
||||
export * from './use-customer-data';
|
||||
export * from './use-checkout-address';
|
||||
export * from './use-checkout-submit';
|
||||
export * from './use-checkout-extension-data';
|
||||
export * from './use-show-shipping-total-warning';
|
||||
export * from './use-validation';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { usePaymentMethodInterface } from './use-payment-method-interface';
|
||||
export * from './use-payment-methods';
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { prepareTotalItems } from '../utils';
|
||||
|
||||
describe( 'prepareTotalItems', () => {
|
||||
const fixture = {
|
||||
total_items: '200',
|
||||
total_items_tax: '20',
|
||||
total_fees: '100',
|
||||
total_fees_tax: '10',
|
||||
total_discount: '350',
|
||||
total_discount_tax: '50',
|
||||
total_shipping: '50',
|
||||
total_shipping_tax: '5',
|
||||
total_tax: '30',
|
||||
};
|
||||
const expected = [
|
||||
{
|
||||
key: 'total_items',
|
||||
label: 'Subtotal:',
|
||||
value: 200,
|
||||
valueWithTax: 220,
|
||||
},
|
||||
{
|
||||
key: 'total_fees',
|
||||
label: 'Fees:',
|
||||
value: 100,
|
||||
valueWithTax: 110,
|
||||
},
|
||||
{
|
||||
key: 'total_discount',
|
||||
label: 'Discount:',
|
||||
value: 350,
|
||||
valueWithTax: 400,
|
||||
},
|
||||
{
|
||||
key: 'total_tax',
|
||||
label: 'Taxes:',
|
||||
value: 30,
|
||||
valueWithTax: 30,
|
||||
},
|
||||
];
|
||||
const expectedWithShipping = [
|
||||
...expected,
|
||||
{
|
||||
key: 'total_shipping',
|
||||
label: 'Shipping:',
|
||||
value: 50,
|
||||
valueWithTax: 55,
|
||||
},
|
||||
];
|
||||
it( 'returns expected values when needsShipping is false', () => {
|
||||
expect( prepareTotalItems( fixture, false ) ).toEqual( expected );
|
||||
} );
|
||||
it( 'returns expected values when needsShipping is true', () => {
|
||||
expect( prepareTotalItems( fixture, true ) ).toEqual(
|
||||
expectedWithShipping
|
||||
);
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
|
||||
import { useCallback, useEffect, useRef } from '@wordpress/element';
|
||||
import PaymentMethodLabel from '@woocommerce/base-components/cart-checkout/payment-method-label';
|
||||
import PaymentMethodIcons from '@woocommerce/base-components/cart-checkout/payment-method-icons';
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
import deprecated from '@wordpress/deprecated';
|
||||
import LoadingMask from '@woocommerce/base-components/loading-mask';
|
||||
import type { PaymentMethodInterface } from '@woocommerce/types';
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
import {
|
||||
CHECKOUT_STORE_KEY,
|
||||
PAYMENT_STORE_KEY,
|
||||
CART_STORE_KEY,
|
||||
} from '@woocommerce/block-data';
|
||||
import { ValidationInputError } from '@woocommerce/blocks-components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from '../cart/use-store-cart';
|
||||
import { useStoreCartCoupons } from '../cart/use-store-cart-coupons';
|
||||
import { noticeContexts, responseTypes } from '../../event-emit';
|
||||
import { useCheckoutEventsContext } from '../../providers/cart-checkout/checkout-events';
|
||||
import { usePaymentEventsContext } from '../../providers/cart-checkout/payment-events';
|
||||
import { useShippingDataContext } from '../../providers/cart-checkout/shipping';
|
||||
import { prepareTotalItems } from './utils';
|
||||
import { useShippingData } from '../shipping/use-shipping-data';
|
||||
|
||||
/**
|
||||
* Returns am interface to use as payment method props.
|
||||
*/
|
||||
export const usePaymentMethodInterface = (): PaymentMethodInterface => {
|
||||
const {
|
||||
onCheckoutBeforeProcessing,
|
||||
onCheckoutValidationBeforeProcessing,
|
||||
onCheckoutAfterProcessingWithSuccess,
|
||||
onCheckoutAfterProcessingWithError,
|
||||
onSubmit,
|
||||
onCheckoutSuccess,
|
||||
onCheckoutFail,
|
||||
onCheckoutValidation,
|
||||
} = useCheckoutEventsContext();
|
||||
|
||||
const { isCalculating, isComplete, isIdle, isProcessing, customerId } =
|
||||
useSelect( ( select ) => {
|
||||
const store = select( CHECKOUT_STORE_KEY );
|
||||
return {
|
||||
isComplete: store.isComplete(),
|
||||
isIdle: store.isIdle(),
|
||||
isProcessing: store.isProcessing(),
|
||||
customerId: store.getCustomerId(),
|
||||
isCalculating: store.isCalculating(),
|
||||
};
|
||||
} );
|
||||
const { paymentStatus, activePaymentMethod, shouldSavePayment } = useSelect(
|
||||
( select ) => {
|
||||
const store = select( PAYMENT_STORE_KEY );
|
||||
|
||||
return {
|
||||
// The paymentStatus is exposed to third parties via the payment method interface so the API must not be changed
|
||||
paymentStatus: {
|
||||
get isPristine() {
|
||||
deprecated( 'isPristine', {
|
||||
since: '9.6.0',
|
||||
alternative: 'isIdle',
|
||||
plugin: 'WooCommerce Blocks',
|
||||
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
|
||||
} );
|
||||
return store.isPaymentIdle();
|
||||
}, // isPristine is the same as isIdle
|
||||
isIdle: store.isPaymentIdle(),
|
||||
isStarted: store.isExpressPaymentStarted(),
|
||||
isProcessing: store.isPaymentProcessing(),
|
||||
get isFinished() {
|
||||
deprecated( 'isFinished', {
|
||||
since: '9.6.0',
|
||||
plugin: 'WooCommerce Blocks',
|
||||
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
|
||||
} );
|
||||
return (
|
||||
store.hasPaymentError() || store.isPaymentReady()
|
||||
);
|
||||
},
|
||||
hasError: store.hasPaymentError(),
|
||||
get hasFailed() {
|
||||
deprecated( 'hasFailed', {
|
||||
since: '9.6.0',
|
||||
plugin: 'WooCommerce Blocks',
|
||||
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
|
||||
} );
|
||||
return store.hasPaymentError();
|
||||
},
|
||||
get isSuccessful() {
|
||||
deprecated( 'isSuccessful', {
|
||||
since: '9.6.0',
|
||||
plugin: 'WooCommerce Blocks',
|
||||
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
|
||||
} );
|
||||
return store.isPaymentReady();
|
||||
},
|
||||
isReady: store.isPaymentReady(),
|
||||
isDoingExpressPayment: store.isExpressPaymentMethodActive(),
|
||||
},
|
||||
activePaymentMethod: store.getActivePaymentMethod(),
|
||||
shouldSavePayment: store.getShouldSavePaymentMethod(),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const { __internalSetExpressPaymentError } =
|
||||
useDispatch( PAYMENT_STORE_KEY );
|
||||
|
||||
const { onPaymentProcessing, onPaymentSetup } = usePaymentEventsContext();
|
||||
const {
|
||||
shippingErrorStatus,
|
||||
shippingErrorTypes,
|
||||
onShippingRateSuccess,
|
||||
onShippingRateFail,
|
||||
onShippingRateSelectSuccess,
|
||||
onShippingRateSelectFail,
|
||||
} = useShippingDataContext();
|
||||
const {
|
||||
shippingRates,
|
||||
isLoadingRates,
|
||||
selectedRates,
|
||||
isSelectingRate,
|
||||
selectShippingRate,
|
||||
needsShipping,
|
||||
} = useShippingData();
|
||||
|
||||
const { billingAddress, shippingAddress } = useSelect( ( select ) =>
|
||||
select( CART_STORE_KEY ).getCustomerData()
|
||||
);
|
||||
const { setShippingAddress } = useDispatch( CART_STORE_KEY );
|
||||
const { cartItems, cartFees, cartTotals, extensions } = useStoreCart();
|
||||
const { appliedCoupons } = useStoreCartCoupons();
|
||||
const currentCartTotals = useRef(
|
||||
prepareTotalItems( cartTotals, needsShipping )
|
||||
);
|
||||
const currentCartTotal = useRef( {
|
||||
label: __( 'Total', 'woo-gutenberg-products-block' ),
|
||||
value: parseInt( cartTotals.total_price, 10 ),
|
||||
} );
|
||||
|
||||
useEffect( () => {
|
||||
currentCartTotals.current = prepareTotalItems(
|
||||
cartTotals,
|
||||
needsShipping
|
||||
);
|
||||
currentCartTotal.current = {
|
||||
label: __( 'Total', 'woo-gutenberg-products-block' ),
|
||||
value: parseInt( cartTotals.total_price, 10 ),
|
||||
};
|
||||
}, [ cartTotals, needsShipping ] );
|
||||
|
||||
const deprecatedSetExpressPaymentError = useCallback(
|
||||
( errorMessage = '' ) => {
|
||||
deprecated(
|
||||
'setExpressPaymentError should only be used by Express Payment Methods (using the provided onError handler).',
|
||||
{
|
||||
alternative: '',
|
||||
plugin: 'woocommerce-gutenberg-products-block',
|
||||
link: 'https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4228',
|
||||
}
|
||||
);
|
||||
__internalSetExpressPaymentError( errorMessage );
|
||||
},
|
||||
[ __internalSetExpressPaymentError ]
|
||||
);
|
||||
|
||||
return {
|
||||
activePaymentMethod,
|
||||
billing: {
|
||||
appliedCoupons,
|
||||
billingAddress,
|
||||
billingData: billingAddress,
|
||||
cartTotal: currentCartTotal.current,
|
||||
cartTotalItems: currentCartTotals.current,
|
||||
currency: getCurrencyFromPriceResponse( cartTotals ),
|
||||
customerId,
|
||||
displayPricesIncludingTax: getSetting(
|
||||
'displayCartPricesIncludingTax',
|
||||
false
|
||||
) as boolean,
|
||||
},
|
||||
cartData: {
|
||||
cartItems,
|
||||
cartFees,
|
||||
extensions,
|
||||
},
|
||||
checkoutStatus: {
|
||||
isCalculating,
|
||||
isComplete,
|
||||
isIdle,
|
||||
isProcessing,
|
||||
},
|
||||
components: {
|
||||
LoadingMask,
|
||||
PaymentMethodIcons,
|
||||
PaymentMethodLabel,
|
||||
ValidationInputError,
|
||||
},
|
||||
emitResponse: {
|
||||
noticeContexts,
|
||||
responseTypes,
|
||||
},
|
||||
eventRegistration: {
|
||||
onCheckoutAfterProcessingWithError,
|
||||
onCheckoutAfterProcessingWithSuccess,
|
||||
onCheckoutBeforeProcessing,
|
||||
onCheckoutValidationBeforeProcessing,
|
||||
onCheckoutSuccess,
|
||||
onCheckoutFail,
|
||||
onCheckoutValidation,
|
||||
onPaymentProcessing,
|
||||
onPaymentSetup,
|
||||
onShippingRateFail,
|
||||
onShippingRateSelectFail,
|
||||
onShippingRateSelectSuccess,
|
||||
onShippingRateSuccess,
|
||||
},
|
||||
onSubmit,
|
||||
paymentStatus,
|
||||
setExpressPaymentError: deprecatedSetExpressPaymentError,
|
||||
shippingData: {
|
||||
isSelectingRate,
|
||||
needsShipping,
|
||||
selectedRates,
|
||||
setSelectedRates: selectShippingRate,
|
||||
setShippingAddress,
|
||||
shippingAddress,
|
||||
shippingRates,
|
||||
shippingRatesLoading: isLoadingRates,
|
||||
},
|
||||
shippingStatus: {
|
||||
shippingErrorStatus,
|
||||
shippingErrorTypes,
|
||||
},
|
||||
shouldSavePayment,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
import type {
|
||||
PaymentMethods,
|
||||
ExpressPaymentMethods,
|
||||
PaymentMethodConfigInstance,
|
||||
ExpressPaymentMethodConfigInstance,
|
||||
} from '@woocommerce/types';
|
||||
import {
|
||||
getPaymentMethods,
|
||||
getExpressPaymentMethods,
|
||||
} from '@woocommerce/blocks-registry';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { PAYMENT_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
interface PaymentMethodState {
|
||||
paymentMethods: PaymentMethods;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
interface ExpressPaymentMethodState {
|
||||
paymentMethods: ExpressPaymentMethods;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
|
||||
const usePaymentMethodState = (
|
||||
express = false
|
||||
): PaymentMethodState | ExpressPaymentMethodState => {
|
||||
const {
|
||||
paymentMethodsInitialized,
|
||||
expressPaymentMethodsInitialized,
|
||||
availablePaymentMethods,
|
||||
availableExpressPaymentMethods,
|
||||
} = useSelect( ( select ) => {
|
||||
const store = select( PAYMENT_STORE_KEY );
|
||||
|
||||
return {
|
||||
paymentMethodsInitialized: store.paymentMethodsInitialized(),
|
||||
expressPaymentMethodsInitialized:
|
||||
store.expressPaymentMethodsInitialized(),
|
||||
availableExpressPaymentMethods:
|
||||
store.getAvailableExpressPaymentMethods(),
|
||||
availablePaymentMethods: store.getAvailablePaymentMethods(),
|
||||
};
|
||||
} );
|
||||
|
||||
const availablePaymentMethodNames = Object.values(
|
||||
availablePaymentMethods
|
||||
).map( ( { name } ) => name );
|
||||
const availableExpressPaymentMethodNames = Object.values(
|
||||
availableExpressPaymentMethods
|
||||
).map( ( { name } ) => name );
|
||||
|
||||
const registeredPaymentMethods = getPaymentMethods();
|
||||
const registeredExpressPaymentMethods = getExpressPaymentMethods();
|
||||
|
||||
// Remove everything from registeredPaymentMethods that is not in availablePaymentMethodNames.
|
||||
const paymentMethods = Object.keys( registeredPaymentMethods ).reduce(
|
||||
( acc: Record< string, PaymentMethodConfigInstance >, key ) => {
|
||||
if ( availablePaymentMethodNames.includes( key ) ) {
|
||||
acc[ key ] = registeredPaymentMethods[ key ];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
// Remove everything from registeredExpressPaymentMethods that is not in availableExpressPaymentMethodNames.
|
||||
const expressPaymentMethods = Object.keys(
|
||||
registeredExpressPaymentMethods
|
||||
).reduce(
|
||||
( acc: Record< string, ExpressPaymentMethodConfigInstance >, key ) => {
|
||||
if ( availableExpressPaymentMethodNames.includes( key ) ) {
|
||||
acc[ key ] = registeredExpressPaymentMethods[ key ];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const currentPaymentMethods = useShallowEqual( paymentMethods );
|
||||
const currentExpressPaymentMethods = useShallowEqual(
|
||||
expressPaymentMethods
|
||||
);
|
||||
|
||||
return {
|
||||
paymentMethods: express
|
||||
? currentExpressPaymentMethods
|
||||
: currentPaymentMethods,
|
||||
isInitialized: express
|
||||
? expressPaymentMethodsInitialized
|
||||
: paymentMethodsInitialized,
|
||||
};
|
||||
};
|
||||
|
||||
export const usePaymentMethods = ():
|
||||
| PaymentMethodState
|
||||
| ExpressPaymentMethodState => usePaymentMethodState( false );
|
||||
export const useExpressPaymentMethods = (): ExpressPaymentMethodState =>
|
||||
usePaymentMethodState( true );
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import {
|
||||
CartResponseTotals,
|
||||
objectHasProp,
|
||||
isString,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
export interface CartTotalItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
valueWithTax: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the total items into a shape usable for display as passed on to
|
||||
* registered payment methods.
|
||||
*
|
||||
* @param {Object} totals Current cart total items
|
||||
* @param {boolean} needsShipping Whether or not shipping is needed.
|
||||
*/
|
||||
export const prepareTotalItems = (
|
||||
totals: CartResponseTotals,
|
||||
needsShipping: boolean
|
||||
): CartTotalItem[] => {
|
||||
const newTotals = [];
|
||||
|
||||
const factory = ( label: string, property: string ): CartTotalItem => {
|
||||
const taxProperty = property + '_tax';
|
||||
const value =
|
||||
objectHasProp( totals, property ) && isString( totals[ property ] )
|
||||
? parseInt( totals[ property ] as string, 10 )
|
||||
: 0;
|
||||
const tax =
|
||||
objectHasProp( totals, taxProperty ) &&
|
||||
isString( totals[ taxProperty ] )
|
||||
? parseInt( totals[ taxProperty ] as string, 10 )
|
||||
: 0;
|
||||
return {
|
||||
key: property,
|
||||
label,
|
||||
value,
|
||||
valueWithTax: value + tax,
|
||||
};
|
||||
};
|
||||
|
||||
newTotals.push(
|
||||
factory(
|
||||
__( 'Subtotal:', 'woo-gutenberg-products-block' ),
|
||||
'total_items'
|
||||
)
|
||||
);
|
||||
|
||||
newTotals.push(
|
||||
factory( __( 'Fees:', 'woo-gutenberg-products-block' ), 'total_fees' )
|
||||
);
|
||||
|
||||
newTotals.push(
|
||||
factory(
|
||||
__( 'Discount:', 'woo-gutenberg-products-block' ),
|
||||
'total_discount'
|
||||
)
|
||||
);
|
||||
|
||||
newTotals.push( {
|
||||
key: 'total_tax',
|
||||
label: __( 'Taxes:', 'woo-gutenberg-products-block' ),
|
||||
value: parseInt( totals.total_tax, 10 ),
|
||||
valueWithTax: parseInt( totals.total_tax, 10 ),
|
||||
} );
|
||||
|
||||
if ( needsShipping ) {
|
||||
newTotals.push(
|
||||
factory(
|
||||
__( 'Shipping:', 'woo-gutenberg-products-block' ),
|
||||
'total_shipping'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return newTotals;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './use-shipping-data';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Cart } from '@woocommerce/type-defs/cart';
|
||||
|
||||
export interface ShippingData {
|
||||
needsShipping: Cart[ 'needsShipping' ];
|
||||
hasCalculatedShipping: Cart[ 'hasCalculatedShipping' ];
|
||||
shippingRates: Cart[ 'shippingRates' ];
|
||||
isLoadingRates: boolean;
|
||||
selectedRates: Record< string, string | unknown >;
|
||||
// Returns a function that accepts a shipping rate ID and a package ID.
|
||||
selectShippingRate: (
|
||||
newShippingRateId: string,
|
||||
packageId?: string | number | undefined
|
||||
) => void;
|
||||
// Only true when ALL packages support local pickup. If true, we can show the collection/delivery toggle
|
||||
isCollectable: boolean;
|
||||
// True when a rate is currently being selected and persisted to the server.
|
||||
isSelectingRate: boolean;
|
||||
// True when the user has chosen a local pickup method.
|
||||
hasSelectedLocalPickup: boolean;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
CART_STORE_KEY as storeKey,
|
||||
processErrorResponse,
|
||||
} from '@woocommerce/block-data';
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
import { isObject } from '@woocommerce/types';
|
||||
import { useEffect, useRef, useCallback } from '@wordpress/element';
|
||||
import {
|
||||
hasCollectableRate,
|
||||
deriveSelectedShippingRates,
|
||||
} from '@woocommerce/base-utils';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
import { previewCart } from '@woocommerce/resource-previews';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreEvents } from '../use-store-events';
|
||||
import type { ShippingData } from './types';
|
||||
|
||||
export const useShippingData = (): ShippingData => {
|
||||
const {
|
||||
shippingRates,
|
||||
needsShipping,
|
||||
hasCalculatedShipping,
|
||||
isLoadingRates,
|
||||
isCollectable,
|
||||
isSelectingRate,
|
||||
} = useSelect( ( select ) => {
|
||||
const isEditor = !! select( 'core/editor' );
|
||||
const store = select( storeKey );
|
||||
const rates = isEditor
|
||||
? previewCart.shipping_rates
|
||||
: store.getShippingRates();
|
||||
return {
|
||||
shippingRates: rates,
|
||||
needsShipping: isEditor
|
||||
? previewCart.needs_shipping
|
||||
: store.getNeedsShipping(),
|
||||
hasCalculatedShipping: isEditor
|
||||
? previewCart.has_calculated_shipping
|
||||
: store.getHasCalculatedShipping(),
|
||||
isLoadingRates: isEditor ? false : store.isCustomerDataUpdating(),
|
||||
isCollectable: rates.every(
|
||||
( { shipping_rates: packageShippingRates } ) =>
|
||||
packageShippingRates.find( ( { method_id: methodId } ) =>
|
||||
hasCollectableRate( methodId )
|
||||
)
|
||||
),
|
||||
isSelectingRate: isEditor
|
||||
? false
|
||||
: store.isShippingRateBeingSelected(),
|
||||
};
|
||||
} );
|
||||
|
||||
// set selected rates on ref so it's always current.
|
||||
const selectedRates = useRef< Record< string, string > >( {} );
|
||||
useEffect( () => {
|
||||
const derivedSelectedRates =
|
||||
deriveSelectedShippingRates( shippingRates );
|
||||
if (
|
||||
isObject( derivedSelectedRates ) &&
|
||||
! isShallowEqual( selectedRates.current, derivedSelectedRates )
|
||||
) {
|
||||
selectedRates.current = derivedSelectedRates;
|
||||
}
|
||||
}, [ shippingRates ] );
|
||||
|
||||
const { selectShippingRate: dispatchSelectShippingRate } = useDispatch(
|
||||
storeKey
|
||||
) as {
|
||||
selectShippingRate: unknown;
|
||||
} as {
|
||||
selectShippingRate: (
|
||||
newShippingRateId: string,
|
||||
packageId?: string | number | null
|
||||
) => Promise< unknown >;
|
||||
};
|
||||
|
||||
const hasSelectedLocalPickup = hasCollectableRate(
|
||||
Object.values( selectedRates.current ).map(
|
||||
( rate ) => rate.split( ':' )[ 0 ]
|
||||
)
|
||||
);
|
||||
// Selects a shipping rate, fires an event, and catch any errors.
|
||||
const { dispatchCheckoutEvent } = useStoreEvents();
|
||||
const selectShippingRate = useCallback(
|
||||
(
|
||||
newShippingRateId: string,
|
||||
packageId?: string | number | undefined
|
||||
): void => {
|
||||
let selectPromise;
|
||||
|
||||
if ( typeof newShippingRateId === 'undefined' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picking location handling
|
||||
*
|
||||
* Forces pickup location to be selected for all packages since we don't allow a mix of shipping and pickup.
|
||||
*/
|
||||
if ( hasCollectableRate( newShippingRateId.split( ':' )[ 0 ] ) ) {
|
||||
selectPromise = dispatchSelectShippingRate(
|
||||
newShippingRateId,
|
||||
null
|
||||
);
|
||||
} else {
|
||||
selectPromise = dispatchSelectShippingRate(
|
||||
newShippingRateId,
|
||||
packageId
|
||||
);
|
||||
}
|
||||
selectPromise
|
||||
.then( () => {
|
||||
dispatchCheckoutEvent( 'set-selected-shipping-rate', {
|
||||
shippingRateId: newShippingRateId,
|
||||
} );
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
processErrorResponse( error );
|
||||
} );
|
||||
},
|
||||
[ dispatchSelectShippingRate, dispatchCheckoutEvent ]
|
||||
);
|
||||
|
||||
return {
|
||||
isSelectingRate,
|
||||
selectedRates: selectedRates.current,
|
||||
selectShippingRate,
|
||||
shippingRates,
|
||||
needsShipping,
|
||||
hasCalculatedShipping,
|
||||
isLoadingRates,
|
||||
isCollectable,
|
||||
hasSelectedLocalPickup,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCheckoutSubmit } from '../use-checkout-submit';
|
||||
import {
|
||||
CHECKOUT_STORE_KEY,
|
||||
config as checkoutStoreConfig,
|
||||
} from '../../../../data/checkout';
|
||||
import {
|
||||
PAYMENT_STORE_KEY,
|
||||
config as paymentDataStoreConfig,
|
||||
} from '../../../../data/payment';
|
||||
|
||||
jest.mock( '../../providers/cart-checkout/checkout-events', () => {
|
||||
const original = jest.requireActual(
|
||||
'../../providers/cart-checkout/checkout-events'
|
||||
);
|
||||
return {
|
||||
...original,
|
||||
useCheckoutEventsContext: () => {
|
||||
return { onSubmit: jest.fn() };
|
||||
},
|
||||
};
|
||||
} );
|
||||
|
||||
describe( 'useCheckoutSubmit', () => {
|
||||
let registry, renderer;
|
||||
|
||||
const getWrappedComponents = ( Component ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent = () => () => {
|
||||
const data = useCheckoutSubmit();
|
||||
return <div { ...data } />;
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry( {
|
||||
[ CHECKOUT_STORE_KEY ]: checkoutStoreConfig,
|
||||
[ PAYMENT_STORE_KEY ]: paymentDataStoreConfig,
|
||||
} );
|
||||
renderer = null;
|
||||
} );
|
||||
|
||||
it( 'onSubmit calls the correct action in the checkout events context', () => {
|
||||
const TestComponent = getTestComponent();
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
//eslint-disable-next-line testing-library/await-async-query
|
||||
const { onSubmit } = renderer.root.findByType( 'div' ).props;
|
||||
|
||||
onSubmit();
|
||||
|
||||
expect( onSubmit ).toHaveBeenCalledTimes( 1 );
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
useQueryStateByContext,
|
||||
useQueryStateByKey,
|
||||
useSynchronizedQueryState,
|
||||
} from '../use-query-state';
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
__esModule: true,
|
||||
QUERY_STATE_STORE_KEY: 'test/store',
|
||||
} ) );
|
||||
|
||||
describe( 'Testing Query State Hooks', () => {
|
||||
let registry, mocks;
|
||||
beforeAll( () => {
|
||||
registry = createRegistry();
|
||||
mocks = {};
|
||||
} );
|
||||
/**
|
||||
* Test helper to return a tuple containing the expected query value and the
|
||||
* expected query state action creator from the given rendered test instance.
|
||||
*
|
||||
* @param {Object} testRenderer An instance of the created test component.
|
||||
*
|
||||
* @return {Array} A tuple containing the expected query value as the first
|
||||
* element and the expected query action creator as the
|
||||
* second argument.
|
||||
*/
|
||||
const getProps = ( testRenderer ) => {
|
||||
//eslint-disable-next-line testing-library/await-async-query
|
||||
const props = testRenderer.root.findByType( 'div' ).props;
|
||||
return [ props[ 'data-queryState' ], props[ 'data-setQueryState' ] ];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the given component wrapped in the registry provider for
|
||||
* instantiating using the TestRenderer using the current prepared registry
|
||||
* for the TestRenderer to instantiate with.
|
||||
*
|
||||
* @param {*} Component The test component to wrap.
|
||||
* @param {Object} props Props to feed the wrapped component.
|
||||
*
|
||||
* @return {*} Wrapped component.
|
||||
*/
|
||||
const getWrappedComponent = ( Component, props ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component { ...props } />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns a TestComponent for the provided hook to test with, and the
|
||||
* expected PropKeys for obtaining the values to be fed to the hook as
|
||||
* arguments.
|
||||
*
|
||||
* @param {Function} hookTested The hook being tested to use in the
|
||||
* test comopnent.
|
||||
* @param {Array} propKeysForArgs An array of keys for the props that
|
||||
* will be used on the test component that
|
||||
* will have values fed to the tested
|
||||
* hook.
|
||||
*
|
||||
* @return {*} A component ready for testing with!
|
||||
*/
|
||||
const getTestComponent = ( hookTested, propKeysForArgs ) => ( props ) => {
|
||||
const args = propKeysForArgs.map( ( key ) => props[ key ] );
|
||||
const [ queryValue, setQueryValue ] = hookTested( ...args );
|
||||
return (
|
||||
<div
|
||||
data-queryState={ queryValue }
|
||||
data-setQueryState={ setQueryValue }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A helper for setting up the `mocks` object and the `registry` mock before
|
||||
* each test.
|
||||
*
|
||||
* @param {string} actionMockName This should be the name of the action
|
||||
* that the hook returns. This will be
|
||||
* mocked using `mocks.action` when
|
||||
* registered in the mock registry.
|
||||
* @param {string} selectorMockName This should be the mame of the selector
|
||||
* that the hook uses. This will be mocked
|
||||
* using `mocks.selector` when registered
|
||||
* in the mock registry.
|
||||
*/
|
||||
const setupMocks = ( actionMockName, selectorMockName ) => {
|
||||
mocks.action = jest.fn().mockReturnValue( { type: 'testAction' } );
|
||||
mocks.selector = jest.fn().mockReturnValue( { foo: 'bar' } );
|
||||
registry.registerStore( storeKey, {
|
||||
reducer: () => ( {} ),
|
||||
actions: {
|
||||
[ actionMockName ]: mocks.action,
|
||||
},
|
||||
selectors: {
|
||||
[ selectorMockName ]: mocks.selector,
|
||||
},
|
||||
} );
|
||||
};
|
||||
describe( 'useQueryStateByContext', () => {
|
||||
const TestComponent = getTestComponent( useQueryStateByContext, [
|
||||
'context',
|
||||
] );
|
||||
let renderer;
|
||||
beforeEach( () => {
|
||||
renderer = null;
|
||||
setupMocks( 'setValueForQueryContext', 'getValueForQueryContext' );
|
||||
} );
|
||||
afterEach( () => {
|
||||
act( () => {
|
||||
renderer.unmount();
|
||||
} );
|
||||
} );
|
||||
it(
|
||||
'calls useSelect with the provided context and returns expected' +
|
||||
' values',
|
||||
() => {
|
||||
const { action, selector } = mocks;
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponent( TestComponent, {
|
||||
context: 'test-context',
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const [ queryState, setQueryState ] = getProps( renderer );
|
||||
// the {} is because all selectors are called internally in the
|
||||
// registry with the first argument being the state which is empty.
|
||||
expect( selector ).toHaveBeenLastCalledWith(
|
||||
{},
|
||||
'test-context',
|
||||
undefined
|
||||
);
|
||||
expect( queryState ).toEqual( { foo: 'bar' } );
|
||||
expect( action ).not.toHaveBeenCalled();
|
||||
|
||||
//execute dispatcher and make sure it's called.
|
||||
act( () => {
|
||||
setQueryState( { foo: 'bar' } );
|
||||
} );
|
||||
expect( action ).toHaveBeenCalledWith( 'test-context', {
|
||||
foo: 'bar',
|
||||
} );
|
||||
}
|
||||
);
|
||||
} );
|
||||
describe( 'useQueryStateByKey', () => {
|
||||
const TestComponent = getTestComponent( useQueryStateByKey, [
|
||||
'queryKey',
|
||||
undefined,
|
||||
'context',
|
||||
] );
|
||||
let renderer;
|
||||
beforeEach( () => {
|
||||
renderer = null;
|
||||
setupMocks( 'setQueryValue', 'getValueForQueryKey' );
|
||||
} );
|
||||
afterEach( () => {
|
||||
act( () => {
|
||||
renderer.unmount();
|
||||
} );
|
||||
} );
|
||||
it(
|
||||
'calls useSelect with the provided context and returns expected' +
|
||||
' values',
|
||||
() => {
|
||||
const { selector, action } = mocks;
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponent( TestComponent, {
|
||||
context: 'test-context',
|
||||
queryKey: 'someValue',
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const [ queryState, setQueryState ] = getProps( renderer );
|
||||
// the {} is because all selectors are called internally in the
|
||||
// registry with the first argument being the state which is empty.
|
||||
expect( selector ).toHaveBeenLastCalledWith(
|
||||
{},
|
||||
'test-context',
|
||||
'someValue',
|
||||
undefined
|
||||
);
|
||||
expect( queryState ).toEqual( { foo: 'bar' } );
|
||||
expect( action ).not.toHaveBeenCalled();
|
||||
|
||||
//execute dispatcher and make sure it's called.
|
||||
act( () => {
|
||||
setQueryState( { foo: 'bar' } );
|
||||
} );
|
||||
expect( action ).toHaveBeenCalledWith(
|
||||
'test-context',
|
||||
'someValue',
|
||||
{ foo: 'bar' }
|
||||
);
|
||||
}
|
||||
);
|
||||
} );
|
||||
// Note: these tests only add partial coverage because the state is not
|
||||
// actually updated by the action dispatch via our mocks.
|
||||
describe( 'useSynchronizedQueryState', () => {
|
||||
const TestComponent = getTestComponent( useSynchronizedQueryState, [
|
||||
'synchronizedQuery',
|
||||
'context',
|
||||
] );
|
||||
const initialQuery = { a: 'b' };
|
||||
let renderer;
|
||||
beforeEach( () => {
|
||||
setupMocks( 'setValueForQueryContext', 'getValueForQueryContext' );
|
||||
} );
|
||||
it( 'returns provided query state on initial render', () => {
|
||||
const { action, selector } = mocks;
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponent( TestComponent, {
|
||||
context: 'test-context',
|
||||
synchronizedQuery: initialQuery,
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const [ queryState ] = getProps( renderer );
|
||||
expect( queryState ).toBe( initialQuery );
|
||||
expect( selector ).toHaveBeenLastCalledWith(
|
||||
{},
|
||||
'test-context',
|
||||
undefined
|
||||
);
|
||||
expect( action ).toHaveBeenCalledWith( 'test-context', {
|
||||
foo: 'bar',
|
||||
a: 'b',
|
||||
} );
|
||||
} );
|
||||
it( 'returns merged queryState on subsequent render', () => {
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponent( TestComponent, {
|
||||
context: 'test-context',
|
||||
synchronizedQuery: initialQuery,
|
||||
} )
|
||||
);
|
||||
} );
|
||||
// note our test doesn't interact with an actual reducer so the
|
||||
// store state is not updated. Here we're just verifying that
|
||||
// what is is returned by the state selector mock is returned.
|
||||
// However we DO expect this to be a new object.
|
||||
const [ queryState ] = getProps( renderer );
|
||||
expect( queryState ).not.toBe( initialQuery );
|
||||
expect( queryState ).toEqual( { foo: 'bar' } );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreProducts } from '../use-store-products';
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
__esModule: true,
|
||||
COLLECTIONS_STORE_KEY: 'test/store',
|
||||
} ) );
|
||||
|
||||
describe( 'useStoreProducts', () => {
|
||||
let registry, mocks, renderer;
|
||||
const getProps = ( testRenderer ) => {
|
||||
const { products, totalProducts, productsLoading } =
|
||||
testRenderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
|
||||
return {
|
||||
products,
|
||||
totalProducts,
|
||||
productsLoading,
|
||||
};
|
||||
};
|
||||
|
||||
const getWrappedComponents = ( Component, props ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component { ...props } />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent =
|
||||
() =>
|
||||
( { query } ) => {
|
||||
const items = useStoreProducts( query );
|
||||
return <div { ...items } />;
|
||||
};
|
||||
|
||||
const setUpMocks = () => {
|
||||
mocks = {
|
||||
selectors: {
|
||||
getCollectionError: jest.fn().mockReturnValue( false ),
|
||||
getCollection: jest
|
||||
.fn()
|
||||
.mockImplementation( () => ( { foo: 'bar' } ) ),
|
||||
getCollectionHeader: jest.fn().mockReturnValue( 22 ),
|
||||
hasFinishedResolution: jest.fn().mockReturnValue( true ),
|
||||
},
|
||||
};
|
||||
registry.registerStore( storeKey, {
|
||||
reducer: () => ( {} ),
|
||||
selectors: mocks.selectors,
|
||||
} );
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry();
|
||||
mocks = {};
|
||||
renderer = null;
|
||||
setUpMocks();
|
||||
} );
|
||||
it(
|
||||
'should return expected behaviour for equivalent query on props ' +
|
||||
'across renders',
|
||||
() => {
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
query: { bar: 'foo' },
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { products } = getProps( renderer );
|
||||
// rerender
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
query: { bar: 'foo' },
|
||||
} )
|
||||
);
|
||||
} );
|
||||
// re-render should result in same products object because although
|
||||
// query-state is a different instance, it's still equivalent.
|
||||
const { products: newProducts } = getProps( renderer );
|
||||
expect( newProducts ).toBe( products );
|
||||
// now let's change the query passed through to verify new object
|
||||
// is created.
|
||||
// remember this won't actually change the results because the mock
|
||||
// selector is returning an equivalent object when it is called,
|
||||
// however it SHOULD be a new object instance.
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
query: { foo: 'bar' },
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { products: productsVerification } = getProps( renderer );
|
||||
expect( productsVerification ).not.toBe( products );
|
||||
expect( productsVerification ).toEqual( products );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
} );
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
defaultAddressFields,
|
||||
AddressFields,
|
||||
ShippingAddress,
|
||||
BillingAddress,
|
||||
getSetting,
|
||||
} from '@woocommerce/settings';
|
||||
import { useCallback } from '@wordpress/element';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCustomerData } from './use-customer-data';
|
||||
import { useShippingData } from './shipping/use-shipping-data';
|
||||
|
||||
interface CheckoutAddress {
|
||||
shippingAddress: ShippingAddress;
|
||||
billingAddress: BillingAddress;
|
||||
setShippingAddress: ( data: Partial< ShippingAddress > ) => void;
|
||||
setBillingAddress: ( data: Partial< BillingAddress > ) => void;
|
||||
setEmail: ( value: string ) => void;
|
||||
useShippingAsBilling: boolean;
|
||||
setUseShippingAsBilling: ( useShippingAsBilling: boolean ) => void;
|
||||
defaultAddressFields: AddressFields;
|
||||
showShippingFields: boolean;
|
||||
showBillingFields: boolean;
|
||||
forcedBillingAddress: boolean;
|
||||
useBillingAsShipping: boolean;
|
||||
needsShipping: boolean;
|
||||
showShippingMethods: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for exposing address related functionality for the checkout address form.
|
||||
*/
|
||||
export const useCheckoutAddress = (): CheckoutAddress => {
|
||||
const { needsShipping } = useShippingData();
|
||||
const { useShippingAsBilling, prefersCollection } = useSelect(
|
||||
( select ) => ( {
|
||||
useShippingAsBilling:
|
||||
select( CHECKOUT_STORE_KEY ).getUseShippingAsBilling(),
|
||||
prefersCollection: select( CHECKOUT_STORE_KEY ).prefersCollection(),
|
||||
} )
|
||||
);
|
||||
const { __internalSetUseShippingAsBilling } =
|
||||
useDispatch( CHECKOUT_STORE_KEY );
|
||||
const {
|
||||
billingAddress,
|
||||
setBillingAddress,
|
||||
shippingAddress,
|
||||
setShippingAddress,
|
||||
} = useCustomerData();
|
||||
|
||||
const setEmail = useCallback(
|
||||
( value: string ) =>
|
||||
void setBillingAddress( {
|
||||
email: value,
|
||||
} ),
|
||||
[ setBillingAddress ]
|
||||
);
|
||||
|
||||
const forcedBillingAddress: boolean = getSetting(
|
||||
'forcedBillingAddress',
|
||||
false
|
||||
);
|
||||
return {
|
||||
shippingAddress,
|
||||
billingAddress,
|
||||
setShippingAddress,
|
||||
setBillingAddress,
|
||||
setEmail,
|
||||
defaultAddressFields,
|
||||
useShippingAsBilling,
|
||||
setUseShippingAsBilling: __internalSetUseShippingAsBilling,
|
||||
needsShipping,
|
||||
showShippingFields:
|
||||
! forcedBillingAddress && needsShipping && ! prefersCollection,
|
||||
showShippingMethods: needsShipping && ! prefersCollection,
|
||||
showBillingFields:
|
||||
! needsShipping || ! useShippingAsBilling || !! prefersCollection,
|
||||
forcedBillingAddress,
|
||||
useBillingAsShipping: forcedBillingAddress || !! prefersCollection,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { useCallback, useRef } from '@wordpress/element';
|
||||
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { CheckoutState } from '../../../data/checkout/default-state';
|
||||
|
||||
/**
|
||||
* Custom hook for setting custom checkout data which is passed to the wc/store/checkout endpoint when processing orders.
|
||||
*/
|
||||
export const useCheckoutExtensionData = (): {
|
||||
extensionData: CheckoutState[ 'extensionData' ];
|
||||
setExtensionData: (
|
||||
namespace: string,
|
||||
key: string,
|
||||
value: unknown
|
||||
) => void;
|
||||
} => {
|
||||
const { __internalSetExtensionData } = useDispatch( CHECKOUT_STORE_KEY );
|
||||
const extensionData = useSelect( ( select ) =>
|
||||
select( CHECKOUT_STORE_KEY ).getExtensionData()
|
||||
);
|
||||
const extensionDataRef = useRef( extensionData );
|
||||
|
||||
const setExtensionData = useCallback(
|
||||
( namespace, key, value ) => {
|
||||
__internalSetExtensionData( namespace, {
|
||||
[ key ]: value,
|
||||
} );
|
||||
},
|
||||
[ __internalSetExtensionData ]
|
||||
);
|
||||
|
||||
return {
|
||||
extensionData: extensionDataRef.current,
|
||||
setExtensionData,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { CHECKOUT_STORE_KEY, PAYMENT_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCheckoutEventsContext } from '../providers';
|
||||
import { usePaymentMethods } from './payment-methods/use-payment-methods';
|
||||
|
||||
/**
|
||||
* Returns the submitButtonText, onSubmit interface from the checkout context,
|
||||
* and an indication of submission status.
|
||||
*/
|
||||
export const useCheckoutSubmit = () => {
|
||||
const {
|
||||
isCalculating,
|
||||
isBeforeProcessing,
|
||||
isProcessing,
|
||||
isAfterProcessing,
|
||||
isComplete,
|
||||
hasError,
|
||||
} = useSelect( ( select ) => {
|
||||
const store = select( CHECKOUT_STORE_KEY );
|
||||
return {
|
||||
isCalculating: store.isCalculating(),
|
||||
isBeforeProcessing: store.isBeforeProcessing(),
|
||||
isProcessing: store.isProcessing(),
|
||||
isAfterProcessing: store.isAfterProcessing(),
|
||||
isComplete: store.isComplete(),
|
||||
hasError: store.hasError(),
|
||||
};
|
||||
} );
|
||||
const { activePaymentMethod, isExpressPaymentMethodActive } = useSelect(
|
||||
( select ) => {
|
||||
const store = select( PAYMENT_STORE_KEY );
|
||||
|
||||
return {
|
||||
activePaymentMethod: store.getActivePaymentMethod(),
|
||||
isExpressPaymentMethodActive:
|
||||
store.isExpressPaymentMethodActive(),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const { onSubmit } = useCheckoutEventsContext();
|
||||
|
||||
const { paymentMethods = {} } = usePaymentMethods();
|
||||
const paymentMethod = paymentMethods[ activePaymentMethod ] || {};
|
||||
const waitingForProcessing =
|
||||
isProcessing || isAfterProcessing || isBeforeProcessing;
|
||||
const waitingForRedirect = isComplete && ! hasError;
|
||||
const paymentMethodButtonLabel = paymentMethod.placeOrderButtonLabel;
|
||||
|
||||
return {
|
||||
paymentMethodButtonLabel,
|
||||
onSubmit,
|
||||
isCalculating,
|
||||
isDisabled: isProcessing || isExpressPaymentMethodActive,
|
||||
waitingForProcessing,
|
||||
waitingForRedirect,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import type { BillingAddress, ShippingAddress } from '@woocommerce/settings';
|
||||
|
||||
export interface CustomerDataType {
|
||||
isInitialized: boolean;
|
||||
billingAddress: BillingAddress;
|
||||
shippingAddress: ShippingAddress;
|
||||
setBillingAddress: ( data: Partial< BillingAddress > ) => void;
|
||||
setShippingAddress: ( data: Partial< ShippingAddress > ) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a custom hook for syncing customer address data (billing and shipping) with the server.
|
||||
*/
|
||||
export const useCustomerData = (): CustomerDataType => {
|
||||
const { customerData, isInitialized } = useSelect( ( select ) => {
|
||||
const store = select( storeKey );
|
||||
return {
|
||||
customerData: store.getCustomerData(),
|
||||
isInitialized: store.hasFinishedResolution( 'getCartData' ),
|
||||
};
|
||||
} );
|
||||
const { setShippingAddress, setBillingAddress } = useDispatch( storeKey );
|
||||
|
||||
return {
|
||||
isInitialized,
|
||||
billingAddress: customerData.billingAddress,
|
||||
shippingAddress: customerData.shippingAddress,
|
||||
setBillingAddress,
|
||||
setShippingAddress,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useEffect, useRef, useState } from '@wordpress/element';
|
||||
import { getAttributes, getTerms } from '@woocommerce/editor-components/utils';
|
||||
import {
|
||||
AttributeObject,
|
||||
AttributeTerm,
|
||||
AttributeWithTerms,
|
||||
} from '@woocommerce/types';
|
||||
import { formatError } from '@woocommerce/base-utils';
|
||||
|
||||
export default function useProductAttributes( shouldLoadAttributes: boolean ) {
|
||||
const [ errorLoadingAttributes, setErrorLoadingAttributes ] =
|
||||
useState< Awaited< ReturnType< typeof formatError > > | null >( null );
|
||||
const [ isLoadingAttributes, setIsLoadingAttributes ] = useState( false );
|
||||
const [ productsAttributes, setProductsAttributes ] = useState<
|
||||
AttributeWithTerms[]
|
||||
>( [] );
|
||||
const hasLoadedAttributes = useRef( false );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
! shouldLoadAttributes ||
|
||||
isLoadingAttributes ||
|
||||
hasLoadedAttributes.current
|
||||
)
|
||||
return;
|
||||
|
||||
async function fetchAttributesWithTerms() {
|
||||
setIsLoadingAttributes( true );
|
||||
|
||||
try {
|
||||
const attributes: AttributeObject[] = await getAttributes();
|
||||
const attributesWithTerms: AttributeWithTerms[] = [];
|
||||
|
||||
for ( const attribute of attributes ) {
|
||||
const terms: AttributeTerm[] = await getTerms(
|
||||
attribute.id
|
||||
);
|
||||
|
||||
attributesWithTerms.push( {
|
||||
...attribute,
|
||||
// Manually adding the parent id because of a Rest API bug
|
||||
// returning always `0` as parent.
|
||||
// see https://github.com/woocommerce/woocommerce-blocks/issues/8501
|
||||
parent: 0,
|
||||
terms: terms.map( ( term ) => ( {
|
||||
...term,
|
||||
attr_slug: attribute.taxonomy,
|
||||
parent: attribute.id,
|
||||
} ) ),
|
||||
} );
|
||||
}
|
||||
|
||||
setProductsAttributes( attributesWithTerms );
|
||||
hasLoadedAttributes.current = true;
|
||||
} catch ( e ) {
|
||||
if ( e instanceof Error ) {
|
||||
setErrorLoadingAttributes( await formatError( e ) );
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingAttributes( false );
|
||||
}
|
||||
}
|
||||
|
||||
fetchAttributesWithTerms();
|
||||
|
||||
return () => {
|
||||
hasLoadedAttributes.current = true;
|
||||
};
|
||||
}, [ isLoadingAttributes, shouldLoadAttributes ] );
|
||||
|
||||
return {
|
||||
errorLoadingAttributes,
|
||||
isLoadingAttributes,
|
||||
productsAttributes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
import { useRef, useEffect, useCallback } from '@wordpress/element';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
import { useShallowEqual, usePrevious } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
|
||||
import { useQueryStateContext } from '../providers/query-state-context';
|
||||
|
||||
/**
|
||||
* A custom hook that exposes the current query state and a setter for the query
|
||||
* state store for the given context.
|
||||
*
|
||||
* "Query State" is a wp.data store that keeps track of an arbitrary object of
|
||||
* query keys and their values.
|
||||
*
|
||||
* @param {string} [context] What context to retrieve the query state for. If not
|
||||
* provided, this hook will attempt to get the context
|
||||
* from the query state context provided by the
|
||||
* QueryStateContextProvider
|
||||
*
|
||||
* @return {Array} An array that has two elements. The first element is the
|
||||
* query state value for the given context. The second element
|
||||
* is a dispatcher function for setting the query state.
|
||||
*/
|
||||
export const useQueryStateByContext = ( context ) => {
|
||||
const queryStateContext = useQueryStateContext();
|
||||
context = context || queryStateContext;
|
||||
const queryState = useSelect(
|
||||
( select ) => {
|
||||
const store = select( storeKey );
|
||||
return store.getValueForQueryContext( context, undefined );
|
||||
},
|
||||
[ context ]
|
||||
);
|
||||
const { setValueForQueryContext } = useDispatch( storeKey );
|
||||
const setQueryState = useCallback(
|
||||
( value ) => {
|
||||
setValueForQueryContext( context, value );
|
||||
},
|
||||
[ context, setValueForQueryContext ]
|
||||
);
|
||||
|
||||
return [ queryState, setQueryState ];
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom hook that exposes the current query state value and a setter for the
|
||||
* given context and query key.
|
||||
*
|
||||
* "Query State" is a wp.data store that keeps track of an arbitrary object of
|
||||
* query keys and their values.
|
||||
*
|
||||
* @param {*} queryKey The specific query key to retrieve the value for.
|
||||
* @param {*} [defaultValue] Default value if query does not exist.
|
||||
* @param {string} [context] What context to retrieve the query state for. If
|
||||
* not provided will attempt to use what is provided
|
||||
* by query state context.
|
||||
*
|
||||
* @return {*} Whatever value is set at the query state index using the
|
||||
* provided context and query key.
|
||||
*/
|
||||
export const useQueryStateByKey = ( queryKey, defaultValue, context ) => {
|
||||
const queryStateContext = useQueryStateContext();
|
||||
context = context || queryStateContext;
|
||||
const queryValue = useSelect(
|
||||
( select ) => {
|
||||
const store = select( storeKey );
|
||||
return store.getValueForQueryKey( context, queryKey, defaultValue );
|
||||
},
|
||||
[ context, queryKey ]
|
||||
);
|
||||
|
||||
const { setQueryValue } = useDispatch( storeKey );
|
||||
const setQueryValueByKey = useCallback(
|
||||
( value ) => {
|
||||
setQueryValue( context, queryKey, value );
|
||||
},
|
||||
[ context, queryKey, setQueryValue ]
|
||||
);
|
||||
|
||||
return [ queryValue, setQueryValueByKey ];
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom hook that works similarly to useQueryStateByContext. However, this
|
||||
* hook allows for synchronizing with a provided queryState object.
|
||||
*
|
||||
* This hook does the following things with the provided `synchronizedQuery`
|
||||
* object:
|
||||
*
|
||||
* - whenever synchronizedQuery varies between renders, the queryState will be
|
||||
* updated to a merged object of the internal queryState and the provided
|
||||
* object. Note, any values from the same properties between objects will
|
||||
* be set from synchronizedQuery.
|
||||
* - if there are no changes between renders, then the existing internal
|
||||
* queryState is always returned.
|
||||
* - on initial render, the synchronizedQuery value is returned.
|
||||
*
|
||||
* Typically, this hook would be used in a scenario where there may be external
|
||||
* triggers for updating the query state (i.e. initial population of query
|
||||
* state by hydration or component attributes, or routing url changes that
|
||||
* affect query state).
|
||||
*
|
||||
* @param {Object} synchronizedQuery A provided query state object to
|
||||
* synchronize internal query state with.
|
||||
* @param {string} [context] What context to retrieve the query state
|
||||
* for. If not provided, will be pulled from
|
||||
* the QueryStateContextProvider in the tree.
|
||||
*/
|
||||
export const useSynchronizedQueryState = ( synchronizedQuery, context ) => {
|
||||
const queryStateContext = useQueryStateContext();
|
||||
context = context || queryStateContext;
|
||||
const [ queryState, setQueryState ] = useQueryStateByContext( context );
|
||||
const currentQueryState = useShallowEqual( queryState );
|
||||
const currentSynchronizedQuery = useShallowEqual( synchronizedQuery );
|
||||
const previousSynchronizedQuery = usePrevious( currentSynchronizedQuery );
|
||||
// used to ensure we allow initial synchronization to occur before
|
||||
// returning non-synced state.
|
||||
const isInitialized = useRef( false );
|
||||
// update queryState anytime incoming synchronizedQuery changes
|
||||
useEffect( () => {
|
||||
if (
|
||||
! isShallowEqual(
|
||||
previousSynchronizedQuery,
|
||||
currentSynchronizedQuery
|
||||
)
|
||||
) {
|
||||
setQueryState(
|
||||
Object.assign( {}, currentQueryState, currentSynchronizedQuery )
|
||||
);
|
||||
isInitialized.current = true;
|
||||
}
|
||||
}, [
|
||||
currentQueryState,
|
||||
currentSynchronizedQuery,
|
||||
previousSynchronizedQuery,
|
||||
setQueryState,
|
||||
] );
|
||||
return isInitialized.current
|
||||
? [ queryState, setQueryState ]
|
||||
: [ synchronizedQuery, setQueryState ];
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { CART_STORE_KEY, CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { useEffect } from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { hasShippingRate } from '@woocommerce/base-components/cart-checkout/totals/shipping/utils';
|
||||
import { hasCollectableRate } from '@woocommerce/base-utils';
|
||||
import { isString } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useShippingData } from './shipping/use-shipping-data';
|
||||
|
||||
export const useShowShippingTotalWarning = () => {
|
||||
const context = 'woocommerce/checkout-totals-block';
|
||||
const errorNoticeId = 'wc-blocks-totals-shipping-warning';
|
||||
|
||||
const { shippingRates } = useShippingData();
|
||||
const hasRates = hasShippingRate( shippingRates );
|
||||
const {
|
||||
prefersCollection,
|
||||
isRateBeingSelected,
|
||||
shippingNotices,
|
||||
cartData,
|
||||
} = useSelect( ( select ) => {
|
||||
return {
|
||||
cartData: select( CART_STORE_KEY ).getCartData(),
|
||||
prefersCollection: select( CHECKOUT_STORE_KEY ).prefersCollection(),
|
||||
isRateBeingSelected:
|
||||
select( CART_STORE_KEY ).isShippingRateBeingSelected(),
|
||||
shippingNotices: select( 'core/notices' ).getNotices( context ),
|
||||
};
|
||||
} );
|
||||
const { createInfoNotice, removeNotice } = useDispatch( 'core/notices' );
|
||||
|
||||
useEffect( () => {
|
||||
if ( ! hasRates || isRateBeingSelected ) {
|
||||
// Early return because shipping rates were not yet loaded from the cart data store, or the user is changing
|
||||
// rate, no need to alter the notice until we know what the actual rate is.
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedRates = cartData?.shippingRates?.reduce(
|
||||
( acc: string[], rate ) => {
|
||||
const selectedRateForPackage = rate.shipping_rates.find(
|
||||
( shippingRate ) => {
|
||||
return shippingRate.selected;
|
||||
}
|
||||
);
|
||||
if (
|
||||
typeof selectedRateForPackage?.method_id !== 'undefined'
|
||||
) {
|
||||
acc.push( selectedRateForPackage?.method_id );
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
const isPickupRateSelected = Object.values( selectedRates ).some(
|
||||
( rate: unknown ) => {
|
||||
if ( isString( rate ) ) {
|
||||
return hasCollectableRate( rate );
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// There is a mismatch between the method the user chose (pickup or shipping) and the currently selected rate.
|
||||
if (
|
||||
hasRates &&
|
||||
! prefersCollection &&
|
||||
! isRateBeingSelected &&
|
||||
isPickupRateSelected &&
|
||||
shippingNotices.length === 0
|
||||
) {
|
||||
createInfoNotice(
|
||||
__(
|
||||
'Totals will be recalculated when a valid shipping method is selected.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
{
|
||||
id: 'wc-blocks-totals-shipping-warning',
|
||||
isDismissible: false,
|
||||
context,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show the notice if they have selected local pickup, or if they have selected a valid regular shipping rate.
|
||||
if (
|
||||
( prefersCollection || ! isPickupRateSelected ) &&
|
||||
shippingNotices.length > 0
|
||||
) {
|
||||
removeNotice( errorNoticeId, context );
|
||||
}
|
||||
}, [
|
||||
cartData?.shippingRates,
|
||||
createInfoNotice,
|
||||
hasRates,
|
||||
isRateBeingSelected,
|
||||
prefersCollection,
|
||||
removeNotice,
|
||||
shippingNotices,
|
||||
shippingRates,
|
||||
] );
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect, useRef } from '@wordpress/element';
|
||||
import { useDispatch } from '@wordpress/data';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import type { CartItem } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from './cart/use-store-cart';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/hooks').StoreCartItemAddToCart} StoreCartItemAddToCart
|
||||
*/
|
||||
|
||||
interface StoreAddToCart {
|
||||
cartQuantity: number;
|
||||
addingToCart: boolean;
|
||||
cartIsLoading: boolean;
|
||||
addToCart: ( quantity?: number ) => Promise< boolean >;
|
||||
}
|
||||
/**
|
||||
* Get the quantity of a product in the cart.
|
||||
*
|
||||
* @param {Object} cartItems Array of items.
|
||||
* @param {number} productId The product id to look for.
|
||||
* @return {number} Quantity in the cart.
|
||||
*/
|
||||
const getQuantityFromCartItems = (
|
||||
cartItems: Array< CartItem >,
|
||||
productId: number
|
||||
): number => {
|
||||
const productItem = cartItems.find( ( { id } ) => id === productId );
|
||||
|
||||
return productItem ? productItem.quantity : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom hook for exposing cart related data for a given product id and an
|
||||
* action for adding a single quantity of the product _to_ the cart.
|
||||
*
|
||||
*
|
||||
* @param {number} productId The product id to be added to the cart.
|
||||
*
|
||||
* @return {StoreCartItemAddToCart} An object exposing data and actions relating
|
||||
* to add to cart functionality.
|
||||
*/
|
||||
export const useStoreAddToCart = ( productId: number ): StoreAddToCart => {
|
||||
const { addItemToCart } = useDispatch( storeKey );
|
||||
const { cartItems, cartIsLoading } = useStoreCart();
|
||||
const { createErrorNotice, removeNotice } = useDispatch( 'core/notices' );
|
||||
|
||||
const [ addingToCart, setAddingToCart ] = useState( false );
|
||||
const currentCartItemQuantity = useRef(
|
||||
getQuantityFromCartItems( cartItems, productId )
|
||||
);
|
||||
|
||||
const addToCart = ( quantity = 1 ) => {
|
||||
setAddingToCart( true );
|
||||
return addItemToCart( productId, quantity )
|
||||
.then( () => {
|
||||
removeNotice( 'add-to-cart' );
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
createErrorNotice( decodeEntities( error.message ), {
|
||||
id: 'add-to-cart',
|
||||
context: 'wc/all-products',
|
||||
isDismissible: true,
|
||||
} );
|
||||
} )
|
||||
.finally( () => {
|
||||
setAddingToCart( false );
|
||||
} );
|
||||
};
|
||||
|
||||
useEffect( () => {
|
||||
const quantity = getQuantityFromCartItems( cartItems, productId );
|
||||
|
||||
if ( quantity !== currentCartItemQuantity.current ) {
|
||||
currentCartItemQuantity.current = quantity;
|
||||
}
|
||||
}, [ cartItems, productId ] );
|
||||
|
||||
return {
|
||||
cartQuantity: Number.isFinite( currentCartItemQuantity.current )
|
||||
? currentCartItemQuantity.current
|
||||
: 0,
|
||||
addingToCart,
|
||||
cartIsLoading,
|
||||
addToCart,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { doAction } from '@wordpress/hooks';
|
||||
import { select } from '@wordpress/data';
|
||||
import { useCallback } from '@wordpress/element';
|
||||
|
||||
type StoreEvent = (
|
||||
eventName: string,
|
||||
eventParams?: Partial< Record< string, unknown > >
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Abstraction on top of @wordpress/hooks for dispatching events via doAction for 3rd parties to hook into.
|
||||
*/
|
||||
export const useStoreEvents = (): {
|
||||
dispatchStoreEvent: StoreEvent;
|
||||
dispatchCheckoutEvent: StoreEvent;
|
||||
} => {
|
||||
const dispatchStoreEvent = useCallback( ( eventName, eventParams = {} ) => {
|
||||
try {
|
||||
doAction(
|
||||
`experimental__woocommerce_blocks-${ eventName }`,
|
||||
eventParams
|
||||
);
|
||||
} catch ( e ) {
|
||||
// We don't handle thrown errors but just console.log for troubleshooting.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error( e );
|
||||
}
|
||||
}, [] );
|
||||
|
||||
const dispatchCheckoutEvent = useCallback(
|
||||
( eventName, eventParams = {} ) => {
|
||||
try {
|
||||
doAction(
|
||||
`experimental__woocommerce_blocks-checkout-${ eventName }`,
|
||||
{
|
||||
...eventParams,
|
||||
storeCart: select( 'wc/store/cart' ).getCartData(),
|
||||
}
|
||||
);
|
||||
} catch ( e ) {
|
||||
// We don't handle thrown errors but just console.log for troubleshooting.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error( e );
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { dispatchStoreEvent, dispatchCheckoutEvent };
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Query, ProductResponseItem } from '@woocommerce/types';
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCollectionHeader, useCollection } from './collections';
|
||||
|
||||
/**
|
||||
* This is a custom hook that is wired up to the `wc/store/collections` data
|
||||
* store for the `wc/store/v1/products` route. Given a query object, this
|
||||
* will ensure a component is kept up to date with the products matching that
|
||||
* query in the store state.
|
||||
*
|
||||
* @param {Object} query An object containing any query arguments to be
|
||||
* included with the collection request for the
|
||||
* products. Does not have to be included.
|
||||
*
|
||||
* @return {Object} This hook will return an object with three properties:
|
||||
* - products An array of product objects.
|
||||
* - totalProducts The total number of products that match
|
||||
* the given query parameters.
|
||||
* - productsLoading A boolean indicating whether the products
|
||||
* are still loading or not.
|
||||
*/
|
||||
export const useStoreProducts = (
|
||||
query: Query
|
||||
): {
|
||||
products: ProductResponseItem[];
|
||||
totalProducts: number;
|
||||
productsLoading: boolean;
|
||||
} => {
|
||||
const collectionOptions = {
|
||||
namespace: '/wc/store/v1',
|
||||
resourceName: 'products',
|
||||
};
|
||||
const { results: products, isLoading: productsLoading } =
|
||||
useCollection< ProductResponseItem >( {
|
||||
...collectionOptions,
|
||||
query,
|
||||
} );
|
||||
const { value: totalProducts } = useCollectionHeader( 'x-wp-total', {
|
||||
...collectionOptions,
|
||||
query,
|
||||
} );
|
||||
return {
|
||||
products,
|
||||
totalProducts: parseInt( totalProducts as string, 10 ),
|
||||
productsLoading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useCallback } from '@wordpress/element';
|
||||
import type {
|
||||
ValidationData,
|
||||
ValidationContextError,
|
||||
} from '@woocommerce/types';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Custom hook for setting for adding errors to the validation system.
|
||||
*/
|
||||
export const useValidation = (): ValidationData => {
|
||||
const { clearValidationError, hideValidationError, setValidationErrors } =
|
||||
useDispatch( VALIDATION_STORE_KEY );
|
||||
|
||||
const prefix = 'extensions-errors';
|
||||
|
||||
const { hasValidationErrors, getValidationError } = useSelect(
|
||||
( mapSelect ) => {
|
||||
const store = mapSelect( VALIDATION_STORE_KEY );
|
||||
return {
|
||||
hasValidationErrors: store.hasValidationErrors(),
|
||||
getValidationError: ( validationErrorId: string ) =>
|
||||
store.getValidationError(
|
||||
`${ prefix }-${ validationErrorId }`
|
||||
),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
hasValidationErrors,
|
||||
getValidationError,
|
||||
clearValidationError: useCallback(
|
||||
( validationErrorId: string ) =>
|
||||
clearValidationError( `${ prefix }-${ validationErrorId }` ),
|
||||
[ clearValidationError ]
|
||||
),
|
||||
hideValidationError: useCallback(
|
||||
( validationErrorId: string ) =>
|
||||
hideValidationError( `${ prefix }-${ validationErrorId }` ),
|
||||
[ hideValidationError ]
|
||||
),
|
||||
setValidationErrors: useCallback(
|
||||
( errorsObject: Record< string, ValidationContextError > ) =>
|
||||
setValidationErrors(
|
||||
Object.fromEntries(
|
||||
Object.entries( errorsObject ).map(
|
||||
( [ validationErrorId, error ] ) => [
|
||||
`${ prefix }-${ validationErrorId }`,
|
||||
error,
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
[ setValidationErrors ]
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './event-emit';
|
||||
export * from './hooks';
|
||||
export * from './providers';
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION_TYPES } from './constants';
|
||||
|
||||
const {
|
||||
SET_PRISTINE,
|
||||
SET_IDLE,
|
||||
SET_DISABLED,
|
||||
SET_PROCESSING,
|
||||
SET_BEFORE_PROCESSING,
|
||||
SET_AFTER_PROCESSING,
|
||||
SET_PROCESSING_RESPONSE,
|
||||
SET_HAS_ERROR,
|
||||
SET_NO_ERROR,
|
||||
SET_QUANTITY,
|
||||
SET_REQUEST_PARAMS,
|
||||
} = ACTION_TYPES;
|
||||
|
||||
/**
|
||||
* All the actions that can be dispatched for the checkout.
|
||||
*/
|
||||
export const actions = {
|
||||
setPristine: () => ( {
|
||||
type: SET_PRISTINE,
|
||||
} ),
|
||||
setIdle: () => ( {
|
||||
type: SET_IDLE,
|
||||
} ),
|
||||
setDisabled: () => ( {
|
||||
type: SET_DISABLED,
|
||||
} ),
|
||||
setProcessing: () => ( {
|
||||
type: SET_PROCESSING,
|
||||
} ),
|
||||
setBeforeProcessing: () => ( {
|
||||
type: SET_BEFORE_PROCESSING,
|
||||
} ),
|
||||
setAfterProcessing: () => ( {
|
||||
type: SET_AFTER_PROCESSING,
|
||||
} ),
|
||||
setProcessingResponse: ( data ) => ( {
|
||||
type: SET_PROCESSING_RESPONSE,
|
||||
data,
|
||||
} ),
|
||||
setHasError: ( hasError = true ) => {
|
||||
const type = hasError ? SET_HAS_ERROR : SET_NO_ERROR;
|
||||
return { type };
|
||||
},
|
||||
setQuantity: ( quantity ) => ( {
|
||||
type: SET_QUANTITY,
|
||||
quantity,
|
||||
} ),
|
||||
setRequestParams: ( data ) => ( {
|
||||
type: SET_REQUEST_PARAMS,
|
||||
data,
|
||||
} ),
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @type {import("@woocommerce/type-defs/add-to-cart-form").AddToCartFormStatusConstants}
|
||||
*/
|
||||
export const STATUS = {
|
||||
PRISTINE: 'pristine',
|
||||
IDLE: 'idle',
|
||||
DISABLED: 'disabled',
|
||||
PROCESSING: 'processing',
|
||||
BEFORE_PROCESSING: 'before_processing',
|
||||
AFTER_PROCESSING: 'after_processing',
|
||||
};
|
||||
|
||||
export const DEFAULT_STATE = {
|
||||
status: STATUS.PRISTINE,
|
||||
hasError: false,
|
||||
quantity: 0,
|
||||
processingResponse: null,
|
||||
requestParams: {},
|
||||
};
|
||||
export const ACTION_TYPES = {
|
||||
SET_PRISTINE: 'set_pristine',
|
||||
SET_IDLE: 'set_idle',
|
||||
SET_DISABLED: 'set_disabled',
|
||||
SET_PROCESSING: 'set_processing',
|
||||
SET_BEFORE_PROCESSING: 'set_before_processing',
|
||||
SET_AFTER_PROCESSING: 'set_after_processing',
|
||||
SET_PROCESSING_RESPONSE: 'set_processing_response',
|
||||
SET_HAS_ERROR: 'set_has_error',
|
||||
SET_NO_ERROR: 'set_no_error',
|
||||
SET_QUANTITY: 'set_quantity',
|
||||
SET_REQUEST_PARAMS: 'set_request_params',
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
emitterCallback,
|
||||
reducer,
|
||||
emitEvent,
|
||||
emitEventWithAbort,
|
||||
} from '../../../event-emit';
|
||||
|
||||
const EMIT_TYPES = {
|
||||
ADD_TO_CART_BEFORE_PROCESSING: 'add_to_cart_before_processing',
|
||||
ADD_TO_CART_AFTER_PROCESSING_WITH_SUCCESS:
|
||||
'add_to_cart_after_processing_with_success',
|
||||
ADD_TO_CART_AFTER_PROCESSING_WITH_ERROR:
|
||||
'add_to_cart_after_processing_with_error',
|
||||
};
|
||||
|
||||
/**
|
||||
* Receives a reducer dispatcher and returns an object with the callback registration function for
|
||||
* the add to cart emit events.
|
||||
*
|
||||
* Calling the event registration function with the callback will register it for the event emitter
|
||||
* and will return a dispatcher for removing the registered callback (useful for implementation
|
||||
* in `useEffect`).
|
||||
*
|
||||
* @param {Function} dispatcher The emitter reducer dispatcher.
|
||||
*
|
||||
* @return {Object} An object with the add to cart form emitter registration
|
||||
*/
|
||||
const emitterObservers = ( dispatcher ) => ( {
|
||||
onAddToCartAfterProcessingWithSuccess: emitterCallback(
|
||||
EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_SUCCESS,
|
||||
dispatcher
|
||||
),
|
||||
onAddToCartProcessingWithError: emitterCallback(
|
||||
EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_ERROR,
|
||||
dispatcher
|
||||
),
|
||||
onAddToCartBeforeProcessing: emitterCallback(
|
||||
EMIT_TYPES.ADD_TO_CART_BEFORE_PROCESSING,
|
||||
dispatcher
|
||||
),
|
||||
} );
|
||||
|
||||
export { EMIT_TYPES, emitterObservers, reducer, emitEvent, emitEventWithAbort };
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
import {
|
||||
productIsPurchasable,
|
||||
productSupportsAddToCartForm,
|
||||
} from '@woocommerce/base-utils';
|
||||
import { useDispatch } from '@wordpress/data';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { actions } from './actions';
|
||||
import { reducer } from './reducer';
|
||||
import { DEFAULT_STATE, STATUS } from './constants';
|
||||
import {
|
||||
EMIT_TYPES,
|
||||
emitterObservers,
|
||||
emitEvent,
|
||||
emitEventWithAbort,
|
||||
reducer as emitReducer,
|
||||
} from './event-emit';
|
||||
import { isErrorResponse, isFailResponse } from '../../../event-emit';
|
||||
import { removeNoticesByStatus } from '../../../../../utils/notices';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/add-to-cart-form').AddToCartFormDispatchActions} AddToCartFormDispatchActions
|
||||
* @typedef {import('@woocommerce/type-defs/add-to-cart-form').AddToCartFormEventRegistration} AddToCartFormEventRegistration
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').AddToCartFormContext} AddToCartFormContext
|
||||
*/
|
||||
|
||||
const AddToCartFormContext = createContext( {
|
||||
product: {},
|
||||
productType: 'simple',
|
||||
productIsPurchasable: true,
|
||||
productHasOptions: false,
|
||||
supportsFormElements: true,
|
||||
showFormElements: false,
|
||||
quantity: 0,
|
||||
minQuantity: 1,
|
||||
maxQuantity: 99,
|
||||
requestParams: {},
|
||||
isIdle: false,
|
||||
isDisabled: false,
|
||||
isProcessing: false,
|
||||
isBeforeProcessing: false,
|
||||
isAfterProcessing: false,
|
||||
hasError: false,
|
||||
eventRegistration: {
|
||||
onAddToCartAfterProcessingWithSuccess: ( callback ) => void callback,
|
||||
onAddToCartAfterProcessingWithError: ( callback ) => void callback,
|
||||
onAddToCartBeforeProcessing: ( callback ) => void callback,
|
||||
},
|
||||
dispatchActions: {
|
||||
resetForm: () => void null,
|
||||
submitForm: () => void null,
|
||||
setQuantity: ( quantity ) => void quantity,
|
||||
setHasError: ( hasError ) => void hasError,
|
||||
setAfterProcessing: ( response ) => void response,
|
||||
setRequestParams: ( data ) => void data,
|
||||
},
|
||||
} );
|
||||
|
||||
/**
|
||||
* @return {AddToCartFormContext} Returns the add to cart form data context value
|
||||
*/
|
||||
export const useAddToCartFormContext = () => {
|
||||
// @ts-ignore
|
||||
return useContext( AddToCartFormContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Add to cart form state provider.
|
||||
*
|
||||
* This provides provides an api interface exposing add to cart form state.
|
||||
*
|
||||
* @param {Object} props Incoming props for the provider.
|
||||
* @param {Object} props.children The children being wrapped.
|
||||
* @param {Object} [props.product] The product for which the form belongs to.
|
||||
* @param {boolean} [props.showFormElements] Should form elements be shown.
|
||||
*/
|
||||
export const AddToCartFormStateContextProvider = ( {
|
||||
children,
|
||||
product,
|
||||
showFormElements,
|
||||
} ) => {
|
||||
const [ addToCartFormState, dispatch ] = useReducer(
|
||||
reducer,
|
||||
DEFAULT_STATE
|
||||
);
|
||||
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
|
||||
const currentObservers = useShallowEqual( observers );
|
||||
const { createErrorNotice } = useDispatch( 'core/notices' );
|
||||
const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
|
||||
|
||||
/**
|
||||
* @type {AddToCartFormEventRegistration}
|
||||
*/
|
||||
const eventRegistration = useMemo(
|
||||
() => ( {
|
||||
onAddToCartAfterProcessingWithSuccess:
|
||||
emitterObservers( observerDispatch )
|
||||
.onAddToCartAfterProcessingWithSuccess,
|
||||
onAddToCartAfterProcessingWithError:
|
||||
emitterObservers( observerDispatch )
|
||||
.onAddToCartAfterProcessingWithError,
|
||||
onAddToCartBeforeProcessing:
|
||||
emitterObservers( observerDispatch )
|
||||
.onAddToCartBeforeProcessing,
|
||||
} ),
|
||||
[ observerDispatch ]
|
||||
);
|
||||
|
||||
/**
|
||||
* @type {AddToCartFormDispatchActions}
|
||||
*/
|
||||
const dispatchActions = useMemo(
|
||||
() => ( {
|
||||
resetForm: () => void dispatch( actions.setPristine() ),
|
||||
submitForm: () => void dispatch( actions.setBeforeProcessing() ),
|
||||
setQuantity: ( quantity ) =>
|
||||
void dispatch( actions.setQuantity( quantity ) ),
|
||||
setHasError: ( hasError ) =>
|
||||
void dispatch( actions.setHasError( hasError ) ),
|
||||
setRequestParams: ( data ) =>
|
||||
void dispatch( actions.setRequestParams( data ) ),
|
||||
setAfterProcessing: ( response ) => {
|
||||
dispatch( actions.setProcessingResponse( response ) );
|
||||
void dispatch( actions.setAfterProcessing() );
|
||||
},
|
||||
} ),
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* This Effect is responsible for disabling or enabling the form based on the provided product.
|
||||
*/
|
||||
useEffect( () => {
|
||||
const status = addToCartFormState.status;
|
||||
const willBeDisabled =
|
||||
! product.id || ! productIsPurchasable( product );
|
||||
|
||||
if ( status === STATUS.DISABLED && ! willBeDisabled ) {
|
||||
dispatch( actions.setIdle() );
|
||||
} else if ( status !== STATUS.DISABLED && willBeDisabled ) {
|
||||
dispatch( actions.setDisabled() );
|
||||
}
|
||||
}, [ addToCartFormState.status, product, dispatch ] );
|
||||
|
||||
/**
|
||||
* This Effect performs events before processing starts.
|
||||
*/
|
||||
useEffect( () => {
|
||||
const status = addToCartFormState.status;
|
||||
|
||||
if ( status === STATUS.BEFORE_PROCESSING ) {
|
||||
removeNoticesByStatus( 'error', 'wc/add-to-cart' );
|
||||
emitEvent(
|
||||
currentObservers,
|
||||
EMIT_TYPES.ADD_TO_CART_BEFORE_PROCESSING,
|
||||
{}
|
||||
).then( ( response ) => {
|
||||
if ( response !== true ) {
|
||||
if ( Array.isArray( response ) ) {
|
||||
response.forEach(
|
||||
( { errorMessage, validationErrors } ) => {
|
||||
if ( errorMessage ) {
|
||||
createErrorNotice( errorMessage, {
|
||||
context: 'wc/add-to-cart',
|
||||
} );
|
||||
}
|
||||
if ( validationErrors ) {
|
||||
setValidationErrors( validationErrors );
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
dispatch( actions.setIdle() );
|
||||
} else {
|
||||
dispatch( actions.setProcessing() );
|
||||
}
|
||||
} );
|
||||
}
|
||||
}, [
|
||||
addToCartFormState.status,
|
||||
setValidationErrors,
|
||||
createErrorNotice,
|
||||
dispatch,
|
||||
currentObservers,
|
||||
product?.id,
|
||||
] );
|
||||
|
||||
/**
|
||||
* This Effect performs events after processing is complete.
|
||||
*/
|
||||
useEffect( () => {
|
||||
if ( addToCartFormState.status === STATUS.AFTER_PROCESSING ) {
|
||||
// @todo: This data package differs from what is passed through in
|
||||
// the checkout state context. Should we introduce a "context"
|
||||
// property in the data package for this emitted event so that
|
||||
// observers are able to know what context the event is firing in?
|
||||
const data = {
|
||||
processingResponse: addToCartFormState.processingResponse,
|
||||
};
|
||||
|
||||
const handleErrorResponse = ( observerResponses ) => {
|
||||
let handled = false;
|
||||
observerResponses.forEach( ( response ) => {
|
||||
const { message, messageContext } = response;
|
||||
if (
|
||||
( isErrorResponse( response ) ||
|
||||
isFailResponse( response ) ) &&
|
||||
message
|
||||
) {
|
||||
const errorOptions = messageContext
|
||||
? { context: messageContext }
|
||||
: undefined;
|
||||
handled = true;
|
||||
createErrorNotice( message, errorOptions );
|
||||
}
|
||||
} );
|
||||
return handled;
|
||||
};
|
||||
|
||||
if ( addToCartFormState.hasError ) {
|
||||
// allow things to customize the error with a fallback if nothing customizes it.
|
||||
emitEventWithAbort(
|
||||
currentObservers,
|
||||
EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_ERROR,
|
||||
data
|
||||
).then( ( observerResponses ) => {
|
||||
if ( ! handleErrorResponse( observerResponses ) ) {
|
||||
// no error handling in place by anything so let's fall back to default
|
||||
const message =
|
||||
data.processingResponse?.message ||
|
||||
__(
|
||||
'Something went wrong. Please contact us for assistance.',
|
||||
'woocommerce'
|
||||
);
|
||||
createErrorNotice( message, {
|
||||
id: 'add-to-cart',
|
||||
context: `woocommerce/single-product/${
|
||||
product?.id || 0
|
||||
}`,
|
||||
} );
|
||||
}
|
||||
dispatch( actions.setIdle() );
|
||||
} );
|
||||
return;
|
||||
}
|
||||
|
||||
emitEventWithAbort(
|
||||
currentObservers,
|
||||
EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_SUCCESS,
|
||||
data
|
||||
).then( ( observerResponses ) => {
|
||||
if ( handleErrorResponse( observerResponses ) ) {
|
||||
// this will set an error which will end up
|
||||
// triggering the onAddToCartAfterProcessingWithError emitter.
|
||||
// and then setting to IDLE state.
|
||||
dispatch( actions.setHasError( true ) );
|
||||
} else {
|
||||
dispatch( actions.setIdle() );
|
||||
}
|
||||
} );
|
||||
}
|
||||
}, [
|
||||
addToCartFormState.status,
|
||||
addToCartFormState.hasError,
|
||||
addToCartFormState.processingResponse,
|
||||
dispatchActions,
|
||||
createErrorNotice,
|
||||
currentObservers,
|
||||
product?.id,
|
||||
] );
|
||||
|
||||
const supportsFormElements = productSupportsAddToCartForm( product );
|
||||
|
||||
/**
|
||||
* @type {AddToCartFormContext}
|
||||
*/
|
||||
const contextData = {
|
||||
product,
|
||||
productType: product.type || 'simple',
|
||||
productIsPurchasable: productIsPurchasable( product ),
|
||||
productHasOptions: product.has_options || false,
|
||||
supportsFormElements,
|
||||
showFormElements: showFormElements && supportsFormElements,
|
||||
quantity:
|
||||
addToCartFormState.quantity || product?.add_to_cart?.minimum || 1,
|
||||
minQuantity: product?.add_to_cart?.minimum || 1,
|
||||
maxQuantity: product?.add_to_cart?.maximum || 99,
|
||||
multipleOf: product?.add_to_cart?.multiple_of || 1,
|
||||
requestParams: addToCartFormState.requestParams,
|
||||
isIdle: addToCartFormState.status === STATUS.IDLE,
|
||||
isDisabled: addToCartFormState.status === STATUS.DISABLED,
|
||||
isProcessing: addToCartFormState.status === STATUS.PROCESSING,
|
||||
isBeforeProcessing:
|
||||
addToCartFormState.status === STATUS.BEFORE_PROCESSING,
|
||||
isAfterProcessing:
|
||||
addToCartFormState.status === STATUS.AFTER_PROCESSING,
|
||||
hasError: addToCartFormState.hasError,
|
||||
eventRegistration,
|
||||
dispatchActions,
|
||||
};
|
||||
return (
|
||||
<AddToCartFormContext.Provider
|
||||
// @ts-ignore
|
||||
value={ contextData }
|
||||
>
|
||||
{ children }
|
||||
</AddToCartFormContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION_TYPES, DEFAULT_STATE, STATUS } from './constants';
|
||||
|
||||
const {
|
||||
SET_PRISTINE,
|
||||
SET_IDLE,
|
||||
SET_DISABLED,
|
||||
SET_PROCESSING,
|
||||
SET_BEFORE_PROCESSING,
|
||||
SET_AFTER_PROCESSING,
|
||||
SET_PROCESSING_RESPONSE,
|
||||
SET_HAS_ERROR,
|
||||
SET_NO_ERROR,
|
||||
SET_QUANTITY,
|
||||
SET_REQUEST_PARAMS,
|
||||
} = ACTION_TYPES;
|
||||
|
||||
const {
|
||||
PRISTINE,
|
||||
IDLE,
|
||||
DISABLED,
|
||||
PROCESSING,
|
||||
BEFORE_PROCESSING,
|
||||
AFTER_PROCESSING,
|
||||
} = STATUS;
|
||||
|
||||
/**
|
||||
* Reducer for the checkout state
|
||||
*
|
||||
* @param {Object} state Current state.
|
||||
* @param {Object} action Incoming action object.
|
||||
* @param {number} action.quantity Incoming quantity.
|
||||
* @param {string} action.type Type of action.
|
||||
* @param {Object} action.data Incoming payload for action.
|
||||
*/
|
||||
export const reducer = ( state = DEFAULT_STATE, { quantity, type, data } ) => {
|
||||
let newState;
|
||||
switch ( type ) {
|
||||
case SET_PRISTINE:
|
||||
newState = DEFAULT_STATE;
|
||||
break;
|
||||
case SET_IDLE:
|
||||
newState =
|
||||
state.status !== IDLE
|
||||
? {
|
||||
...state,
|
||||
status: IDLE,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case SET_DISABLED:
|
||||
newState =
|
||||
state.status !== DISABLED
|
||||
? {
|
||||
...state,
|
||||
status: DISABLED,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case SET_QUANTITY:
|
||||
newState =
|
||||
quantity !== state.quantity
|
||||
? {
|
||||
...state,
|
||||
quantity,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case SET_REQUEST_PARAMS:
|
||||
newState = {
|
||||
...state,
|
||||
requestParams: {
|
||||
...state.requestParams,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case SET_PROCESSING_RESPONSE:
|
||||
newState = {
|
||||
...state,
|
||||
processingResponse: data,
|
||||
};
|
||||
break;
|
||||
case SET_PROCESSING:
|
||||
newState =
|
||||
state.status !== PROCESSING
|
||||
? {
|
||||
...state,
|
||||
status: PROCESSING,
|
||||
hasError: false,
|
||||
}
|
||||
: state;
|
||||
// clear any error state.
|
||||
newState =
|
||||
newState.hasError === false
|
||||
? newState
|
||||
: { ...newState, hasError: false };
|
||||
break;
|
||||
case SET_BEFORE_PROCESSING:
|
||||
newState =
|
||||
state.status !== BEFORE_PROCESSING
|
||||
? {
|
||||
...state,
|
||||
status: BEFORE_PROCESSING,
|
||||
hasError: false,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case SET_AFTER_PROCESSING:
|
||||
newState =
|
||||
state.status !== AFTER_PROCESSING
|
||||
? {
|
||||
...state,
|
||||
status: AFTER_PROCESSING,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case SET_HAS_ERROR:
|
||||
newState = state.hasError
|
||||
? state
|
||||
: {
|
||||
...state,
|
||||
hasError: true,
|
||||
};
|
||||
newState =
|
||||
state.status === PROCESSING ||
|
||||
state.status === BEFORE_PROCESSING
|
||||
? {
|
||||
...newState,
|
||||
status: IDLE,
|
||||
}
|
||||
: newState;
|
||||
break;
|
||||
case SET_NO_ERROR:
|
||||
newState = state.hasError
|
||||
? {
|
||||
...state,
|
||||
hasError: false,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
}
|
||||
// automatically update state to idle from pristine as soon as it initially changes.
|
||||
if (
|
||||
newState !== state &&
|
||||
type !== SET_PRISTINE &&
|
||||
newState.status === PRISTINE
|
||||
) {
|
||||
newState.status = IDLE;
|
||||
}
|
||||
return newState;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { AddToCartFormStateContextProvider } from '../form-state';
|
||||
import FormSubmit from './submit';
|
||||
|
||||
/**
|
||||
* Add to cart form provider.
|
||||
*
|
||||
* This wraps the add to cart form and provides an api interface for children via various hooks.
|
||||
*
|
||||
* @param {Object} props Incoming props for the provider.
|
||||
* @param {Object} props.children The children being wrapped.
|
||||
* @param {Object} [props.product] The product for which the form belongs to.
|
||||
* @param {boolean} [props.showFormElements] Should form elements be shown.
|
||||
*/
|
||||
export const AddToCartFormContextProvider = ( {
|
||||
children,
|
||||
product,
|
||||
showFormElements,
|
||||
} ) => {
|
||||
return (
|
||||
<AddToCartFormStateContextProvider
|
||||
product={ product }
|
||||
showFormElements={ showFormElements }
|
||||
>
|
||||
{ children }
|
||||
<FormSubmit />
|
||||
</AddToCartFormStateContextProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import triggerFetch from '@wordpress/api-fetch';
|
||||
import { useEffect, useCallback, useState } from '@wordpress/element';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import { triggerAddedToCartEvent } from '@woocommerce/base-utils';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useAddToCartFormContext } from '../../form-state';
|
||||
import { useStoreCart } from '../../../../hooks/cart/use-store-cart';
|
||||
|
||||
/**
|
||||
* FormSubmit.
|
||||
*
|
||||
* Subscribes to add to cart form context and triggers processing via the API.
|
||||
*/
|
||||
const FormSubmit = () => {
|
||||
const {
|
||||
dispatchActions,
|
||||
product,
|
||||
quantity,
|
||||
eventRegistration,
|
||||
hasError,
|
||||
isProcessing,
|
||||
requestParams,
|
||||
} = useAddToCartFormContext();
|
||||
const { showAllValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
|
||||
const hasValidationErrors = useSelect( ( select ) => {
|
||||
const store = select( VALIDATION_STORE_KEY );
|
||||
return store.hasValidationErrors;
|
||||
} );
|
||||
const { createErrorNotice, removeNotice } = useDispatch( 'core/notices' );
|
||||
const { receiveCart } = useStoreCart();
|
||||
const [ isSubmitting, setIsSubmitting ] = useState( false );
|
||||
const doSubmit = ! hasError && isProcessing;
|
||||
|
||||
const checkValidationContext = useCallback( () => {
|
||||
if ( hasValidationErrors() ) {
|
||||
showAllValidationErrors();
|
||||
return {
|
||||
type: 'error',
|
||||
};
|
||||
}
|
||||
return true;
|
||||
}, [ hasValidationErrors, showAllValidationErrors ] );
|
||||
|
||||
// Subscribe to emitter before processing.
|
||||
useEffect( () => {
|
||||
const unsubscribeProcessing =
|
||||
eventRegistration.onAddToCartBeforeProcessing(
|
||||
checkValidationContext,
|
||||
0
|
||||
);
|
||||
return () => {
|
||||
unsubscribeProcessing();
|
||||
};
|
||||
}, [ eventRegistration, checkValidationContext ] );
|
||||
|
||||
// Triggers form submission to the API.
|
||||
const submitFormCallback = useCallback( () => {
|
||||
setIsSubmitting( true );
|
||||
removeNotice(
|
||||
'add-to-cart',
|
||||
`woocommerce/single-product/${ product?.id || 0 }`
|
||||
);
|
||||
|
||||
const fetchData = {
|
||||
id: product.id || 0,
|
||||
quantity,
|
||||
...requestParams,
|
||||
};
|
||||
|
||||
triggerFetch( {
|
||||
path: '/wc/store/v1/cart/add-item',
|
||||
method: 'POST',
|
||||
data: fetchData,
|
||||
cache: 'no-store',
|
||||
parse: false,
|
||||
} )
|
||||
.then( ( fetchResponse ) => {
|
||||
// Update nonce.
|
||||
triggerFetch.setNonce( fetchResponse.headers );
|
||||
|
||||
// Handle response.
|
||||
fetchResponse.json().then( function ( response ) {
|
||||
if ( ! fetchResponse.ok ) {
|
||||
// We received an error response.
|
||||
if ( response.body && response.body.message ) {
|
||||
createErrorNotice(
|
||||
decodeEntities( response.body.message ),
|
||||
{
|
||||
id: 'add-to-cart',
|
||||
context: `woocommerce/single-product/${
|
||||
product?.id || 0
|
||||
}`,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
createErrorNotice(
|
||||
__(
|
||||
'Something went wrong. Please contact us for assistance.',
|
||||
'woocommerce'
|
||||
),
|
||||
{
|
||||
id: 'add-to-cart',
|
||||
context: `woocommerce/single-product/${
|
||||
product?.id || 0
|
||||
}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
dispatchActions.setHasError();
|
||||
} else {
|
||||
receiveCart( response );
|
||||
}
|
||||
triggerAddedToCartEvent( { preserveCartData: true } );
|
||||
dispatchActions.setAfterProcessing( response );
|
||||
setIsSubmitting( false );
|
||||
} );
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
error.json().then( function ( response ) {
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( response.data?.cart ) {
|
||||
receiveCart( response.data.cart );
|
||||
}
|
||||
dispatchActions.setHasError();
|
||||
dispatchActions.setAfterProcessing( response );
|
||||
setIsSubmitting( false );
|
||||
} );
|
||||
} );
|
||||
}, [
|
||||
product,
|
||||
createErrorNotice,
|
||||
removeNotice,
|
||||
receiveCart,
|
||||
dispatchActions,
|
||||
quantity,
|
||||
requestParams,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( doSubmit && ! isSubmitting ) {
|
||||
submitFormCallback();
|
||||
}
|
||||
}, [ doSubmit, submitFormCallback, isSubmitting ] );
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default FormSubmit;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './form';
|
||||
export * from './form-state';
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
emitterCallback,
|
||||
reducer,
|
||||
emitEvent,
|
||||
emitEventWithAbort,
|
||||
ActionType,
|
||||
} from '../../../event-emit';
|
||||
|
||||
// These events are emitted when the Cart status is BEFORE_PROCESSING and AFTER_PROCESSING
|
||||
// to enable third parties to hook into the cart process
|
||||
const EVENTS = {
|
||||
PROCEED_TO_CHECKOUT: 'cart_proceed_to_checkout',
|
||||
};
|
||||
|
||||
type EventEmittersType = Record< string, ReturnType< typeof emitterCallback > >;
|
||||
|
||||
/**
|
||||
* Receives a reducer dispatcher and returns an object with the
|
||||
* various event emitters for the payment processing events.
|
||||
*
|
||||
* Calling the event registration function with the callback will register it
|
||||
* for the event emitter and will return a dispatcher for removing the
|
||||
* registered callback (useful for implementation in `useEffect`).
|
||||
*
|
||||
* @param {Function} observerDispatch The emitter reducer dispatcher.
|
||||
* @return {Object} An object with the various payment event emitter registration functions
|
||||
*/
|
||||
const useEventEmitters = (
|
||||
observerDispatch: React.Dispatch< ActionType >
|
||||
): EventEmittersType => {
|
||||
const eventEmitters = useMemo(
|
||||
() => ( {
|
||||
onProceedToCheckout: emitterCallback(
|
||||
EVENTS.PROCEED_TO_CHECKOUT,
|
||||
observerDispatch
|
||||
),
|
||||
} ),
|
||||
[ observerDispatch ]
|
||||
);
|
||||
return eventEmitters;
|
||||
};
|
||||
|
||||
export { EVENTS, useEventEmitters, reducer, emitEvent, emitEventWithAbort };
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
useEventEmitters,
|
||||
reducer as emitReducer,
|
||||
emitEventWithAbort,
|
||||
EVENTS,
|
||||
} from './event-emit';
|
||||
import type { emitterCallback } from '../../../event-emit';
|
||||
|
||||
type CartEventsContextType = {
|
||||
// Used to register a callback that will fire when the cart has been processed and has an error.
|
||||
onProceedToCheckout: ReturnType< typeof emitterCallback >;
|
||||
// Used to register a callback that will fire when the cart has been processed and has an error.
|
||||
dispatchOnProceedToCheckout: () => Promise< unknown[] >;
|
||||
};
|
||||
|
||||
const CartEventsContext = createContext< CartEventsContextType >( {
|
||||
onProceedToCheckout: () => () => void null,
|
||||
dispatchOnProceedToCheckout: () => new Promise( () => void null ),
|
||||
} );
|
||||
|
||||
export const useCartEventsContext = () => {
|
||||
return useContext( CartEventsContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Checkout Events provider
|
||||
* Emit Checkout events and provide access to Checkout event handlers
|
||||
*
|
||||
* @param {Object} props Incoming props for the provider.
|
||||
* @param {Object} props.children The children being wrapped.
|
||||
*/
|
||||
export const CartEventsProvider = ( {
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
} ): JSX.Element => {
|
||||
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
|
||||
const currentObservers = useRef( observers );
|
||||
const { onProceedToCheckout } = useEventEmitters( observerDispatch );
|
||||
|
||||
// set observers on ref so it's always current.
|
||||
useEffect( () => {
|
||||
currentObservers.current = observers;
|
||||
}, [ observers ] );
|
||||
|
||||
const dispatchOnProceedToCheckout = async () => {
|
||||
return await emitEventWithAbort(
|
||||
currentObservers.current,
|
||||
EVENTS.PROCEED_TO_CHECKOUT,
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
const cartEvents = {
|
||||
onProceedToCheckout,
|
||||
dispatchOnProceedToCheckout,
|
||||
};
|
||||
return (
|
||||
<CartEventsContext.Provider value={ cartEvents }>
|
||||
{ children }
|
||||
</CartEventsContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useCartEventsContext } from '@woocommerce/base-context';
|
||||
import { useEffect } from '@wordpress/element';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { CartEventsProvider } from '../index';
|
||||
import Block from '../../../../../../blocks/cart/inner-blocks/proceed-to-checkout-block/block';
|
||||
|
||||
describe( 'CartEventsProvider', () => {
|
||||
it( 'allows observers to unsubscribe', async () => {
|
||||
const mockObserver = jest.fn().mockReturnValue( { type: 'error' } );
|
||||
const MockObserverComponent = () => {
|
||||
const { onProceedToCheckout } = useCartEventsContext();
|
||||
useEffect( () => {
|
||||
const unsubscribe = onProceedToCheckout( () => {
|
||||
mockObserver();
|
||||
unsubscribe();
|
||||
} );
|
||||
}, [ onProceedToCheckout ] );
|
||||
return <div>Mock observer</div>;
|
||||
};
|
||||
|
||||
render(
|
||||
<CartEventsProvider>
|
||||
<div>
|
||||
<MockObserverComponent />
|
||||
<Block checkoutPageId={ 0 } className="test-block" />
|
||||
</div>
|
||||
</CartEventsProvider>
|
||||
);
|
||||
expect( screen.getByText( 'Mock observer' ) ).toBeInTheDocument();
|
||||
const button = screen.getByText( 'Proceed to Checkout' );
|
||||
|
||||
// Forcibly set the button URL to # to prevent JSDOM error: `["Error: Not implemented: navigation (except hash changes)`
|
||||
button.parentElement?.removeAttribute( 'href' );
|
||||
|
||||
// Click twice. The observer should unsubscribe after the first click.
|
||||
button.click();
|
||||
button.click();
|
||||
await waitFor( () => {
|
||||
expect( mockObserver ).toHaveBeenCalledTimes( 1 );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { CheckoutProvider } from '../checkout-provider';
|
||||
|
||||
/**
|
||||
* Cart provider
|
||||
* This wraps the Cart and provides an api interface for the Cart to
|
||||
* children via various hooks.
|
||||
*
|
||||
* @param {Object} props Incoming props for the provider.
|
||||
* @param {Object} [props.children] The children being wrapped.
|
||||
* @param {string} [props.redirectUrl] Initialize what the cart will
|
||||
* redirect to after successful
|
||||
* submit.
|
||||
*/
|
||||
export const CartProvider = ( { children, redirectUrl } ) => {
|
||||
return (
|
||||
<CheckoutProvider redirectUrl={ redirectUrl }>
|
||||
{ children }
|
||||
</CheckoutProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
emitterCallback,
|
||||
reducer,
|
||||
emitEvent,
|
||||
emitEventWithAbort,
|
||||
ActionType,
|
||||
} from '../../../event-emit';
|
||||
|
||||
// These events are emitted when the Checkout status is BEFORE_PROCESSING and AFTER_PROCESSING
|
||||
// to enable third parties to hook into the checkout process
|
||||
const EVENTS = {
|
||||
CHECKOUT_SUCCESS: 'checkout_success',
|
||||
CHECKOUT_FAIL: 'checkout_fail',
|
||||
CHECKOUT_VALIDATION: 'checkout_validation',
|
||||
};
|
||||
|
||||
type EventEmittersType = Record< string, ReturnType< typeof emitterCallback > >;
|
||||
|
||||
/**
|
||||
* Receives a reducer dispatcher and returns an object with the
|
||||
* various event emitters for the payment processing events.
|
||||
*
|
||||
* Calling the event registration function with the callback will register it
|
||||
* for the event emitter and will return a dispatcher for removing the
|
||||
* registered callback (useful for implementation in `useEffect`).
|
||||
*
|
||||
* @param {Function} observerDispatch The emitter reducer dispatcher.
|
||||
* @return {Object} An object with the various payment event emitter registration functions
|
||||
*/
|
||||
const useEventEmitters = (
|
||||
observerDispatch: React.Dispatch< ActionType >
|
||||
): EventEmittersType => {
|
||||
const eventEmitters = useMemo(
|
||||
() => ( {
|
||||
onCheckoutSuccess: emitterCallback(
|
||||
EVENTS.CHECKOUT_SUCCESS,
|
||||
observerDispatch
|
||||
),
|
||||
onCheckoutFail: emitterCallback(
|
||||
EVENTS.CHECKOUT_FAIL,
|
||||
observerDispatch
|
||||
),
|
||||
onCheckoutValidation: emitterCallback(
|
||||
EVENTS.CHECKOUT_VALIDATION,
|
||||
observerDispatch
|
||||
),
|
||||
} ),
|
||||
[ observerDispatch ]
|
||||
);
|
||||
return eventEmitters;
|
||||
};
|
||||
|
||||
export { EVENTS, useEventEmitters, reducer, emitEvent, emitEventWithAbort };
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useRef,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from '@wordpress/element';
|
||||
import { usePrevious } from '@woocommerce/base-hooks';
|
||||
import deprecated from '@wordpress/deprecated';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import {
|
||||
CHECKOUT_STORE_KEY,
|
||||
PAYMENT_STORE_KEY,
|
||||
VALIDATION_STORE_KEY,
|
||||
} from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useEventEmitters, reducer as emitReducer } from './event-emit';
|
||||
import { emitterCallback, noticeContexts } from '../../../event-emit';
|
||||
import { useStoreEvents } from '../../../hooks/use-store-events';
|
||||
import {
|
||||
getExpressPaymentMethods,
|
||||
getPaymentMethods,
|
||||
} from '../../../../../blocks-registry/payment-methods/registry';
|
||||
import { useEditorContext } from '../../editor-context';
|
||||
|
||||
type CheckoutEventsContextType = {
|
||||
// Submits the checkout and begins processing.
|
||||
onSubmit: () => void;
|
||||
// Deprecated in favour of onCheckoutSuccess.
|
||||
onCheckoutAfterProcessingWithSuccess: ReturnType< typeof emitterCallback >;
|
||||
// Deprecated in favour of onCheckoutFail.
|
||||
onCheckoutAfterProcessingWithError: ReturnType< typeof emitterCallback >;
|
||||
// Deprecated in favour of onCheckoutValidationBeforeProcessing.
|
||||
onCheckoutBeforeProcessing: ReturnType< typeof emitterCallback >;
|
||||
// Deprecated in favour of onCheckoutValidation.
|
||||
onCheckoutValidationBeforeProcessing: ReturnType< typeof emitterCallback >;
|
||||
// Used to register a callback that will fire if the api call to /checkout is successful
|
||||
onCheckoutSuccess: ReturnType< typeof emitterCallback >;
|
||||
// Used to register a callback that will fire if the api call to /checkout fails
|
||||
onCheckoutFail: ReturnType< typeof emitterCallback >;
|
||||
// Used to register a callback that will fire when the checkout performs validation on the form
|
||||
onCheckoutValidation: ReturnType< typeof emitterCallback >;
|
||||
};
|
||||
|
||||
const CheckoutEventsContext = createContext< CheckoutEventsContextType >( {
|
||||
onSubmit: () => void null,
|
||||
onCheckoutAfterProcessingWithSuccess: () => () => void null, // deprecated for onCheckoutSuccess
|
||||
onCheckoutAfterProcessingWithError: () => () => void null, // deprecated for onCheckoutFail
|
||||
onCheckoutBeforeProcessing: () => () => void null, // deprecated for onCheckoutValidationBeforeProcessing
|
||||
onCheckoutValidationBeforeProcessing: () => () => void null, // deprecated for onCheckoutValidation
|
||||
onCheckoutSuccess: () => () => void null,
|
||||
onCheckoutFail: () => () => void null,
|
||||
onCheckoutValidation: () => () => void null,
|
||||
} );
|
||||
|
||||
export const useCheckoutEventsContext = () => {
|
||||
return useContext( CheckoutEventsContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Checkout Events provider
|
||||
* Emit Checkout events and provide access to Checkout event handlers
|
||||
*
|
||||
* @param {Object} props Incoming props for the provider.
|
||||
* @param {Object} props.children The children being wrapped.
|
||||
* @param {string} props.redirectUrl Initialize what the checkout will redirect to after successful submit.
|
||||
*/
|
||||
export const CheckoutEventsProvider = ( {
|
||||
children,
|
||||
redirectUrl,
|
||||
}: {
|
||||
children: React.ReactChildren;
|
||||
redirectUrl: string;
|
||||
} ): JSX.Element => {
|
||||
const paymentMethods = getPaymentMethods();
|
||||
const expressPaymentMethods = getExpressPaymentMethods();
|
||||
const { isEditor } = useEditorContext();
|
||||
|
||||
const { __internalUpdateAvailablePaymentMethods } =
|
||||
useDispatch( PAYMENT_STORE_KEY );
|
||||
|
||||
// Update the payment method store when paymentMethods or expressPaymentMethods changes.
|
||||
// Ensure this happens in the editor even if paymentMethods is empty. This won't happen instantly when the objects
|
||||
// are updated, but on the next re-render.
|
||||
useEffect( () => {
|
||||
if (
|
||||
! isEditor &&
|
||||
Object.keys( paymentMethods ).length === 0 &&
|
||||
Object.keys( expressPaymentMethods ).length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
__internalUpdateAvailablePaymentMethods();
|
||||
}, [
|
||||
isEditor,
|
||||
paymentMethods,
|
||||
expressPaymentMethods,
|
||||
__internalUpdateAvailablePaymentMethods,
|
||||
] );
|
||||
|
||||
const {
|
||||
__internalSetRedirectUrl,
|
||||
__internalEmitValidateEvent,
|
||||
__internalEmitAfterProcessingEvents,
|
||||
__internalSetBeforeProcessing,
|
||||
} = useDispatch( CHECKOUT_STORE_KEY );
|
||||
|
||||
const {
|
||||
checkoutRedirectUrl,
|
||||
checkoutStatus,
|
||||
isCheckoutBeforeProcessing,
|
||||
isCheckoutAfterProcessing,
|
||||
checkoutHasError,
|
||||
checkoutOrderId,
|
||||
checkoutOrderNotes,
|
||||
checkoutCustomerId,
|
||||
} = useSelect( ( select ) => {
|
||||
const store = select( CHECKOUT_STORE_KEY );
|
||||
return {
|
||||
checkoutRedirectUrl: store.getRedirectUrl(),
|
||||
checkoutStatus: store.getCheckoutStatus(),
|
||||
isCheckoutBeforeProcessing: store.isBeforeProcessing(),
|
||||
isCheckoutAfterProcessing: store.isAfterProcessing(),
|
||||
checkoutHasError: store.hasError(),
|
||||
checkoutOrderId: store.getOrderId(),
|
||||
checkoutOrderNotes: store.getOrderNotes(),
|
||||
checkoutCustomerId: store.getCustomerId(),
|
||||
};
|
||||
} );
|
||||
|
||||
if ( redirectUrl && redirectUrl !== checkoutRedirectUrl ) {
|
||||
__internalSetRedirectUrl( redirectUrl );
|
||||
}
|
||||
|
||||
const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
|
||||
const { dispatchCheckoutEvent } = useStoreEvents();
|
||||
const { checkoutNotices, paymentNotices, expressPaymentNotices } =
|
||||
useSelect( ( select ) => {
|
||||
const { getNotices } = select( 'core/notices' );
|
||||
const checkoutContexts = Object.values( noticeContexts ).filter(
|
||||
( context ) =>
|
||||
context !== noticeContexts.PAYMENTS &&
|
||||
context !== noticeContexts.EXPRESS_PAYMENTS
|
||||
);
|
||||
const allCheckoutNotices = checkoutContexts.reduce(
|
||||
( acc, context ) => {
|
||||
return [ ...acc, ...getNotices( context ) ];
|
||||
},
|
||||
[]
|
||||
);
|
||||
return {
|
||||
checkoutNotices: allCheckoutNotices,
|
||||
paymentNotices: getNotices( noticeContexts.PAYMENTS ),
|
||||
expressPaymentNotices: getNotices(
|
||||
noticeContexts.EXPRESS_PAYMENTS
|
||||
),
|
||||
};
|
||||
}, [] );
|
||||
|
||||
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
|
||||
const currentObservers = useRef( observers );
|
||||
const { onCheckoutValidation, onCheckoutSuccess, onCheckoutFail } =
|
||||
useEventEmitters( observerDispatch );
|
||||
|
||||
// set observers on ref so it's always current.
|
||||
useEffect( () => {
|
||||
currentObservers.current = observers;
|
||||
}, [ observers ] );
|
||||
|
||||
/**
|
||||
* @deprecated use onCheckoutValidation instead
|
||||
*
|
||||
* To prevent the deprecation message being shown at render time
|
||||
* we need an extra function between useMemo and event emitters
|
||||
* so that the deprecated message gets shown only at invocation time.
|
||||
* (useMemo calls the passed function at render time)
|
||||
* See: https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4039/commits/a502d1be8828848270993264c64220731b0ae181
|
||||
*/
|
||||
const onCheckoutBeforeProcessing = useMemo( () => {
|
||||
return function ( ...args: Parameters< typeof onCheckoutValidation > ) {
|
||||
deprecated( 'onCheckoutBeforeProcessing', {
|
||||
alternative: 'onCheckoutValidation',
|
||||
plugin: 'WooCommerce Blocks',
|
||||
} );
|
||||
return onCheckoutValidation( ...args );
|
||||
};
|
||||
}, [ onCheckoutValidation ] );
|
||||
|
||||
/**
|
||||
* @deprecated use onCheckoutValidation instead
|
||||
*/
|
||||
const onCheckoutValidationBeforeProcessing = useMemo( () => {
|
||||
return function ( ...args: Parameters< typeof onCheckoutValidation > ) {
|
||||
deprecated( 'onCheckoutValidationBeforeProcessing', {
|
||||
since: '9.7.0',
|
||||
alternative: 'onCheckoutValidation',
|
||||
plugin: 'WooCommerce Blocks',
|
||||
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8381',
|
||||
} );
|
||||
return onCheckoutValidation( ...args );
|
||||
};
|
||||
}, [ onCheckoutValidation ] );
|
||||
|
||||
/**
|
||||
* @deprecated use onCheckoutSuccess instead
|
||||
*/
|
||||
const onCheckoutAfterProcessingWithSuccess = useMemo( () => {
|
||||
return function ( ...args: Parameters< typeof onCheckoutSuccess > ) {
|
||||
deprecated( 'onCheckoutAfterProcessingWithSuccess', {
|
||||
since: '9.7.0',
|
||||
alternative: 'onCheckoutSuccess',
|
||||
plugin: 'WooCommerce Blocks',
|
||||
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8381',
|
||||
} );
|
||||
return onCheckoutSuccess( ...args );
|
||||
};
|
||||
}, [ onCheckoutSuccess ] );
|
||||
|
||||
/**
|
||||
* @deprecated use onCheckoutFail instead
|
||||
*/
|
||||
const onCheckoutAfterProcessingWithError = useMemo( () => {
|
||||
return function ( ...args: Parameters< typeof onCheckoutFail > ) {
|
||||
deprecated( 'onCheckoutAfterProcessingWithError', {
|
||||
since: '9.7.0',
|
||||
alternative: 'onCheckoutFail',
|
||||
plugin: 'WooCommerce Blocks',
|
||||
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8381',
|
||||
} );
|
||||
return onCheckoutFail( ...args );
|
||||
};
|
||||
}, [ onCheckoutFail ] );
|
||||
|
||||
// Emit CHECKOUT_VALIDATE event and set the error state based on the response of
|
||||
// the registered callbacks
|
||||
useEffect( () => {
|
||||
if ( isCheckoutBeforeProcessing ) {
|
||||
__internalEmitValidateEvent( {
|
||||
observers: currentObservers.current,
|
||||
setValidationErrors,
|
||||
} );
|
||||
}
|
||||
}, [
|
||||
isCheckoutBeforeProcessing,
|
||||
setValidationErrors,
|
||||
__internalEmitValidateEvent,
|
||||
] );
|
||||
|
||||
const previousStatus = usePrevious( checkoutStatus );
|
||||
const previousHasError = usePrevious( checkoutHasError );
|
||||
|
||||
// Emit CHECKOUT_SUCCESS and CHECKOUT_FAIL events
|
||||
// and set checkout errors according to the callback responses
|
||||
useEffect( () => {
|
||||
if (
|
||||
checkoutStatus === previousStatus &&
|
||||
checkoutHasError === previousHasError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( isCheckoutAfterProcessing ) {
|
||||
__internalEmitAfterProcessingEvents( {
|
||||
observers: currentObservers.current,
|
||||
notices: {
|
||||
checkoutNotices,
|
||||
paymentNotices,
|
||||
expressPaymentNotices,
|
||||
},
|
||||
} );
|
||||
}
|
||||
}, [
|
||||
checkoutStatus,
|
||||
checkoutHasError,
|
||||
checkoutRedirectUrl,
|
||||
checkoutOrderId,
|
||||
checkoutCustomerId,
|
||||
checkoutOrderNotes,
|
||||
isCheckoutAfterProcessing,
|
||||
isCheckoutBeforeProcessing,
|
||||
previousStatus,
|
||||
previousHasError,
|
||||
checkoutNotices,
|
||||
expressPaymentNotices,
|
||||
paymentNotices,
|
||||
__internalEmitValidateEvent,
|
||||
__internalEmitAfterProcessingEvents,
|
||||
] );
|
||||
|
||||
const onSubmit = useCallback( () => {
|
||||
dispatchCheckoutEvent( 'submit' );
|
||||
__internalSetBeforeProcessing();
|
||||
}, [ dispatchCheckoutEvent, __internalSetBeforeProcessing ] );
|
||||
|
||||
const checkoutEventHandlers = {
|
||||
onSubmit,
|
||||
onCheckoutBeforeProcessing,
|
||||
onCheckoutValidationBeforeProcessing,
|
||||
onCheckoutAfterProcessingWithSuccess,
|
||||
onCheckoutAfterProcessingWithError,
|
||||
onCheckoutSuccess,
|
||||
onCheckoutFail,
|
||||
onCheckoutValidation,
|
||||
};
|
||||
return (
|
||||
<CheckoutEventsContext.Provider value={ checkoutEventHandlers }>
|
||||
{ children }
|
||||
</CheckoutEventsContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import triggerFetch from '@wordpress/api-fetch';
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useState,
|
||||
useMemo,
|
||||
} from '@wordpress/element';
|
||||
import {
|
||||
emptyHiddenAddressFields,
|
||||
removeAllNotices,
|
||||
} from '@woocommerce/base-utils';
|
||||
import { useDispatch, useSelect, select as selectStore } from '@wordpress/data';
|
||||
import {
|
||||
CHECKOUT_STORE_KEY,
|
||||
PAYMENT_STORE_KEY,
|
||||
VALIDATION_STORE_KEY,
|
||||
CART_STORE_KEY,
|
||||
processErrorResponse,
|
||||
} from '@woocommerce/block-data';
|
||||
import {
|
||||
getPaymentMethods,
|
||||
getExpressPaymentMethods,
|
||||
} from '@woocommerce/blocks-registry';
|
||||
import {
|
||||
ApiResponse,
|
||||
CheckoutResponseSuccess,
|
||||
CheckoutResponseError,
|
||||
assertResponseIsValid,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { preparePaymentData, processCheckoutResponseHeaders } from './utils';
|
||||
import { useCheckoutEventsContext } from './checkout-events';
|
||||
import { useShippingDataContext } from './shipping';
|
||||
import { useStoreCart } from '../../hooks/cart/use-store-cart';
|
||||
|
||||
/**
|
||||
* CheckoutProcessor component.
|
||||
*
|
||||
* Subscribes to checkout context and triggers processing via the API.
|
||||
*/
|
||||
const CheckoutProcessor = () => {
|
||||
const { onCheckoutValidation } = useCheckoutEventsContext();
|
||||
|
||||
const {
|
||||
hasError: checkoutHasError,
|
||||
redirectUrl,
|
||||
isProcessing: checkoutIsProcessing,
|
||||
isBeforeProcessing: checkoutIsBeforeProcessing,
|
||||
isComplete: checkoutIsComplete,
|
||||
orderNotes,
|
||||
shouldCreateAccount,
|
||||
extensionData,
|
||||
customerId,
|
||||
} = useSelect( ( select ) => {
|
||||
const store = select( CHECKOUT_STORE_KEY );
|
||||
return {
|
||||
hasError: store.hasError(),
|
||||
redirectUrl: store.getRedirectUrl(),
|
||||
isProcessing: store.isProcessing(),
|
||||
isBeforeProcessing: store.isBeforeProcessing(),
|
||||
isComplete: store.isComplete(),
|
||||
orderNotes: store.getOrderNotes(),
|
||||
shouldCreateAccount: store.getShouldCreateAccount(),
|
||||
extensionData: store.getExtensionData(),
|
||||
customerId: store.getCustomerId(),
|
||||
};
|
||||
} );
|
||||
|
||||
const { __internalSetHasError, __internalProcessCheckoutResponse } =
|
||||
useDispatch( CHECKOUT_STORE_KEY );
|
||||
|
||||
const hasValidationErrors = useSelect(
|
||||
( select ) => select( VALIDATION_STORE_KEY ).hasValidationErrors
|
||||
);
|
||||
const { shippingErrorStatus } = useShippingDataContext();
|
||||
|
||||
const { billingAddress, shippingAddress } = useSelect( ( select ) =>
|
||||
select( CART_STORE_KEY ).getCustomerData()
|
||||
);
|
||||
|
||||
const { cartNeedsPayment, cartNeedsShipping, receiveCartContents } =
|
||||
useStoreCart();
|
||||
|
||||
const {
|
||||
activePaymentMethod,
|
||||
paymentMethodData,
|
||||
isExpressPaymentMethodActive,
|
||||
hasPaymentError,
|
||||
isPaymentReady,
|
||||
shouldSavePayment,
|
||||
} = useSelect( ( select ) => {
|
||||
const store = select( PAYMENT_STORE_KEY );
|
||||
|
||||
return {
|
||||
activePaymentMethod: store.getActivePaymentMethod(),
|
||||
paymentMethodData: store.getPaymentMethodData(),
|
||||
isExpressPaymentMethodActive: store.isExpressPaymentMethodActive(),
|
||||
hasPaymentError: store.hasPaymentError(),
|
||||
isPaymentReady: store.isPaymentReady(),
|
||||
shouldSavePayment: store.getShouldSavePaymentMethod(),
|
||||
};
|
||||
}, [] );
|
||||
|
||||
const paymentMethods = getPaymentMethods();
|
||||
const expressPaymentMethods = getExpressPaymentMethods();
|
||||
const currentBillingAddress = useRef( billingAddress );
|
||||
const currentShippingAddress = useRef( shippingAddress );
|
||||
const currentRedirectUrl = useRef( redirectUrl );
|
||||
const [ isProcessingOrder, setIsProcessingOrder ] = useState( false );
|
||||
|
||||
const paymentMethodId = useMemo( () => {
|
||||
const merged = {
|
||||
...expressPaymentMethods,
|
||||
...paymentMethods,
|
||||
};
|
||||
return merged?.[ activePaymentMethod ]?.paymentMethodId;
|
||||
}, [ activePaymentMethod, expressPaymentMethods, paymentMethods ] );
|
||||
|
||||
const checkoutWillHaveError =
|
||||
( hasValidationErrors() && ! isExpressPaymentMethodActive ) ||
|
||||
hasPaymentError ||
|
||||
shippingErrorStatus.hasError;
|
||||
|
||||
const paidAndWithoutErrors =
|
||||
! checkoutHasError &&
|
||||
! checkoutWillHaveError &&
|
||||
( isPaymentReady || ! cartNeedsPayment ) &&
|
||||
checkoutIsProcessing;
|
||||
|
||||
// Determine if checkout has an error.
|
||||
useEffect( () => {
|
||||
if (
|
||||
checkoutWillHaveError !== checkoutHasError &&
|
||||
( checkoutIsProcessing || checkoutIsBeforeProcessing ) &&
|
||||
! isExpressPaymentMethodActive
|
||||
) {
|
||||
__internalSetHasError( checkoutWillHaveError );
|
||||
}
|
||||
}, [
|
||||
checkoutWillHaveError,
|
||||
checkoutHasError,
|
||||
checkoutIsProcessing,
|
||||
checkoutIsBeforeProcessing,
|
||||
isExpressPaymentMethodActive,
|
||||
__internalSetHasError,
|
||||
] );
|
||||
|
||||
// Keep the billing, shipping and redirectUrl current
|
||||
useEffect( () => {
|
||||
currentBillingAddress.current = billingAddress;
|
||||
currentShippingAddress.current = shippingAddress;
|
||||
currentRedirectUrl.current = redirectUrl;
|
||||
}, [ billingAddress, shippingAddress, redirectUrl ] );
|
||||
|
||||
const checkValidation = useCallback( () => {
|
||||
if ( hasValidationErrors() ) {
|
||||
// If there is a shipping rates validation error, return the error message to be displayed.
|
||||
if (
|
||||
selectStore( VALIDATION_STORE_KEY ).getValidationError(
|
||||
'shipping-rates-error'
|
||||
) !== undefined
|
||||
) {
|
||||
return {
|
||||
errorMessage: __(
|
||||
'Sorry, this order requires a shipping option.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
};
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if ( hasPaymentError ) {
|
||||
return {
|
||||
errorMessage: __(
|
||||
'There was a problem with your payment option.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
context: 'wc/checkout/payments',
|
||||
};
|
||||
}
|
||||
if ( shippingErrorStatus.hasError ) {
|
||||
return {
|
||||
errorMessage: __(
|
||||
'There was a problem with your shipping option.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
context: 'wc/checkout/shipping-methods',
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [ hasValidationErrors, hasPaymentError, shippingErrorStatus.hasError ] );
|
||||
|
||||
// Validate the checkout using the CHECKOUT_VALIDATION_BEFORE_PROCESSING event
|
||||
useEffect( () => {
|
||||
let unsubscribeProcessing: () => void;
|
||||
if ( ! isExpressPaymentMethodActive ) {
|
||||
unsubscribeProcessing = onCheckoutValidation( checkValidation, 0 );
|
||||
}
|
||||
return () => {
|
||||
if (
|
||||
! isExpressPaymentMethodActive &&
|
||||
typeof unsubscribeProcessing === 'function'
|
||||
) {
|
||||
unsubscribeProcessing();
|
||||
}
|
||||
};
|
||||
}, [
|
||||
onCheckoutValidation,
|
||||
checkValidation,
|
||||
isExpressPaymentMethodActive,
|
||||
] );
|
||||
|
||||
// Redirect when checkout is complete and there is a redirect url.
|
||||
useEffect( () => {
|
||||
if ( currentRedirectUrl.current ) {
|
||||
window.location.href = currentRedirectUrl.current;
|
||||
}
|
||||
}, [ checkoutIsComplete ] );
|
||||
|
||||
// POST to the Store API and process and display any errors, or set order complete
|
||||
const processOrder = useCallback( async () => {
|
||||
if ( isProcessingOrder ) {
|
||||
return;
|
||||
}
|
||||
setIsProcessingOrder( true );
|
||||
removeAllNotices();
|
||||
|
||||
const paymentData = cartNeedsPayment
|
||||
? {
|
||||
payment_method: paymentMethodId,
|
||||
payment_data: preparePaymentData(
|
||||
paymentMethodData,
|
||||
shouldSavePayment,
|
||||
activePaymentMethod
|
||||
),
|
||||
}
|
||||
: {};
|
||||
|
||||
const data = {
|
||||
shipping_address: cartNeedsShipping
|
||||
? emptyHiddenAddressFields( currentShippingAddress.current )
|
||||
: undefined,
|
||||
billing_address: emptyHiddenAddressFields(
|
||||
currentBillingAddress.current
|
||||
),
|
||||
customer_note: orderNotes,
|
||||
create_account: shouldCreateAccount,
|
||||
...paymentData,
|
||||
extensions: { ...extensionData },
|
||||
};
|
||||
|
||||
triggerFetch( {
|
||||
path: '/wc/store/v1/checkout',
|
||||
method: 'POST',
|
||||
data,
|
||||
cache: 'no-store',
|
||||
parse: false,
|
||||
} )
|
||||
.then( ( response: unknown ) => {
|
||||
assertResponseIsValid< CheckoutResponseSuccess >( response );
|
||||
processCheckoutResponseHeaders( response.headers );
|
||||
if ( ! response.ok ) {
|
||||
throw response;
|
||||
}
|
||||
return response.json();
|
||||
} )
|
||||
.then( ( responseJson: CheckoutResponseSuccess ) => {
|
||||
__internalProcessCheckoutResponse( responseJson );
|
||||
setIsProcessingOrder( false );
|
||||
} )
|
||||
.catch( ( errorResponse: ApiResponse< CheckoutResponseError > ) => {
|
||||
processCheckoutResponseHeaders( errorResponse?.headers );
|
||||
try {
|
||||
// This attempts to parse a JSON error response where the status code was 4xx/5xx.
|
||||
errorResponse
|
||||
.json()
|
||||
.then(
|
||||
( response ) => response as CheckoutResponseError
|
||||
)
|
||||
.then( ( response: CheckoutResponseError ) => {
|
||||
if ( response.data?.cart ) {
|
||||
// We don't want to receive the address here because it will overwrite fields.
|
||||
receiveCartContents( response.data.cart );
|
||||
}
|
||||
processErrorResponse( response );
|
||||
__internalProcessCheckoutResponse( response );
|
||||
} );
|
||||
} catch {
|
||||
let errorMessage = __(
|
||||
'Something went wrong when placing the order. Check your email for order updates before retrying.',
|
||||
'woo-gutenberg-products-block'
|
||||
);
|
||||
|
||||
if ( customerId !== 0 ) {
|
||||
errorMessage = __(
|
||||
"Something went wrong when placing the order. Check your account's order history or your email for order updates before retrying.",
|
||||
'woo-gutenberg-products-block'
|
||||
);
|
||||
}
|
||||
processErrorResponse( {
|
||||
code: 'unknown_error',
|
||||
message: errorMessage,
|
||||
data: null,
|
||||
} );
|
||||
}
|
||||
__internalSetHasError( true );
|
||||
setIsProcessingOrder( false );
|
||||
} );
|
||||
}, [
|
||||
isProcessingOrder,
|
||||
cartNeedsPayment,
|
||||
paymentMethodId,
|
||||
paymentMethodData,
|
||||
shouldSavePayment,
|
||||
activePaymentMethod,
|
||||
orderNotes,
|
||||
shouldCreateAccount,
|
||||
extensionData,
|
||||
cartNeedsShipping,
|
||||
receiveCartContents,
|
||||
__internalSetHasError,
|
||||
__internalProcessCheckoutResponse,
|
||||
] );
|
||||
|
||||
// Process order if conditions are good.
|
||||
useEffect( () => {
|
||||
if ( paidAndWithoutErrors && ! isProcessingOrder ) {
|
||||
processOrder();
|
||||
}
|
||||
}, [ processOrder, paidAndWithoutErrors, isProcessingOrder ] );
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default CheckoutProcessor;
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { PluginArea } from '@wordpress/plugins';
|
||||
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
|
||||
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { PaymentEventsProvider } from './payment-events';
|
||||
import { ShippingDataProvider } from './shipping';
|
||||
import { CheckoutEventsProvider } from './checkout-events';
|
||||
import CheckoutProcessor from './checkout-processor';
|
||||
|
||||
/**
|
||||
* Checkout provider
|
||||
* This wraps the checkout and provides an api interface for the checkout to
|
||||
* children via various hooks.
|
||||
*
|
||||
* @param {Object} props Incoming props for the provider.
|
||||
* @param {Object} props.children The children being wrapped.
|
||||
* component.
|
||||
* @param {string} [props.redirectUrl] Initialize what the checkout will
|
||||
* redirect to after successful
|
||||
* submit.
|
||||
*/
|
||||
export const CheckoutProvider = ( { children, redirectUrl } ) => {
|
||||
return (
|
||||
<CheckoutEventsProvider redirectUrl={ redirectUrl }>
|
||||
<ShippingDataProvider>
|
||||
<PaymentEventsProvider>
|
||||
{ children }
|
||||
{ /* If the current user is an admin, we let BlockErrorBoundary render
|
||||
the error, or we simply die silently. */ }
|
||||
<BlockErrorBoundary
|
||||
renderError={
|
||||
CURRENT_USER_IS_ADMIN ? null : () => null
|
||||
}
|
||||
>
|
||||
<PluginArea scope="woocommerce-checkout" />
|
||||
</BlockErrorBoundary>
|
||||
<CheckoutProcessor />
|
||||
</PaymentEventsProvider>
|
||||
</ShippingDataProvider>
|
||||
</CheckoutEventsProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './payment-events';
|
||||
export * from './shipping';
|
||||
export * from './checkout-events';
|
||||
export * from './cart-events';
|
||||
export * from './cart';
|
||||
export * from './checkout-processor';
|
||||
export * from './checkout-provider';
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
reducer,
|
||||
emitEvent,
|
||||
emitEventWithAbort,
|
||||
emitterCallback,
|
||||
ActionType,
|
||||
} from '../../../event-emit';
|
||||
|
||||
const EMIT_TYPES = {
|
||||
PAYMENT_SETUP: 'payment_setup',
|
||||
};
|
||||
|
||||
type EventEmittersType = Record< string, ReturnType< typeof emitterCallback > >;
|
||||
|
||||
/**
|
||||
* Receives a reducer dispatcher and returns an object with the
|
||||
* various event emitters for the payment processing events.
|
||||
*
|
||||
* Calling the event registration function with the callback will register it
|
||||
* for the event emitter and will return a dispatcher for removing the
|
||||
* registered callback (useful for implementation in `useEffect`).
|
||||
*
|
||||
* @param {Function} observerDispatch The emitter reducer dispatcher.
|
||||
* @return {Object} An object with the various payment event emitter registration functions
|
||||
*/
|
||||
const useEventEmitters = (
|
||||
observerDispatch: React.Dispatch< ActionType >
|
||||
): EventEmittersType => {
|
||||
const eventEmitters = useMemo(
|
||||
() => ( {
|
||||
onPaymentSetup: emitterCallback(
|
||||
EMIT_TYPES.PAYMENT_SETUP,
|
||||
observerDispatch
|
||||
),
|
||||
} ),
|
||||
[ observerDispatch ]
|
||||
);
|
||||
return eventEmitters;
|
||||
};
|
||||
|
||||
export { EMIT_TYPES, useEventEmitters, reducer, emitEvent, emitEventWithAbort };
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from '@wordpress/element';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import {
|
||||
CHECKOUT_STORE_KEY,
|
||||
PAYMENT_STORE_KEY,
|
||||
VALIDATION_STORE_KEY,
|
||||
} from '@woocommerce/block-data';
|
||||
import deprecated from '@wordpress/deprecated';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useEventEmitters, reducer as emitReducer } from './event-emit';
|
||||
import { emitterCallback } from '../../../event-emit';
|
||||
|
||||
type PaymentEventsContextType = {
|
||||
// Event registration callback for registering observers for the payment processing event.
|
||||
onPaymentProcessing: ReturnType< typeof emitterCallback >;
|
||||
onPaymentSetup: ReturnType< typeof emitterCallback >;
|
||||
};
|
||||
|
||||
const PaymentEventsContext = createContext< PaymentEventsContextType >( {
|
||||
onPaymentProcessing: () => () => () => void null,
|
||||
onPaymentSetup: () => () => () => void null,
|
||||
} );
|
||||
|
||||
export const usePaymentEventsContext = () => {
|
||||
return useContext( PaymentEventsContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* PaymentEventsProvider is automatically included in the CheckoutProvider.
|
||||
*
|
||||
* This provides the api interface (via the context hook) for payment status and data.
|
||||
*
|
||||
* @param {Object} props Incoming props for provider
|
||||
* @param {Object} props.children The wrapped components in this provider.
|
||||
*/
|
||||
export const PaymentEventsProvider = ( {
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
} ): JSX.Element => {
|
||||
const {
|
||||
isProcessing: checkoutIsProcessing,
|
||||
isIdle: checkoutIsIdle,
|
||||
isCalculating: checkoutIsCalculating,
|
||||
hasError: checkoutHasError,
|
||||
} = useSelect( ( select ) => {
|
||||
const store = select( CHECKOUT_STORE_KEY );
|
||||
return {
|
||||
isProcessing: store.isProcessing(),
|
||||
isIdle: store.isIdle(),
|
||||
hasError: store.hasError(),
|
||||
isCalculating: store.isCalculating(),
|
||||
};
|
||||
} );
|
||||
const { isPaymentReady } = useSelect( ( select ) => {
|
||||
const store = select( PAYMENT_STORE_KEY );
|
||||
|
||||
return {
|
||||
// The PROCESSING status represents before the checkout runs the observers
|
||||
// registered for the payment_setup event.
|
||||
isPaymentProcessing: store.isPaymentProcessing(),
|
||||
// the READY status represents when the observers have finished processing and payment data
|
||||
// synced with the payment store, ready to be sent to the StoreApi
|
||||
isPaymentReady: store.isPaymentReady(),
|
||||
};
|
||||
} );
|
||||
|
||||
const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
|
||||
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
|
||||
const { onPaymentSetup } = useEventEmitters( observerDispatch );
|
||||
const currentObservers = useRef( observers );
|
||||
|
||||
// ensure observers are always current.
|
||||
useEffect( () => {
|
||||
currentObservers.current = observers;
|
||||
}, [ observers ] );
|
||||
|
||||
const {
|
||||
__internalSetPaymentProcessing,
|
||||
__internalSetPaymentIdle,
|
||||
__internalEmitPaymentProcessingEvent,
|
||||
} = useDispatch( PAYMENT_STORE_KEY );
|
||||
|
||||
// flip payment to processing if checkout processing is complete and there are no errors
|
||||
useEffect( () => {
|
||||
if (
|
||||
checkoutIsProcessing &&
|
||||
! checkoutHasError &&
|
||||
! checkoutIsCalculating
|
||||
) {
|
||||
__internalSetPaymentProcessing();
|
||||
|
||||
// Note: the nature of this event emitter is that it will bail on any
|
||||
// observer that returns a response that !== true. However, this still
|
||||
// allows for other observers that return true for continuing through
|
||||
// to the next observer (or bailing if there's a problem).
|
||||
__internalEmitPaymentProcessingEvent(
|
||||
currentObservers.current,
|
||||
setValidationErrors
|
||||
);
|
||||
}
|
||||
}, [
|
||||
checkoutIsProcessing,
|
||||
checkoutHasError,
|
||||
checkoutIsCalculating,
|
||||
__internalSetPaymentProcessing,
|
||||
__internalEmitPaymentProcessingEvent,
|
||||
setValidationErrors,
|
||||
] );
|
||||
|
||||
// When checkout is returned to idle, and the payment setup has not completed, set payment status to idle
|
||||
useEffect( () => {
|
||||
if ( checkoutIsIdle && ! isPaymentReady ) {
|
||||
__internalSetPaymentIdle();
|
||||
}
|
||||
}, [ checkoutIsIdle, isPaymentReady, __internalSetPaymentIdle ] );
|
||||
|
||||
// if checkout has an error sync payment status back to idle.
|
||||
useEffect( () => {
|
||||
if ( checkoutHasError && isPaymentReady ) {
|
||||
__internalSetPaymentIdle();
|
||||
}
|
||||
}, [ checkoutHasError, isPaymentReady, __internalSetPaymentIdle ] );
|
||||
|
||||
/**
|
||||
* @deprecated use onPaymentSetup instead
|
||||
*/
|
||||
const onPaymentProcessing = useMemo( () => {
|
||||
return function ( ...args: Parameters< typeof onPaymentSetup > ) {
|
||||
deprecated( 'onPaymentProcessing', {
|
||||
alternative: 'onPaymentSetup',
|
||||
plugin: 'WooCommerce Blocks',
|
||||
} );
|
||||
return onPaymentSetup( ...args );
|
||||
};
|
||||
}, [ onPaymentSetup ] );
|
||||
|
||||
const paymentContextData = {
|
||||
onPaymentProcessing,
|
||||
onPaymentSetup,
|
||||
};
|
||||
|
||||
return (
|
||||
<PaymentEventsContext.Provider value={ paymentContextData }>
|
||||
{ children }
|
||||
</PaymentEventsContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { CartShippingAddress } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { ShippingDataContextType, ShippingErrorTypes } from './types';
|
||||
|
||||
export const ERROR_TYPES = {
|
||||
NONE: 'none',
|
||||
INVALID_ADDRESS: 'invalid_address',
|
||||
UNKNOWN: 'unknown_error',
|
||||
} as ShippingErrorTypes;
|
||||
|
||||
export const shippingErrorCodes = {
|
||||
INVALID_COUNTRY: 'woocommerce_rest_cart_shipping_rates_invalid_country',
|
||||
MISSING_COUNTRY: 'woocommerce_rest_cart_shipping_rates_missing_country',
|
||||
INVALID_STATE: 'woocommerce_rest_cart_shipping_rates_invalid_state',
|
||||
};
|
||||
|
||||
export const DEFAULT_SHIPPING_ADDRESS = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
} as CartShippingAddress;
|
||||
|
||||
export const DEFAULT_SHIPPING_CONTEXT_DATA = {
|
||||
shippingErrorStatus: {
|
||||
isPristine: true,
|
||||
isValid: false,
|
||||
hasInvalidAddress: false,
|
||||
hasError: false,
|
||||
},
|
||||
dispatchErrorStatus: ( status ) => status,
|
||||
shippingErrorTypes: ERROR_TYPES,
|
||||
onShippingRateSuccess: () => () => void null,
|
||||
onShippingRateFail: () => () => void null,
|
||||
onShippingRateSelectSuccess: () => () => void null,
|
||||
onShippingRateSelectFail: () => () => void null,
|
||||
} as ShippingDataContextType;
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { emitterCallback, reducer, emitEvent } from '../../../event-emit';
|
||||
|
||||
const EMIT_TYPES = {
|
||||
SHIPPING_RATES_SUCCESS: 'shipping_rates_success',
|
||||
SHIPPING_RATES_FAIL: 'shipping_rates_fail',
|
||||
SHIPPING_RATE_SELECT_SUCCESS: 'shipping_rate_select_success',
|
||||
SHIPPING_RATE_SELECT_FAIL: 'shipping_rate_select_fail',
|
||||
};
|
||||
|
||||
/**
|
||||
* Receives a reducer dispatcher and returns an object with the onSuccess and
|
||||
* onFail callback registration points for the shipping option emit events.
|
||||
*
|
||||
* Calling the event registration function with the callback will register it
|
||||
* for the event emitter and will return a dispatcher for removing the
|
||||
* registered callback (useful for implementation in `useEffect`).
|
||||
*
|
||||
* @param {Function} dispatcher A reducer dispatcher
|
||||
* @return {Object} An object with `onSuccess` and `onFail` emitter registration.
|
||||
*/
|
||||
const emitterObservers = ( dispatcher ) => ( {
|
||||
onSuccess: emitterCallback( EMIT_TYPES.SHIPPING_RATES_SUCCESS, dispatcher ),
|
||||
onFail: emitterCallback( EMIT_TYPES.SHIPPING_RATES_FAIL, dispatcher ),
|
||||
onSelectSuccess: emitterCallback(
|
||||
EMIT_TYPES.SHIPPING_RATE_SELECT_SUCCESS,
|
||||
dispatcher
|
||||
),
|
||||
onSelectFail: emitterCallback(
|
||||
EMIT_TYPES.SHIPPING_RATE_SELECT_FAIL,
|
||||
dispatcher
|
||||
),
|
||||
} );
|
||||
|
||||
export { EMIT_TYPES, emitterObservers, reducer, emitEvent };
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from '@wordpress/element';
|
||||
import { useDispatch } from '@wordpress/data';
|
||||
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type {
|
||||
ShippingDataContextType,
|
||||
ShippingDataProviderProps,
|
||||
} from './types';
|
||||
import { ERROR_TYPES, DEFAULT_SHIPPING_CONTEXT_DATA } from './constants';
|
||||
import { hasInvalidShippingAddress } from './utils';
|
||||
import { errorStatusReducer } from './reducers';
|
||||
import {
|
||||
EMIT_TYPES,
|
||||
emitterObservers,
|
||||
reducer as emitReducer,
|
||||
emitEvent,
|
||||
} from './event-emit';
|
||||
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
|
||||
import { useShippingData } from '../../../hooks/shipping/use-shipping-data';
|
||||
|
||||
const { NONE, INVALID_ADDRESS, UNKNOWN } = ERROR_TYPES;
|
||||
const ShippingDataContext = createContext( DEFAULT_SHIPPING_CONTEXT_DATA );
|
||||
|
||||
export const useShippingDataContext = (): ShippingDataContextType => {
|
||||
return useContext( ShippingDataContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* The shipping data provider exposes the interface for shipping in the checkout/cart.
|
||||
*/
|
||||
export const ShippingDataProvider = ( {
|
||||
children,
|
||||
}: ShippingDataProviderProps ) => {
|
||||
const { __internalIncrementCalculating, __internalDecrementCalculating } =
|
||||
useDispatch( CHECKOUT_STORE_KEY );
|
||||
const { shippingRates, isLoadingRates, cartErrors } = useStoreCart();
|
||||
const { selectedRates, isSelectingRate } = useShippingData();
|
||||
const [ shippingErrorStatus, dispatchErrorStatus ] = useReducer(
|
||||
errorStatusReducer,
|
||||
NONE
|
||||
);
|
||||
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
|
||||
const currentObservers = useRef( observers );
|
||||
const eventObservers = useMemo(
|
||||
() => ( {
|
||||
onShippingRateSuccess:
|
||||
emitterObservers( observerDispatch ).onSuccess,
|
||||
onShippingRateFail: emitterObservers( observerDispatch ).onFail,
|
||||
onShippingRateSelectSuccess:
|
||||
emitterObservers( observerDispatch ).onSelectSuccess,
|
||||
onShippingRateSelectFail:
|
||||
emitterObservers( observerDispatch ).onSelectFail,
|
||||
} ),
|
||||
[ observerDispatch ]
|
||||
);
|
||||
|
||||
// set observers on ref so it's always current.
|
||||
useEffect( () => {
|
||||
currentObservers.current = observers;
|
||||
}, [ observers ] );
|
||||
|
||||
// increment/decrement checkout calculating counts when shipping is loading.
|
||||
useEffect( () => {
|
||||
if ( isLoadingRates ) {
|
||||
__internalIncrementCalculating();
|
||||
} else {
|
||||
__internalDecrementCalculating();
|
||||
}
|
||||
}, [
|
||||
isLoadingRates,
|
||||
__internalIncrementCalculating,
|
||||
__internalDecrementCalculating,
|
||||
] );
|
||||
|
||||
// increment/decrement checkout calculating counts when shipping rates are being selected.
|
||||
useEffect( () => {
|
||||
if ( isSelectingRate ) {
|
||||
__internalIncrementCalculating();
|
||||
} else {
|
||||
__internalDecrementCalculating();
|
||||
}
|
||||
}, [
|
||||
__internalIncrementCalculating,
|
||||
__internalDecrementCalculating,
|
||||
isSelectingRate,
|
||||
] );
|
||||
|
||||
// set shipping error status if there are shipping error codes
|
||||
useEffect( () => {
|
||||
if (
|
||||
cartErrors.length > 0 &&
|
||||
hasInvalidShippingAddress( cartErrors )
|
||||
) {
|
||||
dispatchErrorStatus( { type: INVALID_ADDRESS } );
|
||||
} else {
|
||||
dispatchErrorStatus( { type: NONE } );
|
||||
}
|
||||
}, [ cartErrors ] );
|
||||
|
||||
const currentErrorStatus = useMemo(
|
||||
() => ( {
|
||||
isPristine: shippingErrorStatus === NONE,
|
||||
isValid: shippingErrorStatus === NONE,
|
||||
hasInvalidAddress: shippingErrorStatus === INVALID_ADDRESS,
|
||||
hasError:
|
||||
shippingErrorStatus === UNKNOWN ||
|
||||
shippingErrorStatus === INVALID_ADDRESS,
|
||||
} ),
|
||||
[ shippingErrorStatus ]
|
||||
);
|
||||
|
||||
// emit events.
|
||||
useEffect( () => {
|
||||
if (
|
||||
! isLoadingRates &&
|
||||
( shippingRates.length === 0 || currentErrorStatus.hasError )
|
||||
) {
|
||||
emitEvent(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.SHIPPING_RATES_FAIL,
|
||||
{
|
||||
hasInvalidAddress: currentErrorStatus.hasInvalidAddress,
|
||||
hasError: currentErrorStatus.hasError,
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [
|
||||
shippingRates,
|
||||
isLoadingRates,
|
||||
currentErrorStatus.hasError,
|
||||
currentErrorStatus.hasInvalidAddress,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
! isLoadingRates &&
|
||||
shippingRates.length > 0 &&
|
||||
! currentErrorStatus.hasError
|
||||
) {
|
||||
emitEvent(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.SHIPPING_RATES_SUCCESS,
|
||||
shippingRates
|
||||
);
|
||||
}
|
||||
}, [ shippingRates, isLoadingRates, currentErrorStatus.hasError ] );
|
||||
|
||||
// emit shipping rate selection events.
|
||||
useEffect( () => {
|
||||
if ( isSelectingRate ) {
|
||||
return;
|
||||
}
|
||||
if ( currentErrorStatus.hasError ) {
|
||||
emitEvent(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.SHIPPING_RATE_SELECT_FAIL,
|
||||
{
|
||||
hasError: currentErrorStatus.hasError,
|
||||
hasInvalidAddress: currentErrorStatus.hasInvalidAddress,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
emitEvent(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.SHIPPING_RATE_SELECT_SUCCESS,
|
||||
selectedRates.current
|
||||
);
|
||||
}
|
||||
}, [
|
||||
selectedRates,
|
||||
isSelectingRate,
|
||||
currentErrorStatus.hasError,
|
||||
currentErrorStatus.hasInvalidAddress,
|
||||
] );
|
||||
|
||||
const ShippingData: ShippingDataContextType = {
|
||||
shippingErrorStatus: currentErrorStatus,
|
||||
dispatchErrorStatus,
|
||||
shippingErrorTypes: ERROR_TYPES,
|
||||
...eventObservers,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShippingDataContext.Provider value={ ShippingData }>
|
||||
{ children }
|
||||
</ShippingDataContext.Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ERROR_TYPES } from './constants';
|
||||
|
||||
/**
|
||||
* Reducer for shipping status state
|
||||
*
|
||||
* @param {string} state The current status.
|
||||
* @param {Object} action The incoming action.
|
||||
* @param {string} action.type The type of action.
|
||||
*/
|
||||
export const errorStatusReducer = ( state, { type } ) => {
|
||||
if ( Object.values( ERROR_TYPES ).includes( type ) ) {
|
||||
return type;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { emitterCallback } from '../../../event-emit';
|
||||
|
||||
export type ShippingErrorStatus = {
|
||||
isPristine: boolean;
|
||||
isValid: boolean;
|
||||
hasInvalidAddress: boolean;
|
||||
hasError: boolean;
|
||||
};
|
||||
|
||||
export type ShippingErrorTypes = {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
NONE: 'none';
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
INVALID_ADDRESS: 'invalid_address';
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
UNKNOWN: 'unknown_error';
|
||||
};
|
||||
|
||||
export type ShippingDataContextType = {
|
||||
// A function for dispatching a shipping rate error status.
|
||||
dispatchErrorStatus: React.Dispatch< {
|
||||
type: string;
|
||||
} >;
|
||||
onShippingRateFail: ReturnType< typeof emitterCallback >;
|
||||
// Used to register a callback to be invoked when shipping rate is selected unsuccessfully
|
||||
onShippingRateSelectFail: ReturnType< typeof emitterCallback >;
|
||||
// Used to register a callback to be invoked when shipping rate is selected.
|
||||
onShippingRateSelectSuccess: ReturnType< typeof emitterCallback >;
|
||||
// Used to register a callback to be invoked when shipping rates are retrieved.
|
||||
onShippingRateSuccess: ReturnType< typeof emitterCallback >;
|
||||
// The current shipping error status.
|
||||
shippingErrorStatus: ShippingErrorStatus;
|
||||
// The error type constants for the shipping rate error status.
|
||||
shippingErrorTypes: ShippingErrorTypes;
|
||||
};
|
||||
|
||||
export interface ShippingDataProviderProps {
|
||||
children: JSX.Element | JSX.Element[];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { shippingErrorCodes } from './constants';
|
||||
|
||||
export const hasInvalidShippingAddress = ( errors ) => {
|
||||
return errors.some( ( error ) => {
|
||||
if (
|
||||
error.code &&
|
||||
Object.values( shippingErrorCodes ).includes( error.code )
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} );
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import triggerFetch from '@wordpress/api-fetch';
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Utility function for preparing payment data for the request.
|
||||
*/
|
||||
export const preparePaymentData = (
|
||||
//Arbitrary payment data provided by the payment method.
|
||||
paymentData: Record< string, unknown >,
|
||||
//Whether to save the payment method info to user account.
|
||||
shouldSave: boolean,
|
||||
//The current active payment method.
|
||||
activePaymentMethod: string
|
||||
): { key: string; value: unknown }[] => {
|
||||
const apiData = Object.keys( paymentData ).map( ( property ) => {
|
||||
const value = paymentData[ property ];
|
||||
return { key: property, value };
|
||||
}, [] );
|
||||
const savePaymentMethodKey = `wc-${ activePaymentMethod }-new-payment-method`;
|
||||
apiData.push( {
|
||||
key: savePaymentMethodKey,
|
||||
value: shouldSave,
|
||||
} );
|
||||
return apiData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Process headers from an API response an dispatch updates.
|
||||
*/
|
||||
export const processCheckoutResponseHeaders = (
|
||||
headers: Headers | undefined
|
||||
): void => {
|
||||
if ( ! headers ) {
|
||||
return;
|
||||
}
|
||||
const { __internalSetCustomerId } = dispatch( CHECKOUT_STORE_KEY );
|
||||
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 );
|
||||
}
|
||||
|
||||
// Update user using headers.
|
||||
if ( headers?.get( 'User-ID' ) ) {
|
||||
__internalSetCustomerId(
|
||||
parseInt( headers.get( 'User-ID' ) || '0', 10 )
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createContext, useContext } from '@wordpress/element';
|
||||
import { useContainerQueries } from '@woocommerce/base-hooks';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export type ContainerWidthContextProps = {
|
||||
hasContainerWidth: boolean;
|
||||
containerClassName: string;
|
||||
isMobile: boolean;
|
||||
isSmall: boolean;
|
||||
isMedium: boolean;
|
||||
isLarge: boolean;
|
||||
};
|
||||
|
||||
const ContainerWidthContext: React.Context< ContainerWidthContextProps > =
|
||||
createContext< ContainerWidthContextProps >( {
|
||||
hasContainerWidth: false,
|
||||
containerClassName: '',
|
||||
isMobile: false,
|
||||
isSmall: false,
|
||||
isMedium: false,
|
||||
isLarge: false,
|
||||
} );
|
||||
|
||||
export const useContainerWidthContext = (): ContainerWidthContextProps => {
|
||||
return useContext( ContainerWidthContext );
|
||||
};
|
||||
|
||||
interface ContainerWidthContextProviderProps {
|
||||
children: JSX.Element | JSX.Element[];
|
||||
className: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides an interface to useContainerQueries so children can see what size is being used by the
|
||||
* container.
|
||||
*/
|
||||
export const ContainerWidthContextProvider = ( {
|
||||
children,
|
||||
className = '',
|
||||
}: ContainerWidthContextProviderProps ): JSX.Element => {
|
||||
const [ resizeListener, containerClassName ] = useContainerQueries();
|
||||
|
||||
const contextValue = {
|
||||
hasContainerWidth: containerClassName !== '',
|
||||
containerClassName,
|
||||
isMobile: containerClassName === 'is-mobile',
|
||||
isSmall: containerClassName === 'is-small',
|
||||
isMedium: containerClassName === 'is-medium',
|
||||
isLarge: containerClassName === 'is-large',
|
||||
};
|
||||
|
||||
return (
|
||||
<ContainerWidthContext.Provider value={ contextValue }>
|
||||
<div className={ classNames( className, containerClassName ) }>
|
||||
{ resizeListener }
|
||||
{ children }
|
||||
</div>
|
||||
</ContainerWidthContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createContext, useContext, useCallback } from '@wordpress/element';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
|
||||
interface EditorContextType {
|
||||
// Indicates whether in the editor context.
|
||||
isEditor: boolean;
|
||||
|
||||
// The post ID being edited.
|
||||
currentPostId: number;
|
||||
|
||||
// The current view name, if using a view switcher.
|
||||
currentView: string;
|
||||
|
||||
// Object containing preview data for the editor.
|
||||
previewData: Record< string, unknown >;
|
||||
|
||||
// Get data by name.
|
||||
getPreviewData: ( name: string ) => Record< string, unknown >;
|
||||
|
||||
// Indicates whether in the preview context.
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
const EditorContext = createContext( {
|
||||
isEditor: false,
|
||||
currentPostId: 0,
|
||||
currentView: '',
|
||||
previewData: {},
|
||||
getPreviewData: () => ( {} ),
|
||||
} as EditorContextType );
|
||||
|
||||
export const useEditorContext = (): EditorContextType => {
|
||||
return useContext( EditorContext );
|
||||
};
|
||||
|
||||
export const EditorProvider = ( {
|
||||
children,
|
||||
currentPostId = 0,
|
||||
previewData = {},
|
||||
currentView = '',
|
||||
isPreview = false,
|
||||
}: {
|
||||
children: React.ReactChildren;
|
||||
currentPostId?: number | undefined;
|
||||
previewData?: Record< string, unknown > | undefined;
|
||||
currentView?: string | undefined;
|
||||
isPreview?: boolean | undefined;
|
||||
} ) => {
|
||||
const editingPostId = useSelect(
|
||||
( select ): number =>
|
||||
currentPostId
|
||||
? currentPostId
|
||||
: select( 'core/editor' ).getCurrentPostId(),
|
||||
[ currentPostId ]
|
||||
);
|
||||
|
||||
const getPreviewData = useCallback(
|
||||
( name: string ): Record< string, unknown > => {
|
||||
if ( previewData && name in previewData ) {
|
||||
return previewData[ name ] as Record< string, unknown >;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
[ previewData ]
|
||||
);
|
||||
|
||||
const editorData: EditorContextType = {
|
||||
isEditor: true,
|
||||
currentPostId: editingPostId,
|
||||
currentView,
|
||||
previewData,
|
||||
getPreviewData,
|
||||
isPreview,
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorContext.Provider value={ editorData }>
|
||||
{ children }
|
||||
</EditorContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './editor-context';
|
||||
export * from './add-to-cart-form';
|
||||
export * from './cart-checkout';
|
||||
export * from './container-width-context';
|
||||
export * from './editor-context';
|
||||
export * from './query-state-context';
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createContext, useContext } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Query state context is the index for used for a query state store. By
|
||||
* exposing this via context, it allows for all children blocks to be
|
||||
* synchronized to the same query state defined by the parent in the tree.
|
||||
*
|
||||
* Defaults to 'page' for general global query state shared among all blocks
|
||||
* in a view.
|
||||
*
|
||||
* @member {Object} QueryStateContext A react context object
|
||||
*/
|
||||
const QueryStateContext = createContext( 'page' );
|
||||
|
||||
export const useQueryStateContext = () => useContext( QueryStateContext );
|
||||
export const QueryStateContextProvider = QueryStateContext.Provider;
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { assertValidContextValue } from '../utils';
|
||||
|
||||
describe( 'assertValidContextValue', () => {
|
||||
const contextName = 'testContext';
|
||||
const validationMap = {
|
||||
cheeseburger: {
|
||||
required: false,
|
||||
type: 'string',
|
||||
},
|
||||
amountKetchup: {
|
||||
required: true,
|
||||
type: 'number',
|
||||
},
|
||||
};
|
||||
it.each`
|
||||
testValue
|
||||
${ {} }
|
||||
${ 10 }
|
||||
${ { amountKetchup: '10' } }
|
||||
`(
|
||||
'The value of $testValue is expected to trigger an Error',
|
||||
( { testValue } ) => {
|
||||
const invokeTest = () => {
|
||||
assertValidContextValue(
|
||||
contextName,
|
||||
validationMap,
|
||||
testValue
|
||||
);
|
||||
};
|
||||
expect( invokeTest ).toThrow();
|
||||
}
|
||||
);
|
||||
it.each`
|
||||
testValue
|
||||
${ { amountKetchup: 20 } }
|
||||
${ { cheeseburger: 'fries', amountKetchup: 20 } }
|
||||
`(
|
||||
'The value of $testValue is not expected to trigger an Error',
|
||||
( { testValue } ) => {
|
||||
const invokeTest = () => {
|
||||
assertValidContextValue(
|
||||
contextName,
|
||||
validationMap,
|
||||
testValue
|
||||
);
|
||||
};
|
||||
expect( invokeTest ).not.toThrow();
|
||||
}
|
||||
);
|
||||
} );
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* This is an assertion utility for validating that the incoming value prop
|
||||
* value on a given context provider is valid and throws an error if it isn't.
|
||||
*
|
||||
* Note: this asserts values that are expected to be an object.
|
||||
*
|
||||
* The validationMap is expected to be an object in the following shape.
|
||||
*
|
||||
* {
|
||||
* [expectedPropertyName<String>]: {
|
||||
* required: [expectedRequired<Boolean>]
|
||||
* type: [expectedType<String>]
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @param {string} contextName The name of the context provider being
|
||||
* validated.
|
||||
* @param {Object} validationMap A map for validating the incoming value against.
|
||||
* @param {Object} value The value being validated.
|
||||
*
|
||||
* @throws {Error}
|
||||
*/
|
||||
export const assertValidContextValue = (
|
||||
contextName,
|
||||
validationMap,
|
||||
value
|
||||
) => {
|
||||
if ( typeof value !== 'object' ) {
|
||||
throw new Error(
|
||||
`${ contextName } expects an object for its context value`
|
||||
);
|
||||
}
|
||||
const errors = [];
|
||||
for ( const expectedProperty in validationMap ) {
|
||||
if (
|
||||
validationMap[ expectedProperty ].required &&
|
||||
typeof value[ expectedProperty ] === 'undefined'
|
||||
) {
|
||||
errors.push(
|
||||
`The ${ expectedProperty } is required and is not present.`
|
||||
);
|
||||
} else if (
|
||||
typeof value[ expectedProperty ] !== 'undefined' &&
|
||||
typeof value[ expectedProperty ] !==
|
||||
validationMap[ expectedProperty ].type
|
||||
) {
|
||||
errors.push(
|
||||
`The ${ expectedProperty } must be of ${
|
||||
validationMap[ expectedProperty ].type
|
||||
} and instead was ${ typeof value[ expectedProperty ] }`
|
||||
);
|
||||
}
|
||||
}
|
||||
if ( errors.length > 0 ) {
|
||||
throw new Error(
|
||||
`There was a problem with the value passed in on ${ contextName }:\n ${ errors.join(
|
||||
'\n'
|
||||
) }`
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user