Skip to content

Commit

Permalink
Merge pull request #45327 from software-mansion-labs/onboarding/dismi…
Browse files Browse the repository at this point in the history
…ssing-issue

Dismissing onboarding fix
  • Loading branch information
mountiny committed Jul 19, 2024
2 parents f1b29ee + 5ef6d79 commit b8b99d0
Show file tree
Hide file tree
Showing 18 changed files with 209 additions and 121 deletions.
4 changes: 4 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5362,6 +5362,10 @@ const CONST = {
DATE: 'date',
LIST: 'dropdown',
},

NAVIGATION_ACTIONS: {
RESET: 'RESET',
},
} as const;

type Country = keyof typeof CONST.ALL_COUNTRIES;
Expand Down
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,9 @@ const ONYXKEYS = {
/** Onboarding Purpose selected by the user during Onboarding flow */
ONBOARDING_PURPOSE_SELECTED: 'onboardingPurposeSelected',

/** Onboarding error message to be displayed to the user */
ONBOARDING_ERROR_MESSAGE: 'onboardingErrorMessage',

/** Onboarding policyID selected by the user during Onboarding flow */
ONBOARDING_POLICY_ID: 'onboardingPolicyID',

Expand Down Expand Up @@ -818,6 +821,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.MAX_CANVAS_HEIGHT]: number;
[ONYXKEYS.MAX_CANVAS_WIDTH]: number;
[ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: string;
[ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string;
[ONYXKEYS.ONBOARDING_POLICY_ID]: string;
[ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string;
[ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean;
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1476,6 +1476,7 @@ export default {
title: 'What do you want to do today?',
errorSelection: 'Please make a selection to continue.',
errorContinue: 'Please press continue to get set up.',
errorBackButton: 'Please finish the setup questions to start using the app.',
[CONST.ONBOARDING_CHOICES.EMPLOYER]: 'Get paid back by my employer',
[CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: "Manage my team's expenses",
[CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]: 'Track and budget expenses',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,7 @@ export default {
title: '¿Qué quieres hacer hoy?',
errorSelection: 'Por favor selecciona una opción para continuar.',
errorContinue: 'Por favor, haz click en continuar para configurar tu cuenta.',
errorBackButton: 'Por favor, finaliza las preguntas de configuración para empezar a utilizar la aplicación.',
[CONST.ONBOARDING_CHOICES.EMPLOYER]: 'Cobrar de mi empresa',
[CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: 'Gestionar los gastos de mi equipo',
[CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]: 'Controlar y presupuestar gastos',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import NoDropZone from '@components/DragAndDrop/NoDropZone';
import FocusTrapForScreens from '@components/FocusTrap/FocusTrapForScreen';
import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useOnboardingLayout from '@hooks/useOnboardingLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector';
import OnboardingModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/OnboardingModalNavigatorScreenOptions';
import Navigation from '@libs/Navigation/Navigation';
import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types';
Expand All @@ -26,15 +28,11 @@ function OnboardingModalNavigator() {
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useOnboardingLayout();
const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
selector: (onboarding) => {
// onboarding is an array for old accounts and accounts created from olddot
if (Array.isArray(onboarding)) {
return true;
}
return onboarding?.hasCompletedGuidedSetupFlow;
},
selector: hasCompletedGuidedSetupFlowSelector,
});

useDisableModalDismissOnEscape();

useEffect(() => {
if (!hasCompletedGuidedSetupFlow) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as Session from '@libs/actions/Session';
import interceptAnonymousUser from '@libs/interceptAnonymousUser';
import Navigation from '@libs/Navigation/Navigation';
import linkingConfig from '@libs/Navigation/linkingConfig';
import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import type {RootStackParamList, State} from '@libs/Navigation/types';
import {isCentralPaneName} from '@libs/NavigationUtils';
import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils';
Expand Down Expand Up @@ -54,7 +56,13 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
return;
}

Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_ROOT)});
Welcome.isOnboardingFlowCompleted({
onNotCompleted: () => {
const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT, linkingConfig.config);
navigationRef.resetRoot(adaptedState);
},
});

// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [isLoadingApp]);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import type {RouterConfigOptions, StackNavigationState} from '@react-navigation/native';
import {getPathFromState, StackRouter} from '@react-navigation/native';
import type {CommonActions, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native';
import {findFocusedRoute, getPathFromState, StackRouter} from '@react-navigation/native';
import type {ParamListBase} from '@react-navigation/routers';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
import * as Localize from '@libs/Localize';
import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
import linkingConfig from '@libs/Navigation/linkingConfig';
import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath';
import type {NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types';
import {isCentralPaneName} from '@libs/NavigationUtils';
import {isCentralPaneName, isOnboardingFlowName} from '@libs/NavigationUtils';
import * as Welcome from '@userActions/Welcome';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
import type {ResponsiveStackNavigatorRouterOptions} from './types';
Expand Down Expand Up @@ -97,6 +100,23 @@ function compareAndAdaptState(state: StackNavigationState<RootStackParamList>) {
}
}

function shouldPreventReset(state: StackNavigationState<ParamListBase>, action: CommonActions.Action | StackActionType) {
if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) {
return false;
}
const currentFocusedRoute = findFocusedRoute(state);
const targetFocusedRoute = findFocusedRoute(action?.payload);

// We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen
if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) {
Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton'));
// We reset the URL as the browser sets it in a way that doesn't match the navigation state
// eslint-disable-next-line no-restricted-globals
history.replaceState({}, '', getPathFromState(state, linkingConfig.config));
return true;
}
}

function CustomRouter(options: ResponsiveStackNavigatorRouterOptions) {
const stackRouter = StackRouter(options);

Expand All @@ -107,6 +127,12 @@ function CustomRouter(options: ResponsiveStackNavigatorRouterOptions) {
const state = stackRouter.getRehydratedState(partialState, {routeNames, routeParamList, routeGetIdList});
return state;
},
getStateForAction(state: StackNavigationState<ParamListBase>, action: CommonActions.Action | StackActionType, configOptions: RouterConfigOptions) {
if (shouldPreventReset(state, action)) {
return state;
}
return stackRouter.getStateForAction(state, action, configOptions);
},
};
}

Expand Down
52 changes: 37 additions & 15 deletions src/libs/Navigation/NavigationRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import type {NavigationState} from '@react-navigation/native';
import {DefaultTheme, findFocusedRoute, NavigationContainer} from '@react-navigation/native';
import React, {useContext, useEffect, useMemo, useRef} from 'react';
import {useOnyx} from 'react-native-onyx';
import HybridAppMiddleware from '@components/HybridAppMiddleware';
import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useCurrentReportID from '@hooks/useCurrentReportID';
import useTheme from '@hooks/useTheme';
import useWindowDimensions from '@hooks/useWindowDimensions';
import {FSPage} from '@libs/Fullstory';
import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector';
import Log from '@libs/Log';
import {getPathFromURL} from '@libs/Url';
import {updateLastVisitedPath} from '@userActions/App';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import AppNavigator from './AppNavigator';
import getPolicyIDFromState from './getPolicyIDFromState';
import linkingConfig from './linkingConfig';
Expand Down Expand Up @@ -76,26 +80,44 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
const currentReportIDValue = useCurrentReportID();
const {isSmallScreenWidth} = useWindowDimensions();
const {setActiveWorkspaceID} = useActiveWorkspace();
const [user] = useOnyx(ONYXKEYS.USER);

const initialState = useMemo(
() => {
if (!lastVisitedPath) {
return undefined;
}
const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
selector: hasCompletedGuidedSetupFlowSelector,
});

const path = initialUrl ? getPathFromURL(initialUrl) : null;

// For non-nullable paths we don't want to set initial state
if (path) {
return;
}
const initialState = useMemo(() => {
if (!user || user.isFromPublicDomain) {
return;
}

const {adaptedState} = getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config);
// If the user haven't completed the flow, we want to always redirect them to the onboarding flow.
// We also make sure that the user is authenticated.
if (!hasCompletedGuidedSetupFlow && authenticated) {
const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT, linkingConfig.config);
return adaptedState;
},
}

// If there is no lastVisitedPath, we can do early return. We won't modify the default behavior.
if (!lastVisitedPath) {
return undefined;
}

const path = initialUrl ? getPathFromURL(initialUrl) : null;

// If the user opens the root of app "/" it will be parsed to empty string "".
// If the path is defined and different that empty string we don't want to modify the default behavior.
if (path) {
return;
}

// Otherwise we want to redirect the user to the last visited path.
const {adaptedState} = getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config);
return adaptedState;

