Skip to content

Add CES prompts for initial setup and campaign creation #1152

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Dec 27, 2021
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
36ce432
Add @woocommerce/customer-effort-score module
ianlin Dec 13, 2021
de2d18e
Move checking qs "guide" from SubmissionSuccessGuide to its parent
ianlin Dec 13, 2021
05c5459
Use the guide name defined in the constants
ianlin Dec 13, 2021
e8ef37c
Add a wrapper for window.localStorage
ianlin Dec 14, 2021
0ff1b0f
Display onboarding setup CES prmopt in the right timing
ianlin Dec 14, 2021
83ed21c
Send CES feedback using event tracking
ianlin Dec 15, 2021
346880a
Only display CES prompts when WC tracking is enabled
ianlin Dec 15, 2021
70e8d5a
Replace Ces with CES
ianlin Dec 15, 2021
bd41bcf
Move CustomerEffortScorePrompt from product-feed/ to components/
ianlin Dec 15, 2021
9b6a811
Display Ads campaign creation CES prompt
ianlin Dec 15, 2021
5e0fefb
Revert "Remove not used `Svgr` mock."
ianlin Dec 16, 2021
064000c
Revert "Remove SVG module mapper,"
ianlin Dec 16, 2021
388c8ec
Add tests for js/src/product-feed/index.js
ianlin Dec 17, 2021
74cc674
Add doc to CustomerEffortScorePrompt component
ianlin Dec 17, 2021
fd51468
Add tests for js/src/dashboard/campaign-creation-success-guide
ianlin Dec 17, 2021
2932545
Add docs for event records of CES prompts
ianlin Dec 20, 2021
006b2ac
Remove using PropTypes
ianlin Dec 20, 2021
19ff496
Add optional chaining for getQuery()
ianlin Dec 20, 2021
3c9b2f6
Update event context for CES prompts to match their parents' context
ianlin Dec 20, 2021
07d85e4
Add a component state canCESPromptOpen in ProductFeed
ianlin Dec 20, 2021
739e897
Decouple CustomerEffortScorePrompt and the CampaignCreationSuccessGuide
ianlin Dec 24, 2021
ab165c7
Add @woocommerce/settings dependency (DEWP) in Jest config
ianlin Dec 24, 2021
bc62333
Unit test on Dashboard rather than CampaignCreationSuccessGuide
ianlin Dec 24, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ module.exports = {
`<rootDir>/node_modules/(?!@woocommerce/(${ wcPackagesNeedTransform })(/node_modules/@woocommerce/(${ wcPackagesNeedTransform }))?/build/)`,
],
moduleNameMapper: {
'\\.svg$': '<rootDir>/tests/mocks/assets/svgrMock.js',
'\\.scss$': '<rootDir>/tests/mocks/assets/styleMock.js',
// Transform our `.~/` alias.
'^\\.~/(.*)$': '<rootDir>/js/src/$1',
Expand Down
106 changes: 106 additions & 0 deletions js/src/components/customer-effort-score-prompt/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import CustomerEffortScore from '@woocommerce/customer-effort-score';
import { recordEvent } from '@woocommerce/tracks';

/**
* Internal dependencies
*/
import { LOCAL_STORAGE_KEYS } from '.~/constants';
import localStorage from '.~/utils/localStorage';

/**
* CES prompt snackbar open
*
* @event gla_ces_snackbar_open
*/
/**
* CES prompt snackbar closed
*
* @event gla_ces_snackbar_closed
*/
/**
* CES modal open
*
* @event gla_ces_modal_open
*/
/**
* CES feedback recorded
*
* @event gla_ces_feedback
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @event gla_ces_feedback
* @event gla_ces_feedback
* @property {number} score The provided feedback score.
* @property {string} comments If a comment is provided.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💅

*/

/**
* A CustomerEffortScore wrapper that uses tracks to track the selected
* customer effort score.
*
* @fires gla_ces_snackbar_open whenever the CES snackbar (notice) is open
* @fires gla_ces_snackbar_closed whenever the CES snackbar (notice) is closed
* @fires gla_ces_modal_open whenever the CES modal is open
* @fires gla_ces_feedback whenever the CES feedback is recorded
*
* @param {Object} props React component props.
* @param {string} props.eventContext Context to be used in the CES wrapper events.
* @param {string} props.label Text to be displayed in the CES notice and modal.
* @return {JSX.Element} Rendered element.
*/
const CustomerEffortScorePrompt = ( { eventContext, label } ) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💅 Non blocking

I guess we can add here

if ( ! isWCTracksEnabled() ) return null; Since it is a common check for all the prompts, right?

And then remove them in js/src/dashboard/campaign-creation-success-guide/index.js and js/src/product-feed/index.js

const removeCESPromptFlagFromLocal = () => {
localStorage.remove(
LOCAL_STORAGE_KEYS.CAN_ONBOARDING_SETUP_CES_PROMPT_OPEN
);
};

const onNoticeShown = () => {
removeCESPromptFlagFromLocal();
recordEvent( 'gla_ces_snackbar_open', {
context: eventContext,
} );
};

const onNoticeDismissed = () => {
recordEvent( 'gla_ces_snackbar_closed', {
context: eventContext,
} );
};

const onModalShown = () => {
recordEvent( 'gla_ces_modal_open', {
context: eventContext,
} );
};

const recordScore = ( score, comments ) => {
recordEvent( 'gla_ces_feedback', {
context: eventContext,
score,
comments: comments || '',
} );
};

return (
<CustomerEffortScore
label={ label }
recordScoreCallback={ recordScore }
onNoticeShownCallback={ onNoticeShown }
onNoticeDismissedCallback={ onNoticeDismissed }
onModalShownCallback={ onModalShown }
icon={
<span
style={ { height: 21, width: 21 } }
role="img"
aria-label={ __(
'Pencil icon',
'google-listings-and-ads'
) }
>
✏️
</span>
}
/>
);
};

