diff --git a/assets/js/components/DashboardMainApp.js b/assets/js/components/DashboardMainApp.js
index 94c7519b775..896b7246abd 100644
--- a/assets/js/components/DashboardMainApp.js
+++ b/assets/js/components/DashboardMainApp.js
@@ -50,6 +50,7 @@ import SurveyViewTrigger from './surveys/SurveyViewTrigger';
import CurrentSurveyPortal from './surveys/CurrentSurveyPortal';
import MetricsSelectionPanel from './KeyMetrics/MetricsSelectionPanel';
import UserSettingsSelectionPanel from './email-reporting/UserSettingsSelectionPanel';
+import WelcomeModal from './WelcomeModal';
import { useFeature } from '@/js/hooks/useFeature';
import {
ANCHOR_ID_CONTENT,
@@ -229,6 +230,7 @@ export default function DashboardMainApp() {
} );
const emailReportingEnabled = useFeature( 'proactiveUserEngagement' );
+ const setupFlowRefreshEnabled = useFeature( 'setupFlowRefresh' );
useMonitorInternetConnection();
@@ -320,7 +322,6 @@ export default function DashboardMainApp() {
lastWidgetAnchor === ANCHOR_ID_MONETIZATION,
} ) }
/>
-
}
+ { setupFlowRefreshEnabled && }
+
);
diff --git a/assets/js/components/WelcomeModal.test.tsx b/assets/js/components/WelcomeModal.test.tsx
index 910f24737e5..67ca1a27b68 100644
--- a/assets/js/components/WelcomeModal.test.tsx
+++ b/assets/js/components/WelcomeModal.test.tsx
@@ -16,10 +16,20 @@
* limitations under the License.
*/
+/**
+ * External dependencies
+ */
+import fetchMock from 'fetch-mock';
+
/**
* Internal dependencies
*/
-import { render, createTestRegistry } from '../../../tests/js/test-utils';
+import {
+ createTestRegistry,
+ fireEvent,
+ render,
+ waitFor,
+} from '../../../tests/js/test-utils';
import {
provideModules,
provideUserAuthentication,
@@ -29,11 +39,20 @@ import { MODULE_SLUG_ANALYTICS_4 } from '@/js/modules/analytics-4/constants';
import { MODULE_SLUG_SEARCH_CONSOLE } from '@/js/modules/search-console/constants';
import { MODULES_ANALYTICS_4 } from '@/js/modules/analytics-4/datastore/constants';
import { MODULES_SEARCH_CONSOLE } from '@/js/modules/search-console/datastore/constants';
+import { CORE_USER } from '@/js/googlesitekit/datastore/user/constants';
+import { CORE_UI } from '@/js/googlesitekit/datastore/ui/constants';
import WelcomeModal from './WelcomeModal';
+const WITH_TOUR_DISMISSED_ITEM_SLUG = 'welcome-modal-with-tour';
+const GATHERING_DATA_DISMISSED_ITEM_SLUG = 'welcome-modal-gathering-data';
+
describe( 'WelcomeModal', () => {
let registry: ReturnType< typeof createTestRegistry >;
+ const dismissItemEndpoint = new RegExp(
+ '^/google-site-kit/v1/core/user/data/dismiss-item'
+ );
+
beforeEach( () => {
registry = createTestRegistry();
} );
@@ -64,6 +83,8 @@ describe( 'WelcomeModal', () => {
.dispatch( MODULES_SEARCH_CONSOLE )
.receiveIsDataAvailableOnLoad( true );
+ registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] );
+
const { container, getByText, getByRole, waitForRegistry } = render(
,
{
@@ -112,6 +133,8 @@ describe( 'WelcomeModal', () => {
.dispatch( MODULES_SEARCH_CONSOLE )
.receiveIsDataAvailableOnLoad( true );
+ registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] );
+
const { container, getByText, getByRole, waitForRegistry } = render(
,
{
@@ -138,6 +161,293 @@ describe( 'WelcomeModal', () => {
).toBeInTheDocument();
} );
+ describe.each( [ 'Start tour', 'Maybe later', 'Close' ] )(
+ 'when the "%s" button is clicked for the dashboard tour variant',
+ ( buttonText ) => {
+ beforeEach( () => {
+ provideModules( registry, [
+ {
+ slug: MODULE_SLUG_ANALYTICS_4,
+ active: true,
+ connected: true,
+ },
+ {
+ slug: MODULE_SLUG_SEARCH_CONSOLE,
+ active: true,
+ connected: true,
+ },
+ ] );
+
+ provideGatheringDataState( registry, {
+ [ MODULE_SLUG_ANALYTICS_4 ]: false,
+ [ MODULE_SLUG_SEARCH_CONSOLE ]: false,
+ } );
+
+ registry
+ .dispatch( MODULES_ANALYTICS_4 )
+ .receiveIsDataAvailableOnLoad( true );
+ registry
+ .dispatch( MODULES_SEARCH_CONSOLE )
+ .receiveIsDataAvailableOnLoad( true );
+
+ registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] );
+
+ // Model the responses for the two POST requests to `dismiss-item`.
+ fetchMock.postOnce( dismissItemEndpoint, {
+ body: [ WITH_TOUR_DISMISSED_ITEM_SLUG ],
+ status: 200,
+ } );
+
+ fetchMock.postOnce( dismissItemEndpoint, {
+ body: [
+ WITH_TOUR_DISMISSED_ITEM_SLUG,
+ GATHERING_DATA_DISMISSED_ITEM_SLUG,
+ ],
+ status: 200,
+ } );
+ } );
+
+ it( 'should close the modal', async () => {
+ const { container, getByRole, waitForRegistry } = render(
+ ,
+ {
+ registry,
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `render` is not typed yet.
+ ) as any;
+
+ await waitForRegistry();
+
+ const closeButton = getByRole( 'button', { name: buttonText } );
+ fireEvent.click( closeButton );
+
+ await waitFor( () => {
+ // Wait for the dismissal to complete.
+ expect( fetchMock ).toHaveFetchedTimes( 2 );
+ } );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'should dismiss the items for both the dashboard tour and gathering data variants', async () => {
+ const { getByRole, waitForRegistry } = render(
+ ,
+ {
+ registry,
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `render` is not typed yet.
+ ) as any;
+
+ await waitForRegistry();
+
+ const closeButton = getByRole( 'button', { name: buttonText } );
+ fireEvent.click( closeButton );
+
+ await waitFor( () => {
+ expect( fetchMock ).toHaveFetchedTimes( 2 );
+ } );
+
+ expect( fetchMock ).toHaveFetched( dismissItemEndpoint, {
+ body: {
+ data: {
+ slug: WITH_TOUR_DISMISSED_ITEM_SLUG,
+ expiration: 0,
+ },
+ },
+ } );
+
+ expect( fetchMock ).toHaveFetched( dismissItemEndpoint, {
+ body: {
+ data: {
+ slug: GATHERING_DATA_DISMISSED_ITEM_SLUG,
+ expiration: 0,
+ },
+ },
+ } );
+ } );
+ }
+ );
+
+ it.each( [ 'Maybe later', 'Close' ] )(
+ 'should show a tooltip when the dashboard tour variant is closed by the "%s" button',
+ async ( buttonText ) => {
+ provideModules( registry, [
+ {
+ slug: MODULE_SLUG_ANALYTICS_4,
+ active: true,
+ connected: true,
+ },
+ {
+ slug: MODULE_SLUG_SEARCH_CONSOLE,
+ active: true,
+ connected: true,
+ },
+ ] );
+
+ provideGatheringDataState( registry, {
+ [ MODULE_SLUG_ANALYTICS_4 ]: false,
+ [ MODULE_SLUG_SEARCH_CONSOLE ]: false,
+ } );
+
+ registry
+ .dispatch( MODULES_ANALYTICS_4 )
+ .receiveIsDataAvailableOnLoad( true );
+ registry
+ .dispatch( MODULES_SEARCH_CONSOLE )
+ .receiveIsDataAvailableOnLoad( true );
+
+ registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] );
+
+ // Model the responses for the two POST requests to `dismiss-item`.
+ fetchMock.postOnce( dismissItemEndpoint, {
+ body: [ WITH_TOUR_DISMISSED_ITEM_SLUG ],
+ status: 200,
+ } );
+
+ fetchMock.postOnce( dismissItemEndpoint, {
+ body: [
+ WITH_TOUR_DISMISSED_ITEM_SLUG,
+ GATHERING_DATA_DISMISSED_ITEM_SLUG,
+ ],
+ status: 200,
+ } );
+
+ const { getByRole, waitForRegistry } = render(
+ ,
+ {
+ registry,
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `render` is not typed yet.
+ ) as any;
+
+ await waitForRegistry();
+
+ const closeButton = getByRole( 'button', { name: buttonText } );
+ fireEvent.click( closeButton );
+
+ await waitFor( () => {
+ expect( fetchMock ).toHaveFetchedTimes( 2 );
+ } );
+
+ const tooltipState = registry
+ .select( CORE_UI )
+ .getValue( 'admin-screen-tooltip' );
+
+ expect( tooltipState ).toMatchObject( {
+ isTooltipVisible: true,
+ tooltipSlug: 'dashboard-tour',
+ title: 'You can always take the dashboard tour from the help menu',
+ dismissLabel: 'Got it',
+ } );
+ }
+ );
+
+ it( 'should not show a tooltip when the dashboard tour variant is closed by the "Start tour" button', async () => {
+ provideModules( registry, [
+ {
+ slug: MODULE_SLUG_ANALYTICS_4,
+ active: true,
+ connected: true,
+ },
+ {
+ slug: MODULE_SLUG_SEARCH_CONSOLE,
+ active: true,
+ connected: true,
+ },
+ ] );
+
+ provideGatheringDataState( registry, {
+ [ MODULE_SLUG_ANALYTICS_4 ]: false,
+ [ MODULE_SLUG_SEARCH_CONSOLE ]: false,
+ } );
+
+ registry
+ .dispatch( MODULES_ANALYTICS_4 )
+ .receiveIsDataAvailableOnLoad( true );
+ registry
+ .dispatch( MODULES_SEARCH_CONSOLE )
+ .receiveIsDataAvailableOnLoad( true );
+
+ registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] );
+
+ // Model the responses for the two POST requests to `dismiss-item`.
+ fetchMock.postOnce( dismissItemEndpoint, {
+ body: [ WITH_TOUR_DISMISSED_ITEM_SLUG ],
+ status: 200,
+ } );
+
+ fetchMock.postOnce( dismissItemEndpoint, {
+ body: [
+ WITH_TOUR_DISMISSED_ITEM_SLUG,
+ GATHERING_DATA_DISMISSED_ITEM_SLUG,
+ ],
+ status: 200,
+ } );
+
+ const { getByRole, waitForRegistry } = render(
+ ,
+ {
+ registry,
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `render` is not typed yet.
+ ) as any;
+
+ await waitForRegistry();
+
+ const closeButton = getByRole( 'button', { name: 'Start tour' } );
+ fireEvent.click( closeButton );
+
+ await waitFor( () => {
+ expect( fetchMock ).toHaveFetchedTimes( 2 );
+ } );
+
+ const tooltipState = registry
+ .select( CORE_UI )
+ .getValue( 'admin-screen-tooltip' );
+
+ expect( tooltipState ).toBeUndefined();
+ } );
+
+ it( 'should not show the dashboard tour variant when it has been dismissed', async () => {
+ provideModules( registry, [
+ {
+ slug: MODULE_SLUG_ANALYTICS_4,
+ active: true,
+ connected: true,
+ },
+ {
+ slug: MODULE_SLUG_SEARCH_CONSOLE,
+ active: true,
+ connected: true,
+ },
+ ] );
+
+ provideGatheringDataState( registry, {
+ [ MODULE_SLUG_ANALYTICS_4 ]: false,
+ [ MODULE_SLUG_SEARCH_CONSOLE ]: false,
+ } );
+
+ registry
+ .dispatch( MODULES_ANALYTICS_4 )
+ .receiveIsDataAvailableOnLoad( true );
+ registry
+ .dispatch( MODULES_SEARCH_CONSOLE )
+ .receiveIsDataAvailableOnLoad( true );
+
+ registry
+ .dispatch( CORE_USER )
+ .receiveGetDismissedItems( [ WITH_TOUR_DISMISSED_ITEM_SLUG ] );
+
+ const { container, waitForRegistry } = render( , {
+ registry,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `render` is not typed yet.
+ } ) as any;
+
+ await waitForRegistry();
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
it( 'should show the gathering data variant when Analytics is connected and gathering data', async () => {
provideModules( registry, [
{
@@ -175,6 +485,8 @@ describe( 'WelcomeModal', () => {
}
);
+ registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] );
+
const { container, getByText, getByRole, waitForRegistry } = render(
,
{
@@ -216,6 +528,8 @@ describe( 'WelcomeModal', () => {
[ MODULE_SLUG_SEARCH_CONSOLE ]: true,
} );
+ registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] );
+
const { container, getByText, getByRole, waitForRegistry } = render(
,
{
@@ -238,4 +552,145 @@ describe( 'WelcomeModal', () => {
getByRole( 'button', { name: 'Get started' } )
).toBeInTheDocument();
} );
+
+ describe.each( [ 'Get started', 'Close' ] )(
+ 'when the "%s" button is clicked for the gathering data variant',
+ ( buttonText ) => {
+ beforeEach( () => {
+ provideModules( registry, [
+ {
+ slug: MODULE_SLUG_ANALYTICS_4,
+ active: false,
+ connected: false,
+ },
+ {
+ slug: MODULE_SLUG_SEARCH_CONSOLE,
+ active: true,
+ connected: true,
+ },
+ ] );
+
+ provideGatheringDataState( registry, {
+ [ MODULE_SLUG_SEARCH_CONSOLE ]: true,
+ } );
+
+ registry.dispatch( CORE_USER ).receiveGetDismissedItems( [] );
+
+ fetchMock.postOnce( dismissItemEndpoint, {
+ body: [ GATHERING_DATA_DISMISSED_ITEM_SLUG ],
+ status: 200,
+ } );
+ } );
+
+ // eslint-disable-next-line jest/no-identical-title -- The nested describe block distinguishes the test titles.
+ it( 'should close the modal', async () => {
+ const { container, getByRole, waitForRegistry } = render(
+ ,
+ {
+ registry,
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `render` is not typed yet.
+ ) as any;
+
+ await waitForRegistry();
+
+ const closeButton = getByRole( 'button', {
+ name: buttonText,
+ } );
+ fireEvent.click( closeButton );
+
+ await waitFor( () => {
+ // Wait for the dismissal to complete.
+ expect( fetchMock ).toHaveFetchedTimes( 1 );
+ } );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'should dismiss the item for the gathering data variant', async () => {
+ const { getByRole, waitForRegistry } = render(
+ ,
+ {
+ registry,
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `render` is not typed yet.
+ ) as any;
+
+ await waitForRegistry();
+
+ const closeButton = getByRole( 'button', { name: buttonText } );
+ fireEvent.click( closeButton );
+
+ await waitFor( () => {
+ expect( fetchMock ).toHaveFetchedTimes( 1 );
+ } );
+
+ expect( fetchMock ).toHaveFetched( dismissItemEndpoint, {
+ body: {
+ data: {
+ slug: GATHERING_DATA_DISMISSED_ITEM_SLUG,
+ expiration: 0,
+ },
+ },
+ } );
+ } );
+
+ it( 'should not show a tooltip', async () => {
+ const { getByRole, waitForRegistry } = render(
+ ,
+ {
+ registry,
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `render` is not typed yet.
+ ) as any;
+
+ await waitForRegistry();
+
+ const closeButton = getByRole( 'button', { name: buttonText } );
+ fireEvent.click( closeButton );
+
+ await waitFor( () => {
+ expect( fetchMock ).toHaveFetchedTimes( 1 );
+ } );
+
+ const tooltipState = registry
+ .select( CORE_UI )
+ .getValue( 'admin-screen-tooltip' );
+
+ expect( tooltipState ).toBeUndefined();
+ } );
+ }
+ );
+
+ it( 'should not show the gathering data variant when it has been dismissed', async () => {
+ provideModules( registry, [
+ {
+ slug: MODULE_SLUG_ANALYTICS_4,
+ active: false,
+ connected: false,
+ },
+ {
+ slug: MODULE_SLUG_SEARCH_CONSOLE,
+ active: true,
+ connected: true,
+ },
+ ] );
+
+ provideGatheringDataState( registry, {
+ [ MODULE_SLUG_SEARCH_CONSOLE ]: true,
+ } );
+
+ registry
+ .dispatch( CORE_USER )
+ .receiveGetDismissedItems( [ GATHERING_DATA_DISMISSED_ITEM_SLUG ] );
+
+ const { container, waitForRegistry } = render( , {
+ registry,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `render` is not typed yet.
+ } ) as any;
+
+ await waitForRegistry();
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
} );
diff --git a/assets/js/components/WelcomeModal.tsx b/assets/js/components/WelcomeModal.tsx
index 37cddd89554..b0abc02e317 100644
--- a/assets/js/components/WelcomeModal.tsx
+++ b/assets/js/components/WelcomeModal.tsx
@@ -19,14 +19,20 @@
/**
* WordPress dependencies
*/
-import { createInterpolateElement, Fragment } from '@wordpress/element';
+import {
+ createInterpolateElement,
+ useCallback,
+ useState,
+ Fragment,
+} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
-import { useSelect } from 'googlesitekit-data';
+import { useSelect, useDispatch } from 'googlesitekit-data';
import { CORE_MODULES } from '@/js/googlesitekit/modules/datastore/constants';
+import { CORE_USER } from '@/js/googlesitekit/datastore/user/constants';
import { MODULES_ANALYTICS_4 } from '@/js/modules/analytics-4/datastore/constants';
import { MODULES_SEARCH_CONSOLE } from '@/js/modules/search-console/datastore/constants';
import { MODULE_SLUG_ANALYTICS_4 } from '@/js/modules/analytics-4/constants';
@@ -34,15 +40,21 @@ import { Button } from 'googlesitekit-components';
import { Dialog, DialogContent, DialogFooter } from '@/js/material-components';
import P from '@/js/components/Typography/P';
import Typography from '@/js/components/Typography';
+import { useShowTooltip } from '@/js/components/AdminScreenTooltip';
// @ts-expect-error - We need to add types for imported SVGs.
import CloseIcon from '@/svg/icons/close.svg';
// @ts-expect-error - We need to add types for imported SVGs.
import WelcomeModalGraphic from '@/svg/graphics/welcome-modal-graphic.svg';
+const WITH_TOUR_DISMISSED_ITEM_SLUG = 'welcome-modal-with-tour';
+const GATHERING_DATA_DISMISSED_ITEM_SLUG = 'welcome-modal-gathering-data';
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- `@wordpress/data` is not typed yet.
type SelectFunction = ( select: any ) => any;
export default function WelcomeModal() {
+ const [ isOpen, setIsOpen ] = useState( true );
+
const analyticsConnected = useSelect( ( select: SelectFunction ) =>
select( CORE_MODULES ).isModuleConnected( MODULE_SLUG_ANALYTICS_4 )
);
@@ -62,11 +74,57 @@ export default function WelcomeModal() {
? analyticsGatheringData
: searchConsoleGatheringData;
+ const dismissedItemSlug = showGatheringDataModal
+ ? GATHERING_DATA_DISMISSED_ITEM_SLUG
+ : WITH_TOUR_DISMISSED_ITEM_SLUG;
+
+ const isItemDismissed = useSelect( ( select: SelectFunction ) =>
+ select( CORE_USER ).isItemDismissed( dismissedItemSlug )
+ );
+
+ const { dismissItem } = useDispatch( CORE_USER );
+
+ const tooltipSettings = {
+ target: '.googlesitekit-help-menu__button',
+ tooltipSlug: 'dashboard-tour',
+ title: __(
+ 'You can always take the dashboard tour from the help menu',
+ 'google-site-kit'
+ ),
+ dismissLabel: __( 'Got it', 'google-site-kit' ),
+ };
+
+ const showTooltip = useShowTooltip( tooltipSettings );
+
+ const closeAndDismissModal = useCallback( async () => {
+ setIsOpen( false );
+
+ await dismissItem( dismissedItemSlug );
+
+ if ( ! showGatheringDataModal ) {
+ // If we're in the dashboard tour variant, also dismiss the gathering data variant.
+ await dismissItem( GATHERING_DATA_DISMISSED_ITEM_SLUG );
+ }
+ }, [ dismissItem, dismissedItemSlug, showGatheringDataModal, setIsOpen ] );
+
+ const closeAndDismissModalWithTooltip = useCallback( async () => {
+ await closeAndDismissModal();
+
+ // Don't show the tooltip for the gathering data variant.
+ if ( ! showGatheringDataModal ) {
+ showTooltip();
+ }
+ }, [ closeAndDismissModal, showGatheringDataModal, showTooltip ] );
+
if ( showGatheringDataModal === undefined ) {
// TODO: Implement a loading state when we have a design for it in phase 3 of the Setup Flow Refresh epic.
return null;
}
+ if ( isItemDismissed || ! isOpen ) {
+ return null;
+ }
+
const description = showGatheringDataModal
? createInterpolateElement(
__(
@@ -84,8 +142,8 @@ export default function WelcomeModal() {
return (