Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
227bc35
feat: use sub page
MrMuzyk Dec 10, 2025
2df0c09
Merge branch 'main' of https://github.com/Expensify/App into feat/use…
MrMuzyk Dec 11, 2025
c6a0497
feat: docs and small corrections
MrMuzyk Dec 11, 2025
73a6d2d
Merge branch 'main' of https://github.com/Expensify/App into feat/use…
MrMuzyk Jan 9, 2026
64bce52
fix: automated checks and guide
MrMuzyk Jan 9, 2026
025037d
Merge branch 'main' of https://github.com/Expensify/App into feat/use…
MrMuzyk Jan 15, 2026
2f81035
unit tests and doc corrections
MrMuzyk Jan 16, 2026
9dc04e0
Merge branch 'main' of https://github.com/Expensify/App into feat/use…
MrMuzyk Jan 16, 2026
8b1eee9
fix: continue where you left off
MrMuzyk Jan 19, 2026
3847cf7
fix: linter
MrMuzyk Jan 19, 2026
f91eef1
feat: new header component
MrMuzyk Jan 19, 2026
d9656e6
feat: back animation
MrMuzyk Jan 19, 2026
a3da80e
Merge branch 'main' of https://github.com/Expensify/App into feat/use…
MrMuzyk Jan 20, 2026
a69ad82
feat: updated docs, tests and last fixes
MrMuzyk Jan 20, 2026
ed2495d
Merge branch 'main' of https://github.com/Expensify/App into feat/use…
MrMuzyk Jan 20, 2026
328c262
fix: cspell
MrMuzyk Jan 20, 2026
ed370ba
Merge branch 'main' of https://github.com/Expensify/App into feat/use…
MrMuzyk Jan 20, 2026
06ef42f
fix: icon import
MrMuzyk Jan 20, 2026
2eb8ced
Merge branch 'main' of https://github.com/Expensify/App into feat/use…
MrMuzyk Jan 22, 2026
ec4e8ef
fix: cr fixes
MrMuzyk Jan 22, 2026
831cbd8
fix: Small adjustment in guide
MrMuzyk Jan 23, 2026
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
229 changes: 228 additions & 1 deletion contributingGuides/NAVIGATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ The navigation in the app is built on top of the `react-navigation` library. To
- [Dismissing modals with opening a report](#dismissing-modals-with-opening-a-report)
- [Summary](#summary)
- [Adding new screens](#adding-new-screens)
- [Multi-step flows with URL synchronization](#multi-step-flows-with-url-synchronization)
- [When to use](#when-to-use)
- [Implementation pattern](#implementation-pattern)
- [Debugging](#debugging)
- [Reading state when it changes](#reading-state-when-it-changes)
- [Finding the code that calls the navigation function](#finding-the-code-that-calls-the-navigation-function)
Expand Down Expand Up @@ -396,7 +399,7 @@ export default NewSettingsScreen;
});
```

Let's assume that we want to have PreferencesPage below our new Settings RHP screen.
Let's assume that we want to have PreferencesPage below our new Settings RHP screen.

```ts
// src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts
Expand All @@ -415,6 +418,230 @@ export default NewSettingsScreen;
export default SETTINGS_TO_RHP;
```

## Multi-step flows with URL synchronization

Multi-step flows (wizards, forms with multiple screens) should use URL-based navigation via the `useSubPage` hook or via basic navigation between plain static routes. This approach ensures browser navigation works correctly and page refreshes preserve the current position.

### When to use

You can use `useSubPage` hook for any multi-step flow where:
- Users progress through a series of screens to complete a task
- Each step collects or displays different information
- The flow has a final confirmation or summary step
- You need proper browser back/forward button support
- Page refresh should preserve the user's current position

Common examples include:
- Account setup wizards
- Form flows with multiple sections
- Settings configuration flows

### Implementation pattern

#### 1. Define your routes

Add routes with a `subPage` parameter to `ROUTES.ts`. The `action` parameter is used for edit mode:

```ts
MY_FLOW: {
route: 'my-flow/:subPage?/:action?',
getRoute: (subPage?: string, action?: 'edit') => {
if (!subPage) {
return 'my-flow' as const;
}
return `my-flow/${subPage}${action ? `/${action}` : ''}` as const;
},
},
```

#### 2. Add navigation config

Register the screen in `linkingConfig/config.ts`:

```ts
[SCREENS.MY_FLOW]: {
path: ROUTES.MY_FLOW.route,
exact: true,
},
```

#### 3. Define page constants

Add page name constants to `CONST.ts`:

```ts
MY_FLOW: {
STEP_INDEX_LIST: ['1', '2', '3'],
PAGE_NAME: {
STEP_ONE: 'step-one',
STEP_TWO: 'step-two',
CONFIRMATION: 'confirmation',
},
},
```

#### 4. Create sub-page components

Each sub-page receives `SubPageProps` and any custom props you define:

```ts
import type {SubPageProps} from '@hooks/useSubPage/types';

type CustomSubPageProps = SubPageProps & {
// Add custom props specific to your flow
formValues: MyFormType;
};

function StepOne({isEditing, onNext, onMove, formValues}: CustomSubPageProps) {
const handleSubmit = (data: FormData) => {
saveData(data);
onNext();
};

return (
<FormProvider onSubmit={handleSubmit}>
{/* Form fields */}
</FormProvider>
);
}
```

#### 5. Implement the main flow component

```ts
import useSubPage from '@hooks/useSubPage';
import type {SubPageProps} from '@hooks/useSubPage/types';
import InteractiveStepSubPageHeader from '@components/InteractiveStepSubPageHeader';

type CustomSubPageProps = SubPageProps & {
formValues: MyFormType;
};

const pages = [
{pageName: CONST.MY_FLOW.PAGE_NAME.STEP_ONE, component: StepOne},
{pageName: CONST.MY_FLOW.PAGE_NAME.STEP_TWO, component: StepTwo},
{pageName: CONST.MY_FLOW.PAGE_NAME.CONFIRMATION, component: Confirmation},
];

function MyFlowContent() {
const {
CurrentPage,
isEditing,
pageIndex,
prevPage,
nextPage,
lastPageIndex,
moveTo,
resetToPage,
} = useSubPage<CustomSubPageProps>({
pages,
startFrom: 0,
onFinished: () => {
// Handle flow completion
Navigation.goBack();
},
buildRoute: (pageName, action) => ROUTES.MY_FLOW.getRoute(pageName, action),
});

const handleBackButtonPress = () => {
if (isEditing) {
Navigation.goBack();
return;
}

if (pageIndex === 0) {
Navigation.closeRHPFlow();
return;
}

prevPage();
};

return (
<ScreenWrapper>
<HeaderWithBackButton
title={translate('myFlow.title')}
onBackButtonPress={handleBackButtonPress}
/>
<View style={[styles.ph5, styles.mb3, styles.mt3, {height: CONST.NETSUITE_FORM_STEPS_HEADER_HEIGHT}]}>
<InteractiveStepSubPageHeader
stepNames={CONST.MY_FLOW.STEP_INDEX_LIST}
currentStepIndex={pageIndex}
onStepSelected={moveTo}
/>
</View>
<CurrentPage
isEditing={isEditing}
onNext={nextPage}
onMove={moveTo}
formValues={formValues}
/>
</ScreenWrapper>
);
}
```

### Using InteractiveStepSubPageHeader

The `InteractiveStepSubPageHeader` component is designed to work with the `useSubPage` hook for URL-based multi-step flows.

Key features:
- Displays numbered step indicators with connecting lines
- Shows completed steps with a checkmark icon
- Allows users to tap on completed steps to navigate back (entering edit mode)
- Locked (future) steps are disabled
- Automatically syncs with the current page index from the URL

```ts
<InteractiveStepSubPageHeader
stepNames={CONST.MY_FLOW.STEP_INDEX_LIST}
currentStepIndex={pageIndex}
onStepSelected={moveTo}
/>
```

> **Note**: The `stepNames` array determines the number of steps displayed. The `currentStepIndex` is 0-based. When a user taps a completed step, `onStepSelected` is called with that step's index, which triggers edit mode navigation.

#### 6. Handle edit mode

The hook automatically manages edit mode via the `action=edit` URL parameter. When users navigate to a previous step using `moveTo()`, they enter edit mode. In edit mode, calling `nextPage()` returns them to the last page (typically confirmation) instead of advancing sequentially:

```ts
// In Confirmation component - allow editing previous steps
function Confirmation({onMove, formValues}: CustomSubPageProps) {
return (
<View>
<MenuItem
title={formValues.name}
description={translate('myFlow.name')}
onPress={() => onMove(0)}
/>
{/* More review items */}
</View>
);
}
```

#### 7. Skip pages conditionally

Use `skipPages` to conditionally skip steps based on user input or feature flags:

```ts
const skipPages = useMemo(() => {
const pagesToSkip: string[] = [];
if (!requiresAdditionalInfo) {
pagesToSkip.push(CONST.MY_FLOW.PAGE_NAME.STEP_TWO);
}
return pagesToSkip;
}, [requiresAdditionalInfo]);

const {...} = useSubPage<CustomSubPageProps>({
pages,
skipPages,
// ...other props
});
```

## Debugging

### Reading state when it changes
Expand Down
10 changes: 10 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2684,6 +2684,16 @@ const CONST = {
VENDOR_BILL: 'VENDOR_BILL',
},

MISSING_PERSONAL_DETAILS: {
STEP_INDEX_LIST: ['1', '2', '3', '4'],
PAGE_NAME: {
LEGAL_NAME: 'legal-name',
DATE_OF_BIRTH: 'date-of-birth',
ADDRESS: 'address',
PHONE_NUMBER: 'phone-number',
CONFIRM: 'confirm',
},
},
MISSING_PERSONAL_DETAILS_INDEXES: {
MAPPING: {
LEGAL_NAME: 0,
Expand Down
10 changes: 9 additions & 1 deletion src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3153,7 +3153,15 @@ const ROUTES = {
route: 'restricted-action/workspace/:policyID',
getRoute: (policyID: string) => `restricted-action/workspace/${policyID}` as const,
},
MISSING_PERSONAL_DETAILS: 'missing-personal-details',
MISSING_PERSONAL_DETAILS: {
route: 'missing-personal-details/:subPage?/:action?',
getRoute: (subPage?: string, action?: 'edit') => {
if (!subPage) {
return 'missing-personal-details' as const;
}
return `missing-personal-details/${subPage}${action ? `/${action}` : ''}` as const;
},
},
MISSING_PERSONAL_DETAILS_CONFIRM_MAGIC_CODE: 'missing-personal-details/confirm-magic-code',
POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR: {
route: 'workspaces/:policyID/accounting/netsuite/subsidiary-selector',
Expand Down
2 changes: 1 addition & 1 deletion src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,7 +860,7 @@ const SCREENS = {
TRANSACTION_RECEIPT: 'TransactionReceipt',
FEATURE_TRAINING_ROOT: 'FeatureTraining_Root',
RESTRICTED_ACTION_ROOT: 'RestrictedAction_Root',
MISSING_PERSONAL_DETAILS_ROOT: 'MissingPersonalDetails_Root',
MISSING_PERSONAL_DETAILS: 'MissingPersonalDetails',
MISSING_PERSONAL_DETAILS_CONFIRM_MAGIC_CODE: 'MissingPersonalDetails_ConfirmMagicCode',
ADD_UNREPORTED_EXPENSES_ROOT: 'AddUnreportedExpenses_Root',
DEBUG: {
Expand Down
90 changes: 90 additions & 0 deletions src/components/InteractiveStepSubPageHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from 'react';
import type {ViewStyle} from 'react-native';
import {View} from 'react-native';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useThemeStyles from '@hooks/useThemeStyles';
import colors from '@styles/theme/colors';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import Icon from './Icon';
import PressableWithFeedback from './Pressable/PressableWithFeedback';
import Text from './Text';

type InteractiveStepSubPageHeaderProps = {
/** List of step names to display */
stepNames: readonly string[];

/** Current step index (0-based) */
currentStepIndex: number;

/** Function to call when a step is selected */
onStepSelected?: (stepIndex: number) => void;
};

const MIN_AMOUNT_FOR_EXPANDING = 3;
const MIN_AMOUNT_OF_STEPS = 2;

function InteractiveStepSubPageHeader({stepNames, currentStepIndex, onStepSelected}: InteractiveStepSubPageHeaderProps) {
const styles = useThemeStyles();
const icons = useMemoizedLazyExpensifyIcons(['Checkmark']);
const containerWidthStyle: ViewStyle = stepNames.length < MIN_AMOUNT_FOR_EXPANDING ? styles.mnw60 : styles.mnw100;

if (stepNames.length < MIN_AMOUNT_OF_STEPS) {
throw new Error(`stepNames list must have at least ${MIN_AMOUNT_OF_STEPS} elements.`);
}

const lastStepIndex = stepNames.length - 1;

const handleStepPress = (isLockedStep: boolean, index: number) => {
if (isLockedStep || !onStepSelected) {
return;
}
onStepSelected(index);
};

return (
<View style={[styles.interactiveStepHeaderContainer, containerWidthStyle]}>
{stepNames.map((stepName, index) => {
const isCompletedStep = currentStepIndex > index;
const isLockedStep = currentStepIndex < index;
const isLockedLine = currentStepIndex < index + 1;
const hasConnectingLine = index < lastStepIndex;

return (
<View
style={[styles.interactiveStepHeaderStepContainer, hasConnectingLine && styles.flex1]}
Comment thread
arosiclair marked this conversation as resolved.
key={stepName}
>
<PressableWithFeedback
style={[
styles.interactiveStepHeaderStepButton,
Comment thread
arosiclair marked this conversation as resolved.
isLockedStep && styles.interactiveStepHeaderLockedStepButton,
isCompletedStep && styles.interactiveStepHeaderCompletedStepButton,
!onStepSelected && styles.cursorDefault,
]}
disabled={isLockedStep || !onStepSelected}
onPress={() => handleStepPress(isLockedStep, index)}
accessible
accessibilityLabel={stepName}
role={CONST.ROLE.BUTTON}
>
{isCompletedStep ? (
<Icon
src={icons.Checkmark}
width={variables.iconSizeNormal}
height={variables.iconSizeNormal}
fill={colors.white}
/>
) : (
<Text style={[styles.interactiveStepHeaderStepText, isLockedStep && styles.textSupporting]}>{index + 1}</Text>
Comment thread
arosiclair marked this conversation as resolved.
)}
</PressableWithFeedback>
{hasConnectingLine ? <View style={[styles.interactiveStepHeaderStepLine, isLockedLine && styles.interactiveStepHeaderLockedStepLine]} /> : null}
Comment thread
arosiclair marked this conversation as resolved.
</View>
);
})}
</View>
);
}

export default InteractiveStepSubPageHeader;
2 changes: 1 addition & 1 deletion src/components/ReportActionItem/IssueCardMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function IssueCardMessage({action, policyID}: IssueCardMessageProps) {
<RenderHTML html={`<muted-text>${getCardIssuedMessage({reportAction: action, shouldRenderHTML: true, policyID, expensifyCard, companyCard, translate})}</muted-text>`} />
{shouldShowAddMissingDetailsButton && (
<Button
onPress={() => Navigation.navigate(ROUTES.MISSING_PERSONAL_DETAILS)}
onPress={() => Navigation.navigate(ROUTES.MISSING_PERSONAL_DETAILS.getRoute())}
success
style={[styles.alignSelfStart, styles.mt3]}
text={translate('workspace.expensifyCard.addShippingDetails')}
Expand Down
Loading
Loading