Repository navigation
WOOA7S-2038: Premium Analytics: Show a notice when the site nears or passes its views limit #53401
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
Nikschavan
merged 11 commits into
trunk
from
wooa7s-2038-notifications-for-approaching-and-exceeding-the-plan-limit
Oct 14, 2026
Merged
Changes from 10 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
d74d4a5
Premium Analytics: Show a notice when the site nears or passes its vi…
Nikschavan 3ea615a
Premium Analytics: Track views, upgrade clicks and dismissals of the …
Nikschavan 1e86469
Premium Analytics: Keep a dismissed plan limit notice dismissed acros…
Nikschavan bc43160
Premium Analytics: Show the plan limit upgrade action as a button
Nikschavan 3397874
Premium Analytics: Show the plan limit upgrade action as a link again
Nikschavan a069dff
Premium Analytics: Show the plan limit notice with the other dashboar…
Nikschavan 3ca687f
Premium Analytics: Share the plan upgrade link with the Settings card
Nikschavan 8a16197
Premium Analytics: Use the Stats wording in the plan limit notice
Nikschavan 7974239
Premium Analytics: Answer the plan usage request in the e2e helper
Nikschavan b050ecb
Premium Analytics: Call each e2e fixture builder with its own arguments
Nikschavan 59f0194
Premium Analytics: Say why the over-limit notice ignores the Stats po…
Nikschavan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
4 changes: 4 additions & 0 deletions
4
projects/packages/premium-analytics/changelog/wooa7s-2038-plan-limit-notices
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
1 change: 1 addition & 0 deletions
1
projects/packages/premium-analytics/routes/dashboard/components/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
1 change: 1 addition & 0 deletions
1
projects/packages/premium-analytics/routes/dashboard/components/plan-limit-notice/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { PlanLimitNotice } from './plan-limit-notice'; |
3 changes: 3 additions & 0 deletions
3
...ium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.module.scss
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| .notice { | ||
| margin-block-end: var(--wpds-dimension-gap-lg); | ||
| } | ||
284 changes: 284 additions & 0 deletions
284
...remium-analytics/routes/dashboard/components/plan-limit-notice/plan-limit-notice.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } ); | ||
| } ); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.