initial commit
This commit is contained in:
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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,95 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getObserversByPriority } from './utils';
|
||||
import type { EventObserversType } from './types';
|
||||
import { isErrorResponse, isFailResponse } from '../hooks/use-emit-response';
|
||||
|
||||
/**
|
||||
* 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< Array< unknown > > => {
|
||||
const observerResponses = [];
|
||||
const observersByType = getObserversByPriority( observers, eventType );
|
||||
for ( const observer of observersByType ) {
|
||||
try {
|
||||
const response = await Promise.resolve( observer.callback( data ) );
|
||||
if ( typeof response !== 'object' || response === null ) {
|
||||
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: 'error' } );
|
||||
return observerResponses;
|
||||
}
|
||||
}
|
||||
return observerResponses;
|
||||
};
|
@ -0,0 +1,4 @@
|
||||
export * from './reducer';
|
||||
export * from './emitters';
|
||||
export * from './emitter-callback';
|
||||
export * from './types';
|
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { uniqueId } from 'lodash';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
ACTION,
|
||||
ActionType,
|
||||
ActionCallbackType,
|
||||
EventObserversType,
|
||||
} from './types';
|
||||
|
||||
export const actions = {
|
||||
addEventCallback: (
|
||||
eventType: string,
|
||||
callback: ActionCallbackType,
|
||||
priority = 10
|
||||
): ActionType => {
|
||||
return {
|
||||
id: uniqueId(),
|
||||
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,15 @@
|
||||
/**
|
||||
* 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;
|
||||
} )
|
||||
: [];
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export * from './use-store-cart';
|
||||
export * from './use-store-cart-coupons';
|
||||
export * from './use-store-cart-item-quantity';
|
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import * as mockUseStoreCart from '../use-store-cart';
|
||||
import { useStoreCartItemQuantity } from '../use-store-cart-item-quantity';
|
||||
|
||||
jest.mock( '../use-store-cart', () => ( {
|
||||
useStoreCart: jest.fn(),
|
||||
} ) );
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
__esModule: true,
|
||||
CART_STORE_KEY: 'test/store',
|
||||
} ) );
|
||||
|
||||
// Make debounce instantaneous.
|
||||
jest.mock( 'use-debounce', () => ( {
|
||||
useDebounce: ( a ) => [ a ],
|
||||
} ) );
|
||||
|
||||
describe( 'useStoreCartItemQuantity', () => {
|
||||
let registry, renderer;
|
||||
|
||||
const getWrappedComponents = ( Component ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent = ( options ) => () => {
|
||||
const props = useStoreCartItemQuantity( options );
|
||||
return <div { ...props } />;
|
||||
};
|
||||
|
||||
let mockRemoveItemFromCart;
|
||||
let mockChangeCartItemQuantity;
|
||||
const setupMocks = ( { isPendingDelete, isPendingQuantity } ) => {
|
||||
mockRemoveItemFromCart = jest
|
||||
.fn()
|
||||
.mockReturnValue( { type: 'removeItemFromCartAction' } );
|
||||
mockChangeCartItemQuantity = jest
|
||||
.fn()
|
||||
.mockReturnValue( { type: 'changeCartItemQuantityAction' } );
|
||||
registry.registerStore( storeKey, {
|
||||
reducer: () => ( {} ),
|
||||
actions: {
|
||||
removeItemFromCart: mockRemoveItemFromCart,
|
||||
changeCartItemQuantity: mockChangeCartItemQuantity,
|
||||
},
|
||||
selectors: {
|
||||
isItemPendingDelete: jest
|
||||
.fn()
|
||||
.mockReturnValue( isPendingDelete ),
|
||||
isItemPendingQuantity: jest
|
||||
.fn()
|
||||
.mockReturnValue( isPendingQuantity ),
|
||||
},
|
||||
} );
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry();
|
||||
renderer = null;
|
||||
} );
|
||||
|
||||
afterEach( () => {
|
||||
mockRemoveItemFromCart.mockReset();
|
||||
mockChangeCartItemQuantity.mockReset();
|
||||
} );
|
||||
|
||||
describe( 'with no errors and not pending', () => {
|
||||
beforeEach( () => {
|
||||
setupMocks( { isPendingDelete: false, isPendingQuantity: false } );
|
||||
mockUseStoreCart.useStoreCart.mockReturnValue( {
|
||||
cartErrors: {},
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'update quantity value should happen instantly', () => {
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { setItemQuantity, quantity } = renderer.root.findByType(
|
||||
'div'
|
||||
).props;
|
||||
|
||||
expect( quantity ).toBe( 1 );
|
||||
|
||||
act( () => {
|
||||
setItemQuantity( 2 );
|
||||
} );
|
||||
|
||||
const { quantity: newQuantity } = renderer.root.findByType(
|
||||
'div'
|
||||
).props;
|
||||
|
||||
expect( newQuantity ).toBe( 2 );
|
||||
} );
|
||||
|
||||
it( 'removeItem should call the dispatch action', () => {
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { removeItem } = renderer.root.findByType( 'div' ).props;
|
||||
|
||||
act( () => {
|
||||
removeItem();
|
||||
} );
|
||||
|
||||
expect( mockRemoveItemFromCart ).toHaveBeenCalledWith( '123' );
|
||||
} );
|
||||
|
||||
it( 'setItemQuantity should call the dispatch action', () => {
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { setItemQuantity } = renderer.root.findByType( 'div' ).props;
|
||||
|
||||
act( () => {
|
||||
setItemQuantity( 2 );
|
||||
} );
|
||||
|
||||
expect( mockChangeCartItemQuantity.mock.calls ).toEqual( [
|
||||
[ '123', 2 ],
|
||||
] );
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'should expose store errors', () => {
|
||||
const mockCartErrors = [ { message: 'Test error' } ];
|
||||
setupMocks( { isPendingDelete: false, isPendingQuantity: false } );
|
||||
mockUseStoreCart.useStoreCart.mockReturnValue( {
|
||||
cartErrors: mockCartErrors,
|
||||
} );
|
||||
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { cartItemQuantityErrors } = renderer.root.findByType(
|
||||
'div'
|
||||
).props;
|
||||
|
||||
expect( cartItemQuantityErrors ).toEqual( mockCartErrors );
|
||||
} );
|
||||
|
||||
it( 'isPendingDelete should depend on the value provided by the store', () => {
|
||||
setupMocks( { isPendingDelete: true, isPendingQuantity: false } );
|
||||
mockUseStoreCart.useStoreCart.mockReturnValue( {
|
||||
cartErrors: {},
|
||||
} );
|
||||
|
||||
const TestComponent = getTestComponent( {
|
||||
key: '123',
|
||||
quantity: 1,
|
||||
} );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { isPendingDelete } = renderer.root.findByType( 'div' ).props;
|
||||
|
||||
expect( isPendingDelete ).toBe( true );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,235 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { previewCart } from '@woocommerce/resource-previews';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { defaultCartData, useStoreCart } from '../use-store-cart';
|
||||
import { useEditorContext } from '../../../providers/editor-context';
|
||||
|
||||
jest.mock( '../../../providers/editor-context', () => ( {
|
||||
useEditorContext: jest.fn(),
|
||||
} ) );
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
...jest.requireActual( '@woocommerce/block-data' ),
|
||||
__esModule: true,
|
||||
CART_STORE_KEY: 'test/store',
|
||||
} ) );
|
||||
|
||||
describe( 'useStoreCart', () => {
|
||||
let registry, renderer;
|
||||
|
||||
const receiveCartMock = () => {};
|
||||
|
||||
const previewCartData = {
|
||||
cartCoupons: previewCart.coupons,
|
||||
cartItems: previewCart.items,
|
||||
cartFees: previewCart.fees,
|
||||
cartItemsCount: previewCart.items_count,
|
||||
cartItemsWeight: previewCart.items_weight,
|
||||
cartNeedsPayment: previewCart.needs_payment,
|
||||
cartNeedsShipping: previewCart.needs_shipping,
|
||||
cartTotals: previewCart.totals,
|
||||
cartIsLoading: false,
|
||||
cartItemErrors: [],
|
||||
cartErrors: [],
|
||||
billingAddress: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
},
|
||||
shippingAddress: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
phone: '',
|
||||
},
|
||||
shippingRates: previewCart.shipping_rates,
|
||||
extensions: {},
|
||||
shippingRatesLoading: false,
|
||||
cartHasCalculatedShipping: true,
|
||||
};
|
||||
|
||||
const mockCartItems = [ { key: '1', id: 1, name: 'Lorem Ipsum' } ];
|
||||
const mockShippingAddress = {
|
||||
city: 'New York',
|
||||
};
|
||||
const mockCartData = {
|
||||
coupons: [],
|
||||
items: mockCartItems,
|
||||
fees: [],
|
||||
itemsCount: 1,
|
||||
itemsWeight: 10,
|
||||
needsPayment: true,
|
||||
needsShipping: true,
|
||||
billingAddress: {},
|
||||
shippingAddress: mockShippingAddress,
|
||||
shippingRates: [],
|
||||
hasCalculatedShipping: true,
|
||||
extensions: {},
|
||||
errors: [],
|
||||
receiveCart: undefined,
|
||||
paymentRequirements: [],
|
||||
};
|
||||
const mockCartTotals = {
|
||||
currency_code: 'USD',
|
||||
};
|
||||
const mockCartIsLoading = false;
|
||||
const mockCartErrors = [];
|
||||
const mockStoreCartData = {
|
||||
cartCoupons: [],
|
||||
cartItems: mockCartItems,
|
||||
cartItemErrors: [],
|
||||
cartItemsCount: 1,
|
||||
cartItemsWeight: 10,
|
||||
cartNeedsPayment: true,
|
||||
cartNeedsShipping: true,
|
||||
cartTotals: mockCartTotals,
|
||||
cartIsLoading: mockCartIsLoading,
|
||||
cartErrors: mockCartErrors,
|
||||
cartFees: [],
|
||||
billingAddress: {},
|
||||
shippingAddress: mockShippingAddress,
|
||||
shippingRates: [],
|
||||
extensions: {},
|
||||
shippingRatesLoading: false,
|
||||
cartHasCalculatedShipping: true,
|
||||
receiveCart: undefined,
|
||||
paymentRequirements: [],
|
||||
};
|
||||
|
||||
const getWrappedComponents = ( Component ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent = ( options ) => () => {
|
||||
const { receiveCart, ...results } = useStoreCart( options );
|
||||
return <div results={ results } receiveCart={ receiveCart } />;
|
||||
};
|
||||
|
||||
const setUpMocks = () => {
|
||||
const mocks = {
|
||||
selectors: {
|
||||
getCartData: jest.fn().mockReturnValue( mockCartData ),
|
||||
getCartErrors: jest.fn().mockReturnValue( mockCartErrors ),
|
||||
getCartTotals: jest.fn().mockReturnValue( mockCartTotals ),
|
||||
hasFinishedResolution: jest
|
||||
.fn()
|
||||
.mockReturnValue( ! mockCartIsLoading ),
|
||||
isCustomerDataUpdating: jest.fn().mockReturnValue( false ),
|
||||
},
|
||||
};
|
||||
registry.registerStore( storeKey, {
|
||||
reducer: () => ( {} ),
|
||||
selectors: mocks.selectors,
|
||||
} );
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry();
|
||||
renderer = null;
|
||||
setUpMocks();
|
||||
} );
|
||||
|
||||
afterEach( () => {
|
||||
useEditorContext.mockReset();
|
||||
} );
|
||||
|
||||
describe( 'in frontend', () => {
|
||||
beforeEach( () => {
|
||||
useEditorContext.mockReturnValue( {
|
||||
isEditor: false,
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'return default data when shouldSelect is false', () => {
|
||||
const TestComponent = getTestComponent( { shouldSelect: false } );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { results, receiveCart } = renderer.root.findByType(
|
||||
'div'
|
||||
).props;
|
||||
const {
|
||||
receiveCart: defaultReceiveCart,
|
||||
...remaining
|
||||
} = defaultCartData;
|
||||
expect( results ).toEqual( remaining );
|
||||
expect( receiveCart ).toEqual( defaultReceiveCart );
|
||||
} );
|
||||
|
||||
it( 'return store data when shouldSelect is true', () => {
|
||||
const TestComponent = getTestComponent( { shouldSelect: true } );
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { results, receiveCart } = renderer.root.findByType(
|
||||
'div'
|
||||
).props;
|
||||
|
||||
expect( results ).toEqual( mockStoreCartData );
|
||||
expect( receiveCart ).toBeUndefined();
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'in editor', () => {
|
||||
beforeEach( () => {
|
||||
useEditorContext.mockReturnValue( {
|
||||
isEditor: true,
|
||||
previewData: {
|
||||
previewCart: {
|
||||
...previewCart,
|
||||
receiveCart: receiveCartMock,
|
||||
},
|
||||
},
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'return preview data in editor', () => {
|
||||
const TestComponent = getTestComponent();
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { results, receiveCart } = renderer.root.findByType(
|
||||
'div'
|
||||
).props;
|
||||
|
||||
expect( results ).toEqual( previewCartData );
|
||||
expect( receiveCart ).toEqual( receiveCartMock );
|
||||
} );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,126 @@
|
||||
/** @typedef { import('@woocommerce/type-defs/hooks').StoreCartCoupon } StoreCartCoupon */
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import type { StoreCartCoupon } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from './use-store-cart';
|
||||
import { useStoreSnackbarNotices } from '../use-store-snackbar-notices';
|
||||
import { useValidationContext } from '../../providers/validation';
|
||||
import { useStoreNotices } from '../use-store-notices';
|
||||
|
||||
/**
|
||||
* This is a custom hook for loading the Store API /cart/coupons endpoint and an
|
||||
* action for adding a coupon _to_ the cart.
|
||||
* See also: https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/trunk/src/RestApi/StoreApi
|
||||
*
|
||||
* @return {StoreCartCoupon} An object exposing data and actions from/for the
|
||||
* store api /cart/coupons endpoint.
|
||||
*/
|
||||
export const useStoreCartCoupons = (): StoreCartCoupon => {
|
||||
const { cartCoupons, cartIsLoading } = useStoreCart();
|
||||
const { addErrorNotice } = useStoreNotices();
|
||||
const { addSnackbarNotice } = useStoreSnackbarNotices();
|
||||
const { setValidationErrors } = useValidationContext();
|
||||
|
||||
const results: Pick<
|
||||
StoreCartCoupon,
|
||||
'applyCoupon' | 'removeCoupon' | 'isApplyingCoupon' | 'isRemovingCoupon'
|
||||
> = useSelect(
|
||||
( select, { dispatch } ) => {
|
||||
const store = select( storeKey );
|
||||
const isApplyingCoupon = store.isApplyingCoupon();
|
||||
const isRemovingCoupon = store.isRemovingCoupon();
|
||||
const {
|
||||
applyCoupon,
|
||||
removeCoupon,
|
||||
receiveApplyingCoupon,
|
||||
}: {
|
||||
applyCoupon: ( coupon: string ) => Promise< boolean >;
|
||||
removeCoupon: ( coupon: string ) => Promise< boolean >;
|
||||
receiveApplyingCoupon: ( coupon: string ) => void;
|
||||
} = dispatch( storeKey );
|
||||
|
||||
const applyCouponWithNotices = ( couponCode: string ) => {
|
||||
applyCoupon( couponCode )
|
||||
.then( ( result ) => {
|
||||
if ( result === true ) {
|
||||
addSnackbarNotice(
|
||||
sprintf(
|
||||
/* translators: %s coupon code. */
|
||||
__(
|
||||
'Coupon code "%s" has been applied to your cart.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
couponCode
|
||||
),
|
||||
{
|
||||
id: 'coupon-form',
|
||||
}
|
||||
);
|
||||
}
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
setValidationErrors( {
|
||||
coupon: {
|
||||
message: decodeEntities( error.message ),
|
||||
hidden: false,
|
||||
},
|
||||
} );
|
||||
// Finished handling the coupon.
|
||||
receiveApplyingCoupon( '' );
|
||||
} );
|
||||
};
|
||||
|
||||
const removeCouponWithNotices = ( couponCode: string ) => {
|
||||
removeCoupon( couponCode )
|
||||
.then( ( result ) => {
|
||||
if ( result === true ) {
|
||||
addSnackbarNotice(
|
||||
sprintf(
|
||||
/* translators: %s coupon code. */
|
||||
__(
|
||||
'Coupon code "%s" has been removed from your cart.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
couponCode
|
||||
),
|
||||
{
|
||||
id: 'coupon-form',
|
||||
}
|
||||
);
|
||||
}
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
addErrorNotice( error.message, {
|
||||
id: 'coupon-form',
|
||||
} );
|
||||
// Finished handling the coupon.
|
||||
receiveApplyingCoupon( '' );
|
||||
} );
|
||||
};
|
||||
|
||||
return {
|
||||
applyCoupon: applyCouponWithNotices,
|
||||
removeCoupon: removeCouponWithNotices,
|
||||
isApplyingCoupon,
|
||||
isRemovingCoupon,
|
||||
};
|
||||
},
|
||||
[ addErrorNotice, addSnackbarNotice ]
|
||||
);
|
||||
|
||||
return {
|
||||
appliedCoupons: cartCoupons,
|
||||
isLoading: cartIsLoading,
|
||||
...results,
|
||||
};
|
||||
};
|
@ -0,0 +1,146 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
import { useCallback, useState, useEffect } from '@wordpress/element';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { usePrevious } from '@woocommerce/base-hooks';
|
||||
import { triggerFragmentRefresh } from '@woocommerce/base-utils';
|
||||
import {
|
||||
CartItem,
|
||||
StoreCartItemQuantity,
|
||||
isNumber,
|
||||
isObject,
|
||||
isString,
|
||||
objectHasProp,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from './use-store-cart';
|
||||
import { useCheckoutContext } from '../../providers/cart-checkout';
|
||||
|
||||
/**
|
||||
* Ensures the object passed has props key: string and quantity: number
|
||||
*/
|
||||
const cartItemHasQuantityAndKey = (
|
||||
cartItem: unknown /* Object that may have quantity and key */
|
||||
): cartItem is Partial< CartItem > =>
|
||||
isObject( cartItem ) &&
|
||||
objectHasProp( cartItem, 'key' ) &&
|
||||
objectHasProp( cartItem, 'quantity' ) &&
|
||||
isString( cartItem.key ) &&
|
||||
isNumber( cartItem.quantity );
|
||||
|
||||
/**
|
||||
* This is a custom hook for loading the Store API /cart/ endpoint and actions for removing or changing item quantity.
|
||||
*
|
||||
* @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/trunk/src/RestApi/StoreApi
|
||||
*
|
||||
* @param {CartItem} cartItem The cartItem to get quantity info from and will have quantity updated on.
|
||||
* @return {StoreCartItemQuantity} An object exposing data and actions relating to cart items.
|
||||
*/
|
||||
export const useStoreCartItemQuantity = (
|
||||
cartItem: CartItem | Record< string, unknown >
|
||||
): StoreCartItemQuantity => {
|
||||
const verifiedCartItem = { key: '', quantity: 1 };
|
||||
|
||||
if ( cartItemHasQuantityAndKey( cartItem ) ) {
|
||||
verifiedCartItem.key = cartItem.key;
|
||||
verifiedCartItem.quantity = cartItem.quantity;
|
||||
}
|
||||
const {
|
||||
key: cartItemKey = '',
|
||||
quantity: cartItemQuantity = 1,
|
||||
} = verifiedCartItem;
|
||||
const { cartErrors } = useStoreCart();
|
||||
const { dispatchActions } = useCheckoutContext();
|
||||
|
||||
// Store quantity in hook state. This is used to keep the UI updated while server request is updated.
|
||||
const [ quantity, setQuantity ] = useState< number >( cartItemQuantity );
|
||||
const [ debouncedQuantity ] = useDebounce< number >( quantity, 400 );
|
||||
const previousDebouncedQuantity = usePrevious( debouncedQuantity );
|
||||
const { removeItemFromCart, changeCartItemQuantity } = useDispatch(
|
||||
storeKey
|
||||
);
|
||||
|
||||
// Track when things are already pending updates.
|
||||
const isPending = useSelect(
|
||||
( select ) => {
|
||||
if ( ! cartItemKey ) {
|
||||
return {
|
||||
quantity: false,
|
||||
delete: false,
|
||||
};
|
||||
}
|
||||
const store = select( storeKey );
|
||||
return {
|
||||
quantity: store.isItemPendingQuantity( cartItemKey ),
|
||||
delete: store.isItemPendingDelete( cartItemKey ),
|
||||
};
|
||||
},
|
||||
[ cartItemKey ]
|
||||
);
|
||||
|
||||
const removeItem = useCallback( () => {
|
||||
return cartItemKey
|
||||
? removeItemFromCart( cartItemKey ).then( () => {
|
||||
triggerFragmentRefresh();
|
||||
return true;
|
||||
} )
|
||||
: Promise.resolve( false );
|
||||
}, [ cartItemKey, removeItemFromCart ] );
|
||||
|
||||
// Observe debounced quantity value, fire action to update server on change.
|
||||
useEffect( () => {
|
||||
if (
|
||||
cartItemKey &&
|
||||
isNumber( previousDebouncedQuantity ) &&
|
||||
Number.isFinite( previousDebouncedQuantity ) &&
|
||||
previousDebouncedQuantity !== debouncedQuantity
|
||||
) {
|
||||
changeCartItemQuantity( cartItemKey, debouncedQuantity );
|
||||
}
|
||||
}, [
|
||||
cartItemKey,
|
||||
changeCartItemQuantity,
|
||||
debouncedQuantity,
|
||||
previousDebouncedQuantity,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( isPending.delete ) {
|
||||
dispatchActions.incrementCalculating();
|
||||
} else {
|
||||
dispatchActions.decrementCalculating();
|
||||
}
|
||||
return () => {
|
||||
if ( isPending.delete ) {
|
||||
dispatchActions.decrementCalculating();
|
||||
}
|
||||
};
|
||||
}, [ dispatchActions, isPending.delete ] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( isPending.quantity || debouncedQuantity !== quantity ) {
|
||||
dispatchActions.incrementCalculating();
|
||||
} else {
|
||||
dispatchActions.decrementCalculating();
|
||||
}
|
||||
return () => {
|
||||
if ( isPending.quantity || debouncedQuantity !== quantity ) {
|
||||
dispatchActions.decrementCalculating();
|
||||
}
|
||||
};
|
||||
}, [ dispatchActions, isPending.quantity, debouncedQuantity, quantity ] );
|
||||
|
||||
return {
|
||||
isPendingDelete: isPending.delete,
|
||||
quantity,
|
||||
setItemQuantity: setQuantity,
|
||||
removeItem,
|
||||
cartItemQuantityErrors: cartErrors,
|
||||
};
|
||||
};
|
@ -0,0 +1,240 @@
|
||||
/** @typedef { import('@woocommerce/type-defs/hooks').StoreCart } StoreCart */
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { isEqual } from 'lodash';
|
||||
import { useRef } from '@wordpress/element';
|
||||
import {
|
||||
CART_STORE_KEY as storeKey,
|
||||
EMPTY_CART_COUPONS,
|
||||
EMPTY_CART_ITEMS,
|
||||
EMPTY_CART_FEES,
|
||||
EMPTY_CART_ITEM_ERRORS,
|
||||
EMPTY_CART_ERRORS,
|
||||
EMPTY_SHIPPING_RATES,
|
||||
EMPTY_TAX_LINES,
|
||||
EMPTY_PAYMENT_REQUIREMENTS,
|
||||
EMPTY_EXTENSIONS,
|
||||
} from '@woocommerce/block-data';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import type {
|
||||
StoreCart,
|
||||
CartResponseTotals,
|
||||
CartResponseFeeItem,
|
||||
CartResponseBillingAddress,
|
||||
CartResponseShippingAddress,
|
||||
CartResponseCouponItem,
|
||||
CartResponseCoupons,
|
||||
} from '@woocommerce/types';
|
||||
import {
|
||||
emptyHiddenAddressFields,
|
||||
fromEntriesPolyfill,
|
||||
} from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useEditorContext } from '../../providers/editor-context';
|
||||
|
||||
declare module '@wordpress/html-entities' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
export function decodeEntities< T >( coupon: T ): T;
|
||||
}
|
||||
const defaultShippingAddress: CartResponseShippingAddress = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
phone: '',
|
||||
};
|
||||
|
||||
const defaultBillingAddress: CartResponseBillingAddress = {
|
||||
...defaultShippingAddress,
|
||||
email: '',
|
||||
};
|
||||
|
||||
const defaultCartTotals: CartResponseTotals = {
|
||||
total_items: '',
|
||||
total_items_tax: '',
|
||||
total_fees: '',
|
||||
total_fees_tax: '',
|
||||
total_discount: '',
|
||||
total_discount_tax: '',
|
||||
total_shipping: '',
|
||||
total_shipping_tax: '',
|
||||
total_price: '',
|
||||
total_tax: '',
|
||||
tax_lines: EMPTY_TAX_LINES,
|
||||
currency_code: '',
|
||||
currency_symbol: '',
|
||||
currency_minor_unit: 2,
|
||||
currency_decimal_separator: '',
|
||||
currency_thousand_separator: '',
|
||||
currency_prefix: '',
|
||||
currency_suffix: '',
|
||||
};
|
||||
|
||||
const decodeValues = (
|
||||
object: Record< string, unknown >
|
||||
): Record< string, unknown > =>
|
||||
fromEntriesPolyfill(
|
||||
Object.entries( object ).map( ( [ key, value ] ) => [
|
||||
key,
|
||||
decodeEntities( value ),
|
||||
] )
|
||||
);
|
||||
|
||||
/**
|
||||
* @constant
|
||||
* @type {StoreCart} Object containing cart data.
|
||||
*/
|
||||
export const defaultCartData: StoreCart = {
|
||||
cartCoupons: EMPTY_CART_COUPONS,
|
||||
cartItems: EMPTY_CART_ITEMS,
|
||||
cartFees: EMPTY_CART_FEES,
|
||||
cartItemsCount: 0,
|
||||
cartItemsWeight: 0,
|
||||
cartNeedsPayment: true,
|
||||
cartNeedsShipping: true,
|
||||
cartItemErrors: EMPTY_CART_ITEM_ERRORS,
|
||||
cartTotals: defaultCartTotals,
|
||||
cartIsLoading: true,
|
||||
cartErrors: EMPTY_CART_ERRORS,
|
||||
billingAddress: defaultBillingAddress,
|
||||
shippingAddress: defaultShippingAddress,
|
||||
shippingRates: EMPTY_SHIPPING_RATES,
|
||||
shippingRatesLoading: false,
|
||||
cartHasCalculatedShipping: false,
|
||||
paymentRequirements: EMPTY_PAYMENT_REQUIREMENTS,
|
||||
receiveCart: () => undefined,
|
||||
extensions: EMPTY_EXTENSIONS,
|
||||
};
|
||||
|
||||
/**
|
||||
* This is a custom hook that is wired up to the `wc/store/cart` data
|
||||
* store.
|
||||
*
|
||||
* @param {Object} options An object declaring the various
|
||||
* collection arguments.
|
||||
* @param {boolean} options.shouldSelect If false, the previous results will be
|
||||
* returned and internal selects will not
|
||||
* fire.
|
||||
*
|
||||
* @return {StoreCart} Object containing cart data.
|
||||
*/
|
||||
|
||||
export const useStoreCart = (
|
||||
options: { shouldSelect: boolean } = { shouldSelect: true }
|
||||
): StoreCart => {
|
||||
const { isEditor, previewData } = useEditorContext();
|
||||
const previewCart = previewData?.previewCart;
|
||||
const { shouldSelect } = options;
|
||||
const currentResults = useRef();
|
||||
const results: StoreCart = useSelect(
|
||||
( select, { dispatch } ) => {
|
||||
if ( ! shouldSelect ) {
|
||||
return defaultCartData;
|
||||
}
|
||||
|
||||
if ( isEditor ) {
|
||||
return {
|
||||
cartCoupons: previewCart.coupons,
|
||||
cartItems: previewCart.items,
|
||||
cartFees: previewCart.fees,
|
||||
cartItemsCount: previewCart.items_count,
|
||||
cartItemsWeight: previewCart.items_weight,
|
||||
cartNeedsPayment: previewCart.needs_payment,
|
||||
cartNeedsShipping: previewCart.needs_shipping,
|
||||
cartItemErrors: EMPTY_CART_ITEM_ERRORS,
|
||||
cartTotals: previewCart.totals,
|
||||
cartIsLoading: false,
|
||||
cartErrors: EMPTY_CART_ERRORS,
|
||||
billingAddress: defaultBillingAddress,
|
||||
shippingAddress: defaultShippingAddress,
|
||||
extensions: EMPTY_EXTENSIONS,
|
||||
shippingRates: previewCart.shipping_rates,
|
||||
shippingRatesLoading: false,
|
||||
cartHasCalculatedShipping:
|
||||
previewCart.has_calculated_shipping,
|
||||
paymentRequirements: previewCart.paymentRequirements,
|
||||
receiveCart:
|
||||
typeof previewCart?.receiveCart === 'function'
|
||||
? previewCart.receiveCart
|
||||
: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const store = select( storeKey );
|
||||
const cartData = store.getCartData();
|
||||
const cartErrors = store.getCartErrors();
|
||||
const cartTotals = store.getCartTotals();
|
||||
const cartIsLoading = ! store.hasFinishedResolution(
|
||||
'getCartData'
|
||||
);
|
||||
const shippingRatesLoading = store.isCustomerDataUpdating();
|
||||
const { receiveCart } = dispatch( storeKey );
|
||||
const billingAddress = decodeValues( cartData.billingAddress );
|
||||
const shippingAddress = cartData.needsShipping
|
||||
? decodeValues( cartData.shippingAddress )
|
||||
: billingAddress;
|
||||
const cartFees =
|
||||
cartData.fees.length > 0
|
||||
? cartData.fees.map( ( fee: CartResponseFeeItem ) =>
|
||||
decodeValues( fee )
|
||||
)
|
||||
: EMPTY_CART_FEES;
|
||||
|
||||
// Add a text property to the coupon to allow extensions to modify
|
||||
// the text used to display the coupon, without affecting the
|
||||
// functionality when it comes to removing the coupon.
|
||||
const cartCoupons: CartResponseCoupons =
|
||||
cartData.coupons.length > 0
|
||||
? cartData.coupons.map(
|
||||
( coupon: CartResponseCouponItem ) => ( {
|
||||
...coupon,
|
||||
label: coupon.code,
|
||||
} )
|
||||
)
|
||||
: EMPTY_CART_COUPONS;
|
||||
|
||||
return {
|
||||
cartCoupons,
|
||||
cartItems: cartData.items,
|
||||
cartFees,
|
||||
cartItemsCount: cartData.itemsCount,
|
||||
cartItemsWeight: cartData.itemsWeight,
|
||||
cartNeedsPayment: cartData.needsPayment,
|
||||
cartNeedsShipping: cartData.needsShipping,
|
||||
cartItemErrors: cartData.errors,
|
||||
cartTotals,
|
||||
cartIsLoading,
|
||||
cartErrors,
|
||||
billingAddress: emptyHiddenAddressFields( billingAddress ),
|
||||
shippingAddress: emptyHiddenAddressFields( shippingAddress ),
|
||||
extensions: cartData.extensions,
|
||||
shippingRates: cartData.shippingRates,
|
||||
shippingRatesLoading,
|
||||
cartHasCalculatedShipping: cartData.hasCalculatedShipping,
|
||||
paymentRequirements: cartData.paymentRequirements,
|
||||
receiveCart,
|
||||
};
|
||||
},
|
||||
[ shouldSelect ]
|
||||
);
|
||||
|
||||
if (
|
||||
! currentResults.current ||
|
||||
! isEqual( currentResults.current, results )
|
||||
) {
|
||||
currentResults.current = results;
|
||||
}
|
||||
|
||||
return currentResults.current;
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export * from './use-collection-data';
|
||||
export * from './use-collection-header';
|
||||
export * from './use-collection';
|
@ -0,0 +1,298 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { Component as ReactComponent } from '@wordpress/element';
|
||||
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCollection } from '../use-collection';
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
__esModule: true,
|
||||
COLLECTIONS_STORE_KEY: 'test/store',
|
||||
} ) );
|
||||
|
||||
class TestErrorBoundary extends ReactComponent {
|
||||
constructor( props ) {
|
||||
super( props );
|
||||
this.state = { hasError: false, error: {} };
|
||||
}
|
||||
static getDerivedStateFromError( error ) {
|
||||
// Update state so the next render will show the fallback UI.
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if ( this.state.hasError ) {
|
||||
return <div error={ this.state.error } />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
describe( 'useCollection', () => {
|
||||
let registry, mocks, renderer;
|
||||
const getProps = ( testRenderer ) => {
|
||||
const { results, isLoading } = testRenderer.root.findByType(
|
||||
'div'
|
||||
).props;
|
||||
return {
|
||||
results,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
|
||||
const getWrappedComponents = ( Component, props ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<TestErrorBoundary>
|
||||
<Component { ...props } />
|
||||
</TestErrorBoundary>
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent = () => ( { options } ) => {
|
||||
const items = useCollection( options );
|
||||
return <div { ...items } />;
|
||||
};
|
||||
|
||||
const setUpMocks = () => {
|
||||
mocks = {
|
||||
selectors: {
|
||||
getCollectionError: jest.fn().mockReturnValue( false ),
|
||||
getCollection: jest
|
||||
.fn()
|
||||
.mockImplementation( () => ( { foo: 'bar' } ) ),
|
||||
hasFinishedResolution: jest.fn().mockReturnValue( true ),
|
||||
},
|
||||
};
|
||||
registry.registerStore( storeKey, {
|
||||
reducer: () => ( {} ),
|
||||
selectors: mocks.selectors,
|
||||
} );
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry();
|
||||
mocks = {};
|
||||
renderer = null;
|
||||
setUpMocks();
|
||||
} );
|
||||
it(
|
||||
'should throw an error if an options object is provided without ' +
|
||||
'a namespace property',
|
||||
() => {
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
resourceName: 'products',
|
||||
query: { bar: 'foo' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const props = renderer.root.findByType( 'div' ).props;
|
||||
expect( props.error.message ).toMatch( /options object/ );
|
||||
expect( console ).toHaveErrored( /your React components:/ );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
it(
|
||||
'should throw an error if an options object is provided without ' +
|
||||
'a resourceName property',
|
||||
() => {
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
query: { bar: 'foo' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const props = renderer.root.findByType( 'div' ).props;
|
||||
expect( props.error.message ).toMatch( /options object/ );
|
||||
expect( console ).toHaveErrored( /your React components:/ );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
it(
|
||||
'should return expected behaviour for equivalent query on props ' +
|
||||
'across renders',
|
||||
() => {
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
query: { bar: 'foo' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results } = getProps( renderer );
|
||||
// rerender
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
query: { bar: 'foo' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
// re-render should result in same products object because although
|
||||
// query-state is a different instance, it's still equivalent.
|
||||
const { results: newResults } = getProps( renderer );
|
||||
expect( newResults ).toBe( results );
|
||||
// now let's change the query passed through to verify new object
|
||||
// is created.
|
||||
// remember this won't actually change the results because the mock
|
||||
// selector is returning an equivalent object when it is called,
|
||||
// however it SHOULD be a new object instance.
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
query: { foo: 'bar' },
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results: resultsVerification } = getProps( renderer );
|
||||
expect( resultsVerification ).not.toBe( results );
|
||||
expect( resultsVerification ).toEqual( results );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
it(
|
||||
'should return expected behaviour for equivalent resourceValues on' +
|
||||
' props across renders',
|
||||
() => {
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
resourceValues: [ 10, 20 ],
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results } = getProps( renderer );
|
||||
// rerender
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
resourceValues: [ 10, 20 ],
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
// re-render should result in same products object because although
|
||||
// query-state is a different instance, it's still equivalent.
|
||||
const { results: newResults } = getProps( renderer );
|
||||
expect( newResults ).toBe( results );
|
||||
// now let's change the query passed through to verify new object
|
||||
// is created.
|
||||
// remember this won't actually change the results because the mock
|
||||
// selector is returning an equivalent object when it is called,
|
||||
// however it SHOULD be a new object instance.
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
resourceValues: [ 20, 10 ],
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results: resultsVerification } = getProps( renderer );
|
||||
expect( resultsVerification ).not.toBe( results );
|
||||
expect( resultsVerification ).toEqual( results );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
it( 'should return previous query results if `shouldSelect` is false', () => {
|
||||
mocks.selectors.getCollection.mockImplementation(
|
||||
( state, ...args ) => {
|
||||
return args;
|
||||
}
|
||||
);
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'products',
|
||||
resourceValues: [ 10, 20 ],
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results } = getProps( renderer );
|
||||
// rerender but with shouldSelect to false
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'productsb',
|
||||
resourceValues: [ 10, 30 ],
|
||||
shouldSelect: false,
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results: results2 } = getProps( renderer );
|
||||
expect( results2 ).toBe( results );
|
||||
// expect 2 calls because internally, useSelect invokes callback twice
|
||||
// on mount.
|
||||
expect( mocks.selectors.getCollection ).toHaveBeenCalledTimes( 2 );
|
||||
|
||||
// rerender again but set shouldSelect to true again and we should see
|
||||
// new results
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
options: {
|
||||
namespace: 'test/store',
|
||||
resourceName: 'productsb',
|
||||
resourceValues: [ 10, 30 ],
|
||||
shouldSelect: true,
|
||||
},
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { results: results3 } = getProps( renderer );
|
||||
expect( results3 ).not.toEqual( results );
|
||||
expect( results3 ).toEqual( [
|
||||
'test/store',
|
||||
'productsb',
|
||||
{},
|
||||
[ 10, 30 ],
|
||||
] );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect, useMemo } from '@wordpress/element';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { sortBy } from 'lodash';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useQueryStateByContext, useQueryStateByKey } from '../use-query-state';
|
||||
import { useCollection } from './use-collection';
|
||||
import { useQueryStateContext } from '../../providers/query-state-context';
|
||||
|
||||
const buildCollectionDataQuery = ( collectionDataQueryState ) => {
|
||||
const query = collectionDataQueryState;
|
||||
|
||||
if ( collectionDataQueryState.calculate_attribute_counts ) {
|
||||
query.calculate_attribute_counts = sortBy(
|
||||
collectionDataQueryState.calculate_attribute_counts.map(
|
||||
( { taxonomy, queryType } ) => {
|
||||
return {
|
||||
taxonomy,
|
||||
query_type: queryType,
|
||||
};
|
||||
}
|
||||
),
|
||||
[ 'taxonomy', 'query_type' ]
|
||||
);
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const useCollectionData = ( {
|
||||
queryAttribute,
|
||||
queryPrices,
|
||||
queryStock,
|
||||
queryState,
|
||||
} ) => {
|
||||
let context = useQueryStateContext();
|
||||
context = `${ context }-collection-data`;
|
||||
|
||||
const [ collectionDataQueryState ] = useQueryStateByContext( context );
|
||||
const [
|
||||
calculateAttributesQueryState,
|
||||
setCalculateAttributesQueryState,
|
||||
] = useQueryStateByKey( 'calculate_attribute_counts', [], context );
|
||||
const [
|
||||
calculatePriceRangeQueryState,
|
||||
setCalculatePriceRangeQueryState,
|
||||
] = useQueryStateByKey( 'calculate_price_range', null, context );
|
||||
const [
|
||||
calculateStockStatusQueryState,
|
||||
setCalculateStockStatusQueryState,
|
||||
] = useQueryStateByKey( 'calculate_stock_status_counts', null, context );
|
||||
|
||||
const currentQueryAttribute = useShallowEqual( queryAttribute || {} );
|
||||
const currentQueryPrices = useShallowEqual( queryPrices );
|
||||
const currentQueryStock = useShallowEqual( queryStock );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
typeof currentQueryAttribute === 'object' &&
|
||||
Object.keys( currentQueryAttribute ).length
|
||||
) {
|
||||
const foundAttribute = calculateAttributesQueryState.find(
|
||||
( attribute ) => {
|
||||
return (
|
||||
attribute.taxonomy === currentQueryAttribute.taxonomy
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if ( ! foundAttribute ) {
|
||||
setCalculateAttributesQueryState( [
|
||||
...calculateAttributesQueryState,
|
||||
currentQueryAttribute,
|
||||
] );
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentQueryAttribute,
|
||||
calculateAttributesQueryState,
|
||||
setCalculateAttributesQueryState,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
calculatePriceRangeQueryState !== currentQueryPrices &&
|
||||
currentQueryPrices !== undefined
|
||||
) {
|
||||
setCalculatePriceRangeQueryState( currentQueryPrices );
|
||||
}
|
||||
}, [
|
||||
currentQueryPrices,
|
||||
setCalculatePriceRangeQueryState,
|
||||
calculatePriceRangeQueryState,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
calculateStockStatusQueryState !== currentQueryStock &&
|
||||
currentQueryStock !== undefined
|
||||
) {
|
||||
setCalculateStockStatusQueryState( currentQueryStock );
|
||||
}
|
||||
}, [
|
||||
currentQueryStock,
|
||||
setCalculateStockStatusQueryState,
|
||||
calculateStockStatusQueryState,
|
||||
] );
|
||||
|
||||
// Defer the select query so all collection-data query vars can be gathered.
|
||||
const [ shouldSelect, setShouldSelect ] = useState( false );
|
||||
const [ debouncedShouldSelect ] = useDebounce( shouldSelect, 200 );
|
||||
|
||||
if ( ! shouldSelect ) {
|
||||
setShouldSelect( true );
|
||||
}
|
||||
|
||||
const collectionDataQueryVars = useMemo( () => {
|
||||
return buildCollectionDataQuery( collectionDataQueryState );
|
||||
}, [ collectionDataQueryState ] );
|
||||
|
||||
return useCollection( {
|
||||
namespace: '/wc/store',
|
||||
resourceName: 'products/collection-data',
|
||||
query: {
|
||||
...queryState,
|
||||
page: undefined,
|
||||
per_page: undefined,
|
||||
orderby: undefined,
|
||||
order: undefined,
|
||||
...collectionDataQueryVars,
|
||||
},
|
||||
shouldSelect: debouncedShouldSelect,
|
||||
} );
|
||||
};
|
@ -0,0 +1,86 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* This is a custom hook that is wired up to the `wc/store/collections` data
|
||||
* store. Given a header key and a collections option object, this will ensure a
|
||||
* component is kept up to date with the collection header value matching that
|
||||
* query in the store state.
|
||||
*
|
||||
* @param {string} headerKey Used to indicate which header value to
|
||||
* return for the given collection query.
|
||||
* Example: `'x-wp-total'`
|
||||
* @param {Object} options An object declaring the various
|
||||
* collection arguments.
|
||||
* @param {string} options.namespace The namespace for the collection.
|
||||
* Example: `'/wc/blocks'`
|
||||
* @param {string} options.resourceName The name of the resource for the
|
||||
* collection. Example:
|
||||
* `'products/attributes'`
|
||||
* @param {Array} options.resourceValues An array of values (in correct order)
|
||||
* that are substituted in the route
|
||||
* placeholders for the collection route.
|
||||
* Example: `[10, 20]`
|
||||
* @param {Object} options.query An object of key value pairs for the
|
||||
* query to execute on the collection
|
||||
* (optional). Example:
|
||||
* `{ order: 'ASC', order_by: 'price' }`
|
||||
*
|
||||
* @return {Object} This hook will return an object with two properties:
|
||||
* - value Whatever value is attached to the specified
|
||||
* header.
|
||||
* - isLoading A boolean indicating whether the header is
|
||||
* loading (true) or not.
|
||||
*/
|
||||
export const useCollectionHeader = ( headerKey, options ) => {
|
||||
const {
|
||||
namespace,
|
||||
resourceName,
|
||||
resourceValues = [],
|
||||
query = {},
|
||||
} = options;
|
||||
if ( ! namespace || ! resourceName ) {
|
||||
throw new Error(
|
||||
'The options object must have valid values for the namespace and ' +
|
||||
'the resource name properties.'
|
||||
);
|
||||
}
|
||||
// ensure we feed the previous reference if it's equivalent
|
||||
const currentQuery = useShallowEqual( query );
|
||||
const currentResourceValues = useShallowEqual( resourceValues );
|
||||
const { value, isLoading = true } = useSelect(
|
||||
( select ) => {
|
||||
const store = select( storeKey );
|
||||
// filter out query if it is undefined.
|
||||
const args = [
|
||||
headerKey,
|
||||
namespace,
|
||||
resourceName,
|
||||
currentQuery,
|
||||
currentResourceValues,
|
||||
];
|
||||
return {
|
||||
value: store.getCollectionHeader( ...args ),
|
||||
isLoading: store.hasFinishedResolution(
|
||||
'getCollectionHeader',
|
||||
args
|
||||
),
|
||||
};
|
||||
},
|
||||
[
|
||||
headerKey,
|
||||
namespace,
|
||||
resourceName,
|
||||
currentResourceValues,
|
||||
currentQuery,
|
||||
]
|
||||
);
|
||||
return {
|
||||
value,
|
||||
isLoading,
|
||||
};
|
||||
};
|
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { useRef } from '@wordpress/element';
|
||||
import { useShallowEqual, useThrowError } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* This is a custom hook that is wired up to the `wc/store/collections` data
|
||||
* store. Given a collections option object, this will ensure a component is
|
||||
* kept up to date with the collection matching that query in the store state.
|
||||
*
|
||||
* @throws {Object} Throws an exception object if there was a problem with the
|
||||
* API request, to be picked up by BlockErrorBoundry.
|
||||
*
|
||||
* @param {Object} options An object declaring the various
|
||||
* collection arguments.
|
||||
* @param {string} options.namespace The namespace for the collection.
|
||||
* Example: `'/wc/blocks'`
|
||||
* @param {string} options.resourceName The name of the resource for the
|
||||
* collection. Example:
|
||||
* `'products/attributes'`
|
||||
* @param {Array} [options.resourceValues] An array of values (in correct order)
|
||||
* that are substituted in the route
|
||||
* placeholders for the collection route.
|
||||
* Example: `[10, 20]`
|
||||
* @param {Object} [options.query] An object of key value pairs for the
|
||||
* query to execute on the collection
|
||||
* Example:
|
||||
* `{ order: 'ASC', order_by: 'price' }`
|
||||
* @param {boolean} [options.shouldSelect] If false, the previous results will be
|
||||
* returned and internal selects will not
|
||||
* fire.
|
||||
*
|
||||
* @return {Object} This hook will return an object with two properties:
|
||||
* - results An array of collection items returned.
|
||||
* - isLoading A boolean indicating whether the collection is
|
||||
* loading (true) or not.
|
||||
*/
|
||||
export const useCollection = ( options ) => {
|
||||
const {
|
||||
namespace,
|
||||
resourceName,
|
||||
resourceValues = [],
|
||||
query = {},
|
||||
shouldSelect = true,
|
||||
} = options;
|
||||
if ( ! namespace || ! resourceName ) {
|
||||
throw new Error(
|
||||
'The options object must have valid values for the namespace and ' +
|
||||
'the resource properties.'
|
||||
);
|
||||
}
|
||||
const currentResults = useRef( { results: [], isLoading: true } );
|
||||
// ensure we feed the previous reference if it's equivalent
|
||||
const currentQuery = useShallowEqual( query );
|
||||
const currentResourceValues = useShallowEqual( resourceValues );
|
||||
const throwError = useThrowError();
|
||||
const results = useSelect(
|
||||
( select ) => {
|
||||
if ( ! shouldSelect ) {
|
||||
return null;
|
||||
}
|
||||
const store = select( storeKey );
|
||||
const args = [
|
||||
namespace,
|
||||
resourceName,
|
||||
currentQuery,
|
||||
currentResourceValues,
|
||||
];
|
||||
const error = store.getCollectionError( ...args );
|
||||
|
||||
if ( error ) {
|
||||
throwError( error );
|
||||
}
|
||||
|
||||
return {
|
||||
results: store.getCollection( ...args ),
|
||||
isLoading: ! store.hasFinishedResolution(
|
||||
'getCollection',
|
||||
args
|
||||
),
|
||||
};
|
||||
},
|
||||
[
|
||||
namespace,
|
||||
resourceName,
|
||||
currentResourceValues,
|
||||
currentQuery,
|
||||
shouldSelect,
|
||||
]
|
||||
);
|
||||
// if selector was not bailed, then update current results. Otherwise return
|
||||
// previous results
|
||||
if ( results !== null ) {
|
||||
currentResults.current = results;
|
||||
}
|
||||
return currentResults.current;
|
||||
};
|
@ -0,0 +1,16 @@
|
||||
export * from './cart';
|
||||
export * from './collections';
|
||||
export * from './shipping';
|
||||
export * from './payment-methods';
|
||||
export * from './use-store-notices';
|
||||
export * from './use-store-events';
|
||||
export * from './use-query-state';
|
||||
export * from './use-store-products';
|
||||
export * from './use-store-add-to-cart';
|
||||
export * from './use-customer-data';
|
||||
export * from './use-checkout-address';
|
||||
export * from './use-checkout-notices';
|
||||
export * from './use-checkout-submit';
|
||||
export * from './use-emit-response';
|
||||
export * from './use-checkout-extension-data';
|
||||
export * from './use-validation';
|
@ -0,0 +1,2 @@
|
||||
export { usePaymentMethodInterface } from './use-payment-method-interface';
|
||||
export * from './use-payment-methods';
|
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { prepareTotalItems } from '../utils';
|
||||
|
||||
describe( 'prepareTotalItems', () => {
|
||||
const fixture = {
|
||||
total_items: '200',
|
||||
total_items_tax: '20',
|
||||
total_fees: '100',
|
||||
total_fees_tax: '10',
|
||||
total_discount: '350',
|
||||
total_discount_tax: '50',
|
||||
total_shipping: '50',
|
||||
total_shipping_tax: '5',
|
||||
total_tax: '30',
|
||||
};
|
||||
const expected = [
|
||||
{
|
||||
key: 'total_items',
|
||||
label: 'Subtotal:',
|
||||
value: 200,
|
||||
valueWithTax: 220,
|
||||
},
|
||||
{
|
||||
key: 'total_fees',
|
||||
label: 'Fees:',
|
||||
value: 100,
|
||||
valueWithTax: 110,
|
||||
},
|
||||
{
|
||||
key: 'total_discount',
|
||||
label: 'Discount:',
|
||||
value: 350,
|
||||
valueWithTax: 400,
|
||||
},
|
||||
{
|
||||
key: 'total_tax',
|
||||
label: 'Taxes:',
|
||||
value: 30,
|
||||
valueWithTax: 30,
|
||||
},
|
||||
];
|
||||
const expectedWithShipping = [
|
||||
...expected,
|
||||
{
|
||||
key: 'total_shipping',
|
||||
label: 'Shipping:',
|
||||
value: 50,
|
||||
valueWithTax: 55,
|
||||
},
|
||||
];
|
||||
it( 'returns expected values when needsShipping is false', () => {
|
||||
expect( prepareTotalItems( fixture, false ) ).toEqual( expected );
|
||||
} );
|
||||
it( 'returns expected values when needsShipping is true', () => {
|
||||
expect( prepareTotalItems( fixture, true ) ).toEqual(
|
||||
expectedWithShipping
|
||||
);
|
||||
} );
|
||||
} );
|
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
|
||||
import { useCallback, useEffect, useRef } from '@wordpress/element';
|
||||
import PaymentMethodLabel from '@woocommerce/base-components/cart-checkout/payment-method-label';
|
||||
import PaymentMethodIcons from '@woocommerce/base-components/cart-checkout/payment-method-icons';
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
import deprecated from '@wordpress/deprecated';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ValidationInputError } from '../../providers/validation';
|
||||
import { useStoreCart } from '../cart/use-store-cart';
|
||||
import { useStoreCartCoupons } from '../cart/use-store-cart-coupons';
|
||||
import { useEmitResponse } from '../use-emit-response';
|
||||
import { useCheckoutContext } from '../../providers/cart-checkout/checkout-state';
|
||||
import { usePaymentMethodDataContext } from '../../providers/cart-checkout/payment-methods';
|
||||
import { useShippingDataContext } from '../../providers/cart-checkout/shipping';
|
||||
import { useCustomerDataContext } from '../../providers/cart-checkout/customer';
|
||||
import { prepareTotalItems } from './utils';
|
||||
|
||||
/**
|
||||
* Returns am interface to use as payment method props.
|
||||
*/
|
||||
export const usePaymentMethodInterface = (): Record< string, unknown > => {
|
||||
const {
|
||||
isCalculating,
|
||||
isComplete,
|
||||
isIdle,
|
||||
isProcessing,
|
||||
onCheckoutBeforeProcessing,
|
||||
onCheckoutValidationBeforeProcessing,
|
||||
onCheckoutAfterProcessingWithSuccess,
|
||||
onCheckoutAfterProcessingWithError,
|
||||
onSubmit,
|
||||
customerId,
|
||||
} = useCheckoutContext();
|
||||
const {
|
||||
currentStatus,
|
||||
activePaymentMethod,
|
||||
onPaymentProcessing,
|
||||
setExpressPaymentError,
|
||||
shouldSavePayment,
|
||||
} = usePaymentMethodDataContext();
|
||||
const {
|
||||
shippingErrorStatus,
|
||||
shippingErrorTypes,
|
||||
shippingRates,
|
||||
shippingRatesLoading,
|
||||
selectedRates,
|
||||
setSelectedRates,
|
||||
isSelectingRate,
|
||||
onShippingRateSuccess,
|
||||
onShippingRateFail,
|
||||
onShippingRateSelectSuccess,
|
||||
onShippingRateSelectFail,
|
||||
needsShipping,
|
||||
} = useShippingDataContext();
|
||||
const {
|
||||
billingData,
|
||||
shippingAddress,
|
||||
setShippingAddress,
|
||||
} = useCustomerDataContext();
|
||||
const { cartTotals } = useStoreCart();
|
||||
const { appliedCoupons } = useStoreCartCoupons();
|
||||
const { noticeContexts, responseTypes } = useEmitResponse();
|
||||
const currentCartTotals = useRef(
|
||||
prepareTotalItems( cartTotals, needsShipping )
|
||||
);
|
||||
const currentCartTotal = useRef( {
|
||||
label: __( 'Total', 'woo-gutenberg-products-block' ),
|
||||
value: parseInt( cartTotals.total_price, 10 ),
|
||||
} );
|
||||
|
||||
useEffect( () => {
|
||||
currentCartTotals.current = prepareTotalItems(
|
||||
cartTotals,
|
||||
needsShipping
|
||||
);
|
||||
currentCartTotal.current = {
|
||||
label: __( 'Total', 'woo-gutenberg-products-block' ),
|
||||
value: parseInt( cartTotals.total_price, 10 ),
|
||||
};
|
||||
}, [ cartTotals, needsShipping ] );
|
||||
|
||||
const deprecatedSetExpressPaymentError = useCallback(
|
||||
( errorMessage = '' ) => {
|
||||
deprecated(
|
||||
'setExpressPaymentError should only be used by Express Payment Methods (using the provided onError handler).',
|
||||
{
|
||||
alternative: '',
|
||||
plugin: 'woocommerce-gutenberg-products-block',
|
||||
link:
|
||||
'https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4228',
|
||||
}
|
||||
);
|
||||
setExpressPaymentError( errorMessage );
|
||||
},
|
||||
[ setExpressPaymentError ]
|
||||
);
|
||||
|
||||
return {
|
||||
activePaymentMethod,
|
||||
billing: {
|
||||
billingData,
|
||||
cartTotal: currentCartTotal.current,
|
||||
currency: getCurrencyFromPriceResponse( cartTotals ),
|
||||
cartTotalItems: currentCartTotals.current,
|
||||
displayPricesIncludingTax: getSetting(
|
||||
'displayCartPricesIncludingTax',
|
||||
false
|
||||
) as boolean,
|
||||
appliedCoupons,
|
||||
customerId,
|
||||
},
|
||||
checkoutStatus: {
|
||||
isCalculating,
|
||||
isComplete,
|
||||
isIdle,
|
||||
isProcessing,
|
||||
},
|
||||
components: {
|
||||
ValidationInputError,
|
||||
PaymentMethodIcons,
|
||||
PaymentMethodLabel,
|
||||
},
|
||||
emitResponse: {
|
||||
noticeContexts,
|
||||
responseTypes,
|
||||
},
|
||||
eventRegistration: {
|
||||
onCheckoutBeforeProcessing,
|
||||
onCheckoutValidationBeforeProcessing,
|
||||
onCheckoutAfterProcessingWithSuccess,
|
||||
onCheckoutAfterProcessingWithError,
|
||||
onShippingRateSuccess,
|
||||
onShippingRateFail,
|
||||
onShippingRateSelectSuccess,
|
||||
onShippingRateSelectFail,
|
||||
onPaymentProcessing,
|
||||
},
|
||||
onSubmit,
|
||||
paymentStatus: currentStatus,
|
||||
setExpressPaymentError: deprecatedSetExpressPaymentError,
|
||||
shippingData: {
|
||||
shippingRates,
|
||||
shippingRatesLoading,
|
||||
selectedRates,
|
||||
setSelectedRates,
|
||||
isSelectingRate,
|
||||
shippingAddress,
|
||||
setShippingAddress,
|
||||
needsShipping,
|
||||
},
|
||||
shippingStatus: {
|
||||
shippingErrorStatus,
|
||||
shippingErrorTypes,
|
||||
},
|
||||
shouldSavePayment,
|
||||
};
|
||||
};
|
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
import type {
|
||||
PaymentMethods,
|
||||
ExpressPaymentMethods,
|
||||
} from '@woocommerce/type-defs/payments';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { usePaymentMethodDataContext } from '../../providers/cart-checkout/payment-methods';
|
||||
|
||||
interface PaymentMethodState {
|
||||
paymentMethods: PaymentMethods;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
interface ExpressPaymentMethodState {
|
||||
paymentMethods: ExpressPaymentMethods;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
|
||||
const usePaymentMethodState = (
|
||||
express = false
|
||||
): PaymentMethodState | ExpressPaymentMethodState => {
|
||||
const {
|
||||
paymentMethods,
|
||||
expressPaymentMethods,
|
||||
paymentMethodsInitialized,
|
||||
expressPaymentMethodsInitialized,
|
||||
} = usePaymentMethodDataContext();
|
||||
|
||||
const currentPaymentMethods = useShallowEqual( paymentMethods );
|
||||
const currentExpressPaymentMethods = useShallowEqual(
|
||||
expressPaymentMethods
|
||||
);
|
||||
|
||||
return {
|
||||
paymentMethods: express
|
||||
? currentExpressPaymentMethods
|
||||
: currentPaymentMethods,
|
||||
isInitialized: express
|
||||
? expressPaymentMethodsInitialized
|
||||
: paymentMethodsInitialized,
|
||||
};
|
||||
};
|
||||
|
||||
export const usePaymentMethods = ():
|
||||
| PaymentMethodState
|
||||
| ExpressPaymentMethodState => usePaymentMethodState( false );
|
||||
export const useExpressPaymentMethods = (): ExpressPaymentMethodState =>
|
||||
usePaymentMethodState( true );
|
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import {
|
||||
CartResponseTotals,
|
||||
objectHasProp,
|
||||
isString,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
export interface CartTotalItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
valueWithTax: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the total items into a shape usable for display as passed on to
|
||||
* registered payment methods.
|
||||
*
|
||||
* @param {Object} totals Current cart total items
|
||||
* @param {boolean} needsShipping Whether or not shipping is needed.
|
||||
*/
|
||||
export const prepareTotalItems = (
|
||||
totals: CartResponseTotals,
|
||||
needsShipping: boolean
|
||||
): CartTotalItem[] => {
|
||||
const newTotals = [];
|
||||
|
||||
const factory = ( label: string, property: string ): CartTotalItem => {
|
||||
const taxProperty = property + '_tax';
|
||||
const value =
|
||||
objectHasProp( totals, property ) && isString( totals[ property ] )
|
||||
? parseInt( totals[ property ] as string, 10 )
|
||||
: 0;
|
||||
const tax =
|
||||
objectHasProp( totals, taxProperty ) &&
|
||||
isString( totals[ taxProperty ] )
|
||||
? parseInt( totals[ taxProperty ] as string, 10 )
|
||||
: 0;
|
||||
return {
|
||||
key: property,
|
||||
label,
|
||||
value,
|
||||
valueWithTax: value + tax,
|
||||
};
|
||||
};
|
||||
|
||||
newTotals.push(
|
||||
factory(
|
||||
__( 'Subtotal:', 'woo-gutenberg-products-block' ),
|
||||
'total_items'
|
||||
)
|
||||
);
|
||||
|
||||
newTotals.push(
|
||||
factory( __( 'Fees:', 'woo-gutenberg-products-block' ), 'total_fees' )
|
||||
);
|
||||
|
||||
newTotals.push(
|
||||
factory(
|
||||
__( 'Discount:', 'woo-gutenberg-products-block' ),
|
||||
'total_discount'
|
||||
)
|
||||
);
|
||||
|
||||
newTotals.push( {
|
||||
key: 'total_tax',
|
||||
label: __( 'Taxes:', 'woo-gutenberg-products-block' ),
|
||||
value: parseInt( totals.total_tax, 10 ),
|
||||
valueWithTax: parseInt( totals.total_tax, 10 ),
|
||||
} );
|
||||
|
||||
if ( needsShipping ) {
|
||||
newTotals.push(
|
||||
factory(
|
||||
__( 'Shipping:', 'woo-gutenberg-products-block' ),
|
||||
'total_shipping'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return newTotals;
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export * from './use-select-shipping-rate';
|
||||
export * from './use-select-shipping-rates';
|
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback } from '@wordpress/element';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
import { CartShippingPackageShippingRate } from '@woocommerce/type-defs/cart';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useSelectShippingRates } from './use-select-shipping-rates';
|
||||
import { useStoreEvents } from '../use-store-events';
|
||||
|
||||
/**
|
||||
* Selected rates are derived by looping over the shipping rates.
|
||||
*
|
||||
* @param {Array} shippingRates Array of shipping rates.
|
||||
* @return {string} Selected rate id.
|
||||
*/
|
||||
// This will find the selected rate ID in an array of shipping rates.
|
||||
const deriveSelectedRateId = (
|
||||
shippingRates: CartShippingPackageShippingRate[]
|
||||
) => shippingRates.find( ( rate ) => rate.selected )?.rate_id;
|
||||
|
||||
/**
|
||||
* This is a custom hook for tracking selected shipping rates for a package and selecting a rate. State is used so
|
||||
* changes are reflected in the UI instantly.
|
||||
*
|
||||
* @param {string} packageId Package ID to select rates for.
|
||||
* @param {Array} shippingRates an array of packages with shipping rates.
|
||||
* @return {Object} This hook will return an object with these properties:
|
||||
* - selectShippingRate: A function that immediately returns the selected rate and dispatches an action generator.
|
||||
* - selectedShippingRate: The selected rate id.
|
||||
* - isSelectingRate: True when rates are being resolved to the API.
|
||||
*/
|
||||
export const useSelectShippingRate = (
|
||||
packageId: string | number,
|
||||
shippingRates: CartShippingPackageShippingRate[]
|
||||
): {
|
||||
selectShippingRate: ( newShippingRateId: string ) => unknown;
|
||||
selectedShippingRate: string | undefined;
|
||||
isSelectingRate: boolean;
|
||||
} => {
|
||||
const { dispatchCheckoutEvent } = useStoreEvents();
|
||||
|
||||
// Rates are selected via the shipping data context provider.
|
||||
const { selectShippingRate, isSelectingRate } = useSelectShippingRates();
|
||||
|
||||
// Selected rates are stored in state. This allows shipping rates changes to be shown in the UI instantly.
|
||||
// Defaults to the currently selected rate_id.
|
||||
const [ selectedShippingRate, setSelectedShippingRate ] = useState( () =>
|
||||
deriveSelectedRateId( shippingRates )
|
||||
);
|
||||
|
||||
// This ref is used to track when changes come in via the props. When the incoming shipping rates change, update our local state if there are changes to selected methods.
|
||||
const currentShippingRates = useRef( shippingRates );
|
||||
useEffect( () => {
|
||||
if ( ! isShallowEqual( currentShippingRates.current, shippingRates ) ) {
|
||||
currentShippingRates.current = shippingRates;
|
||||
setSelectedShippingRate( deriveSelectedRateId( shippingRates ) );
|
||||
}
|
||||
}, [ shippingRates ] );
|
||||
|
||||
// Sets a rate for a package in state (so changes are shown right away to consumers of the hook) and in the stores.
|
||||
const setPackageRateId = useCallback(
|
||||
( newShippingRateId ) => {
|
||||
setSelectedShippingRate( newShippingRateId );
|
||||
selectShippingRate( newShippingRateId, packageId );
|
||||
dispatchCheckoutEvent( 'set-selected-shipping-rate', {
|
||||
shippingRateId: newShippingRateId,
|
||||
} );
|
||||
},
|
||||
[ packageId, selectShippingRate, dispatchCheckoutEvent ]
|
||||
);
|
||||
|
||||
return {
|
||||
selectShippingRate: setPackageRateId,
|
||||
selectedShippingRate,
|
||||
isSelectingRate,
|
||||
};
|
||||
};
|
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { useCallback } from '@wordpress/element';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useThrowError } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* This is a custom hook for selecting shipping rates
|
||||
*
|
||||
* @return {Object} This hook will return an object with these properties:
|
||||
* - selectShippingRate: A function that immediately returns the selected rate and dispatches an action generator.
|
||||
* - isSelectingRate: True when rates are being resolved to the API.
|
||||
*/
|
||||
export const useSelectShippingRates = (): {
|
||||
selectShippingRate: (
|
||||
newShippingRateId: string,
|
||||
packageId: string | number
|
||||
) => unknown;
|
||||
isSelectingRate: boolean;
|
||||
} => {
|
||||
const throwError = useThrowError();
|
||||
const { selectShippingRate } = ( useDispatch( storeKey ) as {
|
||||
selectShippingRate: unknown;
|
||||
} ) as {
|
||||
selectShippingRate: (
|
||||
newShippingRateId: string,
|
||||
packageId: string | number
|
||||
) => Promise< unknown >;
|
||||
};
|
||||
|
||||
// Sets a rate for a package in state (so changes are shown right away to consumers of the hook) and in the stores.
|
||||
const setRate = useCallback(
|
||||
( newShippingRateId, packageId ) => {
|
||||
selectShippingRate( newShippingRateId, packageId ).catch(
|
||||
( error ) => {
|
||||
// we throw this error because an error on selecting a rate is problematic.
|
||||
throwError( error );
|
||||
}
|
||||
);
|
||||
},
|
||||
[ throwError, selectShippingRate ]
|
||||
);
|
||||
|
||||
// See if rates are being selected.
|
||||
const isSelectingRate = useSelect< boolean >( ( select ) => {
|
||||
return select( storeKey ).isShippingRateBeingSelected();
|
||||
}, [] );
|
||||
|
||||
return {
|
||||
selectShippingRate: setRate,
|
||||
isSelectingRate,
|
||||
};
|
||||
};
|
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCheckoutSubmit } from '../use-checkout-submit';
|
||||
|
||||
const mockUseCheckoutContext = {
|
||||
onSubmit: jest.fn(),
|
||||
};
|
||||
const mockUsePaymentMethodDataContext = {
|
||||
activePaymentMethod: '',
|
||||
currentStatus: {
|
||||
isDoingExpressPayment: false,
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock( '../../providers/cart-checkout/checkout-state', () => ( {
|
||||
useCheckoutContext: () => mockUseCheckoutContext,
|
||||
} ) );
|
||||
|
||||
jest.mock( '../../providers/cart-checkout/payment-methods', () => ( {
|
||||
usePaymentMethodDataContext: () => mockUsePaymentMethodDataContext,
|
||||
} ) );
|
||||
|
||||
describe( 'useCheckoutSubmit', () => {
|
||||
let registry, renderer;
|
||||
|
||||
const getWrappedComponents = ( Component ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent = () => () => {
|
||||
const data = useCheckoutSubmit();
|
||||
return <div { ...data } />;
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry();
|
||||
renderer = null;
|
||||
} );
|
||||
|
||||
it( 'onSubmit calls the correct action in the checkout context', () => {
|
||||
const TestComponent = getTestComponent();
|
||||
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent )
|
||||
);
|
||||
} );
|
||||
|
||||
const { onSubmit } = renderer.root.findByType( 'div' ).props;
|
||||
|
||||
onSubmit();
|
||||
|
||||
expect( mockUseCheckoutContext.onSubmit ).toHaveBeenCalledTimes( 1 );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,259 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
useQueryStateByContext,
|
||||
useQueryStateByKey,
|
||||
useSynchronizedQueryState,
|
||||
} from '../use-query-state';
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
__esModule: true,
|
||||
QUERY_STATE_STORE_KEY: 'test/store',
|
||||
} ) );
|
||||
|
||||
describe( 'Testing Query State Hooks', () => {
|
||||
let registry, mocks;
|
||||
beforeAll( () => {
|
||||
registry = createRegistry();
|
||||
mocks = {};
|
||||
} );
|
||||
/**
|
||||
* Test helper to return a tuple containing the expected query value and the
|
||||
* expected query state action creator from the given rendered test instance.
|
||||
*
|
||||
* @param {Object} testRenderer An instance of the created test component.
|
||||
*
|
||||
* @return {Array} A tuple containing the expected query value as the first
|
||||
* element and the expected query action creator as the
|
||||
* second argument.
|
||||
*/
|
||||
const getProps = ( testRenderer ) => {
|
||||
const props = testRenderer.root.findByType( 'div' ).props;
|
||||
return [ props.queryState, props.setQueryState ];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the given component wrapped in the registry provider for
|
||||
* instantiating using the TestRenderer using the current prepared registry
|
||||
* for the TestRenderer to instantiate with.
|
||||
*
|
||||
* @param {*} Component The test component to wrap.
|
||||
* @param {Object} props Props to feed the wrapped component.
|
||||
*
|
||||
* @return {*} Wrapped component.
|
||||
*/
|
||||
const getWrappedComponent = ( Component, props ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component { ...props } />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns a TestComponent for the provided hook to test with, and the
|
||||
* expected PropKeys for obtaining the values to be fed to the hook as
|
||||
* arguments.
|
||||
*
|
||||
* @param {Function} hookTested The hook being tested to use in the
|
||||
* test comopnent.
|
||||
* @param {Array} propKeysForArgs An array of keys for the props that
|
||||
* will be used on the test component that
|
||||
* will have values fed to the tested
|
||||
* hook.
|
||||
*
|
||||
* @return {*} A component ready for testing with!
|
||||
*/
|
||||
const getTestComponent = ( hookTested, propKeysForArgs ) => ( props ) => {
|
||||
const args = propKeysForArgs.map( ( key ) => props[ key ] );
|
||||
const [ queryValue, setQueryValue ] = hookTested( ...args );
|
||||
return (
|
||||
<div queryState={ queryValue } setQueryState={ setQueryValue } />
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A helper for setting up the `mocks` object and the `registry` mock before
|
||||
* each test.
|
||||
*
|
||||
* @param {string} actionMockName This should be the name of the action
|
||||
* that the hook returns. This will be
|
||||
* mocked using `mocks.action` when
|
||||
* registered in the mock registry.
|
||||
* @param {string} selectorMockName This should be the mame of the selector
|
||||
* that the hook uses. This will be mocked
|
||||
* using `mocks.selector` when registered
|
||||
* in the mock registry.
|
||||
*/
|
||||
const setupMocks = ( actionMockName, selectorMockName ) => {
|
||||
mocks.action = jest.fn().mockReturnValue( { type: 'testAction' } );
|
||||
mocks.selector = jest.fn().mockReturnValue( { foo: 'bar' } );
|
||||
registry.registerStore( storeKey, {
|
||||
reducer: () => ( {} ),
|
||||
actions: {
|
||||
[ actionMockName ]: mocks.action,
|
||||
},
|
||||
selectors: {
|
||||
[ selectorMockName ]: mocks.selector,
|
||||
},
|
||||
} );
|
||||
};
|
||||
describe( 'useQueryStateByContext', () => {
|
||||
const TestComponent = getTestComponent( useQueryStateByContext, [
|
||||
'context',
|
||||
] );
|
||||
let renderer;
|
||||
beforeEach( () => {
|
||||
renderer = null;
|
||||
setupMocks( 'setValueForQueryContext', 'getValueForQueryContext' );
|
||||
} );
|
||||
afterEach( () => {
|
||||
act( () => {
|
||||
renderer.unmount();
|
||||
} );
|
||||
} );
|
||||
it(
|
||||
'calls useSelect with the provided context and returns expected' +
|
||||
' values',
|
||||
() => {
|
||||
const { action, selector } = mocks;
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponent( TestComponent, {
|
||||
context: 'test-context',
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const [ queryState, setQueryState ] = getProps( renderer );
|
||||
// the {} is because all selectors are called internally in the
|
||||
// registry with the first argument being the state which is empty.
|
||||
expect( selector ).toHaveBeenLastCalledWith(
|
||||
{},
|
||||
'test-context',
|
||||
undefined
|
||||
);
|
||||
expect( queryState ).toEqual( { foo: 'bar' } );
|
||||
expect( action ).not.toHaveBeenCalled();
|
||||
|
||||
//execute dispatcher and make sure it's called.
|
||||
act( () => {
|
||||
setQueryState( { foo: 'bar' } );
|
||||
} );
|
||||
expect( action ).toHaveBeenCalledWith( 'test-context', {
|
||||
foo: 'bar',
|
||||
} );
|
||||
}
|
||||
);
|
||||
} );
|
||||
describe( 'useQueryStateByKey', () => {
|
||||
const TestComponent = getTestComponent( useQueryStateByKey, [
|
||||
'queryKey',
|
||||
undefined,
|
||||
'context',
|
||||
] );
|
||||
let renderer;
|
||||
beforeEach( () => {
|
||||
renderer = null;
|
||||
setupMocks( 'setQueryValue', 'getValueForQueryKey' );
|
||||
} );
|
||||
afterEach( () => {
|
||||
act( () => {
|
||||
renderer.unmount();
|
||||
} );
|
||||
} );
|
||||
it(
|
||||
'calls useSelect with the provided context and returns expected' +
|
||||
' values',
|
||||
() => {
|
||||
const { selector, action } = mocks;
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponent( TestComponent, {
|
||||
context: 'test-context',
|
||||
queryKey: 'someValue',
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const [ queryState, setQueryState ] = getProps( renderer );
|
||||
// the {} is because all selectors are called internally in the
|
||||
// registry with the first argument being the state which is empty.
|
||||
expect( selector ).toHaveBeenLastCalledWith(
|
||||
{},
|
||||
'test-context',
|
||||
'someValue',
|
||||
undefined
|
||||
);
|
||||
expect( queryState ).toEqual( { foo: 'bar' } );
|
||||
expect( action ).not.toHaveBeenCalled();
|
||||
|
||||
//execute dispatcher and make sure it's called.
|
||||
act( () => {
|
||||
setQueryState( { foo: 'bar' } );
|
||||
} );
|
||||
expect( action ).toHaveBeenCalledWith(
|
||||
'test-context',
|
||||
'someValue',
|
||||
{ foo: 'bar' }
|
||||
);
|
||||
}
|
||||
);
|
||||
} );
|
||||
// Note: these tests only add partial coverage because the state is not
|
||||
// actually updated by the action dispatch via our mocks.
|
||||
describe( 'useSynchronizedQueryState', () => {
|
||||
const TestComponent = getTestComponent( useSynchronizedQueryState, [
|
||||
'synchronizedQuery',
|
||||
'context',
|
||||
] );
|
||||
const initialQuery = { a: 'b' };
|
||||
let renderer;
|
||||
beforeEach( () => {
|
||||
setupMocks( 'setValueForQueryContext', 'getValueForQueryContext' );
|
||||
} );
|
||||
it( 'returns provided query state on initial render', () => {
|
||||
const { action, selector } = mocks;
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponent( TestComponent, {
|
||||
context: 'test-context',
|
||||
synchronizedQuery: initialQuery,
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const [ queryState ] = getProps( renderer );
|
||||
expect( queryState ).toBe( initialQuery );
|
||||
expect( selector ).toHaveBeenLastCalledWith(
|
||||
{},
|
||||
'test-context',
|
||||
undefined
|
||||
);
|
||||
expect( action ).toHaveBeenCalledWith( 'test-context', {
|
||||
foo: 'bar',
|
||||
a: 'b',
|
||||
} );
|
||||
} );
|
||||
it( 'returns merged queryState on subsequent render', () => {
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponent( TestComponent, {
|
||||
context: 'test-context',
|
||||
synchronizedQuery: initialQuery,
|
||||
} )
|
||||
);
|
||||
} );
|
||||
// note our test doesn't interact with an actual reducer so the
|
||||
// store state is not updated. Here we're just verifying that
|
||||
// what is is returned by the state selector mock is returned.
|
||||
// However we DO expect this to be a new object.
|
||||
const [ queryState ] = getProps( renderer );
|
||||
expect( queryState ).not.toBe( initialQuery );
|
||||
expect( queryState ).toEqual( { foo: 'bar' } );
|
||||
} );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { StoreNoticesProvider } from '@woocommerce/base-context';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreNotices } from '../use-store-notices';
|
||||
|
||||
describe( 'useStoreNotices', () => {
|
||||
function setup() {
|
||||
const returnVal = {};
|
||||
|
||||
function TestComponent() {
|
||||
Object.assign( returnVal, useStoreNotices() );
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render(
|
||||
<StoreNoticesProvider>
|
||||
<TestComponent />
|
||||
</StoreNoticesProvider>
|
||||
);
|
||||
|
||||
return returnVal;
|
||||
}
|
||||
|
||||
test( 'allows adding and removing notices and checking if there are notices of a specific type', () => {
|
||||
const storeNoticesData = setup();
|
||||
|
||||
// Assert initial state.
|
||||
expect( storeNoticesData.notices ).toEqual( [] );
|
||||
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( false );
|
||||
|
||||
// Add error notice.
|
||||
act( () => {
|
||||
storeNoticesData.addErrorNotice( 'Error notice' );
|
||||
} );
|
||||
|
||||
expect( storeNoticesData.notices.length ).toBe( 1 );
|
||||
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( true );
|
||||
|
||||
expect( storeNoticesData.notices.length ).toBe( 1 );
|
||||
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( true );
|
||||
|
||||
// Remove error notice.
|
||||
act( () => {
|
||||
storeNoticesData.removeNotices( 'error' );
|
||||
} );
|
||||
|
||||
expect( storeNoticesData.notices.length ).toBe( 0 );
|
||||
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( false );
|
||||
|
||||
// Remove all remaining notices.
|
||||
act( () => {
|
||||
storeNoticesData.removeNotices();
|
||||
} );
|
||||
|
||||
expect( storeNoticesData.notices.length ).toBe( 0 );
|
||||
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( false );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreProducts } from '../use-store-products';
|
||||
|
||||
jest.mock( '@woocommerce/block-data', () => ( {
|
||||
__esModule: true,
|
||||
COLLECTIONS_STORE_KEY: 'test/store',
|
||||
} ) );
|
||||
|
||||
describe( 'useStoreProducts', () => {
|
||||
let registry, mocks, renderer;
|
||||
const getProps = ( testRenderer ) => {
|
||||
const {
|
||||
products,
|
||||
totalProducts,
|
||||
productsLoading,
|
||||
} = testRenderer.root.findByType( 'div' ).props;
|
||||
return {
|
||||
products,
|
||||
totalProducts,
|
||||
productsLoading,
|
||||
};
|
||||
};
|
||||
|
||||
const getWrappedComponents = ( Component, props ) => (
|
||||
<RegistryProvider value={ registry }>
|
||||
<Component { ...props } />
|
||||
</RegistryProvider>
|
||||
);
|
||||
|
||||
const getTestComponent = () => ( { query } ) => {
|
||||
const items = useStoreProducts( query );
|
||||
return <div { ...items } />;
|
||||
};
|
||||
|
||||
const setUpMocks = () => {
|
||||
mocks = {
|
||||
selectors: {
|
||||
getCollectionError: jest.fn().mockReturnValue( false ),
|
||||
getCollection: jest
|
||||
.fn()
|
||||
.mockImplementation( () => ( { foo: 'bar' } ) ),
|
||||
getCollectionHeader: jest.fn().mockReturnValue( 22 ),
|
||||
hasFinishedResolution: jest.fn().mockReturnValue( true ),
|
||||
},
|
||||
};
|
||||
registry.registerStore( storeKey, {
|
||||
reducer: () => ( {} ),
|
||||
selectors: mocks.selectors,
|
||||
} );
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
registry = createRegistry();
|
||||
mocks = {};
|
||||
renderer = null;
|
||||
setUpMocks();
|
||||
} );
|
||||
it(
|
||||
'should return expected behaviour for equivalent query on props ' +
|
||||
'across renders',
|
||||
() => {
|
||||
const TestComponent = getTestComponent();
|
||||
act( () => {
|
||||
renderer = TestRenderer.create(
|
||||
getWrappedComponents( TestComponent, {
|
||||
query: { bar: 'foo' },
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { products } = getProps( renderer );
|
||||
// rerender
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
query: { bar: 'foo' },
|
||||
} )
|
||||
);
|
||||
} );
|
||||
// re-render should result in same products object because although
|
||||
// query-state is a different instance, it's still equivalent.
|
||||
const { products: newProducts } = getProps( renderer );
|
||||
expect( newProducts ).toBe( products );
|
||||
// now let's change the query passed through to verify new object
|
||||
// is created.
|
||||
// remember this won't actually change the results because the mock
|
||||
// selector is returning an equivalent object when it is called,
|
||||
// however it SHOULD be a new object instance.
|
||||
act( () => {
|
||||
renderer.update(
|
||||
getWrappedComponents( TestComponent, {
|
||||
query: { foo: 'bar' },
|
||||
} )
|
||||
);
|
||||
} );
|
||||
const { products: productsVerification } = getProps( renderer );
|
||||
expect( productsVerification ).not.toBe( products );
|
||||
expect( productsVerification ).toEqual( products );
|
||||
renderer.unmount();
|
||||
}
|
||||
);
|
||||
} );
|
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { StoreSnackbarNoticesProvider } from '@woocommerce/base-context/providers';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreSnackbarNotices } from '../use-store-snackbar-notices';
|
||||
|
||||
describe( 'useStoreNoticesWithSnackbar', () => {
|
||||
function setup() {
|
||||
const returnVal = {};
|
||||
|
||||
function TestComponent() {
|
||||
Object.assign( returnVal, useStoreSnackbarNotices() );
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render(
|
||||
<StoreSnackbarNoticesProvider>
|
||||
<TestComponent />
|
||||
</StoreSnackbarNoticesProvider>
|
||||
);
|
||||
|
||||
return returnVal;
|
||||
}
|
||||
|
||||
test( 'allows adding and removing notices and checking if there are notices of a specific type', () => {
|
||||
const storeNoticesData = setup();
|
||||
|
||||
// Assert initial state.
|
||||
expect( storeNoticesData.notices ).toEqual( [] );
|
||||
|
||||
// Add snackbar notice.
|
||||
act( () => {
|
||||
storeNoticesData.addSnackbarNotice( 'Snackbar notice' );
|
||||
} );
|
||||
|
||||
expect( storeNoticesData.notices.length ).toBe( 1 );
|
||||
|
||||
// Remove all remaining notices.
|
||||
act( () => {
|
||||
storeNoticesData.removeNotices();
|
||||
} );
|
||||
|
||||
expect( storeNoticesData.notices.length ).toBe( 0 );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { defaultAddressFields } from '@woocommerce/settings';
|
||||
import { useEffect, useCallback, useRef } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
useShippingDataContext,
|
||||
useCustomerDataContext,
|
||||
} from '../providers/cart-checkout';
|
||||
|
||||
/**
|
||||
* Custom hook for exposing address related functionality for the checkout address form.
|
||||
*/
|
||||
export const useCheckoutAddress = () => {
|
||||
const { needsShipping } = useShippingDataContext();
|
||||
const {
|
||||
billingData,
|
||||
setBillingData,
|
||||
shippingAddress,
|
||||
setShippingAddress,
|
||||
shippingAsBilling,
|
||||
setShippingAsBilling,
|
||||
} = useCustomerDataContext();
|
||||
|
||||
const currentShippingAsBilling = useRef( shippingAsBilling );
|
||||
const previousBillingData = useRef( billingData );
|
||||
|
||||
/**
|
||||
* Sets shipping address data, and also billing if using the same address.
|
||||
*/
|
||||
const setShippingFields = useCallback(
|
||||
( value ) => {
|
||||
setShippingAddress( value );
|
||||
|
||||
if ( shippingAsBilling ) {
|
||||
setBillingData( value );
|
||||
}
|
||||
},
|
||||
[ shippingAsBilling, setShippingAddress, setBillingData ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets billing address data, and also shipping if shipping is disabled.
|
||||
*/
|
||||
const setBillingFields = useCallback(
|
||||
( value ) => {
|
||||
setBillingData( value );
|
||||
|
||||
if ( ! needsShipping ) {
|
||||
setShippingAddress( value );
|
||||
}
|
||||
},
|
||||
[ needsShipping, setShippingAddress, setBillingData ]
|
||||
);
|
||||
|
||||
// When the "Use same address" checkbox is toggled we need to update the current billing address to reflect this.
|
||||
// This either sets the billing address to the shipping address, or restores the billing address to it's previous state.
|
||||
useEffect( () => {
|
||||
if ( currentShippingAsBilling.current !== shippingAsBilling ) {
|
||||
if ( shippingAsBilling ) {
|
||||
previousBillingData.current = billingData;
|
||||
setBillingData( shippingAddress );
|
||||
} else {
|
||||
const {
|
||||
// We need to pluck out email from previous billing data because they can be empty, causing the current email to get emptied. See issue #4155
|
||||
/* eslint-disable no-unused-vars */
|
||||
email,
|
||||
/* eslint-enable no-unused-vars */
|
||||
...billingAddress
|
||||
} = previousBillingData.current;
|
||||
|
||||
setBillingData( {
|
||||
...billingAddress,
|
||||
} );
|
||||
}
|
||||
currentShippingAsBilling.current = shippingAsBilling;
|
||||
}
|
||||
}, [ shippingAsBilling, setBillingData, shippingAddress, billingData ] );
|
||||
|
||||
const setEmail = useCallback(
|
||||
( value ) =>
|
||||
void setBillingData( {
|
||||
email: value,
|
||||
} ),
|
||||
[ setBillingData ]
|
||||
);
|
||||
|
||||
const setPhone = useCallback(
|
||||
( value ) =>
|
||||
void setBillingData( {
|
||||
phone: value,
|
||||
} ),
|
||||
[ setBillingData ]
|
||||
);
|
||||
|
||||
const setShippingPhone = useCallback(
|
||||
( value ) =>
|
||||
void setShippingFields( {
|
||||
phone: value,
|
||||
} ),
|
||||
[ setShippingFields ]
|
||||
);
|
||||
|
||||
// Note that currentShippingAsBilling is returned rather than the current state of shippingAsBilling--this is so that
|
||||
// the billing fields are not rendered before sync (billing field values are debounced and would be outdated)
|
||||
return {
|
||||
defaultAddressFields,
|
||||
shippingFields: shippingAddress,
|
||||
setShippingFields,
|
||||
billingFields: billingData,
|
||||
setBillingFields,
|
||||
setEmail,
|
||||
setPhone,
|
||||
setShippingPhone,
|
||||
shippingAsBilling,
|
||||
setShippingAsBilling,
|
||||
showShippingFields: needsShipping,
|
||||
showBillingFields:
|
||||
! needsShipping || ! currentShippingAsBilling.current,
|
||||
};
|
||||
};
|
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from '@wordpress/element';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCheckoutContext } from '../providers/cart-checkout/checkout-state';
|
||||
import type { CheckoutStateContextState } from '../providers/cart-checkout/checkout-state/types';
|
||||
|
||||
/**
|
||||
* Custom hook for setting custom checkout data which is passed to the wc/store/checkout endpoint when processing orders.
|
||||
*/
|
||||
export const useCheckoutExtensionData = (): {
|
||||
extensionData: CheckoutStateContextState[ 'extensionData' ];
|
||||
setExtensionData: (
|
||||
namespace: string,
|
||||
key: string,
|
||||
value: unknown
|
||||
) => void;
|
||||
} => {
|
||||
const { dispatchActions, extensionData } = useCheckoutContext();
|
||||
const extensionDataRef = useRef( extensionData );
|
||||
|
||||
useEffect( () => {
|
||||
if ( ! isShallowEqual( extensionData, extensionDataRef.current ) ) {
|
||||
extensionDataRef.current = extensionData;
|
||||
}
|
||||
}, [ extensionData ] );
|
||||
|
||||
const setExtensionDataWithNamespace = useCallback(
|
||||
( namespace, key, value ) => {
|
||||
const currentData = extensionDataRef.current[ namespace ] || {};
|
||||
dispatchActions.setExtensionData( {
|
||||
...extensionDataRef.current,
|
||||
[ namespace ]: {
|
||||
...currentData,
|
||||
[ key ]: value,
|
||||
},
|
||||
} );
|
||||
},
|
||||
[ dispatchActions ]
|
||||
);
|
||||
|
||||
return {
|
||||
extensionData: extensionDataRef.current,
|
||||
setExtensionData: setExtensionDataWithNamespace,
|
||||
};
|
||||
};
|
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useSelect } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useEmitResponse } from './use-emit-response';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').StoreNoticeObject} StoreNoticeObject
|
||||
* @typedef {import('@woocommerce/type-defs/hooks').CheckoutNotices} CheckoutNotices
|
||||
*/
|
||||
|
||||
/**
|
||||
* A hook that returns all notices visible in the Checkout block.
|
||||
*
|
||||
* @return {CheckoutNotices} Notices from the checkout form or payment methods.
|
||||
*/
|
||||
export const useCheckoutNotices = () => {
|
||||
const { noticeContexts } = useEmitResponse();
|
||||
|
||||
/**
|
||||
* @type {StoreNoticeObject[]}
|
||||
*/
|
||||
const checkoutNotices = useSelect(
|
||||
( select ) => select( 'core/notices' ).getNotices( 'wc/checkout' ),
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* @type {StoreNoticeObject[]}
|
||||
*/
|
||||
const expressPaymentNotices = useSelect(
|
||||
( select ) =>
|
||||
select( 'core/notices' ).getNotices(
|
||||
noticeContexts.EXPRESS_PAYMENTS
|
||||
),
|
||||
[ noticeContexts.EXPRESS_PAYMENTS ]
|
||||
);
|
||||
|
||||
/**
|
||||
* @type {StoreNoticeObject[]}
|
||||
*/
|
||||
const paymentNotices = useSelect(
|
||||
( select ) =>
|
||||
select( 'core/notices' ).getNotices( noticeContexts.PAYMENTS ),
|
||||
[ noticeContexts.PAYMENTS ]
|
||||
);
|
||||
|
||||
return {
|
||||
checkoutNotices,
|
||||
expressPaymentNotices,
|
||||
paymentNotices,
|
||||
};
|
||||
};
|
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCheckoutContext } from '../providers/cart-checkout/checkout-state';
|
||||
import { usePaymentMethodDataContext } from '../providers/cart-checkout/payment-methods';
|
||||
import { usePaymentMethods } from './payment-methods/use-payment-methods';
|
||||
|
||||
/**
|
||||
* Returns the submitButtonText, onSubmit interface from the checkout context,
|
||||
* and an indication of submission status.
|
||||
*/
|
||||
export const useCheckoutSubmit = () => {
|
||||
const {
|
||||
onSubmit,
|
||||
isCalculating,
|
||||
isBeforeProcessing,
|
||||
isProcessing,
|
||||
isAfterProcessing,
|
||||
isComplete,
|
||||
hasError,
|
||||
} = useCheckoutContext();
|
||||
const { paymentMethods = {} } = usePaymentMethods();
|
||||
const {
|
||||
activePaymentMethod,
|
||||
currentStatus: paymentStatus,
|
||||
} = usePaymentMethodDataContext();
|
||||
const paymentMethod = paymentMethods[ activePaymentMethod ] || {};
|
||||
const waitingForProcessing =
|
||||
isProcessing || isAfterProcessing || isBeforeProcessing;
|
||||
const waitingForRedirect = isComplete && ! hasError;
|
||||
|
||||
return {
|
||||
submitButtonText:
|
||||
paymentMethod?.placeOrderButtonLabel ||
|
||||
__( 'Place Order', 'woocommerce' ),
|
||||
onSubmit,
|
||||
isCalculating,
|
||||
isDisabled: isProcessing || paymentStatus.isDoingExpressPayment,
|
||||
waitingForProcessing,
|
||||
waitingForRedirect,
|
||||
};
|
||||
};
|
@ -0,0 +1,186 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useDispatch } from '@wordpress/data';
|
||||
import { useEffect, useState, useCallback, useRef } from '@wordpress/element';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
import {
|
||||
formatStoreApiErrorMessage,
|
||||
pluckAddress,
|
||||
pluckEmail,
|
||||
} from '@woocommerce/base-utils';
|
||||
import type {
|
||||
CartResponseBillingAddress,
|
||||
CartResponseShippingAddress,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
declare type CustomerData = {
|
||||
billingData: CartResponseBillingAddress;
|
||||
shippingAddress: CartResponseShippingAddress;
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from './cart/use-store-cart';
|
||||
import { useStoreNotices } from './use-store-notices';
|
||||
|
||||
function instanceOfCartResponseBillingAddress(
|
||||
address: CartResponseBillingAddress | CartResponseShippingAddress
|
||||
): address is CartResponseBillingAddress {
|
||||
return 'email' in address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a shallow compare of important address data to determine if the cart needs updating on the server.
|
||||
*
|
||||
* This takes the current and previous address into account, as well as the billing email field.
|
||||
*
|
||||
* @param {Object} previousAddress An object containing all previous address information
|
||||
* @param {Object} address An object containing all address information
|
||||
*
|
||||
* @return {boolean} True if the store needs updating due to changed data.
|
||||
*/
|
||||
const shouldUpdateAddressStore = <
|
||||
T extends CartResponseBillingAddress | CartResponseShippingAddress
|
||||
>(
|
||||
previousAddress: T,
|
||||
address: T
|
||||
): boolean => {
|
||||
if (
|
||||
instanceOfCartResponseBillingAddress( address ) &&
|
||||
pluckEmail( address ) !==
|
||||
pluckEmail( previousAddress as CartResponseBillingAddress )
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
!! address.country &&
|
||||
! isShallowEqual(
|
||||
pluckAddress( previousAddress ),
|
||||
pluckAddress( address )
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* This is a custom hook for syncing customer address data (billing and shipping) with the server.
|
||||
*/
|
||||
export const useCustomerData = (): {
|
||||
billingData: CartResponseBillingAddress;
|
||||
shippingAddress: CartResponseShippingAddress;
|
||||
setBillingData: ( data: CartResponseBillingAddress ) => void;
|
||||
setShippingAddress: ( data: CartResponseShippingAddress ) => void;
|
||||
} => {
|
||||
const { updateCustomerData } = useDispatch( storeKey );
|
||||
const { addErrorNotice, removeNotice } = useStoreNotices();
|
||||
|
||||
// Grab the initial values from the store cart hook.
|
||||
const {
|
||||
billingAddress: initialBillingAddress,
|
||||
shippingAddress: initialShippingAddress,
|
||||
}: Omit< CustomerData, 'billingData' > & {
|
||||
billingAddress: CartResponseBillingAddress;
|
||||
} = useStoreCart();
|
||||
|
||||
// State of customer data is tracked here from this point, using the initial values from the useStoreCart hook.
|
||||
const [ customerData, setCustomerData ] = useState< CustomerData >( {
|
||||
billingData: initialBillingAddress,
|
||||
shippingAddress: initialShippingAddress,
|
||||
} );
|
||||
|
||||
// Store values last sent to the server in a ref to avoid requests unless important fields are changed.
|
||||
const previousCustomerData = useRef< CustomerData >( customerData );
|
||||
|
||||
// Debounce updates to the customerData state so it's not triggered excessively.
|
||||
const [ debouncedCustomerData ] = useDebounce( customerData, 1000, {
|
||||
// Default equalityFn is prevData === newData.
|
||||
equalityFn: ( prevData, newData ) => {
|
||||
return (
|
||||
isShallowEqual( prevData.billingData, newData.billingData ) &&
|
||||
isShallowEqual(
|
||||
prevData.shippingAddress,
|
||||
newData.shippingAddress
|
||||
)
|
||||
);
|
||||
},
|
||||
} );
|
||||
|
||||
/**
|
||||
* Set billing data.
|
||||
*
|
||||
* Contains special handling for email so those fields are not overwritten if simply updating address.
|
||||
*/
|
||||
const setBillingData = useCallback( ( newData ) => {
|
||||
setCustomerData( ( prevState ) => {
|
||||
return {
|
||||
...prevState,
|
||||
billingData: {
|
||||
...prevState.billingData,
|
||||
...newData,
|
||||
},
|
||||
};
|
||||
} );
|
||||
}, [] );
|
||||
|
||||
/**
|
||||
* Set shipping data.
|
||||
*/
|
||||
const setShippingAddress = useCallback( ( newData ) => {
|
||||
setCustomerData( ( prevState ) => {
|
||||
return {
|
||||
...prevState,
|
||||
shippingAddress: {
|
||||
...prevState.shippingAddress,
|
||||
...newData,
|
||||
},
|
||||
};
|
||||
} );
|
||||
}, [] );
|
||||
|
||||
/**
|
||||
* This pushes changes to the API when the local state differs from the address in the cart.
|
||||
*/
|
||||
useEffect( () => {
|
||||
// Only push updates when enough fields are populated.
|
||||
if (
|
||||
! shouldUpdateAddressStore(
|
||||
previousCustomerData.current.billingData,
|
||||
debouncedCustomerData.billingData
|
||||
) &&
|
||||
! shouldUpdateAddressStore(
|
||||
previousCustomerData.current.shippingAddress,
|
||||
debouncedCustomerData.shippingAddress
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
previousCustomerData.current = debouncedCustomerData;
|
||||
updateCustomerData( {
|
||||
billing_address: debouncedCustomerData.billingData,
|
||||
shipping_address: debouncedCustomerData.shippingAddress,
|
||||
} )
|
||||
.then( () => {
|
||||
removeNotice( 'checkout' );
|
||||
} )
|
||||
.catch( ( response ) => {
|
||||
addErrorNotice( formatStoreApiErrorMessage( response ), {
|
||||
id: 'checkout',
|
||||
} );
|
||||
} );
|
||||
}, [
|
||||
debouncedCustomerData,
|
||||
addErrorNotice,
|
||||
removeNotice,
|
||||
updateCustomerData,
|
||||
] );
|
||||
return {
|
||||
billingData: customerData.billingData,
|
||||
shippingAddress: customerData.shippingAddress,
|
||||
setBillingData,
|
||||
setShippingAddress,
|
||||
};
|
||||
};
|
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { isObject } from '@woocommerce/types';
|
||||
|
||||
export enum responseTypes {
|
||||
SUCCESS = 'success',
|
||||
FAIL = 'failure',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export enum noticeContexts {
|
||||
PAYMENTS = 'wc/payment-area',
|
||||
EXPRESS_PAYMENTS = 'wc/express-payment-area',
|
||||
}
|
||||
|
||||
export interface ResponseType extends Record< string, unknown > {
|
||||
type: responseTypes;
|
||||
retry?: boolean;
|
||||
}
|
||||
|
||||
const isResponseOf = (
|
||||
response: unknown,
|
||||
type: string
|
||||
): response is ResponseType => {
|
||||
return isObject( response ) && 'type' in response && response.type === type;
|
||||
};
|
||||
|
||||
export const isSuccessResponse = (
|
||||
response: unknown
|
||||
): response is ResponseType => {
|
||||
return isResponseOf( response, responseTypes.SUCCESS );
|
||||
};
|
||||
|
||||
export const isErrorResponse = (
|
||||
response: unknown
|
||||
): response is ResponseType => {
|
||||
return isResponseOf( response, responseTypes.ERROR );
|
||||
};
|
||||
|
||||
export const isFailResponse = (
|
||||
response: unknown
|
||||
): response is ResponseType => {
|
||||
return isResponseOf( response, responseTypes.FAIL );
|
||||
};
|
||||
|
||||
export const shouldRetry = ( response: unknown ): boolean => {
|
||||
return (
|
||||
! isObject( response ) ||
|
||||
typeof response.retry === 'undefined' ||
|
||||
response.retry === true
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom hook exposing response utilities for emitters.
|
||||
*/
|
||||
export const useEmitResponse = () =>
|
||||
( {
|
||||
responseTypes,
|
||||
noticeContexts,
|
||||
shouldRetry,
|
||||
isSuccessResponse,
|
||||
isErrorResponse,
|
||||
isFailResponse,
|
||||
} as const );
|
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
import { useRef, useEffect, useCallback } from '@wordpress/element';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
import { useShallowEqual, usePrevious } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
|
||||
import { useQueryStateContext } from '../providers/query-state-context';
|
||||
|
||||
/**
|
||||
* A custom hook that exposes the current query state and a setter for the query
|
||||
* state store for the given context.
|
||||
*
|
||||
* "Query State" is a wp.data store that keeps track of an arbitrary object of
|
||||
* query keys and their values.
|
||||
*
|
||||
* @param {string} [context] What context to retrieve the query state for. If not
|
||||
* provided, this hook will attempt to get the context
|
||||
* from the query state context provided by the
|
||||
* QueryStateContextProvider
|
||||
*
|
||||
* @return {Array} An array that has two elements. The first element is the
|
||||
* query state value for the given context. The second element
|
||||
* is a dispatcher function for setting the query state.
|
||||
*/
|
||||
export const useQueryStateByContext = ( context ) => {
|
||||
const queryStateContext = useQueryStateContext();
|
||||
context = context || queryStateContext;
|
||||
const queryState = useSelect(
|
||||
( select ) => {
|
||||
const store = select( storeKey );
|
||||
return store.getValueForQueryContext( context, undefined );
|
||||
},
|
||||
[ context ]
|
||||
);
|
||||
const { setValueForQueryContext } = useDispatch( storeKey );
|
||||
const setQueryState = useCallback(
|
||||
( value ) => {
|
||||
setValueForQueryContext( context, value );
|
||||
},
|
||||
[ context, setValueForQueryContext ]
|
||||
);
|
||||
|
||||
return [ queryState, setQueryState ];
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom hook that exposes the current query state value and a setter for the
|
||||
* given context and query key.
|
||||
*
|
||||
* "Query State" is a wp.data store that keeps track of an arbitrary object of
|
||||
* query keys and their values.
|
||||
*
|
||||
* @param {*} queryKey The specific query key to retrieve the value for.
|
||||
* @param {*} [defaultValue] Default value if query does not exist.
|
||||
* @param {string} [context] What context to retrieve the query state for. If
|
||||
* not provided will attempt to use what is provided
|
||||
* by query state context.
|
||||
*
|
||||
* @return {*} Whatever value is set at the query state index using the
|
||||
* provided context and query key.
|
||||
*/
|
||||
export const useQueryStateByKey = ( queryKey, defaultValue, context ) => {
|
||||
const queryStateContext = useQueryStateContext();
|
||||
context = context || queryStateContext;
|
||||
const queryValue = useSelect(
|
||||
( select ) => {
|
||||
const store = select( storeKey );
|
||||
return store.getValueForQueryKey( context, queryKey, defaultValue );
|
||||
},
|
||||
[ context, queryKey ]
|
||||
);
|
||||
|
||||
const { setQueryValue } = useDispatch( storeKey );
|
||||
const setQueryValueByKey = useCallback(
|
||||
( value ) => {
|
||||
setQueryValue( context, queryKey, value );
|
||||
},
|
||||
[ context, queryKey, setQueryValue ]
|
||||
);
|
||||
|
||||
return [ queryValue, setQueryValueByKey ];
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom hook that works similarly to useQueryStateByContext. However, this
|
||||
* hook allows for synchronizing with a provided queryState object.
|
||||
*
|
||||
* This hook does the following things with the provided `synchronizedQuery`
|
||||
* object:
|
||||
*
|
||||
* - whenever synchronizedQuery varies between renders, the queryState will be
|
||||
* updated to a merged object of the internal queryState and the provided
|
||||
* object. Note, any values from the same properties between objects will
|
||||
* be set from synchronizedQuery.
|
||||
* - if there are no changes between renders, then the existing internal
|
||||
* queryState is always returned.
|
||||
* - on initial render, the synchronizedQuery value is returned.
|
||||
*
|
||||
* Typically, this hook would be used in a scenario where there may be external
|
||||
* triggers for updating the query state (i.e. initial population of query
|
||||
* state by hydration or component attributes, or routing url changes that
|
||||
* affect query state).
|
||||
*
|
||||
* @param {Object} synchronizedQuery A provided query state object to
|
||||
* synchronize internal query state with.
|
||||
* @param {string} [context] What context to retrieve the query state
|
||||
* for. If not provided, will be pulled from
|
||||
* the QueryStateContextProvider in the tree.
|
||||
*/
|
||||
export const useSynchronizedQueryState = ( synchronizedQuery, context ) => {
|
||||
const queryStateContext = useQueryStateContext();
|
||||
context = context || queryStateContext;
|
||||
const [ queryState, setQueryState ] = useQueryStateByContext( context );
|
||||
const currentQueryState = useShallowEqual( queryState );
|
||||
const currentSynchronizedQuery = useShallowEqual( synchronizedQuery );
|
||||
const previousSynchronizedQuery = usePrevious( currentSynchronizedQuery );
|
||||
// used to ensure we allow initial synchronization to occur before
|
||||
// returning non-synced state.
|
||||
const isInitialized = useRef( false );
|
||||
// update queryState anytime incoming synchronizedQuery changes
|
||||
useEffect( () => {
|
||||
if (
|
||||
! isShallowEqual(
|
||||
previousSynchronizedQuery,
|
||||
currentSynchronizedQuery
|
||||
)
|
||||
) {
|
||||
setQueryState(
|
||||
Object.assign( {}, currentQueryState, currentSynchronizedQuery )
|
||||
);
|
||||
isInitialized.current = true;
|
||||
}
|
||||
}, [
|
||||
currentQueryState,
|
||||
currentSynchronizedQuery,
|
||||
previousSynchronizedQuery,
|
||||
setQueryState,
|
||||
] );
|
||||
return isInitialized.current
|
||||
? [ queryState, setQueryState ]
|
||||
: [ synchronizedQuery, setQueryState ];
|
||||
};
|
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect, useRef } from '@wordpress/element';
|
||||
import { useDispatch } from '@wordpress/data';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import type { CartItem } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from './cart/use-store-cart';
|
||||
import { useStoreNotices } from './use-store-notices';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/hooks').StoreCartItemAddToCart} StoreCartItemAddToCart
|
||||
*/
|
||||
|
||||
interface StoreAddToCart {
|
||||
cartQuantity: number;
|
||||
addingToCart: boolean;
|
||||
cartIsLoading: boolean;
|
||||
addToCart: ( quantity?: number ) => Promise< boolean >;
|
||||
}
|
||||
/**
|
||||
* Get the quantity of a product in the cart.
|
||||
*
|
||||
* @param {Object} cartItems Array of items.
|
||||
* @param {number} productId The product id to look for.
|
||||
* @return {number} Quantity in the cart.
|
||||
*/
|
||||
const getQuantityFromCartItems = (
|
||||
cartItems: Array< CartItem >,
|
||||
productId: number
|
||||
): number => {
|
||||
const productItem = cartItems.find( ( { id } ) => id === productId );
|
||||
|
||||
return productItem ? productItem.quantity : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom hook for exposing cart related data for a given product id and an
|
||||
* action for adding a single quantity of the product _to_ the cart.
|
||||
*
|
||||
*
|
||||
* @param {number} productId The product id to be added to the cart.
|
||||
*
|
||||
* @return {StoreCartItemAddToCart} An object exposing data and actions relating
|
||||
* to add to cart functionality.
|
||||
*/
|
||||
export const useStoreAddToCart = ( productId: number ): StoreAddToCart => {
|
||||
const { addItemToCart } = useDispatch( storeKey );
|
||||
const { cartItems, cartIsLoading } = useStoreCart();
|
||||
const { addErrorNotice, removeNotice } = useStoreNotices();
|
||||
|
||||
const [ addingToCart, setAddingToCart ] = useState( false );
|
||||
const currentCartItemQuantity = useRef(
|
||||
getQuantityFromCartItems( cartItems, productId )
|
||||
);
|
||||
|
||||
const addToCart = ( quantity = 1 ) => {
|
||||
setAddingToCart( true );
|
||||
return addItemToCart( productId, quantity )
|
||||
.then( () => {
|
||||
removeNotice( 'add-to-cart' );
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
addErrorNotice( decodeEntities( error.message ), {
|
||||
context: 'wc/all-products',
|
||||
id: 'add-to-cart',
|
||||
isDismissible: true,
|
||||
} );
|
||||
} )
|
||||
.finally( () => {
|
||||
setAddingToCart( false );
|
||||
} );
|
||||
};
|
||||
|
||||
useEffect( () => {
|
||||
const quantity = getQuantityFromCartItems( cartItems, productId );
|
||||
|
||||
if ( quantity !== currentCartItemQuantity.current ) {
|
||||
currentCartItemQuantity.current = quantity;
|
||||
}
|
||||
}, [ cartItems, productId ] );
|
||||
|
||||
return {
|
||||
cartQuantity: Number.isFinite( currentCartItemQuantity.current )
|
||||
? currentCartItemQuantity.current
|
||||
: 0,
|
||||
addingToCart,
|
||||
cartIsLoading,
|
||||
addToCart,
|
||||
};
|
||||
};
|
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { doAction } from '@wordpress/hooks';
|
||||
import { useCallback, useRef, useEffect } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from './cart/use-store-cart';
|
||||
|
||||
type StoreEvent = (
|
||||
eventName: string,
|
||||
eventParams?: Partial< Record< string, unknown > >
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Abstraction on top of @wordpress/hooks for dispatching events via doAction for 3rd parties to hook into.
|
||||
*/
|
||||
export const useStoreEvents = (): {
|
||||
dispatchStoreEvent: StoreEvent;
|
||||
dispatchCheckoutEvent: StoreEvent;
|
||||
} => {
|
||||
const storeCart = useStoreCart();
|
||||
const currentStoreCart = useRef( storeCart );
|
||||
|
||||
// Track the latest version of the cart so we can use the current value in our callback function below without triggering
|
||||
// other useEffect hooks using dispatchCheckoutEvent as a dependency.
|
||||
useEffect( () => {
|
||||
currentStoreCart.current = storeCart;
|
||||
}, [ storeCart ] );
|
||||
|
||||
const dispatchStoreEvent = useCallback( ( eventName, eventParams = {} ) => {
|
||||
try {
|
||||
doAction(
|
||||
`experimental__woocommerce_blocks-${ eventName }`,
|
||||
eventParams
|
||||
);
|
||||
} catch ( e ) {
|
||||
// We don't handle thrown errors but just console.log for troubleshooting.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error( e );
|
||||
}
|
||||
}, [] );
|
||||
|
||||
const dispatchCheckoutEvent = useCallback(
|
||||
( eventName, eventParams = {} ) => {
|
||||
try {
|
||||
doAction(
|
||||
`experimental__woocommerce_blocks-checkout-${ eventName }`,
|
||||
{
|
||||
...eventParams,
|
||||
storeCart: currentStoreCart.current,
|
||||
}
|
||||
);
|
||||
} catch ( e ) {
|
||||
// We don't handle thrown errors but just console.log for troubleshooting.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error( e );
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { dispatchStoreEvent, dispatchCheckoutEvent };
|
||||
};
|
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo, useRef, useEffect } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreNoticesContext } from '../providers/store-notices/context';
|
||||
|
||||
type WPNoticeAction = {
|
||||
label: string;
|
||||
url?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type WPNotice = {
|
||||
id: string;
|
||||
status: 'success' | 'info' | 'error' | 'warning';
|
||||
content: string;
|
||||
spokenMessage: string;
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
__unstableHTML: string;
|
||||
isDismissible: boolean;
|
||||
type: 'default' | 'snackbar';
|
||||
speak: boolean;
|
||||
actions: WPNoticeAction[];
|
||||
};
|
||||
|
||||
type NoticeOptions = {
|
||||
id: string;
|
||||
type?: string;
|
||||
isDismissible: boolean;
|
||||
};
|
||||
|
||||
type NoticeCreator = ( text: string, noticeProps: NoticeOptions ) => void;
|
||||
|
||||
export const useStoreNotices = (): {
|
||||
notices: WPNotice[];
|
||||
hasNoticesOfType: ( type: string ) => boolean;
|
||||
removeNotices: ( status: string | null ) => void;
|
||||
removeNotice: ( id: string, context: string ) => void;
|
||||
addDefaultNotice: NoticeCreator;
|
||||
addErrorNotice: NoticeCreator;
|
||||
addWarningNotice: NoticeCreator;
|
||||
addInfoNotice: NoticeCreator;
|
||||
addSuccessNotice: NoticeCreator;
|
||||
setIsSuppressed: ( isSuppressed: boolean ) => void;
|
||||
} => {
|
||||
const {
|
||||
notices,
|
||||
createNotice,
|
||||
removeNotice,
|
||||
setIsSuppressed,
|
||||
} = useStoreNoticesContext();
|
||||
// Added to a ref so the surface for notices doesn't change frequently
|
||||
// and thus can be used as dependencies on effects.
|
||||
const currentNotices = useRef( notices );
|
||||
|
||||
// Update notices ref whenever they change
|
||||
useEffect( () => {
|
||||
currentNotices.current = notices;
|
||||
}, [ notices ] );
|
||||
|
||||
const noticesApi = useMemo(
|
||||
() => ( {
|
||||
hasNoticesOfType: ( type: 'default' | 'snackbar' ): boolean => {
|
||||
return currentNotices.current.some(
|
||||
( notice ) => notice.type === type
|
||||
);
|
||||
},
|
||||
removeNotices: ( status = null ) => {
|
||||
currentNotices.current.forEach( ( notice ) => {
|
||||
if ( status === null || notice.status === status ) {
|
||||
removeNotice( notice.id );
|
||||
}
|
||||
} );
|
||||
},
|
||||
removeNotice,
|
||||
} ),
|
||||
[ removeNotice ]
|
||||
);
|
||||
|
||||
const noticeCreators = useMemo(
|
||||
() => ( {
|
||||
addDefaultNotice: ( text: string, noticeProps = {} ) =>
|
||||
void createNotice( 'default', text, {
|
||||
...noticeProps,
|
||||
} ),
|
||||
addErrorNotice: ( text: string, noticeProps = {} ) =>
|
||||
void createNotice( 'error', text, {
|
||||
...noticeProps,
|
||||
} ),
|
||||
addWarningNotice: ( text: string, noticeProps = {} ) =>
|
||||
void createNotice( 'warning', text, {
|
||||
...noticeProps,
|
||||
} ),
|
||||
addInfoNotice: ( text: string, noticeProps = {} ) =>
|
||||
void createNotice( 'info', text, {
|
||||
...noticeProps,
|
||||
} ),
|
||||
addSuccessNotice: ( text: string, noticeProps = {} ) =>
|
||||
void createNotice( 'success', text, {
|
||||
...noticeProps,
|
||||
} ),
|
||||
} ),
|
||||
[ createNotice ]
|
||||
);
|
||||
|
||||
return {
|
||||
notices,
|
||||
...noticesApi,
|
||||
...noticeCreators,
|
||||
setIsSuppressed,
|
||||
};
|
||||
};
|
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCollectionHeader, useCollection } from './collections';
|
||||
|
||||
/**
|
||||
* This is a custom hook that is wired up to the `wc/store/collections` data
|
||||
* store for the `wc/store/products` route. Given a query object, this
|
||||
* will ensure a component is kept up to date with the products matching that
|
||||
* query in the store state.
|
||||
*
|
||||
* @param {Object} query An object containing any query arguments to be
|
||||
* included with the collection request for the
|
||||
* products. Does not have to be included.
|
||||
*
|
||||
* @return {Object} This hook will return an object with three properties:
|
||||
* - products An array of product objects.
|
||||
* - totalProducts The total number of products that match
|
||||
* the given query parameters.
|
||||
* - productsLoading A boolean indicating whether the products
|
||||
* are still loading or not.
|
||||
*/
|
||||
export const useStoreProducts = ( query ) => {
|
||||
const collectionOptions = {
|
||||
namespace: '/wc/store',
|
||||
resourceName: 'products',
|
||||
};
|
||||
const { results: products, isLoading: productsLoading } = useCollection( {
|
||||
...collectionOptions,
|
||||
query,
|
||||
} );
|
||||
const { value: totalProducts } = useCollectionHeader( 'x-wp-total', {
|
||||
...collectionOptions,
|
||||
query,
|
||||
} );
|
||||
return {
|
||||
products,
|
||||
totalProducts: parseInt( totalProducts, 10 ),
|
||||
productsLoading,
|
||||
};
|
||||
};
|
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo, useRef, useEffect } from '@wordpress/element';
|
||||
import { useStoreSnackbarNoticesContext } from '@woocommerce/base-context/providers';
|
||||
|
||||
export const useStoreSnackbarNotices = () => {
|
||||
const {
|
||||
notices,
|
||||
createSnackbarNotice,
|
||||
removeSnackbarNotice,
|
||||
setIsSuppressed,
|
||||
} = useStoreSnackbarNoticesContext();
|
||||
// Added to a ref so the surface for notices doesn't change frequently
|
||||
// and thus can be used as dependencies on effects.
|
||||
const currentNotices = useRef( notices );
|
||||
|
||||
// Update notices ref whenever they change
|
||||
useEffect( () => {
|
||||
currentNotices.current = notices;
|
||||
}, [ notices ] );
|
||||
|
||||
const noticesApi = useMemo(
|
||||
() => ( {
|
||||
removeNotices: ( status = null ) => {
|
||||
currentNotices.current.forEach( ( notice ) => {
|
||||
if ( status === null || notice.status === status ) {
|
||||
removeSnackbarNotice( notice.id );
|
||||
}
|
||||
} );
|
||||
},
|
||||
removeSnackbarNotice,
|
||||
} ),
|
||||
[ removeSnackbarNotice ]
|
||||
);
|
||||
|
||||
const noticeCreators = useMemo(
|
||||
() => ( {
|
||||
addSnackbarNotice: ( text, noticeProps = {} ) => {
|
||||
createSnackbarNotice( text, noticeProps );
|
||||
},
|
||||
} ),
|
||||
[ createSnackbarNotice ]
|
||||
);
|
||||
|
||||
return {
|
||||
notices,
|
||||
...noticesApi,
|
||||
...noticeCreators,
|
||||
setIsSuppressed,
|
||||
};
|
||||
};
|
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useCallback } from '@wordpress/element';
|
||||
import type {
|
||||
ValidationData,
|
||||
ValidationContextError,
|
||||
} from '@woocommerce/type-defs/contexts';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useValidationContext } from '../providers/validation/';
|
||||
|
||||
/**
|
||||
* Custom hook for setting for adding errors to the validation system.
|
||||
*/
|
||||
export const useValidation = (): ValidationData => {
|
||||
const {
|
||||
hasValidationErrors,
|
||||
getValidationError,
|
||||
clearValidationError,
|
||||
hideValidationError,
|
||||
setValidationErrors,
|
||||
} = useValidationContext();
|
||||
const prefix = 'extensions-errors';
|
||||
|
||||
return {
|
||||
hasValidationErrors,
|
||||
getValidationError: useCallback(
|
||||
( validationErrorId: string ) =>
|
||||
getValidationError( `${ prefix }-${ validationErrorId }` ),
|
||||
[ getValidationError ]
|
||||
),
|
||||
clearValidationError: useCallback(
|
||||
( validationErrorId: string ) =>
|
||||
clearValidationError( `${ prefix }-${ validationErrorId }` ),
|
||||
[ clearValidationError ]
|
||||
),
|
||||
hideValidationError: useCallback(
|
||||
( validationErrorId: string ) =>
|
||||
hideValidationError( `${ prefix }-${ validationErrorId }` ),
|
||||
[ hideValidationError ]
|
||||
),
|
||||
setValidationErrors: useCallback(
|
||||
( errorsObject: Record< string, ValidationContextError > ) =>
|
||||
setValidationErrors(
|
||||
Object.fromEntries(
|
||||
Object.entries(
|
||||
errorsObject
|
||||
).map( ( [ validationErrorId, error ] ) => [
|
||||
`${ prefix }-${ validationErrorId }`,
|
||||
error,
|
||||
] )
|
||||
)
|
||||
),
|
||||
[ setValidationErrors ]
|
||||
),
|
||||
};
|
||||
};
|
@ -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: 1,
|
||||
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,322 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* 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 { useValidationContext } from '../../validation';
|
||||
import { useStoreNotices } from '../../../hooks/use-store-notices';
|
||||
import { useEmitResponse } from '../../../hooks/use-emit-response';
|
||||
|
||||
/**
|
||||
* @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 { addErrorNotice, removeNotices } = useStoreNotices();
|
||||
const { setValidationErrors } = useValidationContext();
|
||||
const {
|
||||
isSuccessResponse,
|
||||
isErrorResponse,
|
||||
isFailResponse,
|
||||
} = useEmitResponse();
|
||||
|
||||
/**
|
||||
* @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 ) {
|
||||
removeNotices( 'error' );
|
||||
emitEvent(
|
||||
currentObservers,
|
||||
EMIT_TYPES.ADD_TO_CART_BEFORE_PROCESSING,
|
||||
{}
|
||||
).then( ( response ) => {
|
||||
if ( response !== true ) {
|
||||
if ( Array.isArray( response ) ) {
|
||||
response.forEach(
|
||||
( { errorMessage, validationErrors } ) => {
|
||||
if ( errorMessage ) {
|
||||
addErrorNotice( errorMessage );
|
||||
}
|
||||
if ( validationErrors ) {
|
||||
setValidationErrors( validationErrors );
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
dispatch( actions.setIdle() );
|
||||
} else {
|
||||
dispatch( actions.setProcessing() );
|
||||
}
|
||||
} );
|
||||
}
|
||||
}, [
|
||||
addToCartFormState.status,
|
||||
setValidationErrors,
|
||||
addErrorNotice,
|
||||
removeNotices,
|
||||
dispatch,
|
||||
currentObservers,
|
||||
] );
|
||||
|
||||
/**
|
||||
* 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;
|
||||
addErrorNotice( 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 to get assistance.',
|
||||
'woocommerce'
|
||||
);
|
||||
addErrorNotice( message, {
|
||||
id: 'add-to-cart',
|
||||
} );
|
||||
}
|
||||
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,
|
||||
addErrorNotice,
|
||||
isErrorResponse,
|
||||
isFailResponse,
|
||||
isSuccessResponse,
|
||||
currentObservers,
|
||||
] );
|
||||
|
||||
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,
|
||||
minQuantity: 1,
|
||||
maxQuantity: product.quantity_limit || 99,
|
||||
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,34 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { AddToCartFormStateContextProvider } from '../form-state';
|
||||
import { ValidationContextProvider } from '../../validation';
|
||||
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 (
|
||||
<ValidationContextProvider>
|
||||
<AddToCartFormStateContextProvider
|
||||
product={ product }
|
||||
showFormElements={ showFormElements }
|
||||
>
|
||||
{ children }
|
||||
<FormSubmit />
|
||||
</AddToCartFormStateContextProvider>
|
||||
</ValidationContextProvider>
|
||||
);
|
||||
};
|
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useAddToCartFormContext } from '../../form-state';
|
||||
import { useValidationContext } from '../../../validation';
|
||||
import { useStoreCart } from '../../../../hooks/cart/use-store-cart';
|
||||
import { useStoreNotices } from '../../../../hooks/use-store-notices';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
hasValidationErrors,
|
||||
showAllValidationErrors,
|
||||
} = useValidationContext();
|
||||
const { addErrorNotice, removeNotice } = useStoreNotices();
|
||||
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' );
|
||||
|
||||
const fetchData = {
|
||||
id: product.id || 0,
|
||||
quantity,
|
||||
...requestParams,
|
||||
};
|
||||
|
||||
triggerFetch( {
|
||||
path: '/wc/store/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 ) {
|
||||
addErrorNotice(
|
||||
decodeEntities( response.body.message ),
|
||||
{
|
||||
id: 'add-to-cart',
|
||||
}
|
||||
);
|
||||
} else {
|
||||
addErrorNotice(
|
||||
__(
|
||||
'Something went wrong. Please contact us to get assistance.',
|
||||
'woocommerce'
|
||||
),
|
||||
{
|
||||
id: 'add-to-cart',
|
||||
}
|
||||
);
|
||||
}
|
||||
dispatchActions.setHasError();
|
||||
} else {
|
||||
receiveCart( response );
|
||||
}
|
||||
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,
|
||||
addErrorNotice,
|
||||
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,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 isCart={ true } redirectUrl={ redirectUrl }>
|
||||
{ children }
|
||||
</CheckoutProvider>
|
||||
);
|
||||
};
|
@ -0,0 +1,269 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import triggerFetch from '@wordpress/api-fetch';
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useState,
|
||||
useMemo,
|
||||
} from '@wordpress/element';
|
||||
import {
|
||||
emptyHiddenAddressFields,
|
||||
formatStoreApiErrorMessage,
|
||||
} from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { preparePaymentData, processCheckoutResponseHeaders } from './utils';
|
||||
import { useCheckoutContext } from './checkout-state';
|
||||
import { useShippingDataContext } from './shipping';
|
||||
import { useCustomerDataContext } from './customer';
|
||||
import { usePaymentMethodDataContext } from './payment-methods';
|
||||
import { useValidationContext } from '../validation';
|
||||
import { useStoreCart } from '../../hooks/cart/use-store-cart';
|
||||
import { useStoreNotices } from '../../hooks/use-store-notices';
|
||||
|
||||
/**
|
||||
* CheckoutProcessor component.
|
||||
*
|
||||
* Subscribes to checkout context and triggers processing via the API.
|
||||
*/
|
||||
const CheckoutProcessor = () => {
|
||||
const {
|
||||
hasError: checkoutHasError,
|
||||
onCheckoutValidationBeforeProcessing,
|
||||
dispatchActions,
|
||||
redirectUrl,
|
||||
isProcessing: checkoutIsProcessing,
|
||||
isBeforeProcessing: checkoutIsBeforeProcessing,
|
||||
isComplete: checkoutIsComplete,
|
||||
orderNotes,
|
||||
shouldCreateAccount,
|
||||
extensionData,
|
||||
} = useCheckoutContext();
|
||||
const { hasValidationErrors } = useValidationContext();
|
||||
const { shippingErrorStatus } = useShippingDataContext();
|
||||
const { billingData, shippingAddress } = useCustomerDataContext();
|
||||
const { cartNeedsPayment, receiveCart } = useStoreCart();
|
||||
const {
|
||||
activePaymentMethod,
|
||||
isExpressPaymentMethodActive,
|
||||
currentStatus: currentPaymentStatus,
|
||||
paymentMethodData,
|
||||
expressPaymentMethods,
|
||||
paymentMethods,
|
||||
shouldSavePayment,
|
||||
} = usePaymentMethodDataContext();
|
||||
const { addErrorNotice, removeNotice, setIsSuppressed } = useStoreNotices();
|
||||
const currentBillingData = useRef( billingData );
|
||||
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 ) ||
|
||||
currentPaymentStatus.hasError ||
|
||||
shippingErrorStatus.hasError;
|
||||
|
||||
const paidAndWithoutErrors =
|
||||
! checkoutHasError &&
|
||||
! checkoutWillHaveError &&
|
||||
( currentPaymentStatus.isSuccessful || ! cartNeedsPayment ) &&
|
||||
checkoutIsProcessing;
|
||||
|
||||
// If express payment method is active, let's suppress notices
|
||||
useEffect( () => {
|
||||
setIsSuppressed( isExpressPaymentMethodActive );
|
||||
}, [ isExpressPaymentMethodActive, setIsSuppressed ] );
|
||||
|
||||
// Determine if checkout has an error.
|
||||
useEffect( () => {
|
||||
if (
|
||||
checkoutWillHaveError !== checkoutHasError &&
|
||||
( checkoutIsProcessing || checkoutIsBeforeProcessing ) &&
|
||||
! isExpressPaymentMethodActive
|
||||
) {
|
||||
dispatchActions.setHasError( checkoutWillHaveError );
|
||||
}
|
||||
}, [
|
||||
checkoutWillHaveError,
|
||||
checkoutHasError,
|
||||
checkoutIsProcessing,
|
||||
checkoutIsBeforeProcessing,
|
||||
isExpressPaymentMethodActive,
|
||||
dispatchActions,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
currentBillingData.current = billingData;
|
||||
currentShippingAddress.current = shippingAddress;
|
||||
currentRedirectUrl.current = redirectUrl;
|
||||
}, [ billingData, shippingAddress, redirectUrl ] );
|
||||
|
||||
const checkValidation = useCallback( () => {
|
||||
if ( hasValidationErrors ) {
|
||||
return false;
|
||||
}
|
||||
if ( currentPaymentStatus.hasError ) {
|
||||
return {
|
||||
errorMessage: __(
|
||||
'There was a problem with your payment option.',
|
||||
'woocommerce'
|
||||
),
|
||||
};
|
||||
}
|
||||
if ( shippingErrorStatus.hasError ) {
|
||||
return {
|
||||
errorMessage: __(
|
||||
'There was a problem with your shipping option.',
|
||||
'woocommerce'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [
|
||||
hasValidationErrors,
|
||||
currentPaymentStatus.hasError,
|
||||
shippingErrorStatus.hasError,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
let unsubscribeProcessing;
|
||||
if ( ! isExpressPaymentMethodActive ) {
|
||||
unsubscribeProcessing = onCheckoutValidationBeforeProcessing(
|
||||
checkValidation,
|
||||
0
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
if ( ! isExpressPaymentMethodActive ) {
|
||||
unsubscribeProcessing();
|
||||
}
|
||||
};
|
||||
}, [
|
||||
onCheckoutValidationBeforeProcessing,
|
||||
checkValidation,
|
||||
isExpressPaymentMethodActive,
|
||||
] );
|
||||
|
||||
// redirect when checkout is complete and there is a redirect url.
|
||||
useEffect( () => {
|
||||
if ( currentRedirectUrl.current ) {
|
||||
window.location.href = currentRedirectUrl.current;
|
||||
}
|
||||
}, [ checkoutIsComplete ] );
|
||||
|
||||
const processOrder = useCallback( async () => {
|
||||
if ( isProcessingOrder ) {
|
||||
return;
|
||||
}
|
||||
setIsProcessingOrder( true );
|
||||
removeNotice( 'checkout' );
|
||||
|
||||
const paymentData = cartNeedsPayment
|
||||
? {
|
||||
payment_method: paymentMethodId,
|
||||
payment_data: preparePaymentData(
|
||||
paymentMethodData,
|
||||
shouldSavePayment,
|
||||
activePaymentMethod
|
||||
),
|
||||
}
|
||||
: {};
|
||||
|
||||
const data = {
|
||||
billing_address: emptyHiddenAddressFields(
|
||||
currentBillingData.current
|
||||
),
|
||||
shipping_address: emptyHiddenAddressFields(
|
||||
currentShippingAddress.current
|
||||
),
|
||||
customer_note: orderNotes,
|
||||
should_create_account: shouldCreateAccount,
|
||||
...paymentData,
|
||||
extensions: { ...extensionData },
|
||||
};
|
||||
|
||||
triggerFetch( {
|
||||
path: '/wc/store/checkout',
|
||||
method: 'POST',
|
||||
data,
|
||||
cache: 'no-store',
|
||||
parse: false,
|
||||
} )
|
||||
.then( ( response ) => {
|
||||
processCheckoutResponseHeaders(
|
||||
response.headers,
|
||||
dispatchActions
|
||||
);
|
||||
if ( ! response.ok ) {
|
||||
throw new Error( response );
|
||||
}
|
||||
return response.json();
|
||||
} )
|
||||
.then( ( response ) => {
|
||||
dispatchActions.setAfterProcessing( response );
|
||||
setIsProcessingOrder( false );
|
||||
} )
|
||||
.catch( ( fetchResponse ) => {
|
||||
processCheckoutResponseHeaders(
|
||||
fetchResponse.headers,
|
||||
dispatchActions
|
||||
);
|
||||
fetchResponse.json().then( ( response ) => {
|
||||
// If updated cart state was returned, update the store.
|
||||
if ( response.data?.cart ) {
|
||||
receiveCart( response.data.cart );
|
||||
}
|
||||
addErrorNotice( formatStoreApiErrorMessage( response ), {
|
||||
id: 'checkout',
|
||||
} );
|
||||
response.additional_errors?.forEach?.(
|
||||
( additionalError ) => {
|
||||
addErrorNotice( additionalError.message, {
|
||||
id: additionalError.error_code,
|
||||
} );
|
||||
}
|
||||
);
|
||||
dispatchActions.setHasError( true );
|
||||
dispatchActions.setAfterProcessing( response );
|
||||
setIsProcessingOrder( false );
|
||||
} );
|
||||
} );
|
||||
}, [
|
||||
isProcessingOrder,
|
||||
removeNotice,
|
||||
orderNotes,
|
||||
shouldCreateAccount,
|
||||
cartNeedsPayment,
|
||||
paymentMethodId,
|
||||
paymentMethodData,
|
||||
shouldSavePayment,
|
||||
activePaymentMethod,
|
||||
extensionData,
|
||||
dispatchActions,
|
||||
addErrorNotice,
|
||||
receiveCart,
|
||||
] );
|
||||
|
||||
// process order if conditions are good.
|
||||
useEffect( () => {
|
||||
if ( paidAndWithoutErrors && ! isProcessingOrder ) {
|
||||
processOrder();
|
||||
}
|
||||
}, [ processOrder, paidAndWithoutErrors, isProcessingOrder ] );
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default CheckoutProcessor;
|
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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 { PaymentMethodDataProvider } from './payment-methods';
|
||||
import { ShippingDataProvider } from './shipping';
|
||||
import { CustomerDataProvider } from './customer';
|
||||
import { CheckoutStateProvider } from './checkout-state';
|
||||
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.
|
||||
* @param {boolean} [props.isCart] Whether it's rendered in the Cart
|
||||
* component.
|
||||
* @param {string} [props.redirectUrl] Initialize what the checkout will
|
||||
* redirect to after successful
|
||||
* submit.
|
||||
*/
|
||||
export const CheckoutProvider = ( {
|
||||
children,
|
||||
isCart = false,
|
||||
redirectUrl,
|
||||
} ) => {
|
||||
return (
|
||||
<CheckoutStateProvider redirectUrl={ redirectUrl } isCart={ isCart }>
|
||||
<CustomerDataProvider>
|
||||
<ShippingDataProvider>
|
||||
<PaymentMethodDataProvider>
|
||||
{ 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 />
|
||||
</PaymentMethodDataProvider>
|
||||
</ShippingDataProvider>
|
||||
</CustomerDataProvider>
|
||||
</CheckoutStateProvider>
|
||||
);
|
||||
};
|
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { PaymentResultDataType, CheckoutStateContextState } from './types';
|
||||
|
||||
export enum ACTION {
|
||||
SET_IDLE = 'set_idle',
|
||||
SET_PRISTINE = 'set_pristine',
|
||||
SET_REDIRECT_URL = 'set_redirect_url',
|
||||
SET_COMPLETE = 'set_checkout_complete',
|
||||
SET_BEFORE_PROCESSING = 'set_before_processing',
|
||||
SET_AFTER_PROCESSING = 'set_after_processing',
|
||||
SET_PROCESSING_RESPONSE = 'set_processing_response',
|
||||
SET_PROCESSING = 'set_checkout_is_processing',
|
||||
SET_HAS_ERROR = 'set_checkout_has_error',
|
||||
SET_NO_ERROR = 'set_checkout_no_error',
|
||||
SET_CUSTOMER_ID = 'set_checkout_customer_id',
|
||||
SET_ORDER_ID = 'set_checkout_order_id',
|
||||
SET_ORDER_NOTES = 'set_checkout_order_notes',
|
||||
INCREMENT_CALCULATING = 'increment_calculating',
|
||||
DECREMENT_CALCULATING = 'decrement_calculating',
|
||||
SET_SHOULD_CREATE_ACCOUNT = 'set_should_create_account',
|
||||
SET_EXTENSION_DATA = 'set_extension_data',
|
||||
}
|
||||
|
||||
export interface ActionType extends Partial< CheckoutStateContextState > {
|
||||
type: ACTION;
|
||||
data?:
|
||||
| Record< string, unknown >
|
||||
| Record< string, never >
|
||||
| PaymentResultDataType;
|
||||
}
|
||||
|
||||
/**
|
||||
* All the actions that can be dispatched for the checkout.
|
||||
*/
|
||||
export const actions = {
|
||||
setPristine: () =>
|
||||
( {
|
||||
type: ACTION.SET_PRISTINE,
|
||||
} as const ),
|
||||
setIdle: () =>
|
||||
( {
|
||||
type: ACTION.SET_IDLE,
|
||||
} as const ),
|
||||
setProcessing: () =>
|
||||
( {
|
||||
type: ACTION.SET_PROCESSING,
|
||||
} as const ),
|
||||
setRedirectUrl: ( redirectUrl: string ) =>
|
||||
( {
|
||||
type: ACTION.SET_REDIRECT_URL,
|
||||
redirectUrl,
|
||||
} as const ),
|
||||
setProcessingResponse: ( data: PaymentResultDataType ) =>
|
||||
( {
|
||||
type: ACTION.SET_PROCESSING_RESPONSE,
|
||||
data,
|
||||
} as const ),
|
||||
setComplete: ( data: Record< string, unknown > = {} ) =>
|
||||
( {
|
||||
type: ACTION.SET_COMPLETE,
|
||||
data,
|
||||
} as const ),
|
||||
setBeforeProcessing: () =>
|
||||
( {
|
||||
type: ACTION.SET_BEFORE_PROCESSING,
|
||||
} as const ),
|
||||
setAfterProcessing: () =>
|
||||
( {
|
||||
type: ACTION.SET_AFTER_PROCESSING,
|
||||
} as const ),
|
||||
setHasError: ( hasError = true ) =>
|
||||
( {
|
||||
type: hasError ? ACTION.SET_HAS_ERROR : ACTION.SET_NO_ERROR,
|
||||
} as const ),
|
||||
incrementCalculating: () =>
|
||||
( {
|
||||
type: ACTION.INCREMENT_CALCULATING,
|
||||
} as const ),
|
||||
decrementCalculating: () =>
|
||||
( {
|
||||
type: ACTION.DECREMENT_CALCULATING,
|
||||
} as const ),
|
||||
setCustomerId: ( customerId: number ) =>
|
||||
( {
|
||||
type: ACTION.SET_CUSTOMER_ID,
|
||||
customerId,
|
||||
} as const ),
|
||||
setOrderId: ( orderId: number ) =>
|
||||
( {
|
||||
type: ACTION.SET_ORDER_ID,
|
||||
orderId,
|
||||
} as const ),
|
||||
setShouldCreateAccount: ( shouldCreateAccount: boolean ) =>
|
||||
( {
|
||||
type: ACTION.SET_SHOULD_CREATE_ACCOUNT,
|
||||
shouldCreateAccount,
|
||||
} as const ),
|
||||
setOrderNotes: ( orderNotes: string ) =>
|
||||
( {
|
||||
type: ACTION.SET_ORDER_NOTES,
|
||||
orderNotes,
|
||||
} as const ),
|
||||
setExtensionData: (
|
||||
extensionData: Record< string, Record< string, unknown > >
|
||||
) =>
|
||||
( {
|
||||
type: ACTION.SET_EXTENSION_DATA,
|
||||
extensionData,
|
||||
} as const ),
|
||||
};
|
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type {
|
||||
CheckoutStateContextType,
|
||||
CheckoutStateContextState,
|
||||
} from './types';
|
||||
|
||||
export enum STATUS {
|
||||
// Checkout is in it's initialized state.
|
||||
PRISTINE = 'pristine',
|
||||
// When checkout state has changed but there is no activity happening.
|
||||
IDLE = 'idle',
|
||||
// After BEFORE_PROCESSING status emitters have finished successfully. Payment processing is started on this checkout status.
|
||||
PROCESSING = 'processing',
|
||||
// After the AFTER_PROCESSING event emitters have completed. This status triggers the checkout redirect.
|
||||
COMPLETE = 'complete',
|
||||
// This is the state before checkout processing begins after the checkout button has been pressed/submitted.
|
||||
BEFORE_PROCESSING = 'before_processing',
|
||||
// After server side checkout processing is completed this status is set
|
||||
AFTER_PROCESSING = 'after_processing',
|
||||
}
|
||||
|
||||
const preloadedApiRequests = getSetting( 'preloadedApiRequests', {} ) as Record<
|
||||
string,
|
||||
{ body: Record< string, unknown > }
|
||||
>;
|
||||
|
||||
const checkoutData = {
|
||||
order_id: 0,
|
||||
customer_id: 0,
|
||||
...( preloadedApiRequests[ '/wc/store/checkout' ]?.body || {} ),
|
||||
};
|
||||
|
||||
export const DEFAULT_CHECKOUT_STATE_DATA: CheckoutStateContextType = {
|
||||
dispatchActions: {
|
||||
resetCheckout: () => void null,
|
||||
setRedirectUrl: ( url ) => void url,
|
||||
setHasError: ( hasError ) => void hasError,
|
||||
setAfterProcessing: ( response ) => void response,
|
||||
incrementCalculating: () => void null,
|
||||
decrementCalculating: () => void null,
|
||||
setCustomerId: ( id ) => void id,
|
||||
setOrderId: ( id ) => void id,
|
||||
setOrderNotes: ( orderNotes ) => void orderNotes,
|
||||
setExtensionData: ( extensionData ) => void extensionData,
|
||||
},
|
||||
onSubmit: () => void null,
|
||||
isComplete: false,
|
||||
isIdle: false,
|
||||
isCalculating: false,
|
||||
isProcessing: false,
|
||||
isBeforeProcessing: false,
|
||||
isAfterProcessing: false,
|
||||
hasError: false,
|
||||
redirectUrl: '',
|
||||
orderId: 0,
|
||||
orderNotes: '',
|
||||
customerId: 0,
|
||||
onCheckoutAfterProcessingWithSuccess: () => () => void null,
|
||||
onCheckoutAfterProcessingWithError: () => () => void null,
|
||||
onCheckoutBeforeProcessing: () => () => void null, // deprecated for onCheckoutValidationBeforeProcessing
|
||||
onCheckoutValidationBeforeProcessing: () => () => void null,
|
||||
hasOrder: false,
|
||||
isCart: false,
|
||||
shouldCreateAccount: false,
|
||||
setShouldCreateAccount: ( value ) => void value,
|
||||
extensionData: {},
|
||||
};
|
||||
|
||||
export const DEFAULT_STATE: CheckoutStateContextState = {
|
||||
redirectUrl: '',
|
||||
status: STATUS.PRISTINE,
|
||||
hasError: false,
|
||||
calculatingCount: 0,
|
||||
orderId: checkoutData.order_id,
|
||||
orderNotes: '',
|
||||
customerId: checkoutData.customer_id,
|
||||
shouldCreateAccount: false,
|
||||
processingResponse: null,
|
||||
extensionData: {},
|
||||
};
|
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
emitterCallback,
|
||||
reducer,
|
||||
emitEvent,
|
||||
emitEventWithAbort,
|
||||
ActionType,
|
||||
} from '../../../event-emit';
|
||||
|
||||
const EMIT_TYPES = {
|
||||
CHECKOUT_VALIDATION_BEFORE_PROCESSING:
|
||||
'checkout_validation_before_processing',
|
||||
CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS:
|
||||
'checkout_after_processing_with_success',
|
||||
CHECKOUT_AFTER_PROCESSING_WITH_ERROR:
|
||||
'checkout_after_processing_with_error',
|
||||
};
|
||||
|
||||
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(
|
||||
() => ( {
|
||||
onCheckoutAfterProcessingWithSuccess: emitterCallback(
|
||||
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS,
|
||||
observerDispatch
|
||||
),
|
||||
onCheckoutAfterProcessingWithError: emitterCallback(
|
||||
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_ERROR,
|
||||
observerDispatch
|
||||
),
|
||||
onCheckoutValidationBeforeProcessing: emitterCallback(
|
||||
EMIT_TYPES.CHECKOUT_VALIDATION_BEFORE_PROCESSING,
|
||||
observerDispatch
|
||||
),
|
||||
} ),
|
||||
[ observerDispatch ]
|
||||
);
|
||||
return eventEmitters;
|
||||
};
|
||||
|
||||
export { EMIT_TYPES, useEventEmitters, reducer, emitEvent, emitEventWithAbort };
|
@ -0,0 +1,397 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useRef,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { usePrevious } from '@woocommerce/base-hooks';
|
||||
import deprecated from '@wordpress/deprecated';
|
||||
import { isObject } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { actions } from './actions';
|
||||
import { reducer } from './reducer';
|
||||
import { getPaymentResultFromCheckoutResponse } from './utils';
|
||||
import {
|
||||
DEFAULT_STATE,
|
||||
STATUS,
|
||||
DEFAULT_CHECKOUT_STATE_DATA,
|
||||
} from './constants';
|
||||
import type {
|
||||
CheckoutStateDispatchActions,
|
||||
CheckoutStateContextType,
|
||||
} from './types';
|
||||
import {
|
||||
EMIT_TYPES,
|
||||
useEventEmitters,
|
||||
emitEvent,
|
||||
emitEventWithAbort,
|
||||
reducer as emitReducer,
|
||||
} from './event-emit';
|
||||
import { useValidationContext } from '../../validation';
|
||||
import { useStoreNotices } from '../../../hooks/use-store-notices';
|
||||
import { useStoreEvents } from '../../../hooks/use-store-events';
|
||||
import { useCheckoutNotices } from '../../../hooks/use-checkout-notices';
|
||||
import { useEmitResponse } from '../../../hooks/use-emit-response';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').CheckoutDataContext} CheckoutDataContext
|
||||
*/
|
||||
|
||||
const CheckoutContext = createContext( DEFAULT_CHECKOUT_STATE_DATA );
|
||||
|
||||
export const useCheckoutContext = (): CheckoutStateContextType => {
|
||||
return useContext( CheckoutContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Checkout state provider
|
||||
* This provides an API interface exposing checkout state for use with cart or checkout blocks.
|
||||
*
|
||||
* @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.
|
||||
* @param {boolean} props.isCart If context provider is being used in cart context.
|
||||
*/
|
||||
export const CheckoutStateProvider = ( {
|
||||
children,
|
||||
redirectUrl,
|
||||
isCart = false,
|
||||
}: {
|
||||
children: React.ReactChildren;
|
||||
redirectUrl: string;
|
||||
isCart: boolean;
|
||||
} ): JSX.Element => {
|
||||
// note, this is done intentionally so that the default state now has
|
||||
// the redirectUrl for when checkout is reset to PRISTINE state.
|
||||
DEFAULT_STATE.redirectUrl = redirectUrl;
|
||||
const [ checkoutState, dispatch ] = useReducer( reducer, DEFAULT_STATE );
|
||||
const { setValidationErrors } = useValidationContext();
|
||||
const { addErrorNotice, removeNotices } = useStoreNotices();
|
||||
const { dispatchCheckoutEvent } = useStoreEvents();
|
||||
const isCalculating = checkoutState.calculatingCount > 0;
|
||||
const {
|
||||
isSuccessResponse,
|
||||
isErrorResponse,
|
||||
isFailResponse,
|
||||
shouldRetry,
|
||||
} = useEmitResponse();
|
||||
const {
|
||||
checkoutNotices,
|
||||
paymentNotices,
|
||||
expressPaymentNotices,
|
||||
} = useCheckoutNotices();
|
||||
|
||||
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
|
||||
const currentObservers = useRef( observers );
|
||||
const {
|
||||
onCheckoutAfterProcessingWithSuccess,
|
||||
onCheckoutAfterProcessingWithError,
|
||||
onCheckoutValidationBeforeProcessing,
|
||||
} = useEventEmitters( observerDispatch );
|
||||
|
||||
// set observers on ref so it's always current.
|
||||
useEffect( () => {
|
||||
currentObservers.current = observers;
|
||||
}, [ observers ] );
|
||||
|
||||
/**
|
||||
* @deprecated use onCheckoutValidationBeforeProcessing 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 onCheckoutValidationBeforeProcessing >
|
||||
) {
|
||||
deprecated( 'onCheckoutBeforeProcessing', {
|
||||
alternative: 'onCheckoutValidationBeforeProcessing',
|
||||
plugin: 'WooCommerce Blocks',
|
||||
} );
|
||||
return onCheckoutValidationBeforeProcessing( ...args );
|
||||
};
|
||||
}, [ onCheckoutValidationBeforeProcessing ] );
|
||||
|
||||
const dispatchActions = useMemo(
|
||||
(): CheckoutStateDispatchActions => ( {
|
||||
resetCheckout: () => void dispatch( actions.setPristine() ),
|
||||
setRedirectUrl: ( url ) =>
|
||||
void dispatch( actions.setRedirectUrl( url ) ),
|
||||
setHasError: ( hasError ) =>
|
||||
void dispatch( actions.setHasError( hasError ) ),
|
||||
incrementCalculating: () =>
|
||||
void dispatch( actions.incrementCalculating() ),
|
||||
decrementCalculating: () =>
|
||||
void dispatch( actions.decrementCalculating() ),
|
||||
setCustomerId: ( id ) =>
|
||||
void dispatch( actions.setCustomerId( id ) ),
|
||||
setOrderId: ( orderId ) =>
|
||||
void dispatch( actions.setOrderId( orderId ) ),
|
||||
setOrderNotes: ( orderNotes ) =>
|
||||
void dispatch( actions.setOrderNotes( orderNotes ) ),
|
||||
setExtensionData: ( extensionData ) =>
|
||||
void dispatch( actions.setExtensionData( extensionData ) ),
|
||||
setAfterProcessing: ( response ) => {
|
||||
const paymentResult = getPaymentResultFromCheckoutResponse(
|
||||
response
|
||||
);
|
||||
|
||||
if ( paymentResult.redirectUrl ) {
|
||||
dispatch(
|
||||
actions.setRedirectUrl( paymentResult.redirectUrl )
|
||||
);
|
||||
}
|
||||
dispatch( actions.setProcessingResponse( paymentResult ) );
|
||||
dispatch( actions.setAfterProcessing() );
|
||||
},
|
||||
} ),
|
||||
[]
|
||||
);
|
||||
|
||||
// emit events.
|
||||
useEffect( () => {
|
||||
const status = checkoutState.status;
|
||||
if ( status === STATUS.BEFORE_PROCESSING ) {
|
||||
removeNotices( 'error' );
|
||||
emitEvent(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.CHECKOUT_VALIDATION_BEFORE_PROCESSING,
|
||||
{}
|
||||
).then( ( response ) => {
|
||||
if ( response !== true ) {
|
||||
if ( Array.isArray( response ) ) {
|
||||
response.forEach(
|
||||
( { errorMessage, validationErrors } ) => {
|
||||
addErrorNotice( errorMessage );
|
||||
setValidationErrors( validationErrors );
|
||||
}
|
||||
);
|
||||
}
|
||||
dispatch( actions.setIdle() );
|
||||
dispatch( actions.setHasError() );
|
||||
} else {
|
||||
dispatch( actions.setProcessing() );
|
||||
}
|
||||
} );
|
||||
}
|
||||
}, [
|
||||
checkoutState.status,
|
||||
setValidationErrors,
|
||||
addErrorNotice,
|
||||
removeNotices,
|
||||
dispatch,
|
||||
] );
|
||||
|
||||
const previousStatus = usePrevious( checkoutState.status );
|
||||
const previousHasError = usePrevious( checkoutState.hasError );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
checkoutState.status === previousStatus &&
|
||||
checkoutState.hasError === previousHasError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleErrorResponse = ( observerResponses: unknown[] ) => {
|
||||
let errorResponse = null;
|
||||
observerResponses.forEach( ( response ) => {
|
||||
if (
|
||||
isErrorResponse( response ) ||
|
||||
isFailResponse( response )
|
||||
) {
|
||||
if ( response.message ) {
|
||||
const errorOptions = response.messageContext
|
||||
? { context: response.messageContext }
|
||||
: undefined;
|
||||
errorResponse = response;
|
||||
addErrorNotice( response.message, errorOptions );
|
||||
}
|
||||
}
|
||||
} );
|
||||
return errorResponse;
|
||||
};
|
||||
|
||||
if ( checkoutState.status === STATUS.AFTER_PROCESSING ) {
|
||||
const data = {
|
||||
redirectUrl: checkoutState.redirectUrl,
|
||||
orderId: checkoutState.orderId,
|
||||
customerId: checkoutState.customerId,
|
||||
orderNotes: checkoutState.orderNotes,
|
||||
processingResponse: checkoutState.processingResponse,
|
||||
};
|
||||
if ( checkoutState.hasError ) {
|
||||
// allow payment methods or other things to customize the error
|
||||
// with a fallback if nothing customizes it.
|
||||
emitEventWithAbort(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_ERROR,
|
||||
data
|
||||
).then( ( observerResponses ) => {
|
||||
const errorResponse = handleErrorResponse(
|
||||
observerResponses
|
||||
);
|
||||
if ( errorResponse !== null ) {
|
||||
// irrecoverable error so set complete
|
||||
if ( ! shouldRetry( errorResponse ) ) {
|
||||
dispatch( actions.setComplete( errorResponse ) );
|
||||
} else {
|
||||
dispatch( actions.setIdle() );
|
||||
}
|
||||
} else {
|
||||
const hasErrorNotices =
|
||||
checkoutNotices.some(
|
||||
( notice: { status: string } ) =>
|
||||
notice.status === 'error'
|
||||
) ||
|
||||
expressPaymentNotices.some(
|
||||
( notice: { status: string } ) =>
|
||||
notice.status === 'error'
|
||||
) ||
|
||||
paymentNotices.some(
|
||||
( notice: { status: string } ) =>
|
||||
notice.status === 'error'
|
||||
);
|
||||
if ( ! hasErrorNotices ) {
|
||||
// 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 to get assistance.',
|
||||
'woo-gutenberg-products-block'
|
||||
);
|
||||
addErrorNotice( message, {
|
||||
id: 'checkout',
|
||||
} );
|
||||
}
|
||||
|
||||
dispatch( actions.setIdle() );
|
||||
}
|
||||
} );
|
||||
} else {
|
||||
emitEventWithAbort(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS,
|
||||
data
|
||||
).then( ( observerResponses: unknown[] ) => {
|
||||
let successResponse = null as null | Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
let errorResponse = null as null | Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
observerResponses.forEach( ( response ) => {
|
||||
if ( isSuccessResponse( response ) ) {
|
||||
// the last observer response always "wins" for success.
|
||||
successResponse = response;
|
||||
}
|
||||
if (
|
||||
isErrorResponse( response ) ||
|
||||
isFailResponse( response )
|
||||
) {
|
||||
errorResponse = response;
|
||||
}
|
||||
} );
|
||||
|
||||
if ( successResponse && ! errorResponse ) {
|
||||
dispatch( actions.setComplete( successResponse ) );
|
||||
} else if ( isObject( errorResponse ) ) {
|
||||
if ( errorResponse.message ) {
|
||||
const errorOptions = errorResponse.messageContext
|
||||
? { context: errorResponse.messageContext }
|
||||
: undefined;
|
||||
addErrorNotice(
|
||||
errorResponse.message,
|
||||
errorOptions
|
||||
);
|
||||
}
|
||||
if ( ! shouldRetry( errorResponse ) ) {
|
||||
dispatch( actions.setComplete( errorResponse ) );
|
||||
} else {
|
||||
// this will set an error which will end up
|
||||
// triggering the onCheckoutAfterProcessingWithError emitter.
|
||||
// and then setting checkout to IDLE state.
|
||||
dispatch( actions.setHasError( true ) );
|
||||
}
|
||||
} else {
|
||||
// nothing hooked in had any response type so let's just
|
||||
// consider successful
|
||||
dispatch( actions.setComplete() );
|
||||
}
|
||||
} );
|
||||
}
|
||||
}
|
||||
}, [
|
||||
checkoutState.status,
|
||||
checkoutState.hasError,
|
||||
checkoutState.redirectUrl,
|
||||
checkoutState.orderId,
|
||||
checkoutState.customerId,
|
||||
checkoutState.orderNotes,
|
||||
checkoutState.processingResponse,
|
||||
previousStatus,
|
||||
previousHasError,
|
||||
dispatchActions,
|
||||
addErrorNotice,
|
||||
isErrorResponse,
|
||||
isFailResponse,
|
||||
isSuccessResponse,
|
||||
shouldRetry,
|
||||
checkoutNotices,
|
||||
expressPaymentNotices,
|
||||
paymentNotices,
|
||||
] );
|
||||
|
||||
const onSubmit = useCallback( () => {
|
||||
dispatchCheckoutEvent( 'submit' );
|
||||
dispatch( actions.setBeforeProcessing() );
|
||||
}, [ dispatchCheckoutEvent ] );
|
||||
|
||||
const checkoutData: CheckoutStateContextType = {
|
||||
onSubmit,
|
||||
isComplete: checkoutState.status === STATUS.COMPLETE,
|
||||
isIdle: checkoutState.status === STATUS.IDLE,
|
||||
isCalculating,
|
||||
isProcessing: checkoutState.status === STATUS.PROCESSING,
|
||||
isBeforeProcessing: checkoutState.status === STATUS.BEFORE_PROCESSING,
|
||||
isAfterProcessing: checkoutState.status === STATUS.AFTER_PROCESSING,
|
||||
hasError: checkoutState.hasError,
|
||||
redirectUrl: checkoutState.redirectUrl,
|
||||
onCheckoutBeforeProcessing,
|
||||
onCheckoutValidationBeforeProcessing,
|
||||
onCheckoutAfterProcessingWithSuccess,
|
||||
onCheckoutAfterProcessingWithError,
|
||||
dispatchActions,
|
||||
isCart,
|
||||
orderId: checkoutState.orderId,
|
||||
hasOrder: !! checkoutState.orderId,
|
||||
customerId: checkoutState.customerId,
|
||||
orderNotes: checkoutState.orderNotes,
|
||||
shouldCreateAccount: checkoutState.shouldCreateAccount,
|
||||
setShouldCreateAccount: ( value ) =>
|
||||
dispatch( actions.setShouldCreateAccount( value ) ),
|
||||
extensionData: checkoutState.extensionData,
|
||||
};
|
||||
return (
|
||||
<CheckoutContext.Provider value={ checkoutData }>
|
||||
{ children }
|
||||
</CheckoutContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { DEFAULT_STATE, STATUS } from './constants';
|
||||
import { ActionType, ACTION } from './actions';
|
||||
import type { CheckoutStateContextState, PaymentResultDataType } from './types';
|
||||
|
||||
/**
|
||||
* Reducer for the checkout state
|
||||
*/
|
||||
export const reducer = (
|
||||
state = DEFAULT_STATE,
|
||||
{
|
||||
redirectUrl,
|
||||
type,
|
||||
customerId,
|
||||
orderId,
|
||||
orderNotes,
|
||||
extensionData,
|
||||
shouldCreateAccount,
|
||||
data,
|
||||
}: ActionType
|
||||
): CheckoutStateContextState => {
|
||||
let newState = state;
|
||||
switch ( type ) {
|
||||
case ACTION.SET_PRISTINE:
|
||||
newState = DEFAULT_STATE;
|
||||
break;
|
||||
case ACTION.SET_IDLE:
|
||||
newState =
|
||||
state.status !== STATUS.IDLE
|
||||
? {
|
||||
...state,
|
||||
status: STATUS.IDLE,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case ACTION.SET_REDIRECT_URL:
|
||||
newState =
|
||||
redirectUrl !== undefined && redirectUrl !== state.redirectUrl
|
||||
? {
|
||||
...state,
|
||||
redirectUrl,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case ACTION.SET_PROCESSING_RESPONSE:
|
||||
newState = {
|
||||
...state,
|
||||
processingResponse: data as PaymentResultDataType,
|
||||
};
|
||||
break;
|
||||
|
||||
case ACTION.SET_COMPLETE:
|
||||
newState =
|
||||
state.status !== STATUS.COMPLETE
|
||||
? {
|
||||
...state,
|
||||
status: STATUS.COMPLETE,
|
||||
// @todo Investigate why redirectURL could be non-truthy and whether this would cause a bug if multiple gateways were used for payment e.g. 1st set the redirect URL but failed, and then the 2nd did not provide a redirect URL and succeeded.
|
||||
redirectUrl:
|
||||
data !== undefined &&
|
||||
typeof data.redirectUrl === 'string' &&
|
||||
data.redirectUrl
|
||||
? data.redirectUrl
|
||||
: state.redirectUrl,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case ACTION.SET_PROCESSING:
|
||||
newState =
|
||||
state.status !== STATUS.PROCESSING
|
||||
? {
|
||||
...state,
|
||||
status: STATUS.PROCESSING,
|
||||
hasError: false,
|
||||
}
|
||||
: state;
|
||||
// clear any error state.
|
||||
newState =
|
||||
newState.hasError === false
|
||||
? newState
|
||||
: { ...newState, hasError: false };
|
||||
break;
|
||||
case ACTION.SET_BEFORE_PROCESSING:
|
||||
newState =
|
||||
state.status !== STATUS.BEFORE_PROCESSING
|
||||
? {
|
||||
...state,
|
||||
status: STATUS.BEFORE_PROCESSING,
|
||||
hasError: false,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case ACTION.SET_AFTER_PROCESSING:
|
||||
newState =
|
||||
state.status !== STATUS.AFTER_PROCESSING
|
||||
? {
|
||||
...state,
|
||||
status: STATUS.AFTER_PROCESSING,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case ACTION.SET_HAS_ERROR:
|
||||
newState = state.hasError
|
||||
? state
|
||||
: {
|
||||
...state,
|
||||
hasError: true,
|
||||
};
|
||||
newState =
|
||||
state.status === STATUS.PROCESSING ||
|
||||
state.status === STATUS.BEFORE_PROCESSING
|
||||
? {
|
||||
...newState,
|
||||
status: STATUS.IDLE,
|
||||
}
|
||||
: newState;
|
||||
break;
|
||||
case ACTION.SET_NO_ERROR:
|
||||
newState = state.hasError
|
||||
? {
|
||||
...state,
|
||||
hasError: false,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case ACTION.INCREMENT_CALCULATING:
|
||||
newState = {
|
||||
...state,
|
||||
calculatingCount: state.calculatingCount + 1,
|
||||
};
|
||||
break;
|
||||
case ACTION.DECREMENT_CALCULATING:
|
||||
newState = {
|
||||
...state,
|
||||
calculatingCount: Math.max( 0, state.calculatingCount - 1 ),
|
||||
};
|
||||
break;
|
||||
case ACTION.SET_CUSTOMER_ID:
|
||||
newState =
|
||||
customerId !== undefined
|
||||
? {
|
||||
...state,
|
||||
customerId,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case ACTION.SET_ORDER_ID:
|
||||
newState =
|
||||
orderId !== undefined
|
||||
? {
|
||||
...state,
|
||||
orderId,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case ACTION.SET_SHOULD_CREATE_ACCOUNT:
|
||||
if (
|
||||
shouldCreateAccount !== undefined &&
|
||||
shouldCreateAccount !== state.shouldCreateAccount
|
||||
) {
|
||||
newState = {
|
||||
...state,
|
||||
shouldCreateAccount,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case ACTION.SET_ORDER_NOTES:
|
||||
if ( orderNotes !== undefined && state.orderNotes !== orderNotes ) {
|
||||
newState = {
|
||||
...state,
|
||||
orderNotes,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case ACTION.SET_EXTENSION_DATA:
|
||||
if (
|
||||
extensionData !== undefined &&
|
||||
state.extensionData !== extensionData
|
||||
) {
|
||||
newState = {
|
||||
...state,
|
||||
extensionData,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
// automatically update state to idle from pristine as soon as it
|
||||
// initially changes.
|
||||
if (
|
||||
newState !== state &&
|
||||
type !== ACTION.SET_PRISTINE &&
|
||||
newState.status === STATUS.PRISTINE
|
||||
) {
|
||||
newState.status = STATUS.IDLE;
|
||||
}
|
||||
return newState;
|
||||
};
|
@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { STATUS } from './constants';
|
||||
import type { emitterCallback } from '../../../event-emit';
|
||||
|
||||
export interface CheckoutResponseError {
|
||||
code: string;
|
||||
message: string;
|
||||
data: {
|
||||
status: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CheckoutResponseSuccess {
|
||||
// eslint-disable-next-line camelcase
|
||||
payment_result: {
|
||||
// eslint-disable-next-line camelcase
|
||||
payment_status: 'success' | 'failure' | 'pending' | 'error';
|
||||
// eslint-disable-next-line camelcase
|
||||
payment_details: Record< string, string > | Record< string, never >;
|
||||
// eslint-disable-next-line camelcase
|
||||
redirect_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type CheckoutResponse = CheckoutResponseSuccess | CheckoutResponseError;
|
||||
|
||||
export interface PaymentResultDataType {
|
||||
message: string;
|
||||
paymentStatus: string;
|
||||
paymentDetails: Record< string, string > | Record< string, never >;
|
||||
redirectUrl: string;
|
||||
}
|
||||
|
||||
type extensionDataNamespace = string;
|
||||
type extensionDataItem = Record< string, unknown >;
|
||||
export type extensionData = Record< extensionDataNamespace, extensionDataItem >;
|
||||
|
||||
export interface CheckoutStateContextState {
|
||||
redirectUrl: string;
|
||||
status: STATUS;
|
||||
hasError: boolean;
|
||||
calculatingCount: number;
|
||||
orderId: number;
|
||||
orderNotes: string;
|
||||
customerId: number;
|
||||
shouldCreateAccount: boolean;
|
||||
processingResponse: PaymentResultDataType | null;
|
||||
extensionData: extensionData;
|
||||
}
|
||||
|
||||
export type CheckoutStateDispatchActions = {
|
||||
resetCheckout: () => void;
|
||||
setRedirectUrl: ( url: string ) => void;
|
||||
setHasError: ( hasError: boolean ) => void;
|
||||
setAfterProcessing: ( response: CheckoutResponse ) => void;
|
||||
incrementCalculating: () => void;
|
||||
decrementCalculating: () => void;
|
||||
setCustomerId: ( id: number ) => void;
|
||||
setOrderId: ( id: number ) => void;
|
||||
setOrderNotes: ( orderNotes: string ) => void;
|
||||
setExtensionData: ( extensionData: extensionData ) => void;
|
||||
};
|
||||
|
||||
export type CheckoutStateContextType = {
|
||||
// Dispatch actions to the checkout provider.
|
||||
dispatchActions: CheckoutStateDispatchActions;
|
||||
// Submits the checkout and begins processing.
|
||||
onSubmit: () => void;
|
||||
// True when checkout is complete and ready for redirect.
|
||||
isComplete: boolean;
|
||||
// True when the checkout state has changed and checkout has no activity.
|
||||
isIdle: boolean;
|
||||
// True when something in the checkout is resulting in totals being calculated.
|
||||
isCalculating: boolean;
|
||||
// True when checkout has been submitted and is being processed. Note, payment related processing happens during this state. When payment status is success, processing happens on the server.
|
||||
isProcessing: boolean;
|
||||
// True during any observers executing logic before checkout processing (eg. validation).
|
||||
isBeforeProcessing: boolean;
|
||||
// True when checkout status is AFTER_PROCESSING.
|
||||
isAfterProcessing: boolean;
|
||||
// Used to register a callback that will fire after checkout has been processed and there are no errors.
|
||||
onCheckoutAfterProcessingWithSuccess: ReturnType< typeof emitterCallback >;
|
||||
// Used to register a callback that will fire when the checkout has been processed and has an error.
|
||||
onCheckoutAfterProcessingWithError: ReturnType< typeof emitterCallback >;
|
||||
// Deprecated in favour of onCheckoutValidationBeforeProcessing.
|
||||
onCheckoutBeforeProcessing: ReturnType< typeof emitterCallback >;
|
||||
// Used to register a callback that will fire when the checkout has been submitted before being sent off to the server.
|
||||
onCheckoutValidationBeforeProcessing: ReturnType< typeof emitterCallback >;
|
||||
// Set if user account should be created.
|
||||
setShouldCreateAccount: ( shouldCreateAccount: boolean ) => void;
|
||||
// True when the checkout has a draft order from the API.
|
||||
hasOrder: boolean;
|
||||
// When true, means the provider is providing data for the cart.
|
||||
isCart: boolean;
|
||||
// True when the checkout is in an error state. Whatever caused the error (validation/payment method) will likely have triggered a notice.
|
||||
hasError: CheckoutStateContextState[ 'hasError' ];
|
||||
// This is the url that checkout will redirect to when it's ready.
|
||||
redirectUrl: CheckoutStateContextState[ 'redirectUrl' ];
|
||||
// This is the ID for the draft order if one exists.
|
||||
orderId: CheckoutStateContextState[ 'orderId' ];
|
||||
// Order notes introduced by the user in the checkout form.
|
||||
orderNotes: CheckoutStateContextState[ 'orderNotes' ];
|
||||
// This is the ID of the customer the draft order belongs to.
|
||||
customerId: CheckoutStateContextState[ 'customerId' ];
|
||||
// Should a user account be created?
|
||||
shouldCreateAccount: CheckoutStateContextState[ 'shouldCreateAccount' ];
|
||||
// Custom checkout data passed to the store API on processing.
|
||||
extensionData: CheckoutStateContextState[ 'extensionData' ];
|
||||
};
|
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { PaymentResultDataType, CheckoutResponse } from './types';
|
||||
|
||||
/**
|
||||
* Prepares the payment_result data from the server checkout endpoint response.
|
||||
*/
|
||||
export const getPaymentResultFromCheckoutResponse = (
|
||||
response: CheckoutResponse
|
||||
): PaymentResultDataType => {
|
||||
const paymentResult = {
|
||||
message: '',
|
||||
paymentStatus: '',
|
||||
redirectUrl: '',
|
||||
paymentDetails: {},
|
||||
} as PaymentResultDataType;
|
||||
|
||||
// payment_result is present in successful responses.
|
||||
if ( 'payment_result' in response ) {
|
||||
paymentResult.paymentStatus = response.payment_result.payment_status;
|
||||
paymentResult.redirectUrl = response.payment_result.redirect_url;
|
||||
|
||||
if (
|
||||
response.payment_result.hasOwnProperty( 'payment_details' ) &&
|
||||
Array.isArray( response.payment_result.payment_details )
|
||||
) {
|
||||
response.payment_result.payment_details.forEach(
|
||||
( { key, value }: { key: string; value: string } ) => {
|
||||
paymentResult.paymentDetails[ key ] = decodeEntities(
|
||||
value
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// message is present in error responses.
|
||||
if ( 'message' in response ) {
|
||||
paymentResult.message = decodeEntities( response.message );
|
||||
}
|
||||
|
||||
// If there was an error code but no message, set a default message.
|
||||
if (
|
||||
! paymentResult.message &&
|
||||
'data' in response &&
|
||||
'status' in response.data &&
|
||||
response.data.status > 299
|
||||
) {
|
||||
paymentResult.message = __(
|
||||
'Something went wrong. Please contact us to get assistance.',
|
||||
'woo-gutenberg-products-block'
|
||||
);
|
||||
}
|
||||
|
||||
return paymentResult;
|
||||
};
|
@ -0,0 +1,122 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createContext, useContext, useState } from '@wordpress/element';
|
||||
import { defaultAddressFields } from '@woocommerce/settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCustomerData } from '../../../hooks/use-customer-data';
|
||||
import { useCheckoutContext } from '../checkout-state';
|
||||
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').CustomerDataContext} CustomerDataContext
|
||||
* @typedef {import('@woocommerce/type-defs/billing').BillingData} BillingData
|
||||
* @typedef {import('@woocommerce/type-defs/shipping').ShippingAddress} ShippingAddress
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {BillingData}
|
||||
*/
|
||||
const defaultBillingData = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {ShippingAddress}
|
||||
*/
|
||||
export const defaultShippingAddress = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
phone: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates CustomerDataContext
|
||||
*/
|
||||
const CustomerDataContext = createContext( {
|
||||
billingData: defaultBillingData,
|
||||
shippingAddress: defaultShippingAddress,
|
||||
setBillingData: () => null,
|
||||
setShippingAddress: () => null,
|
||||
shippingAsBilling: true,
|
||||
setShippingAsBilling: () => null,
|
||||
} );
|
||||
|
||||
/**
|
||||
* @return {CustomerDataContext} Returns data and functions related to customer billing and shipping addresses.
|
||||
*/
|
||||
export const useCustomerDataContext = () => {
|
||||
return useContext( CustomerDataContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare two addresses and see if they are the same.
|
||||
*
|
||||
* @param {Object} address1 First address.
|
||||
* @param {Object} address2 Second address.
|
||||
*/
|
||||
const isSameAddress = ( address1, address2 ) => {
|
||||
return Object.keys( defaultAddressFields ).every(
|
||||
( field ) => address1[ field ] === address2[ field ]
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Customer Data context provider.
|
||||
*
|
||||
* @param {Object} props Incoming props for the provider.
|
||||
* @param {Object} props.children The children being wrapped.
|
||||
*/
|
||||
export const CustomerDataProvider = ( { children } ) => {
|
||||
const {
|
||||
billingData,
|
||||
shippingAddress,
|
||||
setBillingData,
|
||||
setShippingAddress,
|
||||
} = useCustomerData();
|
||||
const { cartNeedsShipping: needsShipping } = useStoreCart();
|
||||
const { customerId } = useCheckoutContext();
|
||||
const [ shippingAsBilling, setShippingAsBilling ] = useState(
|
||||
() =>
|
||||
needsShipping &&
|
||||
( ! customerId || isSameAddress( shippingAddress, billingData ) )
|
||||
);
|
||||
|
||||
/**
|
||||
* @type {CustomerDataContext}
|
||||
*/
|
||||
const contextValue = {
|
||||
billingData,
|
||||
shippingAddress,
|
||||
setBillingData,
|
||||
setShippingAddress,
|
||||
shippingAsBilling,
|
||||
setShippingAsBilling,
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomerDataContext.Provider value={ contextValue }>
|
||||
{ children }
|
||||
</CustomerDataContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
export * from './payment-methods';
|
||||
export * from './shipping';
|
||||
export * from './customer';
|
||||
export * from './checkout-state';
|
||||
export * from './cart';
|
||||
export * from './checkout-processor';
|
||||
export * from './checkout-provider';
|
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
PaymentMethods,
|
||||
ExpressPaymentMethods,
|
||||
} from '@woocommerce/type-defs/payments';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION, STATUS } from './constants';
|
||||
|
||||
export interface ActionType {
|
||||
type: ACTION | STATUS;
|
||||
errorMessage?: string;
|
||||
paymentMethodData?: Record< string, unknown >;
|
||||
paymentMethods?: PaymentMethods | ExpressPaymentMethods;
|
||||
shouldSavePaymentMethod?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* All the actions that can be dispatched for payment methods.
|
||||
*/
|
||||
export const actions = {
|
||||
statusOnly: ( type: STATUS ): { type: STATUS } => ( { type } as const ),
|
||||
error: ( errorMessage: string ): ActionType =>
|
||||
( {
|
||||
type: STATUS.ERROR,
|
||||
errorMessage,
|
||||
} as const ),
|
||||
failed: ( {
|
||||
errorMessage,
|
||||
paymentMethodData,
|
||||
}: {
|
||||
errorMessage: string;
|
||||
paymentMethodData: Record< string, unknown >;
|
||||
} ): ActionType =>
|
||||
( {
|
||||
type: STATUS.FAILED,
|
||||
errorMessage,
|
||||
paymentMethodData,
|
||||
} as const ),
|
||||
success: ( {
|
||||
paymentMethodData,
|
||||
}: {
|
||||
paymentMethodData?: Record< string, unknown >;
|
||||
} ): ActionType =>
|
||||
( {
|
||||
type: STATUS.SUCCESS,
|
||||
paymentMethodData,
|
||||
} as const ),
|
||||
started: ( {
|
||||
paymentMethodData,
|
||||
}: {
|
||||
paymentMethodData?: Record< string, unknown >;
|
||||
} ): ActionType =>
|
||||
( {
|
||||
type: STATUS.STARTED,
|
||||
paymentMethodData,
|
||||
} as const ),
|
||||
setRegisteredPaymentMethods: (
|
||||
paymentMethods: PaymentMethods
|
||||
): ActionType =>
|
||||
( {
|
||||
type: ACTION.SET_REGISTERED_PAYMENT_METHODS,
|
||||
paymentMethods,
|
||||
} as const ),
|
||||
setRegisteredExpressPaymentMethods: (
|
||||
paymentMethods: ExpressPaymentMethods
|
||||
): ActionType =>
|
||||
( {
|
||||
type: ACTION.SET_REGISTERED_EXPRESS_PAYMENT_METHODS,
|
||||
paymentMethods,
|
||||
} as const ),
|
||||
setShouldSavePaymentMethod: (
|
||||
shouldSavePaymentMethod: boolean
|
||||
): ActionType =>
|
||||
( {
|
||||
type: ACTION.SET_SHOULD_SAVE_PAYMENT_METHOD,
|
||||
shouldSavePaymentMethod,
|
||||
} as const ),
|
||||
};
|
||||
|
||||
export default actions;
|
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type {
|
||||
PaymentMethodDataContextType,
|
||||
PaymentMethodDataContextState,
|
||||
} from './types';
|
||||
|
||||
export enum STATUS {
|
||||
PRISTINE = 'pristine',
|
||||
STARTED = 'started',
|
||||
PROCESSING = 'processing',
|
||||
ERROR = 'has_error',
|
||||
FAILED = 'failed',
|
||||
SUCCESS = 'success',
|
||||
COMPLETE = 'complete',
|
||||
}
|
||||
|
||||
export enum ACTION {
|
||||
SET_REGISTERED_PAYMENT_METHODS = 'set_registered_payment_methods',
|
||||
SET_REGISTERED_EXPRESS_PAYMENT_METHODS = 'set_registered_express_payment_methods',
|
||||
SET_SHOULD_SAVE_PAYMENT_METHOD = 'set_should_save_payment_method',
|
||||
}
|
||||
|
||||
// Note - if fields are added/shape is changed, you may want to update PRISTINE reducer clause to preserve your new field.
|
||||
export const DEFAULT_PAYMENT_DATA_CONTEXT_STATE: PaymentMethodDataContextState = {
|
||||
currentStatus: STATUS.PRISTINE,
|
||||
shouldSavePaymentMethod: false,
|
||||
paymentMethodData: {
|
||||
payment_method: '',
|
||||
},
|
||||
hasSavedToken: false,
|
||||
errorMessage: '',
|
||||
paymentMethods: {},
|
||||
expressPaymentMethods: {},
|
||||
};
|
||||
|
||||
export const DEFAULT_PAYMENT_METHOD_DATA: PaymentMethodDataContextType = {
|
||||
setPaymentStatus: () => ( {
|
||||
pristine: () => void null,
|
||||
started: () => void null,
|
||||
processing: () => void null,
|
||||
completed: () => void null,
|
||||
error: ( errorMessage: string ) => void errorMessage,
|
||||
failed: ( errorMessage, paymentMethodData ) =>
|
||||
void [ errorMessage, paymentMethodData ],
|
||||
success: ( paymentMethodData, billingData ) =>
|
||||
void [ paymentMethodData, billingData ],
|
||||
} ),
|
||||
currentStatus: {
|
||||
isPristine: true,
|
||||
isStarted: false,
|
||||
isProcessing: false,
|
||||
isFinished: false,
|
||||
hasError: false,
|
||||
hasFailed: false,
|
||||
isSuccessful: false,
|
||||
isDoingExpressPayment: false,
|
||||
},
|
||||
paymentStatuses: STATUS,
|
||||
paymentMethodData: {},
|
||||
errorMessage: '',
|
||||
activePaymentMethod: '',
|
||||
setActivePaymentMethod: () => void null,
|
||||
activeSavedToken: '',
|
||||
setActiveSavedToken: () => void null,
|
||||
customerPaymentMethods: {},
|
||||
paymentMethods: {},
|
||||
expressPaymentMethods: {},
|
||||
paymentMethodsInitialized: false,
|
||||
expressPaymentMethodsInitialized: false,
|
||||
onPaymentProcessing: () => () => () => void null,
|
||||
setExpressPaymentError: () => void null,
|
||||
isExpressPaymentMethodActive: false,
|
||||
setShouldSavePayment: () => void null,
|
||||
shouldSavePayment: false,
|
||||
};
|
@ -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_PROCESSING: 'payment_processing',
|
||||
};
|
||||
|
||||
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(
|
||||
() => ( {
|
||||
onPaymentProcessing: emitterCallback(
|
||||
EMIT_TYPES.PAYMENT_PROCESSING,
|
||||
observerDispatch
|
||||
),
|
||||
} ),
|
||||
[ observerDispatch ]
|
||||
);
|
||||
return eventEmitters;
|
||||
};
|
||||
|
||||
export { EMIT_TYPES, useEventEmitters, reducer, emitEvent, emitEventWithAbort };
|
@ -0,0 +1,4 @@
|
||||
export {
|
||||
PaymentMethodDataProvider,
|
||||
usePaymentMethodDataContext,
|
||||
} from './payment-method-data-context';
|
@ -0,0 +1,355 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type {
|
||||
CustomerPaymentMethods,
|
||||
PaymentMethodDataContextType,
|
||||
} from './types';
|
||||
import {
|
||||
STATUS,
|
||||
DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
|
||||
DEFAULT_PAYMENT_METHOD_DATA,
|
||||
} from './constants';
|
||||
import reducer from './reducer';
|
||||
import {
|
||||
usePaymentMethods,
|
||||
useExpressPaymentMethods,
|
||||
} from './use-payment-method-registration';
|
||||
import { usePaymentMethodDataDispatchers } from './use-payment-method-dispatchers';
|
||||
import { useActivePaymentMethod } from './use-active-payment-method';
|
||||
import { useCheckoutContext } from '../checkout-state';
|
||||
import { useEditorContext } from '../../editor-context';
|
||||
import {
|
||||
EMIT_TYPES,
|
||||
useEventEmitters,
|
||||
emitEventWithAbort,
|
||||
reducer as emitReducer,
|
||||
} from './event-emit';
|
||||
import { useValidationContext } from '../../validation';
|
||||
import { useStoreNotices } from '../../../hooks/use-store-notices';
|
||||
import { useEmitResponse } from '../../../hooks/use-emit-response';
|
||||
import { getCustomerPaymentMethods } from './utils';
|
||||
|
||||
const PaymentMethodDataContext = createContext( DEFAULT_PAYMENT_METHOD_DATA );
|
||||
|
||||
export const usePaymentMethodDataContext = (): PaymentMethodDataContextType => {
|
||||
return useContext( PaymentMethodDataContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* PaymentMethodDataProvider is automatically included in the CheckoutDataProvider.
|
||||
*
|
||||
* This provides the api interface (via the context hook) for payment method status and data.
|
||||
*
|
||||
* @param {Object} props Incoming props for provider
|
||||
* @param {Object} props.children The wrapped components in this provider.
|
||||
*/
|
||||
export const PaymentMethodDataProvider = ( {
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactChildren;
|
||||
} ): JSX.Element => {
|
||||
const {
|
||||
isProcessing: checkoutIsProcessing,
|
||||
isIdle: checkoutIsIdle,
|
||||
isCalculating: checkoutIsCalculating,
|
||||
hasError: checkoutHasError,
|
||||
} = useCheckoutContext();
|
||||
const { isEditor, getPreviewData } = useEditorContext();
|
||||
const { setValidationErrors } = useValidationContext();
|
||||
const { addErrorNotice, removeNotice } = useStoreNotices();
|
||||
const {
|
||||
isSuccessResponse,
|
||||
isErrorResponse,
|
||||
isFailResponse,
|
||||
noticeContexts,
|
||||
} = useEmitResponse();
|
||||
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
|
||||
const { onPaymentProcessing } = useEventEmitters( observerDispatch );
|
||||
const currentObservers = useRef( observers );
|
||||
|
||||
// ensure observers are always current.
|
||||
useEffect( () => {
|
||||
currentObservers.current = observers;
|
||||
}, [ observers ] );
|
||||
|
||||
const [ paymentData, dispatch ] = useReducer(
|
||||
reducer,
|
||||
DEFAULT_PAYMENT_DATA_CONTEXT_STATE
|
||||
);
|
||||
const {
|
||||
dispatchActions,
|
||||
setPaymentStatus,
|
||||
} = usePaymentMethodDataDispatchers( dispatch );
|
||||
|
||||
const paymentMethodsInitialized = usePaymentMethods(
|
||||
dispatchActions.setRegisteredPaymentMethods
|
||||
);
|
||||
|
||||
const expressPaymentMethodsInitialized = useExpressPaymentMethods(
|
||||
dispatchActions.setRegisteredExpressPaymentMethods
|
||||
);
|
||||
|
||||
const {
|
||||
activePaymentMethod,
|
||||
activeSavedToken,
|
||||
setActivePaymentMethod,
|
||||
setActiveSavedToken,
|
||||
} = useActivePaymentMethod();
|
||||
|
||||
const customerPaymentMethods = useMemo( (): CustomerPaymentMethods => {
|
||||
if ( isEditor ) {
|
||||
return getPreviewData(
|
||||
'previewSavedPaymentMethods'
|
||||
) as CustomerPaymentMethods;
|
||||
}
|
||||
return paymentMethodsInitialized
|
||||
? getCustomerPaymentMethods( paymentData.paymentMethods )
|
||||
: {};
|
||||
}, [
|
||||
isEditor,
|
||||
getPreviewData,
|
||||
paymentMethodsInitialized,
|
||||
paymentData.paymentMethods,
|
||||
] );
|
||||
|
||||
const setExpressPaymentError = useCallback(
|
||||
( message ) => {
|
||||
if ( message ) {
|
||||
addErrorNotice( message, {
|
||||
id: 'wc-express-payment-error',
|
||||
context: noticeContexts.EXPRESS_PAYMENTS,
|
||||
} );
|
||||
} else {
|
||||
removeNotice(
|
||||
'wc-express-payment-error',
|
||||
noticeContexts.EXPRESS_PAYMENTS
|
||||
);
|
||||
}
|
||||
},
|
||||
[ addErrorNotice, noticeContexts.EXPRESS_PAYMENTS, removeNotice ]
|
||||
);
|
||||
|
||||
const isExpressPaymentMethodActive = Object.keys(
|
||||
paymentData.expressPaymentMethods
|
||||
).includes( activePaymentMethod );
|
||||
|
||||
const currentStatus = useMemo(
|
||||
() => ( {
|
||||
isPristine: paymentData.currentStatus === STATUS.PRISTINE,
|
||||
isStarted: paymentData.currentStatus === STATUS.STARTED,
|
||||
isProcessing: paymentData.currentStatus === STATUS.PROCESSING,
|
||||
isFinished: [
|
||||
STATUS.ERROR,
|
||||
STATUS.FAILED,
|
||||
STATUS.SUCCESS,
|
||||
].includes( paymentData.currentStatus ),
|
||||
hasError: paymentData.currentStatus === STATUS.ERROR,
|
||||
hasFailed: paymentData.currentStatus === STATUS.FAILED,
|
||||
isSuccessful: paymentData.currentStatus === STATUS.SUCCESS,
|
||||
isDoingExpressPayment:
|
||||
paymentData.currentStatus !== STATUS.PRISTINE &&
|
||||
isExpressPaymentMethodActive,
|
||||
} ),
|
||||
[ paymentData.currentStatus, isExpressPaymentMethodActive ]
|
||||
);
|
||||
|
||||
// Update the active (selected) payment method when it is empty, or invalid.
|
||||
useEffect( () => {
|
||||
const paymentMethodKeys = Object.keys( paymentData.paymentMethods );
|
||||
const allPaymentMethodKeys = [
|
||||
...paymentMethodKeys,
|
||||
...Object.keys( paymentData.expressPaymentMethods ),
|
||||
];
|
||||
if ( ! paymentMethodsInitialized || ! paymentMethodKeys.length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActivePaymentMethod( ( currentActivePaymentMethod ) => {
|
||||
// If there's no active payment method, or the active payment method has
|
||||
// been removed (e.g. COD vs shipping methods), set one as active.
|
||||
// Note: It's possible that the active payment method might be an
|
||||
// express payment method. So registered express payment methods are
|
||||
// included in the check here.
|
||||
if (
|
||||
! currentActivePaymentMethod ||
|
||||
! allPaymentMethodKeys.includes( currentActivePaymentMethod )
|
||||
) {
|
||||
setPaymentStatus().pristine();
|
||||
return Object.keys( paymentData.paymentMethods )[ 0 ];
|
||||
}
|
||||
return currentActivePaymentMethod;
|
||||
} );
|
||||
}, [
|
||||
paymentMethodsInitialized,
|
||||
paymentData.paymentMethods,
|
||||
paymentData.expressPaymentMethods,
|
||||
setActivePaymentMethod,
|
||||
setPaymentStatus,
|
||||
] );
|
||||
|
||||
// flip payment to processing if checkout processing is complete, there are no errors, and payment status is started.
|
||||
useEffect( () => {
|
||||
if (
|
||||
checkoutIsProcessing &&
|
||||
! checkoutHasError &&
|
||||
! checkoutIsCalculating &&
|
||||
! currentStatus.isFinished
|
||||
) {
|
||||
setPaymentStatus().processing();
|
||||
}
|
||||
}, [
|
||||
checkoutIsProcessing,
|
||||
checkoutHasError,
|
||||
checkoutIsCalculating,
|
||||
currentStatus.isFinished,
|
||||
setPaymentStatus,
|
||||
] );
|
||||
|
||||
// When checkout is returned to idle, set payment status to pristine but only if payment status is already not finished.
|
||||
useEffect( () => {
|
||||
if ( checkoutIsIdle && ! currentStatus.isSuccessful ) {
|
||||
setPaymentStatus().pristine();
|
||||
}
|
||||
}, [ checkoutIsIdle, currentStatus.isSuccessful, setPaymentStatus ] );
|
||||
|
||||
// if checkout has an error and payment is not being made with a saved token and payment status is success, then let's sync payment status back to pristine.
|
||||
useEffect( () => {
|
||||
if (
|
||||
checkoutHasError &&
|
||||
currentStatus.isSuccessful &&
|
||||
! paymentData.hasSavedToken
|
||||
) {
|
||||
setPaymentStatus().pristine();
|
||||
}
|
||||
}, [
|
||||
checkoutHasError,
|
||||
currentStatus.isSuccessful,
|
||||
paymentData.hasSavedToken,
|
||||
setPaymentStatus,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
// 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).
|
||||
if ( currentStatus.isProcessing ) {
|
||||
removeNotice( 'wc-payment-error', noticeContexts.PAYMENTS );
|
||||
emitEventWithAbort(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.PAYMENT_PROCESSING,
|
||||
{}
|
||||
).then( ( observerResponses ) => {
|
||||
let successResponse, errorResponse;
|
||||
observerResponses.forEach( ( response ) => {
|
||||
if ( isSuccessResponse( response ) ) {
|
||||
// the last observer response always "wins" for success.
|
||||
successResponse = response;
|
||||
}
|
||||
if (
|
||||
isErrorResponse( response ) ||
|
||||
isFailResponse( response )
|
||||
) {
|
||||
errorResponse = response;
|
||||
}
|
||||
} );
|
||||
if ( successResponse && ! errorResponse ) {
|
||||
setPaymentStatus().success(
|
||||
successResponse?.meta?.paymentMethodData,
|
||||
successResponse?.meta?.billingData,
|
||||
successResponse?.meta?.shippingData
|
||||
);
|
||||
} else if ( errorResponse && isFailResponse( errorResponse ) ) {
|
||||
if (
|
||||
errorResponse.message &&
|
||||
errorResponse.message.length
|
||||
) {
|
||||
addErrorNotice( errorResponse.message, {
|
||||
id: 'wc-payment-error',
|
||||
isDismissible: false,
|
||||
context:
|
||||
errorResponse?.messageContext ||
|
||||
noticeContexts.PAYMENTS,
|
||||
} );
|
||||
}
|
||||
setPaymentStatus().failed(
|
||||
errorResponse?.message,
|
||||
errorResponse?.meta?.paymentMethodData,
|
||||
errorResponse?.meta?.billingData
|
||||
);
|
||||
} else if ( errorResponse ) {
|
||||
if (
|
||||
errorResponse.message &&
|
||||
errorResponse.message.length
|
||||
) {
|
||||
addErrorNotice( errorResponse.message, {
|
||||
id: 'wc-payment-error',
|
||||
isDismissible: false,
|
||||
context:
|
||||
errorResponse?.messageContext ||
|
||||
noticeContexts.PAYMENTS,
|
||||
} );
|
||||
}
|
||||
setPaymentStatus().error( errorResponse.message );
|
||||
setValidationErrors( errorResponse?.validationErrors );
|
||||
} else {
|
||||
// otherwise there are no payment methods doing anything so
|
||||
// just consider success
|
||||
setPaymentStatus().success();
|
||||
}
|
||||
} );
|
||||
}
|
||||
}, [
|
||||
currentStatus.isProcessing,
|
||||
setValidationErrors,
|
||||
setPaymentStatus,
|
||||
removeNotice,
|
||||
noticeContexts.PAYMENTS,
|
||||
isSuccessResponse,
|
||||
isFailResponse,
|
||||
isErrorResponse,
|
||||
addErrorNotice,
|
||||
] );
|
||||
|
||||
const paymentContextData: PaymentMethodDataContextType = {
|
||||
setPaymentStatus,
|
||||
currentStatus,
|
||||
paymentStatuses: STATUS,
|
||||
paymentMethodData: paymentData.paymentMethodData,
|
||||
errorMessage: paymentData.errorMessage,
|
||||
activePaymentMethod,
|
||||
setActivePaymentMethod,
|
||||
activeSavedToken,
|
||||
setActiveSavedToken,
|
||||
onPaymentProcessing,
|
||||
customerPaymentMethods,
|
||||
paymentMethods: paymentData.paymentMethods,
|
||||
expressPaymentMethods: paymentData.expressPaymentMethods,
|
||||
paymentMethodsInitialized,
|
||||
expressPaymentMethodsInitialized,
|
||||
setExpressPaymentError,
|
||||
isExpressPaymentMethodActive,
|
||||
shouldSavePayment: paymentData.shouldSavePaymentMethod,
|
||||
setShouldSavePayment: dispatchActions.setShouldSavePayment,
|
||||
};
|
||||
|
||||
return (
|
||||
<PaymentMethodDataContext.Provider value={ paymentContextData }>
|
||||
{ children }
|
||||
</PaymentMethodDataContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
ACTION,
|
||||
STATUS,
|
||||
DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
|
||||
} from './constants';
|
||||
import type { PaymentMethodDataContextState } from './types';
|
||||
import type { ActionType } from './actions';
|
||||
|
||||
const hasSavedPaymentToken = (
|
||||
paymentMethodData: Record< string, unknown > | undefined
|
||||
): boolean => {
|
||||
return !! (
|
||||
typeof paymentMethodData === 'object' && paymentMethodData.isSavedToken
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reducer for payment data state
|
||||
*/
|
||||
const reducer = (
|
||||
state = DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
|
||||
{
|
||||
type,
|
||||
paymentMethodData,
|
||||
shouldSavePaymentMethod = false,
|
||||
errorMessage = '',
|
||||
paymentMethods = {},
|
||||
}: ActionType
|
||||
): PaymentMethodDataContextState => {
|
||||
switch ( type ) {
|
||||
case STATUS.STARTED:
|
||||
return {
|
||||
...state,
|
||||
currentStatus: STATUS.STARTED,
|
||||
paymentMethodData: paymentMethodData || state.paymentMethodData,
|
||||
hasSavedToken: hasSavedPaymentToken(
|
||||
paymentMethodData || state.paymentMethodData
|
||||
),
|
||||
};
|
||||
case STATUS.ERROR:
|
||||
return state.currentStatus !== STATUS.ERROR
|
||||
? {
|
||||
...state,
|
||||
currentStatus: STATUS.ERROR,
|
||||
errorMessage: errorMessage || state.errorMessage,
|
||||
}
|
||||
: state;
|
||||
case STATUS.FAILED:
|
||||
return state.currentStatus !== STATUS.FAILED
|
||||
? {
|
||||
...state,
|
||||
currentStatus: STATUS.FAILED,
|
||||
paymentMethodData:
|
||||
paymentMethodData || state.paymentMethodData,
|
||||
errorMessage: errorMessage || state.errorMessage,
|
||||
}
|
||||
: state;
|
||||
case STATUS.SUCCESS:
|
||||
return state.currentStatus !== STATUS.SUCCESS
|
||||
? {
|
||||
...state,
|
||||
currentStatus: STATUS.SUCCESS,
|
||||
paymentMethodData:
|
||||
paymentMethodData || state.paymentMethodData,
|
||||
hasSavedToken: hasSavedPaymentToken(
|
||||
paymentMethodData || state.paymentMethodData
|
||||
),
|
||||
}
|
||||
: state;
|
||||
case STATUS.PROCESSING:
|
||||
return state.currentStatus !== STATUS.PROCESSING
|
||||
? {
|
||||
...state,
|
||||
currentStatus: STATUS.PROCESSING,
|
||||
errorMessage: '',
|
||||
}
|
||||
: state;
|
||||
case STATUS.COMPLETE:
|
||||
return state.currentStatus !== STATUS.COMPLETE
|
||||
? {
|
||||
...state,
|
||||
currentStatus: STATUS.COMPLETE,
|
||||
}
|
||||
: state;
|
||||
|
||||
case STATUS.PRISTINE:
|
||||
return {
|
||||
...DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
|
||||
currentStatus: STATUS.PRISTINE,
|
||||
// keep payment method registration state
|
||||
paymentMethods: {
|
||||
...state.paymentMethods,
|
||||
},
|
||||
expressPaymentMethods: {
|
||||
...state.expressPaymentMethods,
|
||||
},
|
||||
shouldSavePaymentMethod: state.shouldSavePaymentMethod,
|
||||
};
|
||||
case ACTION.SET_REGISTERED_PAYMENT_METHODS:
|
||||
return {
|
||||
...state,
|
||||
paymentMethods,
|
||||
};
|
||||
case ACTION.SET_REGISTERED_EXPRESS_PAYMENT_METHODS:
|
||||
return {
|
||||
...state,
|
||||
expressPaymentMethods: paymentMethods,
|
||||
};
|
||||
case ACTION.SET_SHOULD_SAVE_PAYMENT_METHOD:
|
||||
return {
|
||||
...state,
|
||||
shouldSavePaymentMethod,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default reducer;
|
@ -0,0 +1,251 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { previewCart } from '@woocommerce/resource-previews';
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import {
|
||||
registerPaymentMethod,
|
||||
registerExpressPaymentMethod,
|
||||
__experimentalDeRegisterPaymentMethod,
|
||||
__experimentalDeRegisterExpressPaymentMethod,
|
||||
} from '@woocommerce/blocks-registry';
|
||||
import { default as fetchMock } from 'jest-fetch-mock';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
usePaymentMethodDataContext,
|
||||
PaymentMethodDataProvider,
|
||||
} from '../payment-method-data-context';
|
||||
import {
|
||||
CheckoutExpressPayment,
|
||||
SavedPaymentMethodOptions,
|
||||
} from '../../../../../../blocks/cart-checkout/payment-methods';
|
||||
import { defaultCartState } from '../../../../../../data/default-states';
|
||||
|
||||
jest.mock( '@woocommerce/settings', () => {
|
||||
const originalModule = jest.requireActual( '@woocommerce/settings' );
|
||||
|
||||
return {
|
||||
// @ts-ignore We know @woocommerce/settings is an object.
|
||||
...originalModule,
|
||||
getSetting: ( setting, ...rest ) => {
|
||||
if ( setting === 'customerPaymentMethods' ) {
|
||||
return {
|
||||
cc: [
|
||||
{
|
||||
method: {
|
||||
gateway: 'stripe',
|
||||
last4: '4242',
|
||||
brand: 'Visa',
|
||||
},
|
||||
expires: '12/22',
|
||||
is_default: true,
|
||||
tokenId: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return originalModule.getSetting( setting, ...rest );
|
||||
},
|
||||
};
|
||||
} );
|
||||
|
||||
const registerMockPaymentMethods = () => {
|
||||
[ 'cheque', 'bacs' ].forEach( ( name ) => {
|
||||
registerPaymentMethod( {
|
||||
name,
|
||||
label: name,
|
||||
content: <div>A payment method</div>,
|
||||
edit: <div>A payment method</div>,
|
||||
icons: null,
|
||||
canMakePayment: () => true,
|
||||
supports: {
|
||||
features: [ 'products' ],
|
||||
},
|
||||
ariaLabel: name,
|
||||
} );
|
||||
} );
|
||||
[ 'stripe' ].forEach( ( name ) => {
|
||||
registerPaymentMethod( {
|
||||
name,
|
||||
label: name,
|
||||
content: <div>A payment method</div>,
|
||||
edit: <div>A payment method</div>,
|
||||
icons: null,
|
||||
canMakePayment: () => true,
|
||||
supports: {
|
||||
showSavedCards: true,
|
||||
showSaveOption: true,
|
||||
features: [ 'products' ],
|
||||
},
|
||||
ariaLabel: name,
|
||||
} );
|
||||
} );
|
||||
[ 'express-payment' ].forEach( ( name ) => {
|
||||
const Content = ( {
|
||||
onClose = () => void null,
|
||||
onClick = () => void null,
|
||||
} ) => {
|
||||
return (
|
||||
<>
|
||||
<button onClick={ onClick }>
|
||||
{ name + ' express payment method' }
|
||||
</button>
|
||||
<button onClick={ onClose }>
|
||||
{ name + ' express payment method close' }
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
registerExpressPaymentMethod( {
|
||||
name,
|
||||
content: <Content />,
|
||||
edit: <div>An express payment method</div>,
|
||||
canMakePayment: () => true,
|
||||
paymentMethodId: name,
|
||||
supports: {
|
||||
features: [ 'products' ],
|
||||
},
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
const resetMockPaymentMethods = () => {
|
||||
[ 'cheque', 'bacs', 'stripe' ].forEach( ( name ) => {
|
||||
__experimentalDeRegisterPaymentMethod( name );
|
||||
} );
|
||||
[ 'express-payment' ].forEach( ( name ) => {
|
||||
__experimentalDeRegisterExpressPaymentMethod( name );
|
||||
} );
|
||||
};
|
||||
|
||||
describe( 'Testing Payment Method Data Context Provider', () => {
|
||||
beforeEach( async () => {
|
||||
registerMockPaymentMethods();
|
||||
fetchMock.mockResponse( ( req ) => {
|
||||
if ( req.url.match( /wc\/store\/cart/ ) ) {
|
||||
return Promise.resolve( JSON.stringify( previewCart ) );
|
||||
}
|
||||
return Promise.resolve( '' );
|
||||
} );
|
||||
// need to clear the store resolution state between tests.
|
||||
await dispatch( storeKey ).invalidateResolutionForStore();
|
||||
await dispatch( storeKey ).receiveCart( defaultCartState.cartData );
|
||||
} );
|
||||
afterEach( async () => {
|
||||
resetMockPaymentMethods();
|
||||
fetchMock.resetMocks();
|
||||
} );
|
||||
it( 'toggles active payment method correctly for express payment activation and close', async () => {
|
||||
const TriggerActiveExpressPaymentMethod = () => {
|
||||
const { activePaymentMethod } = usePaymentMethodDataContext();
|
||||
return (
|
||||
<>
|
||||
<CheckoutExpressPayment />
|
||||
{ 'Active Payment Method: ' + activePaymentMethod }
|
||||
</>
|
||||
);
|
||||
};
|
||||
const TestComponent = () => {
|
||||
return (
|
||||
<PaymentMethodDataProvider>
|
||||
<TriggerActiveExpressPaymentMethod />
|
||||
</PaymentMethodDataProvider>
|
||||
);
|
||||
};
|
||||
render( <TestComponent /> );
|
||||
// should initialize by default the first payment method.
|
||||
await waitFor( () => {
|
||||
const activePaymentMethod = screen.queryByText(
|
||||
/Active Payment Method: cheque/
|
||||
);
|
||||
expect( activePaymentMethod ).not.toBeNull();
|
||||
} );
|
||||
// Express payment method clicked.
|
||||
fireEvent.click(
|
||||
screen.getByText( 'express-payment express payment method' )
|
||||
);
|
||||
await waitFor( () => {
|
||||
const activePaymentMethod = screen.queryByText(
|
||||
/Active Payment Method: express-payment/
|
||||
);
|
||||
expect( activePaymentMethod ).not.toBeNull();
|
||||
} );
|
||||
// Express payment method closed.
|
||||
fireEvent.click(
|
||||
screen.getByText( 'express-payment express payment method close' )
|
||||
);
|
||||
await waitFor( () => {
|
||||
const activePaymentMethod = screen.queryByText(
|
||||
/Active Payment Method: cheque/
|
||||
);
|
||||
expect( activePaymentMethod ).not.toBeNull();
|
||||
} );
|
||||
// ["`select` control in `@wordpress/data-controls` is deprecated. Please use built-in `resolveSelect` control in `@wordpress/data` instead."]
|
||||
expect( console ).toHaveWarned();
|
||||
} );
|
||||
|
||||
it( 'resets saved payment method data after starting and closing an express payment method', async () => {
|
||||
const TriggerActiveExpressPaymentMethod = () => {
|
||||
const {
|
||||
activePaymentMethod,
|
||||
paymentMethodData,
|
||||
} = usePaymentMethodDataContext();
|
||||
return (
|
||||
<>
|
||||
<CheckoutExpressPayment />
|
||||
<SavedPaymentMethodOptions onChange={ () => void null } />
|
||||
{ 'Active Payment Method: ' + activePaymentMethod }
|
||||
{ paymentMethodData[ 'wc-stripe-payment-token' ] && (
|
||||
<span>Stripe token</span>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
const TestComponent = () => {
|
||||
return (
|
||||
<PaymentMethodDataProvider>
|
||||
<TriggerActiveExpressPaymentMethod />
|
||||
</PaymentMethodDataProvider>
|
||||
);
|
||||
};
|
||||
render( <TestComponent /> );
|
||||
// Should initialize by default the default saved payment method.
|
||||
await waitFor( () => {
|
||||
const activePaymentMethod = screen.queryByText(
|
||||
/Active Payment Method: stripe/
|
||||
);
|
||||
const stripeToken = screen.queryByText( /Stripe token/ );
|
||||
expect( activePaymentMethod ).not.toBeNull();
|
||||
expect( stripeToken ).not.toBeNull();
|
||||
} );
|
||||
// Express payment method clicked.
|
||||
fireEvent.click(
|
||||
screen.getByText( 'express-payment express payment method' )
|
||||
);
|
||||
await waitFor( () => {
|
||||
const activePaymentMethod = screen.queryByText(
|
||||
/Active Payment Method: express-payment/
|
||||
);
|
||||
const stripeToken = screen.queryByText( /Stripe token/ );
|
||||
expect( activePaymentMethod ).not.toBeNull();
|
||||
expect( stripeToken ).toBeNull();
|
||||
} );
|
||||
// Express payment method closed.
|
||||
fireEvent.click(
|
||||
screen.getByText( 'express-payment express payment method close' )
|
||||
);
|
||||
await waitFor( () => {
|
||||
const activePaymentMethod = screen.queryByText(
|
||||
/Active Payment Method: stripe/
|
||||
);
|
||||
const stripeToken = screen.queryByText( /Stripe token/ );
|
||||
expect( activePaymentMethod ).not.toBeNull();
|
||||
expect( stripeToken ).not.toBeNull();
|
||||
} );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,130 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
PaymentMethodConfiguration,
|
||||
PaymentMethods,
|
||||
ExpressPaymentMethods,
|
||||
} from '@woocommerce/type-defs/payments';
|
||||
import type {
|
||||
EmptyObjectType,
|
||||
ObjectType,
|
||||
} from '@woocommerce/type-defs/objects';
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { emitterCallback } from '../../../event-emit';
|
||||
import { STATUS } from './constants';
|
||||
|
||||
export interface CustomerPaymentMethod {
|
||||
method: PaymentMethodConfiguration;
|
||||
expires: string;
|
||||
is_default: boolean;
|
||||
tokenId: number;
|
||||
actions: ObjectType;
|
||||
}
|
||||
export type CustomerPaymentMethods =
|
||||
| Record< string, CustomerPaymentMethod >
|
||||
| EmptyObjectType;
|
||||
|
||||
export type PaymentMethodDispatchers = {
|
||||
setRegisteredPaymentMethods: ( paymentMethods: PaymentMethods ) => void;
|
||||
setRegisteredExpressPaymentMethods: (
|
||||
paymentMethods: ExpressPaymentMethods
|
||||
) => void;
|
||||
setShouldSavePayment: ( shouldSave: boolean ) => void;
|
||||
};
|
||||
|
||||
export interface PaymentStatusDispatchers {
|
||||
pristine: () => void;
|
||||
started: ( paymentMethodData?: ObjectType | EmptyObjectType ) => void;
|
||||
processing: () => void;
|
||||
completed: () => void;
|
||||
error: ( error: string ) => void;
|
||||
failed: (
|
||||
error?: string,
|
||||
paymentMethodData?: ObjectType | EmptyObjectType,
|
||||
billingData?: ObjectType | EmptyObjectType
|
||||
) => void;
|
||||
success: (
|
||||
paymentMethodData?: ObjectType | EmptyObjectType,
|
||||
billingData?: ObjectType | EmptyObjectType,
|
||||
shippingData?: ObjectType | EmptyObjectType
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface PaymentMethodDataContextState {
|
||||
currentStatus: STATUS;
|
||||
shouldSavePaymentMethod: boolean;
|
||||
paymentMethodData: ObjectType | EmptyObjectType;
|
||||
hasSavedToken: boolean;
|
||||
errorMessage: string;
|
||||
paymentMethods: PaymentMethods;
|
||||
expressPaymentMethods: ExpressPaymentMethods;
|
||||
}
|
||||
|
||||
export type PaymentMethodCurrentStatusType = {
|
||||
// If true then the payment method state in checkout is pristine.
|
||||
isPristine: boolean;
|
||||
// If true then the payment method has been initialized and has started.
|
||||
isStarted: boolean;
|
||||
// If true then the payment method is processing payment.
|
||||
isProcessing: boolean;
|
||||
// If true then the payment method is in a finished state (which may mean it's status is either error, failed, or success).
|
||||
isFinished: boolean;
|
||||
// If true then the payment method is in an error state.
|
||||
hasError: boolean;
|
||||
// If true then the payment method has failed (usually indicates a problem with the payment method used, not logic error).
|
||||
hasFailed: boolean;
|
||||
// If true then the payment method has completed it's processing successfully.
|
||||
isSuccessful: boolean;
|
||||
// If true, an express payment is in progress.
|
||||
isDoingExpressPayment: boolean;
|
||||
};
|
||||
|
||||
export type PaymentMethodDataContextType = {
|
||||
// Sets the payment status for the payment method.
|
||||
setPaymentStatus: () => PaymentStatusDispatchers;
|
||||
// The current payment status.
|
||||
currentStatus: PaymentMethodCurrentStatusType;
|
||||
// An object of payment status constants.
|
||||
paymentStatuses: ObjectType;
|
||||
// Arbitrary data to be passed along for processing by the payment method on the server.
|
||||
paymentMethodData: ObjectType | EmptyObjectType;
|
||||
// An error message provided by the payment method if there is an error.
|
||||
errorMessage: string;
|
||||
// The active payment method slug.
|
||||
activePaymentMethod: string;
|
||||
// A function for setting the active payment method.
|
||||
setActivePaymentMethod: ( paymentMethod: string ) => void;
|
||||
// Current active token.
|
||||
activeSavedToken: string;
|
||||
// A function for setting the active payment method token.
|
||||
setActiveSavedToken: ( activeSavedToken: string ) => void;
|
||||
// Returns the customer payment for the customer if it exists.
|
||||
customerPaymentMethods:
|
||||
| Record< string, CustomerPaymentMethod >
|
||||
| EmptyObjectType;
|
||||
// Registered payment methods.
|
||||
paymentMethods: PaymentMethods;
|
||||
// Registered express payment methods.
|
||||
expressPaymentMethods: ExpressPaymentMethods;
|
||||
// True when all registered payment methods have been initialized.
|
||||
paymentMethodsInitialized: boolean;
|
||||
// True when all registered express payment methods have been initialized.
|
||||
expressPaymentMethodsInitialized: boolean;
|
||||
// Event registration callback for registering observers for the payment processing event.
|
||||
onPaymentProcessing: ReturnType< typeof emitterCallback >;
|
||||
// A function used by express payment methods to indicate an error for checkout to handle. It receives an error message string. Does not change payment status.
|
||||
setExpressPaymentError: ( error: string ) => void;
|
||||
// True if an express payment method is active.
|
||||
isExpressPaymentMethodActive: boolean;
|
||||
// A function used to set the shouldSavePayment value.
|
||||
setShouldSavePayment: ( shouldSavePayment: boolean ) => void;
|
||||
// True means that the configured payment method option is saved for the customer.
|
||||
shouldSavePayment: boolean;
|
||||
};
|
||||
|
||||
export type PaymentMethodsDispatcherType = (
|
||||
paymentMethods: PaymentMethods
|
||||
) => undefined;
|
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreEvents } from '../../../hooks/use-store-events';
|
||||
|
||||
export const useActivePaymentMethod = (): {
|
||||
activePaymentMethod: string;
|
||||
activeSavedToken: string;
|
||||
setActivePaymentMethod: React.Dispatch< React.SetStateAction< string > >;
|
||||
setActiveSavedToken: ( token: string ) => void;
|
||||
} => {
|
||||
const { dispatchCheckoutEvent } = useStoreEvents();
|
||||
|
||||
// The active payment method - e.g. Stripe CC or BACS.
|
||||
const [ activePaymentMethod, setActivePaymentMethod ] = useState( '' );
|
||||
|
||||
// If a previously saved payment method is active, the token for that method. For example, a for a Stripe CC card saved to user account.
|
||||
const [ activeSavedToken, setActiveSavedToken ] = useState( '' );
|
||||
|
||||
// Trigger event on change.
|
||||
useEffect( () => {
|
||||
dispatchCheckoutEvent( 'set-active-payment-method', {
|
||||
activePaymentMethod,
|
||||
} );
|
||||
}, [ dispatchCheckoutEvent, activePaymentMethod ] );
|
||||
|
||||
return {
|
||||
activePaymentMethod,
|
||||
activeSavedToken,
|
||||
setActivePaymentMethod,
|
||||
setActiveSavedToken,
|
||||
};
|
||||
};
|
@ -0,0 +1,105 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useCallback, useMemo } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { actions, ActionType } from './actions';
|
||||
import { STATUS } from './constants';
|
||||
import { useCustomerDataContext } from '../customer';
|
||||
import { useShippingDataContext } from '../shipping';
|
||||
import type {
|
||||
PaymentStatusDispatchers,
|
||||
PaymentMethodDispatchers,
|
||||
} from './types';
|
||||
|
||||
export const usePaymentMethodDataDispatchers = (
|
||||
dispatch: React.Dispatch< ActionType >
|
||||
): {
|
||||
dispatchActions: PaymentMethodDispatchers;
|
||||
setPaymentStatus: () => PaymentStatusDispatchers;
|
||||
} => {
|
||||
const { setBillingData } = useCustomerDataContext();
|
||||
const { setShippingAddress } = useShippingDataContext();
|
||||
|
||||
const dispatchActions = useMemo(
|
||||
(): PaymentMethodDispatchers => ( {
|
||||
setRegisteredPaymentMethods: ( paymentMethods ) =>
|
||||
void dispatch(
|
||||
actions.setRegisteredPaymentMethods( paymentMethods )
|
||||
),
|
||||
setRegisteredExpressPaymentMethods: ( paymentMethods ) =>
|
||||
void dispatch(
|
||||
actions.setRegisteredExpressPaymentMethods( paymentMethods )
|
||||
),
|
||||
setShouldSavePayment: ( shouldSave ) =>
|
||||
void dispatch(
|
||||
actions.setShouldSavePaymentMethod( shouldSave )
|
||||
),
|
||||
} ),
|
||||
[ dispatch ]
|
||||
);
|
||||
|
||||
const setPaymentStatus = useCallback(
|
||||
(): PaymentStatusDispatchers => ( {
|
||||
pristine: () => dispatch( actions.statusOnly( STATUS.PRISTINE ) ),
|
||||
started: ( paymentMethodData ) => {
|
||||
dispatch(
|
||||
actions.started( {
|
||||
paymentMethodData,
|
||||
} )
|
||||
);
|
||||
},
|
||||
processing: () =>
|
||||
dispatch( actions.statusOnly( STATUS.PROCESSING ) ),
|
||||
completed: () => dispatch( actions.statusOnly( STATUS.COMPLETE ) ),
|
||||
error: ( errorMessage ) =>
|
||||
dispatch( actions.error( errorMessage ) ),
|
||||
failed: (
|
||||
errorMessage,
|
||||
paymentMethodData,
|
||||
billingData = undefined
|
||||
) => {
|
||||
if ( billingData ) {
|
||||
setBillingData( billingData );
|
||||
}
|
||||
dispatch(
|
||||
actions.failed( {
|
||||
errorMessage: errorMessage || '',
|
||||
paymentMethodData: paymentMethodData || {},
|
||||
} )
|
||||
);
|
||||
},
|
||||
success: (
|
||||
paymentMethodData,
|
||||
billingData = undefined,
|
||||
shippingData = undefined
|
||||
) => {
|
||||
if ( billingData ) {
|
||||
setBillingData( billingData );
|
||||
}
|
||||
if (
|
||||
typeof shippingData !== undefined &&
|
||||
shippingData?.address
|
||||
) {
|
||||
setShippingAddress(
|
||||
shippingData.address as Record< string, unknown >
|
||||
);
|
||||
}
|
||||
dispatch(
|
||||
actions.success( {
|
||||
paymentMethodData,
|
||||
} )
|
||||
);
|
||||
},
|
||||
} ),
|
||||
[ dispatch, setBillingData, setShippingAddress ]
|
||||
);
|
||||
|
||||
return {
|
||||
dispatchActions,
|
||||
setPaymentStatus,
|
||||
};
|
||||
};
|
@ -0,0 +1,229 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import {
|
||||
getPaymentMethods,
|
||||
getExpressPaymentMethods,
|
||||
} from '@woocommerce/blocks-registry';
|
||||
import { useState, useEffect, useRef, useCallback } from '@wordpress/element';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
import { CURRENT_USER_IS_ADMIN, getSetting } from '@woocommerce/settings';
|
||||
import type {
|
||||
PaymentMethods,
|
||||
ExpressPaymentMethods,
|
||||
PaymentMethodConfigInstance,
|
||||
ExpressPaymentMethodConfigInstance,
|
||||
} from '@woocommerce/type-defs/payments';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useEditorContext } from '../../editor-context';
|
||||
import { useShippingDataContext } from '../shipping';
|
||||
import { useCustomerDataContext } from '../customer';
|
||||
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
|
||||
import { useStoreNotices } from '../../../hooks/use-store-notices';
|
||||
import { useEmitResponse } from '../../../hooks/use-emit-response';
|
||||
import type { PaymentMethodsDispatcherType } from './types';
|
||||
|
||||
/**
|
||||
* This hook handles initializing registered payment methods and exposing all
|
||||
* registered payment methods that can be used in the current environment (via
|
||||
* the payment method's `canMakePayment` property).
|
||||
*
|
||||
* @param {function(Object):undefined} dispatcher A dispatcher for setting registered payment methods to an external state.
|
||||
* @param {Object} registeredPaymentMethods Registered payment methods to process.
|
||||
* @param {Array} paymentMethodsSortOrder Array of payment method names to sort by. This should match keys of registeredPaymentMethods.
|
||||
* @param {string} noticeContext Id of the context to append notices to.
|
||||
*
|
||||
* @return {boolean} Whether the payment methods have been initialized or not. True when all payment methods have been initialized.
|
||||
*/
|
||||
const usePaymentMethodRegistration = (
|
||||
dispatcher: PaymentMethodsDispatcherType,
|
||||
registeredPaymentMethods: PaymentMethods | ExpressPaymentMethods,
|
||||
paymentMethodsSortOrder: string[],
|
||||
noticeContext: string
|
||||
) => {
|
||||
const [ isInitialized, setIsInitialized ] = useState( false );
|
||||
const { isEditor } = useEditorContext();
|
||||
const { selectedRates } = useShippingDataContext();
|
||||
const { billingData, shippingAddress } = useCustomerDataContext();
|
||||
const selectedShippingMethods = useShallowEqual( selectedRates );
|
||||
const paymentMethodsOrder = useShallowEqual( paymentMethodsSortOrder );
|
||||
const cart = useStoreCart();
|
||||
const { cartTotals, cartNeedsShipping, paymentRequirements } = cart;
|
||||
const canPayArgument = useRef( {
|
||||
cart,
|
||||
cartTotals,
|
||||
cartNeedsShipping,
|
||||
billingData,
|
||||
shippingAddress,
|
||||
selectedShippingMethods,
|
||||
paymentRequirements,
|
||||
} );
|
||||
const { addErrorNotice } = useStoreNotices();
|
||||
|
||||
useEffect( () => {
|
||||
canPayArgument.current = {
|
||||
cart,
|
||||
cartTotals,
|
||||
cartNeedsShipping,
|
||||
billingData,
|
||||
shippingAddress,
|
||||
selectedShippingMethods,
|
||||
paymentRequirements,
|
||||
};
|
||||
}, [
|
||||
cart,
|
||||
cartTotals,
|
||||
cartNeedsShipping,
|
||||
billingData,
|
||||
shippingAddress,
|
||||
selectedShippingMethods,
|
||||
paymentRequirements,
|
||||
] );
|
||||
|
||||
const refreshCanMakePayments = useCallback( async () => {
|
||||
let availablePaymentMethods = {};
|
||||
|
||||
const addAvailablePaymentMethod = (
|
||||
paymentMethod:
|
||||
| PaymentMethodConfigInstance
|
||||
| ExpressPaymentMethodConfigInstance
|
||||
) => {
|
||||
availablePaymentMethods = {
|
||||
...availablePaymentMethods,
|
||||
[ paymentMethod.name ]: paymentMethod,
|
||||
};
|
||||
};
|
||||
|
||||
for ( let i = 0; i < paymentMethodsOrder.length; i++ ) {
|
||||
const paymentMethodName = paymentMethodsOrder[ i ];
|
||||
const paymentMethod = registeredPaymentMethods[ paymentMethodName ];
|
||||
if ( ! paymentMethod ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// See if payment method should be available. This always evaluates to true in the editor context.
|
||||
try {
|
||||
const canPay = isEditor
|
||||
? true
|
||||
: await Promise.resolve(
|
||||
paymentMethod.canMakePayment(
|
||||
canPayArgument.current
|
||||
)
|
||||
);
|
||||
|
||||
if ( canPay ) {
|
||||
if (
|
||||
typeof canPay === 'object' &&
|
||||
canPay !== null &&
|
||||
canPay.error
|
||||
) {
|
||||
throw new Error( canPay.error.message );
|
||||
}
|
||||
|
||||
addAvailablePaymentMethod( paymentMethod );
|
||||
}
|
||||
} catch ( e ) {
|
||||
if ( CURRENT_USER_IS_ADMIN || isEditor ) {
|
||||
const errorText = sprintf(
|
||||
/* translators: %s the id of the payment method being registered (bank transfer, Stripe...) */
|
||||
__(
|
||||
`There was an error registering the payment method with id '%s': `,
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
paymentMethod.paymentMethodId
|
||||
);
|
||||
addErrorNotice( `${ errorText } ${ e }`, {
|
||||
context: noticeContext,
|
||||
id: `wc-${ paymentMethod.paymentMethodId }-registration-error`,
|
||||
} );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-dispatch available payment methods to store.
|
||||
dispatcher( availablePaymentMethods );
|
||||
|
||||
// Note: some payment methods use the `canMakePayment` callback to initialize / setup.
|
||||
// Example: Stripe CC, Stripe Payment Request.
|
||||
// That's why we track "is initialized" state here.
|
||||
setIsInitialized( true );
|
||||
}, [
|
||||
addErrorNotice,
|
||||
dispatcher,
|
||||
isEditor,
|
||||
noticeContext,
|
||||
paymentMethodsOrder,
|
||||
registeredPaymentMethods,
|
||||
] );
|
||||
|
||||
const [ debouncedRefreshCanMakePayments ] = useDebouncedCallback(
|
||||
refreshCanMakePayments,
|
||||
500
|
||||
);
|
||||
|
||||
// Determine which payment methods are available initially and whenever
|
||||
// shipping methods, cart or the billing data change.
|
||||
// Some payment methods (e.g. COD) can be disabled for specific shipping methods.
|
||||
useEffect( () => {
|
||||
debouncedRefreshCanMakePayments();
|
||||
}, [
|
||||
debouncedRefreshCanMakePayments,
|
||||
cart,
|
||||
selectedShippingMethods,
|
||||
billingData,
|
||||
] );
|
||||
|
||||
return isInitialized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook for setting up payment methods (standard, non-express).
|
||||
*
|
||||
* @param {function(Object):undefined} dispatcher
|
||||
*
|
||||
* @return {boolean} True when standard payment methods have been initialized.
|
||||
*/
|
||||
export const usePaymentMethods = (
|
||||
dispatcher: PaymentMethodsDispatcherType
|
||||
): boolean => {
|
||||
const standardMethods: PaymentMethods = getPaymentMethods() as PaymentMethods;
|
||||
const { noticeContexts } = useEmitResponse();
|
||||
// Ensure all methods are present in order.
|
||||
// Some payment methods may not be present in paymentGatewaySortOrder if they
|
||||
// depend on state, e.g. COD can depend on shipping method.
|
||||
const displayOrder = new Set( [
|
||||
...( getSetting( 'paymentGatewaySortOrder', [] ) as [ ] ),
|
||||
...Object.keys( standardMethods ),
|
||||
] );
|
||||
return usePaymentMethodRegistration(
|
||||
dispatcher,
|
||||
standardMethods,
|
||||
Array.from( displayOrder ),
|
||||
noticeContexts.PAYMENTS
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook for setting up express payment methods.
|
||||
*
|
||||
* @param {function(Object):undefined} dispatcher
|
||||
*
|
||||
* @return {boolean} True when express payment methods have been initialized.
|
||||
*/
|
||||
export const useExpressPaymentMethods = (
|
||||
dispatcher: PaymentMethodsDispatcherType
|
||||
): boolean => {
|
||||
const expressMethods: ExpressPaymentMethods = getExpressPaymentMethods() as ExpressPaymentMethods;
|
||||
const { noticeContexts } = useEmitResponse();
|
||||
return usePaymentMethodRegistration(
|
||||
dispatcher,
|
||||
expressMethods,
|
||||
Object.keys( expressMethods ),
|
||||
noticeContexts.EXPRESS_PAYMENTS
|
||||
);
|
||||
};
|
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { PaymentMethods, CustomerPaymentMethod } from './types';
|
||||
|
||||
/**
|
||||
* Gets the payment methods saved for the current user after filtering out disabled ones.
|
||||
*/
|
||||
export const getCustomerPaymentMethods = (
|
||||
availablePaymentMethods: PaymentMethods = {}
|
||||
): Record< string, CustomerPaymentMethod > => {
|
||||
if ( Object.keys( availablePaymentMethods ).length === 0 ) {
|
||||
return {};
|
||||
}
|
||||
const customerPaymentMethods = getSetting( 'customerPaymentMethods', {} );
|
||||
const paymentMethodKeys = Object.keys( customerPaymentMethods );
|
||||
const enabledCustomerPaymentMethods = {} as Record<
|
||||
string,
|
||||
CustomerPaymentMethod
|
||||
>;
|
||||
paymentMethodKeys.forEach( ( type ) => {
|
||||
const methods = customerPaymentMethods[ type ].filter(
|
||||
( {
|
||||
method: { gateway },
|
||||
}: {
|
||||
method: {
|
||||
gateway: string;
|
||||
};
|
||||
} ) =>
|
||||
gateway in availablePaymentMethods &&
|
||||
availablePaymentMethods[ gateway ].supports?.showSavedCards
|
||||
);
|
||||
if ( methods.length ) {
|
||||
enabledCustomerPaymentMethods[ type ] = methods;
|
||||
}
|
||||
} );
|
||||
return enabledCustomerPaymentMethods;
|
||||
};
|
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').ShippingErrorTypes} ShippingErrorTypes
|
||||
* @typedef {import('@woocommerce/type-defs/shipping').ShippingAddress} ShippingAddress
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').ShippingDataContext} ShippingDataContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {ShippingErrorTypes}
|
||||
*/
|
||||
export const ERROR_TYPES = {
|
||||
NONE: 'none',
|
||||
INVALID_ADDRESS: 'invalid_address',
|
||||
UNKNOWN: 'unknown_error',
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {ShippingAddress}
|
||||
*/
|
||||
export const DEFAULT_SHIPPING_ADDRESS = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {ShippingDataContext}
|
||||
*/
|
||||
export const DEFAULT_SHIPPING_CONTEXT_DATA = {
|
||||
shippingErrorStatus: {
|
||||
isPristine: true,
|
||||
isValid: false,
|
||||
hasInvalidAddress: false,
|
||||
hasError: false,
|
||||
},
|
||||
dispatchErrorStatus: () => null,
|
||||
shippingErrorTypes: ERROR_TYPES,
|
||||
shippingRates: [],
|
||||
shippingRatesLoading: false,
|
||||
selectedRates: [],
|
||||
setSelectedRates: () => null,
|
||||
shippingAddress: DEFAULT_SHIPPING_ADDRESS,
|
||||
setShippingAddress: () => null,
|
||||
onShippingRateSuccess: () => null,
|
||||
onShippingRateFail: () => null,
|
||||
onShippingRateSelectSuccess: () => null,
|
||||
onShippingRateSelectFail: () => null,
|
||||
needsShipping: false,
|
||||
};
|
@ -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,232 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from '@wordpress/element';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
import { deriveSelectedShippingRates } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
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 { useCheckoutContext } from '../checkout-state';
|
||||
import { useCustomerDataContext } from '../customer';
|
||||
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
|
||||
import { useSelectShippingRates } from '../../../hooks/shipping/use-select-shipping-rates';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').ShippingDataContext} ShippingDataContext
|
||||
* @typedef {import('react')} React
|
||||
*/
|
||||
|
||||
const { NONE, INVALID_ADDRESS, UNKNOWN } = ERROR_TYPES;
|
||||
const ShippingDataContext = createContext( DEFAULT_SHIPPING_CONTEXT_DATA );
|
||||
|
||||
/**
|
||||
* @return {ShippingDataContext} Returns data and functions related to shipping methods.
|
||||
*/
|
||||
export const useShippingDataContext = () => {
|
||||
return useContext( ShippingDataContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* The shipping data provider exposes the interface for shipping in the checkout/cart.
|
||||
*
|
||||
* @param {Object} props Incoming props for provider
|
||||
* @param {React.ReactElement} props.children
|
||||
*/
|
||||
export const ShippingDataProvider = ( { children } ) => {
|
||||
const { dispatchActions } = useCheckoutContext();
|
||||
const { shippingAddress, setShippingAddress } = useCustomerDataContext();
|
||||
const {
|
||||
cartNeedsShipping: needsShipping,
|
||||
cartHasCalculatedShipping: hasCalculatedShipping,
|
||||
shippingRates,
|
||||
shippingRatesLoading,
|
||||
cartErrors,
|
||||
} = useStoreCart();
|
||||
const { selectShippingRate, isSelectingRate } = useSelectShippingRates();
|
||||
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 ] );
|
||||
|
||||
// set selected rates on ref so it's always current.
|
||||
const selectedRates = useRef( () =>
|
||||
deriveSelectedShippingRates( shippingRates )
|
||||
);
|
||||
useEffect( () => {
|
||||
const derivedSelectedRates = deriveSelectedShippingRates(
|
||||
shippingRates
|
||||
);
|
||||
if ( ! isShallowEqual( selectedRates.current, derivedSelectedRates ) ) {
|
||||
selectedRates.current = derivedSelectedRates;
|
||||
}
|
||||
}, [ shippingRates ] );
|
||||
|
||||
// increment/decrement checkout calculating counts when shipping is loading.
|
||||
useEffect( () => {
|
||||
if ( shippingRatesLoading ) {
|
||||
dispatchActions.incrementCalculating();
|
||||
} else {
|
||||
dispatchActions.decrementCalculating();
|
||||
}
|
||||
}, [ shippingRatesLoading, dispatchActions ] );
|
||||
|
||||
// increment/decrement checkout calculating counts when shipping rates are being selected.
|
||||
useEffect( () => {
|
||||
if ( isSelectingRate ) {
|
||||
dispatchActions.incrementCalculating();
|
||||
} else {
|
||||
dispatchActions.decrementCalculating();
|
||||
}
|
||||
}, [ isSelectingRate, dispatchActions ] );
|
||||
|
||||
// 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 (
|
||||
! shippingRatesLoading &&
|
||||
( shippingRates.length === 0 || currentErrorStatus.hasError )
|
||||
) {
|
||||
emitEvent(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.SHIPPING_RATES_FAIL,
|
||||
{
|
||||
hasInvalidAddress: currentErrorStatus.hasInvalidAddress,
|
||||
hasError: currentErrorStatus.hasError,
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [
|
||||
shippingRates,
|
||||
shippingRatesLoading,
|
||||
currentErrorStatus.hasError,
|
||||
currentErrorStatus.hasInvalidAddress,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
! shippingRatesLoading &&
|
||||
shippingRates.length > 0 &&
|
||||
! currentErrorStatus.hasError
|
||||
) {
|
||||
emitEvent(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.SHIPPING_RATES_SUCCESS,
|
||||
shippingRates
|
||||
);
|
||||
}
|
||||
}, [ shippingRates, shippingRatesLoading, 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
|
||||
);
|
||||
}
|
||||
}, [
|
||||
isSelectingRate,
|
||||
currentErrorStatus.hasError,
|
||||
currentErrorStatus.hasInvalidAddress,
|
||||
] );
|
||||
|
||||
/**
|
||||
* @type {ShippingDataContext}
|
||||
*/
|
||||
const ShippingData = {
|
||||
shippingErrorStatus: currentErrorStatus,
|
||||
dispatchErrorStatus,
|
||||
shippingErrorTypes: ERROR_TYPES,
|
||||
shippingRates,
|
||||
shippingRatesLoading,
|
||||
selectedRates: selectedRates.current,
|
||||
setSelectedRates: selectShippingRate,
|
||||
isSelectingRate,
|
||||
shippingAddress,
|
||||
setShippingAddress,
|
||||
needsShipping,
|
||||
hasCalculatedShipping,
|
||||
...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,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';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { CheckoutStateDispatchActions } from './checkout-state/types';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
dispatchActions: CheckoutStateDispatchActions
|
||||
): void => {
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore -- this does exist because it's monkey patched in
|
||||
// middleware/store-api-nonce.
|
||||
triggerFetch.setNonce &&
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore -- this does exist because it's monkey patched in
|
||||
// middleware/store-api-nonce.
|
||||
typeof triggerFetch.setNonce === 'function'
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore -- this does exist because it's monkey patched in
|
||||
// middleware/store-api-nonce.
|
||||
triggerFetch.setNonce( headers );
|
||||
}
|
||||
|
||||
// Update user using headers.
|
||||
if ( headers?.get( 'X-WC-Store-API-User' ) ) {
|
||||
dispatchActions.setCustomerId(
|
||||
parseInt( headers.get( 'X-WC-Store-API-User' ) || '0', 10 )
|
||||
);
|
||||
}
|
||||
};
|
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import { createContext, useContext } from '@wordpress/element';
|
||||
import { useContainerQueries } from '@woocommerce/base-hooks';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').ContainerWidthContext} ContainerWidthContext
|
||||
* @typedef {import('react')} React
|
||||
*/
|
||||
|
||||
const ContainerWidthContext = createContext( {
|
||||
hasContainerWidth: false,
|
||||
containerClassName: '',
|
||||
isMobile: false,
|
||||
isSmall: false,
|
||||
isMedium: false,
|
||||
isLarge: false,
|
||||
} );
|
||||
|
||||
/**
|
||||
* @return {ContainerWidthContext} Returns the container width context value
|
||||
*/
|
||||
export const useContainerWidthContext = () => {
|
||||
return useContext( ContainerWidthContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides an interface to useContainerQueries so children can see what size is being used by the
|
||||
* container.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {React.ReactChildren} props.children React elements wrapped by the component.
|
||||
* @param {string} props.className CSS class in use.
|
||||
*/
|
||||
export const ContainerWidthContextProvider = ( {
|
||||
children,
|
||||
className = '',
|
||||
} ) => {
|
||||
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',
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {ContainerWidthContext}
|
||||
*/
|
||||
return (
|
||||
<ContainerWidthContext.Provider value={ contextValue }>
|
||||
<div className={ classNames( className, containerClassName ) }>
|
||||
{ resizeListener }
|
||||
{ children }
|
||||
</div>
|
||||
</ContainerWidthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
ContainerWidthContextProvider.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createContext, useContext, useCallback } from '@wordpress/element';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').EditorDataContext} EditorDataContext
|
||||
* @typedef {import('@woocommerce/type-defs/cart').CartData} CartData
|
||||
*/
|
||||
|
||||
const EditorContext = createContext( {
|
||||
isEditor: false,
|
||||
currentPostId: 0,
|
||||
previewData: {},
|
||||
getPreviewData: () => void null,
|
||||
} );
|
||||
|
||||
/**
|
||||
* @return {EditorDataContext} Returns the editor data context value
|
||||
*/
|
||||
export const useEditorContext = () => {
|
||||
return useContext( EditorContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Editor provider
|
||||
*
|
||||
* @param {Object} props Incoming props for the provider.
|
||||
* @param {*} props.children The children being wrapped.
|
||||
* @param {Object} [props.previewData] The preview data for editor.
|
||||
* @param {number} [props.currentPostId] The post being edited.
|
||||
*/
|
||||
export const EditorProvider = ( {
|
||||
children,
|
||||
currentPostId = 0,
|
||||
previewData = {},
|
||||
} ) => {
|
||||
/**
|
||||
* @type {number} editingPostId
|
||||
*/
|
||||
const editingPostId = useSelect(
|
||||
( select ) => {
|
||||
if ( ! currentPostId ) {
|
||||
const store = select( 'core/editor' );
|
||||
return store.getCurrentPostId();
|
||||
}
|
||||
return currentPostId;
|
||||
},
|
||||
[ currentPostId ]
|
||||
);
|
||||
|
||||
const getPreviewData = useCallback(
|
||||
( name ) => {
|
||||
if ( name in previewData ) {
|
||||
return previewData[ name ];
|
||||
}
|
||||
return {};
|
||||
},
|
||||
[ previewData ]
|
||||
);
|
||||
|
||||
/**
|
||||
* @type {EditorDataContext}
|
||||
*/
|
||||
const editorData = {
|
||||
isEditor: true,
|
||||
currentPostId: editingPostId,
|
||||
previewData,
|
||||
getPreviewData,
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorContext.Provider value={ editorData }>
|
||||
{ children }
|
||||
</EditorContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
export * from './editor-context';
|
||||
export * from './add-to-cart-form';
|
||||
export * from './cart-checkout';
|
||||
export * from './store-notices';
|
||||
export * from './store-snackbar-notices';
|
||||
export * from './validation';
|
||||
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,73 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { Notice } from 'wordpress-components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
const getWooClassName = ( { status = 'default' } ) => {
|
||||
switch ( status ) {
|
||||
case 'error':
|
||||
return 'woocommerce-error';
|
||||
case 'success':
|
||||
return 'woocommerce-message';
|
||||
case 'info':
|
||||
case 'warning':
|
||||
return 'woocommerce-info';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const StoreNoticesContainer = ( { className, notices, removeNotice } ) => {
|
||||
const regularNotices = notices.filter(
|
||||
( notice ) => notice.type !== 'snackbar'
|
||||
);
|
||||
|
||||
if ( ! regularNotices.length ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wrapperClass = classnames( className, 'wc-block-components-notices' );
|
||||
|
||||
return (
|
||||
<div className={ wrapperClass }>
|
||||
{ regularNotices.map( ( props ) => (
|
||||
<Notice
|
||||
key={ 'store-notice-' + props.id }
|
||||
{ ...props }
|
||||
className={ classnames(
|
||||
'wc-block-components-notices__notice',
|
||||
getWooClassName( props )
|
||||
) }
|
||||
onRemove={ () => {
|
||||
if ( props.isDismissible ) {
|
||||
removeNotice( props.id );
|
||||
}
|
||||
} }
|
||||
>
|
||||
{ props.content }
|
||||
</Notice>
|
||||
) ) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
StoreNoticesContainer.propTypes = {
|
||||
className: PropTypes.string,
|
||||
notices: PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
content: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
isDismissible: PropTypes.bool,
|
||||
type: PropTypes.oneOf( [ 'default', 'snackbar' ] ),
|
||||
} )
|
||||
),
|
||||
};
|
||||
|
||||
export default StoreNoticesContainer;
|
@ -0,0 +1,32 @@
|
||||
.wc-block-components-notices {
|
||||
display: block;
|
||||
margin-bottom: 2em;
|
||||
.wc-block-components-notices__notice {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
.components-notice__dismiss {
|
||||
background: transparent none;
|
||||
padding: 0;
|
||||
margin: 0 0 0 auto;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: currentColor;
|
||||
svg {
|
||||
fill: currentColor;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
}
|
||||
}
|
||||
.wc-block-components-notices__notice + .wc-block-components-notices__notice {
|
||||
margin-top: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
// @todo Either move notice style fixes to Woo core, or take full control over notice component styling in blocks.
|
||||
.theme-twentytwentyone,
|
||||
.theme-twentytwenty {
|
||||
.wc-block-components-notices__notice {
|
||||
padding: 1.5rem 3rem;
|
||||
}
|
||||
}
|
@ -0,0 +1,130 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useState,
|
||||
} from '@wordpress/element';
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreEvents } from '../../hooks/use-store-events';
|
||||
import { useEditorContext } from '../editor-context';
|
||||
import StoreNoticesContainer from './components/store-notices-container';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').NoticeContext} NoticeContext
|
||||
* @typedef {import('react')} React
|
||||
*/
|
||||
|
||||
const StoreNoticesContext = createContext( {
|
||||
notices: [],
|
||||
createNotice: ( status, text, props ) => void { status, text, props },
|
||||
removeNotice: ( id, ctxt ) => void { id, ctxt },
|
||||
setIsSuppressed: ( val ) => void { val },
|
||||
context: 'wc/core',
|
||||
} );
|
||||
|
||||
/**
|
||||
* Returns the notices context values.
|
||||
*
|
||||
* @return {NoticeContext} The notice context value from the notice context.
|
||||
*/
|
||||
export const useStoreNoticesContext = () => {
|
||||
return useContext( StoreNoticesContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides an interface for blocks to add notices to the frontend UI.
|
||||
*
|
||||
* Statuses map to https://github.com/WordPress/gutenberg/tree/master/packages/components/src/notice
|
||||
* - Default (no status)
|
||||
* - Error
|
||||
* - Warning
|
||||
* - Info
|
||||
* - Success
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {JSX.Element} props.children The Elements wrapped by this component.
|
||||
* @param {string} [props.className] CSS class used.
|
||||
* @param {boolean} [props.createNoticeContainer] Whether to create a notice container or not.
|
||||
* @param {string} [props.context] The notice context for notices being rendered.
|
||||
*/
|
||||
export const StoreNoticesProvider = ( {
|
||||
children,
|
||||
className = '',
|
||||
createNoticeContainer = true,
|
||||
context = 'wc/core',
|
||||
} ) => {
|
||||
const { createNotice, removeNotice } = useDispatch( 'core/notices' );
|
||||
const [ isSuppressed, setIsSuppressed ] = useState( false );
|
||||
const { dispatchStoreEvent } = useStoreEvents();
|
||||
const { isEditor } = useEditorContext();
|
||||
|
||||
const createNoticeWithContext = useCallback(
|
||||
( status = 'default', content = '', options = {} ) => {
|
||||
createNotice( status, content, {
|
||||
...options,
|
||||
context: options.context || context,
|
||||
} );
|
||||
dispatchStoreEvent( 'store-notice-create', {
|
||||
status,
|
||||
content,
|
||||
options,
|
||||
} );
|
||||
},
|
||||
[ createNotice, dispatchStoreEvent, context ]
|
||||
);
|
||||
|
||||
const removeNoticeWithContext = useCallback(
|
||||
( id, ctxt = context ) => {
|
||||
removeNotice( id, ctxt );
|
||||
},
|
||||
[ removeNotice, context ]
|
||||
);
|
||||
|
||||
const { notices } = useSelect(
|
||||
( select ) => {
|
||||
return {
|
||||
notices: select( 'core/notices' ).getNotices( context ),
|
||||
};
|
||||
},
|
||||
[ context ]
|
||||
);
|
||||
|
||||
const contextValue = {
|
||||
notices,
|
||||
createNotice: createNoticeWithContext,
|
||||
removeNotice: removeNoticeWithContext,
|
||||
context,
|
||||
setIsSuppressed,
|
||||
};
|
||||
|
||||
const noticeOutput = isSuppressed ? null : (
|
||||
<StoreNoticesContainer
|
||||
className={ className }
|
||||
notices={ contextValue.notices }
|
||||
removeNotice={ contextValue.removeNotice }
|
||||
isEditor={ isEditor }
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<StoreNoticesContext.Provider value={ contextValue }>
|
||||
{ createNoticeContainer && noticeOutput }
|
||||
{ children }
|
||||
</StoreNoticesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
StoreNoticesProvider.propTypes = {
|
||||
className: PropTypes.string,
|
||||
createNoticeContainer: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
context: PropTypes.string,
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export * from './components/store-notices-container';
|
||||
export * from './context';
|
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { SnackbarList } from 'wordpress-components';
|
||||
import classnames from 'classnames';
|
||||
import { __experimentalApplyCheckoutFilter } from '@woocommerce/blocks-checkout';
|
||||
|
||||
const EMPTY_SNACKBAR_NOTICES = {};
|
||||
|
||||
const SnackbarNoticesContainer = ( {
|
||||
className,
|
||||
notices,
|
||||
removeNotice,
|
||||
isEditor,
|
||||
} ) => {
|
||||
if ( isEditor ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snackbarNotices = notices.filter(
|
||||
( notice ) => notice.type === 'snackbar'
|
||||
);
|
||||
|
||||
const noticeVisibility =
|
||||
snackbarNotices.length > 0
|
||||
? snackbarNotices.reduce( ( acc, { content } ) => {
|
||||
acc[ content ] = true;
|
||||
return acc;
|
||||
}, {} )
|
||||
: EMPTY_SNACKBAR_NOTICES;
|
||||
|
||||
const filteredNotices = __experimentalApplyCheckoutFilter( {
|
||||
filterName: 'snackbarNoticeVisibility',
|
||||
defaultValue: noticeVisibility,
|
||||
} );
|
||||
|
||||
const visibleNotices = snackbarNotices.filter(
|
||||
( notice ) => filteredNotices[ notice.content ] === true
|
||||
);
|
||||
|
||||
const wrapperClass = classnames(
|
||||
className,
|
||||
'wc-block-components-notices__snackbar'
|
||||
);
|
||||
|
||||
return (
|
||||
<SnackbarList
|
||||
notices={ visibleNotices }
|
||||
className={ wrapperClass }
|
||||
onRemove={ removeNotice }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SnackbarNoticesContainer;
|
@ -0,0 +1,20 @@
|
||||
.wc-block-components-notices__snackbar {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 16px;
|
||||
width: auto;
|
||||
|
||||
@include breakpoint("<782px") {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 0;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.components-snackbar-list__notice-container {
|
||||
@include breakpoint("<782px") {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useState,
|
||||
} from '@wordpress/element';
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
import SnackbarNoticesContainer from '@woocommerce/base-context/providers/store-snackbar-notices/components/snackbar-notices-container';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreEvents } from '../../hooks/use-store-events';
|
||||
import { useEditorContext } from '../editor-context';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').NoticeContext} NoticeContext
|
||||
* @typedef {import('react')} React
|
||||
*/
|
||||
|
||||
const StoreSnackbarNoticesContext = createContext( {
|
||||
notices: [],
|
||||
createSnackbarNotice: ( content, options ) => void { content, options },
|
||||
removeSnackbarNotice: ( id, ctxt ) => void { id, ctxt },
|
||||
setIsSuppressed: ( val ) => void { val },
|
||||
context: 'wc/core',
|
||||
} );
|
||||
|
||||
/**
|
||||
* Returns the notices context values.
|
||||
*
|
||||
* @return {NoticeContext} The notice context value from the notice context.
|
||||
*/
|
||||
export const useStoreSnackbarNoticesContext = () => {
|
||||
return useContext( StoreSnackbarNoticesContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides an interface for blocks to add notices to the frontend UI.
|
||||
*
|
||||
* Statuses map to https://github.com/WordPress/gutenberg/tree/master/packages/components/src/notice
|
||||
* - Default (no status)
|
||||
* - Error
|
||||
* - Warning
|
||||
* - Info
|
||||
* - Success
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {React.ReactChildren} props.children The Elements wrapped by this component.
|
||||
* @param {string} props.context The notice context for notices being rendered.
|
||||
*/
|
||||
export const StoreSnackbarNoticesProvider = ( {
|
||||
children,
|
||||
context = 'wc/core',
|
||||
} ) => {
|
||||
const { createNotice, removeNotice } = useDispatch( 'core/notices' );
|
||||
const [ isSuppressed, setIsSuppressed ] = useState( false );
|
||||
const { dispatchStoreEvent } = useStoreEvents();
|
||||
const { isEditor } = useEditorContext();
|
||||
|
||||
const createSnackbarNotice = useCallback(
|
||||
( content = '', options = {} ) => {
|
||||
createNotice( 'default', content, {
|
||||
...options,
|
||||
type: 'snackbar',
|
||||
context: options.context || context,
|
||||
} );
|
||||
dispatchStoreEvent( 'store-notice-create', {
|
||||
status: 'default',
|
||||
content,
|
||||
options,
|
||||
} );
|
||||
},
|
||||
[ createNotice, dispatchStoreEvent, context ]
|
||||
);
|
||||
|
||||
const removeSnackbarNotice = useCallback(
|
||||
( id, ctxt = context ) => {
|
||||
removeNotice( id, ctxt );
|
||||
},
|
||||
[ removeNotice, context ]
|
||||
);
|
||||
|
||||
const { notices } = useSelect(
|
||||
( select ) => {
|
||||
return {
|
||||
notices: select( 'core/notices' ).getNotices( context ),
|
||||
};
|
||||
},
|
||||
[ context ]
|
||||
);
|
||||
|
||||
const contextValue = {
|
||||
notices,
|
||||
createSnackbarNotice,
|
||||
removeSnackbarNotice,
|
||||
context,
|
||||
setIsSuppressed,
|
||||
};
|
||||
|
||||
const snackbarNoticeOutput = isSuppressed ? null : (
|
||||
<SnackbarNoticesContainer
|
||||
notices={ contextValue.notices }
|
||||
removeNotice={ contextValue.removeSnackbarNotice }
|
||||
isEditor={ isEditor }
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<StoreSnackbarNoticesContext.Provider value={ contextValue }>
|
||||
{ children }
|
||||
{ snackbarNoticeOutput }
|
||||
</StoreSnackbarNoticesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
StoreSnackbarNoticesProvider.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
context: PropTypes.string,
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './context';
|
@ -0,0 +1 @@
|
||||
export * from './validation-input-error';
|
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useValidationContext } from '../../context';
|
||||
import './style.scss';
|
||||
|
||||
export const ValidationInputError = ( {
|
||||
errorMessage = '',
|
||||
propertyName = '',
|
||||
elementId = '',
|
||||
} ) => {
|
||||
const { getValidationError, getValidationErrorId } = useValidationContext();
|
||||
|
||||
if ( ! errorMessage || typeof errorMessage !== 'string' ) {
|
||||
const error = getValidationError( propertyName ) || {};
|
||||
if ( error.message && ! error.hidden ) {
|
||||
errorMessage = error.message;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wc-block-components-validation-error" role="alert">
|
||||
<p id={ getValidationErrorId( elementId ) }>{ errorMessage }</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ValidationInputError.propTypes = {
|
||||
errorMessage: PropTypes.string,
|
||||
propertyName: PropTypes.string,
|
||||
elementId: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ValidationInputError;
|
@ -0,0 +1,15 @@
|
||||
.wc-block-components-validation-error {
|
||||
@include font-size(smaller);
|
||||
color: $alert-red;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-select + .wc-block-components-validation-error {
|
||||
margin-bottom: $gap-large;
|
||||
}
|
@ -0,0 +1,257 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
} from '@wordpress/element';
|
||||
import { pickBy } from 'lodash';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
|
||||
/**
|
||||
* @typedef { import('@woocommerce/type-defs/contexts').ValidationContext } ValidationContext
|
||||
* @typedef {import('react')} React
|
||||
*/
|
||||
|
||||
const ValidationContext = createContext( {
|
||||
getValidationError: () => '',
|
||||
setValidationErrors: ( errors ) => void errors,
|
||||
clearValidationError: ( property ) => void property,
|
||||
clearAllValidationErrors: () => void null,
|
||||
hideValidationError: () => void null,
|
||||
showValidationError: () => void null,
|
||||
showAllValidationErrors: () => void null,
|
||||
hasValidationErrors: false,
|
||||
getValidationErrorId: ( errorId ) => errorId,
|
||||
} );
|
||||
|
||||
/**
|
||||
* @return {ValidationContext} The context values for the validation context.
|
||||
*/
|
||||
export const useValidationContext = () => {
|
||||
return useContext( ValidationContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation context provider
|
||||
*
|
||||
* Any children of this context will be exposed to validation state and helpers
|
||||
* for tracking validation.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {JSX.Element} props.children What react elements are wrapped by this component.
|
||||
*/
|
||||
export const ValidationContextProvider = ( { children } ) => {
|
||||
const [ validationErrors, updateValidationErrors ] = useState( {} );
|
||||
|
||||
/**
|
||||
* This retrieves any validation error message that exists in state for the
|
||||
* given property name.
|
||||
*
|
||||
* @param {string} property The property the error message is for.
|
||||
*
|
||||
* @return {Object} The error object for the given property.
|
||||
*/
|
||||
const getValidationError = useCallback(
|
||||
( property ) => validationErrors[ property ],
|
||||
[ validationErrors ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Provides an id for the validation error that can be used to fill out
|
||||
* aria-describedby attribute values.
|
||||
*
|
||||
* @param {string} errorId The input css id the validation error is related
|
||||
* to.
|
||||
* @return {string} The id to use for the validation error container.
|
||||
*/
|
||||
const getValidationErrorId = useCallback(
|
||||
( errorId ) => {
|
||||
const error = validationErrors[ errorId ];
|
||||
if ( ! error || error.hidden ) {
|
||||
return '';
|
||||
}
|
||||
return `validate-error-${ errorId }`;
|
||||
},
|
||||
[ validationErrors ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Clears any validation error that exists in state for the given property
|
||||
* name.
|
||||
*
|
||||
* @param {string} property The name of the property to clear if exists in
|
||||
* validation error state.
|
||||
*/
|
||||
const clearValidationError = useCallback(
|
||||
/**
|
||||
* Callback that is memoized.
|
||||
*
|
||||
* @param {string} property
|
||||
*/
|
||||
( property ) => {
|
||||
updateValidationErrors(
|
||||
/**
|
||||
* Callback for validation Errors handling.
|
||||
*
|
||||
* @param {Object} prevErrors
|
||||
*/
|
||||
( prevErrors ) => {
|
||||
if ( ! prevErrors[ property ] ) {
|
||||
return prevErrors;
|
||||
}
|
||||
|
||||
const {
|
||||
// eslint-disable-next-line no-unused-vars -- this is intentional to omit the dynamic property from the returned object.
|
||||
[ property ]: clearedProperty,
|
||||
...newErrors
|
||||
} = prevErrors;
|
||||
return newErrors;
|
||||
}
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Clears the entire validation error state.
|
||||
*/
|
||||
const clearAllValidationErrors = useCallback(
|
||||
() => void updateValidationErrors( {} ),
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Used to record new validation errors in the state.
|
||||
*
|
||||
* @param {Object} newErrors An object where keys are the property names the
|
||||
* validation error is for and values are the
|
||||
* validation error message displayed to the user.
|
||||
*/
|
||||
const setValidationErrors = useCallback( ( newErrors ) => {
|
||||
if ( ! newErrors ) {
|
||||
return;
|
||||
}
|
||||
updateValidationErrors( ( prevErrors ) => {
|
||||
newErrors = pickBy( newErrors, ( error, property ) => {
|
||||
if ( typeof error.message !== 'string' ) {
|
||||
return false;
|
||||
}
|
||||
if ( prevErrors.hasOwnProperty( property ) ) {
|
||||
return ! isShallowEqual( prevErrors[ property ], error );
|
||||
}
|
||||
return true;
|
||||
} );
|
||||
if ( Object.values( newErrors ).length === 0 ) {
|
||||
return prevErrors;
|
||||
}
|
||||
return {
|
||||
...prevErrors,
|
||||
...newErrors,
|
||||
};
|
||||
} );
|
||||
}, [] );
|
||||
|
||||
/**
|
||||
* Used to update a validation error.
|
||||
*
|
||||
* @param {string} property The name of the property to update.
|
||||
* @param {Object} newError New validation error object.
|
||||
*/
|
||||
const updateValidationError = useCallback( ( property, newError ) => {
|
||||
updateValidationErrors( ( prevErrors ) => {
|
||||
if ( ! prevErrors.hasOwnProperty( property ) ) {
|
||||
return prevErrors;
|
||||
}
|
||||
const updatedError = {
|
||||
...prevErrors[ property ],
|
||||
...newError,
|
||||
};
|
||||
return isShallowEqual( prevErrors[ property ], updatedError )
|
||||
? prevErrors
|
||||
: {
|
||||
...prevErrors,
|
||||
[ property ]: updatedError,
|
||||
};
|
||||
} );
|
||||
}, [] );
|
||||
|
||||
/**
|
||||
* Given a property name and if an associated error exists, it sets its
|
||||
* `hidden` value to true.
|
||||
*
|
||||
* @param {string} property The name of the property to set the `hidden`
|
||||
* value to true.
|
||||
*/
|
||||
const hideValidationError = useCallback(
|
||||
( property ) =>
|
||||
void updateValidationError( property, {
|
||||
hidden: true,
|
||||
} ),
|
||||
[ updateValidationError ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Given a property name and if an associated error exists, it sets its
|
||||
* `hidden` value to false.
|
||||
*
|
||||
* @param {string} property The name of the property to set the `hidden`
|
||||
* value to false.
|
||||
*/
|
||||
const showValidationError = useCallback(
|
||||
( property ) =>
|
||||
void updateValidationError( property, {
|
||||
hidden: false,
|
||||
} ),
|
||||
[ updateValidationError ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the `hidden` value of all errors to `false`.
|
||||
*/
|
||||
const showAllValidationErrors = useCallback(
|
||||
() =>
|
||||
void updateValidationErrors( ( prevErrors ) => {
|
||||
const updatedErrors = {};
|
||||
|
||||
Object.keys( prevErrors ).forEach( ( property ) => {
|
||||
if ( prevErrors[ property ].hidden ) {
|
||||
updatedErrors[ property ] = {
|
||||
...prevErrors[ property ],
|
||||
hidden: false,
|
||||
};
|
||||
}
|
||||
} );
|
||||
|
||||
if ( Object.values( updatedErrors ).length === 0 ) {
|
||||
return prevErrors;
|
||||
}
|
||||
|
||||
return {
|
||||
...prevErrors,
|
||||
...updatedErrors,
|
||||
};
|
||||
} ),
|
||||
[]
|
||||
);
|
||||
|
||||
const context = {
|
||||
getValidationError,
|
||||
setValidationErrors,
|
||||
clearValidationError,
|
||||
clearAllValidationErrors,
|
||||
hideValidationError,
|
||||
showValidationError,
|
||||
showAllValidationErrors,
|
||||
hasValidationErrors: Object.keys( validationErrors ).length > 0,
|
||||
getValidationErrorId,
|
||||
};
|
||||
|
||||
return (
|
||||
<ValidationContext.Provider value={ context }>
|
||||
{ children }
|
||||
</ValidationContext.Provider>
|
||||
);
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user