Experimental newsletter subscription checkbox block POC and Store API supporting changes (https://github.com/woocommerce/woocommerce-blocks/pull/4607)

* remove todo from sample block

* Add newsletter block

* Block registration

* Move provider/processor so separate them from context providers

* customData implementation for setting customData for requests

* Make data and schema callbacks optional in extendrestapi class

* schema_type should be data_type

* Allow checkout endpoint to be extended

* Support validation, sanitization, and defaults on nested REST properties

* Experimental endpoint data for newsletter field

* Add extension data to requests

* SET_EXTENSION_DATA

* Update types

* Add todo

* move check within hook function

* Remove newsletter block

This is because we're testing with the integration being done in a separate extension

* Delete newsletter subscription block

* Pass the result of hooks down to the children blocks

We need to do this to allow extension blocks to modify the extensionData (so they can send custom input to the REST api when submitting the checkout form).

* Remove newsletter signup block

* remove checkoutSubmitData

Co-authored-by: Thomas Roberts <thomas.roberts@automattic.com>
Co-authored-by: Nadir Seghir <nadir.seghir@gmail.com>
This commit is contained in:
Mike Jolley 2021-08-30 15:35:20 +01:00 committed by GitHub
parent 819ed4abb2
commit 64fffd7051
25 changed files with 474 additions and 276 deletions

View File

@ -12,3 +12,4 @@ export * from './use-checkout-address';
export * from './use-checkout-notices'; export * from './use-checkout-notices';
export * from './use-checkout-submit'; export * from './use-checkout-submit';
export * from './use-emit-response'; export * from './use-emit-response';
export * from './use-checkout-extension-data';

View File

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

View File

@ -1,7 +1,7 @@
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { CheckoutProvider } from '../checkout'; import { CheckoutProvider } from '../checkout-provider';
/** /**
* Cart provider * Cart provider

View File

@ -18,20 +18,18 @@ import {
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { preparePaymentData } from './utils'; import { preparePaymentData, processCheckoutResponseHeaders } from './utils';
import { useCheckoutContext } from '../../checkout-state'; import { useCheckoutContext } from './checkout-state';
import { useShippingDataContext } from '../../shipping'; import { useShippingDataContext } from './shipping';
import { useCustomerDataContext } from '../../customer'; import { useCustomerDataContext } from './customer';
import { usePaymentMethodDataContext } from '../../payment-methods'; import { usePaymentMethodDataContext } from './payment-methods';
import { useValidationContext } from '../../../validation'; import { useValidationContext } from '../validation';
import { useStoreCart } from '../../../../hooks/cart/use-store-cart'; import { useStoreCart } from '../../hooks/cart/use-store-cart';
import { useStoreNotices } from '../../../../hooks/use-store-notices'; import { useStoreNotices } from '../../hooks/use-store-notices';
/** /**
* CheckoutProcessor component. * CheckoutProcessor component.
* *
* @todo Needs to consume all contexts.
*
* Subscribes to checkout context and triggers processing via the API. * Subscribes to checkout context and triggers processing via the API.
*/ */
const CheckoutProcessor = () => { const CheckoutProcessor = () => {
@ -45,6 +43,7 @@ const CheckoutProcessor = () => {
isComplete: checkoutIsComplete, isComplete: checkoutIsComplete,
orderNotes, orderNotes,
shouldCreateAccount, shouldCreateAccount,
extensionData,
} = useCheckoutContext(); } = useCheckoutContext();
const { hasValidationErrors } = useValidationContext(); const { hasValidationErrors } = useValidationContext();
const { shippingErrorStatus } = useShippingDataContext(); const { shippingErrorStatus } = useShippingDataContext();
@ -75,11 +74,18 @@ const CheckoutProcessor = () => {
currentPaymentStatus.hasError || currentPaymentStatus.hasError ||
shippingErrorStatus.hasError; shippingErrorStatus.hasError;
const paidAndWithoutErrors =
! checkoutHasError &&
! checkoutWillHaveError &&
( currentPaymentStatus.isSuccessful || ! cartNeedsPayment ) &&
checkoutIsProcessing;
// If express payment method is active, let's suppress notices // If express payment method is active, let's suppress notices
useEffect( () => { useEffect( () => {
setIsSuppressed( isExpressPaymentMethodActive ); setIsSuppressed( isExpressPaymentMethodActive );
}, [ isExpressPaymentMethodActive, setIsSuppressed ] ); }, [ isExpressPaymentMethodActive, setIsSuppressed ] );
// Determine if checkout has an error.
useEffect( () => { useEffect( () => {
if ( if (
checkoutWillHaveError !== checkoutHasError && checkoutWillHaveError !== checkoutHasError &&
@ -97,12 +103,6 @@ const CheckoutProcessor = () => {
dispatchActions, dispatchActions,
] ); ] );
const paidAndWithoutErrors =
! checkoutHasError &&
! checkoutWillHaveError &&
( currentPaymentStatus.isSuccessful || ! cartNeedsPayment ) &&
checkoutIsProcessing;
useEffect( () => { useEffect( () => {
currentBillingData.current = billingData; currentBillingData.current = billingData;
currentShippingAddress.current = shippingAddress; currentShippingAddress.current = shippingAddress;
@ -156,10 +156,32 @@ const CheckoutProcessor = () => {
isExpressPaymentMethodActive, isExpressPaymentMethodActive,
] ); ] );
const processOrder = useCallback( () => { // 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 ); setIsProcessingOrder( true );
removeNotice( 'checkout' ); removeNotice( 'checkout' );
let data = {
const paymentData = cartNeedsPayment
? {
payment_method: paymentMethodId,
payment_data: preparePaymentData(
paymentMethodData,
shouldSavePayment,
activePaymentMethod
),
}
: {};
const data = {
billing_address: emptyHiddenAddressFields( billing_address: emptyHiddenAddressFields(
currentBillingData.current currentBillingData.current
), ),
@ -168,18 +190,10 @@ const CheckoutProcessor = () => {
), ),
customer_note: orderNotes, customer_note: orderNotes,
should_create_account: shouldCreateAccount, should_create_account: shouldCreateAccount,
...paymentData,
extensions: { ...extensionData },
}; };
if ( cartNeedsPayment ) {
data = {
...data,
payment_method: paymentMethodId,
payment_data: preparePaymentData(
paymentMethodData,
shouldSavePayment,
activePaymentMethod
),
};
}
triggerFetch( { triggerFetch( {
path: '/wc/store/checkout', path: '/wc/store/checkout',
method: 'POST', method: 'POST',
@ -187,43 +201,26 @@ const CheckoutProcessor = () => {
cache: 'no-store', cache: 'no-store',
parse: false, parse: false,
} ) } )
.then( ( fetchResponse ) => { .then( ( response ) => {
// Update nonce. processCheckoutResponseHeaders(
triggerFetch.setNonce( fetchResponse.headers ); response.headers,
dispatchActions
// Update user using headers.
dispatchActions.setCustomerId(
fetchResponse.headers.get( 'X-WC-Store-API-User' )
); );
if ( ! response.ok ) {
// Handle response. throw new Error( response );
fetchResponse.json().then( function ( response ) {
if ( ! fetchResponse.ok ) {
// We received an error response.
addErrorNotice(
formatStoreApiErrorMessage( response ),
{
id: 'checkout',
}
);
dispatchActions.setHasError();
} }
return response.json();
} )
.then( ( response ) => {
dispatchActions.setAfterProcessing( response ); dispatchActions.setAfterProcessing( response );
setIsProcessingOrder( false ); setIsProcessingOrder( false );
} );
} ) } )
.catch( ( errorResponse ) => { .catch( ( fetchResponse ) => {
// Update nonce. processCheckoutResponseHeaders(
triggerFetch.setNonce( errorResponse.headers ); fetchResponse.headers,
dispatchActions
// If new customer ID returned, update the store.
if ( errorResponse.headers?.get( 'X-WC-Store-API-User' ) ) {
dispatchActions.setCustomerId(
errorResponse.headers.get( 'X-WC-Store-API-User' )
); );
} fetchResponse.json().then( ( response ) => {
errorResponse.json().then( function ( response ) {
// If updated cart state was returned, update the store. // If updated cart state was returned, update the store.
if ( response.data?.cart ) { if ( response.data?.cart ) {
receiveCart( response.data.cart ); receiveCart( response.data.cart );
@ -231,7 +228,6 @@ const CheckoutProcessor = () => {
addErrorNotice( formatStoreApiErrorMessage( response ), { addErrorNotice( formatStoreApiErrorMessage( response ), {
id: 'checkout', id: 'checkout',
} ); } );
response.additional_errors?.forEach?.( response.additional_errors?.forEach?.(
( additionalError ) => { ( additionalError ) => {
addErrorNotice( additionalError.message, { addErrorNotice( additionalError.message, {
@ -239,31 +235,26 @@ const CheckoutProcessor = () => {
} ); } );
} }
); );
dispatchActions.setHasError( true );
dispatchActions.setHasError();
dispatchActions.setAfterProcessing( response ); dispatchActions.setAfterProcessing( response );
setIsProcessingOrder( false ); setIsProcessingOrder( false );
} ); } );
} ); } );
}, [ }, [
addErrorNotice, isProcessingOrder,
removeNotice, removeNotice,
paymentMethodId,
activePaymentMethod,
paymentMethodData,
shouldSavePayment,
cartNeedsPayment,
receiveCart,
dispatchActions,
orderNotes, orderNotes,
shouldCreateAccount, shouldCreateAccount,
cartNeedsPayment,
paymentMethodId,
paymentMethodData,
shouldSavePayment,
activePaymentMethod,
extensionData,
dispatchActions,
addErrorNotice,
receiveCart,
] ); ] );
// redirect when checkout is complete and there is a redirect url.
useEffect( () => {
if ( currentRedirectUrl.current ) {
window.location.href = currentRedirectUrl.current;
}
}, [ checkoutIsComplete ] );
// process order if conditions are good. // process order if conditions are good.
useEffect( () => { useEffect( () => {

View File

@ -7,11 +7,11 @@ import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundar
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { PaymentMethodDataProvider } from '../payment-methods'; import { PaymentMethodDataProvider } from './payment-methods';
import { ShippingDataProvider } from '../shipping'; import { ShippingDataProvider } from './shipping';
import { CustomerDataProvider } from '../customer'; import { CustomerDataProvider } from './customer';
import { CheckoutStateProvider } from '../checkout-state'; import { CheckoutStateProvider } from './checkout-state';
import CheckoutProcessor from './processor'; import CheckoutProcessor from './checkout-processor';
/** /**
* Checkout provider * Checkout provider

View File

@ -1,7 +1,7 @@
/** /**
* Internal dependencies * Internal dependencies
*/ */
import type { PaymentResultDataType } from './types'; import type { PaymentResultDataType, CheckoutStateContextState } from './types';
export enum ACTION { export enum ACTION {
SET_IDLE = 'set_idle', SET_IDLE = 'set_idle',
@ -20,20 +20,15 @@ export enum ACTION {
INCREMENT_CALCULATING = 'increment_calculating', INCREMENT_CALCULATING = 'increment_calculating',
DECREMENT_CALCULATING = 'decrement_calculating', DECREMENT_CALCULATING = 'decrement_calculating',
SET_SHOULD_CREATE_ACCOUNT = 'set_should_create_account', SET_SHOULD_CREATE_ACCOUNT = 'set_should_create_account',
SET_EXTENSION_DATA = 'set_extension_data',
} }
export interface ActionType { export interface ActionType extends Partial< CheckoutStateContextState > {
type: ACTION; type: ACTION;
data?: data?:
| Record< string, unknown > | Record< string, unknown >
| Record< string, never > | Record< string, never >
| PaymentResultDataType; | PaymentResultDataType;
url?: string;
customerId?: number;
orderId?: number;
shouldCreateAccount?: boolean;
hasError?: boolean;
orderNotes?: string;
} }
/** /**
@ -52,10 +47,10 @@ export const actions = {
( { ( {
type: ACTION.SET_PROCESSING, type: ACTION.SET_PROCESSING,
} as const ), } as const ),
setRedirectUrl: ( url: string ) => setRedirectUrl: ( redirectUrl: string ) =>
( { ( {
type: ACTION.SET_REDIRECT_URL, type: ACTION.SET_REDIRECT_URL,
url, redirectUrl,
} as const ), } as const ),
setProcessingResponse: ( data: PaymentResultDataType ) => setProcessingResponse: ( data: PaymentResultDataType ) =>
( { ( {
@ -107,4 +102,11 @@ export const actions = {
type: ACTION.SET_ORDER_NOTES, type: ACTION.SET_ORDER_NOTES,
orderNotes, orderNotes,
} as const ), } as const ),
setExtensionData: (
extensionData: Record< string, Record< string, unknown > >
) =>
( {
type: ACTION.SET_EXTENSION_DATA,
extensionData,
} as const ),
}; };

View File

@ -48,6 +48,7 @@ export const DEFAULT_CHECKOUT_STATE_DATA: CheckoutStateContextType = {
setCustomerId: ( id ) => void id, setCustomerId: ( id ) => void id,
setOrderId: ( id ) => void id, setOrderId: ( id ) => void id,
setOrderNotes: ( orderNotes ) => void orderNotes, setOrderNotes: ( orderNotes ) => void orderNotes,
setExtensionData: ( extensionData ) => void extensionData,
}, },
onSubmit: () => void null, onSubmit: () => void null,
isComplete: false, isComplete: false,
@ -69,6 +70,7 @@ export const DEFAULT_CHECKOUT_STATE_DATA: CheckoutStateContextType = {
isCart: false, isCart: false,
shouldCreateAccount: false, shouldCreateAccount: false,
setShouldCreateAccount: ( value ) => void value, setShouldCreateAccount: ( value ) => void value,
extensionData: {},
}; };
export const DEFAULT_STATE: CheckoutStateContextState = { export const DEFAULT_STATE: CheckoutStateContextState = {
@ -81,4 +83,5 @@ export const DEFAULT_STATE: CheckoutStateContextState = {
customerId: checkoutData.customer_id, customerId: checkoutData.customer_id,
shouldCreateAccount: false, shouldCreateAccount: false,
processingResponse: null, processingResponse: null,
extensionData: {},
}; };

View File

@ -142,6 +142,8 @@ export const CheckoutStateProvider = ( {
void dispatch( actions.setOrderId( orderId ) ), void dispatch( actions.setOrderId( orderId ) ),
setOrderNotes: ( orderNotes ) => setOrderNotes: ( orderNotes ) =>
void dispatch( actions.setOrderNotes( orderNotes ) ), void dispatch( actions.setOrderNotes( orderNotes ) ),
setExtensionData: ( extensionData ) =>
void dispatch( actions.setExtensionData( extensionData ) ),
setAfterProcessing: ( response ) => { setAfterProcessing: ( response ) => {
const paymentResult = getPaymentResultFromCheckoutResponse( const paymentResult = getPaymentResultFromCheckoutResponse(
response response
@ -385,6 +387,7 @@ export const CheckoutStateProvider = ( {
shouldCreateAccount: checkoutState.shouldCreateAccount, shouldCreateAccount: checkoutState.shouldCreateAccount,
setShouldCreateAccount: ( value ) => setShouldCreateAccount: ( value ) =>
dispatch( actions.setShouldCreateAccount( value ) ), dispatch( actions.setShouldCreateAccount( value ) ),
extensionData: checkoutState.extensionData,
}; };
return ( return (
<CheckoutContext.Provider value={ checkoutData }> <CheckoutContext.Provider value={ checkoutData }>

View File

@ -7,25 +7,16 @@ import type { CheckoutStateContextState, PaymentResultDataType } from './types';
/** /**
* Reducer for the checkout state * Reducer for the checkout state
*
* @param {Object} state Current state.
* @param {Object} action Incoming action object.
* @param {string} action.url URL passed in.
* @param {string} action.type Type of action.
* @param {string} action.customerId Customer ID.
* @param {string} action.orderId Order ID.
* @param {Array} action.orderNotes Order notes.
* @param {boolean} action.shouldCreateAccount True if shopper has requested a user account (sign-up checkbox).
* @param {Object} action.data Other action payload.
*/ */
export const reducer = ( export const reducer = (
state = DEFAULT_STATE, state = DEFAULT_STATE,
{ {
url, redirectUrl,
type, type,
customerId, customerId,
orderId, orderId,
orderNotes, orderNotes,
extensionData,
shouldCreateAccount, shouldCreateAccount,
data, data,
}: ActionType }: ActionType
@ -46,10 +37,10 @@ export const reducer = (
break; break;
case ACTION.SET_REDIRECT_URL: case ACTION.SET_REDIRECT_URL:
newState = newState =
url !== undefined && url !== state.redirectUrl redirectUrl !== undefined && redirectUrl !== state.redirectUrl
? { ? {
...state, ...state,
redirectUrl: url, redirectUrl,
} }
: state; : state;
break; break;
@ -183,6 +174,17 @@ export const reducer = (
}; };
} }
break; 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 // automatically update state to idle from pristine as soon as it
// initially changes. // initially changes.

View File

@ -33,7 +33,11 @@ export interface PaymentResultDataType {
redirectUrl: string; redirectUrl: string;
} }
export type CheckoutStateContextState = { type extensionDataNamespace = string;
type extensionDataItem = Record< string, unknown >;
export type extensionData = Record< extensionDataNamespace, extensionDataItem >;
export interface CheckoutStateContextState {
redirectUrl: string; redirectUrl: string;
status: STATUS; status: STATUS;
hasError: boolean; hasError: boolean;
@ -43,7 +47,8 @@ export type CheckoutStateContextState = {
customerId: number; customerId: number;
shouldCreateAccount: boolean; shouldCreateAccount: boolean;
processingResponse: PaymentResultDataType | null; processingResponse: PaymentResultDataType | null;
}; extensionData: extensionData;
}
export type CheckoutStateDispatchActions = { export type CheckoutStateDispatchActions = {
resetCheckout: () => void; resetCheckout: () => void;
@ -55,6 +60,7 @@ export type CheckoutStateDispatchActions = {
setCustomerId: ( id: number ) => void; setCustomerId: ( id: number ) => void;
setOrderId: ( id: number ) => void; setOrderId: ( id: number ) => void;
setOrderNotes: ( orderNotes: string ) => void; setOrderNotes: ( orderNotes: string ) => void;
setExtensionData: ( extensionData: extensionData ) => void;
}; };
export type CheckoutStateContextType = { export type CheckoutStateContextType = {
@ -74,16 +80,6 @@ export type CheckoutStateContextType = {
isBeforeProcessing: boolean; isBeforeProcessing: boolean;
// True when checkout status is AFTER_PROCESSING. // True when checkout status is AFTER_PROCESSING.
isAfterProcessing: boolean; isAfterProcessing: boolean;
// True when the checkout is in an error state. Whatever caused the error (validation/payment method) will likely have triggered a notice.
hasError: boolean;
// This is the url that checkout will redirect to when it's ready.
redirectUrl: string;
// This is the ID for the draft order if one exists.
orderId: number;
// Order notes introduced by the user in the checkout form.
orderNotes: string;
// This is the ID of the customer the draft order belongs to.
customerId: number;
// Used to register a callback that will fire after checkout has been processed and there are no errors. // Used to register a callback that will fire after checkout has been processed and there are no errors.
onCheckoutAfterProcessingWithSuccess: ReturnType< typeof emitterCallback >; onCheckoutAfterProcessingWithSuccess: ReturnType< typeof emitterCallback >;
// Used to register a callback that will fire when the checkout has been processed and has an error. // Used to register a callback that will fire when the checkout has been processed and has an error.
@ -92,12 +88,24 @@ export type CheckoutStateContextType = {
onCheckoutBeforeProcessing: ReturnType< typeof emitterCallback >; 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. // 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 >; 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. // True when the checkout has a draft order from the API.
hasOrder: boolean; hasOrder: boolean;
// When true, means the provider is providing data for the cart. // When true, means the provider is providing data for the cart.
isCart: boolean; 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? // Should a user account be created?
shouldCreateAccount: boolean; shouldCreateAccount: CheckoutStateContextState[ 'shouldCreateAccount' ];
// Set if user account should be created. // Custom checkout data passed to the store API on processing.
setShouldCreateAccount: ( shouldCreateAccount: boolean ) => void; extensionData: CheckoutStateContextState[ 'extensionData' ];
}; };

View File

@ -1,30 +0,0 @@
/**
* @typedef {import('@woocommerce/type-defs/payments').PaymentDataItem} PaymentDataItem
*/
/**
* Utility function for preparing payment data for the request.
*
* @param {Object} paymentData Arbitrary payment data provided by the payment method.
* @param {boolean} shouldSave Whether to save the payment method info to user account.
* @param {Object} activePaymentMethod The current active payment method.
*
* @return {PaymentDataItem[]} Returns the payment data as an array of
* PaymentDataItem objects.
*/
export const preparePaymentData = (
paymentData,
shouldSave,
activePaymentMethod
) => {
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;
};

View File

@ -1,6 +1,7 @@
export * from './payment-methods'; export * from './payment-methods';
export * from './shipping'; export * from './shipping';
export * from './customer'; export * from './customer';
export * from './checkout'; export * from './checkout-state';
export * from './cart'; export * from './cart';
export { useCheckoutContext } from './checkout-state'; export * from './checkout-processor';
export * from './checkout-provider';

View File

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

View File

@ -4,6 +4,7 @@
import { Children, cloneElement, isValidElement } from '@wordpress/element'; import { Children, cloneElement, isValidElement } from '@wordpress/element';
import { getValidBlockAttributes } from '@woocommerce/base-utils'; import { getValidBlockAttributes } from '@woocommerce/base-utils';
import { useStoreCart } from '@woocommerce/base-context'; import { useStoreCart } from '@woocommerce/base-context';
import { useCheckoutExtensionData } from '@woocommerce/base-context/hooks';
import { getRegisteredBlockComponents } from '@woocommerce/blocks-registry'; import { getRegisteredBlockComponents } from '@woocommerce/blocks-registry';
import { import {
withStoreCartApiHydration, withStoreCartApiHydration,
@ -36,12 +37,14 @@ const Wrapper = ( {
// we need to pluck out receiveCart. // we need to pluck out receiveCart.
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const { extensions, receiveCart, ...cart } = useStoreCart(); const { extensions, receiveCart, ...cart } = useStoreCart();
const checkoutExtensionData = useCheckoutExtensionData();
return Children.map( children, ( child ) => { return Children.map( children, ( child ) => {
if ( isValidElement( child ) ) { if ( isValidElement( child ) ) {
const componentProps = { const componentProps = {
extensions, extensions,
cart, cart,
checkoutExtensionData,
}; };
return cloneElement( child, componentProps ); return cloneElement( child, componentProps );
} }

View File

@ -16,7 +16,6 @@ __webpack_public_path__ = WC_BLOCKS_BUILD_URL;
*/ */
import { Edit, Save } from './edit'; import { Edit, Save } from './edit';
// @todo Sample block should only be visible in correct areas, not top level.
registerCheckoutBlock( 'woocommerce/checkout-sample-block', { registerCheckoutBlock( 'woocommerce/checkout-sample-block', {
component: lazy( () => component: lazy( () =>
import( /* webpackChunkName: "checkout-blocks/sample" */ './frontend' ) import( /* webpackChunkName: "checkout-blocks/sample" */ './frontend' )

View File

@ -13,4 +13,3 @@ import './checkout-order-summary-block';
import './checkout-payment-block'; import './checkout-payment-block';
import './checkout-express-payment-block'; import './checkout-express-payment-block';
import './checkout-shipping-methods-block'; import './checkout-shipping-methods-block';
import './checkout-sample-block';

View File

@ -9,6 +9,11 @@ import { WC_BLOCKS_BUILD_URL } from '@woocommerce/block-settings';
// eslint-disable-next-line no-undef,camelcase // eslint-disable-next-line no-undef,camelcase
__webpack_public_path__ = WC_BLOCKS_BUILD_URL; __webpack_public_path__ = WC_BLOCKS_BUILD_URL;
/**
* Internal dependencies
*/
import './checkout-sample-block';
registerBlockComponent( { registerBlockComponent( {
blockName: 'woocommerce/checkout-fields-block', blockName: 'woocommerce/checkout-fields-block',
component: lazy( () => component: lazy( () =>

View File

@ -1,15 +1,18 @@
# Exposing your data in the Store API. # Exposing your data in the Store API.
## The problem ## The problem
You want to extend the Cart and Checkout blocks, but you want to use some custom data not available on Store API or the context. You want to extend the Cart and Checkout blocks, but you want to use some custom data not available on Store API or the context.
You don't want to create your own endpoints or Ajax actions. You want to piggyback on the existing StoreAPI calls. You don't want to create your own endpoints or Ajax actions. You want to piggyback on the existing StoreAPI calls.
## Solution ## Solution
ExtendRestApi offers the possibility to add contextual custom data to Store API endpoints, like `wc/store/cart` and `wc/store/cart/items` endpoints. ExtendRestApi offers the possibility to add contextual custom data to Store API endpoints, like `wc/store/cart` and `wc/store/cart/items` endpoints.
That data is namespaced to your plugin and protected from other plugins causing it to malfunction. That data is namespaced to your plugin and protected from other plugins causing it to malfunction.
The data is available on all frontend filters and slotFills for you to consume. The data is available on all frontend filters and slotFills for you to consume.
## Basic usage ## Basic usage
You can use ExtendRestApi by registering a couple of functions, `schema_callback` and `data_callback` on a specific endpoint namespace. ExtendRestApi will call them at execution time and will pass them relevant data as well. You can use ExtendRestApi by registering a couple of functions, `schema_callback` and `data_callback` on a specific endpoint namespace. ExtendRestApi will call them at execution time and will pass them relevant data as well.
This example below uses the Cart endpoint, [see passed parameters.](./available-endpoints-to-extend.md#wcstorecart) This example below uses the Cart endpoint, [see passed parameters.](./available-endpoints-to-extend.md#wcstorecart)
@ -76,6 +79,7 @@ $product = $cart_item['data'];
## Things To Consider ## Things To Consider
### ExtendRestApi is a shared instance ### ExtendRestApi is a shared instance
The ExtendRestApi is stored as a shared instance between the API and consumers (third-party developers). So you shouldn't initiate the class yourself with `new ExtendRestApi` because it would not work. The ExtendRestApi is stored as a shared instance between the API and consumers (third-party developers). So you shouldn't initiate the class yourself with `new ExtendRestApi` because it would not work.
Instead, you should always use the shared instance from the Package dependency injection container like this. Instead, you should always use the shared instance from the Package dependency injection container like this.
@ -84,6 +88,7 @@ $extend = Package::container()->get( ExtendRestApi::class );
``` ```
### Dependency injection container is not always available ### Dependency injection container is not always available
You can't call `Package::container()` and expect it to work. The Package class is only available after the `woocommerce_blocks_loaded` action has been fired, so you should hook your file that action You can't call `Package::container()` and expect it to work. The Package class is only available after the `woocommerce_blocks_loaded` action has been fired, so you should hook your file that action
```php ```php
@ -94,10 +99,12 @@ add_action( 'woocommerce_blocks_loaded', function() {
``` ```
### Errors and fatals are silence for non-admins ### Errors and fatals are silence for non-admins
If your callback functions `data_callback` and `schema_callback` throw an exception or an error, or you passed the incorrect type of parameter to `register_endpoint_data`; that error would be caught and logged into WooCommerce error logs. If your callback functions `data_callback` and `schema_callback` throw an exception or an error, or you passed the incorrect type of parameter to `register_endpoint_data`; that error would be caught and logged into WooCommerce error logs.
If the current user is a shop manager or an admin, and has WP_DEBUG enabled, the error would be surfaced to the frontend. If the current user is a shop manager or an admin, and has WP_DEBUG enabled, the error would be surfaced to the frontend.
### Callbacks should always return an array ### Callbacks should always return an array
To reduce the chances of breaking your client code or passing the wrong type, and also to keep a consistent REST API response, callbacks like `data_callback` and `schema_callback` should always return an array, even if it was empty. To reduce the chances of breaking your client code or passing the wrong type, and also to keep a consistent REST API response, callbacks like `data_callback` and `schema_callback` should always return an array, even if it was empty.
## API Definition ## API Definition
@ -105,12 +112,12 @@ To reduce the chances of breaking your client code or passing the wrong type, an
- `ExtendRestApi::register_endpoint_data`: Used to register data to a custom endpoint. It takes an array of arguments: - `ExtendRestApi::register_endpoint_data`: Used to register data to a custom endpoint. It takes an array of arguments:
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| :-------- | :----- | :------: | :------------------------------------ | | :---------------- | :------- | :----------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------- |
| `endpoint` | string | Yes | The endpoint you're trying to extend. It is suggested that you use the `::IDENTIFIER` available on the route Schema class to avoid typos. | | `endpoint` | string | Yes | The endpoint you're trying to extend. It is suggested that you use the `::IDENTIFIER` available on the route Schema class to avoid typos. |
| `namespace` | string | Yes | Your plugin namespace, the data will be available under this namespace in the StoreAPI response. | | `namespace` | string | Yes | Your plugin namespace, the data will be available under this namespace in the StoreAPI response. |
| `data_callback` | callback | Yes | A callback that returns an array with your data. | | `data_callback` | callback | Yes | A callback that returns an array with your data. |
| `schema_callback` | callback | Yes | A callback that returns the shape of your data. | | `schema_callback` | callback | Yes | A callback that returns the shape of your data. |
| `data_type` | string | No (default: `ARRAY_A` ) | The type of your data. If you're adding an object (key => values), it should be `ARRAY_A`. If you're adding a list of items, it should be `ARRAY_N`. | | `schema_type` | string | No (default: `ARRAY_A` ) | The type of your data. If you're adding an object (key => values), it should be `ARRAY_A`. If you're adding a list of items, it should be `ARRAY_N`. |
## Putting it all together ## Putting it all together
@ -318,5 +325,6 @@ class WC_Subscriptions_Extend_Store_Endpoint {
``` ```
## Formatting your data ## Formatting your data
You may wish to use our pre-existing Formatters to ensure your data is passed through the Store API in the You may wish to use our pre-existing Formatters to ensure your data is passed through the Store API in the
correct format. More information on the Formatters can be found in the [StoreApi Formatters documentation](./extend-rest-api-formatters.md). correct format. More information on the Formatters can be found in the [StoreApi Formatters documentation](./extend-rest-api-formatters.md).

View File

@ -157,7 +157,7 @@ export type CheckoutBlockOptions = {
// This is a component to render on the frontend in place of this block, when used. // This is a component to render on the frontend in place of this block, when used.
component: component:
| LazyExoticComponent< React.ComponentType< unknown > > | LazyExoticComponent< React.ComponentType< unknown > >
| JSX.Element; | ( () => JSX.Element );
// Area(s) to add the block to. This can be a single area (string) or an array of areas. // Area(s) to add the block to. This can be a single area (string) or an array of areas.
areas: Array< keyof RegisteredBlocks >; areas: Array< keyof RegisteredBlocks >;
// Standard block configuration object. If not passed, the block will not be registered with WordPress and must be done manually. // Standard block configuration object. If not passed, the block will not be registered with WordPress and must be done manually.

View File

@ -183,6 +183,7 @@ final class BlockTypesController {
'checkout-shipping-methods-block', 'checkout-shipping-methods-block',
'checkout-express-payment-block', 'checkout-express-payment-block',
'checkout-terms-block', 'checkout-terms-block',
'checkout-newsletter-subscription-block',
]; ];
} }
} }

View File

@ -100,7 +100,7 @@ class Bootstrap {
* @return boolean * @return boolean
*/ */
protected function has_core_dependencies() { protected function has_core_dependencies() {
$has_needed_dependencies = class_exists( 'WooCommerce' ); $has_needed_dependencies = class_exists( 'WooCommerce', false );
if ( $has_needed_dependencies ) { if ( $has_needed_dependencies ) {
$plugin_data = \get_file_data( $plugin_data = \get_file_data(
$this->package->get_path( 'woocommerce-gutenberg-products-block.php' ), $this->package->get_path( 'woocommerce-gutenberg-products-block.php' ),
@ -259,7 +259,7 @@ class Bootstrap {
GoogleAnalytics::class, GoogleAnalytics::class,
function( Container $container ) { function( Container $container ) {
// Require Google Analytics Integration to be activated. // Require Google Analytics Integration to be activated.
if ( ! class_exists( 'WC_Google_Analytics_Integration' ) ) { if ( ! class_exists( 'WC_Google_Analytics_Integration', false ) ) {
return; return;
} }
$asset_api = $container->get( AssetApi::class ); $asset_api = $container->get( AssetApi::class );

View File

@ -3,9 +3,6 @@ namespace Automattic\WooCommerce\Blocks\Domain\Services;
use Automattic\WooCommerce\Blocks\Domain\Package; use Automattic\WooCommerce\Blocks\Domain\Package;
use Automattic\WooCommerce\Blocks\StoreApi\Routes\RouteException; use Automattic\WooCommerce\Blocks\StoreApi\Routes\RouteException;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartExtensionsSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartItemSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Formatters; use Automattic\WooCommerce\Blocks\StoreApi\Formatters;
use Throwable; use Throwable;
use Exception; use Exception;
@ -14,6 +11,17 @@ use Exception;
* Service class to provide utility functions to extend REST API. * Service class to provide utility functions to extend REST API.
*/ */
final class ExtendRestApi { final class ExtendRestApi {
/**
* List of Store API schema that is allowed to be extended by extensions.
*
* @var array
*/
private $endpoints = [
\Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartItemSchema::IDENTIFIER,
\Automattic\WooCommerce\Blocks\StoreApi\Schemas\CartSchema::IDENTIFIER,
\Automattic\WooCommerce\Blocks\StoreApi\Schemas\CheckoutSchema::IDENTIFIER,
];
/** /**
* Holds the Package instance * Holds the Package instance
* *
@ -49,13 +57,6 @@ final class ExtendRestApi {
return $this->formatters->$name; return $this->formatters->$name;
} }
/**
* Valid endpoints to extend
*
* @var array
*/
private $endpoints = [ CartItemSchema::IDENTIFIER, CartSchema::IDENTIFIER ];
/** /**
* Data to be extended * Data to be extended
* *
@ -87,7 +88,7 @@ final class ExtendRestApi {
* @type string $namespace Plugin namespace. * @type string $namespace Plugin namespace.
* @type callable $schema_callback Callback executed to add schema data. * @type callable $schema_callback Callback executed to add schema data.
* @type callable $data_callback Callback executed to add endpoint data. * @type callable $data_callback Callback executed to add endpoint data.
* @type string $data_type The type of data, object or array. * @type string $schema_type The type of data, object or array.
* } * }
* *
* @throws Exception On failure to register. * @throws Exception On failure to register.
@ -104,24 +105,24 @@ final class ExtendRestApi {
); );
} }
if ( ! is_callable( $args['schema_callback'] ) ) { if ( isset( $args['schema_callback'] ) && ! is_callable( $args['schema_callback'] ) ) {
$this->throw_exception( '$schema_callback must be a callable function.' ); $this->throw_exception( '$schema_callback must be a callable function.' );
} }
if ( ! is_callable( $args['data_callback'] ) ) { if ( isset( $args['data_callback'] ) && ! is_callable( $args['data_callback'] ) ) {
$this->throw_exception( '$data_callback must be a callable function.' ); $this->throw_exception( '$data_callback must be a callable function.' );
} }
if ( isset( $args['data_type'] ) && ! in_array( $args['data_type'], [ ARRAY_N, ARRAY_A ], true ) ) { if ( isset( $args['schema_type'] ) && ! in_array( $args['schema_type'], [ ARRAY_N, ARRAY_A ], true ) ) {
$this->throw_exception( $this->throw_exception(
sprintf( 'Data type must be either ARRAY_N for a numeric array or ARRAY_A for an object like array. You provided %1$s.', $args['data_type'] ) sprintf( 'Data type must be either ARRAY_N for a numeric array or ARRAY_A for an object like array. You provided %1$s.', $args['schema_type'] )
); );
} }
$this->extend_data[ $args['endpoint'] ][ $args['namespace'] ] = [ $this->extend_data[ $args['endpoint'] ][ $args['namespace'] ] = [
'schema_callback' => $args['schema_callback'], 'schema_callback' => isset( $args['schema_callback'] ) ? $args['schema_callback'] : null,
'data_callback' => $args['data_callback'], 'data_callback' => isset( $args['data_callback'] ) ? $args['data_callback'] : null,
'data_type' => isset( $args['data_type'] ) ? $args['data_type'] : ARRAY_A, 'schema_type' => isset( $args['schema_type'] ) ? $args['schema_type'] : ARRAY_A,
]; ];
return true; return true;
@ -216,6 +217,10 @@ final class ExtendRestApi {
foreach ( $this->extend_data[ $endpoint ] as $namespace => $callbacks ) { foreach ( $this->extend_data[ $endpoint ] as $namespace => $callbacks ) {
$data = []; $data = [];
if ( is_null( $callbacks['data_callback'] ) ) {
continue;
}
try { try {
$data = $callbacks['data_callback']( ...$passed_args ); $data = $callbacks['data_callback']( ...$passed_args );
@ -249,6 +254,10 @@ final class ExtendRestApi {
foreach ( $this->extend_data[ $endpoint ] as $namespace => $callbacks ) { foreach ( $this->extend_data[ $endpoint ] as $namespace => $callbacks ) {
$schema = []; $schema = [];
if ( is_null( $callbacks['schema_callback'] ) ) {
continue;
}
try { try {
$schema = $callbacks['schema_callback']( ...$passed_args ); $schema = $callbacks['schema_callback']( ...$passed_args );
@ -260,7 +269,7 @@ final class ExtendRestApi {
continue; continue;
} }
$schema = $this->format_extensions_properties( $namespace, $schema, $callbacks['data_type'] ); $schema = $this->format_extensions_properties( $namespace, $schema, $callbacks['schema_type'] );
$registered_schema[ $namespace ] = $schema; $registered_schema[ $namespace ] = $schema;
} }
@ -345,27 +354,25 @@ final class ExtendRestApi {
* *
* @param string $namespace Error message or Exception. * @param string $namespace Error message or Exception.
* @param array $schema An error to throw if we have debug enabled and user is admin. * @param array $schema An error to throw if we have debug enabled and user is admin.
* @param string $data_type How should data be shaped. * @param string $schema_type How should data be shaped.
* *
* @return array Formatted schema. * @return array Formatted schema.
*/ */
private function format_extensions_properties( $namespace, $schema, $data_type ) { private function format_extensions_properties( $namespace, $schema, $schema_type ) {
if ( ARRAY_N === $data_type ) { if ( ARRAY_N === $schema_type ) {
return [ return [
/* translators: %s: extension namespace */ /* translators: %s: extension namespace */
'description' => sprintf( __( 'Extension data registered by %s', 'woo-gutenberg-products-block' ), $namespace ), 'description' => sprintf( __( 'Extension data registered by %s', 'woo-gutenberg-products-block' ), $namespace ),
'type' => [ 'array', 'null' ], 'type' => 'array',
'context' => [ 'view', 'edit' ], 'context' => [ 'view', 'edit' ],
'readonly' => true,
'items' => $schema, 'items' => $schema,
]; ];
} }
return [ return [
/* translators: %s: extension namespace */ /* translators: %s: extension namespace */
'description' => sprintf( __( 'Extension data registered by %s', 'woo-gutenberg-products-block' ), $namespace ), 'description' => sprintf( __( 'Extension data registered by %s', 'woo-gutenberg-products-block' ), $namespace ),
'type' => [ 'object', 'null' ], 'type' => 'object',
'context' => [ 'view', 'edit' ], 'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => $schema, 'properties' => $schema,
]; ];
} }

View File

@ -106,7 +106,7 @@ class Api {
*/ */
public function register_payment_method_integrations( PaymentMethodRegistry $payment_method_registry ) { public function register_payment_method_integrations( PaymentMethodRegistry $payment_method_registry ) {
// This is temporarily registering Stripe until it's moved to the extension. // This is temporarily registering Stripe until it's moved to the extension.
if ( class_exists( '\WC_Stripe' ) && ! $payment_method_registry->is_registered( 'stripe' ) ) { if ( class_exists( '\WC_Stripe', false ) && ! $payment_method_registry->is_registered( 'stripe' ) ) {
$payment_method_registry->register( $payment_method_registry->register(
Package::container()->get( Stripe::class ) Package::container()->get( Stripe::class )
); );

View File

@ -56,6 +56,26 @@ abstract class AbstractSchema {
); );
} }
/**
* Recursive removal of arg_options.
*
* @param array $properties Schema properties.
*/
protected function remove_arg_options( $properties ) {
return array_map(
function( $property ) {
if ( isset( $property['properties'] ) ) {
$property['properties'] = $this->remove_arg_options( $property['properties'] );
} elseif ( isset( $property['items']['properties'] ) ) {
$property['items']['properties'] = $this->remove_arg_options( $property['items']['properties'] );
}
unset( $property['arg_options'] );
return $property;
},
(array) $properties
);
}
/** /**
* Returns the public schema. * Returns the public schema.
* *
@ -64,8 +84,8 @@ abstract class AbstractSchema {
public function get_public_item_schema() { public function get_public_item_schema() {
$schema = $this->get_item_schema(); $schema = $this->get_item_schema();
foreach ( $schema['properties'] as &$property ) { if ( isset( $schema['properties'] ) ) {
unset( $property['arg_options'] ); $schema['properties'] = $this->remove_arg_options( $schema['properties'] );
} }
return $schema; return $schema;
@ -82,6 +102,106 @@ abstract class AbstractSchema {
return $this->extend->get_endpoint_data( $endpoint, $passed_args ); return $this->extend->get_endpoint_data( $endpoint, $passed_args );
} }
/**
* Gets an array of schema defaults recursively.
*
* @param array $properties Schema property data.
* @return array Array of defaults, pulled from arg_options
*/
protected function get_recursive_schema_property_defaults( $properties ) {
$defaults = [];
foreach ( $properties as $property_key => $property_value ) {
if ( isset( $property_value['arg_options']['default'] ) ) {
$defaults[ $property_key ] = $property_value['arg_options']['default'];
} elseif ( isset( $property_value['properties'] ) ) {
$defaults[ $property_key ] = $this->get_recursive_schema_property_defaults( $property_value['properties'] );
}
}
return $defaults;
}
/**
* Gets a function that validates recursively.
*
* @param array $properties Schema property data.
* @return function Anonymous validation callback.
*/
protected function get_recursive_validate_callback( $properties ) {
/**
* Validate a request argument based on details registered to the route.
*
* @param mixed $values
* @param \WP_REST_Request $request
* @param string $param
* @return true|\WP_Error
*/
return function ( $values, $request, $param ) use ( $properties ) {
foreach ( $properties as $property_key => $property_value ) {
$current_value = isset( $values[ $property_key ] ) ? $values[ $property_key ] : null;
if ( isset( $property_value['arg_options']['validate_callback'] ) ) {
$callback = $property_value['arg_options']['validate_callback'];
$result = is_callable( $callback ) ? $callback( $current_value, $request, $param ) : false;
} else {
$result = rest_validate_value_from_schema( $current_value, $property_value, $param . ' > ' . $property_key );
}
if ( ! $result || is_wp_error( $result ) ) {
return $result;
}
if ( isset( $property_value['properties'] ) ) {
$validate_callback = $this->get_recursive_validate_callback( $property_value['properties'] );
return $validate_callback( $current_value, $request, $param . ' > ' . $property_key );
}
}
return true;
};
}
/**
* Gets a function that sanitizes recursively.
*
* @param array $properties Schema property data.
* @return function Anonymous validation callback.
*/
protected function get_recursive_sanitize_callback( $properties ) {
/**
* Validate a request argument based on details registered to the route.
*
* @param mixed $values
* @param \WP_REST_Request $request
* @param string $param
* @return true|\WP_Error
*/
return function ( $values, $request, $param ) use ( $properties ) {
foreach ( $properties as $property_key => $property_value ) {
$current_value = isset( $values[ $property_key ] ) ? $values[ $property_key ] : null;
if ( isset( $property_value['arg_options']['sanitize_callback'] ) ) {
$callback = $property_value['arg_options']['sanitize_callback'];
$current_value = is_callable( $callback ) ? $callback( $current_value, $request, $param ) : $current_value;
} else {
$current_value = rest_sanitize_value_from_schema( $current_value, $property_value, $param . ' > ' . $property_key );
}
if ( is_wp_error( $current_value ) ) {
return $current_value;
}
if ( isset( $property_value['properties'] ) ) {
$sanitize_callback = $this->get_recursive_sanitize_callback( $property_value['properties'] );
return $sanitize_callback( $current_value, $request, $param . ' > ' . $property_key );
}
}
return true;
};
}
/** /**
* Returns extended schema for a specific endpoint. * Returns extended schema for a specific endpoint.
* *
@ -90,12 +210,18 @@ abstract class AbstractSchema {
* @return array the data that will get added. * @return array the data that will get added.
*/ */
protected function get_extended_schema( $endpoint, ...$passed_args ) { protected function get_extended_schema( $endpoint, ...$passed_args ) {
$extended_schema = $this->extend->get_endpoint_schema( $endpoint, $passed_args );
$defaults = $this->get_recursive_schema_property_defaults( $extended_schema );
return [ return [
'description' => __( 'Extensions data.', 'woo-gutenberg-products-block' ), 'type' => 'object',
'type' => [ 'object' ],
'context' => [ 'view', 'edit' ], 'context' => [ 'view', 'edit' ],
'readonly' => true, 'arg_options' => [
'properties' => $this->extend->get_endpoint_schema( $endpoint, $passed_args ), 'default' => $defaults,
'validate_callback' => $this->get_recursive_validate_callback( $extended_schema ),
'sanitize_callback' => $this->get_recursive_sanitize_callback( $extended_schema ),
],
'properties' => $extended_schema,
]; ];
} }
@ -119,65 +245,18 @@ abstract class AbstractSchema {
/** /**
* Retrieves an array of endpoint arguments from the item schema for the controller. * Retrieves an array of endpoint arguments from the item schema for the controller.
* *
* @uses rest_get_endpoint_args_for_schema()
* @param string $method Optional. HTTP method of the request. * @param string $method Optional. HTTP method of the request.
* @return array Endpoint arguments. * @return array Endpoint arguments.
*/ */
public function get_endpoint_args_for_item_schema( $method = \WP_REST_Server::CREATABLE ) { public function get_endpoint_args_for_item_schema( $method = \WP_REST_Server::CREATABLE ) {
$schema = $this->get_item_schema(); $schema = $this->get_item_schema();
$schema_properties = ! empty( $schema['properties'] ) ? $schema['properties'] : array(); $endpoint_args = rest_get_endpoint_args_for_schema( $schema, $method );
$endpoint_args = array(); $endpoint_args = $this->remove_arg_options( $endpoint_args );
foreach ( $schema_properties as $field_id => $params ) {
// Arguments specified as `readonly` are not allowed to be set.
if ( ! empty( $params['readonly'] ) ) {
continue;
}
$endpoint_args[ $field_id ] = array(
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'rest_sanitize_request_arg',
);
if ( isset( $params['description'] ) ) {
$endpoint_args[ $field_id ]['description'] = $params['description'];
}
if ( \WP_REST_Server::CREATABLE === $method && isset( $params['default'] ) ) {
$endpoint_args[ $field_id ]['default'] = $params['default'];
}
if ( \WP_REST_Server::CREATABLE === $method && ! empty( $params['required'] ) ) {
$endpoint_args[ $field_id ]['required'] = true;
}
foreach ( array( 'type', 'format', 'enum', 'items', 'properties', 'additionalProperties' ) as $schema_prop ) {
if ( isset( $params[ $schema_prop ] ) ) {
$endpoint_args[ $field_id ][ $schema_prop ] = $params[ $schema_prop ];
}
}
// Merge in any options provided by the schema property.
if ( isset( $params['arg_options'] ) ) {
// Only use required / default from arg_options on CREATABLE endpoints.
if ( \WP_REST_Server::CREATABLE !== $method ) {
$params['arg_options'] = array_diff_key(
$params['arg_options'],
array(
'required' => '',
'default' => '',
)
);
}
$endpoint_args[ $field_id ] = array_merge( $endpoint_args[ $field_id ], $params['arg_options'] );
}
}
return $endpoint_args; return $endpoint_args;
} }
/** /**
* Force all schema properties to be readonly. * Force all schema properties to be readonly.
* *
@ -193,7 +272,7 @@ abstract class AbstractSchema {
} }
return $property; return $property;
}, },
$properties (array) $properties
); );
} }

View File

@ -155,6 +155,7 @@ class CheckoutSchema extends AbstractSchema {
], ],
], ],
], ],
self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ),
]; ];
} }
@ -190,6 +191,7 @@ class CheckoutSchema extends AbstractSchema {
'payment_details' => $this->prepare_payment_details_for_response( $payment_result->payment_details ), 'payment_details' => $this->prepare_payment_details_for_response( $payment_result->payment_details ),
'redirect_url' => $payment_result->redirect_url, 'redirect_url' => $payment_result->redirect_url,
], ],
self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER ),
]; ];
} }