diff --git a/assets/js/components/GoogleChart/index.js b/assets/js/components/GoogleChart/index.js
index cea36eda427..c3136c7a86f 100644
--- a/assets/js/components/GoogleChart/index.js
+++ b/assets/js/components/GoogleChart/index.js
@@ -97,7 +97,7 @@ export default function GoogleChart( props ) {
const breakpoint = useBreakpoint();
const { startDate, endDate } = useSelect( ( select ) =>
- select( CORE_USER ).getDateRangeDates()
+ select( CORE_USER ).getDateRangeDates( { offsetDays: 0 } )
);
const viewContext = useViewContext();
diff --git a/assets/js/components/KeyMetrics/MetricsSelectionPanel/CustomDimensionsNotice.js b/assets/js/components/KeyMetrics/MetricsSelectionPanel/CustomDimensionsNotice.js
index 9a6dac10642..1fbb590fc2d 100644
--- a/assets/js/components/KeyMetrics/MetricsSelectionPanel/CustomDimensionsNotice.js
+++ b/assets/js/components/KeyMetrics/MetricsSelectionPanel/CustomDimensionsNotice.js
@@ -89,7 +89,7 @@ function CustomDimensionsNotice() {
if (
currentFocusedElement &&
currentFocusedElement.closest(
- '.googlesitekit-km-selection-panel-metrics__metric-item'
+ '.googlesitekit-selection-panel-item'
) &&
elementsOverlap( noticeRef.current, currentFocusedElement )
) {
@@ -113,10 +113,7 @@ function CustomDimensionsNotice() {
);
return (
-
+
{ customDimensionMessage }
);
diff --git a/assets/js/components/KeyMetrics/MetricsSelectionPanel/Footer.js b/assets/js/components/KeyMetrics/MetricsSelectionPanel/Footer.js
index 9b61d7f82b0..6e8beafbb85 100644
--- a/assets/js/components/KeyMetrics/MetricsSelectionPanel/Footer.js
+++ b/assets/js/components/KeyMetrics/MetricsSelectionPanel/Footer.js
@@ -19,34 +19,24 @@
/**
* External dependencies
*/
-import { isEqual } from 'lodash';
import PropTypes from 'prop-types';
/**
* WordPress dependencies
*/
-import {
- useCallback,
- useEffect,
- useState,
- useMemo,
- createInterpolateElement,
-} from '@wordpress/element';
+import { useCallback } from '@wordpress/element';
import { addQueryArgs } from '@wordpress/url';
import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
-import { Button, SpinnerButton } from 'googlesitekit-components';
import Data from 'googlesitekit-data';
import { CORE_USER } from '../../../googlesitekit/datastore/user/constants';
import { CORE_FORMS } from '../../../googlesitekit/datastore/forms/constants';
import { CORE_LOCATION } from '../../../googlesitekit/datastore/location/constants';
import { CORE_MODULES } from '../../../googlesitekit/modules/datastore/constants';
-import { CORE_UI } from '../../../googlesitekit/datastore/ui/constants';
import {
- KEY_METRICS_SELECTION_PANEL_OPENED_KEY,
KEY_METRICS_SELECTED,
KEY_METRICS_SELECTION_FORM,
MIN_SELECTED_METRICS_COUNT,
@@ -59,13 +49,14 @@ import {
} from '../../../modules/analytics-4/datastore/constants';
import { KEY_METRICS_WIDGETS } from '../key-metrics-widgets';
import { ERROR_CODE_MISSING_REQUIRED_SCOPE } from '../../../util/errors';
-import ErrorNotice from '../../ErrorNotice';
-import { safelySort } from './utils';
import useViewContext from '../../../hooks/useViewContext';
import { trackEvent } from '../../../util';
+import { SelectionPanelFooter } from '../../SelectionPanel';
const { useSelect, useDispatch } = Data;
export default function Footer( {
+ isOpen,
+ closePanel,
savedMetrics,
onNavigationToOAuthURL = () => {},
} ) {
@@ -85,14 +76,6 @@ export default function Footer( {
);
const trackingCategory = `${ viewContext }_kmw-sidebar`;
- const haveSettingsChanged = useMemo( () => {
- // Arrays need to be sorted to match in `isEqual`.
- return ! isEqual(
- safelySort( selectedMetrics ),
- safelySort( savedMetrics )
- );
- }, [ savedMetrics, selectedMetrics ] );
-
const requiredCustomDimensions = selectedMetrics?.flatMap( ( tileName ) => {
const tile = KEY_METRICS_WIDGETS[ tileName ];
return tile?.requiredCustomDimensions || [];
@@ -147,109 +130,70 @@ export default function Footer( {
return select( CORE_LOCATION ).isNavigatingTo( OAuthURL );
} );
- const isOpen = useSelect( ( select ) =>
- select( CORE_UI ).getValue( KEY_METRICS_SELECTION_PANEL_OPENED_KEY )
- );
-
const { saveKeyMetricsSettings, setPermissionScopeError } =
useDispatch( CORE_USER );
- const { setValue } = useDispatch( CORE_UI );
const { setValues } = useDispatch( CORE_FORMS );
- const [ finalButtonText, setFinalButtonText ] = useState( null );
- const [ wasSaved, setWasSaved ] = useState( false );
-
- const currentButtonText =
- savedMetrics?.length > 0 && haveSettingsChanged
- ? __( 'Apply changes', 'google-site-kit' )
- : __( 'Save selection', 'google-site-kit' );
-
- const onSaveClick = useCallback( async () => {
- const { error } = await saveKeyMetricsSettings( {
- widgetSlugs: selectedMetrics,
- } );
-
- if ( ! error ) {
- trackEvent( trackingCategory, 'metrics_sidebar_save' );
+ const saveSettings = useCallback(
+ async ( widgetSlugs ) => {
+ // We could simply return the value of `saveKeyMetricsSettings()` here,
+ // but this makes the expected return value more explicit.
+ const { error } = await saveKeyMetricsSettings( {
+ widgetSlugs,
+ } );
+
+ return { error };
+ },
+ [ saveKeyMetricsSettings ]
+ );
- if ( isGA4Connected && hasMissingCustomDimensions ) {
- setValues( FORM_CUSTOM_DIMENSIONS_CREATE, {
- autoSubmit: true,
+ const onSaveSuccess = useCallback( () => {
+ trackEvent( trackingCategory, 'metrics_sidebar_save' );
+
+ if ( isGA4Connected && hasMissingCustomDimensions ) {
+ setValues( FORM_CUSTOM_DIMENSIONS_CREATE, {
+ autoSubmit: true,
+ } );
+
+ if ( ! hasAnalytics4EditScope ) {
+ // Let parent component know that the user is navigating to OAuth URL
+ // so that the panel is kept open.
+ onNavigationToOAuthURL();
+
+ // Ensure the panel is closed, just in case the user navigates to
+ // the OAuth URL before the function is fully executed.
+ closePanel();
+
+ setPermissionScopeError( {
+ code: ERROR_CODE_MISSING_REQUIRED_SCOPE,
+ message: __(
+ 'Additional permissions are required to create new Analytics custom dimensions',
+ 'google-site-kit'
+ ),
+ data: {
+ status: 403,
+ scopes: [ EDIT_SCOPE ],
+ skipModal: true,
+ redirectURL,
+ },
} );
-
- if ( ! hasAnalytics4EditScope ) {
- // Let parent component know that the user is navigating to OAuth URL
- // so that the panel is kept open.
- onNavigationToOAuthURL();
-
- // Ensure the state is set, just in case the user navigates to the
- // OAuth URL before the function is fully executed.
- setValue( KEY_METRICS_SELECTION_PANEL_OPENED_KEY, false );
-
- setPermissionScopeError( {
- code: ERROR_CODE_MISSING_REQUIRED_SCOPE,
- message: __(
- 'Additional permissions are required to create new Analytics custom dimensions',
- 'google-site-kit'
- ),
- data: {
- status: 403,
- scopes: [ EDIT_SCOPE ],
- skipModal: true,
- redirectURL,
- },
- } );
- }
}
-
- // If the state has not been set to `false` yet, set it now.
- if ( isOpen ) {
- setValue( KEY_METRICS_SELECTION_PANEL_OPENED_KEY, false );
- }
-
- // lock the button label while panel is closing
- setFinalButtonText( currentButtonText );
- setWasSaved( true );
}
}, [
- saveKeyMetricsSettings,
- selectedMetrics,
trackingCategory,
isGA4Connected,
hasMissingCustomDimensions,
- isOpen,
- currentButtonText,
setValues,
hasAnalytics4EditScope,
onNavigationToOAuthURL,
- setValue,
+ closePanel,
setPermissionScopeError,
redirectURL,
] );
- const onCancelClick = useCallback( () => {
- setValue( KEY_METRICS_SELECTION_PANEL_OPENED_KEY, false );
+ const onCancel = useCallback( () => {
trackEvent( trackingCategory, 'metrics_sidebar_cancel' );
- }, [ setValue, trackingCategory ] );
-
- const [ prevIsOpen, setPrevIsOpen ] = useState( null );
-
- useEffect( () => {
- if ( prevIsOpen !== null ) {
- // if current isOpen is true, and different from prevIsOpen
- // meaning it transitioned from false to true and it is not
- // in closing transition, we should reset the button label
- // locked when save button was clicked
- if ( prevIsOpen !== isOpen ) {
- if ( isOpen ) {
- setFinalButtonText( null );
- setWasSaved( false );
- }
- }
- }
-
- setPrevIsOpen( isOpen );
- }, [ isOpen, prevIsOpen ] );
+ }, [ trackingCategory ] );
const selectedMetricsCount = selectedMetrics?.length || 0;
let metricsLimitError;
@@ -277,67 +221,26 @@ export default function Footer( {
}
return (
-
+
);
}
Footer.propTypes = {
+ isOpen: PropTypes.bool,
+ closePanel: PropTypes.func.isRequired,
savedMetrics: PropTypes.array,
onNavigationToOAuthURL: PropTypes.func,
};
diff --git a/assets/js/components/KeyMetrics/MetricsSelectionPanel/Header.js b/assets/js/components/KeyMetrics/MetricsSelectionPanel/Header.js
index 3229cc4aecc..75bf76f7971 100644
--- a/assets/js/components/KeyMetrics/MetricsSelectionPanel/Header.js
+++ b/assets/js/components/KeyMetrics/MetricsSelectionPanel/Header.js
@@ -16,6 +16,11 @@
* limitations under the License.
*/
+/**
+ * External dependencies
+ */
+import PropTypes from 'prop-types';
+
/**
* WordPress dependencies
*/
@@ -28,15 +33,13 @@ import { __ } from '@wordpress/i18n';
import Data from 'googlesitekit-data';
import { CORE_LOCATION } from '../../../googlesitekit/datastore/location/constants';
import { CORE_SITE } from '../../../googlesitekit/datastore/site/constants';
-import { CORE_UI } from '../../../googlesitekit/datastore/ui/constants';
import { CORE_USER } from '../../../googlesitekit/datastore/user/constants';
-import { KEY_METRICS_SELECTION_PANEL_OPENED_KEY } from '../constants';
import Link from '../../Link';
-import CloseIcon from '../../../../svg/icons/close.svg';
+import { SelectionPanelHeader } from '../../SelectionPanel';
import useViewOnly from '../../../hooks/useViewOnly';
const { useSelect, useDispatch } = Data;
-export default function Header() {
+export default function Header( { closePanel } ) {
const isViewOnly = useViewOnly();
const settingsURL = useSelect( ( select ) =>
@@ -46,30 +49,18 @@ export default function Header() {
select( CORE_USER ).isSavingKeyMetricsSettings()
);
- const { setValue } = useDispatch( CORE_UI );
const { navigateTo } = useDispatch( CORE_LOCATION );
- const onCloseClick = useCallback( () => {
- setValue( KEY_METRICS_SELECTION_PANEL_OPENED_KEY, false );
- }, [ setValue ] );
-
const onSettingsClick = useCallback(
() => navigateTo( `${ settingsURL }#/admin-settings` ),
[ navigateTo, settingsURL ]
);
return (
-
-
-
{ __( 'Select your metrics', 'google-site-kit' ) }
-
-
-
-
+
{ ! isViewOnly && (
{ createInterpolateElement(
@@ -90,6 +81,10 @@ export default function Header() {
) }
) }
-
+
);
}
+
+Header.propTypes = {
+ closePanel: PropTypes.func.isRequired,
+};
diff --git a/assets/js/components/KeyMetrics/MetricsSelectionPanel/MetricItem.js b/assets/js/components/KeyMetrics/MetricsSelectionPanel/MetricItem.js
index fb27efb353d..6242fc9bbba 100644
--- a/assets/js/components/KeyMetrics/MetricsSelectionPanel/MetricItem.js
+++ b/assets/js/components/KeyMetrics/MetricsSelectionPanel/MetricItem.js
@@ -32,18 +32,32 @@ import { __, _n, sprintf } from '@wordpress/i18n';
*/
import Data from 'googlesitekit-data';
import { CORE_FORMS } from '../../../googlesitekit/datastore/forms/constants';
+import { CORE_WIDGETS } from '../../../googlesitekit/widgets/datastore/constants';
+import { CORE_MODULES } from '../../../googlesitekit/modules/datastore/constants';
import { KEY_METRICS_SELECTED, KEY_METRICS_SELECTION_FORM } from '../constants';
-import SelectionBox from '../../SelectionBox';
+import { SelectionPanelItem } from '../../SelectionPanel';
const { useSelect, useDispatch } = Data;
export default function MetricItem( {
- id,
slug,
title,
description,
- disconnectedModules,
- savedMetrics = [],
+ savedItemSlugs = [],
} ) {
+ const disconnectedModules = useSelect( ( select ) => {
+ const { getModule } = select( CORE_MODULES );
+ const widget = select( CORE_WIDGETS ).getWidget( slug );
+
+ return widget?.modules.reduce( ( modulesAcc, widgetSlug ) => {
+ const module = getModule( widgetSlug );
+ if ( module?.connected || ! module?.name ) {
+ return modulesAcc;
+ }
+
+ return [ ...modulesAcc, module.name ];
+ }, [] );
+ } );
+
const selectedMetrics = useSelect( ( select ) =>
select( CORE_FORMS ).getValue(
KEY_METRICS_SELECTION_FORM,
@@ -74,45 +88,43 @@ export default function MetricItem( {
const isMetricSelected = selectedMetrics?.includes( slug );
const isMetricDisabled =
- ! savedMetrics.includes( slug ) && disconnectedModules.length > 0;
+ ! savedItemSlugs.includes( slug ) && disconnectedModules.length > 0;
+
+ const id = `key-metric-selection-checkbox-${ slug }`;
return (
-
-
- { description }
- { disconnectedModules.length > 0 && (
-
- { sprintf(
- /* translators: %s: module names. */
- _n(
- '%s is disconnected, no data to show',
- '%s are disconnected, no data to show',
- disconnectedModules.length,
- 'google-site-kit'
- ),
- disconnectedModules.join(
- __( ' and ', 'google-site-kit' )
- )
- ) }
-
- ) }
-
-
+
+ { disconnectedModules.length > 0 && (
+
+ { sprintf(
+ /* translators: %s: module names. */
+ _n(
+ '%s is disconnected, no data to show',
+ '%s are disconnected, no data to show',
+ disconnectedModules.length,
+ 'google-site-kit'
+ ),
+ disconnectedModules.join(
+ __( ' and ', 'google-site-kit' )
+ )
+ ) }
+
+ ) }
+
);
}
MetricItem.propTypes = {
- id: PropTypes.string.isRequired,
slug: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
- disconnectedModules: PropTypes.array,
- savedMetrics: PropTypes.array,
+ savedItemSlugs: PropTypes.array,
};
diff --git a/assets/js/components/KeyMetrics/MetricsSelectionPanel/Metrics.js b/assets/js/components/KeyMetrics/MetricsSelectionPanel/MetricItems.js
similarity index 51%
rename from assets/js/components/KeyMetrics/MetricsSelectionPanel/Metrics.js
rename to assets/js/components/KeyMetrics/MetricsSelectionPanel/MetricItems.js
index 439d06799ff..4e2928c1944 100644
--- a/assets/js/components/KeyMetrics/MetricsSelectionPanel/Metrics.js
+++ b/assets/js/components/KeyMetrics/MetricsSelectionPanel/MetricItems.js
@@ -33,22 +33,19 @@ import Data from 'googlesitekit-data';
import { AREA_MAIN_DASHBOARD_KEY_METRICS_PRIMARY } from '../../../googlesitekit/widgets/default-areas';
import { CORE_USER } from '../../../googlesitekit/datastore/user/constants';
import { CORE_WIDGETS } from '../../../googlesitekit/widgets/datastore/constants';
-import { CORE_MODULES } from '../../../googlesitekit/modules/datastore/constants';
import { KEY_METRICS_WIDGETS } from '../key-metrics-widgets';
import MetricItem from './MetricItem';
+import { SelectionPanelItems } from '../../SelectionPanel';
import useViewOnly from '../../../hooks/useViewOnly';
-import { Fragment } from '@wordpress/element';
const { useSelect } = Data;
-export default function Metrics( { savedMetrics } ) {
+export default function MetricItems( { savedMetrics } ) {
const isViewOnlyDashboard = useViewOnly();
const { isKeyMetricAvailable } = useSelect( ( select ) =>
select( CORE_USER )
);
- const { getModule } = useSelect( ( select ) => select( CORE_MODULES ) );
-
const displayInList = useSelect(
( select ) => ( metric ) =>
KEY_METRICS_WIDGETS[ metric ].displayInList(
@@ -57,41 +54,26 @@ export default function Metrics( { savedMetrics } ) {
)
);
- const getWidget = useSelect(
- ( select ) => ( metric ) => select( CORE_WIDGETS ).getWidget( metric )
- );
-
- const metricsListReducer = ( acc, metric ) => {
- if ( ! isKeyMetricAvailable( metric ) ) {
+ const metricsListReducer = ( acc, metricSlug ) => {
+ if ( ! isKeyMetricAvailable( metricSlug ) ) {
return acc;
}
if (
- typeof KEY_METRICS_WIDGETS[ metric ].displayInList === 'function' &&
- ! displayInList( metric )
+ typeof KEY_METRICS_WIDGETS[ metricSlug ].displayInList ===
+ 'function' &&
+ ! displayInList( metricSlug )
) {
return acc;
}
- const widget = getWidget( metric );
-
- const disconnectedModules = widget.modules.reduce(
- ( modulesAcc, slug ) => {
- const module = getModule( slug );
- if ( module?.connected || ! module?.name ) {
- return modulesAcc;
- }
-
- return [ ...modulesAcc, module.name ];
- },
- []
- );
+ const { title, description } = KEY_METRICS_WIDGETS[ metricSlug ];
return {
...acc,
- [ metric ]: {
- ...KEY_METRICS_WIDGETS[ metric ],
- disconnectedModules,
+ [ metricSlug ]: {
+ title,
+ description,
},
};
};
@@ -119,53 +101,20 @@ export default function Metrics( { savedMetrics } ) {
} )
.reduce( metricsListReducer, {} );
- const renderMetricItems = ( metricSlugs ) => {
- return Object.keys( metricSlugs ).map( ( slug ) => {
- const { title, description, disconnectedModules } =
- metricSlugs[ slug ];
-
- const id = `key-metric-selection-checkbox-${ slug }`;
-
- return (
-
- );
- } );
- };
-
return (
-
- {
- // Split list into two sections with sub-headings for current selection and
- // additional metrics if there are already saved metrics.
- savedMetrics.length !== 0 && (
-
-
- { __( 'Current selection', 'google-site-kit' ) }
-
-
- { renderMetricItems( availableSavedMetrics ) }
-
-
- { __( 'Additional metrics', 'google-site-kit' ) }
-
-
- )
- }
-
- { renderMetricItems( availableUnsavedMetrics ) }
-
-
+
);
}
-Metrics.propTypes = {
+MetricItems.propTypes = {
savedMetrics: PropTypes.array,
};
diff --git a/assets/js/components/KeyMetrics/MetricsSelectionPanel/index.js b/assets/js/components/KeyMetrics/MetricsSelectionPanel/index.js
index 49ce12c9bd6..30e0f02bc73 100644
--- a/assets/js/components/KeyMetrics/MetricsSelectionPanel/index.js
+++ b/assets/js/components/KeyMetrics/MetricsSelectionPanel/index.js
@@ -33,11 +33,11 @@ import {
KEY_METRICS_SELECTION_FORM,
KEY_METRICS_SELECTION_PANEL_OPENED_KEY,
} from '../constants';
-import SideSheet from '../../SideSheet';
+import CustomDimensionsNotice from './CustomDimensionsNotice';
import Header from './Header';
import Footer from './Footer';
-import Metrics from './Metrics';
-import CustomDimensionsNotice from './CustomDimensionsNotice';
+import MetricItems from './MetricItems';
+import SelectionPanel from '../../SelectionPanel';
import useViewContext from '../../../hooks/useViewContext';
import { trackEvent } from '../../../util';
const { useSelect, useDispatch } = Data;
@@ -69,7 +69,7 @@ export default function MetricsSelectionPanel() {
trackEvent( `${ viewContext }_kmw-sidebar`, 'metrics_sidebar_view' );
}, [ savedViewableMetrics, setValues, viewContext ] );
- const sideSheetCloseFn = useCallback( () => {
+ const closePanel = useCallback( () => {
if ( isOpen ) {
setValue( KEY_METRICS_SELECTION_PANEL_OPENED_KEY, false );
}
@@ -79,25 +79,23 @@ export default function MetricsSelectionPanel() {
useState( false );
return (
-
-
-
+
+
{
setIsNavigatingToOAuthURL( true );
} }
/>
-
+
);
}
diff --git a/assets/js/components/KeyMetrics/MetricsSelectionPanel/index.test.js b/assets/js/components/KeyMetrics/MetricsSelectionPanel/index.test.js
index c18cf87ece1..ff1cf3339b1 100644
--- a/assets/js/components/KeyMetrics/MetricsSelectionPanel/index.test.js
+++ b/assets/js/components/KeyMetrics/MetricsSelectionPanel/index.test.js
@@ -183,13 +183,13 @@ describe( 'MetricsSelectionPanel', () => {
expect(
document.querySelector(
- '.googlesitekit-km-selection-panel-metrics'
+ '.googlesitekit-km-selection-panel .googlesitekit-selection-panel-items'
)
).toHaveTextContent( 'Returning visitors' );
expect(
document.querySelector(
- '.googlesitekit-km-selection-panel-metrics'
+ '.googlesitekit-km-selection-panel .googlesitekit-selection-panel-items'
)
).toHaveTextContent( 'Top performing keywords' );
} );
@@ -227,7 +227,7 @@ describe( 'MetricsSelectionPanel', () => {
expect(
document.querySelector(
- '.googlesitekit-km-selection-panel-metrics'
+ '.googlesitekit-km-selection-panel .googlesitekit-selection-panel-items'
)
).toHaveTextContent(
'Search Console is disconnected, no data to show'
@@ -267,7 +267,7 @@ describe( 'MetricsSelectionPanel', () => {
expect(
document.querySelector(
- '.googlesitekit-km-selection-panel-metrics'
+ '.googlesitekit-km-selection-panel .googlesitekit-selection-panel-items'
)
).toHaveTextContent(
'Analytics and Search Console are disconnected, no data to show'
@@ -363,7 +363,7 @@ describe( 'MetricsSelectionPanel', () => {
// Verify the limit of 4 metrics is not reached.
expect(
document.querySelector(
- '.googlesitekit-km-selection-panel-footer__metric-count'
+ '.googlesitekit-km-selection-panel .googlesitekit-selection-panel-footer__item-count'
)
).toHaveTextContent( '1 selected (up to 4)' );
@@ -425,7 +425,7 @@ describe( 'MetricsSelectionPanel', () => {
// Verify that the last metric is positioned at the top.
expect(
document.querySelector(
- '.googlesitekit-km-selection-panel-metrics__metric-item:first-child label'
+ '.googlesitekit-km-selection-panel .googlesitekit-selection-panel-item:first-child label'
)
).toHaveTextContent( 'Top converting traffic source' );
} );
@@ -487,14 +487,14 @@ describe( 'MetricsSelectionPanel', () => {
// Verify that a metric dependent on GA4 isn't listed.
expect(
document.querySelector(
- '.googlesitekit-km-selection-panel-metrics'
+ '.googlesitekit-km-selection-panel .googlesitekit-selection-panel-items'
)
).not.toHaveTextContent( 'Returning visitors' );
// Verify that a metric dependent on Search Console is listed.
expect(
document.querySelector(
- '.googlesitekit-km-selection-panel-metrics'
+ '.googlesitekit-km-selection-panel .googlesitekit-selection-panel-items'
)
).toHaveTextContent( 'Top performing keywords' );
} );
@@ -623,7 +623,7 @@ describe( 'MetricsSelectionPanel', () => {
expect(
document.querySelector(
- '.googlesitekit-km-selection-panel-footer .googlesitekit-button-icon--spinner'
+ '.googlesitekit-km-selection-panel .googlesitekit-selection-panel-footer .googlesitekit-button-icon--spinner'
)
).toBeDisabled();
} );
@@ -646,7 +646,7 @@ describe( 'MetricsSelectionPanel', () => {
expect(
document.querySelector(
- '.googlesitekit-km-selection-panel-footer .googlesitekit-error-text'
+ '.googlesitekit-km-selection-panel .googlesitekit-selection-panel-footer .googlesitekit-error-text'
).textContent
).toBe( 'Select at least 2 metrics (1 selected)' );
@@ -658,7 +658,7 @@ describe( 'MetricsSelectionPanel', () => {
expect(
document.querySelector(
- '.googlesitekit-km-selection-panel-footer .googlesitekit-error-text'
+ '.googlesitekit-km-selection-panel .googlesitekit-selection-panel-footer .googlesitekit-error-text'
)
).not.toBeInTheDocument();
} );
@@ -827,7 +827,7 @@ describe( 'MetricsSelectionPanel', () => {
expect(
document.querySelector(
- '.googlesitekit-km-selection-panel-footer__metric-count'
+ '.googlesitekit-km-selection-panel .googlesitekit-selection-panel-footer__item-count'
)
).toHaveTextContent( '2 selected (up to 4)' );
} );
diff --git a/assets/js/components/SelectionPanel/SelectionPanel.js b/assets/js/components/SelectionPanel/SelectionPanel.js
new file mode 100644
index 00000000000..982a1ea2ceb
--- /dev/null
+++ b/assets/js/components/SelectionPanel/SelectionPanel.js
@@ -0,0 +1,62 @@
+/**
+ * Selection Panel 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 classnames from 'classnames';
+import PropTypes from 'prop-types';
+
+/**
+ * Internal dependencies
+ */
+import SideSheet from '../SideSheet';
+
+export default function SelectionPanel( {
+ children,
+ isOpen,
+ onOpen,
+ closePanel,
+ className,
+} ) {
+ return (
+
+ { children }
+
+ );
+}
+
+SelectionPanel.propTypes = {
+ children: PropTypes.node,
+ isOpen: PropTypes.bool,
+ onOpen: PropTypes.func,
+ closePanel: PropTypes.func,
+ className: PropTypes.string,
+};
diff --git a/assets/js/components/SelectionPanel/SelectionPanelFooter.js b/assets/js/components/SelectionPanel/SelectionPanelFooter.js
new file mode 100644
index 00000000000..2bcc23fc51c
--- /dev/null
+++ b/assets/js/components/SelectionPanel/SelectionPanelFooter.js
@@ -0,0 +1,194 @@
+/**
+ * Selection Panel Footer 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 { isEqual } from 'lodash';
+import PropTypes from 'prop-types';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ createInterpolateElement,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { Button, SpinnerButton } from 'googlesitekit-components';
+import ErrorNotice from '../ErrorNotice';
+import { safelySort } from '../../util';
+
+export default function SelectionPanelFooter( {
+ savedItemSlugs = [],
+ selectedItemSlugs = [],
+ saveSettings,
+ saveError,
+ itemLimitError,
+ minSelectedItemCount = 0,
+ maxSelectedItemCount = 0,
+ isBusy,
+ onSaveSuccess,
+ onCancel = () => {},
+ isOpen,
+ closePanel = () => {},
+} ) {
+ const [ finalButtonText, setFinalButtonText ] = useState( null );
+ const [ wasSaved, setWasSaved ] = useState( false );
+
+ const haveSettingsChanged = useMemo( () => {
+ // Arrays need to be sorted to match in `isEqual`.
+ return ! isEqual(
+ safelySort( selectedItemSlugs ),
+ safelySort( savedItemSlugs )
+ );
+ }, [ savedItemSlugs, selectedItemSlugs ] );
+
+ const currentButtonText =
+ savedItemSlugs?.length > 0 && haveSettingsChanged
+ ? __( 'Apply changes', 'google-site-kit' )
+ : __( 'Save selection', 'google-site-kit' );
+
+ const onSaveClick = useCallback( async () => {
+ const { error } = await saveSettings( selectedItemSlugs );
+
+ if ( ! error ) {
+ onSaveSuccess();
+
+ // Close the panel after saving.
+ closePanel();
+
+ // Lock the button label while panel is closing.
+ setFinalButtonText( currentButtonText );
+ setWasSaved( true );
+ }
+ }, [
+ saveSettings,
+ selectedItemSlugs,
+ onSaveSuccess,
+ closePanel,
+ currentButtonText,
+ ] );
+
+ const onCancelClick = useCallback( () => {
+ closePanel();
+ onCancel();
+ }, [ closePanel, onCancel ] );
+
+ const [ prevIsOpen, setPrevIsOpen ] = useState( null );
+
+ useEffect( () => {
+ if ( prevIsOpen !== null ) {
+ // If current isOpen is true, and different from prevIsOpen
+ // meaning it transitioned from false to true and it is not
+ // in closing transition, we should reset the button label
+ // locked when save button was clicked.
+ if ( prevIsOpen !== isOpen ) {
+ if ( isOpen ) {
+ setFinalButtonText( null );
+ setWasSaved( false );
+ }
+ }
+ }
+
+ setPrevIsOpen( isOpen );
+ }, [ isOpen, prevIsOpen ] );
+
+ const selectedItemCount = selectedItemSlugs?.length || 0;
+
+ return (
+
+ );
+}
+
+SelectionPanelFooter.propTypes = {
+ savedItemSlugs: PropTypes.array,
+ selectedItemSlugs: PropTypes.array,
+ saveSettings: PropTypes.func,
+ saveError: PropTypes.object,
+ itemLimitError: PropTypes.string,
+ minSelectedItemCount: PropTypes.number,
+ maxSelectedItemCount: PropTypes.number,
+ isBusy: PropTypes.bool,
+ onSaveSuccess: PropTypes.func,
+ onCancel: PropTypes.func,
+ isOpen: PropTypes.bool,
+ closePanel: PropTypes.func,
+};
diff --git a/assets/js/components/SelectionPanel/SelectionPanelHeader.js b/assets/js/components/SelectionPanel/SelectionPanelHeader.js
new file mode 100644
index 00000000000..5930bf1d50a
--- /dev/null
+++ b/assets/js/components/SelectionPanel/SelectionPanelHeader.js
@@ -0,0 +1,56 @@
+/**
+ * Selection Panel Header 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';
+
+/**
+ * Internal dependencies
+ */
+import Link from '../Link';
+import CloseIcon from '../../../svg/icons/close.svg';
+
+export default function SelectionPanelHeader( {
+ children,
+ title,
+ onCloseClick,
+} ) {
+ return (
+
+
+
{ title }
+
+
+
+
+ { children }
+
+ );
+}
+
+SelectionPanelHeader.propTypes = {
+ children: PropTypes.node,
+ title: PropTypes.string,
+ onCloseClick: PropTypes.func,
+};
diff --git a/assets/js/components/SelectionPanel/SelectionPanelItem.js b/assets/js/components/SelectionPanel/SelectionPanelItem.js
new file mode 100644
index 00000000000..c161df52ea8
--- /dev/null
+++ b/assets/js/components/SelectionPanel/SelectionPanelItem.js
@@ -0,0 +1,65 @@
+/**
+ * Selection Panel Item 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';
+
+/**
+ * Internal dependencies
+ */
+import SelectionBox from '../SelectionBox';
+
+export default function SelectionPanelItem( {
+ children,
+ id,
+ slug,
+ title,
+ description,
+ isItemSelected,
+ isItemDisabled,
+ onCheckboxChange,
+} ) {
+ return (
+
+
+ { description }
+ { children }
+
+
+ );
+}
+
+SelectionPanelItem.propTypes = {
+ children: PropTypes.node,
+ id: PropTypes.string,
+ slug: PropTypes.string,
+ title: PropTypes.string,
+ description: PropTypes.string,
+ isItemSelected: PropTypes.bool,
+ isItemDisabled: PropTypes.bool,
+ onCheckboxChange: PropTypes.func,
+};
diff --git a/assets/js/components/SelectionPanel/SelectionPanelItems.js b/assets/js/components/SelectionPanel/SelectionPanelItems.js
new file mode 100644
index 00000000000..0889275a5ae
--- /dev/null
+++ b/assets/js/components/SelectionPanel/SelectionPanelItems.js
@@ -0,0 +1,82 @@
+/**
+ * Selection Panel Items 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 { Fragment } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+export default function SelectionPanelItems( {
+ currentSelectionTitle = __( 'Current selection', 'google-site-kit' ),
+ availableItemsTitle = __( 'Additional items', 'google-site-kit' ),
+ savedItemSlugs = [],
+ availableSavedItems = {},
+ availableUnsavedItems = {},
+ ItemComponent,
+} ) {
+ const renderItems = ( items ) => {
+ return Object.keys( items ).map( ( slug ) => (
+
+ ) );
+ };
+
+ return (
+
+ {
+ // Split list into two sections with sub-headings for current selection and
+ // additional items if there are already saved items.
+ savedItemSlugs.length !== 0 && (
+
+
+ { currentSelectionTitle }
+
+
+ { renderItems( availableSavedItems ) }
+
+
+ { availableItemsTitle }
+
+
+ )
+ }
+
+ { renderItems( availableUnsavedItems ) }
+
+
+ );
+}
+
+SelectionPanelItems.propTypes = {
+ currentSelectionTitle: PropTypes.string,
+ availableItemsTitle: PropTypes.string,
+ savedItemSlugs: PropTypes.array,
+ availableSavedItems: PropTypes.object,
+ availableUnsavedItems: PropTypes.object,
+ ItemComponent: PropTypes.elementType,
+};
diff --git a/assets/js/components/SelectionPanel/index.js b/assets/js/components/SelectionPanel/index.js
new file mode 100644
index 00000000000..3c1eb55e3c7
--- /dev/null
+++ b/assets/js/components/SelectionPanel/index.js
@@ -0,0 +1,29 @@
+/**
+ * Selection Panel components.
+ *
+ * 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 SelectionPanel from './SelectionPanel';
+
+export { default as SelectionPanelHeader } from './SelectionPanelHeader';
+export { default as SelectionPanelItem } from './SelectionPanelItem';
+export { default as SelectionPanelItems } from './SelectionPanelItems';
+export { default as SelectionPanelFooter } from './SelectionPanelFooter';
+
+export default SelectionPanel;
diff --git a/assets/js/components/SelectionPanel/index.stories.js b/assets/js/components/SelectionPanel/index.stories.js
new file mode 100644
index 00000000000..278f84db23d
--- /dev/null
+++ b/assets/js/components/SelectionPanel/index.stories.js
@@ -0,0 +1,134 @@
+/**
+ * Selection Panel 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.
+ */
+
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import SelectionPanel from './SelectionPanel';
+import SelectionPanelFooter from './SelectionPanelFooter';
+import SelectionPanelHeader from './SelectionPanelHeader';
+import SelectionPanelItem from './SelectionPanelItem';
+import SelectionPanelItems from './SelectionPanelItems';
+
+function Template( { availableSavedItems = {}, savedItemSlugs = [] } ) {
+ const [ selectedItems, setSeletectedItems ] = useState(
+ Object.keys( availableSavedItems )
+ );
+
+ const availableUnsavedItems =
+ // Create an array of numbers from 1 to 24.
+ Array.from( { length: 24 }, ( _, index ) => index + 1 )
+ // Filter out saved items.
+ .filter(
+ ( number ) =>
+ ! Object.keys( availableSavedItems ).includes(
+ `item-${ number }`
+ )
+ )
+ // Map the numbers to an object with the required properties.
+ .reduce( ( acc, current ) => {
+ const slug = `item-${ current }`;
+
+ return {
+ ...acc,
+ [ slug ]: {
+ slug,
+ title: `Item ${ current }`,
+ description: `Description for item ${ current }`,
+ },
+ };
+ }, {} );
+
+ function ItemComponent( { slug, ...props } ) {
+ return (
+
{
+ setSeletectedItems(
+ event?.target?.checked
+ ? [ ...selectedItems, slug ]
+ : selectedItems.filter( ( item ) => item !== slug )
+ );
+ } }
+ { ...props }
+ />
+ );
+ }
+
+ return (
+
+
+ Select items from the selection below
+
+
+
+
+ );
+}
+
+export const Default = Template.bind( {} );
+Default.storyName = 'Default';
+Default.scenario = {
+ label: 'Components/SelectionPanel/Default',
+};
+
+export const WithSavedItems = Template.bind( {} );
+WithSavedItems.storyName = 'With saved items';
+WithSavedItems.args = {
+ availableSavedItems: {
+ 'item-1': {
+ id: 'item-1',
+ title: 'Item 1',
+ description: 'Description for item 1',
+ },
+ 'item-2': {
+ id: 'item-2',
+ title: 'Item 2',
+ description: 'Description for item 2',
+ },
+ 'item-3': {
+ id: 'item-3',
+ title: 'Item 3',
+ description: 'Description for item 3',
+ },
+ },
+ savedItemSlugs: [ 'item-1', 'item-2', 'item-3' ],
+};
+WithSavedItems.scenario = {
+ label: 'Components/SelectionPanel/WithSavedItems',
+};
+
+export default {
+ title: 'Components/Selection Panel',
+ component: SelectionPanel,
+};
diff --git a/assets/js/components/SideSheet.js b/assets/js/components/SideSheet.js
index b5f7547a9e0..82cf4622338 100644
--- a/assets/js/components/SideSheet.js
+++ b/assets/js/components/SideSheet.js
@@ -46,7 +46,7 @@ export default function SideSheet( {
children,
isOpen,
onOpen = () => {},
- closeFn = () => {},
+ closeSheet = () => {},
focusTrapOptions = {},
} ) {
const sideSheetRef = useRef();
@@ -65,9 +65,9 @@ export default function SideSheet( {
}
}, [ isOpen, onOpen ] );
- useClickAway( sideSheetRef, closeFn );
+ useClickAway( sideSheetRef, closeSheet );
- useKey( ( event ) => isOpen && ESCAPE === event.keyCode, closeFn );
+ useKey( ( event ) => isOpen && ESCAPE === event.keyCode, closeSheet );
return (
@@ -105,6 +105,6 @@ SideSheet.propTypes = {
children: PropTypes.node,
isOpen: PropTypes.bool,
onOpen: PropTypes.func,
- closeFn: PropTypes.func,
+ closeSheet: PropTypes.func,
focusTrapOptions: PropTypes.object,
};
diff --git a/assets/js/components/SideSheet.stories.js b/assets/js/components/SideSheet.stories.js
index c33cb60fcfe..ce77c5c1b34 100644
--- a/assets/js/components/SideSheet.stories.js
+++ b/assets/js/components/SideSheet.stories.js
@@ -35,7 +35,7 @@ function Template( args ) {
setIsOpen( true ) }>Open Side Sheet
setIsOpen( false ) }
+ closeSheet={ () => setIsOpen( false ) }
{ ...args }
>
Side Sheet content
diff --git a/assets/js/components/consent-mode/ConfirmDisableConsentModeDialog.js b/assets/js/components/consent-mode/ConfirmDisableConsentModeDialog.js
index 16cfddfd765..634e907792e 100644
--- a/assets/js/components/consent-mode/ConfirmDisableConsentModeDialog.js
+++ b/assets/js/components/consent-mode/ConfirmDisableConsentModeDialog.js
@@ -46,6 +46,9 @@ export default function ConfirmDisableConsentModeDialog( {
const isAdsConnected = useSelect( ( select ) =>
select( CORE_SITE ).isAdsConnected()
);
+ const consentModeRegions = useSelect( ( select ) =>
+ select( CORE_SITE ).getConsentModeRegions()
+ );
const dependentModuleNames = useSelect( ( select ) =>
[ 'analytics-4', 'ads' ].reduce( ( names, slug ) => {
@@ -83,6 +86,13 @@ export default function ConfirmDisableConsentModeDialog( {
'google-site-kit'
);
+ if ( consentModeRegions?.includes( 'CH' ) ) {
+ subtitle = __(
+ 'Disabling consent mode may affect your ability in the European Economic Area, the UK and Switzerland to:',
+ 'google-site-kit'
+ );
+ }
+
if ( isAdsConnected ) {
provides = [
__( 'Performance of your Ad campaigns', 'google-site-kit' ),
@@ -95,6 +105,13 @@ export default function ConfirmDisableConsentModeDialog( {
'Disabling consent mode may affect your ability to track these in the European Economic Area and the United Kingdom:',
'google-site-kit'
);
+
+ if ( consentModeRegions?.includes( 'CH' ) ) {
+ subtitle = __(
+ 'Disabling consent mode may affect your ability to track these in the European Economic Area, the UK and Switzerland:',
+ 'google-site-kit'
+ );
+ }
}
return (
diff --git a/assets/js/components/consent-mode/ConfirmDisableConsentModeDialog.test.js b/assets/js/components/consent-mode/ConfirmDisableConsentModeDialog.test.js
new file mode 100644
index 00000000000..55cc8717e1a
--- /dev/null
+++ b/assets/js/components/consent-mode/ConfirmDisableConsentModeDialog.test.js
@@ -0,0 +1,137 @@
+/**
+ * ConfirmDisableConsentModeDialog 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,
+ provideModules,
+ provideSiteInfo,
+ render,
+} from '../../../../tests/js/test-utils';
+import ConfirmDisableConsentModeDialog from './ConfirmDisableConsentModeDialog';
+
+describe( 'ConfirmDisableConsentModeDialog', () => {
+ let registry;
+
+ beforeEach( () => {
+ registry = createTestRegistry();
+ } );
+
+ it( 'should display appropriate subtitle with Ads not connected', async () => {
+ provideModules( registry, [
+ { slug: 'ads', active: false, connected: false },
+ ] );
+
+ const { getByText, waitForRegistry } = render(
+ {} }
+ onCancel={ () => {} }
+ />,
+ {
+ registry,
+ }
+ );
+
+ await waitForRegistry();
+
+ expect(
+ getByText(
+ /Disabling consent mode may affect your ability in the European Economic Area and the United Kingdom to/i
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should display appropriate subtitle with Ads connected', async () => {
+ provideModules( registry, [
+ { slug: 'ads', active: true, connected: true },
+ ] );
+
+ const { getByText, waitForRegistry } = render(
+ {} }
+ onCancel={ () => {} }
+ />,
+ {
+ registry,
+ }
+ );
+
+ await waitForRegistry();
+
+ expect(
+ getByText(
+ /Disabling consent mode may affect your ability to track these in the European Economic Area and the United Kingdom/i
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should display appropriate subtitle with Ads not connected and Switzerland included in list of consent mode regions', async () => {
+ provideModules( registry, [
+ { slug: 'ads', active: false, connected: false },
+ ] );
+
+ provideSiteInfo( registry, { consentModeRegions: [ 'CH' ] } );
+
+ const { getByText, waitForRegistry } = render(
+ {} }
+ onCancel={ () => {} }
+ />,
+ {
+ registry,
+ }
+ );
+
+ await waitForRegistry();
+
+ expect(
+ getByText(
+ /Disabling consent mode may affect your ability in the European Economic Area, the UK and Switzerland to/i
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should display appropriate subtitle with Ads connected and Switzerland included in list of consent mode regions', async () => {
+ provideModules( registry, [
+ { slug: 'ads', active: true, connected: true },
+ ] );
+
+ provideSiteInfo( registry, { consentModeRegions: [ 'CH' ] } );
+
+ const { getByText, waitForRegistry } = render(
+ {} }
+ onCancel={ () => {} }
+ />,
+ {
+ registry,
+ features: [ 'consentModeSwitzerland' ],
+ }
+ );
+
+ await waitForRegistry();
+
+ expect(
+ getByText(
+ /Disabling consent mode may affect your ability to track these in the European Economic Area, the UK and Switzerland/i
+ )
+ ).toBeInTheDocument();
+ } );
+} );
diff --git a/assets/js/event-providers/contact-form-7.js b/assets/js/event-providers/contact-form-7.js
index 4cfb0249656..0514743fb95 100644
--- a/assets/js/event-providers/contact-form-7.js
+++ b/assets/js/event-providers/contact-form-7.js
@@ -15,7 +15,7 @@
*/
document.addEventListener( 'wpcf7mailsent', ( event ) => {
- global.gtag( 'event', 'contact', {
+ global._googlesitekit?.trackEvent?.( 'contact', {
// eslint-disable-next-line sitekit/acronym-case
event_category: event.detail.contactFormId,
event_label: event.detail.unitTag,
diff --git a/assets/js/event-providers/mailchimp.js b/assets/js/event-providers/mailchimp.js
new file mode 100644
index 00000000000..ba98e95e6ec
--- /dev/null
+++ b/assets/js/event-providers/mailchimp.js
@@ -0,0 +1,27 @@
+/**
+ * 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.
+ */
+
+( ( mc4wp ) => {
+ if ( ! mc4wp ) {
+ return;
+ }
+
+ mc4wp.forms.on( 'subscribed', () => {
+ global._googlesitekit?.trackEvent?.( 'submit_lead_form', {
+ event_category: 'mailchimp',
+ } );
+ } );
+} )( global.mc4wp );
diff --git a/assets/js/event-providers/optin-monster.js b/assets/js/event-providers/optin-monster.js
index 6cd1863cb0d..1c2c011696a 100644
--- a/assets/js/event-providers/optin-monster.js
+++ b/assets/js/event-providers/optin-monster.js
@@ -16,7 +16,7 @@
document.addEventListener( 'om.Analytics.track', ( { detail } ) => {
if ( 'conversion' === detail.Analytics.type ) {
- global.gtag( 'event', 'submit_lead_form', {
+ global._googlesitekit?.trackEvent?.( 'submit_lead_form', {
campaignID: detail.Campaign.id,
campaignType: detail.Campaign.type,
} );
diff --git a/assets/js/event-providers/popup-maker.js b/assets/js/event-providers/popup-maker.js
new file mode 100644
index 00000000000..2b0a8305821
--- /dev/null
+++ b/assets/js/event-providers/popup-maker.js
@@ -0,0 +1,27 @@
+/**
+ * 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.
+ */
+
+( ( jQuery ) => {
+ // eslint-disable-next-line no-undef
+ if ( ! jQuery || ! PUM ) {
+ return;
+ }
+
+ // eslint-disable-next-line no-undef
+ PUM.hooks.addAction( 'pum.integration.form.success', function () {
+ global._googlesitekit?.trackEvent?.( 'submit_lead_form' );
+ } );
+} )( global.jQuery );
diff --git a/assets/js/event-providers/woocommerce.js b/assets/js/event-providers/woocommerce.js
index 008074f16a7..e4b5c6baa08 100644
--- a/assets/js/event-providers/woocommerce.js
+++ b/assets/js/event-providers/woocommerce.js
@@ -22,10 +22,10 @@
const body = jQuery( 'body' );
body.on( 'added_to_cart', () => {
- global.gtag( 'event', 'add_to_cart' );
+ global._googlesitekit?.trackEvent?.( 'add_to_cart' );
} );
body.on( 'checkout_place_order_success', () => {
- global.gtag( 'event', 'purchase' );
+ global._googlesitekit?.trackEvent?.( 'purchase' );
} );
} )( global.jQuery );
diff --git a/assets/js/event-providers/wpforms.js b/assets/js/event-providers/wpforms.js
index ebc9e5c7eea..7f52c57834a 100644
--- a/assets/js/event-providers/wpforms.js
+++ b/assets/js/event-providers/wpforms.js
@@ -20,6 +20,6 @@
}
jQuery( global.document.body ).on( 'wpformsAjaxSubmitSuccess', () => {
- global.gtag( 'event', 'submit_lead_form' );
+ global._googlesitekit?.trackEvent?.( 'submit_lead_form' );
} );
} )( global.jQuery );
diff --git a/assets/js/googlesitekit-modules-ads.js b/assets/js/googlesitekit-modules-ads.js
index 97a69181f92..26e3d77d775 100644
--- a/assets/js/googlesitekit-modules-ads.js
+++ b/assets/js/googlesitekit-modules-ads.js
@@ -21,7 +21,9 @@
*/
import Data from 'googlesitekit-data';
import Modules from 'googlesitekit-modules';
-import { registerStore, registerModule } from './modules/ads';
+import Widgets from 'googlesitekit-widgets';
+import { registerStore, registerModule, registerWidgets } from './modules/ads';
registerStore( Data );
registerModule( Modules );
+registerWidgets( Widgets );
diff --git a/assets/js/googlesitekit/datastore/site/conversion-tracking.js b/assets/js/googlesitekit/datastore/site/conversion-tracking.js
new file mode 100644
index 00000000000..19e4ebfc4a7
--- /dev/null
+++ b/assets/js/googlesitekit/datastore/site/conversion-tracking.js
@@ -0,0 +1,200 @@
+/**
+ * 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 { isPlainObject, isEqual } from 'lodash';
+import invariant from 'invariant';
+
+/**
+ * Internal dependencies
+ */
+import API from 'googlesitekit-api';
+import Data from 'googlesitekit-data';
+import { createFetchStore } from '../../data/create-fetch-store';
+import { createReducer } from '../../data/create-reducer';
+import { CORE_SITE } from './constants';
+
+const { createRegistrySelector } = Data;
+const { getRegistry } = Data.commonActions;
+
+const SET_CONVERSION_TRACKING_ENABLED = 'SET_CONVERSION_TRACKING_ENABLED';
+
+const settingsReducerCallback = createReducer( ( state, settings ) => {
+ state.conversionTracking.settings = settings;
+ state.conversionTracking.savedSettings = settings;
+} );
+
+const fetchGetConversionTrackingSettingsStore = createFetchStore( {
+ baseName: 'getConversionTrackingSettings',
+ controlCallback: () => {
+ return API.get( 'core', 'site', 'conversion-tracking', null, {
+ useCache: false,
+ } );
+ },
+ reducerCallback: settingsReducerCallback,
+} );
+
+const fetchSaveConversionTrackingSettingsStore = createFetchStore( {
+ baseName: 'saveConversionTrackingSettings',
+ controlCallback: ( { settings } ) => {
+ return API.set( 'core', 'site', 'conversion-tracking', { settings } );
+ },
+ reducerCallback: settingsReducerCallback,
+ argsToParams: ( settings ) => {
+ return { settings };
+ },
+ validateParams: ( { settings } ) => {
+ invariant(
+ isPlainObject( settings ),
+ 'settings must be a plain object.'
+ );
+ },
+} );
+
+const baseInitialState = {
+ conversionTracking: {
+ settings: undefined,
+ savedSettings: undefined,
+ },
+};
+
+const baseActions = {
+ /**
+ * Saves the Conversion Tracking settings.
+ *
+ * @since n.e.x.t
+ *
+ * @return {Object} Object with `response` and `error`.
+ */
+ *saveConversionTrackingSettings() {
+ const { select } = yield getRegistry();
+ const settings = select( CORE_SITE ).getConversionTrackingSettings();
+
+ return yield fetchSaveConversionTrackingSettingsStore.actions.fetchSaveConversionTrackingSettings(
+ settings
+ );
+ },
+
+ /**
+ * Sets the Conversion Tracking enabled status.
+ *
+ * @since n.e.x.t
+ *
+ * @param {string} enabled Consent Mode enabled status.
+ * @return {Object} Redux-style action.
+ */
+ setConversionTrackingEnabled( enabled ) {
+ return {
+ type: SET_CONVERSION_TRACKING_ENABLED,
+ payload: { enabled },
+ };
+ },
+};
+
+const baseControls = {};
+
+const baseReducer = createReducer( ( state, { type, payload } ) => {
+ switch ( type ) {
+ case SET_CONVERSION_TRACKING_ENABLED:
+ state.conversionTracking.settings =
+ state.conversionTracking.settings || {};
+ state.conversionTracking.settings.enabled = !! payload.enabled;
+ break;
+
+ default:
+ break;
+ }
+} );
+
+const baseSelectors = {
+ /**
+ * Gets the Conversion Tracking settings.
+ *
+ * @since n.e.x.t
+ *
+ * @param {Object} state Data store's state.
+ * @return {Object|undefined} Conversion Tracking settings, or `undefined` if not loaded.
+ */
+ getConversionTrackingSettings: ( state ) => {
+ return state.conversionTracking.settings;
+ },
+
+ /**
+ * Gets the Consent Mode enabled status.
+ *
+ * @since n.e.x.t
+ *
+ * @return {boolean|undefined} Consent Mode enabled status, or `undefined` if not loaded.
+ */
+ isConversionTrackingEnabled: createRegistrySelector( ( select ) => () => {
+ const { enabled } =
+ select( CORE_SITE ).getConversionTrackingSettings() || {};
+
+ return enabled;
+ } ),
+
+ /**
+ * Indicates whether the current settings have changed from what is saved.
+ *
+ * @since n.e.x.t
+ *
+ * @param {Object} state Data store's state.
+ * @return {boolean} True if the settings have changed, false otherwise.
+ */
+ haveConversionTrackingSettingsChanged( state ) {
+ const { settings, savedSettings } = state.conversionTracking;
+
+ return ! isEqual( settings, savedSettings );
+ },
+};
+
+const baseResolvers = {
+ *getConversionTrackingSettings() {
+ const { select } = yield getRegistry();
+ const conversionTrackingSettings =
+ select( CORE_SITE ).getConversionTrackingSettings();
+
+ if ( conversionTrackingSettings ) {
+ return;
+ }
+
+ yield fetchGetConversionTrackingSettingsStore.actions.fetchGetConversionTrackingSettings();
+ },
+};
+
+const store = Data.combineStores(
+ fetchGetConversionTrackingSettingsStore,
+ fetchSaveConversionTrackingSettingsStore,
+ {
+ initialState: baseInitialState,
+ actions: baseActions,
+ controls: baseControls,
+ reducer: baseReducer,
+ resolvers: baseResolvers,
+ selectors: baseSelectors,
+ }
+);
+
+export const initialState = store.initialState;
+export const actions = store.actions;
+export const controls = store.controls;
+export const reducer = store.reducer;
+export const resolvers = store.resolvers;
+export const selectors = store.selectors;
+
+export default store;
diff --git a/assets/js/googlesitekit/datastore/site/conversion-tracking.test.js b/assets/js/googlesitekit/datastore/site/conversion-tracking.test.js
new file mode 100644
index 00000000000..d109e118cc3
--- /dev/null
+++ b/assets/js/googlesitekit/datastore/site/conversion-tracking.test.js
@@ -0,0 +1,296 @@
+/**
+ * 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 API from 'googlesitekit-api';
+import {
+ createTestRegistry,
+ subscribeUntil,
+ unsubscribeFromAll,
+ untilResolved,
+ waitForDefaultTimeouts,
+} from '../../../../../tests/js/utils';
+import { CORE_SITE } from './constants';
+
+describe( 'core/site Conversion Tracking', () => {
+ let registry;
+
+ const conversionTrackingSettingsEndpointRegExp = new RegExp(
+ '^/google-site-kit/v1/core/site/data/conversion-tracking'
+ );
+
+ beforeAll( () => {
+ API.setUsingCache( false );
+ } );
+
+ beforeEach( () => {
+ registry = createTestRegistry();
+ } );
+
+ afterAll( () => {
+ API.setUsingCache( true );
+ } );
+
+ afterEach( () => {
+ unsubscribeFromAll( registry );
+ } );
+
+ describe( 'actions', () => {
+ describe( 'saveConversionTrackingSettings', () => {
+ it( 'saves the settings and returns the response', async () => {
+ const updatedSettings = {
+ enabled: true,
+ };
+
+ fetchMock.postOnce( conversionTrackingSettingsEndpointRegExp, {
+ body: updatedSettings,
+ status: 200,
+ } );
+
+ registry
+ .dispatch( CORE_SITE )
+ .receiveGetConversionTrackingSettings( {
+ enabled: false,
+ } );
+
+ registry
+ .dispatch( CORE_SITE )
+ .setConversionTrackingEnabled( true );
+
+ const { response } = await registry
+ .dispatch( CORE_SITE )
+ .saveConversionTrackingSettings();
+
+ expect( fetchMock ).toHaveFetched(
+ conversionTrackingSettingsEndpointRegExp,
+ {
+ body: {
+ data: {
+ settings: updatedSettings,
+ },
+ },
+ }
+ );
+
+ expect( response ).toEqual( updatedSettings );
+ } );
+
+ it( 'returns an error if the request fails', async () => {
+ const errorResponse = {
+ code: 'internal_server_error',
+ message: 'Internal server error',
+ data: { status: 500 },
+ };
+
+ fetchMock.postOnce( conversionTrackingSettingsEndpointRegExp, {
+ body: errorResponse,
+ status: 500,
+ } );
+
+ registry
+ .dispatch( CORE_SITE )
+ .setConversionTrackingEnabled( true );
+
+ const { error } = await registry
+ .dispatch( CORE_SITE )
+ .saveConversionTrackingSettings();
+
+ expect( fetchMock ).toHaveFetched(
+ conversionTrackingSettingsEndpointRegExp,
+ {
+ body: {
+ data: {
+ settings: { enabled: true },
+ },
+ },
+ }
+ );
+
+ expect( error ).toEqual( errorResponse );
+
+ expect( console ).toHaveErrored();
+ } );
+ } );
+
+ describe( 'setConversionTrackingEnabled', () => {
+ it( 'sets the enabled status', () => {
+ registry
+ .dispatch( CORE_SITE )
+ .receiveGetConversionTrackingSettings( {
+ enabled: false,
+ } );
+
+ expect(
+ registry.select( CORE_SITE ).isConversionTrackingEnabled()
+ ).toBe( false );
+
+ registry
+ .dispatch( CORE_SITE )
+ .setConversionTrackingEnabled( true );
+
+ expect(
+ registry.select( CORE_SITE ).isConversionTrackingEnabled()
+ ).toBe( true );
+ } );
+ } );
+ } );
+
+ describe( 'selectors', () => {
+ describe( 'getConversionTrackingSettings', () => {
+ it( 'uses a resolver to make a network request', async () => {
+ const conversionTrackingettings = {
+ enabled: false,
+ };
+
+ fetchMock.getOnce( conversionTrackingSettingsEndpointRegExp, {
+ body: conversionTrackingettings,
+ status: 200,
+ } );
+
+ const initialSettings = registry
+ .select( CORE_SITE )
+ .getConversionTrackingSettings();
+
+ expect( initialSettings ).toBeUndefined();
+
+ await untilResolved(
+ registry,
+ CORE_SITE
+ ).getConversionTrackingSettings();
+
+ const settings = registry
+ .select( CORE_SITE )
+ .getConversionTrackingSettings();
+
+ expect( settings ).toEqual( conversionTrackingettings );
+
+ expect( fetchMock ).toHaveFetched(
+ conversionTrackingSettingsEndpointRegExp
+ );
+ } );
+
+ it( 'returns undefined if the request fails', async () => {
+ fetchMock.getOnce( conversionTrackingSettingsEndpointRegExp, {
+ body: { error: 'something went wrong' },
+ status: 500,
+ } );
+
+ const initialSettings = registry
+ .select( CORE_SITE )
+ .getConversionTrackingSettings();
+
+ expect( initialSettings ).toBeUndefined();
+
+ await untilResolved(
+ registry,
+ CORE_SITE
+ ).getConversionTrackingSettings();
+
+ const settings = registry
+ .select( CORE_SITE )
+ .getConversionTrackingSettings();
+
+ // Verify the settings are still undefined after the selector is resolved.
+ expect( settings ).toBeUndefined();
+
+ await waitForDefaultTimeouts();
+
+ expect( fetchMock ).toHaveFetched(
+ conversionTrackingSettingsEndpointRegExp
+ );
+
+ expect( console ).toHaveErrored();
+ } );
+ } );
+
+ describe( 'isConversionTrackingEnabled', () => {
+ it( 'returns the enabled status', () => {
+ registry
+ .dispatch( CORE_SITE )
+ .receiveGetConversionTrackingSettings( {
+ enabled: true,
+ } );
+
+ expect(
+ registry.select( CORE_SITE ).isConversionTrackingEnabled()
+ ).toBe( true );
+ } );
+ } );
+
+ describe( 'haveConversionTrackingSettingsChanged', () => {
+ it( 'informs whether client-side settings differ from server-side ones', async () => {
+ registry
+ .dispatch( CORE_SITE )
+ .receiveGetConversionTrackingSettings( {
+ enabled: false,
+ } );
+
+ // Initially false.
+ expect(
+ registry
+ .select( CORE_SITE )
+ .haveConversionTrackingSettingsChanged()
+ ).toEqual( false );
+
+ const serverValues = { enabled: false };
+ const clientValues = { enabled: true };
+
+ fetchMock.getOnce( conversionTrackingSettingsEndpointRegExp, {
+ body: serverValues,
+ status: 200,
+ } );
+
+ registry.select( CORE_SITE ).getConversionTrackingSettings();
+ await subscribeUntil(
+ registry,
+ () =>
+ registry
+ .select( CORE_SITE )
+ .getConversionTrackingSettings() !== undefined
+ );
+
+ // Still false after fetching settings from server.
+ expect(
+ registry
+ .select( CORE_SITE )
+ .haveConversionTrackingSettingsChanged()
+ ).toEqual( false );
+
+ // True after updating settings on client.
+ registry
+ .dispatch( CORE_SITE )
+ .setConversionTrackingEnabled( clientValues.enabled );
+ expect(
+ registry
+ .select( CORE_SITE )
+ .haveConversionTrackingSettingsChanged()
+ ).toEqual( true );
+
+ // False after updating settings back to original server value on client.
+ registry
+ .dispatch( CORE_SITE )
+ .setConversionTrackingEnabled( serverValues.enabled );
+ expect(
+ registry
+ .select( CORE_SITE )
+ .haveConversionTrackingSettingsChanged()
+ ).toEqual( false );
+ } );
+ } );
+ } );
+} );
diff --git a/assets/js/googlesitekit/datastore/site/index.js b/assets/js/googlesitekit/datastore/site/index.js
index 951bd3e5aa2..c59dab37a44 100644
--- a/assets/js/googlesitekit/datastore/site/index.js
+++ b/assets/js/googlesitekit/datastore/site/index.js
@@ -23,6 +23,7 @@ import Data from 'googlesitekit-data';
import cache from './cache';
import connection from './connection';
import consentMode from './consent-mode';
+import conversionTracking from './conversion-tracking';
import errors from './errors';
import html from './html';
import info from './info';
@@ -39,6 +40,7 @@ const store = Data.combineStores(
Data.commonStore,
connection,
consentMode,
+ conversionTracking,
errors,
html,
info,
diff --git a/assets/js/googlesitekit/datastore/site/info.js b/assets/js/googlesitekit/datastore/site/info.js
index 27d5cdb46d3..9cd10806fd5 100644
--- a/assets/js/googlesitekit/datastore/site/info.js
+++ b/assets/js/googlesitekit/datastore/site/info.js
@@ -164,6 +164,7 @@ export const reducer = ( state, { payload, type } ) => {
productPostType,
keyMetricsSetupCompletedBy,
keyMetricsSetupNew,
+ consentModeRegions,
} = payload.siteInfo;
return {
@@ -197,6 +198,7 @@ export const reducer = ( state, { payload, type } ) => {
productPostType,
keyMetricsSetupCompletedBy,
keyMetricsSetupNew,
+ consentModeRegions,
},
};
}
@@ -275,6 +277,7 @@ export const resolvers = {
productPostType,
keyMetricsSetupCompletedBy,
keyMetricsSetupNew,
+ consentModeRegions,
} = global._googlesitekitBaseData;
const {
@@ -313,6 +316,7 @@ export const resolvers = {
productPostType,
keyMetricsSetupCompletedBy,
keyMetricsSetupNew,
+ consentModeRegions,
} );
},
};
@@ -848,6 +852,15 @@ export const selectors = {
negateDefined( selectors.getKeyMetricsSetupCompletedBy( state ) )
);
},
+
+ /**
+ * Get the static list of consent mode regions.
+ *
+ * @since n.e.x.t
+ *
+ * @return {Array} Array of consent mode regions.
+ */
+ getConsentModeRegions: getSiteInfoProperty( 'consentModeRegions' ),
};
export default {
diff --git a/assets/js/googlesitekit/datastore/site/info.test.js b/assets/js/googlesitekit/datastore/site/info.test.js
index f8bb16c31ac..7bcf38d113c 100644
--- a/assets/js/googlesitekit/datastore/site/info.test.js
+++ b/assets/js/googlesitekit/datastore/site/info.test.js
@@ -374,6 +374,7 @@ describe( 'core/site site info', () => {
[ 'isWebStoriesActive', 'webStoriesActive' ],
[ 'getProductPostType', 'productPostType' ],
[ 'isKeyMetricsSetupCompleted', 'keyMetricsSetupCompletedBy' ],
+ [ 'getConsentModeRegions', 'consentModeRegions' ],
] )( '%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/date-range.js b/assets/js/googlesitekit/datastore/user/date-range.js
index f14a6606b89..281c086d990 100644
--- a/assets/js/googlesitekit/datastore/user/date-range.js
+++ b/assets/js/googlesitekit/datastore/user/date-range.js
@@ -152,10 +152,17 @@ export const selectors = {
state,
{
compare = false,
- offsetDays = 0,
+ offsetDays,
referenceDate = state.referenceDate,
} = {}
) {
+ if ( offsetDays === undefined ) {
+ global.console.warn(
+ 'getDateRangeDates was called without offsetDays'
+ );
+ offsetDays = 0;
+ }
+
const dateRange = selectors.getDateRange( state );
const endDate = getPreviousDate( referenceDate, offsetDays );
const matches = dateRange.match( '-(.*)-' );
diff --git a/assets/js/googlesitekit/datastore/user/date-range.test.js b/assets/js/googlesitekit/datastore/user/date-range.test.js
index 95c8ec92a42..232761f0a66 100644
--- a/assets/js/googlesitekit/datastore/user/date-range.test.js
+++ b/assets/js/googlesitekit/datastore/user/date-range.test.js
@@ -96,15 +96,37 @@ describe( 'core/user date-range', () => {
additionalOptions = {}
) => {
registry.dispatch( CORE_USER ).setDateRange( dateRange );
+
expect(
registry.select( CORE_USER ).getDateRangeDates( {
...options,
...additionalOptions,
} )
).toEqual( expected );
+
+ if ( additionalOptions.offsetDays === undefined ) {
+ // eslint-disable-next-line no-console
+ expect( console.warn ).toHaveBeenCalled();
+ }
};
- describe( 'with date range', () => {
+ describe( 'with date range and w/o offset', () => {
+ beforeAll( () => {
+ jest.spyOn( console, 'warn' ).mockImplementation(
+ () => {}
+ );
+ } );
+
+ afterAll( () => {
+ // eslint-disable-next-line no-console
+ console.warn.mockRestore();
+ } );
+
+ afterEach( () => {
+ // eslint-disable-next-line no-console
+ console.warn.mockClear();
+ } );
+
// [ dateRange, expectedReturnDates ]
const valuesToTest = [
[
diff --git a/assets/js/googlesitekit/datastore/user/expirable-items.js b/assets/js/googlesitekit/datastore/user/expirable-items.js
new file mode 100644
index 00000000000..249fe897bf5
--- /dev/null
+++ b/assets/js/googlesitekit/datastore/user/expirable-items.js
@@ -0,0 +1,198 @@
+/**
+ * `core/user` data store: expirable items
+ *
+ * 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 invariant from 'invariant';
+
+/**
+ * Internal dependencies
+ */
+import API from 'googlesitekit-api';
+import Data from 'googlesitekit-data';
+import { CORE_USER } from './constants';
+import { createFetchStore } from '../../data/create-fetch-store';
+import { createValidatedAction } from '../../data/utils';
+const { createRegistrySelector, commonActions } = Data;
+const { getRegistry } = commonActions;
+
+function reducerCallback( state, expirableItems ) {
+ return {
+ ...state,
+ expirableItems,
+ };
+}
+
+const fetchGetExpirableItemsStore = createFetchStore( {
+ baseName: 'getExpirableItems',
+ controlCallback: () =>
+ API.get( 'core', 'user', 'expirable-items', {}, { useCache: false } ),
+ reducerCallback,
+} );
+
+const fetchSetExpirableItemTimersStore = createFetchStore( {
+ baseName: 'setExpirableItemTimers',
+ controlCallback: ( items ) =>
+ API.set( 'core', 'user', 'set-expirable-item-timers', items ),
+ reducerCallback,
+ argsToParams: ( items = [] ) => {
+ return items.map( ( item ) => {
+ const { slug, expiresInSeconds } = item;
+
+ return {
+ slug,
+ expiration: expiresInSeconds,
+ };
+ } );
+ },
+ validateParams: ( items ) => {
+ invariant( Array.isArray( items ), 'items are required.' );
+
+ items.forEach( ( item ) => {
+ const { slug, expiresInSeconds = 0 } = item;
+ invariant( slug, 'slug is required.' );
+ invariant(
+ Number.isInteger( expiresInSeconds ),
+ 'expiresInSeconds must be an integer.'
+ );
+ } );
+ },
+} );
+
+const baseInitialState = {
+ expirableItems: undefined,
+};
+
+const baseActions = {
+ setExpirableItemTimers: createValidatedAction(
+ ( items = [] ) => {
+ items.forEach( ( item ) => {
+ const { slug, expiresInSeconds } = item;
+
+ invariant( slug, 'An item slug is required.' );
+ invariant(
+ Number.isInteger( expiresInSeconds ),
+ 'expiresInSeconds must be an integer.'
+ );
+ } );
+ },
+ function ( items ) {
+ return fetchSetExpirableItemTimersStore.actions.fetchSetExpirableItemTimers(
+ items
+ );
+ }
+ ),
+};
+
+const baseResolvers = {
+ *getExpirableItems() {
+ const { select } = yield getRegistry();
+ const expirableItems = select( CORE_USER ).getExpirableItems();
+ if ( expirableItems === undefined ) {
+ yield fetchGetExpirableItemsStore.actions.fetchGetExpirableItems();
+ }
+ },
+};
+
+const baseSelectors = {
+ /**
+ * Returns the expirable items.
+ *
+ * @since n.e.x.t
+ *
+ * @param {Object} state Data store's state.
+ * @return {Array|undefined} Items if exists, `undefined` if not resolved yet.
+ */
+ getExpirableItems( state ) {
+ return state.expirableItems;
+ },
+
+ /**
+ * Determines whether the item exists in expirable items.
+ *
+ * @since n.e.x.t
+ *
+ * @param {Object} state Data store's state.
+ * @param {string} slug Item slug.
+ * @return {(boolean|undefined)} TRUE if exists, otherwise FALSE, `undefined` if not resolved yet.
+ */
+ hasExpirableItem: createRegistrySelector( ( select ) => ( state, slug ) => {
+ const expirableItems = select( CORE_USER ).getExpirableItems();
+
+ if ( expirableItems === undefined ) {
+ return undefined;
+ }
+
+ return expirableItems.hasOwnProperty( slug );
+ } ),
+
+ /**
+ * Determines whether the item is active and not expired.
+ *
+ * @since n.e.x.t
+ *
+ * @param {Object} state Data store's state.
+ * @param {string} slug Item slug.
+ * @return {(boolean|undefined)} TRUE if exists, otherwise FALSE, `undefined` if not resolved yet.
+ */
+ isExpirableItemActive: createRegistrySelector(
+ ( select ) => ( state, slug ) => {
+ const expirableItems = select( CORE_USER ).getExpirableItems();
+
+ if ( expirableItems === undefined ) {
+ return undefined;
+ }
+
+ const expiresInSeconds = expirableItems[ slug ];
+
+ if ( expiresInSeconds === undefined ) {
+ return false;
+ }
+
+ return expiresInSeconds > Math.floor( Date.now() / 1000 );
+ }
+ ),
+};
+
+export const {
+ actions,
+ controls,
+ initialState,
+ reducer,
+ resolvers,
+ selectors,
+} = Data.combineStores(
+ {
+ initialState: baseInitialState,
+ actions: baseActions,
+ resolvers: baseResolvers,
+ selectors: baseSelectors,
+ },
+ fetchGetExpirableItemsStore,
+ fetchSetExpirableItemTimersStore
+);
+
+export default {
+ actions,
+ controls,
+ initialState,
+ reducer,
+ resolvers,
+ selectors,
+};
diff --git a/assets/js/googlesitekit/datastore/user/expirable-items.test.js b/assets/js/googlesitekit/datastore/user/expirable-items.test.js
new file mode 100644
index 00000000000..8ce21cd6ab6
--- /dev/null
+++ b/assets/js/googlesitekit/datastore/user/expirable-items.test.js
@@ -0,0 +1,265 @@
+/**
+ * `core/user` data store: expirable items 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 { CORE_USER } from './constants';
+import {
+ createTestRegistry,
+ muteFetch,
+ untilResolved,
+} from '../../../../../tests/js/utils';
+import fetchMock from 'fetch-mock';
+
+describe( 'core/user expirable-items', () => {
+ const fetchGetExpiredItems = new RegExp(
+ '^/google-site-kit/v1/core/user/data/expirable-items'
+ );
+
+ const fetchExpirableItem = new RegExp(
+ '^/google-site-kit/v1/core/user/data/set-expirable-item-timers'
+ );
+
+ let registry;
+
+ beforeEach( () => {
+ registry = createTestRegistry();
+ } );
+
+ describe( 'actions', () => {
+ describe( 'setExpirableItemTimers', () => {
+ it( 'should save settings and return new expirable items', async () => {
+ fetchMock.postOnce( fetchExpirableItem, {
+ body: [ { foo: 1000 }, { bar: 2000 }, { baz: 700 } ],
+ } );
+
+ await registry.dispatch( CORE_USER ).setExpirableItemTimers( [
+ {
+ slug: 'baz',
+ expiresInSeconds: 700,
+ },
+ ] );
+
+ // Ensure the proper body parameters were sent.
+ expect( fetchMock ).toHaveFetched( fetchExpirableItem, {
+ body: {
+ data: [
+ {
+ slug: 'baz',
+ expiration: 700,
+ },
+ ],
+ },
+ } );
+
+ const expirableItems = registry
+ .select( CORE_USER )
+ .getExpirableItems();
+
+ expect( expirableItems ).toEqual( [
+ { foo: 1000 },
+ { bar: 2000 },
+ { baz: 700 },
+ ] );
+
+ expect( fetchMock ).toHaveFetchedTimes( 1 );
+ } );
+
+ it( 'should dispatch an error if the request fails', async () => {
+ const response = {
+ code: 'internal_server_error',
+ message: 'Internal server error',
+ data: { status: 500 },
+ };
+
+ fetchMock.post( fetchExpirableItem, {
+ body: response,
+ status: 500,
+ } );
+
+ const args = [
+ {
+ slug: 'baz',
+ expiresInSeconds: 700,
+ },
+ ];
+
+ await registry
+ .dispatch( CORE_USER )
+ .setExpirableItemTimers( args );
+
+ expect(
+ registry
+ .select( CORE_USER )
+ .getErrorForAction( 'setExpirableItemTimers', [ args ] )
+ ).toMatchObject( response );
+
+ expect( console ).toHaveErrored();
+ } );
+ } );
+ } );
+
+ describe( 'selectors', () => {
+ describe( 'getExpirableItems', () => {
+ it( 'should return undefined until resolved', async () => {
+ muteFetch( fetchGetExpiredItems, [] );
+ expect(
+ registry.select( CORE_USER ).getExpirableItems()
+ ).toBeUndefined();
+ await untilResolved( registry, CORE_USER ).getExpirableItems();
+ } );
+
+ it( 'should return expirable items received from API', async () => {
+ fetchMock.getOnce( fetchGetExpiredItems, {
+ body: [ { foo: 1000, bar: 2000 } ],
+ } );
+
+ const expirableItems = registry
+ .select( CORE_USER )
+ .getExpirableItems();
+ expect( expirableItems ).toBeUndefined();
+
+ await untilResolved( registry, CORE_USER ).getExpirableItems();
+
+ expect(
+ registry.select( CORE_USER ).getExpirableItems()
+ ).toEqual( [ { foo: 1000, bar: 2000 } ] );
+ expect( fetchMock ).toHaveFetched();
+ } );
+
+ it( 'should throw an error', async () => {
+ const response = {
+ code: 'internal_server_error',
+ message: 'Internal server error',
+ data: { status: 500 },
+ };
+
+ fetchMock.getOnce( fetchGetExpiredItems, {
+ body: response,
+ status: 500,
+ } );
+
+ const expirableItems = registry
+ .select( CORE_USER )
+ .getExpirableItems();
+ expect( expirableItems ).toBeUndefined();
+
+ await untilResolved( registry, CORE_USER ).getExpirableItems();
+
+ registry.select( CORE_USER ).getExpirableItems();
+
+ const error = registry
+ .select( CORE_USER )
+ .getErrorForSelector( 'getExpirableItems' );
+ expect( error ).toMatchObject( response );
+
+ expect( fetchMock ).toHaveFetchedTimes( 1 );
+ expect( console ).toHaveErrored();
+ } );
+ } );
+
+ describe( 'isExpirableItemActive', () => {
+ it( 'should return undefined if getExpirableItems selector is not resolved yet', async () => {
+ fetchMock.getOnce( fetchGetExpiredItems, { body: [] } );
+ const isItemActive = registry
+ .select( CORE_USER )
+ .isExpirableItemActive( 'foo' );
+ expect( isItemActive ).toBeUndefined();
+ await untilResolved( registry, CORE_USER ).getExpirableItems();
+ } );
+ } );
+
+ it( 'should return TRUE if the item has not expired', () => {
+ const currentTimeInSeconds = Math.floor( Date.now() / 1000 );
+
+ registry.dispatch( CORE_USER ).receiveGetExpirableItems( {
+ foo: currentTimeInSeconds + 100,
+ bar: currentTimeInSeconds - 100,
+ } );
+
+ const isItemActive = registry
+ .select( CORE_USER )
+ .isExpirableItemActive( 'foo' );
+
+ expect( isItemActive ).toBe( true );
+ } );
+
+ it( 'should return FALSE if the item has expired', () => {
+ const currentTimeInSeconds = Math.floor( Date.now() / 1000 );
+
+ registry.dispatch( CORE_USER ).receiveGetExpirableItems( {
+ foo: currentTimeInSeconds + 100,
+ bar: currentTimeInSeconds - 100,
+ } );
+
+ const isItemActive = registry
+ .select( CORE_USER )
+ .isExpirableItemActive( 'bar' );
+
+ expect( isItemActive ).toBe( false );
+ } );
+
+ it( 'should return FALSE if the item is not in the set of expirable', () => {
+ const currentTimeInSeconds = Math.floor( Date.now() / 1000 );
+
+ registry.dispatch( CORE_USER ).receiveGetExpirableItems( {
+ foo: currentTimeInSeconds + 100,
+ bar: currentTimeInSeconds - 100,
+ } );
+
+ const isItemActive = registry
+ .select( CORE_USER )
+ .isExpirableItemActive( 'baz' );
+
+ expect( isItemActive ).toBe( false );
+ } );
+ } );
+
+ describe( 'hasExpirableItem', () => {
+ it( 'should return TRUE if the item is in the set of expirable items', () => {
+ const currentTimeInSeconds = Math.floor( Date.now() / 1000 );
+
+ registry.dispatch( CORE_USER ).receiveGetExpirableItems( {
+ foo: currentTimeInSeconds + 100,
+ bar: currentTimeInSeconds - 100,
+ } );
+
+ const itemExists = registry
+ .select( CORE_USER )
+ .isExpirableItemActive( 'foo' );
+
+ expect( itemExists ).toBe( true );
+ } );
+
+ it( 'should return FALSE if the item is not in the set of expirable items', () => {
+ const currentTimeInSeconds = Math.floor( Date.now() / 1000 );
+
+ registry.dispatch( CORE_USER ).receiveGetExpirableItems( {
+ foo: currentTimeInSeconds + 100,
+ bar: currentTimeInSeconds - 100,
+ } );
+
+ const itemExists = registry
+ .select( CORE_USER )
+ .isExpirableItemActive( 'baz' );
+
+ expect( itemExists ).toBe( false );
+ } );
+ } );
+} );
diff --git a/assets/js/googlesitekit/datastore/user/index.js b/assets/js/googlesitekit/datastore/user/index.js
index a91459ba7e7..8c164a301c4 100644
--- a/assets/js/googlesitekit/datastore/user/index.js
+++ b/assets/js/googlesitekit/datastore/user/index.js
@@ -27,6 +27,7 @@ import { CORE_USER } from './constants';
import dateRange from './date-range';
import disconnect from './disconnect';
import dismissedItems from './dismissed-items';
+import expirableItems from './expirable-items';
import featureTours from './feature-tours';
import keyMetrics from './key-metrics';
import notifications from './notifications';
@@ -46,6 +47,7 @@ const store = Data.combineStores(
dateRange,
disconnect,
dismissedItems,
+ expirableItems,
featureTours,
keyMetrics,
notifications,
diff --git a/assets/js/modules/ads/components/PAXEmbeddedApp.js b/assets/js/modules/ads/components/common/PAXEmbeddedApp.js
similarity index 63%
rename from assets/js/modules/ads/components/PAXEmbeddedApp.js
rename to assets/js/modules/ads/components/common/PAXEmbeddedApp.js
index c10e681287f..7d5fdf08135 100644
--- a/assets/js/modules/ads/components/PAXEmbeddedApp.js
+++ b/assets/js/modules/ads/components/common/PAXEmbeddedApp.js
@@ -39,15 +39,19 @@ import { __ } from '@wordpress/i18n';
* Internal dependencies
*/
import Data from 'googlesitekit-data';
-import { CORE_USER } from '../../../googlesitekit/datastore/user/constants';
-import CTA from '../../../components/notifications/CTA';
-import PreviewBlock from '../../../components/PreviewBlock';
-import { createPaxServices } from '../pax/services';
-const { useRegistry, useSelect } = Data;
+import PreviewBlock from '../../../../components/PreviewBlock';
+import CTA from '../../../../components/notifications/CTA';
+import { CORE_USER } from '../../../../googlesitekit/datastore/user/constants';
+import { DATE_RANGE_OFFSET } from '../../../analytics-4/datastore/constants';
+import { createPaxServices } from '../../pax/services';
+import { useMemoOne } from 'use-memo-one';
+import { formatPaxDate } from '../../pax/utils';
+const { useRegistry, useSelect } = Data;
export default function PAXEmbeddedApp( {
displayMode = 'default',
onLaunch,
+ onCampaignCreated,
} ) {
const [ launchGoogleAdsAvailable, setLaunchGoogleAdsAvailable ] = useState(
typeof global?.google?.ads?.integration?.integrator?.launchGoogleAds ===
@@ -61,8 +65,18 @@ export default function PAXEmbeddedApp( {
const registry = useRegistry();
const paxServices = useMemo( () => {
- return createPaxServices( registry );
- }, [ registry ] );
+ return createPaxServices( registry, { onCampaignCreated } );
+ }, [ registry, onCampaignCreated ] );
+
+ const paxDateRange = useSelect( ( select ) => {
+ if ( displayMode !== 'reporting' ) {
+ return {};
+ }
+
+ return select( CORE_USER ).getDateRangeDates( {
+ offsetDays: DATE_RANGE_OFFSET,
+ } );
+ } );
const isAdBlockerActive = useSelect( ( select ) =>
select( CORE_USER ).isAdBlockerActive()
@@ -72,9 +86,11 @@ export default function PAXEmbeddedApp( {
const paxAppRef = useRef();
- const elementID = `googlesitekit-pax-embedded-app-${ instanceID }`;
+ const elementID = useMemoOne( () => {
+ return `googlesitekit-pax-embedded-app-${ instanceID }`;
+ }, [ instanceID ] );
- const paxConfig = useMemo( () => {
+ const paxConfig = useMemoOne( () => {
return {
...( global?._googlesitekitPAXConfig || {} ),
clientConfig: {
@@ -91,7 +107,27 @@ export default function PAXEmbeddedApp( {
};
}, [ elementID, displayMode ] );
+ const setDateRangeForReportingMode = useCallback( () => {
+ if (
+ displayMode === 'reporting' &&
+ paxAppRef?.current &&
+ paxDateRange.startDate &&
+ paxDateRange.endDate
+ ) {
+ paxAppRef.current.getServices().adsDateRangeService.update( {
+ startDate: formatPaxDate( paxDateRange.startDate ),
+ endDate: formatPaxDate( paxDateRange.endDate ),
+ } );
+ }
+ }, [ displayMode, paxDateRange.endDate, paxDateRange.startDate ] );
+
const launchPAXApp = useCallback( async () => {
+ if ( hasLaunchedPAXApp || paxAppRef.current ) {
+ return;
+ }
+
+ setHasLaunchedPAXApp( true );
+
try {
paxAppRef.current =
await global.google.ads.integration.integrator.launchGoogleAds(
@@ -99,6 +135,8 @@ export default function PAXEmbeddedApp( {
paxServices
);
+ setDateRangeForReportingMode();
+
onLaunch?.( paxAppRef.current );
} catch ( error ) {
setLaunchError( error );
@@ -109,7 +147,13 @@ export default function PAXEmbeddedApp( {
}
setIsLoading( false );
- }, [ paxConfig, paxServices, onLaunch ] );
+ }, [
+ hasLaunchedPAXApp,
+ paxConfig,
+ paxServices,
+ setDateRangeForReportingMode,
+ onLaunch,
+ ] );
useInterval(
() => {
@@ -131,9 +175,7 @@ export default function PAXEmbeddedApp( {
);
useEffect( () => {
- if ( launchGoogleAdsAvailable && ! hasLaunchedPAXApp ) {
- setHasLaunchedPAXApp( true );
-
+ if ( launchGoogleAdsAvailable ) {
launchPAXApp();
}
}, [
@@ -143,6 +185,19 @@ export default function PAXEmbeddedApp( {
launchPAXApp,
] );
+ useEffect( () => {
+ setDateRangeForReportingMode();
+ }, [
+ setDateRangeForReportingMode,
+ // `setDateRangeForReportingMode` will change whenever the date range
+ // updates, causing this effect to run again, so the two date range
+ // dependencies are technically redundant, but are explicitly listed
+ // here to make the intent of the code clearer. (They're harmless
+ // to include and do not cause extra renders/requests.)
+ paxDateRange.startDate,
+ paxDateRange.endDate,
+ ] );
+
return (
{ !! launchError && ! isAdBlockerActive && (
diff --git a/assets/js/modules/ads/components/common/index.js b/assets/js/modules/ads/components/common/index.js
index 86bb1b98622..598411eea09 100644
--- a/assets/js/modules/ads/components/common/index.js
+++ b/assets/js/modules/ads/components/common/index.js
@@ -18,3 +18,4 @@
export { default as AdBlockerWarning } from './AdBlockerWarning';
export { default as ConversionIDTextField } from './ConversionIDTextField';
+export { default as PAXEmbeddedApp } from './PAXEmbeddedApp';
diff --git a/assets/js/modules/ads/components/dashboard/PartnerAdsPAXWidget.js b/assets/js/modules/ads/components/dashboard/PartnerAdsPAXWidget.js
new file mode 100644
index 00000000000..d6e65930232
--- /dev/null
+++ b/assets/js/modules/ads/components/dashboard/PartnerAdsPAXWidget.js
@@ -0,0 +1,97 @@
+/**
+ * PartnerAdsPAXWidget 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 { compose } from '@wordpress/compose';
+
+/**
+ * Internal dependencies
+ */
+import Data from 'googlesitekit-data';
+import whenActive from '../../../../util/when-active';
+import whenScopesGranted from '../../../../util/whenScopesGranted';
+import { ADWORDS_SCOPE, MODULES_ADS } from '../../datastore/constants';
+import PAXEmbeddedApp from '../common/PAXEmbeddedApp';
+import { AdBlockerWarning } from '../common';
+import { CORE_USER } from '../../../../googlesitekit/datastore/user/constants';
+import { CORE_WIDGETS } from '../../../../googlesitekit/widgets/datastore/constants';
+const { useSelect } = Data;
+
+function PartnerAdsPAXWidget( { WidgetNull, Widget } ) {
+ const isAdblockerActive = useSelect( ( select ) =>
+ select( CORE_USER ).isAdBlockerActive()
+ );
+
+ const paxConversionID = useSelect( ( select ) =>
+ select( MODULES_ADS ).getPaxConversionID()
+ );
+
+ const widgetRendered = useSelect( ( select ) =>
+ select( CORE_WIDGETS ).isWidgetActive( 'partnerAdsPAX' )
+ );
+
+ // If the user doesn't have a PAX Conversion ID, then they haven't set up
+ // Google Ads Partner experience yet, so we shouldn't render the widget.
+ //
+ // If the widget is rendered but the user doesn't have a PAX Conversion ID,
+ // the setup flow will be triggered and we don't want to show that in the
+ // "reporting" widget.
+ if ( ! paxConversionID?.length ) {
+ return
;
+ }
+
+ if ( isAdblockerActive ) {
+ return (
+
+
+
+ );
+ }
+
+ // If the widget hasn't been rendered in the actual DOM yet,
+ // don't load the PAX app.
+ //
+ // This is done to prevent the PAX app from launching before it's actually
+ // inserted into the DOM.
+ if ( ! widgetRendered ) {
+ return
;
+ }
+
+ return (
+
+
+
+ );
+}
+
+PartnerAdsPAXWidget.propTypes = {
+ Widget: PropTypes.elementType.isRequired,
+ WidgetNull: PropTypes.elementType.isRequired,
+};
+
+export default compose(
+ whenActive( { moduleName: 'ads' } ),
+ whenScopesGranted( { scopes: [ ADWORDS_SCOPE ] } )
+)( PartnerAdsPAXWidget );
diff --git a/assets/js/modules/ads/components/dashboard/index.js b/assets/js/modules/ads/components/dashboard/index.js
new file mode 100644
index 00000000000..8bf344ebcdc
--- /dev/null
+++ b/assets/js/modules/ads/components/dashboard/index.js
@@ -0,0 +1,19 @@
+/**
+ * Ads Dashboard components.
+ *
+ * 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 { default as PartnerAdsPAXWidget } from './PartnerAdsPAXWidget';
diff --git a/assets/js/modules/ads/components/settings/SettingsEdit.stories.js b/assets/js/modules/ads/components/settings/SettingsEdit.stories.js
index 2df5d4218fd..4b841f9e106 100644
--- a/assets/js/modules/ads/components/settings/SettingsEdit.stories.js
+++ b/assets/js/modules/ads/components/settings/SettingsEdit.stories.js
@@ -88,7 +88,6 @@ export default {
);
},
],
- features: [ 'adsModule' ],
};
export const PaxConnected = Template.bind( null );
diff --git a/assets/js/modules/ads/components/settings/SettingsForm.stories.js b/assets/js/modules/ads/components/settings/SettingsForm.stories.js
index 5977fbff9f1..08548f5dd85 100644
--- a/assets/js/modules/ads/components/settings/SettingsForm.stories.js
+++ b/assets/js/modules/ads/components/settings/SettingsForm.stories.js
@@ -95,5 +95,4 @@ export default {
);
},
],
- features: [ 'adsModule' ],
};
diff --git a/assets/js/modules/ads/components/setup/SetupForm.stories.js b/assets/js/modules/ads/components/setup/SetupForm.stories.js
index 8d5a23647b2..9de3d210ad3 100644
--- a/assets/js/modules/ads/components/setup/SetupForm.stories.js
+++ b/assets/js/modules/ads/components/setup/SetupForm.stories.js
@@ -158,7 +158,6 @@ export default {
},
],
parameters: {
- features: [ 'adsModule' ],
padding: 0,
},
};
diff --git a/assets/js/modules/ads/components/setup/SetupMainPAX.js b/assets/js/modules/ads/components/setup/SetupMainPAX.js
index 1564be0478f..fa339e417d6 100644
--- a/assets/js/modules/ads/components/setup/SetupMainPAX.js
+++ b/assets/js/modules/ads/components/setup/SetupMainPAX.js
@@ -16,6 +16,12 @@
* limitations under the License.
*/
+/**
+ * External dependencies
+ */
+import { useCallbackOne } from 'use-memo-one';
+import { useMount } from 'react-use';
+
/**
* WordPress dependencies
*/
@@ -23,7 +29,7 @@ import {
createInterpolateElement,
Fragment,
useCallback,
- useState,
+ useRef,
} from '@wordpress/element';
import { __, _x } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
@@ -39,16 +45,23 @@ import SupportLink from '../../../../components/SupportLink';
import AdBlockerWarning from '../common/AdBlockerWarning';
import { CORE_USER } from '../../../../googlesitekit/datastore/user/constants';
import { CORE_LOCATION } from '../../../../googlesitekit/datastore/location/constants';
-import { ADWORDS_SCOPE, MODULES_ADS } from '../../datastore/constants';
+import {
+ ADWORDS_SCOPE,
+ MODULES_ADS,
+ PAX_SETUP_STEP,
+} from '../../datastore/constants';
import useQueryArg from '../../../../hooks/useQueryArg';
-import PAXEmbeddedApp from '../PAXEmbeddedApp';
+import PAXEmbeddedApp from '../common/PAXEmbeddedApp';
const { useSelect, useDispatch } = Data;
const PARAM_SHOW_PAX = 'pax';
export default function SetupMainPAX( { finishSetup } ) {
- const [ showPaxApp, setShowPaxApp ] = useQueryArg( PARAM_SHOW_PAX );
- const [ paxApp, setPaxApp ] = useState( null );
+ const [ showPaxAppQueryParam, setShowPaxAppQueryParam ] =
+ useQueryArg( PARAM_SHOW_PAX );
+ const showPaxAppStep =
+ !! showPaxAppQueryParam && parseInt( showPaxAppQueryParam, 10 );
+ const paxAppRef = useRef();
const isAdBlockerActive = useSelect( ( select ) =>
select( CORE_USER ).isAdBlockerActive()
@@ -56,9 +69,14 @@ export default function SetupMainPAX( { finishSetup } ) {
const hasAdwordsScope = useSelect( ( select ) =>
select( CORE_USER ).hasScope( ADWORDS_SCOPE )
);
+ const hasPaxSettings = useSelect( ( select ) => {
+ const { getPaxConversionID, getExtCustomerID } = select( MODULES_ADS );
+
+ return getPaxConversionID() && getExtCustomerID();
+ } );
const redirectURL = addQueryArgs( global.location.href, {
- [ PARAM_SHOW_PAX ]: 1,
+ [ PARAM_SHOW_PAX ]: PAX_SETUP_STEP.LAUNCH,
} );
const oAuthURL = useSelect( ( select ) =>
@@ -79,8 +97,22 @@ export default function SetupMainPAX( { finishSetup } ) {
const { setPaxConversionID, setExtCustomerID, submitChanges } =
useDispatch( MODULES_ADS );
- const onCompleteSetup = useCallback( async () => {
- if ( ! paxApp ) {
+ useMount( () => {
+ if ( PAX_SETUP_STEP.FINISHED === showPaxAppStep ) {
+ // If the PAX query param indicates the setup is finished on page load,
+ // set the step back to the PAX launch, as values are only temporarly
+ // saved in state after PAX campaign setup signal is received.
+ setShowPaxAppQueryParam( PAX_SETUP_STEP.LAUNCH );
+ }
+ } );
+
+ // Callback to be executed when a campaign is created in PAX.
+ //
+ // We use `useCallbackOne` to ensure the function is only created once
+ // and not recreated when React potentially uncaches the callback causing
+ // it to be recreated and trigger the PAX app to re-render.
+ const onCampaignCreated = useCallbackOne( async () => {
+ if ( ! paxAppRef?.current ) {
return;
}
@@ -88,7 +120,7 @@ export default function SetupMainPAX( { finishSetup } ) {
// Disabling rule because function and property names
// are expected in current format by PAX API.
const { accountService, conversionTrackingIdService } =
- paxApp.getServices();
+ paxAppRef.current.getServices();
const customerData = await accountService.getAccountId( {} );
const conversionTrackingData =
await conversionTrackingIdService.getConversionTrackingId( {} );
@@ -102,21 +134,18 @@ export default function SetupMainPAX( { finishSetup } ) {
setExtCustomerID( customerData.externalCustomerId );
setPaxConversionID( conversionTrackingData.conversionTrackingId );
+ setShowPaxAppQueryParam( PAX_SETUP_STEP.FINISHED );
/* eslint-enable sitekit/acronym-case */
+ }, [ setExtCustomerID, setPaxConversionID ] );
+ const onCompleteSetup = useCallback( async () => {
const { error } = await submitChanges();
if ( error ) {
return;
}
finishSetup();
- }, [
- paxApp,
- setExtCustomerID,
- setPaxConversionID,
- submitChanges,
- finishSetup,
- ] );
+ }, [ submitChanges, finishSetup ] );
const createAccount = useCallback( () => {
if ( ! hasAdwordsScope ) {
@@ -124,8 +153,8 @@ export default function SetupMainPAX( { finishSetup } ) {
return;
}
- setShowPaxApp( true );
- }, [ navigateTo, setShowPaxApp, hasAdwordsScope, oAuthURL ] );
+ setShowPaxAppQueryParam( PAX_SETUP_STEP.LAUNCH );
+ }, [ navigateTo, setShowPaxAppQueryParam, hasAdwordsScope, oAuthURL ] );
return (
@@ -140,77 +169,90 @@ export default function SetupMainPAX( { finishSetup } ) {
- { ! isAdBlockerActive && !! showPaxApp && hasAdwordsScope && (
-
-
+ { ! isAdBlockerActive &&
+ PAX_SETUP_STEP.FINISHED === showPaxAppStep && (
{ __( 'Complete setup', 'google-site-kit' ) }
-
- ) }
-
- { ! isAdBlockerActive && ( ! showPaxApp || ! hasAdwordsScope ) && (
-
-
- { createInterpolateElement(
- __(
- 'Add your conversion ID below. Site Kit will place it on your site so you can track the performance of your Google Ads campaigns.
Learn more ',
- 'google-site-kit'
- ),
- {
- a: (
-
+ ) }
+ { ! isAdBlockerActive &&
+ PAX_SETUP_STEP.LAUNCH === showPaxAppStep &&
+ hasAdwordsScope && (
+
+ {
+ paxAppRef.current = app;
+ } }
+ onCampaignCreated={ onCampaignCreated }
+ />
+
+ ) }
+
+ { ! isAdBlockerActive &&
+ ( ! showPaxAppStep || ! hasAdwordsScope ) && (
+
+
+ { createInterpolateElement(
+ __(
+ 'Add your conversion ID below. Site Kit will place it on your site so you can track the performance of your Google Ads campaigns.
Learn more ',
+ 'google-site-kit'
),
- }
- ) }
-
- { __(
- 'You can always change this later in Site Kit Settings.',
- 'google-site-kit'
- ) }
-
+ {
+ a: (
+
+ ),
+ }
+ ) }
+
+ { __(
+ 'You can always change this later in Site Kit Settings.',
+ 'google-site-kit'
+ ) }
+
-
-
- { __(
- 'Create an account',
- 'google-site-kit'
- ) }
-
- { ! hasAdwordsScope && (
-
+
+
{ __(
- 'You’ll be asked to grant Site Kit additional permissions during the account creation process to create a new Ads account.',
+ 'Create an account',
'google-site-kit'
) }
-
- ) }
-
- }
- />
-
- ) }
+
+ { ! hasAdwordsScope && (
+
+ { __(
+ 'You’ll be asked to grant Site Kit additional permissions during the account creation process to create a new Ads account.',
+ 'google-site-kit'
+ ) }
+
+ ) }
+
+ }
+ />
+
+ ) }
);
}
diff --git a/assets/js/modules/ads/datastore/constants.js b/assets/js/modules/ads/datastore/constants.js
index a601dcc29b6..2712ec890e8 100644
--- a/assets/js/modules/ads/datastore/constants.js
+++ b/assets/js/modules/ads/datastore/constants.js
@@ -19,3 +19,8 @@
export const MODULES_ADS = 'modules/ads';
export const ADWORDS_SCOPE = 'https://www.googleapis.com/auth/adwords';
+
+export const PAX_SETUP_STEP = {
+ LAUNCH: 1,
+ FINISHED: 2,
+};
diff --git a/assets/js/modules/ads/datastore/moduleData.js b/assets/js/modules/ads/datastore/moduleData.js
index 22fa48cc611..47d5c8df131 100644
--- a/assets/js/modules/ads/datastore/moduleData.js
+++ b/assets/js/modules/ads/datastore/moduleData.js
@@ -52,7 +52,7 @@ export const actions = {
* Because this is frequently-accessed data, this is usually sourced
* from a global variable (`_googlesitekitModulesData`), set by PHP.
*
- * @since n.e.x.t
+ * @since 1.127.0
* @private
*
* @param {Object} moduleData Module data, usually supplied via a global variable from PHP.
@@ -105,7 +105,7 @@ export const selectors = {
* Not intended to be used publicly; this is largely here so other selectors can
* request data using the selector/resolver pattern.
*
- * @since n.e.x.t
+ * @since 1.127.0
* @private
*
* @param {Object} state Data store's state.
@@ -118,7 +118,7 @@ export const selectors = {
/**
* Gets supported conversion events.
*
- * @since n.e.x.t
+ * @since 1.127.0
*
* @param {Object} state Data store's state.
* @return {(Array|undefined)} List of supported conversion events.
diff --git a/assets/js/modules/ads/datastore/settings.js b/assets/js/modules/ads/datastore/settings.js
index 7d6db51f8e6..e3af386e25e 100644
--- a/assets/js/modules/ads/datastore/settings.js
+++ b/assets/js/modules/ads/datastore/settings.js
@@ -30,6 +30,7 @@ import {
INVARIANT_DOING_SUBMIT_CHANGES,
INVARIANT_SETTINGS_NOT_CHANGED,
} from '../../../googlesitekit/data/create-settings-store';
+import { CORE_SITE } from '../../../googlesitekit/datastore/site/constants';
import { MODULES_ADS } from './constants';
import { isValidConversionID } from '../utils/validation';
@@ -38,15 +39,29 @@ export const INVARIANT_INVALID_CONVERSION_ID =
'a valid conversionID is required to submit changes';
export async function submitChanges( { select, dispatch } ) {
+ const haveSettingsChanged = select( MODULES_ADS ).haveSettingsChanged();
+
// This action shouldn't be called if settings haven't changed,
// but this prevents errors in tests.
- if ( select( MODULES_ADS ).haveSettingsChanged() ) {
+ if ( haveSettingsChanged ) {
const { error } = await dispatch( MODULES_ADS ).saveSettings();
if ( error ) {
return { error };
}
}
+ const haveConversionTrackingSettingsChanged =
+ select( CORE_SITE ).haveConversionTrackingSettingsChanged();
+ if ( haveConversionTrackingSettingsChanged ) {
+ const { error } = await dispatch(
+ CORE_SITE
+ ).saveConversionTrackingSettings();
+
+ if ( error ) {
+ return { error };
+ }
+ }
+
await API.invalidateCache( 'modules', 'ads' );
return {};
diff --git a/assets/js/modules/ads/index.js b/assets/js/modules/ads/index.js
index 28659c4f321..b9f6255b612 100644
--- a/assets/js/modules/ads/index.js
+++ b/assets/js/modules/ads/index.js
@@ -25,7 +25,6 @@ import { __ } from '@wordpress/i18n';
* Internal dependencies
*/
import AdsIcon from '../../../svg/graphics/ads.svg';
-import { isFeatureEnabled } from '../../features';
import { SettingsEdit, SettingsView } from './components/settings';
import { SetupMain, SetupMainPAX } from './components/setup';
import { MODULES_ADS } from './datastore/constants';
@@ -33,48 +32,63 @@ import {
CORE_USER,
ERROR_CODE_ADBLOCKER_ACTIVE,
} from '../../googlesitekit/datastore/user/constants';
+import { isFeatureEnabled } from '../../features';
+import PartnerAdsPAXWidget from './components/dashboard/PartnerAdsPAXWidget';
+import { AREA_MAIN_DASHBOARD_TRAFFIC_PRIMARY } from '../../googlesitekit/widgets/default-areas';
export { registerStore } from './datastore';
export const registerModule = ( modules ) => {
- if ( isFeatureEnabled( 'adsModule' ) ) {
- modules.registerModule( 'ads', {
- storeName: MODULES_ADS,
- SettingsEditComponent: SettingsEdit,
- SettingsViewComponent: SettingsView,
- SetupComponent: isFeatureEnabled( 'adsPax' )
- ? SetupMainPAX
- : SetupMain,
- Icon: AdsIcon,
- features: [
- __(
- 'Tagging necessary for your ads campaigns to work',
- 'google-site-kit'
- ),
- __(
- 'Conversion tracking for your ads campaigns',
- 'google-site-kit'
- ),
- ],
- checkRequirements: async ( registry ) => {
- const adBlockerActive = await registry
- .__experimentalResolveSelect( CORE_USER )
- .isAdBlockerActive();
+ modules.registerModule( 'ads', {
+ storeName: MODULES_ADS,
+ SettingsEditComponent: SettingsEdit,
+ SettingsViewComponent: SettingsView,
+ SetupComponent: isFeatureEnabled( 'adsPax' ) ? SetupMainPAX : SetupMain,
+ Icon: AdsIcon,
+ features: [
+ __(
+ 'Tagging necessary for your ads campaigns to work',
+ 'google-site-kit'
+ ),
+ __(
+ 'Conversion tracking for your ads campaigns',
+ 'google-site-kit'
+ ),
+ ],
+ checkRequirements: async ( registry ) => {
+ const adBlockerActive = await registry
+ .__experimentalResolveSelect( CORE_USER )
+ .isAdBlockerActive();
- if ( ! adBlockerActive ) {
- return;
- }
+ if ( ! adBlockerActive ) {
+ return;
+ }
- const message = registry
- .select( MODULES_ADS )
- .getAdBlockerWarningMessage();
+ const message = registry
+ .select( MODULES_ADS )
+ .getAdBlockerWarningMessage();
+
+ throw {
+ code: ERROR_CODE_ADBLOCKER_ACTIVE,
+ message,
+ data: null,
+ };
+ },
+ } );
+};
- throw {
- code: ERROR_CODE_ADBLOCKER_ACTIVE,
- message,
- data: null,
- };
+export const registerWidgets = ( widgets ) => {
+ if ( isFeatureEnabled( 'adsPax' ) ) {
+ widgets.registerWidget(
+ 'partnerAdsPAX',
+ {
+ Component: PartnerAdsPAXWidget,
+ width: widgets.WIDGET_WIDTHS.FULL,
+ priority: 20,
+ wrapWidget: false,
+ modules: [ 'ads' ],
},
- } );
+ [ AREA_MAIN_DASHBOARD_TRAFFIC_PRIMARY ]
+ );
}
};
diff --git a/assets/js/modules/ads/pax/services.js b/assets/js/modules/ads/pax/services.js
index 875c5874cb7..dc74c7191b5 100644
--- a/assets/js/modules/ads/pax/services.js
+++ b/assets/js/modules/ads/pax/services.js
@@ -26,6 +26,9 @@ import apiFetch from '@wordpress/api-fetch';
*/
import { CORE_SITE } from '../../../googlesitekit/datastore/site/constants';
import { MODULES_ADS } from '../datastore/constants';
+import { formatPaxDate } from './utils';
+import { CORE_USER } from '../../../googlesitekit/datastore/user/constants';
+import { DATE_RANGE_OFFSET } from '../../analytics-4/datastore/constants';
const restFetchWpPages = async () => {
try {
@@ -47,16 +50,31 @@ const restFetchWpPages = async () => {
* Returns PAX services.
*
* @since 1.126.0
+ * @since n.e.x.t Added options parameter.
*
- * @param {Object} registry Registry object to dispatch to.
+ * @param {Object} registry Registry object to dispatch to.
+ * @param {Object} options Optional. Additional options.
+ * @param {Function} options.onCampaignCreated Callback function that will be called when campaign is created.
+ * @param {Object} options._global The global window object.
* @return {Object} An object containing various service interfaces.
*/
-export function createPaxServices( registry ) {
+export function createPaxServices( registry, options = {} ) {
+ const { onCampaignCreated = null, _global = global } = options;
+
+ const { select, __experimentalResolveSelect: resolveSelect } = registry;
const accessToken =
- global?._googlesitekitPAXConfig?.authAccess?.oauthTokenAccess?.token;
+ _global?._googlesitekitPAXConfig?.authAccess?.oauthTokenAccess?.token;
- return {
+ const services = {
authenticationService: {
+ // Ignore the ESLint rule that requires `await` in the function body.
+ //
+ // We mark this function as `async` to make it clear that it returns a
+ // promise and in case, in the future, anything here wants to be async.
+ //
+ // Marking this function as `async` makes it clear that this will be
+ // allowed.
+ //
// eslint-disable-next-line require-await
get: async () => {
return { accessToken };
@@ -68,14 +86,12 @@ export function createPaxServices( registry ) {
},
businessService: {
getBusinessInfo: async () => {
- await registry
- .__experimentalResolveSelect( CORE_SITE )
- .getSiteInfo();
+ await resolveSelect( CORE_SITE ).getSiteInfo();
/* eslint-disable sitekit/acronym-case */
// Disabling rule because businessName and businessUrl are expected by PAX API.
- const businessName = registry.select( CORE_SITE ).getSiteName();
- const businessUrl = registry.select( CORE_SITE ).getHomeURL();
+ const businessName = select( CORE_SITE ).getSiteName();
+ const businessUrl = select( CORE_SITE ).getHomeURL();
return { businessName, businessUrl };
/* eslint-enable sitekit/acronym-case */
@@ -86,28 +102,63 @@ export function createPaxServices( registry ) {
},
},
conversionTrackingService: {
- // eslint-disable-next-line require-await
getSupportedConversionLabels: async () => {
- await registry
- .__experimentalResolveSelect( MODULES_ADS )
- .getModuleData();
+ await resolveSelect( MODULES_ADS ).getModuleData();
const conversionEvents =
- registry
- .select( MODULES_ADS )
- .getSupportedConversionEvents() || [];
+ select( MODULES_ADS ).getSupportedConversionEvents() || [];
+
return { conversionLabels: conversionEvents };
},
- // eslint-disable-next-line require-await
getPageViewConversionSetting: async () => {
const websitePages = await restFetchWpPages();
return {
- enablePageViewConversion: true,
websitePages,
};
},
+ // eslint-disable-next-line require-await
+ getSupportedConversionTrackingTypes: async () => {
+ return {
+ conversionTrackingTypes: [
+ // @TODO: Include TYPE_CONVERSION_EVENT in a future update.
+ // 'TYPE_CONVERSION_EVENT',
+ 'TYPE_PAGE_VIEW',
+ ],
+ };
+ },
},
termsAndConditionsService: {
notify: async () => {},
},
+ partnerDateRangeService: {
+ // Ignore the ESLint rule that requires `await` in the function body.
+ //
+ // We mark this function as `async` to make it clear that it returns a
+ // promise and in case, in the future, anything here wants to be async.
+ //
+ // Marking this function as `async` makes it clear that this will be
+ // allowed.
+ //
+ // eslint-disable-next-line require-await
+ get: async () => {
+ const { startDate, endDate } = registry
+ .select( CORE_USER )
+ .getDateRangeDates( {
+ offsetDays: DATE_RANGE_OFFSET,
+ } );
+
+ return {
+ startDate: formatPaxDate( startDate ),
+ endDate: formatPaxDate( endDate ),
+ };
+ },
+ },
};
+
+ if ( onCampaignCreated ) {
+ services.campaignService = {
+ notifyNewCampaignCreated: onCampaignCreated,
+ };
+ }
+
+ return services;
}
diff --git a/assets/js/modules/ads/pax/services.test.js b/assets/js/modules/ads/pax/services.test.js
index bd14646208f..c0d5b81c068 100644
--- a/assets/js/modules/ads/pax/services.test.js
+++ b/assets/js/modules/ads/pax/services.test.js
@@ -23,6 +23,8 @@ import {
createTestRegistry,
provideSiteInfo,
} from '../../../../../tests/js/utils';
+import { CORE_USER } from '../../../googlesitekit/datastore/user/constants';
+import { MODULES_ADS } from '../datastore/constants';
import { createPaxServices } from './services';
describe( 'PAX partner services', () => {
@@ -33,39 +35,30 @@ describe( 'PAX partner services', () => {
beforeEach( () => {
registry = createTestRegistry();
services = createPaxServices( registry );
- global._googlesitekitPAXConfig = {
- authAccess: {
- oauthTokenAccess: {
- token: 'test-auth-token',
- },
- },
- };
- } );
-
- afterAll( () => {
- global._googlesitekitPAXConfig = undefined;
} );
it( 'should return object with correct services', () => {
- expect( services ).toEqual(
- expect.objectContaining( {
- authenticationService: expect.objectContaining( {
- get: expect.any( Function ),
- fix: expect.any( Function ),
- } ),
- businessService: expect.objectContaining( {
- getBusinessInfo: expect.any( Function ),
- fixBusinessInfo: expect.any( Function ),
- } ),
- conversionTrackingService: expect.objectContaining( {
- getSupportedConversionLabels: expect.any( Function ),
- getPageViewConversionSetting: expect.any( Function ),
- } ),
- termsAndConditionsService: expect.objectContaining( {
- notify: expect.any( Function ),
- } ),
- } )
- );
+ expect( services ).toEqual( {
+ authenticationService: {
+ get: expect.any( Function ),
+ fix: expect.any( Function ),
+ },
+ businessService: {
+ getBusinessInfo: expect.any( Function ),
+ fixBusinessInfo: expect.any( Function ),
+ },
+ conversionTrackingService: {
+ getSupportedConversionLabels: expect.any( Function ),
+ getPageViewConversionSetting: expect.any( Function ),
+ getSupportedConversionTrackingTypes: expect.any( Function ),
+ },
+ termsAndConditionsService: {
+ notify: expect.any( Function ),
+ },
+ partnerDateRangeService: expect.objectContaining( {
+ get: expect.any( Function ),
+ } ),
+ } );
} );
describe( 'authenticationService', () => {
@@ -77,6 +70,17 @@ describe( 'PAX partner services', () => {
expect( authAccess ).toHaveProperty( 'accessToken' );
} );
it( 'should contain correct accessToken', async () => {
+ const _googlesitekitPAXConfig = {
+ authAccess: {
+ oauthTokenAccess: {
+ token: 'test-auth-token',
+ },
+ },
+ };
+ services = createPaxServices( registry, {
+ _global: { _googlesitekitPAXConfig },
+ } );
+
const authAccess =
await services.authenticationService.get();
@@ -132,14 +136,9 @@ describe( 'PAX partner services', () => {
it( 'should hold correct value for conversionLabels property when data is present', async () => {
const mockSupportedEvents = [ 'mock-event' ];
- const adsModuleDataVar = '_googlesitekitModulesData';
- const adsModuleDataVarValue = {
- ads: {
- supportedConversionEvents: mockSupportedEvents,
- },
- };
-
- global[ adsModuleDataVar ] = adsModuleDataVarValue;
+ registry.dispatch( MODULES_ADS ).receiveModuleData( {
+ supportedConversionEvents: mockSupportedEvents,
+ } );
const supportedConversionLabels =
await services.conversionTrackingService.getSupportedConversionLabels();
@@ -147,21 +146,10 @@ describe( 'PAX partner services', () => {
expect(
supportedConversionLabels.conversionLabels
).toEqual( mockSupportedEvents );
-
- delete global[ adsModuleDataVar ];
} );
} );
describe( 'getPageViewConversionSetting', () => {
- it( 'should hold correct value for enablePageViewConversion property', async () => {
- const pageViewConversionSetting =
- await services.conversionTrackingService.getPageViewConversionSetting();
-
- expect(
- pageViewConversionSetting.enablePageViewConversion
- ).toBe( true );
- } );
-
it( 'should hold correct value for websitePages property', async () => {
const wpPagesEndpoint = new RegExp( '^/wp/v2/pages' );
@@ -209,6 +197,75 @@ describe( 'PAX partner services', () => {
] );
} );
} );
+ describe( 'campaignService', () => {
+ describe( 'notifyNewCampaignCreated', () => {
+ it( 'should return a callback function', async () => {
+ const mockOnCampaignCreated = jest.fn();
+ const servicesWithCampaign = createPaxServices(
+ registry,
+ { onCampaignCreated: mockOnCampaignCreated }
+ );
+
+ await servicesWithCampaign.campaignService.notifyNewCampaignCreated();
+
+ expect( servicesWithCampaign ).toEqual(
+ expect.objectContaining( {
+ campaignService: expect.objectContaining( {
+ notifyNewCampaignCreated:
+ mockOnCampaignCreated,
+ } ),
+ } )
+ );
+ } );
+ } );
+ } );
+
+ describe( 'partnerDateRangeService', () => {
+ describe( 'get', () => {
+ it( 'should contain startDate and endDate properties', async () => {
+ const partnerDateRange =
+ await services.partnerDateRangeService.get();
+
+ expect( partnerDateRange ).toHaveProperty(
+ 'startDate'
+ );
+ expect( partnerDateRange ).toHaveProperty( 'endDate' );
+ } );
+
+ it( 'should contain correct accessToken', async () => {
+ registry
+ .dispatch( CORE_USER )
+ .setReferenceDate( '2020-09-08' );
+
+ const partnerDateRange =
+ await services.partnerDateRangeService.get();
+
+ expect( partnerDateRange.startDate ).toEqual( {
+ day: 11,
+ month: 8,
+ year: 2020,
+ } );
+ expect( partnerDateRange.endDate ).toEqual( {
+ day: 7,
+ month: 9,
+ year: 2020,
+ } );
+ } );
+ } );
+ } );
+ } );
+
+ describe( 'getSupportedConversionTrackingTypes', () => {
+ it( 'should return the expected supported types', async () => {
+ const supportedTypes =
+ await services.conversionTrackingService.getSupportedConversionTrackingTypes(
+ {}
+ );
+
+ expect( supportedTypes ).toMatchObject( {
+ conversionTrackingTypes: [ 'TYPE_PAGE_VIEW' ],
+ } );
+ } );
} );
} );
} );
diff --git a/assets/js/modules/ads/pax/utils.js b/assets/js/modules/ads/pax/utils.js
new file mode 100644
index 00000000000..e4f2d027f9f
--- /dev/null
+++ b/assets/js/modules/ads/pax/utils.js
@@ -0,0 +1,44 @@
+/**
+ * PAX utility functions.
+ *
+ * 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 { stringToDate } from '../../../util';
+
+/**
+ * Returns formatted date object.
+ *
+ * @since 1.127.0
+ *
+ * @param {string} dateString Date in 'YYYY-MM-DD' format.
+ * @return {Date} Date instance.
+ */
+export function formatPaxDate( dateString ) {
+ const dateObject = stringToDate( dateString );
+
+ return {
+ year: dateObject.getFullYear(),
+ // PAX uses a 1-indexed month value (to match the month string value).
+ //
+ // Our `stringToDate()` function returns 0-indexed month values,
+ // so we need to adjust the values for PAX.
+ month: dateObject.getMonth() + 1,
+ day: dateObject.getDate(),
+ };
+}
diff --git a/assets/js/modules/ads/pax/utils.test.js b/assets/js/modules/ads/pax/utils.test.js
new file mode 100644
index 00000000000..24c5aaece9e
--- /dev/null
+++ b/assets/js/modules/ads/pax/utils.test.js
@@ -0,0 +1,50 @@
+/**
+ * PAX utility functions 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 { INVALID_DATE_STRING_ERROR } from '../../../util';
+import { formatPaxDate } from './utils';
+
+describe( 'formatPaxDate', () => {
+ it.each( [ null, NaN, '', '12345', '1900-00-00', 'not a date string' ] )(
+ 'throws an error when given the invalid date string: %s',
+ ( invalidDateString ) => {
+ expect( () => formatPaxDate( invalidDateString ) ).toThrow(
+ INVALID_DATE_STRING_ERROR
+ );
+ }
+ );
+
+ it( 'uses a one-indexed month', () => {
+ const date = formatPaxDate( '2019-01-31' );
+
+ expect( date.year ).toBe( 2019 );
+ expect( date.month ).toBe( 1 );
+ expect( date.day ).toBe( 31 );
+ } );
+
+ it( 'returns a valid date instance for the given date string', () => {
+ const date = formatPaxDate( '2019-10-31' );
+
+ expect( date.year ).toBe( 2019 );
+ expect( date.month ).toBe( 10 ); // 1-index based month
+ expect( date.day ).toBe( 31 );
+ } );
+} );
diff --git a/assets/js/modules/adsense/components/module/ModuleOverviewWidget/index.js b/assets/js/modules/adsense/components/module/ModuleOverviewWidget/index.js
index 132ff12981f..3a1a5a1d10d 100644
--- a/assets/js/modules/adsense/components/module/ModuleOverviewWidget/index.js
+++ b/assets/js/modules/adsense/components/module/ModuleOverviewWidget/index.js
@@ -30,7 +30,10 @@ import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
-import { MODULES_ADSENSE } from '../../../datastore/constants';
+import {
+ MODULES_ADSENSE,
+ DATE_RANGE_OFFSET,
+} from '../../../datastore/constants';
import { CORE_USER } from '../../../../../googlesitekit/datastore/user/constants';
import { SITE_STATUS_ADDED, legacyAccountStatuses } from '../../../util';
import PreviewBlock from '../../../../../components/PreviewBlock';
@@ -67,7 +70,11 @@ function ModuleOverviewWidget( { Widget, WidgetReportError } ) {
siteStatus === SITE_STATUS_ADDED;
const { startDate, endDate, compareStartDate, compareEndDate } = useSelect(
- ( select ) => select( CORE_USER ).getDateRangeDates( { compare: true } )
+ ( select ) =>
+ select( CORE_USER ).getDateRangeDates( {
+ compare: true,
+ offsetDays: DATE_RANGE_OFFSET,
+ } )
);
const currentRangeArgs = {
diff --git a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTile/index.stories.js b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTile/index.stories.js
index 3ff364706a9..efe61cf7c34 100644
--- a/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTile/index.stories.js
+++ b/assets/js/modules/analytics-4/components/audience-segmentation/dashboard/AudienceTile/index.stories.js
@@ -121,7 +121,8 @@ const readyProps = {
total: 1768,
},
topContentTitles: {
- '/en/test-post-1/': 'Test Post 1',
+ '/en/test-post-1/':
+ 'Test Post 1 - This is a very long title to test the audience segmentation tile that it wraps up and doesn not extend to the next line. It should show ellipsis instead. It must also have some gap at the right side so that the post title does not collide with the user count being shown next to it.',
'/en/test-post-2/': 'Test Post 2',
'/en/test-post-3/': 'Test Post 3',
},
diff --git a/assets/js/modules/analytics-4/components/common/AdsConversionIDTextField.js b/assets/js/modules/analytics-4/components/common/AdsConversionIDTextField.js
deleted file mode 100644
index f37d7262554..00000000000
--- a/assets/js/modules/analytics-4/components/common/AdsConversionIDTextField.js
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * Analytics 4 Ads Conversion ID 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 classnames from 'classnames';
-
-/**
- * WordPress dependencies
- */
-import { useCallback } from '@wordpress/element';
-import { __ } from '@wordpress/i18n';
-
-/**
- * Internal dependencies
- */
-import Data from 'googlesitekit-data';
-import AccessibleWarningIcon from '../../../../components/AccessibleWarningIcon';
-import { TextField } from 'googlesitekit-components';
-import { MODULES_ANALYTICS_4 } from '../../datastore/constants';
-import { isValidConversionID } from '../../../ads/utils/validation';
-const { useSelect, useDispatch } = Data;
-
-export default function AdsConversionIDTextField() {
- const adsConversionID = useSelect( ( select ) =>
- select( MODULES_ANALYTICS_4 ).getAdsConversionID()
- );
- const snippetEnabled = useSelect( ( select ) => {
- return select( MODULES_ANALYTICS_4 ).getUseSnippet();
- } );
-
- const { setAdsConversionID } = useDispatch( MODULES_ANALYTICS_4 );
- const onChange = useCallback(
- ( { currentTarget } ) => {
- let newValue = currentTarget.value.trim().toUpperCase();
- // Automatically add the AW- prefix if not provided.
- if ( 'AW-'.length < newValue.length && ! /^AW-/.test( newValue ) ) {
- newValue = `AW-${ newValue }`;
- }
-
- if ( newValue !== adsConversionID ) {
- setAdsConversionID( newValue );
- }
- },
- [ adsConversionID, setAdsConversionID ]
- );
-
- const isValidValue = Boolean(
- ! adsConversionID || isValidConversionID( adsConversionID )
- );
-
- // Only show the field if the snippet is enabled for output,
- // but only hide it if the value is valid otherwise the user will be blocked.
- if ( isValidValue && ! snippetEnabled ) {
- return null;
- }
-
- return (
-
-
- { __( 'Google Ads', 'google-site-kit' ) }
-
-
-
-
- )
- }
- outlined
- value={ adsConversionID }
- onChange={ onChange }
- />
-
-
- { __(
- 'If you’re using Google Ads, insert your Ads conversion ID if you’d like Site Kit to place the snippet on your site',
- 'google-site-kit'
- ) }
-
-
- );
-}
diff --git a/assets/js/modules/analytics-4/components/common/index.js b/assets/js/modules/analytics-4/components/common/index.js
index 087e278bd7b..545d6493a85 100644
--- a/assets/js/modules/analytics-4/components/common/index.js
+++ b/assets/js/modules/analytics-4/components/common/index.js
@@ -19,7 +19,6 @@
export { default as AccountCreate } from './AccountCreate';
export { default as AccountSelect } from './AccountSelect';
export { default as AccountCreateLegacy } from './AccountCreateLegacy';
-export { default as AdsConversionIDTextField } from './AdsConversionIDTextField';
export { default as EnhancedMeasurementSwitch } from './EnhancedMeasurementSwitch';
export { default as WebDataStreamSelect } from './WebDataStreamSelect';
export { default as PropertySelect } from './PropertySelect';
diff --git a/assets/js/modules/analytics-4/components/settings/OptionalSettingsView.js b/assets/js/modules/analytics-4/components/settings/OptionalSettingsView.js
index f3f9c964432..bb07000da54 100644
--- a/assets/js/modules/analytics-4/components/settings/OptionalSettingsView.js
+++ b/assets/js/modules/analytics-4/components/settings/OptionalSettingsView.js
@@ -30,16 +30,16 @@ import { MODULES_ANALYTICS_4 } from '../../datastore/constants';
import AdsConversionIDSettingsNotice from './AdsConversionIDSettingsNotice';
import DisplaySetting from '../../../../components/DisplaySetting';
import { trackingExclusionLabels } from '../common/TrackingExclusionSwitches';
-import { useFeature } from '../../../../hooks/useFeature';
const { useSelect } = Data;
export default function OptionalSettingsView() {
- const adsModuleEnabled = useFeature( 'adsModule' );
-
const useSnippet = useSelect( ( select ) =>
select( MODULES_ANALYTICS_4 ).getUseSnippet()
);
+ const adsConversionIDMigratedAtMs = useSelect( ( select ) =>
+ select( MODULES_ANALYTICS_4 ).getAdsConversionIDMigratedAtMs()
+ );
const trackingDisabled = useSelect(
( select ) => select( MODULES_ANALYTICS_4 ).getTrackingDisabled() || []
);
@@ -77,24 +77,28 @@ export default function OptionalSettingsView() {
- { ! adsModuleEnabled && useSnippet && (
-