-
Notifications
You must be signed in to change notification settings - Fork 22
Add PMaxImproveAssetsBanner component #2983
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
Changes from 4 commits
10f9879
f18c74a
8273d2b
cd89949
11fd3a1
de009c7
b8d7fb1
a512e79
60e3ced
77e7aeb
be26cbd
c5d07d3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,133 @@ | ||||||
/** | ||||||
* External dependencies | ||||||
*/ | ||||||
import { __, sprintf } from '@wordpress/i18n'; | ||||||
import { useDispatch } from '@wordpress/data'; | ||||||
import { Notice } from '@wordpress/components'; | ||||||
import { getHistory } from '@woocommerce/navigation'; | ||||||
import { store as preferencesStore } from '@wordpress/preferences'; | ||||||
|
||||||
/** | ||||||
* Internal dependencies | ||||||
*/ | ||||||
import { | ||||||
CAMPAIGN_TYPE_PMAX, | ||||||
PREFERENCES_STORE_NAMESPACE, | ||||||
PMAX_IMPROVE_PERFORMANCE_MAX_AD_STRENGTH, | ||||||
DAY_IN_SECONDS, | ||||||
} from '~/constants'; | ||||||
import { getEditCampaignUrl } from '~/utils/urls'; | ||||||
import AppButton from '~/components/app-button'; | ||||||
import usePreference from '~/hooks/usePreference'; | ||||||
import useAdsCampaigns from '~/hooks/useAdsCampaigns'; | ||||||
import useAdsRecommendations from '~/hooks/useAdsRecommendations'; | ||||||
import './index.scss'; | ||||||
|
||||||
const PREFERENCE_BANNER_KEY = 'pmax-improve-assets-banner'; | ||||||
|
||||||
/** | ||||||
* Displays a dismissible banner prompting users to improve assets for their highest-spending enabled Performance Max (PMAX) campaign. | ||||||
* | ||||||
* The banner is shown only if: | ||||||
* - The preference expiry is undefined or expired. | ||||||
* - There are enabled PMAX campaigns. | ||||||
* - There are relevant asset improvement recommendations. | ||||||
* | ||||||
* When dismissed, the banner will not reappear until the expiry time elapses. | ||||||
* Clicking "Improve Assets" navigates to the asset group edit page for the highest-spending PMAX campaign. | ||||||
* | ||||||
* @return {JSX.Element|null} The banner component, or null if not applicable. | ||||||
*/ | ||||||
const PMaxImproveAssetsBanner = () => { | ||||||
const { set } = useDispatch( preferencesStore ); | ||||||
const { expiry } = usePreference( PREFERENCE_BANNER_KEY ) || {}; | ||||||
const { data: adsCampaignsData } = useAdsCampaigns(); | ||||||
const { recommendations } = useAdsRecommendations( | ||||||
PMAX_IMPROVE_PERFORMANCE_MAX_AD_STRENGTH | ||||||
); | ||||||
|
||||||
if ( expiry !== undefined ) { | ||||||
if ( Date.now() < expiry ) { | ||||||
// Do not render the banner if not expired | ||||||
return null; | ||||||
} | ||||||
|
||||||
// If expired, reset the preference | ||||||
set( PREFERENCES_STORE_NAMESPACE, PREFERENCE_BANNER_KEY, { | ||||||
expiry: undefined, // Reset to undefined to show the banner again | ||||||
} ); | ||||||
} | ||||||
|
||||||
if ( ! adsCampaignsData || ! recommendations?.length ) { | ||||||
return null; | ||||||
} | ||||||
|
||||||
const pmaxCampaigns = adsCampaignsData.filter( | ||||||
( { type, status } ) => | ||||||
type === CAMPAIGN_TYPE_PMAX && status === 'enabled' | ||||||
); | ||||||
|
||||||
if ( ! pmaxCampaigns.length ) { | ||||||
return null; | ||||||
} | ||||||
|
||||||
const highestAmountCampaign = pmaxCampaigns.reduce( | ||||||
( max, campaign ) => ( campaign.amount > max.amount ? campaign : max ), | ||||||
pmaxCampaigns[ 0 ] | ||||||
); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This logic could be sped up by wrapping it in a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure! Updated @joemcgill |
||||||
const { id, name } = highestAmountCampaign; | ||||||
|
||||||
const hasHighestSpendingCampaignRecommendation = recommendations.some( | ||||||
( recommendation ) => recommendation.campaign_id === id | ||||||
); | ||||||
|
||||||
if ( ! hasHighestSpendingCampaignRecommendation ) { | ||||||
return null; | ||||||
} | ||||||
|
||||||
const dismissBanner = () => { | ||||||
set( PREFERENCES_STORE_NAMESPACE, PREFERENCE_BANNER_KEY, { | ||||||
expiry: Date.now() + DAY_IN_SECONDS * 30 * 1000, // 30 days in ms | ||||||
} ); | ||||||
}; | ||||||
|
||||||
const handleOnImproveAssets = () => { | ||||||
dismissBanner(); | ||||||
|
||||||
// Navigate to the edit campaign page for the PMAX campaign with the highest spending. | ||||||
const editCampaignUrl = getEditCampaignUrl( id, 'asset-group' ); | ||||||
getHistory().push( editCampaignUrl ); | ||||||
}; | ||||||
|
||||||
return ( | ||||||
<Notice | ||||||
className="gla-pmax-improve-assets-banner" | ||||||
status="info" | ||||||
isDismissible={ true } | ||||||
onRemove={ dismissBanner } | ||||||
> | ||||||
<p className="gla-pmax-improve-assets-banner__text"> | ||||||
{ sprintf( | ||||||
// translators: %s: The PMAX campaign name with the highest spending. | ||||||
__( | ||||||
'Unlock more sales for your campaign, %s, by focusing on improving your campaign assets.Better assets directly increase your ad strength, allowing for a wider variety of ad combinations to be shown across Google.', | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤦 Forgot to push my changes before to address this. Fixed @joemcgill |
||||||
'google-listings-and-ads' | ||||||
), | ||||||
name | ||||||
) } | ||||||
</p> | ||||||
|
||||||
<div className="gla-pmax-improve-assets-banner__actions"> | ||||||
<AppButton onClick={ handleOnImproveAssets } isSecondary> | ||||||
{ __( 'Improve Assets', 'google-listings-and-ads' ) } | ||||||
</AppButton> | ||||||
|
||||||
<AppButton isTertiary onClick={ dismissBanner }> | ||||||
{ __( 'Dismiss', 'google-listings-and-ads' ) } | ||||||
</AppButton> | ||||||
</div> | ||||||
</Notice> | ||||||
); | ||||||
}; | ||||||
|
||||||
export default PMaxImproveAssetsBanner; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
.gla-pmax-improve-assets-banner { | ||
.gla-pmax-improve-assets-banner__text { | ||
margin: 0 0 $grid-unit-20; | ||
} | ||
|
||
.gla-pmax-improve-assets-banner__actions { | ||
display: flex; | ||
gap: $grid-unit-15; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,215 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import '@testing-library/jest-dom'; | ||
import { getHistory } from '@woocommerce/navigation'; | ||
import { useDispatch } from '@wordpress/data'; | ||
import { render, screen, fireEvent } from '@testing-library/react'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import { getEditCampaignUrl } from '~/utils/urls'; | ||
import { PREFERENCES_STORE_NAMESPACE, DAY_IN_SECONDS } from '~/constants'; | ||
import PMaxImproveAssetsBanner from './index'; | ||
import usePreference from '~/hooks/usePreference'; | ||
import useAdsCampaigns from '~/hooks/useAdsCampaigns'; | ||
import useAdsRecommendations from '~/hooks/useAdsRecommendations'; | ||
|
||
jest.mock( '@woocommerce/components', () => ( { | ||
...jest.requireActual( '@woocommerce/components' ), | ||
Spinner: jest.fn( () => <div>spinner</div> ), | ||
} ) ); | ||
|
||
jest.mock( '@wordpress/components', () => ( { | ||
Notice: ( { children, className } ) => ( | ||
<div className={ className }>{ children }</div> | ||
), | ||
} ) ); | ||
|
||
jest.mock( '@woocommerce/navigation', () => ( { | ||
getHistory: jest.fn(), | ||
} ) ); | ||
|
||
jest.mock( '~/components/app-button', () => ( { children, onClick } ) => ( | ||
<button onClick={ onClick }>{ children }</button> | ||
) ); | ||
|
||
jest.mock( '@wordpress/data', () => ( { | ||
__esModule: true, | ||
useDispatch: jest.fn(), | ||
} ) ); | ||
|
||
jest.mock( '~/hooks/usePreference', () => | ||
jest.fn().mockName( 'usePreference' ) | ||
); | ||
|
||
jest.mock( '~/hooks/useAdsCampaigns', () => | ||
jest.fn().mockName( 'useAdsCampaigns' ) | ||
); | ||
|
||
jest.mock( '~/hooks/useAdsRecommendations', () => | ||
jest.fn().mockName( 'useAdsRecommendations' ) | ||
); | ||
|
||
jest.mock( '~/utils/urls', () => ( { | ||
getEditCampaignUrl: jest.fn( () => '/edit/2/asset-group' ), | ||
} ) ); | ||
|
||
const baseCampaigns = [ | ||
{ | ||
id: 1, | ||
name: 'Campaign 1', | ||
type: 'performance_max', | ||
status: 'enabled', | ||
amount: 100, | ||
}, | ||
{ | ||
id: 2, | ||
name: 'Campaign 2', | ||
type: 'performance_max', | ||
status: 'enabled', | ||
amount: 200, | ||
}, | ||
{ | ||
id: 3, | ||
name: 'Campaign 3', | ||
type: 'SEARCH', | ||
status: 'enabled', | ||
amount: 300, | ||
}, | ||
]; | ||
|
||
const recommendations = [ { campaign_id: 2, campaign_name: 'Spring sale' } ]; | ||
|
||
describe( 'PMaxImproveAssetsBanner', () => { | ||
beforeEach( () => { | ||
useDispatch.mockReturnValue( { set: () => null } ); | ||
} ); | ||
|
||
it( 'renders nothing if expiry is not expired', () => { | ||
usePreference.mockReturnValue( { expiry: Date.now() + 100000 } ); | ||
useAdsCampaigns.mockReturnValue( { data: baseCampaigns } ); | ||
useAdsRecommendations.mockReturnValue( { recommendations } ); | ||
const { container } = render( <PMaxImproveAssetsBanner /> ); | ||
expect( container.firstChild ).toBeNull(); | ||
} ); | ||
|
||
it( 'resets expiry if expired and renders banner', () => { | ||
usePreference.mockReturnValue( { expiry: Date.now() - 1000 } ); | ||
useAdsCampaigns.mockReturnValue( { data: baseCampaigns } ); | ||
useAdsRecommendations.mockReturnValue( { recommendations } ); | ||
render( <PMaxImproveAssetsBanner /> ); | ||
expect( | ||
screen.getByRole( 'button', { name: 'Improve Assets' } ) | ||
).toBeInTheDocument(); | ||
expect( | ||
screen.getByRole( 'button', { name: 'Dismiss' } ) | ||
).toBeInTheDocument(); | ||
} ); | ||
|
||
it( 'renders nothing if no campaigns data', () => { | ||
usePreference.mockReturnValue( {} ); | ||
useAdsCampaigns.mockReturnValue( { data: null } ); | ||
useAdsRecommendations.mockReturnValue( { recommendations } ); | ||
const { container } = render( <PMaxImproveAssetsBanner /> ); | ||
expect( container.firstChild ).toBeNull(); | ||
} ); | ||
|
||
it( 'renders nothing if no recommendations', () => { | ||
usePreference.mockReturnValue( {} ); | ||
useAdsCampaigns.mockReturnValue( { data: baseCampaigns } ); | ||
useAdsRecommendations.mockReturnValue( { recommendations: [] } ); | ||
const { container } = render( <PMaxImproveAssetsBanner /> ); | ||
expect( container.firstChild ).toBeNull(); | ||
} ); | ||
|
||
it( 'renders nothing if no enabled PMAX campaigns', () => { | ||
usePreference.mockReturnValue( {} ); | ||
useAdsCampaigns.mockReturnValue( { data: baseCampaigns } ); | ||
useAdsCampaigns.mockReturnValue( { | ||
data: [ | ||
{ | ||
id: 3, | ||
name: 'Campaign 3', | ||
type: 'shopping', | ||
status: 'enabled', | ||
amount: 300, | ||
}, | ||
], | ||
} ); | ||
useAdsRecommendations.mockReturnValue( { recommendations } ); | ||
const { container } = render( <PMaxImproveAssetsBanner /> ); | ||
expect( container.firstChild ).toBeNull(); | ||
} ); | ||
|
||
it( 'renders nothing if no recommendation for highest-spending campaign', () => { | ||
usePreference.mockReturnValue( {} ); | ||
useAdsCampaigns.mockReturnValue( { | ||
data: baseCampaigns, | ||
} ); | ||
|
||
useAdsRecommendations.mockReturnValue( { | ||
recommendations: [ | ||
{ id: 1, campaign_id: 1, campaign_name: 'Spring Campaign' }, | ||
], | ||
} ); | ||
const { container } = render( <PMaxImproveAssetsBanner /> ); | ||
expect( container.firstChild ).toBeNull(); | ||
} ); | ||
|
||
it( 'renders banner for highest-spending enabled PMAX campaign with recommendation', () => { | ||
usePreference.mockReturnValue( {} ); | ||
useAdsCampaigns.mockReturnValue( { | ||
data: baseCampaigns, | ||
} ); | ||
useAdsRecommendations.mockReturnValue( { recommendations } ); | ||
render( <PMaxImproveAssetsBanner /> ); | ||
expect( | ||
screen.getByText( | ||
/Unlock more sales for your campaign, Campaign 2/ | ||
) | ||
).toBeInTheDocument(); | ||
expect( | ||
screen.getByRole( 'button', { name: 'Improve Assets' } ) | ||
).toBeInTheDocument(); | ||
expect( | ||
screen.getByRole( 'button', { name: 'Dismiss' } ) | ||
).toBeInTheDocument(); | ||
} ); | ||
|
||
it( 'navigates to edit campaign and sets expiry when Improve Assets is clicked', () => { | ||
const setMock = jest.fn(); | ||
useDispatch.mockReturnValue( { set: setMock } ); | ||
|
||
const historyPush = jest.fn().mockName( 'getHistory().push' ); | ||
getHistory.mockReturnValue( { push: historyPush } ); | ||
usePreference.mockReturnValue( {} ); | ||
useAdsCampaigns.mockReturnValue( { data: baseCampaigns } ); | ||
useAdsRecommendations.mockReturnValue( { recommendations } ); | ||
|
||
const MOCK_NOW = 1_700_000_000_000; | ||
jest.spyOn( Date, 'now' ).mockReturnValue( MOCK_NOW ); | ||
|
||
render( <PMaxImproveAssetsBanner /> ); | ||
const improveAssetsButton = screen.getByRole( 'button', { | ||
name: 'Improve Assets', | ||
} ); | ||
|
||
fireEvent.click( improveAssetsButton ); | ||
|
||
const expectedExpiry = MOCK_NOW + DAY_IN_SECONDS * 30 * 1000; // 30 days | ||
|
||
expect( setMock ).toHaveBeenCalledWith( | ||
PREFERENCES_STORE_NAMESPACE, | ||
'pmax-improve-assets-banner', | ||
{ | ||
expiry: expectedExpiry, | ||
} | ||
); | ||
|
||
expect( setMock ).toHaveBeenCalled(); | ||
expect( getEditCampaignUrl ).toHaveBeenCalledWith( 2, 'asset-group' ); | ||
expect( historyPush ).toHaveBeenCalledWith( '/edit/2/asset-group' ); | ||
} ); | ||
} ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be in a
useEffect()
hook to avoid unnecessary re-renders?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, just updated it. When I tested there were no re-renders but just to make sure, I've used
useEffect