diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSegmentationSetupCTAWidget.js b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSegmentationSetupCTAWidget.js index 48f4f72ac29..8aa0bdfa78a 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSegmentationSetupCTAWidget.js +++ b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSegmentationSetupCTAWidget.js @@ -104,24 +104,26 @@ function AudienceSegmentationSetupCTAWidget( { id, Notification } ) { const { dismissItem, dismissPrompt } = useDispatch( CORE_USER ); + const onSuccess = useCallback( () => { + invalidateResolution( 'getQueuedNotifications', [ + viewContext, + NOTIFICATION_GROUPS.DEFAULT, + ] ); + dismissPrompt( id, { + expiresInSeconds: 0, + } ); + // Dismiss success notification in settings. + dismissItem( SETTINGS_VISITOR_GROUPS_SETUP_SUCCESS_NOTIFICATION ); + }, [ dismissItem, dismissPrompt, id, invalidateResolution, viewContext ] ); + + const onError = useCallback( () => { + setShowErrorModal( true ); + }, [ setShowErrorModal ] ); + const { apiErrors, failedAudiences, isSaving, onEnableGroups } = useEnableAudienceGroup( { - onSuccess: () => { - invalidateResolution( 'getQueuedNotifications', [ - viewContext, - NOTIFICATION_GROUPS.DEFAULT, - ] ); - dismissPrompt( id, { - expiresInSeconds: 0, - } ); - // Dismiss success notification in settings. - dismissItem( - SETTINGS_VISITOR_GROUPS_SETUP_SUCCESS_NOTIFICATION - ); - }, - onError: () => { - setShowErrorModal( true ); - }, + onSuccess, + onError, } ); const { clearPermissionScopeError } = useDispatch( CORE_USER ); diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSegmentationSetupCTAWidget.test.js b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSegmentationSetupCTAWidget.test.js index 0c37e210fec..c00bb075b00 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSegmentationSetupCTAWidget.test.js +++ b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSegmentationSetupCTAWidget.test.js @@ -28,6 +28,7 @@ import { } from '../../../../../../../tests/js/test-utils'; import { createTestRegistry, + freezeFetch, muteFetch, provideModules, provideSiteInfo, @@ -898,6 +899,8 @@ describe( 'AudienceSegmentationSetupCTAWidget', () => { it( 'should track an event when the Retry button is clicked', () => { mockTrackEvent.mockClear(); + freezeFetch( syncAvailableAudiencesEndpoint ); + act( () => { fireEvent.click( getByRole( 'button', { name: /retry/i } ) diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/settings/SettingsCardVisitorGroups/SetupCTA.js b/assets/js/modules/analytics-4/components/audience-segmentation/settings/SettingsCardVisitorGroups/SetupCTA.js index 7ea9e9dc48a..4b7d62bfa7b 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/settings/SettingsCardVisitorGroups/SetupCTA.js +++ b/assets/js/modules/analytics-4/components/audience-segmentation/settings/SettingsCardVisitorGroups/SetupCTA.js @@ -19,7 +19,7 @@ /** * WordPress dependencies */ -import { useState } from '@wordpress/element'; +import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; /** @@ -46,18 +46,20 @@ export default function SetupCTA() { const { dismissNotification } = useDispatch( CORE_NOTIFICATIONS ); + const onSuccess = useCallback( () => { + // Dismiss success notification in dashboard. + dismissNotification( AUDIENCE_SEGMENTATION_SETUP_SUCCESS_NOTIFICATION ); + }, [ dismissNotification ] ); + + const onError = useCallback( () => { + setShowErrorModal( true ); + }, [ setShowErrorModal ] ); + const { apiErrors, failedAudiences, isSaving, onEnableGroups } = useEnableAudienceGroup( { redirectURL: global.location.href, - onSuccess: () => { - // Dismiss success notification in dashboard. - dismissNotification( - AUDIENCE_SEGMENTATION_SETUP_SUCCESS_NOTIFICATION - ); - }, - onError: () => { - setShowErrorModal( true ); - }, + onSuccess, + onError, } ); const setupErrorCode = useSelect( ( select ) => diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/settings/SettingsCardVisitorGroups/SetupCTA.test.js b/assets/js/modules/analytics-4/components/audience-segmentation/settings/SettingsCardVisitorGroups/SetupCTA.test.js index f9cf30afee5..4df32d45d94 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/settings/SettingsCardVisitorGroups/SetupCTA.test.js +++ b/assets/js/modules/analytics-4/components/audience-segmentation/settings/SettingsCardVisitorGroups/SetupCTA.test.js @@ -60,6 +60,9 @@ describe( 'SettingsCardVisitorGroups SetupCTA', () => { const syncAvailableAudiencesEndpoint = new RegExp( '^/google-site-kit/v1/modules/analytics-4/data/sync-audiences' ); + const syncAvailableCustomDimensionsEndpoint = new RegExp( + '^/google-site-kit/v1/modules/analytics-4/data/sync-custom-dimensions' + ); beforeEach( () => { registry = createTestRegistry(); @@ -157,6 +160,9 @@ describe( 'SettingsCardVisitorGroups SetupCTA', () => { ); expect( fetchMock ).toHaveFetched( syncAvailableAudiencesEndpoint ); + expect( fetchMock ).toHaveFetched( + syncAvailableCustomDimensionsEndpoint + ); await act( waitForDefaultTimeouts ); } ); @@ -185,6 +191,16 @@ describe( 'SettingsCardVisitorGroups SetupCTA', () => { describe( 'AudienceErrorModal', () => { it( 'should show the OAuth error modal when the required scopes are not granted', async () => { + fetchMock.postOnce( syncAvailableAudiencesEndpoint, { + body: [], + status: 200, + } ); + + fetchMock.postOnce( syncAvailableCustomDimensionsEndpoint, { + body: [], + status: 200, + } ); + provideSiteInfo( registry, { setupErrorCode: 'access_denied', } ); @@ -221,13 +237,13 @@ describe( 'SettingsCardVisitorGroups SetupCTA', () => { ); } ); - // Allow the `trackEvent()` promise to resolve. - await waitForDefaultTimeouts(); - - // Verify the error is an OAuth error variant. - expect( - getByText( /Analytics update failed/i ) - ).toBeInTheDocument(); + // Wait for the error modal to be displayed. + await waitFor( () => { + // Verify the error is an OAuth error variant. + expect( + getByText( /Analytics update failed/i ) + ).toBeInTheDocument(); + } ); // Verify the "Get help" link is displayed. expect( getByText( /get help/i ) ).toBeInTheDocument(); @@ -252,7 +268,7 @@ describe( 'SettingsCardVisitorGroups SetupCTA', () => { data: { reason: ERROR_REASON_INSUFFICIENT_PERMISSIONS }, }; - fetchMock.post( syncAvailableAudiencesEndpoint, { + fetchMock.postOnce( syncAvailableAudiencesEndpoint, { body: errorResponse, status: 500, } ); @@ -292,7 +308,7 @@ describe( 'SettingsCardVisitorGroups SetupCTA', () => { data: { status: 500 }, }; - fetchMock.post( syncAvailableAudiencesEndpoint, { + fetchMock.postOnce( syncAvailableAudiencesEndpoint, { body: errorResponse, status: 500, } ); diff --git a/assets/js/modules/analytics-4/datastore/audiences.js b/assets/js/modules/analytics-4/datastore/audiences.js index b9822b021db..9f4b838318e 100644 --- a/assets/js/modules/analytics-4/datastore/audiences.js +++ b/assets/js/modules/analytics-4/datastore/audiences.js @@ -128,6 +128,129 @@ const fetchSyncAvailableAudiencesStore = createFetchStore( { }, } ); +/** + * Retrieves the initial set of selected audiences from the existing audiences. + * + * @since n.e.x.t + * + * @param {Object} registry Registry object. + * @return {Object} Object with `configuredAudiences` or `error`. + */ +async function getConfiguredAudiencesFromExistingAudiences( registry ) { + const { resolveSelect, select } = registry; + + const availableAudiences = + select( MODULES_ANALYTICS_4 ).getAvailableAudiences(); + + const { error, configuredAudiences } = await getInitialConfiguredAudiences( + registry, + availableAudiences + ); + + if ( error ) { + return { error }; + } + + if ( configuredAudiences.length === 1 ) { + // Add 'Purchasers' audience if it has data. + const purchasersAudience = availableAudiences.find( + ( { audienceSlug } ) => audienceSlug === 'purchasers' + ); + + if ( purchasersAudience ) { + const purchasersResourceDataAvailabilityDate = await resolveSelect( + MODULES_ANALYTICS_4 + ).getResourceDataAvailabilityDate( + purchasersAudience.name, + RESOURCE_TYPE_AUDIENCE + ); + + if ( purchasersResourceDataAvailabilityDate ) { + configuredAudiences.push( purchasersAudience.name ); + } + } + } + + return { configuredAudiences }; +} + +/** + * Retrives the initial set of audiences for selection. + * + * @since 1.136.0 + * @since n.e.x.t Extracted to a helper function. Renamed from `retrieveInitialAudienceSelection` to `getInitialConfiguredAudiences`. + * + * @param {Object} registry Registry object. + * @param {Array} availableAudiences List of available audiences. + * @return {Object} Object with properties `configuredAudiences` or `error`. + */ +async function getInitialConfiguredAudiences( registry, availableAudiences ) { + const { select } = registry; + + const configuredAudiences = []; + + const userAudiences = availableAudiences.filter( + ( { audienceType } ) => audienceType === 'USER_AUDIENCE' + ); + + if ( userAudiences.length > 0 ) { + // If there are user audiences, filter and sort them by total users over the last 90 days, + // and add the top two (MAX_INITIAL_AUDIENCES) which have users to the configured audiences. + + const endDate = select( CORE_USER ).getReferenceDate(); + + const startDate = getPreviousDate( + endDate, + 90 + DATE_RANGE_OFFSET // Add offset to ensure we have data for the entirety of the last 90 days. + ); + + const { audienceResourceNames, error } = + await getNonZeroDataAudiencesSortedByTotalUsers( + registry, + userAudiences, + startDate, + endDate + ); + + if ( error ) { + return { error }; + } + + configuredAudiences.push( + ...audienceResourceNames.slice( 0, MAX_INITIAL_AUDIENCES ) + ); + } + + if ( configuredAudiences.length < MAX_INITIAL_AUDIENCES ) { + // If there are less than two (MAX_INITIAL_AUDIENCES) configured user audiences, add the Site Kit-created audiences + // if they exist, up to the limit of two. + + const siteKitAudiences = availableAudiences.filter( + ( { audienceType } ) => audienceType === 'SITE_KIT_AUDIENCE' + ); + + // Audience slugs to sort by: + const sortedSlugs = [ 'new-visitors', 'returning-visitors' ]; + + const sortedSiteKitAudiences = siteKitAudiences.sort( + ( audienceA, audienceB ) => { + const indexA = sortedSlugs.indexOf( audienceA.audienceSlug ); + const indexB = sortedSlugs.indexOf( audienceB.audienceSlug ); + + return indexA - indexB; + } + ); + + const audienceResourceNames = sortedSiteKitAudiences + .slice( 0, MAX_INITIAL_AUDIENCES - configuredAudiences.length ) + .map( ( { name } ) => name ); + + configuredAudiences.push( ...audienceResourceNames ); + } + + return { configuredAudiences }; +} + export const baseInitialState = { isSettingUpAudiences: false, }; @@ -262,88 +385,6 @@ const baseActions = { } }, - /** - * Retrives the initial set of audiences for selection. - * - * @since 1.136.0 - * - * @param {Array} availableAudiences List of available audiences. - * @return {Object} Object with properties `configuredAudiences` or `error`. - */ - *retrieveInitialAudienceSelection( availableAudiences ) { - const registry = yield commonActions.getRegistry(); - - const { select } = registry; - - const configuredAudiences = []; - - const userAudiences = availableAudiences.filter( - ( { audienceType } ) => audienceType === 'USER_AUDIENCE' - ); - - if ( userAudiences.length > 0 ) { - // If there are user audiences, filter and sort them by total users over the last 90 days, - // and add the top two (MAX_INITIAL_AUDIENCES) which have users to the configured audiences. - - const endDate = select( CORE_USER ).getReferenceDate(); - - const startDate = getPreviousDate( - endDate, - 90 + DATE_RANGE_OFFSET // Add offset to ensure we have data for the entirety of the last 90 days. - ); - - const { audienceResourceNames, error } = yield commonActions.await( - getNonZeroDataAudiencesSortedByTotalUsers( - registry, - userAudiences, - startDate, - endDate - ) - ); - - if ( error ) { - return { error }; - } - - configuredAudiences.push( - ...audienceResourceNames.slice( 0, MAX_INITIAL_AUDIENCES ) - ); - } - - if ( configuredAudiences.length < MAX_INITIAL_AUDIENCES ) { - // If there are less than two (MAX_INITIAL_AUDIENCES) configured user audiences, add the Site Kit-created audiences - // if they exist, up to the limit of two. - - const siteKitAudiences = availableAudiences.filter( - ( { audienceType } ) => audienceType === 'SITE_KIT_AUDIENCE' - ); - - // Audience slugs to sort by: - const sortedSlugs = [ 'new-visitors', 'returning-visitors' ]; - - const sortedSiteKitAudiences = siteKitAudiences.sort( - ( audienceA, audienceB ) => { - const indexA = sortedSlugs.indexOf( - audienceA.audienceSlug - ); - const indexB = sortedSlugs.indexOf( - audienceB.audienceSlug - ); - - return indexA - indexB; - } - ); - - const audienceResourceNames = sortedSiteKitAudiences - .slice( 0, MAX_INITIAL_AUDIENCES - configuredAudiences.length ) - .map( ( { name } ) => name ); - - configuredAudiences.push( ...audienceResourceNames ); - } - - return { configuredAudiences }; - }, - /** * Populates the configured audiences with the top two user audiences and/or the Site Kit-created audiences, * depending on their availability and suitability (data over the last 90 days is required for user audiences). @@ -370,43 +411,57 @@ const baseActions = { }, /** - * This contains the main logic for the `*enableAudienceGroup()` action above. + * Checks if the user needs to grant the Analytics 4 edit scope to create audiences or custom dimensions. * - * @since 1.136.0 + * @since n.e.x.t * - * @param {Array} failedSiteKitAudienceSlugs List of failed Site Kit audience slugs to retry. - * @return {Object} Object with `failedSiteKitAudienceSlugs`, `createdSiteKitAudienceSlugs` and `error`. + * @return {Object} Object with `needsScope` or `error`. */ - *enableAudienceGroupMain( failedSiteKitAudienceSlugs ) { + *determineNeedForAnalytics4EditScope() { const registry = yield commonActions.getRegistry(); + const { resolveSelect, select } = registry; - const { dispatch, select, resolveSelect } = registry; - - const { response: availableAudiences, error: syncError } = - yield commonActions.await( - dispatch( MODULES_ANALYTICS_4 ).syncAvailableAudiences() - ); + yield commonActions.await( + resolveSelect( MODULES_ANALYTICS_4 ).getAvailableCustomDimensions() + ); - if ( syncError ) { - return { error: syncError }; + if ( + ! select( MODULES_ANALYTICS_4 ).hasCustomDimensions( + 'googlesitekit_post_type' + ) + ) { + return { needsScope: true }; } - const { error: syncDimensionsError } = yield commonActions.await( - dispatch( MODULES_ANALYTICS_4 ).fetchSyncAvailableCustomDimensions() + const { error, configuredAudiences } = yield commonActions.await( + getConfiguredAudiencesFromExistingAudiences( registry ) ); - if ( syncDimensionsError ) { - return { error: syncDimensionsError }; + if ( error ) { + return { error }; } + return { needsScope: configuredAudiences.length === 0 }; + }, + + /** + * This contains the main logic for the `*enableAudienceGroup()` action above. + * + * @since 1.136.0 + * + * @param {Array} failedSiteKitAudienceSlugs List of failed Site Kit audience slugs to retry. + * @return {Object} Object with `failedSiteKitAudienceSlugs`, `createdSiteKitAudienceSlugs` and `error`. + */ + *enableAudienceGroupMain( failedSiteKitAudienceSlugs ) { + const registry = yield commonActions.getRegistry(); + const { dispatch, select, resolveSelect } = registry; + const configuredAudiences = []; if ( ! failedSiteKitAudienceSlugs?.length ) { const { error, configuredAudiences: audiences } = yield commonActions.await( - dispatch( - MODULES_ANALYTICS_4 - ).retrieveInitialAudienceSelection( availableAudiences ) + getConfiguredAudiencesFromExistingAudiences( registry ) ); if ( error ) { @@ -416,29 +471,6 @@ const baseActions = { configuredAudiences.push( ...audiences ); } - if ( configuredAudiences.length === 1 ) { - // Add 'Purchasers' audience if it has data. - const purchasersAudience = availableAudiences.find( - ( { audienceSlug } ) => audienceSlug === 'purchasers' - ); - - if ( purchasersAudience ) { - const purchasersResourceDataAvailabilityDate = - yield commonActions.await( - resolveSelect( - MODULES_ANALYTICS_4 - ).getResourceDataAvailabilityDate( - purchasersAudience.name, - RESOURCE_TYPE_AUDIENCE - ) - ); - - if ( purchasersResourceDataAvailabilityDate ) { - configuredAudiences.push( purchasersAudience.name ); - } - } - } - if ( configuredAudiences.length === 0 ) { const requiredAudienceSlugs = [ 'new-visitors', @@ -497,11 +529,15 @@ const baseActions = { configuredAudiences.push( ...existingConfiguredAudiences ); // Resync available audiences to ensure the newly created audiences are available. - const { response: newAvailableAudiences } = + const { error, response: newAvailableAudiences } = yield commonActions.await( dispatch( MODULES_ANALYTICS_4 ).syncAvailableAudiences() ); + if ( error ) { + return { error }; + } + // Find the audience in the newly available audiences that matches the required slug. // If a matching audience is found and it's not already in the configured audiences, add it. // This is to ensure if one audience was created successfully but the other failed, @@ -645,9 +681,7 @@ const baseActions = { error: retrieveInitialAudienceSelectionError, configuredAudiences, } = yield commonActions.await( - dispatch( MODULES_ANALYTICS_4 ).retrieveInitialAudienceSelection( - availableAudiences - ) + getInitialConfiguredAudiences( registry, availableAudiences ) ); if ( retrieveInitialAudienceSelectionError ) { diff --git a/assets/js/modules/analytics-4/datastore/audiences.test.js b/assets/js/modules/analytics-4/datastore/audiences.test.js index 48eee1811b2..4f65729d8a1 100644 --- a/assets/js/modules/analytics-4/datastore/audiences.test.js +++ b/assets/js/modules/analytics-4/datastore/audiences.test.js @@ -615,7 +615,7 @@ describe( 'modules/analytics-4 audiences', () => { provideUserAuthentication( registry ); registry.dispatch( MODULES_ANALYTICS_4 ).setSettings( { - availableAudiences: null, + availableAudiences: availableAudiencesFixture, availableCustomDimensions: [ 'googlesitekit_post_type' ], propertyID: testPropertyID, } ); @@ -635,16 +635,6 @@ describe( 'modules/analytics-4 audiences', () => { } ); it( 'sets `isSettingUpAudiences` to true while the action is in progress', async () => { - fetchMock.postOnce( syncAvailableAudiencesEndpoint, { - body: availableAudiencesFixture, - status: 200, - } ); - - fetchMock.postOnce( syncAvailableCustomDimensionsEndpoint, { - body: [ 'googlesitekit_post_type' ], - status: 200, - } ); - fetchMock.postOnce( audienceSettingsEndpoint, { body: { configuredAudiences: [], @@ -699,62 +689,6 @@ describe( 'modules/analytics-4 audiences', () => { ); } ); - it( 'syncs `availableAudiences`', async () => { - fetchMock.postOnce( syncAvailableAudiencesEndpoint, { - body: availableAudiencesFixture, - status: 200, - } ); - - fetchMock.postOnce( syncAvailableCustomDimensionsEndpoint, { - body: [ 'googlesitekit_post_type' ], - status: 200, - } ); - - fetchMock.postOnce( audienceSettingsEndpoint, { - body: { - configuredAudiences: [], - isAudienceSegmentationWidgetHidden, - }, - status: 200, - } ); - - muteFetch( expirableItemEndpoint ); - - const options = registry - .select( MODULES_ANALYTICS_4 ) - .getAudiencesUserCountReportOptions( - [ availableUserAudienceFixture ], - { startDate, endDate: referenceDate } - ); - - registry - .dispatch( MODULES_ANALYTICS_4 ) - .receiveGetReport( {}, { options } ); - - registry - .dispatch( MODULES_ANALYTICS_4 ) - .finishResolution( 'getReport', [ options ] ); - - await registry - .dispatch( MODULES_ANALYTICS_4 ) - .enableAudienceGroup(); - - expect( fetchMock ).toHaveFetchedTimes( - 1, - syncAvailableAudiencesEndpoint - ); - - expect( - registry - .select( MODULES_ANALYTICS_4 ) - .getAvailableAudiences() - ).toEqual( availableAudiencesFixture ); - - await waitFor( () => - expect( fetchMock ).toHaveFetched( surveyTriggerEndpoint ) - ); - } ); - it.each( [ [ 'the top 1 from 1 of 3 candidate user audiences with data over the past 90 days', // Test description differentiator. @@ -803,15 +737,9 @@ describe( 'modules/analytics-4 audiences', () => { _, { totalUsersByAudience, expectedConfiguredAudiences } ) => { - fetchMock.postOnce( syncAvailableAudiencesEndpoint, { - body: availableUserAudiences, - status: 200, - } ); - - fetchMock.postOnce( syncAvailableCustomDimensionsEndpoint, { - body: [ 'googlesitekit_post_type' ], - status: 200, - } ); + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setAvailableAudiences( availableUserAudiences ); fetchMock.postOnce( audienceSettingsEndpoint, { body: { @@ -910,18 +838,12 @@ describe( 'modules/analytics-4 audiences', () => { _, { totalUsersByAudience, expectedConfiguredAudiences } ) => { - fetchMock.postOnce( syncAvailableAudiencesEndpoint, { - body: [ + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setAvailableAudiences( [ ...availableAudiencesFixture, ...availableUserAudiences.slice( 1 ), - ], - status: 200, - } ); - - fetchMock.postOnce( syncAvailableCustomDimensionsEndpoint, { - body: [ 'googlesitekit_post_type' ], - status: 200, - } ); + ] ); fetchMock.postOnce( audienceSettingsEndpoint, { body: { @@ -1005,31 +927,15 @@ describe( 'modules/analytics-4 audiences', () => { ], ]; - fetchMock.postOnce( syncAvailableCustomDimensionsEndpoint, { - body: [ 'googlesitekit_post_type' ], + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setAvailableAudiences( availableUserAudiences ); + + fetchMock.postOnce( syncAvailableAudiencesEndpoint, { + body: finalAvailableAudiences, status: 200, } ); - fetchMock.post( - { - url: syncAvailableAudiencesEndpoint, - repeat: 2, - }, - () => { - const callCount = fetchMock.calls( - syncAvailableAudiencesEndpoint - ).length; - - return { - body: - callCount === 1 - ? availableUserAudiences - : finalAvailableAudiences, - status: 200, - }; - } - ); - const expectedConfiguredAudiences = [ createdNewVisitorsAudienceName, createdReturningVisitorsAudienceName, @@ -1092,7 +998,7 @@ describe( 'modules/analytics-4 audiences', () => { .enableAudienceGroup(); expect( fetchMock ).toHaveFetchedTimes( - 2, + 1, syncAvailableAudiencesEndpoint ); @@ -1176,16 +1082,6 @@ describe( 'modules/analytics-4 audiences', () => { property: {}, } ); - fetchMock.postOnce( syncAvailableAudiencesEndpoint, { - body: availableAudiences, - status: 200, - } ); - - fetchMock.postOnce( syncAvailableCustomDimensionsEndpoint, { - body: [ 'googlesitekit_post_type' ], - status: 200, - } ); - const options = registry .select( MODULES_ANALYTICS_4 ) .getAudiencesUserCountReportOptions( [ userAudience ], { @@ -1247,18 +1143,12 @@ describe( 'modules/analytics-4 audiences', () => { availableReturningVisitorsAudienceFixture.name, ]; - fetchMock.postOnce( syncAvailableAudiencesEndpoint, { - body: [ + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setAvailableAudiences( [ ...availableAudiencesFixture, ...availableUserAudiences.slice( 1 ), - ], - status: 200, - } ); - - fetchMock.postOnce( syncAvailableCustomDimensionsEndpoint, { - body: [ 'googlesitekit_post_type' ], - status: 200, - } ); + ] ); fetchMock.postOnce( audienceSettingsEndpoint, { body: { @@ -1326,18 +1216,12 @@ describe( 'modules/analytics-4 audiences', () => { availableReturningVisitorsAudienceFixture.name, ]; - fetchMock.postOnce( syncAvailableAudiencesEndpoint, { - body: [ + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setAvailableAudiences( [ availableNewVisitorsAudienceFixture, availableReturningVisitorsAudienceFixture, - ], - status: 200, - } ); - - fetchMock.postOnce( syncAvailableCustomDimensionsEndpoint, { - body: [ 'googlesitekit_post_type' ], - status: 200, - } ); + ] ); fetchMock.postOnce( audienceSettingsEndpoint, { body: { @@ -1379,18 +1263,13 @@ describe( 'modules/analytics-4 audiences', () => { provideUserCapabilities( registry ); registry.dispatch( MODULES_ANALYTICS_4 ).setSettings( { - availableAudiences: null, - availableCustomDimensions: null, + availableAudiences: availableAudiencesFixture, + availableCustomDimensions: [], propertyID: testPropertyID, } ); } ); it( "creates the `googlesitekit_post_type` custom dimension if it doesn't exist", async () => { - fetchMock.postOnce( syncAvailableAudiencesEndpoint, { - body: availableAudiencesFixture, - status: 200, - } ); - fetchMock.postOnce( audienceSettingsEndpoint, { body: { configuredAudiences: [], @@ -1399,27 +1278,12 @@ describe( 'modules/analytics-4 audiences', () => { status: 200, } ); - fetchMock.post( - { - url: syncAvailableCustomDimensionsEndpoint, - repeat: 2, - }, - () => { - const callCount = fetchMock.calls( - syncAvailableCustomDimensionsEndpoint - ).length; - - return { - body: - callCount === 1 - ? [] - : [ - CUSTOM_DIMENSION_DEFINITIONS.googlesitekit_post_type, - ], - status: 200, - }; - } - ); + fetchMock.postOnce( syncAvailableCustomDimensionsEndpoint, { + body: [ + CUSTOM_DIMENSION_DEFINITIONS.googlesitekit_post_type, + ], + status: 200, + } ); fetchMock.postOnce( createCustomDimensionEndpoint, { body: CUSTOM_DIMENSION_DEFINITIONS.googlesitekit_post_type, @@ -1462,7 +1326,7 @@ describe( 'modules/analytics-4 audiences', () => { ); expect( fetchMock ).toHaveFetchedTimes( - 2, + 1, syncAvailableCustomDimensionsEndpoint ); @@ -1498,42 +1362,9 @@ describe( 'modules/analytics-4 audiences', () => { }; beforeEach( () => { - fetchMock.postOnce( syncAvailableCustomDimensionsEndpoint, { - body: [ 'googlesitekit_post_type' ], - status: 200, - } ); - } ); - - it( 'should return and dispatch an error if syncing available audiences request fails', async () => { - fetchMock.post( syncAvailableAudiencesEndpoint, { - body: errorResponse, - status: 500, - } ); - - fetchMock.get( audienceSettingsEndpoint, { - body: { - data: { - configuredAudiences: [], - }, - }, - } ); - - const { response, error } = await registry + registry .dispatch( MODULES_ANALYTICS_4 ) - .enableAudienceGroup(); - - await waitForDefaultTimeouts(); - - expect( response ).toBeUndefined(); - expect( error ).toEqual( errorResponse ); - - expect( - registry - .select( MODULES_ANALYTICS_4 ) - .getErrorForAction( 'syncAvailableAudiences' ) - ).toEqual( errorResponse ); - - expect( console ).toHaveErrored(); + .setAvailableAudiences( [] ); } ); it( 'should return failed audience names when creating new visitors and returning visitors audiences fails', async () => { @@ -1542,11 +1373,6 @@ describe( 'modules/analytics-4 audiences', () => { 'returning-visitors', ]; - fetchMock.postOnce( syncAvailableAudiencesEndpoint, { - body: [], - status: 200, - } ); - // Mocking createAudience API call with failure response. fetchMock.post( { url: createAudienceEndpoint, repeat: 2 }, @@ -1606,11 +1432,6 @@ describe( 'modules/analytics-4 audiences', () => { } ); - fetchMock.postOnce( syncAvailableAudiencesEndpoint, { - body: [], - status: 200, - } ); - const result = await registry .dispatch( MODULES_ANALYTICS_4 ) .enableAudienceGroup( failedAudiencesToRetry ); @@ -1631,16 +1452,15 @@ describe( 'modules/analytics-4 audiences', () => { createAudienceEndpoint ); - expect( fetchMock ).toHaveFetchedTimes( - 1, - syncAvailableAudiencesEndpoint - ); - // Ensure conse error is logged only once. expect( console ).toHaveErrored(); } ); it( 'should create provided "failedSiteKitAudienceSlugs" correctly', async () => { + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setAvailableAudiences( availableUserAudiences ); + const failedAudiencesToRetry = [ 'new-visitors', 'returning-visitors', @@ -1670,25 +1490,10 @@ describe( 'modules/analytics-4 audiences', () => { ], ]; - fetchMock.post( - { - url: syncAvailableAudiencesEndpoint, - repeat: 2, - }, - () => { - const callCount = fetchMock.calls( - syncAvailableAudiencesEndpoint - ).length; - - return { - body: - callCount === 1 - ? availableUserAudiences - : finalAvailableAudiences, - status: 200, - }; - } - ); + fetchMock.postOnce( syncAvailableAudiencesEndpoint, { + body: finalAvailableAudiences, + status: 200, + } ); fetchMock.postOnce( audienceSettingsEndpoint, { body: { @@ -1727,7 +1532,7 @@ describe( 'modules/analytics-4 audiences', () => { .enableAudienceGroup( failedAudiencesToRetry ); expect( fetchMock ).toHaveFetchedTimes( - 2, + 1, syncAvailableAudiencesEndpoint ); @@ -1791,11 +1596,6 @@ describe( 'modules/analytics-4 audiences', () => { }, }; - fetchMock.postOnce( syncAvailableAudiencesEndpoint, { - body: [], - status: 200, - } ); - // Mocking createAudience API call with insufficient permissions error. fetchMock.post( { url: createAudienceEndpoint, repeat: 2 }, @@ -1822,6 +1622,94 @@ describe( 'modules/analytics-4 audiences', () => { expect( console ).toHaveErrored(); expect( console ).toHaveErrored(); } ); + + it( 'should return and dispatch an error if syncing available audiences request fails', async () => { + const createdNewVisitorsAudienceName = + 'properties/12345/audiences/888'; + const createdReturningVisitorsAudienceName = + 'properties/12345/audiences/999'; + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setAvailableAudiences( availableUserAudiences ); + + fetchMock.post( syncAvailableAudiencesEndpoint, { + body: errorResponse, + status: 500, + } ); + + const expectedConfiguredAudiences = [ + createdNewVisitorsAudienceName, + createdReturningVisitorsAudienceName, + ]; + + fetchMock.postOnce( audienceSettingsEndpoint, { + body: { + configuredAudiences: expectedConfiguredAudiences, + isAudienceSegmentationWidgetHidden, + }, + status: 200, + } ); + + fetchMock.post( + { url: createAudienceEndpoint, repeat: 2 }, + ( url, opts ) => { + return { + body: opts.body.includes( 'new_visitors' ) + ? { + ...SITE_KIT_AUDIENCE_DEFINITIONS[ + 'new-visitors' + ], + name: createdNewVisitorsAudienceName, + } + : { + ...SITE_KIT_AUDIENCE_DEFINITIONS[ + 'returning-visitors' + ], + name: createdReturningVisitorsAudienceName, + }, + status: 200, + }; + } + ); + + muteFetch( expirableItemEndpoint ); + + const options = registry + .select( MODULES_ANALYTICS_4 ) + .getAudiencesUserCountReportOptions( + availableUserAudiences, + { startDate, endDate: referenceDate } + ); + + registry.dispatch( MODULES_ANALYTICS_4 ).receiveGetReport( + createAudiencesTotalUsersMockReport( { + [ availableUserAudiences[ 0 ].name ]: 0, + [ availableUserAudiences[ 1 ].name ]: 0, + [ availableUserAudiences[ 2 ].name ]: 0, + } ), + { options } + ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .finishResolution( 'getReport', [ options ] ); + + const { response, error } = await registry + .dispatch( MODULES_ANALYTICS_4 ) + .enableAudienceGroup(); + + expect( response ).toBeUndefined(); + expect( error ).toEqual( errorResponse ); + + expect( + registry + .select( MODULES_ANALYTICS_4 ) + .getErrorForAction( 'syncAvailableAudiences' ) + ).toEqual( errorResponse ); + + expect( console ).toHaveErrored(); + } ); } ); } ); diff --git a/assets/js/modules/analytics-4/hooks/useEnableAudienceGroup.js b/assets/js/modules/analytics-4/hooks/useEnableAudienceGroup.js index 2d83c40ad6c..800353478ec 100644 --- a/assets/js/modules/analytics-4/hooks/useEnableAudienceGroup.js +++ b/assets/js/modules/analytics-4/hooks/useEnableAudienceGroup.js @@ -64,7 +64,12 @@ export default function useEnableAudienceGroup( { const { setValues } = useDispatch( CORE_FORMS ); const { setPermissionScopeError } = useDispatch( CORE_USER ); - const { enableAudienceGroup } = useDispatch( MODULES_ANALYTICS_4 ); + const { + enableAudienceGroup, + fetchSyncAvailableCustomDimensions, + determineNeedForAnalytics4EditScope, + syncAvailableAudiences, + } = useDispatch( MODULES_ANALYTICS_4 ); if ( ! redirectURL ) { redirectURL = addQueryArgs( global.location.href, { @@ -72,13 +77,56 @@ export default function useEnableAudienceGroup( { } ); } + const maybeEnableAudienceGroup = useCallback( async () => { + const { error: syncAudiencesError } = await syncAvailableAudiences(); + + if ( syncAudiencesError ) { + return { error: syncAudiencesError }; + } + + const { error: syncDimensionsError } = + await fetchSyncAvailableCustomDimensions(); + + if ( syncDimensionsError ) { + return { error: syncDimensionsError }; + } + + if ( ! hasAnalytics4EditScope ) { + const { error, needsScope } = + await determineNeedForAnalytics4EditScope(); + + if ( error ) { + return { error }; + } else if ( needsScope ) { + return { needsScope: true }; + } + } + + setValues( AUDIENCE_SEGMENTATION_SETUP_FORM, { + autoSubmit: false, + } ); + + const { error, failedSiteKitAudienceSlugs } = + ( await enableAudienceGroup( failedAudiences ) ) || {}; + + return { error, failedSiteKitAudienceSlugs }; + }, [ + enableAudienceGroup, + failedAudiences, + fetchSyncAvailableCustomDimensions, + hasAnalytics4EditScope, + determineNeedForAnalytics4EditScope, + setValues, + syncAvailableAudiences, + ] ); + const onEnableGroups = useCallback( async () => { setIsSaving( true ); - // If scope is not granted, trigger scope error right away. These are - // typically handled automatically based on API responses, but - // this particular case has some special handling to improve UX. - if ( ! hasAnalytics4EditScope ) { + const { error, needsScope, failedSiteKitAudienceSlugs } = + await maybeEnableAudienceGroup(); + + if ( needsScope ) { setValues( AUDIENCE_SEGMENTATION_SETUP_FORM, { autoSubmit: true, } ); @@ -99,14 +147,12 @@ export default function useEnableAudienceGroup( { }, } ); + // Note that we don't set isSaving to false here, this is to ensure that, when the page begins to navigate to the OAuth flow: + // - The in-progress state for the Audience Segmentation Setup CTA is retained. + // - The OAuth error modal doesn't disappear when it's shown and the user clicks the "Retry" button. return; } - setValues( AUDIENCE_SEGMENTATION_SETUP_FORM, { autoSubmit: false } ); - - const { error, failedSiteKitAudienceSlugs } = - ( await enableAudienceGroup( failedAudiences ) ) || {}; - if ( !! error || !! failedSiteKitAudienceSlugs ) { onError?.(); } else { @@ -114,25 +160,27 @@ export default function useEnableAudienceGroup( { } if ( isMounted() ) { + function newArrayIfNotEmpty( currentArray ) { + return currentArray.length ? [] : currentArray; + } + if ( error ) { setApiErrors( [ error ] ); - setFailedAudiences( [] ); + setFailedAudiences( newArrayIfNotEmpty ); } else if ( Array.isArray( failedSiteKitAudienceSlugs ) ) { setFailedAudiences( failedSiteKitAudienceSlugs ); - setApiErrors( [] ); + setApiErrors( newArrayIfNotEmpty ); } else { - setApiErrors( [] ); - setFailedAudiences( [] ); + setApiErrors( newArrayIfNotEmpty ); + setFailedAudiences( newArrayIfNotEmpty ); } setIsSaving( false ); } }, [ - hasAnalytics4EditScope, - setValues, - enableAudienceGroup, - failedAudiences, + maybeEnableAudienceGroup, isMounted, + setValues, setPermissionScopeError, redirectURL, onError, diff --git a/assets/js/modules/analytics-4/hooks/useEnableAudienceGroup.test.js b/assets/js/modules/analytics-4/hooks/useEnableAudienceGroup.test.js index bda7a72e1f6..82299bad687 100644 --- a/assets/js/modules/analytics-4/hooks/useEnableAudienceGroup.test.js +++ b/assets/js/modules/analytics-4/hooks/useEnableAudienceGroup.test.js @@ -144,30 +144,117 @@ describe( 'useEnableAudienceGroup', () => { expect( result.current.isSaving ).toBe( true ); } ); - it( 'should set permission scope error when `onEnableGroups` is called but the user does not have the required scope', () => { + it.each( [ + [ + 'audiences and custom dimension', + { availableAudiences: null, availableCustomDimensions: null }, + ], + [ + 'audiences', + { + availableAudiences: null, + availableCustomDimensions: [ 'googlesitekit_post_type' ], + }, + ], + [ + 'custom dimension', + { + availableAudiences: audiencesFixture, + availableCustomDimensions: null, + }, + ], + ] )( + 'should set permission scope error when `onEnableGroups` is called but the user does not have the required scope and %s', + async ( _, settings ) => { + provideUserAuthentication( registry, { + grantedScopes: [], + } ); + + registry.dispatch( MODULES_ANALYTICS_4 ).setSettings( settings ); + + fetchMock.postOnce( syncAvailableAudiencesEndpoint, { + status: 200, + body: [], + } ); + + fetchMock.postOnce( syncAvailableCustomDimensionsEndpoint, { + body: [], + status: 200, + } ); + + const { result } = renderHook( () => useEnableAudienceGroup(), { + registry, + } ); + + const { onEnableGroups } = result.current; + + await actHook( async () => { + await onEnableGroups(); + } ); + + const { message } = registry + .select( CORE_USER ) + .getPermissionScopeError(); + + expect( message ).toBe( + 'Additional permissions are required to create new audiences in Analytics.' + ); + + expect( enableAudienceGroupSpy ).not.toHaveBeenCalled(); + } + ); + + it( 'should not set permission scope error when `onEnableGroups` is called and the user does not have the required scope, but has required audiences and custom dimension', async () => { provideUserAuthentication( registry, { grantedScopes: [], } ); + registry.dispatch( MODULES_ANALYTICS_4 ).setSettings( { + availableAudiences: audiencesFixture, + availableCustomDimensions: [ 'googlesitekit_post_type' ], + } ); + + fetchMock.post( syncAvailableAudiencesEndpoint, { + status: 200, + body: audiencesFixture, + } ); + + fetchMock.post( syncAvailableCustomDimensionsEndpoint, { + body: [ 'googlesitekit_post_type' ], + status: 200, + } ); + + fetchMock.postOnce( audienceSettingsEndpoint, { + status: 200, + body: { + configuredAudiences: [ + audiencesFixture[ 3 ].name, + audiencesFixture[ 4 ].name, + ], + isAudienceSegmentationWidgetHidden: false, + }, + } ); + + muteFetch( reportEndpoint ); + muteFetch( expirableItemEndpoint ); + + mockSurveyEndpoints(); + const { result } = renderHook( () => useEnableAudienceGroup(), { registry, } ); const { onEnableGroups } = result.current; - actHook( () => { - onEnableGroups(); + await actHook( async () => { + await onEnableGroups(); } ); - const { message } = registry - .select( CORE_USER ) - .getPermissionScopeError(); + expect( + registry.select( CORE_USER ).getPermissionScopeError() + ).toBeNull(); - expect( message ).toBe( - 'Additional permissions are required to create new audiences in Analytics.' - ); - - expect( enableAudienceGroupSpy ).not.toHaveBeenCalled(); + expect( enableAudienceGroupSpy ).toHaveBeenCalledTimes( 1 ); } ); it( 'should automatically call `onEnableGroups` function when user returns from the OAuth screen', async () => {