// The initialState value is relevant only on the first render.
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
[],
);
}, []);

// https://reactnavigation.org/docs/themes
const navigationTheme = useMemo(
Expand Down
3 changes: 3 additions & 0 deletions src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,8 @@ type FullScreenName = keyof FullScreenNavigatorParamList;

type CentralPaneName = keyof CentralPaneScreensParamList;

type OnboardingFlowName = keyof OnboardingModalNavigatorParamList;

type SwitchPolicyIDParams = {
policyID?: string;
route?: Routes;
Expand Down Expand Up @@ -1300,6 +1302,7 @@ export type {
NewChatNavigatorParamList,
NewTaskNavigatorParamList,
OnboardingModalNavigatorParamList,
OnboardingFlowName,
ParticipantsNavigatorParamList,
PrivateNotesNavigatorParamList,
ProfileNavigatorParamList,
Expand Down
14 changes: 12 additions & 2 deletions src/libs/NavigationUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import cloneDeep from 'lodash/cloneDeep';
import SCREENS from '@src/SCREENS';
import getTopmostBottomTabRoute from './Navigation/getTopmostBottomTabRoute';
import type {CentralPaneName, RootStackParamList, State} from './Navigation/types';
import type {CentralPaneName, OnboardingFlowName, RootStackParamList, State} from './Navigation/types';

const CENTRAL_PANE_SCREEN_NAMES = new Set([
SCREENS.SETTINGS.WORKSPACES,
Expand All @@ -17,6 +17,8 @@ const CENTRAL_PANE_SCREEN_NAMES = new Set([
SCREENS.REPORT,
]);

const ONBOARDING_SCREEN_NAMES = new Set([SCREENS.ONBOARDING.PERSONAL_DETAILS, SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK, SCREENS.ONBOARDING_MODAL.ONBOARDING]);

const removePolicyIDParamFromState = (state: State<RootStackParamList>) => {
const stateCopy = cloneDeep(state);
const bottomTabRoute = getTopmostBottomTabRoute(stateCopy);
Expand All @@ -33,4 +35,12 @@ function isCentralPaneName(screen: string | undefined): screen is CentralPaneNam
return CENTRAL_PANE_SCREEN_NAMES.has(screen as CentralPaneName);
}

export {isCentralPaneName, removePolicyIDParamFromState};
function isOnboardingFlowName(screen: string | undefined): screen is OnboardingFlowName {
if (!screen) {
return false;
}

return ONBOARDING_SCREEN_NAMES.has(screen as OnboardingFlowName);
}

export {isCentralPaneName, removePolicyIDParamFromState, isOnboardingFlowName};
68 changes: 45 additions & 23 deletions src/libs/actions/Report.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {findFocusedRoute} from '@react-navigation/native';
import {format as timezoneFormat, utcToZonedTime} from 'date-fns-tz';
import {Str} from 'expensify-common';
import isEmpty from 'lodash/isEmpty';
Expand Down Expand Up @@ -55,12 +56,14 @@ import {prepareDraftComment} from '@libs/DraftCommentUtils';
import * as EmojiUtils from '@libs/EmojiUtils';
import * as Environment from '@libs/Environment/Environment';
import * as ErrorUtils from '@libs/ErrorUtils';
import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector';
import HttpUtils from '@libs/HttpUtils';
import isPublicScreenRoute from '@libs/isPublicScreenRoute';
import * as Localize from '@libs/Localize';
import Log from '@libs/Log';
import {registerPaginationConfig} from '@libs/Middleware/Pagination';
import Navigation from '@libs/Navigation/Navigation';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import {isOnboardingFlowName} from '@libs/NavigationUtils';
import type {NetworkStatus} from '@libs/NetworkConnection';
import LocalNotification from '@libs/Notification/LocalNotification';
import Parser from '@libs/Parser';
Expand Down Expand Up @@ -2580,28 +2583,47 @@ function openReportFromDeepLink(url: string) {
// Navigate to the report after sign-in/sign-up.
InteractionManager.runAfterInteractions(() => {
Session.waitForUserSignIn().then(() => {
Navigation.waitForProtectedRoutes().then(() => {
if (route && Session.isAnonymousUser() && !Session.canAnonymousUserAccessRoute(route)) {
Session.signOutAndRedirectToSignIn(true);
return;
}

// We don't want to navigate to the exitTo route when creating a new workspace from a deep link,
// because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate,
// which is already called when AuthScreens mounts.
if (new URL(url).searchParams.get('exitTo') === ROUTES.WORKSPACE_NEW) {
return;
}

if (shouldSkipDeepLinkNavigation(route)) {
return;
}

if (isAuthenticated) {
return;
}

Navigation.navigate(route as Route, CONST.NAVIGATION.ACTION_TYPE.PUSH);
Onyx.connect({
key: ONYXKEYS.NVP_ONBOARDING,
callback: (onboarding) => {
Navigation.waitForProtectedRoutes().then(() => {
if (route && Session.isAnonymousUser() && !Session.canAnonymousUserAccessRoute(route)) {
Session.signOutAndRedirectToSignIn(true);
return;
}

// We don't want to navigate to the exitTo route when creating a new workspace from a deep link,
// because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate,
// which is already called when AuthScreens mounts.
if (new URL(url).searchParams.get('exitTo') === ROUTES.WORKSPACE_NEW) {
return;
}

if (shouldSkipDeepLinkNavigation(route)) {
return;
}

const state = navigationRef.getRootState();
const currentFocusedRoute = findFocusedRoute(state);
const hasCompletedGuidedSetupFlow = hasCompletedGuidedSetupFlowSelector(onboarding);

// We need skip deeplinking if the user hasn't completed the guided setup flow.
if (!hasCompletedGuidedSetupFlow) {
return;
}

if (isOnboardingFlowName(currentFocusedRoute?.name)) {
Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton'));
return;
}

if (isAuthenticated) {
return;
}

Navigation.navigate(route as Route, CONST.NAVIGATION.ACTION_TYPE.PUSH);
});
},
});
});
});
Expand Down
Loading

0 comments on commit b8b99d0

Please sign in to comment.