export default CustomerEffortScorePrompt;
10 changes: 10 additions & 0 deletions js/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,13 @@ export const REPORT_SOURCE_DEFAULT = REPORT_SOURCE_PAID;

// Programs report related
export const REPORT_PROGRAM_PARAM = 'programs';

export const GUIDE_NAMES = {
SUBMISSION_SUCCESS: 'submission-success',
CAMPAIGN_CREATION_SUCCESS: 'campaign-creation-success',
};

export const LOCAL_STORAGE_KEYS = {
CAN_ONBOARDING_SETUP_CES_PROMPT_OPEN:
'gla-can-onboarding-setup-ces-prompt-open',
};
63 changes: 51 additions & 12 deletions js/src/dashboard/campaign-creation-success-guide/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
* External dependencies
*/
import { getHistory, getNewPath, getQuery } from '@woocommerce/navigation';
import { createInterpolateElement, useEffect } from '@wordpress/element';
import {
createInterpolateElement,
useEffect,
useCallback,
useState,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { recordEvent } from '@woocommerce/tracks';
Expand All @@ -11,14 +16,16 @@ import { recordEvent } from '@woocommerce/tracks';
* Internal dependencies
*/
import { getCreateCampaignUrl } from '.~/utils/urls';
import isWCTracksEnabled from '.~/utils/isWCTracksEnabled';
import AppModal from '.~/components/app-modal';
import GuidePageContent, {
ContentLink,
} from '.~/components/guide-page-content';
import CustomerEffortScorePrompt from '.~/components/customer-effort-score-prompt';
import { GUIDE_NAMES } from '.~/constants';
import headerImageURL from './header.svg';
import './index.scss';

const GUIDE_NAME = 'campaign-creation-success';
const CTA_CREATE_ANOTHER_CAMPAIGN = 'create-another-campaign';

const handleCloseWithAction = ( e, specifiedAction ) => {
Expand All @@ -34,17 +41,33 @@ const handleCloseWithAction = ( e, specifiedAction ) => {
}

recordEvent( 'gla_modal_closed', {
context: GUIDE_NAME,
context: GUIDE_NAMES.CAMPAIGN_CREATION_SUCCESS,
action,
} );
};

const handleRequestClose = ( e ) => handleCloseWithAction( e, 'dismiss' );

const GuideImplementation = () => {
const GuideImplementation = ( { isOpen, setCESPromptOpen } ) => {
const handleGotItClick = useCallback(
( e ) => {
handleCloseWithAction( e );
setCESPromptOpen( true );
},
[ setCESPromptOpen ]
);
Copy link
Contributor

@puntope puntope Dec 17, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ / 💅

What i the rationale behind using handleGotItClick separated. Instead of adding

if ( action === CTA_CREATE_ANOTHER_CAMPAIGN ) {
		getHistory().push( getCreateCampaignUrl() );
}

if ( action === CTA_CONFIRM) {
		setCESPromptOpen( true );
}

I guess is something related to the UseCallback? Not sure.

Copy link
Contributor Author

@ianlin ianlin Dec 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think originally my thought was: the handleCloseWithAction is defined outside of the component GuideImplementation, we will need to pass the function prop setCESPromptOpen() to it and I think it could be inflexible if we have more custom actions. But I agree with you that using handleCloseWithAction to handle multiple actions would be better than using a separated handleGotItClick. What do you think if we move handleCloseWithAction inside the component so it can use the function prop setCESPromptOpen() directly?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohhh, understood, didn't show that one. Thanks for the explanation.

What do you think if we move handleCloseWithAction inside the component

Would be interesting to know why it is outside, wondering if there is an important reason for it, maybe @eason9487 knows.

I would go ahead with your current solution and the maybe refactor after knowing that. It's 100% a non blocking issue. Since I don't want to block the PR I rely on you for the final decision regarding this. Again, looks good now as it's.

Copy link
Member

@eason9487 eason9487 Dec 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those click handlers were not dependent on props and states, so to avoid their references changing all the time, I put them outside then. It's ok to move them inside the component.

💅
BTW, I'd suggest adding a callback prop, such as onGuideRequestClose( action ), to this component and moving the process of handleCloseWithAction handler to the upper component. Therefore, it could decouple the <CustomerEffortScorePrompt> from this guide, and the logic of the opening/closing this guide could also be centralized on the outside. In this way, as with product feed index, the similar logic is implemented at dashboard index.

Copy link
Contributor Author

@ianlin ianlin Dec 24, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eason9487 Great suggestion. Added in bc3aaef 739e897.


useEffect( () => {
recordEvent( 'gla_modal_open', { context: GUIDE_NAME } );
}, [] );
if ( isOpen ) {
recordEvent( 'gla_modal_open', {
context: GUIDE_NAMES.CAMPAIGN_CREATION_SUCCESS,
} );
}
}, [ isOpen ] );

if ( ! isOpen ) {
return null;
}

return (
<AppModal
Expand All @@ -66,7 +89,7 @@ const GuideImplementation = () => {
key="1"
isPrimary
data-action="confirm"
onClick={ handleCloseWithAction }
onClick={ handleGotItClick }
>
{ __( 'Got it', 'google-listings-and-ads' ) }
</Button>,
Expand Down Expand Up @@ -115,10 +138,26 @@ const GuideImplementation = () => {
* For example: `/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard&guide=campaign-creation-success`.
*/
export default function CampaignCreationSuccessGuide() {
const isOpen = getQuery().guide === GUIDE_NAME;
const [ isCESPromptOpen, setCESPromptOpen ] = useState( false );

if ( ! isOpen ) {
return null;
}
return <GuideImplementation />;
const isOpen = getQuery()?.guide === GUIDE_NAMES.CAMPAIGN_CREATION_SUCCESS;
const wcTracksEnabled = isWCTracksEnabled();

return (
<>
<GuideImplementation
isOpen={ isOpen }
setCESPromptOpen={ setCESPromptOpen }
/>
{ isCESPromptOpen && wcTracksEnabled && (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💅

See my previous comment regarding isWCTracksEnabled();

<CustomerEffortScorePrompt
label={ __(
'How easy was it to create a Google Ad campaign?',
'google-listings-and-ads'
) }
eventContext={ GUIDE_NAMES.CAMPAIGN_CREATION_SUCCESS }
/>
) }
</>
);
}
135 changes: 135 additions & 0 deletions js/src/dashboard/campaign-creation-success-guide/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* External dependencies
*/
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getQuery } from '@woocommerce/navigation';

/**
* Internal dependencies
*/
import CampaignCreationSuccessGuide from './index';
import isWCTracksEnabled from '.~/utils/isWCTracksEnabled';
import { GUIDE_NAMES } from '.~/constants';

jest.mock( '@woocommerce/navigation', () => {
return {
...jest.requireActual( '@woocommerce/navigation' ),
getQuery: jest.fn(),
};
} );

jest.mock( '.~/utils/isWCTracksEnabled', () => jest.fn() );

const CAMPAIGN_CREATION_SUCCESS_GUIDE_TEXT =
"You've set up a paid Smart Shopping Campaign!";
const CES_PROMPT_TEXT = 'How easy was it to create a Google Ad campaign?';

jest.mock( '.~/components/customer-effort-score-prompt', () => () => (
<div>{ CES_PROMPT_TEXT }</div>
) );

describe( 'CampaignCreationSuccessGuide', () => {
describe( `When the query string "guide" equals to ${ GUIDE_NAMES.CAMPAIGN_CREATION_SUCCESS }`, () => {
beforeAll( () => {
getQuery.mockImplementation( () => {
return {
guide: GUIDE_NAMES.CAMPAIGN_CREATION_SUCCESS,
};
} );
} );

afterAll( () => {
getQuery.mockReset();
} );

test( 'Should render CampaignCreationSuccessGuide', () => {
const { queryByText } = render( <CampaignCreationSuccessGuide /> );
expect(
queryByText( CAMPAIGN_CREATION_SUCCESS_GUIDE_TEXT )
).toBeInTheDocument();
} );

describe( 'And wcTracksEnabled is false', () => {
beforeAll( () => {
isWCTracksEnabled.mockImplementation( () => {
return false;
} );
} );

afterAll( () => {
isWCTracksEnabled.mockReset();
} );

test( 'Should not render CustomerEffortScorePrompt when user does not click "Got it"', () => {
const { queryByText } = render(
<CampaignCreationSuccessGuide />
);
expect(
queryByText( CES_PROMPT_TEXT )
).not.toBeInTheDocument();
} );

test( 'Should not render CustomerEffortScorePrompt when user clicks "Got it"', () => {
const { queryByText } = render(
<CampaignCreationSuccessGuide />
);
userEvent.click( screen.getByText( 'Got it' ) );

expect(
queryByText( CES_PROMPT_TEXT )
).not.toBeInTheDocument();
} );
} );

describe( 'And wcTracksEnabled is true', () => {
beforeAll( () => {
isWCTracksEnabled.mockImplementation( () => {
return true;
} );
} );

afterAll( () => {
isWCTracksEnabled.mockReset();
} );

test( 'Should not render CustomerEffortScorePrompt when user does not click "Got it"', () => {
const { queryByText } = render(
<CampaignCreationSuccessGuide />
);
expect(
queryByText( CES_PROMPT_TEXT )
).not.toBeInTheDocument();
} );

test( 'Should render CustomerEffortScorePrompt when user clicks "Got it"', () => {
const { queryByText } = render(
<CampaignCreationSuccessGuide />
);
userEvent.click( screen.getByText( 'Got it' ) );

expect( queryByText( CES_PROMPT_TEXT ) ).toBeInTheDocument();
} );
} );
} );

describe( `When the query string "guide" does not equals to ${ GUIDE_NAMES.CAMPAIGN_CREATION_SUCCESS }`, () => {
beforeAll( () => {
getQuery.mockImplementation( () => {
return {};
} );
} );

afterAll( () => {
getQuery.mockReset();
} );

test( 'Should not render CampaignCreationSuccessGuide', () => {
const { queryByText } = render( <CampaignCreationSuccessGuide /> );
expect(
queryByText( CAMPAIGN_CREATION_SUCCESS_GUIDE_TEXT )
).not.toBeInTheDocument();
} );
} );
} );
Loading