diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index 2fd461eb2060..35b18e8b173b 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -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) @@ -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 @@ -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 ( + + {/* Form fields */} + + ); +} +``` + +#### 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({ + 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 ( + + + + + + + + ); +} +``` + +### 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 + +``` + +> **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 ( + + onMove(0)} + /> + {/* More review items */} + + ); +} +``` + +#### 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({ + pages, + skipPages, + // ...other props +}); +``` + ## Debugging ### Reading state when it changes diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2b6e2f223bae..bdba0e164ebb 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -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, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 50d6749e940f..f9fc0d98ab80 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -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', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 739c5ac47279..f7f882f4e73a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -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: { diff --git a/src/components/InteractiveStepSubPageHeader.tsx b/src/components/InteractiveStepSubPageHeader.tsx new file mode 100644 index 000000000000..66b5762490c6 --- /dev/null +++ b/src/components/InteractiveStepSubPageHeader.tsx @@ -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 ( + + {stepNames.map((stepName, index) => { + const isCompletedStep = currentStepIndex > index; + const isLockedStep = currentStepIndex < index; + const isLockedLine = currentStepIndex < index + 1; + const hasConnectingLine = index < lastStepIndex; + + return ( + + handleStepPress(isLockedStep, index)} + accessible + accessibilityLabel={stepName} + role={CONST.ROLE.BUTTON} + > + {isCompletedStep ? ( + + ) : ( + {index + 1} + )} + + {hasConnectingLine ? : null} + + ); + })} + + ); +} + +export default InteractiveStepSubPageHeader; diff --git a/src/components/ReportActionItem/IssueCardMessage.tsx b/src/components/ReportActionItem/IssueCardMessage.tsx index 215038f2d9c3..9adb3619602b 100644 --- a/src/components/ReportActionItem/IssueCardMessage.tsx +++ b/src/components/ReportActionItem/IssueCardMessage.tsx @@ -45,7 +45,7 @@ function IssueCardMessage({action, policyID}: IssueCardMessageProps) { ${getCardIssuedMessage({reportAction: action, shouldRenderHTML: true, policyID, expensifyCard, companyCard, translate})}`} /> {shouldShowAddMissingDetailsButton && (