diff --git a/js/src/components/google-combo-account-card/account-details.js b/js/src/components/google-combo-account-card/account-details.js new file mode 100644 index 0000000000..40f81bcf74 --- /dev/null +++ b/js/src/components/google-combo-account-card/account-details.js @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import useGoogleAccount from '.~/hooks/useGoogleAccount'; +import useGoogleAdsAccount from '.~/hooks/useGoogleAdsAccount'; +import useGoogleMCAccount from '.~/hooks/useGoogleMCAccount'; + +/** + * Account details. + * @return {JSX.Element} JSX markup. + */ +const AccountDetails = () => { + const { google } = useGoogleAccount(); + const { googleAdsAccount } = useGoogleAdsAccount(); + const { googleMCAccount } = useGoogleMCAccount(); + + return ( + <> +

{ google.email }

+

+ { googleMCAccount.id > 0 && + sprintf( + // Translators: %s is the Merchant Center ID + __( + 'Merchant Center ID: %s', + 'google-listings-and-ads' + ), + googleMCAccount.id + ) } +

+

+ { googleAdsAccount.id > 0 && + sprintf( + // Translators: %s is the Google Ads ID + __( 'Google Ads ID: %s', 'google-listings-and-ads' ), + googleAdsAccount.id + ) } +

+ + ); +}; + +export default AccountDetails; diff --git a/js/src/components/google-combo-account-card/connect-google-combo-account-card.js b/js/src/components/google-combo-account-card/connect-google-combo-account-card.js index dfff4dd2c9..a20c0c3de8 100644 --- a/js/src/components/google-combo-account-card/connect-google-combo-account-card.js +++ b/js/src/components/google-combo-account-card/connect-google-combo-account-card.js @@ -14,7 +14,6 @@ import AppButton from '.~/components/app-button'; import readMoreLink from '../google-account-card/read-more-link'; import useGoogleConnectFlow from '../google-account-card/use-google-connect-flow'; import AppDocumentationLink from '../app-documentation-link'; -import './connect-google-combo-account-card.scss'; /** * @param {Object} props React props @@ -34,7 +33,6 @@ const ConnectGoogleComboAccountCard = ( { disabled } ) => { return ( { + const { hasDetermined, creatingWhich } = useAutoCreateAdsMCAccounts(); + const { text, subText } = getAccountCreationTexts( creatingWhich ); + + if ( ! hasDetermined ) { + return ; + } + + return ( + } + helper={ subText } + indicator={ } + /> + ); +}; + +export default ConnectedGoogleComboAccountCard; diff --git a/js/src/components/google-combo-account-card/connected-google-combo-account-card.scss b/js/src/components/google-combo-account-card/connected-google-combo-account-card.scss new file mode 100644 index 0000000000..9c65e53adf --- /dev/null +++ b/js/src/components/google-combo-account-card/connected-google-combo-account-card.scss @@ -0,0 +1,6 @@ +.gla-google-combo-account-card--connected { + + .gla-account-card__description { + gap: 0; + } +} diff --git a/js/src/components/google-combo-account-card/constants.js b/js/src/components/google-combo-account-card/constants.js new file mode 100644 index 0000000000..3086a154d8 --- /dev/null +++ b/js/src/components/google-combo-account-card/constants.js @@ -0,0 +1,3 @@ +export const CREATING_BOTH_ACCOUNTS = 'both'; +export const CREATING_ADS_ACCOUNT = 'ads'; +export const CREATING_MC_ACCOUNT = 'mc'; diff --git a/js/src/components/google-combo-account-card/getAccountCreationTexts.js b/js/src/components/google-combo-account-card/getAccountCreationTexts.js new file mode 100644 index 0000000000..accf7da2bb --- /dev/null +++ b/js/src/components/google-combo-account-card/getAccountCreationTexts.js @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { + CREATING_ADS_ACCOUNT, + CREATING_BOTH_ACCOUNTS, + CREATING_MC_ACCOUNT, +} from './constants'; + +/** + * Account creation in progress description. + * @param {string|null} creatingWhich Which account is being created. + * @return {Object} Text and subtext. + */ +const getAccountCreationTexts = ( creatingWhich ) => { + let text = null; + let subText = null; + + switch ( creatingWhich ) { + case CREATING_BOTH_ACCOUNTS: + text = __( + 'You don’t have Merchant Center nor Google Ads accounts, so we’re creating them for you.', + 'google-listings-and-ads' + ); + subText = __( + 'Merchant Center is required to sync products so they show on Google. Google Ads is required to set up conversion measurement for your store.', + 'google-listings-and-ads' + ); + break; + + case CREATING_ADS_ACCOUNT: + text = __( + 'You don’t have Google Ads account, so we’re creating one for you.', + 'google-listings-and-ads' + ); + subText = __( + 'Required to set up conversion measurement for your store.', + 'google-listings-and-ads' + ); + break; + + case CREATING_MC_ACCOUNT: + text = __( + 'You don’t have Merchant Center account, so we’re creating one for you.', + 'google-listings-and-ads' + ); + subText = __( + 'Required to sync products so they show on Google.', + 'google-listings-and-ads' + ); + break; + } + + return { + text, + subText, + }; +}; + +export default getAccountCreationTexts; diff --git a/js/src/components/google-combo-account-card/index.js b/js/src/components/google-combo-account-card/index.js index 098fb7b4ef..c1db3e5391 100644 --- a/js/src/components/google-combo-account-card/index.js +++ b/js/src/components/google-combo-account-card/index.js @@ -5,8 +5,8 @@ import useGoogleAccount from '.~/hooks/useGoogleAccount'; import AppSpinner from '.~/components/app-spinner'; import AccountCard from '.~/components/account-card'; import RequestFullAccessGoogleAccountCard from '../google-account-card/request-full-access-google-account-card'; -import { ConnectedGoogleAccountCard } from '../google-account-card'; import ConnectGoogleComboAccountCard from './connect-google-combo-account-card'; +import ConnectedGoogleComboAccountCard from './connected-google-combo-account-card'; export default function GoogleComboAccountCard( { disabled = false } ) { const { google, scope, hasFinishedResolution } = useGoogleAccount(); @@ -18,7 +18,7 @@ export default function GoogleComboAccountCard( { disabled = false } ) { const isConnected = google?.active === 'yes'; if ( isConnected && scope.glaRequired ) { - return ; + return ; } if ( isConnected && ! scope.glaRequired ) { diff --git a/js/src/components/google-combo-account-card/indicator.js b/js/src/components/google-combo-account-card/indicator.js new file mode 100644 index 0000000000..5a65c0b6b7 --- /dev/null +++ b/js/src/components/google-combo-account-card/indicator.js @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import ConnectedIconLabel from '.~/components/connected-icon-label'; +import LoadingLabel from '.~/components/loading-label/loading-label'; +import useGoogleAdsAccountReady from '.~/hooks/useGoogleAdsAccountReady'; +import useGoogleMCAccount from '.~/hooks/useGoogleMCAccount'; + +/** + * Account creation indicator. + * Displays a loading indicator when accounts are being created or a connected icon when accounts are connected. + * @param {Object} props Component props. + * @param {boolean} props.showSpinner Whether to display a spinner. + * @return {JSX.Element|null} Indicator component. + */ +const Indicator = ( { showSpinner } ) => { + const isGoogleAdsConnected = useGoogleAdsAccountReady(); + const { isReady: isGoogleMCConnected } = useGoogleMCAccount(); + + if ( showSpinner ) { + return ( + + ); + } + + if ( isGoogleAdsConnected && isGoogleMCConnected ) { + return ; + } + + return null; +}; + +export default Indicator; diff --git a/js/src/components/google-mc-account-card/connect-mc/index.js b/js/src/components/google-mc-account-card/connect-mc/index.js index a47ccfa5fc..658bc245f3 100644 --- a/js/src/components/google-mc-account-card/connect-mc/index.js +++ b/js/src/components/google-mc-account-card/connect-mc/index.js @@ -18,7 +18,7 @@ import ReclaimUrlCard from '../reclaim-url-card'; import AccountCard, { APPEARANCE } from '.~/components/account-card'; import CreateAccountButton from '../create-account-button'; import useConnectMCAccount from '../useConnectMCAccount'; -import useCreateMCAccount from '../useCreateMCAccount'; +import useCreateMCAccount from '.~/hooks/useCreateMCAccount'; import CreatingCard from '../creating-card'; import './index.scss'; diff --git a/js/src/components/google-mc-account-card/create-account.js b/js/src/components/google-mc-account-card/create-account.js index f8c50b4f3b..9bf5831ad0 100644 --- a/js/src/components/google-mc-account-card/create-account.js +++ b/js/src/components/google-mc-account-card/create-account.js @@ -4,7 +4,7 @@ import CreateAccountCard from './create-account-card'; import CreatingCard from './creating-card'; import ReclaimUrlCard from './reclaim-url-card'; -import useCreateMCAccount from './useCreateMCAccount'; +import useCreateMCAccount from '.~/hooks/useCreateMCAccount'; /** * Create Account flow. diff --git a/js/src/constants.js b/js/src/constants.js index de28ac0544..ad0ae6d99d 100644 --- a/js/src/constants.js +++ b/js/src/constants.js @@ -70,6 +70,12 @@ export const GOOGLE_ADS_ACCOUNT_STATUS = { INCOMPLETE: 'incomplete', }; +export const GOOGLE_MC_ACCOUNT_STATUS = { + CONNECTED: 'connected', + DISCONNECTED: 'disconnected', + INCOMPLETE: 'incomplete', +}; + export const GOOGLE_ADS_BILLING_STATUS = { UNKNOWN: 'unknown', PENDING: 'pending', diff --git a/js/src/hooks/useAutoCreateAdsMCAccounts.js b/js/src/hooks/useAutoCreateAdsMCAccounts.js new file mode 100644 index 0000000000..e295f263bb --- /dev/null +++ b/js/src/hooks/useAutoCreateAdsMCAccounts.js @@ -0,0 +1,136 @@ +/** + * External dependencies + */ +import { useEffect, useState, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import useGoogleAdsAccount from './useGoogleAdsAccount'; +import useExistingGoogleAdsAccounts from './useExistingGoogleAdsAccounts'; +import useGoogleMCAccount from './useGoogleMCAccount'; +import useExistingGoogleMCAccounts from './useExistingGoogleMCAccounts'; +import useCreateMCAccount from './useCreateMCAccount'; +import useUpsertAdsAccount from '.~/hooks/useUpsertAdsAccount'; +import { + CREATING_ADS_ACCOUNT, + CREATING_BOTH_ACCOUNTS, + CREATING_MC_ACCOUNT, +} from '.~/components/google-combo-account-card/constants'; + +const useShouldCreateAdsAccount = () => { + const { + hasFinishedResolution: hasResolvedAccount, + hasGoogleAdsConnection: hasConnection, + } = useGoogleAdsAccount(); + + const { + hasFinishedResolution: hasResolvedExistingAccounts, + existingAccounts: accounts, + } = useExistingGoogleAdsAccounts(); + + // Return null if the account hasn't been resolved or the existing accounts haven't been resolved + if ( ! hasResolvedAccount || ! hasResolvedExistingAccounts ) { + return null; + } + + return ! hasConnection && accounts?.length === 0; +}; + +const useShouldCreateMCAccount = () => { + const { + hasFinishedResolution: hasResolvedAccount, + hasGoogleMCConnection: hasConnection, + } = useGoogleMCAccount(); + + const { + hasFinishedResolution: hasResolvedExistingAccounts, + data: accounts, + } = useExistingGoogleMCAccounts(); + + // Return null if the account hasn't been resolved or the existing accounts haven't been resolved + if ( ! hasResolvedAccount || ! hasResolvedExistingAccounts ) { + return null; + } + + return ! hasConnection && accounts?.length === 0; +}; + +/** + * @typedef {Object} AutoCreateAdsMCAccountsData + * @property {boolean} hasDetermined - Whether the checks to determine if accounts should be created are finished. + * @property {('ads'|'mc'|'both'|null)} creatingWhich - Which accounts are being created ('ads', 'mc', 'both'), or `null` if none. + */ + +/** + * useAutoCreateAdsMCAccounts hook. + * Creates Google Ads and Merchant Center accounts if the user doesn't have any existing and connected accounts. + * + * @return {AutoCreateAdsMCAccountsData} Object containing account creation data. + */ +const useAutoCreateAdsMCAccounts = () => { + const lockedRef = useRef( false ); + const [ creatingWhich, setCreatingWhich ] = useState( null ); + const [ hasDetermined, setDetermined ] = useState( false ); + + const shouldCreateAds = useShouldCreateAdsAccount(); + const shouldCreateMC = useShouldCreateMCAccount(); + + const [ handleCreateAccount ] = useCreateMCAccount(); + const [ upsertAdsAccount ] = useUpsertAdsAccount(); + + useEffect( () => { + if ( + // Wait for all determinations to be ready + shouldCreateMC === null || + shouldCreateAds === null || + // Avoid repeated calls + lockedRef.current + ) { + return; + } + + let which = null; + + lockedRef.current = true; + + if ( shouldCreateMC && shouldCreateAds ) { + which = CREATING_BOTH_ACCOUNTS; + } else if ( shouldCreateMC ) { + which = CREATING_MC_ACCOUNT; + } else if ( shouldCreateAds ) { + which = CREATING_ADS_ACCOUNT; + } + + setCreatingWhich( which ); + setDetermined( true ); + + if ( which ) { + const handleCreateAccountCallback = async () => { + if ( which === CREATING_BOTH_ACCOUNTS ) { + await handleCreateAccount(); + await upsertAdsAccount(); + } else if ( which === CREATING_MC_ACCOUNT ) { + await handleCreateAccount(); + } else if ( which === CREATING_ADS_ACCOUNT ) { + await upsertAdsAccount(); + } + setCreatingWhich( null ); + }; + + handleCreateAccountCallback(); + } + }, [ + handleCreateAccount, + shouldCreateAds, + shouldCreateMC, + upsertAdsAccount, + ] ); + + return { + hasDetermined, + creatingWhich, + }; +}; + +export default useAutoCreateAdsMCAccounts; diff --git a/js/src/hooks/useAutoCreateAdsMCAccounts.test.js b/js/src/hooks/useAutoCreateAdsMCAccounts.test.js new file mode 100644 index 0000000000..84c3ce5da5 --- /dev/null +++ b/js/src/hooks/useAutoCreateAdsMCAccounts.test.js @@ -0,0 +1,148 @@ +/** + * External dependencies + */ +import { act, renderHook } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import useAutoCreateAdsMCAccounts from './useAutoCreateAdsMCAccounts'; +import useGoogleAdsAccount from './useGoogleAdsAccount'; +import useExistingGoogleAdsAccounts from './useExistingGoogleAdsAccounts'; +import useGoogleMCAccount from './useGoogleMCAccount'; +import useExistingGoogleMCAccounts from './useExistingGoogleMCAccounts'; +import useCreateMCAccount from './useCreateMCAccount'; +import useUpsertAdsAccount from './useUpsertAdsAccount'; + +jest.mock( './useCreateMCAccount' ); +jest.mock( './useUpsertAdsAccount' ); +jest.mock( './useGoogleAdsAccount' ); +jest.mock( './useExistingGoogleAdsAccounts' ); +jest.mock( './useGoogleMCAccount' ); +jest.mock( './useExistingGoogleMCAccounts' ); + +describe( 'useAutoCreateAdsMCAccounts hook', () => { + let handleCreateAccount; + let upsertAdsAccount; + + beforeEach( () => { + handleCreateAccount = jest.fn( () => Promise.resolve() ); + upsertAdsAccount = jest.fn( () => Promise.resolve() ); + + useGoogleAdsAccount.mockReturnValue( { + hasFinishedResolution: true, + hasGoogleAdsConnection: false, + } ); + + useExistingGoogleAdsAccounts.mockReturnValue( { + hasFinishedResolution: true, + existingAccounts: [], + } ); + + useGoogleMCAccount.mockReturnValue( { + hasFinishedResolution: true, + hasGoogleMCConnection: false, + } ); + + useExistingGoogleMCAccounts.mockReturnValue( { + hasFinishedResolution: true, + data: [], + } ); + } ); + + describe( 'Automatic account creation', () => { + beforeEach( () => { + useCreateMCAccount.mockReturnValue( [ + handleCreateAccount, + { response: undefined }, + ] ); + useUpsertAdsAccount.mockReturnValue( [ + upsertAdsAccount, + { loading: true }, + ] ); + } ); + + it( 'should create both accounts', async () => { + const { result } = renderHook( () => useAutoCreateAdsMCAccounts() ); + + await act( async () => { + expect( result.current.creatingWhich ).toBe( 'both' ); + } ); + + expect( handleCreateAccount ).toHaveBeenCalledTimes( 1 ); + expect( upsertAdsAccount ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should create only the Merchant Center account', async () => { + useExistingGoogleAdsAccounts.mockReturnValue( { + hasFinishedResolution: true, + existingAccounts: [ + { + id: '1234', + name: 'Test Account', + }, + ], + } ); + + const { result } = renderHook( () => useAutoCreateAdsMCAccounts() ); + await act( async () => { + expect( result.current.creatingWhich ).toBe( 'mc' ); + } ); + + expect( handleCreateAccount ).toHaveBeenCalledTimes( 1 ); + expect( upsertAdsAccount ).toHaveBeenCalledTimes( 0 ); + } ); + + it( 'should create only the Google Ads account', async () => { + useExistingGoogleMCAccounts.mockReturnValue( { + hasFinishedResolution: true, + data: [ + { + id: '12345', + name: 'Test Account', + }, + ], + } ); + + const { result } = renderHook( () => useAutoCreateAdsMCAccounts() ); + await act( async () => { + expect( result.current.creatingWhich ).toBe( 'ads' ); + } ); + + expect( handleCreateAccount ).toHaveBeenCalledTimes( 0 ); + expect( upsertAdsAccount ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + + describe( 'Existing accounts', () => { + beforeEach( () => { + useExistingGoogleAdsAccounts.mockReturnValue( { + hasFinishedResolution: true, + existingAccounts: [ + { + id: '1234', + name: 'Test Account', + }, + ], + } ); + + useExistingGoogleMCAccounts.mockReturnValue( { + hasFinishedResolution: true, + data: [ + { + id: '12345', + name: 'Test Account', + }, + ], + } ); + } ); + + it( 'should not create accounts if they already exist', () => { + const { result } = renderHook( () => useAutoCreateAdsMCAccounts() ); + + expect( result.current.creatingWhich ).toBe( null ); + expect( handleCreateAccount ).not.toHaveBeenCalled(); + expect( upsertAdsAccount ).not.toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/js/src/components/google-mc-account-card/useCreateMCAccount.js b/js/src/hooks/useCreateMCAccount.js similarity index 100% rename from js/src/components/google-mc-account-card/useCreateMCAccount.js rename to js/src/hooks/useCreateMCAccount.js diff --git a/js/src/hooks/useExistingGoogleAdsAccounts.js b/js/src/hooks/useExistingGoogleAdsAccounts.js index 8eda3b9a2f..1e84516710 100644 --- a/js/src/hooks/useExistingGoogleAdsAccounts.js +++ b/js/src/hooks/useExistingGoogleAdsAccounts.js @@ -15,8 +15,11 @@ const useExistingGoogleAdsAccounts = () => { const isResolving = select( STORE_KEY ).isResolving( 'getExistingGoogleAdsAccounts' ); + const hasFinishedResolution = select( STORE_KEY ).hasFinishedResolution( + 'getExistingGoogleAdsAccounts' + ); - return { existingAccounts, isResolving }; + return { existingAccounts, hasFinishedResolution, isResolving }; }, [] ); }; diff --git a/js/src/hooks/useGoogleAdsAccountReady.js b/js/src/hooks/useGoogleAdsAccountReady.js new file mode 100644 index 0000000000..b593e44e2d --- /dev/null +++ b/js/src/hooks/useGoogleAdsAccountReady.js @@ -0,0 +1,35 @@ +/** + * Internal dependencies + */ +import useGoogleAdsAccount from '.~/hooks/useGoogleAdsAccount'; +import useGoogleAdsAccountStatus from '.~/hooks/useGoogleAdsAccountStatus'; + +/** + * Hook to check if the Google Ads account is ready. + * This is used to determine if the user can proceed to the next step. + * + * @return {boolean|null} Whether the Google Ads account is ready. `null` if the state is not yet determined. + */ +const useGoogleAdsAccountReady = () => { + const { + hasGoogleAdsConnection, + hasFinishedResolution: adsAccountResolved, + } = useGoogleAdsAccount(); + const { + hasAccess, + step, + hasFinishedResolution: adsAccountStatusResolved, + } = useGoogleAdsAccountStatus(); + + if ( ! adsAccountResolved || ! adsAccountStatusResolved ) { + return null; + } + + return ( + hasGoogleAdsConnection && + hasAccess && + [ '', 'billing', 'link_merchant' ].includes( step ) + ); +}; + +export default useGoogleAdsAccountReady; diff --git a/js/src/hooks/useGoogleMCAccount.js b/js/src/hooks/useGoogleMCAccount.js index 76958ea1d9..22a4ecbc24 100644 --- a/js/src/hooks/useGoogleMCAccount.js +++ b/js/src/hooks/useGoogleMCAccount.js @@ -8,6 +8,7 @@ import { useSelect } from '@wordpress/data'; */ import { STORE_KEY } from '.~/data/constants'; import useGoogleAccount from './useGoogleAccount'; +import { GOOGLE_MC_ACCOUNT_STATUS } from '.~/constants'; /** * @typedef {import('.~/data/selectors').GoogleMCAccount} GoogleMCAccount @@ -17,8 +18,12 @@ import useGoogleAccount from './useGoogleAccount'; * @property {boolean} isResolving Whether resolution is in progress. * @property {boolean} hasFinishedResolution Whether resolution has completed. * @property {boolean} isPreconditionReady Whether the precondition of continued connection processing is fulfilled. + * @property {boolean} hasGoogleMCConnection Whether the user has a Google Merchant Center account connection established. + * @property {boolean} isReady Whether the user has a Google Merchant Center account is in connected state. */ +const googleMCAccountSelector = 'getGoogleMCAccount'; + /** * A hook to load the connection data of Google Merchant Center account. * @@ -43,18 +48,36 @@ const useGoogleMCAccount = () => { // has not been granted necessary access permissions for Google Merchant Center, then // the precondition doesn't meet. isPreconditionReady: false, + hasGoogleMCConnection: false, + isReady: false, }; } - const { getGoogleMCAccount, isResolving, hasFinishedResolution } = - select( STORE_KEY ); + const selector = select( STORE_KEY ); + const acc = selector[ googleMCAccountSelector ](); + const isResolvingGoogleMCAccount = selector.isResolving( + googleMCAccountSelector + ); + + const hasGoogleMCConnection = [ + GOOGLE_MC_ACCOUNT_STATUS.CONNECTED, + GOOGLE_MC_ACCOUNT_STATUS.INCOMPLETE, + ].includes( acc?.status ); + + const isReady = + acc?.status === GOOGLE_MC_ACCOUNT_STATUS.CONNECTED || + ( acc?.status === GOOGLE_MC_ACCOUNT_STATUS.INCOMPLETE && + acc?.step === 'link_ads' ); return { - googleMCAccount: getGoogleMCAccount(), - isResolving: isResolving( 'getGoogleMCAccount' ), - hasFinishedResolution: - hasFinishedResolution( 'getGoogleMCAccount' ), + googleMCAccount: acc, + isResolving: isResolvingGoogleMCAccount, + hasFinishedResolution: selector.hasFinishedResolution( + googleMCAccountSelector + ), isPreconditionReady: true, + hasGoogleMCConnection, + isReady, }; }, [ diff --git a/js/src/setup-ads/ads-stepper/setup-accounts/index.js b/js/src/setup-ads/ads-stepper/setup-accounts/index.js index bab89e5cc1..d9f1c3b6a3 100644 --- a/js/src/setup-ads/ads-stepper/setup-accounts/index.js +++ b/js/src/setup-ads/ads-stepper/setup-accounts/index.js @@ -16,31 +16,23 @@ import { ConnectedGoogleAccountCard } from '.~/components/google-account-card'; import GoogleAdsAccountCard from '.~/components/google-ads-account-card'; import FreeAdCredit from './free-ad-credit'; import useGoogleAdsAccount from '.~/hooks/useGoogleAdsAccount'; -import useGoogleAdsAccountStatus from '.~/hooks/useGoogleAdsAccountStatus'; import useGoogleAccount from '.~/hooks/useGoogleAccount'; import useFreeAdCredit from '.~/hooks/useFreeAdCredit'; import AppSpinner from '.~/components/app-spinner'; import Section from '.~/wcdl/section'; +import useGoogleAdsAccountReady from '.~/hooks/useGoogleAdsAccountReady'; const SetupAccounts = ( props ) => { const { onContinue = () => {} } = props; const { google } = useGoogleAccount(); - const { googleAdsAccount, hasGoogleAdsConnection } = useGoogleAdsAccount(); - const { hasAccess, step } = useGoogleAdsAccountStatus(); + const { googleAdsAccount } = useGoogleAdsAccount(); const hasFreeAdCredit = useFreeAdCredit(); + const isGoogleAdsReady = useGoogleAdsAccountReady(); if ( ! google || ( google.active === 'yes' && ! googleAdsAccount ) ) { return ; } - // Ads is ready when we have a connection and verified and verified access. - // Billing is not required, and the 'link_merchant' step will be resolved - // when the MC the account is connected. - const isGoogleAdsReady = - hasGoogleAdsConnection && - hasAccess && - [ '', 'billing', 'link_merchant' ].includes( step ); - const isContinueButtonDisabled = ! isGoogleAdsReady; return ( diff --git a/js/src/setup-mc/setup-stepper/setup-accounts/index.js b/js/src/setup-mc/setup-stepper/setup-accounts/index.js index d4bfc68f86..cf200ed4df 100644 --- a/js/src/setup-mc/setup-stepper/setup-accounts/index.js +++ b/js/src/setup-mc/setup-stepper/setup-accounts/index.js @@ -24,7 +24,7 @@ import GoogleComboAccountCard from '.~/components/google-combo-account-card'; import Faqs from './faqs'; import './index.scss'; import useGoogleAdsAccount from '.~/hooks/useGoogleAdsAccount'; -import useGoogleAdsAccountStatus from '.~/hooks/useGoogleAdsAccountStatus'; +import useGoogleAdsAccountReady from '.~/hooks/useGoogleAdsAccountReady'; /** * Renders the disclaimer of Comparison Shopping Service (CSS). @@ -83,11 +83,13 @@ const SetupAccounts = ( props ) => { const { onContinue = () => {} } = props; const { jetpack } = useJetpackAccount(); const { google, scope } = useGoogleAccount(); - const { googleMCAccount, isPreconditionReady: isGMCPreconditionReady } = - useGoogleMCAccount(); - const { hasFinishedResolution, hasGoogleAdsConnection } = - useGoogleAdsAccount(); - const { hasAccess, step } = useGoogleAdsAccountStatus(); + const { + googleMCAccount, + isPreconditionReady: isGMCPreconditionReady, + isReady: isGoogleMCReady, + } = useGoogleMCAccount(); + const { hasFinishedResolution } = useGoogleAdsAccount(); + const isGoogleAdsReady = useGoogleAdsAccountReady(); /** * When jetpack is loading, or when google account is loading, @@ -110,23 +112,6 @@ const SetupAccounts = ( props ) => { return ; } - // Ads is ready when we have a connection and verified and verified access. - // Billing is not required, and the 'link_merchant' step will be resolved - // when the MC the account is connected. - const isGoogleAdsReady = - hasGoogleAdsConnection && - hasAccess && - [ '', 'billing', 'link_merchant' ].includes( step ); - - // MC is ready when we have a connection and preconditions are met. - // The `link_ads` step will be resolved when the Ads account is connected - // since these can be connected in any order. - const isGoogleMCReady = - isGMCPreconditionReady && - ( googleMCAccount?.status === 'connected' || - ( googleMCAccount?.status === 'incomplete' && - googleMCAccount?.step === 'link_ads' ) ); - const isContinueButtonDisabled = ! ( hasFinishedResolution && isGoogleAdsReady && diff --git a/tests/e2e/specs/setup-mc/step-1-accounts.test.js b/tests/e2e/specs/setup-mc/step-1-accounts.test.js index 1e8b0cf552..04b3f1cc1a 100644 --- a/tests/e2e/specs/setup-mc/step-1-accounts.test.js +++ b/tests/e2e/specs/setup-mc/step-1-accounts.test.js @@ -2,7 +2,6 @@ * Internal dependencies */ import SetUpAccountsPage from '../../utils/pages/setup-mc/step-1-set-up-accounts'; -import SetupAdsAccountPage from '../../utils/pages/setup-ads/setup-ads-accounts'; import { LOAD_STATE } from '../../utils/constants'; import { getFAQPanelTitle, @@ -24,11 +23,6 @@ test.describe.configure( { mode: 'serial' } ); */ let setUpAccountsPage = null; -/** - * @type {import('../../utils/pages/setup-ads/setup-ads-accounts.js').default} setupAdsAccountPage - */ -let setupAdsAccountPage = null; - /** * @type {import('@playwright/test').Page} page */ @@ -38,7 +32,6 @@ test.describe( 'Set up accounts', () => { test.beforeAll( async ( { browser } ) => { page = await browser.newPage(); setUpAccountsPage = new SetUpAccountsPage( page ); - setupAdsAccountPage = new SetupAdsAccountPage( page ); } ); test.afterAll( async () => { @@ -218,6 +211,68 @@ test.describe( 'Set up accounts', () => { expect( page.url() ).toMatch( baseURL + 'google_auth' ); } ); + + test( 'should create merchant center and ads account if does not exist for the user', async () => { + await setUpAccountsPage.mockJetpackConnected(); + await setUpAccountsPage.mockGoogleConnected(); + + await setUpAccountsPage.fulfillAdsAccounts( [ { id: 1 } ] ); + await setUpAccountsPage.mockMCHasAccounts(); + await setUpAccountsPage.mockAdsAccountIncomplete(); + await setUpAccountsPage.mockMCConnected(); + + const once = setUpAccountsPage.fulfillTimes( 1 ); + + await once.mockAdsHasNoAccounts(); + await once.mockMCHasNoAccounts(); + await once.mockAdsAccountDisconnected(); + await once.mockMCNotConnected(); + + await setUpAccountsPage.goto(); + const googleAccountCard = setUpAccountsPage.getGoogleAccountCard(); + + await expect( + googleAccountCard.getByText( + 'You don’t have Merchant Center nor Google Ads accounts, so we’re creating them for you.', + { + exact: true, + } + ) + ).toBeVisible(); + } ); + + test.describe( 'After connecting Google account', () => { + test.beforeEach( async () => { + await setUpAccountsPage.mockJetpackConnected(); + await setUpAccountsPage.mockGoogleConnected(); + await setUpAccountsPage.mockMCConnected(); + await setUpAccountsPage.mockAdsAccountConnected(); + + await setUpAccountsPage.goto(); + } ); + + test( 'should see the merchant center id and ads account id if connected', async () => { + await setUpAccountsPage.mockAdsStatusClaimed(); + + const googleAccountCard = + setUpAccountsPage.getGoogleAccountCard(); + await expect( + googleAccountCard.getByText( 'Merchant Center ID: 1234', { + exact: true, + } ) + ).toBeVisible(); + + await expect( + googleAccountCard.getByText( 'Google Ads ID: 12345', { + exact: true, + } ) + ).toBeVisible(); + + await expect( + googleAccountCard.getByText( 'Connected', { exact: true } ) + ).toBeVisible(); + } ); + } ); } ); test.describe( 'Continue button', () => { @@ -234,7 +289,7 @@ test.describe( 'Set up accounts', () => { test.describe( 'When only Ads is connected', async () => { test.beforeAll( async () => { - await setupAdsAccountPage.mockAdsAccountConnected(); + await setUpAccountsPage.mockAdsAccountConnected(); await setUpAccountsPage.mockMCNotConnected(); await setUpAccountsPage.goto(); @@ -249,7 +304,7 @@ test.describe( 'Set up accounts', () => { test.describe( 'When only MC is connected', async () => { test.beforeAll( async () => { - await setupAdsAccountPage.mockAdsAccountDisconnected(); + await setUpAccountsPage.mockAdsAccountDisconnected(); await setUpAccountsPage.mockMCConnected(); await setUpAccountsPage.goto(); @@ -264,9 +319,9 @@ test.describe( 'Set up accounts', () => { test.describe( 'When all accounts are connected', async () => { test.beforeAll( async () => { - await setupAdsAccountPage.mockAdsAccountConnected(); - await setupAdsAccountPage.mockAdsStatusClaimed(); + await setUpAccountsPage.mockAdsAccountConnected(); await setUpAccountsPage.mockMCConnected(); + await setUpAccountsPage.mockAdsAccountConnected(); await setUpAccountsPage.goto(); } ); diff --git a/tests/e2e/utils/mock-requests.js b/tests/e2e/utils/mock-requests.js index 050da0bc4e..29306ad059 100644 --- a/tests/e2e/utils/mock-requests.js +++ b/tests/e2e/utils/mock-requests.js @@ -11,6 +11,29 @@ export default class MockRequests { this.page = page; } + /** + * Fulfill a request multiple times. + * + * @param {number} times The number of times to fulfill the request. + * @return {this} A proxied instance intercepts the subsequent fulfillRequest calls to attach the `times` option. + */ + fulfillTimes( times ) { + return new Proxy( this, { + get( target, property ) { + const value = Reflect.get( ...arguments ); + + if ( property === 'fulfillRequest' ) { + return function ( url, payload, status, methods ) { + const args = [ url, payload, status, methods, times ]; + return value.apply( target, args ); + }; + } + + return value; + }, + } ); + } + /** * Fulfill a request with a payload. * @@ -18,24 +41,26 @@ export default class MockRequests { * @param {Object} payload The payload to send. * @param {number} status The HTTP status in the response. * @param {Array} methods The HTTP methods in the request to be fulfill. + * @param {number} [times] The number of times to fulfill the request. Optional. * @return {Promise} */ - async fulfillRequest( url, payload, status = 200, methods = [] ) { - await this.page.route( url, ( route ) => { + async fulfillRequest( url, payload, status = 200, methods = [], times ) { + const handler = async ( route ) => { if ( methods.length === 0 || methods.includes( route.request().method() ) ) { - route.fulfill( { + return route.fulfill( { status, - content: 'application/json', + contentType: 'application/json', headers: { 'Access-Control-Allow-Origin': '*' }, body: JSON.stringify( payload ), } ); - } else { - route.fallback(); } - } ); + return route.fallback(); + }; + + await this.page.route( url, handler, { times } ); } /**