diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index a658fea2cab..11161057e09 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -80,12 +80,15 @@ jobs: matrix: amp_version: [''] wp_version: ['latest', 'nightly'] + ca_cert_refresh: [''] include: - amp_version: '1.5.5' wp_version: '5.2.21' + ca_cert_refresh: true env: AMP_VERSION: ${{ matrix.amp_version }} WP_VERSION: ${{ matrix.wp_version }} + CA_CERT_REFRESH: ${{ matrix.ca_cert_refresh }} runs-on: ubuntu-latest timeout-minutes: 30 if: github.event_name == 'push' || github.event.pull_request.draft == false diff --git a/assets/js/components/DetailsPermaLinks.js b/assets/js/components/DetailsPermaLinks.js index b40ecf3ac24..583a26ccb52 100644 --- a/assets/js/components/DetailsPermaLinks.js +++ b/assets/js/components/DetailsPermaLinks.js @@ -49,6 +49,7 @@ export default function DetailsPermaLinks( { title, path, serviceURL } ) { return ( onClick( slug ) } > { label } + { hasNewBadge && ( + + ) } ); } @@ -64,6 +68,7 @@ Chip.propTypes = { slug: propTypes.string.isRequired, label: propTypes.string.isRequired, isActive: propTypes.bool, + hasNewBadge: propTypes.bool, selectedCount: propTypes.number, onClick: propTypes.func.isRequired, }; diff --git a/assets/js/components/KeyMetrics/ChipTabGroup/index.js b/assets/js/components/KeyMetrics/ChipTabGroup/index.js index da00391f82f..6b28ad0b651 100644 --- a/assets/js/components/KeyMetrics/ChipTabGroup/index.js +++ b/assets/js/components/KeyMetrics/ChipTabGroup/index.js @@ -47,8 +47,13 @@ import MetricItem from '../MetricsSelectionPanel/MetricItem'; import NoSelectedItemsSVG from '../../../../svg/graphics/key-metrics-no-selected-items.svg'; import { BREAKPOINT_SMALL, useBreakpoint } from '../../../hooks/useBreakpoint'; import CheckMark from '../../../../svg/icons/check-2.svg'; -import { MODULES_ANALYTICS_4 } from '../../../modules/analytics-4/datastore/constants'; +import { + CONVERSION_REPORTING_LEAD_EVENTS, + MODULES_ANALYTICS_4, +} from '../../../modules/analytics-4/datastore/constants'; import { CORE_UI } from '../../../googlesitekit/datastore/ui/constants'; +import { CORE_MODULES } from '../../../googlesitekit/modules/datastore/constants'; +import { CORE_USER } from '../../../googlesitekit/datastore/user/constants'; const currentSelectionGroup = { SLUG: KEY_METRICS_CURRENT_SELECTION_GROUP_SLUG, @@ -88,16 +93,53 @@ export default function ChipTabGroup( { allMetricItems, savedItemSlugs } ) { ) || [] ); - const detectedEvents = useSelect( ( select ) => - select( MODULES_ANALYTICS_4 ).getDetectedEvents() + const currentlyActiveEvents = useSelect( ( select ) => { + const userPickedMetrics = select( CORE_USER ).getUserPickedMetrics(); + + if ( userPickedMetrics?.length ) { + // It is safe to access the selector without checking if GA4 is connected, + // since this selector does not make request to the module endpoint. + const keyMetricsConversionEventWidgets = + select( + MODULES_ANALYTICS_4 + ).getKeyMetricsConversionEventWidgets(); + + return Object.keys( keyMetricsConversionEventWidgets ).filter( + ( event ) => + userPickedMetrics.some( ( metric ) => + keyMetricsConversionEventWidgets[ event ].includes( + metric + ) + ) + ); + } + + const userInputSettings = select( CORE_USER ).getUserInputSettings(); + return userInputSettings?.includeConversionEvents?.values; + } ); + const isGA4Connected = useSelect( ( select ) => + select( CORE_MODULES ).isModuleConnected( 'analytics-4' ) ); + const detectedEvents = useSelect( ( select ) => { + if ( ! isGA4Connected ) { + return []; + } + + return select( MODULES_ANALYTICS_4 ).getDetectedEvents(); + } ); const hasGeneratingLeadsGroup = [ 'submit_lead_form', 'contact', 'generate_lead', - ].filter( ( item ) => detectedEvents?.includes( item ) ); + ].filter( + ( item ) => + detectedEvents?.includes( item ) || + currentlyActiveEvents?.includes( item ) + ); const hasSellingProductsGroup = [ 'add_to_cart', 'purchase' ].filter( - ( item ) => detectedEvents?.includes( item ) + ( item ) => + detectedEvents?.includes( item ) || + currentlyActiveEvents?.includes( item ) ); const keyMetricsGroups = useMemo( @@ -120,10 +162,47 @@ export default function ChipTabGroup( { allMetricItems, savedItemSlugs } ) { [ keyMetricsGroups ] ); + const newBadgeEvents = useSelect( ( select ) => { + if ( ! isGA4Connected ) { + return []; + } + + const badgeEvents = select( MODULES_ANALYTICS_4 ).getNewBadgeEvents(); + + if ( detectedEvents?.length && badgeEvents?.length ) { + const detectedLeadEvents = detectedEvents.filter( ( event ) => + CONVERSION_REPORTING_LEAD_EVENTS.includes( event ) + ); + const newLeadEvents = badgeEvents.filter( ( event ) => + CONVERSION_REPORTING_LEAD_EVENTS.includes( event ) + ); + const newNonLeadEvents = badgeEvents.filter( + ( event ) => + ! CONVERSION_REPORTING_LEAD_EVENTS.includes( event ) + ); + + if ( detectedLeadEvents?.length > 1 && newLeadEvents.length > 0 ) { + return newNonLeadEvents; + } + } + + return badgeEvents; + } ); + const conversionReportingEventWidgets = useSelect( ( select ) => { + if ( ! isGA4Connected ) { + return []; + } + + return select( + MODULES_ANALYTICS_4 + ).getKeyMetricsConversionEventWidgets(); + } ); + // Currently selected group does not include total selected number, so it will // always be 0. const selectedCounts = { [ KEY_METRICS_CURRENT_SELECTION_GROUP_SLUG ]: 0 }; const activeMetricItems = {}; + const newlyDetectedMetrics = {}; for ( const metricItemSlug in allMetricItems ) { const metricGroup = allMetricItems[ metricItemSlug ].group; @@ -153,6 +232,23 @@ export default function ChipTabGroup( { allMetricItems, savedItemSlugs } ) { ).length; selectedCounts[ metricGroup ] = selectedCount; } + + // Check if metric is conversion event related and if new badge should be included. + if ( newBadgeEvents?.length ) { + const isNewlyDetectedKeyMetrics = newBadgeEvents.some( + ( conversionEvent ) => + conversionReportingEventWidgets[ conversionEvent ].includes( + metricItemSlug + ) + ); + + if ( isNewlyDetectedKeyMetrics ) { + newlyDetectedMetrics[ metricGroup ] = [ + ...( newlyDetectedMetrics[ metricGroup ] ?? [] ), + metricItemSlug, + ]; + } + } } const { setValues } = useDispatch( CORE_FORMS ); @@ -190,12 +286,24 @@ export default function ChipTabGroup( { allMetricItems, savedItemSlugs } ) { select( CORE_UI ).getValue( KEY_METRICS_SELECTION_PANEL_OPENED_KEY ) ); const isSelectionPanelOpenPrevious = usePrevious( isSelectionPanelOpen ); + const newlyDetectedMetricsKeys = Object.keys( newlyDetectedMetrics ); useEffect( () => { // Ensure that current selection group is always active when selection panel re-opens. if ( ! isSelectionPanelOpenPrevious && isSelectionPanelOpen ) { - setIsActive( KEY_METRICS_CURRENT_SELECTION_GROUP_SLUG ); - setActiveGroupIndex( 0 ); + if ( newlyDetectedMetricsKeys.length && isMobileBreakpoint ) { + const firstNewlyDetectedGroup = allGroups.find( + ( group ) => group.SLUG === newlyDetectedMetricsKeys[ 0 ] + ); + + setActiveGroupIndex( + allGroups.indexOf( firstNewlyDetectedGroup ) + ); + setIsActive( firstNewlyDetectedGroup.SLUG ); + } else { + setActiveGroupIndex( 0 ); + setIsActive( KEY_METRICS_CURRENT_SELECTION_GROUP_SLUG ); + } } if ( isSelectionPanelOpenPrevious && ! isSelectionPanelOpen ) { @@ -206,6 +314,9 @@ export default function ChipTabGroup( { allMetricItems, savedItemSlugs } ) { isSelectionPanelOpen, isSelectionPanelOpenPrevious, unstagedSelection, + allGroups, + isMobileBreakpoint, + newlyDetectedMetricsKeys, resetUnstagedSelection, ] ); @@ -230,6 +341,9 @@ export default function ChipTabGroup( { allMetricItems, savedItemSlugs } ) { key={ group.SLUG } slug={ group.SLUG } label={ group.LABEL } + hasNewBadge={ + !! newlyDetectedMetrics?.[ group.SLUG ] + } isActive={ group.SLUG === isActive } onClick={ onChipChange } selectedCount={ @@ -259,20 +373,30 @@ export default function ChipTabGroup( { allMetricItems, savedItemSlugs } ) { ({ selectedCounts[ group.SLUG ] }) ) } + { !! newlyDetectedMetrics?.[ group.SLUG ] && ( + + ) } ) ) } ) }
- { Object.keys( activeMetricItems ).map( ( slug ) => ( - - ) ) } + { Object.keys( activeMetricItems ).map( ( slug ) => { + const metricGroup = activeMetricItems[ slug ].group; + const isNewlyDetected = + newlyDetectedMetrics?.[ metricGroup ]?.includes( slug ); + + return ( + + ); + } ) } { ! Object.keys( activeMetricItems ).length && (
diff --git a/assets/js/components/KeyMetrics/ChipTabGroup/index.stories.js b/assets/js/components/KeyMetrics/ChipTabGroup/index.stories.js index bdc34a5cfe2..4c4801aa10d 100644 --- a/assets/js/components/KeyMetrics/ChipTabGroup/index.stories.js +++ b/assets/js/components/KeyMetrics/ChipTabGroup/index.stories.js @@ -43,7 +43,7 @@ import { import { CORE_USER, KM_ANALYTICS_NEW_VISITORS, - KM_ANALYTICS_TOP_TRAFFIC_SOURCE, + KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_LEADS, KM_ANALYTICS_VISIT_LENGTH, KM_ANALYTICS_VISITS_PER_VISITOR, } from '../../../googlesitekit/datastore/user/constants'; @@ -132,6 +132,14 @@ WithError.args = { [ KEY_METRICS_SELECTED ]: savedKeyMetrics, [ EFFECTIVE_SELECTION ]: selectedMetrics, } ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .receiveConversionReportingInlineData( { + newEvents: [], + lostEvents: [], + newBadgeEvents: [], + } ); }, features: [ 'conversionReporting' ], }; @@ -174,7 +182,7 @@ export default { KM_ANALYTICS_VISITS_PER_VISITOR, KM_ANALYTICS_VISIT_LENGTH, KM_ANALYTICS_NEW_VISITORS, - KM_ANALYTICS_TOP_TRAFFIC_SOURCE, + KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_LEADS, ]; provideKeyMetrics( registry, { widgetSlugs: savedKeyMetrics } ); @@ -190,6 +198,14 @@ export default { .dispatch( MODULES_ANALYTICS_4 ) .setDetectedEvents( [ 'contact', 'purchase' ] ); + registry + .dispatch( MODULES_ANALYTICS_4 ) + .receiveConversionReportingInlineData( { + newEvents: [ 'contact' ], + lostEvents: [], + newBadgeEvents: [ 'contact' ], + } ); + // Call story-specific setup. if ( args && args?.setupRegistry ) { args.setupRegistry( registry ); diff --git a/assets/js/components/KeyMetrics/ConfirmSitePurposeChangeModal.js b/assets/js/components/KeyMetrics/ConfirmSitePurposeChangeModal.js index 70887dd32d7..af7aa186dcb 100644 --- a/assets/js/components/KeyMetrics/ConfirmSitePurposeChangeModal.js +++ b/assets/js/components/KeyMetrics/ConfirmSitePurposeChangeModal.js @@ -135,26 +135,19 @@ function ConfirmSitePurposeChangeModal( { ).getUserInputPurposeConversionEvents(); } ); - const { - saveUserInputSettings, - setKeyMetricsSetting, - saveKeyMetricsSettings, - } = useDispatch( CORE_USER ); + const { setUserInputSetting, saveUserInputSettings } = + useDispatch( CORE_USER ); const saveChanges = useCallback( async () => { setIsSaving( true ); - await saveUserInputSettings(); - - // Update 'includeConversionTailoredMetrics' key metrics setting with included - // conversion events, to mark that their respective metrics should be included in the + // Update 'includeConversionEvents' setting with included conversion events, + // to mark that their respective metrics should be included in the // list of tailored metrics and persist on the dashboard in case events are lost. - setKeyMetricsSetting( - 'includeConversionTailoredMetrics', + setUserInputSetting( + 'includeConversionEvents', userInputPurposeConversionEvents ); - saveKeyMetricsSettings( { - widgetSlugs: undefined, - } ); + await saveUserInputSettings(); setIsSaving( false ); onClose(); @@ -162,8 +155,7 @@ function ConfirmSitePurposeChangeModal( { saveUserInputSettings, onClose, setIsSaving, - setKeyMetricsSetting, - saveKeyMetricsSettings, + setUserInputSetting, userInputPurposeConversionEvents, ] ); diff --git a/assets/js/components/KeyMetrics/ConversionReportingNotificationCTAWidget.js b/assets/js/components/KeyMetrics/ConversionReportingNotificationCTAWidget.js index d3b4edccd04..4204a374c91 100644 --- a/assets/js/components/KeyMetrics/ConversionReportingNotificationCTAWidget.js +++ b/assets/js/components/KeyMetrics/ConversionReportingNotificationCTAWidget.js @@ -32,9 +32,9 @@ import PropTypes from 'prop-types'; */ import { useSelect, useDispatch } from 'googlesitekit-data'; import { CORE_USER } from '../../googlesitekit/datastore/user/constants'; -import { MODULES_ANALYTICS_4 } from '../../modules/analytics-4/datastore/constants'; import { CORE_UI } from '../../googlesitekit/datastore/ui/constants'; import { CORE_SITE } from '../../googlesitekit/datastore/site/constants'; +import { MODULES_ANALYTICS_4 } from '../../modules/analytics-4/datastore/constants'; import { KEY_METRICS_SELECTION_PANEL_OPENED_KEY } from './constants'; import ConversionReportingDashboardSubtleNotification from './ConversionReportingDashboardSubtleNotification'; import LostEventsSubtleNotification from './LostEventsSubtleNotification'; @@ -65,23 +65,21 @@ function ConversionReportingNotificationCTAWidget( { Widget, WidgetNull } ) { // Initial callout is surfaced to the users with tailored metrics, if detectedEvents setting // has a conversion event associated with the ACR key metrics matching the current site purpose answer. // If new ACR key metrics that can be added are found using haveConversionReportingEventsForTailoredMetrics, - // and have not been already included, which is determined by includeConversionTailoredMetrics setting, callout banner should be displayed. + // and have not been already included, which is determined by includeConversionEvents user input setting, callout banner should be displayed. const shouldShowInitialCalloutForTailoredMetrics = ! hasUserPickedMetrics?.length && isUserInputCompleted && haveConversionReportingEventsForTailoredMetrics; - const userInputPurposeConversionEvents = useSelect( ( select ) => - select( MODULES_ANALYTICS_4 ).getUserInputPurposeConversionEvents() + const hasConversionEventsForUserPickedMetrics = useSelect( ( select ) => + select( MODULES_ANALYTICS_4 ).haveConversionEventsForUserPickedMetrics( + true + ) ); const isKeyMetricsSetupCompleted = useSelect( ( select ) => select( CORE_SITE ).isKeyMetricsSetupCompleted() ); - const hasConversionEventsForUserPickedMetrics = useSelect( ( select ) => - select( MODULES_ANALYTICS_4 ).haveConversionEventsForUserPickedMetrics() - ); - // If users have set up key metrics manually and ACR events are detected, // we display the same callout banner, with a different call to action // "Select metrics" which opens the metric selection panel. @@ -90,7 +88,21 @@ function ConversionReportingNotificationCTAWidget( { Widget, WidgetNull } ) { isKeyMetricsSetupCompleted && hasConversionEventsForUserPickedMetrics; - const { setKeyMetricsSetting, saveKeyMetricsSettings } = + const haveConversionEventsWithDifferentMetrics = useSelect( ( select ) => + select( MODULES_ANALYTICS_4 ).haveConversionEventsWithDifferentMetrics() + ); + + // If new events have been detected after initial set of events, we display + // the same callout banner, with a different call to action "View metrics" + // which opens the metric selection panel. + const shouldShowCalloutForNewEvents = + isKeyMetricsSetupCompleted && haveConversionEventsWithDifferentMetrics; + + const userInputPurposeConversionEvents = useSelect( ( select ) => + select( MODULES_ANALYTICS_4 ).getUserInputPurposeConversionEvents() + ); + + const { setUserInputSetting, saveUserInputSettings } = useDispatch( CORE_USER ); const { dismissNewConversionReportingEvents, @@ -100,20 +112,18 @@ function ConversionReportingNotificationCTAWidget( { Widget, WidgetNull } ) { const handleAddMetricsClick = useCallback( () => { if ( shouldShowInitialCalloutForTailoredMetrics ) { setIsSaving( true ); - setKeyMetricsSetting( - 'includeConversionTailoredMetrics', + setUserInputSetting( + 'includeConversionEvents', userInputPurposeConversionEvents ); - saveKeyMetricsSettings( { - widgetSlugs: undefined, - } ); + saveUserInputSettings(); setIsSaving( false ); } dismissNewConversionReportingEvents(); }, [ - setKeyMetricsSetting, - saveKeyMetricsSettings, + setUserInputSetting, + saveUserInputSettings, dismissNewConversionReportingEvents, userInputPurposeConversionEvents, shouldShowInitialCalloutForTailoredMetrics, @@ -142,11 +152,21 @@ function ConversionReportingNotificationCTAWidget( { Widget, WidgetNull } ) { if ( ! shouldShowInitialCalloutForTailoredMetrics && ! haveLostConversionEvents && - ! shouldShowCalloutForUserPickedMetrics + ! shouldShowCalloutForUserPickedMetrics && + ! shouldShowCalloutForNewEvents ) { return ; } + let ctaLabel = __( 'Select metrics', 'google-site-kit' ); + + if ( shouldShowInitialCalloutForTailoredMetrics ) { + ctaLabel = __( 'Add metrics', 'google-site-kit' ); + } + if ( shouldShowCalloutForNewEvents ) { + ctaLabel = __( 'View metrics', 'google-site-kit' ); + } + return ( { haveLostConversionEvents && ( @@ -156,13 +176,10 @@ function ConversionReportingNotificationCTAWidget( { Widget, WidgetNull } ) { /> ) } { ( shouldShowInitialCalloutForTailoredMetrics || - shouldShowCalloutForUserPickedMetrics ) && ( + shouldShowCalloutForUserPickedMetrics || + shouldShowCalloutForNewEvents ) && ( { const fetchDismissNotification = new RegExp( '^/google-site-kit/v1/modules/analytics-4/data/clear-conversion-reporting-new-events' ); - const coreKeyMetricsEndpointRegExp = new RegExp( - '^/google-site-kit/v1/core/user/data/key-metrics' + const userInputSettingsEndpointRegExp = new RegExp( + '^/google-site-kit/v1/core/user/data/user-input-settings' ); beforeEach( () => { + enabledFeatures.add( 'conversionReporting' ); + registry = createTestRegistry(); provideSiteInfo( registry ); @@ -92,11 +99,14 @@ describe( 'ConversionReportingNotificationCTAWidget', () => { .setDetectedEvents( [ 'contact' ] ); } ); + afterAll( () => { + enabledFeatures.delete( 'conversionReporting' ); + } ); + describe( 'Existing users with tailored metrics', () => { beforeEach( () => { registry.dispatch( CORE_USER ).receiveGetKeyMetricsSettings( { widgetSlugs: [], - includeConversionTailoredMetrics: [], isWidgetHidden: false, } ); @@ -105,6 +115,10 @@ describe( 'ConversionReportingNotificationCTAWidget', () => { values: [ 'publish_blog' ], scope: 'site', }, + includeConversionEvents: { + values: [], + scope: 'site', + }, } ); } ); @@ -148,13 +162,18 @@ describe( 'ConversionReportingNotificationCTAWidget', () => { expect( container ).toBeEmptyDOMElement(); } ); - it( 'does not render when includeConversionTailoredMetrics contains existing events', async () => { + it( 'does not render when includeConversionEvents contains existing events', async () => { registry.dispatch( CORE_USER ).receiveIsUserInputCompleted( true ); - registry.dispatch( CORE_USER ).receiveGetKeyMetricsSettings( { - widgetSlugs: [], - includeConversionTailoredMetrics: [ 'contact' ], - isWidgetHidden: false, + registry.dispatch( CORE_USER ).receiveGetUserInputSettings( { + purpose: { + values: [ 'publish_blog' ], + scope: 'site', + }, + includeConversionEvents: { + values: [ 'contact' ], + scope: 'site', + }, } ); const { container, waitForRegistry } = render( @@ -203,7 +222,7 @@ describe( 'ConversionReportingNotificationCTAWidget', () => { expect( container ).toBeEmptyDOMElement(); } ); - it( 'does render when includeConversionTailoredMetrics is not set and there are new events connected to the ACR KMW matching the currently saved site purpose', async () => { + it( 'does render when includeConversionEvents is not set and there are new events connected to the ACR KMW matching the currently saved site purpose', async () => { registry.dispatch( CORE_USER ).receiveIsUserInputCompleted( true ); const { waitForRegistry } = render( @@ -255,16 +274,19 @@ describe( 'ConversionReportingNotificationCTAWidget', () => { } ); it( 'Add metrics CTA should add ACR metrics and dismiss notification', async () => { - enabledFeatures.add( 'conversionReporting' ); - fetchMock.postOnce( fetchDismissNotification, { body: true, } ); - fetchMock.postOnce( coreKeyMetricsEndpointRegExp, { + fetchMock.postOnce( userInputSettingsEndpointRegExp, { body: { - widgetSlugs: undefined, - includeConversionTailoredMetrics: [ 'contact' ], - isWidgetHidden: false, + purpose: { + values: [ 'publish_blog' ], + scope: 'site', + }, + includeConversionEvents: { + values: [ 'contact' ], + scope: 'site', + }, }, status: 200, } ); @@ -316,9 +338,9 @@ describe( 'ConversionReportingNotificationCTAWidget', () => { ); } ); - const keyMetricSettings = registry + const userInputSettings = registry .select( CORE_USER ) - .getKeyMetricsSettings(); + .getUserInputSettings(); const newMetrics = registry .select( CORE_USER ) @@ -326,7 +348,7 @@ describe( 'ConversionReportingNotificationCTAWidget', () => { expect( fetchMock ).toHaveFetchedTimes( 2 ); expect( - keyMetricSettings?.includeConversionTailoredMetrics + userInputSettings?.includeConversionEvents?.values ).toEqual( [ 'contact' ] ); expect( newMetrics ).toEqual( [ @@ -343,15 +365,7 @@ describe( 'ConversionReportingNotificationCTAWidget', () => { } ); describe( 'Existing user with manually selected metrics', () => { - beforeAll( () => { - enabledFeatures.add( 'conversionReporting' ); - } ); - - afterAll( () => { - enabledFeatures.delete( 'conversionReporting' ); - } ); - - it( 'Does not render when there are no metrics selected.', async () => { + it( 'does not render when there are no metrics selected.', async () => { registry.dispatch( CORE_USER ).receiveIsUserInputCompleted( true ); provideKeyMetrics( registry, { @@ -383,7 +397,7 @@ describe( 'ConversionReportingNotificationCTAWidget', () => { expect( container ).toBeEmptyDOMElement(); } ); - it( 'Does not render if new events would suggest metrics the user has already selected', async () => { + it( 'does not render if new events would suggest metrics the user has already selected', async () => { registry.dispatch( CORE_SITE ).setKeyMetricsSetupCompletedBy( 1 ); registry.dispatch( CORE_USER ).receiveIsUserInputCompleted( true ); @@ -421,7 +435,7 @@ describe( 'ConversionReportingNotificationCTAWidget', () => { expect( container ).toBeEmptyDOMElement(); } ); - it( 'Does not render when key metrics setup is not completed', async () => { + it( 'does not render when key metrics setup is not completed', async () => { registry.dispatch( CORE_USER ).receiveIsUserInputCompleted( false ); provideKeyMetrics( registry, { @@ -447,7 +461,7 @@ describe( 'ConversionReportingNotificationCTAWidget', () => { expect( container ).toBeEmptyDOMElement(); } ); - it( 'Renders when there are new events with metrics the user has not already selected', async () => { + it( 'renders when there are new events with metrics the user has not already selected', async () => { registry.dispatch( CORE_SITE ).setKeyMetricsSetupCompletedBy( 1 ); registry.dispatch( CORE_USER ).receiveIsUserInputCompleted( true ); @@ -486,7 +500,7 @@ describe( 'ConversionReportingNotificationCTAWidget', () => { ).toBeInTheDocument(); } ); - it( 'Renders if user input has been completed and the user switches to manual selection', async () => { + it( 'renders if user input has been completed and the user switches to manual selection', async () => { registry.dispatch( CORE_SITE ).setKeyMetricsSetupCompletedBy( 1 ); registry.dispatch( CORE_USER ).receiveIsUserInputCompleted( true ); @@ -579,4 +593,476 @@ describe( 'ConversionReportingNotificationCTAWidget', () => { ).toBe( true ); } ); } ); + + describe( 'Existing user with previously detected conversion events', () => { + it( 'View metrics CTA should open key metrics panel', async () => { + fetchMock.postOnce( fetchDismissNotification, { + body: true, + } ); + + registry.dispatch( CORE_USER ).receiveIsUserInputCompleted( true ); + + registry.dispatch( CORE_SITE ).setKeyMetricsSetupCompletedBy( 1 ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setDetectedEvents( [ 'contact' ] ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .receiveConversionReportingInlineData( { + newEvents: [ 'purchase' ], + lostEvents: [], + } ); + + provideKeyMetrics( registry, { + widgetSlugs: [ + KM_ANALYTICS_TOP_PAGES_DRIVING_LEADS, + KM_ANALYTICS_TOP_CITIES_DRIVING_LEADS, + ], + isWidgetHidden: false, + } ); + + const { getByRole, waitForRegistry } = render( + , + { + registry, + features: [ 'conversionReporting' ], + } + ); + await waitForRegistry(); + + expect( + getByRole( 'button', { name: 'View metrics' } ) + ).toBeInTheDocument(); + + // eslint-disable-next-line require-await + await act( async () => { + fireEvent.click( + getByRole( 'button', { name: 'View metrics' } ) + ); + } ); + + expect( + registry + .select( CORE_UI ) + .getValue( KEY_METRICS_SELECTION_PANEL_OPENED_KEY ) + ).toBe( true ); + } ); + + describe( 'user with tailored metrics', () => { + it( 'does not render when newly detected events suggest metrics user already has', async () => { + registry + .dispatch( CORE_USER ) + .receiveIsUserInputCompleted( true ); + + registry.dispatch( CORE_USER ).receiveGetKeyMetricsSettings( { + widgetSlugs: [], + isWidgetHidden: false, + } ); + + registry.dispatch( CORE_USER ).receiveGetUserInputSettings( { + purpose: { + values: [ 'publish_blog' ], + scope: 'site', + }, + includeConversionEvents: { + values: [ 'contact' ], + scope: 'site', + }, + } ); + + const inputSettings = registry + .select( CORE_USER ) + .getUserInputSettings(); + + expect( inputSettings.purpose.values[ 0 ] ).toBe( + 'publish_blog' + ); + + // Current saved purpose is 'publish_blog', ACR metrics included for that answer + // are associated with either `contact`, `generate_lead` or `submit_lead_form` events. + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setDetectedEvents( [ 'contact', 'generate_lead' ] ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .receiveConversionReportingInlineData( { + newEvents: [ 'generate_lead' ], + lostEvents: [], + } ); + + const newMetrics = registry + .select( CORE_USER ) + .getAnswerBasedMetrics(); + + // Tailored metrics will already include leads metrics, which are getting data + // from either `contact`, `generate_lead` or `submit_lead_form` events. + expect( newMetrics ).toEqual( [ + KM_ANALYTICS_TOP_CATEGORIES, + KM_ANALYTICS_TOP_CONVERTING_TRAFFIC_SOURCE, + KM_ANALYTICS_TOP_RETURNING_VISITOR_PAGES, + KM_SEARCH_CONSOLE_POPULAR_KEYWORDS, + KM_ANALYTICS_TOP_RECENT_TRENDING_PAGES, + KM_ANALYTICS_TOP_TRAFFIC_SOURCE, + KM_ANALYTICS_TOP_PAGES_DRIVING_LEADS, + KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_LEADS, + ] ); + + const { container, waitForRegistry } = render( + , + { + registry, + features: [ 'conversionReporting' ], + } + ); + await waitForRegistry(); + + expect( container ).toBeEmptyDOMElement(); + } ); + + it( 'does not render when there is a new event matching the saved site purpose, which had no conversion events with previously detected events', async () => { + registry + .dispatch( CORE_USER ) + .receiveIsUserInputCompleted( true ); + + registry.dispatch( CORE_USER ).receiveGetKeyMetricsSettings( { + widgetSlugs: [], + isWidgetHidden: false, + } ); + + registry.dispatch( CORE_USER ).receiveGetUserInputSettings( { + purpose: { + values: [ 'sell_products' ], + scope: 'site', + }, + includeConversionEvents: { + values: [ 'contact' ], + scope: 'site', + }, + } ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setDetectedEvents( [ 'contact', 'purchase' ] ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .receiveConversionReportingInlineData( { + newEvents: [ 'purchase' ], + lostEvents: [], + } ); + + const { getByRole, waitForRegistry } = render( + , + { + registry, + features: [ 'conversionReporting' ], + } + ); + await waitForRegistry(); + + expect( + getByRole( 'button', { name: 'Add metrics' } ) + ).toBeInTheDocument(); + } ); + + it( 'does not render when newly detected events suggest metrics user does not have within same site purpose', async () => { + registry + .dispatch( CORE_SITE ) + .setKeyMetricsSetupCompletedBy( 1 ); + + registry + .dispatch( CORE_USER ) + .receiveIsUserInputCompleted( true ); + + registry.dispatch( CORE_USER ).receiveGetKeyMetricsSettings( { + widgetSlugs: [], + isWidgetHidden: false, + } ); + + registry.dispatch( CORE_USER ).receiveGetUserInputSettings( { + purpose: { + values: [ 'sell_products' ], + scope: 'site', + }, + includeConversionEvents: { + values: [ 'purchase' ], + scope: 'site', + }, + } ); + + const inputSettings = registry + .select( CORE_USER ) + .getUserInputSettings(); + + expect( inputSettings.purpose.values[ 0 ] ).toBe( + 'sell_products' + ); + + // Current saved purpose is 'sell_products', ACR metrics included for that answer + // are associated with `purchase` and `add_to_cart` events. Initially we will simulate + // user saving site purpose with `purchase`, or adding `purchase` metrics during initial events detection. + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setDetectedEvents( [ 'purchase' ] ); + + const currentMetrics = registry + .select( CORE_USER ) + .getAnswerBasedMetrics(); + + // Current metrics should include only `purchase` related metrics on the list. + const expectedMetrics = [ + KM_ANALYTICS_POPULAR_CONTENT, + KM_ANALYTICS_TOP_CITIES_DRIVING_PURCHASES, + KM_ANALYTICS_TOP_DEVICE_DRIVING_PURCHASES, + KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_PURCHASES, + KM_ANALYTICS_ADSENSE_TOP_EARNING_CONTENT, + KM_ANALYTICS_TOP_CONVERTING_TRAFFIC_SOURCE, + KM_SEARCH_CONSOLE_POPULAR_KEYWORDS, + ]; + + expect( currentMetrics ).toEqual( expectedMetrics ); + + // After initial events, `add_to_cart` has been detected. + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setDetectedEvents( [ 'purchase', 'add_to_cart' ] ); + registry + .dispatch( MODULES_ANALYTICS_4 ) + .receiveConversionReportingInlineData( { + newEvents: [ 'add_to_cart' ], + lostEvents: [], + } ); + + const { getByRole, waitForRegistry } = render( + , + { + registry, + features: [ 'conversionReporting' ], + } + ); + await waitForRegistry(); + + expect( + getByRole( 'button', { name: 'Add metrics' } ) + ).toBeInTheDocument(); + } ); + + it( 'renders when newly detected events suggest metrics from different site purpose', async () => { + registry + .dispatch( CORE_SITE ) + .setKeyMetricsSetupCompletedBy( 1 ); + + registry + .dispatch( CORE_USER ) + .receiveIsUserInputCompleted( true ); + + registry.dispatch( CORE_USER ).receiveGetKeyMetricsSettings( { + widgetSlugs: [], + isWidgetHidden: false, + } ); + + registry.dispatch( CORE_USER ).receiveGetUserInputSettings( { + purpose: { + values: [ 'publish_blog' ], + scope: 'site', + }, + includeConversionEvents: { + values: [ 'contact' ], + scope: 'site', + }, + } ); + + const inputSettings = registry + .select( CORE_USER ) + .getUserInputSettings(); + + expect( inputSettings.purpose.values[ 0 ] ).toBe( + 'publish_blog' + ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setDetectedEvents( [ 'contact', 'purchase' ] ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .receiveConversionReportingInlineData( { + newEvents: [ 'purchase' ], + lostEvents: [], + } ); + + const { getByRole, waitForRegistry } = render( + , + { + registry, + features: [ 'conversionReporting' ], + } + ); + await waitForRegistry(); + + expect( + getByRole( 'button', { name: 'View metrics' } ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'user with manually-selected metrics', () => { + it( 'does not render when there are new events with metrics that are already selected', async () => { + registry + .dispatch( CORE_USER ) + .receiveIsUserInputCompleted( false ); + + registry + .dispatch( CORE_SITE ) + .setKeyMetricsSetupCompletedBy( 1 ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setDetectedEvents( [ 'contact', 'generate_lead' ] ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .receiveConversionReportingInlineData( { + newEvents: [ 'generate_lead' ], + lostEvents: [], + } ); + + provideKeyMetrics( registry, { + widgetSlugs: [ + KM_ANALYTICS_TOP_PAGES_DRIVING_LEADS, + KM_ANALYTICS_TOP_CITIES_DRIVING_LEADS, + KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_LEADS, + ], + isWidgetHidden: false, + } ); + + const { container, waitForRegistry } = render( + , + { + registry, + features: [ 'conversionReporting' ], + } + ); + await waitForRegistry(); + + expect( container ).toBeEmptyDOMElement(); + } ); + + it( 'renders when there are new events with metrics that are not selected', async () => { + registry + .dispatch( CORE_USER ) + .receiveIsUserInputCompleted( false ); + + registry + .dispatch( CORE_SITE ) + .setKeyMetricsSetupCompletedBy( 1 ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setDetectedEvents( [ 'contact', 'purchase' ] ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .receiveConversionReportingInlineData( { + newEvents: [ 'purchase' ], + lostEvents: [], + } ); + + provideKeyMetrics( registry, { + widgetSlugs: [ + KM_ANALYTICS_TOP_PAGES_DRIVING_LEADS, + KM_ANALYTICS_TOP_CITIES_DRIVING_LEADS, + ], + isWidgetHidden: false, + } ); + + const { getByRole, waitForRegistry } = render( + , + { + registry, + features: [ 'conversionReporting' ], + } + ); + await waitForRegistry(); + + expect( + getByRole( 'button', { name: 'View metrics' } ) + ).toBeInTheDocument(); + } ); + + it( 'renders when there are new events having partialy unselected metrics', async () => { + registry + .dispatch( CORE_USER ) + .receiveIsUserInputCompleted( false ); + + registry + .dispatch( CORE_SITE ) + .setKeyMetricsSetupCompletedBy( 1 ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setDetectedEvents( [ 'contact', 'add_to_cart' ] ); + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .receiveConversionReportingInlineData( { + newEvents: [ 'contact' ], + lostEvents: [], + } ); + + // There is a third leads metric that is not selected. This scenario simulates + // edge case in which user had 2 out of 3 leads metrics selected, then lost the `contact` event` + // in which case we will surface this variation of callout because 3rd leads metric would + // not be visible in selection panel after event was lost, but will re-appear after event is detected again, + // making it "new" again. + provideKeyMetrics( registry, { + widgetSlugs: [ + KM_ANALYTICS_TOP_PAGES_DRIVING_LEADS, + KM_ANALYTICS_TOP_CITIES_DRIVING_LEADS, + ], + isWidgetHidden: false, + } ); + + const { getByRole, waitForRegistry } = render( + , + { + registry, + features: [ 'conversionReporting' ], + } + ); + await waitForRegistry(); + + expect( + getByRole( 'button', { name: 'View metrics' } ) + ).toBeInTheDocument(); + } ); + } ); + } ); } ); diff --git a/assets/js/components/KeyMetrics/KeyMetricsSetupCTAWidget.js b/assets/js/components/KeyMetrics/KeyMetricsSetupCTAWidget.js index 950a984de4e..f5dcde388ba 100644 --- a/assets/js/components/KeyMetrics/KeyMetricsSetupCTAWidget.js +++ b/assets/js/components/KeyMetrics/KeyMetricsSetupCTAWidget.js @@ -37,7 +37,10 @@ import KeyMetricsCTAContent from './KeyMetricsCTAContent'; import KeyMetricsCTAFooter from './KeyMetricsCTAFooter'; import { CORE_USER } from '../../googlesitekit/datastore/user/constants'; import { CORE_SITE } from '../../googlesitekit/datastore/site/constants'; -import { KEY_METRICS_SETUP_CTA_WIDGET_SLUG } from './constants'; +import { + KEY_METRICS_SETUP_CTA_WIDGET_SLUG, + KEY_METRICS_SELECTION_PANEL_OPENED_KEY, +} from './constants'; import whenActive from '../../util/when-active'; import { AdminMenuTooltip, @@ -49,8 +52,11 @@ import useViewContext from '../../hooks/useViewContext'; import useDisplayCTAWidget from './hooks/useDisplayCTAWidget'; import KeyMetricsSetupCTARenderedEffect from './KeyMetricsSetupCTARenderedEffect'; import { CORE_LOCATION } from '../../googlesitekit/datastore/location/constants'; +import { CORE_UI } from '../../googlesitekit/datastore/ui/constants'; +import { useFeature } from '../../hooks/useFeature'; function KeyMetricsSetupCTAWidget( { Widget, WidgetNull } ) { + const isConversionReportingEnabled = useFeature( 'conversionReporting' ); const viewContext = useViewContext(); const displayCTAWidget = useDisplayCTAWidget(); const ctaLink = useSelect( ( select ) => @@ -65,6 +71,7 @@ function KeyMetricsSetupCTAWidget( { Widget, WidgetNull } ) { KEY_METRICS_SETUP_CTA_WIDGET_SLUG ); + const { setValue } = useDispatch( CORE_UI ); const { dismissItem } = useDispatch( CORE_USER ); const dismissCallback = async () => { @@ -82,12 +89,23 @@ function KeyMetricsSetupCTAWidget( { Widget, WidgetNull } ) { const { navigateTo } = useDispatch( CORE_LOCATION ); const openMetricsSelectionPanel = useCallback( () => { - navigateTo( fullScreenSelectionLink ); + if ( isConversionReportingEnabled ) { + navigateTo( fullScreenSelectionLink ); + } else { + setValue( KEY_METRICS_SELECTION_PANEL_OPENED_KEY, true ); + } + trackEvent( `${ viewContext }_kmw-cta-notification`, 'confirm_pick_own_metrics' ); - }, [ navigateTo, fullScreenSelectionLink, viewContext ] ); + }, [ + navigateTo, + fullScreenSelectionLink, + viewContext, + isConversionReportingEnabled, + setValue, + ] ); const onGetTailoredMetricsClick = useCallback( () => { trackEvent( diff --git a/assets/js/components/KeyMetrics/MetricsSelectionPanel/MetricItem.js b/assets/js/components/KeyMetrics/MetricsSelectionPanel/MetricItem.js index a7f82a3cb48..de388478f34 100644 --- a/assets/js/components/KeyMetrics/MetricsSelectionPanel/MetricItem.js +++ b/assets/js/components/KeyMetrics/MetricsSelectionPanel/MetricItem.js @@ -45,6 +45,7 @@ export default function MetricItem( { slug, title, description, + isNewlyDetected, savedItemSlugs = [], } ) { const disconnectedModules = useSelect( ( select ) => { @@ -107,6 +108,7 @@ export default function MetricItem( { slug={ slug } title={ title } description={ description } + isNewlyDetected={ isNewlyDetected } isItemSelected={ isMetricSelected } isItemDisabled={ isMetricDisabled } onCheckboxChange={ onCheckboxChange } @@ -135,5 +137,6 @@ MetricItem.propTypes = { slug: PropTypes.string.isRequired, title: PropTypes.string.isRequired, description: PropTypes.string.isRequired, + isNewlyDetected: PropTypes.bool, savedItemSlugs: PropTypes.array, }; diff --git a/assets/js/components/KeyMetrics/MetricsSelectionPanel/index.test.js b/assets/js/components/KeyMetrics/MetricsSelectionPanel/index.test.js index 3062f559696..43aa88594e5 100644 --- a/assets/js/components/KeyMetrics/MetricsSelectionPanel/index.test.js +++ b/assets/js/components/KeyMetrics/MetricsSelectionPanel/index.test.js @@ -528,10 +528,6 @@ describe( 'MetricsSelectionPanel', () => { await registry .dispatch( CORE_USER ) .receiveIsUserInputCompleted( false ); - await registry.dispatch( CORE_USER ).receiveGetKeyMetricsSettings( { - widgetSlugs: [], - includeConversionTailoredMetrics: [], - } ); provideKeyMetrics( registry ); diff --git a/assets/js/components/OverlayNotification/AnalyticsAndAdSenseAccountsDetectedAsLinkedOverlayNotification.test.js b/assets/js/components/OverlayNotification/AnalyticsAndAdSenseAccountsDetectedAsLinkedOverlayNotification.test.js index b3de1d27a6a..febb670622e 100644 --- a/assets/js/components/OverlayNotification/AnalyticsAndAdSenseAccountsDetectedAsLinkedOverlayNotification.test.js +++ b/assets/js/components/OverlayNotification/AnalyticsAndAdSenseAccountsDetectedAsLinkedOverlayNotification.test.js @@ -38,7 +38,10 @@ import { import { CORE_UI } from '../../googlesitekit/datastore/ui/constants'; import { CORE_USER } from '../../googlesitekit/datastore/user/constants'; import { MODULES_ADSENSE } from '../../modules/adsense/datastore/constants'; -import { MODULES_ANALYTICS_4 } from '../../modules/analytics-4/datastore/constants'; +import { + DATE_RANGE_OFFSET, + MODULES_ANALYTICS_4, +} from '../../modules/analytics-4/datastore/constants'; import { getAnalytics4MockResponse, provideAnalytics4MockReport, @@ -52,18 +55,6 @@ describe( 'AnalyticsAndAdSenseAccountsDetectedAsLinkedOverlayNotification', () = const adSenseAccountID = 'pub-1234567890'; - const reportOptions = { - startDate: '2020-08-11', - endDate: '2020-09-07', - dimensions: [ 'pagePath', 'adSourceName' ], - metrics: [ { name: 'totalAdRevenue' } ], - dimensionFilters: { - adSourceName: `Google AdSense account (${ adSenseAccountID })`, - }, - orderby: [ { metric: { metricName: 'totalAdRevenue' }, desc: true } ], - limit: 1, - }; - const fetchGetDismissedItemsRegExp = new RegExp( '^/google-site-kit/v1/core/user/data/dismissed-items' ); @@ -95,6 +86,7 @@ describe( 'AnalyticsAndAdSenseAccountsDetectedAsLinkedOverlayNotification', () = 'googlesitekit_read_shared_module_data::["adsense"]': true, }, }; + let reportOptions; beforeEach( () => { registry = createTestRegistry(); @@ -121,9 +113,24 @@ describe( 'AnalyticsAndAdSenseAccountsDetectedAsLinkedOverlayNotification', () = registry.dispatch( MODULES_ADSENSE ).receiveGetSettings( { accountID: adSenseAccountID, } ); + const dateRangeDates = registry + .select( CORE_USER ) + .getDateRangeDates( { offsetDays: DATE_RANGE_OFFSET } ); + reportOptions = { + ...dateRangeDates, + dimensions: [ 'pagePath', 'adSourceName' ], + metrics: [ { name: 'totalAdRevenue' } ], + dimensionFilters: { + adSourceName: `Google AdSense account (${ adSenseAccountID })`, + }, + orderby: [ + { metric: { metricName: 'totalAdRevenue' }, desc: true }, + ], + limit: 1, + }; } ); - it( 'does not render when Analytics module is not connected', () => { + it( 'does not render when Analytics module is not connected', async () => { provideModules( registry, [ { slug: 'adsense', @@ -137,19 +144,20 @@ describe( 'AnalyticsAndAdSenseAccountsDetectedAsLinkedOverlayNotification', () = }, ] ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); expect( container ).not.toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); } ); - it( 'does not render when AdSense module is not connected', () => { + it( 'does not render when AdSense module is not connected', async () => { provideModules( registry, [ { slug: 'adsense', @@ -163,47 +171,50 @@ describe( 'AnalyticsAndAdSenseAccountsDetectedAsLinkedOverlayNotification', () = }, ] ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); expect( container ).not.toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); } ); - it( 'does not render when isAdSenseLinked is `false`', () => { + it( 'does not render when isAdSenseLinked is `false`', async () => { registry.dispatch( MODULES_ANALYTICS_4 ).setAdSenseLinked( false ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); expect( container ).not.toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); } ); - it( 'does not render if dismissed previously', () => { + it( 'does not render if dismissed previously', async () => { registry .dispatch( CORE_USER ) .receiveGetDismissedItems( [ ANALYTICS_ADSENSE_LINKED_OVERLAY_NOTIFICATION, ] ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); expect( container ).not.toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); @@ -222,13 +233,14 @@ describe( 'AnalyticsAndAdSenseAccountsDetectedAsLinkedOverlayNotification', () = ANALYTICS_ADSENSE_LINKED_OVERLAY_NOTIFICATION ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); expect( container ).not.toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); @@ -241,58 +253,61 @@ describe( 'AnalyticsAndAdSenseAccountsDetectedAsLinkedOverlayNotification', () = .dispatch( CORE_UI ) .setOverlayNotificationToShow( 'TestOverlayNotification' ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); expect( container ).not.toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); } ); - it( 'does not render without the feature flag', () => { + it( 'does not render without the feature flag', async () => { registry .dispatch( CORE_USER ) .receiveGetDismissedItems( [ ANALYTICS_ADSENSE_LINKED_OVERLAY_NOTIFICATION, ] ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); expect( container ).not.toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); } ); - it( 'does not render if adSenseLinked is `true` but data is in a "gathering data" state', () => { + it( 'does not render if adSenseLinked is `true` but data is in a "gathering data" state', async () => { registry .dispatch( MODULES_ANALYTICS_4 ) .receiveGetReport( {}, { options: reportOptions } ); registry.dispatch( MODULES_ANALYTICS_4 ).receiveIsGatheringData( true ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); expect( container ).not.toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); } ); - it( 'does not render if adSenseLinked is `true` but there is zero data', () => { + it( 'does not render if adSenseLinked is `true` but there is zero data', async () => { const report = getAnalytics4MockResponse( reportOptions ); const zeroReport = replaceValuesInAnalytics4ReportWithZeroData( report ); @@ -301,135 +316,143 @@ describe( 'AnalyticsAndAdSenseAccountsDetectedAsLinkedOverlayNotification', () = .dispatch( MODULES_ANALYTICS_4 ) .receiveGetReport( zeroReport, { options: reportOptions } ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); expect( container ).not.toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); } ); - it( 'renders if adSenseLinked is `true` and data is available', () => { + it( 'renders if adSenseLinked is `true` and data is available', async () => { provideAnalytics4MockReport( registry, reportOptions ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); expect( container ).toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); } ); - it( 'does not render in entity dashboard', () => { + it( 'does not render in entity dashboard', async () => { provideAnalytics4MockReport( registry, reportOptions ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_ENTITY_DASHBOARD, } ); + await waitForRegistry(); expect( container ).not.toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); } ); - it( 'does not render in "view only" dashboard without Analytics access', () => { + it( 'does not render in "view only" dashboard without Analytics access', async () => { provideUserAuthentication( registry, { authenticated: false } ); registry .dispatch( CORE_USER ) .receiveGetCapabilities( capabilitiesAnalyticsNoAccess.permissions ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD_VIEW_ONLY, } ); + await waitForRegistry(); expect( container ).not.toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); } ); - it( 'does not render in "view only" dashboard without AdSense access', () => { + it( 'does not render in "view only" dashboard without AdSense access', async () => { provideUserAuthentication( registry, { authenticated: false } ); registry .dispatch( CORE_USER ) .receiveGetCapabilities( capabilitiesAdSenseNoAccess.permissions ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD_VIEW_ONLY, } ); + await waitForRegistry(); expect( container ).not.toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); } ); - it( 'renders in "view only" dashboard with Analytics and AdSense access', () => { + it( 'renders in "view only" dashboard with Analytics and AdSense access', async () => { provideUserAuthentication( registry, { authenticated: false } ); registry .dispatch( CORE_USER ) .receiveGetCapabilities( capabilities.permissions ); provideAnalytics4MockReport( registry, reportOptions ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD_VIEW_ONLY, } ); + await waitForRegistry(); expect( container ).toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); } ); - it( 'does not render in "view only" entity dashboard', () => { + it( 'does not render in "view only" entity dashboard', async () => { provideUserAuthentication( registry, { authenticated: false } ); registry .dispatch( CORE_USER ) .receiveGetCapabilities( capabilities.permissions ); provideAnalytics4MockReport( registry, reportOptions ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_ENTITY_DASHBOARD_VIEW_ONLY, } ); + await waitForRegistry(); expect( container ).not.toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); } ); - it( 'renders `Show me` and `Maybe later` buttons`', () => { + it( 'renders `Show me` and `Maybe later` buttons`', async () => { provideAnalytics4MockReport( registry, reportOptions ); - const { container } = render( + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); expect( container ).toHaveTextContent( 'Show me' ); expect( container ).toHaveTextContent( 'Maybe later' ); @@ -488,12 +511,12 @@ describe( 'AnalyticsAndAdSenseAccountsDetectedAsLinkedOverlayNotification', () = 'Data is now available for the pages that earn the most AdSense revenue' ); - act( () => { - fireEvent.click( getByRole( 'button', { name: /maybe later/i } ) ); + await act( async () => { + await fireEvent.click( + getByRole( 'button', { name: /maybe later/i } ) + ); } ); - await waitForRegistry(); - expect( container ).not.toHaveTextContent( 'Data is now available for the pages that earn the most AdSense revenue' ); diff --git a/assets/js/components/ReportTable.js b/assets/js/components/ReportTable.js index e498c91dbfe..611e2117dca 100644 --- a/assets/js/components/ReportTable.js +++ b/assets/js/components/ReportTable.js @@ -24,9 +24,15 @@ import invariant from 'invariant'; import PropTypes from 'prop-types'; import { get } from 'lodash'; +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + /** * Internal dependencies. */ +import { Tab, TabBar } from 'googlesitekit-components'; import GatheringDataNotice from './GatheringDataNotice'; export default function ReportTable( props ) { @@ -37,6 +43,7 @@ export default function ReportTable( props ) { limit, zeroState: ZeroState, gatheringData = false, + tabbedLayout = false, } = props; invariant( Array.isArray( rows ), 'rows must be an array.' ); @@ -52,174 +59,221 @@ export default function ReportTable( props ) { 'limit must be an integer, if provided.' ); - const mobileColumns = columns.filter( ( col ) => ! col.hideOnMobile ); + function isHiddenOnMobile( hideOnMobile ) { + return ! tabbedLayout && hideOnMobile; + } + const hasBadges = columns.some( ( { badge } ) => !! badge ); + const [ activeColumnIndex, setActiveColumnIndex ] = useState( 0 ); + + // The first column is expected to be the row title or label, which will always be + // shown so we exclude it from the tab list. + const tabColumns = tabbedLayout && columns.slice( 1 ); + const contentColumns = tabbedLayout + ? [ columns[ 0 ], tabColumns[ activeColumnIndex ] ] + : columns; + + const mobileColumns = contentColumns.filter( + ( { hideOnMobile } ) => ! isHiddenOnMobile( hideOnMobile ) + ); + return ( -
+ { tabbedLayout && ( + + { tabColumns.map( ( { title, badge } ) => ( + + { title } + { badge } + + ) ) } + ) } - > - - - { hasBadges && ( - - !! badge && ! hideOnMobile - ), - } - ) } - > - { columns.map( - ( - { - badge, - primary, - hideOnMobile, - className: columnClassName, - }, - colIndex - ) => ( - - ) - ) } - +
- { badge } -
- { columns.map( - ( - { - title, - description, - primary, - hideOnMobile, - className: columnClassName, - }, - colIndex - ) => ( - + { hasBadges && ( + + !! badge && + ! isHiddenOnMobile( + hideOnMobile + ) + ), + } ) } - data-tooltip={ description } - key={ `googlesitekit-table__head-row-${ colIndex }` } > - { title } - - ) - ) } - - - - - { gatheringData && ( - - - - ) } - { ! gatheringData && ! rows?.length && ZeroState && ( - - - - ) } - - { ! gatheringData && - rows.slice( 0, limit ).map( ( row, rowIndex ) => ( - - { columns.map( - ( - { - Component, - field, - hideOnMobile, - className: columnClassName, - }, - colIndex - ) => { - const fieldValue = - field !== undefined - ? get( row, field ) - : undefined; - return ( - + ) + ) } + + ) } + + { columns.map( + ( + { + title, + description, + primary, + hideOnMobile, + className: columnClassName, + }, + colIndex + ) => ( + + ) ) } - ) ) } - -
+ { ! tabbedLayout && ( +
- -
- -
( + -
- { Component && ( - - ) } - { ! Component && - fieldValue } -
- - ); - } + { badge } +
+ { title } +
+ + ) } + + + { gatheringData && ( + + + + + + ) } + { ! gatheringData && ! rows?.length && ZeroState && ( + + + + + + ) } + + { ! gatheringData && + rows.slice( 0, limit ).map( ( row, rowIndex ) => ( + + { contentColumns.map( + ( + { + Component, + field, + hideOnMobile, + className: columnClassName, + }, + colIndex + ) => { + const fieldValue = + field !== undefined + ? get( row, field ) + : undefined; + return ( + +
+ { Component && ( + + ) } + { ! Component && + fieldValue } +
+ + ); + } + ) } + + ) ) } + + +
); } @@ -244,4 +298,5 @@ ReportTable.propTypes = { limit: PropTypes.number, zeroState: PropTypes.func, gatheringData: PropTypes.bool, + tabbedLayout: PropTypes.bool, }; diff --git a/assets/js/components/ReportTable.stories.js b/assets/js/components/ReportTable.stories.js index 8993352d863..98b1f7563d3 100644 --- a/assets/js/components/ReportTable.stories.js +++ b/assets/js/components/ReportTable.stories.js @@ -35,16 +35,14 @@ function Template( args ) { return ; } -export const ReportTableBasic = Template.bind( {} ); -ReportTableBasic.storyName = 'Basic'; -ReportTableBasic.decorators = [ - ( Story, { args } ) => { - const registry = createTestRegistry(); - provideModules( registry ); - provideModuleRegistrations( registry ); - const modules = registry.select( CORE_MODULES ).getModules(); - args.rows = Object.values( modules ); - args.columns = [ +function createBasicArgs() { + const registry = createTestRegistry(); + provideModules( registry ); + provideModuleRegistrations( registry ); + const modules = registry.select( CORE_MODULES ).getModules(); + return { + rows: Object.values( modules ), + columns: [ { title: 'Name', description: 'Module name', @@ -70,11 +68,19 @@ ReportTableBasic.decorators = [ return row.Icon && ; }, }, - ]; + ], + }; +} + +export const ReportTableBasic = Template.bind( {} ); +ReportTableBasic.storyName = 'Basic'; +ReportTableBasic.args = createBasicArgs(); +ReportTableBasic.scenario = {}; - return ; - }, -]; +export const ReportTableTabbedLayout = Template.bind( {} ); +ReportTableTabbedLayout.storyName = 'Tabbed Layout'; +ReportTableTabbedLayout.args = { ...createBasicArgs(), tabbedLayout: true }; +ReportTableTabbedLayout.scenario = {}; export const ReportTableGatheringData = Template.bind( {} ); ReportTableGatheringData.storyName = 'Gathering Data'; diff --git a/assets/js/components/SelectionPanel/SelectionPanelItem.js b/assets/js/components/SelectionPanel/SelectionPanelItem.js index 96db81e9b60..4bd67f99c6a 100644 --- a/assets/js/components/SelectionPanel/SelectionPanelItem.js +++ b/assets/js/components/SelectionPanel/SelectionPanelItem.js @@ -21,10 +21,16 @@ */ import PropTypes from 'prop-types'; +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + /** * Internal dependencies */ import SelectionBox from '../SelectionBox'; +import Badge from '../Badge'; export default function SelectionPanelItem( { children, @@ -38,6 +44,7 @@ export default function SelectionPanelItem( { subtitle, suffix, badge, + isNewlyDetected, } ) { return (
@@ -58,6 +65,9 @@ export default function SelectionPanelItem( { { description } { children } + { isNewlyDetected && ( + + ) } { suffix && ( { suffix } @@ -79,4 +89,5 @@ SelectionPanelItem.propTypes = { subtitle: PropTypes.string, suffix: PropTypes.node, badge: PropTypes.node, + isNewlyDetected: PropTypes.bool, }; diff --git a/assets/js/components/SelectionPanel/SelectionPanelItems.js b/assets/js/components/SelectionPanel/SelectionPanelItems.js index 10321d7002c..28c103ebc6a 100644 --- a/assets/js/components/SelectionPanel/SelectionPanelItems.js +++ b/assets/js/components/SelectionPanel/SelectionPanelItems.js @@ -34,6 +34,7 @@ export default function SelectionPanelItems( { availableSavedItems = {}, availableUnsavedItems = {}, ItemComponent, + notice, } ) { const renderItems = ( items ) => { return Object.keys( items ).map( ( slug ) => ( @@ -76,6 +77,7 @@ export default function SelectionPanelItems( { { renderItems( availableUnsavedItems ) }
) } + { notice }
); } @@ -87,4 +89,5 @@ SelectionPanelItems.propTypes = { availableSavedItems: PropTypes.object, availableUnsavedItems: PropTypes.object, ItemComponent: PropTypes.elementType, + notice: PropTypes.node, }; diff --git a/assets/js/components/ViewOnlyMenu/Description.js b/assets/js/components/ViewOnlyMenu/Description.js index 0c407477497..32ea0821ef2 100644 --- a/assets/js/components/ViewOnlyMenu/Description.js +++ b/assets/js/components/ViewOnlyMenu/Description.js @@ -20,7 +20,7 @@ * WordPress dependencies */ import { createInterpolateElement, useCallback } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; +import { __, _x } from '@wordpress/i18n'; /** * Internal dependencies @@ -119,7 +119,11 @@ export default function Description() {

{ description }

{ canAuthenticate && ( ) } diff --git a/assets/js/components/conversion-tracking/ConversionTrackingToggle.js b/assets/js/components/conversion-tracking/ConversionTrackingToggle.js index 47b4a61260d..ae62dae0829 100644 --- a/assets/js/components/conversion-tracking/ConversionTrackingToggle.js +++ b/assets/js/components/conversion-tracking/ConversionTrackingToggle.js @@ -51,30 +51,32 @@ export default function ConversionTrackingToggle( { children, loading } ) { return (
- { - // If Conversion Tracking is currently enabled, show a confirmation - // dialog warning users about the impact of disabling it. - if ( isConversionTrackingEnabled ) { - trackEvent( `${ viewContext }`, 'ect_disable' ); +
+ { + // If Conversion Tracking is currently enabled, show a confirmation + // dialog warning users about the impact of disabling it. + if ( isConversionTrackingEnabled ) { + trackEvent( `${ viewContext }`, 'ect_disable' ); - setShowConfirmDialog( true ); - } else { - trackEvent( `${ viewContext }`, 'ect_enable' ); + setShowConfirmDialog( true ); + } else { + trackEvent( `${ viewContext }`, 'ect_enable' ); - // Conversion Tracking is not currently enabled, so this toggle - // enables it. - setConversionTrackingEnabled( true ); - } - } } - hideLabel={ false } - /> + // Conversion Tracking is not currently enabled, so this toggle + // enables it. + setConversionTrackingEnabled( true ); + } + } } + hideLabel={ false } + /> +
{ !! saveError && } @@ -52,6 +54,18 @@ export default function FirstPartyModeToggle( { className } ) { const { fetchGetFPMServerRequirementStatus, setFirstPartyModeEnabled } = useDispatch( CORE_SITE ); + const learnMoreURL = useSelect( ( select ) => { + return select( CORE_SITE ).getDocumentationLinkURL( + 'first-party-mode-introduction' + ); + } ); + + const serverRequirementsLearnMoreURL = useSelect( ( select ) => { + return select( CORE_SITE ).getDocumentationLinkURL( + 'first-party-mode-server-requirements' + ); + } ); + // Fetch the server requirement status on mount. useMount( fetchGetFPMServerRequirementStatus ); @@ -73,27 +87,65 @@ export default function FirstPartyModeToggle( { className } ) { /> ) } { ! isLoading && ( - +
+ +
+ +
+
) }

- { __( - 'Your tag data will be sent through your own domain to improve data quality and help you recover measurement signals.', - 'google-site-kit' + { createInterpolateElement( + __( + 'Your tag data will be sent through your own domain to improve data quality and help you recover measurement signals. Learn more', + 'google-site-kit' + ), + { + a: ( + + ), + } ) }

{ ! isLoading && ! hasMetServerRequirements && ( Learn more', + 'google-site-kit' + ), + { + a: ( + + ), + } ) } variant="warning" /> diff --git a/assets/js/components/first-party-mode/FirstPartyModeToggle.stories.js b/assets/js/components/first-party-mode/FirstPartyModeToggle.stories.js index 3e3306f3188..a53547280ab 100644 --- a/assets/js/components/first-party-mode/FirstPartyModeToggle.stories.js +++ b/assets/js/components/first-party-mode/FirstPartyModeToggle.stories.js @@ -50,7 +50,7 @@ ServerRequirementsFail.args = { setupRegistry: () => { fetchMock.getOnce( serverRequirementStatusEndpoint, { body: { - isEnabled: null, + isEnabled: false, isFPMHealthy: false, isScriptAccessEnabled: false, }, diff --git a/assets/js/components/first-party-mode/FirstPartyModeToggle.test.js b/assets/js/components/first-party-mode/FirstPartyModeToggle.test.js index 72550908903..cb118fa0551 100644 --- a/assets/js/components/first-party-mode/FirstPartyModeToggle.test.js +++ b/assets/js/components/first-party-mode/FirstPartyModeToggle.test.js @@ -39,7 +39,7 @@ describe( 'FirstPartyModeToggle', () => { registry = createTestRegistry(); registry.dispatch( CORE_SITE ).receiveGetFirstPartyModeSettings( { - isEnabled: null, + isEnabled: false, isFPMHealthy: null, isScriptAccessEnabled: null, } ); @@ -77,7 +77,7 @@ describe( 'FirstPartyModeToggle', () => { it( 'should render in default state', async () => { fetchMock.getOnce( serverRequirementStatusEndpoint, { body: { - isEnabled: null, + isEnabled: false, isFPMHealthy: true, isScriptAccessEnabled: true, }, @@ -101,7 +101,7 @@ describe( 'FirstPartyModeToggle', () => { it( 'should render in disabled state if server requirements are not met', async () => { fetchMock.getOnce( serverRequirementStatusEndpoint, { body: { - isEnabled: null, + isEnabled: false, isFPMHealthy: false, isScriptAccessEnabled: false, }, @@ -130,7 +130,7 @@ describe( 'FirstPartyModeToggle', () => { 'should not render in disabled state unless %s is explicitly false', async ( requirement ) => { const response = { - isEnabled: null, + isEnabled: false, isFPMHealthy: true, isScriptAccessEnabled: true, }; @@ -159,7 +159,7 @@ describe( 'FirstPartyModeToggle', () => { 'should render in disabled state if %s is false', async ( requirement ) => { const response = { - isEnabled: null, + isEnabled: false, isFPMHealthy: true, isScriptAccessEnabled: true, }; @@ -187,7 +187,7 @@ describe( 'FirstPartyModeToggle', () => { it( 'should toggle first party mode on click', async () => { fetchMock.getOnce( serverRequirementStatusEndpoint, { body: { - isEnabled: null, + isEnabled: false, isFPMHealthy: true, isScriptAccessEnabled: true, }, @@ -207,9 +207,9 @@ describe( 'FirstPartyModeToggle', () => { expect( switchControl ).not.toBeChecked(); - expect( - registry.select( CORE_SITE ).isFirstPartyModeEnabled() - ).toBeNull(); + expect( registry.select( CORE_SITE ).isFirstPartyModeEnabled() ).toBe( + false + ); switchControl.click(); @@ -228,4 +228,29 @@ describe( 'FirstPartyModeToggle', () => { false ); } ); + + it( 'should render a "Beta" badge', async () => { + fetchMock.getOnce( serverRequirementStatusEndpoint, { + body: { + isEnabled: false, + isFPMHealthy: true, + isScriptAccessEnabled: true, + }, + status: 200, + } ); + + const { container, waitForRegistry } = render( + , + { + registry, + } + ); + + await waitForRegistry(); + + const badgeElement = container.querySelector( '.googlesitekit-badge' ); + + expect( badgeElement ).toBeInTheDocument(); + expect( badgeElement ).toHaveTextContent( 'Beta' ); + } ); } ); diff --git a/assets/js/components/first-party-mode/__snapshots__/FirstPartyModeToggle.test.js.snap b/assets/js/components/first-party-mode/__snapshots__/FirstPartyModeToggle.test.js.snap index 44ad7f81b7f..72341e216fd 100644 --- a/assets/js/components/first-party-mode/__snapshots__/FirstPartyModeToggle.test.js.snap +++ b/assets/js/components/first-party-mode/__snapshots__/FirstPartyModeToggle.test.js.snap @@ -6,42 +6,74 @@ exports[`FirstPartyModeToggle should render in default state 1`] = ` class="googlesitekit-first-party-mode-toggle" >
-   +
+   +
+
+
+ +
+
+
-
- -
+ Beta +
-

- Your tag data will be sent through your own domain to improve data quality and help you recover measurement signals. + Your tag data will be sent through your own domain to improve data quality and help you recover measurement signals. + + + Learn more + + + + +

@@ -53,43 +85,75 @@ exports[`FirstPartyModeToggle should render in disabled state if server requirem class="googlesitekit-first-party-mode-toggle" >
-   +
+   +
+
+
+ +
+
+
-
- -
+ Beta +
-

- Your tag data will be sent through your own domain to improve data quality and help you recover measurement signals. + Your tag data will be sent through your own domain to improve data quality and help you recover measurement signals. + + + Learn more + + + + +

- Your server’s current settings prevent first-party mode from working. To enable it, please contact your hosting provider and request access to external resources and plugin files. + Your server’s current settings prevent first-party mode from working. To enable it, please contact your hosting provider and request access to external resources and plugin files. + + + Learn more + + + + +

- Your tag data will be sent through your own domain to improve data quality and help you recover measurement signals. + Your tag data will be sent through your own domain to improve data quality and help you recover measurement signals. + + + Learn more + + + + +

diff --git a/assets/js/components/legacy-setup/SetupUsingGCP.js b/assets/js/components/legacy-setup/SetupUsingGCP.js index f9eefad7a20..b60cd1df211 100644 --- a/assets/js/components/legacy-setup/SetupUsingGCP.js +++ b/assets/js/components/legacy-setup/SetupUsingGCP.js @@ -24,7 +24,7 @@ import { delay } from 'lodash'; /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { __, _x } from '@wordpress/i18n'; import { Component, Fragment } from '@wordpress/element'; import { compose } from '@wordpress/compose'; @@ -341,8 +341,9 @@ class SetupUsingGCP extends Component { .onButtonClick } > - { __( + { _x( 'Sign in with Google', + 'Service name', 'google-site-kit' ) } diff --git a/assets/js/components/legacy-setup/wizard-step-authentication.js b/assets/js/components/legacy-setup/wizard-step-authentication.js index 2296bc77dd1..208aebe3c67 100644 --- a/assets/js/components/legacy-setup/wizard-step-authentication.js +++ b/assets/js/components/legacy-setup/wizard-step-authentication.js @@ -24,7 +24,7 @@ import PropTypes from 'prop-types'; /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { __, _x } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; /** @@ -108,8 +108,9 @@ class WizardStepAuthentication extends Component { ) }

diff --git a/assets/js/components/notifications/BannerNotifications.js b/assets/js/components/notifications/BannerNotifications.js index b9bcce31767..b0475a5c373 100644 --- a/assets/js/components/notifications/BannerNotifications.js +++ b/assets/js/components/notifications/BannerNotifications.js @@ -51,6 +51,7 @@ import { READER_REVENUE_MANAGER_MODULE_SLUG } from '../../modules/reader-revenue const MODULES_USING_SUBTLE_NOTIFICATIONS = [ 'ads', READER_REVENUE_MANAGER_MODULE_SLUG, + 'sign-in-with-google', ]; export default function BannerNotifications() { diff --git a/assets/js/components/notifications/ErrorNotifications.js b/assets/js/components/notifications/ErrorNotifications.js index f7b9488b8e9..f809b46287b 100644 --- a/assets/js/components/notifications/ErrorNotifications.js +++ b/assets/js/components/notifications/ErrorNotifications.js @@ -20,104 +20,18 @@ * WordPress dependencies */ import { Fragment } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import { useSelect } from 'googlesitekit-data'; import InternalServerError from './InternalServerError'; -import { - CORE_USER, - FORM_TEMPORARY_PERSIST_PERMISSION_ERROR, -} from '../../googlesitekit/datastore/user/constants'; -import { CORE_SITE } from '../../googlesitekit/datastore/site/constants'; -import { CORE_FORMS } from '../../googlesitekit/datastore/forms/constants'; -import BannerNotification from './BannerNotification'; import Notifications from './Notifications'; import { NOTIFICATION_AREAS } from '../../googlesitekit/notifications/datastore/constants'; export default function ErrorNotifications() { - const isAuthenticated = useSelect( ( select ) => - select( CORE_USER ).isAuthenticated() - ); - - // These will be `null` if no errors exist. - const setupErrorCode = useSelect( ( select ) => - select( CORE_SITE ).getSetupErrorCode() - ); - const setupErrorMessage = useSelect( ( select ) => - select( CORE_SITE ).getSetupErrorMessage() - ); - const temporaryPersistedPermissionsError = useSelect( ( select ) => - select( CORE_FORMS ).getValue( - FORM_TEMPORARY_PERSIST_PERMISSION_ERROR, - 'permissionsError' - ) - ); - const setupErrorRedoURL = useSelect( ( select ) => { - if ( temporaryPersistedPermissionsError?.data ) { - return select( CORE_USER ).getConnectURL( { - additionalScopes: - temporaryPersistedPermissionsError?.data?.scopes, - redirectURL: - temporaryPersistedPermissionsError?.data?.redirectURL || - global.location.href, - } ); - } else if ( - setupErrorCode === 'access_denied' && - ! temporaryPersistedPermissionsError?.data && - isAuthenticated - ) { - return null; - } - - return select( CORE_SITE ).getSetupErrorRedoURL(); - } ); - const errorTroubleshootingLinkURL = useSelect( ( select ) => - select( CORE_SITE ).getErrorTroubleshootingLinkURL( { - code: setupErrorCode, - } ) - ); - - let title = __( 'Error connecting Site Kit', 'google-site-kit' ); - let ctaLabel = __( 'Redo the plugin setup', 'google-site-kit' ); - - if ( setupErrorCode === 'access_denied' ) { - title = __( 'Permissions Error', 'google-site-kit' ); - - if ( temporaryPersistedPermissionsError?.data ) { - ctaLabel = __( 'Grant permission', 'google-site-kit' ); - } else if ( - ! temporaryPersistedPermissionsError?.data && - isAuthenticated - ) { - ctaLabel = null; - } - } - - if ( - temporaryPersistedPermissionsError?.data?.skipDefaultErrorNotifications - ) { - return null; - } - return ( - { setupErrorMessage && ( - - ) } ); diff --git a/assets/js/components/notifications/ErrorNotifications.test.js b/assets/js/components/notifications/ErrorNotifications.test.js index 15458961e01..0b0f30b4d2e 100644 --- a/assets/js/components/notifications/ErrorNotifications.test.js +++ b/assets/js/components/notifications/ErrorNotifications.test.js @@ -27,8 +27,6 @@ import { provideModules, provideSiteInfo, provideNotifications, - act, - waitForDefaultTimeouts, } from '../../../../tests/js/test-utils'; import { CORE_USER, @@ -50,7 +48,7 @@ describe( 'ErrorNotifications', () => { registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] ); } ); - it( 'does not render UnsatisfiedScopesAlert when user is not authenticated', () => { + it( 'does not render UnsatisfiedScopesAlert when user is not authenticated', async () => { provideUserAuthentication( registry, { authenticated: false, unsatisfiedScopes: [ @@ -70,39 +68,39 @@ describe( 'ErrorNotifications', () => { 'authentication-error': DEFAULT_NOTIFICATIONS[ 'authentication-error' ], }, - true + { overwrite: true } ); - const { container } = render( , { + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); + expect( container.childElementCount ).toBe( 0 ); } ); it( 'renders UnsatisfiedScopesAlert when user is authenticated', async () => { - act( () => { - provideUserAuthentication( registry, { - grantedScopes: [ TAGMANAGER_READ_SCOPE ], - unsatisfiedScopes: [ - 'https://www.googleapis.com/auth/analytics.readonly', - ], - } ); - provideModules( registry, [ - { - slug: 'analytics-4', - active: true, - connected: true, - }, - ] ); - provideNotifications( - registry, - { - 'authentication-error': - DEFAULT_NOTIFICATIONS[ 'authentication-error' ], - }, - true - ); + provideUserAuthentication( registry, { + grantedScopes: [ TAGMANAGER_READ_SCOPE ], + unsatisfiedScopes: [ + 'https://www.googleapis.com/auth/analytics.readonly', + ], } ); + provideModules( registry, [ + { + slug: 'analytics-4', + active: true, + connected: true, + }, + ] ); + provideNotifications( + registry, + { + 'authentication-error': + DEFAULT_NOTIFICATIONS[ 'authentication-error' ], + }, + { overwrite: true } + ); const { container, waitForRegistry } = render( , { registry, @@ -110,7 +108,6 @@ describe( 'ErrorNotifications', () => { } ); await waitForRegistry(); - await act( waitForDefaultTimeouts ); expect( container ).toHaveTextContent( 'Site Kit can’t access necessary data' @@ -118,7 +115,7 @@ describe( 'ErrorNotifications', () => { expect( container ).toMatchSnapshot(); } ); - it( 'renders `Get help` link', () => { + it( 'renders `Get help` link', async () => { provideUserAuthentication( registry, { unsatisfiedScopes: [ 'https://www.googleapis.com/auth/analytics.readonly', @@ -129,10 +126,21 @@ describe( 'ErrorNotifications', () => { setupErrorCode: 'error_code', setupErrorMessage: 'An error occurred', } ); - const { container, getByRole } = render( , { + provideNotifications( registry, - viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, - } ); + { + setup_plugin_error: DEFAULT_NOTIFICATIONS.setup_plugin_error, + }, + { overwrite: true } + ); + const { container, getByRole, waitForRegistry } = render( + , + { + registry, + viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, + } + ); + await waitForRegistry(); expect( container ).toHaveTextContent( 'Get help' ); expect( getByRole( 'link', { name: /get help/i } ) ).toHaveAttribute( @@ -144,30 +152,28 @@ describe( 'ErrorNotifications', () => { } ); it( 'renders the GTE message when the only unsatisfied scope is the tagmanager readonly scope', async () => { - act( () => { - provideModules( registry, [ - { - slug: 'analytics-4', - active: true, - connected: true, - }, - ] ); - provideUserAuthentication( registry, { - unsatisfiedScopes: [ - 'https://www.googleapis.com/auth/tagmanager.readonly', - ], - } ); - provideNotifications( - registry, - { - 'authentication-error': - DEFAULT_NOTIFICATIONS[ 'authentication-error' ], - 'authentication-error-gte': - DEFAULT_NOTIFICATIONS[ 'authentication-error-gte' ], - }, - true - ); + provideModules( registry, [ + { + slug: 'analytics-4', + active: true, + connected: true, + }, + ] ); + provideUserAuthentication( registry, { + unsatisfiedScopes: [ + 'https://www.googleapis.com/auth/tagmanager.readonly', + ], } ); + provideNotifications( + registry, + { + 'authentication-error': + DEFAULT_NOTIFICATIONS[ 'authentication-error' ], + 'authentication-error-gte': + DEFAULT_NOTIFICATIONS[ 'authentication-error-gte' ], + }, + { overwrite: true } + ); const { container, waitForRegistry } = render( , { registry, @@ -175,7 +181,6 @@ describe( 'ErrorNotifications', () => { } ); await waitForRegistry(); - await act( waitForDefaultTimeouts ); expect( container ).toHaveTextContent( 'Site Kit needs additional permissions to detect updates to tags on your site' @@ -184,31 +189,29 @@ describe( 'ErrorNotifications', () => { } ); it( 'does not render the GTE message if there are multiple unsatisfied scopes', async () => { - act( () => { - provideModules( registry, [ - { - slug: 'analytics-4', - active: true, - connected: true, - }, - ] ); - provideUserAuthentication( registry, { - unsatisfiedScopes: [ - 'https://www.googleapis.com/auth/tagmanager.readonly', - 'https://www.googleapis.com/auth/analytics.readonly', - ], - } ); - provideNotifications( - registry, - { - 'authentication-error': - DEFAULT_NOTIFICATIONS[ 'authentication-error' ], - 'authentication-error-gte': - DEFAULT_NOTIFICATIONS[ 'authentication-error-gte' ], - }, - true - ); + provideModules( registry, [ + { + slug: 'analytics-4', + active: true, + connected: true, + }, + ] ); + provideUserAuthentication( registry, { + unsatisfiedScopes: [ + 'https://www.googleapis.com/auth/tagmanager.readonly', + 'https://www.googleapis.com/auth/analytics.readonly', + ], } ); + provideNotifications( + registry, + { + 'authentication-error': + DEFAULT_NOTIFICATIONS[ 'authentication-error' ], + 'authentication-error-gte': + DEFAULT_NOTIFICATIONS[ 'authentication-error-gte' ], + }, + { overwrite: true } + ); const { container, waitForRegistry } = render( , { registry, @@ -216,7 +219,6 @@ describe( 'ErrorNotifications', () => { } ); await waitForRegistry(); - await act( waitForDefaultTimeouts ); expect( container ).toHaveTextContent( 'Site Kit can’t access necessary data' @@ -224,87 +226,87 @@ describe( 'ErrorNotifications', () => { expect( container ).toMatchSnapshot(); } ); - it( 'does render the redo setup CTA if initial Site Kit setup authentication is not granted', () => { - act( () => { - provideModules( registry, [ - { - slug: 'analytics-4', - active: true, - connected: true, - }, - ] ); - provideUserAuthentication( registry, { - unsatisfiedScopes: [ - 'https://www.googleapis.com/auth/tagmanager.readonly', - 'https://www.googleapis.com/auth/analytics.readonly', - ], - authenticated: false, - } ); - provideNotifications( - registry, - { - 'authentication-error': - DEFAULT_NOTIFICATIONS[ 'authentication-error' ], - 'authentication-error-gte': - DEFAULT_NOTIFICATIONS[ 'authentication-error-gte' ], - }, - true - ); - provideSiteInfo( registry, { - setupErrorRedoURL: '#', - setupErrorCode: 'access_denied', - setupErrorMessage: - 'Setup was interrupted because you did not grant the necessary permissions', - } ); + it( 'does render the redo setup CTA if initial Site Kit setup authentication is not granted', async () => { + provideModules( registry, [ + { + slug: 'analytics-4', + active: true, + connected: true, + }, + ] ); + provideUserAuthentication( registry, { + unsatisfiedScopes: [ + 'https://www.googleapis.com/auth/tagmanager.readonly', + 'https://www.googleapis.com/auth/analytics.readonly', + ], + authenticated: false, + } ); + provideNotifications( + registry, + { + 'authentication-error': + DEFAULT_NOTIFICATIONS[ 'authentication-error' ], + 'authentication-error-gte': + DEFAULT_NOTIFICATIONS[ 'authentication-error-gte' ], + setup_plugin_error: DEFAULT_NOTIFICATIONS.setup_plugin_error, + }, + { overwrite: true } + ); + provideSiteInfo( registry, { + setupErrorRedoURL: '#', + setupErrorCode: 'access_denied', + setupErrorMessage: + 'Setup was interrupted because you did not grant the necessary permissions', } ); - const { container } = render( , { + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); expect( container ).toHaveTextContent( 'Setup was interrupted' ); expect( container ).toHaveTextContent( 'Redo the plugin setup' ); } ); - it( 'does not render the redo setup CTA if it is not due to the interruption of plugin setup and no permission is temporarily persisted', () => { - act( () => { - provideModules( registry, [ - { - slug: 'analytics-4', - active: true, - connected: true, - }, - ] ); - provideUserAuthentication( registry ); - provideNotifications( - registry, - { - 'authentication-error': - DEFAULT_NOTIFICATIONS[ 'authentication-error' ], - 'authentication-error-gte': - DEFAULT_NOTIFICATIONS[ 'authentication-error-gte' ], - }, - true - ); - provideSiteInfo( registry, { - setupErrorCode: 'access_denied', - setupErrorMessage: - 'Setup was interrupted because you did not grant the necessary permissions', - setupErrorRedoURL: '#', - } ); + it( 'does not render the redo setup CTA if it is not due to the interruption of plugin setup and no permission is temporarily persisted', async () => { + provideModules( registry, [ + { + slug: 'analytics-4', + active: true, + connected: true, + }, + ] ); + provideUserAuthentication( registry ); + provideNotifications( + registry, + { + 'authentication-error': + DEFAULT_NOTIFICATIONS[ 'authentication-error' ], + 'authentication-error-gte': + DEFAULT_NOTIFICATIONS[ 'authentication-error-gte' ], + setup_plugin_error: DEFAULT_NOTIFICATIONS.setup_plugin_error, + }, + { overwrite: true } + ); + provideSiteInfo( registry, { + setupErrorCode: 'access_denied', + setupErrorMessage: + 'Setup was interrupted because you did not grant the necessary permissions', + setupErrorRedoURL: '#', } ); - const { container } = render( , { + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); expect( container ).toHaveTextContent( 'Setup was interrupted' ); expect( container ).not.toHaveTextContent( 'Redo the plugin setup' ); } ); - it( 'does render the grant permission CTA if additional permissions were not granted and permission is temporarily persisted', () => { + it( 'does render the grant permission CTA if additional permissions were not granted and permission is temporarily persisted', async () => { provideUserAuthentication( registry ); provideSiteInfo( registry, { isAuthenticated: true, @@ -325,11 +327,19 @@ describe( 'ErrorNotifications', () => { ], }, } ); + provideNotifications( + registry, + { + setup_plugin_error: DEFAULT_NOTIFICATIONS.setup_plugin_error, + }, + { overwrite: true } + ); - const { container } = render( , { + const { container, waitForRegistry } = render( , { registry, viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, } ); + await waitForRegistry(); expect( container ).toHaveTextContent( 'Setup was interrupted' ); expect( container ).not.toHaveTextContent( 'Grant permission' ); diff --git a/assets/js/components/notifications/FirstPartyModeSetupBanner.js b/assets/js/components/notifications/FirstPartyModeSetupBanner.js index 706231ec690..c876df30f37 100644 --- a/assets/js/components/notifications/FirstPartyModeSetupBanner.js +++ b/assets/js/components/notifications/FirstPartyModeSetupBanner.js @@ -19,7 +19,7 @@ /** * WordPress dependencies */ -import { createInterpolateElement, Fragment } from '@wordpress/element'; +import { Fragment } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; /** @@ -31,8 +31,14 @@ import { useTooltipState, AdminMenuTooltip, } from '../AdminMenuTooltip'; +import { + CORE_NOTIFICATIONS, + NOTIFICATION_GROUPS, +} from '../../googlesitekit/notifications/datastore/constants'; import { CORE_SITE } from '../../googlesitekit/datastore/site/constants'; -import { CORE_NOTIFICATIONS } from '../../googlesitekit/notifications/datastore/constants'; +import { CORE_UI } from '../../googlesitekit/datastore/ui/constants'; +import Description from '../../googlesitekit/notifications/components/common/Description'; +import LearnMoreLink from '../../googlesitekit/notifications/components/common/LearnMoreLink'; import NotificationWithSVG from '../../googlesitekit/notifications/components/layout/NotificationWithSVG'; import ActionsCTALinkDismiss from '../../googlesitekit/notifications/components/common/ActionsCTALinkDismiss'; import FPMSetupCTASVGDesktop from '../../../svg/graphics/first-party-mode-setup-banner-desktop.svg'; @@ -43,15 +49,18 @@ import { BREAKPOINT_TABLET, useBreakpoint, } from '../../hooks/useBreakpoint'; +import useViewContext from '../../hooks/useViewContext'; + +export const FPM_SHOW_SETUP_SUCCESS_NOTIFICATION = + 'fpm-show-setup-success-notification'; export default function FirstPartyModeSetupBanner( { id, Notification } ) { const breakpoint = useBreakpoint(); + const viewContext = useViewContext(); const { setFirstPartyModeEnabled, saveFirstPartyModeSettings } = useDispatch( CORE_SITE ); - const { dismissNotification } = useDispatch( CORE_NOTIFICATIONS ); - const showTooltip = useShowTooltip( id ); const { isTooltipVisible } = useTooltipState( id ); @@ -60,10 +69,26 @@ export default function FirstPartyModeSetupBanner( { id, Notification } ) { select( CORE_NOTIFICATIONS ).isNotificationDismissed( id ) ); + const { dismissNotification, invalidateResolution } = + useDispatch( CORE_NOTIFICATIONS ); + const { setValue } = useDispatch( CORE_UI ); + + const learnMoreURL = useSelect( ( select ) => { + return select( CORE_SITE ).getDocumentationLinkURL( + 'first-party-mode-introduction' + ); + } ); + const onCTAClick = () => { setFirstPartyModeEnabled( true ); saveFirstPartyModeSettings(); + setValue( FPM_SHOW_SETUP_SUCCESS_NOTIFICATION, true ); + invalidateResolution( 'getQueuedNotifications', [ + viewContext, + NOTIFICATION_GROUPS.DEFAULT, + ] ); + dismissNotification( id ); }; @@ -111,15 +136,21 @@ export default function FirstPartyModeSetupBanner( { id, Notification } ) { 'Get more comprehensive stats by collecting metrics via your own site', 'google-site-kit' ) } - description={ createInterpolateElement( - __( - 'Enable First-party mode (beta) to send measurement through your own domain - this helps improve the quality and completeness of Analytics and Ads metrics.', - 'google-site-kit' - ), - { - emphasis: , - } - ) } + description={ + beta) to send measurement through your own domain - this helps improve the quality and completeness of Analytics or Ads metrics.', + 'google-site-kit' + ) } + learnMoreLink={ + + } + /> + } actions={ { provideModules( registry, [ { - slug: FPM_BANNER_ID, + slug: FPM_SETUP_CTA_BANNER_NOTIFICATION, active: false, }, ] ); @@ -65,7 +64,7 @@ export default { // Register the notification to avoid errors in console. registry .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( FPM_BANNER_ID, { + .registerNotification( FPM_SETUP_CTA_BANNER_NOTIFICATION, { Component: FirstPartyModeSetupBanner, areaSlug: NOTIFICATION_AREAS.BANNERS_BELOW_NAV, viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], @@ -80,7 +79,7 @@ export default { ), { body: { - [ FPM_BANNER_ID ]: { + [ FPM_SETUP_CTA_BANNER_NOTIFICATION ]: { expires: Date.now() / 1000 + WEEK_IN_SECONDS, count: 1, }, diff --git a/assets/js/components/notifications/FirstPartyModeSetupBanner.test.js b/assets/js/components/notifications/FirstPartyModeSetupBanner.test.js index f72b7bbe6b2..cc2d231d0f9 100644 --- a/assets/js/components/notifications/FirstPartyModeSetupBanner.test.js +++ b/assets/js/components/notifications/FirstPartyModeSetupBanner.test.js @@ -21,19 +21,26 @@ import fetchMock from 'fetch-mock'; /** * Internal dependencies */ -import FirstPartyModeSetupBanner from './FirstPartyModeSetupBanner'; +import FirstPartyModeSetupBanner, { + FPM_SHOW_SETUP_SUCCESS_NOTIFICATION, +} from './FirstPartyModeSetupBanner'; import { createTestRegistry, fireEvent, + muteFetch, provideModules, provideSiteInfo, provideUserInfo, render, waitFor, } from '../../../../tests/js/test-utils'; +import { CORE_UI } from '../../googlesitekit/datastore/ui/constants'; import { VIEW_CONTEXT_MAIN_DASHBOARD } from '../../googlesitekit/constants'; import { DEFAULT_NOTIFICATIONS } from '../../googlesitekit/notifications/register-defaults'; -import { CORE_NOTIFICATIONS } from '../../googlesitekit/notifications/datastore/constants'; +import { + CORE_NOTIFICATIONS, + NOTIFICATION_GROUPS, +} from '../../googlesitekit/notifications/datastore/constants'; import { CORE_SITE } from '../../googlesitekit/datastore/site/constants'; import { CORE_USER } from '../../googlesitekit/datastore/user/constants'; import { withNotificationComponentProps } from '../../googlesitekit/notifications/util/component-props'; @@ -231,4 +238,54 @@ describe( 'FirstPartyModeSetupBanner', () => { expect( fetchMock ).toHaveFetched( dismissItemEndpoint ); } ); } ); + + it( 'should set FPM_SHOW_SETUP_SUCCESS_NOTIFICATION to true and invalidate the notifications queue resolution when the CTA button is clicked', async () => { + const { getByRole, waitForRegistry } = render( , { + registry, + viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, + } ); + + await waitForRegistry(); + + muteFetch( fpmSettingsEndpoint ); + + fetchMock.post( dismissItemEndpoint, { + body: JSON.stringify( [ FPM_SETUP_BANNER_NOTIFICATION ] ), + status: 200, + } ); + + await registry + .dispatch( CORE_NOTIFICATIONS ) + .receiveQueuedNotifications( [], NOTIFICATION_GROUPS.DEFAULT ); + + registry + .dispatch( CORE_NOTIFICATIONS ) + .finishResolution( 'getQueuedNotifications', [ + VIEW_CONTEXT_MAIN_DASHBOARD, + NOTIFICATION_GROUPS.DEFAULT, + ] ); + + fireEvent.click( + getByRole( 'button', { name: 'Enable First-party mode' } ) + ); + + await waitFor( () => { + expect( fetchMock ).toHaveFetched( dismissItemEndpoint ); + } ); + + expect( + registry + .select( CORE_UI ) + .getValue( FPM_SHOW_SETUP_SUCCESS_NOTIFICATION ) + ).toBe( true ); + + expect( + registry + .select( CORE_NOTIFICATIONS ) + .hasFinishedResolution( 'getQueuedNotifications', [ + VIEW_CONTEXT_MAIN_DASHBOARD, + NOTIFICATION_GROUPS.DEFAULT, + ] ) + ).toBe( false ); + } ); } ); diff --git a/assets/js/components/notifications/FirstPartyModeSetupSuccessSubtleNotification.js b/assets/js/components/notifications/FirstPartyModeSetupSuccessSubtleNotification.js new file mode 100644 index 00000000000..9d7957d1898 --- /dev/null +++ b/assets/js/components/notifications/FirstPartyModeSetupSuccessSubtleNotification.js @@ -0,0 +1,68 @@ +/** + * FirstPartyModeSetupSuccessSubtleNotification component. + * + * Site Kit by Google, Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import SubtleNotification from '../../googlesitekit/notifications/components/layout/SubtleNotification'; +import Dismiss from '../../googlesitekit/notifications/components/common/Dismiss'; + +export const FIRST_PARTY_MODE_SETUP_SUCCESS_NOTIFICATION = + 'setup-success-notification-fpm'; + +export default function FirstPartyModeSetupSuccessSubtleNotification( { + id, + Notification, +} ) { + return ( + + + } + /> + + ); +} + +FirstPartyModeSetupSuccessSubtleNotification.propTypes = { + id: PropTypes.string.isRequired, + Notification: PropTypes.elementType.isRequired, +}; diff --git a/assets/js/components/notifications/FirstPartyModeSetupSuccessSubtleNotification.stories.js b/assets/js/components/notifications/FirstPartyModeSetupSuccessSubtleNotification.stories.js new file mode 100644 index 00000000000..500fad2dda2 --- /dev/null +++ b/assets/js/components/notifications/FirstPartyModeSetupSuccessSubtleNotification.stories.js @@ -0,0 +1,39 @@ +/** + * FirstPartyModeSetupSuccessSubtleNotification Component Stories. + * + * Site Kit by Google, Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ +import { withNotificationComponentProps } from '../../googlesitekit/notifications/util/component-props'; +import FirstPartyModeSetupSuccessSubtleNotification from './FirstPartyModeSetupSuccessSubtleNotification'; + +const NotificationWithComponentProps = withNotificationComponentProps( + 'setup-success-notification-fpm' +)( FirstPartyModeSetupSuccessSubtleNotification ); + +function Template() { + return ; +} + +export const Default = Template.bind( {} ); +Default.storyName = 'FirstPartyModeSetupSuccessSubtleNotification'; +Default.scenario = {}; + +export default { + title: 'Modules/FirstPartyMode/Dashboard/FirstPartyModeSetupSuccessSubtleNotification', +}; diff --git a/assets/js/components/notifications/FirstPartyModeSetupSuccessSubtleNotification.test.js b/assets/js/components/notifications/FirstPartyModeSetupSuccessSubtleNotification.test.js new file mode 100644 index 00000000000..d81ef1cffeb --- /dev/null +++ b/assets/js/components/notifications/FirstPartyModeSetupSuccessSubtleNotification.test.js @@ -0,0 +1,86 @@ +/** + * FirstPartyModeSetupSuccessSubtleNotification component tests. + * + * Site Kit by Google, Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ +import { createTestRegistry, render } from '../../../../tests/js/test-utils'; +import { CORE_UI } from '../../googlesitekit/datastore/ui/constants'; +import { VIEW_CONTEXT_MAIN_DASHBOARD } from '../../googlesitekit/constants'; +import { DEFAULT_NOTIFICATIONS } from '../../googlesitekit/notifications/register-defaults'; +import FirstPartyModeSetupSuccessSubtleNotification from './FirstPartyModeSetupSuccessSubtleNotification'; +import { withNotificationComponentProps } from '../../googlesitekit/notifications/util/component-props'; +import { FPM_SHOW_SETUP_SUCCESS_NOTIFICATION } from './FirstPartyModeSetupBanner'; + +const NotificationWithComponentProps = withNotificationComponentProps( + 'setup-success-notification-fpm' +)( FirstPartyModeSetupSuccessSubtleNotification ); + +describe( 'FirstPartyModeSetupSuccessSubtleNotification', () => { + let registry; + + beforeEach( () => { + registry = createTestRegistry(); + } ); + + it( 'should render correctly', () => { + const { container, getByText } = render( + , + { registry } + ); + + expect( + getByText( + 'You can always disable it in Analytics or Ads settings' + ) + ).toBeInTheDocument(); + + expect( container ).toMatchSnapshot(); + } ); + + describe( 'checkRequirements', () => { + const notification = + DEFAULT_NOTIFICATIONS[ 'setup-success-notification-fpm' ]; + + it( 'is active when FPM_SHOW_SETUP_SUCCESS_NOTIFICATION is true', () => { + registry + .dispatch( CORE_UI ) + .setValue( FPM_SHOW_SETUP_SUCCESS_NOTIFICATION, true ); + + const isActive = notification.checkRequirements( + registry, + VIEW_CONTEXT_MAIN_DASHBOARD + ); + + expect( isActive ).toBe( true ); + } ); + + it( 'is not active when FPM_SHOW_SETUP_SUCCESS_NOTIFICATION is false', () => { + registry + .dispatch( CORE_UI ) + .setValue( FPM_SHOW_SETUP_SUCCESS_NOTIFICATION, false ); + + const isActive = notification.checkRequirements( + registry, + VIEW_CONTEXT_MAIN_DASHBOARD + ); + + expect( isActive ).toBe( false ); + } ); + } ); +} ); diff --git a/assets/js/components/notifications/FirstPartyModeWarningNotification.js b/assets/js/components/notifications/FirstPartyModeWarningNotification.js new file mode 100644 index 00000000000..2f697d049bd --- /dev/null +++ b/assets/js/components/notifications/FirstPartyModeWarningNotification.js @@ -0,0 +1,77 @@ +/** + * FirstPartyModeWarningNotification component. + * + * Site Kit by Google, Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * WordPress dependencies + */ +import { createInterpolateElement } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import SubtleNotification from '../../googlesitekit/notifications/components/layout/SubtleNotification'; +import Link from '../Link'; +import Dismiss from '../../googlesitekit/notifications/components/common/Dismiss'; +import { CORE_SITE } from '../../googlesitekit/datastore/site/constants'; +import { useSelect } from 'googlesitekit-data'; + +export default function FirstPartyModeWarningNotification( { + id, + Notification, +} ) { + const serverRequirementsLearnMoreURL = useSelect( ( select ) => { + return select( CORE_SITE ).getDocumentationLinkURL( + 'first-party-mode-server-requirements' + ); + } ); + + return ( + + Learn more', + 'google-site-kit' + ), + { + a: ( + + ), + } + ) } + dismissCTA={ + + } + type="warning" + /> + + ); +} diff --git a/assets/js/components/notifications/FirstPartyModeWarningNotification.stories.js b/assets/js/components/notifications/FirstPartyModeWarningNotification.stories.js new file mode 100644 index 00000000000..a22848afe0d --- /dev/null +++ b/assets/js/components/notifications/FirstPartyModeWarningNotification.stories.js @@ -0,0 +1,40 @@ +/** + * FirstPartyModeWarningNotification Component Stories. + * + * Site Kit by Google, Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ +import { withNotificationComponentProps } from '../../googlesitekit/notifications/util/component-props'; +import FirstPartyModeWarningNotification from './FirstPartyModeWarningNotification'; +import { FPM_HEALTH_CHECK_WARNING_NOTIFICATION_ID } from '../../googlesitekit/notifications/datastore/constants'; + +const NotificationWithComponentProps = withNotificationComponentProps( + FPM_HEALTH_CHECK_WARNING_NOTIFICATION_ID +)( FirstPartyModeWarningNotification ); + +function Template() { + return ; +} + +export const Default = Template.bind(); +Default.storyName = 'FirstPartyModeWarningNotification'; +Default.scenario = {}; + +export default { + title: 'Modules/FirstPartyMode/Dashboard/FirstPartyModeWarningNotification', +}; diff --git a/assets/js/components/notifications/FirstPartyModeWarningNotification.test.js b/assets/js/components/notifications/FirstPartyModeWarningNotification.test.js new file mode 100644 index 00000000000..c50accc6008 --- /dev/null +++ b/assets/js/components/notifications/FirstPartyModeWarningNotification.test.js @@ -0,0 +1,178 @@ +/** + * FirstPartyModeWarningNotification component tests. + * + * Site Kit by Google, Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import fetchMock from 'fetch-mock'; + +/** + * Internal dependencies + */ +import { + createTestRegistry, + fireEvent, + provideModules, + provideSiteInfo, + provideUserInfo, + render, + waitFor, +} from '../../../../tests/js/test-utils'; +import { VIEW_CONTEXT_MAIN_DASHBOARD } from '../../googlesitekit/constants'; +import { DEFAULT_NOTIFICATIONS } from '../../googlesitekit/notifications/register-defaults'; +import { CORE_SITE } from '../../googlesitekit/datastore/site/constants'; +import { CORE_USER } from '../../googlesitekit/datastore/user/constants'; +import { withNotificationComponentProps } from '../../googlesitekit/notifications/util/component-props'; +import { enabledFeatures } from '../../features'; +import { + CORE_NOTIFICATIONS, + FPM_HEALTH_CHECK_WARNING_NOTIFICATION_ID, +} from '../../googlesitekit/notifications/datastore/constants'; +import FirstPartyModeWarningNotification from './FirstPartyModeWarningNotification'; + +describe( 'FirstPartyModeWarningNotification', () => { + let registry; + + const notification = + DEFAULT_NOTIFICATIONS[ FPM_HEALTH_CHECK_WARNING_NOTIFICATION_ID ]; + + const fpmSettings = { + isEnabled: true, + isFPMHealthy: false, + isScriptAccessEnabled: false, + }; + + const FPMWarningNotificationComponent = withNotificationComponentProps( + FPM_HEALTH_CHECK_WARNING_NOTIFICATION_ID + )( FirstPartyModeWarningNotification ); + + const dismissItemEndpoint = new RegExp( + '^/google-site-kit/v1/core/user/data/dismiss-item' + ); + + beforeEach( () => { + registry = createTestRegistry(); + + enabledFeatures.add( 'firstPartyMode' ); + + provideSiteInfo( registry ); + provideUserInfo( registry ); + provideModules( registry, [ + { + slug: 'analytics-4', + active: true, + connected: true, + }, + { + slug: 'ads', + active: true, + connected: true, + }, + ] ); + + registry + .dispatch( CORE_NOTIFICATIONS ) + .registerNotification( + FPM_HEALTH_CHECK_WARNING_NOTIFICATION_ID, + notification + ); + + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( fpmSettings ); + + registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] ); + } ); + + describe( 'checkRequirements', () => { + it( 'is active when all required conditions are met', async () => { + const isActive = await notification.checkRequirements( + registry, + VIEW_CONTEXT_MAIN_DASHBOARD + ); + + expect( isActive ).toBe( true ); + } ); + + it( 'is not active when server requirements are met and FPM is enabled', async () => { + registry.dispatch( CORE_SITE ).receiveGetFirstPartyModeSettings( { + ...fpmSettings, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + + const isActive = await notification.checkRequirements( + registry, + VIEW_CONTEXT_MAIN_DASHBOARD + ); + + expect( isActive ).toBe( false ); + } ); + + it( 'is not active when server requirements are not met, but FPM is disabled', async () => { + registry.dispatch( CORE_SITE ).receiveGetFirstPartyModeSettings( { + ...fpmSettings, + isEnabled: false, + } ); + + const isActive = await notification.checkRequirements( + registry, + VIEW_CONTEXT_MAIN_DASHBOARD + ); + + expect( isActive ).toBe( false ); + } ); + } ); + + it( 'should render the notification', () => { + const { getByText } = render( , { + registry, + viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, + } ); + + expect( + getByText( + /First-party mode has been disabled due to server configuration issues/i + ) + ).toBeInTheDocument(); + } ); + + it( 'should dismiss the notification when dismiss button is clicked', async () => { + const { getByRole } = render( , { + registry, + viewContext: VIEW_CONTEXT_MAIN_DASHBOARD, + } ); + + const dismissButton = getByRole( 'button', { name: /got it/i } ); + + expect( dismissButton ).toBeInTheDocument(); + + fetchMock.post( dismissItemEndpoint, { + body: JSON.stringify( [ + FPM_HEALTH_CHECK_WARNING_NOTIFICATION_ID, + ] ), + status: 200, + } ); + + fireEvent.click( dismissButton ); + + await waitFor( () => { + expect( fetchMock ).toHaveFetched( dismissItemEndpoint ); + } ); + } ); +} ); diff --git a/assets/js/components/notifications/SetupErrorMessageNotification.js b/assets/js/components/notifications/SetupErrorMessageNotification.js new file mode 100644 index 00000000000..a532e20c8b4 --- /dev/null +++ b/assets/js/components/notifications/SetupErrorMessageNotification.js @@ -0,0 +1,135 @@ +/** + * SetupErrorMessageNotification component. + * + * Site Kit by Google, Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { useSelect } from 'googlesitekit-data'; +import { + CORE_USER, + FORM_TEMPORARY_PERSIST_PERMISSION_ERROR, +} from '../../googlesitekit/datastore/user/constants'; +import { CORE_SITE } from '../../googlesitekit/datastore/site/constants'; +import { CORE_FORMS } from '../../googlesitekit/datastore/forms/constants'; +import NotificationError from '../../googlesitekit/notifications/components/layout/NotificationError'; +import Description from '../../googlesitekit/notifications/components/common/Description'; +import LearnMoreLink from '../../googlesitekit/notifications/components/common/LearnMoreLink'; +import CTALink from '../../googlesitekit/notifications/components/common/CTALink'; +import useViewContext from '../../hooks/useViewContext'; + +export default function SetupErrorMessageNotification( { Notification } ) { + const viewContext = useViewContext(); + const isAuthenticated = useSelect( ( select ) => + select( CORE_USER ).isAuthenticated() + ); + + // These will be `null` if no errors exist. + const setupErrorCode = useSelect( ( select ) => + select( CORE_SITE ).getSetupErrorCode() + ); + const setupErrorMessage = useSelect( ( select ) => + select( CORE_SITE ).getSetupErrorMessage() + ); + const temporaryPersistedPermissionsError = useSelect( ( select ) => + select( CORE_FORMS ).getValue( + FORM_TEMPORARY_PERSIST_PERMISSION_ERROR, + 'permissionsError' + ) + ); + const setupErrorRedoURL = useSelect( ( select ) => { + if ( temporaryPersistedPermissionsError?.data ) { + return select( CORE_USER ).getConnectURL( { + additionalScopes: + temporaryPersistedPermissionsError?.data?.scopes, + redirectURL: + temporaryPersistedPermissionsError?.data?.redirectURL || + global.location.href, + } ); + } else if ( + setupErrorCode === 'access_denied' && + ! temporaryPersistedPermissionsError?.data && + isAuthenticated + ) { + return null; + } + + return select( CORE_SITE ).getSetupErrorRedoURL(); + } ); + const errorTroubleshootingLinkURL = useSelect( ( select ) => + select( CORE_SITE ).getErrorTroubleshootingLinkURL( { + code: setupErrorCode, + } ) + ); + + let title = __( 'Error connecting Site Kit', 'google-site-kit' ); + let ctaLabel = __( 'Redo the plugin setup', 'google-site-kit' ); + + if ( setupErrorCode === 'access_denied' ) { + title = __( 'Permissions Error', 'google-site-kit' ); + + if ( temporaryPersistedPermissionsError?.data ) { + ctaLabel = __( 'Grant permission', 'google-site-kit' ); + } else if ( + ! temporaryPersistedPermissionsError?.data && + isAuthenticated + ) { + ctaLabel = null; + } + } + + const gaTrackingProps = { + gaTrackingEventArgs: { category: `${ viewContext }_setup_error` }, + }; + + return ( + + + } + /> + } + actions={ + setupErrorRedoURL && ( + + ) + } + /> + + ); +} diff --git a/assets/js/components/notifications/ErrorNotifications.stories.js b/assets/js/components/notifications/SetupErrorMessageNotification.stories.js similarity index 87% rename from assets/js/components/notifications/ErrorNotifications.stories.js rename to assets/js/components/notifications/SetupErrorMessageNotification.stories.js index c8dc7e9e570..7adb94d830e 100644 --- a/assets/js/components/notifications/ErrorNotifications.stories.js +++ b/assets/js/components/notifications/SetupErrorMessageNotification.stories.js @@ -1,7 +1,7 @@ /** - * ErrorNotifications Component Stories. + * SetupErrorMessageNotification Component Stories. * - * Site Kit by Google, Copyright 2023 Google LLC + * Site Kit by Google, Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,6 @@ /** * Internal dependencies */ -import ErrorNotifications from './ErrorNotifications'; import WithRegistrySetup from '../../../../tests/js/WithRegistrySetup'; import { provideSiteInfo, @@ -27,15 +26,15 @@ import { } from '../../../../tests/js/utils'; import { FORM_TEMPORARY_PERSIST_PERMISSION_ERROR } from '../../googlesitekit/datastore/user/constants'; import { CORE_FORMS } from '../../googlesitekit/datastore/forms/constants'; -import { Provider as ViewContextProvider } from '../Root/ViewContextContext'; -import { VIEW_CONTEXT_MAIN_DASHBOARD } from '../../googlesitekit/constants'; +import { withNotificationComponentProps } from '../../googlesitekit/notifications/util/component-props'; +import SetupErrorMessageNotification from './SetupErrorMessageNotification'; -function Template( { ...args } ) { - return ( - - - - ); +const NotificationWithComponentProps = withNotificationComponentProps( + 'setup_plugin_error' +)( SetupErrorMessageNotification ); + +function Template() { + return ; } export const PluginSetupError = Template.bind( {} ); diff --git a/assets/js/components/notifications/SetupErrorMessageNotification.test.js b/assets/js/components/notifications/SetupErrorMessageNotification.test.js new file mode 100644 index 00000000000..5d0f155228e --- /dev/null +++ b/assets/js/components/notifications/SetupErrorMessageNotification.test.js @@ -0,0 +1,68 @@ +/** + * SetupErrorMessageNotification component tests. + * + * Site Kit by Google, Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ +import { + createTestRegistry, + provideSiteInfo, +} from '../../../../tests/js/test-utils'; +import { VIEW_CONTEXT_MAIN_DASHBOARD } from '../../googlesitekit/constants'; +import { DEFAULT_NOTIFICATIONS } from '../../googlesitekit/notifications/register-defaults'; + +const SETUP_ERROR_NOTIFICATION = 'setup_plugin_error'; + +describe( 'SetupErrorMessageNotification', () => { + let registry; + + const notification = DEFAULT_NOTIFICATIONS[ SETUP_ERROR_NOTIFICATION ]; + + beforeEach( () => { + registry = createTestRegistry(); + } ); + + describe( 'checkRequirements', () => { + it( 'is active', async () => { + provideSiteInfo( registry, { + setupErrorRedoURL: '#', + setupErrorCode: 'access_denied', + setupErrorMessage: + 'Setup was interrupted because you did not grant the necessary permissions', + } ); + + const isActive = await notification.checkRequirements( + registry, + VIEW_CONTEXT_MAIN_DASHBOARD + ); + + expect( isActive ).toBe( true ); + } ); + + it( 'is not active when there is no setup error', async () => { + provideSiteInfo( registry ); + + const isActive = await notification.checkRequirements( + registry, + VIEW_CONTEXT_MAIN_DASHBOARD + ); + + expect( isActive ).toBe( false ); + } ); + } ); +} ); diff --git a/assets/js/components/notifications/SetupErrorNotification.stories.js b/assets/js/components/notifications/SetupErrorNotification.stories.js index c5c2758f33a..cf13b7d7b70 100644 --- a/assets/js/components/notifications/SetupErrorNotification.stories.js +++ b/assets/js/components/notifications/SetupErrorNotification.stories.js @@ -27,8 +27,10 @@ import { import { withNotificationComponentProps } from '../../googlesitekit/notifications/util/component-props'; import SetupErrorNotification from './SetupErrorNotification'; +const NOTIFICATION_ID = 'setup_plugin_error'; + const NotificationWithComponentProps = withNotificationComponentProps( - 'setup_error' + NOTIFICATION_ID )( SetupErrorNotification ); function Template() { diff --git a/assets/js/components/notifications/SubtleNotification.js b/assets/js/components/notifications/SubtleNotification.js index 292321e1f03..5f75ae557b7 100644 --- a/assets/js/components/notifications/SubtleNotification.js +++ b/assets/js/components/notifications/SubtleNotification.js @@ -108,7 +108,7 @@ function SubtleNotification( { } SubtleNotification.propTypes = { - title: PropTypes.string.isRequired, + title: PropTypes.node.isRequired, description: PropTypes.string, Icon: PropTypes.elementType, ctaLink: PropTypes.string, diff --git a/assets/js/components/notifications/__snapshots__/FirstPartyModeSetupSuccessSubtleNotification.test.js.snap b/assets/js/components/notifications/__snapshots__/FirstPartyModeSetupSuccessSubtleNotification.test.js.snap new file mode 100644 index 00000000000..d055c31fd25 --- /dev/null +++ b/assets/js/components/notifications/__snapshots__/FirstPartyModeSetupSuccessSubtleNotification.test.js.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FirstPartyModeSetupSuccessSubtleNotification should render correctly 1`] = ` +

+
+
+
+
+
+ +
+
+

+ You successfully enabled First-party mode! +

+

+ You can always disable it in Analytics or Ads settings +

+
+
+ +
+
+
+
+
+
+`; diff --git a/assets/js/components/settings/SettingsCardKeyMetrics.test.js b/assets/js/components/settings/SettingsCardKeyMetrics.test.js index 67f12a3b0bf..b9abcac10f7 100644 --- a/assets/js/components/settings/SettingsCardKeyMetrics.test.js +++ b/assets/js/components/settings/SettingsCardKeyMetrics.test.js @@ -129,11 +129,6 @@ describe( 'SettingsCardKeyMetrics', () => { .dispatch( CORE_USER ) .receiveIsUserInputCompleted( true ); - registry.dispatch( CORE_USER ).receiveGetKeyMetricsSettings( { - widgetSlugs: [], - includeConversionTailoredMetrics: [ 'contact' ], - } ); - const { container, waitForRegistry } = render( , { diff --git a/assets/js/components/settings/SettingsStatuses.js b/assets/js/components/settings/SettingsStatuses.js index dc90470d1d5..81380fa9948 100644 --- a/assets/js/components/settings/SettingsStatuses.js +++ b/assets/js/components/settings/SettingsStatuses.js @@ -21,11 +21,38 @@ */ import PropTypes from 'prop-types'; +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { ProgressBar } from 'googlesitekit-components'; + export default function SettingsStatuses( { statuses } ) { if ( ! statuses || statuses.length === 0 ) { return null; } + function renderStatus( status ) { + if ( status === undefined ) { + return ( +
+ +
+ ); + } + return ( +

+ { status + ? __( 'Enabled', 'google-site-kit' ) + : __( 'Disabled', 'google-site-kit' ) } +

+ ); + } + return (
{ statuses.map( ( { label, status } ) => ( @@ -36,9 +63,7 @@ export default function SettingsStatuses( { statuses } ) {
{ label }
-

- { status !== undefined && status } -

+ { renderStatus( status ) }
) ) } @@ -49,7 +74,7 @@ SettingsStatuses.propTypes = { statuses: PropTypes.arrayOf( PropTypes.shape( { label: PropTypes.string.isRequired, - status: PropTypes.string, + status: PropTypes.oneOf( [ undefined, true, false ] ), } ) ), }; diff --git a/assets/js/components/settings/SettingsStatuses.stories.js b/assets/js/components/settings/SettingsStatuses.stories.js index b0cc1d13b33..6be23f8b206 100644 --- a/assets/js/components/settings/SettingsStatuses.stories.js +++ b/assets/js/components/settings/SettingsStatuses.stories.js @@ -37,11 +37,26 @@ Default.args = { statuses: [ { label: 'Label 1', - status: 'Enabled', + status: true, }, { label: 'Label 2', - status: 'Disabled', + status: false, + }, + ], +}; + +export const LoadingValue = Template.bind( null ); +LoadingValue.storyName = 'Loading Value'; +LoadingValue.args = { + statuses: [ + { + label: 'Label 1', + status: undefined, + }, + { + label: 'Label 2', + status: false, }, ], }; diff --git a/assets/js/components/setup/SetupUsingProxyWithSignIn.js b/assets/js/components/setup/SetupUsingProxyWithSignIn.js index 1a86ce8a4ce..1ba468063ee 100644 --- a/assets/js/components/setup/SetupUsingProxyWithSignIn.js +++ b/assets/js/components/setup/SetupUsingProxyWithSignIn.js @@ -29,7 +29,7 @@ import { Fragment, useCallback, } from '@wordpress/element'; -import { __, sprintf } from '@wordpress/i18n'; +import { __, _x, sprintf } from '@wordpress/i18n'; import { getQueryArg, addQueryArgs } from '@wordpress/url'; /** @@ -225,8 +225,12 @@ export default function SetupUsingProxyWithSignIn() { __( 'You revoked access to Site Kit for %s', 'google-site-kit' ), punycode.toUnicode( new URL( siteURL ).hostname ) ); + // Note: This is referencing a button labelled "Sign in with Google" + // in the Site Kit UI, not referencing the "Sign in with Google" service. + // + // Do not use `_x( 'Sign in with Google', 'Service name', 'google-site-kit' )` for Sign in with Google text here. description = __( - 'Site Kit will no longer have access to your account. If you’d like to reconnect Site Kit, click "Sign in with Google" below to generate new credentials.', + 'Site Kit will no longer have access to your account. If you’d like to reconnect Site Kit, click “Sign in with Google“ below to generate new credentials.', 'google-site-kit' ); } else if ( @@ -406,8 +410,9 @@ export default function SetupUsingProxyWithSignIn() { ! complete } > - { __( + { _x( 'Sign in with Google', + 'Prompt to authenticate Site Kit with Google Account', 'google-site-kit' ) } diff --git a/assets/js/components/user-input/UserInputQuestionnaire.js b/assets/js/components/user-input/UserInputQuestionnaire.js index 74d65d3fae7..0f400904d47 100644 --- a/assets/js/components/user-input/UserInputQuestionnaire.js +++ b/assets/js/components/user-input/UserInputQuestionnaire.js @@ -174,27 +174,23 @@ export default function UserInputQuestionnaire() { ).getUserInputPurposeConversionEvents(); } ); - const { setKeyMetricsSetting, saveKeyMetricsSettings } = - useDispatch( CORE_USER ); + const { setUserInputSetting } = useDispatch( CORE_USER ); const submitChanges = useCallback( async () => { trackEvent( gaEventCategory, 'summary_submit' ); + if ( isConversionReportingEnabled ) { + // Update 'includeConversionEvents' setting with included conversion events, + // to mark that their respective metrics should be included in the + // list of tailored metrics and persist on the dashboard in case events are lost. + setUserInputSetting( + 'includeConversionEvents', + userInputPurposeConversionEvents + ); + } + const response = await saveUserInputSettings(); if ( ! response.error ) { - if ( isConversionReportingEnabled ) { - // Update 'includeConversionTailoredMetrics' key metrics setting with included - //conversion events, to mark that their respective metrics should be included in the - // list of tailored metrics and persist on the dashboard in case events are lost. - await setKeyMetricsSetting( - 'includeConversionTailoredMetrics', - userInputPurposeConversionEvents - ); - await saveKeyMetricsSettings( { - widgetSlugs: undefined, - } ); - } - if ( !! userPickedMetrics ) { await resetKeyMetricsSelection(); } @@ -206,8 +202,7 @@ export default function UserInputQuestionnaire() { saveUserInputSettings, userInputPurposeConversionEvents, dashboardURL, - setKeyMetricsSetting, - saveKeyMetricsSettings, + setUserInputSetting, navigateTo, isConversionReportingEnabled, userPickedMetrics, diff --git a/assets/js/googlesitekit/datastore/site/consent-mode.js b/assets/js/googlesitekit/datastore/site/consent-mode.js index 51ac9ce8ee0..70b60cb165d 100644 --- a/assets/js/googlesitekit/datastore/site/consent-mode.js +++ b/assets/js/googlesitekit/datastore/site/consent-mode.js @@ -348,7 +348,7 @@ const baseSelectors = { * * @since 1.124.0 * @since 1.125.0 Updated to consider Ads connection status via the Analytics tag config, and to source Conversion ID field from Ads module. - * @since n.e.x.t Updated to a simple selector which returns value from the state. + * @since 1.142.0 Updated to a simple selector which returns value from the state. * * @param {Object} state Data store's state. * @return {boolean|undefined} True if Google Ads is in use, false otherwise. Undefined if the selectors have not loaded. diff --git a/assets/js/googlesitekit/datastore/site/first-party-mode.js b/assets/js/googlesitekit/datastore/site/first-party-mode.js index c5f5b2ab06f..fad6cf99e81 100644 --- a/assets/js/googlesitekit/datastore/site/first-party-mode.js +++ b/assets/js/googlesitekit/datastore/site/first-party-mode.js @@ -34,6 +34,8 @@ import { } from 'googlesitekit-data'; import { CORE_SITE } from './constants'; import { createFetchStore } from '../../data/create-fetch-store'; +import { isFeatureEnabled } from '../../../features'; +import { CORE_MODULES } from '../../modules/datastore/constants'; const SET_FIRST_PARTY_MODE_ENABLED = 'SET_FIRST_PARTY_MODE_ENABLED'; const RESET_FIRST_PARTY_MODE_SETTINGS = 'RESET_FIRST_PARTY_MODE_SETTINGS'; @@ -131,7 +133,7 @@ const baseActions = { /** * Returns the current settings back to the current saved values. * - * @since n.e.x.t + * @since 1.142.0 * * @return {Object} Redux-style action. */ @@ -194,7 +196,7 @@ const baseSelectors = { * @since 1.141.0 * * @param {Object} state Data store's state. - * @return {boolean|null|undefined} True if first-party mode is enabled, otherwise false. Returns undefined if the state is not loaded. + * @return {boolean|undefined} True if first-party mode is enabled, otherwise false. Returns undefined if the state is not loaded. */ isFirstPartyModeEnabled: createRegistrySelector( ( select ) => () => { const { isEnabled } = @@ -236,7 +238,7 @@ const baseSelectors = { /** * Indicates whether the current first-party mode settings have changed from what is saved. * - * @since n.e.x.t + * @since 1.142.0 * * @param {Object} state Data store's state. * @return {boolean} True if the settings have changed, false otherwise. @@ -246,6 +248,20 @@ const baseSelectors = { return ! isEqual( firstPartyModeSettings, firstPartyModeSavedSettings ); }, + + isAnyFirstPartyModeModuleConnected: createRegistrySelector( + ( select ) => () => { + if ( ! isFeatureEnabled( 'firstPartyMode' ) ) { + return false; + } + + const { isModuleConnected } = select( CORE_MODULES ); + + return ( + isModuleConnected( 'analytics-4' ) || isModuleConnected( 'ads' ) + ); + } + ), }; const store = combineStores( diff --git a/assets/js/googlesitekit/datastore/site/first-party-mode.test.js b/assets/js/googlesitekit/datastore/site/first-party-mode.test.js index a0feca880df..e4887aeff1d 100644 --- a/assets/js/googlesitekit/datastore/site/first-party-mode.test.js +++ b/assets/js/googlesitekit/datastore/site/first-party-mode.test.js @@ -26,7 +26,7 @@ import { } from '../../../../../tests/js/utils'; import { CORE_SITE } from './constants'; -describe( 'core/site First-Party Mode', () => { +describe( 'core/site First-party Mode', () => { let registry; const firstPartyModeSettingsEndpointRegExp = new RegExp( diff --git a/assets/js/googlesitekit/datastore/site/info.js b/assets/js/googlesitekit/datastore/site/info.js index 66bc846d940..5f94f60139c 100644 --- a/assets/js/googlesitekit/datastore/site/info.js +++ b/assets/js/googlesitekit/datastore/site/info.js @@ -186,6 +186,7 @@ export const reducer = ( state, { payload, type } ) => { keyMetricsSetupNew, consentModeRegions, anyoneCanRegister, + isMultisite, } = payload.siteInfo; return { @@ -221,6 +222,7 @@ export const reducer = ( state, { payload, type } ) => { keyMetricsSetupNew, consentModeRegions, anyoneCanRegister, + isMultisite, }, }; } @@ -311,6 +313,7 @@ export const resolvers = { keyMetricsSetupNew, consentModeRegions, anyoneCanRegister, + isMultisite, } = global._googlesitekitBaseData; const { @@ -351,6 +354,7 @@ export const resolvers = { keyMetricsSetupNew, consentModeRegions, anyoneCanRegister, + isMultisite, } ); }, }; @@ -905,6 +909,16 @@ export const selectors = { * @return {boolean|undefined} `true` if registrations are open; `false` if not. Returns `undefined` if not yet loaded. */ getAnyoneCanRegister: getSiteInfoProperty( 'anyoneCanRegister' ), + + /** + * Checks if WordPress site is running in the multisite mode. + * + * @since 1.142.0 + * + * @param {Object} state Data store's state. + * @return {boolean|undefined} `true` if it is multisite; `false` if not. Returns `undefined` if not yet loaded. + */ + isMultisite: getSiteInfoProperty( 'isMultisite' ), }; export default { diff --git a/assets/js/googlesitekit/datastore/site/info.test.js b/assets/js/googlesitekit/datastore/site/info.test.js index 55229fca221..e387375a87d 100644 --- a/assets/js/googlesitekit/datastore/site/info.test.js +++ b/assets/js/googlesitekit/datastore/site/info.test.js @@ -49,6 +49,7 @@ describe( 'core/site site info', () => { }, ], productPostType: [ 'product' ], + isMultisite: false, }; const entityInfoVar = '_googlesitekitEntityData'; const entityInfo = { @@ -411,6 +412,7 @@ describe( 'core/site site info', () => { [ 'getProductPostType', 'productPostType' ], [ 'isKeyMetricsSetupCompleted', 'keyMetricsSetupCompletedBy' ], [ 'getConsentModeRegions', 'consentModeRegions' ], + [ 'isMultisite', 'isMultisite' ], ] )( '%s', ( selector, infoKey ) => { it( 'uses a resolver to load site info then returns the info when this specific selector is used', async () => { global[ baseInfoVar ] = baseInfo; diff --git a/assets/js/googlesitekit/datastore/user/key-metrics.js b/assets/js/googlesitekit/datastore/user/key-metrics.js index a779ce1675b..3b24e8c6244 100644 --- a/assets/js/googlesitekit/datastore/user/key-metrics.js +++ b/assets/js/googlesitekit/datastore/user/key-metrics.js @@ -363,18 +363,15 @@ const baseSelectors = { const hasProductPostType = postTypes.some( ( { slug } ) => slug === 'product' ); - const keyMetricSettings = - select( CORE_USER ).getKeyMetricsSettings(); + const userInputSettings = + select( CORE_USER ).getUserInputSettings(); const showConversionTailoredMetrics = ( events ) => { return events.some( ( event ) => - ( Array.isArray( - keyMetricSettings?.includeConversionTailoredMetrics - ) && - keyMetricSettings?.includeConversionTailoredMetrics?.includes( - event - ) ) || + userInputSettings?.includeConversionEvents?.values?.includes( + event + ) || ( Array.isArray( includeConversionTailoredMetrics ) && includeConversionTailoredMetrics?.includes( event diff --git a/assets/js/googlesitekit/datastore/user/key-metrics.test.js b/assets/js/googlesitekit/datastore/user/key-metrics.test.js index a2bde3d5dc3..5d2d0e94280 100644 --- a/assets/js/googlesitekit/datastore/user/key-metrics.test.js +++ b/assets/js/googlesitekit/datastore/user/key-metrics.test.js @@ -276,12 +276,6 @@ describe( 'core/user key metrics', () => { await registry .dispatch( CORE_USER ) .receiveIsUserInputCompleted( false ); - await registry - .dispatch( CORE_USER ) - .receiveGetKeyMetricsSettings( { - widgetSlugs: [], - includeConversionTailoredMetrics: [], - } ); } ); it( 'should return undefined if user input settings are not resolved', async () => { @@ -514,7 +508,7 @@ describe( 'core/user key metrics', () => { ], ] )( 'should return the correct metrics for the %s purpose when conversionReporting is enabled', - async ( + ( purpose, expectedMetricsIncludingConversionTailored, conversionEvents @@ -533,6 +527,10 @@ describe( 'core/user key metrics', () => { .dispatch( CORE_USER ) .receiveGetUserInputSettings( { purpose: { values: [ purpose ] }, + includeConversionEvents: { + values: [ 'contact' ], + scope: 'site', + }, } ); registry @@ -551,20 +549,21 @@ describe( 'core/user key metrics', () => { } // Conversion Tailored Metrics should be included in the list if the - // includeConversionTailoredMetrics contains their respective conversion reporting events. - await registry + // includeConversionEvents contains their respective conversion reporting events. + registry .dispatch( CORE_USER ) - .receiveGetKeyMetricsSettings( { - widgetSlugs: [], - includeConversionTailoredMetrics: conversionEvents, + .receiveGetUserInputSettings( { + purpose: { values: [ purpose ] }, + includeConversionEvents: { + values: conversionEvents, + scope: 'site', + }, } ); expect( - registry.select( CORE_USER ).getKeyMetricsSettings() - ).toEqual( { - widgetSlugs: [], - includeConversionTailoredMetrics: conversionEvents, - } ); + registry.select( CORE_USER ).getUserInputSettings() + ?.includeConversionEvents?.values + ).toEqual( conversionEvents ); expect( registry.select( CORE_USER ).getAnswerBasedMetrics() diff --git a/assets/js/googlesitekit/notifications/components/Notification/index.js b/assets/js/googlesitekit/notifications/components/Notification/index.js index 210210c7985..38d9ff657c8 100644 --- a/assets/js/googlesitekit/notifications/components/Notification/index.js +++ b/assets/js/googlesitekit/notifications/components/Notification/index.js @@ -39,7 +39,10 @@ export default function Notification( { } ) { const ref = useRef(); const viewed = useHasBeenViewed( id ); - const trackEvents = useNotificationEvents( id ); + const trackEvents = useNotificationEvents( + id, + gaTrackingEventArgs?.category + ); const [ isViewedOnce, setIsViewedOnce ] = useState( false ); diff --git a/assets/js/googlesitekit/notifications/components/common/Dismiss.js b/assets/js/googlesitekit/notifications/components/common/Dismiss.js index 7b2910599da..f749b4583f4 100644 --- a/assets/js/googlesitekit/notifications/components/common/Dismiss.js +++ b/assets/js/googlesitekit/notifications/components/common/Dismiss.js @@ -42,7 +42,10 @@ export default function Dismiss( { gaTrackingEventArgs, dismissOptions, } ) { - const trackEvents = useNotificationEvents( id ); + const trackEvents = useNotificationEvents( + id, + gaTrackingEventArgs?.category + ); const { dismissNotification } = useDispatch( CORE_NOTIFICATIONS ); diff --git a/assets/js/googlesitekit/notifications/components/layout/SubtleNotification.js b/assets/js/googlesitekit/notifications/components/layout/SubtleNotification.js index 0549005ef16..d8fa827c76b 100644 --- a/assets/js/googlesitekit/notifications/components/layout/SubtleNotification.js +++ b/assets/js/googlesitekit/notifications/components/layout/SubtleNotification.js @@ -84,7 +84,7 @@ export default function SubtleNotification( { SubtleNotification.propTypes = { className: PropTypes.string, - title: PropTypes.string.isRequired, + title: PropTypes.node.isRequired, description: PropTypes.node, dismissCTA: PropTypes.node, additionalCTA: PropTypes.node, diff --git a/assets/js/googlesitekit/notifications/constants.js b/assets/js/googlesitekit/notifications/constants.js new file mode 100644 index 00000000000..f7fed08ea66 --- /dev/null +++ b/assets/js/googlesitekit/notifications/constants.js @@ -0,0 +1,20 @@ +/** + * Notifications API constants. + * + * Site Kit by Google, Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const FPM_SETUP_CTA_BANNER_NOTIFICATION = + 'first-party-mode-setup-cta-banner'; diff --git a/assets/js/googlesitekit/notifications/datastore/constants.js b/assets/js/googlesitekit/notifications/datastore/constants.js index a60ab84f9a3..c8fff22ed85 100644 --- a/assets/js/googlesitekit/notifications/datastore/constants.js +++ b/assets/js/googlesitekit/notifications/datastore/constants.js @@ -42,3 +42,6 @@ export const NOTIFICATION_VIEW_CONTEXTS = [ VIEW_CONTEXT_MAIN_DASHBOARD_VIEW_ONLY, VIEW_CONTEXT_ENTITY_DASHBOARD_VIEW_ONLY, ]; + +export const FPM_HEALTH_CHECK_WARNING_NOTIFICATION_ID = + 'fpm-warning-notification'; diff --git a/assets/js/googlesitekit/notifications/datastore/notifications.js b/assets/js/googlesitekit/notifications/datastore/notifications.js index c434c50e643..3176e087fc4 100644 --- a/assets/js/googlesitekit/notifications/datastore/notifications.js +++ b/assets/js/googlesitekit/notifications/datastore/notifications.js @@ -24,7 +24,11 @@ import invariant from 'invariant'; /** * Internal dependencies */ -import { commonActions, createRegistrySelector } from 'googlesitekit-data'; +import { + commonActions, + createRegistryControl, + createRegistrySelector, +} from 'googlesitekit-data'; import { createReducer } from '../../../../js/googlesitekit/data/create-reducer'; import { CORE_NOTIFICATIONS, @@ -34,10 +38,15 @@ import { } from './constants'; import { CORE_USER } from '../../datastore/user/constants'; import { createValidatedAction } from '../../data/utils'; +import { racePrioritizedAsyncTasks } from '../../../util/async'; const REGISTER_NOTIFICATION = 'REGISTER_NOTIFICATION'; const RECEIVE_QUEUED_NOTIFICATIONS = 'RECEIVE_QUEUED_NOTIFICATIONS'; const DISMISS_NOTIFICATION = 'DISMISS_NOTIFICATION'; +const QUEUE_NOTIFICATION = 'QUEUE_NOTIFICATION'; +const RESET_QUEUE = 'RESET_QUEUE'; +// Controls. +const POPULATE_QUEUE = 'POPULATE_QUEUE'; export const initialState = { notifications: {}, @@ -125,6 +134,51 @@ export const actions = { type: RECEIVE_QUEUED_NOTIFICATIONS, }; }, + /** + * Resets a notification queue. + * + * @since 1.142.0 + * + * @param {string?} groupID Group ID of queue to reset. Default: default. + * @return {Object} Redux-style action. + */ + resetQueue( groupID = NOTIFICATION_GROUPS.DEFAULT ) { + return { type: RESET_QUEUE, payload: { groupID } }; + }, + /** + * Populates a queue with qualifying notifications ordered by priority. + * + * @since 1.142.0 + * + * @param {string} viewContext View context to populate queue for. + * @param {string?} groupID Group ID of queue to populate. Default: default. + * @yield {Object} Redux-style action. + */ + *populateQueue( viewContext, groupID = NOTIFICATION_GROUPS.DEFAULT ) { + yield { + type: POPULATE_QUEUE, + payload: { + viewContext, + groupID, + }, + }; + }, + /** + * Adds the given notification to its respective queue. + * + * @since 1.142.0 + * + * @param {Object} notification Notification definition. + * @return {Object} Redux-style action. + */ + queueNotification( notification ) { + return { + payload: { + notification, + }, + type: QUEUE_NOTIFICATION, + }; + }, /** * Dismisses the given notification by its id. * @@ -181,7 +235,59 @@ export const actions = { ), }; -export const controls = {}; +export const controls = { + [ POPULATE_QUEUE ]: createRegistryControl( + ( registry ) => + async ( { payload } ) => { + const { viewContext, groupID } = payload; + const { isNotificationDismissed } = + registry.select( CORE_NOTIFICATIONS ); + const notifications = registry + .select( CORE_NOTIFICATIONS ) + .getNotifications(); + + // Wait for all dismissed items to be available before filtering. + await registry.resolveSelect( CORE_USER ).getDismissedItems(); + + let potentialNotifications = Object.values( notifications ) + .filter( + ( notification ) => notification.groupID === groupID + ) + .filter( ( notification ) => + notification.viewContexts.includes( viewContext ) + ) + .filter( ( { isDismissible, id } ) => + isDismissible ? ! isNotificationDismissed( id ) : true + ) + .map( ( { checkRequirements, ...notification } ) => ( { + ...notification, + checkRequirements, + async check() { + if ( checkRequirements ) { + return await checkRequirements( registry ); + } + return true; + }, + } ) ); + + const { queueNotification } = + registry.dispatch( CORE_NOTIFICATIONS ); + + let nextNotification; + do { + nextNotification = await racePrioritizedAsyncTasks( + potentialNotifications + ); + if ( nextNotification ) { + queueNotification( nextNotification ); + potentialNotifications = potentialNotifications.filter( + ( n ) => n !== nextNotification + ); + } + } while ( nextNotification ); + } + ), +}; export const reducer = createReducer( ( state, { type, payload } ) => { switch ( type ) { @@ -205,6 +311,19 @@ export const reducer = createReducer( ( state, { type, payload } ) => { break; } + case RESET_QUEUE: { + state.queuedNotifications[ payload.groupID ] = []; + break; + } + + case QUEUE_NOTIFICATION: { + const { groupID } = payload.notification; + state.queuedNotifications[ groupID ] = + state.queuedNotifications[ groupID ] || []; + state.queuedNotifications[ groupID ].push( payload.notification ); + break; + } + case DISMISS_NOTIFICATION: { const { id } = payload; @@ -233,71 +352,8 @@ export const resolvers = { viewContext, groupID = NOTIFICATION_GROUPS.DEFAULT ) { - const registry = yield commonActions.getRegistry(); - - const notifications = registry - .select( CORE_NOTIFICATIONS ) - .getNotifications(); - - // Wait for all dismissed items to be available before filtering. - yield commonActions.await( - registry.resolveSelect( CORE_USER ).getDismissedItems() - ); - - const filteredNotifications = Object.values( notifications ).filter( - ( notification ) => { - if ( notification.groupID !== groupID ) { - return false; - } - - if ( ! notification.viewContexts.includes( viewContext ) ) { - return false; - } - - if ( - !! notification.isDismissible && - registry - .select( CORE_NOTIFICATIONS ) - .isNotificationDismissed( notification.id ) - ) { - return false; - } - - return true; - } - ); - - const checkRequirementsResults = yield commonActions.await( - Promise.all( - filteredNotifications.map( async ( { checkRequirements } ) => { - if ( typeof checkRequirements === 'function' ) { - try { - return await checkRequirements( - registry, - viewContext - ); - } catch ( e ) { - return false; // Prevent `Promise.all()` from being rejected for a single failed promise. - } - } - - return true; - } ) - ) - ); - - const queuedNotifications = filteredNotifications.filter( - ( _, i ) => !! checkRequirementsResults[ i ] - ); - - queuedNotifications.sort( ( a, b ) => { - return a.priority - b.priority; - } ); - - yield actions.receiveQueuedNotifications( - queuedNotifications, - groupID - ); + yield actions.resetQueue( groupID ); + yield actions.populateQueue( viewContext, groupID ); }, }; diff --git a/assets/js/googlesitekit/notifications/datastore/notifications.test.js b/assets/js/googlesitekit/notifications/datastore/notifications.test.js index 13e29d4c0d4..5834c641977 100644 --- a/assets/js/googlesitekit/notifications/datastore/notifications.test.js +++ b/assets/js/googlesitekit/notifications/datastore/notifications.test.js @@ -45,10 +45,12 @@ describe( 'core/notifications Notifications', () => { let registry; let store; + let registerNotification; beforeEach( () => { registry = createTestRegistry(); store = registry.stores[ CORE_NOTIFICATIONS ].store; + ( { registerNotification } = registry.dispatch( CORE_NOTIFICATIONS ) ); } ); describe( 'actions', () => { @@ -59,49 +61,39 @@ describe( 'core/notifications Notifications', () => { } it( 'should require a Component to be provided', () => { - expect( () => - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( id, {} ) - ).toThrow( + expect( () => registerNotification( id, {} ) ).toThrow( 'Component is required to register a notification.' ); } ); it( 'should require a valid areaSlug to be provided', () => { expect( () => - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( id, { - Component: TestNotificationComponent, - areaSlug: 'some-random-area', - } ) + registerNotification( id, { + Component: TestNotificationComponent, + areaSlug: 'some-random-area', + } ) ).toThrow( 'Notification area should be one of:' ); } ); it( 'should require a valid array of view contexts to be provided', () => { expect( () => - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( id, { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ 'some-random-view-context' ], - } ) + registerNotification( id, { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ 'some-random-view-context' ], + } ) ).toThrow( 'Notification view context should be one of:' ); } ); it( 'should register the notification with the given settings and component', () => { - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( id, { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - priority: 11, - checkRequirements: () => true, - isDismissible: false, - } ); + registerNotification( id, { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + priority: 11, + checkRequirements: () => true, + isDismissible: false, + } ); const { notifications } = store.getState(); @@ -133,21 +125,17 @@ describe( 'core/notifications Notifications', () => { function NotificationOneRedone() { return
Goodbye you!
; } - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( id, { - Component: NotificationOne, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - } ); + registerNotification( id, { + Component: NotificationOne, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( id, { - Component: NotificationOneRedone, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - } ); + registerNotification( id, { + Component: NotificationOneRedone, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + } ); expect( console ).toHaveWarnedWith( `Could not register notification with ID "${ id }". Notification "${ id }" is already registered.` ); @@ -158,43 +146,36 @@ describe( 'core/notifications Notifications', () => { ); } ); } ); + describe( 'dismissNotification', () => { function TestNotificationComponent() { return
Test notification!
; } + let dismissNotification; beforeEach( () => { + ( { dismissNotification } = + registry.dispatch( CORE_NOTIFICATIONS ) ); // dismissNotification checks for a registered notification's isDismissible property. - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'test-notification', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ 'mainDashboard' ], - isDismissible: true, - } ); + registerNotification( 'test-notification', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + isDismissible: true, + } ); } ); + it( 'should require a valid id to be provided', () => { - expect( () => - registry - .dispatch( CORE_NOTIFICATIONS ) - .dismissNotification() - ).toThrow( + expect( () => dismissNotification() ).toThrow( 'A notification id is required to dismiss a notification.' ); } ); + it( 'should dismiss a notification without a given expiry time', async () => { fetchMock.postOnce( fetchDismissItem, { body: [ 'test-notification' ], } ); - await registry - .dispatch( CORE_NOTIFICATIONS ) - .receiveQueuedNotifications( [ - { id: 'test-notification' }, - ] ); - await registry - .dispatch( CORE_NOTIFICATIONS ) - .dismissNotification( 'test-notification' ); + await dismissNotification( 'test-notification' ); // Ensure the proper body parameters were sent. expect( fetchMock ).toHaveFetched( fetchDismissItem, { @@ -209,26 +190,19 @@ describe( 'core/notifications Notifications', () => { const isNotificationDismissed = registry .select( CORE_NOTIFICATIONS ) .isNotificationDismissed( 'test-notification' ); - expect( isNotificationDismissed ).toBe( true ); + expect( isNotificationDismissed ).toBe( true ); expect( fetchMock ).toHaveFetchedTimes( 1 ); } ); + it( 'should dismiss a notification with a given expiry time', async () => { fetchMock.postOnce( fetchDismissItem, { body: [ 'test-notification' ], } ); - await registry - .dispatch( CORE_NOTIFICATIONS ) - .receiveQueuedNotifications( [ - { id: 'test-notification' }, - ] ); - - await registry - .dispatch( CORE_NOTIFICATIONS ) - .dismissNotification( 'test-notification', { - expiresInSeconds: 3, - } ); + await dismissNotification( 'test-notification', { + expiresInSeconds: 3, + } ); // Ensure the proper body parameters were sent. expect( fetchMock ).toHaveFetched( fetchDismissItem, { @@ -247,51 +221,34 @@ describe( 'core/notifications Notifications', () => { expect( fetchMock ).toHaveFetchedTimes( 1 ); } ); + it( 'should not persist dismissal if notification is not dismissible', async () => { // dismissNotification checks for a registered notification's isDismissible property. - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'not-dismissible-notification', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ 'mainDashboard' ], - isDismissible: false, - } ); - - await registry - .dispatch( CORE_NOTIFICATIONS ) - .receiveQueuedNotifications( [ - { id: 'not-dismissible-notification' }, - ] ); - - await registry - .dispatch( CORE_NOTIFICATIONS ) - .dismissNotification( 'not-dismissible-notification', { - expiresInSeconds: 3, - } ); + registerNotification( 'not-dismissible-notification', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + isDismissible: false, + } ); - expect( fetchMock ).not.toHaveFetched( fetchDismissItem, { - body: { - data: { - slug: 'not-dismissible-notification', - expiration: 3, - }, - }, + await dismissNotification( 'not-dismissible-notification', { + expiresInSeconds: 3, } ); + + expect( fetchMock ).not.toHaveFetched(); } ); + it( 'should persist dismissal if notification is dismissible', async () => { fetchMock.postOnce( fetchDismissItem, { body: [ 'dismissible-notification' ], } ); // dismissNotification checks for a registered notification's isDismissible property. - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'dismissible-notification', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ 'mainDashboard' ], - isDismissible: true, - } ); + registerNotification( 'dismissible-notification', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + isDismissible: true, + } ); await registry .dispatch( CORE_NOTIFICATIONS ) @@ -299,11 +256,9 @@ describe( 'core/notifications Notifications', () => { { id: 'dismissible-notification' }, ] ); - await registry - .dispatch( CORE_NOTIFICATIONS ) - .dismissNotification( 'dismissible-notification', { - expiresInSeconds: 3, - } ); + await dismissNotification( 'dismissible-notification', { + expiresInSeconds: 3, + } ); expect( fetchMock ).toHaveFetched( fetchDismissItem, { body: { @@ -314,57 +269,61 @@ describe( 'core/notifications Notifications', () => { }, } ); } ); + it( 'should remove a notification from queue if skipHidingFromQueue option is not passed', async () => { fetchMock.postOnce( fetchDismissItem, { body: [ 'test-notification' ], } ); - await registry - .dispatch( CORE_NOTIFICATIONS ) - .receiveQueuedNotifications( [ - { id: 'test-notification' }, - ] ); + registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] ); - await registry - .dispatch( CORE_NOTIFICATIONS ) - .dismissNotification( 'test-notification', { - expiresInSeconds: 3, - } ); + let queuedNotifications = await registry + .resolveSelect( CORE_NOTIFICATIONS ) + .getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); - const queuedNotifications = registry - .select( CORE_NOTIFICATIONS ) - .getQueuedNotifications( [ VIEW_CONTEXT_MAIN_DASHBOARD ] ); + expect( queuedNotifications.map( ( { id } ) => id ) ).toContain( + 'test-notification' + ); - expect( queuedNotifications ).toEqual( [] ); + await dismissNotification( 'test-notification' ); + + queuedNotifications = await registry + .resolveSelect( CORE_NOTIFICATIONS ) + .getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); + + expect( + queuedNotifications.map( ( { id } ) => id ) + ).not.toContain( 'test-notification' ); expect( fetchMock ).toHaveFetchedTimes( 1 ); } ); + it( 'should not remove a notification from queue if skipHidingFromQueue option is passed', async () => { fetchMock.postOnce( fetchDismissItem, { body: [ 'test-notification' ], } ); - await registry - .dispatch( CORE_NOTIFICATIONS ) - .receiveQueuedNotifications( [ - { id: 'test-notification' }, - ] ); + registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] ); - await registry - .dispatch( CORE_NOTIFICATIONS ) - .dismissNotification( 'test-notification', { - expiresInSeconds: 3, - skipHidingFromQueue: true, - } ); + let queuedNotifications = await registry + .resolveSelect( CORE_NOTIFICATIONS ) + .getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); - const queuedNotifications = registry - .select( CORE_NOTIFICATIONS ) - .getQueuedNotifications( [ VIEW_CONTEXT_MAIN_DASHBOARD ] ); + expect( queuedNotifications.map( ( { id } ) => id ) ).toContain( + 'test-notification' + ); - expect( queuedNotifications ).toEqual( [ - { id: 'test-notification' }, - ] ); + await dismissNotification( 'test-notification', { + skipHidingFromQueue: true, + } ); + + queuedNotifications = await registry + .resolveSelect( CORE_NOTIFICATIONS ) + .getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); + expect( queuedNotifications.map( ( { id } ) => id ) ).toContain( + 'test-notification' + ); expect( fetchMock ).toHaveFetchedTimes( 1 ); } ); } ); @@ -395,116 +354,81 @@ describe( 'core/notifications Notifications', () => { it( 'should return undefined when no notifications have been registered', () => { const queuedNotifications = registry .select( CORE_NOTIFICATIONS ) - .getQueuedNotifications( [ VIEW_CONTEXT_MAIN_DASHBOARD ] ); + .getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); expect( queuedNotifications ).toBeUndefined(); } ); it( 'should return registered notifications for a given viewContext', async () => { - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'test-notification-1', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'test-notification-2', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_ENTITY_DASHBOARD ], - } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'test-notification-3', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ - VIEW_CONTEXT_ENTITY_DASHBOARD, - VIEW_CONTEXT_MAIN_DASHBOARD, - ], - } ); - - expect( - registry - .select( CORE_NOTIFICATIONS ) - .getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ) - ).toBeUndefined(); - - await untilResolved( - registry, - CORE_NOTIFICATIONS - ).getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); + registerNotification( 'test-notification-1', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + } ); + registerNotification( 'test-notification-2', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_ENTITY_DASHBOARD ], + } ); + registerNotification( 'test-notification-3', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ + VIEW_CONTEXT_ENTITY_DASHBOARD, + VIEW_CONTEXT_MAIN_DASHBOARD, + ], + } ); - const queuedNotifications = registry - .select( CORE_NOTIFICATIONS ) + const queuedNotifications = await registry + .resolveSelect( CORE_NOTIFICATIONS ) .getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); - expect( queuedNotifications ).toHaveLength( 2 ); + expect( queuedNotifications.map( ( { id } ) => id ) ).toEqual( + expect.arrayContaining( [ + 'test-notification-1', + 'test-notification-3', + ] ) + ); } ); it( 'should return registered and grouped notifications by their groupID', async () => { - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'default-1', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - priority: 10, - } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'setup-cta-1', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - groupID: NOTIFICATION_GROUPS.SETUP_CTAS, - priority: 20, - } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'default-2', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - priority: 10, - } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'setup-cta-2', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - groupID: NOTIFICATION_GROUPS.SETUP_CTAS, - priority: 20, - } ); + registerNotification( 'default-1', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + priority: 10, + } ); + registerNotification( 'setup-cta-1', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + groupID: NOTIFICATION_GROUPS.SETUP_CTAS, + priority: 20, + } ); + registerNotification( 'default-2', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + priority: 10, + } ); + registerNotification( 'setup-cta-2', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + groupID: NOTIFICATION_GROUPS.SETUP_CTAS, + priority: 20, + } ); - registry - .select( CORE_NOTIFICATIONS ) + const queuedNotifications = await registry + .resolveSelect( CORE_NOTIFICATIONS ) .getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD, NOTIFICATION_GROUPS.SETUP_CTAS ); - await untilResolved( - registry, - CORE_NOTIFICATIONS - ).getQueuedNotifications( - VIEW_CONTEXT_MAIN_DASHBOARD, - NOTIFICATION_GROUPS.SETUP_CTAS + expect( queuedNotifications.map( ( { id } ) => id ) ).toEqual( + expect.arrayContaining( [ 'setup-cta-1', 'setup-cta-2' ] ) ); - - const queuedNotifications = registry - .select( CORE_NOTIFICATIONS ) - .getQueuedNotifications( - VIEW_CONTEXT_MAIN_DASHBOARD, - NOTIFICATION_GROUPS.SETUP_CTAS - ); - - expect( queuedNotifications ).toHaveLength( 2 ); - expect( queuedNotifications[ 0 ].id ).toBe( 'setup-cta-1' ); - expect( queuedNotifications[ 1 ].id ).toBe( 'setup-cta-2' ); } ); it( 'should return notifications filtered by their checkRequirements callback when specified', async () => { @@ -524,206 +448,143 @@ describe( 'core/notifications Notifications', () => { }, } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'check-requirements-true', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - checkRequirements: ( { select } ) => - select( TEST_STORE ).testActiveNotification(), - } ); + registerNotification( 'check-requirements-true', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + checkRequirements: ( { select } ) => + select( TEST_STORE ).testActiveNotification(), + } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'check-requirements-false', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - checkRequirements: ( { select } ) => - select( TEST_STORE ).testInactiveNotification(), - } ); + registerNotification( 'check-requirements-false', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + checkRequirements: ( { select } ) => + select( TEST_STORE ).testInactiveNotification(), + } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'check-requirements-errored-false', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - checkRequirements: ( { select } ) => - select( - TEST_STORE - ).testErroredInactiveNotification(), - } ); + registerNotification( 'check-requirements-errored-false', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + checkRequirements: ( { select } ) => + select( TEST_STORE ).testErroredInactiveNotification(), + } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'check-requirements-undefined', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - } ); + registerNotification( 'check-requirements-undefined', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + } ); - registry - .select( CORE_NOTIFICATIONS ) + const queuedNotifications = await registry + .resolveSelect( CORE_NOTIFICATIONS ) .getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); - await untilResolved( - registry, - CORE_NOTIFICATIONS - ).getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); - - const queuedNotifications = registry - .select( CORE_NOTIFICATIONS ) - .getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); - expect( queuedNotifications ).toHaveLength( 2 ); - expect( queuedNotifications[ 0 ].id ).toBe( - 'check-requirements-true' - ); - expect( queuedNotifications[ 1 ].id ).toBe( - 'check-requirements-undefined' + expect( queuedNotifications.map( ( { id } ) => id ) ).toEqual( + expect.arrayContaining( [ + 'check-requirements-true', + 'check-requirements-undefined', + ] ) ); } ); it( 'should return registered notifications filtered by their dismissal status when specified', async () => { - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( - 'is-dismissible-true-and-dismissed', - { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - isDismissible: true, - } - ); - - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( - 'is-dismissible-true-but-not-dismissed', - { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - isDismissible: true, - } - ); + registerNotification( 'is-dismissible-true-and-dismissed', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + isDismissible: true, + } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'is-dismissible-false', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - isDismissible: false, - } ); + registerNotification( 'is-dismissible-true-but-not-dismissed', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + isDismissible: true, + } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'is-dismissible-undefined', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - } ); + registerNotification( 'is-dismissible-false', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + isDismissible: false, + } ); - registry.dispatch( CORE_USER ).receiveGetDismissedItems( [ - 'is-dismissible-true-and-dismissed', - 'is-dismissible-false', // should not be checked nor filtered - 'is-dismissible-undefined', // should not be checked nor filtered - ] ); + registerNotification( 'is-dismissible-undefined', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + } ); registry - .select( CORE_NOTIFICATIONS ) - .getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); - - await untilResolved( - registry, - CORE_NOTIFICATIONS - ).getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); + .dispatch( CORE_USER ) + .receiveGetDismissedItems( [ + 'is-dismissible-true-and-dismissed', + ] ); - const queuedNotifications = registry - .select( CORE_NOTIFICATIONS ) + const queuedNotifications = await registry + .resolveSelect( CORE_NOTIFICATIONS ) .getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); - expect( queuedNotifications ).toHaveLength( 3 ); - expect( queuedNotifications[ 0 ].id ).toBe( - 'is-dismissible-true-but-not-dismissed' - ); - expect( queuedNotifications[ 1 ].id ).toBe( - 'is-dismissible-false' - ); - expect( queuedNotifications[ 2 ].id ).toBe( - 'is-dismissible-undefined' + + expect( queuedNotifications.map( ( { id } ) => id ) ).toEqual( + expect.arrayContaining( [ + 'is-dismissible-false', + 'is-dismissible-true-but-not-dismissed', + 'is-dismissible-undefined', + ] ) ); } ); it( 'should return registered notifications ordered by priority', async () => { - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'medium-2-priority', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - priority: 25, - } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'lowest-priority', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - priority: 30, - } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'medium-1-priority', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - priority: 20, - } ); - registry - .dispatch( CORE_NOTIFICATIONS ) - .registerNotification( 'highest-priority', { - Component: TestNotificationComponent, - areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, - viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - } ); - - registry - .select( CORE_NOTIFICATIONS ) - .getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); - - await untilResolved( - registry, - CORE_NOTIFICATIONS - ).getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); + registerNotification( 'medium-2-priority', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + priority: 25, + } ); + registerNotification( 'lowest-priority', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + priority: 30, + } ); + registerNotification( 'medium-1-priority', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + priority: 20, + } ); + registerNotification( 'highest-priority', { + Component: TestNotificationComponent, + areaSlug: NOTIFICATION_AREAS.BANNERS_ABOVE_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + } ); - const queuedNotifications = registry - .select( CORE_NOTIFICATIONS ) + const queuedNotifications = await registry + .resolveSelect( CORE_NOTIFICATIONS ) .getQueuedNotifications( VIEW_CONTEXT_MAIN_DASHBOARD ); - expect( queuedNotifications ).toHaveLength( 4 ); - expect( queuedNotifications[ 0 ].id ).toBe( - 'highest-priority' - ); - expect( queuedNotifications[ 1 ].id ).toBe( - 'medium-1-priority' - ); - expect( queuedNotifications[ 2 ].id ).toBe( - 'medium-2-priority' - ); - expect( queuedNotifications[ 3 ].id ).toBe( 'lowest-priority' ); + expect( queuedNotifications.map( ( { id } ) => id ) ).toEqual( [ + 'highest-priority', + 'medium-1-priority', + 'medium-2-priority', + 'lowest-priority', + ] ); } ); } ); + describe( 'isNotificationDismissed', () => { + let isNotificationDismissed; + beforeEach( () => { + ( { isNotificationDismissed } = + registry.select( CORE_NOTIFICATIONS ) ); + } ); + it( 'should return undefined if getDismissedItems selector is not resolved yet', async () => { fetchMock.getOnce( fetchGetDismissedItems, { body: [] } ); - expect( - registry - .select( CORE_NOTIFICATIONS ) - .isNotificationDismissed( 'foo' ) - ).toBeUndefined(); + expect( isNotificationDismissed( 'foo' ) ).toBeUndefined(); await untilResolved( registry, CORE_USER ).getDismissedItems(); } ); @@ -731,22 +592,14 @@ describe( 'core/notifications Notifications', () => { registry .dispatch( CORE_USER ) .receiveGetDismissedItems( [ 'foo', 'bar' ] ); - expect( - registry - .select( CORE_NOTIFICATIONS ) - .isNotificationDismissed( 'foo' ) - ).toBe( true ); + expect( isNotificationDismissed( 'foo' ) ).toBe( true ); } ); it( 'should return FALSE if the notification is not dismissed', () => { registry .dispatch( CORE_USER ) .receiveGetDismissedItems( [ 'foo', 'bar' ] ); - expect( - registry - .select( CORE_NOTIFICATIONS ) - .isNotificationDismissed( 'baz' ) - ).toBe( false ); + expect( isNotificationDismissed( 'baz' ) ).toBe( false ); } ); } ); } ); diff --git a/assets/js/googlesitekit/notifications/hooks/useNotificationEvents.js b/assets/js/googlesitekit/notifications/hooks/useNotificationEvents.js index 6f0c929350b..b7ac494a0ed 100644 --- a/assets/js/googlesitekit/notifications/hooks/useNotificationEvents.js +++ b/assets/js/googlesitekit/notifications/hooks/useNotificationEvents.js @@ -27,9 +27,9 @@ import { useCallback } from '@wordpress/element'; import useViewContext from '../../../hooks/useViewContext'; import { trackEvent } from '../../../util'; -export default function useNotificationEvents( id ) { +export default function useNotificationEvents( id, category ) { const viewContext = useViewContext(); - const eventCategory = `${ viewContext }_${ id }`; + const eventCategory = category ?? `${ viewContext }_${ id }`; const view = useCallback( ( ...args ) => { diff --git a/assets/js/googlesitekit/notifications/register-defaults.js b/assets/js/googlesitekit/notifications/register-defaults.js index 89afcd297ca..ff844fa1cc5 100644 --- a/assets/js/googlesitekit/notifications/register-defaults.js +++ b/assets/js/googlesitekit/notifications/register-defaults.js @@ -29,6 +29,7 @@ import { CORE_NOTIFICATIONS, NOTIFICATION_AREAS, NOTIFICATION_GROUPS, + FPM_HEALTH_CHECK_WARNING_NOTIFICATION_ID, } from './datastore/constants'; import { CORE_FORMS } from '../datastore/forms/constants'; import { CORE_SITE } from '../datastore/site/constants'; @@ -36,6 +37,7 @@ import { CORE_USER, FORM_TEMPORARY_PERSIST_PERMISSION_ERROR, } from '../datastore/user/constants'; +import { CORE_UI } from '../datastore/ui/constants'; import { CORE_MODULES } from '../modules/datastore/constants'; import { DATE_RANGE_OFFSET, @@ -50,8 +52,14 @@ import UnsatisfiedScopesAlertGTE from '../../components/notifications/Unsatisfie import GatheringDataNotification from '../../components/notifications/GatheringDataNotification'; import ZeroDataNotification from '../../components/notifications/ZeroDataNotification'; import GA4AdSenseLinkedNotification from '../../components/notifications/GA4AdSenseLinkedNotification'; -import FirstPartyModeSetupBanner from '../../components/notifications/FirstPartyModeSetupBanner'; import SetupErrorNotification from '../../components/notifications/SetupErrorNotification'; +import SetupErrorMessageNotification from '../../components/notifications/SetupErrorMessageNotification'; +import FirstPartyModeWarningNotification from '../../components/notifications/FirstPartyModeWarningNotification'; +import FirstPartyModeSetupBanner, { + FPM_SHOW_SETUP_SUCCESS_NOTIFICATION, +} from '../../components/notifications/FirstPartyModeSetupBanner'; +import FirstPartyModeSetupSuccessSubtleNotification from '../../components/notifications/FirstPartyModeSetupSuccessSubtleNotification'; +import { FPM_SETUP_CTA_BANNER_NOTIFICATION } from './constants'; import { isFeatureEnabled } from '../../features'; export const DEFAULT_NOTIFICATIONS = { @@ -186,6 +194,41 @@ export const DEFAULT_NOTIFICATIONS = { }, isDismissible: false, }, + setup_plugin_error: { + Component: SetupErrorMessageNotification, + priority: 140, + areaSlug: NOTIFICATION_AREAS.ERRORS, + viewContexts: [ + VIEW_CONTEXT_MAIN_DASHBOARD, + VIEW_CONTEXT_MAIN_DASHBOARD_VIEW_ONLY, + VIEW_CONTEXT_ENTITY_DASHBOARD, + VIEW_CONTEXT_ENTITY_DASHBOARD_VIEW_ONLY, + VIEW_CONTEXT_SETTINGS, + ], + checkRequirements: async ( { select, resolveSelect } ) => { + await resolveSelect( CORE_SITE ).getSiteInfo(); + + const temporaryPersistedPermissionsError = select( + CORE_FORMS + ).getValue( + FORM_TEMPORARY_PERSIST_PERMISSION_ERROR, + 'permissionsError' + ); + + if ( + temporaryPersistedPermissionsError?.data + ?.skipDefaultErrorNotifications + ) { + return false; + } + + const setupErrorMessage = + select( CORE_SITE ).getSetupErrorMessage(); + + return !! setupErrorMessage; + }, + isDismissible: false, + }, 'top-earning-pages-success-notification': { Component: GA4AdSenseLinkedNotification, priority: 10, @@ -442,25 +485,58 @@ export const DEFAULT_NOTIFICATIONS = { }, isDismissible: true, }, - 'first-party-mode-setup-cta-banner': { + [ FPM_SETUP_CTA_BANNER_NOTIFICATION ]: { Component: FirstPartyModeSetupBanner, priority: 320, areaSlug: NOTIFICATION_AREAS.BANNERS_BELOW_NAV, groupID: NOTIFICATION_GROUPS.SETUP_CTAS, viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], - checkRequirements: async ( { select, resolveSelect } ) => { + checkRequirements: async ( { select, resolveSelect, dispatch } ) => { if ( ! isFeatureEnabled( 'firstPartyMode' ) ) { return false; } - const { isModuleConnected } = select( CORE_MODULES ); + const isFPMModuleConnected = + select( CORE_SITE ).isAnyFirstPartyModeModuleConnected(); - if ( - ! ( - isModuleConnected( 'analytics-4' ) || - isModuleConnected( 'ads' ) - ) - ) { + if ( ! isFPMModuleConnected ) { + return false; + } + + await resolveSelect( CORE_SITE ).getFirstPartyModeSettings(); + + const { + isFirstPartyModeEnabled, + isFPMHealthy, + isScriptAccessEnabled, + } = select( CORE_SITE ); + + if ( isFirstPartyModeEnabled() ) { + return false; + } + + const isHealthy = isFPMHealthy(); + const isAccessEnabled = isScriptAccessEnabled(); + + if ( [ isHealthy, isAccessEnabled ].includes( null ) ) { + dispatch( CORE_SITE ).fetchGetFPMServerRequirementStatus(); + return false; + } + + return isHealthy && isAccessEnabled; + }, + isDismissible: true, + }, + [ FPM_HEALTH_CHECK_WARNING_NOTIFICATION_ID ]: { + Component: FirstPartyModeWarningNotification, + priority: 10, + areaSlug: NOTIFICATION_AREAS.BANNERS_BELOW_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + checkRequirements: async ( { select, resolveSelect } ) => { + const isFPMModuleConnected = + select( CORE_SITE ).isAnyFirstPartyModeModuleConnected(); + + if ( ! isFPMModuleConnected ) { return false; } @@ -473,13 +549,24 @@ export const DEFAULT_NOTIFICATIONS = { } = select( CORE_SITE ); return ( - ! isFirstPartyModeEnabled() && - isFPMHealthy() && - isScriptAccessEnabled() + isFirstPartyModeEnabled() && + ( ! isFPMHealthy() || ! isScriptAccessEnabled() ) ); }, isDismissible: true, }, + 'setup-success-notification-fpm': { + Component: FirstPartyModeSetupSuccessSubtleNotification, + priority: 10, + areaSlug: NOTIFICATION_AREAS.BANNERS_BELOW_NAV, + viewContexts: [ VIEW_CONTEXT_MAIN_DASHBOARD ], + isDismissible: false, + checkRequirements: ( { select } ) => { + return !! select( CORE_UI ).getValue( + FPM_SHOW_SETUP_SUCCESS_NOTIFICATION + ); + }, + }, 'auth-error': { Component: AuthError, priority: 120, diff --git a/assets/js/modules/ads/components/notifications/PAXSetupSuccessSubtleNotification.js b/assets/js/modules/ads/components/notifications/PAXSetupSuccessSubtleNotification.js index 24a691ef604..ef961df5877 100644 --- a/assets/js/modules/ads/components/notifications/PAXSetupSuccessSubtleNotification.js +++ b/assets/js/modules/ads/components/notifications/PAXSetupSuccessSubtleNotification.js @@ -16,6 +16,11 @@ * limitations under the License. */ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + /** * WordPress dependencies */ @@ -93,3 +98,8 @@ export default function PAXSetupSuccessSubtleNotification( { ); } + +PAXSetupSuccessSubtleNotification.propTypes = { + id: PropTypes.string.isRequired, + Notification: PropTypes.elementType.isRequired, +}; diff --git a/assets/js/modules/ads/components/settings/SettingsEdit.stories.js b/assets/js/modules/ads/components/settings/SettingsEdit.stories.js index 95ac7e59628..107f2a47cab 100644 --- a/assets/js/modules/ads/components/settings/SettingsEdit.stories.js +++ b/assets/js/modules/ads/components/settings/SettingsEdit.stories.js @@ -16,13 +16,22 @@ * limitations under the License. */ +/** + * External dependencies + */ +import fetchMock from 'fetch-mock'; + /** * Internal dependencies */ import SettingsEdit from './SettingsEdit'; import { Cell, Grid, Row } from '../../../../material-components'; +import { CORE_SITE } from '../../../../googlesitekit/datastore/site/constants'; import { MODULES_ADS } from '../../datastore/constants'; -import { provideModules } from '../../../../../../tests/js/utils'; +import { + provideModules, + WithTestRegistry, +} from '../../../../../../tests/js/utils'; import WithRegistrySetup from '../../../../../../tests/js/WithRegistrySetup'; function Template( args ) { @@ -172,3 +181,74 @@ IcePaxEnabled.decorators = [ ); }, ]; + +export const FirstPartyModeEnabled = Template.bind( null ); +FirstPartyModeEnabled.storyName = 'FirstPartyModeEnabled'; +FirstPartyModeEnabled.decorators = [ + ( Story ) => { + const setupRegistry = ( registry ) => { + const fpmServerRequirementsEndpoint = new RegExp( + '^/google-site-kit/v1/core/site/data/fpm-server-requirement-status' + ); + + const fpmSettings = { + isEnabled: true, + isFPMHealthy: true, + isScriptAccessEnabled: true, + }; + + fetchMock.getOnce( fpmServerRequirementsEndpoint, { + body: fpmSettings, + } ); + + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( fpmSettings ); + }; + + return ( + + + + ); + }, +]; + +export const FirstPartyModeDisabledWithWarning = Template.bind( null ); +FirstPartyModeDisabledWithWarning.storyName = + 'FirstPartyModeDisabledWithWarning'; +FirstPartyModeDisabledWithWarning.decorators = [ + ( Story ) => { + const setupRegistry = ( registry ) => { + const fpmServerRequirementsEndpoint = new RegExp( + '^/google-site-kit/v1/core/site/data/fpm-server-requirement-status' + ); + + const fpmSettings = { + isEnabled: true, + isFPMHealthy: false, + isScriptAccessEnabled: false, + }; + + fetchMock.getOnce( fpmServerRequirementsEndpoint, { + body: fpmSettings, + } ); + + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( fpmSettings ); + }; + + return ( + + + + ); + }, +]; diff --git a/assets/js/modules/ads/components/settings/SettingsForm.stories.js b/assets/js/modules/ads/components/settings/SettingsForm.stories.js index 08548f5dd85..b46d1718ba1 100644 --- a/assets/js/modules/ads/components/settings/SettingsForm.stories.js +++ b/assets/js/modules/ads/components/settings/SettingsForm.stories.js @@ -16,14 +16,22 @@ * limitations under the License. */ +/** + * External dependencies + */ +import fetchMock from 'fetch-mock'; + /** * Internal dependencies */ import SettingsForm from './SettingsForm'; import { Cell, Grid, Row } from '../../../../material-components'; +import { CORE_SITE } from '../../../../googlesitekit/datastore/site/constants'; import { MODULES_ADS } from '../../datastore/constants'; -import { provideModules } from '../../../../../../tests/js/utils'; -import WithRegistrySetup from '../../../../../../tests/js/WithRegistrySetup'; +import { + provideModules, + WithTestRegistry, +} from '../../../../../../tests/js/utils'; function Template( args ) { return ( @@ -47,37 +55,76 @@ function Template( args ) { export const Default = Template.bind( null ); Default.storyName = 'Default'; -Default.scenario = { - label: 'Modules/Ads/Settings/SettingsForm/Default', - delay: 250, +Default.scenario = {}; +Default.args = { + setupRegistry: ( registry ) => { + registry.dispatch( MODULES_ADS ).receiveGetSettings( { + conversionID: 'AW-123456789', + } ); + }, }; -Default.decorators = [ - ( Story ) => { - const setupRegistry = ( registry ) => { - registry.dispatch( MODULES_ADS ).receiveGetSettings( { - conversionID: 'AW-123456789', - } ); - }; - return ( - - - +export const Empty = Template.bind( null ); +Empty.storyName = 'Empty'; +Empty.scenario = {}; + +export const FirstPartyModeEnabled = Template.bind( null ); +FirstPartyModeEnabled.storyName = 'FirstPartyModeEnabled'; +FirstPartyModeEnabled.scenario = {}; +FirstPartyModeEnabled.args = { + features: [ 'firstPartyMode' ], + setupRegistry: ( registry ) => { + const fpmServerRequirementsEndpoint = new RegExp( + '^/google-site-kit/v1/core/site/data/fpm-server-requirement-status' ); + + const fpmSettings = { + isEnabled: true, + isFPMHealthy: true, + isScriptAccessEnabled: true, + }; + + fetchMock.getOnce( fpmServerRequirementsEndpoint, { + body: fpmSettings, + } ); + + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( fpmSettings ); }, -]; +}; -export const Empty = Template.bind( null ); -Empty.storyName = 'Empty'; -Empty.scenario = { - label: 'Modules/Ads/Settings/SettingsForm/Empty', - delay: 250, +export const FirstPartyModeDisabledWithWarning = Template.bind( null ); +FirstPartyModeDisabledWithWarning.storyName = + 'FirstPartyModeDisabledWithWarning'; +FirstPartyModeDisabledWithWarning.scenario = {}; +FirstPartyModeDisabledWithWarning.args = { + features: [ 'firstPartyMode' ], + setupRegistry: ( registry ) => { + const fpmServerRequirementsEndpoint = new RegExp( + '^/google-site-kit/v1/core/site/data/fpm-server-requirement-status' + ); + + const fpmSettings = { + isEnabled: true, + isFPMHealthy: false, + isScriptAccessEnabled: false, + }; + + fetchMock.getOnce( fpmServerRequirementsEndpoint, { + body: fpmSettings, + } ); + + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( fpmSettings ); + }, }; export default { title: 'Modules/Ads/Settings/SettingsForm', decorators: [ - ( Story ) => { + ( Story, { args } ) => { const setupRegistry = ( registry ) => { provideModules( registry, [ { @@ -86,12 +133,16 @@ export default { connected: true, }, ] ); + args.setupRegistry?.( registry ); }; return ( - + - + ); }, ], diff --git a/assets/js/modules/ads/components/settings/SettingsView.js b/assets/js/modules/ads/components/settings/SettingsView.js index d3b39632bfd..b8880fb0cc0 100644 --- a/assets/js/modules/ads/components/settings/SettingsView.js +++ b/assets/js/modules/ads/components/settings/SettingsView.js @@ -67,9 +67,19 @@ export default function SettingsView() { select( CORE_SITE ).isConversionTrackingEnabled() ); - const isFirstPartyModeEnabled = useSelect( ( select ) => - select( CORE_SITE ).isFirstPartyModeEnabled() - ); + const isFPMEnabled = useSelect( ( select ) => { + if ( ! fpmEnabled ) { + return false; + } + const { isFirstPartyModeEnabled, isFPMHealthy, isScriptAccessEnabled } = + select( CORE_SITE ); + + return ( + isFirstPartyModeEnabled() && + isFPMHealthy() && + isScriptAccessEnabled() + ); + } ); return (
@@ -91,18 +101,14 @@ export default function SettingsView() { 'Enhanced Conversion Tracking', 'google-site-kit' ), - status: isConversionTrackingEnabled - ? __( 'Enabled', 'google-site-kit' ) - : __( 'Disabled', 'google-site-kit' ), + status: isConversionTrackingEnabled, }, { label: __( - 'First-Party Mode', + 'First-party Mode', 'google-site-kit' ), - status: isFirstPartyModeEnabled - ? __( 'Enabled', 'google-site-kit' ) - : __( 'Disabled', 'google-site-kit' ), + status: isFPMEnabled, }, ] : [ @@ -111,9 +117,7 @@ export default function SettingsView() { 'Conversion Tracking', 'google-site-kit' ), - status: isConversionTrackingEnabled - ? __( 'Enabled', 'google-site-kit' ) - : __( 'Disabled', 'google-site-kit' ), + status: isConversionTrackingEnabled, }, ] } diff --git a/assets/js/modules/ads/components/settings/SettingsView.stories.js b/assets/js/modules/ads/components/settings/SettingsView.stories.js index 8952df56ce3..348ec0721a6 100644 --- a/assets/js/modules/ads/components/settings/SettingsView.stories.js +++ b/assets/js/modules/ads/components/settings/SettingsView.stories.js @@ -60,12 +60,12 @@ IceEnabled.parameters = { features: [ 'firstPartyMode' ], }; -export const IceDisabled = Template.bind( null ); -IceDisabled.storyName = 'With ICE disabled'; -IceDisabled.args = { - enhancedConversionTracking: false, +export const FPMEnabled = Template.bind( null ); +FPMEnabled.storyName = 'With First-party Mode Enabled'; +FPMEnabled.args = { + firstPartyMode: true, }; -IceDisabled.parameters = { +FPMEnabled.parameters = { features: [ 'firstPartyMode' ], }; @@ -86,13 +86,19 @@ export default { conversionID: 'AW-123456789', } ); - if ( args.hasOwnProperty( 'enhancedConversionTracking' ) ) { - registry - .dispatch( CORE_SITE ) - .setConversionTrackingEnabled( - args.enhancedConversionTracking - ); - } + registry + .dispatch( CORE_SITE ) + .setConversionTrackingEnabled( + args.enhancedConversionTracking || false + ); + + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( { + isEnabled: args.firstPartyMode || false, + isFPMHealthy: args.firstPartyMode || false, + isScriptAccessEnabled: args.firstPartyMode || false, + } ); }; return ( @@ -131,26 +137,3 @@ PaxConnected.decorators = [ ); }, ]; - -export const FPMEnabled = Template.bind( null ); -FPMEnabled.storyName = 'With First-Party Mode enabled'; -FPMEnabled.scenario = { - label: 'Modules/Ads/Settings/SettingsView/First-Party Mode Enabled', -}; -FPMEnabled.parameters = { - features: [ 'firstPartyMode' ], -}; -FPMEnabled.decorators = [ - ( Story ) => { - const setupRegistry = ( registry ) => { - registry.dispatch( MODULES_ADS ).setConversionID( 'AW-123456789' ); - registry.dispatch( CORE_SITE ).setFirstPartyModeEnabled( true ); - }; - - return ( - - - - ); - }, -]; diff --git a/assets/js/modules/ads/datastore/settings.js b/assets/js/modules/ads/datastore/settings.js index 578ecfb181d..c7579868174 100644 --- a/assets/js/modules/ads/datastore/settings.js +++ b/assets/js/modules/ads/datastore/settings.js @@ -31,9 +31,11 @@ import { INVARIANT_DOING_SUBMIT_CHANGES, INVARIANT_SETTINGS_NOT_CHANGED, } from '../../../googlesitekit/data/create-settings-store'; +import { CORE_NOTIFICATIONS } from '../../../googlesitekit/notifications/datastore/constants'; import { CORE_SITE } from '../../../googlesitekit/datastore/site/constants'; import { MODULES_ADS } from './constants'; import { isValidConversionID } from '../utils/validation'; +import { FPM_SETUP_CTA_BANNER_NOTIFICATION } from '../../../googlesitekit/notifications/constants'; // Invariant error messages. export const INVARIANT_INVALID_CONVERSION_ID = @@ -74,6 +76,22 @@ export async function submitChanges( { select, dispatch } ) { if ( error ) { return { error }; } + + if ( + select( CORE_SITE ).isFirstPartyModeEnabled() && + ! select( CORE_NOTIFICATIONS ).isNotificationDismissed( + FPM_SETUP_CTA_BANNER_NOTIFICATION + ) + ) { + const { error: dismissError } = + ( await dispatch( CORE_NOTIFICATIONS ).dismissNotification( + FPM_SETUP_CTA_BANNER_NOTIFICATION + ) ) || {}; + + if ( dismissError ) { + return { error: dismissError }; + } + } } await API.invalidateCache( 'modules', 'ads' ); diff --git a/assets/js/modules/ads/datastore/settings.test.js b/assets/js/modules/ads/datastore/settings.test.js index 6cd3c6669f7..0596b514cbb 100644 --- a/assets/js/modules/ads/datastore/settings.test.js +++ b/assets/js/modules/ads/datastore/settings.test.js @@ -20,11 +20,17 @@ * Internal dependencies */ import API from 'googlesitekit-api'; -import { createTestRegistry } from '../../../../../tests/js/utils'; -import { MODULES_ADS } from './constants'; -import { validateCanSubmitChanges } from './settings'; +import { + createTestRegistry, + provideNotifications, +} from '../../../../../tests/js/utils'; import { INVARIANT_SETTINGS_NOT_CHANGED } from '../../../googlesitekit/data/create-settings-store'; +import { DEFAULT_NOTIFICATIONS } from '../../../googlesitekit/notifications/register-defaults'; +import { FPM_SETUP_CTA_BANNER_NOTIFICATION } from '../../../googlesitekit/notifications/constants'; import { CORE_SITE } from '../../../googlesitekit/datastore/site/constants'; +import { CORE_USER } from '../../../googlesitekit/datastore/user/constants'; +import { MODULES_ADS } from './constants'; +import { validateCanSubmitChanges } from './settings'; describe( 'modules/ads settings', () => { let registry; @@ -35,12 +41,6 @@ describe( 'modules/ads settings', () => { beforeEach( () => { registry = createTestRegistry(); - - registry.dispatch( CORE_SITE ).receiveGetFirstPartyModeSettings( { - isEnabled: false, - isFPMHealthy: true, - isScriptAccessEnabled: true, - } ); } ); afterAll( () => { @@ -51,6 +51,18 @@ describe( 'modules/ads settings', () => { const settingsEndpoint = new RegExp( '^/google-site-kit/v1/modules/ads/data/settings' ); + const fpmSettingsEndpoint = new RegExp( + '^/google-site-kit/v1/core/site/data/fpm-settings' + ); + const dismissItemEndpoint = new RegExp( + '^/google-site-kit/v1/core/user/data/dismiss-item' + ); + + const error = { + code: 'internal_error', + message: 'Something wrong happened.', + data: { status: 500 }, + }; beforeEach( () => { registry.dispatch( MODULES_ADS ).receiveGetSettings( { @@ -65,7 +77,7 @@ describe( 'modules/ads settings', () => { it( 'should send a POST request when saving changed settings', async () => { fetchMock.postOnce( settingsEndpoint, ( url, opts ) => ( { - body: JSON.parse( opts.body )?.data, + body: JSON.parse( opts.body ).data, status: 200, } ) ); @@ -82,20 +94,38 @@ describe( 'modules/ads settings', () => { } ); it( 'should send a POST request to the FPM settings endpoint when the toggle state is changed', async () => { - const fpmSettingsEndpoint = new RegExp( - '^/google-site-kit/v1/core/site/data/fpm-settings' - ); + registry.dispatch( CORE_SITE ).receiveGetFirstPartyModeSettings( { + isEnabled: false, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + + registry + .dispatch( CORE_USER ) + .receiveGetDismissedItems( [ + FPM_SETUP_CTA_BANNER_NOTIFICATION, + ] ); fetchMock.postOnce( settingsEndpoint, ( url, opts ) => ( { body: JSON.parse( opts.body )?.data, status: 200, } ) ); - fetchMock.postOnce( fpmSettingsEndpoint, { - body: JSON.stringify( { - data: { settings: { isEnabled: true } }, - } ), - status: 200, + fetchMock.postOnce( fpmSettingsEndpoint, ( url, opts ) => { + const { + data: { + settings: { isEnabled }, + }, + } = JSON.parse( opts.body ); + + return { + body: { + isEnabled, // Return the `isEnabled` value passed to the API. + isFPMHealthy: true, + isScriptAccessEnabled: true, + }, + status: 200, + }; } ); registry.dispatch( CORE_SITE ).setFirstPartyModeEnabled( true ); @@ -109,6 +139,216 @@ describe( 'modules/ads settings', () => { }, } ); } ); + + it( 'should handle an error when sending a POST request to the FPM settings endpoint', async () => { + registry.dispatch( CORE_SITE ).receiveGetFirstPartyModeSettings( { + isEnabled: false, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + + fetchMock.postOnce( settingsEndpoint, ( url, opts ) => ( { + body: JSON.parse( opts.body ).data, + status: 200, + } ) ); + + fetchMock.postOnce( fpmSettingsEndpoint, { + body: error, + status: 500, + } ); + + registry.dispatch( CORE_SITE ).setFirstPartyModeEnabled( true ); + const { error: submitChangesError } = await registry + .dispatch( MODULES_ADS ) + .submitChanges(); + + expect( submitChangesError ).toEqual( error ); + + expect( console ).toHaveErrored(); + } ); + + it( 'should not send a POST request to the FPM settings endpoint when the toggle state is not changed', async () => { + registry.dispatch( CORE_SITE ).receiveGetFirstPartyModeSettings( { + isEnabled: false, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + + fetchMock.postOnce( settingsEndpoint, ( url, opts ) => ( { + body: JSON.parse( opts.body )?.data, + status: 200, + } ) ); + + await registry.dispatch( MODULES_ADS ).submitChanges(); + + expect( fetchMock ).not.toHaveFetched( fpmSettingsEndpoint ); + } ); + + it( 'should dismiss the FPM setup CTA banner when the FPM `isEnabled` setting is changed to `true`', async () => { + provideNotifications( + registry, + { + [ FPM_SETUP_CTA_BANNER_NOTIFICATION ]: + DEFAULT_NOTIFICATIONS[ + FPM_SETUP_CTA_BANNER_NOTIFICATION + ], + }, + { overwrite: true } + ); + + registry.dispatch( CORE_SITE ).receiveGetFirstPartyModeSettings( { + isEnabled: false, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + + registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] ); + + fetchMock.postOnce( settingsEndpoint, ( url, opts ) => ( { + body: JSON.parse( opts.body ).data, + status: 200, + } ) ); + + fetchMock.postOnce( fpmSettingsEndpoint, ( url, opts ) => { + const { + data: { + settings: { isEnabled }, + }, + } = JSON.parse( opts.body ); + + return { + body: { + isEnabled, // Return the `isEnabled` value passed to the API. + isFPMHealthy: true, + isScriptAccessEnabled: true, + }, + status: 200, + }; + } ); + + fetchMock.postOnce( dismissItemEndpoint, { + body: [ FPM_SETUP_CTA_BANNER_NOTIFICATION ], + status: 200, + } ); + + registry.dispatch( CORE_SITE ).setFirstPartyModeEnabled( true ); + await registry.dispatch( MODULES_ADS ).submitChanges(); + + expect( fetchMock ).toHaveFetched( dismissItemEndpoint, { + body: { + data: { + slug: FPM_SETUP_CTA_BANNER_NOTIFICATION, + expiration: 0, + }, + }, + } ); + expect( fetchMock ).toHaveFetchedTimes( 3 ); + } ); + + it( 'should handle an error when dismissing the FPM setup CTA banner', async () => { + provideNotifications( + registry, + { + [ FPM_SETUP_CTA_BANNER_NOTIFICATION ]: + DEFAULT_NOTIFICATIONS[ + FPM_SETUP_CTA_BANNER_NOTIFICATION + ], + }, + { overwrite: true } + ); + + registry.dispatch( CORE_SITE ).receiveGetFirstPartyModeSettings( { + isEnabled: false, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + + registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] ); + + fetchMock.postOnce( settingsEndpoint, ( url, opts ) => ( { + body: JSON.parse( opts.body ).data, + status: 200, + } ) ); + + fetchMock.postOnce( fpmSettingsEndpoint, ( url, opts ) => { + const { + data: { + settings: { isEnabled }, + }, + } = JSON.parse( opts.body ); + + return { + body: { + isEnabled, // Return the `isEnabled` value passed to the API. + isFPMHealthy: true, + isScriptAccessEnabled: true, + }, + status: 200, + }; + } ); + + fetchMock.postOnce( dismissItemEndpoint, { + body: error, + status: 500, + } ); + + registry.dispatch( CORE_SITE ).setFirstPartyModeEnabled( true ); + const { error: submitChangesError } = await registry + .dispatch( MODULES_ADS ) + .submitChanges(); + + expect( submitChangesError ).toEqual( error ); + expect( console ).toHaveErrored(); + } ); + + it( 'should not dismiss the FPM setup CTA banner when the FPM `isEnabled` setting is changed to `false`', async () => { + provideNotifications( + registry, + { + [ FPM_SETUP_CTA_BANNER_NOTIFICATION ]: + DEFAULT_NOTIFICATIONS[ + FPM_SETUP_CTA_BANNER_NOTIFICATION + ], + }, + { overwrite: true } + ); + + registry.dispatch( CORE_SITE ).receiveGetFirstPartyModeSettings( { + isEnabled: true, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + + registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] ); + + fetchMock.postOnce( settingsEndpoint, ( url, opts ) => ( { + body: JSON.parse( opts.body ).data, + status: 200, + } ) ); + + fetchMock.postOnce( fpmSettingsEndpoint, ( url, opts ) => { + const { + data: { + settings: { isEnabled }, + }, + } = JSON.parse( opts.body ); + + return { + body: { + isEnabled, // Return the `isEnabled` value passed to the API. + isFPMHealthy: true, + isScriptAccessEnabled: true, + }, + status: 200, + }; + } ); + + registry.dispatch( CORE_SITE ).setFirstPartyModeEnabled( false ); + await registry.dispatch( MODULES_ADS ).submitChanges(); + + expect( fetchMock ).not.toHaveFetched( dismissItemEndpoint ); + expect( fetchMock ).toHaveFetchedTimes( 2 ); + } ); } ); describe( 'validateCanSubmitChanges', () => { @@ -157,6 +397,13 @@ describe( 'modules/ads settings', () => { } ); registry.dispatch( MODULES_ADS ).setConversionID( '56789' ); + + registry.dispatch( CORE_SITE ).receiveGetFirstPartyModeSettings( { + isEnabled: false, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + registry.dispatch( CORE_SITE ).setFirstPartyModeEnabled( true ); registry.dispatch( MODULES_ADS ).rollbackChanges(); diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSegmentationSetupSuccessSubtleNotification.js b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSegmentationSetupSuccessSubtleNotification.js index e0517a6c98c..a8bd20cbbc3 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSegmentationSetupSuccessSubtleNotification.js +++ b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSegmentationSetupSuccessSubtleNotification.js @@ -16,6 +16,11 @@ * limitations under the License. */ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + /** * WordPress dependencies */ @@ -104,3 +109,8 @@ export default function AudienceSegmentationSetupSuccessSubtleNotification( { ); } + +AudienceSegmentationSetupSuccessSubtleNotification.propTypes = { + id: PropTypes.string.isRequired, + Notification: PropTypes.elementType.isRequired, +}; diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSelectionPanel/AudienceItems.js b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSelectionPanel/AudienceItems.js index 92cbf7def7b..dd4d54b0e33 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSelectionPanel/AudienceItems.js +++ b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSelectionPanel/AudienceItems.js @@ -26,7 +26,7 @@ import { useDeepCompareEffect } from 'react-use'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { useState, useEffect } from '@wordpress/element'; +import { useState, useEffect, Fragment } from '@wordpress/element'; /** * Internal dependencies @@ -44,11 +44,15 @@ import { WEEK_IN_SECONDS } from '../../../../../../util'; import AudienceItem from './AudienceItem'; import { SelectionPanelItems } from '../../../../../../components/SelectionPanel'; import AudienceItemPreviewBlock from './AudienceItemPreviewBlock'; +import AddGroupNotice from './AddGroupNotice'; +import useViewOnly from '../../../../../../hooks/useViewOnly'; +import AudienceCreationNotice from './AudienceCreationNotice'; export default function AudienceItems( { savedItemSlugs = [] } ) { const [ firstView, setFirstView ] = useState( true ); const { setExpirableItemTimers } = useDispatch( CORE_USER ); const { syncAvailableAudiences } = useDispatch( MODULES_ANALYTICS_4 ); + const viewOnlyDashboard = useViewOnly(); const isOpen = useSelect( ( select ) => select( CORE_UI ).getValue( AUDIENCE_SELECTION_PANEL_OPENED_KEY ) @@ -268,6 +272,12 @@ export default function AudienceItems( { savedItemSlugs = [] } ) { isLoading ? AudienceItemPreviewBlock : AudienceItem } savedItemSlugs={ savedItemSlugs } + notice={ + + + { ! viewOnlyDashboard && } + + } /> ); } diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSelectionPanel/Panel.js b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSelectionPanel/Panel.js index dc811fcaa4f..70ee6ce863d 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSelectionPanel/Panel.js +++ b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceSelectionPanel/Panel.js @@ -26,7 +26,6 @@ import { useCallback } from '@wordpress/element'; */ import { useSelect, useDispatch, useInViewSelect } from 'googlesitekit-data'; import useViewContext from '../../../../../../hooks/useViewContext'; -import useViewOnly from '../../../../../../hooks/useViewOnly'; import { trackEvent } from '../../../../../../util'; import { AUDIENCE_CREATION_FORM, @@ -40,19 +39,16 @@ import { CORE_FORMS } from '../../../../../../googlesitekit/datastore/forms/cons import { CORE_UI } from '../../../../../../googlesitekit/datastore/ui/constants'; import { CORE_USER } from '../../../../../../googlesitekit/datastore/user/constants'; import { MODULES_ANALYTICS_4 } from '../../../../datastore/constants'; -import AddGroupNotice from './AddGroupNotice'; import AudienceItems from './AudienceItems'; import ErrorNotice from './ErrorNotice'; import Footer from './Footer'; import Header from './Header'; import LearnMoreLink from './LearnMoreLink'; import SelectionPanel from '../../../../../../components/SelectionPanel'; -import AudienceCreationNotice from './AudienceCreationNotice'; import AudienceCreationSuccessNotice from './AudienceCreationSuccessNotice'; export default function Panel() { const viewContext = useViewContext(); - const viewOnlyDashboard = useViewOnly(); const isOpen = useSelect( ( select ) => select( CORE_UI ).getValue( AUDIENCE_SELECTION_PANEL_OPENED_KEY ) @@ -113,8 +109,6 @@ export default function Panel() { >
- - { ! viewOnlyDashboard && } diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/AudienceTiles.js b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/AudienceTiles.js index 980f152540c..1b83030d91d 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/AudienceTiles.js +++ b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/AudienceTiles.js @@ -475,7 +475,7 @@ export default function AudienceTiles( { Widget, widgetLoading } ) { setActiveTile( visibleAudiences[ index ] ) diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/__snapshots__/index.test.js.snap b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/__snapshots__/index.test.js.snap index 21221d6a1c0..e6fe5ef3e1b 100644 --- a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/__snapshots__/index.test.js.snap +++ b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTilesWidget/__snapshots__/index.test.js.snap @@ -9,7 +9,7 @@ exports[`AudienceTilesWidget should not render audiences that are not available class="googlesitekit-widget__body" >
@@ -369,7 +369,7 @@ exports[`AudienceTilesWidget should render correctly when there is partial data class="googlesitekit-widget__body" >
@@ -910,7 +910,7 @@ exports[`AudienceTilesWidget should render when all configured audiences are mat class="googlesitekit-widget__body" >
@@ -1298,7 +1298,7 @@ exports[`AudienceTilesWidget should render when configured audience is matching class="googlesitekit-widget__body" >
diff --git a/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/index.js b/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/index.js index 7a4d45642b3..8b11be1b017 100644 --- a/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/index.js +++ b/assets/js/modules/analytics-4/components/module/ModulePopularPagesWidgetGA4/index.js @@ -19,6 +19,7 @@ /** * External dependencies */ +import classnames from 'classnames'; import PropTypes from 'prop-types'; import { cloneDeep } from 'lodash'; @@ -45,12 +46,19 @@ import PreviewTable from '../../../../../components/PreviewTable'; import { ZeroDataMessage } from '../../common'; import Header from './Header'; import Footer from './Footer'; +import { + BREAKPOINT_SMALL, + BREAKPOINT_TABLET, + useBreakpoint, +} from '../../../../../hooks/useBreakpoint'; import useViewOnly from '../../../../../hooks/useViewOnly'; import ga4ReportingTour from '../../../../../feature-tours/ga4-reporting'; function ModulePopularPagesWidgetGA4( props ) { const { Widget, WidgetReportError } = props; + const breakpoint = useBreakpoint(); + const isGatheringData = useInViewSelect( ( select ) => select( MODULES_ANALYTICS_4 ).isGatheringData() ); @@ -185,7 +193,6 @@ function ModulePopularPagesWidgetGA4( props ) { { title: __( 'Sessions', 'google-site-kit' ), description: __( 'Sessions', 'google-site-kit' ), - hideOnMobile: true, field: 'metricValues.1.value', className: 'googlesitekit-table__head-item--sessions', Component( { fieldValue } ) { @@ -197,7 +204,6 @@ function ModulePopularPagesWidgetGA4( props ) { { title: __( 'Engagement Rate', 'google-site-kit' ), description: __( 'Engagement Rate', 'google-site-kit' ), - hideOnMobile: true, field: 'metricValues.2.value', className: 'googlesitekit-table__head-item--engagement-rate', Component( { fieldValue } ) { @@ -207,7 +213,6 @@ function ModulePopularPagesWidgetGA4( props ) { { title: __( 'Session Duration', 'google-site-kit' ), description: __( 'Session Duration', 'google-site-kit' ), - hideOnMobile: true, field: 'metricValues.3.value', Component( { fieldValue } ) { return { numFmt( fieldValue, 's' ) }; @@ -232,16 +237,30 @@ function ModulePopularPagesWidgetGA4( props ) { } ); } + const tabbedLayout = + breakpoint === BREAKPOINT_SMALL || breakpoint === BREAKPOINT_TABLET; + + const reportTable = ( + + ); + return ( - - - + { tabbedLayout ? ( + reportTable + ) : ( + { reportTable } + ) } ); } diff --git a/assets/js/modules/analytics-4/components/settings/OptionalSettingsView.js b/assets/js/modules/analytics-4/components/settings/OptionalSettingsView.js index d45de125697..b01645c8e87 100644 --- a/assets/js/modules/analytics-4/components/settings/OptionalSettingsView.js +++ b/assets/js/modules/analytics-4/components/settings/OptionalSettingsView.js @@ -26,12 +26,9 @@ import { Fragment } from '@wordpress/element'; * Internal dependencies */ import { useSelect } from 'googlesitekit-data'; -import { CORE_SITE } from '../../../../googlesitekit/datastore/site/constants'; import { MODULES_ANALYTICS_4 } from '../../datastore/constants'; import AdsConversionIDSettingsNotice from './AdsConversionIDSettingsNotice'; -import DisplaySetting, { - BLANK_SPACE, -} from '../../../../components/DisplaySetting'; +import DisplaySetting from '../../../../components/DisplaySetting'; import { trackingExclusionLabels } from '../common/TrackingExclusionSwitches'; export default function OptionalSettingsView() { @@ -48,10 +45,6 @@ export default function OptionalSettingsView() { select( MODULES_ANALYTICS_4 ).getAdsConversionID() ); - const isConversionTrackingEnabled = useSelect( ( select ) => - select( CORE_SITE ).isConversionTrackingEnabled() - ); - return (
@@ -82,19 +75,6 @@ export default function OptionalSettingsView() {
-
-
- { __( 'Enhanced Conversion Tracking', 'google-site-kit' ) } -
-

- { isConversionTrackingEnabled && - __( 'Enabled', 'google-site-kit' ) } - { isConversionTrackingEnabled === false && - __( 'Disabled', 'google-site-kit' ) } - { isConversionTrackingEnabled === undefined && BLANK_SPACE } -

-
- { /* Prevent the Ads Conversion ID setting displaying after this field has been migrated to the Ads module, even after resetting the Analytics module. */ } { useSnippet && diff --git a/assets/js/modules/analytics-4/components/settings/SettingsEnhancedMeasurementView.js b/assets/js/modules/analytics-4/components/settings/SettingsEnhancedMeasurementView.js deleted file mode 100644 index 551d8e83698..00000000000 --- a/assets/js/modules/analytics-4/components/settings/SettingsEnhancedMeasurementView.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * SettingsEnhancedMeasurementView component. - * - * Site Kit by Google, Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import { useSelect } from 'googlesitekit-data'; -import { MODULES_ANALYTICS_4 } from '../../datastore/constants'; -import DisplaySetting from '../../../../components/DisplaySetting'; -import { ProgressBar } from 'googlesitekit-components'; -import { - isValidPropertyID, - isValidWebDataStreamID, -} from '../../utils/validation'; - -export default function SettingsEnhancedMeasurementView() { - const ga4PropertyID = useSelect( ( select ) => - select( MODULES_ANALYTICS_4 ).getPropertyID() - ); - const webDataStreamID = useSelect( ( select ) => - select( MODULES_ANALYTICS_4 ).getWebDataStreamID() - ); - - const isEnhancedMeasurementStreamEnabled = useSelect( ( select ) => { - if ( - ! isValidPropertyID( ga4PropertyID ) || - ! isValidWebDataStreamID( webDataStreamID ) - ) { - return null; - } - - return select( MODULES_ANALYTICS_4 ).isEnhancedMeasurementStreamEnabled( - ga4PropertyID, - webDataStreamID - ); - } ); - - return ( -
-
-
- { __( 'Enhanced Measurement', 'google-site-kit' ) } -
- { undefined === isEnhancedMeasurementStreamEnabled && ( - - ) } -

- - { true === isEnhancedMeasurementStreamEnabled && ( - - ) } - { false === isEnhancedMeasurementStreamEnabled && ( - - ) } - { null === isEnhancedMeasurementStreamEnabled && ( - - ) } - -

-
-
- ); -} diff --git a/assets/js/modules/analytics-4/components/settings/SettingsForm.js b/assets/js/modules/analytics-4/components/settings/SettingsForm.js index c53e0b31d2e..86008f140ed 100644 --- a/assets/js/modules/analytics-4/components/settings/SettingsForm.js +++ b/assets/js/modules/analytics-4/components/settings/SettingsForm.js @@ -32,18 +32,22 @@ import { __ } from '@wordpress/i18n'; */ import { useSelect } from 'googlesitekit-data'; import { TrackingExclusionSwitches } from '../common'; +import { CORE_SITE } from '../../../../googlesitekit/datastore/site/constants'; import { MODULES_ANALYTICS_4 } from '../../datastore/constants'; import SettingsControls from './SettingsControls'; import AdsConversionIDSettingsNotice from './AdsConversionIDSettingsNotice'; -import EntityOwnershipChangeNotice from '../../../../components/settings/EntityOwnershipChangeNotice'; -import { isValidAccountID } from '../../utils/validation'; import ConversionTrackingToggle from '../../../../components/conversion-tracking/ConversionTrackingToggle'; -import { CORE_SITE } from '../../../../googlesitekit/datastore/site/constants'; +import EntityOwnershipChangeNotice from '../../../../components/settings/EntityOwnershipChangeNotice'; +import FirstPartyModeToggle from '../../../../components/first-party-mode/FirstPartyModeToggle'; import Link from '../../../../components/Link'; import SettingsGroup from '../../../../components/settings/SettingsGroup'; +import { isValidAccountID } from '../../utils/validation'; +import { useFeature } from '../../../../hooks/useFeature'; import SettingsEnhancedMeasurementSwitch from './SettingsEnhancedMeasurementSwitch'; export default function SettingsForm( { hasModuleAccess } ) { + const fpmEnabled = useFeature( 'firstPartyMode' ); + const accountID = useSelect( ( select ) => select( MODULES_ANALYTICS_4 ).getAccountID() ); @@ -90,6 +94,7 @@ export default function SettingsForm( { hasModuleAccess } ) { } ) } + { fpmEnabled && } { isValidAccountID( accountID ) && ( diff --git a/assets/js/modules/analytics-4/components/settings/SettingsForm.stories.js b/assets/js/modules/analytics-4/components/settings/SettingsForm.stories.js index d1b9913004a..13f30d4e1da 100644 --- a/assets/js/modules/analytics-4/components/settings/SettingsForm.stories.js +++ b/assets/js/modules/analytics-4/components/settings/SettingsForm.stories.js @@ -16,11 +16,17 @@ * limitations under the License. */ +/** + * External dependencies + */ +import fetchMock from 'fetch-mock'; + /** * Internal dependencies */ import SettingsForm from './SettingsForm'; import { Cell, Grid, Row } from '../../../../material-components'; +import { CORE_SITE } from '../../../../googlesitekit/datastore/site/constants'; import { MODULES_ANALYTICS_4 } from '../../datastore/constants'; import { provideModules } from '../../../../../../tests/js/utils'; import WithRegistrySetup from '../../../../../../tests/js/WithRegistrySetup'; @@ -92,6 +98,78 @@ EnhancedMeasurementSwitch.decorators = [ }, ]; +export const WithFirstPartyModeAvailable = Template.bind( null ); +WithFirstPartyModeAvailable.storyName = 'With first party mode available'; +WithFirstPartyModeAvailable.parameters = { + features: [ 'firstPartyMode' ], +}; +WithFirstPartyModeAvailable.decorators = [ + ( Story ) => { + const setupRegistry = ( registry ) => { + const fpmServerRequirementsEndpoint = new RegExp( + '^/google-site-kit/v1/core/site/data/fpm-server-requirement-status' + ); + + const fpmSettings = { + isEnabled: true, + isFPMHealthy: true, + isScriptAccessEnabled: true, + }; + + fetchMock.get( fpmServerRequirementsEndpoint, { + body: fpmSettings, + } ); + + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( fpmSettings ); + }; + + return ( + + + + ); + }, +]; +WithFirstPartyModeAvailable.scenario = {}; + +export const WithFirstPartyModeUnavailable = Template.bind( null ); +WithFirstPartyModeUnavailable.storyName = 'With first party mode unavailable'; +WithFirstPartyModeUnavailable.parameters = { + features: [ 'firstPartyMode' ], +}; +WithFirstPartyModeUnavailable.decorators = [ + ( Story ) => { + const setupRegistry = ( registry ) => { + const fpmServerRequirementsEndpoint = new RegExp( + '^/google-site-kit/v1/core/site/data/fpm-server-requirement-status' + ); + + const fpmSettings = { + isEnabled: true, + isFPMHealthy: false, + isScriptAccessEnabled: false, + }; + + fetchMock.get( fpmServerRequirementsEndpoint, { + body: fpmSettings, + } ); + + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( fpmSettings ); + }; + + return ( + + + + ); + }, +]; +WithFirstPartyModeUnavailable.scenario = {}; + export const WithoutModuleAccess = Template.bind( null ); WithoutModuleAccess.storyName = 'Without module access'; WithoutModuleAccess.args = { @@ -245,7 +323,7 @@ IceEnabled.decorators = [ export default { title: 'Modules/Analytics4/Settings/SettingsEdit', decorators: [ - ( Story ) => { + ( Story, { parameters } ) => { const setupRegistry = ( registry ) => { global._googlesitekitDashboardSharingData = { settings: {}, @@ -295,7 +373,10 @@ export default { }; return ( - + ); diff --git a/assets/js/modules/analytics-4/components/settings/SettingsView.js b/assets/js/modules/analytics-4/components/settings/SettingsView.js index 3a5ea972ba3..465b886250c 100644 --- a/assets/js/modules/analytics-4/components/settings/SettingsView.js +++ b/assets/js/modules/analytics-4/components/settings/SettingsView.js @@ -31,7 +31,6 @@ import { PROPERTY_CREATE, } from '../../datastore/constants'; import OptionalSettingsView from './OptionalSettingsView'; -import SettingsEnhancedMeasurementView from './SettingsEnhancedMeasurementView'; import StoreErrorNotices from '../../../../components/StoreErrorNotices'; import DisplaySetting, { BLANK_SPACE, @@ -39,8 +38,17 @@ import DisplaySetting, { import Link from '../../../../components/Link'; import VisuallyHidden from '../../../../components/VisuallyHidden'; import { escapeURI } from '../../../../util/escape-uri'; +import { useFeature } from '../../../../hooks/useFeature'; +import SettingsStatuses from '../../../../components/settings/SettingsStatuses'; +import { + isValidPropertyID, + isValidWebDataStreamID, +} from '../../utils/validation'; +import { CORE_SITE } from '../../../../googlesitekit/datastore/site/constants'; export default function SettingsView() { + const fpmEnabled = useFeature( 'firstPartyMode' ); + const accountID = useSelect( ( select ) => select( MODULES_ANALYTICS_4 ).getAccountID() ); @@ -65,6 +73,43 @@ export default function SettingsView() { select( MODULES_ANALYTICS_4 ).getServiceEntityAccessURL() ); + const webDataStreamID = useSelect( ( select ) => + select( MODULES_ANALYTICS_4 ).getWebDataStreamID() + ); + + const isEnhancedMeasurementStreamEnabled = useSelect( ( select ) => { + if ( + ! isValidPropertyID( propertyID ) || + ! isValidWebDataStreamID( webDataStreamID ) + ) { + return null; + } + + return select( MODULES_ANALYTICS_4 ).isEnhancedMeasurementStreamEnabled( + propertyID, + webDataStreamID + ); + } ); + + const isConversionTrackingEnabled = useSelect( ( select ) => + select( CORE_SITE ).isConversionTrackingEnabled() + ); + + const isFPMEnabled = useSelect( ( select ) => { + if ( ! fpmEnabled ) { + return false; + } + + const { isFirstPartyModeEnabled, isFPMHealthy, isScriptAccessEnabled } = + select( CORE_SITE ); + + return ( + isFirstPartyModeEnabled() && + isFPMHealthy() && + isScriptAccessEnabled() + ); + } ); + if ( ! propertyID || propertyID === PROPERTY_CREATE ) { return null; } @@ -181,9 +226,34 @@ export default function SettingsView() {
- - + +
); } diff --git a/assets/js/modules/analytics-4/components/settings/SettingsView.stories.js b/assets/js/modules/analytics-4/components/settings/SettingsView.stories.js index e7af571c0cd..3a2bc1c946b 100644 --- a/assets/js/modules/analytics-4/components/settings/SettingsView.stories.js +++ b/assets/js/modules/analytics-4/components/settings/SettingsView.stories.js @@ -56,18 +56,38 @@ function Template() { } export const Default = Template.bind( null ); -Default.storyName = 'SettingsView'; +Default.storyName = 'Default'; +Default.scenario = {}; +Default.parameters = { + features: [ 'firstPartyMode' ], +}; export const IceEnabled = Template.bind( null ); IceEnabled.storyName = 'SettingsView ICE Enabled'; IceEnabled.args = { enhancedConversionTracking: true, }; +IceEnabled.parameters = { + features: [ 'firstPartyMode' ], +}; + +export const IceResolving = Template.bind( null ); +IceResolving.storyName = 'SettingsView ICE Resolving'; +IceResolving.args = { + enhancedConversionTracking: 'resolving', +}; +IceResolving.parameters = { + features: [ 'firstPartyMode' ], +}; -export const IceDisabled = Template.bind( null ); -IceDisabled.storyName = 'SettingsView ICE Disabled'; -IceDisabled.args = { +export const FPMEnabled = Template.bind( null ); +FPMEnabled.storyName = 'SettingsView First-party Mode Enabled'; +FPMEnabled.args = { enhancedConversionTracking: false, + firstPartyMode: true, +}; +FPMEnabled.parameters = { + features: [ 'firstPartyMode' ], }; export default { @@ -100,13 +120,21 @@ export default { true ); - if ( args.hasOwnProperty( 'enhancedConversionTracking' ) ) { + if ( args.enhancedConversionTracking !== 'resolving' ) { registry .dispatch( CORE_SITE ) .setConversionTrackingEnabled( - args.enhancedConversionTracking + args.enhancedConversionTracking || false ); } + + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( { + isEnabled: args.firstPartyMode || false, + isFPMHealthy: args.firstPartyMode || false, + isScriptAccessEnabled: args.firstPartyMode || false, + } ); }; return ( diff --git a/assets/js/modules/analytics-4/components/settings/index.js b/assets/js/modules/analytics-4/components/settings/index.js index 4b52291c399..6b97e57e5c3 100644 --- a/assets/js/modules/analytics-4/components/settings/index.js +++ b/assets/js/modules/analytics-4/components/settings/index.js @@ -23,7 +23,6 @@ export { default as PropertyOrWebDataStreamNotAvailableError } from './PropertyO export { default as SettingsControls } from './SettingsControls'; export { default as SettingsEdit } from './SettingsEdit'; export { default as SettingsEnhancedMeasurementSwitch } from './SettingsEnhancedMeasurementSwitch'; -export { default as SettingsEnhancedMeasurementView } from './SettingsEnhancedMeasurementView'; export { default as SettingsForm } from './SettingsForm'; export { default as SettingsUseSnippetSwitch } from './SettingsUseSnippetSwitch'; export { default as SettingsView } from './SettingsView'; diff --git a/assets/js/modules/analytics-4/datastore/constants.js b/assets/js/modules/analytics-4/datastore/constants.js index 6b2d35103d1..6e766730cc7 100644 --- a/assets/js/modules/analytics-4/datastore/constants.js +++ b/assets/js/modules/analytics-4/datastore/constants.js @@ -88,6 +88,12 @@ export const CUSTOM_DIMENSION_DEFINITIONS = { }, }; +export const CONVERSION_REPORTING_LEAD_EVENTS = [ + 'contact', + 'generate_lead', + 'submit_lead_form', +]; + // Audience enums. export const AUDIENCE_FILTER_CLAUSE_TYPE_ENUM = { AUDIENCE_CLAUSE_TYPE_UNSPECIFIED: 'AUDIENCE_CLAUSE_TYPE_UNSPECIFIED', diff --git a/assets/js/modules/analytics-4/datastore/conversion-reporting.js b/assets/js/modules/analytics-4/datastore/conversion-reporting.js index b616be4478c..d8c9ee77ee6 100644 --- a/assets/js/modules/analytics-4/datastore/conversion-reporting.js +++ b/assets/js/modules/analytics-4/datastore/conversion-reporting.js @@ -20,6 +20,7 @@ * External dependencies */ import invariant from 'invariant'; +import { isEqual } from 'lodash'; /** * Internal dependencies @@ -31,9 +32,6 @@ import { createRegistrySelector, createReducer, } from 'googlesitekit-data'; -import { MODULES_ANALYTICS_4 } from './constants'; -import { createFetchStore } from '../../../googlesitekit/data/create-fetch-store'; -import { negateDefined } from '../../../util/negate'; import { CORE_USER, KM_ANALYTICS_TOP_CITIES_DRIVING_ADD_TO_CART, @@ -45,7 +43,15 @@ import { KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_LEADS, KM_ANALYTICS_TOP_TRAFFIC_SOURCE_DRIVING_PURCHASES, } from '../../../googlesitekit/datastore/user/constants'; +import { CORE_MODULES } from '../../../googlesitekit/modules/datastore/constants'; +import { + CONVERSION_REPORTING_LEAD_EVENTS, + MODULES_ANALYTICS_4, +} from './constants'; import { USER_INPUT_PURPOSE_TO_CONVERSION_EVENTS_MAPPING } from '../../../components/user-input/util/constants'; +import { createFetchStore } from '../../../googlesitekit/data/create-fetch-store'; +import { negateDefined } from '../../../util/negate'; +import { safelySort } from '../../../util'; function hasConversionReportingEventsOfType( propName ) { return createRegistrySelector( ( select ) => () => { @@ -132,12 +138,13 @@ export const resolvers = { return; } - const { newEvents, lostEvents } = + const { newEvents, lostEvents, newBadgeEvents } = global._googlesitekitModulesData[ 'analytics-4' ]; yield actions.receiveConversionReportingInlineData( { newEvents, lostEvents, + newBadgeEvents, } ); }, }; @@ -187,9 +194,13 @@ export const actions = { export const reducer = createReducer( ( state, { payload, type } ) => { switch ( type ) { case RECEIVE_CONVERSION_REPORTING_INLINE_DATA: { - const { newEvents, lostEvents } = payload.data; + const { newEvents, lostEvents, newBadgeEvents } = payload.data; - state.detectedEventsChange = { newEvents, lostEvents }; + state.detectedEventsChange = { + newEvents, + lostEvents, + newBadgeEvents, + }; break; } @@ -265,12 +276,25 @@ export const selectors = { hasLostConversionReportingEvents: hasConversionReportingEventsOfType( 'lostEvents' ), + /** + * Returns newBadgeEvents if present. + * + * @since n.e.x.t + * + * @return {Array|undefined} `newBadgeEvents` array if events are present, `undefined` otherwise. + */ + getNewBadgeEvents: createRegistrySelector( ( select ) => () => { + const inlineData = + select( MODULES_ANALYTICS_4 ).getConversionReportingEventsChange(); + + return inlineData?.newBadgeEvents; + } ), + /** * Checks if there are key metrics widgets connected with the detected events for the supplied purpose answer. * * @since 1.141.0 * - * @param {string} purpose Value of saved site purpose from user input settings. * @param {boolean} useNewEvents Flag inclusion of detected new events, otherwise initial detected events will be used. * @return {boolean|undefined} TRUE if current site purpose will have any ACR key metrics widgets assigned to it, FALSE otherwise, and undefined if metrics are not loaded. */ @@ -299,7 +323,7 @@ export const selectors = { /** * Checks if there are key metrics widgets that rely on the conversion events that have been lost. * - * @since n.e.x.t + * @since 1.142.0 * * @return {boolean|undefined} TRUE if current metrics are depending on the conversion events that have been lost, FALSE otherwise, and undefined if event change data is not resolved. */ @@ -328,7 +352,7 @@ export const selectors = { /** * Returns the conversion events associated with the current site purpose. * - * @since n.e.x.t + * @since 1.142.0 * * @return {Array|undefined} List of detected conversion events connected to the current site purpose, or undefined if data is not resolved. */ @@ -357,7 +381,7 @@ export const selectors = { /** * Gets conversion events related metrics. * - * @since n.e.x.t + * @since 1.142.0 * @private * * @return {Object} Metrics list object. @@ -388,7 +412,7 @@ export const selectors = { /** * Checks if there are conversion events for the user picked metrics. * - * @since n.e.x.t + * @since 1.142.0 * * @param {boolean} useNewEvents Flag inclusion of detected new events, otherwise initial detected events will be used. * @return {boolean|undefined} `true` if there are any ACR key metrics based on the users existing selected metrics, `false` otherwise. Will return `undefined` if the data is not loaded yet. @@ -416,6 +440,118 @@ export const selectors = { ); } ), + + /** + * Checks if there are new conversion events after initial events were detected. Regardless of how KM were setup. + * + * @since 1.142.0 + * + * @return {boolean} `true` if there are metrics related to the new conversion events that differ from already detected/selected ones, `false` otherwise. + */ + haveConversionEventsWithDifferentMetrics: createRegistrySelector( + ( select ) => () => { + const isGA4Connected = + select( CORE_MODULES ).isModuleConnected( 'analytics-4' ); + + if ( ! isGA4Connected ) { + return false; + } + + const { + getDetectedEvents, + getConversionReportingEventsChange, + haveConversionEventsForUserPickedMetrics, + haveConversionEventsForTailoredMetrics, + getKeyMetricsConversionEventWidgets, + } = select( MODULES_ANALYTICS_4 ); + + const detectedEvents = getDetectedEvents(); + const conversionReportingEventsChange = + getConversionReportingEventsChange(); + + if ( + ! detectedEvents?.length || + ! conversionReportingEventsChange?.newEvents?.length || + // If events in detectedEvents do not differ from the new ones it means + // it is the initial detection, since after initial detection newEvents will + // only contain the difference in events. + isEqual( + safelySort( conversionReportingEventsChange?.newEvents ), + safelySort( detectedEvents ) + ) + ) { + return false; + } + + const detectedLeadEvents = detectedEvents.filter( ( event ) => + CONVERSION_REPORTING_LEAD_EVENTS.includes( event ) + ); + const newLeadEvents = + conversionReportingEventsChange.newEvents.filter( ( event ) => + CONVERSION_REPORTING_LEAD_EVENTS.includes( event ) + ); + const newNonLeadEvents = + conversionReportingEventsChange.newEvents.filter( + ( event ) => + ! CONVERSION_REPORTING_LEAD_EVENTS.includes( event ) + ); + + // If new events include only additional lead events return early. + if ( + detectedLeadEvents.length > 1 && + newLeadEvents.length > 0 && + ! newNonLeadEvents.length + ) { + return false; + } + + const { getUserPickedMetrics, getKeyMetrics } = select( CORE_USER ); + + const userPickedMetrics = getUserPickedMetrics(); + const haveNewConversionEventsForUserPickedMetrics = + haveConversionEventsForUserPickedMetrics( true ); + + if ( + userPickedMetrics?.length && + ! haveNewConversionEventsForUserPickedMetrics + ) { + return false; + } + + const keyMetricsConversionEventWidgets = + getKeyMetricsConversionEventWidgets(); + const newConversionEventKeyMetrics = []; + + // Pick all conversion event widgets associated with new events. + for ( const event in keyMetricsConversionEventWidgets ) { + if ( + conversionReportingEventsChange.newEvents.includes( event ) + ) { + newConversionEventKeyMetrics.push( + ...keyMetricsConversionEventWidgets[ event ] + ); + } + } + const currentKeyMetrics = getKeyMetrics(); + const haveAllConversionEventMetrics = + newConversionEventKeyMetrics.every( ( keyMetric ) => + currentKeyMetrics?.includes( keyMetric ) + ); + + // If the current site purpose has all conversion event metrics, + // or there are some metrics that can be added via "Add + // metrics CTA", don't show the "View metrics" variation. + if ( + ! userPickedMetrics?.length && + ( haveConversionEventsForTailoredMetrics( true ) || + haveAllConversionEventMetrics ) + ) { + return false; + } + + return true; + } + ), }; export default combineStores( diff --git a/assets/js/modules/analytics-4/datastore/conversion-reporting.test.js b/assets/js/modules/analytics-4/datastore/conversion-reporting.test.js index 52b803bbdc8..07846bc9842 100644 --- a/assets/js/modules/analytics-4/datastore/conversion-reporting.test.js +++ b/assets/js/modules/analytics-4/datastore/conversion-reporting.test.js @@ -69,6 +69,7 @@ describe( 'modules/analytics-4 conversion-reporting', () => { const data = { newEvents: [ 'purchase' ], lostEvents: [], + newBadgeEvents: [ 'purchase' ], }; await registry @@ -177,6 +178,7 @@ describe( 'modules/analytics-4 conversion-reporting', () => { const inlineData = { newEvents: [ 'contact' ], lostEvents: [], + newBadgeEvents: [ 'contact' ], }; global._googlesitekitModulesData = { @@ -302,6 +304,7 @@ describe( 'modules/analytics-4 conversion-reporting', () => { .receiveConversionReportingInlineData( { newEvents: [ 'contact' ], lostEvents: [], + newBadgeEvents: [], } ); const haveConversionEventsForTailoredMetrics = registry @@ -336,6 +339,7 @@ describe( 'modules/analytics-4 conversion-reporting', () => { .receiveConversionReportingInlineData( { newEvents: [ 'add_to_cart' ], lostEvents: [], + newBadgeEvents: [], } ); const haveConversionEventsForTailoredMetrics = registry @@ -394,7 +398,6 @@ describe( 'modules/analytics-4 conversion-reporting', () => { registry.dispatch( CORE_USER ).receiveGetKeyMetricsSettings( { widgetSlugs: [], isWidgetHidden: false, - includeConversionTailoredMetrics: [ 'contact' ], } ); } ); @@ -409,6 +412,10 @@ describe( 'modules/analytics-4 conversion-reporting', () => { registry.dispatch( CORE_USER ).receiveGetUserInputSettings( { purpose: { values: [ 'publish_blog' ] }, + includeConversionEvents: { + values: [ 'contact' ], + scope: 'site', + }, } ); registry @@ -420,6 +427,7 @@ describe( 'modules/analytics-4 conversion-reporting', () => { .receiveConversionReportingInlineData( { newEvents: [ 'contact' ], lostEvents: [ 'purchase' ], + newBadgeEvents: [], } ); const haveLostEventsForCurrentMetrics = registry @@ -433,12 +441,12 @@ describe( 'modules/analytics-4 conversion-reporting', () => { registry .dispatch( CORE_USER ) .receiveIsUserInputCompleted( true ); - registry.dispatch( CORE_USER ).receiveGetKeyMetricsSettings( { - widgetSlugs: [], - includeConversionTailoredMetrics: [ 'contact' ], - } ); registry.dispatch( CORE_USER ).receiveGetUserInputSettings( { purpose: { values: [ 'publish_blog' ] }, + includeConversionEvents: { + values: [ 'contact' ], + scope: 'site', + }, } ); registry @@ -450,6 +458,7 @@ describe( 'modules/analytics-4 conversion-reporting', () => { .receiveConversionReportingInlineData( { newEvents: [], lostEvents: [ 'contact' ], + newBadgeEvents: [], } ); const haveLostEventsForCurrentMetrics = registry @@ -477,6 +486,7 @@ describe( 'modules/analytics-4 conversion-reporting', () => { .receiveConversionReportingInlineData( { newEvents: [ 'contact' ], lostEvents: [ 'purchase' ], + newBadgeEvents: [], } ); const haveLostEventsForCurrentMetrics = registry @@ -505,6 +515,7 @@ describe( 'modules/analytics-4 conversion-reporting', () => { .receiveConversionReportingInlineData( { newEvents: [ 'contact' ], lostEvents: [ 'purchase' ], + newBadgeEvents: [], } ); const haveLostEventsForCurrentMetrics = registry @@ -533,6 +544,7 @@ describe( 'modules/analytics-4 conversion-reporting', () => { .receiveConversionReportingInlineData( { newEvents: [ 'purchase' ], lostEvents: [ 'contact' ], + newBadgeEvents: [], } ); const haveLostEventsForCurrentMetrics = registry diff --git a/assets/js/modules/analytics-4/datastore/settings.js b/assets/js/modules/analytics-4/datastore/settings.js index d229a2ec5dc..e1986ac3833 100644 --- a/assets/js/modules/analytics-4/datastore/settings.js +++ b/assets/js/modules/analytics-4/datastore/settings.js @@ -52,6 +52,8 @@ import { } from './constants'; import { isValidConversionID } from '../../ads/utils/validation'; import { CORE_SITE } from '../../../googlesitekit/datastore/site/constants'; +import { CORE_NOTIFICATIONS } from '../../../googlesitekit/notifications/datastore/constants'; +import { FPM_SETUP_CTA_BANNER_NOTIFICATION } from '../../../googlesitekit/notifications/constants'; // Invariant error messages. export const INVARIANT_INVALID_PROPERTY_SELECTION = @@ -191,6 +193,34 @@ async function saveSettings( select, dispatch ) { } } + const haveFirstPartyModeSettingsChanged = + select( CORE_SITE ).haveFirstPartyModeSettingsChanged(); + if ( haveFirstPartyModeSettingsChanged ) { + const { error } = await dispatch( + CORE_SITE + ).saveFirstPartyModeSettings(); + + if ( error ) { + return { error }; + } + + if ( + select( CORE_SITE ).isFirstPartyModeEnabled() && + ! select( CORE_NOTIFICATIONS ).isNotificationDismissed( + FPM_SETUP_CTA_BANNER_NOTIFICATION + ) + ) { + const { error: dismissError } = + ( await dispatch( CORE_NOTIFICATIONS ).dismissNotification( + FPM_SETUP_CTA_BANNER_NOTIFICATION + ) ) || {}; + + if ( dismissError ) { + return { error: dismissError }; + } + } + } + return {}; } diff --git a/assets/js/modules/analytics-4/datastore/settings.test.js b/assets/js/modules/analytics-4/datastore/settings.test.js index 630960daf02..6578325a45a 100644 --- a/assets/js/modules/analytics-4/datastore/settings.test.js +++ b/assets/js/modules/analytics-4/datastore/settings.test.js @@ -23,13 +23,17 @@ import API from 'googlesitekit-api'; import { createTestRegistry, muteFetch, + provideNotifications, provideUserAuthentication, untilResolved, } from '../../../../../tests/js/utils'; import { withActive } from '../../../googlesitekit/modules/datastore/__fixtures__'; import { CORE_FORMS } from '../../../googlesitekit/datastore/forms/constants'; import { CORE_MODULES } from '../../../googlesitekit/modules/datastore/constants'; +import { CORE_SITE } from '../../../googlesitekit/datastore/site/constants'; import { CORE_USER } from '../../../googlesitekit/datastore/user/constants'; +import { FPM_SETUP_CTA_BANNER_NOTIFICATION } from '../../../googlesitekit/notifications/constants'; +import { DEFAULT_NOTIFICATIONS } from '../../../googlesitekit/notifications/register-defaults'; import { ENHANCED_MEASUREMENT_ENABLED, ENHANCED_MEASUREMENT_FORM, @@ -58,9 +62,6 @@ describe( 'modules/analytics-4 settings', () => { data: { status: 500 }, }; - const settingsEndpoint = new RegExp( - '^/google-site-kit/v1/modules/analytics-4/data/settings' - ); const createPropertyEndpoint = new RegExp( '^/google-site-kit/v1/modules/analytics-4/data/create-property' ); @@ -86,6 +87,16 @@ describe( 'modules/analytics-4 settings', () => { describe( 'actions', () => { describe( 'submitChanges', () => { + const settingsEndpoint = new RegExp( + '^/google-site-kit/v1/modules/analytics-4/data/settings' + ); + const fpmSettingsEndpoint = new RegExp( + '^/google-site-kit/v1/core/site/data/fpm-settings' + ); + const dismissItemEndpoint = new RegExp( + '^/google-site-kit/v1/core/user/data/dismiss-item' + ); + beforeEach( () => { provideUserAuthentication( registry ); @@ -371,10 +382,6 @@ describe( 'modules/analytics-4 settings', () => { [ ENHANCED_MEASUREMENT_SHOULD_DISMISS_ACTIVATION_BANNER ]: true, } ); - const dismissItemEndpoint = new RegExp( - '^/google-site-kit/v1/core/user/data/dismiss-item' - ); - fetchMock.postOnce( dismissItemEndpoint, { body: JSON.stringify( [ ENHANCED_MEASUREMENT_ACTIVATION_BANNER_DISMISSED_ITEM_KEY, @@ -496,6 +503,336 @@ describe( 'modules/analytics-4 settings', () => { ).toBe( false ); } ); + it( 'should send a POST request to the FPM settings endpoint when the toggle state is changed', async () => { + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( { + isEnabled: false, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + + registry + .dispatch( CORE_USER ) + .receiveGetDismissedItems( [ + FPM_SETUP_CTA_BANNER_NOTIFICATION, + ] ); + + const validSettings = { + accountID: fixtures.createProperty._accountID, + propertyID: fixtures.createProperty._id, + webDataStreamID: fixtures.createWebDataStream._id, + }; + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setSettings( validSettings ); + + fetchMock.postOnce( settingsEndpoint, { + body: validSettings, + status: 200, + } ); + + fetchMock.postOnce( fpmSettingsEndpoint, ( url, opts ) => { + const { + data: { + settings: { isEnabled }, + }, + } = JSON.parse( opts.body ); + + return { + body: { + isEnabled, // Return the `isEnabled` value passed to the API. + isFPMHealthy: true, + isScriptAccessEnabled: true, + }, + status: 200, + }; + } ); + + registry.dispatch( CORE_SITE ).setFirstPartyModeEnabled( true ); + await registry.dispatch( MODULES_ANALYTICS_4 ).submitChanges(); + + expect( fetchMock ).toHaveFetched( fpmSettingsEndpoint, { + body: { + data: { + settings: { isEnabled: true }, + }, + }, + } ); + } ); + + it( 'should handle an error when sending a POST request to the FPM settings endpoint', async () => { + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( { + isEnabled: false, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + + const validSettings = { + accountID: fixtures.createProperty._accountID, + propertyID: fixtures.createProperty._id, + webDataStreamID: fixtures.createWebDataStream._id, + }; + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setSettings( validSettings ); + + fetchMock.postOnce( settingsEndpoint, { + body: validSettings, + status: 200, + } ); + + fetchMock.postOnce( fpmSettingsEndpoint, { + body: error, + status: 500, + } ); + + registry.dispatch( CORE_SITE ).setFirstPartyModeEnabled( true ); + const { error: submitChangesError } = await registry + .dispatch( MODULES_ANALYTICS_4 ) + .submitChanges(); + + expect( submitChangesError ).toEqual( error ); + + expect( console ).toHaveErrored(); + } ); + + it( 'should not send a POST request to the FPM settings endpoint when the toggle state is changed', async () => { + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( { + isEnabled: false, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + + const validSettings = { + accountID: fixtures.createProperty._accountID, + propertyID: fixtures.createProperty._id, + webDataStreamID: fixtures.createWebDataStream._id, + }; + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setSettings( validSettings ); + + fetchMock.postOnce( settingsEndpoint, { + body: validSettings, + status: 200, + } ); + + await registry.dispatch( MODULES_ANALYTICS_4 ).submitChanges(); + + expect( fetchMock ).not.toHaveFetched( fpmSettingsEndpoint ); + } ); + + it( 'should dismiss the FPM setup CTA banner when the FPM `isEnabled` setting is changed to `true`', async () => { + provideNotifications( + registry, + { + [ FPM_SETUP_CTA_BANNER_NOTIFICATION ]: + DEFAULT_NOTIFICATIONS[ + FPM_SETUP_CTA_BANNER_NOTIFICATION + ], + }, + { overwrite: true } + ); + + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( { + isEnabled: false, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + + registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] ); + + const validSettings = { + accountID: fixtures.createProperty._accountID, + propertyID: fixtures.createProperty._id, + webDataStreamID: fixtures.createWebDataStream._id, + }; + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setSettings( validSettings ); + + fetchMock.postOnce( settingsEndpoint, { + body: validSettings, + status: 200, + } ); + + fetchMock.postOnce( fpmSettingsEndpoint, ( url, opts ) => { + const { + data: { + settings: { isEnabled }, + }, + } = JSON.parse( opts.body ); + + return { + body: { + isEnabled, // Return the `isEnabled` value passed to the API. + isFPMHealthy: true, + isScriptAccessEnabled: true, + }, + status: 200, + }; + } ); + + fetchMock.postOnce( dismissItemEndpoint, { + body: [ FPM_SETUP_CTA_BANNER_NOTIFICATION ], + status: 200, + } ); + + registry.dispatch( CORE_SITE ).setFirstPartyModeEnabled( true ); + await registry.dispatch( MODULES_ANALYTICS_4 ).submitChanges(); + + expect( fetchMock ).toHaveFetched( dismissItemEndpoint, { + body: { + data: { + slug: FPM_SETUP_CTA_BANNER_NOTIFICATION, + expiration: 0, + }, + }, + } ); + expect( fetchMock ).toHaveFetchedTimes( 3 ); + } ); + it( 'should handle an error when dismissing the FPM setup CTA banner', async () => { + provideNotifications( + registry, + { + [ FPM_SETUP_CTA_BANNER_NOTIFICATION ]: + DEFAULT_NOTIFICATIONS[ + FPM_SETUP_CTA_BANNER_NOTIFICATION + ], + }, + { overwrite: true } + ); + + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( { + isEnabled: false, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + + registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] ); + + const validSettings = { + accountID: fixtures.createProperty._accountID, + propertyID: fixtures.createProperty._id, + webDataStreamID: fixtures.createWebDataStream._id, + }; + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setSettings( validSettings ); + + fetchMock.postOnce( settingsEndpoint, { + body: validSettings, + status: 200, + } ); + + fetchMock.postOnce( fpmSettingsEndpoint, ( url, opts ) => { + const { + data: { + settings: { isEnabled }, + }, + } = JSON.parse( opts.body ); + + return { + body: { + isEnabled, // Return the `isEnabled` value passed to the API. + isFPMHealthy: true, + isScriptAccessEnabled: true, + }, + status: 200, + }; + } ); + + fetchMock.postOnce( dismissItemEndpoint, { + body: error, + status: 500, + } ); + + registry.dispatch( CORE_SITE ).setFirstPartyModeEnabled( true ); + const { error: submitChangesError } = await registry + .dispatch( MODULES_ANALYTICS_4 ) + .submitChanges(); + + expect( submitChangesError ).toEqual( error ); + expect( console ).toHaveErrored(); + } ); + + it( 'should not dismiss the FPM setup CTA banner when the FPM `isEnabled` setting is changed to `false`', async () => { + provideNotifications( + registry, + { + [ FPM_SETUP_CTA_BANNER_NOTIFICATION ]: + DEFAULT_NOTIFICATIONS[ + FPM_SETUP_CTA_BANNER_NOTIFICATION + ], + }, + { overwrite: true } + ); + + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( { + isEnabled: true, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + + registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] ); + + const validSettings = { + accountID: fixtures.createProperty._accountID, + propertyID: fixtures.createProperty._id, + webDataStreamID: fixtures.createWebDataStream._id, + }; + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setSettings( validSettings ); + + fetchMock.postOnce( settingsEndpoint, { + body: validSettings, + status: 200, + } ); + + fetchMock.postOnce( fpmSettingsEndpoint, ( url, opts ) => { + const { + data: { + settings: { isEnabled }, + }, + } = JSON.parse( opts.body ); + + return { + body: { + isEnabled, // Return the `isEnabled` value passed to the API. + isFPMHealthy: true, + isScriptAccessEnabled: true, + }, + status: 200, + }; + } ); + + registry + .dispatch( CORE_SITE ) + .setFirstPartyModeEnabled( false ); + await registry.dispatch( MODULES_ANALYTICS_4 ).submitChanges(); + + expect( fetchMock ).not.toHaveFetched( dismissItemEndpoint ); + expect( fetchMock ).toHaveFetchedTimes( 2 ); + } ); + it( 'should reset audience settings in the store when Analytics settings have successfully saved', async () => { const analyticsSettings = { accountID: fixtures.createProperty._accountID, @@ -602,6 +939,36 @@ describe( 'modules/analytics-4 settings', () => { ); } ); } ); + + describe( 'rollbackChanges', () => { + it( 'should rollback to the original settings', () => { + const validSettings = { + accountID: fixtures.createProperty._accountID, + propertyID: fixtures.createProperty._id, + webDataStreamID: fixtures.createWebDataStream._id, + }; + + registry + .dispatch( MODULES_ANALYTICS_4 ) + .setSettings( validSettings ); + + registry + .dispatch( CORE_SITE ) + .receiveGetFirstPartyModeSettings( { + isEnabled: false, + isFPMHealthy: true, + isScriptAccessEnabled: true, + } ); + + registry.dispatch( CORE_SITE ).setFirstPartyModeEnabled( true ); + + registry.dispatch( MODULES_ANALYTICS_4 ).rollbackChanges(); + + expect( + registry.select( CORE_SITE ).isFirstPartyModeEnabled() + ).toBe( false ); + } ); + } ); } ); describe( 'selectors', () => { diff --git a/assets/js/modules/sign-in-with-google/components/common/AnyoneCanRegisterDisabledNotice.js b/assets/js/modules/sign-in-with-google/components/common/AnyoneCanRegisterDisabledNotice.js new file mode 100644 index 00000000000..eb5bb2efac7 --- /dev/null +++ b/assets/js/modules/sign-in-with-google/components/common/AnyoneCanRegisterDisabledNotice.js @@ -0,0 +1,130 @@ +/** + * Site Kit by Google, Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { createInterpolateElement, Fragment } from '@wordpress/element'; +import { __, _x, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { useSelect, useDispatch } from 'googlesitekit-data'; +import { + CORE_USER, + PERMISSION_MANAGE_OPTIONS, +} from '../../../../googlesitekit/datastore/user/constants'; +import { CORE_SITE } from '../../../../googlesitekit/datastore/site/constants'; +import { + BREAKPOINT_DESKTOP, + BREAKPOINT_XLARGE, + useBreakpoint, +} from '../../../../hooks/useBreakpoint'; +import SettingsNotice, { + TYPE_INFO, +} from '../../../../components/SettingsNotice'; +import Link from '../../../../components/Link'; +import InfoIcon from '../../../../../svg/icons/info-circle.svg'; + +const ANYONE_CAN_REGISTER_DISABLED_NOTICE = + 'sign-in-with-google-anyone-can-register-notice'; + +export default function AnyoneCanRegisterDisabledNotice( { className } ) { + const breakpoint = useBreakpoint(); + + const canManageOptions = useSelect( ( select ) => + select( CORE_USER ).hasCapability( PERMISSION_MANAGE_OPTIONS ) + ); + const isMultisite = useSelect( ( select ) => + select( CORE_SITE ).isMultisite() + ); + const generalSettingsURL = useSelect( + ( select ) => + new URL( + isMultisite ? 'network/settings.php' : 'options-general.php', + select( CORE_SITE ).getAdminURL() + ).href + ); + + const anyoneCanRegister = useSelect( ( select ) => + select( CORE_SITE ).getAnyoneCanRegister() + ); + const isDismissed = useSelect( ( select ) => + select( CORE_USER ).isItemDismissed( + ANYONE_CAN_REGISTER_DISABLED_NOTICE + ) + ); + + const { dismissItem } = useDispatch( CORE_USER ); + + if ( isDismissed === true || anyoneCanRegister === true ) { + return null; + } + + return ( + + dismissItem( ANYONE_CAN_REGISTER_DISABLED_NOTICE ) + } + dismissLabel={ __( 'Got it', 'google-site-kit' ) } + notice={ createInterpolateElement( + sprintf( + /* translators: %1$s: Setting name, %2$s: Sign in with Google service name */ + __( + 'Enable the %1$s setting to allow your visitors to create an account using the %2$s button.
Visit WordPress Settings to manage this setting.', + 'google-site-kit' + ), + isMultisite + ? __( '“Allow new registrations”', 'google-site-kit' ) + : __( '“Anyone can register”', 'google-site-kit' ), + _x( + 'Sign in with Google', + 'Service name', + 'google-site-kit' + ) + ), + { + a: + ! canManageOptions && isMultisite ? ( + + ) : ( + + ), + br: + breakpoint === BREAKPOINT_XLARGE || + breakpoint === BREAKPOINT_DESKTOP ? ( +
+ ) : ( + + ), + } + ) } + /> + ); +} diff --git a/assets/js/modules/sign-in-with-google/components/common/AnyoneCanRegisterReadOnly.js b/assets/js/modules/sign-in-with-google/components/common/AnyoneCanRegisterReadOnly.js new file mode 100644 index 00000000000..9a2efe259f7 --- /dev/null +++ b/assets/js/modules/sign-in-with-google/components/common/AnyoneCanRegisterReadOnly.js @@ -0,0 +1,115 @@ +/** + * Site Kit by Google, Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * WordPress dependencies + */ +import { createInterpolateElement, Fragment } from '@wordpress/element'; +import { __, _x, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { useSelect } from 'googlesitekit-data'; +import { + CORE_USER, + PERMISSION_MANAGE_OPTIONS, +} from '../../../../googlesitekit/datastore/user/constants'; +import { CORE_SITE } from '../../../../googlesitekit/datastore/site/constants'; +import { + BREAKPOINT_SMALL, + useBreakpoint, +} from '../../../../hooks/useBreakpoint'; +import { HelperText } from 'googlesitekit-components'; +import Link from '../../../../components/Link'; + +export default function AnyoneCanRegisterReadOnly() { + const breakpoint = useBreakpoint(); + + const anyoneCanRegister = useSelect( ( select ) => + select( CORE_SITE ).getAnyoneCanRegister() + ); + const canManageOptions = useSelect( ( select ) => + select( CORE_USER ).hasCapability( PERMISSION_MANAGE_OPTIONS ) + ); + const isMultisite = useSelect( ( select ) => + select( CORE_SITE ).isMultisite() + ); + const generalSettingsURL = useSelect( + ( select ) => + new URL( + isMultisite ? 'network/settings.php' : 'options-general.php', + select( CORE_SITE ).getAdminURL() + ).href + ); + + return ( +
+ { __( 'User registration', 'google-site-kit' ) } + { anyoneCanRegister && ( + + { createInterpolateElement( + sprintf( + /* translators: %s: Sign in with Google service name */ + __( + 'Users can create new accounts on this site using %s.
Visit WP settings page to manage this membership setting.', + 'google-site-kit' + ), + _x( + 'Sign in with Google', + 'Service name', + 'google-site-kit' + ) + ), + { + a: + ! canManageOptions && isMultisite ? ( + + ) : ( + + ), + br: + breakpoint !== BREAKPOINT_SMALL ? ( +
+ ) : ( + + ), + } + ) } +
+ ) } + { anyoneCanRegister === false && ( + + { sprintf( + /* translators: %s: Sign in with Google service name */ + __( + 'Only existing users can use %s to access their accounts.', + 'google-site-kit' + ), + _x( + 'Sign in with Google', + 'Service name', + 'google-site-kit' + ) + ) } + + ) } +
+ ); +} diff --git a/assets/js/modules/sign-in-with-google/components/common/ClientIDTextField.js b/assets/js/modules/sign-in-with-google/components/common/ClientIDTextField.js index 43bfbe73474..1412cee4f11 100644 --- a/assets/js/modules/sign-in-with-google/components/common/ClientIDTextField.js +++ b/assets/js/modules/sign-in-with-google/components/common/ClientIDTextField.js @@ -20,11 +20,12 @@ * External dependencies */ import classnames from 'classnames'; +import PropTypes from 'prop-types'; /** * WordPress dependencies */ -import { useCallback, useState } from '@wordpress/element'; +import { useCallback, useState, useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; /** @@ -36,17 +37,30 @@ import { MODULES_SIGN_IN_WITH_GOOGLE } from '../../datastore/constants'; import { isValidClientID } from '../../utils/validation'; import { useDebounce } from '../../../../hooks/useDebounce'; -export default function ClientIDTextField() { +export default function ClientIDTextField( { existingClientID = '' } ) { const clientID = useSelect( ( select ) => select( MODULES_SIGN_IN_WITH_GOOGLE ).getClientID() ); + const [ existingClientIDWasSet, setExistingClientIDWasSet ] = + useState( false ); + const [ isValid, setIsValid ] = useState( ! clientID || isValidClientID( clientID ) ); const debounceSetIsValid = useDebounce( setIsValid, 500 ); const { setClientID } = useDispatch( MODULES_SIGN_IN_WITH_GOOGLE ); + + useEffect( () => { + if ( ! clientID && existingClientID && ! existingClientIDWasSet ) { + setClientID( existingClientID ); + // Prevent the existingClientID from prefilling again when a user + // clears the clientID in the field fully. + setExistingClientIDWasSet( true ); + } + }, [ clientID, setClientID, existingClientID, existingClientIDWasSet ] ); + const onChange = useCallback( ( { currentTarget } ) => { const newValue = currentTarget.value; @@ -60,6 +74,21 @@ export default function ClientIDTextField() { [ clientID, setClientID, debounceSetIsValid ] ); + let helperText; + if ( ! isValid ) { + helperText = __( + 'A valid Client ID is required to use Sign in with Google', + 'google-site-kit' + ); + } + + if ( existingClientID && clientID === existingClientID ) { + helperText = __( + 'Sign in with Google was already set up on this site. We recommend using your existing Client ID.', + 'google-site-kit' + ); + } + return (
); } + +ClientIDTextField.propTypes = { + existingClientID: PropTypes.string, +}; diff --git a/assets/js/modules/sign-in-with-google/components/common/Preview.js b/assets/js/modules/sign-in-with-google/components/common/Preview.js index 729d60dd4f3..2996a9c34ce 100644 --- a/assets/js/modules/sign-in-with-google/components/common/Preview.js +++ b/assets/js/modules/sign-in-with-google/components/common/Preview.js @@ -16,17 +16,73 @@ * limitations under the License. */ +/** + * WordPress dependencies + */ +import { useState, useEffect, useRef } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { useSelect } from 'googlesitekit-data'; +import { MODULES_SIGN_IN_WITH_GOOGLE } from '../../datastore/constants'; + export default function Preview() { - // TODO: this preview of the Sign in with Google button will be implemented in a future epic ticket. + const [ scriptLoaded, setScriptLoaded ] = useState( false ); + const containerRef = useRef(); + + const shape = useSelect( ( select ) => + select( MODULES_SIGN_IN_WITH_GOOGLE ).getShape() + ); + const text = useSelect( ( select ) => + select( MODULES_SIGN_IN_WITH_GOOGLE ).getText() + ); + const theme = useSelect( ( select ) => + select( MODULES_SIGN_IN_WITH_GOOGLE ).getTheme() + ); + + useEffect( () => { + const script = document.createElement( 'script' ); + const onLoad = () => { + setScriptLoaded( true ); + + // Using a fake client ID here since the user won't be able + // to click on the button anyway. + global.google.accounts.id.initialize( { + client_id: 'notrealclientid', + } ); + }; + + script.src = 'https://accounts.google.com/gsi/client'; + script.addEventListener( 'load', onLoad ); + + document.body.appendChild( script ); + + return () => { + setScriptLoaded( false ); + script.removeEventListener( 'load', onLoad ); + document.body.removeChild( script ); + }; + }, [ setScriptLoaded ] ); + + useEffect( () => { + if ( scriptLoaded ) { + global.google.accounts.id.renderButton( containerRef.current, { + text, + theme, + shape, + } ); + } + }, [ scriptLoaded, containerRef, text, theme, shape ] ); + return ( -
+
+

+ { __( 'Preview', 'google-site-kit' ) } +

+
+
+
); } diff --git a/assets/js/modules/sign-in-with-google/components/common/index.js b/assets/js/modules/sign-in-with-google/components/common/index.js index bea91fec119..f1c81bf4393 100644 --- a/assets/js/modules/sign-in-with-google/components/common/index.js +++ b/assets/js/modules/sign-in-with-google/components/common/index.js @@ -20,5 +20,7 @@ export { default as ButtonTextSelect } from './ButtonTextSelect'; export { default as ButtonThemeSelect } from './ButtonThemeSelect'; export { default as ButtonShapeSelect } from './ButtonShapeSelect'; export { default as ClientIDTextField } from './ClientIDTextField'; +export { default as AnyoneCanRegisterReadOnly } from './AnyoneCanRegisterReadOnly'; +export { default as AnyoneCanRegisterDisabledNotice } from './AnyoneCanRegisterDisabledNotice'; export { default as OneTapToggle } from './OneTapToggle'; export { default as Preview } from './Preview'; diff --git a/assets/js/modules/sign-in-with-google/components/dashboard/SetupSuccessSubtleNotification.js b/assets/js/modules/sign-in-with-google/components/dashboard/SetupSuccessSubtleNotification.js new file mode 100644 index 00000000000..3267efe01dd --- /dev/null +++ b/assets/js/modules/sign-in-with-google/components/dashboard/SetupSuccessSubtleNotification.js @@ -0,0 +1,92 @@ +/** + * SetupSuccessSubtleNotification component. + * + * Site Kit by Google, Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * WordPress dependencies + */ +import { __, _x, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import SubtleNotification from '../../../../googlesitekit/notifications/components/layout/SubtleNotification'; +import Dismiss from '../../../../googlesitekit/notifications/components/common/Dismiss'; +import CTALinkSubtle from '../../../../googlesitekit/notifications/components/common/CTALinkSubtle'; +import useQueryArg from '../../../../hooks/useQueryArg'; +import { useSelect } from 'googlesitekit-data'; +import { CORE_SITE } from '../../../../googlesitekit/datastore/site/constants'; + +export default function SetupSuccessSubtleNotification( { id, Notification } ) { + const [ , setNotification ] = useQueryArg( 'notification' ); + const [ , setSlug ] = useQueryArg( 'slug' ); + + const settingsURL = useSelect( ( select ) => + select( CORE_SITE ).getAdminURL( 'googlesitekit-settings' ) + ); + + const onDismiss = () => { + setNotification( undefined ); + setSlug( undefined ); + }; + + return ( + + + } + additionalCTA={ + + } + /> + + ); +} diff --git a/assets/js/modules/sign-in-with-google/components/dashboard/SetupSuccessSubtleNotification.stories.js b/assets/js/modules/sign-in-with-google/components/dashboard/SetupSuccessSubtleNotification.stories.js new file mode 100644 index 00000000000..32692acb334 --- /dev/null +++ b/assets/js/modules/sign-in-with-google/components/dashboard/SetupSuccessSubtleNotification.stories.js @@ -0,0 +1,61 @@ +/** + * SetupSuccessSubtleNotification Component Stories. + * + * Site Kit by Google, Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { withQuery } from '@storybook/addon-queryparams'; + +/** + * Internal dependencies + */ +import SetupSuccessSubtleNotification from './SetupSuccessSubtleNotification'; +import { WithTestRegistry } from '../../../../../../tests/js/utils'; +import { withNotificationComponentProps } from '../../../../googlesitekit/notifications/util/component-props'; + +const NotificationWithComponentProps = withNotificationComponentProps( + 'setup-success-notification-siwg' +)( SetupSuccessSubtleNotification ); + +function Template( { ...args } ) { + return ; +} + +export const Default = Template.bind( {} ); +Default.storyName = 'SetupSuccessSubtleNotification'; +Default.parameters = { + query: { + notification: 'authentication_success', + slug: 'sign-in-with-google', + }, +}; + +export default { + title: 'Modules/SignInWithGoogle/Dashboard/SetupSuccessSubtleNotification', + component: SetupSuccessSubtleNotification, + decorators: [ + withQuery, + ( Story ) => { + return ( + + + + ); + }, + ], +}; diff --git a/assets/js/modules/sign-in-with-google/components/dashboard/SignInWithGoogleSetupCTABanner.js b/assets/js/modules/sign-in-with-google/components/dashboard/SignInWithGoogleSetupCTABanner.js index cd4cb266caf..96b285bd78a 100644 --- a/assets/js/modules/sign-in-with-google/components/dashboard/SignInWithGoogleSetupCTABanner.js +++ b/assets/js/modules/sign-in-with-google/components/dashboard/SignInWithGoogleSetupCTABanner.js @@ -20,17 +20,19 @@ * External dependencies */ import PropTypes from 'prop-types'; +import { useMount } from 'react-use'; /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { __, _x, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ -import { useSelect } from 'googlesitekit-data'; +import { useSelect, useDispatch } from 'googlesitekit-data'; import { CORE_SITE } from '../../../../googlesitekit/datastore/site/constants'; +import { CORE_USER } from '../../../../googlesitekit/datastore/user/constants'; import useActivateModuleCallback from '../../../../hooks/useActivateModuleCallback'; import NotificationWithSVG from '../../../../googlesitekit/notifications/components/layout/NotificationWithSVG'; import Description from '../../../../googlesitekit/notifications/components/common/Description'; @@ -45,22 +47,43 @@ export default function SignInWithGoogleSetupCTABanner( { id, Notification } ) { ); } ); + const { triggerSurvey } = useDispatch( CORE_USER ); + useMount( () => { + triggerSurvey( 'view_siwg_setup_cta' ); + } ); + const onSetupActivate = useActivateModuleCallback( 'sign-in-with-google' ); return ( { + ( Story, { args } ) => { const setupRegistry = ( registry ) => { + const { anyoneCanRegister = false } = args; + provideSiteInfo( registry, { anyoneCanRegister } ); + registry .dispatch( MODULES_SIGN_IN_WITH_GOOGLE ) .receiveGetSettings( { @@ -60,6 +71,11 @@ Default.decorators = [ shape: 'rectangular', OneTapEnabled: true, } ); + + // Story-specific setup. + if ( args.setupRegistry ) { + args.setupRegistry( registry ); + } }; return ( @@ -72,12 +88,11 @@ Default.decorators = [ export const InvalidClientID = Template.bind( null ); InvalidClientID.storyName = 'Invalid Client ID'; -InvalidClientID.scenario = { - label: 'Modules/Sign in with Google/Settings/SettingsForm/Invalid Client ID', -}; InvalidClientID.decorators = [ ( Story ) => { const setupRegistry = ( registry ) => { + provideSiteInfo( registry, { anyoneCanRegister: true } ); + registry .dispatch( MODULES_SIGN_IN_WITH_GOOGLE ) .receiveGetSettings( { @@ -97,11 +112,15 @@ InvalidClientID.decorators = [ }, ]; +export const Empty = Template.bind( null ); +Empty.storyName = 'Empty'; + export default { title: 'Modules/SignInWithGoogle/Settings/SettingsEdit', decorators: [ ( Story ) => { const setupRegistry = ( registry ) => { + provideSiteInfo( registry, { anyoneCanRegister: true } ); provideModules( registry, [ { slug: 'sign-in-with-google', @@ -119,9 +138,3 @@ export default { }, ], }; - -export const Empty = Template.bind( null ); -Empty.storyName = 'Empty'; -Empty.scenario = { - label: 'Modules/SignInWithGoogle/Settings/SettingsForm/Empty', -}; diff --git a/assets/js/modules/sign-in-with-google/components/settings/SettingsForm.js b/assets/js/modules/sign-in-with-google/components/settings/SettingsForm.js index 3548aa4a4a1..776b1a4ae26 100644 --- a/assets/js/modules/sign-in-with-google/components/settings/SettingsForm.js +++ b/assets/js/modules/sign-in-with-google/components/settings/SettingsForm.js @@ -20,11 +20,14 @@ * Internal dependencies */ import { + AnyoneCanRegisterReadOnly, + AnyoneCanRegisterDisabledNotice, ButtonShapeSelect, ButtonTextSelect, ButtonThemeSelect, ClientIDTextField, OneTapToggle, + Preview, } from '../common'; import { MODULES_SIGN_IN_WITH_GOOGLE } from '../../datastore/constants'; import StoreErrorNotices from '../../../../components/StoreErrorNotices'; @@ -59,9 +62,27 @@ export default function SettingsForm() { + + + + + + + + + + + + + + +
diff --git a/assets/js/modules/sign-in-with-google/components/settings/SettingsView.js b/assets/js/modules/sign-in-with-google/components/settings/SettingsView.js index 6cd7d66bd16..274550d520a 100644 --- a/assets/js/modules/sign-in-with-google/components/settings/SettingsView.js +++ b/assets/js/modules/sign-in-with-google/components/settings/SettingsView.js @@ -20,37 +20,23 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { createInterpolateElement, Fragment } from '@wordpress/element'; /** * Internal dependencies */ -import { useDispatch, useSelect } from 'googlesitekit-data'; +import { useSelect } from 'googlesitekit-data'; +import { CORE_SITE } from '../../../../googlesitekit/datastore/site/constants'; import { MODULES_SIGN_IN_WITH_GOOGLE, SIGN_IN_WITH_GOOGLE_SHAPES, SIGN_IN_WITH_GOOGLE_TEXTS, SIGN_IN_WITH_GOOGLE_THEMES, } from '../../datastore/constants'; +import { AnyoneCanRegisterDisabledNotice } from '../common'; import StoreErrorNotices from '../../../../components/StoreErrorNotices'; import DisplaySetting from '../../../../components/DisplaySetting'; -import Link from '../../../../components/Link'; -import SettingsNotice, { - TYPE_WARNING, -} from '../../../../components/SettingsNotice'; -import { CORE_SITE } from '../../../../googlesitekit/datastore/site/constants'; -import { CORE_USER } from '../../../../googlesitekit/datastore/user/constants'; -import WarningIcon from '../../../../../svg/icons/warning.svg'; -import { - BREAKPOINT_XLARGE, - useBreakpoint, -} from '../../../../hooks/useBreakpoint'; export default function SettingsView() { - const breakpoint = useBreakpoint(); - - const { dismissItem } = useDispatch( CORE_USER ); - const clientID = useSelect( ( select ) => select( MODULES_SIGN_IN_WITH_GOOGLE ).getClientID() ); @@ -87,18 +73,6 @@ export default function SettingsView() { select( MODULES_SIGN_IN_WITH_GOOGLE ).getOneTapEnabled() ); - const generalSettingsURL = useSelect( - ( select ) => - new URL( 'options-general.php', select( CORE_SITE ).getAdminURL() ) - .href - ); - - const anyoneCanRegisterNoticeDismissed = useSelect( ( select ) => { - return select( CORE_USER ).isItemDismissed( - 'sign-in-with-google-anyone-can-register-notice' - ); - } ); - // If Sign in with Google does not have a client ID, do not display the // settings view. if ( ! clientID ) { @@ -172,10 +146,7 @@ export default function SettingsView() {
- { __( - 'Users can create new accounts', - 'google-site-kit' - ) } + { __( 'User registration', 'google-site-kit' ) }
{ anyoneCanRegister !== undefined && (

@@ -190,36 +161,7 @@ export default function SettingsView() { ) }

- - { anyoneCanRegisterNoticeDismissed === false && - anyoneCanRegister === false && ( - { - dismissItem( - 'sign-in-with-google-anyone-can-register-notice' - ); - } } - dismissLabel={ __( 'Got it', 'google-site-kit' ) } - Icon={ WarningIcon } - notice={ createInterpolateElement( - __( - 'Enable the “Anyone can register” setting to allow your visitors to create an account using the Sign in with Google button.
Visit WordPress Settings to manage this setting.', - 'google-site-kit' - ), - { - a: , - br: - breakpoint === BREAKPOINT_XLARGE ? ( -
- ) : ( - - ), - } - ) } - /> - ) } +
); } diff --git a/assets/js/modules/sign-in-with-google/components/setup/SetupForm.js b/assets/js/modules/sign-in-with-google/components/setup/SetupForm.js index 282a4eeefad..a3273b3f4c9 100644 --- a/assets/js/modules/sign-in-with-google/components/setup/SetupForm.js +++ b/assets/js/modules/sign-in-with-google/components/setup/SetupForm.js @@ -16,16 +16,26 @@ * limitations under the License. */ +/** + * External dependencies + */ +import { useMount } from 'react-use'; + /** * WordPress dependencies */ -import { lazy, Suspense, createInterpolateElement } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; +import { + lazy, + Suspense, + createInterpolateElement, + useState, +} from '@wordpress/element'; +import { __, _x, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ -import { useSelect } from 'googlesitekit-data'; +import { useRegistry, useSelect } from 'googlesitekit-data'; import StoreErrorNotices from '../../../../components/StoreErrorNotices'; import { CORE_SITE } from '../../../../googlesitekit/datastore/site/constants'; import { MODULES_SIGN_IN_WITH_GOOGLE } from '../../datastore/constants'; @@ -40,6 +50,9 @@ const LazyGraphicSVG = lazy( () => ); export default function SetupForm() { + const registry = useRegistry(); + const [ existingClientID, setExistingClientID ] = useState(); + const learnMoreURL = useSelect( ( select ) => { return select( CORE_SITE ).getDocumentationLinkURL( 'sign-in-with-google' @@ -52,6 +65,33 @@ export default function SetupForm() { ).getServiceClientIDProvisioningURL() ); + // Prefill the clientID field with a value from a previous module connection, if it exists. + useMount( async () => { + // Allow default `settings` and `savedSettings` to load before updating + // the `clientID` setting again. + await registry + .resolveSelect( MODULES_SIGN_IN_WITH_GOOGLE ) + .getSettings(); + + // The clientID is fetched again as useMount does not receive the + // updated clientID. + const currentClientID = registry + .select( MODULES_SIGN_IN_WITH_GOOGLE ) + .getClientID(); + + if ( + currentClientID === '' && + global._googlesitekitModulesData?.[ 'sign-in-with-google' ]?.[ + 'existingClientID' + ] + ) { + setExistingClientID( + global._googlesitekitModulesData[ 'sign-in-with-google' ] + .existingClientID + ); + } + } ); + return (
@@ -61,9 +101,17 @@ export default function SetupForm() { />

{ createInterpolateElement( - __( - 'To set up Sign in with Google, Site Kit will help you create an "OAuth Client ID" that will be used to enable Sign in with Google on your website. You will be directed to a page that will allow you to generate an "OAuth Client ID". Learn more', - 'google-site-kit' + sprintf( + /* translators: %1$s: Sign in with Google service name */ + __( + 'To set up %1$s, Site Kit will help you create an “OAuth Client ID“ that will be used to enable %1$s on your website. You will be directed to a page that will allow you to generate an “OAuth Client ID“. Learn more', + 'google-site-kit' + ), + _x( + 'Sign in with Google', + 'Service name', + 'google-site-kit' + ) ), { a: , @@ -77,7 +125,7 @@ export default function SetupForm() { ) }

- +