-
Notifications
You must be signed in to change notification settings - Fork 4k
Add a one-click deeplink that signs the user in and creates a Submit workspace #98887
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
Open
abzokhattab
wants to merge
14
commits into
Expensify:main
Choose a base branch
from
abzokhattab:abzokhattab/98644-submit-onboarding-deeplink
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 5 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
72e106e
Add onboarding intent deeplink that creates a Submit workspace
abzokhattab 1bf9098
Route the Submit deeplink through onboarding instead of racing it
abzokhattab 06e18f0
Create the Submit workspace directly instead of routing through onboa…
abzokhattab f843d86
Act on the Submit deeplink only for users who already onboarded
abzokhattab 8b21e71
Fix spellcheck failures in deeplink comments
abzokhattab 3215c16
Fix native deeplink paths for the Submit onboarding intent
abzokhattab 7fad41a
Parse the deeplink with getRouteFromLink instead of a local helper
abzokhattab 8bb0054
Trim comments down to the parts that aren't evident from the code
abzokhattab 22db33d
Build the encoded exitTo in tests instead of hardcoding the escapes
abzokhattab f1887b4
Apply the Submit intent at most once per app process
abzokhattab fca471c
Drop the unrelated comment fix in SubmitPlanWelcomeModal
abzokhattab 120ac22
Merge remote-tracking branch 'origin/main' into abzokhattab/98644-sub…
abzokhattab a6e5615
Cover the bare-path magic link HybridApp passes to NewDot
abzokhattab 418fd88
Drop the intent on supportal and pre-empt the welcome modal race
abzokhattab 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
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
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
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
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,24 @@ | ||
| import {useInitialURLState} from '@components/InitialURLContextProvider'; | ||
|
|
||
| import getOnboardingIntentFromUrl from '@libs/getOnboardingIntentFromUrl'; | ||
| import getCurrentUrl from '@libs/Navigation/currentUrl'; | ||
|
|
||
| import type {OnboardingIntent} from '@src/CONST'; | ||
|
|
||
| import {useState} from 'react'; | ||
|
|
||
| /** | ||
| * Resolves the onboarding outcome requested by the deeplink this session was opened with, e.g. `onboarding?intent=submit`. | ||
| * | ||
| * The URL is latched at mount because the app rewrites it as soon as it navigates, which happens well before the | ||
| * intent has been acted on. The initial URL is the only source on native, where the browser URL is empty and the | ||
| * deeplink resolves asynchronously. | ||
| */ | ||
| function useOnboardingDeeplinkIntent(): OnboardingIntent | undefined { | ||
| const {initialURL} = useInitialURLState(); | ||
| const [urlAtMount] = useState(getCurrentUrl); | ||
|
|
||
| return getOnboardingIntentFromUrl(urlAtMount) ?? getOnboardingIntentFromUrl(initialURL); | ||
|
MelvinBot marked this conversation as resolved.
|
||
| } | ||
|
|
||
| export default useOnboardingDeeplinkIntent; | ||
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
62 changes: 62 additions & 0 deletions
62
src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.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,62 @@ | ||
| import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; | ||
| import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; | ||
| import useOnyx from '@hooks/useOnyx'; | ||
|
|
||
| import {setSubmitMigrationModalShown} from '@userActions/User'; | ||
|
|
||
| import ONYXKEYS from '@src/ONYXKEYS'; | ||
|
|
||
| import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; | ||
| import {isSupportalSessionSelector} from '@selectors/Session'; | ||
| import {useEffect, useRef} from 'react'; | ||
|
|
||
| /** | ||
| * Creates the Submit workspace requested by an `intent=submit` onboarding deeplink. | ||
| * | ||
| * The link is only sent to existing users, so it acts solely on recipients who have already been through guided | ||
| * setup: for them the workspace is created outright and they land wherever the "Submit to my employer" flow normally | ||
| * leaves them. Anyone who still has onboarding ahead of them is left to it untouched, since that flow already offers | ||
| * the Submit outcome. | ||
| * | ||
| * Only rendered once the deeplink has been recognized, so the Onyx subscriptions behind `useAutoCreateSubmitWorkspace` | ||
| * are never set up for ordinary sessions. | ||
| */ | ||
| function ApplySubmitOnboardingIntent() { | ||
| const {firstName, lastName} = useCurrentUserPersonalDetails(); | ||
| const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); | ||
|
|
||
| // HAS_LOADED_APP only flips true once this session's account data has landed, so waiting on it keeps the | ||
| // eligibility checks inside useAutoCreateSubmitWorkspace (existing workspaces, restricted policy creation) | ||
| // from running against a half-populated store and creating a duplicate workspace. | ||
| const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP); | ||
| const [isOnboardingCompleted] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); | ||
| const [isSupportalSession] = useOnyx(ONYXKEYS.SESSION, {selector: isSupportalSessionSelector}); | ||
|
|
||
| const hasRun = useRef(false); | ||
|
|
||
| useEffect(() => { | ||
| if (hasRun.current || !hasLoadedApp || isOnboardingCompleted === undefined || isSupportalSession) { | ||
| return; | ||
| } | ||
| hasRun.current = true; | ||
|
|
||
| // Recipients who never finished guided setup are left to the normal onboarding flow, which already offers | ||
| // the Submit outcome. | ||
| if (!isOnboardingCompleted) { | ||
| return; | ||
| } | ||
|
|
||
| // The deeplink delivers the same outcome as the Submit plan welcome modal, so record the modal as seen to | ||
| // stop it from opening on top of the workspace we're about to create. | ||
| setSubmitMigrationModalShown(); | ||
|
|
||
| // Guided setup is already done, so it must not run again. When the user already owns a Submit workspace, | ||
| // useAutoCreateSubmitWorkspace skips creation and navigates to that workspace instead, which is what makes | ||
| // repeat clicks of the link idempotent. | ||
| autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', false); | ||
| }, [autoCreateSubmitWorkspace, firstName, hasLoadedApp, isOnboardingCompleted, isSupportalSession, lastName]); | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| export default ApplySubmitOnboardingIntent; |
22 changes: 22 additions & 0 deletions
22
src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.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,22 @@ | ||
| import useOnboardingDeeplinkIntent from '@hooks/useOnboardingDeeplinkIntent'; | ||
|
|
||
| import CONST from '@src/CONST'; | ||
|
|
||
| import React from 'react'; | ||
|
|
||
| import ApplySubmitOnboardingIntent from './ApplySubmitOnboardingIntent'; | ||
|
|
||
| /** | ||
| * Recognizes the `intent=submit` onboarding deeplink and hands off to the component that acts on it. | ||
| */ | ||
| function SubmitIntentDeeplinkHandler() { | ||
| const onboardingDeeplinkIntent = useOnboardingDeeplinkIntent(); | ||
|
|
||
| if (onboardingDeeplinkIntent !== CONST.ONBOARDING_INTENTS.SUBMIT) { | ||
| return null; | ||
| } | ||
|
|
||
| return <ApplySubmitOnboardingIntent />; | ||
| } | ||
|
|
||
| export default SubmitIntentDeeplinkHandler; |
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,47 @@ | ||
| /** | ||
| * Reads the `intent` param of the onboarding deeplink (e.g. `onboarding?intent=submit`), which lets a one-click | ||
| * link pre-select an onboarding outcome instead of asking the recipient to pick it in the UI. | ||
| * | ||
| * The param arrives in one of two shapes: | ||
| * - directly, when the recipient is already signed in: `/onboarding?intent=submit` | ||
| * - nested in the `exitTo` of an auth handoff link, where `onboarding?intent=submit` is URL-encoded: | ||
| * `/transition?...&exitTo=<encoded>` or `/v/<accountID>/<validateCode>?exitTo=<encoded>` | ||
| * | ||
| * Nesting it in `exitTo` is what carries the intent across the logged-out -> logged-in transition: the deeplink | ||
| * outlives the sign-in itself, so the intent is still readable once the authenticated screens mount. | ||
| */ | ||
| import type {OnboardingIntent} from '@src/CONST'; | ||
| import CONST from '@src/CONST'; | ||
| import ROUTES from '@src/ROUTES'; | ||
|
|
||
| import {getSearchParamFromPath} from './Url'; | ||
|
|
||
| const ONBOARDING_INTENT_VALUES = new Set<string>(Object.values(CONST.ONBOARDING_INTENTS)); | ||
|
|
||
| function isOnboardingIntent(value: string | null): value is OnboardingIntent { | ||
| return !!value && ONBOARDING_INTENT_VALUES.has(value); | ||
| } | ||
|
|
||
| /** Strips the scheme and host so absolute URLs and in-app paths can be inspected the same way. */ | ||
| function getPathWithQuery(url: string): string { | ||
| const [withoutHash] = url.replace(/^[a-z][\w+.-]*:\/\/[^/]*/i, '').split('#', 2); | ||
| return withoutHash.replace(/^\/+/, ''); | ||
|
MelvinBot marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| function getOnboardingIntentFromUrl(url: string | null | undefined): OnboardingIntent | undefined { | ||
| if (!url) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const pathWithQuery = getPathWithQuery(url); | ||
| const onboardingPathWithQuery = pathWithQuery.startsWith(ROUTES.ONBOARDING_ROOT.route) ? pathWithQuery : getSearchParamFromPath(pathWithQuery, 'exitTo'); | ||
|
|
||
| if (!onboardingPathWithQuery?.startsWith(ROUTES.ONBOARDING_ROOT.route)) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const intent = getSearchParamFromPath(onboardingPathWithQuery, 'intent'); | ||
| return isOnboardingIntent(intent) ? intent : undefined; | ||
| } | ||
|
|
||
| export default getOnboardingIntentFromUrl; | ||
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,42 @@ | ||
| import getOnboardingIntentFromUrl from '@libs/getOnboardingIntentFromUrl'; | ||
|
|
||
| import CONST from '@src/CONST'; | ||
|
|
||
| describe('getOnboardingIntentFromUrl', () => { | ||
| it('reads the intent from a direct onboarding link', () => { | ||
| expect(getOnboardingIntentFromUrl('https://new.expensify.com/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); | ||
| }); | ||
|
|
||
| it('reads the intent from an in-app path without an origin', () => { | ||
| expect(getOnboardingIntentFromUrl('/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); | ||
| }); | ||
|
|
||
| it('reads the intent from the exitTo of an OldDot transition link', () => { | ||
| const url = 'https://new.expensify.com/transition?email=me%40example.com&shortLivedAuthToken=abc123&exitTo=onboarding%3Fintent%3Dsubmit'; | ||
|
|
||
| expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); | ||
| }); | ||
|
|
||
| it('reads the intent from the exitTo of a magic link', () => { | ||
| const url = 'https://new.expensify.com/v/12345/678910?exitTo=onboarding%3Fintent%3Dsubmit'; | ||
|
|
||
| expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); | ||
| }); | ||
|
|
||
| it('reads the intent from an unencoded exitTo, which OldDot mobile does not encode', () => { | ||
| const url = 'https://new.expensify.com/transition?shortLivedAuthToken=abc123&exitTo=onboarding?intent=submit'; | ||
|
|
||
| expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); | ||
| }); | ||
|
|
||
| it.each([ | ||
| ['no url', undefined], | ||
| ['an empty url', ''], | ||
| ['an onboarding link without an intent', 'https://new.expensify.com/onboarding'], | ||
| ['an unknown intent value', 'https://new.expensify.com/onboarding?intent=notARealIntent'], | ||
| ['an intent on a non-onboarding route', 'https://new.expensify.com/settings/profile?intent=submit'], | ||
| ['an intent on a non-onboarding exitTo', 'https://new.expensify.com/transition?exitTo=workspace%2Fnew%3Fintent%3Dsubmit'], | ||
| ])('returns undefined for %s', (_description, url) => { | ||
| expect(getOnboardingIntentFromUrl(url)).toBeUndefined(); | ||
| }); | ||
| }); |
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.