Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5159,11 +5159,13 @@ const CONST = {
REPLACE: 'REPLACE',
PUSH: 'PUSH',
NAVIGATE: 'NAVIGATE',
SET_PARAMS: 'SET_PARAMS',

/** These action types are custom for RootNavigator */
SWITCH_POLICY_ID: 'SWITCH_POLICY_ID',
DISMISS_MODAL: 'DISMISS_MODAL',
OPEN_WORKSPACE_SPLIT: 'OPEN_WORKSPACE_SPLIT',
SET_HISTORY_PARAM: 'SET_HISTORY_PARAM',
},
},
TIME_PERIOD: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import {CommonActions, findFocusedRoute} from '@react-navigation/native';
import type {ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType} from '@react-navigation/native';
import type {PlatformStackNavigationState, PlatformStackRouterFactory, PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types';
import type {SetParamsAction} from '@libs/Navigation/types';
import CONST from '@src/CONST';
import SCREEN_TO_HISTORY_PARAM from '@src/libs/Navigation/linkingConfig/RELATIONS/SCREEN_TO_HISTORY_PARAM';
import type {Screen} from '@src/SCREENS';
import type {HistoryStackNavigatorAction, SetHistoryParamActionType} from './types';

const CUSTOM_HISTORY_PREFIX = 'CUSTOM_HISTORY';

function isSetParamsAction(action: HistoryStackNavigatorAction): action is SetParamsAction {
return action.type === CONST.NAVIGATION.ACTION_TYPE.SET_PARAMS;
}

function isSetHistoryParamAction(action: HistoryStackNavigatorAction): action is SetHistoryParamActionType {
return action.type === CONST.NAVIGATION.ACTION_TYPE.SET_HISTORY_PARAM;
}

// The history can be anything. For now, string is enough but we can extend it to include more data if necessary.
function getCustomHistoryEntry(routeName: string) {
return `${CUSTOM_HISTORY_PREFIX}-${routeName}`;
}

/**
* Higher-order function that extends the React Navigation stack router with custom history functionality.
* It allows tracking and managing navigation history entries that are not determined by the routes of navigator.
* The extension adds support for custom history entries through route params and maintains a history stack
* that can be manipulated independently of the navigation state.
*
* @param originalStackRouter - The original stack router function to be extended
* @returns Enhanced router with custom history functionality
*/

function addCustomHistoryRouterExtension<RouterOptions extends PlatformStackRouterOptions = PlatformStackRouterOptions>(
originalRouter: PlatformStackRouterFactory<ParamListBase, RouterOptions>,
) {
return (options: RouterOptions): Router<PlatformStackNavigationState<ParamListBase>, HistoryStackNavigatorAction> => {
const router = originalRouter(options);

const enhanceStateWithHistory = (state: PlatformStackNavigationState<ParamListBase>) => {
return {
...state,
history: state.routes.map((route) => route.key),
};
};

// Override methods to enhance state with history
const getInitialState = (configOptions: RouterConfigOptions) => {
const state = router.getInitialState(configOptions);
return enhanceStateWithHistory(state);
};

const getRehydratedState = (partialState: PartialState<PlatformStackNavigationState<ParamListBase>>, configOptions: RouterConfigOptions) => {
const state = router.getRehydratedState(partialState, configOptions);
const stateWithInitialHistory = enhanceStateWithHistory(state);

const focusedRoute = findFocusedRoute(stateWithInitialHistory);

// There always be a focused route in the state. It's for type safety.
if (!focusedRoute) {
return stateWithInitialHistory;
}

// @ts-expect-error focusedRoute.key is always defined because it is a route from a rehydrated state. Find focused route isn't correctly typed in this case.
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const customHistoryEntry = getCustomHistoryEntry(focusedRoute.key);

const customHistoryParamName = SCREEN_TO_HISTORY_PARAM[focusedRoute.name as Screen];

if (
// The custom history param name should be defined
typeof customHistoryParamName === 'string' &&
// Params for the focused route should be defined
focusedRoute.params &&
// The custom history param with given name should be defined in the params
customHistoryParamName in focusedRoute.params &&
// The custom history param should be set to true
(focusedRoute.params as Record<string, unknown>)[customHistoryParamName] &&
// The last history entry should not be the custom history entry for the focused route to avoid duplication
stateWithInitialHistory.history.at(-1) !== customHistoryEntry
) {
// Add the custom history entry to the initial history
stateWithInitialHistory.history = [...stateWithInitialHistory.history, customHistoryEntry];
}

return stateWithInitialHistory;
};

const getStateForAction = (
state: PlatformStackNavigationState<ParamListBase>,
action: CommonActions.Action | StackActionType | HistoryStackNavigatorAction,
configOptions: RouterConfigOptions,
) => {
// We want to set the right param and then update the history
if (isSetHistoryParamAction(action)) {
const customHistoryEntry = getCustomHistoryEntry(action.payload.key);

// Start with updating the param.
const setParamsAction = CommonActions.setParams({[action.payload.key]: action.payload.value});
const stateWithUpdatedParams = router.getStateForAction(state, setParamsAction, configOptions);

// This shouldn't ever happen as the history should be always defined. It's for type safety.
if (!stateWithUpdatedParams?.history) {
return stateWithUpdatedParams;
}

// If it's set to true, we need to add the history entry if it's not already there.
if (action.payload.value && stateWithUpdatedParams.history.at(-1) !== customHistoryEntry) {
return {...stateWithUpdatedParams, history: [...stateWithUpdatedParams.history, customHistoryEntry]};
}

// If it's set to false, we need to remove the history entry if it's there.
if (!action.payload.value) {
return {...stateWithUpdatedParams, history: stateWithUpdatedParams.history.filter((entry) => entry !== customHistoryEntry)};
}

// Else, do not change history.
return stateWithUpdatedParams;
}

const newState = router.getStateForAction(state, action, configOptions);

// If the action was not handled, return null.
if (!newState) {
return null;
}

// If the action was a setParams action, we need to preserve the history.
if (isSetParamsAction(action) && state.history) {
return {
...newState,
history: [...state.history],
};
}

// Handle every other action.
// @ts-expect-error newState can be partial or not. But getRehydratedState will handle it correctly even if the stale === false.
// And we need to update the history if routes have changed.
return getRehydratedState(newState, configOptions);
};

return {
...router,
getInitialState,
getRehydratedState,
getStateForAction,
};
};
}

export default addCustomHistoryRouterExtension;
2 changes: 2 additions & 0 deletions src/libs/Navigation/AppNavigator/customHistory/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export {default as addCustomHistoryRouterExtension} from './addCustomHistoryRouterExtension';
export {default as useCustomHistoryParam} from './useCustomHistoryParam';
18 changes: 18 additions & 0 deletions src/libs/Navigation/AppNavigator/customHistory/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type {CommonActions, StackActionType} from '@react-navigation/native';
import type CONST from '@src/CONST';

type HistoryStackNavigatorAction = CommonActions.Action | StackActionType | HistoryStackNavigatorActionType;

type HistoryStackNavigatorActionType = {
type: typeof CONST.NAVIGATION.ACTION_TYPE.SET_HISTORY_PARAM;
payload: {
key: string;
value: boolean;
};
};

type SetHistoryParamActionType = HistoryStackNavigatorAction & {
type: typeof CONST.NAVIGATION.ACTION_TYPE.SET_HISTORY_PARAM;
};

export type {HistoryStackNavigatorAction, HistoryStackNavigatorActionType, SetHistoryParamActionType};
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {useNavigation, useRoute} from '@react-navigation/native';
import SCREEN_TO_HISTORY_PARAM from '@libs/Navigation/linkingConfig/RELATIONS/SCREEN_TO_HISTORY_PARAM';
import CONST from '@src/CONST';
import type {Screen} from '@src/SCREENS';

/**
* Custom hook for managing navigation history entries
* It works as useState but it pushes the history entry when the value is true.
* The value will change to false if the user navigates back with the browser back button.
* It uses screen param to store the information and make it visible in the url so the state persist after refreshing the page.
*
* @returns A tuple containing [historyParam, setHistoryParam] where:
* - historyParam: boolean | undefined - The current state of the history parameter
* - setHistoryParam: (value: boolean) => void - Function to update the history parameter
*/
function useCustomHistoryParam() {
const navigation = useNavigation();
const route = useRoute();
const historyParamName = SCREEN_TO_HISTORY_PARAM[route.name as Screen];

if (!historyParamName || typeof historyParamName !== 'string') {
throw new Error(`Screen ${route.name} does not have a history param. You can use this hook only on the screens that have one defined in SCREEN_TO_HISTORY_PARAM.`);
}

const historyParam = route.params && (route.params as Record<string, unknown>)[historyParamName] ? ((route.params as Record<string, unknown>)[historyParamName] as boolean) : false;

if (typeof historyParam !== 'boolean') {
throw new Error(`The history param ${historyParamName} is not a boolean. Make sure that you used getHistoryParamParse for this screen in linkingConfig/config.ts`);
}

return [
historyParam,
(value: boolean) => {
navigation.dispatch({
type: CONST.NAVIGATION.ACTION_TYPE.SET_HISTORY_PARAM,
payload: {key: historyParamName, value},
});
},
] as const;
}

export default useCustomHistoryParam;
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {StackRouter, useNavigationBuilder} from '@react-navigation/native';
import {NativeStackView} from '@react-navigation/native-stack';
import type {NativeStackNavigationEventMap, NativeStackNavigationOptions} from '@react-navigation/native-stack';
import React, {useMemo} from 'react';
import {addCustomHistoryRouterExtension} from '@libs/Navigation/AppNavigator/customHistory';
import convertToNativeNavigationOptions from '@libs/Navigation/PlatformStackNavigation/navigationOptions/convertToNativeNavigationOptions';
import type {
CreatePlatformStackNavigatorComponentOptions,
Expand All @@ -18,7 +19,7 @@ function createPlatformStackNavigatorComponent<RouterOptions extends PlatformSta
displayName: string,
options?: CreatePlatformStackNavigatorComponentOptions<RouterOptions>,
) {
const createRouter = options?.createRouter ?? StackRouter;
const createRouter = addCustomHistoryRouterExtension(options?.createRouter ?? StackRouter);
const defaultScreenOptions = options?.defaultScreenOptions;
const useCustomState = options?.useCustomState ?? (() => undefined);
const useCustomEffects = options?.useCustomEffects ?? (() => undefined);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {StackRouter, useNavigationBuilder} from '@react-navigation/native';
import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack';
import {StackView} from '@react-navigation/stack';
import React, {useMemo} from 'react';
import {addCustomHistoryRouterExtension} from '@libs/Navigation/AppNavigator/customHistory';
import convertToWebNavigationOptions from '@libs/Navigation/PlatformStackNavigation/navigationOptions/convertToWebNavigationOptions';
import type {
CreatePlatformStackNavigatorComponentOptions,
Expand All @@ -18,7 +19,7 @@ function createPlatformStackNavigatorComponent<RouterOptions extends PlatformSta
displayName: string,
options?: CreatePlatformStackNavigatorComponentOptions<RouterOptions>,
) {
const createRouter = options?.createRouter ?? StackRouter;
const createRouter = addCustomHistoryRouterExtension(options?.createRouter ?? StackRouter);
const useCustomState = options?.useCustomState ?? (() => undefined);
const defaultScreenOptions = options?.defaultScreenOptions;
const ExtraContent = options?.ExtraContent;
Expand Down
5 changes: 5 additions & 0 deletions src/libs/Navigation/linkingConfig/HISTORY_PARAM.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const HISTORY_PARAM = {
SHOW_VALIDATE_CODE_ACTION_MODAL: 'showValidateActionModal',
} as const;

export default HISTORY_PARAM;
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type {NavigatorScreenParams} from '@react-navigation/native';
import HISTORY_PARAM from '@libs/Navigation/linkingConfig/HISTORY_PARAM';
import type {RootNavigatorParamList} from '@libs/Navigation/types';
import type {Screen} from '@src/SCREENS';
import SCREENS from '@src/SCREENS';

type DeepPairsOf<ParamList> = DeepPairsForScreen<ParamList, keyof ParamList>;

type DeepPairsForScreen<ParamList, Key> = Key extends keyof ParamList ? (ParamList[Key] extends NavigatorScreenParams<infer T> ? DeepPairsOf<T> : [Key, keyof ParamList[Key]]) : never;

type DeepPairsOfRoot = DeepPairsOf<RootNavigatorParamList>;

type ScreenToHistoryParamMap = Partial<{
[TScreen in Screen]: Extract<DeepPairsOfRoot, [TScreen, unknown]>[1];
}>;

// This file maps screens to their history parameters
const SCREEN_TO_HISTORY_PARAM: ScreenToHistoryParamMap = {
[SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM]: HISTORY_PARAM.SHOW_VALIDATE_CODE_ACTION_MODAL,
};

export default SCREEN_TO_HISTORY_PARAM;
3 changes: 3 additions & 0 deletions src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import NAVIGATORS from '@src/NAVIGATORS';
import ROUTES from '@src/ROUTES';
import type {Screen} from '@src/SCREENS';
import SCREENS from '@src/SCREENS';
import getHistoryParamParse from './helpers/getHistoryParamParse';
import HISTORY_PARAM from './HISTORY_PARAM';

// Moved to a separate file to avoid cyclic dependencies.
const config: LinkingOptions<RootNavigatorParamList>['config'] = {
Expand Down Expand Up @@ -328,6 +330,7 @@ const config: LinkingOptions<RootNavigatorParamList>['config'] = {
path: ROUTES.SETTINGS_DELEGATE_CONFIRM.route,
parse: {
login: (login: string) => decodeURIComponent(login),
...getHistoryParamParse(HISTORY_PARAM.SHOW_VALIDATE_CODE_ACTION_MODAL),
},
},
[SCREENS.SETTINGS.PROFILE.STATUS]: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const getHistoryParamParse = (historyParamName: string) => ({
[historyParamName]: (value: string) => value === 'true',
});

export default getHistoryParamParse;
1 change: 1 addition & 0 deletions src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2137,5 +2137,6 @@ export type {
TestDriveModalNavigatorParamList,
WorkspaceScreenName,
TestDriveDemoNavigatorParamList,
SetParamsAction,
WorkspacesTabNavigatorName,
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useEffect, useState} from 'react';
import React, {useState} from 'react';
import type {ValueOf} from 'type-fest';
import Button from '@components/Button';
import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper';
Expand All @@ -13,6 +13,7 @@ import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import {formatPhoneNumber} from '@libs/LocalePhoneNumber';
import {useCustomHistoryParam} from '@libs/Navigation/AppNavigator/customHistory';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
Expand All @@ -24,21 +25,17 @@ import DelegateMagicCodeModal from './DelegateMagicCodeModal';

type ConfirmDelegatePageProps = PlatformStackScreenProps<SettingsNavigatorParamList, typeof SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM>;

function ConfirmDelegatePage({route, navigation}: ConfirmDelegatePageProps) {
function ConfirmDelegatePage({route}: ConfirmDelegatePageProps) {
const {translate} = useLocalize();

const styles = useThemeStyles();
const login = route.params.login;
const role = route.params.role as ValueOf<typeof CONST.DELEGATE_ROLE>;
const showValidateActionModal = route.params.showValidateActionModal === 'true';
const {isOffline} = useNetwork();
const [shouldDisableModalAnimation, setShouldDisableModalAnimation] = useState(true);
const [shouldShowLoading, setShouldShowLoading] = useState(showValidateActionModal ?? false);

const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(showValidateActionModal ?? false);
useEffect(() => {
navigation.setParams({showValidateActionModal: String(isValidateCodeActionModalVisible)});
}, [isValidateCodeActionModalVisible, navigation]);
const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useCustomHistoryParam();
const [shouldShowLoading, setShouldShowLoading] = useState(isValidateCodeActionModalVisible ?? false);

const personalDetails = getPersonalDetailByEmail(login);
const avatarIcon = personalDetails?.avatar ?? FallbackAvatar;
Expand Down Expand Up @@ -93,7 +90,6 @@ function ConfirmDelegatePage({route, navigation}: ConfirmDelegatePageProps) {
// We should disable the animation initially and only enable it when the user manually opens the modal
// to ensure it appears immediately when refreshing the page.
disableAnimation={shouldDisableModalAnimation}
shouldHandleNavigationBack
login={login}
role={role}
onClose={() => {
Expand Down