Skip to content

Commit d7c23b9

Browse files
allilevineclaude
andauthored
Write On: thread source through the anon flow (#113395)
* Write On: thread `source` query param through the anon flow Read `source` from the query string (mirroring write-new-site) and carry it into the `calypso_write_on_flow_entered` Tracks event and the Write editor redirect, so the anon funnel's source survives signup for attribution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D3rtpQqCkgRJvNLFpFd4Qn * Write On: add positive test for source forwarding into the redirect Mirror write-new-site's forwarding test — set `source` in the query string and assert it lands in the Write editor redirect URL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D3rtpQqCkgRJvNLFpFd4Qn * Write On: sanitize and omit-when-empty the source param Normalize `source` to [a-z0-9_-] lowercased and omit the Tracks prop when empty, matching the anon funnel's events (wpcom/jetpack) so the whole journey reports one consistent value. Add a sanitization test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D3rtpQqCkgRJvNLFpFd4Qn --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fae3dee commit d7c23b9

2 files changed

Lines changed: 70 additions & 2 deletions

File tree

client/landing/stepper/declarative-flow/flows/write-on/test/write-on.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import writeOn, { ANON_DRAFT_STORAGE_KEY, MAX_DRAFT_SIZE } from '../write-on';
99

1010
const mockIsEnabled = jest.fn( ( flag: string ) => flag === 'calypso/write-on-flow' );
1111

12+
let mockSearch = '';
13+
1214
jest.mock( '@automattic/calypso-config', () => {
1315
const fn = Object.assign( ( key: string ) => key, {
1416
isEnabled: ( flag: string ) => mockIsEnabled( flag ),
@@ -37,6 +39,10 @@ jest.mock( 'calypso/landing/stepper/stores', () => ( {
3739
ONBOARD_STORE: 'ONBOARD_STORE',
3840
} ) );
3941

42+
jest.mock( '../../../../hooks/use-query', () => ( {
43+
useQuery: () => new URLSearchParams( mockSearch ),
44+
} ) );
45+
4046
jest.mock( 'calypso/landing/stepper/utils/steps-with-required-login', () => ( {
4147
stepsWithRequiredLogin: ( steps: unknown ) => steps,
4248
} ) );
@@ -62,6 +68,7 @@ describe( 'write-on flow', () => {
6268

6369
beforeEach( () => {
6470
jest.clearAllMocks();
71+
mockSearch = '';
6572
mockIsEnabled.mockImplementation( ( flag: string ) => flag === 'calypso/write-on-flow' );
6673
window.localStorage.clear();
6774
Object.defineProperty( window, 'location', {
@@ -136,6 +143,44 @@ describe( 'write-on flow', () => {
136143
} );
137144
} );
138145

146+
it( 'forwards the source query param into the Write editor redirect', async () => {
147+
mockSearch = 'source=post_new';
148+
window.localStorage.setItem(
149+
ANON_DRAFT_STORAGE_KEY,
150+
JSON.stringify( { title: 'My title', content: '<p>Body</p>', ts: 1 } )
151+
);
152+
wpcomPostMock.mockResolvedValue( { ID: 42 } );
153+
154+
await submitFor( 'processing', {
155+
processingResult: ProcessingResult.SUCCESS,
156+
siteId: 99,
157+
siteSlug: 'example.wordpress.com',
158+
} );
159+
160+
expect( window.location.assign ).toHaveBeenCalledWith(
161+
'https://example.wordpress.com/wp-admin/admin.php?page=write&post=42&source=post_new'
162+
);
163+
} );
164+
165+
it( 'sanitizes the source query param to match the anon funnel before forwarding', async () => {
166+
mockSearch = 'source=Post_New%21';
167+
window.localStorage.setItem(
168+
ANON_DRAFT_STORAGE_KEY,
169+
JSON.stringify( { title: 'My title', content: '<p>Body</p>', ts: 1 } )
170+
);
171+
wpcomPostMock.mockResolvedValue( { ID: 42 } );
172+
173+
await submitFor( 'processing', {
174+
processingResult: ProcessingResult.SUCCESS,
175+
siteId: 99,
176+
siteSlug: 'example.wordpress.com',
177+
} );
178+
179+
expect( window.location.assign ).toHaveBeenCalledWith(
180+
'https://example.wordpress.com/wp-admin/admin.php?page=write&post=42&source=post_new'
181+
);
182+
} );
183+
139184
it( 'preserves the localStorage draft, logs to logstash, and lands on the site home when the POST fails', async () => {
140185
window.localStorage.setItem(
141186
ANON_DRAFT_STORAGE_KEY,

client/landing/stepper/declarative-flow/flows/write-on/write-on.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { logToLogstash } from 'calypso/lib/logstash';
88
import wpcom from 'calypso/lib/wp';
99
import { useSelector } from 'calypso/state';
1010
import { isUserLoggedIn } from 'calypso/state/current-user/selectors';
11+
import { useQuery } from '../../../hooks/use-query';
1112
import { ONBOARD_STORE } from '../../../stores';
1213
import { stepsWithRequiredLogin } from '../../../utils/steps-with-required-login';
1314
import { STEPS } from '../../internals/steps';
@@ -58,6 +59,14 @@ function clearAnonDraft() {
5859
}
5960
}
6061

62+
// Normalize the funnel `source` to [a-z0-9_-], lowercased first — kept identical
63+
// to the anon funnel's reader (wpcom's write-editor-anon.php / jetpack's view.js,
64+
// both mirroring PHP sanitize_key) so the whole journey reports one consistent
65+
// value. Empty string when absent, so callers can omit the prop entirely.
66+
function sanitizeSource( raw: string | null ): string {
67+
return ( raw || '' ).toLowerCase().replace( /[^a-z0-9_-]/g, '' );
68+
}
69+
6170
function initialize() {
6271
// The flag is a kill switch for the flow. When it is off, redirect to the
6372
// standard onboarding flow so there is nothing to land users on.
@@ -79,6 +88,11 @@ const writeOn: FlowV2< typeof initialize > = {
7988
const isLoggedIn = useSelector( isUserLoggedIn );
8089
const hasRunEntryChecks = useRef( false );
8190

91+
// The anon entry point links here with a `source` (e.g. `?source=post_new`).
92+
// Stepper preserves the query string across steps, so it is still present at
93+
// entry — carry it into the funnel's Tracks events for attribution.
94+
const source = sanitizeSource( useQuery().get( 'source' ) );
95+
8296
// Entry checks must fire at most once per mount. Re-running them on a
8397
// later isLoggedIn flip (e.g. mid-signup) would redirect the user out of
8498
// the flow during the auth round-trip.
@@ -110,14 +124,19 @@ const writeOn: FlowV2< typeof initialize > = {
110124

111125
recordTracksEvent( 'calypso_write_on_flow_entered', {
112126
draft_size: draft.title.length + draft.content.length,
127+
...( source ? { source } : {} ),
113128
} );
114129

115130
if ( draft.title ) {
116131
setSiteTitle( draft.title );
117132
}
118-
}, [ currentStepSlug, isLoggedIn, setSiteTitle ] );
133+
}, [ currentStepSlug, isLoggedIn, setSiteTitle, source ] );
119134
},
120135
useStepNavigation( currentStepSlug, navigate ) {
136+
// Same `source` the entry point carried in — forward it into the Write
137+
// editor so its back button and Tracks stay attributed to the funnel.
138+
const source = sanitizeSource( useQuery().get( 'source' ) );
139+
121140
const submit: SubmitHandler< typeof initialize > = async ( submittedStep ) => {
122141
const { slug, providedDependencies } = submittedStep;
123142
switch ( slug ) {
@@ -153,8 +172,12 @@ const writeOn: FlowV2< typeof initialize > = {
153172
site_id: siteId,
154173
} );
155174

175+
const params = new URLSearchParams( { page: 'write', post: String( post.ID ) } );
176+
if ( source ) {
177+
params.set( 'source', source );
178+
}
156179
window.location.assign(
157-
`https://${ siteSlug }/wp-admin/admin.php?page=write&post=${ post.ID }`
180+
`https://${ siteSlug }/wp-admin/admin.php?${ params.toString() }`
158181
);
159182
} catch ( error ) {
160183
// Surfacing the failure to the user is handled upstream by

0 commit comments

Comments
 (0)