diff --git a/assets/js/base/components/payment-methods/express-checkout.js b/assets/js/base/components/payment-methods/express-checkout.js index 59b6e428538..2c9e12b7144 100644 --- a/assets/js/base/components/payment-methods/express-checkout.js +++ b/assets/js/base/components/payment-methods/express-checkout.js @@ -2,6 +2,7 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; +import { useExpressPaymentMethods } from '@woocommerce/base-hooks'; /** * Internal dependencies @@ -24,6 +25,17 @@ const ExpressCheckoutContainer = ( { children } ) => { }; const ExpressCheckoutFormControl = () => { + const { paymentMethods, isInitialized } = useExpressPaymentMethods(); + + // determine whether we even show this + // @todo if in the editor we probably would want to show a placeholder maybe? + if ( + ! isInitialized || + ( isInitialized && Object.keys( paymentMethods ).length === 0 ) + ) { + return null; + } + return (

diff --git a/assets/js/base/components/payment-methods/express-payment-methods.js b/assets/js/base/components/payment-methods/express-payment-methods.js index 596cddcb692..e13daf06d7f 100644 --- a/assets/js/base/components/payment-methods/express-payment-methods.js +++ b/assets/js/base/components/payment-methods/express-payment-methods.js @@ -1,18 +1,25 @@ /** * External dependencies */ -import { getExpressPaymentMethods } from '@woocommerce/blocks-registry'; -import { useCheckoutData, usePaymentEvents } from '@woocommerce/base-hooks'; +import { + useCheckoutData, + usePaymentEvents, + useExpressPaymentMethods, +} from '@woocommerce/base-hooks'; const ExpressPaymentMethods = () => { const [ checkoutData ] = useCheckoutData(); const { dispatch, select } = usePaymentEvents(); - const paymentMethods = getExpressPaymentMethods(); + // not implementing isInitialized here because it's utilized further + // up in the tree for express payment methods. We won't even get here if + // there's no payment methods after initialization. + const { paymentMethods } = useExpressPaymentMethods(); const paymentMethodSlugs = Object.keys( paymentMethods ); const content = paymentMethodSlugs.length > 0 ? ( paymentMethodSlugs.map( ( slug ) => { - const ExpressPaymentMethod = paymentMethods[ slug ]; + const ExpressPaymentMethod = + paymentMethods[ slug ].activeContent; const paymentEvents = { dispatch, select }; return (

  • { const paymentMethodsKeys = Object.keys( paymentMethods ); return paymentMethodsKeys.length > 0 ? paymentMethodsKeys.map( ( key ) => { - const { tab, ariaLabel } = paymentMethods[ key ]; + const { label, ariaLabel } = paymentMethods[ key ]; return { name: key, - title: tab, + title: label, ariaLabel, }; } ) : [ noPaymentMethodTab() ]; }; -const paymentMethods = getPaymentMethods(); - const PaymentMethods = () => { const [ checkoutData ] = useCheckoutData(); const { dispatch, select } = usePaymentEvents(); + const { isInitialized, paymentMethods } = usePaymentMethods(); const { activePaymentMethod, setActivePaymentMethod, @@ -51,19 +50,8 @@ const PaymentMethods = () => { () => ( selectedTab ) => { const PaymentMethod = ( paymentMethods[ selectedTab ] && - paymentMethods[ selectedTab ].content ) || + paymentMethods[ selectedTab ].activeContent ) || null; - // @todo if undefined return placeholder for no registered payment methods - if ( ! PaymentMethod ) { - return ( -

    - { __( - 'No payment methods setup', - 'woo-gutenberg-products-block' - ) } -

    - ); - } const paymentEvents = { dispatch, select }; return ( { }, [ checkoutData, dispatch, select ] ); + if ( + ! isInitialized || + ( Object.keys( paymentMethods ).length === 0 && isInitialized ) + ) { + // @todo this can be a placeholder informing the user there are no + // payment methods setup? + return
    No Payment Methods Initialized
    ; + } return ( { const { activePaymentMethod, setActivePaymentMethod, } = useCheckoutContext(); + const { paymentMethods, isInitialized } = usePaymentMethods(); // if payment method has not been set yet, let's set it. useEffect( () => { + // if not initialized yet bail + if ( ! isInitialized ) { + return; + } if ( ! activePaymentMethod && activePaymentMethod !== null ) { - const paymentMethods = getPaymentMethods(); - const paymentMethodSlugs = Object.keys( paymentMethods ); + const paymentMethodIds = Object.keys( paymentMethods ); setActivePaymentMethod( - paymentMethodSlugs.length > 0 - ? paymentMethods[ paymentMethodSlugs[ 0 ] ].name + paymentMethodIds.length > 0 + ? paymentMethods[ paymentMethodIds[ 0 ] ].name : null ); } - }, [ activePaymentMethod, setActivePaymentMethod ] ); + }, [ activePaymentMethod, setActivePaymentMethod, isInitialized ] ); return { activePaymentMethod, setActivePaymentMethod }; }; diff --git a/assets/js/base/hooks/payment-methods/use-payment-methods.js b/assets/js/base/hooks/payment-methods/use-payment-methods.js new file mode 100644 index 00000000000..cf2639d8062 --- /dev/null +++ b/assets/js/base/hooks/payment-methods/use-payment-methods.js @@ -0,0 +1,59 @@ +/** + * External dependencies + */ +import { + getPaymentMethods, + getExpressPaymentMethods, +} from '@woocommerce/blocks-registry'; +import { useState, useEffect, useRef } from '@wordpress/element'; + +const usePaymentMethodState = ( registeredPaymentMethods ) => { + const [ paymentMethods, setPaymentMethods ] = useState( [] ); + const [ isInitialized, setIsInitialized ] = useState( false ); + const countPaymentMethodsInitializing = useRef( + Object.keys( registeredPaymentMethods ).length + ); + + useEffect( () => { + // if all payment methods are initialized then bail. + if ( isInitialized ) { + return; + } + // loop through payment methods and see what the state is + for ( const paymentMethodId in registeredPaymentMethods ) { + const current = registeredPaymentMethods[ paymentMethodId ]; + current.canMakePayment + .then( ( canPay ) => { + if ( canPay ) { + setPaymentMethods( ( previousPaymentMethods ) => { + return { + ...previousPaymentMethods, + [ current.id ]: current, + }; + } ); + } + // update the initialized count + countPaymentMethodsInitializing.current--; + // if count remaining less than 1, then set initialized. + if ( countPaymentMethodsInitializing.current < 1 ) { + setIsInitialized( true ); + } + } ) + .catch( ( error ) => { + // @todo, would be a good place to use the checkout error + // hooks here? Or maybe throw and catch by error boundary? + throw new Error( + 'Problem with payment method initialization' + + ( error.message || '' ) + ); + } ); + } + }, [ isInitialized ] ); + + return { paymentMethods, isInitialized }; +}; + +export const usePaymentMethods = () => + usePaymentMethodState( getPaymentMethods() ); +export const useExpressPaymentMethods = () => + usePaymentMethodState( getExpressPaymentMethods() ); diff --git a/assets/js/blocks-registry/payment-methods/assertions.js b/assets/js/blocks-registry/payment-methods/assertions.js index 921fa718506..d8dbb99bd04 100644 --- a/assets/js/blocks-registry/payment-methods/assertions.js +++ b/assets/js/blocks-registry/payment-methods/assertions.js @@ -1,41 +1,36 @@ -export const assertValidPaymentMethodComponent = ( paymentMethod ) => { +export const assertValidPaymentMethodComponent = ( + component, + componentName +) => { // @todo detect if functional component (not render prop) - if ( typeof paymentMethod !== 'function' ) { + if ( typeof component !== 'function' ) { throw new Error( - 'The registered payment method must be a functional component' + `The ${ componentName } for the payment method must be a functional component` ); } }; -export const assertValidPaymentMethod = ( paymentMethod ) => { - // paymentMethods are expected to have 4 properties, tab, content, name, ariaLabel. - if ( ! paymentMethod.tab ) { - throw new Error( - 'A payment method is expected to have a tab property' - ); +export const assertConfigHasProperties = ( + config, + expectedProperties = [] +) => { + const missingProperties = expectedProperties.reduce( ( acc, property ) => { + if ( ! config[ property ] ) { + acc.push( property ); + } + return acc; + }, [] ); + if ( missingProperties.length > 0 ) { + const message = + 'The payment method configuration object is missing the following properties:'; + throw new Error( message + missingProperties.join( ', ' ) ); } - if ( ! paymentMethod.content ) { - throw new Error( - 'A payment method is expected to have a content property' - ); - } - if ( ! paymentMethod.ariaLabel ) { - throw new Error( - 'A payment method is expected to have an ariaLabel property. This is used for tabs aria-label property.' - ); - } - try { - assertValidPaymentMethodComponent( paymentMethod.content ); - } catch ( e ) { - throw new Error( - 'The paymentMethod.content value must be a functional components' - ); - } - try { - assertValidPaymentMethodComponent( paymentMethod.tab ); - } catch ( e ) { +}; + +export const assertValidPaymentMethodCreator = ( creator, configName ) => { + if ( typeof creator !== 'function' ) { throw new Error( - 'The paymentMethod.tab value must be a functional components' + `A payment method must be registered with a function that creates and returns a ${ configName } instance` ); } }; diff --git a/assets/js/blocks-registry/payment-methods/express-payment-method-config.js b/assets/js/blocks-registry/payment-methods/express-payment-method-config.js new file mode 100644 index 00000000000..69ae42a8132 --- /dev/null +++ b/assets/js/blocks-registry/payment-methods/express-payment-method-config.js @@ -0,0 +1,36 @@ +/** + * Internal dependencies + */ +import { + assertConfigHasProperties, + assertValidPaymentMethodComponent, +} from './assertions'; + +export default class ExpressPaymentMethodConfig { + constructor( config ) { + // validate config + ExpressPaymentMethodConfig.assertValidConfig( config ); + this.id = config.id; + this.activeContent = config.activeContent; + this.canMakePayment = config.canMakePayment; + Object.freeze( this ); + } + + static assertValidConfig = ( config ) => { + assertConfigHasProperties( config, [ 'id', 'activeContent' ] ); + if ( typeof config.id !== 'string' ) { + throw new Error( + 'The id for the express payment method must be a string' + ); + } + assertValidPaymentMethodComponent( + config.activeContent, + 'activeContent' + ); + if ( ! ( config.canMakePayment instanceof Promise ) ) { + throw new Error( + 'The canMakePayment property for the express payment method must be a promise.' + ); + } + }; +} diff --git a/assets/js/blocks-registry/payment-methods/payment-method-config.js b/assets/js/blocks-registry/payment-methods/payment-method-config.js new file mode 100644 index 00000000000..14ff2a69708 --- /dev/null +++ b/assets/js/blocks-registry/payment-methods/payment-method-config.js @@ -0,0 +1,51 @@ +/** + * Internal dependencies + */ +import { + assertConfigHasProperties, + assertValidPaymentMethodComponent, +} from './assertions'; + +export default class PaymentMethodConfig { + constructor( config ) { + // validate config + PaymentMethodConfig.assertValidConfig( config ); + this.id = config.id; + this.label = config.label; + this.stepContent = config.stepContent; + this.ariaLabel = config.ariaLabel; + this.activeContent = config.activeContent; + this.canMakePayment = config.canMakePayment; + Object.freeze( this ); + } + + static assertValidConfig = ( config ) => { + assertConfigHasProperties( config, [ + 'id', + 'label', + 'stepContent', + 'ariaLabel', + 'activeContent', + 'canMakePayment', + ] ); + if ( typeof config.id !== 'string' ) { + throw new Error( 'The id for the payment method must be a string' ); + } + assertValidPaymentMethodComponent( config.label, 'label' ); + assertValidPaymentMethodComponent( config.stepContent, 'stepContent' ); + assertValidPaymentMethodComponent( + config.activeContent, + 'activeContent' + ); + if ( typeof config.ariaLabel !== 'string' ) { + throw new Error( + 'The ariaLabel for the payment method must be a string' + ); + } + if ( ! ( config.canMakePayment instanceof Promise ) ) { + throw new Error( + 'The canMakePayment property for the payment method must be a promise.' + ); + } + }; +} diff --git a/assets/js/blocks-registry/payment-methods/registry.js b/assets/js/blocks-registry/payment-methods/registry.js index 38361a99ca0..1d0aadaf4a4 100644 --- a/assets/js/blocks-registry/payment-methods/registry.js +++ b/assets/js/blocks-registry/payment-methods/registry.js @@ -1,10 +1,9 @@ /** * Internal dependencies */ -import { - assertValidPaymentMethod, - assertValidPaymentMethodComponent, -} from './assertions'; +import { assertValidPaymentMethodCreator } from './assertions'; +import { default as PaymentMethodConfig } from './payment-method-config'; +import { default as ExpressPaymentMethodConfig } from './express-payment-method-config'; // currently much leeway is given to the payment method for the shape of their components. We should investigate payment methods // using a component creator that is fed a configuration object so that the built component for the payment method is tightly @@ -14,14 +13,28 @@ import { const paymentMethods = {}; const expressPaymentMethods = {}; -export const registerPaymentMethod = ( slug, paymentMethod ) => { - assertValidPaymentMethod( paymentMethod ); - paymentMethods[ slug ] = paymentMethod; +export const registerPaymentMethod = ( paymentMethodCreator ) => { + assertValidPaymentMethodCreator( + paymentMethodCreator, + 'PaymentMethodConfig' + ); + const paymentMethodConfig = paymentMethodCreator( PaymentMethodConfig ); + if ( paymentMethodConfig instanceof PaymentMethodConfig ) { + paymentMethods[ paymentMethodConfig.id ] = paymentMethodConfig; + } }; -export const registerExpressPaymentMethod = ( slug, expressPaymentMethod ) => { - assertValidPaymentMethodComponent( expressPaymentMethod ); - expressPaymentMethods[ slug ] = expressPaymentMethod; +export const registerExpressPaymentMethod = ( expressPaymentMethodCreator ) => { + assertValidPaymentMethodCreator( + expressPaymentMethodCreator, + 'ExpressPaymentMethodConfig' + ); + const paymentMethodConfig = expressPaymentMethodCreator( + ExpressPaymentMethodConfig + ); + if ( paymentMethodConfig instanceof ExpressPaymentMethodConfig ) { + expressPaymentMethods[ paymentMethodConfig.id ] = paymentMethodConfig; + } }; export const getPaymentMethods = () => { diff --git a/assets/js/payment-methods-demo/index.js b/assets/js/payment-methods-demo/index.js index e5a44c97018..5f5556fdcfa 100644 --- a/assets/js/payment-methods-demo/index.js +++ b/assets/js/payment-methods-demo/index.js @@ -10,9 +10,23 @@ import { * Internal dependencies */ import { ExpressApplePay, ExpressPaypal } from './express-payment'; -import { PaypalPaymentMethod, ccPaymentMethod } from './payment-methods'; +import { paypalPaymentMethod, ccPaymentMethod } from './payment-methods'; -registerExpressPaymentMethod( 'applepay', ExpressApplePay ); -registerExpressPaymentMethod( 'paypal', ExpressPaypal ); -registerPaymentMethod( 'paypal', PaypalPaymentMethod ); -registerPaymentMethod( 'cc', ccPaymentMethod ); +registerExpressPaymentMethod( + ( Config ) => + new Config( { + id: 'applepay', + activeContent: ExpressApplePay, + canMakePayment: Promise.resolve( true ), + } ) +); +registerExpressPaymentMethod( + ( Config ) => + new Config( { + id: 'paypal', + activeContent: ExpressPaypal, + canMakePayment: Promise.resolve( true ), + } ) +); +registerPaymentMethod( ( Config ) => new Config( paypalPaymentMethod ) ); +registerPaymentMethod( ( Config ) => new Config( ccPaymentMethod ) ); diff --git a/assets/js/payment-methods-demo/payment-methods/index.js b/assets/js/payment-methods-demo/payment-methods/index.js index 12f566a960a..48e32ec9f00 100644 --- a/assets/js/payment-methods-demo/payment-methods/index.js +++ b/assets/js/payment-methods-demo/payment-methods/index.js @@ -4,22 +4,28 @@ import { paypalSvg } from './paypal'; import { ccSvg } from './cc'; -export const PaypalPaymentMethod = { - tab: () => , - content: () => ( +export const paypalPaymentMethod = { + id: 'paypal', + label: () => , + stepContent: () =>
    Billing steps
    , + activeContent: () => (

    This is where paypal payment method stuff would be.

    ), + canMakePayment: Promise.resolve( true ), ariaLabel: 'paypal payment method', }; export const ccPaymentMethod = { - tab: () => , - content: () => ( + id: 'cc', + label: () => , + stepContent: () => null, + activeContent: () => (

    This is where cc payment method stuff would be.

    ), + canMakePayment: Promise.resolve( true ), ariaLabel: 'credit-card-payment-method', }; diff --git a/webpack.config.js b/webpack.config.js index 3a67caa261a..474af12086b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -63,6 +63,11 @@ const CoreConfig = { loader: 'babel-loader?cacheDirectory', options: { presets: [ '@wordpress/babel-preset-default' ], + plugins: [ + require.resolve( + '@babel/plugin-proposal-class-properties' + ), + ].filter( Boolean ), }, }, },