Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Dashboard: Show a notice when the site nears or passes its plan's monthly views limit.
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,39 @@ export function useStatsAppNotices( params?: StatsAppNoticesParams, options?: Us
return useStatsAppQuery( statsAppNoticesQuery( params ), options );
}

export function useStatsAppNoticeMutation() {
const NOTICES_QUERY_KEY = [ 'stats-app', 'notices' ];

/**
* Saves a notice's visibility. The change lands in the cache before the request
* does, so every mounted copy of the notice hides at once; a failure restores it.
*
* @param options - Mutation options.
* @param options.onError - Runs after a failed save, even when the caller has since unmounted.
* @return The mutation.
*/
export function useStatsAppNoticeMutation( { onError }: { onError?: () => void } = {} ) {
const queryClient = useQueryClient();

return useMutation( {
mutationFn: ( data: StatsAppNoticeMutationParams ) => updateStatsAppNotice( data ),
onSuccess: () => {
queryClient.invalidateQueries( { queryKey: [ 'stats-app', 'notices' ] } );
onMutate: async ( data: StatsAppNoticeMutationParams ) => {
await queryClient.cancelQueries( { queryKey: NOTICES_QUERY_KEY } );
const previous = queryClient.getQueriesData< StatsAppNotices >( {
queryKey: NOTICES_QUERY_KEY,
} );
queryClient.setQueriesData< StatsAppNotices >( { queryKey: NOTICES_QUERY_KEY }, notices =>
notices ? { ...notices, [ data.id ]: false } : notices
);
return { previous };
},
onError: ( _error, _data, context ) => {
context?.previous.forEach( ( [ queryKey, notices ] ) =>
queryClient.setQueryData( queryKey, notices )
);
onError?.();
},
onSettled: () => {
queryClient.invalidateQueries( { queryKey: NOTICES_QUERY_KEY } );
},
} );
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { DashboardSections } from './dashboard-sections/dashboard-sections';
export { FeedbackBanner } from './feedback-banner';
export { PlanLimitNotice } from './plan-limit-notice';
export { OnboardingTour, onboardingTourSteps } from './onboarding-tour';
export { RefreshFailureNotice } from './refresh-failure-notice/refresh-failure-notice';
export { SectionSyncNotice } from './section-sync-notice/section-sync-notice';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PlanLimitNotice } from './plan-limit-notice';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.notice {
margin-block-end: var(--wpds-dimension-gap-lg);
}
Comment thread
Nikschavan marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
/**
* External dependencies
*/
import analytics from '@automattic/jetpack-analytics';
import { queryClient } from '@jetpack-premium-analytics/data';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import apiFetch from '@wordpress/api-fetch';
import { RegistryProvider } from '@wordpress/data';
/**
* Internal dependencies
*/
import { createNoticesRegistry } from '../../../../tests/js/notice-test-utils';
import { PlanLimitNotice, resetPlanLimitNoticeForTesting } from './plan-limit-notice';

jest.mock(
'@automattic/jetpack-analytics',
() => jest.requireActual( '../../../../tests/js/analytics-test-utils' ).mockJetpackAnalytics
);
jest.mock( '@wordpress/api-fetch', () => jest.fn() );

const mockApiFetch = apiFetch as unknown as jest.Mock;
const recordEvent = jest.mocked( analytics.tracks.recordEvent );

type Usage = { views_count: number; views_limit: number | null };
type Notices = { tier_upgrade?: boolean };

let notices = createNoticesRegistry();

/**
* Answers the usage and notices requests the notice makes.
*
* @param usage - This cycle's views and the plan's limit.
* @param state - The Stats notice visibility state.
* @param saveReply - The reply to saving a notice's visibility.
*/
function mockEndpoints(
usage: Usage,
state: Notices = {},
saveReply: () => Promise< unknown > = () => Promise.resolve( { tier_upgrade: false } )
) {
mockApiFetch.mockImplementation( ( { path, method }: { path: string; method?: string } ) => {
if ( path.includes( 'jetpack-stats/usage' ) ) {
return Promise.resolve( {
current_usage: { views_count: usage.views_count },
views_limit: usage.views_limit,
} );
}
if ( path.includes( 'notices' ) ) {
return method === 'POST' ? saveReply() : Promise.resolve( state );
}
return Promise.reject( new Error( `Unexpected request: ${ path }` ) );
} );
}

/**
* Seeds the script data the notice reads the host and purchase URL from.
*
* @param host - The site's host, as script data reports it.
*/
function setHost( host: string ) {
window.JetpackScriptData = {
site: {
admin_url: 'https://example.com/wp-admin/',
host,
wpcom: { blog_id: 123456789 },
},
} as typeof window.JetpackScriptData;
}

/**
* Renders the notice and settles its requests.
*
* @return The render result.
*/
async function renderNotice() {
const view = render(
<RegistryProvider value={ notices.registry }>
<PlanLimitNotice />
</RegistryProvider>
);
// The notices request waits on the usage reply, so settle both rounds.
await act( () => jest.runAllTimersAsync() );
await act( () => jest.runAllTimersAsync() );
return view;
}

/*
* The notice speaks its message through `@wordpress/a11y`, which mirrors the
* text into a live region on `document.body`. Assertions read the rendered
* container so they don't match that copy too.
*/
describe( 'PlanLimitNotice', () => {
beforeEach( () => {
jest.useFakeTimers();
queryClient.clear();
resetPlanLimitNoticeForTesting();
notices = createNoticesRegistry();
recordEvent.mockClear();
mockApiFetch.mockReset();
setHost( 'jetpack' );
} );

afterEach( () => {
jest.useRealTimers();
} );

it( 'warns a site at 90% of its limit and links to the upgrade', async () => {
mockEndpoints( { views_count: 9000, views_limit: 10000 } );

const { container } = await renderNotice();

expect( container ).toHaveTextContent( 'You are nearing your monthly limit' );
expect( screen.getByRole( 'link', { name: 'Upgrade now' } ) ).toHaveAttribute(
'href',
expect.stringContaining(
'admin.php?page=stats#!/stats/purchase/123456789?from=jetpack-premium-analytics-plan-limit-notice'
)
);
} );

it( 'says nothing below 90% of the limit', async () => {
mockEndpoints( { views_count: 8999, views_limit: 10000 } );

const { container } = await renderNotice();

expect( container ).toBeEmptyDOMElement();
} );

it( 'says nothing to a site without a views limit', async () => {
mockEndpoints( { views_count: 50000, views_limit: null } );

const { container } = await renderNotice();

expect( container ).toBeEmptyDOMElement();
} );

it( 'cannot be dismissed once the site reaches its limit', async () => {
mockEndpoints( { views_count: 10000, views_limit: 10000 }, { tier_upgrade: false } );

const { container } = await renderNotice();

expect( container ).toHaveTextContent( 'You have reached your views limit' );
expect( screen.queryByRole( 'button', { name: 'Dismiss' } ) ).not.toBeInTheDocument();
} );

it( 'stays away near the limit while Stats has it postponed', async () => {
mockEndpoints( { views_count: 9120, views_limit: 10000 }, { tier_upgrade: false } );

const { container } = await renderNotice();

expect( container ).toBeEmptyDOMElement();
} );

it( 'postpones the near-limit notice for a week when dismissed', async () => {
mockEndpoints( { views_count: 9120, views_limit: 10000 } );
const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
const { container } = await renderNotice();

await user.click( screen.getByRole( 'button', { name: 'Dismiss' } ) );

expect( container ).toBeEmptyDOMElement();
expect( mockApiFetch ).toHaveBeenCalledWith(
expect.objectContaining( {
method: 'POST',
data: { id: 'tier_upgrade', status: 'postponed', postponed_for: 604800 },
} )
);
expect( recordEvent ).toHaveBeenCalledWith(
'jetpack_premium_analytics_plan_limit_notice_dismiss',
undefined
);
} );

it( 'stays dismissed in a section that mounts before the dismissal is saved', async () => {
let saveDismissal = () => {};
mockEndpoints(
{ views_count: 9120, views_limit: 10000 },
{},
() => new Promise( resolve => ( saveDismissal = () => resolve( { tier_upgrade: false } ) ) )
);
const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
const { unmount } = await renderNotice();
await user.click( screen.getByRole( 'button', { name: 'Dismiss' } ) );
unmount();
const view = render(
<RegistryProvider value={ notices.registry }>
<PlanLimitNotice />
</RegistryProvider>
);

expect( view.container ).toBeEmptyDOMElement();
saveDismissal();
} );

it( 'brings the notice back and says so when the dismissal fails', async () => {
mockEndpoints( { views_count: 9120, views_limit: 10000 }, {}, () =>
Promise.reject( new Error( 'offline' ) )
);
const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
const { container } = await renderNotice();
// Offline: the refetch after the failure never answers either.
mockApiFetch.mockImplementation( ( { method }: { method?: string } ) =>
method === 'POST' ? Promise.reject( new Error( 'offline' ) ) : new Promise( () => {} )
);

await user.click( screen.getByRole( 'button', { name: 'Dismiss' } ) );
await act( () => jest.runAllTimersAsync() );

expect( container ).toHaveTextContent( 'You are nearing your monthly limit' );
expect( notices.createErrorNotice ).toHaveBeenCalledWith(
'Couldn’t dismiss the notice. Please try again.',
{ type: 'snackbar' }
);
} );

it( 'reports a failed dismissal after the reader has left the section', async () => {
let failDismissal = () => {};
mockEndpoints(
{ views_count: 9120, views_limit: 10000 },
{},
() =>
new Promise(
( _resolve, reject ) => ( failDismissal = () => reject( new Error( 'offline' ) ) )
)
);
const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
const { unmount } = await renderNotice();
await user.click( screen.getByRole( 'button', { name: 'Dismiss' } ) );
unmount();

failDismissal();
await act( () => jest.runAllTimersAsync() );

expect( notices.createErrorNotice ).toHaveBeenCalledWith(
'Couldn’t dismiss the notice. Please try again.',
{ type: 'snackbar' }
);
} );

it( 'records one view per page load, though every section tab mounts the notice', async () => {
mockEndpoints( { views_count: 11480, views_limit: 10000 } );

const { unmount } = await renderNotice();
unmount();
await renderNotice();

const views = recordEvent.mock.calls.filter(
( [ name ] ) => name === 'jetpack_premium_analytics_plan_limit_notice_view'
);
expect( views ).toEqual( [
[ 'jetpack_premium_analytics_plan_limit_notice_view', { status: 'over' } ],
] );
} );

it.each( [
[ 'near', 9120 ],
[ 'over', 11480 ],
] )( 'records an upgrade click from the %s notice', async ( status, views_count ) => {
mockEndpoints( { views_count, views_limit: 10000 } );
const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
await renderNotice();
// jsdom cannot follow the link to another page.
const stopNavigation = ( event: Event ) => event.preventDefault();
document.addEventListener( 'click', stopNavigation );

await user.click( screen.getByRole( 'link', { name: 'Upgrade now' } ) );

document.removeEventListener( 'click', stopNavigation );
expect( recordEvent ).toHaveBeenCalledWith(
'jetpack_premium_analytics_plan_limit_notice_upgrade_click',
{ status }
);
} );

it.each( [ 'wpcom', 'vip' ] )( 'says nothing on a %s site', async host => {
setHost( host );
mockEndpoints( { views_count: 11480, views_limit: 10000 } );

const { container } = await renderNotice();

expect( container ).toBeEmptyDOMElement();
} );
} );
Loading
Loading