initial commit

This commit is contained in:
2021-12-10 12:03:04 +00:00
commit c46c7ddbf0
3643 changed files with 582794 additions and 0 deletions

View File

@ -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,
} ),
};

View File

@ -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',
};

View File

@ -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 };

View File

@ -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>
);
};

View File

@ -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;
};

View File

@ -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>
);
};

View File

@ -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;

View File

@ -0,0 +1,2 @@
export * from './form';
export * from './form-state';