diff --git a/.claude/skills/agent-device/flows/macros/android/complete-onboarding.ad b/.claude/skills/agent-device/flows/macros/android/complete-onboarding.ad index c2b7b29ae6c5..39d5e35c849b 100644 --- a/.claude/skills/agent-device/flows/macros/android/complete-onboarding.ad +++ b/.claude/skills/agent-device/flows/macros/android/complete-onboarding.ad @@ -1,13 +1,14 @@ context platform=android # @desc Complete onboarding with minimal choices (skip work email, pick "Something else" purpose, enter generic name). Lands on Home. # @pre text="What’s your work email?" -# @post text="Home" # @post role="button" label="Search" # @param FIRST_NAME First name to enter on onboarding profile step. # @param LAST_NAME Last name to enter on onboarding profile step. press "id=\"onboardingPrivateEmailSkipButton\" || role=\"button\" label=\"Skip\" || label=\"Skip\"" +wait "label=\"Something else\"" press "role=\"button\" label=\"Something else\" || label=\"Something else\"" +wait "label=\"First name\" editable=true" fill "role=\"textfield\" label=\"First name\" editable=true || label=\"First name\" editable=true" "${FIRST_NAME}" fill "role=\"textfield\" label=\"Last name\" editable=true || label=\"Last name\" editable=true" "${LAST_NAME}" press "role=\"button\" label=\"Continue\" || label=\"Continue\"" diff --git a/.claude/skills/agent-device/flows/macros/android/sign-in.ad b/.claude/skills/agent-device/flows/macros/android/sign-in.ad index b228982abc7e..157af9ff27a2 100644 --- a/.claude/skills/agent-device/flows/macros/android/sign-in.ad +++ b/.claude/skills/agent-device/flows/macros/android/sign-in.ad @@ -5,5 +5,6 @@ context platform=android # @post role="button" label="Join" || role="button" label="Search" # @param EMAIL Login email. Use randomized alias format `agent-device-testing+<9digits>@gmail.com` to avoid account flagging. -fill "id=\"username\" || label=\"Phone or email\" editable=true" "${EMAIL}" +press "id=\"username\" || role=\"edittext\" label=\"Phone or email\" editable=true || label=\"Phone or email\" editable=true" +fill "id=\"username\" || role=\"edittext\" label=\"Phone or email\" editable=true || label=\"Phone or email\" editable=true" "${EMAIL}" press "role=\"button\" label=\"Continue\" || label=\"Continue\"" diff --git a/.claude/skills/agent-device/flows/macros/web/complete-onboarding.ad b/.claude/skills/agent-device/flows/macros/web/complete-onboarding.ad index f84ff00572b7..227bcd65cabd 100644 --- a/.claude/skills/agent-device/flows/macros/web/complete-onboarding.ad +++ b/.claude/skills/agent-device/flows/macros/web/complete-onboarding.ad @@ -1,7 +1,7 @@ context platform=web # @desc Complete onboarding with minimal choices (skip work email, pick "Something else" purpose, enter generic name). Lands on Home. # @pre text="What’s your work email?" -# @post text="Home" +# @post role="heading" label="Home" # @post role="button" label="Search" # @param FIRST_NAME First name to enter on onboarding profile step. # @param LAST_NAME Last name to enter on onboarding profile step. diff --git a/.claude/skills/agent-device/flows/macros/web/sign-in.ad b/.claude/skills/agent-device/flows/macros/web/sign-in.ad index 93b9cd4b0499..6e55713f8df7 100644 --- a/.claude/skills/agent-device/flows/macros/web/sign-in.ad +++ b/.claude/skills/agent-device/flows/macros/web/sign-in.ad @@ -2,7 +2,7 @@ context platform=web # @desc Sign in with the shared agent-device test account. Supports both new-account and returning-account outcomes. Caller MUST randomize EMAIL via `-e EMAIL=agent-device-testing+<9digits>@gmail.com` to avoid account flagging. # @pre role="textbox" label="Phone or email" # @pre role="button" label="Continue" -# @post text="Welcome!" || text="Home" +# @post text="Welcome!" || role="heading" label="Home" # @post role="button" label="Join" || role="button" label="Search" # @param EMAIL Login email. Use randomized alias format `agent-device-testing+<9digits>@gmail.com` to avoid account flagging. diff --git a/.claude/skills/coding-standards/rules/perf-18-use-pre-mount-destination.md b/.claude/skills/coding-standards/rules/perf-18-use-pre-mount-destination.md index 4f67ad417e8e..b33d34519d79 100644 --- a/.claude/skills/coding-standards/rules/perf-18-use-pre-mount-destination.md +++ b/.claude/skills/coding-standards/rules/perf-18-use-pre-mount-destination.md @@ -51,10 +51,22 @@ const handleSubmit = () => { ### Hook API +**Layout strategies:** + +- `PRE_INSERT` (narrow layout only): eagerly pre-mounts the destination behind the RHP after the open transition, at idle priority. +- `REVEAL`: skips eager pre-mount; the destination is inserted and revealed together when `reveal()` runs. This fallback is used: + - on wide layout always + - on narrow layout if pre-insert hasn't finished yet + **Reveal methods:** -- `reveal(afterTransition?)`: if the hook owns a pre-inserted narrow route, clears the pre-insert flag and dismisses the RHP over that route. Otherwise, inserts the destination under the RHP and then dismisses it. -- `cleanupPreMount()`: removes the owned pre-inserted destination before a back-out path closes the RHP without revealing the destination. +- `reveal(afterTransition?)`: if the hook owns a pre-inserted route, clears the pre-insert flag and dismisses the RHP over that route. Otherwise, inserts the destination under the RHP then dismisses it. +- `cleanupPreMount()`: removes the owned pre-inserted destination before a back-out path closes the RHP without revealing the destination. Safe to call unconditionally - no-ops if this instance never pre-inserted anything. + +**Other invariants:** + +- Only one component may own a pre-inserted route at a time. `reveal()` logs an alert if the global pre-insert flag is set by a different flow when it runs - a sign the previous owner didn't clean up. +- When the destination resolves to one of the app's root tabs (Home, Inbox, Search, Settings, or Workspaces), pre-insert switches to that tab instead of pushing (`[Tab(A), RHP] -> [Tab(B), RHP]`), with the original tab saved for restore-on-cancel. For any other destination, it pushes a new route between the origin and the RHP (`[origin, RHP] -> [origin, destination, RHP]`). Determined by the destination route, not caller-configured. **Caller responsibilities:** @@ -89,11 +101,15 @@ Use `usePreMountDestination` when **all** of these are true: ### When NOT to use +Pre-inserting is a real second screen mounted concurrently. Its effects run whether or not the user ever reveals it. If they back out, `cleanupPreMount()` removes the route but not the work that mount already triggered: + - Destination is not known in advance - There is no modal/RHP to dismiss - The destination is already the screen behind the modal -- The transition is already fast enough. Profile first, do not add complexity speculatively -- Flow-specific dismiss strategies that do not use pre-insert/reveal. Keep those helpers +- The destination is heavy or rarely actually reached from this dismiss path. In these cases, pre-inserting on every open pays the concurrent-mount cost more often than it pays off +- The transition is already fast enough on its own +- The dismiss is not RHP-to-fullscreen with a destination known at mount time. Helpers like `dismissModalWithReport` and the strategies in `submitDismissStrategies.ts` cover shapes the hook does not model - RHP-to-RHP transitions, and destinations resolved at dismiss time - so they are not call sites waiting to be migrated +- The caller is reaching for `shouldPreservePreInsertedRouteOnUnmount` to sidestep cleanup ordering. It exists only for the case where a genuinely different component finishes the dismiss after this one unmounts - anywhere else it leaves a pre-inserted route with no owner left to clean it up ### Review Metadata @@ -104,6 +120,7 @@ Flag when: - A caller relies on `reveal(afterTransition)` for work that must happen before navigation, such as validation, target-route selection, or a synchronous write needed before the destination is revealed - A back-out path closes the RHP without calling `cleanupPreMount()` when the component owns a pre-inserted route - A submit path unmounts the component before `reveal()` runs but does not preserve the pre-inserted route with `shouldPreservePreInsertedRouteOnUnmount` +- `shouldPreservePreInsertedRouteOnUnmount` is passed without a clear reason a *different* component finishes the dismiss - if nothing else picks up the pre-insert, this just delays cleanup rather than serving its purpose - New code reimplements pre-insert timing, back-out cleanup, or reveal-before-dismiss orchestration inline instead of using the hook, even if it avoids a direct `preInsertFullscreenUnderRHP` call **DO NOT flag if:** diff --git a/.github/workflows/androidBump.yml b/.github/workflows/androidBump.yml index e9e2972ecc61..39aedc819b4b 100644 --- a/.github/workflows/androidBump.yml +++ b/.github/workflows/androidBump.yml @@ -10,7 +10,7 @@ jobs: android_bump: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/authorChecklist.yml b/.github/workflows/authorChecklist.yml index db53ec0b7cca..2603542b47f8 100644 --- a/.github/workflows/authorChecklist.yml +++ b/.github/workflows/authorChecklist.yml @@ -19,7 +19,7 @@ jobs: && github.actor != 'MelvinBot' steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Check contributor authorization id: gate diff --git a/.github/workflows/buildAdHoc.yml b/.github/workflows/buildAdHoc.yml index d9303fcc4795..f1deafa80af9 100644 --- a/.github/workflows/buildAdHoc.yml +++ b/.github/workflows/buildAdHoc.yml @@ -141,7 +141,7 @@ jobs: needs: [buildWeb, deployWebAdHoc, buildAndroid, buildIOS] steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.APP_REF }} @@ -181,7 +181,7 @@ jobs: needs: [buildWeb, deployWebAdHoc, buildAndroid, buildIOS] steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.APP_REF }} diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml index 93455cbe92fd..684cdb64a5d4 100644 --- a/.github/workflows/buildAndroid.yml +++ b/.github/workflows/buildAndroid.yml @@ -63,7 +63,7 @@ jobs: PROGUARD_MAPPING_FILENAME: mapping.txt steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: submodules: true ref: ${{ inputs.ref }} diff --git a/.github/workflows/buildVictoryChartRenderer.yml b/.github/workflows/buildVictoryChartRenderer.yml index 3f92f7b36f9e..868e8988d17b 100644 --- a/.github/workflows/buildVictoryChartRenderer.yml +++ b/.github/workflows/buildVictoryChartRenderer.yml @@ -25,7 +25,7 @@ jobs: BINARY_FILENAME: victory-chart-renderer-linux-x64 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.ref }} diff --git a/.github/workflows/buildWeb.yml b/.github/workflows/buildWeb.yml index 33bd4f3b64c5..4506e2f6bec0 100644 --- a/.github/workflows/buildWeb.yml +++ b/.github/workflows/buildWeb.yml @@ -39,7 +39,7 @@ jobs: PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }} steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.ref }} diff --git a/.github/workflows/bunTests.yml b/.github/workflows/bunTests.yml index 4d887f17ccd0..d32246c0c73d 100644 --- a/.github/workflows/bunTests.yml +++ b/.github/workflows/bunTests.yml @@ -34,7 +34,7 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/checkSVGCompression.yml b/.github/workflows/checkSVGCompression.yml index 0b5d9519edae..baa9526fcc94 100644 --- a/.github/workflows/checkSVGCompression.yml +++ b/.github/workflows/checkSVGCompression.yml @@ -21,7 +21,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/checkValidateCodeTerminology.yml b/.github/workflows/checkValidateCodeTerminology.yml index 83f017813dbd..5835bb2f06da 100644 --- a/.github/workflows/checkValidateCodeTerminology.yml +++ b/.github/workflows/checkValidateCodeTerminology.yml @@ -11,7 +11,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Check validateCode terminology run: ./scripts/checkValidateCodeTerminology.sh diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml index 0ecba2a0e892..098a2696e5af 100644 --- a/.github/workflows/cherryPick.yml +++ b/.github/workflows/cherryPick.yml @@ -101,7 +101,7 @@ jobs: run: echo "CONFLICT_BRANCH_NAME=cherry-pick-${{ inputs.TARGET }}-${{ steps.getPRInfo.outputs.PR_NUMBER }}-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" - name: Checkout target branch - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.TARGET }} token: ${{ secrets.OS_BOTIFY_TOKEN }} diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 6f6507f63788..9a822b6158a4 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -15,7 +15,7 @@ jobs: IS_AUTHORIZED: ${{ steps.gate.outputs.IS_AUTHORIZED }} steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Check contributor authorization id: gate diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 6e97e47af701..e3245f759eba 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -22,7 +22,7 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Check contributor authorization id: gate @@ -45,7 +45,7 @@ jobs: - name: Checkout repository if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true' - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: fetch-depth: 1 diff --git a/.github/workflows/createDeployChecklist.yml b/.github/workflows/createDeployChecklist.yml index 6f4e0d55f38c..96f833c516f1 100644 --- a/.github/workflows/createDeployChecklist.yml +++ b/.github/workflows/createDeployChecklist.yml @@ -14,7 +14,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.REF || github.sha }} diff --git a/.github/workflows/cspell.yml b/.github/workflows/cspell.yml index c193fcf12af1..67a9591f0176 100644 --- a/.github/workflows/cspell.yml +++ b/.github/workflows/cspell.yml @@ -9,7 +9,7 @@ jobs: spellcheck: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 38a6d507f108..c567befafc72 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,7 +37,7 @@ jobs: IOS_VERSION: ${{ steps.getIOSVersion.outputs.IOS_VERSION }} steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.ENVIRONMENT || github.sha }} token: ${{ secrets.OS_BOTIFY_TOKEN }} @@ -215,7 +215,7 @@ jobs: if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} @@ -269,7 +269,7 @@ jobs: if: ${{ always() && !cancelled() && needs.prep.outputs.DEPLOY_ENV == 'production' && needs.androidBuild.result != 'failure' && needs.androidUploadGooglePlay.result != 'failure' }} steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} @@ -705,7 +705,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} @@ -767,7 +767,7 @@ jobs: continue-on-error: true steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} @@ -811,7 +811,7 @@ jobs: ] steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Post Slack message on failure uses: ./.github/actions/composite/announceFailedWorkflowInSlack @@ -948,7 +948,7 @@ jobs: needs: [prep, checkDeploymentSuccess] steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode @@ -1123,7 +1123,7 @@ jobs: SENTRY_URL: ${{ steps.sentry-upload.outputs.SENTRY_URL }} steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Upload to Sentry for size analysis id: sentry-upload @@ -1152,7 +1152,7 @@ jobs: SENTRY_URL: ${{ steps.sentry-upload.outputs.SENTRY_URL }} steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Upload to Sentry for size analysis id: sentry-upload diff --git a/.github/workflows/deployBlocker.yml b/.github/workflows/deployBlocker.yml index bcec004a56b4..3de8b9c5f68a 100644 --- a/.github/workflows/deployBlocker.yml +++ b/.github/workflows/deployBlocker.yml @@ -16,7 +16,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Give the issue/PR the Hourly, Engineering labels run: gh issue edit ${{ github.event.issue.number }} --add-label 'Engineering,Hourly' --remove-label 'Daily,Weekly,Monthly' diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index ffaf22b59c2c..8979988505b0 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -33,7 +33,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: fetch-depth: 0 diff --git a/.github/workflows/failureNotifier.yml b/.github/workflows/failureNotifier.yml index 46f9902903da..0ebfc617a293 100644 --- a/.github/workflows/failureNotifier.yml +++ b/.github/workflows/failureNotifier.yml @@ -16,7 +16,7 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'failure' }} steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Process Failed Jobs uses: ./.github/actions/javascript/failureNotifier diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml index 29813be60d65..b7880d77c08b 100644 --- a/.github/workflows/finishReleaseCycle.yml +++ b/.github/workflows/finishReleaseCycle.yml @@ -13,7 +13,7 @@ jobs: isValid: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) && !fromJSON(steps.checkDeployBlockers.outputs.HAS_DEPLOY_BLOCKERS) && (contains(github.event.issue.labels.*.name, 'ForceProductionDeploy') || fromJSON(steps.verifyStagingBuilds.outputs.ALL_NATIVE_BUILDS_SUCCEEDED || 'false')) }} steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: main token: ${{ secrets.OS_BOTIFY_TOKEN }} diff --git a/.github/workflows/formatCodeCovComment.yml b/.github/workflows/formatCodeCovComment.yml index 34b8fa95665f..ee379d83a216 100644 --- a/.github/workflows/formatCodeCovComment.yml +++ b/.github/workflows/formatCodeCovComment.yml @@ -13,7 +13,7 @@ jobs: if: github.event.issue.pull_request && github.event.comment.user.login == 'codecov[bot]' steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Format CodeCov Comment uses: ./.github/actions/javascript/formatCodeCovComment diff --git a/.github/workflows/generateTranslations.yml b/.github/workflows/generateTranslations.yml index 06f79683b0de..b3109fc9f83e 100644 --- a/.github/workflows/generateTranslations.yml +++ b/.github/workflows/generateTranslations.yml @@ -42,7 +42,7 @@ jobs: GITHUB_TOKEN: ${{ github.token }} - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ steps.pr-data.outputs.HEAD_SHA }} diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml index 6d51d65b4686..92fe004e6cb9 100644 --- a/.github/workflows/knip.yml +++ b/.github/workflows/knip.yml @@ -27,7 +27,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout PR - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f664e686c845..72cbf457423c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,7 +36,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: # Only use the elevated OSBotify token on the post-merge `workflow_call` run # (so the auto-commit step below can push to the protected `main` branch). diff --git a/.github/workflows/lockDeploys.yml b/.github/workflows/lockDeploys.yml index 58e30ad59434..884b4e51f157 100644 --- a/.github/workflows/lockDeploys.yml +++ b/.github/workflows/lockDeploys.yml @@ -10,7 +10,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Wait for staging deploys to finish uses: ./.github/actions/javascript/awaitStagingDeploys diff --git a/.github/workflows/oxfmt.yml b/.github/workflows/oxfmt.yml index a050f8dbdaa7..93d5e4fbf335 100644 --- a/.github/workflows/oxfmt.yml +++ b/.github/workflows/oxfmt.yml @@ -18,7 +18,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/postDeployComments.yml b/.github/workflows/postDeployComments.yml index ec76d1a62175..6778eaedf38f 100644 --- a/.github/workflows/postDeployComments.yml +++ b/.github/workflows/postDeployComments.yml @@ -89,7 +89,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml index f61b057e0f0b..0c09e86b3098 100644 --- a/.github/workflows/preDeploy.yml +++ b/.github/workflows/preDeploy.yml @@ -30,7 +30,7 @@ jobs: if: ${{ always() }} steps: - - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Exit failed workflow if: ${{ needs.typecheck.result == 'failure' || needs.lint.result == 'failure' || needs.test.result == 'failure' || needs.bunTests.result == 'failure' }} @@ -46,7 +46,7 @@ jobs: SHOULD_DEPLOY: ${{ fromJSON(steps.shouldDeploy.outputs.SHOULD_DEPLOY) }} steps: - - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Get merged pull request id: getMergedPullRequest diff --git a/.github/workflows/proposalPolice.yml b/.github/workflows/proposalPolice.yml index a81b72c4049f..8992fc4fface 100644 --- a/.github/workflows/proposalPolice.yml +++ b/.github/workflows/proposalPolice.yml @@ -43,7 +43,7 @@ jobs: echo "TRUSTED=${TRUSTED}" >> "$GITHUB_OUTPUT" - - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 # Classifies new comments that don't follow the proposal template, detects duplicate proposals, # and grades edits to existing proposals. Action type logic can be found in the script files. diff --git a/.github/workflows/publishReactNativeAndroidArtifacts.yml b/.github/workflows/publishReactNativeAndroidArtifacts.yml index af3c9c82e290..8048abe07e4d 100644 --- a/.github/workflows/publishReactNativeAndroidArtifacts.yml +++ b/.github/workflows/publishReactNativeAndroidArtifacts.yml @@ -40,7 +40,7 @@ jobs: cancel-in-progress: true steps: - name: Checkout Code - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.app_ref }} submodules: ${{ matrix.is_hybrid }} diff --git a/.github/workflows/publishReactNativeArtifacts.yml b/.github/workflows/publishReactNativeArtifacts.yml index 2686feb91160..50edbcdb48d7 100644 --- a/.github/workflows/publishReactNativeArtifacts.yml +++ b/.github/workflows/publishReactNativeArtifacts.yml @@ -51,8 +51,8 @@ jobs: build_targets: ${{ steps.getArtifactBuildTargets.outputs.BUILD_TARGETS }} steps: - name: Checkout - # v1.6.0 - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 + # v1.7.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 with: submodules: true ref: ${{ github.event.before || 'main' }} diff --git a/.github/workflows/publishReactNativeiOSArtifacts.yml b/.github/workflows/publishReactNativeiOSArtifacts.yml index 0a32c5b6aebe..56aebc1d2322 100644 --- a/.github/workflows/publishReactNativeiOSArtifacts.yml +++ b/.github/workflows/publishReactNativeiOSArtifacts.yml @@ -190,7 +190,7 @@ jobs: cancel-in-progress: false steps: - name: Checkout Code - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ inputs.app_ref }} submodules: ${{ matrix.is_hybrid }} diff --git a/.github/workflows/react-compiler-compliance.yml b/.github/workflows/react-compiler-compliance.yml index b3d7ae302f68..46b1db684387 100644 --- a/.github/workflows/react-compiler-compliance.yml +++ b/.github/workflows/react-compiler-compliance.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/reassurePerformanceTests.yml b/.github/workflows/reassurePerformanceTests.yml index 0888a6c0eaae..422ffc782c75 100644 --- a/.github/workflows/reassurePerformanceTests.yml +++ b/.github/workflows/reassurePerformanceTests.yml @@ -18,7 +18,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout baseline branch - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Checkout baseline branch shell: bash @@ -80,7 +80,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup NodeJS uses: ./.github/actions/composite/setupNode @@ -137,7 +137,7 @@ jobs: needs: [baseline-perf-tests, branch-perf-tests] steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup NodeJS uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/remote-build-android.yml b/.github/workflows/remote-build-android.yml index 07e00602e59f..6bf7b31b06fa 100644 --- a/.github/workflows/remote-build-android.yml +++ b/.github/workflows/remote-build-android.yml @@ -57,7 +57,7 @@ jobs: is_hybrid_build: true steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ needs.resolveRefs.outputs.APP_REF }} submodules: ${{ matrix.is_hybrid_build || false }} diff --git a/.github/workflows/reviewerChecklist.yml b/.github/workflows/reviewerChecklist.yml index 4f07c450dcce..e85b8bfe5b49 100644 --- a/.github/workflows/reviewerChecklist.yml +++ b/.github/workflows/reviewerChecklist.yml @@ -9,7 +9,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' steps: - - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Filter paths uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 diff --git a/.github/workflows/seedCache.yml b/.github/workflows/seedCache.yml index 8b9ae9a57d8a..ae6a9472c29e 100644 --- a/.github/workflows/seedCache.yml +++ b/.github/workflows/seedCache.yml @@ -15,7 +15,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node (seed) uses: ./.github/actions/composite/setupNode @@ -27,7 +27,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: submodules: true token: ${{ secrets.OS_BOTIFY_TOKEN }} diff --git a/.github/workflows/seedJestPerfCache.yml b/.github/workflows/seedJestPerfCache.yml index f4b63706f26d..02f62bc64000 100644 --- a/.github/workflows/seedJestPerfCache.yml +++ b/.github/workflows/seedJestPerfCache.yml @@ -22,7 +22,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 # Writes normalized-package-lock.json, which the cache key hashes. - name: Setup NodeJS diff --git a/.github/workflows/shellCheck.yml b/.github/workflows/shellCheck.yml index 8be310351d8d..871e5ab42124 100644 --- a/.github/workflows/shellCheck.yml +++ b/.github/workflows/shellCheck.yml @@ -13,7 +13,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Lint shell scripts with ShellCheck run: npm run shellcheck diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f19cf4b1d5d..163a97c69349 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: name: test (job ${{ fromJSON(matrix.chunk) }}) steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode @@ -87,7 +87,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 name: Storybook tests steps: - - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/testBuildOnPush.yml b/.github/workflows/testBuildOnPush.yml index 328dff653d13..b7f3d20e3d0b 100644 --- a/.github/workflows/testBuildOnPush.yml +++ b/.github/workflows/testBuildOnPush.yml @@ -20,7 +20,7 @@ jobs: BUILD_MOBILE: ${{ steps.detectOSBotifyPush.outputs.BUILD_MOBILE || 'true' }} steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Validate that user is an Expensify employee uses: ./.github/actions/composite/validateActor diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index a4bc0de4cb28..dce010bd5bc2 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -16,7 +16,7 @@ jobs: if: ${{ github.actor != 'OSBotify' || github.event_name == 'workflow_call' }} runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/unused-styles.yml b/.github/workflows/unused-styles.yml index 26b44fb6a45e..03afa2affa95 100644 --- a/.github/workflows/unused-styles.yml +++ b/.github/workflows/unused-styles.yml @@ -18,7 +18,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/updateHelpDotRedirects.yml b/.github/workflows/updateHelpDotRedirects.yml index 76a1ecde942f..8026529e7abe 100644 --- a/.github/workflows/updateHelpDotRedirects.yml +++ b/.github/workflows/updateHelpDotRedirects.yml @@ -22,7 +22,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Create help dot redirect env: diff --git a/.github/workflows/updateProtectedBranch.yml b/.github/workflows/updateProtectedBranch.yml index caf0eb3b7dc0..869ef5152d2a 100644 --- a/.github/workflows/updateProtectedBranch.yml +++ b/.github/workflows/updateProtectedBranch.yml @@ -28,7 +28,7 @@ jobs: fi - name: Checkout source branch - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: ref: ${{ steps.getSourceBranch.outputs.SOURCE_BRANCH }} token: ${{ secrets.OS_BOTIFY_TOKEN }} diff --git a/.github/workflows/validateBuildRequest.yml b/.github/workflows/validateBuildRequest.yml index 4f43ffa56f8e..d212bf02f818 100644 --- a/.github/workflows/validateBuildRequest.yml +++ b/.github/workflows/validateBuildRequest.yml @@ -27,7 +27,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Validate that user is an Expensify employee if: ${{ github.event_name == 'workflow_dispatch' }} diff --git a/.github/workflows/validateContributorPR.yml b/.github/workflows/validateContributorPR.yml index 109ff07ac7c4..7b1866355a3d 100644 --- a/.github/workflows/validateContributorPR.yml +++ b/.github/workflows/validateContributorPR.yml @@ -18,7 +18,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Check contributor authorization id: gate diff --git a/.github/workflows/validateDocsRoutes.yml b/.github/workflows/validateDocsRoutes.yml index 0f4fc1772dd4..0e9ba202003d 100644 --- a/.github/workflows/validateDocsRoutes.yml +++ b/.github/workflows/validateDocsRoutes.yml @@ -11,7 +11,7 @@ jobs: if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/validateGithubActions.yml b/.github/workflows/validateGithubActions.yml index 4147a45ee628..4d4f9b5ef9fa 100644 --- a/.github/workflows/validateGithubActions.yml +++ b/.github/workflows/validateGithubActions.yml @@ -12,7 +12,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/validateMobileExpensifySubmodule.yml b/.github/workflows/validateMobileExpensifySubmodule.yml index 6b16ae9fb548..95ee53aef1fb 100644 --- a/.github/workflows/validateMobileExpensifySubmodule.yml +++ b/.github/workflows/validateMobileExpensifySubmodule.yml @@ -13,10 +13,10 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout App - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Checkout Mobile-Expensify - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: repository: Expensify/Mobile-Expensify path: .github/mobile-expensify-repo diff --git a/.github/workflows/validatePatches.yml b/.github/workflows/validatePatches.yml index 1390979a7abf..20f23630742f 100644 --- a/.github/workflows/validatePatches.yml +++ b/.github/workflows/validatePatches.yml @@ -11,7 +11,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Fetch main branch run: git fetch origin --depth=1 main diff --git a/.github/workflows/verifySignedCommits.yml b/.github/workflows/verifySignedCommits.yml index b5b963bf227b..6f6b8d10559d 100644 --- a/.github/workflows/verifySignedCommits.yml +++ b/.github/workflows/verifySignedCommits.yml @@ -9,7 +9,7 @@ jobs: verifySignedCommits: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Verify signed commits uses: ./.github/actions/javascript/verifySignedCommits diff --git a/.github/workflows/welcome.yml b/.github/workflows/welcome.yml index c0cd43749f74..be82bc3787c2 100644 --- a/.github/workflows/welcome.yml +++ b/.github/workflows/welcome.yml @@ -10,7 +10,7 @@ jobs: if: ${{ github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' }} steps: - name: Checkout - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0 + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - name: Get merged pull request id: getMergedPullRequest diff --git a/Mobile-Expensify b/Mobile-Expensify index 57708e6b576b..941700ccc01f 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 57708e6b576bfb38ca22dc596f0c4b47a22d6319 +Subproject commit 941700ccc01fccadfab499655c760661892168d6 diff --git a/android/app/build.gradle b/android/app/build.gradle index 8e9f05e06202..23fa0efc9ce8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009047400 - versionName "9.4.74-0" + versionCode 1009047500 + versionName "9.4.75-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 17c05c300f51..b0fb8fac8a7d 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1207,7 +1207,6 @@ "../../src/pages/workspace/accounting/xero/XeroTrackingCategoryConfigurationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/categories/CategorySettingsPage.tsx" "react-hooks/preserve-manual-memoization" 1 "../../src/pages/workspace/categories/WorkspaceCategoriesPage.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/companyCards/BankConnection/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1247,7 +1246,6 @@ "../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/taxes/WorkspaceTaxesPage.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 3 "../../src/pages/workspace/travel/WorkspaceTravelBillingSettlementAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/upgrade/UpgradeIntro.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx" "no-restricted-imports" 1 @@ -1257,7 +1255,6 @@ "../../src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 2 "../../src/pages/workspace/WorkspaceMembersPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/WorkspaceMembersPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index 661f4e211e87..9974de382c19 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -11,6 +11,8 @@ The navigation in the app is built on top of the `react-navigation` library. To - [Going back](#going-back) - [Dismissing modals](#dismissing-modals) - [Dismissing modals with opening a report](#dismissing-modals-with-opening-a-report) + - [Pre-mounting a destination behind an RHP](#pre-mounting-a-destination-behind-an-rhp) + - [What pre-inserting actually costs](#what-pre-inserting-actually-costs) - [Summary](#summary) - [Adding new screens](#adding-new-screens) - [Multi-step flows with URL synchronization](#multi-step-flows-with-url-synchronization) @@ -251,6 +253,59 @@ Navigation.dismissModalWithReport({ > 1. On a narrow screen, we do not want to perform two operations: closing the modal and opening the report. This would cause two actions to be displayed on the screen, which could be confusing for users. Instead of two operations, we perform a replace on the modal, thanks to which there is a smooth transition to the report with simultaneous closing of the modal. > 2. On a wide screen, we need to be sure that the modal has been closed before we want to navigate to the report. For this purpose, `navigate` is passed as the `afterTransition` callback to `dismissModal`, so it only runs once the dismiss transition has completed (tracked via `TransitionTracker`). +### Pre-mounting a destination behind an RHP + +When dismissing an RHP reveals a **different** fullscreen destination (not the screen already behind it - see [Dismissing modals with opening a report](#dismissing-modals-with-opening-a-report) for that case), the destination needs to be mounted before the dismissal reveals it. Otherwise there's a visible gap on narrow layout, or a flash of the previous page on wide layout, while React mounts the destination tree. + +`usePreMountDestination` (`src/hooks/usePreMountDestination`) centralizes this lifecycle. It has two layout-specific strategies: + +- **Narrow layout (default, `narrowDestinationStrategy: CONST.NARROW_DESTINATION_STRATEGY.PRE_INSERT`):** on mount, waits for the RHP's open transition, then pre-inserts the destination route underneath the RHP at idle priority (`preInsertFullscreenUnderRHP`). By the time the user dismisses, the destination is already mounted, so dismissal just reveals it. +- **Narrow layout with `narrowDestinationStrategy: CONST.NARROW_DESTINATION_STRATEGY.REVEAL`, or wide layout (always), or narrow layout where the pre-insert hasn't finished yet:** `reveal()` calls `Navigation.revealRouteBeforeDismissingModal` instead - it swaps in the destination and dismisses in one step, at reveal time rather than eagerly. Correctness is the same either way; only the narrow pre-insert path has the mount-ahead-of-time perf win. + +```tsx +const destinationRoute = buildDestinationRoute(itemID); +const {reveal, cleanupPreMount} = usePreMountDestination(destinationRoute); + +const handleSubmit = () => { + saveDataRequiredByDestination(); // synchronous work happens before reveal() + reveal(); +}; + +const handleBackOut = () => { + cleanupPreMount(); // safe to call unconditionally - no-ops if nothing was pre-inserted + Navigation.goBack(); +}; +``` + +See `IOURequestStepConfirmation.tsx` for a reference implementation. + +- `reveal(afterTransition?)`: dismisses the RHP over the pre-inserted destination if the hook owns one, otherwise falls back to `revealRouteBeforeDismissingModal` (see above). +- `cleanupPreMount()`: removes the owned pre-insert, if any. Call it unconditionally on every back-out path (header back, hardware back) that closes the RHP without calling `reveal()` - it's a no-op when this instance never actually pre-inserted anything. +- `shouldPreservePreInsertedRouteOnUnmount`: pass when the component unmounts before `reveal()` runs but the pre-insert should survive (e.g. the caller dismisses separately after a submit). + +> [!NOTE] +> Only one component may own a pre-inserted route at a time. `reveal()` logs an alert if it runs while a *different* flow's pre-insert flag is still set - that's a sign the previous owner didn't clean up. + +> [!NOTE] +> When the destination resolves to one of the app's root tabs (Home, Inbox, Search, Settings, or Workspaces), pre-insert switches to that tab instead of pushing (`[Tab(A), RHP] -> [Tab(B), RHP]`), with the original tab saved for restore-on-cancel. For any other destination, it pushes a new route between the origin and the RHP instead (`[origin, RHP] -> [origin, destination, RHP]`). Which one happens is determined by the destination route, not by anything the caller configures. + +> [!NOTE] +> See [PERF-18](../.claude/skills/coding-standards/rules/perf-18-use-pre-mount-destination.md) for the AI-review checklist covering this hook. + +#### What pre-inserting actually costs + +Pre-inserting is not a lightweight placeholder swap - it's a real second screen mounted in the stack, with its own Onyx connections, effects, and any API calls it fires on mount, running concurrently with the RHP that's still open. That mount happens whether or not the user ever reveals it: if they back out, `cleanupPreMount()` removes the *route*, but any data fetch the destination's mount already triggered already ran. For a screen with meaningful data-fetching behind it, that's wasted work on every back-out, not a free perf win. + +This is why the hook's own precondition matters and isn't just a checkbox: the destination has to be known at mount time, otherwise there's nothing correct to pre-insert. Dwell time is not a precondition - the pre-insert waits for the RHP's open transition and then schedules at idle priority, and if the user dismisses before that fires, the unmount cleanup cancels it and nothing was mounted. + +Native swipe-back can pop the RHP at the native layer before any JS cleanup runs, briefly flashing the pre-inserted destination. This is a known limitation without a deterministic fix yet. + +**Examples when NOT to use `usePreMountDestination`:** + +- **The destination is heavy and rarely reached from the dismiss path.** If most users dismiss rather than actually submitting, pre-inserting on every RHP open pays the concurrent-mount cost far more often than it pays off. Search logs to profile the production dismiss-to-reveal ratio before assuming pre-insert wins. +- **The dismiss isn't RHP-to-fullscreen with a destination known at mount time.** `dismissModalWithReport` and the strategies in `submitDismissStrategies.ts` handle shapes this hook doesn't model: RHP-to-RHP transitions (`dismissToPreviousRHP`), and destinations resolved at dismiss time (`dismissRHPToReport` branches on `report.transactionCount` when the user submits). Converging those on the hook would mean growing the hook to cover them, not migrating call sites. +- **You're tempted to use `shouldPreservePreInsertedRouteOnUnmount` as a default escape hatch.** It exists for one narrow case: a *different* caller finishes the dismiss after this component unmounts. Anywhere else it leaves a pre-inserted route with no owner left to clean it up, so the next flow's `reveal()` runs into an already-set pre-insert flag. + ### Summary - `Navigation.navigate` is used to navigate between screens. Remember that it calls the `linkTo` method implemented by us. It accepts the route as a parameter not a screen name. @@ -258,6 +313,7 @@ Navigation.dismissModalWithReport({ - If you want to go back to the screen regardless of its parameter values, pass `{compareParams: false}` to `Navigation.goBack`. - If you want to close the entire modal window, regardless of how many pages you have opened, use `Navigation.dismissModal` to do that. - If you want to open a report from RHP to prevent navigation back to this modal window, use `Navigation.dismissModalWithReport`. +- If dismissing an RHP reveals a different fullscreen destination, use `usePreMountDestination` to mount it ahead of time - but only when the destination is known at mount time; it's a real concurrent mount, not a free perf win, so profile before reaching for it on a heavy or rarely-reached destination. ## Adding new screens diff --git a/cspell.json b/cspell.json index 786c2758735a..107bc4d52591 100644 --- a/cspell.json +++ b/cspell.json @@ -568,6 +568,7 @@ "cpuprofile", "creditamount", "creditcards", + "creditedamount", "crios", "csvexport", "csvg", @@ -581,6 +582,7 @@ "dgst", "deapexer", "debitamount", + "debitedamount", "deburr", "deburred", "decidium", diff --git a/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md b/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md index 2226e6b24d74..b2d85427ed10 100644 --- a/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md +++ b/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md @@ -94,6 +94,10 @@ Enter any of the following formulas into the Formula field for each column. Be s | {report:type} | Would output "Expense Report" assuming that is the report's type.| | Reimbursable Total | Is the total amount that is reimbursable on the report.| | {report:reimbursable} | Would output $143.43 assuming the report's reimbursable total was 143.43 US Dollars.| +| Debited Amount | Amount taken from the company bank account when the report was reimbursed across currencies. Empty if the report is not a cross-border reimbursement.| +| {report:debitedAmount} | Would output $82.50 assuming that is what the company was debited.| +| Credited Amount | Amount deposited to the employee bank account when the report was reimbursed across currencies. Empty if the report is not a cross-border reimbursement.| +| {report:creditedAmount} | Would output C$110.00 assuming that is what the employee received.| | Currency | Is the currency to which all expenses on the report are being converted.| | {report:currency} | Would output USD assuming that the report total was calculated in US dollars.| || Note - Currency accepts an optional three character currency code or NONE. If you want to do any math operations on the report total, you should use {report:total:nosymbol} to avoid an error. Please see Expense:Amount for more information on currencies.| diff --git a/docs/articles/new-expensify/connections/dualentry/_meta.yml b/docs/articles/new-expensify/connections/dualentry/_meta.yml new file mode 100644 index 000000000000..500580055e8c --- /dev/null +++ b/docs/articles/new-expensify/connections/dualentry/_meta.yml @@ -0,0 +1 @@ +title: DualEntry diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md index 26b11e6bcace..22f8864830a6 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md @@ -60,6 +60,13 @@ Expensify offers pre-built export templates, or you can build your own custom ex **Note** Currently, it's not possible to build custom export templates on New Expensify, they can only be created on Expensify Classic. However, once built they will be available on New Expensify when exporting reports. [Learn how to build a custom export template in Expensify Classic](/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports#create-a-custom-export-template). +When you export, the file either downloads immediately or is prepared in the background: + +- **Immediate download** – **Basic export** and **Export current view**, when you export selected reports or use **Select all on this page**. +- **Prepared in the background** – When you use **Select all** to export all matching reports, or select **All Data - expense level**, **All Data - report level**, or a **Custom template**. + +While an export is being prepared, either wait for it to download automatically or select **Send me the file when it's ready** to close the export window and receive the file later. When it's ready, Expensify delivers it through Concierge and by email. If the export can't be generated, an error appears in the export window, or is delivered through Concierge if **Send me the file when it's ready** was selected. + ## How to download a single report as a PDF 1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Spend > Reports**. @@ -111,7 +118,7 @@ You can download the receipts on several reports at once in a single ZIP file. ## Where do I find the exported CSV file? -For the Basic Export template, the file downloads directly to your device. For all other templates, Concierge sends the file to you in a direct message. Open your Concierge chat in the **Inbox** to find it. +**Basic export** and **Export current view** download directly to your device. Other templates — and any export started with **Select all** — are prepared in the background; the file then downloads automatically, or is delivered through Concierge and by email if you selected **Send me the file when it's ready**. ## What happens if some reports fail to download as PDFs? diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 9cdd4527ec98..476472cf753b 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.4.74 + 9.4.75 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.74.0 + 9.4.75.0 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 6c1f0c80bf39..5451d1bad1d3 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.74 + 9.4.75 CFBundleVersion - 9.4.74.0 + 9.4.75.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 92588233b07c..f30055e31729 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.74 + 9.4.75 CFBundleVersion - 9.4.74.0 + 9.4.75.0 NSExtension NSExtensionAttributes diff --git a/jest.config.js b/jest.config.js index 14b6e257de98..c929d2249591 100644 --- a/jest.config.js +++ b/jest.config.js @@ -25,7 +25,7 @@ module.exports = { '^.+\\.svg?$': 'jest-transformer-svg', }, transformIgnorePatterns: [ - '/node_modules/(?!.*(react-native|expo|react-navigation|uuid|@shopify\/flash-list).*/)', + '/node_modules/(?!.*(react-native|expo|react-navigation|uuid).*/)', // Prevent Babel from transforming worklets in this file so they are treated as normal functions, otherwise FormatSelectionUtilsTest won't run. '/node_modules/@expensify/react-native-live-markdown/lib/commonjs/parseExpensiMark.js', ], diff --git a/jest/setup.ts b/jest/setup.ts index 98bd75c5a569..cfab21045fdb 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -1,6 +1,5 @@ import type {RenderInfo} from '@components/FlatList/RenderTaskQueue'; -import '@shopify/flash-list/jestSetup'; import type * as LegendListModule from '@legendapp/list/react-native'; import type {ReactNode} from 'react'; import type React from 'react'; diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts index 49701c9c77bb..2bf9decad871 100644 --- a/jest/setupAfterEnv.ts +++ b/jest/setupAfterEnv.ts @@ -37,35 +37,6 @@ if (Keyboard && typeof Keyboard.addListener === 'function') { }) as typeof Keyboard.addListener; } -// This mock must live in setupAfterEnv (not setupFiles) because @shopify/flash-list/jestSetup, -// imported in setup.ts, registers its own measureLayout mock. Placing ours here ensures it -// runs after FlashList's setup and takes precedence. -jest.mock( - '@shopify/flash-list/dist/recyclerview/utils/measureLayout', - () => - ({ - ...jest.requireActual('@shopify/flash-list/dist/recyclerview/utils/measureLayout'), - measureParentSize: jest.fn().mockImplementation(() => ({ - x: 0, - y: 0, - width: 300, - height: 400, - })), - measureFirstChildLayout: jest.fn().mockImplementation(() => ({ - x: 0, - y: 0, - width: 300, - height: 400, - })), - measureItemLayout: jest.fn().mockImplementation(() => ({ - x: 0, - y: 0, - width: 300, - height: 75, - })), - }) as Record, -); - // Auto-initialize Onyx for tests. // Tests that already call Onyx.init() in their own beforeAll will safely re-configure Onyx — // the second init() just re-runs initStoreValues and re-resolves the already-resolved deferred task. diff --git a/package-lock.json b/package-lock.json index 9f281864e26b..7c36bc4e14af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.74-0", + "version": "9.4.75-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.74-0", + "version": "9.4.75-0", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -56,7 +56,6 @@ "@sbaiahmed1/react-native-biometrics": "0.15.0", "@sentry/core": "10.47.0", "@sentry/react-native": "8.7.0", - "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.18", "@ua/react-native-airship": "26.5.0", "array.prototype.tosorted": "^1.1.4", @@ -125,7 +124,7 @@ "react-native-nitro-fetch": "1.5.4", "react-native-nitro-modules": "0.36.3", "react-native-nitro-sqlite": "9.6.0", - "react-native-onyx": "3.0.110", + "react-native-onyx": "3.0.111", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -16964,17 +16963,6 @@ "webpack": ">=5.0.0" } }, - "node_modules/@shopify/flash-list": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.3.0.tgz", - "integrity": "sha512-DR7VuN8KJHTYj9zv1/IhpqrMBMQyeeW/DCWCbVQAAkWhHrc6ylIbXOY+qK93CuHABV+dNHXK/3V6p4wCSW/+wA==", - "license": "MIT", - "peerDependencies": { - "@babel/runtime": "*", - "react": "*", - "react-native": "*" - } - }, "node_modules/@shopify/react-native-skia": { "version": "2.4.18", "resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.4.18.tgz", @@ -35843,9 +35831,9 @@ } }, "node_modules/react-native-onyx": { - "version": "3.0.110", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.110.tgz", - "integrity": "sha512-Uo6z6ILQ5+pa+VtQVaSj8JU41JZ4v1o+H9uaBjaS6+sbzQWuxe5ds3Db+/pOVUk5trblL0Evfxg8B1f+LROFaA==", + "version": "3.0.111", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.111.tgz", + "integrity": "sha512-Y8vGElYidypujOIhdCU6HE69ZH1ZEvyWShZvXiKBMVjWhUmQ/dDCtbk6ypc+q1LS3lJ5EIjNPNmQD668hUKbsQ==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", diff --git a/package.json b/package.json index 526863340136..deb448cf7a10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.74-0", + "version": "9.4.75-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -132,7 +132,6 @@ "@sbaiahmed1/react-native-biometrics": "0.15.0", "@sentry/core": "10.47.0", "@sentry/react-native": "8.7.0", - "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.18", "@ua/react-native-airship": "26.5.0", "array.prototype.tosorted": "^1.1.4", @@ -201,7 +200,7 @@ "react-native-nitro-fetch": "1.5.4", "react-native-nitro-modules": "0.36.3", "react-native-nitro-sqlite": "9.6.0", - "react-native-onyx": "3.0.110", + "react-native-onyx": "3.0.111", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch deleted file mode 100644 index e9d9e3dfd981..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch +++ /dev/null @@ -1,51 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -index fb40ded..12375d9 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -@@ -92,6 +92,17 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager { - */ - normalizeLayoutHeights(layoutInfo) { - var _a, _b; -+ // If the tallest item was removed from the list (e.g. item deletion), -+ // reset tracking and clear minHeight so items get re-measured naturally. -+ if (this.tallestItem && !this.layouts.includes(this.tallestItem)) { -+ for (const layout of this.layouts) { -+ layout.minHeight = 0; -+ } -+ this.tallestItem = undefined; -+ this.tallestItemHeight = 0; -+ this.requiresRepaint = true; -+ return; -+ } - let newTallestItem; - for (const info of layoutInfo) { - const { index } = info; -@@ -115,8 +126,26 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager { - layout.minHeight = targetMinHeight; - } - newTallestItem.minHeight = 0; -- this.tallestItem = newTallestItem; -- this.tallestItemHeight = newTallestItem.height; -+ // When items shrink (targetMinHeight = 0), reset tracking so the -+ // next cycle re-detects the tallest item after repaint and properly -+ // re-applies minHeight for all layouts. -+ if (targetMinHeight === 0) { -+ this.tallestItem = undefined; -+ this.tallestItemHeight = 0; -+ } else { -+ this.tallestItem = newTallestItem; -+ this.tallestItemHeight = newTallestItem.height; -+ } -+ return; -+ } -+ // Normalize newly added items that haven't been assigned minHeight yet. -+ if (this.tallestItem) { -+ for (const layout of this.layouts) { -+ if (layout !== this.tallestItem && layout.minHeight !== this.tallestItemHeight) { -+ layout.minHeight = this.tallestItemHeight; -+ layout.height = this.tallestItemHeight; -+ } -+ } - } - } - /** diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch deleted file mode 100644 index eae8317799bf..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch +++ /dev/null @@ -1,49 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 8b75322..dd2d3bc 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -74,6 +74,10 @@ const RecyclerViewComponent = (props, ref) => { - if (internalViewRef.current && firstChildViewRef.current) { - // Measure the outer container size and inner container layout - const outerViewSize = measureParentSize(internalViewRef.current); -+ if (outerViewSize.width === 0 && outerViewSize.height === 0) { -+ containerViewSizeRef.current = outerViewSize; -+ return; -+ } - const firstChildViewLayout = measureFirstChildLayout(firstChildViewRef.current, internalViewRef.current); - containerViewSizeRef.current = outerViewSize; - // firstChildViewLayout is already relative to the outer container, -@@ -103,6 +107,10 @@ const RecyclerViewComponent = (props, ref) => { - if (pendingChildIds.size > 0) { - return; - } -+ if (((_a = containerViewSizeRef.current) === null || _a === void 0 ? void 0 : _a.width) === 0 && -+ ((_b = containerViewSizeRef.current) === null || _b === void 0 ? void 0 : _b.height) === 0) { -+ return; -+ } - const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => { - const layout = measureItemLayout(viewHolderRef.current, recyclerViewManager.tryGetLayout(index)); - // comapre height with stored layout -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 70f856a..9908674 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -165,7 +165,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - const { horizontal } = recyclerViewManager.props; - if (scrollViewRef.current) { - // Adjust offset for RTL layouts in horizontal mode -- if (I18nManager.isRTL && horizontal) { -+ if (I18nManager.isRTL && horizontal && recyclerViewManager.hasLayout()) { - // eslint-disable-next-line no-param-reassign - offset = - adjustOffsetForRTL(offset, recyclerViewManager.getChildContainerDimensions().width, recyclerViewManager.getWindowSize().width) + -@@ -235,6 +235,9 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - * Returns a Promise that resolves when the scroll is complete. - */ - scrollToIndex: ({ index, animated, viewPosition, viewOffset, }) => { -+ if (!recyclerViewManager.hasLayout()) { -+ return Promise.resolve(); -+ } - return new Promise((resolve) => { - const { horizontal } = recyclerViewManager.props; - if (scrollViewRef.current && diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch deleted file mode 100644 index edb436a356b4..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch +++ /dev/null @@ -1,191 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index dd2d3bc..d7a3d84 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -2,8 +2,8 @@ - * RecyclerView is a high-performance list component that efficiently renders and recycles list items. - * It's designed to handle large lists with optimal memory usage and smooth scrolling. - */ --import React, { useCallback, useLayoutEffect, useMemo, useRef, forwardRef, useState, useId, } from "react"; --import { Animated, I18nManager, } from "react-native"; -+import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, forwardRef, useState, useId, } from "react"; -+import { Animated, I18nManager, Platform, } from "react-native"; - import { ErrorMessages } from "../errors/ErrorMessages"; - import { WarningMessages } from "../errors/WarningMessages"; - import { areDimensionsNotEqual, measureFirstChildLayout, measureItemLayout, measureParentSize, } from "./utils/measureLayout"; -@@ -66,6 +66,66 @@ const RecyclerViewComponent = (props, ref) => { - // Hook to detect when scrolling reaches list bounds - const { checkBounds } = useBoundDetection(recyclerViewManager, scrollViewRef); - const isHorizontalRTL = I18nManager.isRTL && horizontal; -+ // Web-only: Fix inverted scroll direction. -+ useEffect(() => { -+ if (!inverted || Platform.OS !== "web") { -+ return; -+ } -+ const scrollRef = scrollViewRef.current; -+ if (!scrollRef || typeof scrollRef.getScrollableNode !== "function") { -+ return; -+ } -+ const node = scrollRef.getScrollableNode(); -+ if (!node) { -+ return; -+ } -+ const wheelHandler = (ev) => { -+ const target = ev.target; -+ const deltaX = ev.deltaX || ev.wheelDeltaX || 0; -+ const deltaY = ev.deltaY || ev.wheelDeltaY || 0; -+ // Compute scroll limits from the DOM node for overscroll recoil prevention. -+ const nodeScrollOffset = horizontal ? node.scrollLeft : node.scrollTop; -+ const nodeScrollLength = horizontal ? node.scrollWidth : node.scrollHeight; -+ const nodeClientLength = horizontal ? node.clientWidth : node.clientHeight; -+ const isOnScrollLimit = nodeScrollOffset <= 0 || Math.ceil(nodeScrollOffset) >= nodeScrollLength - nodeClientLength; -+ const scrollOffset = horizontal ? target.scrollLeft : target.scrollTop; -+ const scrollLength = horizontal ? target.scrollWidth : target.scrollHeight; -+ const clientLength = horizontal ? target.clientWidth : target.clientHeight; -+ const isEventTargetScrollable = scrollLength > clientLength; -+ const delta = horizontal ? deltaX : deltaY; -+ let leftoverDelta = delta; -+ if (isEventTargetScrollable) { -+ leftoverDelta = delta < 0 -+ ? Math.min(delta + scrollOffset, 0) -+ : Math.max(delta - (scrollLength - clientLength - scrollOffset), 0); -+ } -+ const targetDelta = delta - leftoverDelta; -+ if (horizontal) { -+ if (Math.abs(deltaX) > Math.abs(deltaY)) { -+ target.scrollLeft += targetDelta; -+ node.scrollLeft = node.scrollLeft - leftoverDelta; -+ ev.preventDefault(); -+ ev.stopPropagation(); -+ } -+ } -+ else { -+ // Prevent overscroll recoil/rubber band at scroll boundaries. -+ if (isOnScrollLimit && Math.abs(deltaY) > 0) { -+ ev.preventDefault(); -+ } -+ if (Math.abs(deltaY) > Math.abs(deltaX)) { -+ target.scrollTop += targetDelta; -+ node.scrollTop = node.scrollTop - leftoverDelta; -+ ev.preventDefault(); -+ ev.stopPropagation(); -+ } -+ } -+ }; -+ node.addEventListener("wheel", wheelHandler, { passive: false }); -+ return () => { -+ node.removeEventListener("wheel", wheelHandler); -+ }; -+ }, [inverted, horizontal]); - /** - * Initialize the RecyclerView by measuring and setting up the window size - * This effect runs when the component mounts or when layout changes -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index 34722d4..ea801d2 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -5,6 +5,7 @@ - import React, { - RefObject, - useCallback, -+ useEffect, - useLayoutEffect, - useMemo, - useRef, -@@ -17,6 +18,7 @@ import { - I18nManager, - NativeScrollEvent, - NativeSyntheticEvent, -+ Platform, - } from "react-native"; - - import { FlashListRef } from "../FlashListRef"; -@@ -158,6 +160,88 @@ const RecyclerViewComponent = ( - - const isHorizontalRTL = I18nManager.isRTL && horizontal; - -+ /** -+ * Web-only: Fix inverted scroll direction. -+ * When a list is visually inverted via scaleY/scaleX: -1, the browser's native -+ * wheel scroll goes in the wrong visual direction. This effect attaches a wheel -+ * event listener that negates the delta to correct the scroll direction. -+ * Mirrors the fix in react-native-web's VirtualizedList. -+ */ -+ useEffect(() => { -+ if (!inverted || Platform.OS !== "web") { -+ return; -+ } -+ const scrollRef = scrollViewRef.current; -+ if (!scrollRef || typeof (scrollRef as any).getScrollableNode !== "function") { -+ return; -+ } -+ const node = (scrollRef as any).getScrollableNode() as HTMLElement; -+ if (!node) { -+ return; -+ } -+ -+ const wheelHandler = (ev: WheelEvent) => { -+ const target = ev.target as HTMLElement; -+ const deltaX = ev.deltaX || (ev as any).wheelDeltaX || 0; -+ const deltaY = ev.deltaY || (ev as any).wheelDeltaY || 0; -+ -+ // Compute scroll limits from the DOM node for overscroll recoil prevention. -+ const nodeScrollOffset = horizontal ? node.scrollLeft : node.scrollTop; -+ const nodeScrollLength = horizontal ? node.scrollWidth : node.scrollHeight; -+ const nodeClientLength = horizontal ? node.clientWidth : node.clientHeight; -+ const isOnScrollLimit = -+ nodeScrollOffset <= 0 || -+ Math.ceil(nodeScrollOffset) >= nodeScrollLength - nodeClientLength; -+ -+ const scrollOffset = horizontal ? target.scrollLeft : target.scrollTop; -+ const scrollLength = horizontal ? target.scrollWidth : target.scrollHeight; -+ const clientLength = horizontal ? target.clientWidth : target.clientHeight; -+ const isEventTargetScrollable = scrollLength > clientLength; -+ const delta = horizontal ? deltaX : deltaY; -+ -+ // Calculate how much delta the event target can consume vs leftover for parent -+ let leftoverDelta = delta; -+ if (isEventTargetScrollable) { -+ leftoverDelta = -+ delta < 0 -+ ? Math.min(delta + scrollOffset, 0) -+ : Math.max( -+ delta - (scrollLength - clientLength - scrollOffset), -+ 0 -+ ); -+ } -+ const targetDelta = delta - leftoverDelta; -+ -+ // Only adjust scroll and consume the event when the dominant axis -+ // matches the list orientation. stopPropagation prevents parent -+ // inverted lists from also handling this event. -+ if (horizontal) { -+ if (Math.abs(deltaX) > Math.abs(deltaY)) { -+ target.scrollLeft += targetDelta; -+ node.scrollLeft = node.scrollLeft - leftoverDelta; -+ ev.preventDefault(); -+ ev.stopPropagation(); -+ } -+ } else { -+ // Prevent overscroll recoil/rubber band at scroll boundaries. -+ if (isOnScrollLimit && Math.abs(deltaY) > 0) { -+ ev.preventDefault(); -+ } -+ if (Math.abs(deltaY) > Math.abs(deltaX)) { -+ target.scrollTop += targetDelta; -+ node.scrollTop = node.scrollTop - leftoverDelta; -+ ev.preventDefault(); -+ ev.stopPropagation(); -+ } -+ } -+ }; -+ -+ node.addEventListener("wheel", wheelHandler, { passive: false }); -+ return () => { -+ node.removeEventListener("wheel", wheelHandler); -+ }; -+ }, [inverted, horizontal]); -+ - /** - * Initialize the RecyclerView by measuring and setting up the window size - * This effect runs when the component mounts or when layout changes diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch deleted file mode 100644 index 98bac124be64..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch +++ /dev/null @@ -1,38 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index d7a3d84..ffcdad8 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -142,9 +142,11 @@ const RecyclerViewComponent = (props, ref) => { - containerViewSizeRef.current = outerViewSize; - // firstChildViewLayout is already relative to the outer container, - // so its x/y directly gives the first item offset. -- const firstItemOffset = horizontal -- ? firstChildViewLayout.x -- : firstChildViewLayout.y; -+ const firstItemOffset = inverted -+ ? 0 -+ : horizontal -+ ? firstChildViewLayout.x -+ : firstChildViewLayout.y; - // Update the RecyclerView manager with window dimensions - recyclerViewManager.updateLayoutParams({ - width: horizontal ? outerViewSize.width : firstChildViewLayout.width, -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index ea801d2..8a7deff 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -259,9 +259,11 @@ const RecyclerViewComponent = ( - - // firstChildViewLayout is already relative to the outer container, - // so its x/y directly gives the first item offset. -- const firstItemOffset = horizontal -- ? firstChildViewLayout.x -- : firstChildViewLayout.y; -+ const firstItemOffset = inverted -+ ? 0 -+ : horizontal -+ ? firstChildViewLayout.x -+ : firstChildViewLayout.y; - - // Update the RecyclerView manager with window dimensions - recyclerViewManager.updateLayoutParams( diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch deleted file mode 100644 index 6b96845c5875..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch +++ /dev/null @@ -1,68 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index ffcdad8..ee42f63 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -166,9 +166,6 @@ const RecyclerViewComponent = (props, ref) => { - // eslint-disable-next-line react-hooks/exhaustive-deps - useLayoutEffect(() => { - var _a, _b; -- if (pendingChildIds.size > 0) { -- return; -- } - if (((_a = containerViewSizeRef.current) === null || _a === void 0 ? void 0 : _a.width) === 0 && - ((_b = containerViewSizeRef.current) === null || _b === void 0 ? void 0 : _b.height) === 0) { - return; -@@ -196,8 +193,17 @@ const RecyclerViewComponent = (props, ref) => { - } - if (recyclerViewManager.modifyChildrenLayout(layoutInfo, (_a = data === null || data === void 0 ? void 0 : data.length) !== null && _a !== void 0 ? _a : 0) && - !hasExceededMaxRendersWithoutCommit) { -- // Trigger re-render if layout modifications were made -- setRenderId((prev) => prev + 1); -+ if (pendingChildIds.size > 0) { -+ // When child FlashLists are still loading, avoid triggering a full -+ // RecyclerView re-render (setRenderId) to prevent cascading setState -+ // calls that could cause "Maximum update depth exceeded" errors. -+ // Instead, just commit the layout to update item positions in -+ // ViewHolderCollection without re-measuring. -+ (_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.commitLayout(); -+ } else { -+ // Trigger re-render if layout modifications were made -+ setRenderId((prev) => prev + 1); -+ } - } - else { - (_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.commitLayout(); -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index 8a7deff..b2bd67a 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -287,9 +287,6 @@ const RecyclerViewComponent = ( - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - useLayoutEffect(() => { -- if (pendingChildIds.size > 0) { -- return; -- } - const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => { - const layout = measureItemLayout( - viewHolderRef.current!, -@@ -323,8 +320,17 @@ const RecyclerViewComponent = ( - recyclerViewManager.modifyChildrenLayout(layoutInfo, data?.length ?? 0) && - !hasExceededMaxRendersWithoutCommit - ) { -- // Trigger re-render if layout modifications were made -- setRenderId((prev) => prev + 1); -+ if (pendingChildIds.size > 0) { -+ // When child FlashLists are still loading, avoid triggering a full -+ // RecyclerView re-render (setRenderId) to prevent cascading setState -+ // calls that could cause "Maximum update depth exceeded" errors. -+ // Instead, just commit the layout to update item positions in -+ // ViewHolderCollection without re-measuring. -+ viewHolderCollectionRef.current?.commitLayout(); -+ } else { -+ // Trigger re-render if layout modifications were made -+ setRenderId((prev) => prev + 1); -+ } - } else { - viewHolderCollectionRef.current?.commitLayout(); - applyOffsetCorrection(); diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch deleted file mode 100644 index 48a9b893bbfd..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch +++ /dev/null @@ -1,114 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 70f856a..52546f7 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -1,5 +1,5 @@ - import { useCallback, useImperativeHandle, useMemo, useRef, useState, } from "react"; --import { I18nManager } from "react-native"; -+import { I18nManager, Platform } from "react-native"; - import { adjustOffsetForRTL } from "../utils/adjustOffsetForRTL"; - import { PlatformConfig } from "../../native/config/PlatformHelper"; - import { WarningMessages } from "../../errors/WarningMessages"; -@@ -25,6 +25,8 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - const isUnmounted = useUnmountFlag(); - const [_, setRenderId] = useState(0); - const pauseOffsetCorrection = useRef(false); -+ const pendingAndroidInvertedRafId = useRef(null); -+ const skipNextAndroidInvertedCorrection = useRef(false); - const lastDataLengthRef = useRef(recyclerViewManager.getDataLength()); - const { setTimeout } = useUnmountAwareTimeout(); - // Track the first visible item for maintaining scroll position -@@ -79,7 +81,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - */ - const applyOffsetCorrection = useCallback(() => { - var _a, _b, _c; -- const { horizontal, data } = recyclerViewManager.props; -+ const { horizontal, data, inverted } = recyclerViewManager.props; - // Execute all pending callbacks from previous scroll offset updates - // This ensures any scroll operations that were waiting for render are completed - const callbacks = pendingScrollCallbacks.current; -@@ -91,6 +93,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - currentDataLength > 0 && - recyclerViewManager.shouldMaintainVisibleContentPosition()) { - const hasDataChanged = currentDataLength !== lastDataLengthRef.current; -+ // Read and reset the skip flag so it never persists across multiple correction cycles -+ const shouldSkipAndroidInvertedCorrection = hasDataChanged && inverted && Platform.OS === 'android' && skipNextAndroidInvertedCorrection.current; -+ if (shouldSkipAndroidInvertedCorrection) { -+ skipNextAndroidInvertedCorrection.current = false; -+ } - // If we have a tracked first visible item, maintain its position - if (firstVisibleItemKey.current) { - const currentIndexOfFirstVisibleItem = (_a = recyclerViewManager -@@ -115,10 +122,31 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - !pauseOffsetCorrection.current && - !recyclerViewManager.animationOptimizationsEnabled) { - // console.log("diff", diff, firstVisibleItemKey.current); -- if (PlatformConfig.supportsOffsetCorrection) { -- // console.log("scrollBy", diff); -+ const useAndroidInvertedFallback = hasDataChanged && inverted && Platform.OS === 'android'; -+ if (PlatformConfig.supportsOffsetCorrection && !useAndroidInvertedFallback) { - (_b = scrollAnchorRef.current) === null || _b === void 0 ? void 0 : _b.scrollBy(diff); - } -+ else if (useAndroidInvertedFallback) { -+ if (!shouldSkipAndroidInvertedCorrection) { -+ const scrollToParams = horizontal -+ ? { -+ x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ animated: false, -+ } -+ : { -+ y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ animated: false, -+ }; -+ if (pendingAndroidInvertedRafId.current !== null) { -+ cancelAnimationFrame(pendingAndroidInvertedRafId.current); -+ } -+ // rAF scrollTo to correct after native layout commits -+ pendingAndroidInvertedRafId.current = requestAnimationFrame(() => { -+ pendingAndroidInvertedRafId.current = null; -+ (_c = scrollViewRef.current) === null || _c === void 0 ? void 0 : _c.scrollTo(scrollToParams); -+ }); -+ } -+ } - else { - const scrollToParams = horizontal - ? { -@@ -162,6 +190,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - * Handles RTL layouts and first item offset adjustments. - */ - scrollToOffset: ({ offset, animated, skipFirstItemOffset = true, }) => { -+ if (pendingAndroidInvertedRafId.current !== null) { -+ cancelAnimationFrame(pendingAndroidInvertedRafId.current); -+ pendingAndroidInvertedRafId.current = null; -+ } -+ if (recyclerViewManager.props.inverted && Platform.OS === 'android') { -+ skipNextAndroidInvertedCorrection.current = true; -+ } - const { horizontal } = recyclerViewManager.props; - if (scrollViewRef.current) { - // Adjust offset for RTL layouts in horizontal mode -@@ -205,6 +240,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - * Scrolls to the end of the list. - */ - scrollToEnd: async ({ animated } = {}) => { -+ if (pendingAndroidInvertedRafId.current !== null) { -+ cancelAnimationFrame(pendingAndroidInvertedRafId.current); -+ pendingAndroidInvertedRafId.current = null; -+ } -+ if (recyclerViewManager.props.inverted && Platform.OS === 'android') { -+ skipNextAndroidInvertedCorrection.current = true; -+ } - const { data } = recyclerViewManager.props; - if (data && data.length > 0) { - const lastIndex = data.length - 1; -@@ -238,6 +280,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - if (!recyclerViewManager.hasLayout()) { - return Promise.resolve(); - } -+ if (pendingAndroidInvertedRafId.current !== null) { -+ cancelAnimationFrame(pendingAndroidInvertedRafId.current); -+ pendingAndroidInvertedRafId.current = null; -+ } - return new Promise((resolve) => { - const { horizontal } = recyclerViewManager.props; - if (scrollViewRef.current && diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch deleted file mode 100644 index 7f1700a548d9..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch +++ /dev/null @@ -1,80 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index ee42f63..4e8d8c0 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -380,16 +380,15 @@ const RecyclerViewComponent = (props, ref) => { - } - return onScrollHandler; - }, [onScrollHandler, scrollY, stickyHeaders, stickyHeaderUseNativeDriver]); -- const shouldMaintainVisibleContentPosition = recyclerViewManager.shouldMaintainVisibleContentPosition(); - const maintainVisibleContentPositionInternal = useMemo(() => { -- if (shouldMaintainVisibleContentPosition) { -+ if (maintainVisibleContentPosition != null && !maintainVisibleContentPosition.disabled) { - return { - ...maintainVisibleContentPosition, - minIndexForVisible: 0, - }; - } - return undefined; -- }, [maintainVisibleContentPosition, shouldMaintainVisibleContentPosition]); -+ }, [maintainVisibleContentPosition]); - const shouldRenderFromBottom = recyclerViewManager.getDataLength() > 0 && - ((_d = maintainVisibleContentPosition === null || maintainVisibleContentPosition === void 0 ? void 0 : maintainVisibleContentPosition.startRenderingFromBottom) !== null && _d !== void 0 ? _d : false); - // Create view for measuring bounded size -@@ -401,11 +399,11 @@ const RecyclerViewComponent = (props, ref) => { - }, ref: firstChildViewRef })); - }, [horizontal, stickyHeaderOffset]); - const scrollAnchor = useMemo(() => { -- if (shouldMaintainVisibleContentPosition) { -+ if (maintainVisibleContentPosition != null) { - return (React.createElement(ScrollAnchor, { horizontal: Boolean(horizontal), scrollAnchorRef: scrollAnchorRef })); - } - return null; -- }, [horizontal, shouldMaintainVisibleContentPosition]); -+ }, [horizontal, maintainVisibleContentPosition]); - // console.log("render", recyclerViewManager.getRenderStack()); - // Render the main RecyclerView structure - return (React.createElement(RecyclerViewContextProvider, { value: recyclerViewContext }, -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index b2bd67a..d4bf02d 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -572,18 +572,15 @@ const RecyclerViewComponent = ( - return onScrollHandler; - }, [onScrollHandler, scrollY, stickyHeaders, stickyHeaderUseNativeDriver]); - -- const shouldMaintainVisibleContentPosition = -- recyclerViewManager.shouldMaintainVisibleContentPosition(); -- - const maintainVisibleContentPositionInternal = useMemo(() => { -- if (shouldMaintainVisibleContentPosition) { -+ if (maintainVisibleContentPosition != null && !maintainVisibleContentPosition.disabled) { - return { - ...maintainVisibleContentPosition, - minIndexForVisible: 0, - }; - } - return undefined; -- }, [maintainVisibleContentPosition, shouldMaintainVisibleContentPosition]); -+ }, [maintainVisibleContentPosition]); - - const shouldRenderFromBottom = - recyclerViewManager.getDataLength() > 0 && -@@ -604,7 +600,7 @@ const RecyclerViewComponent = ( - }, [horizontal, stickyHeaderOffset]); - - const scrollAnchor = useMemo(() => { -- if (shouldMaintainVisibleContentPosition) { -+ if (maintainVisibleContentPosition != null) { - return ( - ( - ); - } - return null; -- }, [horizontal, shouldMaintainVisibleContentPosition]); -+ }, [horizontal, maintainVisibleContentPosition]); - - // console.log("render", recyclerViewManager.getRenderStack()); - diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch deleted file mode 100644 index a76bba73cc2d..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 51b6f8c..d4ca252 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -507,7 +507,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - setTimeout(() => { - recyclerViewManager.isInitialScrollComplete = true; - pauseOffsetCorrection.current = false; -- }, 100); -+ }, 500); - pauseOffsetCorrection.current = true; - const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; - const offset = horizontal diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch deleted file mode 100644 index 928c0d6c28a6..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch +++ /dev/null @@ -1,29 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -316,7 +316,10 @@ function RecyclerView(props) { - */ - const validateItemSize = useCallback((index, size) => { - var _a, _b, _c, _d; -- const layout = recyclerViewManager.getLayout(index); -+ const layout = recyclerViewManager.tryGetLayout(index); -+ if (layout === undefined) { -+ return; -+ } - const width = Math.max(Math.min(layout.width, (_a = layout.maxWidth) !== null && _a !== void 0 ? _a : Infinity), (_b = layout.minWidth) !== null && _b !== void 0 ? _b : 0); - const height = Math.max(Math.min(layout.height, (_c = layout.maxHeight) !== null && _c !== void 0 ? _c : Infinity), (_d = layout.minHeight) !== null && _d !== void 0 ? _d : 0); - if (areDimensionsNotEqual(width, size.width) || -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -465,6 +465,9 @@ function RecyclerView(props: RecyclerViewProps) { - const validateItemSize = useCallback( - (index: number, size: RVDimension) => { -- const layout = recyclerViewManager.getLayout(index); -+ const layout = recyclerViewManager.tryGetLayout(index); -+ if (layout === undefined) { -+ return; -+ } - const width = Math.max( - Math.min(layout.width, layout.maxWidth ?? Infinity), - layout.minWidth ?? 0 diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch deleted file mode 100644 index c66e0497ccfd..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch +++ /dev/null @@ -1,16 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/utils/measureLayout.web.js b/node_modules/@shopify/flash-list/dist/recyclerview/utils/measureLayout.web.js -index 17d9812..fe112f1 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/utils/measureLayout.web.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/utils/measureLayout.web.js -@@ -28,7 +28,10 @@ export function areDimensionsEqual(value1, value2) { - return Math.abs(value1 - value2) <= 1; - } - export function roundOffPixel(value) { -- return value; -+ const dpr = typeof window !== "undefined" && window.devicePixelRatio -+ ? window.devicePixelRatio -+ : 1; -+ return Math.round(value * dpr) / dpr; - } - /** - * Measures the size of the RecyclerView's outer container. diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch deleted file mode 100644 index 321a812fa2cc..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch +++ /dev/null @@ -1,542 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts b/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts -index 08b83f3..05a64b1 100644 ---- a/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts -+++ b/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts -@@ -167,6 +167,30 @@ export interface FlashListRef { - * }); - */ - scrollToIndex: (params: ScrollToIndexParams) => Promise; -+ /** -+ * Announces an imminent programmatic scroll before `scrollToIndex` is -+ * actually called, so DOM-mutating side-effects gated on -+ * `isScrollingProgrammatically()` (notably the on-web sort applied by -+ * `ViewHolderCollection`) defer until the upcoming smooth scroll -+ * settles, rather than running synchronously and cancelling it. -+ * -+ * Useful when the focus assignment happens first and `scrollToIndex` -+ * follows a few ticks later — as long as the call is guaranteed to -+ * happen, queue it up front so the intervening `focusin` doesn't -+ * trigger an immediate sort that the smooth scroll would then cancel. -+ * -+ * Cleared automatically when the next `scrollToIndex` is invoked -+ * (handed off to the in-flight flag) and again when the resulting -+ * scroll's momentum ends. Safe to call multiple times. -+ * -+ * @example -+ * listRef.current?.announceProgrammaticScroll(); -+ * itemDomNode.focus(); -+ * setTimeout(() => { -+ * listRef.current?.scrollToIndex({ index: nextIndex, animated: true }); -+ * }, 0); -+ */ -+ announceProgrammaticScroll: () => void; - /** - * Scrolls to a specific item in the list. - * -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 4e53325..ef4daf2 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -58,7 +58,7 @@ const RecyclerViewComponent = (props, ref) => { - const refHolder = useMemo(() => new Map(), []); - // Initialize core RecyclerView manager and content offset management - const { recyclerViewManager, velocityTracker } = useRecyclerViewManager(props); -- const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, } = useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef); -+ const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, isScrollingProgrammatically, isScrolling, runAfterProgrammaticScroll, notifyProgrammaticScrollSettled, notifyScrollActive, notifyScrollSettled, getLastScrollTime, } = useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef); - // Initialize view holder collection ref - const viewHolderCollectionRef = useRef(null); - // Hook to handle list loading -@@ -238,12 +238,19 @@ const RecyclerViewComponent = (props, ref) => { - return; - } - if (isMomentumEnd) { -+ notifyScrollSettled(); -+ // Drain BEFORE the early return below so the drain still -+ // fires while offset projection is still disabled. -+ notifyProgrammaticScrollSettled(); - computeFirstVisibleIndexForOffsetCorrection(); - if (!recyclerViewManager.isOffsetProjectionEnabled) { - return; - } - recyclerViewManager.resetVelocityCompute(); - } -+ else { -+ notifyScrollActive(); -+ } - // Update scroll position and trigger re-render if needed - if (recyclerViewManager.updateScrollOffset(scrollOffset, velocity)) { - setRenderId((prev) => prev + 1); -@@ -266,6 +273,9 @@ const RecyclerViewComponent = (props, ref) => { - computeFirstVisibleIndexForOffsetCorrection, - horizontal, - isHorizontalRTL, -+ notifyProgrammaticScrollSettled, -+ notifyScrollActive, -+ notifyScrollSettled, - recyclerViewManager, - velocityTracker, - ]); -@@ -461,7 +471,7 @@ const RecyclerViewComponent = (props, ref) => { - recyclerViewManager.animationOptimizationsEnabled = false; - }, CellRendererComponent: CellRendererComponent, ItemSeparatorComponent: ItemSeparatorComponent, isInLastRow: (index) => recyclerViewManager.isInLastRow(index), getChildContainerLayout: () => recyclerViewManager.hasLayout() - ? recyclerViewManager.getChildContainerDimensions() -- : undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell, inverted: inverted }), -+ : undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell, inverted: inverted, isScrollingProgrammatically: isScrollingProgrammatically, isScrolling: isScrolling, runAfterProgrammaticScroll: runAfterProgrammaticScroll, getLastScrollTime: getLastScrollTime }), - renderEmpty, - renderFooter), - stickyHeaderIndices && stickyHeaderIndices.length > 0 -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js -index 0df2879..f639313 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js -@@ -3,9 +3,11 @@ - * It handles the rendering of list items, separators, and manages layout updates for each item. - * The component is memoized to prevent unnecessary re-renders and includes layout comparison logic. - */ -+import { Platform } from "react-native"; - import React, { useCallback, useLayoutEffect, useMemo, useRef, } from "react"; - import { CompatView } from "./components/CompatView"; - import { getInvertedTransformStyle } from "./utils/getInvertedTransformStyle"; -+const INVISIBLE_MARKER_STYLE = { display: "none" }; - /** - * Internal ViewHolder component that handles the actual rendering of list items - * @template TItem - The type of item being rendered in the list -@@ -57,6 +59,7 @@ const ViewHolderInternal = (props) => { - const CompatContainer = (CellRendererComponent !== null && CellRendererComponent !== void 0 ? CellRendererComponent : CompatView); - return (React.createElement(CompatContainer, { ref: viewRef, onLayout: onLayout, style: style, index: index }, - children, -+ Platform.OS === "web" && (React.createElement("div", { "data-flashlist-index": index, "aria-hidden": true, style: INVISIBLE_MARKER_STYLE })), - separator)); - }; - /** -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts -index c37c4f3..fd2ff94 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts -@@ -54,6 +54,14 @@ export interface ViewHolderCollectionProps { - isInLastRow: (index: number) => boolean; - /** Whether the list is inverted */ - inverted: FlashListProps["inverted"]; -+ /** True while a programmatic scroll is queued or in flight. */ -+ isScrollingProgrammatically: () => boolean; -+ /** True while any scroll is in flight. */ -+ isScrolling: () => boolean; -+ /** Register a callback to run when the current programmatic-scroll animation settles. */ -+ runAfterProgrammaticScroll: (cb: () => void) => void; -+ /** Returns the timestamp (`Date.now()`) of the most recent scroll event, or 0 if none. */ -+ getLastScrollTime: () => number; - } - /** - * Ref interface for ViewHolderCollection that exposes methods to control layout updates -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -index 8e3db51..e66d406 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -@@ -3,17 +3,81 @@ - * It handles the rendering of a collection of list items, manages layout updates, - * and coordinates with the RecyclerView context for layout changes. - */ --import React, { useEffect, useImperativeHandle, useLayoutEffect } from "react"; -+import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useReducer, useRef, } from "react"; -+import { Platform } from "react-native"; - import { ViewHolder } from "./ViewHolder"; - import { CompatView } from "./components/CompatView"; - import { useRecyclerViewContext } from "./RecyclerViewContextProvider"; -+const SORT_DELAY_MS = 1000; -+// Max gap from last `focusin` to last `scroll` event for the scroll to -+// count as a focus-induced auto-scroll-into-view (vs a user-driven scroll). -+const FOCUS_INDUCED_SCROLL_WINDOW_MS = 30; -+/** -+ * Single-slot setTimeout with a fire-time gate. Calling `schedule` again -+ * replaces any pending fire. When the timer expires, if `shouldDefer()` -+ * returns true the timer reschedules itself instead of invoking -+ * `callback`. Auto-cancels on unmount. -+ * -+ * @returns A tuple of `[schedule, cancel]`. `schedule` arms (or re-arms) -+ * the timer; `cancel` evicts whatever is in the slot. -+ */ -+function useDeferredCallback(callback, delayMs, shouldDefer) { -+ const timeoutRef = useRef(null); -+ const cancel = useCallback(() => { -+ if (timeoutRef.current !== null) { -+ clearTimeout(timeoutRef.current); -+ timeoutRef.current = null; -+ } -+ }, []); -+ const schedule = useCallback(() => { -+ cancel(); -+ timeoutRef.current = setTimeout(() => { -+ if (shouldDefer()) { -+ schedule(); -+ return; -+ } -+ timeoutRef.current = null; -+ callback(); -+ }, delayMs); -+ }, [callback, delayMs, shouldDefer, cancel]); -+ useEffect(() => cancel, [cancel]); -+ return [schedule, cancel]; -+} -+/** -+ * Walks up from `target` to find a `data-flashlist-index` marker among -+ * a parent's direct children, returning the marker's `index` and the -+ * walk-up `depth` (number of `parentElement` hops). Iterates siblings -+ * last-to-first — the marker sits between `{children}` and `{separator}` -+ * inside the ViewHolder, so it's near the end. Returns `null` if no -+ * marker is found before reaching `root`. -+ */ -+function findFocusedIndexFromMarker(target, root) { -+ var _a; -+ let current = target; -+ let depth = 0; -+ while (current && current !== root) { -+ const parent = current.parentElement; -+ if (!parent) -+ break; -+ for (let i = parent.children.length - 1; i >= 0; i--) { -+ const child = parent.children[i]; -+ const idxStr = (_a = child.dataset) === null || _a === void 0 ? void 0 : _a.flashlistIndex; -+ if (idxStr != null) { -+ return { index: Number(idxStr), depth }; -+ } -+ } -+ current = parent; -+ depth++; -+ } -+ return null; -+} - /** - * ViewHolderCollection component that manages the rendering of multiple ViewHolder instances - * and handles layout updates for the entire collection - * @template TItem - The type of items in the data array - */ - export const ViewHolderCollection = (props) => { -- const { data, renderStack, getLayout, refHolder, onSizeChanged, renderItem, extraData, viewHolderCollectionRef, getChildContainerLayout, onCommitLayoutEffect, CellRendererComponent, ItemSeparatorComponent, onCommitEffect, horizontal, getAdjustmentMargin, currentStickyIndex, hideStickyHeaderRelatedCell, isInLastRow, inverted, } = props; -+ const { data, renderStack, getLayout, refHolder, onSizeChanged, renderItem, extraData, viewHolderCollectionRef, getChildContainerLayout, onCommitLayoutEffect, CellRendererComponent, ItemSeparatorComponent, onCommitEffect, horizontal, getAdjustmentMargin, currentStickyIndex, hideStickyHeaderRelatedCell, isInLastRow, inverted, isScrollingProgrammatically, isScrolling, runAfterProgrammaticScroll, getLastScrollTime, } = props; - const [renderId, setRenderId] = React.useState(0); - const containerLayout = getChildContainerLayout(); - const fixedContainerSize = horizontal -@@ -72,9 +136,160 @@ export const ViewHolderCollection = (props) => { - // return `${index} => ${reactKey}`; - // }) - // ); -- return (React.createElement(CompatView, { style: hasData && containerStyle }, containerLayout && -+ const containerRef = useRef(null); -+ const lastFocusTimeRef = useRef(0); -+ const lastFocusedIndexRef = useRef(null); -+ const lastFocusedDepthRef = useRef(null); -+ const shouldSortOnNextFocusRef = useRef(false); -+ const renderEntriesRef = useRef(Array.from(renderStack.entries())); -+ // Tracks the modality of the user's most recent input ("pointer" vs -+ // "keyboard"). Pointer interactions defer the sync sort to avoid -+ // re-rendering between `mousedown` and `click` (which makes the browser -+ // drop the click). -+ const lastInputModalityRef = useRef("pointer"); -+ const [, bumpSortVersion] = useReducer((x) => x + 1, 0); -+ const sortItems = useCallback(() => { -+ const entries = renderEntriesRef.current; -+ const direction = inverted ? -1 : 1; -+ const isSorted = entries.every((entry, i) => i === 0 || direction * (entries[i - 1][1].index - entry[1].index) <= 0); -+ if (isSorted) { -+ return; -+ } -+ entries.sort(([, a], [, b]) => direction * (a.index - b.index)); -+ bumpSortVersion(); -+ }, [inverted]); -+ const [schedulePendingSort, clearPendingSort] = useDeferredCallback(sortItems, SORT_DELAY_MS, isScrolling); -+ const maybeDoSortOnFocus = useCallback(() => { -+ clearPendingSort(); -+ if (isScrollingProgrammatically()) { -+ runAfterProgrammaticScroll(schedulePendingSort); -+ return; -+ } -+ // Pointer-driven focus: defer the sync sort so we don't reorder the -+ // DOM between `mousedown` and `click` (the browser would drop the -+ // click). The pending sort will commit later via the timer. -+ if (shouldSortOnNextFocusRef.current && -+ lastInputModalityRef.current === "pointer") { -+ schedulePendingSort(); -+ return; -+ } -+ if (shouldSortOnNextFocusRef.current) { -+ shouldSortOnNextFocusRef.current = false; -+ sortItems(); -+ } -+ schedulePendingSort(); -+ }, [ -+ isScrollingProgrammatically, -+ runAfterProgrammaticScroll, -+ schedulePendingSort, -+ clearPendingSort, -+ sortItems, -+ ]); -+ const maybeDoSortOnScroll = useCallback(() => { -+ shouldSortOnNextFocusRef.current = true; -+ // Evict any stale timer from a previous scroll's drain so it can't -+ // fire mid-scroll during rapid-fire arrow nav (where `isMomentumEnd` -+ // doesn't fire between key presses). -+ clearPendingSort(); -+ if (isScrollingProgrammatically()) { -+ runAfterProgrammaticScroll(schedulePendingSort); -+ return; -+ } -+ if (isScrolling()) { -+ // Focus-induced auto-scroll-into-view: sort sync to keep DOM -+ // aligned for the next Tab. User-driven scrolls (negative Δ or Δ -+ // past the window) defer to avoid sorting mid-mousewheel. -+ const scrollSinceFocus = getLastScrollTime() - lastFocusTimeRef.current; -+ const scrollNow = scrollSinceFocus >= 0 && -+ scrollSinceFocus < FOCUS_INDUCED_SCROLL_WINDOW_MS; -+ if (scrollNow) { -+ sortItems(); -+ shouldSortOnNextFocusRef.current = false; -+ return; -+ } -+ } -+ schedulePendingSort(); -+ }, [ -+ isScrollingProgrammatically, -+ isScrolling, -+ runAfterProgrammaticScroll, -+ schedulePendingSort, -+ clearPendingSort, -+ sortItems, -+ getLastScrollTime, -+ ]); -+ if (Platform.OS === "web") { -+ // Reconcile: remove stale keys, append new keys -+ const existingKeys = new Set(renderEntriesRef.current.map(([key]) => key)); -+ renderEntriesRef.current = renderEntriesRef.current.filter(([key]) => renderStack.has(key)); -+ for (const key of renderStack.keys()) { -+ if (!existingKeys.has(key)) { -+ renderEntriesRef.current.push([key, renderStack.get(key)]); -+ } -+ } -+ } -+ else { -+ renderEntriesRef.current = Array.from(renderStack.entries()); -+ } -+ useEffect(() => { -+ const container = containerRef.current; -+ if (Platform.OS !== "web" || !container) { -+ return; -+ } -+ const onFocusIn = (e) => { -+ var _a, _b; -+ // Filter spurious focusins (recycle re-focus, mutation-phase -+ // phantoms). -+ const focused = findFocusedIndexFromMarker(e.target, containerRef.current); -+ const focusedIndex = (_a = focused === null || focused === void 0 ? void 0 : focused.index) !== null && _a !== void 0 ? _a : null; -+ const focusedDepth = (_b = focused === null || focused === void 0 ? void 0 : focused.depth) !== null && _b !== void 0 ? _b : null; -+ const isSameLogicalRow = focusedIndex !== null && -+ focusedIndex === lastFocusedIndexRef.current && -+ focusedDepth === lastFocusedDepthRef.current; -+ const isPhantomMutationFocus = e.relatedTarget === null && focusedIndex !== null; -+ if (isSameLogicalRow || isPhantomMutationFocus) { -+ return; -+ } -+ lastFocusedIndexRef.current = focusedIndex; -+ lastFocusedDepthRef.current = focusedDepth; -+ lastFocusTimeRef.current = Date.now(); -+ maybeDoSortOnFocus(); -+ }; -+ container.addEventListener("focusin", onFocusIn); -+ return () => container.removeEventListener("focusin", onFocusIn); -+ }, [maybeDoSortOnFocus]); -+ useEffect(() => { -+ if (Platform.OS !== "web") { -+ return; -+ } -+ maybeDoSortOnScroll(); -+ return clearPendingSort; -+ // eslint-disable-next-line react-hooks/exhaustive-deps -+ }, [renderStack, renderId]); -+ // Track input modality globally. Capture-phase document listeners so -+ // we observe events before any handler can call `stopPropagation()`. -+ // `pointerdown` covers mouse/touch/pen; `keydown` covers Tab and -+ // assistive technologies (e.g. VoiceOver injects keydowns). -+ useEffect(() => { -+ if (Platform.OS !== "web") { -+ return; -+ } -+ const onDocKeyDown = () => { -+ lastInputModalityRef.current = "keyboard"; -+ }; -+ const onDocPointerDown = () => { -+ lastInputModalityRef.current = "pointer"; -+ }; -+ document.addEventListener("keydown", onDocKeyDown, true); -+ document.addEventListener("pointerdown", onDocPointerDown, true); -+ return () => { -+ document.removeEventListener("keydown", onDocKeyDown, true); -+ document.removeEventListener("pointerdown", onDocPointerDown, true); -+ }; -+ }, []); -+ return (React.createElement(CompatView, { ref: containerRef, style: hasData && containerStyle }, containerLayout && - hasData && -- Array.from(renderStack.entries(), ([reactKey, { index }]) => { -+ renderEntriesRef.current.map(([reactKey, { index }]) => { - const item = data[index]; - // Suppress separators for items in the last row to prevent - // height mismatch. The last data item has no separator (no -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts -index 62d55cd..b715484 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts -@@ -24,5 +24,12 @@ export declare function useRecyclerViewController(recyclerViewManager: Recycl - computeFirstVisibleIndexForOffsetCorrection: () => void; - applyInitialScrollIndex: () => void; - handlerMethods: FlashListRef; -+ isScrollingProgrammatically: () => boolean; -+ isScrolling: () => boolean; -+ runAfterProgrammaticScroll: (cb: () => void) => void; -+ notifyProgrammaticScrollSettled: () => void; -+ notifyScrollActive: () => void; -+ notifyScrollSettled: () => void; -+ getLastScrollTime: () => number; - }; - //# sourceMappingURL=useRecyclerViewController.d.ts.map -\ No newline at end of file -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 165b080..18e59ce 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -25,6 +25,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - const isUnmounted = useUnmountFlag(); - const [_, setRenderId] = useState(0); - const pauseOffsetCorrection = useRef(false); -+ // True while a `scrollToIndex` / `scrollToOffset` smooth scroll is in -+ // flight. Cleared exactly once on `isMomentumEnd` via -+ // `notifyProgrammaticScrollSettled`. -+ const isProgrammaticScrollActiveRef = useRef(false); -+ // Set by `announceProgrammaticScroll()` to announce an imminent scroll. -+ // Handed off to `isProgrammaticScrollActiveRef` at `scrollToIndex` entry. -+ const isProgrammaticScrollQueuedRef = useRef(false); -+ // Source-agnostic "viewport in motion" flag. -+ const isScrollingRef = useRef(false); -+ // Timestamp of the most recent scroll event; used to correlate scroll -+ // and focus events for the focus-induced-scroll heuristic. -+ const lastScrollTimeRef = useRef(0); -+ // Holds at most one callback registered via `runAfterProgrammaticScroll`, -+ // drained from `notifyProgrammaticScrollSettled`. -+ const pendingAfterScrollRef = useRef(null); - const pendingAndroidInvertedRafId = useRef(null); - const skipNextAndroidInvertedCorrection = useRef(false); - const lastDataLengthRef = useRef(recyclerViewManager.getDataLength()); -@@ -180,6 +195,33 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - updateScrollOffsetWithCallback, - computeFirstVisibleIndexForOffsetCorrection, - ]); -+ const isScrollingProgrammatically = useCallback(() => isProgrammaticScrollActiveRef.current || -+ isProgrammaticScrollQueuedRef.current, []); -+ const isScrolling = useCallback(() => isScrollingRef.current, []); -+ const runAfterProgrammaticScroll = useCallback((cb) => { -+ pendingAfterScrollRef.current = cb; -+ }, []); -+ // Public API; see `FlashListRef#announceProgrammaticScroll`. -+ const announceProgrammaticScroll = useCallback(() => { -+ isProgrammaticScrollQueuedRef.current = true; -+ }, []); -+ // Invoked from `RecyclerView.onScrollHandler` on `isMomentumEnd` (~100ms -+ // after the last scroll event). Drains the pending callback if any. -+ const notifyProgrammaticScrollSettled = useCallback(() => { -+ isProgrammaticScrollActiveRef.current = false; -+ isProgrammaticScrollQueuedRef.current = false; -+ const cb = pendingAfterScrollRef.current; -+ pendingAfterScrollRef.current = null; -+ cb === null || cb === void 0 ? void 0 : cb(); -+ }, []); -+ const notifyScrollActive = useCallback(() => { -+ isScrollingRef.current = true; -+ lastScrollTimeRef.current = Date.now(); -+ }, []); -+ const notifyScrollSettled = useCallback(() => { -+ isScrollingRef.current = false; -+ }, []); -+ const getLastScrollTime = useCallback(() => lastScrollTimeRef.current, []); - const handlerMethods = useMemo(() => { - return { - get props() { -@@ -271,6 +313,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - animated, - }); - }, -+ /** -+ * Announces an imminent programmatic scroll. See -+ * `FlashListRef#announceProgrammaticScroll` for full semantics. -+ */ -+ announceProgrammaticScroll, - /** - * Scrolls to a specific index in the list. - * Supports viewPosition and viewOffset for precise positioning. -@@ -292,6 +339,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - // Pause the scroll offset adjustments - pauseOffsetCorrection.current = true; - recyclerViewManager.setOffsetProjectionEnabled(false); -+ // Cleared on `isMomentumEnd` via `notifyProgrammaticScrollSettled`. -+ // Hand off "queued" → "active" here so any stale queue flag -+ // can't gate sorts indefinitely. -+ isProgrammaticScrollQueuedRef.current = false; -+ isProgrammaticScrollActiveRef.current = true; - const getFinalOffset = () => { - const layout = recyclerViewManager.getLayout(index); - const offset = horizontal ? layout.x : layout.y; -@@ -496,6 +548,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - setTimeout, - isUnmounted, - updateScrollOffsetWithCallback, -+ announceProgrammaticScroll, - ]); - const applyInitialScrollIndex = useCallback(() => { - var _a, _b, _c; -@@ -550,6 +603,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - computeFirstVisibleIndexForOffsetCorrection, - applyInitialScrollIndex, - handlerMethods, -+ isScrollingProgrammatically, -+ isScrolling, -+ runAfterProgrammaticScroll, -+ notifyProgrammaticScrollSettled, -+ notifyScrollActive, -+ notifyScrollSettled, -+ getLastScrollTime, - }; - } - //# sourceMappingURL=useRecyclerViewController.js.map -\ No newline at end of file -diff --git a/node_modules/@shopify/flash-list/src/FlashListRef.ts b/node_modules/@shopify/flash-list/src/FlashListRef.ts -index 07bac2a..af9ee7d 100644 ---- a/node_modules/@shopify/flash-list/src/FlashListRef.ts -+++ b/node_modules/@shopify/flash-list/src/FlashListRef.ts -@@ -181,6 +181,31 @@ export interface FlashListRef { - */ - scrollToIndex: (params: ScrollToIndexParams) => Promise; - -+ /** -+ * Announces an imminent programmatic scroll before `scrollToIndex` is -+ * actually called, so DOM-mutating side-effects gated on -+ * `isScrollingProgrammatically()` (notably the on-web sort applied by -+ * `ViewHolderCollection`) defer until the upcoming smooth scroll -+ * settles, rather than running synchronously and cancelling it. -+ * -+ * Useful when the focus assignment happens first and `scrollToIndex` -+ * follows a few ticks later — as long as the call is guaranteed to -+ * happen, queue it up front so the intervening `focusin` doesn't -+ * trigger an immediate sort that the smooth scroll would then cancel. -+ * -+ * Cleared automatically when the next `scrollToIndex` is invoked -+ * (handed off to the in-flight flag) and again when the resulting -+ * scroll's momentum ends. Safe to call multiple times. -+ * -+ * @example -+ * listRef.current?.announceProgrammaticScroll(); -+ * itemDomNode.focus(); -+ * setTimeout(() => { -+ * listRef.current?.scrollToIndex({ index: nextIndex, animated: true }); -+ * }, 0); -+ */ -+ announceProgrammaticScroll: () => void; -+ - /** - * Scrolls to a specific item in the list. - * diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch deleted file mode 100644 index 03a1dbf1249d..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch +++ /dev/null @@ -1,126 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -index 12375d9..bfc3ee2 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -@@ -1,4 +1,13 @@ -+import { Platform } from "react-native"; - import { RVLayoutManager, } from "./LayoutManager"; -+// How many recent widths we keep while watching for a scrollbar flicker. -+const BOUNDED_SIZE_HISTORY_LENGTH = 8; -+// One scrollbar toggle only flips twice, so three means it's bouncing. -+const MIN_OSCILLATION_FLIPS = 3; -+// Scrollbars are ~15-17px. A jump this small is a scrollbar, not a resize. -+const SCROLLBAR_OSCILLATION_TOLERANCE = 25; -+// How many times we trust the wider width before we keep the lock instead. -+const MAX_LOCK_RELEASE_CYCLES = 1; - /** - * LinearLayoutManager implementation that arranges items in a single row or column. - * Supports both horizontal and vertical layouts with dynamic item sizing. -@@ -10,6 +19,8 @@ - this.hasSize = false; - /** Height of the tallest item */ - this.tallestItemHeight = 0; -+ /** How many times the scrollbar lock has been released for the current pair of widths */ -+ this.scrollbarLockReleases = 0; - this.boundedSize = this.horizontal - ? params.windowSize.height - : params.windowSize.width; -@@ -23,9 +34,15 @@ - const prevHorizontal = this.horizontal; - super.updateLayoutParams(params); - const oldBoundedSize = this.boundedSize; -- this.boundedSize = this.horizontal -+ const measuredBoundedSize = this.horizontal - ? params.windowSize.height - : params.windowSize.width; -+ // On web, a scrollbar showing and hiding can kick off an endless re-layout loop that -+ // crashes the app. Native scrollbars float on top and never do this, so only guard on web. -+ this.boundedSize = -+ Platform.OS === "web" -+ ? this.settleScrollbarOscillation(measuredBoundedSize) -+ : measuredBoundedSize; - if (oldBoundedSize !== this.boundedSize || - prevHorizontal !== this.horizontal) { - if (this.layouts.length > 0) { -@@ -36,6 +53,81 @@ - } - } - /** -+ * Web only. Stops the re-layout loop caused by a scrollbar that keeps showing and hiding. -+ * -+ * The list measures its width without the scrollbar, so each toggle changes the width, which -+ * relayouts, which changes the height, which toggles the scrollbar again. React eventually -+ * gives up with "Maximum update depth exceeded" (#185). -+ * -+ * We watch for a width bouncing between two values a scrollbar apart and lock to the smaller -+ * one. The toggle can lag a frame, so the bounce is often A,A,B,B rather than A,B,A,B. A real -+ * resize passes through many widths and never looks like this. -+ * -+ * The lock has to survive the wider width. These lists get shorter as they narrow, so once we -+ * lock, the scrollbar goes away and the wider width comes right back. Letting go every time -+ * just slows the loop down. -+ * @param measuredBoundedSize Cross-axis size we just measured -+ * @returns The cross-axis size to lay out with -+ */ -+ settleScrollbarOscillation(measuredBoundedSize) { -+ var _a; -+ // Round to whole pixels so subpixel drift doesn't break the checks below. -+ const size = Math.round(measuredBoundedSize); -+ const settledPair = this.scrollbarOscillationPair; -+ if (settledPair) { -+ const [smaller, larger] = settledPair; -+ // Still on the smaller width, so the scrollbar is still there. Keep the lock. -+ if (size === smaller) { -+ return smaller; -+ } -+ if (size === larger) { -+ // Wider again. Either the scrollbar went away for good (happens once) or the -+ // flicker is still going (happens every round). Trust it once, then stop. -+ // Not reset on re-lock, or a flicker would top it up forever. -+ this.scrollbarLockReleases++; -+ if (this.scrollbarLockReleases > MAX_LOCK_RELEASE_CYCLES) { -+ return smaller; -+ } -+ } -+ else { -+ // Some other width, so this is a real resize and the pair is stale. -+ this.scrollbarLockReleases = 0; -+ } -+ this.scrollbarOscillationPair = undefined; -+ this.recentBoundedSizes = undefined; -+ } -+ const history = ((_a = this.recentBoundedSizes) !== null && _a !== void 0 ? _a : (this.recentBoundedSizes = [])); -+ history.push(size); -+ if (history.length > BOUNDED_SIZE_HISTORY_LENGTH) { -+ history.shift(); -+ } -+ const distinctSizes = Array.from(new Set(history)); -+ if (distinctSizes.length !== 2 || -+ Math.abs(distinctSizes[0] - distinctSizes[1]) > SCROLLBAR_OSCILLATION_TOLERANCE) { -+ return measuredBoundedSize; -+ } -+ let flips = 0; -+ for (let i = 1; i < history.length; i++) { -+ if (history[i] !== history[i - 1]) { -+ flips++; -+ } -+ } -+ if (flips < MIN_OSCILLATION_FLIPS) { -+ return measuredBoundedSize; -+ } -+ const smaller = Math.min(distinctSizes[0], distinctSizes[1]); -+ // Only lock on a smaller-width frame so we lock to the width that's on screen. An old -+ // bounce can still be in the history, and locking then would shrink rows for no reason. -+ if (size !== smaller) { -+ return measuredBoundedSize; -+ } -+ this.scrollbarOscillationPair = [ -+ smaller, -+ Math.max(distinctSizes[0], distinctSizes[1]), -+ ]; -+ return smaller; -+ } -+ /** - * Processes layout information for items, updating their dimensions. - * For horizontal layouts, also normalizes heights of items. - * @param layoutInfo Array of layout information for items diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch deleted file mode 100644 index 521561d5ef2b..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch +++ /dev/null @@ -1,166 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -index fa786bf..586014c 100644 ---- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -+++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -@@ -127,10 +127,12 @@ export interface FlashListProps extends Omit 0) { -+ initialItemOffset = Math.max(0, initialItemOffset - (windowSize - itemSize) * viewPosition); -+ } -+ } - this.engagedIndicesTracker.scrollOffset = initialItemOffset; - } - else { -@@ -317,8 +332,20 @@ export class RecyclerViewManager { - this.applyInitialScrollAdjustment(); - const visibleIndices = this.computeVisibleIndices(); - // console.log("---------> visibleIndices", visibleIndices); -- this.hasRenderedProgressively = visibleIndices.every((index) => layoutManager.getLayout(index).isHeightMeasured && -- layoutManager.getLayout(index).isWidthMeasured); -+ const isFullyMeasured = (index) => layoutManager.getLayout(index).isHeightMeasured && -+ layoutManager.getLayout(index).isWidthMeasured; -+ // With an explicit initialScrollIndex, also wait for the drawDistance buffer to be measured before -+ // completing the first layout, so estimate-driven layout shifts converge before anything is on screen. -+ let targetIndices = visibleIndices; -+ if (this.propsRef.initialScrollIndex !== undefined && visibleIndices.length > 0 && visibleIndices.every(isFullyMeasured)) { -+ const windowSize = this.propsRef.horizontal ? this.getWindowSize().width : this.getWindowSize().height; -+ const viewportStart = this.engagedIndicesTracker.scrollOffset; -+ // Cover the worst-case one-sided buffer the engaged tracker can mount after -+ // first layout (totalBuffer * largeMultiplier in the scroll direction). -+ const bufferDistance = this.engagedIndicesTracker.drawDistance * 2 * this.engagedIndicesTracker.largeMultiplier; -+ targetIndices = layoutManager.getVisibleLayouts(Math.max(0, viewportStart - bufferDistance), viewportStart + windowSize + bufferDistance); -+ } -+ this.hasRenderedProgressively = targetIndices.every(isFullyMeasured); - if (this.hasRenderedProgressively) { - this.isFirstLayoutComplete = true; - } -@@ -327,9 +354,13 @@ export class RecyclerViewManager { - // If everything is measured then render stack will be in sync. The buffer items will get rendered in the next update - // triggered by the useOnLoad hook. - !this.hasRenderedProgressively && -- this.updateRenderStack( -- // pick first n indices from visible ones based on batch size -- visibleIndices.slice(0, Math.min(visibleIndices.length, this.getRenderStack().size + batchSize))); -+ this.updateRenderStack(targetIndices === visibleIndices -+ ? // pick first n indices from visible ones based on batch size -+ visibleIndices.slice(0, Math.min(visibleIndices.length, this.getRenderStack().size + batchSize)) -+ : // buffer phase: visible items are already measured, mount the whole -+ // buffer window at once. Same single-commit cost the engaged tracker -+ // would pay post-paint, just moved to where nothing is visible yet. -+ targetIndices); - } - } - getItemType(index) { -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 18e59ce..40bdddb 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -25,6 +25,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - const isUnmounted = useUnmountFlag(); - const [_, setRenderId] = useState(0); - const pauseOffsetCorrection = useRef(false); -+ // Latest offset computed by applyInitialScrollIndex. The deferred (setTimeout) re-scroll reads this at -+ // fire-time instead of the value it closed over, so a stale timeout scheduled by an earlier commit can't -+ // snap back to an outdated offset after a newer commit. -+ const latestInitialScrollOffsetRef = useRef(0); - // True while a `scrollToIndex` / `scrollToOffset` smooth scroll is in - // flight. Cleared exactly once on `isMomentumEnd` via - // `notifyProgrammaticScrollSettled`. -@@ -566,18 +570,55 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - }, 500); - pauseOffsetCorrection.current = true; - const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; -- const offset = horizontal -- ? recyclerViewManager.getLayout(initialScrollIndex).x + additionalOffset -- : recyclerViewManager.getLayout(initialScrollIndex).y + -- additionalOffset; -+ const initialItemLayout = recyclerViewManager.getLayout(initialScrollIndex); -+ let offset = (horizontal ? initialItemLayout.x : initialItemLayout.y) + -+ additionalOffset; -+ // Position the target item within the viewport (0 = start, 0.5 = center, 1 = end), mirroring scrollToIndex. -+ const viewPosition = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewPosition; -+ if (viewPosition !== undefined) { -+ const containerSize = horizontal -+ ? recyclerViewManager.getWindowSize().width -+ : recyclerViewManager.getWindowSize().height; -+ const itemSize = horizontal -+ ? initialItemLayout.width -+ : initialItemLayout.height; -+ if (containerSize > 0) { -+ offset = Math.max(0, offset - (containerSize - itemSize) * viewPosition); -+ } -+ } -+ // Make it clear there are more items to scroll to underneath the bottom edge. -+ // If the bottom item is (essentially) fully visible against the bottom edge AND there -+ // is an item underneath it, nudge the bottom edge up so CROP_OFFSET px of the current -+ // bottom item gets cropped, signalling that more content can be scrolled into view. -+ if (viewPosition !== undefined && !horizontal && recyclerViewManager.props.inverted && offset > 0) { -+ const CROP_OFFSET = 10; -+ let bottomIndex = -1; -+ for (let i = initialScrollIndex; i >= 0; i--) { -+ if (recyclerViewManager.getLayout(i).y <= offset) { -+ bottomIndex = i; -+ break; -+ } -+ } -+ if (bottomIndex > 0) { -+ const bottomItemLayout = recyclerViewManager.getLayout(bottomIndex); -+ const hiddenPortion = offset - bottomItemLayout.y; -+ // 8px is bottom padding of every item -+ if (hiddenPortion <= 8) { -+ // Crop the current bottom item rather than letting it sit flush against the edge. -+ offset = bottomItemLayout.y + CROP_OFFSET; -+ } -+ } -+ } -+ latestInitialScrollOffsetRef.current = offset; - handlerMethods.scrollToOffset({ - offset, - animated: false, - skipFirstItemOffset: false, - }); -+ - setTimeout(() => { - handlerMethods.scrollToOffset({ -- offset, -+ offset: latestInitialScrollOffsetRef.current, - animated: false, - skipFirstItemOffset: false, - }); diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch deleted file mode 100644 index 971a05fa078b..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch +++ /dev/null @@ -1,104 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -index fa786bf..014eb62 100644 ---- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -+++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -@@ -99,6 +99,15 @@ export interface FlashListProps extends Omit | React.ExoticComponent | React.FC; -+ /** -+ * When set, the list uses this as its visible window size instead of measuring its outer container. -+ * Intended for externally-driven lists (a custom non-scrolling `renderScrollComponent` fed synthetic scroll -+ * events), where the outer container is as tall as the full content and can't be used as the viewport. -+ */ -+ overrideWindowSize?: { -+ width: number; -+ height: number; -+ }; - /** - * Draw distance for advanced rendering (in dp/px) - */ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index ef4daf2..063fd1a 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -28,7 +28,7 @@ import { RenderTimeTracker } from "./helpers/RenderTimeTracker"; - const RecyclerViewComponent = (props, ref) => { - var _a, _b, _c, _d; - // Destructure props and initialize refs -- const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, ...rest } = props; -+ const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, overrideWindowSize, ...rest } = props; - const [renderTimeTracker] = useState(() => new RenderTimeTracker()); - renderTimeTracker.startTracking(); - // Sticky header config -@@ -147,12 +147,15 @@ const RecyclerViewComponent = (props, ref) => { - : horizontal - ? firstChildViewLayout.x - : firstChildViewLayout.y; -+ // overrideWindowSize lets an externally-driven list (no own scroller, container is as tall as the full -+ // content) declare its visible window; everything else still uses the real measurement. -+ const windowSize = overrideWindowSize !== null && overrideWindowSize !== void 0 ? overrideWindowSize : outerViewSize; - // Update the RecyclerView manager with window dimensions - recyclerViewManager.updateLayoutParams({ -- width: horizontal ? outerViewSize.width : firstChildViewLayout.width, -+ width: horizontal ? windowSize.width : firstChildViewLayout.width, - height: horizontal - ? firstChildViewLayout.height -- : outerViewSize.height, -+ : windowSize.height, - }, isHorizontalRTL && recyclerViewManager.hasLayout() - ? firstItemOffset - - recyclerViewManager.getChildContainerDimensions().width -diff --git a/node_modules/@shopify/flash-list/src/FlashListProps.ts b/node_modules/@shopify/flash-list/src/FlashListProps.ts -index 76dd0c8..5a5c0f5 100644 ---- a/node_modules/@shopify/flash-list/src/FlashListProps.ts -+++ b/node_modules/@shopify/flash-list/src/FlashListProps.ts -@@ -160,6 +160,16 @@ export interface FlashListProps - | React.ExoticComponent - | React.FC; - -+ /** -+ * When set, the list uses this as its visible window size instead of measuring its outer container. -+ * Intended for externally-driven lists (a custom non-scrolling `renderScrollComponent` fed synthetic scroll -+ * events), where the outer container is as tall as the full content and can't be used as the viewport. -+ */ -+ overrideWindowSize?: { -+ width: number; -+ height: number; -+ }; -+ - /** - * Draw distance for advanced rendering (in dp/px) - */ -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index bc27739..a7829d5 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -90,6 +90,7 @@ const RecyclerViewComponent = ( - onChangeStickyIndex, - stickyHeaderConfig, - inverted, -+ overrideWindowSize, - ...rest - } = props; - -@@ -265,13 +266,17 @@ const RecyclerViewComponent = ( - ? firstChildViewLayout.x - : firstChildViewLayout.y; - -+ // overrideWindowSize lets an externally-driven list (no own scroller, container is as tall as the full -+ // content) declare its visible window; everything else still uses the real measurement. -+ const windowSize = overrideWindowSize ?? outerViewSize; -+ - // Update the RecyclerView manager with window dimensions - recyclerViewManager.updateLayoutParams( - { -- width: horizontal ? outerViewSize.width : firstChildViewLayout.width, -+ width: horizontal ? windowSize.width : firstChildViewLayout.width, - height: horizontal - ? firstChildViewLayout.height -- : outerViewSize.height, -+ : windowSize.height, - }, - isHorizontalRTL && recyclerViewManager.hasLayout() - ? firstItemOffset - diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch deleted file mode 100644 index 5d59a58de2ff..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch +++ /dev/null @@ -1,358 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 063fd1a..c37ff3a 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -349,8 +349,27 @@ const RecyclerViewComponent = (props, ref) => { - recyclerViewContext.layout(); - } - }, [recyclerViewContext, recyclerViewManager]); -+ // The ListHeaderComponent can resize on its own (async content settling inside it) without this component -+ // re-rendering — firstItemOffset is only re-measured in this component's layout effects, so items would keep -+ // stale on-screen positions and applyOffsetCorrection would never see the header delta. Trigger a layout pass -+ // when the header's main-axis size changes. Inverted lists skip this: firstItemOffset is forced to 0 there, -+ // so a header resize cannot shift item positions. -+ const lastHeaderSizeRef = useRef(-1); -+ const onHeaderLayout = useCallback((event) => { -+ if (inverted) { -+ return; -+ } -+ const headerSize = horizontal -+ ? event.nativeEvent.layout.width -+ : event.nativeEvent.layout.height; -+ if (lastHeaderSizeRef.current >= 0 && -+ areDimensionsNotEqual(lastHeaderSizeRef.current, headerSize)) { -+ recyclerViewContext.layout(); -+ } -+ lastHeaderSizeRef.current = headerSize; -+ }, [horizontal, inverted, recyclerViewContext]); - // Get secondary props and components -- const { refreshControl, renderHeader, renderFooter, renderEmpty, CompatScrollView, renderStickyHeaderBackdrop, } = useSecondaryProps(props); -+ const { refreshControl, renderHeader, renderFooter, renderEmpty, CompatScrollView, renderStickyHeaderBackdrop, } = useSecondaryProps(props, onHeaderLayout); - if (!recyclerViewManager.getIsFirstLayoutComplete() && - recyclerViewManager.getDataLength() > 0) { - parentRecyclerViewContext === null || parentRecyclerViewContext === void 0 ? void 0 : parentRecyclerViewContext.markChildLayoutAsPending(recyclerViewId); -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 40bdddb..c505e59 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -51,6 +51,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - // Track the first visible item for maintaining scroll position - const firstVisibleItemKey = useRef(undefined); - const firstVisibleItemLayout = useRef(undefined); -+ // firstItemOffset (the ListHeaderComponent's size) at the time the anchor above was captured. Item layouts are -+ // header-relative, so a header resize shifts every item on screen without changing any tracked x/y — the delta -+ // must be captured separately or offset correction is blind to it. -+ const firstVisibleItemFirstItemOffset = useRef(0); - // Queue to store callbacks that should be executed after scroll offset updates - const pendingScrollCallbacks = useRef([]); - // Handle initial scroll position when the list first loads -@@ -82,6 +86,16 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - recyclerViewManager.hasStableDataKeys() && - recyclerViewManager.getDataLength() > 0 && - recyclerViewManager.shouldMaintainVisibleContentPosition()) { -+ // When the viewport top sits inside the ListHeaderComponent, the header — not any data item — is the -+ // user's visual anchor. The Math.max(0, startIndex) clamp below would otherwise anchor item 0 (which can -+ // be far below the fold) and "maintain" its position through header resizes or data prepends, yanking -+ // the viewport away from what the user is looking at. The header's own top never moves, so the correct -+ // correction while it is the anchor is none — drop the tracked item instead. -+ if (recyclerViewManager.getAbsoluteLastScrollOffset() < -+ recyclerViewManager.firstItemOffset) { -+ firstVisibleItemKey.current = undefined; -+ return; -+ } - // Update the tracked first visible item - const firstVisibleIndex = Math.max(0, recyclerViewManager.computeVisibleIndices().startIndex); - if (firstVisibleIndex !== undefined && firstVisibleIndex >= 0) { -@@ -90,6 +104,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - firstVisibleItemLayout.current = { - ...recyclerViewManager.getLayout(firstVisibleIndex), - }; -+ firstVisibleItemFirstItemOffset.current = recyclerViewManager.firstItemOffset; - } - } - }, [recyclerViewManager]); -@@ -128,15 +143,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - : undefined); - if (currentIndexOfFirstVisibleItem !== undefined && - currentIndexOfFirstVisibleItem >= 0) { -- // Calculate the difference in position and apply the offset -- const diff = horizontal -+ // Calculate the difference in position and apply the offset. Item layouts are header-relative, -+ // so a ListHeaderComponent resize shifts every item on screen by the same amount without -+ // changing any layout — it is only observable as a firstItemOffset delta, tracked separately. -+ const layoutDiff = horizontal - ? recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).x - - firstVisibleItemLayout.current.x - : recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).y - - firstVisibleItemLayout.current.y; -+ const diff = layoutDiff + -+ (recyclerViewManager.firstItemOffset - -+ firstVisibleItemFirstItemOffset.current); - firstVisibleItemLayout.current = { - ...recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem), - }; -+ firstVisibleItemFirstItemOffset.current = recyclerViewManager.firstItemOffset; - if (diff !== 0 && - !pauseOffsetCorrection.current && - !recyclerViewManager.animationOptimizationsEnabled) { -@@ -147,13 +168,16 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - } - else if (useAndroidInvertedFallback) { - if (!shouldSkipAndroidInvertedCorrection) { -+ // getAbsoluteLastScrollOffset() already reflects the current firstItemOffset (the -+ // tracker's relative offset only resyncs on scroll events), so the header delta is -+ // already embedded in it — add only the layout diff on scrollTo paths. - const scrollToParams = horizontal - ? { -- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ x: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, - animated: false, - } - : { -- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ y: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, - animated: false, - }; - if (pendingAndroidInvertedRafId.current !== null) { -@@ -169,17 +193,17 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - else { - const scrollToParams = horizontal - ? { -- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ x: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, - animated: false, - } - : { -- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ y: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, - animated: false, - }; - (_c = scrollViewRef.current) === null || _c === void 0 ? void 0 : _c.scrollTo(scrollToParams); - } - if (hasDataChanged) { -- updateScrollOffsetWithCallback(recyclerViewManager.getAbsoluteLastScrollOffset() + diff, () => { }); -+ updateScrollOffsetWithCallback(recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, () => { }); - recyclerViewManager.ignoreScrollEvents = true; - setTimeout(() => { - recyclerViewManager.ignoreScrollEvents = false; -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js -index 4d7a945..26133d6 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js -@@ -21,7 +21,7 @@ import { getInvertedTransformStyle } from "../utils/getInvertedTransformStyle"; - * - renderStickyHeaderBackdrop: The sticky header backdrop component renderer - * - CompatScrollView: The animated scroll component - */ --export function useSecondaryProps(props) { -+export function useSecondaryProps(props, onHeaderLayout) { - const { ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ListEmptyComponent, ListEmptyComponentStyle, renderScrollComponent, refreshing, progressViewOffset, onRefresh, data, refreshControl: customRefreshControl, stickyHeaderConfig, inverted, horizontal, } = props; - const invertedTransformStyle = inverted - ? getInvertedTransformStyle(horizontal) -@@ -45,8 +45,8 @@ export function useSecondaryProps(props) { - if (!ListHeaderComponent) { - return null; - } -- return (React.createElement(CompatView, { style: [ListHeaderComponentStyle, invertedTransformStyle] }, getValidComponent(ListHeaderComponent))); -- }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle]); -+ return (React.createElement(CompatView, { style: [ListHeaderComponentStyle, invertedTransformStyle], onLayout: onHeaderLayout }, getValidComponent(ListHeaderComponent))); -+ }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle, onHeaderLayout]); - /** - * Creates the footer component with optional styling. - */ -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index a7829d5..ec5d7b7 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -16,6 +16,7 @@ import React, { - import { - Animated, - I18nManager, -+ LayoutChangeEvent, - NativeScrollEvent, - NativeSyntheticEvent, - Platform, -@@ -501,6 +502,31 @@ const RecyclerViewComponent = ( - [recyclerViewContext, recyclerViewManager] - ); - -+ // The ListHeaderComponent can resize on its own (async content settling inside it) without this component -+ // re-rendering — firstItemOffset is only re-measured in this component's layout effects, so items would keep -+ // stale on-screen positions and applyOffsetCorrection would never see the header delta. Trigger a layout pass -+ // when the header's main-axis size changes. Inverted lists skip this: firstItemOffset is forced to 0 there, -+ // so a header resize cannot shift item positions. -+ const lastHeaderSizeRef = useRef(-1); -+ const onHeaderLayout = useCallback( -+ (event: LayoutChangeEvent) => { -+ if (inverted) { -+ return; -+ } -+ const headerSize = horizontal -+ ? event.nativeEvent.layout.width -+ : event.nativeEvent.layout.height; -+ if ( -+ lastHeaderSizeRef.current >= 0 && -+ areDimensionsNotEqual(lastHeaderSizeRef.current, headerSize) -+ ) { -+ recyclerViewContext.layout(); -+ } -+ lastHeaderSizeRef.current = headerSize; -+ }, -+ [horizontal, inverted, recyclerViewContext] -+ ); -+ - // Get secondary props and components - const { - refreshControl, -@@ -509,7 +535,7 @@ const RecyclerViewComponent = ( - renderEmpty, - CompatScrollView, - renderStickyHeaderBackdrop, -- } = useSecondaryProps(props); -+ } = useSecondaryProps(props, onHeaderLayout); - - if ( - !recyclerViewManager.getIsFirstLayoutComplete() && -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx -index 3012391..7375752 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx -@@ -57,6 +57,10 @@ export function useRecyclerViewController( - // Track the first visible item for maintaining scroll position - const firstVisibleItemKey = useRef(undefined); - const firstVisibleItemLayout = useRef(undefined); -+ // firstItemOffset (the ListHeaderComponent's size) at the time the anchor above was captured. Item layouts are -+ // header-relative, so a header resize shifts every item on screen without changing any tracked x/y — the delta -+ // must be captured separately or offset correction is blind to it. -+ const firstVisibleItemFirstItemOffset = useRef(0); - - // Queue to store callbacks that should be executed after scroll offset updates - const pendingScrollCallbacks = useRef<(() => void)[]>([]); -@@ -96,6 +100,18 @@ export function useRecyclerViewController( - recyclerViewManager.getDataLength() > 0 && - recyclerViewManager.shouldMaintainVisibleContentPosition() - ) { -+ // When the viewport top sits inside the ListHeaderComponent, the header — not any data item — is the -+ // user's visual anchor. The Math.max(0, startIndex) clamp below would otherwise anchor item 0 (which can -+ // be far below the fold) and "maintain" its position through header resizes or data prepends, yanking -+ // the viewport away from what the user is looking at. The header's own top never moves, so the correct -+ // correction while it is the anchor is none — drop the tracked item instead. -+ if ( -+ recyclerViewManager.getAbsoluteLastScrollOffset() < -+ recyclerViewManager.firstItemOffset -+ ) { -+ firstVisibleItemKey.current = undefined; -+ return; -+ } - // Update the tracked first visible item - const firstVisibleIndex = Math.max( - 0, -@@ -107,6 +123,8 @@ export function useRecyclerViewController( - firstVisibleItemLayout.current = { - ...recyclerViewManager.getLayout(firstVisibleIndex), - }; -+ firstVisibleItemFirstItemOffset.current = -+ recyclerViewManager.firstItemOffset; - } - } - }, [recyclerViewManager]); -@@ -156,15 +174,23 @@ export function useRecyclerViewController( - currentIndexOfFirstVisibleItem !== undefined && - currentIndexOfFirstVisibleItem >= 0 - ) { -- // Calculate the difference in position and apply the offset -- const diff = horizontal -+ // Calculate the difference in position and apply the offset. Item layouts are header-relative, -+ // so a ListHeaderComponent resize shifts every item on screen by the same amount without -+ // changing any layout — it is only observable as a firstItemOffset delta, tracked separately. -+ const layoutDiff = horizontal - ? recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).x - - firstVisibleItemLayout.current!.x - : recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).y - - firstVisibleItemLayout.current!.y; -+ const diff = -+ layoutDiff + -+ (recyclerViewManager.firstItemOffset - -+ firstVisibleItemFirstItemOffset.current); - firstVisibleItemLayout.current = { - ...recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem), - }; -+ firstVisibleItemFirstItemOffset.current = -+ recyclerViewManager.firstItemOffset; - if ( - diff !== 0 && - !pauseOffsetCorrection.current && -@@ -175,20 +201,27 @@ export function useRecyclerViewController( - // console.log("scrollBy", diff); - scrollAnchorRef.current?.scrollBy(diff); - } else { -+ // getAbsoluteLastScrollOffset() already reflects the current firstItemOffset (the -+ // tracker's relative offset only resyncs on scroll events), so the header delta is -+ // already embedded in it — add only the layout diff on scrollTo paths. - const scrollToParams = horizontal - ? { -- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ x: -+ recyclerViewManager.getAbsoluteLastScrollOffset() + -+ layoutDiff, - animated: false, - } - : { -- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ y: -+ recyclerViewManager.getAbsoluteLastScrollOffset() + -+ layoutDiff, - animated: false, - }; - scrollViewRef.current?.scrollTo(scrollToParams); - } - if (hasDataChanged) { - updateScrollOffsetWithCallback( -- recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, - () => {} - ); - recyclerViewManager.ignoreScrollEvents = true; -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx -index a64742c..7be3eb2 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx -@@ -1,4 +1,4 @@ --import { Animated, RefreshControl } from "react-native"; -+import { Animated, LayoutChangeEvent, RefreshControl } from "react-native"; - import React, { useMemo } from "react"; - - import { RecyclerViewProps } from "../RecyclerViewProps"; -@@ -24,7 +24,10 @@ import { getInvertedTransformStyle } from "../utils/getInvertedTransformStyle"; - * - renderStickyHeaderBackdrop: The sticky header backdrop component renderer - * - CompatScrollView: The animated scroll component - */ --export function useSecondaryProps(props: RecyclerViewProps) { -+export function useSecondaryProps( -+ props: RecyclerViewProps, -+ onHeaderLayout?: (event: LayoutChangeEvent) => void -+) { - const { - ListHeaderComponent, - ListHeaderComponentStyle, -@@ -73,11 +76,19 @@ export function useSecondaryProps(props: RecyclerViewProps) { - return null; - } - return ( -- -+ - {getValidComponent(ListHeaderComponent)} - - ); -- }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle]); -+ }, [ -+ ListHeaderComponent, -+ ListHeaderComponentStyle, -+ invertedTransformStyle, -+ onHeaderLayout, -+ ]); - - /** - * Creates the footer component with optional styling. diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch deleted file mode 100644 index 572d6b9790bf..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch +++ /dev/null @@ -1,97 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -467,7 +467,7 @@ function RecyclerView(props) { - isHorizontalRTL && viewToMeasureBoundedSize, - renderHeader, - !isHorizontalRTL && viewToMeasureBoundedSize, -- React.createElement(ViewHolderCollection, { viewHolderCollectionRef: viewHolderCollectionRef, data: data, horizontal: horizontal, renderStack: recyclerViewManager.getRenderStack(), getLayout: (index) => recyclerViewManager.getLayout(index), getAdjustmentMargin: () => { -+ React.createElement(ViewHolderCollection, { viewHolderCollectionRef: viewHolderCollectionRef, data: data, horizontal: horizontal, renderStack: recyclerViewManager.getRenderStack(), getLayout: (index) => recyclerViewManager.tryGetLayout(index), getAdjustmentMargin: () => { - if (!shouldRenderFromBottom || !recyclerViewManager.hasLayout()) { - return 0; - } -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts ---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts -@@ -19,7 +19,7 @@ export interface ViewHolderCollectionProps { - index: number; - }>; - /** Function to get layout information for a specific index */ -- getLayout: (index: number) => RVLayout; -+ getLayout: (index: number) => RVLayout | undefined; - /** Ref to control layout updates from parent components */ - viewHolderCollectionRef: React.Ref; - /** Map to store refs for each ViewHolder instance */ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js ---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -@@ -290,6 +290,13 @@ export function ViewHolderCollection(props) { - return (React.createElement(CompatView, { ref: containerRef, style: hasData && containerStyle }, containerLayout && - hasData && - renderEntriesRef.current.map(([reactKey, { index }]) => { -+ const layout = getLayout(index); -+ // The render stack can retain an entry whose index points past the -+ // end of the current layouts array when the data length shrinks -+ // mid-render. Skip it instead of throwing indexOutOfBounds. -+ if (layout === undefined) { -+ return null; -+ } - const item = data[index]; - // Suppress separators for items in the last row to prevent - // height mismatch. The last data item has no separator (no -@@ -298,7 +305,7 @@ export function ViewHolderCollection(props) { - ? data[index + 1] - : undefined; - return (React.createElement(ViewHolder, { key: reactKey, index: index, item: item, trailingItem: trailingItem, layout: { -- ...getLayout(index), -+ ...layout, - }, refHolder: refHolder, onSizeChanged: onSizeChanged, target: "Cell", renderItem: renderItem, extraData: extraData, CellRendererComponent: CellRendererComponent, ItemSeparatorComponent: ItemSeparatorComponent, horizontal: horizontal, hidden: hideStickyHeaderRelatedCell && currentStickyIndex === index, inverted: inverted })); - }))); - }; -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -705,7 +705,7 @@ function RecyclerView(props: RecyclerViewProps) { - data={data} - horizontal={horizontal} - renderStack={recyclerViewManager.getRenderStack()} -- getLayout={(index) => recyclerViewManager.getLayout(index)} -+ getLayout={(index) => recyclerViewManager.tryGetLayout(index)} - getAdjustmentMargin={() => { - if (!shouldRenderFromBottom || !recyclerViewManager.hasLayout()) { - return 0; -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx b/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx ---- a/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx -@@ -23,7 +23,7 @@ export interface ViewHolderCollectionProps { - /** Map of indices to React keys for each rendered item */ - renderStack: Map; - /** Function to get layout information for a specific index */ -- getLayout: (index: number) => RVLayout; -+ getLayout: (index: number) => RVLayout | undefined; - /** Ref to control layout updates from parent components */ - viewHolderCollectionRef: React.Ref; - /** Map to store refs for each ViewHolder instance */ -@@ -176,6 +176,13 @@ export function ViewHolderCollection(props: ViewHolderCollectionProps) { - {containerLayout && - hasData && - Array.from(renderStack.entries(), ([reactKey, { index }]) => { -+ const layout = getLayout(index); -+ // The render stack can retain an entry whose index points past the -+ // end of the current layouts array when the data length shrinks -+ // mid-render. Skip it instead of throwing indexOutOfBounds. -+ if (layout === undefined) { -+ return null; -+ } - const item = data[index]; - // Suppress separators for items in the last row to prevent - // height mismatch. The last data item has no separator (no -@@ -192,7 +199,7 @@ export function ViewHolderCollection(props: ViewHolderCollectionProps) { - item={item} - trailingItem={trailingItem} - layout={{ -- ...getLayout(index), -+ ...layout, - }} - refHolder={refHolder} - onSizeChanged={onSizeChanged} diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md deleted file mode 100644 index c2a5178df699..000000000000 --- a/patches/@shopify/flash-list/details.md +++ /dev/null @@ -1,203 +0,0 @@ -# `@shopify/flash-list` patches - -### [@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch](@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch) - -- Reason: Fixes height normalization in horizontal FlashList when items change. `LinearLayoutManager.normalizeLayoutHeights` had three issues: - 1. **Screen resize / item shrink**: When items shrink, `tallestItemHeight` was updated prematurely, causing the next cycle to skip re-normalization. Fixed by resetting tallest item tracking when `targetMinHeight === 0` so the next repaint re-detects the tallest item. - 2. **Tallest item removed**: When the tallest item is deleted from the list, all remaining items kept the old `minHeight` forever because no item could pass the `height > minHeight` check. Fixed by detecting when `tallestItem` is no longer in `this.layouts` and resetting tracking with a repaint. - 3. **New smaller item added**: When the tallest item is already tracked, newly added items never got `minHeight` applied because there was no code path to normalize them. Fixed by applying `minHeight`/`height` to any unnormalized items when a tallest item is already tracked. -- Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2096 -- E/App issue: https://github.com/Expensify/App/issues/33725 -- PR introducing patch: https://github.com/Expensify/App/pull/81566 - -### [@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch](@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch) - -- Reason: Prevents FlashList from losing its render state when a navigation stack hides the parent container with `display: none`. Four guards in total — two in `RecyclerView` to skip layout processing while hidden, and two in `useRecyclerViewController` to make scroll methods safe while hidden: - 1. **First `useLayoutEffect`** in `RecyclerView` (measures parent container): After calling `measureParentSize()`, if both width and height are 0, return early before calling `updateLayoutParams()` or updating `containerViewSizeRef`. This preserves the last known valid window size and prevents the layout manager from receiving zero dimensions. - 2. **Second `useLayoutEffect`** in `RecyclerView` (measures individual items): If `containerViewSizeRef.current` is 0x0 (because the first effect bailed out), return early before calling `modifyChildrenLayout()`. This prevents item measurements taken under `display: none` (also 0) from corrupting stored layouts. - 3. **`scrollToIndex`** in `useRecyclerViewController`: When the list is hidden, guards 1/2 leave `layoutManager` undefined. Any `scrollToIndex` call (also reached via `scrollToEnd`, `scrollToItem`, `scrollToTop`) would then throw "LayoutManager is not initialized, window size is unavailable" from `recyclerViewManager.getWindowSize()`. Early-return a resolved Promise when `!recyclerViewManager.hasLayout()` so the call becomes a safe no-op; the list will scroll correctly on its next layout pass. - 4. **`scrollToOffset` RTL+horizontal branch** in `useRecyclerViewController`: Only the `I18nManager.isRTL && horizontal` branch reads `getChildContainerDimensions()` and `getWindowSize()`, both of which throw when `layoutManager` is undefined. Gate the branch on `recyclerViewManager.hasLayout()` so the RTL math is skipped while hidden; the non-RTL / vertical paths are unaffected and continue using the underlying `scrollViewRef.scrollTo()` directly. - When the container becomes visible again, `onLayout` fires (React Native Web uses ResizeObserver), triggering a re-render with correct dimensions so FlashList resumes normally without re-initialization. -- Files changed: `dist/recyclerview/RecyclerView.js` and `dist/recyclerview/hooks/useRecyclerViewController.js`. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/83976 (original), https://github.com/Expensify/App/issues/90756 (scroll-while-hidden follow-up) -- PR introducing patch: https://github.com/Expensify/App/pull/84887 - -### [@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch](@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch) - -- Reason: Fixes inverted scroll direction on web. FlashList uses `scaleY: -1` / `scaleX: -1` CSS transform to visually invert the list, but the browser's native wheel scroll doesn't flip accordingly — scrolling down visually scrolls up and vice versa. This patch adds a `useEffect` in `RecyclerView` that attaches a `wheel` event listener on web when `inverted` is true, intercepting the event, negating the scroll delta, and manually adjusting `scrollTop`/`scrollLeft`. Mirrors the same fix applied in react-native-web's `VirtualizedList`. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/33725 -- PR introducing patch: https://github.com/Expensify/App/pull/85114 - -### [@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch](@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch) - -- Reason: Fixes inverted lists rendering only a few items with white space on scroll. FlashList's `RecyclerView` measures `firstItemOffset` by calling `measureFirstChildLayout` relative to the outer container. When `inverted` is true, the outer container has `scaleY: -1`, which flips the coordinate system — causing the measured y-offset to equal the container height instead of 0. This makes all scroll offsets negative after adjustment (`adjustedOffset = scrollOffset - firstItemOffset`), so the viewport thinks it's in negative space where no items exist. Only items caught by the draw-distance buffer render. The fix forces `firstItemOffset` to 0 for inverted lists, since the transform already handles visual inversion. -- Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2300 -- E/App issue: https://github.com/Expensify/App/issues/33725 -- PR introducing patch: https://github.com/Expensify/App/pull/85114 - -### [@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch](@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch) - -- Reason: Fixes items overlapping on initial load when a list contains nested FlashLists (e.g. a horizontal list inside a chat message). The `RecyclerView` layout measurement `useLayoutEffect` had an early return when `pendingChildIds.size > 0` — while any nested FlashList was still doing its progressive first layout, the parent list skipped ALL measurement processing. This meant newly added items stayed at estimated positions (wrong heights/y-offsets) while being visible (`opacity: 1`), causing overlap. The fix moves the `pendingChildIds` check so that measurements are always collected and processed by the layout manager, but when children are pending, `commitLayout()` is called instead of `setRenderId()`. This updates item positions in `ViewHolderCollection` without triggering a full `RecyclerView` re-render, avoiding the cascading `setState` calls that the original guard was meant to prevent. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/33725 -- PR introducing patch: https://github.com/Expensify/App/pull/85114 - -### [@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch](@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch) - -- Reason: Fixes `maintainVisibleContentPosition` not working on Android for inverted lists when items are prepended (e.g. new messages arriving, or `useFlashListScrollKey` switching from sliced to full data). FlashList's offset correction uses a `ScrollAnchor` component — an invisible absolutely-positioned element whose `top` changes to trigger the native `maintainVisibleContentPosition` on the ScrollView. On Android, where inversion uses `rotate: 180deg` (vs `scaleY: -1` on iOS), this mechanism silently fails: the anchor position changes but the native ScrollView does not adjust its scroll offset. The fix detects the specific case (`inverted && Platform.OS === 'android' && hasDataChanged`) and bypasses `ScrollAnchor` in favor of a deferred `scrollTo` via `requestAnimationFrame`, which fires after the native layout has committed the new content size. Non-inverted lists, iOS, web, and layout-only corrections (no data change) are unaffected and continue using the original code paths. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/33725 -- PR introducing patch: https://github.com/Expensify/App/pull/85114 - -### [@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch](@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch) - -- Reason: Fixes a scroll position reset on iOS when `maintainVisibleContentPosition.disabled` toggles from `true` to `false` (e.g. when `shouldMaintainVisibleContentPosition` changes based on scroll offset). Root cause: `ScrollAnchor` was conditionally rendered based on `shouldMaintainVisibleContentPosition()`. When MVCP was disabled, the anchor unmounted, which made the native Fabric `_firstVisibleView` weak-ref become nil. When MVCP was re-enabled, the anchor remounted at `top: 1,000,000` (its initial position), but `_prevFirstVisibleFrame` was stale at `1,000,000 + X` from the prior anchor instance. `_adjustForMaintainVisibleContentPosition` then computed `deltaY = 0 - (1,000,000 + X)` — a massive negative offset — causing the list to jump to the start. The fix decouples anchor lifetime from the `disabled` flag: `ScrollAnchor` is now always mounted (and `maintainVisibleContentPositionInternal` always non-null) whenever `maintainVisibleContentPosition` prop is defined. The `disabled` flag continues to gate JS-level `scrollBy` corrections in `applyOffsetCorrection` (via `shouldMaintainVisibleContentPosition()`), so the anchor stays in place when MVCP is logically off — the native side always has a live `_firstVisibleView` and a fresh `_prevFirstVisibleFrame` to diff against. -- Files changed: Both `src/recyclerview/RecyclerView.tsx` and `dist/recyclerview/RecyclerView.js`. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/33725 -- PR introducing patch: https://github.com/Expensify/App/pull/88923 - -### [@shopify+flash-list+2.3.0+008+increase-timeout.patch](@shopify+flash-list+2.3.0+008+increase-timeout.patch) - -- Reason: Fixes an initial-render scroll jump on iOS for inverted lists using `initialScrollIndex`. The existing 100 ms `pauseOffsetCorrection` window in `applyInitialScrollIndex` wasn't long enough — MVCP resumed before the corrective `scrollToOffset` had settled, exposing the jump. Bumped to 500 ms. -- Files changed: `dist/recyclerview/hooks/useRecyclerViewController.js` only. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/89768 -- PR introducing patch: https://github.com/Expensify/App/pull/90218 - -### [@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch](@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch) - -- Reason: Prevents stale `ViewHolder.onLayout` callbacks from crashing FlashList after the list data/layout table has changed. `validateItemSize` previously read the stored layout with `recyclerViewManager.getLayout(index)`, which throws when the callback's render-time index is no longer present in the layout manager. The patch uses `recyclerViewManager.tryGetLayout(index)` and returns early when the layout is missing, so obsolete measurements are ignored while current indexes continue through the existing width/height comparison. -- Files changed: Both `src/recyclerview/RecyclerView.tsx` and `dist/recyclerview/RecyclerView.js`. -- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2291 -- E/App issue: https://github.com/Expensify/App/issues/89933 -- PR introducing patch: https://github.com/Expensify/App/pull/91248 - -### [@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch](@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch) - -- Reason: Fixes a "Maximum update depth exceeded" infinite render loop on web (mostly Windows with fractional display scaling). `roundOffPixel` on web was a no-op, so subpixel drift in the child container's `getBoundingClientRect()` width re-triggered `ViewHolderCollection`'s `[fixedContainerSize]` layout effect on every measurement. The patch implements `roundOffPixel` to snap to the device-pixel grid (`Math.round(value * devicePixelRatio) / devicePixelRatio`), matching native `PixelRatio.roundToNearestPixel`. Two measurements that paint the same physical pixel now collapse to the same JS value, breaking the loop. -- Files changed: `dist/recyclerview/utils/measureLayout.web.js` only. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/91584 -- Sentry: https://expensify.sentry.io/issues/APP-DQ2 -- PR introducing patch: https://github.com/Expensify/App/pull/91799 - -### [@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch](@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch) - -- Reason: Fixes scrambled DOM order in virtualized list items on web. FlashList uses `position: absolute` to position items, so visual order is determined by CSS `top`/`left` values rather than DOM order. Due to recycling (reusing ViewHolder components for different data items), the DOM order reflects Map insertion order rather than data index order. This causes three web-specific issues: - - 1. **Screen reader reading order**: Assistive technologies follow DOM order, so items are read in a scrambled sequence that doesn't match the visual layout. - 2. **Keyboard Tab navigation**: Tab key follows DOM order, so focus jumps unpredictably between items instead of following the visual top-to-bottom sequence. - 3. **Cross-item text selection**: Selecting text across multiple list items selects them in DOM order rather than visual order, producing garbled selections. - - **How it works:** - - 1. **Stable render order during scroll**: Render entries are maintained in a ref (`renderEntriesRef`) that preserves its order across renders. On each render, a reconcile step removes keys that left the render stack and appends new keys. Because FlashList's recycling mutates index values in place on shared object references (`keyInfo.index = newIndex`), the entries in the ref always have current index values without needing updates — only the array order can be stale. This means during normal scrolling, React sees children in the same order and produces zero `insertBefore` calls, avoiding any DOM reordering. - - 2. **Deferred sort after scroll** (default `SORT_DELAY_MS` = 1000ms): After scrolling pauses, a single-slot `setTimeout` (armed by `schedulePendingSort`, with the handle held inside `useDeferredCallback`) sorts the ref by data index and triggers a re-render. This is the only moment React reorders DOM nodes via `insertBefore`. The delay gives the browser time to process queued pointer events (hover state cleanup) from CSS position changes before the structural DOM reorder occurs. When the timer fires, it re-checks scroll state via `isScrolling()` — if any scroll is still in progress (a freshly started mousewheel, a continued momentum scroll, etc.), the timer reschedules itself rather than committing, so a long-running scroll never lets a stale timer fire in the middle of motion. The sort uses a separate, sort-only re-render trigger (`bumpSortVersion` from a `useReducer` counter) instead of reusing FlashList's `renderId`, so the sort does not fire lifecycle callbacks (`onCommitLayoutEffect`, `onCommitEffect`) that would cause duplicate `onViewableItemsChanged` or `onEndReached` calls. - - 3. **Focus-aware sort triggering**: Tab navigation walks DOM order on web, so an out-of-date order makes the next Tab press land on the wrong row. A `focusin` event listener on the container resolves which logical row received focus by reading a `data-flashlist-index` DOM marker that each `ViewHolder` renders alongside its children, and routes real focus changes to `maybeDoSortOnFocus`. Spurious refocus events caused by recycling and React's mutation-phase selection-preservation are filtered out so they don't trigger a sort cascade — see [viewholder-marker-and-focus-filter.md](viewholder-marker-and-focus-filter.md) for the full filter design. Tab itself doesn't scroll, but tabbing to a row that's outside the viewport makes the browser auto-scroll to bring it into view; that scroll re-renders the list and runs a separate `maybeDoSortOnScroll` callback. The actual synchronous sort during Tab navigation happens in the scroll callback (see #4); the focus callback typically just schedules a deferred sort. - - 4. **Two `maybeDoSort` callbacks + programmatic-scroll gating**: The focus path and the scroll path have different decisions to make, so the original single `maybeDoSort` is split into two callbacks that cooperate via a one-shot flag (`shouldSortOnNextFocusRef`): - - - **`maybeDoSortOnScroll`** runs from the effect that fires on `renderStack` / `renderId` changes — i.e. whenever recycling produced a new layout. It arms `shouldSortOnNextFocusRef`, evicts any pending-sort timer (a stale timer from a previous scroll's drain cannot fire mid-motion during rapid arrow-key repeats), then picks one of three branches: - - *Programmatic scroll queued or in flight* (`isScrollingProgrammatically()` is true): hand off via `runAfterProgrammaticScroll` → `schedulePendingSort`. Once the scroll settles we still wait an additional `SORT_DELAY_MS` for queued pointer/focus events to land before committing. The flag stays armed. - - *In-motion scroll caused by a recent focus* (`isScrolling()` is true and the last `scroll` event landed within `FOCUS_INDUCED_SCROLL_WINDOW_MS` = 30 ms after the last `focusin`): call `sortItems` synchronously and reset the flag. This is the browser's auto-scroll-into-view from a Tab/focus on an off-viewport row — keeping DOM order synced is critical for the next Tab to land on the right row, even at the cost of perturbing the auto-scroll. **This is the path that does the sync sort during Tab navigation.** - - *Anything else* (user mousewheel/scrollbar/touch, or a quiet list): schedule the deferred sort. The flag stays armed for the next focusin to consume. - - - **`maybeDoSortOnFocus`** runs from the `focusin` listener. It evicts any pending-sort timer; if `shouldSortOnNextFocusRef` is armed it consumes the flag and commits `sortItems` synchronously; either way it then schedules a fresh deferred sort. In the common Tab → auto-scroll flow, `maybeDoSortOnScroll`'s focus-induced branch has already done the sync sort and reset the flag *before* the next focusin gets here, so the sync-sort path inside this callback is mainly a safety net for scroll-less re-renders and for the programmatic-scroll branch (where the flag was armed but no sync sort fired). - - The deferred-sort timer is provided by `useDeferredCallback`, a small inline hook that wraps a single-slot `setTimeout` with a fire-time `shouldDefer` predicate. When the timer expires it re-checks `isScrolling()` and reschedules itself if a scroll is still in progress, so a long-running scroll never lets a stale timer fire in the middle of motion. The "scroll has truly ended" signal driving the programmatic-defer drain is FlashList's existing `isMomentumEnd`, fired by `VelocityTracker` ~100 ms after the last `scroll` event — distance-independent and naturally overlap-safe (the browser merges overlapping smooth scrolls into one). - - 5. **Pre-scroll announcement (`announceProgrammaticScroll`)**: A new public method on `FlashListRef` lets the consumer announce an imminent programmatic scroll *before* `scrollToIndex` is actually called. It flips an "is queued" ref that `isScrollingProgrammatically()` already ORs in, so any sort triggered by an intervening event (notably the `focusin` that fires when the consumer focuses the target row first and only then calls `scrollToIndex`) is correctly held off rather than committing immediately and cancelling the upcoming smooth scroll. The queued flag is handed off to the in-flight ref at `scrollToIndex` entry and finally cleared when the scroll settles, so it cannot get stuck on. - - **Why the deferred approach is necessary:** - - Two distinct web-only hazards make immediate, mid-scroll DOM reordering wrong: - - 1. **Hover/pointer state loss**: When recycling moves items to new CSS positions, the browser queues `mouseleave`/`pointerleave` events for elements that are no longer under the pointer. However, if `insertBefore` executes before the browser has processed those queued pointer events, the structural DOM move interferes with the browser's hover tracking — the pending `mouseleave` is effectively lost, and recycled items retain stale hover/tooltip states. Keeping the array order stable during scrolling and only committing after the list goes idle gives the browser time to drain those events before any reorder. - - 2. **Smooth-scroll cancellation**: When a list row is focused and a sort commit lands during an in-flight smooth `scrollToIndex`, React's commit-time selection-preservation logic saves and writes back `scrollTop` on every scrollable ancestor of the focused element (including the FlashList scroll container). Per CSSOM, writing `scrollTop` performs an instant scroll, which aborts any in-flight `behavior: 'smooth'` animation on that element — the visible "scroll starts then freezes" symptom on long arrow-key navigations. The programmatic-scroll gating in both `maybeDoSort*` callbacks keeps commits out of the smooth-scroll window, so a `scrollToIndex` animation lands only after it has truly ended (`isMomentumEnd`). Browser auto-scroll-into-view triggered by Tab focusing an off-viewport row is intentionally *not* gated this way (see #4 above) — Tab-navigation correctness takes priority over preserving that auto-scroll's centring. - - **Platform gating:** - - On web: render entries are held in the order-preserving ref, the deferred sort fires after scrolling pauses, the `focusin` listener (filtered via the `data-flashlist-index` marker) routes real focus changes through `maybeDoSortOnFocus`, and `maybeDoSortOnScroll` decides per-render whether to sort synchronously, defer until momentum-end, or defer the standard `SORT_DELAY_MS`. The deferred path itself reschedules until any scroll has settled, via `useDeferredCallback`'s timer-fire `isScrolling()` re-check. - On non-web: the ref is set to a fresh `Array.from(renderStack.entries())` on every render, preserving original behavior identically. The marker JSX, the focusin listener, and both `maybeDoSort*` callbacks are gated to web only. - -- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/1955 -- E/App issue: https://github.com/Expensify/App/issues/86126 -- PR introducing patch: https://github.com/Expensify/App/pull/85825 - -### [@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch](@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch) - -- Reason: Fixes a "Maximum update depth exceeded" (#185) infinite render loop on web with classic (non-overlay) scrollbars — i.e. Windows/Linux Chrome and macOS with "Always show scroll bars". - - A vertical list gets its width from `firstChildViewLayout.width`, the scroll viewport's **client** width, which leaves out the scrollbar. So every time the scrollbar shows or hides, the width changes by about 15px. That relayouts, which changes the content height, which toggles the scrollbar again, and it never settles. - - This only happens on lists that get **shorter as they get narrower**. If narrowing made them taller, the scrollbar would stay put after one toggle and the layout would settle on its own. That matters for how the lock is released below. - - `LinearLayoutManager.updateLayoutParams` sends the measured size through `settleScrollbarOscillation` (web only, since native scrollbars overlay the content). We only call it a flicker when all of these are true, so a real resize is never mistaken for one: - - 1. **Two distinct values** in the last 8 rounded sizes. A drag passes through many widths, and rounding absorbs subpixel drift. - 2. **At least 3 flips** between them. One toggle only flips twice. We keep 8 samples rather than 4 because the toggle can lag a frame, so the bounce is often `A,A,B,B`. - 3. **A scrollbar-sized gap**, at most 25px. Classic scrollbars are ~15-17px. - 4. **The current frame is on the smaller value**, so we lock to the width that's actually on screen. - - Then we lock `boundedSize` to the smaller value, which already leaves room for the scrollbar, so rows never overflow. - - **Releasing.** Once we lock, the scrollbar disappears and the next frame measures the wider width again. Releasing as soon as we see it doesn't end the loop, it just makes each round slower. So: - - - The **smaller** value keeps the lock. - - The **larger** value gives the real width back `MAX_LOCK_RELEASE_CYCLES` (1) time, then the lock holds. The counter isn't reset when the same pair locks again, or a flicker would top it up forever. - - Anything **outside the pair** is a real resize, so release and reset. - - With the lock held, `boundedSize` stops changing, `recomputeLayouts` stops running and the re-renders stop. The trade-off: the flicker usually uses up the one allowed release, so the width stays put until the next real resize. A list that later stops needing a scrollbar keeps about 15px of empty space on the right. -- Files changed: `dist/recyclerview/layout-managers/LinearLayoutManager.js` only. -- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2334 -- E/App issue: https://github.com/Expensify/App/issues/91584, https://github.com/Expensify/App/issues/92263, https://github.com/Expensify/App/issues/95719 -- PR introducing patch: https://github.com/Expensify/App/pull/92520 (hardened for #95719) - -### [@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch](@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch) - -- Reason: Adds `viewPosition` support to `initialScrollIndexParams` (0 = start, 0.5 = center, 1 = end — same semantics as `scrollToIndex`'s `viewPosition`). Six changes: - 1. **`applyInitialScrollIndex`** in `useRecyclerViewController.js`: the corrective scroll for `initialScrollIndex` now shifts the target offset by `(containerSize - itemSize) * viewPosition` (clamped to ≥ 0, and skipped while the container is unmeasured), mirroring `scrollToIndex`'s math. - 2. **`applyInitialScrollAdjustment`** in `RecyclerViewManager.js`: the initial render window is anchored with the same `viewPosition` adjustment, so the very first painted frame already renders the items around the centered position — without this, the first frame renders items from the target's raw offset (target at the viewport edge) and visibly jumps once the first corrective scroll lands. - 3. **Bottom crop** in `applyInitialScrollIndex` (`useRecyclerViewController.js`): for inverted vertical lists positioned via `viewPosition`, when the bottom-most visible item is flush against the bottom edge and another item exists underneath it, the offset is nudged up so the current bottom item is cropped by a few pixels — signaling there is more content below. - 4. **`recomputeLayouts` range** in `applyInitialScrollAdjustment` (`RecyclerViewManager.js`): the recompute that precedes reading the target offset is widened from `recomputeLayouts(0, initialScrollIndex)` to `recomputeLayouts(0, this.getDataLength() - 1)`, so every item gets a measured/re-estimated layout before the positioning. - 5. **Deferred re-scroll reads the latest offset** in `applyInitialScrollIndex` (`useRecyclerViewController.js`): the `setTimeout(0)` re-scroll used to close over the `offset` from its own commit. When a later commit recomputed a newer offset before that timeout fired, the stale timeout snapped the list back to the outdated offset — a visible jump. The offset is now stored in `latestInitialScrollOffsetRef` and read at fire-time, so any pending re-scroll targets the current offset instead of a stale one. - 6. **Progressive render covers the drawDistance buffer** in `renderProgressively` (`RecyclerViewManager.js`): with an explicit `initialScrollIndex`, the drawDistance buffer used to mount right after first paint; its measurements re-estimated every still-unmeasured item before the target, which could collapse the content height below the applied scroll offset and make the native ScrollView clamp. Now the progressive-render phase also waits for the buffer around the viewport to be measured, so the layout converges before anything is painted. Only applies when `initialScrollIndex` is set; other lists keep stock behavior. -- Files changed: `dist/FlashListProps.d.ts`, `dist/recyclerview/hooks/useRecyclerViewController.js`, `dist/recyclerview/RecyclerViewManager.js`. -- Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2318 (for point 4) -- E/App issue: https://github.com/Expensify/App/issues/92152 -- PR introducing patch: https://github.com/Expensify/App/pull/93403 - -### [@shopify+flash-list+2.3.0+014+external-window-size.patch](@shopify+flash-list+2.3.0+014+external-window-size.patch) - -- Reason: Adds an **`overrideWindowSize`** prop that lets a list declare its visible window (`{width, height}`) instead of deriving it from `measureParentSize(internalViewRef)`. Needed for an *externally-driven* list — one whose `renderScrollComponent` is a non-scrolling `View` that grows to the full content height and receives synthetic scroll events from a parent scroller. Without this, FlashList measures the outer container (as tall as all content) as its viewport and renders every row, defeating virtualization. The change is minimal: `measureParentSize` is still assigned to `outerViewSize` and used for `containerViewSizeRef` (layout-change detection) and the 0×0 hidden-guard from patch 002; only the `windowSize` fed to `updateLayoutParams` is `overrideWindowSize ?? outerViewSize`. Fully backward compatible — when the prop is unset, `windowSize === outerViewSize` and behavior is byte-identical. Used by `MoneyRequestReportView`'s horizontally-scrollable transaction table (`ExternalScrollFlashListTable`), which windows its rows against the unified list's vertical scroll. -- Files changed: `src/FlashListProps.ts`, `src/recyclerview/RecyclerView.tsx`, `dist/FlashListProps.d.ts`, `dist/recyclerview/RecyclerView.js`. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/91425 -- PR introducing patch: https://github.com/Expensify/App/pull/91422 - -### [@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch](@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch) - -- Reason: Makes `maintainVisibleContentPosition` aware of the `ListHeaderComponent`. Item layouts are header-relative, so MVCP was blind to the header in two symmetric ways: - 1. **Header resize was never corrected**: when the header changes height after layout (e.g. a nested virtualized table settling from estimated to measured row heights, ~400px on a 207-row table), every data item shifts on screen but no tracked `x`/`y` changes — the anchored item (e.g. a deep-linked report action positioned via `initialScrollIndex`) drifts out of the viewport with no correction. Fixed by capturing `firstItemOffset` alongside the anchor layout (`firstVisibleItemFirstItemOffset`) and including its delta in the correction diff. On the `ScrollAnchor.scrollBy` path (iOS/Android) the full diff is applied; on the `scrollTo` fallback paths (web, Android inverted) only the layout diff is added, because `getAbsoluteLastScrollOffset()` already reflects the current `firstItemOffset` (the tracker's relative offset only resyncs on scroll events), so the header delta is already embedded in it. - 2. **Wrong anchor while viewing the header**: `computeFirstVisibleIndexForOffsetCorrection` clamps the anchor to `Math.max(0, startIndex)`, so with the viewport over the header it tracked item 0 — possibly far below the fold — and "maintained" that off-screen item's position through data prepends/header growth, yanking the viewport away from the header (report opening at the top visibly jumped down to the chat). Fixed by dropping the anchor (`firstVisibleItemKey = undefined`) whenever the absolute scroll offset is smaller than `firstItemOffset`: the header is the user's visual anchor then, and its top never moves, so the correct correction is none. - Additionally, `RecyclerView` now observes the header wrapper's `onLayout` (main-axis size only, skipped for inverted lists where `firstItemOffset` is forced to 0) and triggers `recyclerViewContext.layout()` on change. Without this, a header that resizes from a commit inside its own subtree (the nested table settling) never causes the parent list to re-measure `firstItemOffset`, so items keep stale positions and `applyOffsetCorrection` never sees the delta. - Used by `MoneyRequestReportView`'s horizontally-scrollable transaction table, where the whole table is the unified list's `ListHeaderComponent`: deep links into the report actions below the table now stay anchored while the table settles, and MVCP could be re-enabled for that mode (the `{disabled: true}` workaround is removed). -- Files changed: `src/recyclerview/RecyclerView.tsx`, `src/recyclerview/hooks/useRecyclerViewController.tsx`, `src/recyclerview/hooks/useSecondaryProps.tsx`, and their `dist` counterparts. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/91425 -- PR introducing patch: https://github.com/Expensify/App/pull/91422 - -### [@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch](@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch) - -- Reason: Prevents an `index out of bounds, not enough layouts` crash thrown while `ViewHolderCollection` renders. This is the render-path sibling of patch `009`, which only guarded the `validateItemSize` measurement callback. The crash originates in upstream flash-list and reproduces on **every platform** (native crash: `APP-8PG`), not just web. The render stack (`RenderStackManager.keyMap`, returned by `RecyclerViewManager.getRenderStack()`) can hold an entry whose stored `index` exceeds the current `layouts` length when the list `data` shrinks between renders (e.g. deleting a report action, IOU actions being filtered once transactions load, or a Concierge draft being removed). This is a timing gap inside flash-list's own update pipeline: on a data shrink `LayoutManager.modifyLayout` truncates `this.layouts` synchronously (`getLayoutCount()` drops immediately), but the render stack is pruned of the now-out-of-bounds keys only later, when `RenderStackManager.sync()` runs. Any render committed in that gap iterates a `keyMap` still carrying a pre-shrink `index` against the already-shortened `layouts`, so the unguarded `getLayout(index)` wired at `RecyclerView` → `LayoutManager.getLayout` throws. Upstream already guards this same staleness on the measurement path — `modifyLayout` filters stale `layoutInfo` with the comment _"layoutInfo may contain stale indices from ViewHolders that were rendered before the data shrunk"_ — but left the render path unguarded. The patch wires `ViewHolderCollection`'s `getLayout` prop to the bounds-safe `recyclerViewManager.tryGetLayout(index)` and skips (returns `null` for) any render entry whose layout is `undefined`, so a stale index is dropped for that render instead of crashing. Because `keyMap`/`LayoutManager` are shared, platform-agnostic state, the guard applies on both render branches — web's `renderEntriesRef.current.map` and native's `Array.from(renderStack.entries())`. Patch `011` (which introduces web's `renderEntriesRef` copy) only carries the index forward; it is not the source of the stale index. -- Files changed: `src/recyclerview/RecyclerView.tsx`, `src/recyclerview/ViewHolderCollection.tsx`, and their `dist` counterparts (`dist/recyclerview/RecyclerView.js`, `dist/recyclerview/ViewHolderCollection.js`, `dist/recyclerview/ViewHolderCollection.d.ts`). -- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2440 -- E/App issue: https://github.com/Expensify/App/issues/97472 -- Sentry: https://expensify.sentry.io/issues/APP-8PG -- PR introducing patch: https://github.com/Expensify/App/pull/98015 diff --git a/patches/react-native/details.md b/patches/react-native/details.md index 4395fa022dbb..7b69f3793020 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -9,9 +9,10 @@ ### [react-native+0.86.0+002+fixMVCPAndroid.patch](react-native+0.86.0+002+fixMVCPAndroid.patch) -- Reason: Fixes content jumping issues with `MaintainVisibleContentPosition` on Android, particularly in bidirectional pagination scenarios. The patch makes two key improvements: +- Reason: Fixes content jumping issues with `MaintainVisibleContentPosition` on Android, particularly in bidirectional pagination scenarios. The patch: 1. Changes when the first visible view is calculated - now happens on scroll events instead of during Fabric's willMountItems lifecycle, which was causing incorrect updates 2. Improves first visible view selection logic to handle Fabric's z-index-based view reordering by finding the view with the smallest position that's still greater than the scroll position + 3. Preserves a positioned, zero-sized first child as a scroll anchor. LegendList moves this anchor to compensate for item measurements. Selecting its surrounding container or rejecting its empty frame prevents native scroll compensation and makes the chat jump as estimated rows shrink. - Upstream PR/issue: https://github.com/facebook/react-native/pull/46247 - E/App issue: 🛑 - PR Introducing Patch: https://github.com/Expensify/App/pull/46315 (introduced), https://github.com/Expensify/App/pull/45289 (refactored) diff --git a/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch b/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch index 223cce3db9b3..90b705c6df69 100644 --- a/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch +++ b/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch @@ -35,9 +35,16 @@ index 2bee605..ba26e7b 100644 for (i in config.minIndexForVisible until contentView.childCount) { val child = contentView.getChildAt(i) -@@ -128,27 +135,49 @@ internal class MaintainVisibleScrollPositionHelper( +@@ -128,27 +135,57 @@ internal class MaintainVisibleScrollPositionHelper( val position = if (horizontal) child.x + child.width else child.y + child.height ++ // Virtualized lists can use a zero-sized, positioned first child as their scroll anchor. ++ // Preserve it instead of choosing the container that holds all rendered items. ++ if (i == config.minIndexForVisible && child.width == 0 && child.height == 0 && position > currentScroll) { ++ firstVisibleView = child ++ break ++ } ++ // If the child is partially visible or this is the last child, select it as the anchor. - if (position > currentScroll || i == contentView.childCount - 1) { - firstVisibleViewRef = WeakReference(child) @@ -70,7 +77,8 @@ index 2bee605..ba26e7b 100644 } + val frame = Rect() + firstVisibleView.getHitRect(frame) -+ if (frame.width() > 0 || frame.height() > 0) { ++ // Zero-sized anchors have a meaningful position even though they have no area. ++ if (frame.width() > 0 || frame.height() > 0 || frame.left != 0 || frame.top != 0) { + prevFirstVisibleFrame = frame + } else { + prevFirstVisibleFrame = null diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e9258be28a30..4067968dcbb2 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1055,7 +1055,6 @@ const CONST = { PAY_INVOICE_VIA_EXPENSIFY: 'payInvoiceViaExpensify', SUGGESTED_FOLLOWUPS: 'suggestedFollowups', BULK_EDIT: 'bulkEdit', - NEW_MANUAL_EXPENSE_FLOW: 'newManualExpenseFlow', BULK_SUBMIT_APPROVE_PAY: 'bulkSubmitApprovePay', VENDOR_MATCHING: 'vendorMatching', DUALENTRY: 'dualEntry', @@ -9362,11 +9361,13 @@ const CONST = { MERCHANT_RULE_ITEM: 'WorkspaceRules-MerchantRuleItem', REQUIRE_FIELDS_RULE_ITEM: 'WorkspaceRules-RequireFieldsRuleItem', REQUIRE_FIELDS_RULE_SAVE: 'WorkspaceRules-RequireFieldsRuleSave', + REQUIRE_FIELDS_RULE_DELETE: 'WorkspaceRules-RequireFieldsRuleDelete', REQUIRE_FIELDS_RULE_CATEGORY: 'WorkspaceRules-RequireFieldsRuleCategory', REQUIRE_FIELDS_RULE_FIELD_TOGGLE: 'WorkspaceRules-RequireFieldsRuleFieldToggle', REQUIRE_FIELDS_RULE_DIRECTION_TOGGLE: 'WorkspaceRules-RequireFieldsRuleDirectionToggle', FLAG_FOR_REVIEW_RULE_ITEM: 'WorkspaceRules-FlagForReviewRuleItem', FLAG_FOR_REVIEW_RULE_SAVE: 'WorkspaceRules-FlagForReviewRuleSave', + FLAG_FOR_REVIEW_RULE_DELETE: 'WorkspaceRules-FlagForReviewRuleDelete', FLAG_FOR_REVIEW_RULE_CATEGORY: 'WorkspaceRules-FlagForReviewRuleCategory', FLAG_FOR_REVIEW_RULE_AMOUNT: 'WorkspaceRules-FlagForReviewRuleAmount', FLAG_FOR_REVIEW_RULE_EXPENSE_LIMIT_TYPE: 'WorkspaceRules-FlagForReviewRuleExpenseLimitType', @@ -9384,6 +9385,7 @@ const CONST = { CURRENCY_SELECTOR: 'WorkspaceRules-CurrencySelector', SPEND_RULE_SECTION_ITEM: 'WorkspaceRules-SpendRuleSectionItem', SPEND_RULE_SAVE: 'WorkspaceRules-SpendRuleSave', + SPEND_RULE_DELETE: 'WorkspaceRules-SpendRuleDelete', SPEND_RULE_RESTRICTION_TYPE: 'WorkspaceRules-SpendRuleRestrictionType', AGENT_RULE_ITEM: 'WorkspaceRules-AgentRuleItem', ADD_AGENT_RULE: 'WorkspaceRules-AddAgentRule', diff --git a/src/CONST/runtimeConfigured.ts b/src/CONST/runtimeConfigured.ts index 070d61bdf70b..b527c0fd789c 100644 --- a/src/CONST/runtimeConfigured.ts +++ b/src/CONST/runtimeConfigured.ts @@ -75,6 +75,7 @@ const CONST_RUNTIME: ConstRuntime = { SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT, SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP, + SCREENS.PRE_MOUNT_BUFFER, ...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION), ], }; diff --git a/src/CONST/runtimeDefaults.ts b/src/CONST/runtimeDefaults.ts index 0562c91da7c8..e94804ef6431 100644 --- a/src/CONST/runtimeDefaults.ts +++ b/src/CONST/runtimeDefaults.ts @@ -98,6 +98,7 @@ const CONST_RUNTIME_DEFAULTS: ConstRuntime = { 'Dynamic_AIFeaturesPromoModal_Root', 'Money_Request_Step_Scan', 'Members_Move_To_Group', + 'PreMountBuffer', 'Multifactor_Authentication_Validate_Code', 'Multifactor_Authentication_Outcome_Success', 'Multifactor_Authentication_Outcome_Failure', diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index f674b3fe6f44..62d9f6d2acc2 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -215,6 +215,8 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { introSelected, betas, conciergeChat, + // The public room already exists on the server, so no optimistic report is created and the personal details are never read. + personalDetails: undefined, hasReportActions: false, currentUserAccountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 5a559f98b0ef..a0a20048e485 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -619,6 +619,9 @@ const ONYXKEYS = { /** Indicates whether the debug mode is currently enabled */ IS_DEBUG_MODE_ENABLED: 'isDebugModeEnabled', + /** Local overrides for beta feature flags, set from the Test Tool Menu on dev/staging. Takes precedence over the server-provided betas */ + BETA_OVERRIDES: 'betaOverrides', + /** Indicates whether the git branch name should be shown in the browser tab title */ SHOULD_SHOW_BRANCH_NAME_IN_TITLE: 'shouldShowBranchNameInTitle', @@ -768,6 +771,9 @@ const ONYXKEYS = { /** Information about travel provisioning process */ TRAVEL_PROVISIONING: 'travelProvisioning', + /** Signals the UI to show the Enable Global Reimbursements modal when a pay attempt fails because the workspace USD VBBA is not set up on Corpay */ + RAM_ONLY_CORPAY_PAY_MODAL: 'corpayPayModal', + /** Stores the information about the state of side panel */ NVP_SIDE_PANEL: 'nvp_sidePanel', @@ -899,6 +905,12 @@ const ONYXKEYS = { REPORT: 'report_', REPORT_NAME_VALUE_PAIRS: 'reportNameValuePairs_', REPORT_DRAFT: 'reportDraft_', + // Boolean marker (no report data) flagging that report_ is a speculative copy of reportDraft_, written by + // preMountDraftReport so a pre-mounted destination can render before submit. Must persist (not RAM-only): the + // report_ row it points at lives in the persisted REPORT collection, and Onyx RAM-only applies per key or whole + // collection, not per row, so that row survives an app kill (where no unmount cleanup runs). A RAM-only marker would + // vanish while the row stays behind. The next launch uses this marker to find and delete it. + REPORT_PRE_MOUNTED_DRAFT: 'reportPreMountedDraft_', // REPORT_METADATA holds report-level business state that is NOT the report itself // (optimistic flag, pending chat members, report-level errors, DEW pendingExpenseAction). // Loading flags / pagination cursors / last-visit timestamp live in dedicated @@ -1494,6 +1506,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report; [ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]: OnyxTypes.ReportNameValuePairs; [ONYXKEYS.COLLECTION.REPORT_DRAFT]: OnyxTypes.Report; + [ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT]: boolean; [ONYXKEYS.COLLECTION.REPORT_METADATA]: OnyxTypes.ReportMetadata; [ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE]: OnyxTypes.ReportLoadingState; [ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE]: OnyxTypes.CompanyCardsLoadingState; @@ -1755,6 +1768,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ACTIVE_SERVER]: ValueOf; [ONYXKEYS.CLOUDFLARE_SESSION]: OnyxTypes.CloudflareSession; [ONYXKEYS.IS_DEBUG_MODE_ENABLED]: boolean; + [ONYXKEYS.BETA_OVERRIDES]: OnyxTypes.BetaOverrides; [ONYXKEYS.SHOULD_SHOW_BRANCH_NAME_IN_TITLE]: boolean; [ONYXKEYS.IS_SENTRY_DEBUG_ENABLED]: boolean; [ONYXKEYS.IS_SENTRY_SEND_ENABLED]: boolean; @@ -1815,6 +1829,7 @@ type OnyxValuesMapping = { [ONYXKEYS.CORPAY_ONBOARDING_FIELDS]: OnyxTypes.CorpayOnboardingFields; [ONYXKEYS.LAST_FULL_RECONNECT_TIME]: string; [ONYXKEYS.TRAVEL_PROVISIONING]: OnyxTypes.TravelProvisioning; + [ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL]: OnyxTypes.CorpayPayModal; [ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE]: boolean | undefined; [ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING]: boolean | undefined; [ONYXKEYS.BILLING_RECEIPT_DETAILS]: OnyxTypes.BillingReceiptDetails; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 237ebb350a8e..6f1c83cb8fd2 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -93,6 +93,16 @@ type DynamicRoutes = Record; * Avoid for: regular navigation, single-entry workflows * */ +const ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS = [ + SCREENS.REPORT, + SCREENS.RIGHT_MODAL.SEARCH_REPORT, + SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, + SCREENS.HOME, + SCREENS.SEARCH.ROOT, + SCREENS.SETTINGS.WALLET.ROOT, +] as const; + const DYNAMIC_ROUTES = { VERIFY_ACCOUNT: { path: 'verify-account', @@ -173,6 +183,46 @@ const DYNAMIC_ROUTES = { }), queryParams: ['shouldSkipPurposeSelection', 'shouldSetUpUSBankAccount'], }, + ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS: { + path: 'enable-global-reimbursements/business/:bankAccountID/:subPage/:action?', + entryScreens: ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS, + getRoute: (bankAccountID: string | number, subPage: string, action?: 'edit', params?: {bankCountry?: string; bankCurrency?: string}) => + getUrlWithParams(`enable-global-reimbursements/business/${bankAccountID}/${subPage}${action ? `/${action}` : ''}`, { + bankCountry: params?.bankCountry, + bankCurrency: params?.bankCurrency, + }), + queryParams: ['bankCountry', 'bankCurrency'], + }, + ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS: { + path: 'enable-global-reimbursements/agreements/:bankAccountID', + entryScreens: [ + ...ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS, + SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS, + SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS, + ], + getRoute: (bankAccountID: string | number, params?: {bankCountry?: string; bankCurrency?: string}) => + getUrlWithParams(`enable-global-reimbursements/agreements/${bankAccountID}`, { + bankCountry: params?.bankCountry, + bankCurrency: params?.bankCurrency, + }), + queryParams: ['bankCountry', 'bankCurrency'], + }, + ENABLE_GLOBAL_REIMBURSEMENTS_SIGN: { + path: 'enable-global-reimbursements/sign/:bankAccountID', + entryScreens: [ + ...ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS, + SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS, + SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS, + SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS, + SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS, + ], + getRoute: (bankAccountID: string | number, params?: {bankCountry?: string; bankCurrency?: string}) => + getUrlWithParams(`enable-global-reimbursements/sign/${bankAccountID}`, { + bankCountry: params?.bankCountry, + bankCurrency: params?.bankCurrency, + }), + queryParams: ['bankCountry', 'bankCurrency'], + }, BANK_ACCOUNT_VERIFY_ACCOUNT: { path: 'verify-bank-account', entryScreens: [SCREENS.REIMBURSEMENT_ACCOUNT], @@ -2323,16 +2373,27 @@ const ROUTES = { }, SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS: { route: 'settings/wallet/:bankAccountID/enable-global-reimbursements/business/:subPage/:action?', - getRoute: (bankAccountID: number | undefined, subPage: string, action?: 'edit') => - `settings/wallet/${bankAccountID}/enable-global-reimbursements/business/${subPage}${action ? `/${action}` : ''}` as const, + getRoute: (bankAccountID: number | undefined, subPage: string, action?: 'edit', params?: {bankCountry?: string; bankCurrency?: string}) => + getUrlWithParams(`settings/wallet/${bankAccountID}/enable-global-reimbursements/business/${subPage}${action ? `/${action}` : ''}`, { + bankCountry: params?.bankCountry, + bankCurrency: params?.bankCurrency, + }), }, SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS: { route: 'settings/wallet/:bankAccountID/enable-global-reimbursements/agreements', - getRoute: (bankAccountID: number | undefined) => `settings/wallet/${bankAccountID}/enable-global-reimbursements/agreements` as const, + getRoute: (bankAccountID: number | undefined, params?: {bankCountry?: string; bankCurrency?: string}) => + getUrlWithParams(`settings/wallet/${bankAccountID}/enable-global-reimbursements/agreements`, { + bankCountry: params?.bankCountry, + bankCurrency: params?.bankCurrency, + }), }, SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN: { route: 'settings/wallet/:bankAccountID/enable-global-reimbursements/sign', - getRoute: (bankAccountID: number | undefined) => `settings/wallet/${bankAccountID}/enable-global-reimbursements/sign` as const, + getRoute: (bankAccountID: number | undefined, params?: {bankCountry?: string; bankCurrency?: string}) => + getUrlWithParams(`settings/wallet/${bankAccountID}/enable-global-reimbursements/sign`, { + bankCountry: params?.bankCountry, + bankCurrency: params?.bankCurrency, + }), }, SETTINGS_WALLET_SHARE_BANK_ACCOUNT: { route: 'settings/wallet/:bankAccountID/share-bank-account', @@ -2496,6 +2557,7 @@ const ROUTES = { SETTINGS_STATUS_CLEAR_AFTER_TIME: 'settings/profile/status/clear-after/time', SETTINGS_VACATION_DELEGATE: 'settings/profile/status/vacation-delegate', SETTINGS_TROUBLESHOOT: 'settings/troubleshoot', + SETTINGS_TROUBLESHOOT_BETA_OVERRIDES: 'settings/troubleshoot/beta-overrides', SETTINGS_HELP: 'settings/help', SETTINGS_SAVE_THE_WORLD: 'settings/teachersunite', @@ -2510,7 +2572,7 @@ const ROUTES = { REPORT: 'r', REPORT_WITH_ID: { route: 'r/:reportID?/:reportActionID?', - getRoute: (reportID: string | undefined, reportActionID?: string, referrer?: string, backTo?: string, secureKey?: string) => { + getRoute: (reportID: string | undefined, reportActionID?: string, referrer?: string, backTo?: string, secureKey?: string, isPendingCreation?: boolean) => { if (!reportID) { Log.warn('Invalid reportID is used to build the REPORT_WITH_ID route'); return getUrlWithBackToParam(ROUTES.HOME, backTo); @@ -2525,6 +2587,9 @@ const ROUTES = { if (secureKey) { queryParams.push(`secureKey=${encodeURIComponent(secureKey)}`); } + if (isPendingCreation) { + queryParams.push('isPendingCreation=true'); + } const queryString = queryParams.length > 0 ? `?${queryParams.join('&')}` : ''; diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 6336a927a863..ac03bc5ee366 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -189,6 +189,9 @@ const SCREENS = { ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS: 'Settings_Wallet_Enable_Global_Reimbursements_Business', ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS: 'Settings_Wallet_Enable_Global_Reimbursements_Agreements', ENABLE_GLOBAL_REIMBURSEMENTS_SIGN: 'Settings_Wallet_Enable_Global_Reimbursements_Sign', + DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS: 'Dynamic_Settings_Wallet_Enable_Global_Reimbursements_Business', + DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS: 'Dynamic_Settings_Wallet_Enable_Global_Reimbursements_Agreements', + DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN: 'Dynamic_Settings_Wallet_Enable_Global_Reimbursements_Sign', SHARE_BANK_ACCOUNT: 'Settings_Wallet_Share_Bank_Account', TRAVEL_CVV: 'Settings_Wallet_Travel_CVV', TRAVEL_CVV_VERIFY_ACCOUNT: 'Settings_Wallet_Travel_CVV_VerifyAccount', @@ -304,6 +307,7 @@ const SCREENS = { REPORT_EXPORT: 'Report_Export', MISSING_PERSONAL_DETAILS: 'MissingPersonalDetails', DEBUG: 'Debug', + BETA_OVERRIDES: 'BetaOverrides', ADD_EXISTING_EXPENSE: 'AddExistingExpense', SCHEDULE_CALL: 'ScheduleCall', REPORT_CHANGE_APPROVER: 'Report_Change_Approver', @@ -316,6 +320,7 @@ const SCREENS = { CHRONOS_SCHEDULE_OOO: 'Chronos_Schedule_OOO', AVATAR_CROP: 'AvatarCrop', }, + PRE_MOUNT_BUFFER: 'PreMountBuffer', REPORT_CARD_ACTIVATE: 'Report_Card_Activate_Root', SAML_SIGN_IN: 'SAMLSignIn', WORKSPACE_JOIN_USER: 'WorkspaceJoinUser', diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index d2889a00b939..6dc96e12be9d 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -14,6 +14,7 @@ import type {NumberWithSymbolFormRef} from './NumberWithSymbolForm'; import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types'; import NumberWithSymbolForm from './NumberWithSymbolForm'; +import NumericField from './NumericField'; type AmountFormProps = { /** Amount supplied by the FormProvider */ @@ -70,10 +71,10 @@ type AmountFormProps = { /** Callback when the input is focused */ onFocus?: () => void; -} & Pick; +} & Pick; /** - * Wrapper around NumberWithSymbolForm with currency handling. + * Wrapper around the numeric form components with currency handling. */ function AmountForm({ value, @@ -92,8 +93,6 @@ function AmountForm({ currencyButtonAccessibilityLabel, disabled = false, autoFocus, - autoGrowExtraSpace, - autoGrowMarginSide, onSubmitEditing, onFocus, onBlur, @@ -104,6 +103,33 @@ function AmountForm({ const styles = useThemeStyles(); const {getCurrencyDecimals} = useCurrencyListActions(); const decimals = decimalsProp ?? getCurrencyDecimals(currency); + const symbol = getLocalizedCurrencySymbol(preferredLocale, currency) ?? ''; + + // Use NumericField for standard text input. Currency-button variants still use the legacy form. + if (displayAsTextInput && !shouldShowCurrencyButton) { + return ( + + + + ); + } return ( Promise; }; - -/** - * Ensures asset has proper fileName and type properties - */ -const processAssetWithFallbacks = (asset: Asset): Asset => { - // Generate fallback name: extract from URI if available, otherwise use timestamped default - const fallbackName = asset.uri - ? asset.uri - .substring(asset.uri.lastIndexOf('/') + 1) - .split('?') - .at(0) - : `image_${Date.now()}.jpeg`; - const fileName = asset.fileName ?? fallbackName; - return { - ...asset, - fileName, - // Default to JPEG if no type specified - type: asset.type ?? 'image/jpeg', - }; -}; - /** * Return imagePickerOptions based on the type */ @@ -223,68 +201,7 @@ function AttachmentPicker({ return resolve(); } - const processedAssets: Asset[] = []; - let processedCount = 0; - - const checkAllProcessed = () => { - processedCount++; - if (processedCount === assets.length) { - resolve(processedAssets.length > 0 ? processedAssets : undefined); - } - }; - - for (const asset of assets) { - if (!asset.uri) { - checkAllProcessed(); - continue; - } - - if (asset.type?.startsWith('image')) { - verifyFileFormat({fileUri: asset.uri, formatSignatures: CONST.HEIC_SIGNATURES}) - .then((isHEIC) => { - // react-native-image-picker incorrectly changes file extension without transcoding the HEIC file, so we are doing it manually if we detect HEIC signature - if (isHEIC && asset.uri) { - ImageManipulator.manipulate(asset.uri) - .renderAsync() - .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG})) - .then((manipulationResult) => { - const uri = manipulationResult.uri; - const convertedAsset = { - uri, - name: uri - .substring(uri.lastIndexOf('/') + 1) - .split('?') - .at(0), - type: 'image/jpeg', - width: manipulationResult.width, - height: manipulationResult.height, - }; - processedAssets.push(convertedAsset); - checkAllProcessed(); - }) - .catch((error: Error) => { - Log.warn('Failed to convert HEIC image, skipping asset', {error: error.message}); - showGeneralAlert(translate('attachmentPicker.errorWhileConvertingHeic')); - checkAllProcessed(); - }); - } else { - // Ensure the asset has proper fileName and type for non-HEIC images - const processedAsset = processAssetWithFallbacks(asset); - processedAssets.push(processedAsset); - checkAllProcessed(); - } - }) - .catch((error: Error) => { - showGeneralAlert(error.message ?? 'An unknown error occurred'); - checkAllProcessed(); - }); - } else { - // Ensure the asset has proper fileName and type - const processedAsset = processAssetWithFallbacks(asset); - processedAssets.push(processedAsset); - checkAllProcessed(); - } - } + processPickedAssetsSequentially(assets, showGeneralAlert, translate).then(resolve).catch(reject); }); }), [fileLimit, showGeneralAlert, translate, type], diff --git a/src/components/ConfirmationPage.tsx b/src/components/ConfirmationPage.tsx index f147200c9fb4..fdb8a8deea26 100644 --- a/src/components/ConfirmationPage.tsx +++ b/src/components/ConfirmationPage.tsx @@ -54,6 +54,12 @@ type ConfirmationPageProps = { onSecondaryButtonPress?: () => void; shouldShowSecondaryButton?: boolean; + + /** Whether the secondary confirmation button should be disabled */ + isSecondaryButtonDisabled?: boolean; + + /** Whether the secondary confirmation button should show a loading spinner */ + isSecondaryButtonLoading?: boolean; headingStyle?: TextStyle; /** Additional style for the animation */ @@ -85,6 +91,8 @@ function ConfirmationPage({ secondaryButtonText = '', onSecondaryButtonPress = () => {}, shouldShowSecondaryButton = false, + isSecondaryButtonDisabled = false, + isSecondaryButtonLoading = false, headingStyle, illustrationStyle, descriptionStyle, @@ -155,6 +163,8 @@ function ConfirmationPage({ size={CONST.BUTTON_SIZE.LARGE} testID="confirmation-secondary-button" style={styles.mt3} + isDisabled={isSecondaryButtonDisabled} + isLoading={isSecondaryButtonLoading} onPress={onSecondaryButtonPress} > {secondaryButtonText} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx index 59f8dab0210e..3eadb9ad3ec2 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx @@ -10,12 +10,12 @@ import type {EmojiPickerList, EmojiPickerListItem, HeaderIndices} from '@libs/Em import CONST from '@src/CONST'; -import type {FlashListRef, ListRenderItem} from '@shopify/flash-list'; +import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native'; import type {ForwardedRef} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {FlashList} from '@shopify/flash-list'; +import {LegendList} from '@legendapp/list/react-native'; import React from 'react'; import {View} from 'react-native'; @@ -33,7 +33,7 @@ type BaseEmojiPickerMenuProps = { listWrapperStyle?: StyleProp; data: EmojiPickerList; - renderItem: ListRenderItem; + renderItem: NonNullable['renderItem']>; extraData?: Array | ((skinTone: number) => void)>; stickyHeaderIndices?: number[]; alwaysBounceVertical?: boolean; @@ -42,11 +42,11 @@ type BaseEmojiPickerMenuProps = { /** The current search input value, used for accessibility re-announcements */ searchValue?: string; - ref?: ForwardedRef>; + ref?: ForwardedRef; }; /** - * Improves FlashList's recycling when there are different types of items + * Improves LegendList's recycling when there are different types of items */ const getItemType = (item: EmojiPickerListItem): string | undefined => { // item is undefined only when list is empty @@ -116,7 +116,8 @@ function BaseEmojiPickerMenu({ /> )} - } alwaysBounceVertical={alwaysBounceVertical} contentContainerStyle={styles.ph4} - extraData={extraData} + extraData={[extraData, renderItem]} getItemType={getItemType} onMomentumScrollEnd={onMomentumScrollEnd} - overrideProps={{ - // scrollPaddingTop set to consider sticky header while scrolling, https://github.com/Expensify/App/issues/36883 - style: { - minHeight: 1, - minWidth: 1, - scrollPaddingTop: isFiltered ? 0 : CONST.EMOJI_PICKER_ITEM_HEIGHT, - }, + style={{ + minHeight: 1, + minWidth: 1, + // Keep keyboard scrolling below the sticky category header. + scrollPaddingTop: isFiltered ? 0 : CONST.EMOJI_PICKER_ITEM_HEIGHT, }} scrollEnabled={data.length > 0} /> diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx index 40eee6a9aaea..c4f2a795fbe1 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx @@ -19,7 +19,7 @@ import {getRemovedSkinToneEmoji} from '@libs/EmojiUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import type {ListRenderItem} from '@shopify/flash-list'; +import type {LegendListProps} from '@legendapp/list/react-native'; import lodashDebounce from 'lodash/debounce'; import React, {useCallback, useMemo, useRef, useState} from 'react'; @@ -145,8 +145,8 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro * Items with the code "SPACER" return nothing and are used to fill rows up to 8 * so that the sticky headers function properly. */ - const renderItem: ListRenderItem = useCallback( - ({item, target, index}) => { + const renderItem: NonNullable['renderItem']> = useCallback( + ({item, index}) => { const code = item.code; const types = 'types' in item ? item.types : undefined; @@ -161,7 +161,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro accessible accessibilityRole="header" accessibilityLabel={translate(`emojiPicker.headers.${code}` as TranslationPaths)} - style={[styles.emojiHeaderContainer, target === 'StickyHeader' ? styles.mh4 : {width: windowWidth}]} + style={[styles.emojiHeaderContainer, {width: windowWidth}]} onLayout={() => handleHeaderLayout(index)} > {translate(`emojiPicker.headers.${code}` as TranslationPaths)} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx index 49fb15ae2b3f..4568f08c3846 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx @@ -24,7 +24,7 @@ import {shouldAutoFocusOnKeyPress} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import type {ListRenderItem} from '@shopify/flash-list'; +import type {LegendListProps} from '@legendapp/list/react-native'; import throttle from 'lodash/throttle'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -320,8 +320,8 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro * so that the sticky headers function properly. * */ - const renderItem: ListRenderItem = useCallback( - ({item, index, target}) => { + const renderItem: NonNullable['renderItem']> = useCallback( + ({item, index}) => { const code = item.code; const types = 'types' in item ? item.types : undefined; @@ -336,11 +336,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro tabIndex={-1} role={CONST.ROLE.HEADING} onLayout={() => handleHeaderLayout(index)} - style={[ - styles.emojiHeaderContainer, - styles.emojiHeaderContainerWidth(shouldUseNarrowLayout, windowWidth), - target === 'StickyHeader' ? styles.stickyHeaderEmoji : undefined, - ]} + style={[styles.emojiHeaderContainer, styles.emojiHeaderContainerWidth(shouldUseNarrowLayout, windowWidth)]} > {translate(`emojiPicker.headers.${code}` as TranslationPaths)} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts index cb3c2901ca9a..9047783abd1f 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts +++ b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts @@ -8,25 +8,25 @@ import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useStyleUtils from '@hooks/useStyleUtils'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import type {EmojiPickerList, EmojiPickerListItem} from '@libs/EmojiUtils'; +import type {EmojiPickerList} from '@libs/EmojiUtils'; import {getHeaderEmojis, getSpacersIndexes, mergeEmojisWithFrequentlyUsedEmojis, processFrequentlyUsedEmojis, suggestEmojis} from '@libs/EmojiUtils'; import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; import ONYXKEYS from '@src/ONYXKEYS'; import calculateModalHeightInLandscapeMode from '@src/utils/calculateModalHeightInLandscapeMode'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; const useEmojiPickerMenu = () => { - const emojiListRef = useRef>(null); + const emojiListRef = useRef(null); const [frequentlyUsedEmojis] = useOnyx(ONYXKEYS.FREQUENTLY_USED_EMOJIS); - const allEmojis = useMemo(() => mergeEmojisWithFrequentlyUsedEmojis(emojis, processFrequentlyUsedEmojis(frequentlyUsedEmojis)), [frequentlyUsedEmojis]); - const headerEmojis = useMemo(() => getHeaderEmojis(allEmojis), [allEmojis]); - const headerRowIndices = useMemo(() => headerEmojis.map((headerEmoji) => headerEmoji.index), [headerEmojis]); - const spacersIndexes = useMemo(() => getSpacersIndexes(allEmojis), [allEmojis]); + const allEmojis = mergeEmojisWithFrequentlyUsedEmojis(emojis, processFrequentlyUsedEmojis(frequentlyUsedEmojis)); + const headerEmojis = getHeaderEmojis(allEmojis); + const headerRowIndices = headerEmojis.map((headerEmoji) => headerEmoji.index); + const spacersIndexes = getSpacersIndexes(allEmojis); const [filteredEmojis, setFilteredEmojis] = useState(allEmojis); const [headerIndices, setHeaderIndices] = useState(headerRowIndices); const isListFiltered = allEmojis.length !== filteredEmojis.length; @@ -60,15 +60,12 @@ const useEmojiPickerMenu = () => { /** * Suggest emojis based on the search term */ - const suggestEmojisCallback = useCallback( - (searchTerm: string) => { - const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', ''); - const emojisSuggestions = suggestEmojis(`:${normalizedSearchTerm}`, preferredLocale, allEmojis.length); + const suggestEmojisCallback = (searchTerm: string) => { + const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', ''); + const emojisSuggestions = suggestEmojis(`:${normalizedSearchTerm}`, preferredLocale, allEmojis.length); - return [normalizedSearchTerm, emojisSuggestions] as const; - }, - [allEmojis.length, preferredLocale], - ); + return [normalizedSearchTerm, emojisSuggestions] as const; + }; return { allEmojis, diff --git a/src/components/EnableGlobalReimbursementsPayModal.tsx b/src/components/EnableGlobalReimbursementsPayModal.tsx new file mode 100644 index 000000000000..ddb2fea19793 --- /dev/null +++ b/src/components/EnableGlobalReimbursementsPayModal.tsx @@ -0,0 +1,74 @@ +import useConfirmModal from '@hooks/useConfirmModal'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; + +import {getEnableGlobalReimbursementsBusinessNavigationRoute} from '@libs/Navigation/helpers/enableGlobalReimbursementsNavigationUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import {clearCorpayPayModal} from '@userActions/App'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type CorpayPayModal from '@src/types/onyx/CorpayPayModal'; + +import {useEffect, useEffectEvent, useRef} from 'react'; + +import {useLockedAccountActions, useLockedAccountState} from './LockedAccountModalProvider'; +import {ModalActions} from './Modal/Global/ModalContext'; + +function EnableGlobalReimbursementsPayModal() { + const {translate} = useLocalize(); + const [corpayPayModal] = useOnyx(ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL); + const {showConfirmModal} = useConfirmModal(); + const {isAccountLocked} = useLockedAccountState(); + const {showLockedAccountModal} = useLockedAccountActions(); + const isModalOpenRef = useRef(false); + + const showCorpayPayModal = useEffectEvent(async (modalData: CorpayPayModal) => { + if (isModalOpenRef.current) { + return; + } + isModalOpenRef.current = true; + const navigationPathAtSignal = Navigation.getActiveRoute(); + const result = await showConfirmModal({ + id: 'corpayPayModal', + title: translate('common.corpayPayModalTitle'), + prompt: translate('common.corpayPayModalPrompt'), + confirmText: translate('common.enableGlobalReimbursements'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + }); + isModalOpenRef.current = false; + if (result.action === ModalActions.CONFIRM) { + if (isAccountLocked) { + showLockedAccountModal(); + } else { + const {bankAccountID, bankCountry, bankCurrency} = modalData; + Navigation.navigate( + getEnableGlobalReimbursementsBusinessNavigationRoute( + bankAccountID, + CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.REGISTRATION_NUMBER, + { + bankCountry, + bankCurrency, + }, + navigationPathAtSignal, + ), + {skipMatchingFullScreenRoute: true}, + ); + } + } + clearCorpayPayModal(); + }); + + useEffect(() => { + if (!corpayPayModal) { + return; + } + showCorpayPayModal(corpayPayModal); + }, [corpayPayModal]); + + return null; +} + +export default EnableGlobalReimbursementsPayModal; diff --git a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx b/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx deleted file mode 100644 index bc11ccf61296..000000000000 --- a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; - -import React from 'react'; -import {View} from 'react-native'; - -type CellRendererComponentProps = ViewProps & { - index: number; - style?: StyleProp; -}; - -function CellRendererComponent(props: CellRendererComponentProps) { - return ( - - ); -} - -export default CellRendererComponent; diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx deleted file mode 100644 index b343f95b8be1..000000000000 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type FlatListRefType from '@components/FlashList/types'; - -import type {FlashListProps} from '@shopify/flash-list'; - -import React from 'react'; - -import FlashList from '..'; -import CellRendererComponent from './CellRendererComponent'; - -type InvertedFlashListProps = FlashListProps & { - data: T[]; - keyExtractor: (item: T, index: number) => string; - - /** Ref to the underlying list instance. */ - ref: FlatListRefType; -}; - -function InvertedFlashList(props: InvertedFlashListProps) { - return ( - - {...props} - inverted - CellRendererComponent={CellRendererComponent} - /> - ); -} - -export default InvertedFlashList; diff --git a/src/components/FlashList/index.tsx b/src/components/FlashList/index.tsx deleted file mode 100644 index e98507c9a3bd..000000000000 --- a/src/components/FlashList/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; - -import type {FlashListProps} from '@shopify/flash-list'; -import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; - -import {FlashList as ShopifyFlashList} from '@shopify/flash-list'; -import React from 'react'; - -function FlashList({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps) { - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); - - const handleScroll = (e: NativeSyntheticEvent) => { - onScrollProp?.(e); - // Emit scroll events so that ActiveHoverable can suppress hover effects during scroll - emitComposerScrollEvents(); - }; - - return ( - - {...restProps} - inverted={inverted} - onScroll={handleScroll} - /> - ); -} - -export default FlashList; diff --git a/src/components/FlashList/types.ts b/src/components/FlashList/types.ts deleted file mode 100644 index cf7718d3d148..000000000000 --- a/src/components/FlashList/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {RefObject} from 'react'; -import type {FlatList} from 'react-native'; - -/** Ref to the underlying list instance attached via `ref={}`. */ -type FlatListRefType = RefObject | null> | null; - -export default FlatListRefType; diff --git a/src/components/FlatList/FlatList/index.ios.tsx b/src/components/FlatList/FlatList/index.ios.tsx index 5fc8e9b546a7..05bfe439c045 100644 --- a/src/components/FlatList/FlatList/index.ios.tsx +++ b/src/components/FlatList/FlatList/index.ios.tsx @@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import React, {useCallback, useRef, useState} from 'react'; +import {useRef, useState} from 'react'; import {FlatList} from 'react-native'; import type {CustomFlatListProps} from './types'; @@ -26,30 +26,21 @@ function CustomFlatList({ }: CustomFlatListProps) { const [isScrolling, setIsScrolling] = useState(false); const styles = useThemeStyles(); - const handleScrollBegin = useCallback( - (event: NativeSyntheticEvent) => { - onMomentumScrollBegin?.(event); - setIsScrolling(true); - }, - [onMomentumScrollBegin], - ); + const handleScrollBegin = (event: NativeSyntheticEvent) => { + onMomentumScrollBegin?.(event); + setIsScrolling(true); + }; - const handleScrollEnd = useCallback( - (event: NativeSyntheticEvent) => { - onMomentumScrollEnd?.(event); - setIsScrolling(false); - }, - [onMomentumScrollEnd], - ); + const handleScrollEnd = (event: NativeSyntheticEvent) => { + onMomentumScrollEnd?.(event); + setIsScrolling(false); + }; - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal, inverted: restProps.inverted}); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - onScrollProp?.(e); - emitComposerScrollEvents(); - }, - [emitComposerScrollEvents, onScrollProp], - ); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal && !!restProps.inverted}); + const handleScroll = (e: NativeSyntheticEvent) => { + onScrollProp?.(e); + emitComposerScrollEvents(); + }; const listRef = useRef | null>(null); useFlatListHandle({ diff --git a/src/components/FlatList/FlatList/index.tsx b/src/components/FlatList/FlatList/index.tsx index fb977c60a232..9c13ff4c7068 100644 --- a/src/components/FlatList/FlatList/index.tsx +++ b/src/components/FlatList/FlatList/index.tsx @@ -245,7 +245,7 @@ function MVCPFlatList({ }; }, []); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted: restProps.inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!restProps.inverted}); const handleScroll = useCallback( (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/FullscreenLoadingIndicator.tsx b/src/components/FullscreenLoadingIndicator.tsx index a8501d1615e0..89ae1ced01a8 100644 --- a/src/components/FullscreenLoadingIndicator.tsx +++ b/src/components/FullscreenLoadingIndicator.tsx @@ -24,6 +24,8 @@ type FullScreenLoadingIndicatorProps = { /** Whether the "Go Back" button appears after a timeout. */ shouldUseGoBackButton?: boolean; + onGoBack?: () => void; + testID?: string; /** Extra loading context to be passed to the logAppStateOnLongLoading function */ @@ -34,6 +36,7 @@ function FullScreenLoadingIndicator({ style, iconSize = CONST.ACTIVITY_INDICATOR_SIZE.LARGE, shouldUseGoBackButton = false, + onGoBack = Navigation.goBack, testID = '', extraLoadingContext, }: FullScreenLoadingIndicatorProps) { @@ -65,7 +68,7 @@ function FullScreenLoadingIndicator({ {translate('common.thisIsTakingLongerThanExpected')} - diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index 51bdf4377e4c..5cabfc298086 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -28,7 +28,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import {doesPersonalDetailExistSelector, personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; -import {lastWorkspaceNumberSelector} from '@src/selectors/Policy'; +import {lastWorkspaceNumberSelector, ownerPoliciesSelector} from '@src/selectors/Policy'; import type {BankAccountList, PersonalDetailsList, Policy} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import viewRef from '@src/types/utils/viewRef'; @@ -223,6 +223,7 @@ function KYCWall({ reportActionsList: filteredReportActions, doesEmployeePersonalDetailExist: doesSubmitterPersonalDetailExist ?? false, getCurrencyDecimals, + hasOwnedPaidPolicy: ownerPoliciesSelector(policies, currentUserAccountID).length > 0, }) ?? {}; if (policyID && iouReport?.policyID) { savePreferredPaymentMethod(iouReport.policyID, policyID, CONST.LAST_PAYMENT_METHOD.IOU, lastPaymentMethod?.[iouReport?.policyID]); diff --git a/src/components/KeyboardDismissibleFlatList/index.tsx b/src/components/KeyboardDismissibleFlatList/index.tsx index 043dba4d39db..3e8109d2d0e6 100644 --- a/src/components/KeyboardDismissibleFlatList/index.tsx +++ b/src/components/KeyboardDismissibleFlatList/index.tsx @@ -11,7 +11,7 @@ import {useKeyboardDismissibleFlatListActions} from './KeyboardDismissibleFlatLi function KeyboardDismissibleFlatList({onScroll: onScrollProp, inverted, ref, ...restProps}: AnimatedFlatListWithCellRendererProps) { const {onScroll: onScrollHandleKeyboard} = useKeyboardDismissibleFlatListActions(); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted}); const additionalOnScroll = useAnimatedScrollHandler({ onScroll: emitComposerScrollEvents, diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index e19e60f80ef5..cbe017d7775c 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -1,3 +1,4 @@ +import setLegendListItemZIndex from '@components/LegendList/setLegendListItemZIndex'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import useNetwork from '@hooks/useNetwork'; @@ -15,19 +16,18 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; -import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; +import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native'; import type {ReactElement} from 'react'; +import {LegendList} from '@legendapp/list/react-native'; import {useRoute} from '@react-navigation/native'; -import {FlashList} from '@shopify/flash-list'; -import React, {memo, useCallback, useContext, useEffect, useMemo, useRef} from 'react'; +import React, {useContext, useEffect, useRef} from 'react'; import {StyleSheet, View} from 'react-native'; import type {LHNOptionsListProps, RenderItemProps} from './types'; import LHNTooltipContextProvider from './LHNTooltipContextProvider'; import OptionRowLHNData from './OptionRowLHN'; -import OptionRowRendererComponent from './OptionRowRendererComponent'; const keyExtractor = (item: Report) => `report_${item.reportID}`; const platform = getPlatform(); @@ -36,7 +36,7 @@ const isWeb = platform === CONST.PLATFORM.WEB; function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optionMode, shouldDisableFocusOptions = false, onFirstItemRendered = () => {}}: LHNOptionsListProps) { const {saveScrollOffset, getScrollOffset, saveScrollIndex, getScrollIndex} = useContext(ScrollOffsetContext); const {isOffline} = useNetwork(); - const flashListRef = useRef>(null); + const legendListRef = useRef(null); const route = useRoute(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const reportAttributes = useReportAttributes(); @@ -49,13 +49,49 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio // When the first item renders we want to call the onFirstItemRendered callback. // At this point in time we know that the list is actually displaying items. const hasCalledOnLayout = React.useRef(false); - const onLayoutItem = useCallback(() => { + const onLayoutItem = () => { if (hasCalledOnLayout.current) { return; } hasCalledOnLayout.current = true; onFirstItemRendered(); - }, [onFirstItemRendered]); + }; + + const updateItemZIndex = (index: number) => { + if (isWeb) { + return; + } + + setLegendListItemZIndex(legendListRef.current, index, -index); + }; + + const updateMountedItemZIndices = () => { + if (isWeb || !legendListRef.current) { + return; + } + + const state = legendListRef.current.getState(); + const startIndex = Math.max(0, state.startBuffered); + const endIndex = Math.min(state.data.length - 1, state.endBuffered); + if (!Number.isFinite(startIndex) || !Number.isFinite(endIndex) || endIndex < startIndex) { + return; + } + + for (let index = startIndex; index <= endIndex; index++) { + updateItemZIndex(index); + } + }; + + const handleItemLayout = (index: number) => { + onLayoutItem(); + updateItemZIndex(index); + }; + + const onViewableItemsChanged: NonNullable['onViewableItemsChanged']> = ({viewableItems}) => { + for (const item of viewableItems) { + updateItemZIndex(item.index); + } + }; // Controls the visibility of the educational tooltip based on user scrolling. // Hides the tooltip when the user is scrolling and displays it once scrolling stops. @@ -64,94 +100,94 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio /** * Function which renders a row in the list */ - const renderItem = useCallback( - ({item, index}: RenderItemProps): ReactElement | null => { - if (!item) { - return null; - } - const reportID = item.reportID; - const itemReportAttributes = reportAttributes?.[reportID]; - const itemParentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.parentReportID}`]; - const itemOneTransactionThreadReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${itemReportAttributes?.oneTransactionThreadReportID}`]; - - let invoiceReceiverPolicyID = '-1'; - if (item.invoiceReceiver && 'policyID' in item.invoiceReceiver) { - invoiceReceiverPolicyID = item.invoiceReceiver.policyID; - } - if (itemParentReport?.invoiceReceiver && 'policyID' in itemParentReport.invoiceReceiver) { - invoiceReceiverPolicyID = itemParentReport.invoiceReceiver.policyID; - } - const itemInvoiceReceiverPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiverPolicyID}`]; - const itemPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`]; - - return ( - - ); - }, - [reportAttributes, reports, policy, personalDetails, optionMode, shouldDisableFocusOptions, onSelectRow, onLayoutItem], - ); - - const extraData = useMemo( - () => [reports, reportAttributes, policy, personalDetails, data.length, optionMode, isOffline], - [reports, reportAttributes, policy, personalDetails, data.length, optionMode, isOffline], - ); + const renderItem = ({item, index}: RenderItemProps): ReactElement | null => { + if (!item) { + return null; + } + const reportID = item.reportID; + const itemReportAttributes = reportAttributes?.[reportID]; + const itemParentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.parentReportID}`]; + const itemOneTransactionThreadReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${itemReportAttributes?.oneTransactionThreadReportID}`]; + + let invoiceReceiverPolicyID = '-1'; + if (item.invoiceReceiver && 'policyID' in item.invoiceReceiver) { + invoiceReceiverPolicyID = item.invoiceReceiver.policyID; + } + if (itemParentReport?.invoiceReceiver && 'policyID' in itemParentReport.invoiceReceiver) { + invoiceReceiverPolicyID = itemParentReport.invoiceReceiver.policyID; + } + const itemInvoiceReceiverPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiverPolicyID}`]; + const itemPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`]; + + return ( + handleItemLayout(index)} + testID={index} + /> + ); + }; + + const extraData = [reports, reportAttributes, policy, personalDetails, data.length, optionMode, isOffline, renderItem]; const previousOptionMode = usePrevious(optionMode); useEffect(() => { - if (previousOptionMode === null || previousOptionMode === optionMode || !flashListRef.current) { + if (isWeb) { + return; + } + + const animationFrame = requestAnimationFrame(updateMountedItemZIndices); + return () => cancelAnimationFrame(animationFrame); + }, [data, updateMountedItemZIndices]); + + useEffect(() => { + if (previousOptionMode === null || previousOptionMode === optionMode || !legendListRef.current) { return; } // If the option mode changes want to scroll to the top of the list because rendered items will have different height. - flashListRef.current.scrollToOffset({offset: 0}); + legendListRef.current.scrollToOffset({offset: 0}); }, [previousOptionMode, optionMode]); - const onScroll = useCallback['onScroll']>>( - (e) => { - // If the layout measurement is 0, it means the FlashList is not displayed but the onScroll may be triggered with offset value 0. - // We should ignore this case. - if (e.nativeEvent.layoutMeasurement.height === 0) { - return; - } - saveScrollOffset(route, e.nativeEvent.contentOffset.y); - if (isWeb) { - saveScrollIndex(route, Math.floor(e.nativeEvent.contentOffset.y / estimatedItemSize)); - } - triggerScrollEvent(); - }, - [estimatedItemSize, route, saveScrollIndex, saveScrollOffset, triggerScrollEvent], - ); + const onScroll: NonNullable['onScroll']> = (e) => { + // If the layout measurement is 0, it means the LegendList is not displayed but the onScroll may be triggered with offset value 0. + // We should ignore this case. + if (e.nativeEvent.layoutMeasurement.height === 0) { + return; + } + saveScrollOffset(route, e.nativeEvent.contentOffset.y); + if (isWeb) { + saveScrollIndex(route, Math.floor(e.nativeEvent.contentOffset.y / estimatedItemSize)); + } + triggerScrollEvent(); + }; - const onLayout = useCallback(() => { + const onLayout = () => { const offset = getScrollOffset(route); - if (!(offset && flashListRef.current) || isWeb) { + if (!(offset && legendListRef.current) || isWeb) { return; } // We need to use requestAnimationFrame to make sure it will scroll properly on iOS. requestAnimationFrame(() => { - if (!(offset && flashListRef.current)) { + if (!(offset && legendListRef.current)) { return; } - flashListRef.current.scrollToOffset({offset}); + legendListRef.current.scrollToOffset({offset}); }); - }, [getScrollOffset, route]); + }; const savedScrollIndex = getScrollIndex(route); const initialScrollIndex = isWeb && savedScrollIndex !== undefined && savedScrollIndex >= 0 && savedScrollIndex < data.length ? savedScrollIndex : undefined; @@ -159,11 +195,10 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio return ( - ); } -export default memo(LHNOptionsList); +export default LHNOptionsList; diff --git a/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx b/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx deleted file mode 100644 index 9c9fba6d5f61..000000000000 --- a/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type {StyleProp, ViewStyle} from 'react-native'; - -import {View} from 'react-native'; - -type OptionRowRendererComponentProps = { - /** The index position of this option row in the list */ - index: number; - - onLayout?: () => void; - - /** Style prop for customizing the option row */ - style?: StyleProp; -}; - -function OptionRowRendererComponent({...props}: OptionRowRendererComponentProps) { - return ( - - ); -} - -export default OptionRowRendererComponent; diff --git a/src/components/LHNOptionsList/OptionRowRendererComponent/index.tsx b/src/components/LHNOptionsList/OptionRowRendererComponent/index.tsx deleted file mode 100644 index 25afb0124e9f..000000000000 --- a/src/components/LHNOptionsList/OptionRowRendererComponent/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -const OptionRowRendererComponent = undefined; - -export default OptionRowRendererComponent; diff --git a/src/components/MenuItem/MenuItemAccessibilityContext.tsx b/src/components/MenuItem/MenuItemAccessibilityContext.tsx index eb41bd4b9f5e..a464e7ee001f 100644 --- a/src/components/MenuItem/MenuItemAccessibilityContext.tsx +++ b/src/components/MenuItem/MenuItemAccessibilityContext.tsx @@ -2,8 +2,11 @@ import type {TupleToUnion, ValueOf} from 'type-fest'; import {createContext, useContext, useEffect, useState} from 'react'; -/** The text slots a `MenuItem` row can contribute to its label, in the order they are announced */ -const MENU_ITEM_LABEL_SLOTS = ['title', 'description'] as const; +/** + * Label slots a `MenuItem` row can contribute, in the order they are announced. Keyed by line rather + * than role, so the announced order matches the visual one for both field and navigation rows. + */ +const MENU_ITEM_LABEL_SLOTS = ['top', 'bottom'] as const; type MenuItemLabelSlot = TupleToUnion; @@ -35,7 +38,7 @@ const MenuItemAccessibilityContext = createContext() { /** Assembles the row's accessibility label from what its sub-components registered, plus the value for `MenuItemAccessibilityContext.Provider` */ function useMenuItemAccessibility() { - // Text contributed by Title/Description children, keyed by slot + // Text contributed by the text leaves, keyed by the line each one occupies const {entries: labels, register: registerLabel, unregister: unregisterLabel} = useKeyedRegistry(); // Facts contributed by any child, keyed by the fact @@ -119,4 +122,5 @@ function useMenuItemAccessibility() { } export default MenuItemAccessibilityContext; +export type {MenuItemLabelSlot}; export {MENU_ITEM_ACCESSIBILITY_ANNOUNCEMENT, useMenuItemAccessibilityLabel, useMenuItemAccessibilityAnnouncement, useMenuItemAccessibility}; diff --git a/src/components/MenuItem/index.ts b/src/components/MenuItem/index.ts index a91508b60518..f615659f7408 100644 --- a/src/components/MenuItem/index.ts +++ b/src/components/MenuItem/index.ts @@ -5,7 +5,7 @@ * imports keep working), extended with the compound sub-components following the * composition-over-configuration pattern. * - * The row's accessibility label is derived from the `Title`/`Description` text, followed by the + * The row's accessibility label is derived from the text leaves, announced top line first and followed by the * hints trailing leaves register. * * @example Simple navigation row @@ -32,11 +32,13 @@ import MenuItemLeading from './layout/MenuItemLeading'; import MenuItemRoot from './layout/MenuItemRoot'; import MenuItemRow from './layout/MenuItemRow'; import MenuItemTrailing from './layout/MenuItemTrailing'; +import MenuItemDescription from './leaves/content/MenuItemDescription'; +import MenuItemFieldName from './leaves/content/MenuItemFieldName'; +import MenuItemFieldNamePlaceholder from './leaves/content/MenuItemFieldNamePlaceholder'; +import MenuItemFieldValue from './leaves/content/MenuItemFieldValue'; +import MenuItemLabel from './leaves/content/MenuItemLabel'; +import MenuItemTitle from './leaves/content/MenuItemTitle'; import MenuItemIcon from './leaves/leading/MenuItemIcon'; -import MenuItemDescription from './leaves/text/description/MenuItemDescription'; -import MenuItemDescriptionPlaceholder from './leaves/text/description/MenuItemDescriptionPlaceholder'; -import MenuItemLabel from './leaves/text/MenuItemLabel'; -import MenuItemTitle from './leaves/text/MenuItemTitle'; import MenuItemChevron from './leaves/trailing/icons/MenuItemChevron'; import MenuItemNewWindowIcon from './leaves/trailing/icons/MenuItemNewWindowIcon'; import MenuItemRightLabel from './leaves/trailing/MenuItemRightLabel'; @@ -52,7 +54,9 @@ const MenuItem = Object.assign(LegacyMenuItem, { Label: MenuItemLabel, Title: MenuItemTitle, Description: MenuItemDescription, - DescriptionPlaceholder: MenuItemDescriptionPlaceholder, + FieldName: MenuItemFieldName, + FieldNamePlaceholder: MenuItemFieldNamePlaceholder, + FieldValue: MenuItemFieldValue, Chevron: MenuItemChevron, NewWindowIcon: MenuItemNewWindowIcon, RightLabel: MenuItemRightLabel, diff --git a/src/components/MenuItem/leaves/content/MenuItemDescription.tsx b/src/components/MenuItem/leaves/content/MenuItemDescription.tsx new file mode 100644 index 000000000000..d003c517de9b --- /dev/null +++ b/src/components/MenuItem/leaves/content/MenuItemDescription.tsx @@ -0,0 +1,22 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {MenuItemSupportingTextProps} from './base/types'; + +import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText'; + +/** Supporting text under a `MenuItem.Title`, the bottom line of a `MenuItem.Content` */ +function MenuItemDescription(props: MenuItemSupportingTextProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default MenuItemDescription; diff --git a/src/components/MenuItem/leaves/content/MenuItemFieldName.tsx b/src/components/MenuItem/leaves/content/MenuItemFieldName.tsx new file mode 100644 index 000000000000..4880f9deac52 --- /dev/null +++ b/src/components/MenuItem/leaves/content/MenuItemFieldName.tsx @@ -0,0 +1,22 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {MenuItemSupportingTextProps} from './base/types'; + +import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText'; + +/** Name of a filled field */ +function MenuItemFieldName(props: MenuItemSupportingTextProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default MenuItemFieldName; diff --git a/src/components/MenuItem/leaves/content/MenuItemFieldNamePlaceholder.tsx b/src/components/MenuItem/leaves/content/MenuItemFieldNamePlaceholder.tsx new file mode 100644 index 000000000000..9425f09e90a2 --- /dev/null +++ b/src/components/MenuItem/leaves/content/MenuItemFieldNamePlaceholder.tsx @@ -0,0 +1,24 @@ +import useStyleUtils from '@hooks/useStyleUtils'; + +import {fontScale, lineHeightScale} from '@styles/typography'; + +import React from 'react'; + +import type {MenuItemSupportingTextProps} from './base/types'; + +import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText'; + +/** Name of an empty field, standing in for the missing value */ +function MenuItemFieldNamePlaceholder(props: MenuItemSupportingTextProps) { + const StyleUtils = useStyleUtils(); + + return ( + + ); +} + +export default MenuItemFieldNamePlaceholder; diff --git a/src/components/MenuItem/leaves/content/MenuItemFieldValue.tsx b/src/components/MenuItem/leaves/content/MenuItemFieldValue.tsx new file mode 100644 index 000000000000..d6bc0a31bb84 --- /dev/null +++ b/src/components/MenuItem/leaves/content/MenuItemFieldValue.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import type {MenuItemPrimaryTextProps} from './base/types'; + +import BaseMenuItemPrimaryText from './base/BaseMenuItemPrimaryText'; + +/** Value a field holds */ +function MenuItemFieldValue(props: MenuItemPrimaryTextProps) { + return ( + + ); +} + +export default MenuItemFieldValue; diff --git a/src/components/MenuItem/leaves/content/MenuItemLabel.tsx b/src/components/MenuItem/leaves/content/MenuItemLabel.tsx new file mode 100644 index 000000000000..c2cfda11ad2f --- /dev/null +++ b/src/components/MenuItem/leaves/content/MenuItemLabel.tsx @@ -0,0 +1,28 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {MenuItemSupportingTextProps} from './base/types'; + +import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText'; + +/** + * Text naming what the row holds, the top line of a `MenuItem.Content`. + * + * Use this when the label and the value should read as one lockup — it lives inside `Root`, so it + * shares the row's press target and hover background. Reach for the `MenuItemWithLabel` preset + * instead when the label should stay outside both. + */ +function MenuItemLabel(props: MenuItemSupportingTextProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default MenuItemLabel; diff --git a/src/components/MenuItem/leaves/content/MenuItemTitle.tsx b/src/components/MenuItem/leaves/content/MenuItemTitle.tsx new file mode 100644 index 000000000000..764e7ae35c79 --- /dev/null +++ b/src/components/MenuItem/leaves/content/MenuItemTitle.tsx @@ -0,0 +1,22 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {MenuItemPrimaryTextProps} from './base/types'; + +import BaseMenuItemPrimaryText from './base/BaseMenuItemPrimaryText'; + +/** The title block of a `MenuItem.Content`. Bold, single line */ +function MenuItemTitle(props: MenuItemPrimaryTextProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default MenuItemTitle; diff --git a/src/components/MenuItem/leaves/text/MenuItemTitle.tsx b/src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx similarity index 51% rename from src/components/MenuItem/leaves/text/MenuItemTitle.tsx rename to src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx index d9fbf40d7fc0..fd95c1b6a980 100644 --- a/src/components/MenuItem/leaves/text/MenuItemTitle.tsx +++ b/src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx @@ -8,34 +8,20 @@ import convertToLTR from '@libs/convertToLTR'; import CONST from '@src/CONST'; -import type {ReactElement} from 'react'; - import React from 'react'; -type MenuItemTitleProps = - | { - /** Text to render as the title */ - children: string | number; - - accessibilityLabel?: never; - } - | { - /** Element to render in place of plain text, e.g. a `DisplayNames` with per-name tooltips */ - children: ReactElement; - - accessibilityLabel: string; - }; +import type {BaseMenuItemTextProps, MenuItemPrimaryTextProps} from './types'; -/** The title block of a `MenuItem.Content`. Bold, single line */ -function MenuItemTitle({children, accessibilityLabel}: MenuItemTitleProps) { +/** Base of the full-contrast leaves */ +function BaseMenuItemPrimaryText({children, accessibilityLabel, slot, style}: MenuItemPrimaryTextProps & BaseMenuItemTextProps) { const styles = useThemeStyles(); const {isDisabled, isInteractive} = useMenuItemConfig(); - useMenuItemAccessibilityLabel('title', accessibilityLabel ?? String(children)); + useMenuItemAccessibilityLabel(slot, accessibilityLabel ?? String(children)); return ( @@ -44,4 +30,4 @@ function MenuItemTitle({children, accessibilityLabel}: MenuItemTitleProps) { ); } -export default MenuItemTitle; +export default BaseMenuItemPrimaryText; diff --git a/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx b/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx new file mode 100644 index 000000000000..7c7c5744a256 --- /dev/null +++ b/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx @@ -0,0 +1,26 @@ +import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext'; +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {BaseMenuItemTextProps, MenuItemSupportingTextProps} from './types'; + +/** Base of the muted leaves */ +function BaseMenuItemSupportingText({children, numberOfLines = 2, slot, style}: MenuItemSupportingTextProps & BaseMenuItemTextProps) { + const styles = useThemeStyles(); + + useMenuItemAccessibilityLabel(slot, String(children)); + + return ( + + {children} + + ); +} + +export default BaseMenuItemSupportingText; diff --git a/src/components/MenuItem/leaves/content/base/types.ts b/src/components/MenuItem/leaves/content/base/types.ts new file mode 100644 index 000000000000..61ce842c11c2 --- /dev/null +++ b/src/components/MenuItem/leaves/content/base/types.ts @@ -0,0 +1,40 @@ +import type {MenuItemLabelSlot} from '@components/MenuItem/MenuItemAccessibilityContext'; + +import type {ReactElement} from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; + +/** Props of the full-contrast leaves */ +type MenuItemPrimaryTextProps = + | { + /** Plain text to render as the primary content */ + children: string | number; + + accessibilityLabel?: never; + } + | { + /** For content plain text can't express, e.g. `DisplayNames` with per-name tooltips */ + children: ReactElement; + + /** Required here because the row builds its label from strings and can't read one out of an element */ + accessibilityLabel: string; + }; + +/** Props of the muted leaves */ +type MenuItemSupportingTextProps = { + /** Text to render */ + children: string | number; + + /** Defaults to 2. Supporting text wraps, unlike primary text, which is always one line */ + numberOfLines?: number; +}; + +/** Props shared by MenuItem's content text */ +type BaseMenuItemTextProps = { + /** Which line of the row this leaf occupies, so the row announces its text in visual order */ + slot: MenuItemLabelSlot; + + /** Typography layered on top of the shared base. Each leaf brings its own size and line height */ + style?: StyleProp; +}; + +export type {MenuItemSupportingTextProps, MenuItemPrimaryTextProps, BaseMenuItemTextProps}; diff --git a/src/components/MenuItem/leaves/text/MenuItemLabel.tsx b/src/components/MenuItem/leaves/text/MenuItemLabel.tsx deleted file mode 100644 index 90e254f11e83..000000000000 --- a/src/components/MenuItem/leaves/text/MenuItemLabel.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import Text from '@components/Text'; - -import useThemeStyles from '@hooks/useThemeStyles'; - -import React from 'react'; - -type MenuItemLabelProps = { - /** Text naming what the row holds */ - children: string; -}; - -/** - * The label block of a `MenuItem.Content`, sitting above the row's value. - * - * Use this when the label and the value should read as one lockup — it lives inside `Root`, so it - * shares the row's press target and hover background. Reach for the `MenuItemWithLabel` preset - * instead when the label should stay outside both. - */ -function MenuItemLabel({children}: MenuItemLabelProps) { - const styles = useThemeStyles(); - - return {children}; -} - -export default MenuItemLabel; diff --git a/src/components/MenuItem/leaves/text/description/MenuItemDescription.tsx b/src/components/MenuItem/leaves/text/description/MenuItemDescription.tsx deleted file mode 100644 index adeca7966a67..000000000000 --- a/src/components/MenuItem/leaves/text/description/MenuItemDescription.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext'; -import Text from '@components/Text'; - -import useThemeStyles from '@hooks/useThemeStyles'; - -import React from 'react'; - -import type MenuItemDescriptionProps from './types'; - -/** The small supporting-label description of a `MenuItem.Content`, for a description that sits under a title */ -function MenuItemDescription({children, numberOfLines = 2}: MenuItemDescriptionProps) { - const styles = useThemeStyles(); - - useMenuItemAccessibilityLabel('description', String(children)); - - return ( - - {children} - - ); -} - -export default MenuItemDescription; diff --git a/src/components/MenuItem/leaves/text/description/MenuItemDescriptionPlaceholder.tsx b/src/components/MenuItem/leaves/text/description/MenuItemDescriptionPlaceholder.tsx deleted file mode 100644 index 7aa7233d481c..000000000000 --- a/src/components/MenuItem/leaves/text/description/MenuItemDescriptionPlaceholder.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext'; -import Text from '@components/Text'; - -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {fontScale, lineHeightScale} from '@styles/typography'; - -import React from 'react'; - -import type MenuItemDescriptionProps from './types'; - -/** The normal-size description of a `MenuItem.Content`, for a description-only row */ -function MenuItemDescriptionPlaceholder({children, numberOfLines = 2}: MenuItemDescriptionProps) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - - useMenuItemAccessibilityLabel('description', String(children)); - - return ( - - {children} - - ); -} - -export default MenuItemDescriptionPlaceholder; diff --git a/src/components/MenuItem/leaves/text/description/types.ts b/src/components/MenuItem/leaves/text/description/types.ts deleted file mode 100644 index 8b2b782a0d2e..000000000000 --- a/src/components/MenuItem/leaves/text/description/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Props shared by every description leaf of a `MenuItem.Content` */ -type MenuItemDescriptionProps = { - /** Text to render as the description */ - children: string | number; - - /** Maximum number of lines to render before the text is truncated */ - numberOfLines?: number; -}; - -export default MenuItemDescriptionProps; diff --git a/src/components/MenuItem/leaves/trailing/icons/MenuItemChevron.tsx b/src/components/MenuItem/leaves/trailing/icons/MenuItemChevron.tsx index a07c4a88305c..88c3322639a9 100644 --- a/src/components/MenuItem/leaves/trailing/icons/MenuItemChevron.tsx +++ b/src/components/MenuItem/leaves/trailing/icons/MenuItemChevron.tsx @@ -22,7 +22,10 @@ function MenuItemChevron() { const {isHovered} = useMenuItemInteraction(); return ( - + void | Promise; - - /** Whether the menu item is disabled */ - isDisabled?: boolean; - }; - -/** The empty-field MenuItem preset — a form field the user has not filled in yet */ -function MenuItemEmptyField({description, onPress, children, isDisabled = false, sentryLabel, testID}: MenuItemEmptyFieldProps) { - return ( - - - - {description} - - - {children} - - - - - ); -} - -export default MenuItemEmptyField; diff --git a/src/components/MenuItem/presets/MenuItemField.tsx b/src/components/MenuItem/presets/MenuItemField.tsx new file mode 100644 index 000000000000..81308f96e27d --- /dev/null +++ b/src/components/MenuItem/presets/MenuItemField.tsx @@ -0,0 +1,54 @@ +import MenuItemContent from '@components/MenuItem/layout/MenuItemContent'; +import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot'; +import type {MenuItemRootProps} from '@components/MenuItem/layout/MenuItemRoot'; +import MenuItemRow from '@components/MenuItem/layout/MenuItemRow'; +import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing'; +import MenuItemFieldName from '@components/MenuItem/leaves/content/MenuItemFieldName'; +import MenuItemFieldNamePlaceholder from '@components/MenuItem/leaves/content/MenuItemFieldNamePlaceholder'; +import MenuItemFieldValue from '@components/MenuItem/leaves/content/MenuItemFieldValue'; +import MenuItemChevron from '@components/MenuItem/leaves/trailing/icons/MenuItemChevron'; + +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + +import React from 'react'; + +type MenuItemFieldProps = Omit & { + /** Name of the field */ + name: string; + + /** Value the field holds. Omit it, or pass an empty string, for a field not filled in yet */ + value?: string; +}; + +/** Field preset: a field name plus its value. With no `value` the name takes over the row */ +function MenuItemField({name, value, children, onPress, isDisabled = false, sentryLabel, testID}: MenuItemFieldProps) { + return ( + + + + {value ? ( + <> + {name} + {value} + + ) : ( + {name} + )} + + {(!!children || !!onPress) && ( + + {children} + {!!onPress && } + + )} + + + ); +} + +export default MenuItemField; diff --git a/src/components/MenuItem/presets/MenuItemNavigation.tsx b/src/components/MenuItem/presets/MenuItemNavigation.tsx index 8154b2fd492c..349517e1cc3f 100644 --- a/src/components/MenuItem/presets/MenuItemNavigation.tsx +++ b/src/components/MenuItem/presets/MenuItemNavigation.tsx @@ -3,8 +3,8 @@ import MenuItemLeading from '@components/MenuItem/layout/MenuItemLeading'; import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot'; import MenuItemRow from '@components/MenuItem/layout/MenuItemRow'; import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing'; +import MenuItemTitle from '@components/MenuItem/leaves/content/MenuItemTitle'; import MenuItemIcon from '@components/MenuItem/leaves/leading/MenuItemIcon'; -import MenuItemTitle from '@components/MenuItem/leaves/text/MenuItemTitle'; import MenuItemChevron from '@components/MenuItem/leaves/trailing/icons/MenuItemChevron'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; diff --git a/src/components/MenuItem/presets/MenuItemWithLabel.tsx b/src/components/MenuItem/presets/MenuItemWithLabel.tsx index 5c0a82a28838..b276bf3595d5 100644 --- a/src/components/MenuItem/presets/MenuItemWithLabel.tsx +++ b/src/components/MenuItem/presets/MenuItemWithLabel.tsx @@ -1,6 +1,6 @@ import type {MenuItemRootProps} from '@components/MenuItem/layout/MenuItemRoot'; import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot'; -import Text from '@components/Text'; +import MenuItemLabel from '@components/MenuItem/leaves/content/MenuItemLabel'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -24,7 +24,7 @@ function MenuItemWithLabel({label, onPress, isDisabled = false, sentryLabel, tes return ( - {label} + {label} void; - /** When set, used in the new manual expense flow to open the parent-owned participant picker instead of navigating away */ - onOpenParticipantPicker?: () => void; + /** Opens the participant picker owned by the page hosting this list. Pages that cannot show an editable participant row pass a no-op. */ + onOpenParticipantPicker: () => void; /** Whether the parent-owned participant picker modal is currently open (new manual expense flow). Drives amount autofocus on picker close. */ isParticipantPickerVisible?: boolean; @@ -202,8 +198,6 @@ function MoneyRequestConfirmationList({ const transactionReport = useTransactionReportForConfirmation(transaction?.reportID); const {policyForMovingExpenses, shouldSelectPolicy} = usePolicyForMovingExpenses(); const isMovingTransactionFromTrackExpense = isMovingTransactionFromTrackExpenseUtil(action); - const {isBetaEnabled} = usePermissions(); - const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const isInLandscapeMode = useIsInLandscapeMode(); @@ -250,7 +244,6 @@ function MoneyRequestConfirmationList({ const isTypeRequest = iouType === CONST.IOU.TYPE.SUBMIT; const isTypeSend = iouType === CONST.IOU.TYPE.PAY; - const isTypeTrackExpense = iouType === CONST.IOU.TYPE.TRACK; const isTypeInvoice = iouType === CONST.IOU.TYPE.INVOICE; const isFromGlobalCreateAndCanEditParticipant = !!transaction?.isFromGlobalCreate && !isPerDiemRequest && !isTimeRequest; @@ -313,7 +306,7 @@ function MoneyRequestConfirmationList({ }); const isManualRequest = transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL; - const shouldForceTopEmptySections = isNewManualExpenseFlowEnabled && (iouType === CONST.IOU.TYPE.CREATE || isManualRequest || isScanRequest); + const shouldForceTopEmptySections = iouType === CONST.IOU.TYPE.CREATE || isManualRequest || isScanRequest; const isFocused = useIsFocused(); @@ -353,7 +346,6 @@ function MoneyRequestConfirmationList({ routeError, isTypeSplit, shouldShowReadOnlySplits, - isNewManualExpenseFlowEnabled, isDistanceRequest, isReadOnly, shouldShowDate, @@ -375,7 +367,6 @@ function MoneyRequestConfirmationList({ const splitOrRequestOptions = useConfirmationCtaText({ expensesNumber, isTypeInvoice, - isTypeTrackExpense, isTypeSplit, isTypeRequest, iouAmount, @@ -385,7 +376,6 @@ function MoneyRequestConfirmationList({ receiptPath, isDistanceRequestWithPendingRoute, isPerDiemRequest, - isNewManualExpenseFlowEnabled, }); const selectedParticipants = selectedParticipantsProp.filter((participant) => participant.selected); @@ -443,13 +433,7 @@ function MoneyRequestConfirmationList({ return; } - if (isNewManualExpenseFlowEnabled) { - onOpenParticipantPicker?.(); - return; - } - - const newIOUType = iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.TRACK ? CONST.IOU.TYPE.CREATE : iouType; - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute({action, iouType: newIOUType, transactionID, reportID: transaction?.reportID}))); + onOpenParticipantPicker(); }; const {validate} = useConfirmationValidation({ @@ -480,7 +464,6 @@ function MoneyRequestConfirmationList({ isMovingTransactionFromTrackExpense, isTimeRequest, routeError, - isNewManualExpenseFlowEnabled, isReadOnly, shouldShowDate, isTaxAmountEmpty, @@ -545,7 +528,6 @@ function MoneyRequestConfirmationList({ isReadOnly={isReadOnly} didConfirm={!!didConfirm} isEditingSplitBill={isEditingSplitBill} - isNewManualExpenseFlowEnabled={isNewManualExpenseFlowEnabled} isPolicyExpenseChat={isPolicyExpenseChat} isScanRequest={isScanRequest} isDistanceRequest={isDistanceRequest} diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts index d599da7c823f..a109c1d747b1 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts @@ -16,9 +16,6 @@ type UseConfirmationCtaTextParams = { /** Whether the current IOU type is invoice */ isTypeInvoice: boolean; - /** Whether the current IOU type is track-expense */ - isTypeTrackExpense: boolean; - /** Whether the current IOU type is split */ isTypeSplit: boolean; @@ -45,22 +42,18 @@ type UseConfirmationCtaTextParams = { /** Whether the transaction is a per-diem request */ isPerDiemRequest: boolean; - - /** Whether the new manual expense flow beta is enabled */ - isNewManualExpenseFlowEnabled: boolean; }; /** * Computes the primary confirm button label for the Money Request confirmation flow. * - * Picks between create / create-with-amount / split / invoice / next variants based on - * the IOU type, manual-expense-flow beta, bulk-expense count, and amount, returning a - * single-entry DropdownOption array shaped for the ButtonWithDropdownMenu consumer. + * Picks between create / split / invoice / next variants based on the IOU type and + * bulk-expense count, returning a single-entry DropdownOption array shaped for the + * ButtonWithDropdownMenu consumer. */ function useConfirmationCtaText({ expensesNumber, isTypeInvoice, - isTypeTrackExpense, isTypeSplit, isTypeRequest, iouAmount, @@ -70,7 +63,6 @@ function useConfirmationCtaText({ receiptPath, isDistanceRequestWithPendingRoute, isPerDiemRequest, - isNewManualExpenseFlowEnabled, }: UseConfirmationCtaTextParams): Array> { const {translate} = useLocalize(); @@ -83,29 +75,16 @@ function useConfirmationCtaText({ } else { text = translate('common.next'); } - } else if (isTypeTrackExpense) { - text = translate('iou.createExpense'); - if (iouAmount !== 0 && !isNewManualExpenseFlowEnabled) { - text = translate('iou.createExpenseWithAmount', {amount: formattedAmount}); - } } else if (isTypeSplit && iouAmount === 0) { text = translate('iou.splitExpense'); } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute || isPerDiemRequest) { + // Sits between the two split branches on purpose: a non-zero split with a pending distance route or per diem + // keeps the create copy rather than falling through to "Split expense". text = translate('iou.createExpense'); - if (iouAmount !== 0 && !isNewManualExpenseFlowEnabled) { - text = translate('iou.createExpenseWithAmount', {amount: formattedAmount}); - } } else if (isTypeSplit) { - text = translate('iou.splitAmount', formattedAmount); - if (isNewManualExpenseFlowEnabled) { - text = translate('iou.splitExpense'); - } - } else if (iouAmount === 0) { - text = translate('iou.createExpense'); - } else if (isNewManualExpenseFlowEnabled) { - text = translate('iou.createExpense'); + text = translate('iou.splitExpense'); } else { - text = translate('iou.createExpenseWithAmount', {amount: formattedAmount}); + text = translate('iou.createExpense'); } return [ { diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index a1b3160fc062..9ad71cb5aa46 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -113,8 +113,6 @@ type UseConfirmationValidationParams = { /** Truthy when the route to the confirmation page has a known error */ routeError: string | null | undefined; - isNewManualExpenseFlowEnabled: boolean; - /** Whether the confirmation fields are read-only (date is not inline-editable) */ isReadOnly: boolean; @@ -167,7 +165,6 @@ function useConfirmationValidation({ isMovingTransactionFromTrackExpense, isTimeRequest, routeError, - isNewManualExpenseFlowEnabled, isReadOnly, shouldShowDate, isTaxAmountEmpty, @@ -190,11 +187,10 @@ function useConfirmationValidation({ if (!isScanRequestUtil(transaction) && !isTimeRequest && !isDistanceRequest && iouAmount === 0 && isP2P) { return {errorKey: 'common.error.invalidAmount'}; } - if (isNewManualExpenseFlowEnabled && isConfirmationAmountMissing(transaction)) { + if (isConfirmationAmountMissing(transaction)) { return {errorKey: 'common.error.fieldRequired'}; } if ( - isNewManualExpenseFlowEnabled && transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && transaction?.isAmountSet && !isScanRequestUtil(transaction) && @@ -205,9 +201,9 @@ function useConfirmationValidation({ ) { return {errorKey: 'common.error.invalidAmount'}; } - // The date is an inline, clearable required field in the new manual flow for every type that shows it - // (manual, distance, time, invoice, ...). Block confirmation when the user cleared it. - if (isNewManualExpenseFlowEnabled && isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly)) { + // The date is an inline, clearable required field for every type that shows it (manual, distance, time, + // invoice, ...). Block confirmation when the user cleared it. + if (isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly)) { return {errorKey: 'common.error.fieldRequired'}; } const merchantValue = iouMerchant ?? ''; @@ -267,14 +263,14 @@ function useConfirmationValidation({ return {errorKey: 'violations.taxOutOfPolicy'}; } - if (isNewManualExpenseFlowEnabled && shouldShowTax && !isDistanceRequest && isTaxAmountEmpty) { + if (shouldShowTax && !isDistanceRequest && isTaxAmountEmpty) { return {errorKey: 'iou.error.invalidAmount'}; } - // In the new manual expense flow the tax amount is edited inline, so the standalone tax amount step's + // In the manual expense flow the tax amount is edited inline, so the standalone tax amount step's // guard (tax amount can't exceed the tax computed from the rate and the expense amount) runs here. // This also blocks creation when an invalid tax amount was persisted to the draft and then reloaded. - if (isNewManualExpenseFlowEnabled && shouldShowTax && !isDistanceRequest) { + if (shouldShowTax && !isDistanceRequest) { const decimals = getCurrencyDecimals(iouCurrencyCode); const maxTaxAmount = getCalculatedTaxAmount(policy, transaction, iouCurrencyCode, decimals); const currentTaxAmount = convertToFrontendAmountAsString(Math.abs(getTaxAmount(transaction, false)), decimals); diff --git a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts index 6c4a6a0899c1..752f6eeb3c25 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts @@ -72,9 +72,6 @@ type UseFormErrorManagementParams = { /** Whether splits are rendered read-only (suppresses some field errors) */ shouldShowReadOnlySplits: boolean; - /** Whether the new manual expense flow is enabled (amount/date errors surface inline) */ - isNewManualExpenseFlowEnabled: boolean; - /** Whether the transaction is a distance request (its amount is read-only, so amount errors are not shown inline) */ isDistanceRequest: boolean; @@ -147,7 +144,6 @@ function useFormErrorManagement({ routeError, isTypeSplit, shouldShowReadOnlySplits, - isNewManualExpenseFlowEnabled, isDistanceRequest, shouldShowDate, isReadOnly, @@ -222,11 +218,11 @@ function useFormErrorManagement({ const isAmountRequiredMissing = isConfirmationAmountMissing(transaction); const isDateRequiredMissing = isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly); useEffect(() => { - if (!isNewManualExpenseFlowEnabled || formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing) { + if (formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing) { return; } setFormError(''); - }, [isNewManualExpenseFlowEnabled, isAmountRequiredMissing, isDateRequiredMissing, setFormError]); + }, [isAmountRequiredMissing, isDateRequiredMissing, setFormError]); useEffect(() => { const currentFormError = formErrorRef.current; @@ -253,27 +249,27 @@ function useFormErrorManagement({ } }, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, isViolationFixed, setFormError]); - // In the new manual expense flow the amount/date/merchant fields surface these required/invalid errors inline, so - // repeating them at the bottom of the form would show "This field is required" twice. + // The amount/date/merchant fields surface these required/invalid errors inline, so repeating them at the bottom of + // the form would show "This field is required" twice. // `common.error.invalidAmount` is the one exception: it is only surfaced inline while the editable amount input is // rendered. Distance requests disable that input, and the read-only menu row it falls back to doesn't show the error, // so the distance-amount error stays in the footer. Otherwise an invalid distance expense would fail silently. - const isSuppressedInNewFlow = (error: TranslationPaths | ''): boolean => - isNewManualExpenseFlowEnabled && (error === 'common.error.fieldRequired' || error === 'iou.error.invalidMerchant' || (!isDistanceRequest && error === 'common.error.invalidAmount')); + const isSuppressedInline = (error: TranslationPaths | ''): boolean => + error === 'common.error.fieldRequired' || error === 'iou.error.invalidMerchant' || (!isDistanceRequest && error === 'common.error.invalidAmount'); const computeErrorMessage = (): string | undefined => { if (routeError) { return routeError; } // This runs ahead of the split branch below because splits render those same inline fields, so they duplicate the same way. - if (isSuppressedInNewFlow(formError)) { + if (isSuppressedInline(formError)) { return undefined; } if (isTypeSplit && !shouldShowReadOnlySplits) { // Splits render the debounced value, so the suppression has to be re-checked against it. `formError` clears // the instant the user fixes the field while `debouncedFormError` lags by USE_DEBOUNCED_STATE_DELAY, and // without this the suppressed error would pop into the footer for that window (#96565). - return debouncedFormError && !isSuppressedInNewFlow(debouncedFormError) ? translate(debouncedFormError) : undefined; + return debouncedFormError && !isSuppressedInline(debouncedFormError) ? translate(debouncedFormError) : undefined; } // Don't show error at the bottom of the form for missing attendees — the field surfaces it inline. if (formError === 'violations.missingAttendees') { diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index fb470f3fe845..10819d0b7cf4 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -66,7 +66,7 @@ function AmountField({ isParticipantPickerVisible = false, }: AmountFieldProps) { const shouldAutoFocusOnMount = !canUseTouchScreen(); - const {isEditingSplitBill, isNewManualExpenseFlowEnabled, isReadOnly, didConfirm, transactionID, action, iouType, reportID, reportActionID} = useConfirmationFields(); + const {isEditingSplitBill, isReadOnly, didConfirm, transactionID, action, iouType, reportID, reportActionID} = useConfirmationFields(); const styles = useThemeStyles(); const {translate, preferredLocale} = useLocalize(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -84,10 +84,7 @@ function AmountField({ const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); const isAmountFieldDisabled = didConfirm || isReadOnly || shouldShowTimeRequestFields || isDistanceRequest; - const firstParticipant = transactionSlice?.participants?.at(0); - const isP2P = isNewManualExpenseFlowEnabled - ? isParticipantP2P(getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0)) - : !!(firstParticipant?.accountID && !firstParticipant?.isPolicyExpenseChat); + const isP2P = isParticipantP2P(getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0)); // `common.error.fieldRequired` is shared with the date field, so only surface it on the amount input when the // amount itself is the missing value. `isConfirmationAmountMissing` is the same predicate validation raises the // error from, so a scan expense (where the amount is populated programmatically and `isAmountSet` is never set) @@ -104,21 +101,20 @@ function AmountField({ const effectiveCurrency = isDistanceRequest ? distanceRateCurrency : (iouCurrencyCode ?? CONST.CURRENCY.USD); const decimals = getCurrencyDecimals(effectiveCurrency); - // In the new manual expense flow the amount field starts empty (transaction.amount defaults to 0 before the user + // In the manual expense flow the amount field starts empty (transaction.amount defaults to 0 before the user // touches it). Once the user explicitly sets an amount – including 0 – isAmountSet becomes true and we show the // real value. This avoids showing "$0.00" as a pre-filled default. Scan and other non-manual flows populate // amount programmatically and never set isAmountSet. - const shouldShowEmptyAmount = isNewManualExpenseFlowEnabled && !transactionSlice?.isAmountSet && transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL; + const shouldShowEmptyAmount = !transactionSlice?.isAmountSet && transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL; const transactionAmount = shouldShowEmptyAmount ? '' : convertToFrontendAmountAsString(amount, decimals); - const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants, isNewManualExpenseFlowEnabled); + const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants); // `autoFocus` on our TextInput only runs on mount. Closing and reopening the RHP often keeps the same mounted // instance, so autofocus does not run again. We re-focus when the parent-owned participant picker closes - // (visible → hidden) so the amount input gains focus once the user selects a participant in the new manual - // expense flow. The setTimeout defers focus past the RHP entry / picker close animation so the input reliably - // receives focus. + // (visible → hidden) so the amount input gains focus once the user selects a participant. The setTimeout defers + // focus past the RHP entry / picker close animation so the input reliably receives focus. useEffect(() => { - if (!shouldAutoFocusOnMount || isAmountFieldDisabled || !isNewManualExpenseFlowEnabled || isParticipantPickerVisible) { + if (!shouldAutoFocusOnMount || isAmountFieldDisabled || isParticipantPickerVisible) { return; } @@ -130,7 +126,7 @@ function AmountField({ } clearTimeout(focusTimeoutRef.current); }; - }, [shouldAutoFocusOnMount, isAmountFieldDisabled, isNewManualExpenseFlowEnabled, isParticipantPickerVisible]); + }, [shouldAutoFocusOnMount, isAmountFieldDisabled, isParticipantPickerVisible]); const showCurrencyPicker = () => { setIsCurrencyPickerVisible(true); @@ -299,7 +295,7 @@ function AmountField({ value={effectiveCurrency} onInputChange={updateCurrency} /> - {isNewManualExpenseFlowEnabled && !isAmountFieldDisabled ? ( + {!isAmountFieldDisabled ? ( - {isNewManualExpenseFlowEnabled && !isReadOnly ? ( + {!isReadOnly ? ( - {!!senderWorkspace?.name && {senderWorkspace.name}} {senderWorkspace?.name ? ( - {translate('workspace.common.workspace')} + <> + {senderWorkspace.name} + {translate('workspace.common.workspace')} + ) : ( - {translate('workspace.common.workspace')} + {translate('workspace.common.workspace')} )} {isInteractive && ( diff --git a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx index ed953aabf5b9..3467995a3e18 100644 --- a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx @@ -31,7 +31,7 @@ type MerchantFieldProps = { }; function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: MerchantFieldProps) { - const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isEditingSplitBill, isNewManualExpenseFlowEnabled} = useConfirmationFields(); + const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isEditingSplitBill} = useConfirmationFields(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -111,7 +111,7 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: setMoneyRequestMerchant(transactionID, newMerchant, true, transactionHasReceipt); }; - if (isNewManualExpenseFlowEnabled && !isReadOnly) { + if (!isReadOnly) { return ( (null); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); @@ -103,9 +103,6 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT }; useEffect(() => { - if (!isNewManualExpenseFlowEnabled) { - return; - } // Compare the numeric value rather than the formatted string. An in-progress edit such as "5.0" (or an // empty field) represents the same stored amount as the re-padded "5.00", so it must not be overwritten // while the user is typing. Only refresh the field when the stored tax amount genuinely differs (e.g. the @@ -119,21 +116,21 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT } numberFormRef.current?.updateNumber(taxAmountInput); onTaxAmountEmptyChange?.(false); - }, [isNewManualExpenseFlowEnabled, taxAmount, taxAmountInput, onTaxAmountEmptyChange]); + }, [taxAmount, taxAmountInput, onTaxAmountEmptyChange]); useEffect(() => { - if (isNewManualExpenseFlowEnabled && canModifyTaxFields) { + if (canModifyTaxFields) { return () => onTaxAmountEmptyChange?.(false); } onTaxAmountEmptyChange?.(false); - }, [isNewManualExpenseFlowEnabled, canModifyTaxFields, onTaxAmountEmptyChange]); + }, [canModifyTaxFields, onTaxAmountEmptyChange]); useEffect(() => { - if (!isNewManualExpenseFlowEnabled || formError !== 'iou.error.invalidTaxAmount' || taxAmount > maxTaxAmount) { + if (formError !== 'iou.error.invalidTaxAmount' || taxAmount > maxTaxAmount) { return; } clearFormErrors(['iou.error.invalidTaxAmount']); - }, [isNewManualExpenseFlowEnabled, formError, taxAmount, maxTaxAmount, clearFormErrors]); + }, [formError, taxAmount, maxTaxAmount, clearFormErrors]); return ( <> @@ -158,7 +155,7 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT errorText={shouldDisplayTaxRateError ? translate(formError as TranslationPaths) : ''} sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.TAX_RATE_FIELD} /> - {isNewManualExpenseFlowEnabled && canModifyTaxFields ? ( + {canModifyTaxFields ? ( @@ -135,7 +135,6 @@ function ClassificationFields({ shouldDisplayFieldError={errorState.shouldDisplayFieldError} didConfirm={didConfirm} isReadOnly={isReadOnly} - isNewManualExpenseFlowEnabled={isNewManualExpenseFlowEnabled} formError={errorState.formError} transactionID={transactionID} action={action} diff --git a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx deleted file mode 100644 index a209e7fd0c03..000000000000 --- a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import ScrollView from '@components/ScrollView'; - -import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; -import type {NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps} from 'react-native'; - -// Deliberately not the @components/FlashList wrapper: it doesn't type `ref` (this list needs one for layout reads), -// and its only addition — composer scroll-event emission — would fire duplicates here, since this list's scroll events -// are synthesized from the parent list's scroll, which already emits them. -import {FlashList} from '@shopify/flash-list'; -import React, {useEffect, useImperativeHandle, useRef} from 'react'; -import {View} from 'react-native'; - -/** - * A tiny subscribe/notify store carrying the parent list's vertical scroll offset. It is fed by the parent's onScroll - * WITHOUT React state, so the parent (and every sibling report action) never re-renders on scroll; the offset reaches - * the driver, which turns it into a synthetic scroll event that updates only the nested FlashList's render stack. - */ -type ScrollOffsetStore = { - getOffset: () => number; - setOffset: (offset: number) => void; - subscribe: (listener: () => void) => () => void; -}; - -function createScrollOffsetStore(): ScrollOffsetStore { - let offset = 0; - const listeners = new Set<() => void>(); - return { - getOffset: () => offset, - setOffset: (next: number) => { - if (next === offset) { - return; - } - offset = next; - for (const listener of listeners) { - listener(); - } - }, - subscribe: (listener: () => void) => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - }; -} - -// The subset of the ScrollView imperative surface FlashList actually drives — `getScrollableNode` is read internally -// by RecyclerView for bound detection, and `scrollTo`/`scrollToEnd`/`flashScrollIndicators`/`getNativeScrollRef` back -// FlashList's public ref. Naming it makes "the part of ScrollView the driver must honor" explicit rather than erased. -type MinimalScrollRef = { - scrollTo: () => void; - scrollToEnd: () => void; - flashScrollIndicators: () => void; - getScrollableNode: () => View | null; - getNativeScrollRef: () => View | null; -}; - -// `store` and `offsetTop` are injected at runtime by FlashList via `overrideProps`, never by FlashList's own typed -// call site — declaring them optional makes the driver structurally a ScrollView component, so no cast is needed to -// pass it as `renderScrollComponent`. FlashList's renderScrollComponent wrapper passes the ref as a prop, so the -// driver takes `ref` directly (React 19 style) rather than via forwardRef. -type ExternalScrollDriverProps = Omit & { - /** Source of the parent list's vertical scroll offset. */ - store?: ScrollOffsetStore; - - /** Where the table region starts within the parent page's scrollable content (px from the top). */ - offsetTop?: number; - - /** Imperative handle FlashList drives (scrollTo/scrollToEnd/getScrollableNode…). */ - ref?: React.Ref; -}; - -/** - * Replacement scroll container for the nested table FlashList. It does NOT scroll — it is a plain View that grows to - * the full content height so the parent page scrolls through it — and it synthesizes FlashList's vertical scroll - * events from the parent's offset. FlashList reads `getScrollableNode` internally and delegates its public - * `scrollTo`/`scrollToEnd`/`flashScrollIndicators`/`getNativeScrollRef` to this ref; the scroll no-ops mean offset - * corrections settle below the fold exactly like the parent-driven windowing. Must be a stable module-level component: - * FlashList memoizes its scroll component on identity. - */ -function ExternalScrollDriver({store, offsetTop = 0, onScroll, children, style, ref}: ExternalScrollDriverProps) { - const nodeRef = useRef(null); - - useImperativeHandle( - ref, - () => ({ - scrollTo: () => {}, - scrollToEnd: () => {}, - flashScrollIndicators: () => {}, - getScrollableNode: () => nodeRef.current, - getNativeScrollRef: () => nodeRef.current, - }), - [], - ); - - useEffect(() => { - if (!store) { - return; - } - const emit = () => { - // Offset into the nested list's own coordinate space (FlashList subtracts its measured firstItemOffset — - // the header height — internally). Only `contentOffset.y` is read for a vertical list, so nothing else is - // populated; windowing comes from the `overrideWindowSize` prop, not this event. - const y = Math.max(0, store.getOffset() - offsetTop); - try { - // Synthesizing a native scroll event requires an assertion: NativeSyntheticEvent's target/currentTarget - // are RN HostInstances that can't be constructed in JS. FlashList's vertical handler reads only - // contentOffset.y. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - onScroll?.({nativeEvent: {contentOffset: {x: 0, y}}} as NativeSyntheticEvent); - } catch { - // During back-navigation teardown FlashList can be driven after its layout manager is gone, which - // throws ("LayoutManager is not initialized"). This call only feeds a synthetic scroll frame to - // window the nested list, so a dropped frame here is harmless — swallow it rather than crash. - } - }; - // Seed the initial window, then track subsequent parent scrolls. Re-subscribes only when the store, the - // measured offset, or FlashList's scroll handler change — none of which happen per scroll frame. - emit(); - return store.subscribe(emit); - }, [store, offsetTop, onScroll]); - - return ( - - {children} - - ); -} - -type ExternalScrollFlashListTableHandle = { - /** Page-space position of a row — where it sits within the parent's scrollable content. Derived from the nested - * list's layout data, so it works for rows that aren't mounted. The table owns this math (offsetTop + its own - * header height + the row's layout) so the parent never learns the nested coordinate system. */ - getRowPageOffset: (index: number) => {top: number; height: number} | undefined; -}; - -type ExternalScrollFlashListTableProps = { - /** Rows to render. FlashList windows and recycles them against the parent's scroll offset. */ - items: T[]; - - /** Stable key per row. */ - keyExtractor: (item: T, index: number) => string; - - /** Recycling bucket per row (transaction vs. group header) so FlashList reuses like with like. */ - getItemType: (item: T) => string; - - /** Renders a single row. */ - renderItem: (item: T, index: number, meta: {isFirst: boolean; isLast: boolean}) => React.ReactElement | null; - - /** Column header rendered above the rows and scrolled horizontally with them. */ - renderHeader: () => React.ReactElement | null; - - /** Estimated row height used before a row has been measured. */ - estimatedRowHeight: number; - - /** Full table width (wider than the viewport). Drives the horizontal scroll range. */ - contentWidth: number; - - /** Shared offset store fed by the parent's onScroll. */ - store: ScrollOffsetStore; - - /** Visible height of the parent viewport. */ - viewportHeight: number; - - /** Where the table region starts within the parent page's scrollable content (px from the top). */ - offsetTop: number; - - /** Imperative handle exposing row positions in page space (see ExternalScrollFlashListTableHandle). */ - ref?: React.Ref; -}; - -/** - * A vertically-virtualized, horizontally-scrollable table built on FlashList instead of a hand-rolled virtualized list. - * - * The nested FlashList is fed a non-scrolling `ExternalScrollDriver` as its scroll container, so it grows to full - * content height (the parent page scrolls through it) while FlashList still recycles rows against the parent's scroll - * offset — via the patched `overrideWindowSize` prop, which lets FlashList treat the parent viewport as its window - * instead of measuring its own (full-height) container. A single native horizontal ScrollView wraps the whole list, so - * all rows share one smooth horizontal scroll and nothing outside it moves sideways. - * - * LAYOUT NOTE: FlashList's outer container defaults to `flex: 1` + `overflow: hidden` (a clipping viewport). We - * override it via `style` below to grow to content height and not clip, since the page — not the list — owns vertical - * scroll. - */ -function ExternalScrollFlashListTable({ - items, - keyExtractor, - getItemType, - renderItem, - renderHeader, - estimatedRowHeight, - contentWidth, - store, - viewportHeight, - offsetTop, - ref, -}: ExternalScrollFlashListTableProps) { - const lastIndex = items.length - 1; - const listRef = useRef>(null); - - useImperativeHandle( - ref, - () => ({ - getRowPageOffset: (index: number) => { - const layout = listRef.current?.getLayout(index); - if (!layout) { - return undefined; - } - return {top: offsetTop + (listRef.current?.getFirstItemOffset() ?? 0) + layout.y, height: layout.height}; - }, - }), - [offsetTop], - ); - - return ( - - - ref={listRef} - data={items} - keyExtractor={keyExtractor} - getItemType={getItemType} - renderItem={({item, index}: ListRenderItemInfo) => renderItem(item, index, {isFirst: index === 0, isLast: index === lastIndex})} - ListHeaderComponent={renderHeader()} - drawDistance={estimatedRowHeight * 12} - renderScrollComponent={ExternalScrollDriver} - // Consumed by ExternalScrollDriver (FlashList spreads overrideProps onto the scroll component). - overrideProps={{store, offsetTop}} - // Treat the parent viewport as the list's window instead of measuring the (full-height) driver View. - overrideWindowSize={{width: contentWidth, height: viewportHeight}} - // Grow to content height and don't clip — the parent page owns vertical scroll, so the list's own - // clipping viewport must be neutralized. - style={{width: contentWidth, flexGrow: 0, flexShrink: 0, flexBasis: 'auto', overflow: 'visible'}} - scrollEnabled={false} - /> - - ); -} - -export default ExternalScrollFlashListTable; -export {createScrollOffsetStore}; -export type {ExternalScrollFlashListTableHandle}; diff --git a/src/components/MoneyRequestReportView/ExternalScrollLegendListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollLegendListTable.tsx new file mode 100644 index 000000000000..2dc60dfaab68 --- /dev/null +++ b/src/components/MoneyRequestReportView/ExternalScrollLegendListTable.tsx @@ -0,0 +1,280 @@ +import ScrollView from '@components/ScrollView'; + +import type {LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps} from 'react-native'; + +import {LegendList} from '@legendapp/list/react-native'; +import React, {useEffect, useImperativeHandle, useRef} from 'react'; +import {View} from 'react-native'; + +/** + * A tiny subscribe/notify store carrying the parent list's vertical scroll offset. It is fed by the parent's onScroll + * WITHOUT React state, so the parent (and every sibling report action) never re-renders on scroll; the offset reaches + * the driver, which turns it into a synthetic scroll event that updates only the nested LegendList's render window. + */ +type ScrollOffsetStore = { + getOffset: () => number; + setOffset: (offset: number) => void; + subscribe: (listener: () => void) => () => void; +}; + +function createScrollOffsetStore(): ScrollOffsetStore { + let offset = 0; + const listeners = new Set<() => void>(); + return { + getOffset: () => offset, + setOffset: (next: number) => { + if (next === offset) { + return; + } + offset = next; + for (const listener of listeners) { + listener(); + } + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} + +type MeasureCallback = (x: number, y: number, width: number, height: number, pageX: number, pageY: number) => void; + +// The ScrollView methods LegendList reads from its custom scroll component. The driver does not scroll itself, but it +// must report the parent viewport during layout and provide the no-op scrolling methods used by LegendList internals. +type MinimalScrollRef = { + measure: (callback: MeasureCallback) => void; + scrollTo: (options?: {x?: number; y?: number; animated?: boolean}) => void; + scrollToEnd: (options?: {animated?: boolean}) => void; + flashScrollIndicators: () => void; + getScrollableNode: () => View | null; + getNativeScrollRef: () => View | null; + getScrollResponder: () => null; + getCurrentScrollOffset: () => number; +}; + +type ExternalScrollDriverProps = Omit & { + /** Source of the parent list's vertical scroll offset. */ + store: ScrollOffsetStore; + + /** Where the table region starts within the parent page's scrollable content (px from the top). */ + offsetTop: number; + + /** Height LegendList must use for its virtualized viewport instead of the full-height driver View. */ + viewportHeight: number; + + /** Imperative handle LegendList drives. */ + ref?: React.Ref; +}; + +/** + * Replacement scroll container for the nested table LegendList. It does not scroll. It is a plain View that grows to + * the full content height so the parent page scrolls through it. Its layout callbacks substitute the parent viewport + * height for the View's real height, giving LegendList a bounded virtualized window without a package patch. + */ +function ExternalScrollDriver({store, offsetTop, viewportHeight, onLayout, onScroll, children, style, testID, ref}: ExternalScrollDriverProps) { + const nodeRef = useRef(null); + const lastLayoutEventRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + measure: (callback) => { + const node = nodeRef.current; + if (!node) { + callback(0, 0, 0, viewportHeight, 0, 0); + return; + } + + node.measure((x, y, width, _height, pageX, pageY) => callback(x, y, width, viewportHeight, pageX, pageY)); + }, + scrollTo: () => {}, + scrollToEnd: () => {}, + flashScrollIndicators: () => {}, + getScrollableNode: () => nodeRef.current, + getNativeScrollRef: () => nodeRef.current, + getScrollResponder: () => null, + getCurrentScrollOffset: () => getLocalScrollOffset(store, offsetTop), + }), + [offsetTop, store, viewportHeight], + ); + + useEffect(() => { + const emit = () => { + // NativeSyntheticEvent's host targets cannot be constructed in JavaScript. LegendList's vertical handler + // reads contentOffset.y and treats the event like a normal parent-driven scroll frame. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + onScroll?.({nativeEvent: {contentOffset: {x: 0, y: getLocalScrollOffset(store, offsetTop)}}} as NativeSyntheticEvent); + }; + emit(); + return store.subscribe(emit); + }, [offsetTop, onScroll, store]); + + useEffect(() => { + const event = lastLayoutEventRef.current; + if (!event) { + return; + } + + onLayout?.(getViewportLayoutEvent(event, viewportHeight)); + }, [onLayout, viewportHeight]); + + const handleLayout = (event: LayoutChangeEvent) => { + lastLayoutEventRef.current = event; + onLayout?.(getViewportLayoutEvent(event, viewportHeight)); + }; + + return ( + + {children} + + ); +} + +function getLocalScrollOffset(store: ScrollOffsetStore, offsetTop: number): number { + return Math.max(0, store.getOffset() - offsetTop); +} + +function getViewportLayoutEvent(event: LayoutChangeEvent, viewportHeight: number): LayoutChangeEvent { + return { + ...event, + nativeEvent: { + ...event.nativeEvent, + layout: {...event.nativeEvent.layout, height: viewportHeight}, + }, + }; +} + +type ExternalScrollLegendListTableHandle = { + /** Page-space position of a row — where it sits within the parent's scrollable content. Derived from the nested + * list's layout data, so it works for rows that aren't mounted. The table owns this math (offsetTop + its own + * header height + the row's layout) so the parent never learns the nested coordinate system. */ + getRowPageOffset: (index: number) => {top: number; height: number} | undefined; +}; + +type ExternalScrollLegendListTableProps = { + /** Rows to render. LegendList windows them against the parent's scroll offset. */ + items: T[]; + + /** Stable key per row. */ + keyExtractor: (item: T, index: number) => string; + + /** Item type per row, used for independent row-size estimates. */ + getItemType: (item: T) => string; + + /** Renders a single row. */ + renderItem: (item: T, index: number, meta: {isFirst: boolean; isLast: boolean}) => React.ReactElement | null; + + /** Column header rendered above the rows and scrolled horizontally with them. */ + renderHeader: () => React.ReactElement | null; + + /** Estimated row height used before a row has been measured. */ + estimatedRowHeight: number; + + /** Full table width (wider than the viewport). Drives the horizontal scroll range. */ + contentWidth: number; + + /** Shared offset store fed by the parent's onScroll. */ + store: ScrollOffsetStore; + + /** Visible height of the parent viewport. */ + viewportHeight: number; + + /** Where the table region starts within the parent page's scrollable content (px from the top). */ + offsetTop: number; + + /** Imperative handle exposing row positions in page space. */ + ref?: React.Ref; +}; + +/** + * A vertically virtualized, horizontally scrollable table driven by its parent LegendList's vertical offset. + * The custom scroll driver grows to content height while reporting the parent's bounded viewport to LegendList. A + * single horizontal ScrollView keeps the column header and rows aligned without moving the chat below it. + */ +function ExternalScrollLegendListTable({ + items, + keyExtractor, + getItemType, + renderItem, + renderHeader, + estimatedRowHeight, + contentWidth, + store, + viewportHeight, + offsetTop, + ref, +}: ExternalScrollLegendListTableProps) { + const lastIndex = items.length - 1; + const listRef = useRef(null); + const headerSizeRef = useRef(0); + + useImperativeHandle( + ref, + () => ({ + getRowPageOffset: (index: number) => { + const state = listRef.current?.getState(); + const rowTop = state?.positionAtIndex(index); + if (rowTop === undefined || !Number.isFinite(rowTop)) { + return undefined; + } + return { + top: offsetTop + headerSizeRef.current + rowTop, + height: state?.sizeAtIndex(index) ?? estimatedRowHeight, + }; + }, + }), + [estimatedRowHeight, offsetTop], + ); + + const renderScrollComponent = (scrollProps: ScrollViewProps) => ( + + ); + + return ( + + + ref={listRef} + data={items} + keyExtractor={keyExtractor} + getItemType={getItemType} + renderItem={({item, index}: LegendListRenderItemProps) => renderItem(item, index, {isFirst: index === 0, isLast: index === lastIndex})} + extraData={renderItem} + ListHeaderComponent={renderHeader()} + drawDistance={estimatedRowHeight * 12} + estimatedItemSize={estimatedRowHeight} + estimatedListSize={{width: contentWidth, height: viewportHeight}} + renderScrollComponent={renderScrollComponent} + onMetricsChange={({headerSize}) => { + headerSizeRef.current = headerSize; + }} + // Grow to content height and don't clip — the parent page owns vertical scroll, so the list's own + // clipping viewport must be neutralized. + style={{width: contentWidth, flexGrow: 0, flexShrink: 0, flexBasis: 'auto', overflow: 'visible'}} + scrollEnabled={false} + /> + + ); +} + +export default ExternalScrollLegendListTable; +export {createScrollOffsetStore}; +export type {ExternalScrollLegendListTableHandle}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 1b8f09edaa03..72aacd5b03c7 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -1,5 +1,3 @@ -import ScrollView from '@components/ScrollView'; - import useAppFocusEvent from '@hooks/useAppFocusEvent'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; @@ -76,9 +74,8 @@ import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {DeviceEventEmitter, View} from 'react-native'; +import MoneyRequestReportEmptyStateView from './MoneyRequestReportEmptyStateView'; import MoneyRequestReportTransactionList from './MoneyRequestReportTransactionList'; -import MoneyRequestViewReportFields from './MoneyRequestViewReportFields'; -import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyState'; import SelectionToolbar from './SelectionToolbar'; /** @@ -639,8 +636,12 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) hasNextActionMadeBySameActor(visibleReportActions, indexWithinReportActions, isOffline); const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; + // This value cannot be memoized, because it is based on the indexWithinReportActions which changes on every render. + // eslint-disable-next-line react/jsx-no-constructed-context-values + const reportActionIndexContextValue = {index: indexWithinReportActions, isNewest: indexWithinReportActions === visibleReportActions.length - 1}; + return ( - + - - - + )} {!isReportEmpty && !!reportStable && ( ; + + onLayout?: (event: LayoutChangeEvent) => void; +}; + +/** Rendered instead of the unified list when the report has no transactions and no comments. */ +function MoneyRequestReportEmptyStateView({report, policy, onLayout}: MoneyRequestReportEmptyStateViewProps) { + const styles = useThemeStyles(); + + return ( + + + + + ); +} + +export default MoneyRequestReportEmptyStateView; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 74597bda34dd..6b345ac9c619 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,7 +1,6 @@ import LinkButton from '@components/ButtonComposed/composed/LinkButton'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import Checkbox from '@components/Checkbox'; -import type FlatListRefType from '@components/FlashList/types'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; @@ -63,6 +62,8 @@ import shouldShowTransactionYear from '@libs/TransactionUtils/shouldShowTransact import isReportOpenInSuperWideRHP from '@navigation/helpers/isReportOpenInSuperWideRHP'; import Navigation from '@navigation/Navigation'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; + import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -104,7 +105,7 @@ type TransactionListItemData = {type: 'section-header'; groupKey: string; group: /** * Bundle of data + JSX nodes the parent needs to render the unified list around the transaction-list state. * Wide on purpose: this is the single integration point between TransactionList's internal state and the parent - * FlatList that renders both transactions and report actions in one virtualized scroll. Splitting would just smear the + * list that renders both transactions and report actions in one virtualized scroll. Splitting would just smear the * same locals across multiple call sites without earning an abstraction. */ type MoneyRequestReportTransactionListController = { @@ -123,7 +124,7 @@ type MoneyRequestReportTransactionListController = { /** Chrome rendered below the transaction items (pending placeholder, Add Expense, breakdown, total). Null when there are no transactions. */ afterListContent: React.ReactElement | null; - /** True when the rendered table is wider than the viewport; the parent renders it via `ExternalScrollFlashListTable` with its own horizontal scroller. */ + /** True when the rendered table is wider than the viewport; the parent renders it via `ExternalScrollLegendListTable` with its own horizontal scroller. */ shouldScrollHorizontally: boolean; /** Pixel width of the table at full column visibility — passed to the horizontal scroll wrapper as `contentWidth`. */ @@ -138,7 +139,7 @@ const EMPTY_VIOLATIONS: OnyxTypes.TransactionViolations = []; /** * Looks up violations from the bulk collection and filters them via `getVisibleTransactionViolations`. * Returns the stable EMPTY_VIOLATIONS reference for the common no-violations case so the row's prop - * identity stays stable across FlashList recycles. + * identity stays stable across recycled list rows. */ function filterTransactionViolations( transaction: TransactionWithOptionalHighlight, @@ -202,8 +203,8 @@ type MoneyRequestReportTransactionListProps = { /** Report action ID the unified list should initially scroll to, when deep-linked. */ linkedReportActionID: string | undefined; - /** Ref forwarded to the underlying FlashList. */ - listRef: FlatListRefType; + /** Ref forwarded to the underlying action list. */ + listRef: ActionListRefType; /** Reports the unified list's last item index so the parent can jump to the bottom via scrollToIndex. */ onLastItemIndexChange?: (index: number) => void; @@ -211,28 +212,28 @@ type MoneyRequestReportTransactionListProps = { /** Accessibility label for the unified list. */ accessibilityLabel: string; - /** FlashList onLayout callback (distinct from the empty-state `onLayout` above). */ + /** Action list onLayout callback (distinct from the empty-state `onLayout` above). */ onListLayout: () => void; - /** FlashList onScroll callback. */ + /** Action list onScroll callback. */ onScroll: (event: NativeSyntheticEvent) => void; - /** FlashList onScrollBeginDrag callback. */ + /** Action list onScrollBeginDrag callback. */ onScrollBeginDrag: () => void; - /** FlashList onContentSizeChange callback. */ + /** Action list onContentSizeChange callback. */ onContentSizeChange: () => void; - /** FlashList onViewableItemsChanged callback. */ + /** Action list onViewableItemsChanged callback. */ onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; - /** FlashList onEndReached callback. */ + /** Action list onEndReached callback. */ onEndReached: () => void; - /** FlashList onStartReached callback. */ + /** Action list onStartReached callback. */ onStartReached: () => void; - /** FlashList contentContainerStyle. */ + /** Action list contentContainerStyle. */ contentContainerStyle: StyleProp; /** Whether the initial report actions are still loading. */ diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index d74572515164..e52d4cc7face 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -1,26 +1,26 @@ -import FlashList from '@components/FlashList'; -import type FlatListRefType from '@components/FlashList/types'; - import useWindowDimensions from '@hooks/useWindowDimensions'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; + import variables from '@styles/variables'; import type * as OnyxTypes from '@src/types/onyx'; -import type {FlashListProps, ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListRef, LegendListRenderItemProps, ViewToken as LegendListViewToken} from '@legendapp/list/react-native'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; -import React, {memo, useEffect, useRef, useState} from 'react'; +import {LegendList} from '@legendapp/list/react-native'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; -import type {ExternalScrollFlashListTableHandle} from './ExternalScrollFlashListTable'; +import type {ExternalScrollLegendListTableHandle} from './ExternalScrollLegendListTable'; import type {MoneyRequestReportTransactionListController, TransactionListItemData} from './MoneyRequestReportTransactionList'; -import ExternalScrollFlashListTable, {createScrollOffsetStore} from './ExternalScrollFlashListTable'; +import ExternalScrollLegendListTable, {createScrollOffsetStore} from './ExternalScrollLegendListTable'; import MoneyRequestViewReportFields from './MoneyRequestViewReportFields'; import ReportActionsListLoadingSkeleton from './ReportActionsListLoadingSkeleton'; -/** Single virtualized data item rendered by the unified FlatList. Mixes transactions, a footer marker, and report actions in one scroll. */ +/** Single virtualized data item rendered by the unified list. Mixes transactions, a footer marker, and report actions in one scroll. */ type UnifiedListItem = TransactionListItemData | {readonly type: 'transactions-footer'} | {readonly type: 'report-action'; readonly action: OnyxTypes.ReportAction}; const TRANSACTIONS_FOOTER_ITEM: UnifiedListItem = {type: 'transactions-footer'}; @@ -44,26 +44,6 @@ function unifiedListItemType(item: UnifiedListItem) { return item.type === 'report-action' ? item.action.actionName : item.type; } -type MoneyRequestReportFlashListProps = FlashListProps & { - /** Ref to the underlying list, shared via the ActionList context (typed for the legacy FlatList). */ - ref: FlatListRefType; -}; - -/** - * Forwards the shared ActionList context ref to the underlying FlashList. That context slot predates this FlashList-based - * list and is still shared with the legacy report list, so it is typed for a FlatList. Mirroring InvertedFlashList, the - * ref is forwarded through @components/FlashList — which receives it as an untyped runtime prop — so no type assertion is - * needed. The scroll manager relies on the FlashList registering into this slot. - */ -function MoneyRequestReportFlashList(props: MoneyRequestReportFlashListProps) { - return ( - - // thin forwarder; spreading the props (including the ref) is the point - {...props} - /> - ); -} - type MoneyRequestReportUnifiedListProps = { /** Controller that owns the transaction rows and their selection/long-press state. */ controller: MoneyRequestReportTransactionListController; @@ -90,7 +70,7 @@ type MoneyRequestReportUnifiedListProps = { newTransactionID?: string; /** Ref to the underlying list, shared via the ActionList context. */ - listRef: FlatListRefType; + listRef: ActionListRefType; accessibilityLabel: string; @@ -152,8 +132,8 @@ function MoneyRequestReportUnifiedList({ listFooterComponent, }: MoneyRequestReportUnifiedListProps) { // When the table is wider than the viewport it can't share the horizontally-scrolled container with the chat (chat - // would drift sideways / jump on web). Instead the FlashList keeps ONLY the report actions virtualized, and the table is - // rendered as the list header via ExternalScrollFlashListTable — a nested FlashList in its own single native + // would drift sideways / jump on web). Instead the LegendList keeps ONLY the report actions virtualized, and the table is + // rendered as the list header via ExternalScrollLegendListTable, a nested LegendList in its own single native // horizontal scroller that windows its rows against THIS list's vertical scroll offset. Chat never lives inside a // horizontal scroller, so it never moves sideways. Everywhere else the transactions stay virtualized inline with // the report actions. @@ -180,10 +160,10 @@ function MoneyRequestReportUnifiedList({ const reportActionIndexOffset = shouldInlineTransactions ? controller.transactionListItems.length + 1 : 0; // Latest viewable items, kept current from onViewableItemsChanged, so the new-transaction scroll can skip when the row is already on screen. - const viewableItemsRef = useRef([]); + const viewableItemsRef = useRef>>([]); // Handle to the nested table (horizontal mode) — read for row page positions, never driven to scroll (its scroll is a no-op). - const tableRef = useRef(null); + const tableRef = useRef(null); // Viewport height + table offset fed to the nested table so it can window its rows against this list's scroll. // tableOffsetTop is the height of everything above the table region (the report-fields header). @@ -193,7 +173,7 @@ function MoneyRequestReportUnifiedList({ const [viewportHeight, setViewportHeight] = useState(windowHeight); const [tableOffsetTop, setTableOffsetTop] = useState(0); - // A subscribe/notify store carries the scroll offset to the nested table FlashList with zero parent re-renders. + // A subscribe/notify store carries the scroll offset to the nested table LegendList with zero parent re-renders. // Lazy useState initializer (not useRef.current) so it is created exactly once without reading a ref during render. const [scrollOffsetStore] = useState(createScrollOffsetStore); @@ -204,7 +184,7 @@ function MoneyRequestReportUnifiedList({ }, [report.reportID, scrollOffsetStore]); const handleScroll = (event: NativeSyntheticEvent) => { - // Always feed the offset store (emitter, not state: the nested FlashList updates its own render stack without + // Always feed the offset store (emitter, not state: the nested LegendList updates its own render window without // re-rendering the parent). Feed it even in inline mode — the store has no subscribers then, so this is a cheap // write — so that if the layout flips to the horizontal table, the nested list windows against the real scroll // offset instead of a stale 0. @@ -218,9 +198,9 @@ function MoneyRequestReportUnifiedList({ }; // The hook compares unreadMarkerReportActionIndex (0-based within visibleReportActions) against - // raw FlashList indices. When transactions are present, report actions start at reportActionIndexOffset, + // raw LegendList indices. When transactions are present, report actions start at reportActionIndexOffset, // so we shift all viewable indices down before forwarding so the comparison is apples-to-apples. - const onViewableItemsChangedAdjusted = (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { + const onViewableItemsChangedAdjusted = (info: {viewableItems: Array>; changed: Array>}) => { // Keep the raw array so the new-transaction effect can tell whether the new row is already on screen. viewableItemsRef.current = info.viewableItems; if (reportActionIndexOffset === 0) { @@ -233,7 +213,9 @@ function MoneyRequestReportUnifiedList({ }); }; - const dispatchRenderItem = ({item, index}: ListRenderItemInfo) => { + const listExtraData = {reportActionsExtraData, renderReportAction, renderTransactionListItem: controller.renderTransactionListItem, afterListContent: controller.afterListContent}; + + const dispatchRenderItem = ({item, index}: LegendListRenderItemProps) => { switch (item.type) { case 'section-header': case 'transaction': @@ -250,7 +232,7 @@ function MoneyRequestReportUnifiedList({ const linkedActionLocalIndex = linkedReportActionID ? visibleReportActions.findIndex((action) => action.reportActionID === linkedReportActionID) : -1; const initialScrollIndex = linkedActionLocalIndex >= 0 ? linkedActionLocalIndex + reportActionIndexOffset : undefined; - // FlashList's `initialScrollIndex` is captured once at mount. On a cold deep-link open the linked action is + // LegendList's `initialScrollIndex` is captured once at mount. On a cold deep-link open the linked action is // often not in `visibleReportActions` yet (it paginates in after mount), so the mount-only hint resolves to // undefined and the list never anchors on the linked message. Re-anchor imperatively once the linked action is // present. Guarded so it fires exactly once per linked target and never yanks the user after they've scrolled. @@ -302,7 +284,7 @@ function MoneyRequestReportUnifiedList({ return () => cancelAnimationFrame(rafId); } - // Horizontal table: the rows live in a nested FlashList whose own scroll is a no-op — only the parent page + // Horizontal table: the rows live in a nested LegendList whose own scroll is a no-op. Only the parent page // scrolls. Ask the table where the row sits in page space (works for unmounted rows) and scroll the parent there. const rafId = requestAnimationFrame(() => { scrolledToNewTransactionIDRef.current = newTransactionID; @@ -326,18 +308,27 @@ function MoneyRequestReportUnifiedList({ /> ); + const setListRef = (instance: LegendListRef | null) => { + const targetListRef = listRef; + if (!targetListRef) { + return; + } + targetListRef.current = instance; + }; + return ( - + ref={setListRef} accessibilityLabel={accessibilityLabel} testID="money-request-report-actions-list" data={data} - extraData={reportActionsExtraData} + extraData={listExtraData} renderItem={dispatchRenderItem} keyExtractor={unifiedListKeyExtractor} getItemType={unifiedListItemType} initialScrollIndex={initialScrollIndex} - maintainVisibleContentPosition={{autoscrollToBottomThreshold: undefined}} + maintainVisibleContentPosition + recycleItems onViewableItemsChanged={onViewableItemsChangedAdjusted} onLayout={handleLayout} onEndReached={onEndReached} @@ -353,7 +344,7 @@ function MoneyRequestReportUnifiedList({ {reportFieldsHeader} {controller.beforeListContent} - + items={controller.transactionListItems} keyExtractor={unifiedListKeyExtractor} getItemType={unifiedListItemType} @@ -394,4 +385,4 @@ function MoneyRequestReportUnifiedList({ ); } -export default memo(MoneyRequestReportUnifiedList); +export default MoneyRequestReportUnifiedList; diff --git a/src/components/Navigation/QuickCreationActionsBar/index.tsx b/src/components/Navigation/QuickCreationActionsBar/index.tsx index aa8e3d7c8467..8fa2e74b75c4 100644 --- a/src/components/Navigation/QuickCreationActionsBar/index.tsx +++ b/src/components/Navigation/QuickCreationActionsBar/index.tsx @@ -20,7 +20,6 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import getCreateReportRoute, {getReportsRootRoute, navigateToCreateReportWorkspaceSelection} from '@libs/Navigation/helpers/getCreateReportRoute'; import Navigation from '@libs/Navigation/Navigation'; import {openTravelDotLink} from '@libs/openTravelDotLink'; -import Permissions from '@libs/Permissions'; import {getDefaultChatEnabledPolicySelection, hasAcceptedTravelTerms, isPaidGroupPolicy, isPolicyAccessible, isWorkspaceProvisionedForTravel} from '@libs/PolicyUtils'; import {generateReportID, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; @@ -84,7 +83,7 @@ function QuickCreationActionsBar() { const shouldShowBookTravel = !!travelEnabledPolicy; - const isBlockedFromSpotnanaTravel = Permissions.isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL, allBetas); + const isBlockedFromSpotnanaTravel = isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL); const primaryContactMethod = primaryLogin ?? session?.email ?? ''; const isTravelReady = useMemo(() => { if (!!isBlockedFromSpotnanaTravel || !primaryContactMethod || Str.isSMSLogin(primaryContactMethod) || !isPaidGroupPolicy(travelEnabledPolicy)) { diff --git a/src/components/NumericEditingController/hooks/useNumericEditingController.ts b/src/components/NumericEditingController/hooks/useNumericEditingController.ts new file mode 100644 index 000000000000..f18a370956d7 --- /dev/null +++ b/src/components/NumericEditingController/hooks/useNumericEditingController.ts @@ -0,0 +1,127 @@ +import {normalizeNumericInput} from '@components/NumericEditingController/utils'; + +import useLocalize from '@hooks/useLocalize'; + +import {replaceAllDigits, stripDecimalsFromAmount, validateAmount} from '@libs/MoneyRequestUtils'; + +import {useEffect, useEffectEvent, useLayoutEffect, useRef, useState} from 'react'; + +import useNumericSelection from './useNumericSelection'; + +type UseNumericEditingControllerParams = { + value?: string; + + onInputChange?: (value: string) => void; + + allowNegative?: boolean; + + /** Number of decimal places accepted by the controller. */ + decimals?: number; + + /** Maximum number of integer digits accepted by the controller. */ + maxLength?: number; +}; + +/** Runs on mount and whenever `decimals` changes, sanitizing values that exceed the new precision. */ +function useDecimalsChangeEffect(decimals: number, sanitizeForDecimals: (decimals: number) => void) { + const previousDecimals = useRef(undefined); + const sanitizeForDecimalsEvent = useEffectEvent(sanitizeForDecimals); + + useEffect(() => { + if (previousDecimals.current === decimals) { + return; + } + + previousDecimals.current = decimals; + sanitizeForDecimalsEvent(decimals); + }, [decimals]); +} + +/** Owns numeric value, formatting, validation, and commits while delegating caret state to `useNumericSelection`. */ +function useNumericEditingController({value: externalValueProp, onInputChange, allowNegative = false, decimals = 0, maxLength}: UseNumericEditingControllerParams) { + const {fromLocaleDigit, toLocaleDigit} = useLocalize(); + + const externalValue = externalValueProp ?? ''; + + const [currentValue, setCurrentValue] = useState(externalValue); + const [previousExternalValue, setPreviousExternalValue] = useState(externalValue); + + // Keep the latest committed value available across batched state updates. + const committedValueRef = useRef(externalValue); + + const formattedNumber = replaceAllDigits(currentValue, toLocaleDigit); + + const {selection, collapse, reset, syncToEnd, syncAfterEdit, handleKeyPress, rejectEdit, handleNativeSelectionChange} = useNumericSelection({displayText: formattedNumber}); + + // Reset when the external value is cleared. Ignore other external changes while editing. + if (previousExternalValue !== externalValue) { + setPreviousExternalValue(externalValue); + if (externalValue === '') { + setCurrentValue(''); + reset(); + } + } + + // Commits a canonical value and returns the previously committed value. + const applyValue = (nextValue: string, {notify = true}: {notify?: boolean} = {}) => { + const previousValue = committedValueRef.current; + + committedValueRef.current = nextValue; + setCurrentValue(nextValue); + + if (notify && nextValue !== previousValue) { + onInputChange?.(nextValue); + } + + return previousValue; + }; + + const setNumber = (inputValue: string) => { + const numberWithLeadingZero = normalizeNumericInput(inputValue, {fromLocaleDigit, allowNegative}); + + if (!validateAmount(numberWithLeadingZero, decimals, maxLength, allowNegative)) { + rejectEdit(); + return; + } + + const previousValue = applyValue(numberWithLeadingZero); + + syncAfterEdit({previousText: previousValue, nextText: numberWithLeadingZero}); + }; + + // Replaces the canonical value without validation or notification and moves the caret to the end. + const updateNumber = (newNumber: string) => { + applyValue(newNumber, {notify: false}); + syncToEnd(newNumber); + }; + + const getNumber = () => committedValueRef.current; + + useLayoutEffect(() => { + // Keep the ref in sync with external resets, which bypass applyValue. + committedValueRef.current = currentValue; + }, [currentValue]); + + useDecimalsChangeEffect(decimals, (newDecimals) => { + // Empty values and values already valid at the new precision need no update. + if (externalValue === '' || validateAmount(currentValue, newDecimals, maxLength, allowNegative)) { + return; + } + + setNumber(stripDecimalsFromAmount(currentValue)); + }); + + return { + value: currentValue, + formattedNumber, + selection, + setNumber, + updateNumber, + getNumber, + clearSelection: collapse, + handleSelectionChange: handleNativeSelectionChange, + handleKeyPress, + }; +} + +export default useNumericEditingController; diff --git a/src/components/NumericEditingController/hooks/useNumericSelection.ts b/src/components/NumericEditingController/hooks/useNumericSelection.ts new file mode 100644 index 000000000000..700eddfa49a0 --- /dev/null +++ b/src/components/NumericEditingController/hooks/useNumericSelection.ts @@ -0,0 +1,148 @@ +import type {NumericEditingKeyPressEvent, NumericEditingSelection} from '@components/NumericEditingController/types'; +import {clampSelection, collapseSelection, getSelectionAfterEdit, getSelectionAtOffset, isForwardDeleteKeyPress} from '@components/NumericEditingController/utils'; + +import shouldIgnoreSelectionWhenUpdatedManually from '@libs/shouldIgnoreSelectionWhenUpdatedManually'; + +import {useIsFocused} from '@react-navigation/native'; +import {useLayoutEffect, useRef, useState} from 'react'; + +type UseNumericSelectionParams = { + /** Displayed text used to clamp native selections. */ + displayText: string; +}; + +type NumericSelectionEdit = { + previousText: string; + + nextText: string; +}; + +/** Manages the caret and guards against stale native selection events. */ +function useNumericSelection({displayText}: UseNumericSelectionParams) { + const isFocused = useIsFocused(); + + const [selection, setSelection] = useState(() => getSelectionAtOffset(displayText.length)); + const [previousIsFocused, setPreviousIsFocused] = useState(isFocused); + + // Native events can arrive before the pending text renders, so its length is kept to clamp them. + const pendingDisplayLengthRef = useRef(undefined); + // Native echoes one stale event after a controlled selection update. Consume it once. Only ever armed on the + // platforms that echo, so no platform check is needed where it is read. + const willSelectionBeUpdatedManually = useRef(false); + // Ignore the selection event generated by rejected input. + const willSelectionBeRestoredAfterInvalidInput = useRef(false); + // Forward-delete removes the next character, so the caret offset stays. + const forwardDeletePressedRef = useRef(false); + + const collapse = () => { + setSelection(collapseSelection); + }; + + // Reset selection when returning to the screen. + if (previousIsFocused !== isFocused) { + setPreviousIsFocused(isFocused); + if (isFocused) { + collapse(); + } + } + + // Prepare guards for a manual selection update. + const prepareSelectionUpdate = (nextText: string) => { + willSelectionBeUpdatedManually.current = shouldIgnoreSelectionWhenUpdatedManually; + pendingDisplayLengthRef.current = nextText.length; + }; + + // Moves the caret to the start after an external clear. + const reset = () => { + setSelection(getSelectionAtOffset(0)); + }; + + const syncToEnd = (nextText: string) => { + const offset = nextText.length; + + if (displayText.length === offset && selection.start === offset && selection.end === offset) { + return; + } + + prepareSelectionUpdate(nextText); + setSelection(getSelectionAtOffset(offset)); + }; + + // Tracks whether the next edit follows a forward-delete key press. + const handleKeyPress = (event: NumericEditingKeyPressEvent) => { + forwardDeletePressedRef.current = isForwardDeleteKeyPress(event); + }; + + // Restores the last valid caret after a rejected edit. + const rejectEdit = () => { + // React Native syncs against its last reported position, so ignore this event. + willSelectionBeRestoredAfterInvalidInput.current = true; + // Shallow copy forces native selection reset: https://github.com/Expensify/App/issues/16385 + setSelection((currentSelection) => ({...currentSelection})); + }; + + // Adjusts the caret for a text-length change. + const syncAfterEdit = ({previousText, nextText}: NumericSelectionEdit) => { + const wasForwardDeleteKeyPressed = forwardDeletePressedRef.current; + forwardDeletePressedRef.current = false; + + if (previousText === nextText) { + rejectEdit(); + return; + } + + prepareSelectionUpdate(nextText); + setSelection((currentSelection) => getSelectionAfterEdit(currentSelection, previousText, nextText, wasForwardDeleteKeyPressed)); + }; + + const handleNativeSelectionChange = (selectionStart: number, selectionEnd: number) => { + if (willSelectionBeRestoredAfterInvalidInput.current) { + willSelectionBeRestoredAfterInvalidInput.current = false; + return; + } + + if (willSelectionBeUpdatedManually.current) { + willSelectionBeUpdatedManually.current = false; + return; + } + + // iOS may report selection before the text renders, so use pending bounds. + const maxSelection = pendingDisplayLengthRef.current ?? displayText.length; + pendingDisplayLengthRef.current = undefined; + const reportedSelection = clampSelection({start: selectionStart, end: selectionEnd}, maxSelection); + + setSelection((currentSelection) => + // The input already holds this selection, so keep the current object and skip the render it would cost. + currentSelection.start === reportedSelection.start && currentSelection.end === reportedSelection.end ? currentSelection : reportedSelection, + ); + }; + + useLayoutEffect(() => { + // Clear the rejected-input guard after the selection commits. Keep the manual-update guard until its native + // echo is handled, because that event can arrive after the commit. + willSelectionBeRestoredAfterInvalidInput.current = false; + }, [selection]); + + useLayoutEffect(() => { + if (!isFocused) { + return; + } + + pendingDisplayLengthRef.current = undefined; + willSelectionBeUpdatedManually.current = false; + willSelectionBeRestoredAfterInvalidInput.current = false; + }, [isFocused]); + + return { + selection, + collapse, + reset, + syncToEnd, + syncAfterEdit, + handleKeyPress, + rejectEdit, + handleNativeSelectionChange, + }; +} + +export default useNumericSelection; diff --git a/src/components/NumericEditingController/index.ts b/src/components/NumericEditingController/index.ts new file mode 100644 index 000000000000..9c1484cf701f --- /dev/null +++ b/src/components/NumericEditingController/index.ts @@ -0,0 +1,2 @@ +export {default as useNumericEditingController} from './hooks/useNumericEditingController'; +export type {NumericEditingKeyPressEvent, NumericEditingRef} from './types'; diff --git a/src/components/NumericEditingController/types.ts b/src/components/NumericEditingController/types.ts new file mode 100644 index 000000000000..1eec2c742efb --- /dev/null +++ b/src/components/NumericEditingController/types.ts @@ -0,0 +1,19 @@ +type NumericEditingRef = { + clearSelection: () => void; + updateNumber: (newNumber: string) => void; + getNumber: () => string; +}; + +type NumericEditingKeyPressEvent = { + nativeEvent: { + key: string; + ctrlKey?: boolean; + }; +}; + +type NumericEditingSelection = { + start: number; + end: number; +}; + +export type {NumericEditingKeyPressEvent, NumericEditingRef, NumericEditingSelection}; diff --git a/src/components/NumericEditingController/utils.ts b/src/components/NumericEditingController/utils.ts new file mode 100644 index 000000000000..2eb81f7a2795 --- /dev/null +++ b/src/components/NumericEditingController/utils.ts @@ -0,0 +1,68 @@ +import {isMobileSafari} from '@libs/Browser'; +import getOperatingSystem from '@libs/getOperatingSystem'; +import {addLeadingZero, replaceAllDigits, replaceCommasWithPeriod, stripCommaFromAmount, stripSpacesFromAmount} from '@libs/MoneyRequestUtils'; + +import CONST from '@src/CONST'; + +import type {NumericEditingKeyPressEvent, NumericEditingSelection} from './types'; + +type NormalizeNumericInputOptions = { + fromLocaleDigit: (digit: string) => string; + + allowNegative?: boolean; +}; + +/** Normalizes localized input to the canonical form expected by `validateAmount`, without validating it. */ +function normalizeNumericInput(inputValue: string, {fromLocaleDigit, allowNegative = false}: NormalizeNumericInputOptions): string { + // Remove spaces iOS Safari adds when pasting: https://github.com/Expensify/App/issues/16974 + const inputWithoutSpaces = stripSpacesFromAmount(inputValue); + const inputWithCanonicalDigits = replaceAllDigits(inputWithoutSpaces, fromLocaleDigit); + const inputWithPeriodSeparator = inputWithCanonicalDigits.includes('.') ? stripCommaFromAmount(inputWithCanonicalDigits) : replaceCommasWithPeriod(inputWithCanonicalDigits); + + return addLeadingZero(inputWithPeriodSeparator, allowNegative); +} + +function isForwardDeleteKeyPress(event: NumericEditingKeyPressEvent): boolean { + const key = event.nativeEvent.key.toLowerCase(); + + if (isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) { + // Handle the Mac Accessibility keyboard shortcut on iOS Safari. + return true; + } + + const operatingSystem = getOperatingSystem(); + const isMacOrIOS = operatingSystem === CONST.OS.MAC_OS || operatingSystem === CONST.OS.IOS; + + // Control-D is the macOS/iOS hardware keyboard shortcut. + return key === 'delete' || (isMacOrIOS && !!event.nativeEvent.ctrlKey && key === 'd'); +} + +function clampOffset(offset: number, maxLength: number): number { + return Math.min(Math.max(offset, 0), maxLength); +} + +function getSelectionAtOffset(offset: number): NumericEditingSelection { + return {start: offset, end: offset}; +} + +function getSelectionAfterEdit(selection: NumericEditingSelection, previousText: string, nextText: string, wasForwardDeleteKeyPressed: boolean): NumericEditingSelection { + const isCollapsed = selection.start === selection.end; + const isDeletion = nextText.length < previousText.length; + const isForwardDelete = wasForwardDeleteKeyPressed && isCollapsed && isDeletion && nextText.startsWith(previousText.slice(0, selection.start)); + const offset = isForwardDelete ? selection.end : selection.end + (nextText.length - previousText.length); + + return getSelectionAtOffset(clampOffset(offset, nextText.length)); +} + +function collapseSelection(selection: NumericEditingSelection): NumericEditingSelection { + return getSelectionAtOffset(selection.end); +} + +function clampSelection(selection: NumericEditingSelection, maxLength: number): NumericEditingSelection { + return { + start: clampOffset(selection.start, maxLength), + end: clampOffset(selection.end, maxLength), + }; +} + +export {clampSelection, collapseSelection, getSelectionAfterEdit, getSelectionAtOffset, isForwardDeleteKeyPress, normalizeNumericInput}; diff --git a/src/components/NumericField/NumericField.tsx b/src/components/NumericField/NumericField.tsx new file mode 100644 index 000000000000..56472cda1602 --- /dev/null +++ b/src/components/NumericField/NumericField.tsx @@ -0,0 +1,68 @@ +import {useNumericEditingController} from '@components/NumericEditingController'; +import type {NumericEditingRef} from '@components/NumericEditingController'; + +import type {ForwardedRef, ReactNode} from 'react'; + +import {useImperativeHandle} from 'react'; + +import type {NumericFieldActionsContextValue, NumericFieldStateContextValue} from './context/types'; + +import {NumericFieldActionsContext, NumericFieldStateContext} from './context'; + +type NumericFieldProps = { + /** Canonical value shared by composed primitives; only an empty value resets editing state. */ + value?: string; + + /** Called with the canonical value whenever the user commits an edit. */ + onInputChange?: (value: string) => void; + + /** Whether negative values are allowed; the canonical value always stores its sign. */ + allowNegative?: boolean; + + /** Number of decimal places accepted by the form. */ + decimals?: number; + + /** Maximum number of integer digits accepted by the form. */ + maxLength?: number; + + /** Error supplied by FormProvider and rendered by the text input. */ + errorText?: string; + + /** Ref exposing the number editing imperative API. */ + ref?: ForwardedRef; + + /** Composed primitives that consume NumericField state and actions through context. */ + children: ReactNode; +}; + +function NumericField({value = '', onInputChange, allowNegative = false, decimals = 0, maxLength, errorText, ref, children}: NumericFieldProps) { + const controller = useNumericEditingController({value, onInputChange, allowNegative, decimals, maxLength}); + + useImperativeHandle(ref, () => ({ + clearSelection: controller.clearSelection, + getNumber: controller.getNumber, + updateNumber: controller.updateNumber, + })); + + const stateContextValue: NumericFieldStateContextValue = { + value: controller.value, + formattedNumber: controller.formattedNumber, + selection: controller.selection, + allowNegative, + errorText, + }; + + const actionsContextValue: NumericFieldActionsContextValue = { + setNumber: controller.setNumber, + handleSelectionChange: controller.handleSelectionChange, + handleKeyPress: controller.handleKeyPress, + }; + + return ( + + {children} + + ); +} + +export default NumericField; diff --git a/src/components/NumericField/context/NumericFieldContext.ts b/src/components/NumericField/context/NumericFieldContext.ts new file mode 100644 index 000000000000..977d4e0191ab --- /dev/null +++ b/src/components/NumericField/context/NumericFieldContext.ts @@ -0,0 +1,18 @@ +import createContextNamespace from '@hooks/createContextNamespace'; + +import type {NumericFieldActionsContextValue, NumericFieldStateContextValue} from './types'; + +const createNumericFieldContext = createContextNamespace('NumericField'); + +const [NumericFieldStateContext, useNumericFieldStateContext] = createNumericFieldContext('State'); +const [NumericFieldActionsContext, useNumericFieldActionsContext] = createNumericFieldContext('Actions'); + +function useNumericFieldState() { + return useNumericFieldStateContext('useNumericFieldState'); +} + +function useNumericFieldActions() { + return useNumericFieldActionsContext('useNumericFieldActions'); +} + +export {NumericFieldActionsContext, NumericFieldStateContext, useNumericFieldActions, useNumericFieldState}; diff --git a/src/components/NumericField/context/index.ts b/src/components/NumericField/context/index.ts new file mode 100644 index 000000000000..f51bc3b0e573 --- /dev/null +++ b/src/components/NumericField/context/index.ts @@ -0,0 +1 @@ +export {NumericFieldActionsContext, NumericFieldStateContext, useNumericFieldActions, useNumericFieldState} from './NumericFieldContext'; diff --git a/src/components/NumericField/context/types.ts b/src/components/NumericField/context/types.ts new file mode 100644 index 000000000000..d719b27b0089 --- /dev/null +++ b/src/components/NumericField/context/types.ts @@ -0,0 +1,30 @@ +import type {NumericEditingKeyPressEvent, NumericEditingSelection} from '@components/NumericEditingController/types'; + +type NumericFieldStateContextValue = { + /** Canonical signed value owned by the root. */ + value: string; + + /** Canonical value rendered with locale digits. */ + formattedNumber: string; + + /** Selection clamped to the displayed text. */ + selection: NumericEditingSelection; + + allowNegative: boolean; + + /** Error supplied by FormProvider and rendered by the text input. */ + errorText?: string; +}; + +type NumericFieldActionsContextValue = { + /** Normalizes, validates, and commits displayed text. */ + setNumber: (text: string) => void; + + /** Applies a native selection change, dropping stale events from manual updates. */ + handleSelectionChange: (selectionStart: number, selectionEnd: number) => void; + + /** Tracks forward-delete key presses for caret positioning. */ + handleKeyPress: (event: NumericEditingKeyPressEvent) => void; +}; + +export type {NumericFieldActionsContextValue, NumericFieldStateContextValue}; diff --git a/src/components/NumericField/index.tsx b/src/components/NumericField/index.tsx new file mode 100644 index 000000000000..a91a40c22b38 --- /dev/null +++ b/src/components/NumericField/index.tsx @@ -0,0 +1,39 @@ +/** + * NumericField – a composable field for editing numeric values. + * + * The root component owns the canonical value, the selection, validation, and + * the input callbacks through a root-instantiated edit controller. Compose the + * input presentation as a child: + * + * @example + * ```tsx + * import NumericField from '@components/NumericField'; + * + * + * + * + * ``` + * + * The `useNumericFieldState` and `useNumericFieldActions` hooks are also exported + * for custom composed primitives. + */ +import NumericFieldComponent from './NumericField'; +import NumericTextInput from './primitives/NumericTextInput'; + +const NumericField = Object.assign(NumericFieldComponent, { + /** Renders a numeric input using the standard text input component. */ + TextInput: NumericTextInput, +}); + +export default NumericField; +export {useNumericFieldActions, useNumericFieldState} from './context'; +export type {NumericFieldRef, NumericTextInputProps} from './types'; diff --git a/src/components/NumericField/primitives/NumericTextInput.tsx b/src/components/NumericField/primitives/NumericTextInput.tsx new file mode 100644 index 000000000000..c02a3c837c37 --- /dev/null +++ b/src/components/NumericField/primitives/NumericTextInput.tsx @@ -0,0 +1,80 @@ +import type {NumericEditingKeyPressEvent} from '@components/NumericEditingController'; +import {useNumericFieldActions, useNumericFieldState} from '@components/NumericField/context'; +import type {NumericTextInputProps} from '@components/NumericField/types'; +import TextInput from '@components/TextInput'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import type {TextInputSelectionChangeEvent} from 'react-native'; + +/** Text input primitive connected to a NumericField root. */ +function NumericTextInput({ + prefixCharacter = '', + ref, + onKeyPress, + onBlur, + accessibilityLabel, + autoFocus, + contentWidth, + disabled, + disableKeyboard, + keyboardType, + label, + onFocus, + prefixContainerStyle, + shouldApplyPaddingToContainer, + shouldUseDefaultLineHeightForPrefix, + onSubmitEditing, + submitBehavior, + testID, + touchableInputWrapperStyle, + style, +}: NumericTextInputProps) { + const styles = useThemeStyles(); + const {errorText, formattedNumber, selection} = useNumericFieldState(); + const {handleKeyPress, handleSelectionChange, setNumber} = useNumericFieldActions(); + + const handleInputKeyPress = (event: NumericEditingKeyPressEvent) => { + handleKeyPress(event); + onKeyPress?.(event); + }; + + return ( + handleSelectionChange(event.nativeEvent.selection.start, event.nativeEvent.selection.end)} + onSubmitEditing={onSubmitEditing} + prefixCharacter={prefixCharacter} + prefixContainerStyle={prefixContainerStyle} + prefixStyle={styles.colorMuted} + ref={ref} + selection={selection} + shouldApplyPaddingToContainer={shouldApplyPaddingToContainer} + shouldUseDefaultLineHeightForPrefix={shouldUseDefaultLineHeightForPrefix} + submitBehavior={submitBehavior} + testID={testID} + touchableInputWrapperStyle={touchableInputWrapperStyle} + value={formattedNumber} + /> + ); +} + +export default NumericTextInput; diff --git a/src/components/NumericField/types.ts b/src/components/NumericField/types.ts new file mode 100644 index 000000000000..59f69b83f99b --- /dev/null +++ b/src/components/NumericField/types.ts @@ -0,0 +1,39 @@ +import type {NumericEditingKeyPressEvent, NumericEditingRef} from '@components/NumericEditingController/types'; +import type {BaseTextInputProps, BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; + +import type {ForwardedRef} from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; + +type NumericFieldRef = NumericEditingRef; + +type NumericTextInputProps = { + /** Style applied to the number input. */ + style?: StyleProp; + + /** Reference to the underlying text input. */ + ref?: ForwardedRef; + + /** Callback for keyboard events received by the numeric input. */ + onKeyPress?: (event: NumericEditingKeyPressEvent) => void; +} & Pick< + BaseTextInputProps, + | 'accessibilityLabel' + | 'autoFocus' + | 'contentWidth' + | 'disabled' + | 'disableKeyboard' + | 'keyboardType' + | 'label' + | 'onBlur' + | 'onFocus' + | 'onSubmitEditing' + | 'prefixCharacter' + | 'prefixContainerStyle' + | 'shouldApplyPaddingToContainer' + | 'shouldUseDefaultLineHeightForPrefix' + | 'submitBehavior' + | 'testID' + | 'touchableInputWrapperStyle' +>; + +export type {NumericFieldRef, NumericTextInputProps}; diff --git a/src/components/OnyxListItemProvider.tsx b/src/components/OnyxListItemProvider.tsx index 54a076658207..309d8a412537 100644 --- a/src/components/OnyxListItemProvider.tsx +++ b/src/components/OnyxListItemProvider.tsx @@ -15,6 +15,7 @@ const [PersonalDetailsProvider, , usePersonalDetails] = createOnyxContext(ONYXKE const [BlockedFromConciergeProvider, , useBlockedFromConcierge] = createOnyxContext(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const [BetasProvider, BetasContext] = createOnyxContext(ONYXKEYS.BETAS); const [BetaConfigurationProvider, BetaConfigurationContext] = createOnyxContext(ONYXKEYS.BETA_CONFIGURATION); +const [BetaOverridesProvider, BetaOverridesContext] = createOnyxContext(ONYXKEYS.BETA_OVERRIDES); const [SessionProvider, , useSession] = createOnyxContext(ONYXKEYS.SESSION); const [PolicyCategoriesProvider, , usePolicyCategories] = createOnyxContext(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [PolicyTagsProvider, , usePolicyTags] = createOnyxContext(ONYXKEYS.COLLECTION.POLICY_TAGS); @@ -35,6 +36,7 @@ function OnyxListItemProvider(props: OnyxListItemProviderProps) { BlockedFromConciergeProvider, BetasProvider, BetaConfigurationProvider, + BetaOverridesProvider, SessionProvider, PolicyCategoriesProvider, PolicyTagsProvider, @@ -56,6 +58,7 @@ export { usePersonalDetails, BetasContext, BetaConfigurationContext, + BetaOverridesContext, useBlockedFromConcierge, useSession, usePolicyCategories, diff --git a/src/components/PopoverMenu/v2/content/ScrollableContent.tsx b/src/components/PopoverMenu/v2/content/ScrollableContent.tsx index 95cc55b11527..09123411da89 100644 --- a/src/components/PopoverMenu/v2/content/ScrollableContent.tsx +++ b/src/components/PopoverMenu/v2/content/ScrollableContent.tsx @@ -36,7 +36,7 @@ function ScrollableContent({contentContainerStyle, children, ...rest}: Scrollabl const childCount = React.Children.count(children); if (childCount > VIRTUALIZATION_RECOMMENDED_THRESHOLD) { Log.warn( - ` received ${childCount} children — renders all rows synchronously and will jank on lower-end devices for unbounded counts. Consider a virtualized list (FlashList) wrapper.`, + ` received ${childCount} children — renders all rows synchronously and will jank on lower-end devices for unbounded counts. Consider a virtualized list (LegendList) wrapper.`, ); } } diff --git a/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx b/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx index 27171c3e183d..84f9189259f4 100644 --- a/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx +++ b/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx @@ -5,9 +5,10 @@ import PDFThumbnailError from '@components/PDFThumbnail/PDFThumbnailError'; import useThemeStyles from '@hooks/useThemeStyles'; +import {useReportActionItemState} from '@pages/inbox/report/ReportActionIndexContext'; + import CONST from '@src/CONST'; -import React, {useState} from 'react'; import {View} from 'react-native'; import type LocalPDFReceiptPreviewProps from './types'; @@ -18,9 +19,9 @@ const DOCUMENT_OPTIONS = {cMapUrl: '/cmaps/', cMapPacked: true}; function LocalPDFReceiptPreview({sourceURL, shouldUseFullHeight, onLoadFailure, onLoadSuccess}: LocalPDFReceiptPreviewProps) { const styles = useThemeStyles(); - const [failedToLoad, setFailedToLoad] = useState(false); - const [containerSize, setContainerSize] = useState<{width: number; height: number} | undefined>(undefined); - const [pageAspectRatio, setPageAspectRatio] = useState(undefined); + const [failedToLoad, setFailedToLoad] = useReportActionItemState(false); + const [containerSize, setContainerSize] = useReportActionItemState<{width: number; height: number} | undefined>(undefined); + const [pageAspectRatio, setPageAspectRatio] = useReportActionItemState(undefined); const handleDocumentLoadSuccess = (pdf: PDFDocumentProxy) => { pdf.getPage(1) diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx index 280a4d2433e1..7481504b3ae7 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx @@ -25,11 +25,11 @@ import type {PersonalDetails, Policy, Report, ReportAction, Transaction, Transac import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import type {ListRenderItem} from '@shopify/flash-list'; +import type {LegendListProps} from '@legendapp/list/react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useDeferredValue, useState} from 'react'; +import React, {useDeferredValue, useState} from 'react'; import type {MoneyRequestReportPreviewStyleType} from './types'; @@ -66,7 +66,7 @@ type MoneyRequestReportPreviewProviderProps = ChildrenProps & { lastTransactionViolations: TransactionViolations; onPaymentOptionsShow?: () => void; onPaymentOptionsHide?: () => void; - renderTransactionItem: ListRenderItem; + renderTransactionItem: NonNullable['renderItem']>; onOrderedTransactionsChange?: (orderedTransactions: Transaction[]) => void; onCancelPendingPress?: () => void; currentWidth: number; @@ -118,18 +118,16 @@ function MoneyRequestReportPreviewProvider({ methodID: number | undefined; } | null>(null); - useFocusEffect( - useCallback(() => { - if (!isTransitionPending) { - return; - } - const handle = TransitionTracker.runAfterTransitions({ - callback: () => setIsTransitionPending(false), - waitForUpcomingTransition: true, - }); - return () => handle.cancel(); - }, [isTransitionPending]), - ); + useFocusEffect(() => { + if (!isTransitionPending) { + return; + } + const handle = TransitionTracker.runAfterTransitions({ + callback: () => setIsTransitionPending(false), + waitForUpcomingTransition: true, + }); + return () => handle.cancel(); + }); const shouldShowLoading = chatReportLoadingState != null && chatReportLoadingState.hasOnceLoadedReportActions !== true && transactions.length === 0 && !isOptimisticChatReport; const [transactionViolations] = useReportTransactionViolations(transactions); @@ -207,7 +205,7 @@ function MoneyRequestReportPreviewProvider({ onOrderedTransactionsChange, }); - const openReportFromPreview = useCallback(() => { + const openReportFromPreview = () => { if (!iouReportID) { return; } @@ -232,13 +230,13 @@ function MoneyRequestReportPreviewProvider({ } else { Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo})); } - }, [iouReportID, isSmallScreenWidth, onCancelPendingPress]); + }; // Only the pay flow opens this menu; approve surfaces its partial/full choice in the approve dropdown instead. - const onHoldMenuOpen = useCallback((paymentType?: PaymentMethodType, canPay?: boolean, methodID?: number) => { + const onHoldMenuOpen = (paymentType?: PaymentMethodType, canPay?: boolean, methodID?: number) => { setHoldMenu({paymentType, canPay: !!canPay, methodID}); - }, []); - const onHoldMenuClose = useCallback(() => setHoldMenu(null), []); + }; + const onHoldMenuClose = () => setHoldMenu(null); const shouldShowCarouselArrows = !shouldUseNarrowLayout && !shouldShowAccessPlaceHolder && transactions.length > 2 && reportPreviewStyles.expenseCountVisible; const buttonMaxWidth = diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/TransactionReportCarousel.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/TransactionReportCarousel.tsx index 630bf5135711..bee2b5ca99bc 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/TransactionReportCarousel.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/TransactionReportCarousel.tsx @@ -5,7 +5,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import {FlashList} from '@shopify/flash-list'; +import {LegendList} from '@legendapp/list/react-native'; import React from 'react'; import {View} from 'react-native'; @@ -49,7 +49,8 @@ function TransactionReportCarousel() { return ( - `${item.transactionID}_${reportPreviewStyles.transactionPreviewCarouselStyle.width}`} diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx index d5e3d80e3507..7b9d07c220dc 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx @@ -18,13 +18,16 @@ import {createTransactionThreadReport, openReport, setOptimisticTransactionThrea import {clearActiveTransactionIDs, getActiveTransactionIDs, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import { + getAllReportActions, getIOUActionForReportID, + getIOUActionForTransactionID, getOriginalMessage, + isDeletedAction, isMoneyRequestAction, isSplitBillAction as isSplitBillActionReportActionsUtils, isTrackExpenseAction as isTrackExpenseActionReportActionsUtils, } from '@libs/ReportActionsUtils'; -import {areAllRequestsBeingSmartScanned as areAllRequestsBeingSmartScannedReportUtils, getTransactionsWithReceipts, isIOUReport} from '@libs/ReportUtils'; +import {areAllRequestsBeingSmartScanned as areAllRequestsBeingSmartScannedReportUtils, getReportOrDraftReport, getTransactionsWithReceipts, isIOUReport} from '@libs/ReportUtils'; import {startSpan} from '@libs/telemetry/activeSpans'; import {hasNonReimbursableTransactions as hasNonReimbursableTransactionsTransactionUtils, isTransactionPendingDelete} from '@libs/TransactionUtils'; @@ -36,14 +39,14 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {hasOnceLoadedReportActionsSelector, isLoadingInitialReportActionsSelector, pendingNewTransactionIDsSelector} from '@src/selectors/ReportMetaData'; -import type {ReportActions, Transaction} from '@src/types/onyx'; +import type {ReportAction, ReportActions, Transaction} from '@src/types/onyx'; -import type {ListRenderItem} from '@shopify/flash-list'; +import type {LegendListProps} from '@legendapp/list/react-native'; import type {LayoutChangeEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/core'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import type {MoneyRequestReportPreviewProps} from './types'; @@ -55,6 +58,10 @@ const hasReportActionsSelector = (reportActions: OnyxEntry) => Ob // The stagger between the report and the expense that design asked for: https://github.com/Expensify/App/pull/92546#issuecomment-4687440972 const PRESSED_EXPENSE_CASCADE_DELAY = 180; +function isLiveIOUAction(reportAction: OnyxEntry): reportAction is ReportAction { + return !!reportAction && !isDeletedAction(reportAction) && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; +} + function MoneyRequestReportPreview({ iouReportID, iouReport, @@ -91,21 +98,21 @@ function MoneyRequestReportPreview({ const allReportTransactions = Object.values(reportTransactionsCollection ?? {}).filter((transaction): transaction is Transaction => !!transaction); const transactions = allReportTransactions.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const orderedTransactionsRef = useRef([]); - const handleOrderedTransactionsChange = useCallback((orderedTransactions: Transaction[]) => { + const handleOrderedTransactionsChange = (orderedTransactions: Transaction[]) => { orderedTransactionsRef.current = orderedTransactions; - }, []); + }; const [hasIOUReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(iouReportID)}`, { selector: hasReportActionsSelector, }); const pendingExpenseTransactionRef = useRef<{transaction: Transaction; originRoute: string} | null>(null); // Subscribing to the whole action list re-rendered every card on each write, and a split writes a burst of // them. Narrow it to the one action a deferred press is waiting for, so nothing pending means nothing changes. - const pendingPressActionCountSelector = useCallback((reportActions: OnyxEntry) => { + const pendingPressActionCountSelector = (reportActions: OnyxEntry) => { if (!pendingExpenseTransactionRef.current) { return undefined; } return Object.keys(reportActions ?? {}).length; - }, []); + }; const [pendingPressActionCount] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(iouReportID)}`, { selector: pendingPressActionCountSelector, }); @@ -125,48 +132,34 @@ function MoneyRequestReportPreview({ const widthsRef = useRef<{currentWidth: number | null; currentWrapperWidth: number | null}>({currentWidth: null, currentWrapperWidth: null}); const [widths, setWidths] = useState({currentWidth: 0, currentWrapperWidth: 0}); - const updateWidths = useCallback(() => { + const updateWidths = () => { const {currentWidth, currentWrapperWidth} = widthsRef.current; if (currentWidth && currentWrapperWidth) { setWidths({currentWidth, currentWrapperWidth}); } - }, []); - - const onCarouselLayout = useCallback( - (e: LayoutChangeEvent) => { - const newWidth = e.nativeEvent.layout.width; - if (widthsRef.current.currentWidth !== newWidth) { - widthsRef.current.currentWidth = newWidth; - updateWidths(); - } - }, - [updateWidths], - ); - const onWrapperLayout = useCallback( - (e: LayoutChangeEvent) => { - const newWrapperWidth = e.nativeEvent.layout.width; - if (widthsRef.current.currentWrapperWidth !== newWrapperWidth) { - widthsRef.current.currentWrapperWidth = newWrapperWidth; - updateWidths(); - } - }, - [updateWidths], - ); + }; - const reportPreviewStyles = useMemo( - () => StyleUtils.getMoneyRequestReportPreviewStyle(shouldUseNarrowLayoutIgnoringWideRHP, transactions.length, widths.currentWidth, widths.currentWrapperWidth), - [StyleUtils, widths, shouldUseNarrowLayoutIgnoringWideRHP, transactions.length], - ); - const shouldShowPayerAndReceiver = useMemo(() => { - if (!isIOUReport(iouReport) && action.childType !== CONST.REPORT.TYPE.IOU) { - return false; + const onCarouselLayout = (e: LayoutChangeEvent) => { + const newWidth = e.nativeEvent.layout.width; + if (widthsRef.current.currentWidth !== newWidth) { + widthsRef.current.currentWidth = newWidth; + updateWidths(); } + }; + const onWrapperLayout = (e: LayoutChangeEvent) => { + const newWrapperWidth = e.nativeEvent.layout.width; + if (widthsRef.current.currentWrapperWidth !== newWrapperWidth) { + widthsRef.current.currentWrapperWidth = newWrapperWidth; + updateWidths(); + } + }; - return transactions.some((transaction) => (Number(transaction?.modifiedAmount) || transaction?.amount) < 0); - }, [transactions, action.childType, iouReport]); + const reportPreviewStyles = StyleUtils.getMoneyRequestReportPreviewStyle(shouldUseNarrowLayoutIgnoringWideRHP, transactions.length, widths.currentWidth, widths.currentWrapperWidth); + const shouldShowPayerAndReceiver = + (isIOUReport(iouReport) || action.childType === CONST.REPORT.TYPE.IOU) && transactions.some((transaction) => (Number(transaction?.modifiedAmount) || transaction?.amount) < 0); - const cancelPendingPress = useCallback(() => { + const cancelPendingPress = () => { pendingExpenseTransactionRef.current = null; if (!cascadeTimerRef.current) { return; @@ -174,9 +167,9 @@ function MoneyRequestReportPreview({ clearTimeout(cascadeTimerRef.current.timer); cascadeTimerRef.current.release(); cascadeTimerRef.current = null; - }, []); + }; - const openReportFromPreview = useCallback(() => { + const openReportFromPreview = () => { if (!iouReportID || contextMenuRef.current?.isContextMenuOpening) { return; } @@ -202,7 +195,7 @@ function MoneyRequestReportPreview({ } else { Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo})); } - }, [cancelPendingPress, iouReportID, isSmallScreenWidth]); + }; const [hasOnceLoadedReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${chatReportID}`, { selector: hasOnceLoadedReportActionsSelector, }); @@ -229,180 +222,154 @@ function MoneyRequestReportPreview({ const transactionPreviewContainerStyles = [styles.h100, reportPreviewStyles.transactionPreviewCarouselStyle]; - const resolveChildReportID = useCallback( - (transaction: Transaction) => { - const transactionIOUAction = getIOUActionForReportID(transaction.reportID, transaction.transactionID); - let childReportID = transactionIOUAction?.childReportID ?? transaction.transactionThreadReportID; - if (childReportID) { - setOptimisticTransactionThread(childReportID, iouReport?.reportID ?? transaction.reportID, transactionIOUAction?.reportActionID, iouReport?.policyID ?? policyID); - } else if (transactionIOUAction?.reportActionID) { - const transactionID = isMoneyRequestAction(transactionIOUAction) ? getOriginalMessage(transactionIOUAction)?.IOUTransactionID : undefined; - if (transactionID) { - childReportID = createTransactionThreadReport({ - introSelected, - conciergeChat, - currentUserLogin: currentUserEmail ?? '', - currentUserAccountID, - betas, - iouReport, - iouReportAction: transactionIOUAction, - personalDetails: personalDetailsList, - })?.reportID; - } + const resolveChildReportID = (transaction: Transaction) => { + let transactionIOUAction = getIOUActionForReportID(transaction.reportID, transaction.transactionID); + if (transactionIOUAction && !isLiveIOUAction(transactionIOUAction)) { + const liveIOUAction = getIOUActionForTransactionID(Object.values(getAllReportActions(transaction.reportID) ?? {}).filter(isLiveIOUAction), transaction.transactionID); + if (!liveIOUAction) { + return undefined; } - return childReportID; - }, - [betas, conciergeChat, currentUserAccountID, currentUserEmail, introSelected, iouReport, personalDetailsList, policyID], - ); + transactionIOUAction = liveIOUAction; + } + let childReportID = transactionIOUAction?.childReportID ?? transaction.transactionThreadReportID; + if (childReportID) { + const existingThread = getReportOrDraftReport(childReportID); + if (existingThread && !existingThread.reportID) { + return undefined; + } + setOptimisticTransactionThread(childReportID, iouReport?.reportID ?? transaction.reportID, transactionIOUAction?.reportActionID, iouReport?.policyID ?? policyID); + } else if (transactionIOUAction?.reportActionID) { + const transactionID = isMoneyRequestAction(transactionIOUAction) ? getOriginalMessage(transactionIOUAction)?.IOUTransactionID : undefined; + if (transactionID) { + childReportID = createTransactionThreadReport({ + introSelected, + conciergeChat, + currentUserLogin: currentUserEmail ?? '', + currentUserAccountID, + betas, + iouReport, + iouReportAction: transactionIOUAction, + personalDetails: personalDetailsList, + })?.reportID; + } + } + return childReportID; + }; // `routeAtPress` is captured when the user pressed, not read live: a second press inside the cascade window // runs after the first press already navigated, so the live route is the report we opened and `backTo` would // point at itself. - const navigateToExpense = useCallback( - (childReportID: string, routeAtPress: string) => { - startSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${childReportID}`, { - name: 'MoneyRequestReportPreview.Transaction', - op: CONST.TELEMETRY.SPAN_OPEN_REPORT, - }); - - const openableTransactionIDs = (orderedTransactionsRef.current.length > 0 ? orderedTransactionsRef.current : transactions) - .filter((pressedTransaction) => !isTransactionPendingDelete(pressedTransaction)) - .map((pressedTransaction) => pressedTransaction.transactionID); - - if (isSmallScreenWidth && iouReportID) { - const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, `r/${iouReportID}`); - const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, backTo); - if (!wasPressedFromReport) { - Navigation.navigate(reportRoute); - } - const seeded = setActiveTransactionIDs(openableTransactionIDs); - const release = () => { - seeded.then(() => { - if (getActiveTransactionIDs().ids !== openableTransactionIDs) { - return; - } - clearActiveTransactionIDs(); - }); - }; - const timer = setTimeout(() => { - cascadeTimerRef.current = null; - if (!Navigation.isActiveRoute(reportRoute)) { - release(); - return; - } - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute})); - }, PRESSED_EXPENSE_CASCADE_DELAY); - cascadeTimerRef.current = {timer, release}; - return; - } + const navigateToExpense = (childReportID: string, routeAtPress: string) => { + startSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${childReportID}`, { + name: 'MoneyRequestReportPreview.Transaction', + op: CONST.TELEMETRY.SPAN_OPEN_REPORT, + }); - if (isSmallScreenWidth) { - setActiveTransactionIDs(openableTransactionIDs); - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: routeAtPress})); - return; + const openableTransactionIDs = (orderedTransactionsRef.current.length > 0 ? orderedTransactionsRef.current : transactions) + .filter((pressedTransaction) => !isTransactionPendingDelete(pressedTransaction)) + .map((pressedTransaction) => pressedTransaction.transactionID); + + if (isSmallScreenWidth && iouReportID) { + const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, `r/${iouReportID}`); + const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, backTo); + if (!wasPressedFromReport) { + Navigation.navigate(reportRoute); } + setActiveTransactionIDs(openableTransactionIDs); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute})); + return; + } - if (iouReportID) { - const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, `e/${iouReportID}`); - const reportRoute = ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}); - markReportRHPWidth(iouReportID, 'super-wide'); - if (!wasPressedFromReport) { - Navigation.navigate(reportRoute); - } - const seeded = setActiveTransactionIDs(openableTransactionIDs); - markReportRHPWidth(childReportID, 'wide'); - const release = () => { - unmarkReportRHPWidth(childReportID); - // Only clears if the report still carries our hint, so it can't undo one set by the report itself. - unmarkReportRHPWidth(iouReportID, 'super-wide'); - seeded.then(() => { - if (getActiveTransactionIDs().ids !== openableTransactionIDs) { - return; - } - clearActiveTransactionIDs(); - }); - }; - const timer = setTimeout(() => { - cascadeTimerRef.current = null; - if (!Navigation.isActiveRoute(reportRoute)) { - release(); + if (isSmallScreenWidth) { + setActiveTransactionIDs(openableTransactionIDs); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: routeAtPress})); + return; + } + + if (iouReportID) { + const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, `e/${iouReportID}`); + const reportRoute = ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}); + markReportRHPWidth(iouReportID, 'super-wide'); + if (!wasPressedFromReport) { + Navigation.navigate(reportRoute); + } + const seeded = setActiveTransactionIDs(openableTransactionIDs); + markReportRHPWidth(childReportID, 'wide'); + const release = () => { + unmarkReportRHPWidth(childReportID); + // Only clears if the report still carries our hint, so it can't undo one set by the report itself. + unmarkReportRHPWidth(iouReportID, 'super-wide'); + seeded.then(() => { + if (getActiveTransactionIDs().ids !== openableTransactionIDs) { return; } - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute})); - }, PRESSED_EXPENSE_CASCADE_DELAY); - cascadeTimerRef.current = {timer, release}; - return; - } + clearActiveTransactionIDs(); + }); + }; + const timer = setTimeout(() => { + cascadeTimerRef.current = null; + if (!Navigation.isActiveRoute(reportRoute)) { + release(); + return; + } + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute})); + }, PRESSED_EXPENSE_CASCADE_DELAY); + cascadeTimerRef.current = {timer, release}; + return; + } - setActiveTransactionIDs(openableTransactionIDs).then(() => { - markReportRHPWidth(childReportID, 'wide'); - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: routeAtPress})); - }); - }, - [isSmallScreenWidth, iouReportID, markReportRHPWidth, unmarkReportRHPWidth, transactions], - ); + setActiveTransactionIDs(openableTransactionIDs).then(() => { + markReportRHPWidth(childReportID, 'wide'); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: routeAtPress})); + }); + }; - const openTransactionFromPreview = useCallback( - (transaction: Transaction) => { - if (contextMenuRef.current?.isContextMenuOpening) { - return; - } + const openTransactionFromPreview = (transaction: Transaction) => { + if (contextMenuRef.current?.isContextMenuOpening) { + return; + } - const routeAtPress = Navigation.getActiveRoute(); + const routeAtPress = Navigation.getActiveRoute(); - pendingExpenseTransactionRef.current = null; - if (cascadeTimerRef.current) { - clearTimeout(cascadeTimerRef.current.timer); - cascadeTimerRef.current.release(); - cascadeTimerRef.current = null; - } + pendingExpenseTransactionRef.current = null; + if (cascadeTimerRef.current) { + clearTimeout(cascadeTimerRef.current.timer); + cascadeTimerRef.current.release(); + cascadeTimerRef.current = null; + } - if (transactions.length <= 1) { - openReportFromPreview(); - return; - } + if (transactions.length <= 1) { + openReportFromPreview(); + return; + } - if (transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - openReportFromPreview(); - return; - } + if (transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + openReportFromPreview(); + return; + } - const isIOUActionLoaded = !!getIOUActionForReportID(transaction.reportID, transaction.transactionID); - const childReportID = resolveChildReportID(transaction); - if (childReportID) { - if (!isIOUActionLoaded && iouReportID) { - if (isOffline) { - openReportFromPreview(); - return; - } - openReport({reportID: iouReportID, introSelected, conciergeChat, betas, currentUserAccountID, hasReportActions: !!hasIOUReportActions}); + const isIOUActionLoaded = !!getIOUActionForReportID(transaction.reportID, transaction.transactionID); + const childReportID = resolveChildReportID(transaction); + if (childReportID) { + if (!isIOUActionLoaded && iouReportID) { + if (isOffline) { + openReportFromPreview(); + return; } - navigateToExpense(childReportID, routeAtPress); - return; - } - - if (!isIOUActionLoaded && iouReportID && !isOffline) { - pendingExpenseTransactionRef.current = {transaction, originRoute: routeAtPress}; openReport({reportID: iouReportID, introSelected, conciergeChat, betas, currentUserAccountID, hasReportActions: !!hasIOUReportActions}); - return; } + navigateToExpense(childReportID, routeAtPress); + return; + } - openReportFromPreview(); - }, - [ - betas, - conciergeChat, - currentUserAccountID, - hasIOUReportActions, - introSelected, - iouReportID, - isOffline, - navigateToExpense, - openReportFromPreview, - resolveChildReportID, - transactions.length, - ], - ); + if (!isIOUActionLoaded && iouReportID && !isOffline) { + pendingExpenseTransactionRef.current = {transaction, originRoute: routeAtPress}; + openReport({reportID: iouReportID, introSelected, conciergeChat, betas, currentUserAccountID, hasReportActions: !!hasIOUReportActions}); + return; + } + + openReportFromPreview(); + }; useEffect(() => { const pendingPress = pendingExpenseTransactionRef.current; @@ -436,7 +403,7 @@ function MoneyRequestReportPreview({ [], ); - const renderItem: ListRenderItem = ({item}) => { + const renderItem: NonNullable['renderItem']> = ({item}) => { const transactionIOUAction = getIOUActionForReportID(item.reportID, item.transactionID); return ( void; - renderTransactionItem: ListRenderItem; + renderTransactionItem: NonNullable['renderItem']>; /** Called with the transactions in the order the carousel renders them */ onOrderedTransactionsChange?: (orderedTransactions: Transaction[]) => void; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx index ede408e2a1b1..e408fc247485 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx @@ -15,11 +15,11 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import type {Policy, Report, Transaction} from '@src/types/onyx'; -import type {FlashListRef, ListRenderItem, ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native'; import type {ViewToken} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {MoneyRequestReportPreviewStyleType} from './types'; @@ -60,7 +60,7 @@ type UseReportPreviewCarouselParams = { newTransactionIDs?: Set; /** Renders a single transaction preview item */ - renderTransactionItem: ListRenderItem; + renderTransactionItem: NonNullable['renderItem']>; }; /** @@ -85,31 +85,27 @@ function useReportPreviewCarousel({ const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(iouReport?.ownerAccountID)}); const isFocusedRef = useIsFocusedRef(); - const sortedTransactions = useMemo(() => { - if (shouldShowAccessPlaceHolder) { - return []; - } - const sorted = [...transactions].sort((a, b) => { - const rbrComparison = compareByRBR( - a, - b, - transactionViolations, - currentUserDetails?.login ?? '', - currentUserDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, - iouReport, - ownerLogin, - policy, - ); - if (rbrComparison !== 0) { - return rbrComparison; - } - // Tiebreak by date (ascending — oldest first) so position is stable across RBR state changes - return localeCompare(getCreated(a), getCreated(b)); - }); - return sorted; - }, [shouldShowAccessPlaceHolder, transactions, transactionViolations, currentUserDetails?.login, currentUserDetails?.accountID, iouReport, ownerLogin, policy, localeCompare]); - - const carouselTransactions = useMemo(() => sortedTransactions.slice(0, MAX_PREVIEWS_NUMBER + 1), [sortedTransactions]); + const sortedTransactions = shouldShowAccessPlaceHolder + ? [] + : [...transactions].sort((a, b) => { + const rbrComparison = compareByRBR( + a, + b, + transactionViolations, + currentUserDetails?.login ?? '', + currentUserDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, + iouReport, + ownerLogin, + policy, + ); + if (rbrComparison !== 0) { + return rbrComparison; + } + // Tiebreak by date (ascending — oldest first) so position is stable across RBR state changes + return localeCompare(getCreated(a), getCreated(b)); + }); + + const carouselTransactions = sortedTransactions.slice(0, MAX_PREVIEWS_NUMBER + 1); const prevCarouselTransactionLength = useRef(0); useEffect(() => { @@ -126,13 +122,13 @@ function useReportPreviewCarousel({ // value ensures that disabled state is applied instantly and not overridden by onViewableItemsChanged when scrolling // undefined makes arrow buttons react on currentIndex changes when scrolling manually const [optimisticIndex, setOptimisticIndex] = useState(undefined); - const carouselRef = useRef | null>(null); + const carouselRef = useRef(null); // Expose a callback ref instead of the ref object so the ref does not flow through the hook's return value // (React Compiler forbids reading/passing refs during render). - const setCarouselRef = useCallback((node: FlashListRef | null) => { + const setCarouselRef = (node: LegendListRef | null) => { carouselRef.current = node; - }, []); + }; const prevTransactionCountForScroll = useRef(carouselTransactions.length); const [carouselKey, setCarouselKey] = useState(0); @@ -148,15 +144,11 @@ function useReportPreviewCarousel({ prevTransactionCountForScroll.current = carouselTransactions.length; }, [carouselTransactions.length]); - const visibleItemsOnEndCount = useMemo(() => { - const lastItemWidth = transactions.length > MAX_PREVIEWS_NUMBER ? footerWidth : reportPreviewStyles.transactionPreviewCarouselStyle.width; - const lastItemWithGap = lastItemWidth + styles.gap2.gap; - const itemWithGap = reportPreviewStyles.transactionPreviewCarouselStyle.width + styles.gap2.gap; - return Math.floor((currentWidth - 2 * styles.pl2.paddingLeft - lastItemWithGap) / itemWithGap) + 1; - }, [transactions.length, footerWidth, reportPreviewStyles.transactionPreviewCarouselStyle.width, styles.gap2.gap, styles.pl2.paddingLeft, currentWidth]); - const viewabilityConfig = useMemo(() => { - return {itemVisiblePercentThreshold: 100}; - }, []); + const lastItemWidth = transactions.length > MAX_PREVIEWS_NUMBER ? footerWidth : reportPreviewStyles.transactionPreviewCarouselStyle.width; + const lastItemWithGap = lastItemWidth + styles.gap2.gap; + const itemWithGap = reportPreviewStyles.transactionPreviewCarouselStyle.width + styles.gap2.gap; + const visibleItemsOnEndCount = Math.floor((currentWidth - 2 * styles.pl2.paddingLeft - lastItemWithGap) / itemWithGap) + 1; + const viewabilityConfig = {itemVisiblePercentThreshold: 100}; const carouselTransactionsRef = useRef(carouselTransactions); @@ -194,14 +186,14 @@ function useReportPreviewCarousel({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [newTransactionIDs]); - const onViewableItemsChanged = useCallback(({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { + const onViewableItemsChanged = ({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { const newIndex = viewableItems.at(0)?.index; if (typeof newIndex === 'number') { setCurrentIndex(newIndex); } const viewableItemsIndexes = viewableItems.map((item) => item.index).filter((item): item is number => item !== null); setCurrentVisibleItems(viewableItemsIndexes); - }, []); + }; const snapOffsets = carouselTransactions.map((_, index) => index * (reportPreviewStyles.transactionPreviewCarouselStyle.width + styles.transactionsCarouselGap.width)); @@ -217,7 +209,7 @@ function useReportPreviewCarousel({ } if (index < 0) { setOptimisticIndex(0); - carouselRef.current?.scrollToTop({animated: true}); + carouselRef.current?.scrollToOffset({offset: 0, animated: true}); return; } if (index === carouselTransactions.length - visibleItemsOnEndCount) { @@ -232,7 +224,7 @@ function useReportPreviewCarousel({ }); }; - const renderItem = (itemInfo: ListRenderItemInfo) => { + const renderItem: NonNullable['renderItem']> = (itemInfo) => { if (itemInfo.index > MAX_PREVIEWS_NUMBER - 1) { return ( { + const adjustScroll = () => { // Workaround for a known React Native bug on Android (https://github.com/facebook/react-native/issues/27504): // When the FlatList is scrolled to the end and the last item is deleted, a blank space is left behind. // To fix this, we detect when onEndReached is triggered due to an item deletion, @@ -277,7 +269,7 @@ function useReportPreviewCarousel({ } prevCarouselTransactionLength.current = carouselTransactions.length; carouselRef.current?.scrollToEnd(); - }, [carouselTransactions.length]); + }; const renderSeparator = () => ; diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx index 6e64cd2d4184..24c0014f920c 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx @@ -3,6 +3,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import {useReportActionItemState} from '@pages/inbox/report/ReportActionIndexContext'; + import variables from '@styles/variables'; import {retrieveMaxCanvasArea, retrieveMaxCanvasHeight, retrieveMaxCanvasWidth} from '@userActions/CanvasSize'; @@ -10,7 +12,7 @@ import {retrieveMaxCanvasArea, retrieveMaxCanvasHeight, retrieveMaxCanvasWidth} import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect} from 'react'; import {PDFPreviewer} from 'react-fast-pdf'; import {View} from 'react-native'; @@ -52,7 +54,7 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure // Track which URL failed so hasFailed resets automatically when fileURL changes (e.g. after auth token refresh), // mirroring the pattern in ThumbnailImage. No useEffect needed — the comparison runs synchronously during render. - const [failedURL, setFailedURL] = useState(null); + const [failedURL, setFailedURL] = useReportActionItemState(null); const hasFailed = failedURL !== null && failedURL === fileURL; // If the PDF can't be rendered, fall back to the thumbnail underneath by rendering nothing. diff --git a/src/components/ReportActionItem/ReportActionItemImages.tsx b/src/components/ReportActionItem/ReportActionItemImages.tsx index 9a5615d3efba..f566ef523cce 100644 --- a/src/components/ReportActionItem/ReportActionItemImages.tsx +++ b/src/components/ReportActionItem/ReportActionItemImages.tsx @@ -9,7 +9,6 @@ import type {ThumbnailAndImageURI} from '@libs/ReceiptUtils'; import variables from '@styles/variables'; -import {useMappingHelper} from '@shopify/flash-list'; import {Str} from 'expensify-common'; import React from 'react'; import {View} from 'react-native'; @@ -53,7 +52,6 @@ function ReportActionItemImages({images, size, total, isHovered = false, onPress const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {getMappingKey} = useMappingHelper(); // Calculate the number of images to be shown, limited by the value of 'size' (if defined) // or the total number of images. const numberOfShownImages = Math.min(size ?? images.length, images.length); @@ -84,7 +82,7 @@ function ReportActionItemImages({images, size, total, isHovered = false, onPress // Show a border to separate multiple images. Shown to the right for each except the last. const shouldShowBorder = shownImages.length > 1 && index < shownImages.length - 1; const borderStyle = shouldShowBorder ? styles.reportActionItemImageBorder : {}; - const key = getMappingKey(image ?? '', index); + const key = `${image ?? ''}:${index}`; return ( ); } diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx index 3f1459c89b6d..ee4416975976 100644 --- a/src/components/Search/ExpenseGroupedSearchView.tsx +++ b/src/components/Search/ExpenseGroupedSearchView.tsx @@ -44,7 +44,7 @@ const isRowSelected = (key: string | undefined, selectedTransactions: SelectedTr /** * On wide web layouts each group is split into a sticky header row plus a children-container row, so the * rendered list differs from the source data. Returns the rendered list plus the sticky-header and - * children-container indices FlashList needs. On narrow/native layouts the group rows render as-is. + * children-container indices LegendList needs. On narrow/native layouts the group rows render as-is. */ function buildSplitGroupData(data: SearchListItem[], shouldSplitGroups: boolean) { if (!shouldSplitGroups) { @@ -202,16 +202,7 @@ function ExpenseGroupedSearchView({ return 'default'; }; - const overrideItemLayout = (layout: {size?: number; span?: number}, item: SearchListItem) => { - if (!isGroupHeaderItem(item)) { - return; - } - // FlashList requires mutating the layout object passed to overrideItemLayout. - // eslint-disable-next-line no-param-reassign -- FlashList overrideItemLayout API - layout.size = variables.tableRowHeight; - }; - - const stickyHeaderConfig = shouldSplit ? {hideRelatedCell: true, useNativeDriver: true, zIndex: 2} : undefined; + const getFixedItemSize = (item: SearchListItem) => (isGroupHeaderItem(item) ? variables.tableRowHeight : undefined); const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => { if (isGroupHeaderItem(item)) { @@ -348,9 +339,8 @@ function ExpenseGroupedSearchView({ nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} stickyHeaderIndices={stickyHeaderIndices} getItemType={getItemType} - stickyHeaderConfig={stickyHeaderConfig} disabledIndexes={shouldSplit ? childrenContainerIndices : undefined} - overrideItemLayout={shouldSplit ? overrideItemLayout : undefined} + getFixedItemSize={shouldSplit ? getFixedItemSize : undefined} /> {modal} diff --git a/src/components/Search/FilterDropdowns/DisplayPopup.tsx b/src/components/Search/FilterDropdowns/DisplayPopup.tsx index fea0dfa50996..4b9970af916f 100644 --- a/src/components/Search/FilterDropdowns/DisplayPopup.tsx +++ b/src/components/Search/FilterDropdowns/DisplayPopup.tsx @@ -1,6 +1,6 @@ import CompactMenuContext from '@components/CompactMenuContext'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import ScrollView from '@components/ScrollView'; import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; import type {SearchQueryJSON} from '@components/Search/types'; @@ -80,47 +80,42 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP return ( - setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY)} sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_SORT_BY} + value={`${translate(getSearchColumnTranslationKey(sortByValue))} ${CONST.DOT_SEPARATOR} ${translate(`search.filters.sortOrder.${sortOrderValue}`)}`} /> {(isExpenseType || isTripType) && ( - setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY)} sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_GROUP_BY} + value={groupByValue ? translate(`search.filters.groupBy.${groupByValue}`) : undefined} /> )} {!!groupBy && ( - setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY)} sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_GROUP_CURRENCY} + value={groupCurrencyValue} /> )} {isExpenseType && !!groupByValue && ( - setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW)} sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_VIEW} + value={viewValue ? translate(`search.view.${viewValue}`) : undefined} /> )} {isExpenseType && ( - setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT)} sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_LIMIT} + value={limitValue} /> )} {shouldShowColumnsButton && ( diff --git a/src/components/Search/FilterDropdowns/SortByPopup.tsx b/src/components/Search/FilterDropdowns/SortByPopup.tsx index ab5d17990869..698ad4331211 100644 --- a/src/components/Search/FilterDropdowns/SortByPopup.tsx +++ b/src/components/Search/FilterDropdowns/SortByPopup.tsx @@ -1,4 +1,4 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import ListFilterWrapper from '@components/Search/FilterComponents/ListFilterViewWrapper'; import type {SingleSelectItem} from '@components/Search/FilterComponents/SingleSelect'; import {useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; @@ -109,11 +109,10 @@ function SortByPopup({searchResults, queryJSON, groupBy, onSort, onSortOrderPres hasHeader extraHeight={variables.optionRowHeight + DIVIDER_HEIGHT} > - ); - function BaseSearchList({ data, renderItem, @@ -22,33 +19,32 @@ function BaseSearchList({ contentContainerStyle, stickyHeaderIndices, getItemType, + getFixedItemSize, }: BaseSearchListProps) { - const renderItemWithoutKeyboardFocus = useCallback( - ({item, index}: {item: SearchListItem; index: number}) => { - return renderItem(item, index, false, undefined); - }, - [renderItem], - ); + const renderItemWithoutKeyboardFocus = ({item, index}: {item: SearchListItem; index: number}) => { + return renderItem(item, index, false, undefined); + }; return ( - ); } diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index 5f7273b9f6f8..9bde31f49517 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -16,38 +16,15 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isModalActiveSelector} from '@src/selectors/Modal'; -import type {GestureResponderEvent, NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle} from 'react-native'; +import type {GestureResponderEvent, NativeSyntheticEvent} from 'react-native'; +import {AnimatedLegendList} from '@legendapp/list/reanimated'; import {useIsFocused} from '@react-navigation/native'; -import {FlashList} from '@shopify/flash-list'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; -import Animated from 'react-native-reanimated'; import type BaseSearchListProps from './types'; -const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); - -type CellRendererComponentProps = ViewProps & { - ref?: React.Ref; - style?: StyleProp; -}; - -function CellRendererComponent({children, ref, style, ...props}: CellRendererComponentProps) { - const styles = useThemeStyles(); - - return ( - - {children} - - ); -} - function BaseSearchList({ data, columns, @@ -71,22 +48,23 @@ function BaseSearchList({ stickyHeaderConfig, getItemType, disabledIndexes, - overrideItemLayout, + getFixedItemSize, }: BaseSearchListProps) { + const styles = useThemeStyles(); const hasKeyBeenPressed = useRef(false); const isFocused = useIsFocused(); const {focusedCellId, isEditingCell} = useEditingCellState(); const [isModalVisible] = useOnyx(ONYXKEYS.MODAL, {selector: isModalActiveSelector}); - const setHasKeyBeenPressed = useCallback(() => { + const setHasKeyBeenPressed = () => { if (hasKeyBeenPressed.current) { return; } // We need to track whether a key has been pressed to enable focus syncing only if a key has been pressed. // This is to avoid the default behavior of web showing blue border on click of items after a page refresh. hasKeyBeenPressed.current = true; - }, []); + }; const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ initialFocusedIndex: -1, @@ -95,9 +73,6 @@ function BaseSearchList({ onFocusedIndexChange: (index: number) => { scrollToIndex?.(index); }, - onArrowUpDownCallback: () => { - ref?.current?.announceProgrammaticScroll(); - }, setHasKeyBeenPressed, isFocused, captureOnInputs: false, @@ -126,34 +101,31 @@ function BaseSearchList({ const renderItemWithKeyboardFocus = ({item, index}: {item: SearchListItem; index: number}) => { const isItemFocused = focusedIndex === index; - return renderItem(item, index, isItemFocused, getOnFocus(index)); + return {renderItem(item, index, isItemFocused, getOnFocus(index))}; }; - const selectFocusedOption = useCallback( - (event?: GestureResponderEvent | KeyboardEvent) => { - // Allow event propagation during cell editing so Enter can trigger TextInput.onSubmitEditing. - // When not editing, stop propagation to prevent unintended button activation and handle row selection. - if (isEditingCell) { - return; - } + const selectFocusedOption = (event?: GestureResponderEvent | KeyboardEvent) => { + // Allow event propagation during cell editing so Enter can trigger TextInput.onSubmitEditing. + // When not editing, stop propagation to prevent unintended button activation and handle row selection. + if (isEditingCell) { + return; + } - // If a cell has keyboard focus (via Tab), let the Enter event propagate to trigger the cell's onPress - if (focusedCellId) { - return; - } + // If a cell has keyboard focus (via Tab), let the Enter event propagate to trigger the cell's onPress + if (focusedCellId) { + return; + } - event?.stopPropagation(); + event?.stopPropagation(); - const focusedItem = data.at(focusedIndex); + const focusedItem = data.at(focusedIndex); - if (!focusedItem) { - return; - } + if (!focusedItem) { + return; + } - onSelectRow(focusedItem); - }, - [data, focusedCellId, focusedIndex, isEditingCell, onSelectRow], - ); + onSelectRow(focusedItem); + }; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { captureOnInputs: true, @@ -170,13 +142,10 @@ function BaseSearchList({ return () => removeKeyDownPressListener(setHasKeyBeenPressed); }, [setHasKeyBeenPressed]); - const extraData = useMemo( - () => [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], - [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], - ); + const extraData = [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy, renderItem]; return ( - ); } diff --git a/src/components/Search/SearchList/BaseSearchList/types.ts b/src/components/Search/SearchList/BaseSearchList/types.ts index c18b7a1719df..dfaafd97018c 100644 --- a/src/components/Search/SearchList/BaseSearchList/types.ts +++ b/src/components/Search/SearchList/BaseSearchList/types.ts @@ -4,12 +4,12 @@ import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/typ import type {CardList, Transaction} from '@src/types/onyx'; -import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; +import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native'; import type {RefObject} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; type BaseSearchListProps = Pick< - FlashListProps, + LegendListProps, | 'onScroll' | 'contentContainerStyle' | 'onEndReached' @@ -21,7 +21,7 @@ type BaseSearchListProps = Pick< | 'onLayout' | 'stickyHeaderIndices' | 'stickyHeaderConfig' - | 'overrideItemLayout' + | 'getFixedItemSize' > & { data: SearchListItem[]; renderItem: (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => React.JSX.Element; @@ -37,7 +37,7 @@ type BaseSearchListProps = Pick< /** The callback, which is run when a row is pressed */ onSelectRow: (item: SearchListItem) => void; - ref: RefObject | null>; + ref: RefObject; scrollToIndex?: (index: number, animated?: boolean) => void; /** Precomputed attendee-tracking boolean (derived from policy-for-moving-expenses) */ @@ -46,8 +46,8 @@ type BaseSearchListProps = Pick< /** Non-personal and workspace cards for triggering re-render via extraData */ nonPersonalAndWorkspaceCards?: CardList; - /** Function to determine item type for FlashList recycling */ - getItemType?: (item: SearchListItem, index: number) => string | number | undefined; + /** Function to determine item type for LegendList recycling */ + getItemType?: LegendListProps['getItemType']; /** Indexes to skip during keyboard arrow navigation */ disabledIndexes?: readonly number[]; diff --git a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts index 108b2cbc6626..76270dba19bb 100644 --- a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts +++ b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts @@ -19,7 +19,6 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import getCreateReportRoute, {getReportsRootRoute, navigateToCreateReportWorkspaceSelection} from '@libs/Navigation/helpers/getCreateReportRoute'; import Navigation from '@libs/Navigation/Navigation'; import {openTravelDotLink} from '@libs/openTravelDotLink'; -import Permissions from '@libs/Permissions'; // eslint-disable-next-line no-restricted-imports -- TravelDot booking requires a paid workspace, matching the existing FAB behavior. import {canSendInvoice, getDefaultChatEnabledPolicy, getGroupPoliciesWhereReportCanBeCreated, hasAcceptedTravelTerms, isPaidGroupPolicy, shouldShowPolicy} from '@libs/PolicyUtils'; import {generateReportID} from '@libs/ReportUtils'; @@ -113,7 +112,7 @@ function useCreateNavigationSuggestions(query = ''): NavigationSuggestionSourceI const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy([...groupPoliciesWithChatEnabled], activePolicy); const isInvoiceVisible = canSendInvoice(allPolicies ?? null, sessionEmail); const isTravelVisible = !!activePolicy?.isTravelEnabled; - const isBlockedFromSpotnanaTravel = Permissions.isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL, allBetas); + const isBlockedFromSpotnanaTravel = isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL); const primaryContactMethod = primaryLogin ?? sessionEmail ?? ''; const isTravelEnabled = !isBlockedFromSpotnanaTravel && diff --git a/src/components/Search/hooks/useSearchListViewState.ts b/src/components/Search/hooks/useSearchListViewState.ts index c7f94336779b..076b7f140592 100644 --- a/src/components/Search/hooks/useSearchListViewState.ts +++ b/src/components/Search/hooks/useSearchListViewState.ts @@ -21,7 +21,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Transaction} from '@src/types/onyx'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; import {useRef} from 'react'; @@ -65,7 +65,7 @@ function useSearchListViewState({data, listData = data, isMobileSelectionModeEna const {isSmallScreenWidth, isLargeScreenWidth} = useResponsiveLayout(); const {isEditingCell, wasRecentlyEditingCell} = useEditingCellState(); - const listRef = useRef>(null); + const listRef = useRef(null); const prevDataLength = usePrevious(data.length); const hasItemsBeingRemoved = !!prevDataLength && prevDataLength > data.length; diff --git a/src/components/Search/primitives/useScrollRestoration.ts b/src/components/Search/primitives/useScrollRestoration.ts index bde273561304..373d2243a45b 100644 --- a/src/components/Search/primitives/useScrollRestoration.ts +++ b/src/components/Search/primitives/useScrollRestoration.ts @@ -1,34 +1,32 @@ import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; import type {RefObject} from 'react'; import {useFocusEffect, useRoute} from '@react-navigation/native'; -import {useCallback, useContext} from 'react'; +import {useContext} from 'react'; /** * Restores the Search list's vertical scroll position when the screen regains focus. * * The offset is saved per route in ScrollOffsetContext by the page wrappers; on focus we read it back - * and apply it to the FlashList on the next frame, so a back-navigation lands at the prior position + * and apply it to the LegendList on the next frame, so a back-navigation lands at the prior position * instead of the top. Extracted from SearchList so ExpenseFlatSearchView can reuse it. */ -function useScrollRestoration(listRef: RefObject | null>) { +function useScrollRestoration(listRef: RefObject) { const route = useRoute(); const {getScrollOffset} = useContext(ScrollOffsetContext); - useFocusEffect( - useCallback(() => { - const offset = getScrollOffset(route); - requestAnimationFrame(() => { - if (!offset || !listRef.current) { - return; - } + useFocusEffect(() => { + const offset = getScrollOffset(route); + requestAnimationFrame(() => { + if (!offset || !listRef.current) { + return; + } - listRef.current.scrollToOffset({offset, animated: false}); - }); - }, [getScrollOffset, route, listRef]), - ); + listRef.current.scrollToOffset({offset, animated: false}); + }); + }); } export default useScrollRestoration; diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 262c8b077221..6ddf28cffcb5 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -7,12 +7,12 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import getEmptyArray from '@src/types/utils/getEmptyArray'; -import type {FlashListRef, ListRenderItem, ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListProps, LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native'; +import {LegendList} from '@legendapp/list/react-native'; import {useIsFocused} from '@react-navigation/native'; -import {FlashList} from '@shopify/flash-list'; import {deepEqual} from 'fast-equals'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import {Keyboard, View} from 'react-native'; import type {DataDetailsType, ListItem, SelectionListProps} from './types'; @@ -112,61 +112,21 @@ function BaseSelectionListImpl({ // Kept out of the destructuring default so the `!!` doesn't bail the component out of React Compiler. const shouldShowTextInput = shouldShowTextInputProp ?? !!textInputOptions?.label; - const listRef = useRef | null>(null); + const listRef = useRef(null); const {scrollToIndex, debouncedScrollToIndex} = useSelectionListScroll(listRef, data); const itemFocusTimeoutRef = useRef(null); const keyboardListenerRef = useRef | null>(null); - const initialFocusedIndex = useMemo(() => data.findIndex((i) => i.keyForList === initiallyFocusedItemKey), [data, initiallyFocusedItemKey]); + const initialFocusedIndex = data.findIndex((i) => i.keyForList === initiallyFocusedItemKey); const [itemsToHighlight, setItemsToHighlight] = useState | null>(null); - const isItemSelected = useCallback( - (item: ListItem) => item.isSelected ?? ((isSelected?.(item) ?? selectedItems.includes(item.keyForList)) && canSelectMultiple), - [isSelected, selectedItems, canSelectMultiple], - ); + const isItemSelected = (item: ListItem) => item.isSelected ?? ((isSelected?.(item) ?? selectedItems.includes(item.keyForList)) && canSelectMultiple); - const paddingBottomStyle = useMemo(() => !isKeyboardShown && safeAreaPaddingBottomStyle, [isKeyboardShown, safeAreaPaddingBottomStyle]); + const paddingBottomStyle = !isKeyboardShown && safeAreaPaddingBottomStyle; const hasFooter = !!footerContent || confirmButtonOptions?.showButton; - const dataDetails = useMemo>(() => { - const {disabledIndexes, disabledArrowKeyIndexes, selectedOptions, disabledSelectedIndexes} = data.reduce( - (acc: {disabledIndexes: number[]; disabledArrowKeyIndexes: number[]; selectedOptions: ListItem[]; disabledSelectedIndexes: number[]}, item: ListItem, index: number) => { - const idx = item.index ?? index; - const itemIsSelected = isItemSelected(item); - const isItemDisabled = isDisabled || (!!item?.isDisabled && !itemIsSelected); - const isEffectivelyDisabled = isItemDisabled || !!item?.isDisabledCheckbox; - - if (itemIsSelected && (canSelectMultiple || acc.selectedOptions.length === 0)) { - acc.selectedOptions.push(item); - } - - if (!isEffectivelyDisabled) { - return acc; - } - - acc.disabledIndexes.push(idx); - - if (isItemDisabled) { - acc.disabledArrowKeyIndexes.push(idx); - } - - if (itemIsSelected) { - acc.disabledSelectedIndexes.push(idx); - } - - return acc; - }, - {disabledIndexes: [], disabledArrowKeyIndexes: [], selectedOptions: [], disabledSelectedIndexes: []}, - ); - - const totalSelectable = data.length - disabledIndexes.length; - const selectableSelectedCount = selectedOptions.length - disabledSelectedIndexes.length; - const allSelected = selectableSelectedCount > 0 && selectableSelectedCount === totalSelectable; - const someSelected = selectableSelectedCount > 0 && selectableSelectedCount < totalSelectable; - - return {data, allSelected, someSelected, selectedOptions, disabledIndexes, disabledArrowKeyIndexes}; - }, [canSelectMultiple, data, isDisabled, isItemSelected]); + const dataDetails = getDataDetails({data, canSelectMultiple, isDisabled, isItemSelected}); const {focusedIndex, setFocusedIndex, isKeyboardNavigating, setHasKeyBeenPressed} = useSelectionListKeyboardFocus({ initialFocusedIndex, @@ -178,68 +138,39 @@ function BaseSelectionListImpl({ shouldDebounceScrolling, scrollToIndex, debouncedScrollToIndex, - announceProgrammaticScroll: () => listRef.current?.announceProgrammaticScroll(), setShouldDisableHoverStyle, }); const {innerTextInputRef, isTextInputFocusedRef, focusTextInput, textInputKeyPress} = useSelectionListTextInput(setHasKeyBeenPressed); - // extraData helps FlashList detect when data changes significantly (e.g., during filtering) - // Including data.length ensures FlashList resets its layout cache when the list size changes - // This prevents "index out of bounds" errors when filtering reduces the list size - const extraData = useMemo(() => [data.length], [data.length]); const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; - const selectRow = useCallback( - (item: ListItem, indexToFocus?: number) => { - if (!isFocused) { - return; - } - if (canSelectMultiple) { - if (shouldShowTextInput && shouldClearInputOnSelect) { - textInputOptions?.onChangeText?.(''); - } else if (isSmallScreenWidth) { - if (!item.isDisabledCheckbox) { - onSelectionButtonPress?.(item); - } - return; + const selectRow = (item: ListItem, indexToFocus?: number) => { + if (!isFocused) { + return; + } + if (canSelectMultiple) { + if (shouldShowTextInput && shouldClearInputOnSelect) { + textInputOptions?.onChangeText?.(''); + } else if (isSmallScreenWidth) { + if (!item.isDisabledCheckbox) { + onSelectionButtonPress?.(item); } + return; } - if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') { - setFocusedIndex(indexToFocus); - } - onSelectRow(item); - - if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow) { - focusTextInput(); - } - }, - [ - isFocused, - canSelectMultiple, - shouldUpdateFocusedIndex, - onSelectRow, - shouldShowTextInput, - shouldClearInputOnSelect, - shouldPreventDefaultFocusOnSelectRow, - isSmallScreenWidth, - textInputOptions, - onSelectionButtonPress, - setFocusedIndex, - focusTextInput, - ], - ); - - const focusedOption = useMemo(() => { - if (focusedIndex < 0 || focusedIndex >= data.length) { - return; } - const option = data.at(focusedIndex); - if (!option || (option.isDisabled && !isItemSelected(option))) { - return; + if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') { + setFocusedIndex(indexToFocus); + } + onSelectRow(item); + + if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow) { + focusTextInput(); } - return option; - }, [data, focusedIndex, isItemSelected]); + }; + + const focusedOptionCandidate = focusedIndex >= 0 && focusedIndex < data.length ? data.at(focusedIndex) : undefined; + const focusedOption = focusedOptionCandidate && (!focusedOptionCandidate.isDisabled || isItemSelected(focusedOptionCandidate)) ? focusedOptionCandidate : undefined; const selectFocusedOption = () => { if (!focusedOption || focusedOption.isInteractive === false) { @@ -248,12 +179,26 @@ function BaseSelectionListImpl({ selectRow(focusedOption); }; + const hasSelectedItems = dataDetails.selectedOptions.length > 0; + const isFooterConfirmEnabled = confirmButtonOptions?.isFooterConfirmEnabled ?? hasSelectedItems; + const isCustomFooterConfirmEnabled = isFooterConfirmEnabled && confirmButtonOptions?.isDisabled !== true && confirmButtonOptions?.isFooterConfirmEnterKeyEnabled !== false; + // Whether Enter should trigger an enabled confirm button instead of the list. + // Footer renders footerContent in place of the built-in button, so the two paths are mutually + // exclusive; custom footers count only if they are Enter-capable and enabled. Owners can + // override the enabled state when selection persists outside the currently rendered rows. + const hasEnabledEnterConfirm = + (!footerContent && !!confirmButtonOptions?.showButton && !confirmButtonOptions?.isDisabled) || (!!footerContent && !!confirmButtonOptions?.onConfirm && isCustomFooterConfirmEnabled); + // Whether the focused row should handle plain Enter. + // Enter selects the row when keyboard navigation/search is active, propagation should stop, + // or there is no enabled Enter-capable confirm control that should handle the keypress instead. + const shouldSelectOnEnter = isKeyboardNavigating || !!syncedSearchValue?.trim() || !hasEnabledEnterConfirm || shouldStopPropagation; + useSelectionListShortcuts({ selectFocusedItem: selectFocusedOption, getFocusedOption: () => focusedOption, confirmButtonOptions, isActive: isFocused, - focusedIndex, + focusedIndex: shouldSelectOnEnter ? focusedIndex : -1, disableKeyboardShortcuts, shouldStopPropagation, shouldBubble: !focusedOption, @@ -282,7 +227,7 @@ function BaseSelectionListImpl({ ); }; - const renderItem: ListRenderItem = ({item, index}: ListRenderItemInfo) => { + const renderItem: NonNullable['renderItem']> = ({item, index}: LegendListRenderItemProps) => { const selected = isItemSelected(item); const isItemDisabled = isDisabled || (!!item.isDisabled && !selected); const isItemFocused = (!isDisabled || selected) && focusedIndex === index; @@ -349,87 +294,78 @@ function BaseSelectionListImpl({ // The function scrolls to the focused input to prevent keyboard occlusion. // It ensures the entire list item is visible, not just the input field. // Added specifically for SplitExpensePage - const scrollToFocusedInput = useCallback( - (item: ListItem) => { - if (!listRef.current) { + const scrollToFocusedInput = (item: ListItem) => { + if (!listRef.current) { + return; + } + + // Clear any existing timer and listener before starting new ones + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + if (keyboardListenerRef.current) { + keyboardListenerRef.current.remove(); + } + + const performScroll = () => { + const index = data.findIndex((dataItem) => dataItem.keyForList === item.keyForList); + if (index === -1) { return; } + // Use scrollToIndex with viewPosition 0.5 to center the item in the visible area + // This ensures the item is visible above the keyboard + listRef.current?.scrollToIndex({index, animated: true, viewPosition: 0.5}); + }; - // Clear any existing timer and listener before starting new ones + // Wait for keyboard to fully appear, then scroll + keyboardListenerRef.current = Keyboard.addListener('keyboardDidShow', () => { + keyboardListenerRef.current?.remove(); + keyboardListenerRef.current = null; + // Clear fallback timeout since keyboard event fired if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); + scrollTimeoutRef.current = null; } - if (keyboardListenerRef.current) { - keyboardListenerRef.current.remove(); - } - - const performScroll = () => { - const index = data.findIndex((dataItem) => dataItem.keyForList === item.keyForList); - if (index === -1) { - return; - } - // Use scrollToIndex with viewPosition 0.5 to center the item in the visible area - // This ensures the item is visible above the keyboard - listRef.current?.scrollToIndex({index, animated: true, viewPosition: 0.5}); - }; - - // Wait for keyboard to fully appear, then scroll - keyboardListenerRef.current = Keyboard.addListener('keyboardDidShow', () => { - keyboardListenerRef.current?.remove(); - keyboardListenerRef.current = null; - // Clear fallback timeout since keyboard event fired - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); - scrollTimeoutRef.current = null; - } - // Add small delay after keyboard is shown for layout to settle - scrollTimeoutRef.current = setTimeout(performScroll, CONST.ANIMATION_IN_TIMING); - }); - - // Fallback timeout in case keyboard event doesn't fire (e.g., keyboard already open) - scrollTimeoutRef.current = setTimeout(() => { - keyboardListenerRef.current?.remove(); - keyboardListenerRef.current = null; - performScroll(); - }, CONST.ANIMATED_TRANSITION); - }, - [data], - ); + // Add small delay after keyboard is shown for layout to settle + scrollTimeoutRef.current = setTimeout(performScroll, CONST.ANIMATION_IN_TIMING); + }); + + // Fallback timeout in case keyboard event doesn't fire (e.g., keyboard already open) + scrollTimeoutRef.current = setTimeout(() => { + keyboardListenerRef.current?.remove(); + keyboardListenerRef.current = null; + performScroll(); + }, CONST.ANIMATED_TRANSITION); + }; - const scrollAndHighlightItem = useCallback( - (items: string[]) => { - const newItemsToHighlight = new Set(items); + const scrollAndHighlightItem = (items: string[]) => { + const newItemsToHighlight = new Set(items); - if (deepEqual(itemsToHighlight, newItemsToHighlight)) { - return; - } + if (deepEqual(itemsToHighlight, newItemsToHighlight)) { + return; + } - const index = data.findIndex((option) => newItemsToHighlight.has(option.keyForList)); - scrollToIndex(index); - setItemsToHighlight(newItemsToHighlight); + const index = data.findIndex((option) => newItemsToHighlight.has(option.keyForList)); + scrollToIndex(index); + setItemsToHighlight(newItemsToHighlight); - if (itemFocusTimeoutRef.current) { - clearTimeout(itemFocusTimeoutRef.current); - } - itemFocusTimeoutRef.current = setTimeout(() => { - setItemsToHighlight(null); - }, ANIMATED_HIGHLIGHT_DURATION); - }, - [data, itemsToHighlight, scrollToIndex], - ); + if (itemFocusTimeoutRef.current) { + clearTimeout(itemFocusTimeoutRef.current); + } + itemFocusTimeoutRef.current = setTimeout(() => { + setItemsToHighlight(null); + }, ANIMATED_HIGHLIGHT_DURATION); + }; - const updateFocusedIndex = useCallback( - (newFocusedIndex: number, shouldScroll = false) => { - if (newFocusedIndex < 0 || newFocusedIndex >= data.length) { - return; - } - setFocusedIndex(newFocusedIndex); - if (shouldScroll) { - scrollToIndex(newFocusedIndex); - } - }, - [data.length, scrollToIndex, setFocusedIndex], - ); + const updateFocusedIndex = (newFocusedIndex: number, shouldScroll = false) => { + if (newFocusedIndex < 0 || newFocusedIndex >= data.length) { + return; + } + setFocusedIndex(newFocusedIndex); + if (shouldScroll) { + scrollToIndex(newFocusedIndex); + } + }; useSelectedItemFocusSync({ data, @@ -458,12 +394,12 @@ function BaseSelectionListImpl({ clearTimeout(itemFocusTimeoutRef.current); }, []); - const handleSelectAll = useCallback(() => { + const handleSelectAll = () => { onSelectAll?.(); if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow) { focusTextInput(); } - }, [onSelectAll, shouldShowTextInput, shouldPreventDefaultFocusOnSelectRow, focusTextInput]); + }; useImperativeHandle(ref, () => ({scrollAndHighlightItem, scrollToIndex, updateFocusedIndex, scrollToFocusedInput, focusTextInput}), [ focusTextInput, @@ -502,13 +438,13 @@ function BaseSelectionListImpl({ ) : ( <> {!shouldHeaderBeInsideList && header} - item.keyForList} - extraData={extraData} + extraData={renderItem} ListFooterComponent={listFooterContent} ListFooterComponentStyle={style?.listFooterContentStyle} scrollEnabled={scrollEnabled} @@ -522,7 +458,7 @@ function BaseSelectionListImpl({ contentContainerStyle={[styles.pb3, style?.contentContainerStyle]} initialScrollIndex={shouldScrollToFocusedIndexOnMount ? initialFocusedIndex : undefined} onScrollBeginDrag={onScrollBeginDrag} - maintainVisibleContentPosition={{disabled: disableMaintainingScrollPosition}} + maintainVisibleContentPosition={!disableMaintainingScrollPosition} ListHeaderComponent={ <> {customListHeaderContent} @@ -548,4 +484,53 @@ function BaseSelectionList(props: SelectionListProps)} />; } +function getDataDetails({ + data, + canSelectMultiple, + isDisabled, + isItemSelected, +}: { + data: ListItem[]; + canSelectMultiple: boolean; + isDisabled: boolean; + isItemSelected: (item: ListItem) => boolean; +}): DataDetailsType { + const {disabledIndexes, disabledArrowKeyIndexes, selectedOptions, disabledSelectedIndexes} = data.reduce( + (acc: {disabledIndexes: number[]; disabledArrowKeyIndexes: number[]; selectedOptions: ListItem[]; disabledSelectedIndexes: number[]}, item: ListItem, index: number) => { + const idx = item.index ?? index; + const itemIsSelected = isItemSelected(item); + const isItemDisabled = isDisabled || (!!item?.isDisabled && !itemIsSelected); + const isEffectivelyDisabled = isItemDisabled || !!item?.isDisabledCheckbox; + + if (itemIsSelected && (canSelectMultiple || acc.selectedOptions.length === 0)) { + acc.selectedOptions.push(item); + } + + if (!isEffectivelyDisabled) { + return acc; + } + + acc.disabledIndexes.push(idx); + + if (isItemDisabled) { + acc.disabledArrowKeyIndexes.push(idx); + } + + if (itemIsSelected) { + acc.disabledSelectedIndexes.push(idx); + } + + return acc; + }, + {disabledIndexes: [], disabledArrowKeyIndexes: [], selectedOptions: [], disabledSelectedIndexes: []}, + ); + + const totalSelectable = data.length - disabledIndexes.length; + const selectableSelectedCount = selectedOptions.length - disabledSelectedIndexes.length; + const allSelected = selectableSelectedCount > 0 && selectableSelectedCount === totalSelectable; + const someSelected = selectableSelectedCount > 0 && selectableSelectedCount < totalSelectable; + + return {data, allSelected, someSelected, selectedOptions, disabledIndexes, disabledArrowKeyIndexes}; +} + export default BaseSelectionList; diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 501e3e25be01..9a7e6c32982d 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -22,12 +22,12 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native'; import type {ValueOf} from 'type-fest'; +import {LegendList} from '@legendapp/list/react-native'; import {useIsFocused} from '@react-navigation/native'; -import {FlashList} from '@shopify/flash-list'; -import React, {useCallback, useImperativeHandle, useRef} from 'react'; +import React, {useImperativeHandle, useRef} from 'react'; import {View} from 'react-native'; import type {FlattenedItem, ListItem, SelectionListWithSectionsProps} from './types'; @@ -98,7 +98,9 @@ function BaseSelectionListWithSectionsImpl({ const paddingBottomStyle = !isKeyboardShown && !footerContent && safeAreaPaddingBottomStyle; const {flattenedData, disabledIndexes, itemsCount, selectedItems, initialFocusedIndex, firstFocusableIndex} = useFlattenedSections(sections, initiallyFocusedItemKey); - const listRef = useRef> | null>(null); + // `initialFocusedIndex` is -1 when nothing is focused, which is not a valid scroll target. + const targetScrollIndex = initialScrollIndex ?? (initialFocusedIndex < 0 ? undefined : initialFocusedIndex); + const listRef = useRef(null); const {scrollToIndex, debouncedScrollToIndex} = useSelectionListScroll(listRef, flattenedData); const {containerRef, trackScrollOffset, scrollInputIntoView} = useScrollToFocusedInput(listRef, isKeyboardShown); @@ -112,13 +114,12 @@ function BaseSelectionListWithSectionsImpl({ shouldDebounceScrolling, scrollToIndex, debouncedScrollToIndex, - announceProgrammaticScroll: () => listRef.current?.announceProgrammaticScroll(), setShouldDisableHoverStyle, }); const {innerTextInputRef, isTextInputFocusedRef, focusTextInput, textInputKeyPress} = useSelectionListTextInput(setHasKeyBeenPressed); - const getFocusedItem = useCallback((): ListItem | undefined => { + const getFocusedItem = (): ListItem | undefined => { if (focusedIndex < 0 || focusedIndex >= flattenedData.length) { return; } @@ -127,7 +128,7 @@ function BaseSelectionListWithSectionsImpl({ return; } return item as ListItem; - }, [flattenedData, focusedIndex]); + }; const selectRow = (item: ListItem, indexToFocus?: number) => { if (!isScreenFocused) { @@ -160,29 +161,23 @@ function BaseSelectionListWithSectionsImpl({ selectRow(focusedItem); }; - const clearInputAfterSelect = useCallback(() => { + const clearInputAfterSelect = () => { textInputOptions?.onChangeText?.(''); - }, [textInputOptions]); + }; - const updateAndScrollToFocusedIndex = useCallback( - (index: number, shouldScroll = true) => { - setFocusedIndex(index); - if (shouldScroll) { - scrollToIndex(index); - } - }, - [scrollToIndex, setFocusedIndex], - ); + const updateAndScrollToFocusedIndex = (index: number, shouldScroll = true) => { + setFocusedIndex(index); + if (shouldScroll) { + scrollToIndex(index); + } + }; /** * Handles isTextInputFocusedRef value when using external TextInput, so external TextInput does not lose focus when typing in it. */ - const updateExternalTextInputFocus = useCallback( - (isTextInputFocused: boolean) => { - isTextInputFocusedRef.current = isTextInputFocused; - }, - [isTextInputFocusedRef], - ); + const updateExternalTextInputFocus = (isTextInputFocused: boolean) => { + isTextInputFocusedRef.current = isTextInputFocused; + }; useImperativeHandle( ref, @@ -200,12 +195,26 @@ function BaseSelectionListWithSectionsImpl({ const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; + const hasSelectedItems = selectedItems.length > 0; + const isFooterConfirmEnabled = confirmButtonOptions?.isFooterConfirmEnabled ?? hasSelectedItems; + const isCustomFooterConfirmEnabled = isFooterConfirmEnabled && confirmButtonOptions?.isDisabled !== true && confirmButtonOptions?.isFooterConfirmEnterKeyEnabled !== false; + // Whether Enter should trigger an enabled confirm button instead of the list. + // Footer renders footerContent in place of the built-in button, so the two paths are mutually + // exclusive; custom footers count only if they are Enter-capable and enabled. Owners can + // override the enabled state when selection persists outside the currently rendered rows. + const hasEnabledEnterConfirm = + (!footerContent && !!confirmButtonOptions?.showButton && !confirmButtonOptions?.isDisabled) || (!!footerContent && !!confirmButtonOptions?.onConfirm && isCustomFooterConfirmEnabled); + // Whether the focused row should handle plain Enter. + // Enter selects the row when keyboard navigation/search is active, propagation should stop, + // or there is no enabled Enter-capable confirm control that should handle the keypress instead. + const shouldSelectOnEnter = isKeyboardNavigating || !!syncedSearchValue?.trim() || !hasEnabledEnterConfirm || shouldStopPropagation; + useSelectionListShortcuts({ selectFocusedItem, getFocusedOption: getFocusedItem, confirmButtonOptions, isActive: isScreenFocused, - focusedIndex, + focusedIndex: shouldSelectOnEnter ? focusedIndex : -1, disableKeyboardShortcuts, shouldStopPropagation, shouldBubble: itemsCount > 0 && !getFocusedItem(), @@ -254,7 +263,7 @@ function BaseSelectionListWithSectionsImpl({ ); }; - const renderItem = ({item, index}: ListRenderItemInfo>) => { + const renderItem = ({item, index}: LegendListRenderItemProps>) => { if (!item) { return null; } @@ -326,14 +335,14 @@ function BaseSelectionListWithSectionsImpl({ listEmptyContent={listEmptyContent} /> ) : ( - ('flatListKey' in item ? item.flatListKey : item.keyForList)} onEndReached={onEndReached} onEndReachedThreshold={onEndReachedThreshold} @@ -352,7 +361,7 @@ function BaseSelectionListWithSectionsImpl({ ListFooterComponentStyle={style?.listFooterContentStyle} style={style?.listStyle} contentContainerStyle={style?.contentContainerStyle} - maintainVisibleContentPosition={{disabled: true}} + maintainVisibleContentPosition={false} /> )} {!!footerContent && ( diff --git a/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts b/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts index 1d7010e067e3..3cdc62325671 100644 --- a/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts +++ b/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts @@ -1,6 +1,6 @@ import type {MeasurableInput} from '@components/SelectionList/SelectionListWithSections/types'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; import type {RefObject} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, View} from 'react-native'; @@ -15,7 +15,7 @@ type UseScrollToFocusedInputResult = { scrollInputIntoView: (input: MeasurableInput) => void; }; -type UseScrollToFocusedInput = (listRef: RefObject, 'scrollToOffset'> | null>, isKeyboardShown: boolean) => UseScrollToFocusedInputResult; +type UseScrollToFocusedInput = (listRef: RefObject | null>, isKeyboardShown: boolean) => UseScrollToFocusedInputResult; // eslint-disable-next-line import/prefer-default-export export type {UseScrollToFocusedInput}; diff --git a/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts b/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts index 176ed6162cba..951411053fa1 100644 --- a/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts +++ b/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts @@ -18,7 +18,6 @@ type UseSelectionListKeyboardFocusParams = { shouldDebounceScrolling: boolean; scrollToIndex: ScrollToIndex; debouncedScrollToIndex: ScrollToIndex; - announceProgrammaticScroll: () => void; setShouldDisableHoverStyle: (shouldDisableHoverStyle: boolean) => void; }; @@ -40,7 +39,6 @@ function useSelectionListKeyboardFocus({ shouldDebounceScrolling, scrollToIndex, debouncedScrollToIndex, - announceProgrammaticScroll, setShouldDisableHoverStyle, }: UseSelectionListKeyboardFocusParams): UseSelectionListKeyboardFocusResult { const hasKeyBeenPressed = useRef(false); @@ -82,7 +80,6 @@ function useSelectionListKeyboardFocus({ isFocused, onArrowUpDownCallback: () => { setShouldDisableHoverStyle(true); - announceProgrammaticScroll(); }, }); diff --git a/src/components/SelectionList/hooks/useSelectionListScroll.ts b/src/components/SelectionList/hooks/useSelectionListScroll.ts index 5165585e73c5..05211a37a996 100644 --- a/src/components/SelectionList/hooks/useSelectionListScroll.ts +++ b/src/components/SelectionList/hooks/useSelectionListScroll.ts @@ -4,7 +4,7 @@ import Log from '@libs/Log'; import CONST from '@src/CONST'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; import type {RefObject} from 'react'; type ScrollToIndex = (index: number, animated?: boolean) => void; @@ -14,8 +14,8 @@ type UseSelectionListScrollResult = { debouncedScrollToIndex: ScrollToIndex; }; -/** Bounds-checked scroll-to-index helpers (immediate + debounced) over the component-owned FlashList ref. */ -function useSelectionListScroll(listRef: RefObject, 'scrollToIndex'> | null>, data: TData[]): UseSelectionListScrollResult { +/** Bounds-checked scroll-to-index helpers (immediate + debounced) over the component-owned LegendList ref. */ +function useSelectionListScroll(listRef: RefObject | null>, data: TData[]): UseSelectionListScrollResult { const scrollToIndex: ScrollToIndex = (index, animated = true) => { if (index < 0 || index >= data.length || !listRef.current) { return; @@ -25,9 +25,11 @@ function useSelectionListScroll(listRef: RefObject { + Log.warn('SelectionList: error scrolling to index', {error}); + }); } catch (error) { - // FlashList can throw if this index isn't laid out yet (e.g. rapid search filtering); it resolves on the next render. + // LegendList can throw if this index isn't laid out yet (e.g. rapid search filtering); it resolves on the next render. Log.warn('SelectionList: error scrolling to index', {error}); } }; diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 296bfdfcbea5..37603afc5b88 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -235,6 +235,20 @@ type ConfirmButtonOptions = { * Defaults to large for backwards compatibility. */ confirmButtonSize?: 'large' | 'medium' | 'small'; + + /** + * Whether a custom footer confirm control can handle a plain Enter key on the current platform. + * Defaults to `true` — footers built with an unconditional `` are Enter-capable everywhere. + * Pass `false` when the footer is intentionally unable to handle Enter on a platform. + */ + isFooterConfirmEnterKeyEnabled?: boolean; + + /** + * Whether a custom footer confirm control is currently rendered and enabled. + * Defaults to inferring the state from the rendered rows. Pass the authoritative state when the footer's + * enabled state depends on selection that may not be reflected in the currently rendered rows. + */ + isFooterConfirmEnabled?: boolean; }; type SelectionListHandle = { diff --git a/src/components/SettlementButton/index.tsx b/src/components/SettlementButton/index.tsx index 5d69422a4bfc..935f7108944f 100644 --- a/src/components/SettlementButton/index.tsx +++ b/src/components/SettlementButton/index.tsx @@ -12,6 +12,7 @@ import useActiveAdminPolicies from '@hooks/useActiveAdminPolicies'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useHasOwnedPaidPolicy from '@hooks/useHasOwnedPaidPolicy'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -137,6 +138,7 @@ function SettlementButton({ const invoiceReceiverPolicy = usePolicy(invoiceReceiverPolicyID); const activePolicy = usePolicy(activePolicyID); const activeAdminPolicies = useActiveAdminPolicies(); + const hasOwnedPaidPolicy = useHasOwnedPaidPolicy(); const reportID = iouReport?.reportID; const personalPolicy = usePolicy(personalPolicyID); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); @@ -403,6 +405,7 @@ function SettlementButton({ betas, isSelfTourViewed, hasActiveAdminPolicies: !!activeAdminPolicies.length, + hasOwnedPaidPolicy, policyName: generateDefaultWorkspaceName(email, lastWorkspaceNumber, translate), }).policyID; }; diff --git a/src/components/SidePanel/SidePanelOverlay.tsx b/src/components/SidePanel/SidePanelOverlay.tsx index 3c2832102b44..2bafdd0d05a7 100644 --- a/src/components/SidePanel/SidePanelOverlay.tsx +++ b/src/components/SidePanel/SidePanelOverlay.tsx @@ -1,13 +1,10 @@ -import {getModalInAnimation, getModalOutAnimation} from '@components/Modal/ReanimatedModal/utils'; import {PressableWithoutFeedback} from '@components/Pressable'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; - import React from 'react'; -import Animated, {Keyframe} from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; type SidePanelOverlayProps = { /** Whether the Side Panel is displayed over RHP */ @@ -20,15 +17,8 @@ function SidePanelOverlay({shouldBeVisible, onBackdropPress}: SidePanelOverlayPr const styles = useThemeStyles(); const {translate} = useLocalize(); - const CustomFadeIn = new Keyframe(getModalInAnimation('fadeIn')).duration(CONST.MODAL.ANIMATION_TIMING.DEFAULT_IN); - const CustomFadeOut = new Keyframe(getModalOutAnimation('fadeOut')).duration(CONST.MODAL.ANIMATION_TIMING.DEFAULT_OUT); - return ( - + {translate('countryStep.confirmBusinessBank')} - {!!policyID && ( diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 948228b4f87f..e096770bf553 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -16,11 +16,11 @@ import {acquireBackgroundInputFocusSuppression} from '@libs/ModalFocusManager'; import CONST from '@src/CONST'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; import type {ReactElement} from 'react'; import type {LayoutChangeEvent} from 'react-native'; -import React, {useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useLayoutEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {TableListMetadata} from './buildTableListData'; @@ -28,7 +28,7 @@ import type {TableContextValue} from './TableContext'; import type {TableHeaderProps} from './TableHeader'; import type {TableData, TableHandle, TableMethods, TableProps, TableRow} from './types'; -import {getDataVisibleIndices, getListIndex, getTableListMetadata} from './buildTableListData'; +import {getDataIndex, getDataVisibleIndices, getListIndex, getTableListMetadata} from './buildTableListData'; import useFiltering from './middlewares/filtering'; import useHighlighting from './middlewares/highlight'; import useSearching from './middlewares/searching'; @@ -64,17 +64,16 @@ function isTableListHeaderElement(child: React.ReactNode): child is ReactElement /** * Builds the Proxy exposed through the Table's ref, forwarding to `tableMethods` first and - * falling back to FlashList's own methods (e.g. `scrollToIndex`). + * falling back to LegendList's own methods (e.g. `scrollToIndex`). * * This is a standalone top-level function (rather than being inlined in the `useImperativeHandle` * callback) because OXC's React Compiler currently fails to compile a component when a generic type * cast referencing the component's own type parameters (e.g. `as TableHandle`) - * appears inside a nested closure. That bailout is silent (no build warning) and disables automatic - * memoization for the entire file, which is what previously caused an infinite FlashList re-render. + * appears inside a nested closure. That bailout is silent and disables automatic memoization for the entire file. */ function createTableHandle( tableMethods: TableMethods, - listRef: React.RefObject | null>, + listRef: React.RefObject, getProcessedData: () => Array>, tableListMetadata: TableListMetadata, ): TableHandle { @@ -94,49 +93,64 @@ function createTableHandle['scrollToIndex']>[0]) => + return (params: Parameters[0]) => scrollToIndex({ ...params, index: getListIndex(params.index, tableListMetadata), }); } - if (property === 'getLayout') { - const getLayout = listRef.current?.getLayout; - if (tableListMetadata.listDataRowOffset === 0 || !getLayout) { - return getLayout; + if (property === 'scrollIndexIntoView') { + const scrollIndexIntoView = listRef.current?.scrollIndexIntoView; + if (tableListMetadata.listDataRowOffset === 0 || !scrollIndexIntoView) { + return scrollIndexIntoView; } - return (index: number) => getLayout(getListIndex(index, tableListMetadata)); - } - - if (property === 'computeVisibleIndices') { - const computeVisibleIndices = listRef.current?.computeVisibleIndices; - if (tableListMetadata.listDataRowOffset === 0 || !computeVisibleIndices) { - return computeVisibleIndices; - } - - return () => getDataVisibleIndices(computeVisibleIndices(), tableListMetadata); + return (params: Parameters[0]) => + scrollIndexIntoView({ + ...params, + index: getListIndex(params.index, tableListMetadata), + }); } - if (property === 'getFirstVisibleIndex') { - const computeVisibleIndices = listRef.current?.computeVisibleIndices; - const getFirstVisibleIndex = listRef.current?.getFirstVisibleIndex; - if (tableListMetadata.listDataRowOffset === 0 || !computeVisibleIndices) { - return getFirstVisibleIndex; + if (property === 'getState') { + const getState = listRef.current?.getState; + if (tableListMetadata.listDataRowOffset === 0 || !getState) { + return getState; } - return () => { - const {startIndex} = getDataVisibleIndices(computeVisibleIndices(), tableListMetadata); - return startIndex; - }; + return () => getTableListState(getState(), tableListMetadata); } - return listRef.current?.[property as keyof FlashListRef]; + return listRef.current?.[property as keyof LegendListRef]; }, }) as TableHandle; } +function getTableListState(state: ReturnType, tableListMetadata: TableListMetadata): ReturnType { + const {startIndex, endIndex} = getDataVisibleIndices({startIndex: state.start, endIndex: state.end}, tableListMetadata); + const {startIndex: startBuffered, endIndex: endBuffered} = getDataVisibleIndices({startIndex: state.startBuffered, endIndex: state.endBuffered}, tableListMetadata); + + return { + ...state, + data: state.data.slice(tableListMetadata.listDataRowOffset), + start: startIndex, + end: endIndex, + startBuffered, + endBuffered, + elementAtIndex: (index) => { + const element: unknown = state.elementAtIndex(getListIndex(index, tableListMetadata)); + return element; + }, + indexByKey: (key) => { + const index = state.indexByKey(key); + return index === undefined ? undefined : getDataIndex(index, tableListMetadata); + }, + positionAtIndex: (index) => state.positionAtIndex(getListIndex(index, tableListMetadata)), + sizeAtIndex: (index) => state.sizeAtIndex(getListIndex(index, tableListMetadata)), + }; +} + /** * A composable table component that provides filtering, search, and sorting functionality. * @@ -151,7 +165,7 @@ function createTableHandle` - The parent component that manages state and provides context * - `` - Renders sortable column headers - * - `` - Renders the data rows using FlashList + * - `` - Renders the data rows using LegendList * - `` - Renders a search input that filters data * * ## Middleware Architecture @@ -328,7 +342,7 @@ function Table(); const processedData = highlightMiddleware(selectionData); - const listRef = useRef>(null); + const listRef = useRef(null); const releaseBackgroundInputFocusSuppressionRef = useRef<(() => void) | null>(null); const mobileSelectionModalRowKeyRef = useRef(mobileSelectionModalRowKey); const [shouldSubmitMobileSelection, setShouldSubmitMobileSelection] = useState(false); @@ -399,18 +413,14 @@ function Table 0 && !!tableHeaderElement && hasPageHeader && !(shouldUseNarrowTableLayout && !title); - const tableListMetadata = useMemo( - () => - getTableListMetadata({ - listHeaderElement, - listHeaderComponent: listProps.ListHeaderComponent, - shouldRenderStickyHeader, - }), - [listHeaderElement, listProps.ListHeaderComponent, shouldRenderStickyHeader], - ); + const tableListMetadata = getTableListMetadata({ + listHeaderElement, + listHeaderComponent: listProps.ListHeaderComponent, + shouldRenderStickyHeader, + }); /** * Exposes table control methods through the ref. - * Uses a Proxy to also forward FlashList methods (like scrollToIndex). + * Uses a Proxy to also forward LegendList methods such as scrollToIndex. */ useImperativeHandle(ref, () => createTableHandle(tableMethods, listRef, () => processedData, tableListMetadata)); diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 3063c70a6920..53697d377dfa 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -7,17 +7,17 @@ import useLocalize from '@hooks/useLocalize'; import useScrollEnabled from '@hooks/useScrollEnabled'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {ListRenderItemInfo, ViewToken} from '@shopify/flash-list'; +import type {LegendListRenderItemProps, ViewToken} from '@legendapp/list/react-native'; import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; -import {FlashList} from '@shopify/flash-list'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {LegendList} from '@legendapp/list/react-native'; +import React from 'react'; import {StyleSheet, View} from 'react-native'; import type {TableData} from '.'; import type {TableListMetadata} from './buildTableListData'; -import {buildTableListData, getAdjustedStickyHeaderIndices, getDataIndex, getListIndex, getSyntheticRowKind} from './buildTableListData'; +import {buildTableListData, getAdjustedStickyHeaderIndices, getDataIndex, getDataVisibleIndices, getListIndex, getSyntheticRowKind} from './buildTableListData'; import {getRowGroupAccessibilityProps, getTableContainerAccessibilityProps, getVirtualizedRowSemanticID, shouldUseTableSemantics} from './tableAccessibility'; import {TableRowSemanticIDContext, useTableContext} from './TableContext'; @@ -25,7 +25,7 @@ import {TableRowSemanticIDContext, useTableContext} from './TableContext'; * Props for the TableBody component. */ type TableBodyProps = ViewProps & { - /** Optional custom styles for the FlashList content container. */ + /** Optional custom styles for the LegendList content container. */ contentContainerStyle?: StyleProp; }; @@ -37,6 +37,10 @@ type TableBodyListProps = TableBodyProps & { type ViewabilityInfo = { viewableItems: Array>; changed: Array>; + start: number; + end: number; + startBuffered: number; + endBuffered: number; }; function getDataViewabilityInfo(info: ViewabilityInfo, metadata: TableListMetadata): ViewabilityInfo { @@ -52,9 +56,17 @@ function getDataViewabilityInfo(info: ViewabilityInfo, metadata: TableListMetada return {...token, index: getDataIndex(token.index, metadata)}; }; + const visibleIndices = getDataVisibleIndices({startIndex: info.start, endIndex: info.end}, metadata); + const bufferedIndices = getDataVisibleIndices({startIndex: info.startBuffered, endIndex: info.endBuffered}, metadata); + return { + ...info, viewableItems: info.viewableItems.map(getDataViewToken).filter((token): token is ViewToken => token !== null), changed: info.changed.map(getDataViewToken).filter((token): token is ViewToken => token !== null), + start: visibleIndices.startIndex, + end: visibleIndices.endIndex, + startBuffered: bufferedIndices.startIndex, + endBuffered: bufferedIndices.endIndex, }; } @@ -68,9 +80,9 @@ function doesBodyRenderWhenEmpty(listProps: {ListEmptyComponent?: unknown; ListH } /** - * Renders the table body using FlashList when data rows are present or a page-header search/filter has no results. + * Renders the table body using LegendList when data rows are present or a page-header search/filter has no results. * - * This component consumes the Table context to access processed data and FlashList props. + * This component consumes the Table context to access processed data and LegendList props. * It automatically handles empty states, including a special "no results found" message * when search returns no results but original data exists. * @@ -98,9 +110,6 @@ function doesBodyRenderWhenEmpty(listProps: {ListEmptyComponent?: unknown; ListH function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, ...props}: TableBodyListProps) { const styles = useThemeStyles(); const scrollEnabled = useScrollEnabled(); - const [isListLoaded, setIsListLoaded] = useState(false); - const [hasActivatedStickyHeader, setHasActivatedStickyHeader] = useState(false); - const [activeStickyHeaderIndex, setActiveStickyHeaderIndex] = useState(-1); const { processedData: filteredAndSortedData, listProps, @@ -120,7 +129,6 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. } = useTableContext(); const { ListEmptyComponent, - ListEmptyComponentStyle, ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, @@ -130,7 +138,6 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. keyExtractor, onEndReached, onLoad, - onChangeStickyIndex, onScroll, onStartReached, onViewableItemsChanged, @@ -140,6 +147,7 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. viewabilityConfigCallbackPairs, ...restListProps } = listProps ?? {}; + const extraData: unknown = listProps?.extraData; const tableBodyContentContainerStyle = useBottomSafeSafeAreaPaddingStyle({ addBottomSafeAreaPadding: true, @@ -153,78 +161,38 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. const contentMinHeight = flattenedContentContainerStyle?.minHeight; const {paddingBottom: tableBodyBottomPadding} = StyleSheet.flatten(tableBodyContentContainerStyle) ?? {}; - const shouldRenderStickyHeader = tableListMetadata.shouldRenderStickyHeader; const hasRows = filteredAndSortedData.length > 0; - const shouldRenderFlashList = hasRows || (tableListMetadata.hasPageHeader && isEmptyResult); + const shouldRenderLegendList = hasRows || (tableListMetadata.hasPageHeader && isEmptyResult); const isTableSemanticsEnabled = shouldUseTableSemantics(shouldUseNarrowTableLayout); const shouldApplyPageHeaderTable = isTableSemanticsEnabled && tableListMetadata.hasPageHeader && hasRows; const shouldApplyBodyRowGroup = isTableSemanticsEnabled && !tableListMetadata.hasPageHeader; const semanticTableHasHeader = !tableListMetadata.hasPageHeader || tableListMetadata.shouldRenderStickyHeader; const semanticColumnCount = columns.length + (selectionEnabled ? 1 : 0); + const rowExtraData = {extraData, renderItem, tableHeaderElement, tableListMetadata, isTableSemanticsEnabled}; const tableBodyAccessibilityProps = tableListMetadata.hasPageHeader ? getTableContainerAccessibilityProps(shouldApplyPageHeaderTable, title, filteredAndSortedData.length, semanticColumnCount, semanticTableHasHeader) : getRowGroupAccessibilityProps(shouldApplyBodyRowGroup); - const currentListState = {shouldRenderFlashList, shouldRenderStickyHeader}; - const [previousListState, setPreviousListState] = useState(currentListState); - const shouldResetListLoad = previousListState.shouldRenderFlashList !== shouldRenderFlashList; - const shouldResetStickyHeader = previousListState.shouldRenderStickyHeader !== shouldRenderStickyHeader; - - if (shouldResetListLoad || shouldResetStickyHeader) { - setPreviousListState(currentListState); + const handleViewableItemsChanged: NonNullable = (info) => onViewableItemsChanged?.(getDataViewabilityInfo(info, tableListMetadata)); - if (shouldResetListLoad) { - setIsListLoaded(false); - } + const viewabilityConfigCallbackPairsForList = viewabilityConfigCallbackPairs?.map((pair) => ({ + ...pair, + onViewableItemsChanged: pair.onViewableItemsChanged ? (info: ViewabilityInfo) => pair.onViewableItemsChanged?.(getDataViewabilityInfo(info, tableListMetadata)) : null, + })); - if (shouldResetStickyHeader) { - setHasActivatedStickyHeader(false); - setActiveStickyHeaderIndex(-1); - } - } - - useEffect(() => { - if (!hasRows || !tableListMetadata.shouldRenderStickyHeader || !isListLoaded || hasActivatedStickyHeader) { + const overrideItemLayoutForList: NonNullable = (layout, item, index, maxColumns) => { + if (getSyntheticRowKind(index, tableListMetadata) !== 'data') { return; } - const frame = requestAnimationFrame(() => setHasActivatedStickyHeader(true)); - return () => cancelAnimationFrame(frame); - }, [hasActivatedStickyHeader, hasRows, isListLoaded, tableListMetadata.shouldRenderStickyHeader]); - - const handleChangeStickyIndex: NonNullable = useCallback( - (current, previous) => { - setActiveStickyHeaderIndex((activeIndex) => (activeIndex === current ? activeIndex : current)); - onChangeStickyIndex?.(current, previous); - }, - [onChangeStickyIndex], - ); - - const handleViewableItemsChanged: NonNullable = useCallback( - (info) => onViewableItemsChanged?.(getDataViewabilityInfo(info, tableListMetadata)), - [onViewableItemsChanged, tableListMetadata], - ); - - const viewabilityConfigCallbackPairsForList = useMemo( - () => - viewabilityConfigCallbackPairs?.map((pair) => ({ - ...pair, - onViewableItemsChanged: pair.onViewableItemsChanged ? (info: ViewabilityInfo) => pair.onViewableItemsChanged?.(getDataViewabilityInfo(info, tableListMetadata)) : null, - })), - [tableListMetadata, viewabilityConfigCallbackPairs], - ); - - const overrideItemLayoutForList: NonNullable = useCallback( - (layout, item, index, maxColumns, extraData) => { - if (getSyntheticRowKind(index, tableListMetadata) !== 'data') { - return; - } - - overrideItemLayout?.(layout, item, getDataIndex(index, tableListMetadata), maxColumns, extraData); - }, - [overrideItemLayout, tableListMetadata], - ); + overrideItemLayout?.(layout, item, getDataIndex(index, tableListMetadata), maxColumns, extraData); + }; - const initialScrollIndexForList = initialScrollIndex == null ? initialScrollIndex : getListIndex(initialScrollIndex, tableListMetadata); + let initialScrollIndexForList = initialScrollIndex; + if (typeof initialScrollIndex === 'number') { + initialScrollIndexForList = getListIndex(initialScrollIndex, tableListMetadata); + } else if (initialScrollIndex) { + initialScrollIndexForList = {...initialScrollIndex, index: getListIndex(initialScrollIndex.index, tableListMetadata)}; + } const renderListComponent = (component: typeof ListHeaderComponent | typeof ListEmptyComponent | typeof ListFooterComponent) => { if (!component) { @@ -275,7 +243,7 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. }, ]; - if (!shouldRenderFlashList) { + if (!shouldRenderLegendList) { return ( no results -> rows transitions. - // FlashList renders ListHeaderComponent outside its virtualized item collection, so controls such + // Keep the page header in the same LegendList across rows -> no results -> rows transitions. + // LegendList renders ListHeaderComponent outside its virtualized item collection, so controls such // as the search input keep their identity. The full-layout wrapper below is the semantic table ancestor; // keeping rows in their physical accessibility tree avoids focus/scroll jumps caused by detached aria-owns rows. // A truly empty table still uses the standalone centered layout above. const listData = buildTableListData(filteredAndSortedData, tableListMetadata); const adjustedStickyHeaderIndices = getAdjustedStickyHeaderIndices(tableListMetadata, stickyHeaderIndices); - const canRenderStickyHeader = !tableListMetadata.shouldRenderStickyHeader || (isListLoaded && hasActivatedStickyHeader); - const isTableHeaderSticky = activeStickyHeaderIndex === tableListMetadata.stickyTableHeaderIndex; const shouldRenderEmptyStateInList = !hasRows && tableListMetadata.hasPageHeader; const handleLoad: NonNullable = (info) => { - setIsListLoaded(true); onLoad?.(info); }; - const renderListItem = (info: ListRenderItemInfo) => { + const renderListItem = (info: LegendListRenderItemProps) => { const rowKind = getSyntheticRowKind(info.index, tableListMetadata); switch (rowKind) { @@ -328,23 +293,19 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. return null; } - const isAccessibleTableHeader = info.target === (isTableHeaderSticky ? 'StickyHeader' : 'Cell'); - const isAccessibilityHidden = isTableSemanticsEnabled && !isAccessibleTableHeader; return React.cloneElement(tableHeaderElement, { isStickyListHeader: true, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'aria-hidden': isAccessibilityHidden ? true : undefined, - isAccessibilityHidden, }); } case 'data': default: { const dataIndex = getDataIndex(info.index, tableListMetadata); - const semanticRowID = getVirtualizedRowSemanticID(isTableSemanticsEnabled, info.target); + const semanticRowID = getVirtualizedRowSemanticID(isTableSemanticsEnabled); return ( {renderItem?.({ ...info, + extraData, index: dataIndex, }) ?? null} @@ -363,14 +324,14 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. return keyExtractor?.(item, getDataIndex(index, tableListMetadata)) ?? item.keyForList; }; - const getItemTypeForList = (item: TableData, index: number, extraData: unknown) => { + const getItemTypeForList = (item: TableData, index: number) => { const rowKind = getSyntheticRowKind(index, tableListMetadata); if (rowKind !== 'data') { return item.keyForList; } - return getItemType?.(item, getDataIndex(index, tableListMetadata), extraData); + return getItemType?.(item, getDataIndex(index, tableListMetadata)); }; return ( @@ -381,20 +342,18 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. {...tableBodyAccessibilityProps} {...props} > - + ref={listRef} data={listData} style={[styles.flex1, styles.mnh0]} showsVerticalScrollIndicator={false} - maintainVisibleContentPosition={{disabled: true}} + maintainVisibleContentPosition={false} ListHeaderComponent={pageHeaderElement} - ListEmptyComponent={shouldRenderEmptyStateInList ? emptyStateContent : ListEmptyComponent} - ListEmptyComponentStyle={[ListEmptyComponentStyle, shouldRenderEmptyStateInList && styles.flexGrow1, shouldRenderEmptyStateInList && styles.justifyContentCenter]} + ListEmptyComponent={shouldRenderEmptyStateInList ? {emptyStateContent} : ListEmptyComponent} ListFooterComponent={ListFooterComponent} ListFooterComponentStyle={shouldRenderEmptyStateInList ? emptyStateFooterStyle : ListFooterComponentStyle} onLoad={handleLoad} - onChangeStickyIndex={handleChangeStickyIndex} - stickyHeaderIndices={hasRows && canRenderStickyHeader ? adjustedStickyHeaderIndices : undefined} + stickyHeaderIndices={hasRows ? adjustedStickyHeaderIndices : undefined} contentContainerStyle={[ listContentContainerStyle, tableBodyContentContainerStyle, @@ -421,6 +380,7 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. onScroll?.(event); }} {...restListProps} + extraData={rowExtraData} scrollEnabled={scrollEnabled} /> diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index e21873f50acf..109aa11d7ff2 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -1,6 +1,6 @@ import type {MeasurableInput} from '@components/SelectionList/SelectionListWithSections/types'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; import type {NativeScrollEvent, NativeSyntheticEvent, View} from 'react-native'; import React, {createContext, useContext} from 'react'; @@ -22,7 +22,7 @@ type TableContextValue | null>; + /** Reference to the underlying LegendList for programmatic control. */ + listRef: React.RefObject; /** Ref for the view wrapping the table list; its top is the anchor used when scrolling a focused input above the keyboard. */ listContainerRef: React.RefObject; @@ -46,7 +46,7 @@ type TableContextValue void; - /** FlashList props passed through from the Table component. */ + /** LegendList props passed through from the Table component. */ listProps: SharedListProps; /** Whether or not selection is enabled for the table */ @@ -95,7 +95,7 @@ type TableContextValue - - ); diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 2294cde0ca13..27341ccc3c37 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -34,7 +34,7 @@ const NUMBER_OF_TOGGLES_BEFORE_RESET = 2; * Props for the TableHeader component. */ type TableHeaderProps = ViewProps & { - /** Whether this header is rendered as a sticky FlashList item. */ + /** Whether this header is rendered as a sticky LegendList item. */ isStickyListHeader?: boolean; /** Whether this duplicate sticky-header render must be hidden and removed from keyboard focus. */ diff --git a/src/components/Table/TableListHeader.tsx b/src/components/Table/TableListHeader.tsx index bc62148e3a31..4d574ce439dd 100644 --- a/src/components/Table/TableListHeader.tsx +++ b/src/components/Table/TableListHeader.tsx @@ -3,7 +3,7 @@ import type {PropsWithChildren} from 'react'; /** * Declarative slot for content that should scroll away with the table rows. * - * The Table root extracts this marker and renders its children through FlashList's + * The Table root extracts this marker and renders its children through LegendList's * ListHeaderComponent path, so the marker itself is never rendered inline. */ function TableListHeader({children}: PropsWithChildren) { diff --git a/src/components/Table/buildTableListData.ts b/src/components/Table/buildTableListData.ts index 5739bf15fd45..4d2237ab5f8c 100644 --- a/src/components/Table/buildTableListData.ts +++ b/src/components/Table/buildTableListData.ts @@ -34,7 +34,7 @@ function getTableListMetadata({listHeaderElement, li } function createSyntheticRow(keyForList: string): DataType { - // FlashList data is typed to consumer rows, but synthetic rows are intercepted before consumer callbacks. + // LegendList data is typed to consumer rows, but synthetic rows are intercepted before consumer callbacks. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return {keyForList} as DataType; } diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx index ca994883f0c5..e7e51d3e2421 100644 --- a/src/components/Table/index.tsx +++ b/src/components/Table/index.tsx @@ -52,7 +52,7 @@ import TableRow from './TableRow'; * - `Table.Context` - The React context (for advanced usage) * - `Table.Header` - Sortable column headers * - `Table.ListHeader` - Content that scrolls with the table rows - * - `Table.Body` - Data rows using FlashList + * - `Table.Body` - Data rows using LegendList * - `Table.FilterBar` - Search input & filter bar * - `Table.Row` - A single row in the table * - `Table.EmptyState` - Renders when the table has no rows @@ -69,7 +69,7 @@ const Table = Object.assign(TableComponent, { /** Content that scrolls with the table rows. */ ListHeader: TableListHeader, - /** Renders data rows using FlashList. */ + /** Renders data rows using LegendList. */ Body: TableBody, /** Renders a row in the table */ diff --git a/src/components/Table/tableAccessibility.ts b/src/components/Table/tableAccessibility.ts index 7b6892819af5..73fde3277005 100644 --- a/src/components/Table/tableAccessibility.ts +++ b/src/components/Table/tableAccessibility.ts @@ -43,7 +43,7 @@ function shouldUseTableSemantics(shouldUseNarrowTableLayout: boolean): boolean { } /** - * Props for the element wrapping the whole table. The row count lives on the container because FlashList virtualizes + * Props for the element wrapping the whole table. The row count lives on the container because LegendList virtualizes * rows, so a screen reader cannot derive the total by walking the DOM. `columnCount` includes the leading selection * column when present, so it matches the 1-based `aria-colindex` assigned to headers and cells (the checkbox is * column 1). `hasHeaderRow` reflects whether the active virtualized layout currently exposes a column-header row. @@ -78,7 +78,7 @@ function getRowGroupAccessibilityProps(isEnabled: boolean): TableAccessibilityPr } /** - * Props for a table row. `aria-rowindex` is 1-based and has to be set explicitly because FlashList only keeps the + * Props for a table row. `aria-rowindex` is 1-based and has to be set explicitly because LegendList only keeps the * visible rows in the DOM, so a screen reader would otherwise announce the position within the rendered window. * Data rows start at 2 when a header occupies index 1, and at 1 when the current table layout has no exposed header. */ @@ -95,13 +95,10 @@ function getRowAccessibilityProps(isEnabled: boolean, rowIndex: number, isHeader }; } -/** Keeps real FlashList cells in the semantic tree while marking measurement and sticky clones as hidden. */ -function getVirtualizedRowSemanticID(isEnabled: boolean, target: VirtualizedRowTarget): null | undefined { - if (!isEnabled) { - return undefined; - } - - return target === 'Cell' ? undefined : null; +/** LegendList moves the original row when it becomes sticky, so every rendered row remains in the semantic tree. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function getVirtualizedRowSemanticID(isEnabled: boolean, legacyTarget?: VirtualizedRowTarget): undefined { + return undefined; } /** diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 8ddd9d9ca673..c3bd65b7c37d 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -1,5 +1,5 @@ -import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; -import type {PropsWithChildren} from 'react'; +import type {LegendListProps, LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native'; +import type {PropsWithChildren, ReactNode} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {FilterConfig, FilteringMethods, IsItemInFilterCallback} from './middlewares/filtering'; @@ -135,24 +135,26 @@ type TableMethods = FlashListRef & +type TableHandle = LegendListRef & TableMethods & { /** Method to get all of the processed data after filtering, searching, and sorting have been applied. */ getProcessedData: () => Array>; }; /** - * FlashList props with the 'data' prop omitted, as the Table manages data internally. + * LegendList data-mode props with the list-owned props omitted, as the Table manages data internally. * * @template DataType - The type of items in the table's data array. */ -type SharedListProps = Omit, 'data'>; +type SharedListProps = Omit, 'children' | 'data' | 'renderItem'> & { + renderItem: (props: LegendListRenderItemProps) => ReactNode; +}; /** * Props for the Table component. diff --git a/src/components/Tables/AgentsTable/index.tsx b/src/components/Tables/AgentsTable/index.tsx index d23fa6926caf..560c8daadce0 100644 --- a/src/components/Tables/AgentsTable/index.tsx +++ b/src/components/Tables/AgentsTable/index.tsx @@ -15,7 +15,7 @@ import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; -import type {ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListRenderItemProps} from '@legendapp/list/react-native'; import React from 'react'; import {View} from 'react-native'; @@ -86,7 +86,7 @@ export default function AgentsTable({ref, agents, headerComponent, canSelectAgen return results.length > 0; }; - const renderTableItem = ({item, index}: ListRenderItemInfo) => ( + const renderTableItem = ({item, index}: LegendListRenderItemProps) => ( 0; }; - const renderTableItem = ({item, index}: ListRenderItemInfo) => ( + const renderTableItem = ({item, index}: LegendListRenderItemProps) => ( 0; }; - const renderTableItem = ({item, index}: ListRenderItemInfo) => ( + const renderTableItem = ({item, index}: LegendListRenderItemProps) => ( ; const tableHeaderComponent = composeTableListHeader(headerComponent, searchBarComponent); - const renderTableItem = ({item, index}: ListRenderItemInfo) => { + const renderTableItem = ({item, index}: LegendListRenderItemProps) => { return ( 0; }; - const renderTableItem = ({item, index}: ListRenderItemInfo) => ( + const renderTableItem = ({item, index}: LegendListRenderItemProps) => ( 0; }; - const renderPersonalExpenseRuleItem = ({item, index}: ListRenderItemInfo) => ( + const renderPersonalExpenseRuleItem = ({item, index}: LegendListRenderItemProps) => ( 0; }; - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = ({item, index}: LegendListRenderItemProps) => ( 0; }; - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = ({item, index}: LegendListRenderItemProps) => ( 0; }; - const renderCategoryItem = ({item, index}: ListRenderItemInfo) => ( + const renderCategoryItem = ({item, index}: LegendListRenderItemProps) => ( 0; }; - const renderItem = ({item, index}: ListRenderItemInfo) => renderRow({item, rowIndex: index, shouldUseNarrowTableLayout}); + const renderItem = ({item, index}: LegendListRenderItemProps) => renderRow({item, rowIndex: index, shouldUseNarrowTableLayout}); const searchBarComponent = ; const tableHeaderComponent = composeTableListHeader(headerComponent, searchBarComponent); diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx index 7984216fb048..dd3d9ab693cb 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx @@ -29,7 +29,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import type {ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListRenderItemProps} from '@legendapp/list/react-native'; import {companyCardCustomNamesSelector} from '@selectors/Card'; import React, {useImperativeHandle, useRef, useState} from 'react'; @@ -348,7 +348,7 @@ function WorkspaceCompanyCardsTable({ /> ); - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = ({item, index}: LegendListRenderItemProps) => ( ({ - [CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE]: translate('workspace.distanceRates.statusActive'), - [CONST.CUSTOM_UNITS.RATE_STATUS.FUTURE]: translate('workspace.distanceRates.statusFuture'), - [CONST.CUSTOM_UNITS.RATE_STATUS.EXPIRED]: translate('workspace.distanceRates.statusExpired'), - [CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE]: translate('workspace.distanceRates.statusInactive'), - }), - [translate], - ); + const statusLabels = { + [CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE]: translate('workspace.distanceRates.statusActive'), + [CONST.CUSTOM_UNITS.RATE_STATUS.FUTURE]: translate('workspace.distanceRates.statusFuture'), + [CONST.CUSTOM_UNITS.RATE_STATUS.EXPIRED]: translate('workspace.distanceRates.statusExpired'), + [CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE]: translate('workspace.distanceRates.statusInactive'), + }; - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = ({item, index}: LegendListRenderItemProps) => ( 0; }; - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = ({item, index}: LegendListRenderItemProps) => ( = (item, searchValue) => filterCardsByPersonalDetails(item.card, searchValue, personalDetails); - const renderCardItem = ({item, index}: ListRenderItemInfo) => ( + const renderCardItem = ({item, index}: LegendListRenderItemProps) => ( ) => { + const renderTableItem = ({item, index}: LegendListRenderItemProps) => { return ( ) => { + const renderTableItem = ({item, index}: LegendListRenderItemProps) => { return ( 0; }; - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = ({item, index}: LegendListRenderItemProps) => ( 0; }; - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = ({item, index}: LegendListRenderItemProps) => ( row.keyForList === highlightedRoom.keyForList); if (!highlightedRow) { @@ -93,7 +93,7 @@ function WorkspaceRoomsTable({rooms, policyID, highlightedReportID, headerCompon const isItemInSearch: IsItemInSearchCallback = (item, searchValue) => item.name.toLowerCase().includes(searchValue.toLowerCase()); - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = ({item, index}: LegendListRenderItemProps) => ( 0; }; - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = ({item, index}: LegendListRenderItemProps) => ( 0; }; - const renderTagItem = ({item, index}: ListRenderItemInfo) => ( + const renderTagItem = ({item, index}: LegendListRenderItemProps) => ( 0; }; - const renderTaxItem = ({item, index}: ListRenderItemInfo) => ( + const renderTaxItem = ({item, index}: LegendListRenderItemProps) => ( 0; }; - const renderVendorItem = ({item, index}: ListRenderItemInfo) => ( + const renderVendorItem = ({item, index}: LegendListRenderItemProps) => ( ) => ( + const renderItem = ({item, index}: LegendListRenderItemProps) => ( + {/* Allows locally overriding beta feature flags for testing. Not rendered in production because this is not something regular users should reach, and forcing a beta on can leave the app half broken. */} + {!isProduction && ( + + + + )} + {/* Allows testing and revoking biometric multifactor authentication */} {isAgentAccount === false && } diff --git a/src/components/VacationDelegateMenuItem.tsx b/src/components/VacationDelegateMenuItem.tsx index cc2198706112..c2b624c59dcd 100644 --- a/src/components/VacationDelegateMenuItem.tsx +++ b/src/components/VacationDelegateMenuItem.tsx @@ -13,7 +13,7 @@ import React from 'react'; import UserAvatar from './Avatar/UserAvatar'; import MenuItem from './MenuItem'; -import MenuItemEmptyField from './MenuItem/presets/MenuItemEmptyField'; +import MenuItemField from './MenuItem/presets/MenuItemField'; import MenuItemWithLabel from './MenuItem/presets/MenuItemWithLabel'; import OfflineWithFeedback from './OfflineWithFeedback'; @@ -80,8 +80,8 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl ) : ( - )} diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index 822addce1a78..36ea3df19431 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -39,6 +39,7 @@ function useAutoCreateSubmitWorkspace() { formatPhoneNumber, isRestrictedPolicyCreation, hasActiveAdminPolicies, + hasOwnedPaidPolicy, onboardingMessages, lastWorkspaceNumber, shouldUseNarrowLayout, @@ -86,6 +87,7 @@ function useAutoCreateSubmitWorkspace() { betas, isSelfTourViewed, hasActiveAdminPolicies, + hasOwnedPaidPolicy, }) : {adminsChatReportID: onboardingAdminsChatReportID, policyID: onboardingPolicyID}; @@ -146,6 +148,7 @@ function useAutoCreateSubmitWorkspace() { onboardingMessages, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, shouldUseNarrowLayout, conciergeChat, ], diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index b8e704853ed0..6db550c83090 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -43,6 +43,7 @@ function useAutoCreateTrackWorkspace() { formatPhoneNumber, isRestrictedPolicyCreation, hasActiveAdminPolicies, + hasOwnedPaidPolicy, onboardingMessages, lastWorkspaceNumber, shouldUseNarrowLayout, @@ -95,6 +96,7 @@ function useAutoCreateTrackWorkspace() { betas, isSelfTourViewed, hasActiveAdminPolicies, + hasOwnedPaidPolicy, personalTrackGoal: onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.TRACK_PERSONAL && !!personalTrackGoal ? personalTrackGoal : undefined, }) : {adminsChatReportID: onboardingAdminsChatReportID, policyID: onboardingPolicyID}; @@ -163,6 +165,7 @@ function useAutoCreateTrackWorkspace() { onboardingMessages, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, shouldUseNarrowLayout, isBetaEnabled, conciergeChatReportID, diff --git a/src/hooks/useCompleteOnboarding.ts b/src/hooks/useCompleteOnboarding.ts index d0051998406c..f22164d926b5 100644 --- a/src/hooks/useCompleteOnboarding.ts +++ b/src/hooks/useCompleteOnboarding.ts @@ -19,6 +19,7 @@ import {useState} from 'react'; import useActivePolicy from './useActivePolicy'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useHasActiveAdminPolicies from './useHasActiveAdminPolicies'; +import useHasOwnedPaidPolicy from './useHasOwnedPaidPolicy'; import useLastWorkspaceNumber from './useLastWorkspaceNumber'; import useLocalize from './useLocalize'; import useOnboardingMessages from './useOnboardingMessages'; @@ -42,6 +43,7 @@ function useCompleteOnboarding() { const {isBetaEnabled} = usePermissions(); const activePolicy = useActivePolicy(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const hasOwnedPaidPolicy = useHasOwnedPaidPolicy(); const lastWorkspaceNumber = useLastWorkspaceNumber(); const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); @@ -97,6 +99,7 @@ function useCompleteOnboarding() { betas, isSelfTourViewed, hasActiveAdminPolicies, + hasOwnedPaidPolicy, conciergeChat, }) : {adminsChatReportID: onboardingAdminsChatReportID, policyID: onboardingPolicyID}; diff --git a/src/hooks/useConciergeSessionStartTime.ts b/src/hooks/useConciergeSessionStartTime.ts new file mode 100644 index 000000000000..627c7bde9048 --- /dev/null +++ b/src/hooks/useConciergeSessionStartTime.ts @@ -0,0 +1,18 @@ +import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; + +import useIsInSidePanel from './useIsInSidePanel'; +import useSidePanelState from './useSidePanelState'; + +/** + * Returns the Concierge session start time from the context that actually tracks it: SidePanelStateContext in the + * side panel, ConciergeSessionContext in the main DM. Each is null on the other surface, so always read via this + * hook. Only meaningful in a Concierge chat - callers gate with e.g. isConciergeHiddenHistory. + */ +function useConciergeSessionStartTime(): string | null { + const isInSidePanel = useIsInSidePanel(); + const {sessionStartTime: mainDMSessionStartTime} = useConciergeSessionState(); + const {sessionStartTime: sidePanelSessionStartTime} = useSidePanelState(); + return isInSidePanel ? sidePanelSessionStartTime : mainDMSessionStartTime; +} + +export default useConciergeSessionStartTime; diff --git a/src/hooks/useDefaultParticipants.ts b/src/hooks/useDefaultParticipants.ts index 1c71e92f58a4..dcc59a50f155 100644 --- a/src/hooks/useDefaultParticipants.ts +++ b/src/hooks/useDefaultParticipants.ts @@ -29,9 +29,6 @@ type UseDefaultParticipantsParams = { /** The IOU type from the route params. */ iouType?: IOUType; - - /** When false, the hook short-circuits and returns an empty list (the new manual expense flow beta is off). */ - isNewManualExpenseFlowEnabled?: boolean; }; type UseDefaultParticipantsResult = { @@ -52,7 +49,7 @@ type UseDefaultParticipantsResult = { * Shared by `useResetIOUType` (to seed the freshly-rebuilt transaction so the confirmation's auto-assign effect * short-circuits) and `IOURequestStepConfirmation` (to compute the participants it auto-assigns) so both stay in sync. */ -function useDefaultParticipants({sourceReport, transaction, iouType, isNewManualExpenseFlowEnabled = true}: UseDefaultParticipantsParams): UseDefaultParticipantsResult { +function useDefaultParticipants({sourceReport, transaction, iouType}: UseDefaultParticipantsParams): UseDefaultParticipantsResult { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const defaultExpensePolicy = useDefaultExpensePolicy(); const personalPolicy = usePersonalPolicy(); @@ -64,15 +61,9 @@ function useDefaultParticipants({sourceReport, transaction, iouType, isNewManual const accountID = currentUserPersonalDetails.accountID; - const isLoading = - isNewManualExpenseFlowEnabled && - (!accountID || isLoadingSelfDMReport || isLoadingOnyxValue(policyCollectionResult, amountOwedResult, userBillingGracePeriodEndsResult, ownerBillingGracePeriodEndResult)); + const isLoading = !accountID || isLoadingSelfDMReport || isLoadingOnyxValue(policyCollectionResult, amountOwedResult, userBillingGracePeriodEndsResult, ownerBillingGracePeriodEndResult); const participants = useMemo(() => { - if (!isNewManualExpenseFlowEnabled) { - return []; - } - const reportParticipants = getMoneyRequestParticipantsFromReport(sourceReport, accountID).filter((participant) => participant.selected); if (reportParticipants.length > 0) { return reportParticipants; @@ -96,7 +87,6 @@ function useDefaultParticipants({sourceReport, transaction, iouType, isNewManual const defaultTargetReport = shouldAutoReport ? getPolicyExpenseChat(accountID, defaultExpensePolicy?.id) : selfDMReport; return getMoneyRequestParticipantsFromReport(defaultTargetReport, accountID).filter((participant) => participant.selected); }, [ - isNewManualExpenseFlowEnabled, sourceReport, accountID, transaction?.isFromGlobalCreate, diff --git a/src/hooks/useEmitComposerScrollEvents/index.ts b/src/hooks/useEmitComposerScrollEvents/index.ts index ba65bfcdcf5e..cfd42ee98773 100644 --- a/src/hooks/useEmitComposerScrollEvents/index.ts +++ b/src/hooks/useEmitComposerScrollEvents/index.ts @@ -5,19 +5,16 @@ import {DeviceEventEmitter} from 'react-native'; type UseEmitComposerScrollEventsOptions = { enabled?: boolean; - inverted: boolean | null | undefined; }; /** * This is used to trigger scroll behavior in the composer on web. On native, this is a no-op. - * The scroll events are only emitted when the list is inverted, since it is only used in the report screen in combination with the composer. * Since our custom FlatList implementation can either be a `KeyboardDismissibleFlatList` or a regular `FlatList`, * we need to emit the scroll events inside the scroll handler of the specific implementation. - * @param inverted - Whether the list is inverted. * @returns A function that can be used to emit the scroll events. */ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOptions) { - const {enabled = true, inverted} = options ?? {}; + const {enabled = true} = options ?? {}; const lastScrollEvent = useRef(null); const scrollEndTimeout = useRef(null); @@ -28,7 +25,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * invokes the onScroll callback function from props. */ const onScroll = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } @@ -44,7 +41,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * Emits when the scrolling has ended. */ const onScrollEnd = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } @@ -67,7 +64,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * */ const emitComposerScrollEvents = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } diff --git a/src/hooks/useEnableGlobalReimbursementsNavigation.ts b/src/hooks/useEnableGlobalReimbursementsNavigation.ts new file mode 100644 index 000000000000..7daaa8382f95 --- /dev/null +++ b/src/hooks/useEnableGlobalReimbursementsNavigation.ts @@ -0,0 +1,67 @@ +/** + * Resolves Enable Global Reimbursements routes for the static settings/wallet flow and the dynamic report/search flow. + */ + +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import isDynamicRouteScreen from '@libs/Navigation/helpers/dynamicRoutesUtils/isDynamicRouteScreen'; +import type {EnableGlobalReimbursementsRouteParams} from '@libs/Navigation/helpers/enableGlobalReimbursementsNavigationUtils'; +import { + getDynamicBasePathFromNavigationPath, + getEnableGlobalReimbursementsRootBackPath, + shouldUseDynamicEnableGlobalReimbursementsBase, +} from '@libs/Navigation/helpers/enableGlobalReimbursementsNavigationUtils'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; +import type {State} from '@libs/Navigation/types'; + +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import type {Screen} from '@src/SCREENS'; + +import {useRoute} from '@react-navigation/native'; + +import useRootNavigationState from './useRootNavigationState'; + +function useEnableGlobalReimbursementsNavigation() { + const route = useRoute(); + const isDynamic = isDynamicRouteScreen(route.name as Screen); // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion -- route.name is string at runtime + const navigationPath = useRootNavigationState((state) => (isDynamic && state ? getPathFromState(state as State) : undefined)); + const dynamicBasePath = isDynamic ? getDynamicBasePathFromNavigationPath(navigationPath) : ''; + + const getBusinessRoute = (bankAccountID: number | string, subPage: string, action?: 'edit', params?: EnableGlobalReimbursementsRouteParams): Route => { + if (isDynamic) { + return createDynamicRoute(DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute(String(bankAccountID), subPage, action, params), dynamicBasePath); + } + return ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute(Number(bankAccountID), subPage, action, params); + }; + + const getAgreementsRoute = (bankAccountID: number | string, params?: EnableGlobalReimbursementsRouteParams): Route => { + if (isDynamic) { + return createDynamicRoute(DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS.getRoute(String(bankAccountID), params), dynamicBasePath); + } + return ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS.getRoute(Number(bankAccountID), params); + }; + + const getSignRoute = (bankAccountID: number | string, params?: EnableGlobalReimbursementsRouteParams): Route => { + if (isDynamic) { + return createDynamicRoute(DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN.getRoute(String(bankAccountID), params), dynamicBasePath); + } + return ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN.getRoute(Number(bankAccountID), params); + }; + + const getRootBackPath = (): Route => { + if (isDynamic && shouldUseDynamicEnableGlobalReimbursementsBase(dynamicBasePath)) { + return getEnableGlobalReimbursementsRootBackPath(dynamicBasePath); + } + return ROUTES.SETTINGS_WALLET; + }; + + return { + isDynamic, + getBusinessRoute, + getAgreementsRoute, + getSignRoute, + getRootBackPath, + }; +} + +export default useEnableGlobalReimbursementsNavigation; diff --git a/src/hooks/useHasOwnedPaidPolicy.ts b/src/hooks/useHasOwnedPaidPolicy.ts new file mode 100644 index 000000000000..c7d0447e3b47 --- /dev/null +++ b/src/hooks/useHasOwnedPaidPolicy.ts @@ -0,0 +1,18 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import {ownerPoliciesSelector} from '@src/selectors/Policy'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useOnyx from './useOnyx'; + +/** Whether the current user already owns at least one paid workspace. */ +function useHasOwnedPaidPolicy() { + const {accountID} = useCurrentUserPersonalDetails(); + const selector = (policies: OnyxCollection) => ownerPoliciesSelector(policies, accountID).length > 0; + const [hasOwnedPaidPolicy] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector}); + return !!hasOwnedPaidPolicy; +} + +export default useHasOwnedPaidPolicy; diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index 140dd63b9ef5..04456142df61 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -76,6 +76,15 @@ function useLoadReportActions({ } } + const currentReportNewestActionID = newestFetchedReportActionID ?? currentReportNewestAction?.reportActionID; + const newestReportActionsRequestCursor = isTransactionThreadReport + ? JSON.stringify([currentReportNewestActionID, transactionThreadNewestAction?.reportActionID]) + : (currentReportNewestActionID ?? newestReportAction?.reportActionID); + const oldestReportActionsRequestCursor = isTransactionThreadReport + ? JSON.stringify([currentReportOldestAction?.reportActionID, transactionThreadOldestAction?.reportActionID]) + : currentReportOldestAction?.reportActionID; + const canLoadNewerChats = !!isFocused && !!newestReportAction && newestReportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + /** * Retrieves the next set of reportActions for the chat once we are nearing the end of what we are currently * displaying. @@ -102,7 +111,7 @@ function useLoadReportActions({ const loadNewerChats = (force = false) => { if ( !force && - (!isFocused || + (!canLoadNewerChats || !newestReportAction || !hasNewerActions || isOffline || @@ -138,6 +147,10 @@ function useLoadReportActions({ loadNewerChats, // The exact cursor `loadOlderChats` sends, which is not always the end of the rendered chain. currentReportOldestActionID: currentReportOldestAction?.reportActionID, + currentReportNewestActionID, + oldestReportActionsRequestCursor, + newestReportActionsRequestCursor, + canLoadNewerChats, }; } diff --git a/src/hooks/useOnboardingWorkspaceCreationState.ts b/src/hooks/useOnboardingWorkspaceCreationState.ts index 1573fc7363b5..b199a2ad5467 100644 --- a/src/hooks/useOnboardingWorkspaceCreationState.ts +++ b/src/hooks/useOnboardingWorkspaceCreationState.ts @@ -6,6 +6,7 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; import useActivePolicy from './useActivePolicy'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useHasActiveAdminPolicies from './useHasActiveAdminPolicies'; +import useHasOwnedPaidPolicy from './useHasOwnedPaidPolicy'; import useLastWorkspaceNumber from './useLastWorkspaceNumber'; import useLocalize from './useLocalize'; import useOnboardingMessages from './useOnboardingMessages'; @@ -33,6 +34,7 @@ function useOnboardingWorkspaceCreationState() { const {translate, formatPhoneNumber} = useLocalize(); const {isRestrictedPolicyCreation} = usePreferredPolicy(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const hasOwnedPaidPolicy = useHasOwnedPaidPolicy(); const {onboardingMessages} = useOnboardingMessages(); const lastWorkspaceNumber = useLastWorkspaceNumber(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -52,6 +54,7 @@ function useOnboardingWorkspaceCreationState() { formatPhoneNumber, isRestrictedPolicyCreation, hasActiveAdminPolicies, + hasOwnedPaidPolicy, onboardingMessages, lastWorkspaceNumber, shouldUseNarrowLayout, diff --git a/src/hooks/useOptimisticPersonalDetails.ts b/src/hooks/useOptimisticPersonalDetails.ts new file mode 100644 index 000000000000..967cc04d23b5 --- /dev/null +++ b/src/hooks/useOptimisticPersonalDetails.ts @@ -0,0 +1,18 @@ +import ONYXKEYS from '@src/ONYXKEYS'; + +import {optimisticPersonalDetailsSelector} from '@selectors/PersonalDetails'; + +import useOnyx from './useOnyx'; + +/** + * Subscribes to the personal details that were created optimistically only. + * Use it instead of `usePersonalDetails` when the consumer only cares about optimistic accounts: + * the optimistic set is small, so the component re-renders only when an optimistic personal detail changes + * instead of on every change of the whole personal details list. + */ +function useOptimisticPersonalDetails() { + const [optimisticPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: optimisticPersonalDetailsSelector}); + return optimisticPersonalDetails; +} + +export default useOptimisticPersonalDetails; diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts index abf1ad7bcfc6..6c27c4d93fc1 100644 --- a/src/hooks/usePermissions.ts +++ b/src/hooks/usePermissions.ts @@ -1,4 +1,4 @@ -import {BetaConfigurationContext, BetasContext} from '@components/OnyxListItemProvider'; +import {BetaConfigurationContext, BetasContext, BetaOverridesContext} from '@components/OnyxListItemProvider'; import Permissions from '@libs/Permissions'; @@ -13,9 +13,10 @@ let permissionKey: PermissionKey; export default function usePermissions(): UsePermissions { const betas = useContext(BetasContext); const betaConfiguration = useContext(BetaConfigurationContext); + const betaOverrides = useContext(BetaOverridesContext); return useMemo(() => { const permissions: UsePermissions = { - isBetaEnabled: (beta: Beta) => Permissions.isBetaEnabled(beta, betas, betaConfiguration), + isBetaEnabled: (beta: Beta) => Permissions.isBetaEnabled(beta, betas, betaConfiguration, betaOverrides), }; for (permissionKey in Permissions) { @@ -26,5 +27,5 @@ export default function usePermissions(): UsePermissions { } return permissions; - }, [betas, betaConfiguration]); + }, [betas, betaConfiguration, betaOverrides]); } diff --git a/src/hooks/usePersonalDetailSearchSelector/base.ts b/src/hooks/usePersonalDetailSearchSelector/base.ts index 86dcf6baf70f..7d205f44df1c 100644 --- a/src/hooks/usePersonalDetailSearchSelector/base.ts +++ b/src/hooks/usePersonalDetailSearchSelector/base.ts @@ -203,6 +203,10 @@ function usePersonalDetailSearchSelectorBase({ return options; })(); + // Trim before matching, otherwise a leading/trailing space makes Str.isValidEmail fail in getUserToInviteOption + // and the "invite user" option disappears for logins that don't have an account yet. + const trimmedSearchTerm = debouncedSearchTerm.trim(); + const optionsList = !areOptionsInitialized ? defaultListOptions : getValidOptions(transformedOptions, currentUserEmail, formatPhoneNumber, countryCode, loginList, { @@ -211,7 +215,7 @@ function usePersonalDetailSearchSelectorBase({ includeSelectedOptions: shouldKeepSelectedInAvailableOptions, includeRecentReports, recentAttendees, - searchString: debouncedSearchTerm, + searchString: trimmedSearchTerm, maxElements, recentMaxElements: maxRecentReportsToShow, includeUserToInvite, diff --git a/src/hooks/usePreMountDestination/index.ts b/src/hooks/usePreMountDestination/index.ts index 0a261eac3217..07377b2bb77d 100644 --- a/src/hooks/usePreMountDestination/index.ts +++ b/src/hooks/usePreMountDestination/index.ts @@ -82,7 +82,7 @@ function usePreMountDestination(route: Route | undefined, options?: UsePreMountD // set it is stale (e.g. preserved across a route change) and would mis-drive getSubmitHandler on the next submit. This // should not happen under the single-pre-inserter invariant, so surface it loudly rather than leaving it silent. if (Navigation.getIsFullscreenPreInsertedUnderRHP()) { - Log.warn('[usePreMountDestination] reveal() reached the non-owned path while a pre-inserted fullscreen flag is still set'); + Log.alert('[usePreMountDestination] reveal() reached the non-owned path while a pre-inserted fullscreen flag is still set'); } if (!route) { diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index b3e438fd3adb..a7b0fc35fbab 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -58,6 +58,8 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; const hasLoadingOlderReportActionsError = reportLoadingState?.hasLoadingOlderReportActionsError; + const isLoadingNewerReportActions = reportLoadingState?.isLoadingNewerReportActions; + const hasLoadingNewerReportActionsError = reportLoadingState?.hasLoadingNewerReportActionsError; const {sessionStartTime, showFullHistory: conciergeShowFullHistory, hadMessagesAtSessionStart: conciergeHadMessagesAtSessionStart} = useConciergeSessionState(); const {setShowFullHistory: setConciergeShowFullHistory, setHadMessagesAtSessionStart: setConciergeHadMessagesAtSessionStart} = useConciergeSessionActions(); @@ -71,15 +73,16 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const [reportPaginationState] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_PAGINATION_STATE}${reportID}`); - const {loadOlderChats, loadNewerChats, currentReportOldestActionID} = useLoadReportActions({ - reportID, - reportActions, - allReportActionIDs, - transactionThreadReportID, - hasOlderActions, - hasNewerActions, - newestFetchedReportActionID: reportPaginationState?.newestFetchedReportActionID, - }); + const {loadOlderChats, loadNewerChats, currentReportOldestActionID, currentReportNewestActionID, oldestReportActionsRequestCursor, newestReportActionsRequestCursor, canLoadNewerChats} = + useLoadReportActions({ + reportID, + reportActions, + allReportActionIDs, + transactionThreadReportID, + hasOlderActions, + hasNewerActions, + newestFetchedReportActionID: reportPaginationState?.newestFetchedReportActionID, + }); const { sortedReportActions, @@ -146,7 +149,17 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const state = { report, hasOnceLoadedReportActions, + hasOlderActions, hasNewerActions, + isLoadingOlderReportActions, + hasLoadingOlderReportActionsError, + isLoadingNewerReportActions, + hasLoadingNewerReportActionsError, + oldestReportActionID: currentReportOldestActionID, + newestReportActionID: currentReportNewestActionID, + olderReportActionsRequestCursor: oldestReportActionsRequestCursor, + newerReportActionsRequestCursor: newestReportActionsRequestCursor, + canLoadNewerChats, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, diff --git a/src/hooks/useReportActionsPaginationScroll.ts b/src/hooks/useReportActionsPaginationScroll.ts new file mode 100644 index 000000000000..97f0f4437f4d --- /dev/null +++ b/src/hooks/useReportActionsPaginationScroll.ts @@ -0,0 +1,278 @@ +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import type {RefObject} from 'react'; + +import {useEffect, useEffectEvent, useLayoutEffect, useRef} from 'react'; + +const REPORT_ACTIONS_PAGINATION_THRESHOLD = 0.25; + +type PaginationGeometry = { + scroll: number; + scrollLength: number; + contentLength: number; +}; + +type PaginationListRef = { + getState: () => PaginationGeometry | undefined; +}; + +type ReportActionsPaginationDistances = { + older: number; + newer: number; +}; + +type UseReportActionsPaginationScrollArguments = { + reportID: string; + linkedReportActionID: string | undefined; + listRef: RefObject; + viewportHeight: number; + olderPaginationExtent: number; + newerPaginationExtent: number; + olderCursor: string | undefined; + newerCursor: string | undefined; + hasOlderActions: boolean; + hasNewerActions: boolean; + isLoadingOlderReportActions: boolean; + isLoadingNewerReportActions: boolean; + hasLoadingOlderReportActionsError: boolean; + hasLoadingNewerReportActionsError: boolean; + isOffline: boolean; + canLoadOlder: boolean; + canLoadNewer: boolean; + loadOlderActions: () => void; + loadNewerActions: () => void; +}; + +function useReportActionsPaginationScroll(options: UseReportActionsPaginationScrollArguments) { + const { + reportID, + linkedReportActionID, + olderCursor, + newerCursor, + isLoadingOlderReportActions, + isLoadingNewerReportActions, + isOffline, + canLoadOlder, + canLoadNewer, + viewportHeight, + olderPaginationExtent, + newerPaginationExtent, + hasOlderActions, + hasNewerActions, + } = options; + const latestArgumentsRef = useRef(options); + const lastRequestedOlderCursorRef = useRef(undefined); + const lastRequestedNewerCursorRef = useRef(undefined); + const wasNearOlderBoundaryRef = useRef(false); + const wasNearNewerBoundaryRef = useRef(false); + const wasOfflineRef = useRef(isOffline); + const couldLoadOlderRef = useRef(canLoadOlder); + const couldLoadNewerRef = useRef(canLoadNewer); + const wasLoadingOlderRef = useRef(isLoadingOlderReportActions); + const wasLoadingNewerRef = useRef(isLoadingNewerReportActions); + const paginationWindowGenerationRef = useRef(0); + const scheduledFramesRef = useRef(new Set()); + + useLayoutEffect(() => { + latestArgumentsRef.current = options; + }); + + const scheduleFrame = (callback: () => void, generation?: number) => { + const expectedGeneration = generation ?? paginationWindowGenerationRef.current; + const frame = requestAnimationFrame(() => { + scheduledFramesRef.current.delete(frame); + if (expectedGeneration === paginationWindowGenerationRef.current) { + callback(); + } + }); + scheduledFramesRef.current.add(frame); + }; + + const cancelScheduledFrames = () => { + for (const frame of scheduledFramesRef.current) { + cancelAnimationFrame(frame); + } + scheduledFramesRef.current.clear(); + }; + + const requestNewerActions = (cursor: string, canRetryError: boolean) => { + const runRequest = () => { + const latestArguments = latestArgumentsRef.current; + if ( + latestArguments.newerCursor !== cursor || + lastRequestedNewerCursorRef.current !== cursor || + !latestArguments.canLoadNewer || + !latestArguments.hasNewerActions || + latestArguments.isOffline || + latestArguments.isLoadingNewerReportActions || + (latestArguments.hasLoadingNewerReportActionsError && !canRetryError) + ) { + return; + } + latestArguments.loadNewerActions(); + }; + + if (!isSearchTopmostFullScreenRoute()) { + runRequest(); + return; + } + + const generation = paginationWindowGenerationRef.current; + TransitionTracker.runAfterTransitions({callback: () => scheduleFrame(runRequest, generation)}); + }; + + const checkPaginationBoundaries = () => { + const latestArguments = latestArgumentsRef.current; + const listState = latestArguments.listRef.current?.getState(); + if (!listState || latestArguments.viewportHeight <= 0) { + return; + } + + const distances = getReportActionsPaginationDistances(listState, latestArguments.olderPaginationExtent, latestArguments.newerPaginationExtent); + const threshold = latestArguments.viewportHeight * REPORT_ACTIONS_PAGINATION_THRESHOLD; + const isNearOlderBoundary = distances.older <= threshold; + const isNearNewerBoundary = distances.newer <= threshold; + if (!isNearOlderBoundary) { + lastRequestedOlderCursorRef.current = undefined; + } + if (!isNearNewerBoundary) { + lastRequestedNewerCursorRef.current = undefined; + } + + const canRetryOlderError = !wasNearOlderBoundaryRef.current; + const canRetryNewerError = !wasNearNewerBoundaryRef.current; + + if ( + isNearOlderBoundary && + latestArguments.canLoadOlder && + latestArguments.hasOlderActions && + !latestArguments.isOffline && + !latestArguments.isLoadingOlderReportActions && + (!latestArguments.hasLoadingOlderReportActionsError || canRetryOlderError) && + latestArguments.olderCursor && + lastRequestedOlderCursorRef.current !== latestArguments.olderCursor + ) { + lastRequestedOlderCursorRef.current = latestArguments.olderCursor; + latestArguments.loadOlderActions(); + } + + if ( + isNearNewerBoundary && + latestArguments.canLoadNewer && + latestArguments.hasNewerActions && + !latestArguments.isOffline && + !latestArguments.isLoadingNewerReportActions && + (!latestArguments.hasLoadingNewerReportActionsError || canRetryNewerError) && + latestArguments.newerCursor && + lastRequestedNewerCursorRef.current !== latestArguments.newerCursor + ) { + lastRequestedNewerCursorRef.current = latestArguments.newerCursor; + requestNewerActions(latestArguments.newerCursor, canRetryNewerError); + } + + wasNearOlderBoundaryRef.current = isNearOlderBoundary; + wasNearNewerBoundaryRef.current = isNearNewerBoundary; + }; + + const schedulePaginationBoundaryCheck = () => scheduleFrame(checkPaginationBoundaries); + const cancelScheduledFramesEffect = useEffectEvent(cancelScheduledFrames); + const schedulePaginationBoundaryCheckEffect = useEffectEvent(schedulePaginationBoundaryCheck); + + useEffect(() => { + paginationWindowGenerationRef.current += 1; + cancelScheduledFramesEffect(); + lastRequestedOlderCursorRef.current = undefined; + lastRequestedNewerCursorRef.current = undefined; + wasNearOlderBoundaryRef.current = false; + wasNearNewerBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [reportID, linkedReportActionID]); + + useEffect(() => { + lastRequestedOlderCursorRef.current = undefined; + schedulePaginationBoundaryCheckEffect(); + }, [olderCursor]); + + useEffect(() => { + lastRequestedNewerCursorRef.current = undefined; + schedulePaginationBoundaryCheckEffect(); + }, [newerCursor]); + + useEffect(() => { + const finishedLoadingOlder = wasLoadingOlderRef.current && !isLoadingOlderReportActions; + const finishedLoadingNewer = wasLoadingNewerRef.current && !isLoadingNewerReportActions; + wasLoadingOlderRef.current = isLoadingOlderReportActions; + wasLoadingNewerRef.current = isLoadingNewerReportActions; + if (finishedLoadingOlder || finishedLoadingNewer) { + schedulePaginationBoundaryCheckEffect(); + } + }, [isLoadingOlderReportActions, isLoadingNewerReportActions]); + + useEffect(() => { + const didReconnect = wasOfflineRef.current && !isOffline; + wasOfflineRef.current = isOffline; + if (!didReconnect) { + return; + } + + lastRequestedOlderCursorRef.current = undefined; + lastRequestedNewerCursorRef.current = undefined; + wasNearOlderBoundaryRef.current = false; + wasNearNewerBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [isOffline]); + + useEffect(() => { + const canNowLoadOlder = !couldLoadOlderRef.current && canLoadOlder; + couldLoadOlderRef.current = canLoadOlder; + if (!canNowLoadOlder) { + return; + } + lastRequestedOlderCursorRef.current = undefined; + wasNearOlderBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [canLoadOlder]); + + useEffect(() => { + const canNowLoadNewer = !couldLoadNewerRef.current && canLoadNewer; + couldLoadNewerRef.current = canLoadNewer; + if (!canNowLoadNewer) { + return; + } + lastRequestedNewerCursorRef.current = undefined; + wasNearNewerBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [canLoadNewer]); + + useEffect(() => { + schedulePaginationBoundaryCheckEffect(); + }, [viewportHeight, olderPaginationExtent, newerPaginationExtent, hasOlderActions, hasNewerActions]); + + useEffect( + () => () => { + paginationWindowGenerationRef.current += 1; + cancelScheduledFramesEffect(); + }, + [], + ); + + return { + onScroll: checkPaginationBoundaries, + onContentSizeChange: schedulePaginationBoundaryCheck, + }; +} + +function getReportActionsPaginationDistances( + {scroll, scrollLength, contentLength}: PaginationGeometry, + olderPaginationExtent: number, + newerPaginationExtent: number, +): ReportActionsPaginationDistances { + return { + older: scroll - olderPaginationExtent, + newer: contentLength - scrollLength - scroll - newerPaginationExtent, + }; +} + +export default useReportActionsPaginationScroll; +export {REPORT_ACTIONS_PAGINATION_THRESHOLD}; diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index b899c31b1312..3f5ce23335be 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -34,8 +34,6 @@ import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; import usePrevious from './usePrevious'; import useReportScrollManager from './useReportScrollManager'; -import useScrollToEndOnNewMessageReceived from './useScrollToEndOnNewMessageReceived'; -import useWindowDimensions from './useWindowDimensions'; type UseReportActionsScrollParams = { /** The Concierge chat report */ @@ -55,15 +53,13 @@ type UseReportActionsScrollParams = { /** Sorted actions that should be visible to the user */ sortedVisibleReportActions: OnyxTypes.ReportAction[]; - /** Actions actually rendered by the list (may include a synthetic draft), used for mount scroll positioning */ + /** Actions actually rendered by the list in chronological order (may include a synthetic draft), used for scroll positioning */ renderedVisibleReportActions: OnyxTypes.ReportAction[]; /** Extracts the list key for an action; used to locate the initial scroll target */ keyExtractor: (item: OnyxTypes.ReportAction) => string; /** Whether the user has scrolled past the "visible" threshold */ - hasScrolledOverThreshold: boolean; - /** Marks the newest action as read and clears any pending skipped mark-as-read */ markNewestActionAsRead: () => void; @@ -79,9 +75,6 @@ type UseReportActionsScrollParams = { /** Whether the report has newer actions to load */ hasNewerActions: boolean; - /** Stable key that changes when a streamed concierge draft becomes visible, used to trigger autoscroll */ - draftAutoScrollKey: string; - /** The index of the action badge target in the rendered actions list (-1 if none) */ actionBadgeTargetIndex: number; @@ -112,28 +105,16 @@ type UseReportActionsScrollResult = { scrollToActionBadgeTarget: () => void; - /** Completes a live-tail scroll-to-bottom once the list has laid out; call on every list layout */ - flushPendingScrollToBottom: () => void; - /** Whether the list should be pinned to the visual top (transaction thread / money request) */ shouldBeAlignedToTop: boolean; - /** Whether the list should focus to the visual top on mount */ - shouldFocusToTopOnMount: boolean; - - /** The initial scroll target key for the list */ - initialScrollKey: string | undefined; - - /** maintainVisibleContentPosition config for the inverted list */ - maintainVisibleContentPosition: {disabled: boolean; autoscrollToBottomThreshold?: number; animateAutoScrollToBottom?: boolean}; - /** The index the list should scroll to on mount (undefined to keep default position) */ initialScrollIndex: number | undefined; /** Positioning params (viewPosition/viewOffset) paired with initialScrollIndex */ initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; - /** onLoad handler that disables autoscroll-to-top once the initial render settles */ + /** onLoad handler that enables pill tracking after initial positioning settles */ onLoad: () => void; }; @@ -146,13 +127,11 @@ function useReportActionsScroll({ sortedVisibleReportActions, renderedVisibleReportActions, keyExtractor, - hasScrolledOverThreshold, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, unreadMarkerReportActionIndex, hasNewerActions, - draftAutoScrollKey, actionBadgeTargetIndex, sortedAllReportActionsForPagination, treatAsNoPaginationAnchor, @@ -160,7 +139,6 @@ function useReportActionsScroll({ }: UseReportActionsScrollParams): UseReportActionsScrollResult { const reportScrollManager = useReportScrollManager(); const {scrollOffsetRef} = useActionListContext(); - const {windowHeight} = useWindowDimensions(); const route = useRoute>(); const linkedReportActionID = route?.params?.reportActionID; const backTo = route?.params?.backTo; @@ -204,18 +182,8 @@ function useReportActionsScroll({ } const shouldFocusToTopOnMount = shouldBeAlignedToTop && !initialScrollKey && !shouldScrollToLatestOnOpen; - const shouldMaintainVisibleContentPosition = hasScrolledOverThreshold || shouldFocusToTopOnMount; - const [shouldAutoscrollToBottom, setShouldAutoscrollToBottom] = useState(shouldFocusToTopOnMount); const [shouldDisablePillTracking, setShouldDisablePillTracking] = useState(!!initialScrollKey); - const maintainVisibleContentPosition = { - disabled: !shouldMaintainVisibleContentPosition, - // Focus-to-top mode: once autoscroll is released, keep the threshold at 0 rather than - // removing it — FlashList only clears its pending-autoscroll flag while threshold >= 0, - // otherwise the next content change (e.g. mark-as-unread) scrolls back to top. - ...(shouldFocusToTopOnMount ? {autoscrollToBottomThreshold: shouldAutoscrollToBottom ? CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD : 0, animateAutoScrollToBottom: false} : {}), - }; - const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, isActionBadgeAboveViewport, trackVerticalScrolling, onViewableItemsChanged, updatePillVisibility} = useReportUnreadMessageScrollTracking({ reportID, @@ -223,7 +191,7 @@ function useReportActionsScroll({ onUnreadActionVisible: completeSkippedMarkAsRead, hasNewerActions, unreadMarkerReportActionIndex, - isInverted: true, + isInverted: false, shouldDisablePillTracking, onTrackScrolling: (event: NativeSyntheticEvent) => { scrollOffsetRef.current = event.nativeEvent.contentOffset.y; @@ -247,7 +215,7 @@ function useReportActionsScroll({ hasNewerActions, linkedReportActionID, hasNewestReportAction, - sortedVisibleReportActions, + renderedVisibleReportActions, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor, @@ -256,57 +224,17 @@ function useReportActionsScroll({ reportLoadingState, }); - useScrollToEndOnNewMessageReceived({ - sizeChangeType: 'changed', - scrollOffsetRef, - lastActionID: lastAction?.reportActionID, - visibleActionsLength: sortedVisibleReportActions.length, - hasNewestReportAction, - setIsFloatingMessageCounterVisible, - scrollToEnd: reportScrollManager.scrollToBottom, - // Include reportID so list-length / last-id baselines reset when the same screen instance shows another report. - resetKey: `${reportID}:${linkedReportActionID}`, - }); - - const previousDraftAutoScrollKey = usePrevious(draftAutoScrollKey); - + // Consume an explicit live-tail request after the data render, not on an unrelated future + // viewport layout (for example, opening the keyboard after the user has scrolled away). + // Incoming messages and streaming draft growth are followed by LegendList itself. useEffect(() => { - if (!draftAutoScrollKey || previousDraftAutoScrollKey === draftAutoScrollKey) { - return; - } - - if (scrollOffsetRef.current >= CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD || !hasNewestReportAction) { + if (!isScrollToBottomEnabled) { return; } - - setIsFloatingMessageCounterVisible(false); - requestAnimationFrame(() => { - reportScrollManager.scrollToBottom(); - }); - }, [draftAutoScrollKey, hasNewestReportAction, previousDraftAutoScrollKey, reportScrollManager, scrollOffsetRef, setIsFloatingMessageCounterVisible]); - - const scheduleInitialScrollToBottom = useEffectEvent(() => { - if (initialScrollKey) { - return undefined; - } - - return TransitionTracker.runAfterTransitions({ - callback: () => { - if (shouldFocusToTopOnMount) { - return; - } - setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); - }, - waitForUpcomingTransition: true, - }); - }); - - // The initial scroll-to-bottom must be scheduled exactly once, on mount; re-running it as deps change would yank the user back down while they read history. - useEffect(() => { - const handle = scheduleInitialScrollToBottom(); - return () => handle?.cancel(); - }, []); + reportScrollManager.scrollToBottom(); + setIsScrollToBottomEnabled(false); + completeLiveTailPruneAfterScrollToBottom(); + }, [isScrollToBottomEnabled, reportScrollManager, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom]); // Clear the shouldScrollToLatest route param once the mount scroll above has consumed it, so a later remount of // this report doesn't pull the user down again. MoneyRequestReportActionsList clears it the same way for the @@ -393,64 +321,33 @@ function useReportActionsScroll({ if (actionBadgeTargetIndex < 0) { return; } - reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }; - const flushPendingScrollToBottom = () => { - if (!isScrollToBottomEnabled) { - return; - } - reportScrollManager.scrollToBottom(); - setIsScrollToBottomEnabled(false); - completeLiveTailPruneAfterScrollToBottom(); - }; - - // Data is ready at the moment FlashList finishes its first render. + // Data is ready when LegendList finishes its first render. const onLoad = () => { - if (shouldDisablePillTracking) { - // Wait one frame so the initial positioning can settle, then disable it. - requestAnimationFrame(() => { - setShouldDisablePillTracking(false); - updatePillVisibility(); - }); - } - if (!shouldFocusToTopOnMount) { + if (!shouldDisablePillTracking) { return; } - if (!reportLoadingState?.hasOnceLoadedReportActions && !isOffline) { - return; - } - // Wait one frame so the initial autoscroll-to-top can settle, then disable it. - requestAnimationFrame(() => setShouldAutoscrollToBottom(false)); - }; - const prevHasOnceLoadedReportActions = usePrevious(reportLoadingState?.hasOnceLoadedReportActions); - // Data finished initial loading after the list mounted. onLoad has already fired, so we need - // a separate trigger to turn off autoscroll-to-top. - useEffect(() => { - if (!shouldFocusToTopOnMount || !shouldAutoscrollToBottom) { - return; - } - if (prevHasOnceLoadedReportActions || !reportLoadingState?.hasOnceLoadedReportActions) { - return; - } - requestAnimationFrame(() => setShouldAutoscrollToBottom(false)); - }, [shouldFocusToTopOnMount, shouldAutoscrollToBottom, prevHasOnceLoadedReportActions, reportLoadingState?.hasOnceLoadedReportActions]); + // Wait one frame so the initial positioning can settle, then disable it. + requestAnimationFrame(() => { + setShouldDisablePillTracking(false); + updatePillVisibility(); + }); + }; // Decide where the list should be positioned on mount. - // 1. If we're opening a linked message (initialScrollKey), find that action in the list and scroll it to the top - // of the viewport (viewPosition: 1) with a small offset so the message above is partly visible. - // 2. Otherwise, if the report should be opened at top (ex: for transaction threads), scroll to the top message and offset by - // the window height so we land at top of the top message for sure. + // 1. If we're opening a linked or unread message, find that action in the chronological list. + // 2. Otherwise, aligned-to-top reports start at the first action. const targetIndex = initialScrollKey ? renderedVisibleReportActions.findIndex((item) => keyExtractor(item) === initialScrollKey) : -1; let initialScrollIndex: number | undefined; let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; - if (targetIndex > 0) { + if (targetIndex >= 0) { initialScrollIndex = targetIndex; - initialScrollIndexParams = {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; + initialScrollIndexParams = {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; } else if (shouldFocusToTopOnMount) { - initialScrollIndex = renderedVisibleReportActions.length - 1; - initialScrollIndexParams = {viewOffset: windowHeight}; + initialScrollIndex = 0; } return { @@ -460,11 +357,7 @@ function useReportActionsScroll({ isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget, - flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldFocusToTopOnMount, - initialScrollKey, - maintainVisibleContentPosition, initialScrollIndex, initialScrollIndexParams, onLoad, diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index 97c9a92cf1f5..c1823e3dc0a8 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -21,17 +21,14 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToIndex({index, animated, viewOffset, viewPosition}); }; - /** - * Scroll to the bottom of the inverted FlatList. - * When FlatList is inverted it's "bottom" is really it's top - */ + /** Scroll to the bottom of the chronological action list. */ const scrollToBottom = () => { const listRef = getListRef(); if (!listRef?.current) { return; } - listRef.current.scrollToIndex({animated: false, index: 0}); + listRef.current.scrollToEnd({animated: false}); }; /** diff --git a/src/hooks/useReportScrollManager/index.ts b/src/hooks/useReportScrollManager/index.ts index a2e615e7d6d6..09ce64a5687c 100644 --- a/src/hooks/useReportScrollManager/index.ts +++ b/src/hooks/useReportScrollManager/index.ts @@ -18,17 +18,14 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToIndex({index, animated, viewOffset, viewPosition}); }; - /** - * Scroll to the bottom of the inverted FlatList. - * When FlatList is inverted it's "bottom" is really it's top - */ + /** Scroll to the bottom of the chronological action list. */ const scrollToBottom = () => { const listRef = getListRef(); if (!listRef?.current) { return; } - listRef.current.scrollToIndex({animated: false, index: 0}); + listRef.current.scrollToEnd({animated: false}); }; /** diff --git a/src/hooks/useResetIOUType.ts b/src/hooks/useResetIOUType.ts index 7bcc887792e1..961a50ba8900 100644 --- a/src/hooks/useResetIOUType.ts +++ b/src/hooks/useResetIOUType.ts @@ -53,10 +53,6 @@ type UseResetIOUTypeParams = { /** Whether to skip keyboard dismiss for per diem tab */ skipKeyboardDismissForPerDiem?: boolean; - - /** Whether the new manual expense flow beta is enabled. When true, the fresh transaction is seeded with - * participants from the current report so the embedded confirmation's auto-assign useEffect short-circuits. */ - isNewManualExpenseFlowEnabled?: boolean; }; /** @@ -74,7 +70,6 @@ function useResetIOUType({ policy, isTrackDistanceExpense = false, skipKeyboardDismissForPerDiem = false, - isNewManualExpenseFlowEnabled = false, }: UseResetIOUTypeParams): (newIOUType: IOURequestType) => void { const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); const [hasOnlyPersonalPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasOnlyPersonalPoliciesSelector}); @@ -91,14 +86,13 @@ function useResetIOUType({ isLoadingSelectedTab, }); - // For the new manual flow, derive participants from the current report (or the global-create fallback) so the - // freshly-rebuilt transaction already includes them. This prevents the embedded confirmation's auto-assign - // useEffect from re-firing on every cleanup and dragging back unrelated draft state (receipt, billable, etc.). + // Derive participants from the current report (or the global-create fallback) so the freshly-rebuilt transaction + // already includes them. This prevents the embedded confirmation's auto-assign useEffect from re-firing on every + // cleanup and dragging back unrelated draft state (receipt, billable, etc.). const {participants: resolvedDefaultParticipants} = useDefaultParticipants({ sourceReport: report, transaction, iouType, - isNewManualExpenseFlowEnabled, }); const defaultParticipants = resolvedDefaultParticipants.length > 0 ? resolvedDefaultParticipants : undefined; diff --git a/src/hooks/useSearchSelector/base.ts b/src/hooks/useSearchSelector/base.ts index d13ba561086c..264e96e0fc7f 100644 --- a/src/hooks/useSearchSelector/base.ts +++ b/src/hooks/useSearchSelector/base.ts @@ -269,8 +269,10 @@ function useSearchSelectorBase({ }; })(); - const computedSearchTerm = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode); + // Trim before deriving the phone/email search value, otherwise a leading/trailing space makes Str.isValidEmail fail + // and the "invite user" option disappears for logins that don't have an account yet. const trimmedSearchInput = debouncedSearchTerm.trim(); + const computedSearchTerm = getSearchValueForPhoneOrEmail(trimmedSearchInput, countryCode); const {options: baseOptions, hasMore} = (() => { if (!areOptionsInitialized) { diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index 5150cb55f154..2806094c5b11 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -42,6 +42,8 @@ type SidebarOrderedReportsActionsContextValue = { clearLHNCache: () => void; setActiveTab: (tab: ValueOf) => void; setStickyReportID: (reportID: string) => void; + /** The report IDs listed under the given Inbox tab, read on demand by bulk tab actions (e.g. "Mark all as read"). */ + getReportIDsForTab: (tab: ValueOf) => string[]; }; type ReportsToDisplayInLHN = Record< @@ -69,6 +71,7 @@ const SidebarOrderedReportsActionsContext = createContext {}, setActiveTab: () => {}, setStickyReportID: () => {}, + getReportIDsForTab: () => [], }); // This file does not compile with React Compiler (render-time ref cache below keeps referential @@ -343,6 +346,17 @@ function SidebarOrderedReportsContextProvider({ // The count shown in each tab's badge, derived from the full "All" set (not the currently filtered view). const inboxTabCounts = useMemo(() => SidebarUtils.getInboxTabCounts(orderedReportIDs, reportsToDisplayInLHN), [orderedReportIDs, reportsToDisplayInLHN]); + // Held in a ref so getReportIDsForTab stays referentially stable (keeping the actions context stable) and only + // filters when a bulk tab action actually asks for a tab's reports, rather than on every LHN update. + const inboxTabSourcesRef = useRef({orderedReportIDs, reportsToDisplayInLHN}); + useEffect(() => { + inboxTabSourcesRef.current = {orderedReportIDs, reportsToDisplayInLHN}; + }, [orderedReportIDs, reportsToDisplayInLHN]); + const getReportIDsForTab = useCallback( + (tab: ValueOf) => SidebarUtils.filterReportsForInboxTab(inboxTabSourcesRef.current.orderedReportIDs, inboxTabSourcesRef.current.reportsToDisplayInLHN, tab), + [], + ); + // Get the actual reports based on the filtered IDs const getOrderedReports = useCallback( (reportIDs: string[]): OnyxTypes.Report[] => { @@ -437,7 +451,10 @@ function SidebarOrderedReportsContextProvider({ reportsToDisplayInLHN, ]); - const actionsValue: SidebarOrderedReportsActionsContextValue = useMemo(() => ({clearLHNCache, setActiveTab, setStickyReportID}), [clearLHNCache, setActiveTab, setStickyReportID]); + const actionsValue: SidebarOrderedReportsActionsContextValue = useMemo( + () => ({clearLHNCache, setActiveTab, setStickyReportID, getReportIDsForTab}), + [clearLHNCache, setActiveTab, setStickyReportID, getReportIDsForTab], + ); return ( diff --git a/src/hooks/useTransactionInlineEdit.ts b/src/hooks/useTransactionInlineEdit.ts index ebbb6cc4525d..dacd2da9e2f9 100644 --- a/src/hooks/useTransactionInlineEdit.ts +++ b/src/hooks/useTransactionInlineEdit.ts @@ -38,6 +38,7 @@ import useDistanceRateOriginalPolicy from './useDistanceRateOriginalPolicy'; import {useLiveDuplicateTransactionsAndViolations} from './useDuplicateTransactionsAndViolations'; import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; import usePersonalPolicy from './usePersonalPolicy'; import usePolicyForMovingExpenses from './usePolicyForMovingExpenses'; import usePolicyForTransaction from './usePolicyForTransaction'; @@ -151,6 +152,7 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [session] = useOnyx(ONYXKEYS.SESSION); const [betas] = useOnyx(ONYXKEYS.BETAS); + const {isBetaEnabled} = usePermissions(); // Scoped transaction/violation collections (the edited transaction plus any duplicates) are read here and // passed into the pure edit actions, which need them to resolve duplicate-transaction violations. This mirrors @@ -219,6 +221,7 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use transactions: duplicateTransactions, transactionViolations: duplicateTransactionViolations, betas, + isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), introSelected, currentUserAccountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserEmail: session?.email ?? '', diff --git a/src/hooks/useUnreadMarker.ts b/src/hooks/useUnreadMarker.ts index 6bc23fc6bad1..efc377dc8634 100644 --- a/src/hooks/useUnreadMarker.ts +++ b/src/hooks/useUnreadMarker.ts @@ -3,6 +3,7 @@ import Visibility from '@libs/Visibility'; import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -34,6 +35,10 @@ type UseUnreadMarkerParams = { /** Whether report actions have loaded at least once; once true, the pagination anchor is ignored in favor of the scan */ hasOnceLoadedReportActions: boolean; + + /** Concierge hidden-history boundary: actions created before this were revealed/loaded from history, + * not received live, so they are never treated as read-on-arrival */ + newMessageBoundaryTime?: string | null; }; type UseUnreadMarkerResult = { @@ -53,6 +58,7 @@ function useUnreadMarker({ oldestUnreadReportActionID, isScrolledOverThreshold, hasOnceLoadedReportActions, + newMessageBoundaryTime, }: UseUnreadMarkerParams): UseUnreadMarkerResult { const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const isAnonymousUser = useIsAnonymousUser(); @@ -122,6 +128,7 @@ function useUnreadMarker({ isAnonymousUser, prevUnreadMarkerReportActionID, hasWindowFocus: Visibility.hasFocus(), + newMessageBoundaryTime, }); // Pagination is anchored to the oldest unread on first open; that anchor does not change when the user // marks read or unread, or when messages are deleted. Prefer the scan when it does not match that stale id. @@ -132,11 +139,16 @@ function useUnreadMarker({ setPrevUnreadMarkerReportActionID(unreadMarkerReportActionID); } - // When the user reads a new message as it is received, push unreadMarkerTime down to the - // latest action's timestamp so new incoming actions display over those new messages instead of - // sticking to the initial lastReadTime. - const mostRecentReportActionCreated = sortedVisibleReportActions.at(0)?.created ?? ''; - if (!isAnonymousUser && !unreadMarkerReportActionID && mostRecentReportActionCreated > unreadMarkerTime) { + // When the user reads a new message as it arrives, advance the watermark so that only actions + // arriving after it count as unread. Only push when the newest visible `created` has advanced: + // a bulk history reveal (Concierge "Show history") also scans to a null marker, and pushing then + // would move the watermark past the unread message and permanently hide the New divider. The + // synthetic greeting is not a valid push target either, because its `created` tracks + // report.lastReadTime and would drag the watermark to "now". + const newestVisibleReportActionCreated = sortedVisibleReportActions.at(0)?.created ?? ''; + const prevNewestVisibleReportActionCreated = usePrevious(newestVisibleReportActionCreated); + const mostRecentReportActionCreated = sortedVisibleReportActions.find((action) => action.reportActionID !== CONST.CONCIERGE_GREETING_ACTION_ID)?.created ?? ''; + if (!isAnonymousUser && !unreadMarkerReportActionID && mostRecentReportActionCreated > unreadMarkerTime && newestVisibleReportActionCreated > prevNewestVisibleReportActionCreated) { setUnreadMarkerTime(mostRecentReportActionCreated); } diff --git a/src/languages/de.ts b/src/languages/de.ts index 840d2e53cd4e..64369fbe6d64 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -465,6 +465,9 @@ const translations: TranslationDeepObject = { none: 'Keine', unstableInternetConnection: 'Instabile Internetverbindung. Bitte überprüfe dein Netzwerk und versuche es erneut.', enableGlobalReimbursements: 'Globale Rückerstattungen aktivieren', + corpayPayModalTitle: 'Bericht bezahlen', + corpayPayModalPrompt: + 'Diese einreichende Person hat ein Bankkonto in einer anderen Währung als USD. Aktiviere globale Rückerstattungen, um den Bericht zu bezahlen, oder bitte sie, ein USD-Bankkonto hinzuzufügen.', purchaseAmount: 'Kaufbetrag', originalAmount: 'Ursprünglicher Betrag', frequency: 'Häufigkeit', @@ -2296,6 +2299,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: 'Hervorgehobene Spannen-Namen', sentryHighlightedSpanOpsPlaceholder: 'ui.interaktion.klick, navigation, ui.laden', showBranchNameInTitle: 'Branch-Namen im Browser-Titel anzeigen', + betaOverrides: 'Beta-Überschreibungen', + betaOverridesDescription: + 'Überschreibungen gelten nur für dieses Gerät und wirken sich nur auf Frontend-Prüfungen aus. Eine Beta behält eine Überschreibung nur so lange, wie sie von den Betas Ihres Kontos abweicht; schalten Sie sie zurück, wird die Überschreibung entfernt. „Alle Überschreibungen zurücksetzen“ stellt die Werte Ihres Kontos wieder her. Einige Betas werden zusätzlich vom Backend gesteuert und können daher weiterhin auf Anfrageebene fehlschlagen.', + resetAllOverrides: 'Alle Überschreibungen zurücksetzen', + overridden: 'Überschrieben', qaAuth: 'QA-Authentifizierung (Cloudflare)', qaAuthRunProbe: 'Test ausführen', qaAuthSession: 'QA-Authentifizierungssitzung', @@ -3160,6 +3168,7 @@ ${amount} für ${merchant} – ${date}`, unread: 'Ungelesen', markAllAsRead: 'Alle als gelesen markieren', markAllAsReadConfirmationPrompt: 'Möchtest du wirklich alle Chats als gelesen markieren?', + markAllTodosAsReadConfirmationPrompt: 'Möchtest du wirklich alle offenen Chats als gelesen markieren?', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, @@ -10942,6 +10951,7 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, notVerified: 'Nicht verifiziert', retry: 'Wiederholen', requestSent: 'Anfrage gesendet', + requestAccessError: 'Wir konnten deine Anfrage nicht senden. Bitte versuche es erneut.', verifyDomain: { title: 'Domain bestätigen', beforeProceeding: ({domainName}: {domainName: string}) => @@ -11002,13 +11012,12 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, setMetadataGenericError: 'SAML-Metadaten konnten nicht festgelegt werden', }, accessRestricted: { - title: 'Zugriff eingeschränkt', - subtitle: (domainName: string) => - `Bitte bestätigen Sie sich als autorisierte/r Firmenadministrator/in für ${domainName}, wenn Sie die Kontrolle über Folgendes benötigen:`, - companyCardManagement: 'Firmenkartenverwaltung', - accountCreationAndDeletion: 'Kontoerstellung und -löschung', - workspaceCreation: 'Bereichserstellung', - samlSSO: 'SAML-SSO', + headerTitle: 'Zugriff eingeschränkt', + title: 'Verifizierung erforderlich', + description: (domainName: string) => + `Bitte bestätige dich als autorisierte/r Firmenadministrator/in für ${domainName} oder fordere Zugriff von bestehenden Administratoren an.`, + requestAdminAccess: 'Admin-Zugriff anfordern', + verifyYourself: 'Verifiziere dich', }, addDomain: { title: 'Domain hinzufügen', @@ -11022,7 +11031,6 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, title: 'Domain bereits eingerichtet. Zugriff anfragen?', description: 'Jemand hat diese Domain bereits in Expensify eingerichtet. Möchtest du Administratorzugriff anfragen?', requestAccess: 'Admin-Zugriff anfragen', - requestAccessError: 'Wir konnten deine Anfrage nicht senden. Bitte versuche es erneut.', }, domainAdded: { title: 'Domain hinzugefügt', diff --git a/src/languages/el.ts b/src/languages/el.ts index ecc1ea5eeff0..9761562bca1f 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -465,6 +465,9 @@ const translations: TranslationDeepObject = { none: 'Κανένα', unstableInternetConnection: 'Η σύνδεση στο διαδίκτυο είναι ασταθής. Ελέγξτε το δίκτυό σας και προσπαθήστε ξανά.', enableGlobalReimbursements: 'Ενεργοποίηση διεθνών αποζημιώσεων', + corpayPayModalTitle: 'Πληρωμή αναφοράς', + corpayPayModalPrompt: + 'Αυτός ο υποβάλλων έχει τραπεζικό λογαριασμό που δεν είναι σε USD. Ενεργοποιήστε τις διεθνείς αποζημιώσεις για να πληρώσετε την αναφορά, ή ζητήστε του να προσθέσει τραπεζικό λογαριασμό σε USD.', purchaseAmount: 'Ποσό αγοράς', originalAmount: 'Αρχικό ποσό', frequency: 'Συχνότητα', @@ -2338,6 +2341,11 @@ const translations: TranslationDeepObject = { usingImportedState: 'Χρησιμοποιείτε εισαγόμενη κατάσταση. Πατήστε εδώ για να την καθαρίσετε.', debugMode: 'Λειτουργία αποσφαλμάτωσης', showBranchNameInTitle: 'Εμφάνιση ονόματος κλάδου στον τίτλο του προγράμματος περιήγησης', + betaOverrides: 'Παρακάμψεις beta', + betaOverridesDescription: + 'Οι παρακάμψεις ισχύουν μόνο για αυτή τη συσκευή και επηρεάζουν μόνο τους ελέγχους του frontend. Μια beta διατηρεί μια παράκαμψη μόνο όσο διαφέρει από τις beta του λογαριασμού σας, οπότε αν την αλλάξετε ξανά η παράκαμψη αφαιρείται. Η «Επαναφορά όλων των παρακάμψεων» επαναφέρει τις τιμές του λογαριασμού σας. Ορισμένες beta ελέγχονται και από το backend, οπότε μπορεί να αποτύχουν σε επίπεδο αιτήματος.', + resetAllOverrides: 'Επαναφορά όλων των παρακάμψεων', + overridden: 'Παρακάμφθηκε', invalidFile: 'Μη έγκυρο αρχείο', invalidFileDescription: 'Το αρχείο που προσπαθείτε να εισαγάγετε δεν είναι έγκυρο. Παρακαλούμε δοκιμάστε ξανά.', invalidateWithDelay: 'Ακύρωση με καθυστέρηση', @@ -3214,6 +3222,7 @@ ${amount} για ${merchant} - ${date}`, unread: 'Μη αναγνωσμένα', markAllAsRead: 'Επισήμανση όλων ως αναγνωσμένων', markAllAsReadConfirmationPrompt: 'Είστε βέβαιοι ότι θέλετε να επισημάνετε όλες τις συνομιλίες ως αναγνωσμένες;', + markAllTodosAsReadConfirmationPrompt: 'Είστε βέβαιοι ότι θέλετε να επισημάνετε ως αναγνωσμένες όλες τις συνομιλίες σε εκκρεμότητα;', }, reportDetailsPage: { goToRoom: 'Μετάβαση στο δωμάτιο', @@ -11134,6 +11143,7 @@ ${reportName}`, notVerified: 'Μη επαληθευμένο', retry: 'Προσπαθήστε ξανά', requestSent: 'Το αίτημα εστάλη', + requestAccessError: 'Δεν μπορέσαμε να στείλουμε το αίτημά σου. Παρακαλώ δοκίμασε ξανά.', verifyDomain: { title: 'Επαλήθευση τομέα', beforeProceeding: ({domainName}: {domainName: string}) => `Πριν συνεχίσετε, επιβεβαιώστε ότι σας ανήκει το ${domainName} ενημερώνοντας τις ρυθμίσεις DNS του.`, @@ -11193,13 +11203,12 @@ ${reportName}`, setMetadataGenericError: 'Δεν ήταν δυνατός ο ορισμός των μεταδεδομένων SAML', }, accessRestricted: { - title: 'Περιορισμένη πρόσβαση', - subtitle: (domainName: string) => - `Παρακαλούμε επιβεβαιώστε ότι είστε εξουσιοδοτημένος διαχειριστής της εταιρείας για το ${domainName} εάν χρειάζεστε έλεγχο στα εξής:`, - companyCardManagement: 'Διαχείριση εταιρικής κάρτας', - accountCreationAndDeletion: 'Δημιουργία και διαγραφή λογαριασμού', - workspaceCreation: 'Δημιουργία χώρου εργασίας', - samlSSO: 'SAML SSO', + headerTitle: 'Περιορισμένη πρόσβαση', + title: 'Απαιτείται επαλήθευση', + description: (domainName: string) => + `Παρακαλούμε επιβεβαιώστε ότι είστε εξουσιοδοτημένος διαχειριστής εταιρείας για το ${domainName} ή ζητήστε πρόσβαση από τους υπάρχοντες διαχειριστές.`, + requestAdminAccess: 'Αίτημα πρόσβασης διαχειριστή', + verifyYourself: 'Επιβεβαιώστε την ταυτότητά σας', }, addDomain: { title: 'Προσθήκη τομέα', @@ -11213,7 +11222,6 @@ ${reportName}`, title: 'Ο τομέας έχει ήδη ρυθμιστεί. Αίτημα πρόσβασης;', description: 'Κάποιος έχει ήδη ρυθμίσει αυτόν τον τομέα στο Expensify. Θέλετε να ζητήσετε πρόσβαση διαχειριστή;', requestAccess: 'Αίτημα πρόσβασης διαχειριστή', - requestAccessError: 'Δεν μπορέσαμε να στείλουμε το αίτημά σου. Παρακαλώ δοκίμασε ξανά.', }, domainAdded: { title: 'Το domain προστέθηκε', diff --git a/src/languages/en.ts b/src/languages/en.ts index 1c8355c888a7..3411b8da2b85 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -511,6 +511,8 @@ const translations = { none: 'None', unstableInternetConnection: 'Unstable internet connection. Please check your network and try again.', enableGlobalReimbursements: 'Enable Global Reimbursements', + corpayPayModalTitle: 'Pay report', + corpayPayModalPrompt: 'This submitter has a non-USD bank account. Enable global reimbursements to pay the report, or ask them to add a USD bank account.', purchaseAmount: 'Purchase amount', originalAmount: 'Original amount', frequency: 'Frequency', @@ -2397,6 +2399,11 @@ const translations = { usingImportedState: 'You are using imported state. Press here to clear it.', debugMode: 'Debug mode', showBranchNameInTitle: 'Show branch name in browser title', + betaOverrides: 'Beta overrides', + betaOverridesDescription: + 'Overrides are local to this device and only affect frontend checks. A beta keeps an override only while it differs from your account betas, so toggling it back removes the override. “Reset all overrides” restores the values from your account. Some betas are gated on the backend too, so those can still fail at the request level.', + resetAllOverrides: 'Reset all overrides', + overridden: 'Overridden', invalidFile: 'Invalid file', invalidFileDescription: 'The file you are trying to import is not valid. Please try again.', invalidateWithDelay: 'Invalidate with delay', @@ -3259,6 +3266,7 @@ const translations = { unread: 'Unread', markAllAsRead: 'Mark all as read', markAllAsReadConfirmationPrompt: 'Are you sure you want to mark all chats as read?', + markAllTodosAsReadConfirmationPrompt: 'Are you sure you want to mark all to-do chats as read?', }, reportDetailsPage: { goToRoom: 'Go to room', @@ -11113,6 +11121,7 @@ const translations = { notVerified: 'Not verified', retry: 'Retry', requestSent: 'Request sent', + requestAccessError: "We couldn't send your request. Please try again.", verifyDomain: { title: 'Verify domain', beforeProceeding: ({domainName}: {domainName: string}) => `Before proceeding, verify that you own ${domainName} by updating its DNS settings.`, @@ -11172,12 +11181,12 @@ const translations = { setMetadataGenericError: "Couldn't set SAML MetaData", }, accessRestricted: { - title: 'Access restricted', - subtitle: (domainName: string) => `Please verify yourself as an authorized company administrator for ${domainName} if you need control over:`, - companyCardManagement: 'Company card management', - accountCreationAndDeletion: 'Account creation and deletion', - workspaceCreation: 'Workspace creation', - samlSSO: 'SAML SSO', + headerTitle: 'Access restricted', + title: 'Verification required', + description: (domainName: string) => + `Please verify yourself as an authorized company administrator for ${domainName} or request access from existing admins.`, + requestAdminAccess: 'Request admin access', + verifyYourself: 'Verify yourself', }, addDomain: { title: 'Add domain', @@ -11191,7 +11200,6 @@ const translations = { title: 'Domain already set up. Request access?', description: 'Someone already set this domain up in Expensify. Want to request admin access?', requestAccess: 'Ask for admin access', - requestAccessError: "We couldn't send your request. Please try again.", }, domainAdded: { title: 'Domain added', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2c72f3b4cdb1..c23f6ba2d1bf 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -453,6 +453,9 @@ const translations: TranslationDeepObject = { none: 'Ninguno', unstableInternetConnection: 'Conexión a internet inestable. Por favor, revisa tu red e inténtalo de nuevo.', enableGlobalReimbursements: 'Habilitar Reembolsos Globales', + corpayPayModalTitle: 'Pagar informe', + corpayPayModalPrompt: + 'Esta persona que envió el informe tiene una cuenta bancaria que no es en USD. Habilita los reembolsos globales para pagar el informe, o pídele que añada una cuenta bancaria en USD.', purchaseAmount: 'Importe de compra', originalAmount: 'Importe original', frequency: 'Frecuencia', @@ -2219,6 +2222,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: 'Nombres de spans resaltados', sentryHighlightedSpanOpsPlaceholder: 'ui.interaction.click, navigation, ui.load', showBranchNameInTitle: 'Mostrar nombre de rama en el título del navegador', + betaOverrides: 'Anulaciones de betas', + betaOverridesDescription: + 'Las anulaciones solo se aplican a este dispositivo y solo afectan a las comprobaciones del frontend. Una beta conserva una anulación solo mientras difiere de las betas de tu cuenta, por lo que al volver a cambiarla se elimina la anulación. «Restablecer todas las anulaciones» recupera los valores de tu cuenta. Algunas betas también están controladas por el backend, así que pueden fallar a nivel de solicitud.', + resetAllOverrides: 'Restablecer todas las anulaciones', + overridden: 'Anulada', qaAuth: 'Autenticación QA (Cloudflare)', qaAuthRunProbe: 'Ejecutar prueba', qaAuthSession: 'Sesión de autenticación QA', @@ -3068,6 +3076,7 @@ ${amount} para ${merchant} - ${date}`, unread: 'No leído', markAllAsRead: 'Marcar todo como leído', markAllAsReadConfirmationPrompt: '¿Seguro que quieres marcar todos los chats como leídos?', + markAllTodosAsReadConfirmationPrompt: '¿Seguro que quieres marcar como leídos todos los chats pendientes?', }, reportDetailsPage: { goToRoom: 'Ir a la sala', @@ -11149,6 +11158,7 @@ ${reportName}`, notVerified: 'No verificado', retry: 'Reintentar', requestSent: 'Solicitud enviada', + requestAccessError: 'No pudimos enviar tu solicitud. Por favor, inténtalo de nuevo.', verifyDomain: { title: 'Verificar dominio', beforeProceeding: ({domainName}: {domainName: string}) => @@ -11210,12 +11220,12 @@ ${reportName}`, setMetadataGenericError: 'No se pudieron establecer los metadatos de SAML', }, accessRestricted: { - title: 'Acceso restringido', - subtitle: (domainName: string) => `Por favor, verifícate como un administrador autorizado de la empresa para ${domainName} si necesitas control sobre:`, - companyCardManagement: 'Gestión de tarjetas de la empresa', - accountCreationAndDeletion: 'Creación y eliminación de cuentas', - workspaceCreation: 'Creación de espacios de trabajo', - samlSSO: 'SAML SSO', + headerTitle: 'Acceso restringido', + title: 'Verificación requerida', + description: (domainName: string) => + `Por favor, verifícate como administrador autorizado de la empresa para ${domainName} o solicita acceso a los administradores existentes.`, + requestAdminAccess: 'Solicitar acceso de administrador', + verifyYourself: 'Verifícate', }, addDomain: { title: 'Añadir dominio', @@ -11229,7 +11239,6 @@ ${reportName}`, title: 'Dominio ya configurado. ¿Solicitar acceso?', description: 'Alguien ya configuró este dominio en Expensify. ¿Quieres solicitar acceso de administrador?', requestAccess: 'Solicitar acceso de administrador', - requestAccessError: 'No pudimos enviar tu solicitud. Por favor, inténtalo de nuevo.', }, domainAdded: { title: 'Dominio añadido', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 7e38f9c8595a..6a8fd7075903 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -465,6 +465,9 @@ const translations: TranslationDeepObject = { none: 'Aucun', unstableInternetConnection: 'Connexion Internet instable. Veuillez vérifier votre réseau et réessayer.', enableGlobalReimbursements: 'Activer les remboursements globaux', + corpayPayModalTitle: 'Payer la note de frais', + corpayPayModalPrompt: + "Cette personne a un compte bancaire qui n'est pas en USD. Activez les remboursements globaux pour payer la note de frais, ou demandez-lui d'ajouter un compte bancaire en USD.", purchaseAmount: "Montant de l'achat", originalAmount: 'Montant initial', frequency: 'Fréquence', @@ -2302,6 +2305,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: 'Noms de segments surlignés', sentryHighlightedSpanOpsPlaceholder: 'ui.interaction.clic, navigation, ui.chargement', showBranchNameInTitle: 'Afficher le nom de la branche dans le titre du navigateur', + betaOverrides: 'Remplacements de bêtas', + betaOverridesDescription: + "Les remplacements ne s'appliquent qu'à cet appareil et n'affectent que les vérifications côté frontend. Une bêta ne conserve un remplacement que tant qu'il diffère des bêtas de votre compte ; la rebasculer supprime donc le remplacement. « Réinitialiser tous les remplacements » rétablit les valeurs de votre compte. Certaines bêtas sont aussi contrôlées par le backend et peuvent donc échouer au niveau de la requête.", + resetAllOverrides: 'Réinitialiser tous les remplacements', + overridden: 'Remplacée', qaAuth: 'Authentification QA (Cloudflare)', qaAuthRunProbe: 'Lancer le test', qaAuthSession: 'Session d’authentification QA', @@ -3167,6 +3175,7 @@ ${amount} pour ${merchant} - ${date}`, unread: 'Non lu', markAllAsRead: 'Tout marquer comme lu', markAllAsReadConfirmationPrompt: 'Voulez-vous vraiment marquer toutes les discussions comme lues ?', + markAllTodosAsReadConfirmationPrompt: 'Voulez-vous vraiment marquer comme lues toutes les discussions à traiter ?', }, reportDetailsPage: { inWorkspace: (policyName: string) => `dans ${policyName}`, @@ -10963,6 +10972,7 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, notVerified: 'Non vérifié', retry: 'Réessayer', requestSent: 'Demande envoyée', + requestAccessError: "Nous n'avons pas pu envoyer votre demande. Veuillez réessayer.", verifyDomain: { title: 'Vérifier le domaine', beforeProceeding: ({domainName}: {domainName: string}) => @@ -11023,13 +11033,12 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, setMetadataGenericError: 'Impossible de définir les métadonnées SAML', }, accessRestricted: { - title: 'Accès restreint', - subtitle: (domainName: string) => - `Veuillez vous vérifier en tant qu’administrateur d’entreprise autorisé pour ${domainName} si vous avez besoin de contrôle sur :`, - companyCardManagement: 'Gestion des cartes d’entreprise', - accountCreationAndDeletion: 'Création et suppression de compte', - workspaceCreation: 'Création d’espace de travail', - samlSSO: 'SSO SAML', + headerTitle: 'Accès restreint', + title: 'Vérification requise', + description: (domainName: string) => + `Veuillez vous vérifier en tant qu’administrateur d’entreprise autorisé pour ${domainName} ou demander l’accès aux administrateurs existants.`, + requestAdminAccess: 'Demander l’accès administrateur', + verifyYourself: 'Vérifiez-vous', }, addDomain: { title: 'Ajouter un domaine', @@ -11043,7 +11052,6 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, title: "Domaine déjà configuré. Demander l'accès ?", description: "Quelqu'un a déjà configuré ce domaine dans Expensify. Voulez-vous demander l'accès administrateur ?", requestAccess: "Demander l'accès administrateur", - requestAccessError: "Nous n'avons pas pu envoyer votre demande. Veuillez réessayer.", }, domainAdded: { title: 'Domaine ajouté', diff --git a/src/languages/it.ts b/src/languages/it.ts index 965a11e7e9d0..821601675e2e 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -465,6 +465,8 @@ const translations: TranslationDeepObject = { none: 'Nessuno', unstableInternetConnection: 'Connessione Internet instabile. Controlla la rete e riprova.', enableGlobalReimbursements: 'Abilita rimborsi globali', + corpayPayModalTitle: 'Paga nota spese', + corpayPayModalPrompt: 'Questa persona ha un conto bancario non in USD. Abilita i rimborsi globali per pagare la nota spese, o chiedi di aggiungere un conto bancario in USD.', purchaseAmount: "Importo dell'acquisto", originalAmount: 'Importo originale', frequency: 'Frequenza', @@ -2293,6 +2295,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: 'Nomi degli intervalli evidenziati', sentryHighlightedSpanOpsPlaceholder: 'ui.interaction.click, navigazione, ui.load', showBranchNameInTitle: 'Mostra nome del ramo nel titolo del browser', + betaOverrides: 'Sostituzioni delle beta', + betaOverridesDescription: + 'Le sostituzioni si applicano solo a questo dispositivo e influiscono solo sui controlli del frontend. Una beta mantiene una sostituzione solo finché differisce dalle beta del tuo account, quindi riportandola indietro la sostituzione viene rimossa. «Reimposta tutte le sostituzioni» ripristina i valori del tuo account. Alcune beta sono controllate anche dal backend, quindi possono comunque fallire a livello di richiesta.', + resetAllOverrides: 'Reimposta tutte le sostituzioni', + overridden: 'Sostituita', qaAuth: 'Autenticazione QA (Cloudflare)', qaAuthRunProbe: 'Esegui probe', qaAuthSession: 'Sessione di autenticazione QA', @@ -3151,6 +3158,7 @@ ${amount} per ${merchant} - ${date}`, unread: 'Non letti', markAllAsRead: 'Segna tutto come letto', markAllAsReadConfirmationPrompt: 'Vuoi davvero segnare tutte le chat come lette?', + markAllTodosAsReadConfirmationPrompt: 'Vuoi davvero segnare come lette tutte le chat da gestire?', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, @@ -10897,6 +10905,7 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, notVerified: 'Non verificato', retry: 'Riprova', requestSent: 'Richiesta inviata', + requestAccessError: 'Non siamo riusciti a inviare la tua richiesta. Riprova.', verifyDomain: { title: 'Verifica dominio', beforeProceeding: ({domainName}: {domainName: string}) => @@ -10957,12 +10966,12 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, setMetadataGenericError: 'Impossibile impostare i metadati SAML', }, accessRestricted: { - title: 'Accesso limitato', - subtitle: (domainName: string) => `Verificati come amministratore autorizzato dell’azienda per ${domainName} se hai bisogno di controllare:`, - companyCardManagement: 'Gestione carte aziendali', - accountCreationAndDeletion: 'Creazione ed eliminazione dell’account', - workspaceCreation: 'Creazione dello spazio di lavoro', - samlSSO: 'SSO SAML', + headerTitle: 'Accesso limitato', + title: 'Verifica richiesta', + description: (domainName: string) => + `Verificati come amministratore autorizzato dell’azienda per ${domainName} oppure richiedi l’accesso agli amministratori esistenti.`, + requestAdminAccess: 'Richiedi accesso amministratore', + verifyYourself: 'Verificati', }, addDomain: { title: 'Aggiungi dominio', @@ -10976,7 +10985,6 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, title: "Dominio già configurato. Vuoi richiedere l'accesso?", description: "Qualcuno ha già configurato questo dominio in Expensify. Vuoi richiedere l'accesso come amministratore?", requestAccess: "Richiedi l'accesso come amministratore", - requestAccessError: 'Non siamo riusciti a inviare la tua richiesta. Riprova.', }, domainAdded: { title: 'Dominio aggiunto', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 563df448991a..91ac703f21f7 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -464,6 +464,8 @@ const translations: TranslationDeepObject = { none: 'なし', unstableInternetConnection: 'インターネット接続が不安定です。ネットワークを確認して、もう一度お試しください。', enableGlobalReimbursements: 'グローバル払い戻しを有効にする', + corpayPayModalTitle: 'レポートを支払う', + corpayPayModalPrompt: 'この提出者は非USDの銀行口座を使用しています。レポートを支払うにはグローバル払い戻しを有効にするか、USDの銀行口座を追加するよう依頼してください。', purchaseAmount: '購入金額', originalAmount: '元の金額', frequency: '頻度', @@ -2274,6 +2276,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: '強調表示されたスパン名', sentryHighlightedSpanOpsPlaceholder: 'ui.interaction.click、ナビゲーション、ui.load', showBranchNameInTitle: 'ブラウザのタイトルにブランチ名を表示', + betaOverrides: 'ベータのオーバーライド', + betaOverridesDescription: + 'オーバーライドはこの端末にのみ適用され、フロントエンドのチェックにのみ影響します。ベータはアカウントのベータと異なる間だけオーバーライドを保持するため、元に戻すとオーバーライドは削除されます。「すべてのオーバーライドをリセット」はアカウントの値に戻します。一部のベータはバックエンドでも制御されているため、リクエストレベルで失敗する場合があります。', + resetAllOverrides: 'すべてのオーバーライドをリセット', + overridden: 'オーバーライド済み', qaAuth: 'QA 認証(Cloudflare)', qaAuthRunProbe: 'プローブを実行', qaAuthSession: 'QA 認証セッション', @@ -3119,7 +3126,14 @@ ${date} の ${merchant} への ${amount}`, prompt: (priorityModePageUrl: string) => `未読のチャットや対応が必要なチャットだけを表示して、状況を常に把握できるようにしましょう。いつでも設定で変更できます。`, }, - inboxTabs: {all: 'すべて', todo: 'To-do リスト', unread: '未読', markAllAsRead: 'すべて既読にする', markAllAsReadConfirmationPrompt: 'すべてのチャットを既読にしてもよろしいですか?'}, + inboxTabs: { + all: 'すべて', + todo: 'To-do リスト', + unread: '未読', + markAllAsRead: 'すべて既読にする', + markAllAsReadConfirmationPrompt: 'すべてのチャットを既読にしてもよろしいですか?', + markAllTodosAsReadConfirmationPrompt: 'To-do のチャットをすべて既読にしてもよろしいですか?', + }, reportDetailsPage: { inWorkspace: (policyName: string) => `${policyName} 内`, generatingPDF: 'PDFを生成', @@ -10745,6 +10759,7 @@ ${reportName}`, notVerified: '未確認', retry: '再試行', requestSent: 'リクエストを送信しました', + requestAccessError: 'リクエストを送信できませんでした。もう一度お試しください。', verifyDomain: { title: 'ドメインを確認', beforeProceeding: ({domainName}: {domainName: string}) => `続行する前に、DNS 設定を更新して、${domainName} の所有者であることを確認してください。`, @@ -10804,12 +10819,12 @@ ${reportName}`, setMetadataGenericError: 'SAMLメタデータを設定できませんでした', }, accessRestricted: { - title: 'アクセスが制限されています', - subtitle: (domainName: string) => `以下の管理が必要な場合は、${domainName} の承認済み会社管理者としてご本人確認を行ってください。`, - companyCardManagement: 'コーポレートカード管理', - accountCreationAndDeletion: 'アカウントの作成と削除', - workspaceCreation: 'ワークスペースの作成', - samlSSO: 'SAML SSO', + headerTitle: 'アクセスが制限されています', + title: '確認が必要です', + description: (domainName: string) => + `${domainName} の承認済み会社管理者としてご本人確認を行うか、既存の管理者にアクセスをリクエストしてください。`, + requestAdminAccess: '管理者アクセスをリクエスト', + verifyYourself: '本人確認を行う', }, addDomain: { title: 'ドメインを追加', @@ -10823,7 +10838,6 @@ ${reportName}`, title: 'このドメインはすでに設定されています。アクセスをリクエストしますか?', description: '誰かがこのドメインをExpensifyにすでに設定しています。管理者アクセスをリクエストしますか?', requestAccess: '管理者アクセスをリクエスト', - requestAccessError: 'リクエストを送信できませんでした。もう一度お試しください。', }, domainAdded: { title: 'ドメインを追加しました', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index db30114cff11..d9d5feaf84d7 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -464,6 +464,9 @@ const translations: TranslationDeepObject = { none: 'Geen', unstableInternetConnection: 'Onstabiele internetverbinding. Controleer je netwerk en probeer het opnieuw.', enableGlobalReimbursements: 'Wereldwijde terugbetalingen inschakelen', + corpayPayModalTitle: 'Rapport betalen', + corpayPayModalPrompt: + 'Deze indiener heeft een bankrekening die niet in USD is. Schakel wereldwijde terugbetalingen in om het rapport te betalen, of vraag hen een USD-bankrekening toe te voegen.', purchaseAmount: 'Aankoopsbedrag', originalAmount: 'Oorspronkelijk bedrag', frequency: 'Frequentie', @@ -2288,6 +2291,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: 'Gemarkeerde span-namen', sentryHighlightedSpanOpsPlaceholder: 'ui.interactie.klik, navigatie, ui.laden', showBranchNameInTitle: 'Toon branchnaam in titel van browser', + betaOverrides: 'Bèta-overrides', + betaOverridesDescription: + "Overrides gelden alleen voor dit apparaat en zijn alleen van invloed op frontendcontroles. Een bèta houdt een override alleen zolang die afwijkt van de bèta's van je account, dus door hem terug te zetten wordt de override verwijderd. 'Alle overrides resetten' herstelt de waarden van je account. Sommige bèta's worden ook door de backend bepaald en kunnen dus alsnog mislukken op verzoekniveau.", + resetAllOverrides: 'Alle overrides resetten', + overridden: 'Overschreven', qaAuth: 'QA-authenticatie (Cloudflare)', qaAuthRunProbe: 'Probe uitvoeren', qaAuthSession: 'QA-authsessie', @@ -3152,6 +3160,7 @@ ${amount} voor ${merchant} - ${date}`, unread: 'Ongelezen', markAllAsRead: 'Alles als gelezen markeren', markAllAsReadConfirmationPrompt: 'Weet je zeker dat je alle chats als gelezen wilt markeren?', + markAllTodosAsReadConfirmationPrompt: 'Weet je zeker dat je alle openstaande chats als gelezen wilt markeren?', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, @@ -10862,6 +10871,7 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, notVerified: 'Niet geverifieerd', retry: 'Opnieuw proberen', requestSent: 'Aanvraag verzonden', + requestAccessError: 'We konden je aanvraag niet verzenden. Probeer het opnieuw.', verifyDomain: { title: 'Domein verifiëren', beforeProceeding: ({domainName}: {domainName: string}) => @@ -10922,12 +10932,12 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, setMetadataGenericError: 'Kon SAML-metadata niet instellen', }, accessRestricted: { - title: 'Toegang beperkt', - subtitle: (domainName: string) => `Verifieer jezelf als bevoegde bedrijfsbeheerder voor ${domainName} als je beheer nodig hebt over:`, - companyCardManagement: 'Beheer van bedrijfskaarten', - accountCreationAndDeletion: 'Account aanmaken en verwijderen', - workspaceCreation: 'Werkruimte aanmaken', - samlSSO: 'SAML-SSO', + headerTitle: 'Toegang beperkt', + title: 'Verificatie vereist', + description: (domainName: string) => + `Verifieer jezelf als bevoegde bedrijfsbeheerder voor ${domainName} of vraag toegang aan bij bestaande beheerders.`, + requestAdminAccess: 'Beheerderstoegang aanvragen', + verifyYourself: 'Verifieer jezelf', }, addDomain: { title: 'Domein toevoegen', @@ -10941,7 +10951,6 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, title: 'Domein al ingesteld. Toegang aanvragen?', description: 'Iemand heeft dit domein al ingesteld in Expensify. Wil je beheerderstoegang aanvragen?', requestAccess: 'Beheerderstoegang aanvragen', - requestAccessError: 'We konden je aanvraag niet verzenden. Probeer het opnieuw.', }, domainAdded: { title: 'Domein toegevoegd', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 8e9140dd1dfc..1347db8696ef 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -468,6 +468,8 @@ const translations: TranslationDeepObject = { none: 'Brak', unstableInternetConnection: 'Niestabilne połączenie internetowe. Sprawdź swoją sieć i spróbuj ponownie.', enableGlobalReimbursements: 'Włącz globalne zwroty', + corpayPayModalTitle: 'Zapłać raport', + corpayPayModalPrompt: 'Ta osoba zgłaszająca ma konto bankowe inne niż USD. Włącz globalne zwroty, aby zapłacić raport, lub poproś o dodanie konta bankowego w USD.', purchaseAmount: 'Kwota zakupu', originalAmount: 'Kwota pierwotna', frequency: 'Częstotliwość', @@ -2323,6 +2325,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: 'Wyróżnione nazwy zakresów', sentryHighlightedSpanOpsPlaceholder: 'kliknięcie interfejsu, nawigacja, ładowanie interfejsu', showBranchNameInTitle: 'Pokaż nazwę gałęzi w tytule przeglądarki', + betaOverrides: 'Nadpisywanie bet', + betaOverridesDescription: + 'Nadpisywanie działa tylko na tym urządzeniu i tylko po stronie frontendu. Beta zachowuje nadpisanie tylko wtedy, gdy różni się od bet z Twojego konta, więc przełączenie jej z powrotem usuwa nadpisanie. „Zresetuj wszystkie nadpisania” przywraca wartości z Twojego konta. Niektóre bety są też kontrolowane przez backend, więc mogą w pełni nie działać zwłaszcza przy żądaniach wysyłanych do backendu.', + resetAllOverrides: 'Zresetuj wszystkie nadpisania', + overridden: 'Nadpisana', qaAuth: 'Uwierzytelnianie QA (Cloudflare)', qaAuthRunProbe: 'Uruchom test', qaAuthSession: 'Sesja uwierzytelniania QA', @@ -3181,6 +3188,7 @@ ${amount} dla ${merchant} - ${date}`, unread: 'Nieprzeczytane', markAllAsRead: 'Oznacz wszystkie jako przeczytane', markAllAsReadConfirmationPrompt: 'Czy na pewno chcesz oznaczyć wszystkie czaty jako przeczytane?', + markAllTodosAsReadConfirmationPrompt: 'Czy na pewno chcesz oznaczyć wszystkie czaty do zrobienia jako przeczytane?', }, reportDetailsPage: { inWorkspace: (policyName: string) => `w ${policyName}`, @@ -10885,6 +10893,7 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, notVerified: 'Niezweryfikowane', retry: 'Ponów próbę', requestSent: 'Wysłano prośbę', + requestAccessError: 'Nie udało się wysłać Twojej prośby. Spróbuj ponownie.', verifyDomain: { title: 'Zweryfikuj domenę', beforeProceeding: ({domainName}: {domainName: string}) => @@ -10945,12 +10954,12 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, setMetadataGenericError: 'Nie można było ustawić metadanych SAML', }, accessRestricted: { - title: 'Dostęp ograniczony', - subtitle: (domainName: string) => `Zwierzyń się proszę jako upoważniony administrator firmy dla ${domainName}, jeśli potrzebujesz kontroli nad:`, - companyCardManagement: 'Zarządzanie kartami służbowymi', - accountCreationAndDeletion: 'Tworzenie i usuwanie konta', - workspaceCreation: 'Tworzenie przestrzeni roboczej', - samlSSO: 'SSO SAML', + headerTitle: 'Dostęp ograniczony', + title: 'Wymagana weryfikacja', + description: (domainName: string) => + `Zweryfikuj się jako upoważniony administrator firmy dla ${domainName} lub poproś o dostęp obecnych administratorów.`, + requestAdminAccess: 'Poproś o dostęp administratora', + verifyYourself: 'Zweryfikuj się', }, addDomain: { title: 'Dodaj domenę', @@ -10964,7 +10973,6 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, title: 'Domena jest już skonfigurowana. Poprosić o dostęp?', description: 'Ktoś już skonfigurował tę domenę w Expensify. Chcesz poprosić o dostęp administratora?', requestAccess: 'Poproś o dostęp administratora', - requestAccessError: 'Nie udało się wysłać Twojej prośby. Spróbuj ponownie.', }, domainAdded: { title: 'Dodano domenę', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 77e0d34e89ab..f747ae51e755 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -463,6 +463,9 @@ const translations: TranslationDeepObject = { none: 'Nenhum', unstableInternetConnection: 'Conexão de internet instável. Verifique sua rede e tente novamente.', enableGlobalReimbursements: 'Ativar reembolsos globais', + corpayPayModalTitle: 'Pagar relatório', + corpayPayModalPrompt: + 'Esta pessoa que enviou o relatório tem uma conta bancária que não é em USD. Ative os reembolsos globais para pagar o relatório, ou peça para adicionar uma conta bancária em USD.', purchaseAmount: 'Valor da compra', originalAmount: 'Valor original', frequency: 'Frequência', @@ -2282,6 +2285,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: 'Nomes de trechos destacados', sentryHighlightedSpanOpsPlaceholder: 'ui.interaction.click, navegação, ui.carregar', showBranchNameInTitle: 'Mostrar nome da branch no título do navegador', + betaOverrides: 'Substituições de betas', + betaOverridesDescription: + 'As substituições se aplicam apenas a este dispositivo e afetam somente as verificações do frontend. Uma beta mantém uma substituição apenas enquanto ela difere das betas da sua conta, então alternar de volta remove a substituição. “Redefinir todas as substituições” restaura os valores da sua conta. Algumas betas também são controladas pelo backend, então podem falhar no nível da solicitação.', + resetAllOverrides: 'Redefinir todas as substituições', + overridden: 'Substituída', qaAuth: 'Autenticação de QA (Cloudflare)', qaAuthRunProbe: 'Executar sondagem', qaAuthSession: 'Sessão de autenticação de QA', @@ -3142,6 +3150,7 @@ ${amount} para ${merchant} - ${date}`, unread: 'Não lidas', markAllAsRead: 'Marcar tudo como lido', markAllAsReadConfirmationPrompt: 'Tem certeza de que deseja marcar todos os chats como lidos?', + markAllTodosAsReadConfirmationPrompt: 'Tem certeza de que deseja marcar como lidos todos os chats pendentes?', }, reportDetailsPage: { inWorkspace: (policyName: string) => `em ${policyName}`, @@ -10857,6 +10866,7 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, notVerified: 'Não verificado', retry: 'Tentar novamente', requestSent: 'Solicitação enviada', + requestAccessError: 'Não foi possível enviar sua solicitação. Tente novamente.', verifyDomain: { title: 'Verificar domínio', beforeProceeding: ({domainName}: {domainName: string}) => @@ -10917,12 +10927,12 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, setMetadataGenericError: 'Não foi possível definir os metadados SAML', }, accessRestricted: { - title: 'Acesso restrito', - subtitle: (domainName: string) => `Verifique se você é um administrador autorizado da empresa para ${domainName} se precisar de controle sobre:`, - companyCardManagement: 'Gerenciamento de cartão corporativo', - accountCreationAndDeletion: 'Criação e exclusão de conta', - workspaceCreation: 'Criação de workspace', - samlSSO: 'SSO SAML', + headerTitle: 'Acesso restrito', + title: 'Verificação necessária', + description: (domainName: string) => + `Verifique-se como administrador autorizado da empresa para ${domainName} ou solicite acesso aos administradores existentes.`, + requestAdminAccess: 'Solicitar acesso de administrador', + verifyYourself: 'Verifique-se', }, addDomain: { title: 'Adicionar domínio', @@ -10936,7 +10946,6 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, title: 'Domínio já configurado. Solicitar acesso?', description: 'Alguém já configurou este domínio no Expensify. Quer solicitar acesso de administrador?', requestAccess: 'Solicitar acesso de administrador', - requestAccessError: 'Não foi possível enviar sua solicitação. Tente novamente.', }, domainAdded: { title: 'Domínio adicionado', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ab6bb3cbfa8c..d44fdfb3957a 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -460,6 +460,8 @@ const translations: TranslationDeepObject = { none: '无', unstableInternetConnection: '网络连接不稳定。请检查您的网络后重试。', enableGlobalReimbursements: '启用全球报销', + corpayPayModalTitle: '支付报告', + corpayPayModalPrompt: '此提交者使用的是非美元银行账户。请启用全球报销以支付该报告,或请其添加美元银行账户。', purchaseAmount: '购买金额', originalAmount: '原始金额', frequency: '频率', @@ -2215,6 +2217,11 @@ const translations: TranslationDeepObject = { sentryHighlightedSpanOps: '高亮的跨度名称', sentryHighlightedSpanOpsPlaceholder: 'ui.interaction.click,navigation,ui.load', showBranchNameInTitle: '在浏览器标题中显示分支名称', + betaOverrides: 'Beta 覆盖', + betaOverridesDescription: + '覆盖仅适用于此设备,且仅影响前端检查。仅当 Beta 与你账户中的 Beta 不同时,才会保留覆盖,因此切换回原值会移除该覆盖。“重置所有覆盖”会恢复你账户中的值。部分 Beta 同时受后端控制,因此仍可能在请求层面失败。', + resetAllOverrides: '重置所有覆盖', + overridden: '已覆盖', qaAuth: 'QA 认证(Cloudflare)', qaAuthRunProbe: '运行探针', qaAuthSession: 'QA 身份验证会话', @@ -3041,7 +3048,14 @@ ${amount},商户:${merchant} - 日期:${date}`, title: '欢迎使用 #focus 模式!', prompt: (priorityModePageUrl: string) => `通过只查看未读聊天或需要你关注的聊天,随时掌握最新进展。别担心,你可以随时在设置中更改此项。`, }, - inboxTabs: {all: '全部', todo: '待办事项', unread: '未读', markAllAsRead: '全部标记为已读', markAllAsReadConfirmationPrompt: '确定要将所有聊天标记为已读吗?'}, + inboxTabs: { + all: '全部', + todo: '待办事项', + unread: '未读', + markAllAsRead: '全部标记为已读', + markAllAsReadConfirmationPrompt: '确定要将所有聊天标记为已读吗?', + markAllTodosAsReadConfirmationPrompt: '确定要将所有待办事项聊天标记为已读吗?', + }, reportDetailsPage: { inWorkspace: (policyName: string) => `在 ${policyName} 中`, generatingPDF: '生成 PDF', @@ -10477,6 +10491,7 @@ ${reportName}`, notVerified: '未验证', retry: '重试', requestSent: '请求已发送', + requestAccessError: '我们无法发送你的请求。请重试。', verifyDomain: { title: '验证域名', beforeProceeding: ({domainName}: {domainName: string}) => `在继续之前,请通过更新其 DNS 设置来验证您拥有 ${domainName}。`, @@ -10536,12 +10551,12 @@ ${reportName}`, setMetadataGenericError: '无法设置 SAML 元数据', }, accessRestricted: { - title: '访问受限', - subtitle: (domainName: string) => `如果你需要管理以下内容,请先验证你自己是 ${domainName} 的授权公司管理员:`, - companyCardManagement: '公司卡管理', - accountCreationAndDeletion: '账户创建和删除', - workspaceCreation: '工作区创建', - samlSSO: 'SAML 单点登录', + headerTitle: '访问受限', + title: '需要验证', + description: (domainName: string) => + `请验证你自己是 ${domainName} 的授权公司管理员,或向现有管理员申请访问权限。`, + requestAdminAccess: '申请管理员权限', + verifyYourself: '验证自己', }, addDomain: { title: '添加域名', @@ -10555,7 +10570,6 @@ ${reportName}`, title: '该域名已被设置。要申请访问权限吗?', description: '有人已经在 Expensify 中设置了此域名。要申请管理员权限吗?', requestAccess: '申请管理员权限', - requestAccessError: '我们无法发送你的请求。请重试。', }, domainAdded: { title: '已添加域名', diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 099db342e63c..e437a5a5648b 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -708,6 +708,9 @@ const WRITE_COMMANDS = { JOIN_REPORT_VIA_SECURE_LINK: 'JoinReportViaSecureLink', } as const; +/** `payMoneyRequest` sends the wallet command for Expensify Wallet payments and the plain one for everything else, both built from the same params. */ +const PAYMENT_COMMANDS = new Set([WRITE_COMMANDS.PAY_MONEY_REQUEST, WRITE_COMMANDS.PAY_MONEY_REQUEST_WITH_WALLET]); + type WriteCommand = ValueOf; type WriteCommandParameters = { @@ -1756,7 +1759,7 @@ type SideEffectRequestCommandParameters = { type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; -export {WRITE_COMMANDS, READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, AUTHENTICATION_COMMAND}; +export {WRITE_COMMANDS, READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, AUTHENTICATION_COMMAND, PAYMENT_COMMANDS}; type ApiCommand = WriteCommand | ReadCommand | SideEffectRequestCommand; type CommandOfType = TRequestType extends typeof CONST.API_REQUEST_TYPE.WRITE diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 0124fff6b4e2..b32d30db5e98 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -177,6 +177,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.ATTACHMENT_RECORD_PATHS_MIGRATED, ONYXKEYS.BETAS, ONYXKEYS.BETA_CONFIGURATION, + ONYXKEYS.BETA_OVERRIDES, ONYXKEYS.CACHED_PDF_PATHS, ONYXKEYS.CARD_SUPPORTED_COUNTRIES, ONYXKEYS.COLLECTION.CONCIERGE_PENDING_FOLLOWUP_LIST, @@ -203,6 +204,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE, ONYXKEYS.COLLECTION.REPORT_METADATA, ONYXKEYS.COLLECTION.REPORT_PAGINATION_STATE, + ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT, ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM, ONYXKEYS.COLLECTION.SELECTED_DISTANCE_REQUEST_TAB, ONYXKEYS.COLLECTION.SELECTED_TAB, @@ -322,6 +324,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, ONYXKEYS.PREFERRED_THEME, ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, + ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL, ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, diff --git a/src/libs/Formula.ts b/src/libs/Formula.ts index d214500907c4..60322d9da044 100644 --- a/src/libs/Formula.ts +++ b/src/libs/Formula.ts @@ -280,6 +280,14 @@ function isSubmissionInfoPart(part: FormulaPart): boolean { return part.type === FORMULA_PART_TYPES.REPORT && part.fieldPath.at(0)?.toLowerCase() === 'submit'; } +/** + * Empty is the value until a cross-border reimbursement exists, matching backend. + */ +function isReimbursementAmountPart(part: FormulaPart): boolean { + const field = part.fieldPath.at(0)?.toLowerCase(); + return part.type === FORMULA_PART_TYPES.REPORT && (field === 'debitedamount' || field === 'creditedamount'); +} + /** * Compute a formula and report whether any tokenized part fell back to its raw `{...}` definition. * Callers doing optimistic recomputes use the flag to discard outputs the BE will render better. @@ -299,9 +307,8 @@ function computeWithMetadata(formula?: string, context?: FormulaContext): {value switch (part.type) { case FORMULA_PART_TYPES.REPORT: value = computeReportPart(part, context); - // Apply fallback to formula definition for empty values, except for submission info - // Submission info explicitly returns empty strings when data is missing (matches backend) - if (value === '' && !isSubmissionInfoPart(part)) { + // Empty is a real value for submit and reimbursement-amount tokens. Keep it instead of falling back to the raw definition. + if (value === '' && !isSubmissionInfoPart(part) && !isReimbursementAmountPart(part)) { value = part.definition; } break; @@ -361,6 +368,19 @@ function computeAutoReportingInfo(part: FormulaPart, context: FormulaContext, su } } +/** + * Format a cross-border reimbursement amount (debited or credited), or empty if it hasn't happened yet. + */ +function formatReimbursementAmount(amount: number | undefined, currency: string | undefined, format: string | undefined, part: FormulaPart, context: FormulaContext): string { + if (!amount || !currency) { + return ''; + } + // formatAmount can return '' (not just null) for an unrecognized display currency modifier, which must + // fall back to the raw definition too so an invalid modifier doesn't look like a resolved empty amount. + const formattedAmount = formatAmount(amount, currency, format, context.getCurrencyDecimals); + return formattedAmount === null || formattedAmount === '' ? part.definition : formattedAmount; +} + /** * Compute the value of a report formula part */ @@ -397,6 +417,10 @@ function computeReportPart(part: FormulaPart, context: FormulaContext): string { const formattedAmount = formatAmount(getMoneyRequestSpendBreakdown(report).reimbursableSpend, report.currency, format, context.getCurrencyDecimals); return formattedAmount ?? ''; } + case 'debitedamount': + return formatReimbursementAmount(report.debitedAmount, report.debitedCurrency, format, part, context); + case 'creditedamount': + return formatReimbursementAmount(report.creditedAmount, report.creditedCurrency, format, part, context); case 'currency': return report.currency ?? ''; case 'policyname': diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 8a1a7e6d426e..fe3fff8159df 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -507,11 +507,20 @@ function getInitialPerDiemTargetReport( /** * Resolves the chat report ID for navigation, generating an optimistic ID if no existing chat is found. */ -function resolveOptimisticChatReportID(participantAccountIDs: number[], existingReport?: OnyxInputOrEntry) { +function resolveOptimisticChatReportID(participantAccountIDs: number[], existingReport?: OnyxInputOrEntry, optimisticChatReportID?: string) { const existingChat = existingReport?.reportID ? existingReport : getChatByParticipants(participantAccountIDs); - const optimisticChatReportID = existingChat?.reportID ? undefined : generateReportID(); - const chatReportID = existingChat?.reportID ?? optimisticChatReportID; - return {optimisticChatReportID, chatReportID}; + if (existingChat?.reportID) { + return {optimisticChatReportID: undefined, chatReportID: existingChat.reportID}; + } + + const chatReportID = optimisticChatReportID ?? generateReportID(); + return {optimisticChatReportID: chatReportID, chatReportID}; +} + +/** Returns `transactionReportID` if the participant isn't a workspace and has no existing chat, so the ID can be reused for their new chat report; otherwise undefined. */ +function getReusableP2PReportID(participant: Participant, transactionReportID: string | undefined): string | undefined { + const isBrandNewP2PRecipient = !participant.isPolicyExpenseChat && !participant.reportID; + return isBrandNewP2PRecipient && !!transactionReportID && transactionReportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? transactionReportID : undefined; } /** @@ -672,6 +681,7 @@ export { calculateDefaultReimbursable, getInitialPerDiemTargetReport, getIsWorkspacesOnlyForTransaction, + getReusableP2PReportID, isParticipantP2P, isSelfDMSoleDestination, isLookingAroundSearchRoutingActive, diff --git a/src/libs/Middleware/GlobalReimbursementPayError.ts b/src/libs/Middleware/GlobalReimbursementPayError.ts new file mode 100644 index 000000000000..4dc12fdc601c --- /dev/null +++ b/src/libs/Middleware/GlobalReimbursementPayError.ts @@ -0,0 +1,62 @@ +import {PAYMENT_COMMANDS} from '@libs/API/types'; +import Log from '@libs/Log'; +import {isRecord} from '@libs/ObjectUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {AnyOnyxUpdate, PaginatedRequest} from '@src/types/onyx/Request'; +import type Request from '@src/types/onyx/Request'; +import type Response from '@src/types/onyx/Response'; + +import type {OnyxKey} from 'react-native-onyx'; + +import type Middleware from './types'; + +/** + * Middleware that detects the Corpay pay modal signal sent by the backend when a pay attempt fails because the + * workspace USD VBBA is not set up on Corpay. The backend sends an Onyx SET on corpayPayModal instead of writing + * an inline error onto the report action. + */ +const GlobalReimbursementPayError: Middleware = (responsePromise: Promise | void>, request: Request | PaginatedRequest) => + responsePromise.then((response) => { + if (!request?.command || !PAYMENT_COMMANDS.has(request.command) || !response || response.jsonCode === CONST.JSON_CODE.SUCCESS) { + return response; + } + + const onyxData = response.onyxData ?? []; + const hasCorpayPayModal = onyxData.some((update) => update.key === ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL); + + if (!hasCorpayPayModal) { + return response; + } + + const iouReportID = request?.data?.iouReportID; + const reportActionID = request?.data?.reportActionID; + + if (typeof iouReportID !== 'string' || typeof reportActionID !== 'string' || !request?.failureData) { + return response; + } + + const actionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`; + for (const update of request.failureData as AnyOnyxUpdate[]) { + if (update.key !== actionsKey || !update.value) { + continue; + } + + if (!isRecord(update.value)) { + continue; + } + + // Drop the optimistic PAY action instead of merging an error onto it. + update.value = { + ...update.value, + [reportActionID]: null, + }; + } + + Log.info('GlobalReimbursementPayError: replaced optimistic PAY action-error with action-null for corpayPayModal', false, {iouReportID, reportActionID}); + + return response; + }); + +export default GlobalReimbursementPayError; diff --git a/src/libs/Middleware/HandleMovedScanFailedExpenses.ts b/src/libs/Middleware/HandleMovedScanFailedExpenses.ts index a235a8acd2ea..e4256102f312 100644 --- a/src/libs/Middleware/HandleMovedScanFailedExpenses.ts +++ b/src/libs/Middleware/HandleMovedScanFailedExpenses.ts @@ -1,4 +1,5 @@ -import {WRITE_COMMANDS} from '@libs/API/types'; +import {PAYMENT_COMMANDS} from '@libs/API/types'; +import {isRecord} from '@libs/ObjectUtils'; import type {Middleware} from '@libs/Request'; import reconcileMovedScanFailedReport, {getMovedScanFailedTransactionIDs} from '@userActions/IOU/reconcileMovedScanFailedReport'; @@ -26,13 +27,6 @@ type RealReport = { actionIDByTransactionID: Map; }; -/** `payMoneyRequest` sends the wallet command for Expensify Wallet payments and the plain one for everything else, both built from the same params. */ -const PAYMENT_COMMANDS = new Set([WRITE_COMMANDS.PAY_MONEY_REQUEST, WRITE_COMMANDS.PAY_MONEY_REQUEST_WITH_WALLET]); - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object'; -} - function getString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } diff --git a/src/libs/Middleware/index.ts b/src/libs/Middleware/index.ts index 87aa9ee1fada..42a72e066ade 100644 --- a/src/libs/Middleware/index.ts +++ b/src/libs/Middleware/index.ts @@ -1,5 +1,6 @@ import FailureTracking from './FailureTracking'; import FraudMonitoring from './FraudMonitoring'; +import GlobalReimbursementPayError from './GlobalReimbursementPayError'; import handleDeletedAccount from './HandleDeletedAccount'; import HandleMovedScanFailedExpenses from './HandleMovedScanFailedExpenses'; import HandleUnusedOptimisticID from './HandleUnusedOptimisticID'; @@ -25,6 +26,7 @@ export { Pagination, handleDeletedAccount, SupportalPermission, + GlobalReimbursementPayError, FraudMonitoring, LoadPostDataForOpenOrReconnect, SentryServerTiming, diff --git a/src/libs/Middleware/register.ts b/src/libs/Middleware/register.ts index ce1501f157d0..972a91ccb241 100644 --- a/src/libs/Middleware/register.ts +++ b/src/libs/Middleware/register.ts @@ -3,6 +3,7 @@ import {addMiddleware} from '@libs/Request'; import { FailureTracking, FraudMonitoring, + GlobalReimbursementPayError, handleDeletedAccount, HandleMovedScanFailedExpenses, HandleUnusedOptimisticID, @@ -51,6 +52,9 @@ function registerMiddlewares() { // Handle supportal permission denial centrally addMiddleware(SupportalPermission); + // Handle the Corpay pay modal signal: when the backend signals that the workspace USD VBBA is not set up on Corpay, replace the optimistic PAY action-error with an action-null so no inline error shows. + addMiddleware(GlobalReimbursementPayError); + // If an optimistic ID is not used by the server, this will update the remaining serialized requests using that optimistic ID to use the correct ID instead. addMiddleware(HandleUnusedOptimisticID); diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 95fd074e8722..eb615e3639bf 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -1,5 +1,6 @@ import ComposeProviders from '@components/ComposeProviders'; import DelegateNoAccessModalProvider from '@components/DelegateNoAccessModalProvider'; +import EnableGlobalReimbursementsPayModal from '@components/EnableGlobalReimbursementsPayModal'; import ExportDownloadStatusManager from '@components/ExportDownloadStatusManager'; import GPSInProgressModal from '@components/GPSInProgressModal'; import GPSTripStateChecker from '@components/GPSTripStateChecker'; @@ -88,6 +89,7 @@ const loadLogOutPreviousUserPage = () => require('../../.. const loadConciergePage = () => require('../../../pages/ConciergePage').default; const loadTrackExpensePage = () => require('../../../pages/TrackExpensePage').default; const loadSubmitExpensePage = () => require('../../../pages/SubmitExpensePage').default; +const loadPreMountBufferPage = () => require('../../../pages/PreMountBufferPage').default; const loadWorkspaceJoinUser = () => require('@pages/workspace/WorkspaceJoinUserPage').default; const RootStack = createRootStackNavigator(); @@ -230,6 +232,13 @@ function AuthScreens() { options={defaultScreenOptions} getComponent={loadSubmitExpensePage} /> + {/* Internal placeholder screen, not reachable via a URL/deep link. animation: none so it + appears and disappears instantly instead of sliding in. */} + + diff --git a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx index b3065181570e..ea5bcaeaf0a8 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx @@ -4,6 +4,7 @@ import useActivePolicy from '@hooks/useActivePolicy'; import useAIFeaturesPromoModal from '@hooks/useAIFeaturesPromoModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies'; +import useHasOwnedPaidPolicy from '@hooks/useHasOwnedPaidPolicy'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; import useLocalize from '@hooks/useLocalize'; import useOneTransactionThreadReportID from '@hooks/useOneTransactionThreadReportID'; @@ -86,6 +87,7 @@ function AuthScreensInitHandler() { const {initialURL, isAuthenticatedAtStartup} = useInitialURLState(); const {setIsAuthenticatedAtStartup} = useInitialURLActions(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const hasOwnedPaidPolicy = useHasOwnedPaidPolicy(); const [session] = useOnyx(ONYXKEYS.SESSION); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); @@ -213,6 +215,7 @@ function AuthScreensInitHandler() { isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, lastWorkspaceNumber, translate, conciergeChat, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 46b6cb63346b..0ae6806b8391 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -77,6 +77,15 @@ const OPTIONS_PER_SCREEN: Partial [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_ADD_NEW]: { animationTypeForReplace: 'push', }, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS]: { + animationTypeForReplace: 'push', + }, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS]: { + animationTypeForReplace: 'push', + }, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN]: { + animationTypeForReplace: 'push', + }, [SCREENS.SEARCH.DYNAMIC_MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS]: { animation: Animations.NONE, }, @@ -462,6 +471,12 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsAgreementsPage').default, [SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN]: () => require('../../../../pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage').default, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS]: () => + require('../../../../pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage').default, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS]: () => + require('../../../../pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsAgreementsPage').default, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN]: () => + require('../../../../pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage').default, [SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/ShareBankAccount/ShareBankAccount').default, [SCREENS.SETTINGS.WALLET.TRAVEL_CVV]: () => require('../../../../pages/settings/Wallet/TravelCVVPage/TravelCVVPage').default, [SCREENS.SETTINGS.WALLET.TRAVEL_CVV_VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage').default, diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 026c03f68794..b1c496e6c8fe 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -114,6 +114,7 @@ function SecondaryOverlay() { const loadRHPReportScreen = () => require('../../../../pages/inbox/RHPReportScreen').default; const loadSearchMoneyRequestReportPage = () => require('../../../../pages/Search/SearchMoneyRequestReportPage').default; const loadSearchSavePage = () => require('../../../../pages/Search/SearchSavePage').default; +const loadBetaOverridesPage = () => require('../../../../pages/settings/Troubleshoot/BetaOverridesPage').default; type RightModalDialogFrameProps = { /** Whether the RHP container should carry dialog semantics (role=dialog + aria-modal) — true on wide layout. */ @@ -454,6 +455,10 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { getComponent={loadSearchSavePage} options={modalStackScreenOptions} /> + [Tab(B), RHP] * State transition for other fullscreen targets: [FS, RHP] -> [FS, FS', RHP] * - * @see removePreInsertedFullscreenIfNeeded in Navigation.ts — the caller that cleans up + * @see removePreInsertedFullscreenIfNeeded in helpers/preMountBuffer.ts — the caller that cleans up * the pre-insertion when the user cancels. */ + +/** + * Guards against a native swipe-back gesture popping the RHP before JS cleanup runs; web has no such + * gesture. Only inserted when the caller opts in, since some callers dismiss synchronously with no + * gesture involved and never clear the buffer afterward - inserting one there would leave it stuck on top. + */ +function buildPreMountBufferRoute(rhpRouteKey: string, shouldInsertPreMountBuffer: boolean | undefined): StackNavigationState['routes'][number] | undefined { + if (!hasNativeSwipeBackGesture() || !shouldInsertPreMountBuffer) { + return undefined; + } + return {name: SCREENS.PRE_MOUNT_BUFFER, key: `pre-mount-buffer-${rhpRouteKey}`}; +} + function handleReplaceFullscreenUnderRHP( state: StackNavigationState, action: ReplaceFullscreenUnderRHPActionType, @@ -531,7 +545,16 @@ function handleReplaceFullscreenUnderRHP( preInsertedOriginalTabRoute = existingTabState?.routes?.length ? existingTabRoute : ({...existingTabRoute, state: buildTabNavigatorNestedState({name: TAB_SCREENS[0]})} as StackNavigationState['routes'][number]); - const newRoutes = [...routesWithoutRHP.slice(0, tabNavIndex), updatedTabRoute, ...routesWithoutRHP.slice(tabNavIndex + 1), rhpRoute]; + // Add Buffer to the routes this call returns, rather than as a separate dispatch afterward. A + // second dispatch would re-run state rehydration on top of the placeholder tab state built + // above, permanently locking in that placeholder instead of letting it resolve normally. + const bufferRouteForTab = buildPreMountBufferRoute(rhpRoute.key, action.payload.shouldInsertPreMountBuffer); + const newRoutes = [...routesWithoutRHP]; + newRoutes[tabNavIndex] = updatedTabRoute; + if (bufferRouteForTab) { + newRoutes.push(bufferRouteForTab); + } + newRoutes.push(rhpRoute); return stackRouter.getRehydratedState({...state, routes: newRoutes, index: newRoutes.length - 1}, configOptions); } @@ -558,10 +581,18 @@ function handleReplaceFullscreenUnderRHP( } const rehydratedStateAfterPush = stackRouter.getRehydratedState(stateAfterPush, configOptions); + // Build Buffer into this same dispatch (same reasoning as the tab branch above). + const bufferRouteForPush = buildPreMountBufferRoute(rhpRoute.key, action.payload.shouldInsertPreMountBuffer); + const routesWithRHP = [...rehydratedStateAfterPush.routes]; + if (bufferRouteForPush) { + routesWithRHP.push(bufferRouteForPush); + } + routesWithRHP.push(rhpRoute); + return { ...rehydratedStateAfterPush, - routes: [...rehydratedStateAfterPush.routes, rhpRoute], - index: rehydratedStateAfterPush.routes.length, + routes: routesWithRHP, + index: routesWithRHP.length - 1, }; } @@ -587,7 +618,7 @@ function handleRemoveFullscreenUnderRHP( return null; } - const routesWithoutRHP = state.routes.slice(0, -1); + const routesWithoutRHP = state.routes.slice(0, -1).filter((r) => r.name !== SCREENS.PRE_MOUNT_BUFFER); // Tab-switch path: restore the original TAB_NAVIGATOR route saved during pre-insertion. if (preInsertedOriginalTabRoute) { diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts index adf44ae1f7f3..2371a29b3680 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts @@ -28,7 +28,7 @@ type RootStackNavigatorActionType = } | { type: typeof CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP; - payload: {route: Route}; + payload: {route: Route; shouldInsertPreMountBuffer?: boolean}; } | { type: typeof CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP; @@ -69,7 +69,7 @@ type DismissModalActionType = RootStackNavigatorActionType & { type ReplaceFullscreenUnderRHPActionType = RootStackNavigatorActionType & { type: typeof CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP; - payload: {route: Route}; + payload: {route: Route; shouldInsertPreMountBuffer?: boolean}; }; type RemoveFullscreenUnderRHPActionType = RootStackNavigatorActionType & { diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index c0e74c04ff47..e1e695c335ea 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -44,7 +44,7 @@ import type { State, } from './types'; -import {clearPreInsertedOriginalTabRoute, getPreInsertedOriginalTabRoute} from './AppNavigator/createRootStackNavigator/GetStateForActionHandlers'; +import {getPreInsertedOriginalTabRoute} from './AppNavigator/createRootStackNavigator/GetStateForActionHandlers'; import getInitialSplitNavigatorState from './AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; import originalCloseRHPFlow from './helpers/closeRHPFlow'; import getActiveTabName from './helpers/getActiveTabName'; @@ -59,6 +59,16 @@ import isSideModalNavigator from './helpers/isSideModalNavigator'; import linkTo from './helpers/linkTo'; import getMinimalAction from './helpers/linkTo/getMinimalAction'; import {popAndRealignMfaMarker} from './helpers/mfaModalMarkerPreservation'; +import { + canNativeSwipeDismissRHP, + captureBufferTransaction, + clearFullscreenPreInsertedFlag, + getIsFullscreenPreInsertedUnderRHP, + getPreInsertedFullscreenRouteName, + markFullscreenPreInsertedUnderRHP, + recoverFromPreMountBuffer, + removePreInsertedFullscreenIfNeeded, +} from './helpers/preMountBuffer'; import replaceWithSplitNavigator from './helpers/replaceWithSplitNavigator'; import setNavigationActionToMicrotaskQueue from './helpers/setNavigationActionToMicrotaskQueue'; import {linkingConfig} from './linkingConfig'; @@ -826,18 +836,20 @@ function navContainsProtectedRoutes(state: State | undefined): boolean { function waitForProtectedRoutes() { return new Promise((resolve) => { isNavigationReady().then(() => { - const currentState = navigationRef.current?.getState(); - if (navContainsProtectedRoutes(currentState)) { + // `getState()` and the `state` event expose the container's own copy of the state, which has + // `routeNames` stripped until a navigator pushes its state up after mounting. Use `getRootState()`, + // which reads the hydrated state from the navigator and always carries `routeNames`. + if (navContainsProtectedRoutes(navigationRef.getRootState())) { resolve(); return; } - const unsubscribe = navigationRef.current?.addListener('state', ({data}) => { - const state = data?.state; - if (navContainsProtectedRoutes(state)) { - unsubscribe?.(); - resolve(); + const unsubscribe = navigationRef.addListener('state', () => { + if (!navContainsProtectedRoutes(navigationRef.getRootState())) { + return; } + unsubscribe(); + resolve(); }); }); }); @@ -953,6 +965,12 @@ const dismissModalWithReport = ( const isReportsSplitTopmostFullScreen = isReportTopmostSplitNavigator(); if (topmostReportID === reportID && areReportsIDsDefined && isReportsSplitTopmostFullScreen) { options?.onBeforeNavigate?.(false); + // Clear any pre-insert/buffer state for this report before dismissing, or the buffer logic + // sees the RHP disappear unexpectedly and reverts back to whatever was showing before this + // report was pre-inserted, replacing it even though it's already the report we want to end up on. + if (getIsFullscreenPreInsertedUnderRHP()) { + clearFullscreenPreInsertedFlag(); + } dismissModal({afterTransition: options?.afterTransition}); return; } @@ -1128,13 +1146,6 @@ function revealRouteBeforeDismissingModal(route: Route, options?: {afterTransiti }); } -// Module-level state tracking the pre-inserted fullscreen route. This follows the same -// pattern as other module-level navigation state in this file (e.g. pendingRoute). -// It is only mutated from preInsertFullscreenUnderRHP / clearFullscreenPreInsertedFlag / -// removePreInsertedFullscreenIfNeeded, which are always called from the JS thread. -let isFullscreenPreInsertedUnderRHP = false; -let preInsertedFullscreenRouteName: string | undefined; - /** * Pre-inserts a fullscreen route (e.g. Search) underneath the currently open RHP on narrow layout. * The route renders behind the fullscreen RHP so that when the user later submits, @@ -1150,7 +1161,7 @@ function preInsertFullscreenUnderRHP(route: Route) { return; } - if (isFullscreenPreInsertedUnderRHP) { + if (getIsFullscreenPreInsertedUnderRHP()) { return; } @@ -1171,7 +1182,7 @@ function preInsertFullscreenUnderRHP(route: Route) { navigationRef.current.dispatch({ type: CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP, - payload: {route}, + payload: {route, shouldInsertPreMountBuffer: canNativeSwipeDismissRHP()}, }); const stateAfter = navigationRef.current.getRootState(); @@ -1183,99 +1194,11 @@ function preInsertFullscreenUnderRHP(route: Route) { return; } - isFullscreenPreInsertedUnderRHP = true; - preInsertedFullscreenRouteName = targetRouteName; + markFullscreenPreInsertedUnderRHP(targetRouteName); DeviceEventEmitter.emit(CONST.MODAL_EVENTS.DISABLE_RHP_ANIMATION); -} - -function getIsFullscreenPreInsertedUnderRHP() { - return isFullscreenPreInsertedUnderRHP; -} - -function getPreInsertedFullscreenRouteName() { - return preInsertedFullscreenRouteName; -} - -function clearFullscreenPreInsertedFlag() { - isFullscreenPreInsertedUnderRHP = false; - preInsertedFullscreenRouteName = undefined; - clearPreInsertedOriginalTabRoute(); -} - -/** - * Removes a pre-inserted fullscreen route when the user backs out without submitting. - * If the RHP is still on top, the pre-inserted route is popped from under it. - * If the RHP is already gone (back-dismissed), the pre-inserted route is the topmost - * fullscreen and is popped directly. - */ -function removePreInsertedFullscreenIfNeeded() { - if (!isFullscreenPreInsertedUnderRHP) { - return; - } - - const routeNameToRemove = preInsertedFullscreenRouteName; - isFullscreenPreInsertedUnderRHP = false; - preInsertedFullscreenRouteName = undefined; - - DeviceEventEmitter.emit(CONST.MODAL_EVENTS.RESTORE_RHP_ANIMATION); - - const rootState = navigationRef.getRootState(); - if (!rootState) { - return; - } - - const topRoute = rootState.routes.at(-1); - const isRHPStillOnTop = topRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; - - if (isRHPStillOnTop && routeNameToRemove) { - navigationRef.current?.dispatch({ - type: CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP, - payload: {expectedRouteName: routeNameToRemove}, - }); - return; - } - - // RHP already dismissed. For the tab-switch path, jump back to the original tab. - // For the push path, pop the pre-inserted route directly. - const originalTabRoute = getPreInsertedOriginalTabRoute(); - if (originalTabRoute) { - clearPreInsertedOriginalTabRoute(); - const originalTabState = originalTabRoute.state; - const originalFocusedTabIndex = originalTabState?.index ?? 0; - const originalTabName = originalTabState?.routes?.[originalFocusedTabIndex]?.name; - if (originalTabName) { - requestAnimationFrame(() => { - const currentState = navigationRef.getRootState(); - const tabNavRoute = currentState?.routes.findLast((r) => r.name === NAVIGATORS.TAB_NAVIGATOR); - if (!tabNavRoute?.state?.key) { - return; - } - navigationRef.current?.dispatch({ - ...TabActions.jumpTo(originalTabName), - target: tabNavRoute.state.key, - }); - }); - } - return; - } - - // Push path: the pre-inserted fullscreen is now the topmost route; pop it. - // Deferred to the next frame to avoid dispatching during a React commit. - // Capture the route key now so the rAF callback can match on identity, not just name. - const targetRouteKey = rootState.routes.at(-1)?.key; - requestAnimationFrame(() => { - const currentState = navigationRef.getRootState(); - const topmostRoute = currentState?.routes.at(-1); - if (!topmostRoute || topmostRoute.key !== targetRouteKey || topmostRoute.name !== routeNameToRemove) { - return; - } - if (!navigationRef.current?.canGoBack()) { - return; - } - navigationRef.current.goBack(); - }); + captureBufferTransaction(stateAfter, wasTabSwitched); } function getTopmostSearchReportRouteParams(state = navigationRef.getRootState()): RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT] | undefined { @@ -1344,6 +1267,7 @@ export default { getIsFullscreenPreInsertedUnderRHP, getPreInsertedFullscreenRouteName, clearFullscreenPreInsertedFlag, + recoverFromPreMountBuffer, removePreInsertedFullscreenIfNeeded, getTopmostSearchReportID, getTopmostSuperWideRHPReportParams, diff --git a/src/libs/Navigation/helpers/enableGlobalReimbursementsNavigationUtils.ts b/src/libs/Navigation/helpers/enableGlobalReimbursementsNavigationUtils.ts new file mode 100644 index 000000000000..be8af02ccb43 --- /dev/null +++ b/src/libs/Navigation/helpers/enableGlobalReimbursementsNavigationUtils.ts @@ -0,0 +1,95 @@ +/** + * Route helpers for Enable Global Reimbursements in wallet settings or on search and report screens. + */ +import Log from '@libs/Log'; + +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; + +import createDynamicRoute from './dynamicRoutesUtils/createDynamicRoute'; +import findAllMatchingDynamicSuffixes from './dynamicRoutesUtils/findAllMatchingDynamicSuffixes'; +import getPathWithoutDynamicSuffix from './dynamicRoutesUtils/getPathWithoutDynamicSuffix'; +import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard'; +import getStateFromPath from './getStateFromPath'; + +type EnableGlobalReimbursementsRouteParams = { + /** The country of the bank account */ + bankCountry?: string; + + /** The currency of the bank account */ + bankCurrency?: string; +}; + +const ENABLE_GLOBAL_REIMBURSEMENTS_SUFFIX_PATTERNS = new Set([ + DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.path, + DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS.path, + DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN.path, +]); + +const ENABLE_GLOBAL_REIMBURSEMENTS_PATH_PREFIX = DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.path.split('/').at(0) ?? ''; + +function getDynamicBasePathFromNavigationPath(path: string | undefined): string { + if (!path) { + return ROUTES.HOME; + } + + const pathWithoutLeadingSlash = path.replaceAll(/^\/+/g, ''); + const suffixMatches = findAllMatchingDynamicSuffixes(pathWithoutLeadingSlash); + const match = suffixMatches.find((suffixMatch) => ENABLE_GLOBAL_REIMBURSEMENTS_SUFFIX_PATTERNS.has(suffixMatch.pattern)); + if (match) { + return getPathWithoutDynamicSuffix(match.pathUsedForMatching, match.actualSuffix, match.pattern); + } + + return pathWithoutLeadingSlash; +} + +function getEnableGlobalReimbursementsRootBackPath(dynamicBasePath: string): Route { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- runtime path from getPathFromState; caller must validate with shouldUseDynamicEnableGlobalReimbursementsBase first + return dynamicBasePath as Route; +} + +const ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS = new Set(DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.entryScreens); + +function shouldUseDynamicEnableGlobalReimbursementsBase(basePath: string): boolean { + const pathWithoutQuery = basePath.split('?').at(0) ?? ''; + + if (!pathWithoutQuery || pathWithoutQuery.includes(ENABLE_GLOBAL_REIMBURSEMENTS_PATH_PREFIX)) { + return false; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- path parsed to verify focused route name against entry screens + const focusedRouteName = findFocusedRouteWithOnyxTabGuard(getStateFromPath(pathWithoutQuery as Route) ?? {})?.name; + if (focusedRouteName && ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS.has(focusedRouteName)) { + return true; + } + } catch (error) { + Log.warn('shouldUseDynamicEnableGlobalReimbursementsBase: failed to resolve route state from path', {basePath, error: error instanceof Error ? error.message : String(error)}); + return false; + } + + return false; +} + +function getEnableGlobalReimbursementsBusinessNavigationRoute( + bankAccountID: number, + subPage: string, + params?: EnableGlobalReimbursementsRouteParams, + navigationPathAtSignal?: string, +): Route { + const basePath = navigationPathAtSignal ? getDynamicBasePathFromNavigationPath(navigationPathAtSignal) : undefined; + + if (basePath && shouldUseDynamicEnableGlobalReimbursementsBase(basePath)) { + return createDynamicRoute(DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute(String(bankAccountID), subPage, undefined, params), basePath); + } + + return ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute(bankAccountID, subPage, undefined, params); +} + +export type {EnableGlobalReimbursementsRouteParams}; +export { + getDynamicBasePathFromNavigationPath, + getEnableGlobalReimbursementsBusinessNavigationRoute, + getEnableGlobalReimbursementsRootBackPath, + shouldUseDynamicEnableGlobalReimbursementsBase, +}; diff --git a/src/libs/Navigation/helpers/hasNativeSwipeBackGesture/index.native.ts b/src/libs/Navigation/helpers/hasNativeSwipeBackGesture/index.native.ts new file mode 100644 index 000000000000..6c3e0643cdde --- /dev/null +++ b/src/libs/Navigation/helpers/hasNativeSwipeBackGesture/index.native.ts @@ -0,0 +1,6 @@ +/** iOS and Android can pop a modal with a swipe-back gesture before JS cleanup runs. */ +function hasNativeSwipeBackGesture(): boolean { + return true; +} + +export default hasNativeSwipeBackGesture; diff --git a/src/libs/Navigation/helpers/hasNativeSwipeBackGesture/index.ts b/src/libs/Navigation/helpers/hasNativeSwipeBackGesture/index.ts new file mode 100644 index 000000000000..7fbc01f2f9d8 --- /dev/null +++ b/src/libs/Navigation/helpers/hasNativeSwipeBackGesture/index.ts @@ -0,0 +1,6 @@ +/** Web and desktop have no swipe-back gesture that could pop a modal outside of JS. */ +function hasNativeSwipeBackGesture(): boolean { + return false; +} + +export default hasNativeSwipeBackGesture; diff --git a/src/libs/Navigation/helpers/linkTo/index.ts b/src/libs/Navigation/helpers/linkTo/index.ts index f05f5fd1553e..697792619f68 100644 --- a/src/libs/Navigation/helpers/linkTo/index.ts +++ b/src/libs/Navigation/helpers/linkTo/index.ts @@ -25,6 +25,7 @@ import getMinimalAction from './getMinimalAction'; const defaultLinkToOptions: LinkToOptions = { forceReplace: false, + skipMatchingFullScreenRoute: false, }; /** @@ -160,7 +161,7 @@ export default function linkTo(navigation: NavigationContainerRef; + const {forceReplace, skipMatchingFullScreenRoute} = {...defaultLinkToOptions, ...options} as Required; const normalizedPath = normalizePath(path) as Route; const normalizedPathAfterRedirection = (getMatchingNewRoute(normalizedPath) ?? normalizedPath) as Route; @@ -240,7 +241,7 @@ export default function linkTo(navigation: NavigationContainerRef void; // If true, waits for ongoing transitions to finish before navigating. Defaults to false (navigates immediately). waitForTransition?: boolean; + // If true, skip full-screen route matching when opening an RHP. Use when the central pane should stay on the current tab. + skipMatchingFullScreenRoute?: boolean; }; export type {ActionPayload, ActionPayloadParams, LinkToOptions}; diff --git a/src/libs/Navigation/helpers/preMountBuffer.ts b/src/libs/Navigation/helpers/preMountBuffer.ts new file mode 100644 index 000000000000..b4cf2297a26f --- /dev/null +++ b/src/libs/Navigation/helpers/preMountBuffer.ts @@ -0,0 +1,310 @@ +import {clearPreInsertedOriginalTabRoute, getPreInsertedOriginalTabRoute} from '@libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers'; +import navigationRef from '@libs/Navigation/navigationRef'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import {CommonActions, TabActions} from '@react-navigation/native'; +import {DeviceEventEmitter} from 'react-native'; + +import hasNativeSwipeBackGesture from './hasNativeSwipeBackGesture'; + +// Always set and cleared together - the route name is only meaningful while the flag is true. +let isFullscreenPreInsertedUnderRHP = false; +let preInsertedFullscreenRouteName: string | undefined; + +// Set while a neutral placeholder route sits directly under the RHP, so a native swipe-dismiss +// reveals that placeholder instead of the real destination underneath. +// +// Watches the root 'state' event instead of transitionEnd, since transitionEnd never fires on +// Android when the animation is none - 'state' fires reliably on both platforms. +let bufferTransaction: {rhpRouteKey: string; bufferRouteKey: string; mode: 'push' | 'tab'; destinationRouteKey?: string} | undefined; +let bufferStateListenerUnsubscribe: (() => void) | undefined; + +function clearBufferStateListener() { + bufferStateListenerUnsubscribe?.(); + bufferStateListenerUnsubscribe = undefined; +} + +/** + * No-op while the RHP is still in the stack. Once it is gone without commit/cancel having run, restores the + * origin with a single reset dispatch: the original tab route, or the stack without Buffer and the pushed destination. + */ +function revertPreMountBufferIfRHPClosed() { + if (!bufferTransaction) { + return; + } + const {rhpRouteKey, bufferRouteKey, mode, destinationRouteKey} = bufferTransaction; + const rootState = navigationRef.getRootState(); + if (!rootState) { + return; + } + const stillHasRHP = rootState.routes.some((r) => r.key === rhpRouteKey); + if (stillHasRHP) { + return; + } + + DeviceEventEmitter.emit(CONST.MODAL_EVENTS.RESTORE_RHP_ANIMATION); + + // Reaching here means the RHP is gone but commit/cancel never ran (they would have cleared + // bufferTransaction and returned above) - something else removed it (native swipe, external + // dismissal). Treat it the same as a normal cancel: end up back at the origin. + bufferTransaction = undefined; + clearBufferStateListener(); + isFullscreenPreInsertedUnderRHP = false; + preInsertedFullscreenRouteName = undefined; + + if (mode === 'tab') { + const originalTabRoute = getPreInsertedOriginalTabRoute(); + clearPreInsertedOriginalTabRoute(); + const tabNavIndex = rootState.routes.findLastIndex((r) => r.name === NAVIGATORS.TAB_NAVIGATOR); + if (!originalTabRoute || tabNavIndex < 0) { + const fallbackRoutes = rootState.routes.filter((r) => r.key !== bufferRouteKey); + navigationRef.current?.dispatch(CommonActions.reset({...rootState, routes: fallbackRoutes, index: fallbackRoutes.length - 1})); + return; + } + const withoutBuffer = rootState.routes.filter((r) => r.key !== bufferRouteKey); + const lastTabIndex = withoutBuffer.findLastIndex((r) => r.name === NAVIGATORS.TAB_NAVIGATOR); + const newRoutes = withoutBuffer.map((r, i) => (i === lastTabIndex ? originalTabRoute : r)); + navigationRef.current?.dispatch(CommonActions.reset({...rootState, routes: newRoutes, index: newRoutes.length - 1})); + return; + } + + // Push path: this transaction pushed the destination as a new route rather than swapping it into + // a tab, so remove both it and Buffer in one dispatch - two dispatches would leave a render in + // between where only one of them is gone. + const newRoutes = rootState.routes.filter((r) => r.key !== bufferRouteKey && r.key !== destinationRouteKey); + navigationRef.current?.dispatch( + CommonActions.reset({ + ...rootState, + routes: newRoutes, + index: newRoutes.length - 1, + }), + ); +} + +/** Restores the route that was visible before the pre-mount buffer route */ +function recoverFromPreMountBuffer() { + const rootState = navigationRef.getRootState(); + if (rootState?.routes.at(-1)?.name !== SCREENS.PRE_MOUNT_BUFFER) { + return; + } + + if (bufferTransaction) { + revertPreMountBufferIfRHPClosed(); + return; + } + + // Defensive fallback for a lost transaction: restore the original tab route when pre-mount + // switched tabs, otherwise remove both Buffer and the speculative pushed destination. + const originalTabRoute = getPreInsertedOriginalTabRoute(); + const routesWithoutBuffer = rootState.routes.slice(0, -1); + const tabNavIndex = originalTabRoute ? routesWithoutBuffer.findLastIndex((route) => route.name === NAVIGATORS.TAB_NAVIGATOR) : -1; + const routes = originalTabRoute && tabNavIndex >= 0 ? routesWithoutBuffer.map((route, index) => (index === tabNavIndex ? originalTabRoute : route)) : rootState.routes.slice(0, -2); + if (!routes.length) { + return; + } + isFullscreenPreInsertedUnderRHP = false; + preInsertedFullscreenRouteName = undefined; + clearPreInsertedOriginalTabRoute(); + clearBufferStateListener(); + navigationRef.current?.dispatch(CommonActions.reset({...rootState, routes, index: routes.length - 1})); +} + +/** + * Reads the navigation state just after Buffer was inserted (in an earlier dispatch, not here) and, + * if Buffer really did land under the RHP, records it as `bufferTransaction` and starts watching for + * the RHP closing so it can be cleaned up. + */ +function captureBufferTransaction(stateAfter: ReturnType, wasTabSwitched: boolean) { + if (!hasNativeSwipeBackGesture() || !stateAfter) { + return; + } + const rhpRoute = stateAfter.routes.at(-1); + const bufferRoute = stateAfter.routes.at(-2); + const isTopModalBufferHost = rhpRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR || rhpRoute?.name === NAVIGATORS.SHARE_MODAL_NAVIGATOR; + if (!isTopModalBufferHost || bufferRoute?.name !== SCREENS.PRE_MOUNT_BUFFER) { + return; + } + + if (wasTabSwitched) { + bufferTransaction = {rhpRouteKey: rhpRoute.key, bufferRouteKey: bufferRoute.key, mode: 'tab'}; + } else { + const destinationRoute = stateAfter.routes.at(-3); + if (!destinationRoute) { + return; + } + bufferTransaction = {rhpRouteKey: rhpRoute.key, bufferRouteKey: bufferRoute.key, mode: 'push', destinationRouteKey: destinationRoute.key}; + } + + clearBufferStateListener(); + bufferStateListenerUnsubscribe = navigationRef.current?.addListener('state', revertPreMountBufferIfRHPClosed); +} + +/** Removes just the Buffer route, leaving the pushed destination in place, and clears the transaction (including its own state listener). */ +function removeBufferRouteOnly() { + if (!bufferTransaction) { + return; + } + const {bufferRouteKey} = bufferTransaction; + bufferTransaction = undefined; + clearBufferStateListener(); + + const rootState = navigationRef.getRootState(); + if (!rootState) { + return; + } + const newRoutes = rootState.routes.filter((r) => r.key !== bufferRouteKey); + if (newRoutes.length === rootState.routes.length) { + return; + } + navigationRef.current?.dispatch( + CommonActions.reset({ + ...rootState, + routes: newRoutes, + index: newRoutes.length - 1, + }), + ); +} + +/** + * Whether a swipe-back gesture would dismiss the whole RHP, rather than just popping one screen off + * its inner stack. Only true when the inner stack has nothing left to pop back to - otherwise the + * swipe stays inside the RHP and never reaches it. + * + * Defaults to true (assume dismissible) when the state can't be read, so the buffer never gets + * silently skipped on uncertain data. + */ +function canNativeSwipeDismissRHP(): boolean { + const rootState = navigationRef.getRootState(); + const rhpRoute = rootState?.routes.at(-1); + if (rhpRoute?.name !== NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { + return true; + } + + const focusedRHPStackRoute = rhpRoute.state?.routes?.at(rhpRoute.state?.index ?? 0); + const innerFlowState = focusedRHPStackRoute?.state; + if (!innerFlowState) { + return true; + } + + return (innerFlowState.index ?? 0) === 0; +} + +/** Records that `preInsertFullscreenUnderRHP` landed a destination under the RHP, so the cleanup helpers know what to remove later. */ +function markFullscreenPreInsertedUnderRHP(routeName: string | undefined) { + isFullscreenPreInsertedUnderRHP = true; + preInsertedFullscreenRouteName = routeName; +} + +function getIsFullscreenPreInsertedUnderRHP() { + return isFullscreenPreInsertedUnderRHP; +} + +function getPreInsertedFullscreenRouteName() { + return preInsertedFullscreenRouteName; +} + +/** Called once the pre-inserted destination is confirmed, so it should stay - only the Buffer route in front of it needs cleaning up. */ +function clearFullscreenPreInsertedFlag() { + removeBufferRouteOnly(); + isFullscreenPreInsertedUnderRHP = false; + preInsertedFullscreenRouteName = undefined; + clearPreInsertedOriginalTabRoute(); +} + +/** + * Removes a pre-inserted fullscreen route when the user backs out without submitting. + * If the RHP is still on top, the pre-inserted route is popped from under it. + * If the RHP is already gone (back-dismissed), the pre-inserted route is the topmost + * fullscreen and is popped directly. + */ +function removePreInsertedFullscreenIfNeeded() { + if (!isFullscreenPreInsertedUnderRHP) { + return; + } + + const routeNameToRemove = preInsertedFullscreenRouteName; + + isFullscreenPreInsertedUnderRHP = false; + preInsertedFullscreenRouteName = undefined; + + DeviceEventEmitter.emit(CONST.MODAL_EVENTS.RESTORE_RHP_ANIMATION); + + const rootState = navigationRef.getRootState(); + if (!rootState) { + return; + } + + const topRoute = rootState.routes.at(-1); + const isRHPStillOnTop = topRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; + + if (isRHPStillOnTop && routeNameToRemove) { + // Call this before dispatching below, so its listener teardown happens before this dispatch + // can trigger it. + removeBufferRouteOnly(); + navigationRef.current?.dispatch({ + type: CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP, + payload: {expectedRouteName: routeNameToRemove}, + }); + return; + } + + // Skip this if a buffer transaction is still live - something else already resets the tab and + // buffer together in one step. Doing it here too would race that and briefly show the wrong screen. + if (bufferTransaction) { + return; + } + + // RHP already dismissed. For the tab-switch path, jump back to the original tab. + // For the push path, pop the pre-inserted route directly. + const originalTabRoute = getPreInsertedOriginalTabRoute(); + if (originalTabRoute) { + clearPreInsertedOriginalTabRoute(); + const originalTabState = originalTabRoute.state; + const originalFocusedTabIndex = originalTabState?.index ?? 0; + const originalTabName = originalTabState?.routes?.[originalFocusedTabIndex]?.name; + if (originalTabName) { + requestAnimationFrame(() => { + const currentState = navigationRef.getRootState(); + const tabNavRoute = currentState?.routes.findLast((r) => r.name === NAVIGATORS.TAB_NAVIGATOR); + if (!tabNavRoute?.state?.key) { + return; + } + navigationRef.current?.dispatch({ + ...TabActions.jumpTo(originalTabName), + target: tabNavRoute.state.key, + }); + }); + } + return; + } + + // Push path: the pre-inserted fullscreen is now the topmost route; pop it. + // Deferred to the next frame to avoid dispatching during a React commit. + // Capture the route key now so the rAF callback can match on identity, not just name. + const targetRouteKey = rootState.routes.at(-1)?.key; + requestAnimationFrame(() => { + const currentState = navigationRef.getRootState(); + const topmostRoute = currentState?.routes.at(-1); + if (!topmostRoute || topmostRoute.key !== targetRouteKey || topmostRoute.name !== routeNameToRemove) { + return; + } + if (!navigationRef.current?.canGoBack()) { + return; + } + navigationRef.current.goBack(); + }); +} + +export { + canNativeSwipeDismissRHP, + captureBufferTransaction, + clearFullscreenPreInsertedFlag, + getIsFullscreenPreInsertedUnderRHP, + getPreInsertedFullscreenRouteName, + markFullscreenPreInsertedUnderRHP, + recoverFromPreMountBuffer, + removePreInsertedFullscreenIfNeeded, +}; diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index eb5d4f9cd26f..052db2eb6b28 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -129,7 +129,7 @@ const SETTINGS_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN.route, exact: true, }, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS]: DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.path, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS]: DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS.path, + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN]: DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN.path, [SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: { path: ROUTES.SETTINGS_WALLET_SHARE_BANK_ACCOUNT.route, exact: true, @@ -2024,6 +2027,7 @@ const config: LinkingOptions['config'] = { }, }, [SCREENS.RIGHT_MODAL.SEARCH_SAVE]: ROUTES.SEARCH_SAVE, + [SCREENS.RIGHT_MODAL.BETA_OVERRIDES]: ROUTES.SETTINGS_TROUBLESHOOT_BETA_OVERRIDES, [SCREENS.RIGHT_MODAL.SEARCH_SAVED_SEARCH]: { screens: { [SCREENS.SEARCH.SAVED_SEARCH_RENAME_RHP]: ROUTES.SEARCH_SAVED_SEARCH_RENAME.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index e52e20a62a92..9130fe65d7bd 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -203,12 +203,35 @@ type SettingsNavigatorParamList = { bankAccountID: string; subPage: string; action?: 'edit'; + bankCountry?: string; + bankCurrency?: string; }; [SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS]: { bankAccountID: string; + bankCountry?: string; + bankCurrency?: string; }; [SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN]: { bankAccountID: string; + bankCountry?: string; + bankCurrency?: string; + }; + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS]: { + bankAccountID: string; + subPage: string; + action?: 'edit'; + bankCountry?: string; + bankCurrency?: string; + }; + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS]: { + bankAccountID: string; + bankCountry?: string; + bankCurrency?: string; + }; + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN]: { + bankAccountID: string; + bankCountry?: string; + bankCurrency?: string; }; [SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: { bankAccountID: string; @@ -2800,6 +2823,7 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.RESTRICTED_ACTION]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.SEARCH_ADVANCED_FILTERS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.SEARCH_SAVE]: undefined; + [SCREENS.RIGHT_MODAL.BETA_OVERRIDES]: undefined; [SCREENS.RIGHT_MODAL.SEARCH_SAVED_SEARCH]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.MISSING_PERSONAL_DETAILS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.DEBUG]: NavigatorScreenParams; @@ -2886,6 +2910,11 @@ type ReportsSplitNavigatorParamList = { referrer?: string; /** Submit-via-PDF secure access link key. When present, the viewer is validated and joined to the report. */ secureKey?: string; + /** + * Set when reportID is a client-generated optimistic ID for a chat that doesn't exist on the server yet + * Suppresses openReport until the report exists locally. + */ + isPendingCreation?: string; /** When 'true', a money-request report opens scrolled to its latest message instead of the top (used by the "X Replies" link). */ shouldScrollToLatest?: string; // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md @@ -3346,6 +3375,7 @@ type AuthScreensParamList = SharedScreensParamList & [NAVIGATORS.SHARE_MODAL_NAVIGATOR]: NavigatorScreenParams; [SCREENS.BANK_CONNECTION_COMPLETE]: undefined; [NAVIGATORS.TEST_TOOLS_MODAL_NAVIGATOR]: NavigatorScreenParams; + [SCREENS.PRE_MOUNT_BUFFER]: undefined; }; type SearchReportActionsParamList = { diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 66cc2e2cd991..4dc08babb22f 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -1,9 +1,22 @@ +import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import type Beta from '@src/types/onyx/Beta'; import type BetaConfiguration from '@src/types/onyx/BetaConfiguration'; +import type BetaOverrides from '@src/types/onyx/BetaOverrides'; import type {OnyxEntry} from 'react-native-onyx'; +import {isProduction} from './Environment/Environment'; + +// Start from the synchronous config so overrides never apply in production, then refine with the resolved +// environment, which downgrades TestFlight builds to staging +let isProductionEnvironment = CONFIG.ENVIRONMENT === CONST.ENVIRONMENT.PRODUCTION; +isProduction() + .then((value) => { + isProductionEnvironment = value; + }) + .catch(() => {}); + // eslint-disable-next-line rulesdir/no-beta-handler function canUseAllBetas(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.ALL); @@ -16,7 +29,14 @@ function canUseLinkPreviews(): boolean { return false; } -function isBetaEnabled(beta: Beta, betas: OnyxEntry, betaConfiguration?: OnyxEntry): boolean { +function isBetaEnabled(beta: Beta, betas: OnyxEntry, betaConfiguration?: OnyxEntry, betaOverrides?: OnyxEntry): boolean { + if (!isProductionEnvironment) { + const override = betaOverrides?.[beta]; + if (override !== undefined) { + return override; + } + } + const hasAllBetasEnabled = canUseAllBetas(betas); const isFeatureEnabled = !!betas?.includes(beta); diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index b8859b01617c..a2c2f0f6a0ed 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2017,9 +2017,11 @@ function isSelfDMOrSelfDMThread(report: OnyxEntry, currentUserAccountID? } /** - * Returns true if the report is an expense report, a group policy, a self-DM, or the iouType is create, and the iouType is not split or invoice. + * Returns true if negative amounts are supported: the report is an expense report, the policy is a group policy, the report is a self-DM, + * the iouType is create with no P2P recipient selected, or it is the first time creating a report (submit with no report, policy or P2P + * recipient). Split and invoice never support negative amounts. */ -function shouldEnableNegative(report: OnyxEntry, policy?: OnyxEntry, iouType?: string, participants?: Participant[], isNewManualExpenseFlow = false) { +function shouldEnableNegative(report: OnyxEntry, policy?: OnyxEntry, iouType?: string, participants?: Participant[]) { const isSelfDMReport = isSelfDMOrSelfDMThread(report); const isUserInRecipients = participants?.some((participant) => !participant.isSender && !participant.isPolicyExpenseChat && participant.accountID); @@ -2027,7 +2029,7 @@ function shouldEnableNegative(report: OnyxEntry, policy?: OnyxEntry; hasActiveAdminPolicies: boolean; + hasOwnedPaidPolicy: boolean; isAnnualSubscription?: boolean; }; @@ -638,6 +640,7 @@ function createWorkspaceWithPolicyDraftAndNavigateToIt(params: CreateWorkspaceWi isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, isAnnualSubscription = false, } = params; @@ -678,6 +681,7 @@ function createWorkspaceWithPolicyDraftAndNavigateToIt(params: CreateWorkspaceWi isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, isAnnualSubscription, }); @@ -718,6 +722,7 @@ function createWorkspaceWithPolicyDraft(params: CreateWorkspaceWithPolicyDraftPa isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, } = params; createDraftInitialWorkspace({ @@ -747,6 +752,7 @@ function createWorkspaceWithPolicyDraft(params: CreateWorkspaceWithPolicyDraftPa isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, }); } @@ -769,6 +775,7 @@ type SavePolicyDraftByNewWorkspaceParams = { type?: PolicyType; betas: OnyxEntry; hasActiveAdminPolicies: boolean; + hasOwnedPaidPolicy: boolean; isAnnualSubscription?: boolean; }; @@ -794,6 +801,7 @@ function savePolicyDraftByNewWorkspace({ isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, isAnnualSubscription = false, }: SavePolicyDraftByNewWorkspaceParams) { createWorkspace({ @@ -816,6 +824,7 @@ function savePolicyDraftByNewWorkspace({ isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, isAnnualSubscription, }); } @@ -848,6 +857,7 @@ type SetUpPoliciesAndNavigateParams = { conciergeChat: OnyxEntry; policyOwnerAccountID: number | undefined; policyOwnerDisplayName: string | undefined; + hasOwnedPaidPolicy: boolean; }; function setUpPoliciesAndNavigate({ @@ -858,6 +868,7 @@ function setUpPoliciesAndNavigate({ isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, lastWorkspaceNumber, translate, conciergeChat, @@ -898,6 +909,7 @@ function setUpPoliciesAndNavigate({ isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, }); return; } @@ -1006,6 +1018,13 @@ function showSupportalPermissionDenied(payload: OnyxTypes.SupportalPermissionDen Onyx.set(ONYXKEYS.SUPPORTAL_PERMISSION_DENIED, payload); } +/** + * Clears the Corpay pay modal signal for the current session. + */ +function clearCorpayPayModal() { + Onyx.set(ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL, null); +} + export { setLocale, setSidebarLoaded, @@ -1026,6 +1045,7 @@ export { clearOnyxAndResetApp, clearSupportalPermissionDenied, showSupportalPermissionDenied, + clearCorpayPayModal, setPreservedUserSession, getNonOptimisticPolicyIDs, setPreservedAccount, diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index 3ead161e3afe..245ebafd4b51 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -51,6 +51,7 @@ const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [ ONYXKEYS.NETWORK, ONYXKEYS.ACTIVE_SERVER, ONYXKEYS.IS_DEBUG_MODE_ENABLED, + ONYXKEYS.BETA_OVERRIDES, ONYXKEYS.COLLECTION.PASSKEY_CREDENTIALS, ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS, diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index 7cfdfa6e424c..def7b3e2b9a7 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -574,7 +574,7 @@ function requestDomainAdminship(domainAccountID: number, currentUserAccountID: n { onyxMethod: Onyx.METHOD.MERGE, key: domainErrorsKey, - value: {requestAdminshipError: getMicroSecondOnyxErrorWithTranslationKey('domain.domainAlreadyExists.requestAccessError')}, + value: {requestAdminshipError: getMicroSecondOnyxErrorWithTranslationKey('domain.requestAccessError')}, }, ]; diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index 19bcda02336f..fa68d17714fa 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -248,6 +248,9 @@ function getPayMoneyRequestParams({ isSelfTourViewed, // hasActiveAdminPolicies is only needed if lastUsedPaymentMethod is passed hasActiveAdminPolicies: undefined, + // This workspace is created by the invoice payment command, which does not apply CreatePolicy's + // paid-workspace check, so the #admins room keeps starting out pinned here. + hasOwnedPaidPolicy: undefined, }); const {adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, expenseCreatedReportActionID, customUnitRateID, customUnitID, ownerEmail, policyName} = params; diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index de9744e7cceb..f7ae3d5fb8dd 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -1035,6 +1035,9 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T conciergeChat, // hasActiveAdminPolicies is only needed if lastUsedPaymentMethod is passed hasActiveAdminPolicies: undefined, + // This workspace is created by AddTrackedExpenseToPolicy, which does not apply CreatePolicy's + // paid-workspace check, so the #admins room keeps starting out pinned here. + hasOwnedPaidPolicy: undefined, betas, isSelfTourViewed, }); diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index 9549ebaa87e4..690f913020b0 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -481,6 +481,8 @@ function openReportFromDeepLink( introSelected, // Unauthenticated public-room path: there is no signed-in user, so no Concierge chat exists to thread. conciergeChat: undefined, + // The public room already exists on the server, so no optimistic report is created and the personal details are never read. + personalDetails: undefined, parentReportActionID: '0', isFromDeepLink: true, betas, diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts index 156754d0a350..5ac284da37ea 100644 --- a/src/libs/actions/OnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdates.ts @@ -22,10 +22,12 @@ let lastUpdateIDAppliedToClient: number | undefined = 0; // Highest update ID staged for the deferred WRITE flush but not yet persisted. Gap detection treats these as // applied so queued WRITE responses don't look like gaps; reset if the flush fails so recovery can kick in. -let lastUpdateIDPendingFlush = 0; +let lastUpdateIDPendingWriteFlush = 0; + +let lastUpdateIDPendingPusherApply = 0; function getEffectiveLastUpdateID(): number { - return Math.max(lastUpdateIDAppliedToClient ?? 0, lastUpdateIDPendingFlush); + return Math.max(lastUpdateIDAppliedToClient ?? 0, lastUpdateIDPendingWriteFlush); } function getPersistedLastUpdateID(): number { @@ -41,7 +43,8 @@ Onyx.connectWithoutView({ // The persisted watermark is only ever cleared by Onyx.clear (sign-out), so drop the pending marker // too — a stale value from the previous session would mask real gaps after signing back in. if (val === undefined) { - lastUpdateIDPendingFlush = 0; + lastUpdateIDPendingWriteFlush = 0; + lastUpdateIDPendingPusherApply = 0; } }, }); @@ -203,12 +206,19 @@ function apply({lastUpdateID, type, request, response, upd Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, Number(lastUpdateID)); } // The persisted watermark now covers the staged WRITE updates, so the pending marker is no longer needed - if (lastUpdateIDPendingFlush && lastUpdateIDPendingFlush <= Number(lastUpdateID)) { - lastUpdateIDPendingFlush = 0; + if (lastUpdateIDPendingWriteFlush && lastUpdateIDPendingWriteFlush <= Number(lastUpdateID)) { + lastUpdateIDPendingWriteFlush = 0; + } + if (lastUpdateIDPendingPusherApply && lastUpdateIDPendingPusherApply <= Number(lastUpdateID)) { + lastUpdateIDPendingPusherApply = 0; } return result; }) .catch((error) => { + // Intentionally cleared for any failed apply, including HTTPS and Airship: the marker is a flat max, so + // keeping it after an unrelated lower-ID failure would mask that gap. Errs toward a redundant refetch. + lastUpdateIDPendingPusherApply = 0; + if (shouldAdvanceLastUpdateID) { Log.alert('[OnyxUpdateManagerError] Applying the updates failed, not advancing lastUpdateID so the client can recover on the next reconnect', { type, @@ -229,18 +239,22 @@ function apply({lastUpdateID, type, request, response, upd // SequentialQueue only flushes after this promise settles, so awaiting the flush here would deadlock. if (request.data?.apiRequestType === CONST.API_REQUEST_TYPE.WRITE) { if (shouldAdvanceLastUpdateID) { - lastUpdateIDPendingFlush = Math.max(lastUpdateIDPendingFlush, Number(lastUpdateID)); + lastUpdateIDPendingWriteFlush = Math.max(lastUpdateIDPendingWriteFlush, Number(lastUpdateID)); } advanceLastUpdateIDAfterApply(applyPromise.then(() => getCurrentFlushPromise())).catch(() => { // The staged updates never applied, so stop counting them as pending — the next gap check // then sees the missing range against the persisted watermark and triggers recovery. - lastUpdateIDPendingFlush = 0; + lastUpdateIDPendingWriteFlush = 0; }); return applyPromise; } return advanceLastUpdateIDAfterApply(applyPromise); } if (type === CONST.ONYX_UPDATE_TYPES.PUSHER && updates) { + if (shouldAdvanceLastUpdateID) { + lastUpdateIDPendingPusherApply = Math.max(lastUpdateIDPendingPusherApply, Number(lastUpdateID)); + } + return advanceLastUpdateIDAfterApply(applyPusherOnyxUpdates(updates, Number(lastUpdateID))); } if (type === CONST.ONYX_UPDATE_TYPES.AIRSHIP && updates) { @@ -268,23 +282,32 @@ function saveUpdateInformation(updateParams: OnyxUpdatesFr type DoesClientNeedToBeUpdatedParams = { clientLastUpdateID?: number; previousUpdateID?: number; + updateType?: AnyOnyxUpdatesFromServer['type']; }; +function isSerializedBehindPusherApply(updateType?: AnyOnyxUpdatesFromServer['type']): boolean { + return updateType === CONST.ONYX_UPDATE_TYPES.PUSHER; +} + /** * This function will receive the previousUpdateID from any request/pusher update that has it, compare to our current app state * and return if an update is needed * @param previousUpdateID The previousUpdateID contained in the response object * @param clientLastUpdateID an optional override for the lastUpdateIDAppliedToClient + * @param updateType the transport the update being checked arrived on */ -function doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID}: DoesClientNeedToBeUpdatedParams): boolean { +function doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID, updateType}: DoesClientNeedToBeUpdatedParams): boolean { // If no previousUpdateID is sent, this is not a WRITE request so we don't need to update our current state if (!previousUpdateID) { return false; } - // Updates staged for the deferred WRITE flush count as applied here, otherwise the responses of queued - // WRITE requests would look like gaps until the flush runs and needlessly pause the queue to refetch. - const lastUpdateIDFromClient = Math.max(clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? 0, lastUpdateIDPendingFlush); + // QueuedOnyxUpdates defers the Onyx write for WRITE requests, so their own responses arrive before the watermark moves. + const lastUpdateIDFromClient = Math.max( + clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? 0, + lastUpdateIDPendingWriteFlush, + isSerializedBehindPusherApply(updateType) ? lastUpdateIDPendingPusherApply : 0, + ); // If we don't have any value in lastUpdateIDFromClient, this is the first time we're receiving anything, so we need to do a last reconnectApp if (!lastUpdateIDFromClient) { diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 9379ef73584a..cb320ca6cde1 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -231,6 +231,8 @@ type CreateWorkspaceFromIOUPaymentOptions = { reportActionsList: OnyxCollection; doesEmployeePersonalDetailExist: boolean; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + /** Whether the current user already owns a paid workspace. CreatePolicy leaves the #admins room unpinned when they do. */ + hasOwnedPaidPolicy: boolean; }; type PolicyCashExpenseMode = ValueOf; @@ -281,6 +283,8 @@ type BuildPolicyDataOptions = { // TODO: Make it required once we complete refactoring the buildPolicyData function to use isSelfTourViewed. Refactor issue: https://github.com/Expensify/App/issues/66424 isSelfTourViewed?: boolean; hasActiveAdminPolicies: boolean | undefined; + /** Whether the current user already owns a paid workspace. CreatePolicy leaves the #admins room unpinned when they do. */ + hasOwnedPaidPolicy: boolean | undefined; betas?: OnyxEntry; personalTrackGoal?: string; }; @@ -2751,6 +2755,7 @@ function buildPolicyData(options: BuildPolicyDataOptions): OnyxData) { +/** + * Marks every unread report as read. Pass `reportIDs` to limit it to a subset, e.g. only the reports listed under one + * Inbox tab; when omitted, every unread report is marked read. + */ +function markAllMessagesAsRead(reportNameValuePairs: OnyxCollection, reportIDs?: string[]) { if (isAnonymousUser()) { return; } @@ -46,7 +50,8 @@ function markAllMessagesAsRead(reportNameValuePairs: OnyxCollection = {}; const failureReports: Record = {}; const reportIDList: string[] = []; - for (const report of Object.values(allReports ?? {})) { + const reportsToMark = reportIDs ? reportIDs.map((reportID) => allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]) : Object.values(allReports ?? {}); + for (const report of reportsToMark) { if (!report) { continue; } diff --git a/src/libs/actions/Report/PreMountedDraftReport.ts b/src/libs/actions/Report/PreMountedDraftReport.ts new file mode 100644 index 000000000000..8fb957f305cb --- /dev/null +++ b/src/libs/actions/Report/PreMountedDraftReport.ts @@ -0,0 +1,36 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import type {OnyxMultiSetInput} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +/** + * Copies an already-built draft report into COLLECTION.REPORT so a pre-mounted destination screen can render immediately. + * Also writes a persisted marker so an interrupted flow (app killed before submit) can be cleaned up on the next launch. + */ +function preMountDraftReport(reportID: string, draftReport: Report) { + const preMountData: OnyxMultiSetInput = {}; + preMountData[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] = draftReport; + preMountData[`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`] = true; + return Onyx.multiSet(preMountData); +} + +/** + * Removes a report created by `preMountDraftReport`, for when the caller backs out before submission actually happens. + */ +function clearPreMountedDraftReport(reportID: string) { + return Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: null, + [`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`]: null, + }); +} + +/** + * Clears only the pre-mount marker left by `preMountDraftReport`. + */ +function clearPreMountedDraftReportMarker(reportID: string) { + return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`, null); +} + +export {preMountDraftReport, clearPreMountedDraftReport, clearPreMountedDraftReportMarker}; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index fae3da3e9be2..453c279c1937 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -1679,7 +1679,6 @@ function openReport(params: OpenReportActionParams) { } const participantLoginList = participants.map((p) => p.login).filter((login) => !!login); - // TODO: allPersonalDetails fallback should be removed in follow-up PRs https://github.com/Expensify/App/issues/73656 const participantAccountIDList = participants.map((p) => p.accountID).filter((id): id is number => id !== undefined); const existingReportName = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportName; const isCreatingNewReport = !isEmptyObject(newReportObject); @@ -1980,6 +1979,7 @@ function openReport(params: OpenReportActionParams) { const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins(participantLoginList); for (const [index, login] of participantLoginList.entries()) { const accountID = participantAccountIDs.at(index) ?? -1; + // TODO: allPersonalDetails fallback should be removed in follow-up PRs https://github.com/Expensify/App/issues/73656 const isOptimisticAccount = !(personalDetails ?? allPersonalDetails)?.[accountID]; if (!isOptimisticAccount) { @@ -2611,7 +2611,17 @@ function navigateToAndOpenReport({ if (!shouldRevalidateExistingChat) { if (isOnboardingPending) { - openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, betas, hasReportActions, currentUserAccountID, conciergeChat}); + openReport({ + reportID: chat.reportID, + introSelected, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + betas, + personalDetails, + hasReportActions, + currentUserAccountID, + conciergeChat, + }); } navigateToReport(chat.reportID, {shouldDismissModal, ...linkToOptions}); return; @@ -2639,7 +2649,7 @@ function navigateToAndOpenReport({ // Re-open existing chats to re-validate server-side access and refresh stale local state. Pass hasCompletedGuidedSetupFlow // so a pending onboarding OpenReport is enqueued here too (see the create-path assumption note above). - openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, betas, hasReportActions, currentUserAccountID, conciergeChat}); + openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, betas, personalDetails, hasReportActions, currentUserAccountID, conciergeChat}); navigateToReport(chat.reportID, {shouldDismissModal, ...linkToOptions}); } @@ -2777,7 +2787,7 @@ function navigateToAndOpenReportWithAccountIDs( }); // Re-open existing chats to re-validate server-side access and refresh stale local state. - openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, betas, hasReportActions, currentUserAccountID, conciergeChat}); + openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, betas, personalDetails, hasReportActions, currentUserAccountID, conciergeChat}); navigateToReport(chat.reportID, {shouldDismissModal: false}); } @@ -3876,7 +3886,7 @@ function toggleSubscribeToChildReport({ hasReportActions, }: ToggleSubscribeToChildReportParams) { if (childReportID) { - openReport({reportID: childReportID, introSelected, betas, isSelfTourViewed, hasCompletedGuidedSetupFlow, hasReportActions, currentUserAccountID, conciergeChat}); + openReport({reportID: childReportID, introSelected, betas, personalDetails, isSelfTourViewed, hasCompletedGuidedSetupFlow, hasReportActions, currentUserAccountID, conciergeChat}); const parentReportActionID = parentReportAction.reportActionID; if (!prevNotificationPreference || isHiddenForCurrentUser(prevNotificationPreference)) { updateNotificationPreference( @@ -4869,11 +4879,11 @@ function navigateToConciergeChatAndDeleteReport( ); } -function cleanUpOptimisticPersonalDetailsForFailedChat(report: OnyxEntry, currentUserAccountID: number) { +function cleanUpOptimisticPersonalDetailsForFailedChat(report: OnyxEntry, currentUserAccountID: number, optimisticPersonalDetails: OnyxEntry) { const personalDetailsToRemove: PersonalDetailsList = {}; for (const accountID of Object.keys(report?.participants ?? {}).map(Number)) { - if (accountID === currentUserAccountID || !allPersonalDetails?.[accountID]?.isOptimisticPersonalDetail) { + if (accountID === currentUserAccountID || !optimisticPersonalDetails?.[accountID]?.isOptimisticPersonalDetail) { continue; } personalDetailsToRemove[accountID] = null; @@ -4896,6 +4906,7 @@ function clearCreateChatError( reportOwnerPersonalDetail: OnyxEntry, currentUserPersonalDetail: OnyxEntry, conciergePersonalDetail: OnyxEntry, + optimisticPersonalDetails: OnyxEntry, ) { const metaData = getReportMetadata(report?.reportID); const isOptimisticReport = metaData?.isOptimisticReport; @@ -4905,7 +4916,7 @@ function clearCreateChatError( } if (report?.errorFields?.createChat && isOptimisticReport) { - cleanUpOptimisticPersonalDetailsForFailedChat(report, currentUserAccountID); + cleanUpOptimisticPersonalDetailsForFailedChat(report, currentUserAccountID, optimisticPersonalDetails); } navigateToConciergeChatAndDeleteReport( diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 2c7babb5e485..f0862fb1ad7a 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -363,6 +363,7 @@ const KEYS_TO_PRESERVE_SUPPORTAL = [ ONYXKEYS.NETWORK, ONYXKEYS.ACTIVE_SERVER, ONYXKEYS.IS_DEBUG_MODE_ENABLED, + ONYXKEYS.BETA_OVERRIDES, // Preserve IS_USING_IMPORTED_STATE so that when transitioning to/from supportal, // we know if we're in imported state mode and should skip API calls that would cause infinite loading diff --git a/src/libs/actions/SignInRedirect.ts b/src/libs/actions/SignInRedirect.ts index 643f5ce1bff1..4d16f6b40362 100644 --- a/src/libs/actions/SignInRedirect.ts +++ b/src/libs/actions/SignInRedirect.ts @@ -60,6 +60,7 @@ function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: keysToPreserve.push(ONYXKEYS.DEVICE_ID); keysToPreserve.push(ONYXKEYS.ACTIVE_SERVER); keysToPreserve.push(ONYXKEYS.IS_DEBUG_MODE_ENABLED); + keysToPreserve.push(ONYXKEYS.BETA_OVERRIDES); keysToPreserve.push(ONYXKEYS.COLLECTION.PASSKEY_CREDENTIALS); keysToPreserve.push(ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS); diff --git a/src/libs/actions/TransactionInlineEdit.ts b/src/libs/actions/TransactionInlineEdit.ts index efc1aa20bf0e..cc6b76d91f14 100644 --- a/src/libs/actions/TransactionInlineEdit.ts +++ b/src/libs/actions/TransactionInlineEdit.ts @@ -4,7 +4,6 @@ import {isCategoryMissing} from '@libs/CategoryUtils'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import {isValidMerchant, isValidMoneyRequestAmount} from '@libs/MoneyRequestUtils'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; -import Permissions from '@libs/Permissions'; import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; import {getTagLists, isGroupPolicy, isMultiLevelTags, resolveCurrentTaxCode} from '@libs/PolicyUtils'; import {isMoneyRequestAction} from '@libs/ReportActionsUtils'; @@ -142,9 +141,12 @@ type GetIouParamsInput = { /** Violations for the transaction being edited plus any of its duplicates, scoped by the caller. */ transactionViolations: OnyxCollection; - /** Betas the current user has access to, used to gate ASAP submit behavior. */ + /** Betas the current user has access to, forwarded when a transaction thread report has to be built. */ betas: Beta[] | undefined; + /** Resolved by the caller through usePermissions so local beta overrides apply here too. */ + isASAPSubmitBetaEnabled: boolean; + /** Onboarding intro data, needed to build a transaction thread report when one doesn't exist yet. */ introSelected: OnyxEntry; @@ -190,6 +192,7 @@ function getIouParamsForTransaction({ getCurrencySymbol, transactionViolations, betas, + isASAPSubmitBetaEnabled, introSelected, currentUserAccountID, currentUserEmail, @@ -229,7 +232,7 @@ function getIouParamsForTransaction({ policyCategories, currentUserAccountIDParam: currentUserAccountID, currentUserEmailParam: currentUserEmail, - isASAPSubmitBetaEnabled: Permissions.isBetaEnabled(CONST.BETAS.ASAP_SUBMIT, betas), + isASAPSubmitBetaEnabled, delegateAccountID, isTrackIntentUser, getCurrencyDecimals, diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 2b3686907312..68d7691c9eb5 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -44,7 +44,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {ExpenseRuleForm, FlagForReviewRuleForm, MerchantRuleForm, MerchantTypeRuleForm, RequireFieldsRuleForm, SpendRuleForm} from '@src/types/form'; -import type {AppReview, BlockedFromConcierge, CustomStatusDraft, ExpenseRule, NewLogin, ReportAttributesDerivedValue} from '@src/types/onyx'; +import type {AppReview, Beta, BlockedFromConcierge, CustomStatusDraft, ExpenseRule, NewLogin, ReportAttributesDerivedValue} from '@src/types/onyx'; import type Login from '@src/types/onyx/Login'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type {AnyOnyxServerUpdate, OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; @@ -1319,6 +1319,18 @@ function setShouldShowBranchNameInTitle(value: boolean) { Onyx.set(ONYXKEYS.SHOULD_SHOW_BRANCH_NAME_IN_TITLE, value); } +function setBetaOverride(beta: Beta, value: boolean) { + Onyx.merge(ONYXKEYS.BETA_OVERRIDES, {[beta]: value}); +} + +function clearBetaOverride(beta: Beta) { + Onyx.merge(ONYXKEYS.BETA_OVERRIDES, {[beta]: null}); +} + +function clearBetaOverrides() { + Onyx.set(ONYXKEYS.BETA_OVERRIDES, null); +} + function lockAccount(currentUserAccountID: number, accountID: number | undefined, domainAccountID: number | undefined, domainName: string | undefined) { let domainOptimisticData: DomainOnyxUpdate[] = []; let domainFailureData: DomainOnyxUpdate[] = []; @@ -1949,6 +1961,9 @@ export { clearValidateCodeActionError, setIsDebugModeEnabled, setShouldShowBranchNameInTitle, + setBetaOverride, + clearBetaOverride, + clearBetaOverrides, lockAccount, requestUnlockAccount, respondToProactiveAppReview, diff --git a/src/libs/actions/applyOnyxUpdatesReliably.ts b/src/libs/actions/applyOnyxUpdatesReliably.ts index 11b276ec1eab..1204577b892d 100644 --- a/src/libs/actions/applyOnyxUpdatesReliably.ts +++ b/src/libs/actions/applyOnyxUpdatesReliably.ts @@ -48,7 +48,7 @@ export default function applyOnyxUpdatesReliably( } const previousUpdateID = Number(updates.previousUpdateID) ?? CONST.DEFAULT_NUMBER_ID; - if (!doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID})) { + if (!doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID, updateType: updates.type})) { return onyxApply(updates).then(); } diff --git a/src/libs/actions/replaceOptimisticReportWithActualReport.ts b/src/libs/actions/replaceOptimisticReportWithActualReport.ts index 4f94629c2236..67f9b83e4b91 100644 --- a/src/libs/actions/replaceOptimisticReportWithActualReport.ts +++ b/src/libs/actions/replaceOptimisticReportWithActualReport.ts @@ -226,7 +226,16 @@ function replaceOptimisticReportWithActualReport(report: Report, draftReportComm // betas and conciergeChat are safe to pass as undefined because introSelected is undefined, so the // guided-setup code path that uses them is never reached. Passing them explicitly so the compiler // flags this when they become required. Refactor issues: https://github.com/Expensify/App/issues/66424 - openReport({reportID: parentReportID, introSelected: undefined, betas: undefined, conciergeChat: undefined, hasReportActions, currentUserAccountID}); + // personalDetails is undefined because the parent report already exists, so no optimistic report is created and they are never read. + openReport({ + reportID: parentReportID, + introSelected: undefined, + betas: undefined, + conciergeChat: undefined, + personalDetails: undefined, + hasReportActions, + currentUserAccountID, + }); }); } else { callback(); @@ -235,7 +244,16 @@ function replaceOptimisticReportWithActualReport(report: Report, draftReportComm // betas and conciergeChat are safe to pass as undefined because introSelected is undefined, so the // guided-setup code path that uses them is never reached. Passing them explicitly so the compiler // flags this when they become required. Refactor issues: https://github.com/Expensify/App/issues/66424 - openReport({reportID: parentReportID, introSelected: undefined, betas: undefined, conciergeChat: undefined, hasReportActions, currentUserAccountID}); + // personalDetails is undefined because the parent report already exists, so no optimistic report is created and they are never read. + openReport({ + reportID: parentReportID, + introSelected: undefined, + betas: undefined, + conciergeChat: undefined, + personalDetails: undefined, + hasReportActions, + currentUserAccountID, + }); } return; } diff --git a/src/libs/cleanupPreMountedDraftReports.ts b/src/libs/cleanupPreMountedDraftReports.ts new file mode 100644 index 000000000000..fdb0b8c3c198 --- /dev/null +++ b/src/libs/cleanupPreMountedDraftReports.ts @@ -0,0 +1,78 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxMultiSetInput} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +/** + * Startup cleanup for speculative report rows left by interrupted draft pre-mounts. + * REPORT_PRE_MOUNTED_DRAFT holds no report data, only a boolean marker saying report_ was copied from reportDraft_ + * by preMountDraftReport. The draft itself lives in REPORT_DRAFT (written by createDraftWorkspace) until submit runs + * the real CreateWorkspace, which clears the draft and takes over report_. + */ +function getPreMountedDraftReportCleanupData(markers: OnyxCollection, reportDrafts: OnyxCollection): OnyxMultiSetInput { + const cleanupData: OnyxMultiSetInput = {}; + + for (const key of Object.keys(markers ?? {})) { + const reportID = key.slice(ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT.length); + const markerKey: `${typeof ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${string}` = `${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`; + cleanupData[markerKey] = null; + + // If the draft still exists, submit never ran (CreateWorkspace clears the draft on submit), so report_ is still + // just our speculative copy and is safe to delete. If the draft is gone, submit happened and report_ is real now. + if (reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportID}`]) { + const reportKey: `${typeof ONYXKEYS.COLLECTION.REPORT}${string}` = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; + cleanupData[reportKey] = null; + } + } + + return cleanupData; +} + +/** + * Reads the draft reports once to decide which speculative rows are safe to delete. Runs before React mounts, + * outside any component, so there's no hook available - takes a single snapshot instead and disconnects + * immediately, since nothing here renders or needs to stay subscribed. + */ +function cleanupReportDrafts(markers: OnyxCollection) { + let reportDraftsConnection: ReturnType; + + function handleReportDrafts(reportDrafts: OnyxCollection) { + Onyx.disconnect(reportDraftsConnection); + Onyx.multiSet(getPreMountedDraftReportCleanupData(markers, reportDrafts)); + } + + reportDraftsConnection = Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.REPORT_DRAFT, + callback: handleReportDrafts, + }); +} + +/** + * Clears speculative report rows left by an interrupted draft pre-mount. This runs before React mounts because + * an app termination does not run component cleanup. A missing draft means submission already converted the draft, + * so only the stale pre-mount marker is removed and the real report is preserved. + */ +function cleanupPreMountedDraftReports() { + // Same reasoning as cleanupReportDrafts above: no hook available yet, so take a single snapshot + // and disconnect immediately. + let markersConnection: ReturnType; + + function handleMarkers(markers: OnyxCollection) { + Onyx.disconnect(markersConnection); + if (!Object.keys(markers ?? {}).length) { + return; + } + + cleanupReportDrafts(markers); + } + + markersConnection = Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT, + callback: handleMarkers, + }); +} + +export {getPreMountedDraftReportCleanupData}; +export default cleanupPreMountedDraftReports; diff --git a/src/libs/fileDownload/processPickedAssets/index.native.ts b/src/libs/fileDownload/processPickedAssets/index.native.ts new file mode 100644 index 000000000000..129cf5589f23 --- /dev/null +++ b/src/libs/fileDownload/processPickedAssets/index.native.ts @@ -0,0 +1,132 @@ +import {getFileName, verifyFileFormat} from '@libs/fileDownload/FileUtils'; +import Log from '@libs/Log'; + +import CONST from '@src/CONST'; + +import type {Asset} from 'react-native-image-picker'; + +import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; + +import type ProcessPickedAssetsFunction from './types'; + +/** + * Ensures asset has proper fileName and type properties. Callers only pass assets that have a `uri`. + */ +function processAssetWithFallbacks(asset: Asset): Asset { + return { + ...asset, + fileName: asset.fileName ?? getFileName(asset.uri ?? ''), + // Default to JPEG if no type specified + type: asset.type ?? 'image/jpeg', + }; +} + +function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} + +/** + * Frees a native image resource. Releasing is best-effort cleanup, so a failure here must never change + * whether the converted asset is kept. + */ +function releaseQuietly(releasable: {release: () => void}) { + try { + releasable.release(); + } catch (error) { + Log.warn('Failed to release native image resource', {error: getErrorMessage(error, 'An unknown error occurred')}); + } +} + +/** + * Transcodes a single HEIC image to JPEG, returning `undefined` if the conversion fails. + * + * The native context and the rendered bitmap are released as soon as they are no longer needed rather + * than waiting for the garbage collector, which has no visibility into the native memory they retain. + * + * This repeats the manipulate/render/save sequence from `heicConverter` rather than calling it, because + * that helper decides what to convert from the `.heic`/`.heif` extension. react-native-image-picker + * relabels the extension without transcoding, so this path has to detect HEIC from the file signature + * instead and cannot go through `convertHeicImage`. + */ +async function convertHeicToJpeg(uri: string): Promise { + const imageManipulatorContext = ImageManipulator.manipulate(uri); + try { + const manipulatedImage = await imageManipulatorContext.renderAsync(); + try { + const manipulationResult = await manipulatedImage.saveAsync({format: SaveFormat.JPEG}); + return { + uri: manipulationResult.uri, + fileName: getFileName(manipulationResult.uri), + type: 'image/jpeg', + width: manipulationResult.width, + height: manipulationResult.height, + }; + } finally { + releaseQuietly(manipulatedImage); + } + } catch (error) { + Log.warn('Failed to convert HEIC image, skipping asset', {error: getErrorMessage(error, 'An unknown error occurred')}); + return undefined; + } finally { + releaseQuietly(imageManipulatorContext); + } +} + +/** + * Convert the picked assets one at a time, transcoding any HEIC images to JPEG. + * + * The conversion is deliberately sequential: `ImageManipulator` decodes each image into a full-size + * bitmap in native memory, so converting a whole selection at once (the picker allows up to + * `CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT` files) holds every bitmap simultaneously and the + * OS terminates the app for exceeding its memory limit. Processing one image at a time keeps the peak + * at a single bitmap regardless of how many files were picked. + */ +const processPickedAssetsSequentially: ProcessPickedAssetsFunction = async (assets, showGeneralAlert, translate) => { + const processedAssets: Asset[] = []; + // Collected instead of alerted inline so the whole selection produces a single alert: alerting per + // asset would leave the user dismissing one native modal after another. + const failureMessages = new Set(); + + for (const asset of assets) { + if (!asset.uri) { + continue; + } + + if (!asset.type?.startsWith('image')) { + // Ensure the asset has proper fileName and type + processedAssets.push(processAssetWithFallbacks(asset)); + continue; + } + + try { + // eslint-disable-next-line no-await-in-loop -- converting one image at a time is the point, see the doc comment above + const isHEIC = await verifyFileFormat({fileUri: asset.uri, formatSignatures: CONST.HEIC_SIGNATURES}); + + if (!isHEIC) { + // Ensure the asset has proper fileName and type for non-HEIC images + processedAssets.push(processAssetWithFallbacks(asset)); + continue; + } + + // react-native-image-picker incorrectly changes file extension without transcoding the HEIC file, so we are doing it manually if we detect HEIC signature + // eslint-disable-next-line no-await-in-loop -- converting one image at a time is the point, see the doc comment above + const convertedAsset = await convertHeicToJpeg(asset.uri); + + if (convertedAsset) { + processedAssets.push(convertedAsset); + } else { + failureMessages.add(translate('attachmentPicker.errorWhileConvertingHeic')); + } + } catch (error) { + failureMessages.add(getErrorMessage(error, translate('attachmentPicker.errorWhileSelectingAttachment'))); + } + } + + if (failureMessages.size > 0) { + showGeneralAlert([...failureMessages].join('\n')); + } + + return processedAssets.length > 0 ? processedAssets : undefined; +}; + +export default processPickedAssetsSequentially; diff --git a/src/libs/fileDownload/processPickedAssets/index.ts b/src/libs/fileDownload/processPickedAssets/index.ts new file mode 100644 index 000000000000..7e67741049b5 --- /dev/null +++ b/src/libs/fileDownload/processPickedAssets/index.ts @@ -0,0 +1,9 @@ +import type ProcessPickedAssetsFunction from './types'; + +/** + * Web has no native image picker, so nothing reaches this module. It exists so the native + * implementation resolves through a platform-agnostic import path. + */ +const processPickedAssetsSequentially: ProcessPickedAssetsFunction = (assets) => Promise.resolve(assets.length > 0 ? assets : undefined); + +export default processPickedAssetsSequentially; diff --git a/src/libs/fileDownload/processPickedAssets/types.ts b/src/libs/fileDownload/processPickedAssets/types.ts new file mode 100644 index 000000000000..627ac6e5eb3e --- /dev/null +++ b/src/libs/fileDownload/processPickedAssets/types.ts @@ -0,0 +1,7 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import type {Asset} from 'react-native-image-picker'; + +type ProcessPickedAssetsFunction = (assets: Asset[], showGeneralAlert: (message?: string) => void, translate: LocaleContextProps['translate']) => Promise; + +export default ProcessPickedAssetsFunction; diff --git a/src/pages/DynamicReportParticipantDetailsPage.tsx b/src/pages/DynamicReportParticipantDetailsPage.tsx index 138c17076ad4..a99b196fe260 100644 --- a/src/pages/DynamicReportParticipantDetailsPage.tsx +++ b/src/pages/DynamicReportParticipantDetailsPage.tsx @@ -1,8 +1,8 @@ import UserAvatar from '@components/Avatar/UserAvatar'; import Button from '@components/ButtonComposed'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -132,12 +132,11 @@ function DynamicReportParticipantDetails({report, route}: DynamicReportParticipa {isCurrentUserAdmin && ( - )} diff --git a/src/pages/DynamicReportParticipantsInvitePage.tsx b/src/pages/DynamicReportParticipantsInvitePage.tsx index a76f74f1f7bb..c190fe669a2d 100644 --- a/src/pages/DynamicReportParticipantsInvitePage.tsx +++ b/src/pages/DynamicReportParticipantsInvitePage.tsx @@ -19,6 +19,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {inviteToGroupChat, searchUserInServer} from '@libs/actions/Report'; import {clearUserSearchPhrase, updateUserSearchPhrase} from '@libs/actions/RoomMembersUserSearchPhrase'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; import {appendCountryCode} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils'; @@ -208,6 +209,8 @@ function DynamicReportParticipantsInvitePage({report}: DynamicReportParticipants ListItem={InviteMemberListItem} confirmButtonOptions={{ onConfirm: inviteUsers, + isFooterConfirmEnabled: selectedOptions.length > 0, + isFooterConfirmEnterKeyEnabled: getPlatform() !== CONST.PLATFORM.ANDROID, }} shouldShowTextInput textInputOptions={textInputOptions} diff --git a/src/pages/DynamicRoomInvitePage.tsx b/src/pages/DynamicRoomInvitePage.tsx index f7f9a139a45c..5653d7281f11 100644 --- a/src/pages/DynamicRoomInvitePage.tsx +++ b/src/pages/DynamicRoomInvitePage.tsx @@ -24,6 +24,7 @@ import {inviteToRoom, inviteToRoomAction, searchUserInServer} from '@libs/action import {clearUserSearchPhrase, updateUserSearchPhrase} from '@libs/actions/RoomMembersUserSearchPhrase'; import {READ_COMMANDS} from '@libs/API/types'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; import HttpUtils from '@libs/HttpUtils'; import {appendCountryCode} from '@libs/LoginUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -247,6 +248,8 @@ function DynamicRoomInvitePage({report, policy, didScreenTransitionEnd}: Dynamic confirmButtonOptions={{ isDisabled: !validSelectedOptions.length, onConfirm: inviteUsers, + isFooterConfirmEnabled: validSelectedOptions.length > 0, + isFooterConfirmEnterKeyEnabled: getPlatform() !== CONST.PLATFORM.ANDROID, }} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} shouldUpdateFocusedIndex diff --git a/src/pages/EnablePayments/Wallet/AddBankAccount/substeps/ConfirmationStep.tsx b/src/pages/EnablePayments/Wallet/AddBankAccount/substeps/ConfirmationStep.tsx index 926b51b77eb3..8582f5dee69a 100644 --- a/src/pages/EnablePayments/Wallet/AddBankAccount/substeps/ConfirmationStep.tsx +++ b/src/pages/EnablePayments/Wallet/AddBankAccount/substeps/ConfirmationStep.tsx @@ -1,6 +1,6 @@ import Button from '@components/ButtonComposed'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -52,13 +52,19 @@ function ConfirmationStep({onNext, onMove}: ConfirmationStepProps) { > {translate('walletPage.confirmYourBankAccount')} {translate('bankAccount.letsDoubleCheck')} - + + + + {!!bankName && {bankName}} + {`${translate('bankAccount.accountEnding')} ${accountNumber.slice(-4)}`} + + {!isBankAccountAdded && ( + + + + )} + + {!!error && error.length > 0 && ( (latestSelectedOptionsRef.current.length > 0 ? createGroup() : selectOption(option)), + isFooterConfirmEnabled: selectedOptions.length > 0, }} rightHandSideComponent={itemRightSideComponent} footerContent={footerContent} diff --git a/src/pages/PreMountBufferPage.tsx b/src/pages/PreMountBufferPage.tsx new file mode 100644 index 000000000000..bf299825dd5e --- /dev/null +++ b/src/pages/PreMountBufferPage.tsx @@ -0,0 +1,17 @@ +import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; + +import Navigation from '@libs/Navigation/Navigation'; + +// Neutral placeholder shown under the RHP while a destination is pre-mounted. A native +// swipe-dismiss reveals this instead of the pre-inserted destination. Not reachable via a URL/deep link. +function PreMountBufferPage() { + return ( + + ); +} + +export default PreMountBufferPage; diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index a27d725b4c87..fb2743add81f 100755 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -5,6 +5,7 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -324,13 +325,12 @@ function ProfilePage({route}: ProfilePageProps) { /> )} {shouldShowNotificationPreference && ( - { Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NOTIFICATION_PREFERENCES.getRoute(report.reportID))); }} + value={notificationPreference} /> )} {Permissions.canUsePrivateNotes() && !isEmptyObject(report) && !!report.reportID && !isCurrentUser && ( diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx index c778612fcc38..73ca52f2ba6e 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx @@ -121,6 +121,14 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen const hasClearedStalePlaidErrorsRef = useRef(false); const isChangingBankAccountRef = useRef(isChangingBankAccount); const hasShownConnectedBankAccountRef = useRef(false); + // Latches the pending-USD redirect below so the effect dispatches the navigation at most once per mount, even + // though its dependencies change again while the transition is in flight. + const hasRedirectedToPendingValidationRef = useRef(false); + // Set once this page has actually been covered by the validation step. The redirect ref alone cannot tell that + // apart from the redirect still being in flight, because it flips while this page is still focused. + const hasBlurredAfterPendingRedirectRef = useRef(false); + // Set when this page leaves the pending-validation flow, so the unmount cleanup stops preserving the account data. + const isLeavingPendingValidationFlowRef = useRef(false); const prevReimbursementAccount = usePrevious(reimbursementAccount); const prevIsOffline = usePrevious(isOffline); const achData = reimbursementAccount?.achData; @@ -185,8 +193,17 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen const isChangingBankAccountInstance = isChangingBankAccountRef.current; return () => { if (!isChangingBankAccountInstance) { + // The draft is always safe to clear. Nothing in the validation step branches on it, while the + // micro-deposit inputs save into it, so leaving it behind prefills the previous attempt's amounts + // against the limited number of validation attempts the next time the account is opened. clearReimbursementAccountDraft(); - clearReimbursementAccount(); + // The account itself must survive an unmount that happens only because this page redirected into the + // validation step of the same flow. ConnectBankAccount reads achData.state and does no fetching, so + // clearing here resets it to DEFAULT_DATA underneath it and renders a blank header-only RHP. Once the + // flow is actually being left, nothing is left to read it and the usual wipe applies again. + if (!hasRedirectedToPendingValidationRef.current || isLeavingPendingValidationFlowRef.current) { + clearReimbursementAccount(); + } } cancelChangingToNewBankAccount(); getPaymentMethods(); @@ -228,6 +245,39 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen const isDefaultReimbursementAccountData = deepEqual(reimbursementAccount, CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA); const hasLoadedData = reimbursementAccount?.achData && !isDefaultReimbursementAccountData && !reimbursementAccount?.isLoading; + const canManageWorkspaceBankAccount = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS); + // The redirect fires from an effect, and effects run even on the renders that return FullPageNotFoundView below. + // The validation step has no authorization guard of its own, so a stale cached account for a workspace the user + // can no longer manage would carry them past the not-authorized screen and into the micro-deposit form. Redirect + // only once the policy has loaded and positively grants access. When it has not, this page falls through to its + // normal render, which shows the loader while the policy is loading and the not-found screen once it is not. + const isAuthorizedToValidateBankAccount = !isLoadingPolicy && !isEmptyObject(policy) && canManageWorkspaceBankAccount && !isPendingDeletePolicy(policy); + // For a pending USD account this page only redirects, so it must never paint the entry point. Derived during render + // because effects run after paint. Not latched on the redirect ref: that flips mid-transition and the next render + // would fall through to the entry point. policyID must match because REIMBURSEMENT_ACCOUNT is persisted, so on a + // cold load achData can still describe another policy's account. Entry points that pass no policyID at all (the + // Wallet ones) are excluded outright, since there is nothing to match them against. + const shouldRedirectToPendingValidation = + policyCurrency === CONST.CURRENCY.USD && + achData?.state === CONST.BANK_ACCOUNT.STATE.PENDING && + !!hasLoadedData && + !isChangingBankAccount && + !!policyIDParam && + achData?.policyID === policyIDParam && + isAuthorizedToValidateBankAccount; + + // Leaves the setup flow entirely. Used everywhere the pending-validation redirect needs an exit, because going back + // to this page would only redirect again. + const leavePendingValidationFlow = useCallback(() => { + // Tells the unmount cleanup that the validation step is not going to keep reading the preserved account data, + // so the usual wipe can run. A ref rather than state, so this cannot repaint the entry point on the way out. + isLeavingPendingValidationFlowRef.current = true; + if (backTo) { + Navigation.goBack(backTo); + return; + } + Navigation.dismissModal(); + }, [backTo]); /** When this page is first opened, `reimbursementAccount` prop might not yet be fully loaded from Onyx. Calculating `shouldShowContinueSetupButton` immediately on initial render doesn't make sense as @@ -358,11 +408,13 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen return; } - // If USD bank account is in pending state, we should navigate straight to the validation step and skip Continue step - if (policyCurrency === CONST.CURRENCY.USD && achData?.state === CONST.BANK_ACCOUNT.STATE.PENDING) { - setUSDBankAccountStep(CONST.BANK_ACCOUNT.STEP.VALIDATION); - goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.VALIDATION); - setShouldShowContinueSetupButton(shouldShowContinueSetupButtonValue); + // Navigate straight to the validation step and skip the Continue step. Done from inside this page so that + // openReimbursementAccountPage has already populated the achData that ConnectBankAccount reads. + if (shouldRedirectToPendingValidation && !hasRedirectedToPendingValidationRef.current) { + hasRedirectedToPendingValidationRef.current = true; + // A push, not a forceReplace: replacing unmounts this page and its cleanup wipes REIMBURSEMENT_ACCOUNT, + // leaving ConnectBankAccount with state 'SETUP' and a blank RHP. + Navigation.navigate(ROUTES.BANK_ACCOUNT_USD_SETUP.getRoute({policyID: policyIDParam, page: CONST.BANK_ACCOUNT.PAGE_NAMES.VALIDATION, backTo})); return; } @@ -378,7 +430,28 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen // achData changes — not to local USDBankAccountStep updates — otherwise it races with prepareNextStep // and briefly pulls USDBankAccountStep back to the server value before the Onyx merge lands. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [policyIDParam, achData?.currentStep, shouldShowContinueSetupButtonValue, isNonUSDSetup, isPreviousPolicy, achData?.state, policyCurrency]); + }, [policyIDParam, achData?.currentStep, shouldShowContinueSetupButtonValue, isNonUSDSetup, isPreviousPolicy, achData?.state, policyCurrency, shouldRedirectToPendingValidation, backTo]); + + // Declared after the redirect effect so that on the commit where the redirect fires this runs with the blur flag + // still unset and bails out, rather than racing the navigation it is meant to follow. + useEffect(() => { + if (!hasRedirectedToPendingValidationRef.current) { + return; + } + + if (!isFocused) { + hasBlurredAfterPendingRedirectRef.current = true; + return; + } + + // The user navigated back onto this page, which the back handler cannot intercept for browser back. This page + // only redirects for a pending account, so it would otherwise sit on the loader forever. Leave the flow. + if (!hasBlurredAfterPendingRedirectRef.current || !shouldRedirectToPendingValidation) { + return; + } + + leavePendingValidationFlow(); + }, [isFocused, shouldRedirectToPendingValidation, leavePendingValidationFlow]); useEffect(() => { if (!prevPolicyCurrency || policyCurrency === prevPolicyCurrency) { @@ -576,8 +649,6 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen return ; } - const canManageWorkspaceBankAccount = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS); - if (!!policyIDParam && ((!isLoading && (isEmptyObject(policy) || !canManageWorkspaceBankAccount)) || isPendingDeletePolicy(policy))) { return ( @@ -648,6 +719,13 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen ); } + // Keep the loader on screen for a pending USD account so the "Continue setup / Start over" entry point is never + // painted on the way to the validation step. The back button leaves the flow rather than using goBack, which + // switches on achData.currentStep and performs no navigation at all for several of its cases. + if (shouldRedirectToPendingValidation) { + return ; + } + // Once fresh data has loaded, trust the live value to avoid a one-frame flash from the effect-synced state lagging achData. // On a "change bank account" instance never show "continue setup", since the shared data still describes the account being replaced. const shouldShowContinueSetupButtonToDisplay = !isChangingBankAccount && (hasLoadedData ? shouldShowContinueSetupButtonValue : shouldShowContinueSetupButton); diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx index 2b988f0a9114..3ae983c5b0f3 100644 --- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx +++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx @@ -2,7 +2,7 @@ import CheckboxWithLabel from '@components/CheckboxWithLabel'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; @@ -68,83 +68,72 @@ function ConfirmationBusiness({onNext, onMove}: SubPageProps) { return ( {translate('businessInfoStep.letsDoubleCheck')} - { onMove(BUSINESS_INFO_STEP_INDEXES.BUSINESS_NAME); }} + value={values[BUSINESS_INFO_STEP_KEYS.COMPANY_NAME]} /> - { onMove(BUSINESS_INFO_STEP_INDEXES.TAX_ID_NUMBER); }} + value={values[BUSINESS_INFO_STEP_KEYS.COMPANY_TAX_ID]} /> - { onMove(BUSINESS_INFO_STEP_INDEXES.COMPANY_ADDRESS); }} + value={`${values[BUSINESS_INFO_STEP_KEYS.STREET]}, ${values[BUSINESS_INFO_STEP_KEYS.CITY]}, ${values[BUSINESS_INFO_STEP_KEYS.STATE]} ${values[BUSINESS_INFO_STEP_KEYS.ZIP_CODE]}`} /> - { onMove(BUSINESS_INFO_STEP_INDEXES.PHONE_NUMBER); }} + value={values[BUSINESS_INFO_STEP_KEYS.COMPANY_PHONE]} /> - { onMove(BUSINESS_INFO_STEP_INDEXES.COMPANY_WEBSITE); }} + value={values[BUSINESS_INFO_STEP_KEYS.COMPANY_WEBSITE]} /> - { onMove(BUSINESS_INFO_STEP_INDEXES.COMPANY_TYPE); }} + value={ + values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_TYPE] + ? translate(`businessInfoStep.incorporationType.${values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_TYPE]}` as TranslationPaths) + : undefined + } /> - { onMove(BUSINESS_INFO_STEP_INDEXES.INCORPORATION_DATE); }} + value={values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_DATE]} /> - { onMove(BUSINESS_INFO_STEP_INDEXES.INCORPORATION_STATE); }} + value={values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_STATE] ? translate(`allStates.${values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_STATE] as States}.stateName`) : undefined} /> - { onMove(BUSINESS_INFO_STEP_INDEXES.INCORPORATION_CODE); }} + value={values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_CODE]} /> { // When the bank account is pending validation it has already been submitted, so stepping back through the - // setup pages doesn't make sense. Pop back to the entry point screen the user came from. + // setup pages doesn't make sense. Leave the flow entirely rather than popping to ReimbursementAccountPage: + // that page redirects a pending account straight back here, so returning to it would trap the user in a loop. if (currentEntry?.pageName === PAGE_NAMES.VALIDATION && reimbursementAccount?.achData?.state === CONST.BANK_ACCOUNT.STATE.PENDING) { - Navigation.goBack(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute({policyID, backTo})); + if (backTo) { + Navigation.goBack(backTo); + } else { + Navigation.dismissModal(); + } return; } diff --git a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx index d0a2b1c85b60..42270c798afc 100644 --- a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx @@ -3,6 +3,7 @@ import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithLabel from '@components/MenuItem/presets/MenuItemWithLabel'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; @@ -137,10 +138,9 @@ function ScheduleCallConfirmationPage() { Navigation.goBack(ROUTES.SCHEDULE_CALL_BOOK.getRoute(route?.params?.reportID)); }} /> - diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index a247a941ea12..20cec0a9c6f9 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -222,6 +222,7 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { introSelected, conciergeChat, betas, + personalDetails, hasReportActions, currentUserAccountID, isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index cf551caabf73..72dd4408ee74 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -298,6 +298,7 @@ function SubmitDetailsPage({ iouType, isCreatingTrackExpense, isSelfDMDestination: isSelfDM(report), + isOptimisticNewChatDestination: false, isLookingAroundUser, isMovingTransactionFromTrackExpense: false, }); @@ -670,6 +671,8 @@ function SubmitDetailsPage({ {}} iouType={iouType} onToggleBillable={setBillable} onToggleReimbursable={setReimbursable} diff --git a/src/pages/Travel/CarTripDetails.tsx b/src/pages/Travel/CarTripDetails.tsx index 85e7280a94d7..175d2b908d79 100644 --- a/src/pages/Travel/CarTripDetails.tsx +++ b/src/pages/Travel/CarTripDetails.tsx @@ -1,3 +1,4 @@ +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import Text from '@components/Text'; import UserPills from '@components/UserPills'; @@ -64,10 +65,9 @@ function CarTripDetails({reservation, personalDetails}: CarTripDetailsProps) { helperTextStyle={[styles.pb3, styles.mtn2]} /> {!!reservation.carInfo?.name && ( - )} {!!cancellationText && ( diff --git a/src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx b/src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx index 42a618d9599b..ad9a9ba566c7 100644 --- a/src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx +++ b/src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx @@ -6,6 +6,7 @@ import useActivePolicy from '@hooks/useActivePolicy'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies'; +import useHasOwnedPaidPolicy from '@hooks/useHasOwnedPaidPolicy'; import useOnyx from '@hooks/useOnyx'; import {createDraftWorkspace, createWorkspace} from '@libs/actions/Policy/Policy'; @@ -28,6 +29,7 @@ function DynamicWorkspaceConfirmationForTravelPage() { const activePolicy = useActivePolicy(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const hasOwnedPaidPolicy = useHasOwnedPaidPolicy(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.TRAVEL_WORKSPACE_CONFIRMATION.path); const goBack = () => { @@ -58,6 +60,7 @@ function DynamicWorkspaceConfirmationForTravelPage() { betas, isSelfTourViewed, hasActiveAdminPolicies, + hasOwnedPaidPolicy, }); goBack(); }; diff --git a/src/pages/Travel/HotelTripDetails.tsx b/src/pages/Travel/HotelTripDetails.tsx index ec618c5e29e2..3bf887816228 100644 --- a/src/pages/Travel/HotelTripDetails.tsx +++ b/src/pages/Travel/HotelTripDetails.tsx @@ -1,3 +1,4 @@ +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import Text from '@components/Text'; import UserPills from '@components/UserPills'; @@ -65,10 +66,9 @@ function HotelTripDetails({reservation, personalDetails}: HotelTripDetailsProps) /> {!!reservation.roomClass && ( - )} {!!cancellationText && ( diff --git a/src/pages/Travel/TrainTripDetails.tsx b/src/pages/Travel/TrainTripDetails.tsx index 9a9d1edcf324..9560c480924e 100644 --- a/src/pages/Travel/TrainTripDetails.tsx +++ b/src/pages/Travel/TrainTripDetails.tsx @@ -1,3 +1,4 @@ +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import Text from '@components/Text'; import UserPills from '@components/UserPills'; @@ -44,10 +45,9 @@ function TrainTripDetails({reservation, personalDetails}: TrainTripDetailsProps) copyable interactive={false} /> - {!!reservation.coachNumber && ( - )} {!!reservation.seatNumber && ( - )} diff --git a/src/pages/domain/Admins/DomainAddAdminPage.tsx b/src/pages/domain/Admins/DomainAddAdminPage.tsx index f8fa6e1a73a0..6a0c131fc414 100644 --- a/src/pages/domain/Admins/DomainAddAdminPage.tsx +++ b/src/pages/domain/Admins/DomainAddAdminPage.tsx @@ -13,6 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {searchUserInServer} from '@libs/actions/Report'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; import {appendCountryCode} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -180,6 +181,8 @@ function DomainAddAdminPage({route}: DomainAddAdminProps) { textInputOptions={textInputOptions} confirmButtonOptions={{ onConfirm: inviteUser, + isFooterConfirmEnabled: selectedOptions.length > 0, + isFooterConfirmEnterKeyEnabled: getPlatform() !== CONST.PLATFORM.ANDROID, }} shouldShowLoadingPlaceholder={!areOptionsInitialized || !didScreenTransitionEnd} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} diff --git a/src/pages/domain/Admins/DomainAdminsSettingsPage.tsx b/src/pages/domain/Admins/DomainAdminsSettingsPage.tsx index 5af234c5a363..8c289b5b6676 100644 --- a/src/pages/domain/Admins/DomainAdminsSettingsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminsSettingsPage.tsx @@ -1,4 +1,4 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import RenderHTML from '@components/RenderHTML'; @@ -48,11 +48,10 @@ function DomainAdminsSettingsPage({route}: DomainAdminsSettingsPageProps) { errors={getLatestError(domainErrors?.technicalContactEmailErrors)} onClose={() => clearSetPrimaryContactError(domainAccountID)} > - Navigation.navigate(ROUTES.DOMAIN_ADD_PRIMARY_CONTACT.getRoute(domainAccountID))} + value={technicalContactSettings?.technicalContactEmail} /> ; -const FEATURES: TranslationPaths[] = [ - 'domain.accessRestricted.companyCardManagement', - 'domain.accessRestricted.accountCreationAndDeletion', - 'domain.accessRestricted.workspaceCreation', - 'domain.accessRestricted.samlSSO', -]; - function DomainAccessRestrictedPage({route}: DomainAccessRestrictedPageProps) { - const {asset: Checkmark} = useMemoizedLazyAsset(() => loadExpensifyIcon('Checkmark')); + const {domainAccountID} = route.params; + const icons = useMemoizedLazyExpensifyIcons(['EmptyStateSpyPigeon']); const styles = useThemeStyles(); - const theme = useTheme(); const {translate} = useLocalize(); + const {isOffline} = useNetwork(); - const {domainAccountID} = route.params; + const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + const [hasPendingRequest] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {selector: hasPendingAdminshipRequestSelector(currentUserAccountID)}); + const [isRequestPending] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, {selector: (pendingActions) => !!pendingActions?.requestAdminship}); + const [requestError] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, {selector: (errors) => errors?.requestAdminshipError}); + + useEffect(() => { + return () => clearRequestAdminshipError(domainAccountID); + }, [domainAccountID]); return ( ( - - - - - - - {FEATURES.map((featureTranslationPath) => ( - - - {translate(featureTranslationPath)} - - ))} - - - - - + + + + } + footerComponent={ + !!requestError && ( + + ) + } + shouldShowSecondaryButton + secondaryButtonText={translate(hasPendingRequest ? 'domain.requestSent' : 'domain.accessRestricted.requestAdminAccess')} + isSecondaryButtonLoading={isRequestPending} + isSecondaryButtonDisabled={!isRequestPending && (!!hasPendingRequest || isOffline)} + onSecondaryButtonPress={() => { + if (!currentUserAccountID) { + return; + } + requestDomainAdminship(domainAccountID, currentUserAccountID, false); + }} + shouldShowButton + buttonText={translate('domain.accessRestricted.verifyYourself')} + onButtonPress={() => Navigation.navigate(ROUTES.WORKSPACES_VERIFY_DOMAIN.getRoute(domainAccountID))} + /> )} diff --git a/src/pages/domain/Groups/BaseDomainGroupPreferredWorkspacePage.tsx b/src/pages/domain/Groups/BaseDomainGroupPreferredWorkspacePage.tsx new file mode 100644 index 000000000000..9029e7ec1bac --- /dev/null +++ b/src/pages/domain/Groups/BaseDomainGroupPreferredWorkspacePage.tsx @@ -0,0 +1,131 @@ +/** + * Shared preferred-workspace selector for domain groups. Both the group-create and group-edit + * pages delegate to this component; it renders the admin workspace list with a search field and + * gates access behind DomainNotFoundPageWrapper. + */ +import type {FullPageNotFoundViewProps} from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import UserListItem from '@components/SelectionList/ListItem/UserListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useSearchResults from '@hooks/useSearchResults'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import tokenizedSearch from '@libs/tokenizedSearch'; + +import DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import {createAdminPoliciesSelector} from '@selectors/Policy'; +import React from 'react'; + +type WorkspaceListItem = { + policyID: string; + + /** The timestamp of when the policy was created */ + created?: string; +} & ListItem; + +type BaseDomainGroupPreferredWorkspacePageProps = { + /** AccountID of the domain */ + domainAccountID: number; + + /** The policy ID of the currently selected preferred workspace */ + selectedPolicyID: string | undefined; + + /** Called with the policy ID of the workspace the user picked */ + onSelectWorkspace: (policyID: string) => void; + + /** Called when the back button is pressed */ + onBackButtonPress: () => void; + + /** Used to locate the page in the tests */ + testID: string; + + /** Whether or not to block user from accessing the page */ + shouldBeBlocked?: boolean; + + /** Props for customizing fallback pages */ + fullPageNotFoundViewProps?: FullPageNotFoundViewProps; +}; + +function BaseDomainGroupPreferredWorkspacePage({ + domainAccountID, + selectedPolicyID, + onSelectWorkspace, + onBackButtonPress, + testID, + shouldBeBlocked, + fullPageNotFoundViewProps, +}: BaseDomainGroupPreferredWorkspacePageProps) { + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createAdminPoliciesSelector(selectedPolicyID)}); + + const workspaceOptions: WorkspaceListItem[] = []; + for (const policy of Object.values(policies ?? {})) { + if (!policy?.name || !policy?.id) { + continue; + } + + workspaceOptions.push({ + text: policy.name, + policyID: policy.id, + created: policy.created, + keyForList: policy.id, + isSelected: selectedPolicyID === policy.id, + }); + } + workspaceOptions.sort((a, b) => localeCompare(a.created ?? '', b.created ?? '')); + + const [searchTerm, setSearchTerm, filteredWorkspaceOptions] = useSearchResults( + workspaceOptions, + (option, searchInput) => tokenizedSearch([option], searchInput, () => [option.text ?? '']).length > 0, + ); + + // The search input is gated on the unfiltered list length so it doesn't disappear once a query narrows the results. + const shouldShowSearchInput = workspaceOptions.length >= CONST.STANDARD_LIST_ITEM_LIMIT; + + return ( + + + + {translate('domain.groups.preferredWorkspaceSelectDescription')} + + data={filteredWorkspaceOptions} + ListItem={UserListItem} + textInputOptions={{ + label: shouldShowSearchInput ? translate('common.search') : undefined, + value: searchTerm, + onChangeText: setSearchTerm, + headerMessage: workspaceOptions.length > 0 && filteredWorkspaceOptions.length === 0 ? translate('common.noResultsFound') : '', + }} + onSelectRow={(item: WorkspaceListItem) => onSelectWorkspace(item.policyID)} + initiallyFocusedItemKey={selectedPolicyID} + shouldUpdateFocusedIndex + /> + + + ); +} + +export default BaseDomainGroupPreferredWorkspacePage; diff --git a/src/pages/domain/Groups/DomainGroupCreatePage.tsx b/src/pages/domain/Groups/DomainGroupCreatePage.tsx index fe1aa0cbc6a4..5b225f69d5e8 100644 --- a/src/pages/domain/Groups/DomainGroupCreatePage.tsx +++ b/src/pages/domain/Groups/DomainGroupCreatePage.tsx @@ -2,7 +2,7 @@ import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; @@ -68,6 +68,7 @@ function DomainGroupCreatePage({route}: DomainGroupCreatePageProps) { .sort((a, b) => localeCompare(a?.created ?? '', b?.created ?? '')) .at(0); const hasAdminPolicies = !!firstAdminPolicy; + const preferredWorkspaceName = preferredPolicyName ?? firstAdminPolicy?.name; useEffect(() => { return () => { @@ -203,12 +204,11 @@ function DomainGroupCreatePage({route}: DomainGroupCreatePageProps) { shouldPlaceSubtitleBelowSwitch /> {hasAdminPolicies && ( - Navigation.navigate(ROUTES.DOMAIN_GROUP_CREATE_PREFERRED_WORKSPACE.getRoute(domainAccountID))} - disabled={!preferredWorkspace} + isDisabled={!preferredWorkspace} + value={preferredWorkspaceName} /> )} ; function DomainGroupCreatePreferredWorkspacePage({route}: DomainGroupCreatePreferredWorkspacePageProps) { const {domainAccountID} = route.params; - const styles = useThemeStyles(); - const {translate, localeCompare} = useLocalize(); - const [currentPolicyID] = useOnyx(ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createAdminPoliciesSelector(currentPolicyID)}); - - const workspaceOptions: WorkspaceListItem[] = []; - for (const policy of Object.values(policies ?? {})) { - if (!policy?.name || !policy?.id) { - continue; - } - - workspaceOptions.push({ - text: policy.name, - policyID: policy.id, - created: policy.created, - keyForList: policy.id, - isSelected: currentPolicyID === policy.id, - }); - } return ( - - - Navigation.goBack(ROUTES.DOMAIN_GROUP_CREATE.getRoute(domainAccountID))} - /> - {translate('domain.groups.preferredWorkspaceSelectDescription')} - - data={workspaceOptions.sort((a, b) => localeCompare(a.created ?? '', b.created ?? ''))} - ListItem={UserListItem} - onSelectRow={(item: WorkspaceListItem) => { - setDomainGroupCreatePreferredPolicyID(item.policyID); - Navigation.goBack(ROUTES.DOMAIN_GROUP_CREATE.getRoute(domainAccountID)); - }} - initiallyFocusedItemKey={currentPolicyID} - shouldUpdateFocusedIndex - /> - - + Navigation.goBack(ROUTES.DOMAIN_GROUP_CREATE.getRoute(domainAccountID))} + onSelectWorkspace={(policyID: string) => { + setDomainGroupCreatePreferredPolicyID(policyID); + Navigation.goBack(ROUTES.DOMAIN_GROUP_CREATE.getRoute(domainAccountID)); + }} + /> ); } diff --git a/src/pages/domain/Groups/DomainGroupDetailsPage.tsx b/src/pages/domain/Groups/DomainGroupDetailsPage.tsx index 375202bdf991..bc3055082f89 100644 --- a/src/pages/domain/Groups/DomainGroupDetailsPage.tsx +++ b/src/pages/domain/Groups/DomainGroupDetailsPage.tsx @@ -1,5 +1,5 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -75,11 +75,10 @@ function DomainGroupDetailsPage({route}: DomainGroupDetailsPageProps) { onClose={() => clearDomainSecurityGroupSettingError(domainAccountID, groupID, 'nameErrors')} errorRowStyles={[styles.mh5]} > - Navigation.navigate(ROUTES.DOMAIN_GROUP_EDIT_NAME.getRoute(domainAccountID, groupID))} + value={group?.name} /> ; function DomainGroupPreferredWorkspacePage({route}: DomainGroupPreferredWorkspacePageProps) { const {domainAccountID, groupID} = route.params; - const styles = useThemeStyles(); - const {translate, localeCompare} = useLocalize(); - const [group] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { selector: selectGroupByID(groupID), }); @@ -46,58 +28,24 @@ function DomainGroupPreferredWorkspacePage({route}: DomainGroupPreferredWorkspac selector: domainSecurityGroupSettingPendingActionSelector('deleteGroup', groupID), }); - const currentPolicyID = group?.restrictedPrimaryPolicyID; - - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createAdminPoliciesSelector(currentPolicyID)}); - - const workspaceOptions: WorkspaceListItem[] = []; - for (const policy of Object.values(policies ?? {})) { - if (!policy?.name || !policy?.id) { - continue; - } - - workspaceOptions.push({ - text: policy.name, - policyID: policy.id, - created: policy.created, - keyForList: policy.id, - isSelected: currentPolicyID === policy.id, - }); - } - return ( - Navigation.goBack(ROUTES.DOMAIN_GROUPS.getRoute(domainAccountID)), }} - > - - Navigation.goBack(ROUTES.DOMAIN_GROUP_DETAILS.getRoute(domainAccountID, groupID))} - /> - {translate('domain.groups.preferredWorkspaceSelectDescription')} - - data={workspaceOptions.sort((a, b) => localeCompare(a.created ?? '', b.created ?? ''))} - ListItem={UserListItem} - onSelectRow={(item: WorkspaceListItem) => { - if (!group) { - return; - } - updateDomainSecurityGroup(domainAccountID, groupID, group, {restrictedPrimaryPolicyID: item.policyID}, 'restrictedPrimaryPolicyID'); - Navigation.goBack(ROUTES.DOMAIN_GROUP_DETAILS.getRoute(domainAccountID, groupID)); - }} - initiallyFocusedItemKey={currentPolicyID} - shouldUpdateFocusedIndex - /> - - + onBackButtonPress={() => Navigation.goBack(ROUTES.DOMAIN_GROUP_DETAILS.getRoute(domainAccountID, groupID))} + onSelectWorkspace={(policyID: string) => { + if (!group) { + return; + } + updateDomainSecurityGroup(domainAccountID, groupID, group, {restrictedPrimaryPolicyID: policyID}, 'restrictedPrimaryPolicyID'); + Navigation.goBack(ROUTES.DOMAIN_GROUP_DETAILS.getRoute(domainAccountID, groupID)); + }} + /> ); } diff --git a/src/pages/domain/Groups/PreferredWorkspaceToggle.tsx b/src/pages/domain/Groups/PreferredWorkspaceToggle.tsx index 56b04984b3ba..6f7756df7c9d 100644 --- a/src/pages/domain/Groups/PreferredWorkspaceToggle.tsx +++ b/src/pages/domain/Groups/PreferredWorkspaceToggle.tsx @@ -1,4 +1,4 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useConfirmModal from '@hooks/useConfirmModal'; @@ -47,6 +47,7 @@ function PreferredWorkspaceToggle({domainAccountID, groupID}: PreferredWorkspace // When the requester is not a member of the preferred policy, BE adds a minimal {avatarURL, id, name} policy Onyx data for the policy to the policy collection, so this resolves to the configured workspace's name even for domain admins without access to that policy. const [preferredPolicyName] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${preferredPolicyID}`, {selector: policyNameSelector}); + const preferredWorkspaceName = preferredPolicyName ?? firstAdminPolicy?.name; const [enableRestrictedPrimaryPolicyPendingAction] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, { selector: domainSecurityGroupSettingPendingActionSelector('enableRestrictedPrimaryPolicy', groupID), @@ -116,12 +117,11 @@ function PreferredWorkspaceToggle({domainAccountID, groupID}: PreferredWorkspace onClose={() => clearDomainSecurityGroupSettingError(domainAccountID, groupID, 'restrictedPrimaryPolicyIDErrors')} errorRowStyles={[styles.mh5]} > - Navigation.navigate(ROUTES.DOMAIN_SECURITY_GROUPS_PREFERRED_WORKSPACE.getRoute(domainAccountID, groupID))} - disabled={!isEnabled || (!hasAdminPolicies && !!preferredPolicyName)} + isDisabled={!isEnabled || (!hasAdminPolicies && !!preferredPolicyName)} + value={preferredWorkspaceName} /> )} diff --git a/src/pages/domain/Members/DomainMemberDetailsPage.tsx b/src/pages/domain/Members/DomainMemberDetailsPage.tsx index 5d6a3e241289..be149a7f1fb8 100644 --- a/src/pages/domain/Members/DomainMemberDetailsPage.tsx +++ b/src/pages/domain/Members/DomainMemberDetailsPage.tsx @@ -2,7 +2,7 @@ import Button from '@components/ButtonComposed'; import DecisionModal from '@components/DecisionModal'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import VacationDelegateMenuItem from '@components/VacationDelegateMenuItem'; @@ -159,11 +159,10 @@ function DomainMemberDetailsPage({route}: DomainMemberDetailsPageProps) { errors={getLatestError(domainErrors?.memberErrors?.[memberLogin]?.changeDomainSecurityGroupErrors)} onClose={() => clearChangeDomainSecurityGroupError(domainAccountID, memberLogin)} > - Navigation.navigate(ROUTES.DOMAIN_MEMBER_MOVE_TO_GROUP.getRoute(domainAccountID, accountID))} - shouldShowRightIcon + value={userSecurityGroup?.securityGroup?.name} /> ; @@ -12,10 +12,10 @@ type ActionListContextType = { getScrollOffset: () => number; /** Each list publishes its locally-owned ref on mount; pass `null` to clear on unmount. */ - registerListRef: (ref: FlatListRefType) => void; + registerListRef: (ref: ActionListRefType) => void; /** Reads the currently registered list ref. Call from handlers only, never during render. */ - getListRef: () => FlatListRefType; + getListRef: () => ActionListRefType; }; const ActionListContext = createContext({ @@ -35,7 +35,7 @@ function useActionListContext() { */ function useActionListRef() { const {registerListRef} = useActionListContext(); - const listRef = useRef(null); + const listRef = useRef(null); useLayoutEffect(() => { registerListRef(listRef); @@ -49,7 +49,7 @@ function useActionListRef() { function ActionListContextProvider({children}: {children: ReactNode}) { // Each list owns its own ref locally and publishes it here on mount; only the register/get // callbacks live in context, so attaching `ref={}` stays local to each list. - const listRefHolder = useRef(null); + const listRefHolder = useRef(null); const scrollOffsetRef = useRef(0); const value: ActionListContextType = { diff --git a/src/pages/inbox/ActionListTypes.ts b/src/pages/inbox/ActionListTypes.ts new file mode 100644 index 000000000000..d429168c46cb --- /dev/null +++ b/src/pages/inbox/ActionListTypes.ts @@ -0,0 +1,31 @@ +import type {RefObject} from 'react'; + +type ScrollToIndexParams = { + animated?: boolean; + index: number; + viewOffset?: number; + viewPosition?: number; +}; + +type ScrollToOffsetParams = { + animated?: boolean; + offset: number; +}; + +type ScrollToEndParams = { + animated?: boolean; +}; + +/** Common imperative API used by the report scroll manager across FlatList, FlashList, and LegendList. */ +type ActionListRef = { + scrollToIndex: (params: ScrollToIndexParams) => void; + scrollToOffset: (params: ScrollToOffsetParams) => void; + scrollToEnd: (params?: ScrollToEndParams) => void; + getNativeScrollRef?: () => unknown; +}; + +/** Ref to the underlying list instance attached via `ref={}`. */ +type ActionListRefType = RefObject | null; + +export default ActionListRefType; +export type {ActionListRef}; diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index 72120797437d..aa440f89610c 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -92,6 +92,7 @@ function ReportFetchHandler() { // Only the main report route carries a Submit-via-PDF secure access key. const secureKeyFromRoute = route.name === SCREENS.REPORT ? route.params?.secureKey : undefined; + const isPendingCreationFromRoute = route.name === SCREENS.REPORT ? route.params?.isPendingCreation === 'true' : false; const shouldReplaceWithExpenseReportRHP = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT && route.params?.[REPORT_LINK_ROUTE_PARAMS.SHOULD_REPLACE_WITH_EXPENSE_REPORT_RHP] === 'true'; const navigation = useNavigation>(); @@ -111,6 +112,7 @@ function ReportFetchHandler() { const [reportOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`); const [hasReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDFromRoute}`, {selector: Boolean}); const [reportDraftOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportIDFromRoute}`); + const [isPreMountedDraft] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportIDFromRoute}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportOnyx?.chatReportID}`); const [reportMetadata = defaultReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDFromRoute}`); const [reportLoadingState = defaultReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportIDFromRoute}`); @@ -174,6 +176,14 @@ function ReportFetchHandler() { return; } + // isPendingCreationFromRoute means reportIDFromRoute is a client-generated ID that doesn't exist + // on the server yet. Calling openReport for it would 403 and show the not-found page instead. + // Once the real submit creates the report under this ID, reportOnyx?.reportID becomes truthy + // and normal fetching resumes. + if (isPendingCreationFromRoute && !reportOnyx?.reportID) { + return; + } + if (reportMetadata.isOptimisticReport && report?.type === CONST.REPORT.TYPE.CHAT && !isPolicyExpenseChat(report)) { // openReport is intentionally never called for an optimistic chat report, so nothing else can settle its // initial-load state. The stamp written at creation lives in a RAM-only key and is lost on an app restart, @@ -194,6 +204,13 @@ function ReportFetchHandler() { return; } + // A pre-mounted draft gets copied into the real report collection so it can render immediately, + // which makes reportOnyx?.reportID truthy even though the row is still speculative - the guard + // above no longer catches it, so check the pre-mount marker directly instead. + if (isPreMountedDraft) { + return; + } + if (report?.errorFields?.notFound && isOffline) { return; } @@ -216,6 +233,7 @@ function ReportFetchHandler() { reportActionID: reportActionIDFromRoute, participants: dmParticipants, betas, + personalDetails, hasReportActions, currentUserAccountID, isSelfTourViewed, @@ -256,7 +274,7 @@ function ReportFetchHandler() { if (!shouldUseNarrowLayout || !isChatThread(report) || !isHiddenForCurrentUser(report) || isTransactionThreadView) { return; } - openReport({reportID, introSelected, conciergeChat, betas, hasReportActions, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow}); + openReport({reportID, introSelected, conciergeChat, betas, personalDetails, hasReportActions, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow}); }); const joinPublicRoomIfNeeded = useEffectEvent(() => { @@ -269,6 +287,7 @@ function ReportFetchHandler() { introSelected, conciergeChat, betas, + personalDetails, hasReportActions: hasViewingPublicRoomReportActions, currentUserAccountID, isSelfTourViewed, @@ -325,6 +344,16 @@ function ReportFetchHandler() { navigation.setParams({secureKey: undefined}); }, [secureKeyFromRoute, reportIDFromRoute, report?.reportID, report?.errorFields?.notFound, navigation]); + // isPendingCreation is only needed before the report exists. Clear it from the route params once it + // does, so a copied URL, restored navigation state, or reload doesn't carry the stale flag and skip + // fetching again later. + useEffect(() => { + if (!isPendingCreationFromRoute || !reportOnyx?.reportID) { + return; + } + navigation.setParams({isPendingCreation: undefined}); + }, [isPendingCreationFromRoute, reportOnyx?.reportID, navigation]); + useEffect(() => { if (!isAnonymousUser) { return; diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 2d47bb8dc47c..a4b996145a09 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -646,12 +646,13 @@ const ContextMenuActions: ContextMenuAction[] = [ childReportActions, currentUserAccountID, conciergeChat, + personalDetails, }, ) => { if (isMoneyRequestAction(reportAction) || isMoneyRequestAction(moneyRequestAction)) { const editExpense = () => { const childReportID = reportAction?.childReportID; - openReport({reportID: childReportID, introSelected, betas, hasReportActions: !!childReportActions, currentUserAccountID, conciergeChat}); + openReport({reportID: childReportID, introSelected, betas, personalDetails, hasReportActions: !!childReportActions, currentUserAccountID, conciergeChat}); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); }; if (closePopover) { diff --git a/src/pages/inbox/report/MoneyReportContentCreated.tsx b/src/pages/inbox/report/MoneyReportContentCreated.tsx index 46096456685d..efd9f93c761b 100644 --- a/src/pages/inbox/report/MoneyReportContentCreated.tsx +++ b/src/pages/inbox/report/MoneyReportContentCreated.tsx @@ -75,6 +75,7 @@ function MoneyReportContentCreated({report, policy, transaction, transactionThre (0); +type ReportActionPosition = { + index: number; + isNewest: boolean; + isRecycling?: boolean; +}; + +const ReportActionIndexContext = createContext({index: 0, isNewest: false}); + +/** + * Uses LegendList's recycling-aware state in the main report list and behaves like useState in shared, non-recycled lists. + */ +function useReportActionItemState(initialState: State | (() => State)): [State, Dispatch>] { + const {isRecycling = false} = useContext(ReportActionIndexContext); + const state = useState(initialState); + const recyclingState = useRecyclingState(initialState); + return isRecycling ? [...recyclingState] : state; +} +export {useReportActionItemState}; export default ReportActionIndexContext; diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 7011ba259e63..37c041d5b16e 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -79,12 +79,13 @@ import {isEmptyObject, isEmptyValueObject} from '@src/types/utils/EmptyObject'; import type {GestureResponderEvent, TextInput} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {useRecyclingEffect} from '@legendapp/list/react-native'; import {useNavigation} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {personalDetailsDisplayNameSelector} from '@selectors/PersonalDetails'; import {deepEqual} from 'fast-equals'; import mapValues from 'lodash/mapValues'; -import React, {useContext, useEffect, useRef, useState} from 'react'; +import React, {useContext, useEffect, useRef} from 'react'; import {Keyboard, View} from 'react-native'; import type {ContextMenuAnchor} from './ContextMenu/ReportActionContextMenu'; @@ -95,6 +96,7 @@ import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMe import {hideContextMenu, hideDeleteModal, isActiveReportAction, showContextMenu} from './ContextMenu/ReportActionContextMenu'; import LinkPreviewer from './LinkPreviewer'; import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; +import {useReportActionItemState} from './ReportActionIndexContext'; import ReportActionItemContentCreated from './ReportActionItemContentCreated'; import ReportActionItemFrame from './ReportActionItemFrame'; import ReportActionItemThread from './ReportActionItemThread'; @@ -203,16 +205,19 @@ function ReportActionItem({ const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const [isContextMenuActive, setIsContextMenuActive] = useState(() => isActiveReportAction(action.reportActionID)); - const [isEmojiPickerActive, setIsEmojiPickerActive] = useState(); - const [isPaymentMethodPopoverActive, setIsPaymentMethodPopoverActive] = useState(); - const [isHidden, setIsHidden] = useState(false); + const [isContextMenuActive, setIsContextMenuActive] = useReportActionItemState(() => isActiveReportAction(action.reportActionID)); + const [isEmojiPickerActive, setIsEmojiPickerActive] = useReportActionItemState(undefined); + const [isPaymentMethodPopoverActive, setIsPaymentMethodPopoverActive] = useReportActionItemState(undefined); + const [isHidden, setIsHidden] = useReportActionItemState(false); const {isActiveReportAction: isActiveReactionListReportAction, hideReactionList} = useContext(ReactionListContext); const {updateHiddenAttachments} = useContext(AttachmentModalContext); const popoverAnchorRef = useRef>(null); const downloadedPreviews = useRef([]); + useRecyclingEffect(() => { + downloadedPreviews.current = []; + }); const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; - const [isReportActionActive, setIsReportActionActive] = useState(!!isReportActionLinked); + const [isReportActionActive, setIsReportActionActive] = useReportActionItemState(!!isReportActionLinked); const shouldBreakGrouping = shouldBreakAccessibilityGrouping(); const isScreenReaderActive = Accessibility.useScreenReaderStatus(); @@ -356,7 +361,7 @@ function ReportActionItem({ return; } setIsHidden(false); - }, [latestDecision, action]); + }, [latestDecision, action, setIsHidden]); const toggleContextMenuFromActiveReportAction = () => { setIsContextMenuActive(isActiveReportAction(action.reportActionID)); diff --git a/src/pages/inbox/report/ReportActionItemContentCreated.tsx b/src/pages/inbox/report/ReportActionItemContentCreated.tsx index c284acd7a82a..d8fe6333c1b3 100644 --- a/src/pages/inbox/report/ReportActionItemContentCreated.tsx +++ b/src/pages/inbox/report/ReportActionItemContentCreated.tsx @@ -96,6 +96,7 @@ function ReportActionItemContentCreated({parentReportAction, transactionID, draf diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index c7bc3f0953af..9933a42fa34f 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -86,7 +86,7 @@ const DEFAULT_MODAL_VALUE = { }; function ReportActionItemMessageEdit({action, reportID, originalReportID, policyID, ref}: ReportActionItemMessageEditProps) { - const index = useContext(ReportActionIndexContext); + const {index, isNewest} = useContext(ReportActionIndexContext); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportID)}`); @@ -258,7 +258,7 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy reportID, originalReportID, reportAction: action, - shouldScrollToLastMessage: index === 0, + shouldScrollToLastMessage: isNewest, debouncedCommentMaxLengthValidation, composerRef, }); diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index cb69d6059692..c88c7e2c650e 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,24 +1,23 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; -import InvertedFlashList from '@components/FlashList/InvertedFlashList'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import useConciergeSessionStartTime from '@hooks/useConciergeSessionStartTime'; +import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; import useEnvironment from '@hooks/useEnvironment'; import useLinkedMessageOfflineLoading from '@hooks/useLinkedMessageOfflineLoading'; import useLocalize from '@hooks/useLocalize'; import useMarkAsRead from '@hooks/useMarkAsRead'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useReportActionsPaginationScroll from '@hooks/useReportActionsPaginationScroll'; import useReportActionsScroll from '@hooks/useReportActionsScroll'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useUnreadMarker from '@hooks/useUnreadMarker'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; import { getFirstVisibleReportActionID, getReportActionHtml, @@ -46,8 +45,8 @@ import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; +import type {ActionListRef} from '@pages/inbox/ActionListTypes'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; -import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -55,13 +54,15 @@ import type SCREENS from '@src/SCREENS'; import {getStableReportSelector} from '@src/selectors/Report'; import type * as OnyxTypes from '@src/types/onyx'; -import type {ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {LegendList} from '@legendapp/list/react-native'; import {useRoute} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; +import {View} from 'react-native'; import FloatingMessageCounter from './FloatingMessageCounter'; import ReportActionIndexContext from './ReportActionIndexContext'; @@ -69,6 +70,7 @@ import {useReportActionsListActions, useReportActionsListState} from './ReportAc import ReportActionsListHeader from './ReportActionsListHeader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; import ReportActionsListPaddingView from './ReportActionsListPaddingView'; +import ReportActionsPaginationLoadingIndicator, {PAGINATION_LOADING_INDICATOR_HEIGHT} from './ReportActionsPaginationLoadingIndicator'; import ReportActionsSkeletonGuard from './ReportActionsSkeletonGuard'; import ShowPreviousMessagesButton from './ShowPreviousMessagesButton'; import useFollowActionBadgeTarget from './useFollowActionBadgeTarget'; @@ -83,13 +85,55 @@ type ReportActionsListContentProps = { type ReportActionsListProps = ReportActionsListContentProps; +const REPORT_ACTIONS_DRAW_DISTANCE = 1500; + +const REPORT_ACTION_COMMENT_SIZE = { + SHORT: 'short', + MEDIUM: 'medium', + LONG: 'long', + EXTRA_LONG: 'extra-long', +} as const; + +function getReportActionCommentSize(messageLength: number): string { + if (messageLength <= 80) { + return REPORT_ACTION_COMMENT_SIZE.SHORT; + } + if (messageLength <= 320) { + return REPORT_ACTION_COMMENT_SIZE.MEDIUM; + } + if (messageLength <= 1200) { + return REPORT_ACTION_COMMENT_SIZE.LONG; + } + return REPORT_ACTION_COMMENT_SIZE.EXTRA_LONG; +} + +function getItemType(item: OnyxTypes.ReportAction): string { + if (item.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) { + return item.actionName; + } + + const message = getReportActionMessage(item); + const commentSize = getReportActionCommentSize(message?.text.length ?? 0); + + if (item.isAttachmentOnly) { + return `${item.actionName}-attachment`; + } + if (item.isAttachmentWithText) { + return `${item.actionName}-attachment-${commentSize}`; + } + if (item.linkMetadata?.length) { + return `${item.actionName}-link-preview-${commentSize}`; + } + return `${item.actionName}-${commentSize}`; +} + /** - * Create a unique key for each action in the FlatList. + * Create a unique key for each action in the list. * We use the reportActionID that is a string representation of a random 64-bit int, which should be * random enough to avoid collisions */ function keyExtractor(item: OnyxTypes.ReportAction): string { - // A report has exactly one CREATED action. Using a stable key lets FlashList recycle the same cell + // A report has exactly one CREATED action. Using a stable key lets the list recycle the same cell // when the optimistic CREATED is swapped for the server one, avoiding a remount-induced scroll jump. if (item.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { return CONST.REPORT.ACTIONS.TYPE.CREATED; @@ -105,14 +149,23 @@ function keyExtractor(item: OnyxTypes.ReportAction): string { function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportActionsListContentProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isProduction} = useEnvironment(); const { report, hasOnceLoadedReportActions, + hasOlderActions, hasNewerActions, + isLoadingOlderReportActions, + hasLoadingOlderReportActionsError, + isLoadingNewerReportActions, + hasLoadingNewerReportActionsError, + oldestReportActionID, + newestReportActionID, + olderReportActionsRequestCursor, + newerReportActionsRequestCursor, + canLoadNewerChats, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, @@ -131,9 +184,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {isOffline} = useNetwork(); const route = useRoute>(); const reportActionIDFromRoute = route?.params?.reportActionID; - const {sessionStartTime} = useConciergeSessionState(); + const sessionStartTime = useConciergeSessionStartTime(); const didLayout = useRef(false); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true}); useEffect(() => { didLayout.current = false; @@ -141,8 +195,14 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); - // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. - const listID = [reportID, reportActionIDFromRoute, hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID].join(':'); + // OpenReport can first provide a tiny cached page and then replace it with the hydrated page. Remounting + // gives the complete dataset a fresh initial layout so initialScrollAtEnd targets its actual end. + const listID = [ + reportID, + reportActionIDFromRoute, + hasOnceLoadedReportActions ? 'hydrated' : 'initial', + hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID, + ].join(':'); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); const isReportArchived = !!isArchivedReport(reportNameValuePairs); @@ -172,22 +232,72 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {getScrollOffset} = useActionListContext(); const listRef = useActionListRef(); + const legendListRef = useRef(null); + const [viewportHeight, setViewportHeight] = useState(0); + const [newerFooterHeight, setNewerFooterHeight] = useState(0); + const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); + + useImperativeHandle( + listRef, + (): ActionListRef => ({ + getNativeScrollRef: () => legendListRef.current?.getNativeScrollRef(), + scrollToEnd: (options) => { + legendListRef.current?.scrollToEnd(options); + }, + scrollToIndex: (options) => { + legendListRef.current?.scrollToIndex(options); + }, + scrollToOffset: (options) => { + legendListRef.current?.scrollToOffset(options); + }, + }), + [], + ); const {draftReportAction, isDraftPendingCompletion} = useConciergeDraft(); const {clearDraft, revealDraftFromReportAction} = useConciergeDraftActions(); const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; const onShowPreviousMessages = handleShowPreviousMessages; + const canPaginateOlder = viewportHeight > 0 && !isOffline && !!hasOnceLoadedReportActions && hasOlderActions && !showHiddenHistory; + const canPaginateNewer = viewportHeight > 0 && !isOffline && !!hasOnceLoadedReportActions && hasNewerActions; + const shouldShowOlderPaginationLoadingIndicator = canPaginateOlder && !!isLoadingOlderReportActions && !hasLoadingOlderReportActionsError; + const shouldShowNewerPaginationLoadingIndicator = canPaginateNewer && !!isLoadingNewerReportActions && !hasLoadingNewerReportActionsError; + const olderPaginationExtent = shouldShowOlderPaginationLoadingIndicator ? PAGINATION_LOADING_INDICATOR_HEIGHT : 0; + const newerPaginationExtent = canPaginateNewer ? newerFooterHeight + (shouldShowNewerPaginationLoadingIndicator ? PAGINATION_LOADING_INDICATOR_HEIGHT : 0) : 0; + + const {onScroll: checkPaginationOnScroll, onContentSizeChange: checkPaginationOnContentSizeChange} = useReportActionsPaginationScroll({ + reportID, + linkedReportActionID: reportActionIDFromRoute, + listRef: legendListRef, + viewportHeight, + olderPaginationExtent, + newerPaginationExtent, + olderCursor: olderReportActionsRequestCursor ?? oldestReportActionID, + newerCursor: newerReportActionsRequestCursor ?? newestReportActionID, + hasOlderActions, + hasNewerActions, + isLoadingOlderReportActions: !!isLoadingOlderReportActions, + isLoadingNewerReportActions: !!isLoadingNewerReportActions, + hasLoadingOlderReportActionsError: !!hasLoadingOlderReportActionsError, + hasLoadingNewerReportActionsError: !!hasLoadingNewerReportActionsError, + isOffline, + canLoadOlder: !showHiddenHistory && loadedInitialViewportListID === listID, + canLoadNewer: canLoadNewerChats && loadedInitialViewportListID === listID, + loadOlderActions: () => loadOlderChats(false), + loadNewerActions: () => loadNewerChats(false), + }); const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - const {unreadMarkerReportActionID, unreadMarkerReportActionIndex} = useUnreadMarker({ + const {unreadMarkerReportActionID} = useUnreadMarker({ reportID, sortedVisibleReportActions, sortedReportActions, oldestUnreadReportActionID: oldestUnreadReportAction?.reportActionID, isScrolledOverThreshold: hasScrolledOverThreshold, hasOnceLoadedReportActions: !!hasOnceLoadedReportActions, + newMessageBoundaryTime: isConciergeHiddenHistory ? sessionStartTime : undefined, }); const {markNewestActionAsRead, completeSkippedMarkAsRead} = useMarkAsRead({ @@ -234,10 +344,25 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return visibleReportActionsWithDraft; })(); + const [initialReportActionsSnapshot, setInitialReportActionsSnapshot] = useState<{reportActions: OnyxTypes.ReportAction[]; reportID: string}>(); + const hasInitialReportActionsSnapshot = initialReportActionsSnapshot?.reportID === reportID; + + // OpenReport starts with a tiny cached page before replacing it with the hydrated page. Keep that + // already-visible page mounted until hydration finishes instead of exposing intermediate estimated + // layouts. The hydrated list then mounts from scratch using the full dataset. + if (!hasOnceLoadedReportActions && !hasInitialReportActionsSnapshot && renderedVisibleReportActions.length > 0) { + setInitialReportActionsSnapshot({reportActions: renderedVisibleReportActions, reportID}); + } + + const reportActionsToRender = !hasOnceLoadedReportActions && hasInitialReportActionsSnapshot ? initialReportActionsSnapshot.reportActions : renderedVisibleReportActions; + + // Report actions are stored newest-first. LegendList intentionally has no inverted mode, so + // give it chronological data and use its normal start/end and scrolling semantics. + const listData = reportActionsToRender.toReversed(); + const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; const draftReportActionID = draftReportAction?.reportActionID; const isSyntheticDraftVisible = !!draftReportAction && renderedVisibleReportActions !== sortedVisibleReportActions; - const draftAutoScrollKey = isSyntheticDraftVisible ? `${draftReportAction.reportActionID}:${draftMessageHTML ?? ''}` : ''; useEffect(() => { if (!draftReportAction || isSyntheticDraftVisible) { @@ -255,9 +380,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct revealDraftFromReportAction(persistedDraftReportAction); }, [draftReportAction, persistedDraftReportAction, revealDraftFromReportAction]); - // Find the index of the action badge target in the rendered actions list (which is what the FlatList uses as data) + // Find the index of the action badge target in the chronological data rendered by LegendList. const actionBadgeTargetID = reportAttributes?.actionTargetReportActionID; - const actionBadgeTargetIndex = actionBadgeTargetID ? renderedVisibleReportActions.findIndex((action) => action.reportActionID === actionBadgeTargetID) : -1; + const actionBadgeTargetIndex = actionBadgeTargetID ? listData.findIndex((action) => action.reportActionID === actionBadgeTargetID) : -1; + const unreadMarkerListIndex = unreadMarkerReportActionID ? listData.findIndex((action) => action.reportActionID === unreadMarkerReportActionID) : -1; const { trackVerticalScrolling, @@ -266,12 +392,9 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget, - flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldFocusToTopOnMount, initialScrollIndex, initialScrollIndexParams, - maintainVisibleContentPosition, onLoad, } = useReportActionsScroll({ reportID, @@ -280,44 +403,42 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct transactionThreadReport, parentReportAction, sortedVisibleReportActions, - renderedVisibleReportActions, + renderedVisibleReportActions: listData, keyExtractor, - hasScrolledOverThreshold, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, - unreadMarkerReportActionIndex, + unreadMarkerReportActionIndex: unreadMarkerListIndex, hasNewerActions, - draftAutoScrollKey, actionBadgeTargetIndex, sortedAllReportActionsForPagination: sortedAllReportActions ?? [], treatAsNoPaginationAnchor, setTreatAsNoPaginationAnchor, }); - const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { - trackVerticalScrolling(event); - setHasScrolledOverThreshold(event.nativeEvent.contentOffset.y >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - }; + const shouldShowInitialViewportSkeleton = !isOffline && (!hasOnceLoadedReportActions || loadedInitialViewportListID !== listID); - const loadOlderChatsOnEndReached = () => { - if (showHiddenHistory) { - return; - } - loadOlderChats(false); + const handleListLoad = () => { + onLoad(); + setLoadedInitialViewportListID(listID); }; - const loadNewerChatsAfterTransitions = () => { - if (!isSearchTopmostFullScreenRoute()) { - loadNewerChats(false); - return; - } - - TransitionTracker.runAfterTransitions({ - callback: () => { - requestAnimationFrame(() => loadNewerChats(false)); + const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const distanceFromBottom = Math.max(0, contentSize.height - layoutMeasurement.height - contentOffset.y); + checkPaginationOnScroll(); + + const bottomRelativeEvent = { + ...event, + nativeEvent: { + ...event.nativeEvent, + contentOffset: {...contentOffset, y: distanceFromBottom}, }, - }); + }; + + trackVerticalScrolling(bottomRelativeEvent); + setHasScrolledOverThreshold(distanceFromBottom >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + emitComposerScrollEvents(); }; const firstVisibleReportActionID = getFirstVisibleReportActionID(sortedReportActions, isOffline); @@ -327,7 +448,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct reportID, actionTargetReportActionID: reportAttributes?.actionTargetReportActionID, actionBadgeTargetIndex, - renderedVisibleReportActions, + renderedVisibleReportActions: listData, scrollToActionBadgeTarget, }); @@ -355,11 +476,12 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); })(); - const renderItem = ({item: reportAction, index}: ListRenderItemInfo) => { + const renderItem = ({item: reportAction, index}: LegendListRenderItemProps) => { const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; + const reportActionIndex = reportActionsToRender.length - index - 1; return ( - + 1} + shouldDisplayReplyDivider={reportActionsToRender.length > 1} isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} shouldUseThreadDividerLine={shouldUseThreadDividerLine} isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReportAction} @@ -403,16 +525,34 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct isDraftPendingCompletion, ]; - const listHeaderComponent = ( - + const handleViewportLayout = (event: LayoutChangeEvent) => { + setViewportHeight(event.nativeEvent.layout.height); + }; + + const handleNewerFooterLayout = (event: LayoutChangeEvent) => { + setNewerFooterHeight(event.nativeEvent.layout.height); + }; + + const newerListFooterComponent = ( + <> + + + + {shouldShowNewerPaginationLoadingIndicator && } + ); const shouldShowOfflineSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - const listFooterComponent = shouldShowOfflineSkeleton ? : undefined; + const olderListHeaderComponent = ( + <> + {shouldShowOlderPaginationLoadingIndicator && } + {shouldShowOfflineSkeleton && } + + ); const shouldShowMarkAsDoneCopy = shouldShowMarkAsDone({ policy, @@ -456,46 +596,61 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct report={report} isReportArchived={isReportArchived} > - { - recordTimeToMeasureItemLayout(event); - flushPendingScrollToBottom(); - }} - onScroll={trackScrollPositionAndThreshold} - onViewableItemsChanged={onViewableItemsChanged} - extraData={extraData} - key={listID} - overrideProps={{ - isInvertedVirtualizedList: true, - contentOffset: shouldFocusToTopOnMount ? {x: 0, y: windowHeight} : undefined, - }} - getItemType={(item) => item.actionName} - initialScrollIndex={initialScrollIndex} - initialScrollIndexParams={initialScrollIndexParams} - maintainVisibleContentPosition={maintainVisibleContentPosition} - onLoad={onLoad} - onContentSizeChange={() => { - trackVerticalScrolling(undefined); - }} - /> + + {viewportHeight > 0 ? ( + { + trackVerticalScrolling(undefined); + checkPaginationOnContentSizeChange(); + }} + /> + ) : ( + + )} + {viewportHeight > 0 && shouldShowInitialViewportSkeleton && ( + + + + )} + ); diff --git a/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx b/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx new file mode 100644 index 000000000000..383564ba4ad7 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx @@ -0,0 +1,48 @@ +import ActivityIndicator from '@components/ActivityIndicator'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; + +type PaginationDirection = 'older' | 'newer'; + +type ReportActionsPaginationLoadingIndicatorProps = { + direction: PaginationDirection; +}; + +const PAGINATION_LOADING_INDICATOR_HEIGHT = 72; +const PAGINATION_LOADING_INDICATOR_TOP_PADDING = 24; +const PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING = 24; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + height: PAGINATION_LOADING_INDICATOR_HEIGHT, + justifyContent: 'center', + paddingBottom: PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, + paddingTop: PAGINATION_LOADING_INDICATOR_TOP_PADDING, + }, +}); + +function ReportActionsPaginationLoadingIndicator({direction}: ReportActionsPaginationLoadingIndicatorProps) { + const testID = `report-actions-pagination-${direction}`; + + return ( + + + + ); +} + +export default ReportActionsPaginationLoadingIndicator; +export {PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, PAGINATION_LOADING_INDICATOR_HEIGHT, PAGINATION_LOADING_INDICATOR_TOP_PADDING}; diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index 0e8f9d76f666..5f55dfb63898 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -204,7 +204,7 @@ function ActionContentRouter({ if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { return ( {isEditingInline ? ( { const isNextMessageUnread = !!nextMessage && isReportActionUnread(nextMessage, unreadMarkerTime); @@ -91,7 +96,12 @@ const shouldDisplayNewMarkerOnReportAction = ({ return false; } - return !isNewMessage || isScrolledOverThreshold || !hasWindowFocus; + // An action created before the session boundary was revealed or loaded from history, not received live, + // so it is never treated as read-on-arrival. + const isRevealedHistoryMessage = !!newMessageBoundaryTime && message.created < newMessageBoundaryTime; + + const result = !isNewMessage || isRevealedHistoryMessage || isScrolledOverThreshold || !hasWindowFocus; + return result; }; export default shouldDisplayNewMarkerOnReportAction; @@ -126,6 +136,10 @@ type GetUnreadMarkerReportActionParams = { prevUnreadMarkerReportActionID?: string | null; /** Whether the app window is focused */ hasWindowFocus?: boolean; + + /** Actions created before this time cannot have "just arrived" (e.g. Concierge history revealed via "Show history"), + * so the live auto-read suppression for new-to-list messages does not apply to them */ + newMessageBoundaryTime?: string | null; }; /** @@ -144,6 +158,7 @@ const getUnreadMarkerReportAction = ({ isAnonymousUser = false, prevUnreadMarkerReportActionID, hasWindowFocus = true, + newMessageBoundaryTime, }: GetUnreadMarkerReportActionParams): [string | null, number] => { if (isAnonymousUser) { return [null, -1]; @@ -185,6 +200,7 @@ const getUnreadMarkerReportAction = ({ isOffline, prevUnreadMarkerReportActionID, hasWindowFocus, + newMessageBoundaryTime, }); if (shouldShowMarker) { diff --git a/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts b/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts index 5d8940ff5020..b9b9ba105753 100644 --- a/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts +++ b/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts @@ -8,19 +8,19 @@ type ShouldFollowActionBadgeTargetParams = { /** The report action the badge targeted on the previous render */ prevActionTargetReportActionID: string | undefined; - /** Index of the current target in the rendered (inverted) list, or -1 when it is not rendered */ + /** Index of the current target in the chronological list, or -1 when it is not rendered */ actionBadgeTargetIndex: number; - /** Index of the previous target in the rendered (inverted) list, or -1 when it is not rendered */ + /** Index of the previous target in the chronological list, or -1 when it is not rendered */ prevActionBadgeTargetIndex: number; }; /** * Decide whether to auto-scroll the report list to follow the action badge once its current target is resolved. * - * The list is inverted: index 0 is the newest action at the bottom and higher indexes are older actions at the top. The badge - * always targets the oldest actionable preview, so resolving it advances the target to a newer preview (a strictly lower index). - * We only follow when the target moves to a lower index, so we scroll downward to the next actionable preview and never jump + * The list is chronological: index 0 is the oldest action and higher indexes are newer actions. The badge always targets the + * oldest actionable preview, so resolving it advances the target to a newer preview (a strictly higher index). + * We only follow when the target moves to a higher index, so we scroll downward to the next actionable preview and never jump * upward/backward (e.g. when older actions are loaded in via pagination). */ function shouldFollowActionBadgeTarget({ @@ -33,7 +33,7 @@ function shouldFollowActionBadgeTarget({ if (isProduction || !actionTargetReportActionID || !prevActionTargetReportActionID || actionTargetReportActionID === prevActionTargetReportActionID || actionBadgeTargetIndex < 0) { return false; } - return prevActionBadgeTargetIndex >= 0 && actionBadgeTargetIndex < prevActionBadgeTargetIndex; + return prevActionBadgeTargetIndex >= 0 && actionBadgeTargetIndex > prevActionBadgeTargetIndex; } export default shouldFollowActionBadgeTarget; diff --git a/src/pages/inbox/report/useFollowActionBadgeTarget.ts b/src/pages/inbox/report/useFollowActionBadgeTarget.ts index b15df8c3a2ff..67c952452920 100644 --- a/src/pages/inbox/report/useFollowActionBadgeTarget.ts +++ b/src/pages/inbox/report/useFollowActionBadgeTarget.ts @@ -18,10 +18,10 @@ type UseFollowActionBadgeTargetParams = { /** The report action the badge currently targets (the oldest preview still requiring action) */ actionTargetReportActionID: string | undefined; - /** Index of the current target in the rendered (inverted) list, or -1 when it is not rendered */ + /** Index of the current target in the chronological list, or -1 when it is not rendered */ actionBadgeTargetIndex: number; - /** The rendered (inverted) report actions the list is displaying */ + /** The chronological report actions the list is displaying */ renderedVisibleReportActions: OnyxTypes.ReportAction[]; /** Scrolls the list to the current action-badge target */ diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index da424fdf5e13..02b8d8665a76 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -19,7 +19,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {useNavigation} from '@react-navigation/native'; -import {useCallback, useEffect, useEffectEvent, useRef, useState} from 'react'; +import {useEffect, useEffectEvent, useRef, useState} from 'react'; // In the component we are subscribing to the arrival of new actions. // As there is the possibility that there are multiple instances of a ReportScreen @@ -48,7 +48,8 @@ type UseReportActionsNewActionLiveTailParams = { hasNewerActions: boolean; linkedReportActionID: string | undefined; hasNewestReportAction: boolean; - sortedVisibleReportActions: OnyxTypes.ReportAction[]; + /** Actions rendered by the list in chronological order. */ + renderedVisibleReportActions: OnyxTypes.ReportAction[]; sortedAllReportActionsForPagination: OnyxTypes.ReportAction[]; reportActionPages: OnyxTypes.Pages | undefined; setTreatAsNoPaginationAnchor: (value: boolean) => void; @@ -61,9 +62,8 @@ type LiveTailJumpStage = 'idle' | 'open_report' | 'await_scroll' | 'await_prune' /** * Owns subscribe-to-new-action scrolling, live-tail jump (openReport → scroll → prune), and the - * deferred scroll + pagination prune after layout. Uses useEffectEvent for the Pusher subscription handler so it - * always sees the latest props without mirror refs. The layout-time prune step uses useCallback so callers can invoke - * it from list `onLayout` outside this hook. + * deferred scroll + pagination prune after the data render. Uses useEffectEvent for the Pusher subscription handler so it + * always sees the latest props without mirror refs. The prune callback completes the explicit scroll request in the caller. */ function useReportActionsNewActionLiveTail({ conciergeChat, @@ -80,7 +80,7 @@ function useReportActionsNewActionLiveTail({ hasNewerActions, linkedReportActionID, hasNewestReportAction, - sortedVisibleReportActions, + renderedVisibleReportActions, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor, @@ -132,14 +132,14 @@ function useReportActionsNewActionLiveTail({ return; } - const index = sortedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); + const index = renderedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { - if (index > 0) { + setIsFloatingMessageCounterVisible(false); + if (index >= 0 && index < renderedVisibleReportActions.length - 1) { setTimeout(() => { reportScrollManager.scrollToIndex(index); }, 100); } else { - setIsFloatingMessageCounterVisible(false); reportScrollManager.scrollToBottom(); } if (action?.reportActionID) { @@ -147,22 +147,20 @@ function useReportActionsNewActionLiveTail({ } } else { setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); + setIsScrollToBottomEnabled(true); } - - setIsScrollToBottomEnabled(true); }, }); }); - const completeLiveTailPruneAfterScrollToBottom = useCallback(() => { + const completeLiveTailPruneAfterScrollToBottom = () => { if (liveTailJumpRef.current.stage !== 'await_prune') { return; } pruneReportActionPagesToNewestWindow(reportID, sortedAllReportActionsForPagination, reportActionPages); setTreatAsNoPaginationAnchor(false); liveTailJumpRef.current = {stage: 'idle'}; - }, [reportID, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor]); + }; useEffect(() => { liveTailJumpRef.current = {stage: 'idle'}; diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index 053d23522914..8f41679a75fd 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -157,11 +157,9 @@ export default function useReportUnreadMessageScrollTracking({ ref.current.onUnreadActionVisible(); } - // Track whether the action badge target is above the viewport (i.e., not visible and at a higher index in the inverted list) + // Track whether the action badge target is above the viewport. const badgeTargetIndex = ref.current.actionBadgeTargetIndex; if (badgeTargetIndex !== -1) { - // In an inverted list, higher indexes are "above" (older messages). The target is above the viewport - // when its index is greater than the max visible index. const isAbove = isInverted ? badgeTargetIndex > maxIndex : badgeTargetIndex < minIndex; setIsActionBadgeAboveViewport(isAbove); } else { diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TravelMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TravelMenuItem.tsx index 253bbb102df2..1bc9427510fb 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TravelMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TravelMenuItem.tsx @@ -1,11 +1,11 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; import {openTravelDotLink, shouldOpenTravelDotLinkWeb} from '@libs/openTravelDotLink'; -import Permissions from '@libs/Permissions'; import {hasAcceptedTravelTerms, isPaidGroupPolicy, isWorkspaceProvisionedForTravel} from '@libs/PolicyUtils'; import FABFocusableMenuItem from '@pages/inbox/sidebar/FABPopoverContent/FABFocusableMenuItem'; @@ -24,13 +24,13 @@ const ITEM_ID = CONST.FAB_MENU_ITEM_IDS.TRAVEL; function TravelMenuItem() { const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const {translate} = useLocalize(); + const {isBetaEnabled} = usePermissions(); const icons = useMemoizedLazyExpensifyIcons(['Suitcase', 'NewWindow']); const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); const [travelSettings] = useOnyx(ONYXKEYS.NVP_TRAVEL_SETTINGS); const [primaryLogin] = useOnyx(ONYXKEYS.ACCOUNT, {selector: primaryLoginSelector}); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); - const [allBetas] = useOnyx(ONYXKEYS.BETAS); - const isBlockedFromSpotnanaTravel = Permissions.isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL, allBetas); + const isBlockedFromSpotnanaTravel = isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL); const primaryContactMethod = primaryLogin ?? sessionEmail ?? ''; const isVisible = isWorkspaceProvisionedForTravel(activePolicy?.travelSettings); diff --git a/src/pages/inbox/sidebar/InboxTabSelector.tsx b/src/pages/inbox/sidebar/InboxTabSelector.tsx index b88245f17f9b..3f9f12b09907 100644 --- a/src/pages/inbox/sidebar/InboxTabSelector.tsx +++ b/src/pages/inbox/sidebar/InboxTabSelector.tsx @@ -20,6 +20,8 @@ import type {AnchorPosition} from '@styles/index'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {ValueOf} from 'type-fest'; + import {reportNameValuePairsArchivedSelector} from '@selectors/ReportNameValuePairs'; import React, {useRef, useState} from 'react'; import {View} from 'react-native'; @@ -33,41 +35,56 @@ function InboxTabSelector() { const {translate} = useLocalize(); const styles = useThemeStyles(); const {activeTab, inboxTabCounts} = useSidebarOrderedReportsState(); - const {setActiveTab} = useSidebarOrderedReportsActions(); + const {setActiveTab, getReportIDsForTab} = useSidebarOrderedReportsActions(); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, {selector: reportNameValuePairsArchivedSelector}); const icons = useMemoizedLazyExpensifyIcons(['Checkmark']); const {showConfirmModal} = useConfirmModal(); - // Anchor the popover to the Unread tab itself (not the whole tab row) so it opens at that tab's left edge. + // Anchor the popover to the tab it was opened from (not the whole tab row) so it opens at that tab's left edge. + const allTabRef = useRef(null); const unreadTabRef = useRef(null); + const todoTabRef = useRef(null); + const tabRefs = { + [CONST.INBOX_TAB.ALL]: allTabRef, + [CONST.INBOX_TAB.UNREAD]: unreadTabRef, + [CONST.INBOX_TAB.TODO]: todoTabRef, + }; const {calculatePopoverPosition} = usePopoverPosition(); const [popoverPosition, setPopoverPosition] = useState(); const [isMenuVisible, setIsMenuVisible] = useState(false); + const [menuTab, setMenuTab] = useState>(CONST.INBOX_TAB.ALL); const getBadgeText = (count: number) => (count > 0 ? count.toString() : undefined); + const isInboxTab = (key: string): key is ValueOf => { + return key === CONST.INBOX_TAB.ALL || key === CONST.INBOX_TAB.UNREAD || key === CONST.INBOX_TAB.TODO; + }; + const openMarkAllAsReadMenu = (key: string) => { - // The bulk "mark all as read" affordance only makes sense on the Unread tab. - if (key !== CONST.INBOX_TAB.UNREAD) { + if (!isInboxTab(key)) { return; } - calculatePopoverPosition(unreadTabRef, anchorAlignment).then((position) => { + calculatePopoverPosition(tabRefs[key], anchorAlignment).then((position) => { + setMenuTab(key); setPopoverPosition(position); setIsMenuVisible(true); }); }; const confirmMarkAllAsRead = () => { + const isTodoTab = menuTab === CONST.INBOX_TAB.TODO; showConfirmModal({ title: translate('inboxTabs.markAllAsRead'), - prompt: translate('inboxTabs.markAllAsReadConfirmationPrompt'), + prompt: translate(isTodoTab ? 'inboxTabs.markAllTodosAsReadConfirmationPrompt' : 'inboxTabs.markAllAsReadConfirmationPrompt'), confirmText: translate('inboxTabs.markAllAsRead'), cancelText: translate('common.cancel'), }).then(({action}) => { if (action !== ModalActions.CONFIRM) { return; } - markAllMessagesAsRead(reportNameValuePairs); + // From the To-dos tab only the chats listed there are marked read. The All and Unread tabs both cover every + // unread chat, so they mark all of them. + markAllMessagesAsRead(reportNameValuePairs, isTodoTab ? getReportIDsForTab(CONST.INBOX_TAB.TODO) : undefined); }); }; @@ -85,6 +102,10 @@ function InboxTabSelector() { { key: CONST.INBOX_TAB.ALL, title: translate('inboxTabs.all'), + tabRef: allTabRef, + // Every tab opens the "Mark all as read" menu on long-press / right-click, so they all wire the secondary + // interaction (which suppresses the native browser context menu on web). + shouldEnableLongPress: true, }, { key: CONST.INBOX_TAB.UNREAD, @@ -93,8 +114,6 @@ function InboxTabSelector() { isBadgeCondensed: true, badgeStyles: styles.tabSelectorBadge, tabRef: unreadTabRef, - // Only the Unread tab opens the "Mark all as read" menu on long-press / right-click, so it's the - // only tab that wires the secondary interaction. All/To-dos keep the native browser context menu. shouldEnableLongPress: true, }, { @@ -103,6 +122,8 @@ function InboxTabSelector() { badgeText: getBadgeText(inboxTabCounts[CONST.INBOX_TAB.TODO]), isBadgeCondensed: true, badgeStyles: styles.tabSelectorBadge, + tabRef: todoTabRef, + shouldEnableLongPress: true, }, ]; @@ -113,7 +134,7 @@ function InboxTabSelector() { tabs={tabs} activeTabKey={activeTab} onTabPress={(key) => { - if (key !== CONST.INBOX_TAB.ALL && key !== CONST.INBOX_TAB.UNREAD && key !== CONST.INBOX_TAB.TODO) { + if (!isInboxTab(key)) { return; } setActiveTab(key); @@ -126,7 +147,7 @@ function InboxTabSelector() { onClose={() => setIsMenuVisible(false)} onItemSelected={() => setIsMenuVisible(false)} menuItems={menuItems} - anchorRef={unreadTabRef} + anchorRef={tabRefs[menuTab]} anchorPosition={popoverPosition ?? {horizontal: 0, vertical: 0}} anchorAlignment={anchorAlignment} // Safari ignores shouldCallAfterModalHide by default, which would show the confirmation modal while the diff --git a/src/pages/iou/DynamicSplitBillDetailsPage.tsx b/src/pages/iou/DynamicSplitBillDetailsPage.tsx index 91639cb7a5e1..b30129950651 100644 --- a/src/pages/iou/DynamicSplitBillDetailsPage.tsx +++ b/src/pages/iou/DynamicSplitBillDetailsPage.tsx @@ -178,6 +178,8 @@ function DynamicSplitBillDetailsPage({report, reportAction}: SplitBillDetailsPag payeePersonalDetails={payeePersonalDetails} selectedParticipants={participantsExcludingPayee} shouldDisplayReceipt + // Split bill details never render an editable participant row (the transaction is not from global create), so there is nothing to open. + onOpenParticipantPicker={() => {}} iouType={CONST.IOU.TYPE.SPLIT} isReadOnly={!isEditingSplitBill} shouldShowSmartScanFields diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index 5bfe6b638687..968b07e220cc 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -10,7 +10,6 @@ import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useResetIOUType from '@hooks/useResetIOUType'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -158,9 +157,6 @@ function IOURequestStartPage({ return undefined; }, [transaction?.iouRequestType, isStaleTransactionDraft, shouldUseTab, selectedTab, availableTabs]); - const {isBetaEnabled} = usePermissions(); - const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); - const resetIOUTypeIfChanged = useResetIOUType({ reportID, report, @@ -171,7 +167,6 @@ function IOURequestStartPage({ iouType, policy, skipKeyboardDismissForPerDiem: true, - isNewManualExpenseFlowEnabled, }); useEffect(() => { @@ -185,7 +180,7 @@ function IOURequestStartPage({ }, []); const navigateBack = () => { - // In the new manual expense beta the confirmation is embedded with its header hidden, + // The confirmation is embedded with its header hidden, // so this back button is the only way to abandon the flow. Cancel any active span // unconditionally (mirrors IOURequestStepConfirmation.navigateBack). No-op when no // tracking session is active. @@ -221,7 +216,7 @@ function IOURequestStartPage({ // it, so PAY is the only type this has to add back.) // The pay quick action still writes SKIP_CONFIRMATION, but IOURequestStepAmount is its only reader and no longer // mounts for PAY - the embedded confirmation carries the amount inline, so there is no separate step left to skip. - const shouldEmbedConfirmation = isNewManualExpenseFlowEnabled && (shouldUseTab || iouType === CONST.IOU.TYPE.PAY); + const shouldEmbedConfirmation = shouldUseTab || iouType === CONST.IOU.TYPE.PAY; // The embedded confirmation renders its body without a ScreenWrapper of its own, so that this page's focus trap // stays the sole owner of the header + tab bar + content Tab cycle. Its viewport sizing has to move here with it: @@ -280,14 +275,14 @@ function IOURequestStartPage({ canSendInvoice={iouRequestStartPolicies?.canSendInvoiceFromAnyWorkspace} > - {/* If the new manual expense flow is enabled, the confirmation screen is shown on the start page, so we do not want to disable the drag and drop provider in that case */} - + {/* The confirmation screen is shown on the start page for the manual tab, so we do not want to disable the drag and drop provider in that case */} + 0, }} footerContent={footerContent} isLoadingNewOptions={!!isSearchingForReports} diff --git a/src/pages/iou/request/ParticipantSearchResults.tsx b/src/pages/iou/request/ParticipantSearchResults.tsx index 790f702617ae..935377c57fa3 100644 --- a/src/pages/iou/request/ParticipantSearchResults.tsx +++ b/src/pages/iou/request/ParticipantSearchResults.tsx @@ -557,6 +557,10 @@ function ParticipantSearchResults({ 0 || isCategorizeOrShareAction, + // Pass the footer Next button's disabled state so Enter falls back to the list when split-bill disables Next; + // otherwise Enter can't toggle off the conflicting row. + isDisabled: shouldShowSplitBillErrorMessage, }} sections={sections} ListItem={InviteMemberListItem} diff --git a/src/pages/iou/request/step/DynamicIOURequestStepParticipants.tsx b/src/pages/iou/request/step/DynamicIOURequestStepParticipants.tsx index f28e6b54ca70..469d1e53a60e 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepParticipants.tsx @@ -5,10 +5,9 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useParticipantSubmission from '@hooks/useParticipantSubmission'; -import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getIsWorkspacesOnlyForTransaction, isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils} from '@libs/IOUUtils'; +import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import {endSpan} from '@libs/telemetry/activeSpans'; import { @@ -64,8 +63,6 @@ function DynamicIOURequestStepParticipants({ const isPerDiem = isPerDiemRequest(initialTransaction); const isTime = isTimeRequestUtil(initialTransaction); const isTransactionFromCreditCardImport = isFromCreditCardImport(initialTransaction); - const {isBetaEnabled} = usePermissions(); - const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); let headerTitle = translate('iou.chooseRecipient'); if (action === CONST.IOU.ACTION.CATEGORIZE) { @@ -81,12 +78,11 @@ function DynamicIOURequestStepParticipants({ } // Split expenses can only be submitted to a workspace, so restrict the recipient list to workspaces. - // In new flow - the amount step is skipped, so we need to include the recents for all the cases. + // The amount step is skipped, so we include recents for every other case. This step is still reachable with an amount + // set (confirmation's back navigation returns here), but negatives are handled by `shouldExcludeP2P` below - only the + // zero-quantity distance case of `getIsWorkspacesOnlyForTransaction` is knowingly dropped here. // Submit-only implies workspaces-only (we still hide individuals/recents in the Submit-to-employer picker). - const isWorkspacesOnly = - isWorkspacesOnlyFromRoute || - (action === CONST.IOU.ACTION.SUBMIT && isSplitChildTransaction(initialTransaction)) || - (isNewManualExpenseFlowEnabled ? false : getIsWorkspacesOnlyForTransaction(initialTransaction, iouRequestType)); + const isWorkspacesOnly = isWorkspacesOnlyFromRoute || (action === CONST.IOU.ACTION.SUBMIT && isSplitChildTransaction(initialTransaction)); const {addParticipant, goToNextStep} = useParticipantSubmission({ reportID, diff --git a/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx b/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx index 1a35f7f61aa6..594ff38dd5eb 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx @@ -14,6 +14,7 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies'; +import useHasOwnedPaidPolicy from '@hooks/useHasOwnedPaidPolicy'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -70,6 +71,7 @@ function DynamicIOURequestStepUpgrade({ const activePolicy = useActivePolicy(); const personalPolicy = usePersonalPolicy(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const hasOwnedPaidPolicy = useHasOwnedPaidPolicy(); const lastWorkspaceNumber = useLastWorkspaceNumber(); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); @@ -334,6 +336,7 @@ function DynamicIOURequestStepUpgrade({ betas, isSelfTourViewed, hasActiveAdminPolicies, + hasOwnedPaidPolicy, }); setIsUpgraded(true); policyDataRef.current = policyData; @@ -357,6 +360,7 @@ function DynamicIOURequestStepUpgrade({ betas, isSelfTourViewed, hasActiveAdminPolicies, + hasOwnedPaidPolicy, }); policyDataRef.current = policyData; setCreatedPolicyName(params.name); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 352ac2cb3e3e..03791db62d75 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -26,7 +26,6 @@ import useOdometerReceiptStitcher from '@hooks/useOdometerReceiptStitcher'; import useOnyx from '@hooks/useOnyx'; import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions'; import useParticipantsPolicies from '@hooks/useParticipantsPolicies'; -import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePreMountDestination from '@hooks/usePreMountDestination'; @@ -38,6 +37,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {setMoneyRequestBillable, setMoneyRequestReimbursable} from '@libs/actions/IOU/MoneyRequest'; +import {clearPreMountedDraftReport, clearPreMountedDraftReportMarker, preMountDraftReport} from '@libs/actions/Report/PreMountedDraftReport'; import {setTransactionReport} from '@libs/actions/Transaction'; import {isMobileSafari} from '@libs/Browser'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; @@ -45,6 +45,7 @@ import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import { getIsWorkspacesOnlyForTransaction, + getReusableP2PReportID, getSelectedWorkspacePolicyID, isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, isParticipantP2P, @@ -63,7 +64,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; -import {findSelfDMReportID, generateReportID, getReportOrDraftReport, isMoneyRequestReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; +import {findSelfDMReportID, generateReportID, getChatByParticipants, getReportOrDraftReport, isMoneyRequestReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import {cancelTracking, getPendingSubmitFollowUpAction, isTracking} from '@libs/telemetry/submitFollowUpAction'; import { getRequestType, @@ -241,8 +242,6 @@ function IOURequestStepConfirmationContent({ const styles = useThemeStyles(); const theme = useTheme(); const {translate, dateFnsLocale} = useLocalize(); - const {isBetaEnabled} = usePermissions(); - const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); const {isOffline} = useNetwork(); const {showConfirmModal} = useConfirmModal(); // isConfirming, selectedParticipantList, and startLocationPermissionFlow state @@ -360,8 +359,8 @@ function IOURequestStepConfirmationContent({ const transactionParticipants = transaction?.participants ?? []; const hasTransactionParticipants = transactionParticipants.length > 0; const hasDefaultParticipants = defaultParticipants.length > 0; - return !hasTransactionParticipants && !hasDefaultParticipants && !isLoadingDefaultParticipants && isNewManualExpenseFlowEnabled && isManualRequest; - }, [transaction?.transactionID, transaction?.participants, defaultParticipants.length, isLoadingDefaultParticipants, isNewManualExpenseFlowEnabled, isManualRequest]); + return !hasTransactionParticipants && !hasDefaultParticipants && !isLoadingDefaultParticipants && isManualRequest; + }, [transaction?.transactionID, transaction?.participants, defaultParticipants.length, isLoadingDefaultParticipants, isManualRequest]); const activeTransactionID = transaction?.transactionID; const [manuallyOpenedParticipantPickerForTransactionID, setManuallyOpenedParticipantPickerForTransactionID] = useState(); const [dismissedAutoOpenParticipantPickerForTransactionID, setDismissedAutoOpenParticipantPickerForTransactionID] = useState(); @@ -542,7 +541,7 @@ function IOURequestStepConfirmationContent({ } else if (firstDefault?.reportID) { setTransactionReport(transaction.transactionID, {reportID: firstDefault.reportID}, true); } - }, [transaction?.transactionID, transaction?.participants, defaultParticipants, isNewManualExpenseFlowEnabled, isManualRequest, navigation]); + }, [transaction?.transactionID, transaction?.participants, defaultParticipants, isManualRequest, navigation]); const isPolicyExpenseChat = useMemo(() => { const hasPolicyExpenseChat = (participantList: typeof defaultParticipants) => @@ -597,6 +596,8 @@ function IOURequestStepConfirmationContent({ // excluded too. Pre-inserting the Search route would leave a stale entry in the navigation stack. const canPreInsertSearch = iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.SPLIT && iouType !== CONST.IOU.TYPE.TRACK && !isSelfDMDestination; + const preMountedDraftReportIDRef = useRef(undefined); + const {createTransaction, sendMoney, isConfirmed, setIsConfirmed, formHasBeenSubmitted} = useExpenseSubmission({ transaction, transactions, @@ -625,6 +626,14 @@ function IOURequestStepConfirmationContent({ draftTransactionIDs, privateIsArchivedMap, backToReport, + onExpenseWriteWillStart: () => { + const preMountedReportID = preMountedDraftReportIDRef.current; + if (!preMountedReportID) { + return; + } + preMountedDraftReportIDRef.current = undefined; + clearPreMountedDraftReportMarker(preMountedReportID); + }, }); // handleSearchDismiss doesn't pre-insert - it just dismisses the modal when search is @@ -640,7 +649,34 @@ function IOURequestStepConfirmationContent({ const shouldUsePerDiemChatReport = isPerDiemRequest && isMRReport && Navigation.getTopmostReportId() !== report?.reportID; const routeDestinationReportID = shouldUsePerDiemChatReport ? report?.chatReportID : report?.reportID; const destinationReportID = (isSelfDMDestination ? selfDMReportID : (backToReport ?? routeDestinationReportID)) ?? selfDMReportID; - const [destinationReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${destinationReportID}`); + + // The user can swap recipients here without a remount, so `report` can still lag behind the current + // pick. Resolve the P2P participant separately: existing chats win; only a genuinely new chat reuses + // the optimistic reportID useParticipantSubmission committed. + const firstParticipant = participants.at(0); + + // Split creates or resolves its own group chat report ID, so it cannot reuse the transaction's P2P report ID. + const isP2PDestination = iouType !== CONST.IOU.TYPE.SPLIT && !!firstParticipant && !firstParticipant.isPolicyExpenseChat; + const reusableP2PReportID = isP2PDestination ? getReusableP2PReportID(firstParticipant, transaction?.reportID) : undefined; + const p2pRecipientAccountID = firstParticipant?.accountID ?? CONST.DEFAULT_NUMBER_ID; + + // Read reports reactively: if the chat lands mid-flow the optimistic ID must drop out, or we'd reveal an uncreated report. + const existingP2PChatSelector = useCallback( + (reports: Parameters[1]) => + isP2PDestination ? getChatByParticipants([p2pRecipientAccountID, currentUserPersonalDetails.accountID], reports)?.reportID : undefined, + [isP2PDestination, p2pRecipientAccountID, currentUserPersonalDetails.accountID], + ); + const [existingP2PDestinationReportID] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: existingP2PChatSelector}); + const optimisticP2PDestinationReportID = !existingP2PDestinationReportID && reusableP2PReportID ? reusableP2PReportID : undefined; + // Trust `report` when it already belongs to this participant (their chat, or an IOU report under it), so a + // flow started from an IOU report keeps that report as destination instead of falling back to the chat. + const isReportParticipantChat = !!report?.reportID && report.reportID === existingP2PDestinationReportID; + const isReportUnderParticipantChat = !!report?.reportID && report.chatReportID === existingP2PDestinationReportID; + const isReportAlignedWithParticipant = isReportParticipantChat || isReportUnderParticipantChat; + const shouldPreferRouteDestination = isReportAlignedWithParticipant || !!backToReport; + const preMountDestinationReportID = optimisticP2PDestinationReportID ?? (shouldPreferRouteDestination ? destinationReportID : (existingP2PDestinationReportID ?? destinationReportID)); + const [destinationReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${preMountDestinationReportID}`); + const destinationReportDraft = reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${preMountDestinationReportID}`]; // All reactive inputs are in the deps; the builder's own live Navigation reads aren't reactive values so they don't belong // here. A recompute driven by a non-route-determining dep yields the same string route - a no-op for usePreMountDestination's @@ -651,20 +687,23 @@ function IOURequestStepConfirmationContent({ () => getSubmitExpensePreMountDestinationRoute({ isTransactionReady, - destinationReportID, - destinationReport, + destinationReportID: preMountDestinationReportID, + destinationReport: destinationReport ?? destinationReportDraft, isFromGlobalCreate, canPreInsertSearch, iouType, isCreatingTrackExpense, isSelfDMDestination, + isOptimisticNewChatDestination: !!optimisticP2PDestinationReportID, isLookingAroundUser, isMovingTransactionFromTrackExpense, }), [ isTransactionReady, - destinationReportID, + preMountDestinationReportID, + optimisticP2PDestinationReportID, destinationReport, + destinationReportDraft, isFromGlobalCreate, canPreInsertSearch, iouType, @@ -675,10 +714,49 @@ function IOURequestStepConfirmationContent({ ], ); + // Excludes the optimistic P2P case explicitly (never has a draft to pre-mount), rather than relying only + // on the route string, so this can't silently break if that route ever gains a query param. + const preMountDestinationReportRoute = preMountDestinationReportID ? ROUTES.REPORT_WITH_ID.getRoute(preMountDestinationReportID) : undefined; + const shouldPreMountDestinationDraft = !optimisticP2PDestinationReportID && !!preMountDestinationReportRoute && preMountDestinationRoute === preMountDestinationReportRoute; + + // DraftWorkspaceOpener creates a draft policy expense chat, under the reportID the real backend + // commit will eventually use, before this screen mounts. Copy it into the real report collection only + // when it's the eligible pre-mount target; the backend overwrites it with confirmed data on submit. + useEffect(() => { + if (!shouldPreMountDestinationDraft || !preMountDestinationReportID || destinationReport || !destinationReportDraft) { + return; + } + + preMountedDraftReportIDRef.current = preMountDestinationReportID; + preMountDraftReport(preMountDestinationReportID, destinationReportDraft); + }, [shouldPreMountDestinationDraft, preMountDestinationReportID, destinationReport, destinationReportDraft]); + const {reveal: revealPreMountDestination, cleanupPreMount} = usePreMountDestination(preMountDestinationRoute, { shouldPreservePreInsertedRouteOnUnmount: () => formHasBeenSubmitted.current, }); + // Only remove the speculative report row once the pre-mounted screen reading it is confirmed gone. + useEffect(() => { + return () => { + const preMountedReportID = preMountedDraftReportIDRef.current; + // Read the latest submission state at cleanup time because submission can start or finish after this effect runs. + const hasSubmitIntent = !!getPendingSubmitFollowUpAction(); + if (!preMountedReportID || preMountedReportID !== preMountDestinationReportID || Navigation.getIsFullscreenPreInsertedUnderRHP()) { + return; + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + if (hasSubmitIntent || formHasBeenSubmitted.current) { + // The real write's own callback clears the marker once it runs, which may race this cleanup - leave + // it alone here, or the row could end up unmarked before that write actually happens. + return; + } + + preMountedDraftReportIDRef.current = undefined; + clearPreMountedDraftReport(preMountedReportID); + }; + }, [preMountDestinationReportID, formHasBeenSubmitted]); + // Cancel the telemetry span when confirmation unmounts without a completed submission. // If getPendingSubmitFollowUpAction() is set, the orchestrator (or sendMoney flow) has // already taken ownership of the span lifecycle - do not interfere. @@ -708,8 +786,10 @@ function IOURequestStepConfirmationContent({ return; } - const resolvedReportIDs = resolveOptimisticChatReportID([participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID], report); - const payDestinationReportID = destinationReportID ?? resolvedReportIDs.chatReportID; + const resolvedReportIDs = optimisticP2PDestinationReportID + ? {optimisticChatReportID: optimisticP2PDestinationReportID, chatReportID: optimisticP2PDestinationReportID} + : resolveOptimisticChatReportID([participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID], report); + const payDestinationReportID = optimisticP2PDestinationReportID ?? destinationReportID ?? resolvedReportIDs.chatReportID; if (!payDestinationReportID || Navigation.getTopmostReportId() === payDestinationReportID) { sendMoney(paymentMethod, {resolvedReportIDs}); return; @@ -734,7 +814,7 @@ function IOURequestStepConfirmationContent({ }, }); }, - [currentUserPersonalDetails.accountID, destinationReportID, isConfirmed, setIsConfirmed, participants, report, sendMoney, transaction?.receipt], + [currentUserPersonalDetails.accountID, destinationReportID, isConfirmed, optimisticP2PDestinationReportID, setIsConfirmed, participants, report, sendMoney, transaction?.receipt], ); const navigateBack = useCallback(() => { @@ -1012,7 +1092,7 @@ function IOURequestStepConfirmationContent({ )} - {isNewManualExpenseFlowEnabled && ( - Navigation.dismissModal()} - shouldBlockParticipantSelection={blockDistanceRequestIfNeeded} - /> - )} + Navigation.dismissModal()} + shouldBlockParticipantSelection={blockDistanceRequestIfNeeded} + /> diff --git a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts index 58ed0eec7585..e45a9ba24b18 100644 --- a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts +++ b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts @@ -5,7 +5,6 @@ import useChangeTransactionsReportReports from '@hooks/useChangeTransactionsRepo import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import {setCustomUnitID, setCustomUnitRateID} from '@libs/actions/IOU/MoneyRequest'; import {clearSubrates} from '@libs/actions/IOU/PerDiem'; @@ -115,8 +114,6 @@ function useReportSelectionActions({ const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); const [selfDMReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(selfDMReportID)}`); const {removeTransaction} = useSearchSelectionActions(); - const {isBetaEnabled} = usePermissions(); - const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); const reports = useChangeTransactionsReportReports(transaction ? [transaction] : [], undefined); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const delegateAccountID = useDelegateAccountID(); @@ -177,14 +174,7 @@ function useReportSelectionActions({ return; } - if (isNewManualExpenseFlowEnabled) { - Navigation.goBack(backPath); - return; - } - - const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, reportOrDraftReportFromValue?.chatReportID); - // `goBack` replaces the current route when the confirmation screen isn't on the stack. - Navigation.goBack(iouConfirmationPageRoute, {compareParams: false}); + Navigation.goBack(backPath); }; const handleRegularReportSelection = (item: TransactionGroupListItem, report: OnyxEntry) => { diff --git a/src/pages/iou/request/step/confirmation/getSubmitExpensePreMountDestinationRoute.ts b/src/pages/iou/request/step/confirmation/getSubmitExpensePreMountDestinationRoute.ts index 1510706fc53f..4de5a5be0924 100644 --- a/src/pages/iou/request/step/confirmation/getSubmitExpensePreMountDestinationRoute.ts +++ b/src/pages/iou/request/step/confirmation/getSubmitExpensePreMountDestinationRoute.ts @@ -23,6 +23,7 @@ type GetSubmitExpensePreMountDestinationRouteParams = { iouType: IOUType; isCreatingTrackExpense: boolean; isSelfDMDestination: boolean; + isOptimisticNewChatDestination: boolean; isLookingAroundUser: boolean; /** Whether the flow relocates an already-tracked expense (SUBMIT/SHARE/CATEGORIZE) rather than creating one in place. */ isMovingTransactionFromTrackExpense: boolean; @@ -41,11 +42,11 @@ function getSubmitExpensePreMountDestinationRoute({ iouType, isCreatingTrackExpense, isSelfDMDestination, + isOptimisticNewChatDestination, isLookingAroundUser, isMovingTransactionFromTrackExpense, }: GetSubmitExpensePreMountDestinationRouteParams): Route | undefined { - // Unlike getSkipConfirmationPreMountDestinationRoute (which lets usePreMountDestination own the narrow gate), this builder - // returns undefined on wide up front - it avoids the nav reads below, and reveal() would never consume a wide result anyway. + // Bail out early on wide layout: nothing here is ever shown on wide, so skip the navigation reads below entirely. if (!isTransactionReady || !getIsNarrowLayout()) { return undefined; } @@ -85,12 +86,17 @@ function getSubmitExpensePreMountDestinationRoute({ // screen opens, so the destination is a report the user has never been on. Skipping it costs only the pre-mount. const isReplacingVisibleReport = !hasPreInsertedFullscreen && isMovingTransactionFromTrackExpense && isReportTopmostSplitNavigator() && Navigation.getTopmostReportId() !== destinationReportID; - // The report must be in the REPORT collection so the pre-inserted screen can render immediately. A draft-only report - // (e.g. the expense chat of a freshly created draft workspace in the zero-workspace "Submit to my employer" flow) can't - // render - the report screen only reads COLLECTION.REPORT - so pre-inserting one would strand the user on an infinite - // skeleton if they back out before submitting. Passing an empty draft to getReportOrDraftReport skips its REPORT_DRAFT - // fallback while keeping the module-cache fallback for real reports that useOnyx hasn't hydrated yet. - const isDestinationReportLoaded = !!destinationReportID && !!getReportOrDraftReport(destinationReportID, undefined, undefined, {}, destinationReport)?.reportID; + // Passing {} as the draft argument only blocks the REPORT_DRAFT collection fallback. A draft the caller + // passes in via destinationReport still resolves here, because getReportOrDraftReport checks its `report` + // slot before falling back to the draft slot - and that is intentional: the caller copies that draft into + // COLLECTION.REPORT before reveal, so it is safe to treat as renderable. + const isDestinationReportRenderable = !!destinationReportID && !!getReportOrDraftReport(destinationReportID, undefined, undefined, {}, destinationReport)?.reportID; + // Only pre-insert a report that's actually renderable. This is false when the destination ID points at a report + // that exists on the server but isn't in Onyx yet (e.g. deep-linked or evicted reportID with no draft) - pre-inserting + // it would show an infinite skeleton after backing out. + // An optimistic new chat is the one exception: it has no report row yet, but that's fine since submit + // will create it under this same ID. + const isDestinationReportLoaded = isOptimisticNewChatDestination || isDestinationReportRenderable; const shouldPreInsertReport = canUseReportPreInsert && isOutsideRHP && hasValidDestination && isDestinationReportLoaded && !isReplacingVisibleReport; if (!shouldPreInsertSearch && !shouldPreInsertReport) { @@ -103,7 +109,10 @@ function getSubmitExpensePreMountDestinationRoute({ }); } - return ROUTES.REPORT_WITH_ID.getRoute(destinationReportID); + // The last argument tells the report screen this ID is client-generated and doesn't exist on the server + // yet, so it should keep showing its normal loading state instead of fetching (which would 403 and show + // a not-found page). The other arguments in between aren't used for this route. + return ROUTES.REPORT_WITH_ID.getRoute(destinationReportID, undefined, undefined, undefined, undefined, isOptimisticNewChatDestination); } export default getSubmitExpensePreMountDestinationRoute; diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 52fa3925a829..a41da639c66d 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -22,7 +22,7 @@ import {reserveDeferredWriteChannel} from '@libs/deferredLayoutWrite'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getCurrentPosition from '@libs/getCurrentPosition'; import {getStringifiedGPSCoordinates} from '@libs/GPSDraftDetailsUtils'; -import {getExistingTransactionID, isLookingAroundSearchRoutingActive, isSelfDMSoleDestination, resolveOptimisticChatReportID} from '@libs/IOUUtils'; +import {getExistingTransactionID, getReusableP2PReportID, isLookingAroundSearchRoutingActive, isSelfDMSoleDestination, resolveOptimisticChatReportID} from '@libs/IOUUtils'; import Log from '@libs/Log'; import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpenseCreate'; import cleanupAndNavigateAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; @@ -150,6 +150,13 @@ type UseExpenseSubmissionParams = { // Navigation backToReport?: string; + + /** + * Called once validation has passed and the write is guaranteed to happen. Clear a pre-mount + * pre-mount marker here, not earlier - clearing it before validation could pass risks orphaning + * the pre-mounted report if validation then bails with no write. + */ + onExpenseWriteWillStart?: () => void; }; type SendMoneyReportIDs = { @@ -198,6 +205,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { draftTransactionIDs, privateIsArchivedMap, backToReport, + onExpenseWriteWillStart, } = params; // Localization @@ -421,8 +429,15 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { if (requiresLinkedTracked && !transactions.every((item) => item.linkedTrackedExpenseReportAction && item.linkedTrackedExpenseReportID)) { return; } - - const optimisticChatReportID = generateReportID(); + onExpenseWriteWillStart?.(); + + // For a brand-new P2P recipient, reuse the optimistic report ID the confirmation screen already + // committed to the transaction, so the chat report built here is the one the screen subscribes + // to - otherwise it'd wait forever on an ID that's never created. + const transactionReportID = transaction?.reportID; + const reusableP2PReportID = getReusableP2PReportID(participant, transactionReportID); + const participantAccountIDs = [participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID]; + const {chatReportID: optimisticChatReportID} = resolveOptimisticChatReportID(participantAccountIDs, undefined, reusableP2PReportID); const optimisticCreatedReportActionID = rand64(); const optimisticReportPreviewActionID = rand64(); let existingIOUReport: Report | undefined; @@ -606,6 +621,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { if (!participant || isEmptyObject(transaction.comment) || isEmptyObject(transaction.comment.customUnit)) { return; } + onExpenseWriteWillStart?.(); if (isTrackExpense) { // Mirror the action's bail: a submit it would no-op must not clean up or dismiss. if (!isEmptyObject(policy) && hasCompletePerDiemCustomUnit(transaction.comment?.customUnit)) { @@ -651,10 +667,18 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { } else if (!report?.reportID && participant.isPolicyExpenseChat && participant.reportID) { existingChatReport = getReportOrDraftReport(participant.reportID); } - const {optimisticChatReportID, chatReportID} = resolveOptimisticChatReportID( - [participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID], - existingChatReport, - ); + // The recipient can be swapped without this screen remounting, so `existingChatReport` above + // can still be whoever was selected before. Use the ID confirmation committed for the current + // pick instead, so the pre-mounted report stays aligned with a brand-new P2P recipient. + const transactionReportID = transaction.reportID; + // Reuse it so the pre-mounted screen subscribes to the report created on submission. + const reusableP2PReportID = !isExpenseReport ? getReusableP2PReportID(participant, transactionReportID) : undefined; + const participantAccountIDs = [participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID]; + const reportIDs = + !isExpenseReport && !participant.isPolicyExpenseChat + ? resolveOptimisticChatReportID(participantAccountIDs, undefined, reusableP2PReportID) + : resolveOptimisticChatReportID(participantAccountIDs, existingChatReport); + const {optimisticChatReportID, chatReportID} = reportIDs; const activeReportID = isExpenseReport ? report?.reportID : chatReportID; const notifyReportID = isExpenseReport && Navigation.getTopmostReportId() === report?.reportID ? report?.reportID : chatReportID; @@ -739,6 +763,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { if (requiresLinkedTracked && !transactions.every((item) => item.linkedTrackedExpenseReportAction && item.linkedTrackedExpenseReportID)) { return; } + onExpenseWriteWillStart?.(); const optimisticSelfDMReportID = selfDMReport?.reportID ?? generateReportID(); // When the destination resolved to the current user/self-DM, force the self-DM as the chat (clearing any // non-self route report) so getTrackExpenseInformation defaults to the self-DM instead of the route report. @@ -865,13 +890,11 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { if (!participant) { return; } + onExpenseWriteWillStart?.(); - // For a brand-new P2P recipient (no existing chat), the confirmation screen has already committed the draft - // transaction to a freshly generated optimistic reportID via setTransactionReport. Build the optimistic chat - // report at that same ID so the report the screen subscribes to is the one that actually gets created. - // Otherwise the builder mints a different ID and the screen hangs waiting on a report that never materializes. - const isBrandNewP2PRecipient = !report && !participant.isPolicyExpenseChat && !participant.reportID; - const optimisticChatReportID = isBrandNewP2PRecipient && !!transaction.reportID && transaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? transaction.reportID : undefined; + // Same reasoning as above: reuse the confirmation screen's optimistic report ID for a brand-new + // P2P recipient, so the screen isn't left subscribed to a report ID that's never created. + const optimisticChatReportID = getReusableP2PReportID(participant, transaction.reportID); const shouldIncludeCommuterExclusionOverrides = hasAppliedCommuterExclusion(transaction); const {chatReportID: distanceChatReportID, transactionID: distanceTransactionID} = createDistanceRequestIOUActions({ @@ -1239,9 +1262,12 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const {optimisticChatReportID, chatReportID} = resolvedReportIDs ?? resolveOptimisticChatReportID([participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID], report); + // An explicit optimistic ID means the selected recipient has no chat yet. Do not let a stale page-level + // report override that ID in getSendMoneyParams when the recipient changed without remounting this screen. + const sendMoneyReport = optimisticChatReportID ? undefined : report; const sendMoneyParams = { getCurrencyDecimals, - report, + report: sendMoneyReport, quickAction, amount: transaction.amount, currency, @@ -1258,9 +1284,11 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { }; if (paymentMethod === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + onExpenseWriteWillStart?.(); setIsConfirmed(true); sendMoneyElsewhere(sendMoneyParams); } else if (paymentMethod === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + onExpenseWriteWillStart?.(); setIsConfirmed(true); sendMoneyWithWallet(sendMoneyParams); } else { diff --git a/src/pages/settings/Agents/EditAgentPage.tsx b/src/pages/settings/Agents/EditAgentPage.tsx index ba377ac48bfc..9e9d51cdbae9 100644 --- a/src/pages/settings/Agents/EditAgentPage.tsx +++ b/src/pages/settings/Agents/EditAgentPage.tsx @@ -2,6 +2,7 @@ import UserAvatar from '@components/Avatar/UserAvatar'; import AvatarButtonWithIcon from '@components/AvatarButtonWithIcon'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -128,11 +129,10 @@ function EditAgentPage({route}: EditAgentPageProps) { errorRowStyles={[styles.mh5, styles.mb2]} onClose={() => clearAgentNameUpdateError(accountID)} > - ; @@ -42,6 +43,9 @@ function EditPromptPage({route}: EditPromptPageProps) { const shouldShrinkPromptInput = isInLandscapeMode && isKeyboardActive; const accountID = route.params.accountID; const [agentPrompt] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); + const formRef = useRef(null); + const promptTopOffsetRef = useRef(0); + const scrollToInput = () => scrollToMultilineInput(formRef, isInLandscapeMode, promptTopOffsetRef.current); const validate = (values: FormOnyxValues): FormInputErrors => { const errors: FormInputErrors = {}; @@ -88,12 +92,13 @@ function EditPromptPage({route}: EditPromptPageProps) { /> - - + + { + promptTopOffsetRef.current = event.nativeEvent.layout.y; + }} + > + + + {translate('workspace.rules.agentRules.disclaimer')} - {translate('workspace.rules.agentRules.disclaimer')} ); diff --git a/src/pages/settings/Profile/TimezoneInitialPage.tsx b/src/pages/settings/Profile/TimezoneInitialPage.tsx index 5fd525088cc0..2bf5205c37f7 100644 --- a/src/pages/settings/Profile/TimezoneInitialPage.tsx +++ b/src/pages/settings/Profile/TimezoneInitialPage.tsx @@ -1,5 +1,5 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import ScreenWrapper from '@components/ScreenWrapper'; import Switch from '@components/Switch'; import Text from '@components/Text'; @@ -65,12 +65,11 @@ function TimezoneInitialPage({currentUserPersonalDetails}: TimezoneInitialPagePr /> - Navigation.navigate(ROUTES.SETTINGS_TIMEZONE_SELECT)} + isDisabled={!!timezone.automatic} + value={timezone.selected} /> diff --git a/src/pages/settings/Report/DynamicReportSettingsPage.tsx b/src/pages/settings/Report/DynamicReportSettingsPage.tsx index a2fce3a8195a..e250f12273c9 100644 --- a/src/pages/settings/Report/DynamicReportSettingsPage.tsx +++ b/src/pages/settings/Report/DynamicReportSettingsPage.tsx @@ -1,6 +1,6 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -73,52 +73,49 @@ function DynamicReportSettingsPage({report, policy}: DynamicReportSettingsPagePr /> {shouldShowNotificationPref && ( - { if (!reportID) { return; } Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NOTIFICATION_PREFERENCES.getRoute(reportID))); }} + value={notificationPreference} /> )} - {shouldShowWriteCapability && - (shouldAllowWriteCapabilityEditing ? ( - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_SETTINGS_WRITE_CAPABILITY.path))} - /> - ) : ( - - - {translate('writeCapabilityPage.label')} - - - {writeCapabilityText} - - - ))} + {shouldShowWriteCapability && shouldAllowWriteCapabilityEditing && ( + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_SETTINGS_WRITE_CAPABILITY.path))} + value={writeCapabilityText} + /> + )} + {shouldShowWriteCapability && !shouldAllowWriteCapabilityEditing && ( + + + {translate('writeCapabilityPage.label')} + + + {writeCapabilityText} + + + )} {!!report?.visibility && report.chatType !== CONST.REPORT.CHAT_TYPE.INVOICE && (shouldAllowChangeVisibility ? ( - { Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_SETTINGS_VISIBILITY.path)); }} + value={translate(`newRoomPage.visibilityOptions.${report.visibility}`)} /> ) : ( diff --git a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx index fcfa27d56137..ec5bd734fc12 100644 --- a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx +++ b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx @@ -1,9 +1,10 @@ import UserAvatar from '@components/Avatar/UserAvatar'; import Button from '@components/ButtonComposed'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; +import FormHelpMessage from '@components/FormHelpMessage'; import HeaderPageLayout from '@components/HeaderPageLayout'; import MenuItem from '@components/MenuItem'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -23,6 +24,7 @@ import type SCREENS from '@src/SCREENS'; import type {ValueOf} from 'type-fest'; import React from 'react'; +import {View} from 'react-native'; type ConfirmDelegatePageProps = PlatformStackScreenProps; @@ -83,13 +85,19 @@ function ConfirmDelegatePage({route}: ConfirmDelegatePageProps) { - Navigation.navigate(ROUTES.SETTINGS_DELEGATE_ROLE.getRoute(login, role, ROUTES.SETTINGS_DELEGATE_CONFIRM.getRoute(login, role)))} - shouldShowRightIcon - /> + + Navigation.navigate(ROUTES.SETTINGS_DELEGATE_ROLE.getRoute(login, role, ROUTES.SETTINGS_DELEGATE_CONFIRM.getRoute(login, role)))} + /> + + ); diff --git a/src/pages/settings/Security/DeviceManagementPage.tsx b/src/pages/settings/Security/DeviceManagementPage.tsx index dd54bd91a912..b243737af12e 100644 --- a/src/pages/settings/Security/DeviceManagementPage.tsx +++ b/src/pages/settings/Security/DeviceManagementPage.tsx @@ -18,10 +18,10 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Credentials} from '@src/types/onyx'; import type {Login} from '@src/types/onyx/Logins'; -import type {ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListRenderItemProps} from '@legendapp/list/react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {FlashList} from '@shopify/flash-list'; +import {LegendList} from '@legendapp/list/react-native'; import React from 'react'; import {View} from 'react-native'; @@ -42,7 +42,7 @@ function DeviceManagementPage() { ); - const renderItem = ({item}: ListRenderItemInfo) => { + const renderItem = ({item}: LegendListRenderItemProps) => { const {deviceName, deviceVersion, os, osVersion} = item.additionalData ?? {}; const displayName = getDeviceDisplayName(item, deviceName, deviceVersion, os, osVersion, translate('deviceManagementPage.unknownDevice')); return ( @@ -76,11 +76,12 @@ function DeviceManagementPage() { title={translate('deviceManagementPage.title')} onBackButtonPress={Navigation.goBack} /> - diff --git a/src/pages/settings/Security/LockAccount/LockAccountPageBase.tsx b/src/pages/settings/Security/LockAccount/LockAccountPageBase.tsx index 6a763591eb74..25d7e2fbae43 100644 --- a/src/pages/settings/Security/LockAccount/LockAccountPageBase.tsx +++ b/src/pages/settings/Security/LockAccount/LockAccountPageBase.tsx @@ -45,7 +45,7 @@ function LockAccountPageBase({ const [isLoading, setIsLoading] = useState(false); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {showConfirmModal} = useConfirmModal(); + const {showConfirmModal, closeModal} = useConfirmModal(); const handleReportSuspiciousActivity = async () => { if (!accountID && !currentUserPersonalDetails.accountID) { @@ -70,6 +70,9 @@ function LockAccountPageBase({ const response = await lockAccount(currentUserPersonalDetails.accountID, accountID, domainAccountID, domainName); setIsLoading(false); + // Passing isConfirmLoading keeps the modal open on confirm, so it has to be closed here. + closeModal(); + handleLockRequestFinish(response); }; diff --git a/src/pages/settings/Subscription/SubscriptionSize/subPages/Confirmation.tsx b/src/pages/settings/Subscription/SubscriptionSize/subPages/Confirmation.tsx index c99d8e55b5a3..20d0e01287f8 100644 --- a/src/pages/settings/Subscription/SubscriptionSize/subPages/Confirmation.tsx +++ b/src/pages/settings/Subscription/SubscriptionSize/subPages/Confirmation.tsx @@ -1,6 +1,6 @@ import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -32,15 +32,13 @@ function Confirmation({onNext}: ConfirmationProps) { return ( {translate('subscription.subscriptionSize.confirmDetails')} - - + + + + + ); +} + +BetaOverridesPage.displayName = 'BetaOverridesPage'; + +export default BetaOverridesPage; diff --git a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsAgreementsPage.tsx b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsAgreementsPage.tsx index 769dc989869b..2752f2609c60 100644 --- a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsAgreementsPage.tsx +++ b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsAgreementsPage.tsx @@ -1,5 +1,6 @@ import AgreementsFullStep from '@components/SubStepForms/AgreementsFullStep'; +import useEnableGlobalReimbursementsNavigation from '@hooks/useEnableGlobalReimbursementsNavigation'; import useOnyx from '@hooks/useOnyx'; import Navigation from '@libs/Navigation/Navigation'; @@ -8,13 +9,15 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/EnableGlobalReimbursementsForm'; import React from 'react'; -type EnableGlobalReimbursementsAgreementsPageProps = PlatformStackScreenProps; +type EnableGlobalReimbursementsAgreementsPageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS | typeof SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS +>; const inputIDs = { provideTruthfulInformation: INPUT_IDS.PROVIDE_TRUTHFUL_INFORMATION, @@ -24,8 +27,10 @@ const inputIDs = { }; function EnableGlobalReimbursementsAgreementsPage({route}: EnableGlobalReimbursementsAgreementsPageProps) { + const {getBusinessRoute, getSignRoute, isDynamic} = useEnableGlobalReimbursementsNavigation(); const bankAccountID = route.params?.bankAccountID; - const [currency = ''] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {selector: (list) => list?.[bankAccountID]?.bankCurrency}); + const [bankAccountCurrency = ''] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {selector: (list) => list?.[bankAccountID]?.bankCurrency}); + const currency = route.params?.bankCurrency ?? bankAccountCurrency; const [enableGlobalReimbursementsDraft] = useOnyx(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT); const defaultValues: Record = Object.fromEntries( Object.keys(inputIDs).map((key) => { @@ -35,12 +40,22 @@ function EnableGlobalReimbursementsAgreementsPage({route}: EnableGlobalReimburse ) as Record; const bankStatementDefaultValue = enableGlobalReimbursementsDraft?.[INPUT_IDS.BANK_STATEMENT] ?? []; + const persistedRouteParams = { + bankCountry: route.params?.bankCountry, + bankCurrency: route.params?.bankCurrency, + }; + const goBack = () => { - Navigation.goBack(ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute(Number(bankAccountID), CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.CONFIRM)); + const confirmRoute = getBusinessRoute(Number(bankAccountID), CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.CONFIRM, undefined, persistedRouteParams); + if (isDynamic) { + Navigation.navigate(confirmRoute, {forceReplace: true}); + return; + } + Navigation.goBack(confirmRoute); }; const goToSignPage = () => { - Navigation.navigate(ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN.getRoute(Number(bankAccountID))); + Navigation.navigate(getSignRoute(Number(bankAccountID), persistedRouteParams), isDynamic ? {forceReplace: true} : undefined); }; return ( diff --git a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/index.tsx b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/index.tsx index cbe380c0fb25..bb53ce086f8b 100644 --- a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/index.tsx +++ b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/index.tsx @@ -1,10 +1,14 @@ import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; +import useEnableGlobalReimbursementsNavigation from '@hooks/useEnableGlobalReimbursementsNavigation'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useRootNavigationState from '@hooks/useRootNavigationState'; import useSubPage from '@hooks/useSubPage'; import {getCorpayOnboardingFields} from '@libs/actions/BankAccounts'; +import getActiveTabName from '@libs/Navigation/helpers/getActiveTabName'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -13,8 +17,8 @@ import {clearErrors} from '@userActions/FormActions'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React, {useEffect} from 'react'; @@ -27,7 +31,10 @@ import Confirmation from './subPages/Confirmation'; import PaymentVolume from './subPages/PaymentVolume'; import RegistrationNumber from './subPages/RegistrationNumber'; -type EnableGlobalReimbursementsBusinessPageProps = PlatformStackScreenProps; +type EnableGlobalReimbursementsBusinessPageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS | typeof SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS +>; const pages = [ {pageName: CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.REGISTRATION_NUMBER, component: RegistrationNumber}, @@ -41,17 +48,29 @@ function EnableGlobalReimbursementsBusinessPage({route}: EnableGlobalReimburseme const {translate} = useLocalize(); const bankAccountID = route.params?.bankAccountID; const [bankAccount] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {selector: (list) => list?.[bankAccountID]}); - const currency = bankAccount?.bankCurrency ?? ''; - const country = bankAccount?.bankCountry as Country; + const country = (route.params?.bankCountry ?? bankAccount?.bankCountry ?? '') as Country; + const currency = route.params?.bankCurrency ?? bankAccount?.bankCurrency ?? ''; + + const persistedRouteParams = { + bankCountry: country || undefined, + bankCurrency: currency || undefined, + }; + + const {getAgreementsRoute, getBusinessRoute, getRootBackPath, isDynamic} = useEnableGlobalReimbursementsNavigation(); + const topmostFullScreenRoute = useRootNavigationState((state) => state?.routes.findLast((navigationRoute) => isFullScreenName(navigationRoute.name))); + const activeTab = getActiveTabName(topmostFullScreenRoute); + + const buildBusinessRoute = (subPage: string, action?: 'edit') => getBusinessRoute(Number(bankAccountID), subPage, action, persistedRouteParams); const goToAgreementsPage = () => { - Navigation.navigate(ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS.getRoute(Number(bankAccountID))); + Navigation.navigate(getAgreementsRoute(Number(bankAccountID), persistedRouteParams), isDynamic ? {forceReplace: true} : undefined); }; const {CurrentPage, isEditing, pageIndex, prevPage, nextPage, moveTo} = useSubPage({ pages, onFinished: goToAgreementsPage, - buildRoute: (pageName, action) => ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute(Number(bankAccountID), pageName, action), + buildRoute: (pageName, action) => buildBusinessRoute(pageName, action), + shouldReplaceRoute: isDynamic, }); useEffect(() => { @@ -63,7 +82,12 @@ function EnableGlobalReimbursementsBusinessPage({route}: EnableGlobalReimburseme }, []); const goBackToConfirmStep = () => { - Navigation.goBack(ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute(Number(bankAccountID), CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.CONFIRM)); + const confirmRoute = buildBusinessRoute(CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.CONFIRM); + if (isDynamic) { + Navigation.navigate(confirmRoute, {forceReplace: true}); + return; + } + Navigation.goBack(confirmRoute); }; const handleBackButtonPress = () => { @@ -74,7 +98,22 @@ function EnableGlobalReimbursementsBusinessPage({route}: EnableGlobalReimburseme } if (pageIndex === 0) { - Navigation.goBack(); + if (isDynamic) { + Navigation.goBack(getRootBackPath()); + return; + } + + switch (activeTab) { + case NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR: + Navigation.goBack(getRootBackPath()); + break; + case NAVIGATORS.REPORTS_SPLIT_NAVIGATOR: + Navigation.closeRHPFlow(); + break; + default: + Navigation.goBack(); + break; + } return; } diff --git a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/BusinessType.tsx b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/BusinessType.tsx index 1a5eb494fb2e..daef9a705ad9 100644 --- a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/BusinessType.tsx +++ b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/BusinessType.tsx @@ -1,8 +1,10 @@ +import ActivityIndicator from '@components/ActivityIndicator'; import PushRowFieldsStep from '@components/SubStepForms/PushRowFieldsStep'; import useEnableGlobalReimbursementsStepFormSubmit from '@hooks/useEnableGlobalReimbursementsStepFormSubmit'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; import getListOptionsFromCorpayPicklist from '@pages/ReimbursementAccount/NonUSD/utils/getListOptionsFromCorpayPicklist'; import type {BusinessInfoSubPageProps} from '@pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/types'; @@ -11,12 +13,14 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/EnableGlobalReimbursementsForm'; import React, {useMemo} from 'react'; +import {View} from 'react-native'; const {APPLICANT_TYPE_ID, BUSINESS_CATEGORY} = INPUT_IDS; const STEP_FIELDS = [APPLICANT_TYPE_ID, BUSINESS_CATEGORY]; function BusinessType({onNext, onMove, isEditing}: BusinessInfoSubPageProps) { const {translate} = useLocalize(); + const styles = useThemeStyles(); const [enableGlobalReimbursementsDraft] = useOnyx(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT); const [corpayOnboardingFields] = useOnyx(ONYXKEYS.CORPAY_ONBOARDING_FIELDS); @@ -55,7 +59,11 @@ function BusinessType({onNext, onMove, isEditing}: BusinessInfoSubPageProps) { }); if (corpayOnboardingFields === undefined) { - return null; + return ( + + + + ); } return ( diff --git a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage.tsx b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage.tsx index a33928e19359..501468a1e234 100644 --- a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage.tsx +++ b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage.tsx @@ -1,5 +1,6 @@ import DocusignFullStep from '@components/SubStepForms/DocusignFullStep'; +import useEnableGlobalReimbursementsNavigation from '@hooks/useEnableGlobalReimbursementsNavigation'; import useOnyx from '@hooks/useOnyx'; import {clearEnableGlobalReimbursementsForUSDBankAccount, enableGlobalReimbursementsForUSDBankAccount} from '@libs/actions/BankAccounts'; @@ -9,28 +10,41 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/EnableGlobalReimbursementsForm'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import React, {useEffect} from 'react'; -type EnableGlobalReimbursementsSignPageProps = PlatformStackScreenProps; +type EnableGlobalReimbursementsSignPageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN | typeof SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN +>; function EnableGlobalReimbursementsSignPage({route}: EnableGlobalReimbursementsSignPageProps) { + const {getAgreementsRoute, isDynamic} = useEnableGlobalReimbursementsNavigation(); const bankAccountID = route.params?.bankAccountID; const [bankAccount] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {selector: (list) => list?.[bankAccountID]}); - const currency = bankAccount?.bankCurrency ?? ''; - const country = bankAccount?.bankCountry; + const currency = route.params?.bankCurrency ?? bankAccount?.bankCurrency ?? ''; + const country = route.params?.bankCountry ?? bankAccount?.bankCountry; const [enableGlobalReimbursements] = useOnyx(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS); const [enableGlobalReimbursementsDraft, enableGlobalReimbursementsDraftMetadata] = useOnyx(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT); const isLoadingDraft = isLoadingOnyxValue(enableGlobalReimbursementsDraftMetadata); const defaultValue = enableGlobalReimbursementsDraft?.[INPUT_IDS.ACH_AUTHORIZATION_FORM] ?? []; const bankStatement = enableGlobalReimbursementsDraft?.[INPUT_IDS.BANK_STATEMENT]; + const persistedRouteParams = { + bankCountry: route.params?.bankCountry, + bankCurrency: route.params?.bankCurrency, + }; + const goBack = () => { - Navigation.goBack(ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS.getRoute(Number(bankAccountID))); + const agreementsRoute = getAgreementsRoute(Number(bankAccountID), persistedRouteParams); + if (isDynamic) { + Navigation.navigate(agreementsRoute, {forceReplace: true}); + return; + } + Navigation.goBack(agreementsRoute); }; useEffect(() => { @@ -38,8 +52,8 @@ function EnableGlobalReimbursementsSignPage({route}: EnableGlobalReimbursementsS return; } - Navigation.navigate(ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS.getRoute(Number(bankAccountID))); - }, [bankAccountID, bankStatement?.length, isLoadingDraft]); + Navigation.navigate(getAgreementsRoute(Number(bankAccountID), persistedRouteParams), isDynamic ? {forceReplace: true} : undefined); + }, [bankAccountID, bankStatement?.length, getAgreementsRoute, isDynamic, isLoadingDraft, persistedRouteParams]); const onSubmit = () => { enableGlobalReimbursementsForUSDBankAccount({ diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx index f23ca7b30031..fcb1abea6420 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx @@ -3,7 +3,7 @@ import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import FormHelpMessage from '@components/FormHelpMessage'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import RenderHTML from '@components/RenderHTML'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -33,7 +33,6 @@ type MenuItemProps = { id: string; description: string; title: string; - shouldShowRightIcon: boolean; onPress: () => void; interactive?: boolean; disabled?: boolean; @@ -95,7 +94,6 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp id: 'bankCountry', description: translate('common.country'), title: translate(`allCountries.${formValues.bankCountry}` as TranslationPaths), - shouldShowRightIcon: true, onPress: () => { onMove(STEP_INDEXES.COUNTRY_SELECTOR); }, @@ -105,7 +103,6 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp id: 'bankCurrency', description: translate('common.currency'), title: `${formValues.bankCurrency} - ${getCurrencySymbol(formValues.bankCurrency)}`, - shouldShowRightIcon: true, onPress: () => { onMove(STEP_INDEXES.BANK_ACCOUNT_DETAILS); }, @@ -118,7 +115,6 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp id: `${CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS}-${fieldName}`, description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), title: getTitle(field, fieldName), - shouldShowRightIcon: true, onPress: () => { onMove(STEP_INDEXES.BANK_ACCOUNT_DETAILS); }, @@ -130,7 +126,6 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp id: `${CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE}-${fieldName}`, description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), title: getTitle(field, fieldName), - shouldShowRightIcon: true, onPress: () => { onMove(STEP_INDEXES.ACCOUNT_TYPE); }, @@ -144,7 +139,6 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp id: `${CONST.CORPAY_FIELDS.PAGE_NAME.BANK_INFORMATION}-${fieldName}`, description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), title: getTitle(field, fieldName), - shouldShowRightIcon: true, onPress: () => { onMove(STEP_INDEXES.BANK_INFORMATION); }, @@ -158,7 +152,6 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp id: `${CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS}-${fieldName}`, description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), title: fieldName === CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_COUNTRY_KEY ? translate(`allCountries.${formValues.bankCountry}` as TranslationPaths) : getTitle(field, fieldName), - shouldShowRightIcon: fieldName !== CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_COUNTRY_KEY, onPress: () => { onMove(STEP_INDEXES.ACCOUNT_HOLDER_INFORMATION); }, @@ -183,16 +176,14 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp {translate('addPersonalBankAccount.confirmationStepHeader')} {translate('addPersonalBankAccount.confirmationStepSubHeader')} - {summaryItems.map(({id, description, title, shouldShowRightIcon, interactive, disabled, onPress}) => ( - ( + ))} - + + maintainVisibleContentPosition data={itemsToRender} renderItem={renderItem} keyExtractor={keyExtractor} diff --git a/src/pages/settings/Wallet/PersonalCards/upgrade/PersonalCardUpgradePage.tsx b/src/pages/settings/Wallet/PersonalCards/upgrade/PersonalCardUpgradePage.tsx index 1fa05243b920..e3d09806317f 100644 --- a/src/pages/settings/Wallet/PersonalCards/upgrade/PersonalCardUpgradePage.tsx +++ b/src/pages/settings/Wallet/PersonalCards/upgrade/PersonalCardUpgradePage.tsx @@ -5,6 +5,7 @@ import ScrollView from '@components/ScrollView'; import useActivePolicy from '@hooks/useActivePolicy'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies'; +import useHasOwnedPaidPolicy from '@hooks/useHasOwnedPaidPolicy'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -45,6 +46,7 @@ function PersonalCardUpgradePage() { const {accountID, email = ''} = currentUserPersonalDetails; const activePolicy = useActivePolicy(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const hasOwnedPaidPolicy = useHasOwnedPaidPolicy(); const lastWorkspaceNumber = useLastWorkspaceNumber(); const onUpgrade = () => { @@ -64,6 +66,7 @@ function PersonalCardUpgradePage() { isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, }); setIsUpgraded(true); }; diff --git a/src/pages/tasks/DynamicNewTaskPage.tsx b/src/pages/tasks/DynamicNewTaskPage.tsx index 37a6a457fd3d..3e5e0c4c627d 100644 --- a/src/pages/tasks/DynamicNewTaskPage.tsx +++ b/src/pages/tasks/DynamicNewTaskPage.tsx @@ -5,7 +5,7 @@ import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import {useMenuItemConfig, useMenuItemInteraction} from '@components/MenuItem/MenuItemContext'; -import MenuItemEmptyField from '@components/MenuItem/presets/MenuItemEmptyField'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithLabel from '@components/MenuItem/presets/MenuItemWithLabel'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ReportActionAvatars from '@components/ReportActionAvatars'; @@ -237,8 +237,8 @@ function DynamicNewTaskPage() { ) : ( - )} @@ -274,20 +274,12 @@ function DynamicNewTaskPage() { ) : ( - - - - {translate('common.share')} - - - {translate('common.required')} - {!!navigateToShareDestination && } - - - + {translate('common.required')} + )} diff --git a/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx b/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx index 515c305f0308..faf5f8a06232 100644 --- a/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx +++ b/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx @@ -6,6 +6,7 @@ import useActivePolicy from '@hooks/useActivePolicy'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies'; +import useHasOwnedPaidPolicy from '@hooks/useHasOwnedPaidPolicy'; import useOnyx from '@hooks/useOnyx'; import usePrivateSubscription from '@hooks/usePrivateSubscription'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -41,6 +42,7 @@ function DynamicWorkspaceConfirmationPage() { const isAnnualSubscription = privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL; const activePolicy = useActivePolicy(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const hasOwnedPaidPolicy = useHasOwnedPaidPolicy(); // On narrow layout the new workspace is mounted under this RHP and revealed when the modal // dismisses (via revealRouteBeforeDismissingModal). The reveal waits for the new screen to lay @@ -79,6 +81,7 @@ function DynamicWorkspaceConfirmationPage() { isSelfTourViewed, betas, hasActiveAdminPolicies, + hasOwnedPaidPolicy, isAnnualSubscription, }); }; diff --git a/src/pages/workspace/DynamicWorkspaceInvitePage.tsx b/src/pages/workspace/DynamicWorkspaceInvitePage.tsx index cd2c24c42205..0e359646180c 100644 --- a/src/pages/workspace/DynamicWorkspaceInvitePage.tsx +++ b/src/pages/workspace/DynamicWorkspaceInvitePage.tsx @@ -19,6 +19,7 @@ import {clearErrors, openWorkspaceInvitePage as policyOpenWorkspaceInvitePage} f import {searchUserInServer} from '@libs/actions/Report'; import {READ_COMMANDS} from '@libs/API/types'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; import HttpUtils from '@libs/HttpUtils'; import {appendCountryCode} from '@libs/LoginUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -277,6 +278,8 @@ function DynamicWorkspaceInvitePageContent({route, policy, invitedEmailsToAccoun confirmButtonOptions={{ onConfirm: inviteUser, isDisabled: !selectedOptions.length, + isFooterConfirmEnabled: selectedOptions.length > 0, + isFooterConfirmEnterKeyEnabled: getPlatform() !== CONST.PLATFORM.ANDROID, }} shouldShowLoadingPlaceholder={!areOptionsInitialized || !didScreenTransitionEnd} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} diff --git a/src/pages/workspace/WorkspaceNewRoomPage.tsx b/src/pages/workspace/WorkspaceNewRoomPage.tsx index eda2950f872b..53a40c440918 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.tsx +++ b/src/pages/workspace/WorkspaceNewRoomPage.tsx @@ -4,7 +4,7 @@ import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; import RoomNameInput from '@components/RoomNameInput'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -332,10 +332,9 @@ function WorkspaceNewRoomPage({ref, policyID: lockedPolicyID}: WorkspaceNewRoomP {isLocked ? ( - ) : ( diff --git a/src/pages/workspace/accounting/common/TravelBillingContinuousReconciliationSection.tsx b/src/pages/workspace/accounting/common/TravelBillingContinuousReconciliationSection.tsx index 0931ffbc7f8d..ef1efa26ff35 100644 --- a/src/pages/workspace/accounting/common/TravelBillingContinuousReconciliationSection.tsx +++ b/src/pages/workspace/accounting/common/TravelBillingContinuousReconciliationSection.tsx @@ -1,4 +1,4 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useLocalize from '@hooks/useLocalize'; @@ -89,11 +89,10 @@ function TravelBillingContinuousReconciliationSection({policy, connectionName, i /> {!!travelBillingContinuousReconciliation && ( - )} diff --git a/src/pages/workspace/accounting/intacct/advanced/SageIntacctAutoSyncPageBase.tsx b/src/pages/workspace/accounting/intacct/advanced/SageIntacctAutoSyncPageBase.tsx index 759c8f4fd202..6369261d0ee9 100644 --- a/src/pages/workspace/accounting/intacct/advanced/SageIntacctAutoSyncPageBase.tsx +++ b/src/pages/workspace/accounting/intacct/advanced/SageIntacctAutoSyncPageBase.tsx @@ -1,5 +1,5 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useLocalize from '@hooks/useLocalize'; @@ -73,14 +73,13 @@ function SageIntacctAutoSyncPageBase({policy, navigateBackTo}: SageIntacctAutoSy /> {!!autoSync?.enabled && ( - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ACCOUNTING_METHOD.path))} /> diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx index 712bd4e0d4a1..862508c29f67 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx @@ -1,4 +1,5 @@ import ConnectionLayout from '@components/ConnectionLayout'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -44,10 +45,9 @@ function DynamicSageIntacctTravelBillingConfigurationPage({policy}: WithPolicyCo connectionName={CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT} onBackButtonPress={() => Navigation.goBack(backPath)} > - - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NETSUITE_ACCOUNTING_METHOD.path))} /> diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomListStep.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomListStep.tsx index ec9fbcd4297c..5ba2520e4bc4 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomListStep.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomListStep.tsx @@ -1,6 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import Button from '@components/ButtonComposed'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; @@ -38,15 +38,14 @@ function ConfirmCustomListStep({onMove, netSuiteCustomFieldFormValues: values, o {translate('workspace.common.letsDoubleCheck')} {fieldNames.map((fieldName, index) => ( - { onMove(index); }} diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomSegmentList.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomSegmentList.tsx index e99dd24e2f6c..9600315422a2 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomSegmentList.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomSegmentList.tsx @@ -1,6 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import Button from '@components/ButtonComposed'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; @@ -38,17 +38,16 @@ function ConfirmCustomSegmentStep({onMove, customSegmentType, netSuiteCustomFiel {translate('workspace.common.letsDoubleCheck')} {fieldNames.map((fieldName, index) => ( - { onMove(index + 1); }} diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldView.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldView.tsx index 52bf1ba9dacd..c8f770d65e88 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldView.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldView.tsx @@ -1,6 +1,6 @@ import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -124,11 +124,10 @@ function NetSuiteImportCustomFieldView({ {fieldList.map((fieldName) => { const isEditable = !config?.pendingFields?.[importCustomField] && isNetSuiteCustomFieldPropertyEditable(customField, fieldName); return ( - {!!autoSync?.enabled && ( - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_ACCOUNTING_METHOD.getRoute(policyID))} /> diff --git a/src/pages/workspace/accounting/qbo/advanced/DynamicQuickbooksOnlineAutoSyncPage.tsx b/src/pages/workspace/accounting/qbo/advanced/DynamicQuickbooksOnlineAutoSyncPage.tsx index 7a4177b293cb..58ed0acf563d 100644 --- a/src/pages/workspace/accounting/qbo/advanced/DynamicQuickbooksOnlineAutoSyncPage.tsx +++ b/src/pages/workspace/accounting/qbo/advanced/DynamicQuickbooksOnlineAutoSyncPage.tsx @@ -1,5 +1,5 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -73,14 +73,13 @@ function DynamicQuickbooksOnlineAutoSyncPage({policy, route}: WithPolicyConnecti /> {!!config?.autoSync?.enabled && ( - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ACCOUNTING_METHOD.path))} /> diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksTravelBillingConfigurationPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksTravelBillingConfigurationPage.tsx index 8f519c9067de..82dbee195f83 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksTravelBillingConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksTravelBillingConfigurationPage.tsx @@ -1,4 +1,5 @@ import ConnectionLayout from '@components/ConnectionLayout'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -45,10 +46,9 @@ function QuickbooksTravelBillingConfigurationPage({policy}: WithPolicyConnection connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.getRoute(policyID))} > - {!!autoSync?.enabled && ( - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_ACCOUNTING_METHOD.path))} /> diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroTravelBillingConfigurationPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroTravelBillingConfigurationPage.tsx index b60069c00d49..b758fca80657 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroTravelBillingConfigurationPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroTravelBillingConfigurationPage.tsx @@ -1,4 +1,6 @@ import ConnectionLayout from '@components/ConnectionLayout'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -46,12 +48,15 @@ function DynamicXeroTravelBillingConfigurationPage({policy}: WithPolicyConnectio connectionName={CONST.POLICY.CONNECTIONS.NAME.XERO} onBackButtonPress={() => Navigation.goBack(backPath)} > - + - - { - if (!isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute( - policyID, - CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, - isQuickSettingsFlow - ? ROUTES.SETTINGS_CATEGORY_GL_CODE.getRoute(policyID, policyCategory.name, backTo) - : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_GL_CODE.path), - ), - ); - return; - } - Navigation.navigate( - isQuickSettingsFlow - ? ROUTES.SETTINGS_CATEGORY_GL_CODE.getRoute(policyID, policyCategory.name, backTo) - : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_GL_CODE.path), - ); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} + { + if (!isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute( + policyID, + CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, + isQuickSettingsFlow + ? ROUTES.SETTINGS_CATEGORY_GL_CODE.getRoute(policyID, policyCategory.name, backTo) + : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_GL_CODE.path), + ), + ); + return; + } + Navigation.navigate( + isQuickSettingsFlow + ? ROUTES.SETTINGS_CATEGORY_GL_CODE.getRoute(policyID, policyCategory.name, backTo) + : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_GL_CODE.path), + ); + } + : undefined + } + value={policyCategory['GL Code']} /> - { - if (!isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute( - policyID, - CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, - createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.path), - ), - ); - return; - } - Navigation.navigate( - isQuickSettingsFlow - ? ROUTES.SETTINGS_CATEGORY_PAYROLL_CODE.getRoute(policyID, policyCategory.name, backTo) - : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.path), - ); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} + { + if (!isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute( + policyID, + CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, + createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.path), + ), + ); + return; + } + Navigation.navigate( + isQuickSettingsFlow + ? ROUTES.SETTINGS_CATEGORY_PAYROLL_CODE.getRoute(policyID, policyCategory.name, backTo) + : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.path), + ); + } + : undefined + } + value={policyCategory['Payroll Code']} /> {categoryRulesEnabled && isRulesRevampEnabled && ( @@ -422,14 +425,16 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti shouldParseHelperText /> {!!policy?.tax?.trackingEnabled && ( - { - navigateToCategoryRule(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DEFAULT_TAX_RATE.path); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} + { + navigateToCategoryRule(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DEFAULT_TAX_RATE.path); + } + : undefined + } + value={defaultTaxRateText} /> )} @@ -479,14 +484,16 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti shouldParseHelperText /> {!!policy?.tax?.trackingEnabled && ( - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DEFAULT_TAX_RATE.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DEFAULT_TAX_RATE.path)); + } + : undefined + } + value={defaultTaxRateText} /> )} {/* @@ -495,47 +502,55 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti * of keeping these Category settings routes. */} - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER.path)); + } + : undefined + } + value={flagAmountsOverText} /> - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRE_RECEIPTS_OVER.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRE_RECEIPTS_OVER.path)); + } + : undefined + } + value={requireReceiptsOverText} /> - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRE_ITEMIZED_RECEIPTS_OVER.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRE_ITEMIZED_RECEIPTS_OVER.path)); + } + : undefined + } + value={requireItemizedReceiptsOverText} /> - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRED_FIELDS.path)); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_REQUIRED_FIELDS.path)); + } + : undefined + } + value={requiredFieldsTitle} /> diff --git a/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx b/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx index 97689caca997..7001fcdd13fe 100644 --- a/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx +++ b/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx @@ -1,9 +1,10 @@ -import ConfirmModal from '@components/ConfirmModal'; import {useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; +import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsBlockedToAddFeed from '@hooks/useIsBlockedToAddFeed'; import useLocalize from '@hooks/useLocalize'; @@ -27,7 +28,7 @@ import ROUTES from '@src/ROUTES'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {hasSeenTourSelector} from '@selectors/Onboarding'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import AmexCustomFeed from './AmexCustomFeed'; @@ -47,7 +48,7 @@ function DynamicAddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) { const [addNewCardFeed, addNewCardFeedMetadata] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); const {currentStep} = addNewCardFeed ?? {}; const {isBlockedToAddNewFeeds, isAllFeedsResultLoading, cardFeeds, workspaceAccountID} = useIsBlockedToAddFeed(policyID); - const [isModalVisible, setIsModalVisible] = useState(false); + const {showConfirmModal} = useConfirmModal(); const {translate} = useLocalize(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); @@ -97,6 +98,21 @@ function DynamicAddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) { ); } + const handlePlaidExit = () => { + showConfirmModal({ + title: translate('workspace.companyCards.addNewCard.exitModal.title'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + confirmText: translate('workspace.companyCards.addNewCard.exitModal.confirmText'), + cancelText: translate('workspace.companyCards.addNewCard.exitModal.cancelText'), + prompt: translate('workspace.companyCards.addNewCard.exitModal.prompt'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas, false); + }); + }; + let CurrentStep: React.JSX.Element; switch (currentStep) { case CONST.COMPANY_CARDS.STEP.SELECT_BANK: @@ -130,7 +146,7 @@ function DynamicAddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) { CurrentStep = ; break; case CONST.COMPANY_CARDS.STEP.PLAID_CONNECTION: - CurrentStep = setIsModalVisible(true)} />; + CurrentStep = ; break; case CONST.COMPANY_CARDS.STEP.IMPORT_FROM_FILE: CurrentStep = ; @@ -149,19 +165,6 @@ function DynamicAddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) { policyFeatureAccess={CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE} > {CurrentStep} - setIsModalVisible(false)} - onConfirm={() => { - setIsModalVisible(false); - navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas, false); - }} - /> ); } diff --git a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx index c0c8b13bb29a..4e21dcbdad03 100644 --- a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx @@ -1,7 +1,7 @@ import Button from '@components/ButtonComposed'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import MenuItemAvatarNavigation from '@components/MenuItem/presets/MenuItemAvatarNavigation'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -81,6 +81,11 @@ function ConfirmationStep({route}: ConfirmationStepProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; + const cardNameTitle = maskCardNumber(cardToAssign?.cardName ?? '', cardToAssign?.bankName); + + const transactionStartDateTitle = + cardToAssign?.dateOption === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING ? translate('workspace.companyCards.fromTheBeginning') : cardToAssign?.startDate; + useEffect(() => { if (!assignCard?.isAssignmentFinished) { return; @@ -183,10 +188,9 @@ function ConfirmationStep({route}: ConfirmationStepProps) { > {translate('workspace.companyCards.letsDoubleCheck')} {translate('workspace.companyCards.confirmationDescription')} - {translate('common.to')} @@ -199,21 +203,15 @@ function ConfirmationStep({route}: ConfirmationStepProps) { testID={CONST.ASSIGN_CARD_CARDHOLDER_ROW_TEST_ID} onPress={() => editStep(CONST.COMPANY_CARD.STEP.ASSIGNEE)} /> - editStep(CONST.COMPANY_CARD.STEP.TRANSACTION_START_DATE)} + value={transactionStartDateTitle} /> - editStep(CONST.COMPANY_CARD.STEP.CARD_NAME)} + value={cardToAssign?.customCardName} /> clearErrorFields('taxRateExternalID')} > - diff --git a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx index ffc3888edc94..7c72fa71d2cb 100644 --- a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx @@ -7,6 +7,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ImageSVG from '@components/ImageSVG'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -390,12 +391,10 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen )} - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.EXPENSIFY_CARD_NAME.path))} - interactive={canWriteExpensifyCard} + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.EXPENSIFY_CARD_NAME.path)) : undefined} + value={card?.nameValuePairs?.cardTitle} /> - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.EXPENSIFY_CARD_LIMIT.path))} - interactive={canWriteExpensifyCard} + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.EXPENSIFY_CARD_LIMIT.path)) : undefined} + value={formattedLimit} /> diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardSettingsPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardSettingsPage.tsx index 4916e060691c..8b95ef5fcda9 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardSettingsPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardSettingsPage.tsx @@ -1,4 +1,5 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -81,11 +82,10 @@ function WorkspaceCardSettingsPage({route}: WorkspaceCardSettingsPageProps) { > - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_SETTINGS_ACCOUNT.path))} + value={bankAccountNumber ? `${CONST.MASKED_PAN_PREFIX}${getLastFourDigits(bankAccountNumber)}` : undefined} /> diff --git a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx index 21b49ddab826..8cbb24892959 100644 --- a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx @@ -1,6 +1,6 @@ import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -126,6 +126,7 @@ function ConfirmationStep({policyID, stepNames, startStepIndex}: ConfirmationSte }; const translationForLimitType = getTranslationKeyForLimitType(data?.limitType); + const limitTitle = convertToShortDisplayString(data?.limit, data?.currency); const isPhysicalCard = data?.cardType === CONST.EXPENSIFY_CARD.CARD_TYPE.PHYSICAL; const cardReadyTranslationKey = isPhysicalCard ? 'workspace.card.issueNewCard.willBeReadyToShip' : 'workspace.card.issueNewCard.willBeReadyToUse'; @@ -186,52 +187,44 @@ function ConfirmationStep({policyID, stepNames, startStepIndex}: ConfirmationSte > {translate('workspace.card.issueNewCard.letsDoubleCheck')} {translate(cardReadyTranslationKey)} - editStep(CONST.EXPENSIFY_CARD.STEP.ASSIGNEE)} + editStep(CONST.EXPENSIFY_CARD.STEP.ASSIGNEE) : undefined} + value={cardholder} /> - editStep(CONST.EXPENSIFY_CARD.STEP.CARD_TYPE)} + value={data?.cardType ? translate(`workspace.card.issueNewCard.${data.cardType}Card`) : undefined} /> - editStep(CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE)} + value={limitTitle} /> - editStep(CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE)} + value={translationForLimitType ? translate(translationForLimitType) : undefined} /> {!!expirationDateTitle && shouldShowExpirationDate && ( - editStep(CONST.EXPENSIFY_CARD.STEP.SPEND_RULES)} + value={expirationDateTitle} /> )} {isSpendRuleApplied && areRulesEnabled && ( - editStep(CONST.EXPENSIFY_CARD.STEP.SPEND_RULES)} + value={cardRuleRestrictionsTitle} /> )} - editStep(CONST.EXPENSIFY_CARD.STEP.CARD_NAME)} + value={data?.cardTitle} /> {spendRuleOption === CONST.EXPENSIFY_CARD.SPEND_RULE_OPTION.COPY_EXISTING && ( - )} diff --git a/src/pages/workspace/fields/WorkspaceFieldsSection.tsx b/src/pages/workspace/fields/WorkspaceFieldsSection.tsx index 3e83c7f1a58e..5017a5944686 100644 --- a/src/pages/workspace/fields/WorkspaceFieldsSection.tsx +++ b/src/pages/workspace/fields/WorkspaceFieldsSection.tsx @@ -33,12 +33,12 @@ import type {Route} from '@src/ROUTES'; import type {Policy, PolicyConnectionName} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListRenderItemProps} from '@legendapp/list/react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {FlashList} from '@shopify/flash-list'; +import {LegendList} from '@legendapp/list/react-native'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; type FieldListValue = NonNullable[string]; @@ -116,12 +116,12 @@ function WorkspaceFieldsSection({ const isConnectionVerified = connectedIntegration && !isConnectionUnverified(policy, connectedIntegration); const currentConnectionName = getCurrentAccountingIntegrationName(policy, translate); const fieldList = policy?.fieldList; - const hasImportedField = useMemo(() => Object.values(fieldList ?? {}).some((field) => fieldFilter(field) && isReportFieldImportedFromIntegration(field)), [fieldFilter, fieldList]); + const hasImportedField = Object.values(fieldList ?? {}).some((field) => fieldFilter(field) && isReportFieldImportedFromIntegration(field)); const {canWrite, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, policyFeature); - const fetchFields = useCallback(() => { + const fetchFields = () => { openFieldsPage(policyID); - }, [openFieldsPage, policyID]); + }; const {isOffline} = useNetwork({onReconnect: fetchFields}); @@ -129,51 +129,41 @@ function WorkspaceFieldsSection({ fetchFields(); }, [fetchFields]); - const fields = useMemo(() => { - if (!fieldList) { - return []; - } - - return Object.values(fieldList) - .filter(fieldFilter) - .sort((a, b) => localeCompare(a.name, b.name)) - .map((field) => ({ - text: field.name, - keyForList: String(field.fieldID), - fieldID: field.fieldID, - pendingAction: field.pendingAction, - isDisabled: field.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - rightLabel: Str.recapitalize(translate(getReportFieldTypeTranslationKey(field.type ?? CONST.REPORT_FIELD_TYPES.TEXT))), - })); - }, [fieldFilter, fieldList, localeCompare, translate]); + const fields: FieldListItem[] = fieldList + ? Object.values(fieldList) + .filter(fieldFilter) + .sort((a, b) => localeCompare(a.name, b.name)) + .map((field) => ({ + text: field.name, + keyForList: String(field.fieldID), + fieldID: field.fieldID, + pendingAction: field.pendingAction, + isDisabled: field.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + rightLabel: Str.recapitalize(translate(getReportFieldTypeTranslationKey(field.type ?? CONST.REPORT_FIELD_TYPES.TEXT))), + })) + : []; - const navigateToFieldSettings = useCallback( - (item: FieldListItem) => { - if (!canWrite) { - return; - } + const navigateToFieldSettings = (item: FieldListItem) => { + if (!canWrite) { + return; + } - Navigation.navigate(getSettingsRoute(policyID, item.fieldID)); - }, - [canWrite, getSettingsRoute, policyID], - ); + Navigation.navigate(getSettingsRoute(policyID, item.fieldID)); + }; - const renderItem = useCallback( - ({item}: ListRenderItemInfo) => ( - - navigateToFieldSettings(item)} - description={item.text} - disabled={item.isDisabled} - shouldShowRightIcon={!item.isDisabled && canWrite} - interactive={!item.isDisabled && canWrite} - rightLabel={item.rightLabel} - descriptionTextStyle={[styles.popoverMenuText, styles.textStrong]} - /> - - ), - [canWrite, navigateToFieldSettings, shouldUseNarrowLayout, styles.ph5, styles.ph8, styles.popoverMenuText, styles.textStrong], + const renderItem = ({item}: LegendListRenderItemProps) => ( + + navigateToFieldSettings(item)} + description={item.text} + disabled={item.isDisabled} + shouldShowRightIcon={!item.isDisabled && canWrite} + interactive={!item.isDisabled && canWrite} + rightLabel={item.rightLabel} + descriptionTextStyle={[styles.popoverMenuText, styles.textStrong]} + /> + ); const headerText = @@ -256,11 +246,12 @@ function WorkspaceFieldsSection({ <> {!isLoading && ( - )} diff --git a/src/pages/workspace/members/ImportedMembersConfirmationPage.tsx b/src/pages/workspace/members/ImportedMembersConfirmationPage.tsx index c1b7e6fe9cd0..eaba77b92714 100644 --- a/src/pages/workspace/members/ImportedMembersConfirmationPage.tsx +++ b/src/pages/workspace/members/ImportedMembersConfirmationPage.tsx @@ -2,7 +2,7 @@ import MultiAccountAvatar from '@components/Avatar/connected/MultiAccountAvatar' import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -169,12 +169,10 @@ function ImportedMembersConfirmationPage({route}: ImportedMembersConfirmationPag - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.IMPORTED_MEMBERS_ROLE.path))} + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.IMPORTED_MEMBERS_ROLE.path)) : undefined} + value={translate(`workspace.common.roleName`, role)} /> diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index cf41ce40a454..6f90429d74be 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -4,6 +4,7 @@ import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; @@ -249,6 +250,14 @@ function WorkspaceInviteMessageComponent({ const invitingMemberEmail = Object.keys(invitedEmailsToAccountIDsDraft ?? {}).at(0) ?? ''; const invitingMemberDetails = usePersonalDetailByLogin(invitingMemberEmail); const invitingMemberName = Str.removeSMSDomain(invitingMemberDetails?.displayName ?? ''); + const invitingMemberTitle = invitingMemberName && invitingMemberName !== invitingMemberEmail ? invitingMemberName : invitingMemberEmail; + const approverName = temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: approverDetails, + defaultValue: workspaceInviteApproverDraft, + shouldFallbackToHidden: false, + translate, + formatPhoneNumber, + }); useEffect(() => { return () => { @@ -306,10 +315,9 @@ function WorkspaceInviteMessageComponent({ {isInviteNewMemberStep && ( - )} {shouldShowMemberNames && !isInviteNewMemberStep && ( @@ -323,30 +331,25 @@ function WorkspaceInviteMessageComponent({ }} /> )} - { - if (tryNavigateToSubmitWorkspaceUpgrade(policy, true, CONST.UPGRADE_FEATURE_INTRO_MAPPING.roles.alias, Navigation.getActiveRoute())) { - return; - } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_INVITE_MESSAGE_ROLE.path)); - }} + { + if (tryNavigateToSubmitWorkspaceUpgrade(policy, true, CONST.UPGRADE_FEATURE_INTRO_MAPPING.roles.alias, Navigation.getActiveRoute())) { + return; + } + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_INVITE_MESSAGE_ROLE.path)); + } + : undefined + } + value={translate(`workspace.common.roleName`, workspaceInviteRoleDraft)} /> {!!shouldShowApproverRow && ( - )} diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 4759337e9690..625e0403e87a 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -4,6 +4,7 @@ import Button from '@components/ButtonComposed'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -409,23 +410,19 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM {isControlPolicy(policy) && ( <> - Navigation.navigate(ROUTES.WORKSPACE_CUSTOM_FIELDS.getRoute(policyID, accountID, 'customField1'))} - pressableTestID="member-customField1-menu-item" + Navigation.navigate(ROUTES.WORKSPACE_CUSTOM_FIELDS.getRoute(policyID, accountID, 'customField1')) : undefined} + testID="member-customField1-menu-item" + value={member.employeeUserID} /> - Navigation.navigate(ROUTES.WORKSPACE_CUSTOM_FIELDS.getRoute(policyID, accountID, 'customField2'))} - pressableTestID="member-customField2-menu-item" + Navigation.navigate(ROUTES.WORKSPACE_CUSTOM_FIELDS.getRoute(policyID, accountID, 'customField2')) : undefined} + testID="member-customField2-menu-item" + value={member.employeePayrollID} /> diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemDetailsPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemDetailsPage.tsx index 30f4fca5c974..a6bba34a28a0 100644 --- a/src/pages/workspace/perDiem/WorkspacePerDiemDetailsPage.tsx +++ b/src/pages/workspace/perDiem/WorkspacePerDiemDetailsPage.tsx @@ -1,6 +1,6 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -90,33 +90,25 @@ function WorkspacePerDiemDetailsPage({route}: WorkspacePerDiemDetailsPageProps) contentContainerStyle={styles.flexGrow1} keyboardShouldPersistTaps="always" > - Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_EDIT_DESTINATION.getRoute(policyID, rateID, subRateID))} - interactive={canWritePerDiem} - shouldShowRightIcon={canWritePerDiem} + Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_EDIT_DESTINATION.getRoute(policyID, rateID, subRateID)) : undefined} + value={selectedRate?.name} /> - Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_EDIT_SUBRATE.getRoute(policyID, rateID, subRateID))} - interactive={canWritePerDiem} - shouldShowRightIcon={canWritePerDiem} + Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_EDIT_SUBRATE.getRoute(policyID, rateID, subRateID)) : undefined} + value={selectedSubRate?.name} /> - Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_EDIT_AMOUNT.getRoute(policyID, rateID, subRateID))} - interactive={canWritePerDiem} - shouldShowRightIcon={canWritePerDiem} + Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_EDIT_AMOUNT.getRoute(policyID, rateID, subRateID)) : undefined} + value={amountValue} /> - Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_EDIT_CURRENCY.getRoute(policyID, rateID, subRateID))} - interactive={canWritePerDiem} - shouldShowRightIcon={canWritePerDiem} + Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_EDIT_CURRENCY.getRoute(policyID, rateID, subRateID)) : undefined} + value={currencyValue} /> {canWritePerDiem && ( { - if (!policy || !agentRule) { - return; - } + const inputWrapperStyles = useAgentPromptInputStyles(); - showConfirmModal({ - title: translate('workspace.rules.agentRules.deleteRule'), - prompt: translate('workspace.rules.agentRules.deleteRuleConfirmation'), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; + const {deleteHeaderProps} = useRuleDeleteHeaderProps({ + canDelete: !!policy && !!agentRule && agentRule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + onDelete: () => { + if (!policy) { + return false; } - deletePolicyAgentRule(policy, ruleID); - Navigation.goBack(); - }); - }; - - const inputWrapperStyles = useAgentPromptInputStyles(); + return true; + }, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.AGENT_RULE_DELETE, + titleKey: 'workspace.rules.agentRules.deleteRule', + promptKey: 'workspace.rules.agentRules.deleteRuleConfirmation', + }); if (!agentRule) { return ; @@ -128,7 +116,10 @@ function EditAgentRulePage({ shouldEnableMaxHeight={shouldUseExpandedRevampFormLayout} > - + - {translate('workspace.rules.agentRules.deleteRule')} - - } > diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index d2130af73ef2..50b4a00bdb08 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -21,12 +21,13 @@ import Tab from '@libs/actions/Tab'; import {clearDraftFlagForReviewRule, setDraftFlagForReviewRule} from '@libs/actions/User'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; -import {getFlagForReviewFormFromCategory, getFlagForReviewRuleAmountError, saveFlagForReviewRule} from '@libs/FlagForReviewRulesUtils'; +import {deleteFlagForReviewRule, getFlagForReviewFormFromCategory, getFlagForReviewRuleAmountError, hasExplicitFlagAmount, saveFlagForReviewRule} from '@libs/FlagForReviewRulesUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -181,6 +182,21 @@ function FlagForReviewRulePageBase({ handleSave(); }; + // The rule IS the category's flag amount, so there is only something to delete once one is set, and the category's + // own pending state is the rule's: while a delete is in flight, deleting again would fire the same write twice. + const isRuleBeingDeleted = category?.pendingFields?.maxExpenseAmount === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const {deleteHeaderProps} = useRuleDeleteHeaderProps({ + canDelete: canWriteRules && isEditing && hasExplicitFlagAmount(category?.maxExpenseAmount) && !isRuleBeingDeleted, + onDelete: () => { + deleteFlagForReviewRule(policyID, categoryName ?? '', policyData.categories); + return true; + }, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.FLAG_FOR_REVIEW_RULE_DELETE, + // Category settings opens this rule itself, so going back a screen would land on the New rule hub the user + // never passed through. Same route the save path picks, for the same reason. + backTo: initialCategoryName ? (categorySettingsBackPath ?? getWorkspaceCategorySettingsRoute(policyID, initialCategoryName)) : undefined, + }); + if (isEditing && categoryName && !category) { return ; } @@ -214,7 +230,10 @@ function FlagForReviewRulePageBase({ offlineIndicatorStyle={styles.mtAuto} includeSafeAreaPaddingBottom > - + {translate('workspace.rules.flagForReviewRule.subtitle')} diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx index 4f7cb21d795d..4a176be43c68 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx @@ -2,7 +2,7 @@ import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; @@ -115,11 +115,10 @@ function AddMerchantToMatchPage({route}: AddMerchantToMatchPageProps) { containerStyles={[styles.ph5]} /> - Navigation.navigate(ROUTES.RULES_MERCHANT_MATCH_TYPE.getRoute(policyID, isEditing ? ruleID : undefined))} + value={getMatchTypeLabel()} /> diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index a4ca5bde95db..01576c8185ea 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -39,6 +39,7 @@ import {getTagArrayFromName} from '@libs/TransactionUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -438,37 +439,32 @@ function MerchantRulePageBase({policyID, ruleID, editCategoryTaxRuleFor, titleKe startWithLoading(() => saveRule()); }; - const handleDelete = () => { - if (!canWriteRules) { - return; - } + const deleteRule = () => { if (!policy) { - return; + return false; } - if (!ruleID && !editCategoryTaxRuleFor) { - return; + setIsClosing(true); + if (editCategoryTaxRuleFor) { + deletePolicyCategoryTax(policy, editCategoryTaxRuleFor); + } else if (ruleID) { + deletePolicyCodingRule(policy, ruleID); } - - showConfirmModal({ - title: translate('workspace.rules.merchantRules.deleteRule'), - prompt: translate('workspace.rules.merchantRules.deleteRuleConfirmation'), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; - } - setIsClosing(true); - if (editCategoryTaxRuleFor) { - deletePolicyCategoryTax(policy, editCategoryTaxRuleFor); - } else if (ruleID) { - deletePolicyCodingRule(policy, ruleID); - } - Navigation.goBack(); - }); + return true; }; + // A category tax rule is its category, so it carries no pending state of its own. Only a merchant rule can + // already be on its way out. Declared above the not-found returns below, since a hook can't run conditionally. + const isRuleBeingDeleted = existingRule?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const canDeleteRule = canWriteRules && !!policy && !isRuleBeingDeleted && (isEditing || canDeleteCategoryTaxRule); + + // This page is reachable without the revamp beta, from the classic rules page, so the trashcan is gated the way + // the reset button beside it already is. Without the beta the labelled footer button below stays instead. + const {deleteHeaderProps, confirmDelete} = useRuleDeleteHeaderProps({ + canDelete: canDeleteRule && isRulesRevampEnabled, + onDelete: deleteRule, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_DELETE, + }); + const sections: SectionType[] = [ { titleTranslationKey: 'workspace.rules.merchantRules.expensesWith', @@ -646,10 +642,12 @@ function MerchantRulePageBase({policyID, ruleID, editCategoryTaxRuleFor, titleKe {translate('workspace.rules.merchantRules.previewMatches')} )} - {(isEditing || canDeleteCategoryTaxRule) && ( + {/* Pre-revamp this delete was a labelled button here rather than the header trashcan, and this page + is still reachable without the beta, so that is what those admins keep seeing. */} + {canDeleteRule && !isRulesRevampEnabled && ( diff --git a/src/pages/workspace/rules/useRuleDeleteHeaderProps.ts b/src/pages/workspace/rules/useRuleDeleteHeaderProps.ts new file mode 100644 index 000000000000..1a0d6b1d35e1 --- /dev/null +++ b/src/pages/workspace/rules/useRuleDeleteHeaderProps.ts @@ -0,0 +1,99 @@ +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; + +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type {Route} from '@src/ROUTES'; + +import type {ValueOf} from 'type-fest'; + +type RuleDeleteHeaderPropsParams = { + /** Whether there is a rule to delete and the user may delete it. */ + canDelete: boolean; + + /** + * Deletes the rule. Confirming is handled here, and so is leaving the page, but only on a `true` return: a caller + * that bails out keeps the user where they are rather than sending them off a page whose rule is still there. + */ + onDelete: () => boolean; + + sentryLabel: ValueOf; + + /** + * Where to land after deleting, for the editors that can be reached from more than one place. Category settings + * opens its own rules directly, so going back a screen from there would land on the New rule hub the user never + * saw. The save paths pass the same route for the same reason. + */ + backTo?: Route; + + /** Overrides the confirmation copy for rule types that word it their own way. */ + titleKey?: TranslationPaths; + promptKey?: TranslationPaths; +}; + +/** + * The header props that put a rule's delete on a trashcan in the top right, and the confirmation behind it, which + * every rule editor shares so the gesture is the same whichever kind of rule is open. + * + * `HeaderWithBackButton` renders a single three-dots item as the icon itself, so one item is what makes this a + * trashcan rather than a menu. `canDelete` decides whether it appears at all: an unsaved rule has nothing to delete, + * a member without write access can't, and a rule already on its way out would otherwise be deleted twice. + * + * The copy defaults to the pair the rules tables already use for their bulk delete, so deleting one rule and deleting + * several read alike. It lives under `merchantRules` because that table had delete first, not because it is + * merchant-specific. + */ +function useRuleDeleteHeaderProps({canDelete, onDelete, sentryLabel, backTo, titleKey, promptKey}: RuleDeleteHeaderPropsParams) { + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); + + const confirmDelete = () => { + showConfirmModal({ + title: translate(titleKey ?? 'workspace.rules.merchantRules.deleteRule'), + prompt: translate(promptKey ?? 'workspace.rules.merchantRules.deleteRuleConfirmation'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + + if (!onDelete()) { + return; + } + + Navigation.goBack(backTo); + }); + }; + + const threeDotsMenuItems: PopoverMenuItem[] = [ + { + icon: icons.Trashcan, + text: translate('common.delete'), + onSelected: confirmDelete, + sentryLabel, + }, + ]; + + return { + /** Spread onto `HeaderWithBackButton`. */ + deleteHeaderProps: { + threeDotsMenuItems, + shouldShowThreeDotsButton: canDelete, + shouldMinimizeMenuButton: true, + }, + + /** The same confirmation, for the pre-revamp footer button the two beta-gated pages still show. */ + confirmDelete, + }; +} + +export default useRuleDeleteHeaderProps; diff --git a/src/pages/workspace/tags/DynamicTagSettingsPage.tsx b/src/pages/workspace/tags/DynamicTagSettingsPage.tsx index edee0501c549..bfe8574c46e4 100644 --- a/src/pages/workspace/tags/DynamicTagSettingsPage.tsx +++ b/src/pages/workspace/tags/DynamicTagSettingsPage.tsx @@ -1,5 +1,6 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -92,6 +93,8 @@ function DynamicTagSettingsPage({route, navigation}: DynamicTagSettingsPageProps return ; } + const cleanedTagName = getCleanedTagName(currentPolicyTag.name); + const updateWorkspaceTagEnabled = (value: boolean) => { if (shouldPreventDisableOrDelete) { showConfirmModal({ @@ -196,12 +199,10 @@ function DynamicTagSettingsPage({route, navigation}: DynamicTagSettingsPageProps )} - {(!hasDependentTags || !!currentPolicyTag?.['GL Code']) && ( diff --git a/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx index d901615dd943..1f21708ce4a2 100644 --- a/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx @@ -1,6 +1,6 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import Switch from '@components/Switch'; @@ -50,6 +50,7 @@ function WorkspaceTagsSettingsPage({route}: WorkspaceTagsSettingsPageProps) { const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const getReviewWorkspaceSettingsTaskCompletion = useReviewWorkspaceSettingsTaskCompletion(); const [policyTagLists, isMultiLevelTags] = useMemo(() => [getTagListsUtil(policyTags), isMultiLevelTagsUtil(policyTags)], [policyTags]); + const customTagName = policyTagLists.at(0)?.name; const isLoading = !getTagListsUtil(policyTags)?.at(0) || Object.keys(policyTags ?? {}).at(0) === 'undefined'; const {isOffline} = useNetwork(); const hasEnabledOptions = hasEnabledOptionsUtil(Object.values(policyTags ?? {}).flatMap(({tags}) => Object.values(tags))); @@ -83,9 +84,8 @@ function WorkspaceTagsSettingsPage({route}: WorkspaceTagsSettingsPageProps) { pendingAction={policyTags?.[policyTagLists.at(0)?.name ?? '']?.pendingAction} errorRowStyles={styles.mh5} > - { Navigation.navigate( isQuickSettingsFlow @@ -93,7 +93,7 @@ function WorkspaceTagsSettingsPage({route}: WorkspaceTagsSettingsPageProps) { : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EDIT_TAGS.getRoute(policyTagLists.at(0)?.orderWeight ?? 0)), ); }} - shouldShowRightIcon + value={customTagName} /> )} diff --git a/src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx index 556793e48157..232e306a7fee 100644 --- a/src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx @@ -1,5 +1,4 @@ import Button from '@components/ButtonComposed'; -import ConfirmModal from '@components/ConfirmModal'; import FormHelpMessageRowWithRetryButton from '@components/Domain/FormHelpMessageRowWithRetryButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -58,7 +57,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import TravelBillingLearnHow from './TravelBillingLearnHow'; @@ -82,9 +81,6 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection const {allFeeds: accessibleTravelFeeds} = useExpensifyCardFeedsForFeedSelector(policyID, [CONST.TRAVEL.PROGRAM_TRAVEL_US]); const {showConfirmModal, closeModal} = useConfirmModal(); - const [isDisableConfirmModalVisible, setIsDisableConfirmModalVisible] = useState(false); - const [isOutstandingBalanceModalVisible, setIsOutstandingBalanceModalVisible] = useState(false); - const [isPayBalanceModalVisible, setIsPayBalanceModalVisible] = useState(false); // Ref to track if the "Update to USD" modal is open const isCurrencyModalOpen = useRef(false); @@ -124,6 +120,13 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection const shouldShowPayButton = travelSpend > 0 && travelSpend > pendingInvoiceAmount && isMonthlySettlementFrequency && !hasPendingSettlement; const formattedSpend = convertToDisplayString(travelSpend, CONST.CURRENCY.USD); + // Mirror the spend so the pay-balance confirmation settles the balance as it stands when the user confirms. + // The awaited handler would otherwise keep the value captured when the modal was opened. + const travelSpendRef = useRef(travelSpend); + useEffect(() => { + travelSpendRef.current = travelSpend; + }, [travelSpend]); + // Pay-by-invoice customers settle by wire against an invoice, so the pay CTA and modal use invoice copy const isPayByInvoice = getIsTravelBillingPayByInvoice(travelSettings); const payBalanceCtaText = translate( @@ -187,10 +190,20 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection const hasTravelProvisioningErrors = isTravelBillingEnabled && !!travelProvisioningErrors && Object.keys(travelProvisioningErrors).length > 0; /** - * Opens the pay balance confirmation modal. + * Opens the pay balance confirmation modal and, once confirmed, triggers the API call with optimistic Onyx update. */ - const handlePayBalance = () => { - setIsPayBalanceModalVisible(true); + const handlePayBalance = async () => { + const result = await showConfirmModal({ + title: payBalanceModalTitle, + prompt: payBalanceModalBody, + confirmText: payBalanceCtaText, + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + payTravelBillingSpend(policyID, defaultFundID, travelSpendRef.current); }; /** @@ -206,15 +219,6 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query})); }; - /** - * Handles the confirmed payment of the outstanding travel balance. - * Closes the modal and triggers the API call with optimistic Onyx update. - */ - const handleConfirmPayBalance = () => { - setIsPayBalanceModalVisible(false); - payTravelBillingSpend(policyID, defaultFundID, travelSpend); - }; - const continueToggleFlow = () => { if (areTravelPersonalDetailsMissing(privatePersonalDetails)) { shouldResumeToggleRef.current = true; @@ -271,6 +275,20 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection continueToggleFlow(); }; + const promptDisableAndDeactivate = async () => { + const result = await showConfirmModal({ + title: translate('workspace.moreFeatures.travel.travelInvoicing.disableModal.title'), + prompt: translate('workspace.moreFeatures.travel.travelInvoicing.disableModal.body'), + confirmText: translate('workspace.moreFeatures.travel.travelInvoicing.disableModal.confirm'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + deactivateTravelBilling(policyID, defaultFundID); + }; + /** * Handle toggle change for Travel Billing. * When turning ON: @@ -295,12 +313,17 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection if (!isEnabled) { // Trying to disable - check for outstanding balance first if (hasOutstandingBalance) { - // Show blocker modal with error message - setIsOutstandingBalanceModalVisible(true); + // Show blocker modal with error message. It is acknowledgement-only, so the result is ignored. + showConfirmModal({ + title: translate('workspace.moreFeatures.travel.travelInvoicing.outstandingBalanceModal.title'), + prompt: translate('workspace.moreFeatures.travel.travelInvoicing.outstandingBalanceModal.body'), + confirmText: translate('workspace.moreFeatures.travel.travelInvoicing.outstandingBalanceModal.confirm'), + shouldShowCancelButton: false, + }); return; } // Show confirmation modal before disabling - setIsDisableConfirmModalVisible(true); + promptDisableAndDeactivate(); return; } @@ -312,11 +335,6 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection continueToggleFlow(); }; - const handleConfirmDisable = () => { - setIsDisableConfirmModalVisible(false); - deactivateTravelBilling(policyID, defaultFundID); - }; - // Dismiss the "Update to USD" modal check if the currency changes to USD externally (e.g. from another device) useEffect(() => { if (policy?.outputCurrency !== CONST.CURRENCY.USD || !isCurrencyModalOpen.current) { @@ -480,57 +498,23 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection ); return ( - <> -
- clearTravelBillingErrors(defaultFundID)} - subMenuItems={travelBillingSubMenuItems} - /> -
- - setIsDisableConfirmModalVisible(false)} - prompt={translate('workspace.moreFeatures.travel.travelInvoicing.disableModal.body')} - confirmText={translate('workspace.moreFeatures.travel.travelInvoicing.disableModal.confirm')} - cancelText={translate('common.cancel')} - buttonVariant={CONST.BUTTON_VARIANT.DANGER} - /> - - setIsOutstandingBalanceModalVisible(false)} - onCancel={() => setIsOutstandingBalanceModalVisible(false)} - prompt={translate('workspace.moreFeatures.travel.travelInvoicing.outstandingBalanceModal.body')} - confirmText={translate('workspace.moreFeatures.travel.travelInvoicing.outstandingBalanceModal.confirm')} - shouldShowCancelButton={false} +
+ clearTravelBillingErrors(defaultFundID)} + subMenuItems={travelBillingSubMenuItems} /> - - setIsPayBalanceModalVisible(false)} - prompt={payBalanceModalBody} - confirmText={payBalanceCtaText} - cancelText={translate('common.cancel')} - buttonVariant={CONST.BUTTON_VARIANT.SUCCESS} - /> - +
); } diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx index 4965fe148cf9..005a801f81ca 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx @@ -1,6 +1,5 @@ import Badge from '@components/Badge'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import ConfirmModal from '@components/ConfirmModal'; import ErrorMessageRow from '@components/ErrorMessageRow'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -96,11 +95,9 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR const [selectedPayer, setSelectedPayer] = useState(policy?.achAccount?.reimburser ?? policy?.owner); const shouldShowSuccess = sharedBankAccountData?.shouldShowSuccess ?? false; const styles = useThemeStyles(); - const {showConfirmModal} = useConfirmModal(); + const {showConfirmModal, closeModal} = useConfirmModal(); const {isLoading, startWithLoading} = usePressLoading({isLoading: sharedBankAccountData?.isLoading ?? false}); const [isAlertVisible, setIsAlertVisible] = useState(false); - const [showValidationModal, setShowValidationModal] = useState(false); - const [showErrorModal, setShowErrorModal] = useState(false); const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); const selectedPayerDisplayName = usePersonalDetailByLogin(selectedPayer, displayNameSelector); const ownerDisplayName = usePersonalDetailByLogin(policy?.owner, displayNameSelector); @@ -215,6 +212,67 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR Navigation.closeRHPFlow(); }; + // Acknowledgement-only modal shown when the bank account still needs to be validated. The result is ignored. + // The only actionable path is the link inside the prompt, which closes the modal before navigating away. + const showBankAccountValidationModal = () => { + showConfirmModal({ + title: translate('workflowsPayerPage.shareBankAccount.validationTitle'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + prompt: ( + + { + closeModal(); + navigateToBankAccountRoute({policyID, backTo: ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID)}); + }} + html={translate('workflowsPayerPage.shareBankAccount.validationDescription', { + admin: selectedPayerDisplayName ?? '', + })} + /> + + ), + shouldShowCancelButton: false, + confirmText: translate('common.buttonConfirm'), + }); + }; + + // Acknowledgement-only modal shown when the current user isn't allowed to share the bank account. The result is ignored. + // The link inside the prompt closes the modal and opens a chat with the workspace owner. + const showMissingSharePermissionModal = () => { + showConfirmModal({ + title: translate('workflowsPayerPage.shareBankAccount.errorTitle'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + prompt: ( + + { + if (!currentUserPersonalDetails?.accountID || !policy?.ownerAccountID) { + return; + } + closeModal(); + navigateToAndOpenReportWithAccountIDs( + [policy.ownerAccountID], + currentUserPersonalDetails.accountID, + introSelected, + guidedSetupAndTourStatus?.isSelfTourViewed, + guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + betas, + personalDetails, + conciergeChat, + ); + }} + html={translate('workflowsPayerPage.shareBankAccount.errorDescription', { + admin: selectedPayerDisplayName ?? '', + owner: ownerDisplayName ?? '', + })} + /> + + ), + shouldShowCancelButton: false, + confirmText: translate('common.buttonConfirm'), + }); + }; + const handleShareBankAccount = () => { // No payer selected — nothing to share with if (!selectedPayer) { @@ -239,7 +297,7 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR // Bank account setup incomplete — block and show validation if (isBankAccountPartiallySetup(bankAccountInfo?.accountData?.state)) { - setShowValidationModal(true); + showBankAccountValidationModal(); return; } const isAccountAlreadySharedWithCurrentUser = @@ -249,7 +307,7 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR // Current user has no right to share (not owner, payments admin or a sharee) — show error if (!isOwner && !canCurrentUserManagePayments && !isAccountAlreadyShared && !isAccountAlreadySharedWithCurrentUser) { - setShowErrorModal(true); + showMissingSharePermissionModal(); return; } showConfirmModal({ @@ -354,67 +412,6 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR )} - { - setShowValidationModal(false); - }} - buttonVariant={CONST.BUTTON_VARIANT.SUCCESS} - onCancel={() => setShowValidationModal(false)} - prompt={ - - { - setShowValidationModal(false); - navigateToBankAccountRoute({policyID, backTo: ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID)}); - }} - html={translate('workflowsPayerPage.shareBankAccount.validationDescription', { - admin: selectedPayerDisplayName ?? '', - })} - /> - - } - shouldShowCancelButton={false} - confirmText={translate('common.buttonConfirm')} - /> - setShowErrorModal(false)} - onConfirm={() => { - setShowErrorModal(false); - }} - buttonVariant={CONST.BUTTON_VARIANT.SUCCESS} - prompt={ - - { - if (!currentUserPersonalDetails?.accountID || !policy?.ownerAccountID) { - return; - } - setShowErrorModal(false); - navigateToAndOpenReportWithAccountIDs( - [policy.ownerAccountID], - currentUserPersonalDetails.accountID, - introSelected, - guidedSetupAndTourStatus?.isSelfTourViewed, - guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, - betas, - personalDetails, - conciergeChat, - ); - }} - html={translate('workflowsPayerPage.shareBankAccount.errorDescription', { - admin: selectedPayerDisplayName ?? '', - owner: ownerDisplayName ?? '', - })} - /> - - } - shouldShowCancelButton={false} - confirmText={translate('common.buttonConfirm')} - /> ); } diff --git a/src/selectors/PersonalDetails.ts b/src/selectors/PersonalDetails.ts index d70f02ab46cb..060c5bd68408 100644 --- a/src/selectors/PersonalDetails.ts +++ b/src/selectors/PersonalDetails.ts @@ -94,6 +94,21 @@ const isOptimisticPersonalDetailSelector = return isPersonalDetailOptimistic(personalDetailsList[accountID]); }; +/** + * Returns only the personal details that were created optimistically. The optimistic set is tiny compared to the whole + * personal details list, so subscribers using it don't re-render every time an unrelated (server-backed) detail changes. + */ +const optimisticPersonalDetailsSelector = (personalDetailsList: OnyxEntry): PersonalDetailsList => { + const optimisticPersonalDetails: PersonalDetailsList = {}; + for (const [accountID, personalDetail] of Object.entries(personalDetailsList ?? {})) { + if (!personalDetail?.isOptimisticPersonalDetail) { + continue; + } + optimisticPersonalDetails[accountID] = personalDetail; + } + return optimisticPersonalDetails; +}; + const newAccountIDsAndLoginsSelector = (invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs | undefined) => (personalDetailsList: OnyxEntry) => getNewAccountIDsAndLogins(invitedEmailsToAccountIDs, personalDetailsList); @@ -113,6 +128,7 @@ export { doesPersonalDetailExistSelector, accountIDToLoginSelector, isOptimisticPersonalDetailSelector, + optimisticPersonalDetailsSelector, createDisplayDetailsByAccountIDsSelector, newAccountIDsAndLoginsSelector, displayNameSelector, diff --git a/src/selectors/ReportMetaData.ts b/src/selectors/ReportMetaData.ts index 12e9d65eae98..8d95a2397768 100644 --- a/src/selectors/ReportMetaData.ts +++ b/src/selectors/ReportMetaData.ts @@ -18,13 +18,25 @@ const reportActionsLoadingStateSelector = (loadingState: OnyxEntry, -): Pick | undefined => +): + | Pick< + ReportLoadingState, + | 'hasOnceLoadedReportActions' + | 'isLoadingInitialReportActions' + | 'isLoadingOlderReportActions' + | 'hasLoadingOlderReportActionsError' + | 'isLoadingNewerReportActions' + | 'hasLoadingNewerReportActionsError' + > + | undefined => loadingState ? { hasOnceLoadedReportActions: loadingState.hasOnceLoadedReportActions, isLoadingInitialReportActions: loadingState.isLoadingInitialReportActions, isLoadingOlderReportActions: loadingState.isLoadingOlderReportActions, hasLoadingOlderReportActionsError: loadingState.hasLoadingOlderReportActionsError, + isLoadingNewerReportActions: loadingState.isLoadingNewerReportActions, + hasLoadingNewerReportActionsError: loadingState.hasLoadingNewerReportActionsError, } : undefined; diff --git a/src/setup/index.ts b/src/setup/index.ts index 1e095fa0dba9..3a7382a3dd5f 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -1,3 +1,4 @@ +import cleanupPreMountedDraftReports from '@libs/cleanupPreMountedDraftReports'; import {finishCloudflareSignInFromURL} from '@libs/CloudflareAccess/finishSignInFromURL'; import intlPolyfill from '@libs/IntlPolyfill'; import registerMiddlewares from '@libs/Middleware/register'; @@ -64,6 +65,8 @@ export default function () { // Ensure the Supportal permission modal doesn't persist across reloads [ONYXKEYS.SUPPORTAL_PERMISSION_DENIED]: null, [ONYXKEYS.IS_OPEN_APP_FAILURE_MODAL_OPEN]: false, + // Without a default this server-owned NVP has no row until it arrives, and its loading status holds the Search router behind a skeleton + [ONYXKEYS.RECENT_SEARCHES]: {}, }, skippableCollectionMemberIDs: CONST.SKIPPABLE_COLLECTION_MEMBER_IDS, snapshotMergeKeys: ['pendingAction', 'pendingFields'], @@ -89,9 +92,12 @@ export default function () { ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD, ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE, ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER, + ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL, ], }); + cleanupPreMountedDraftReports(); + // Register the commands after Onyx is initialized so every JS runtime can process paginated // responses. Initial snapshots remain asynchronous and gate only pagination, not app startup. registerReportActionsPagination(); diff --git a/src/stories/MenuItemComparison.stories.tsx b/src/stories/MenuItemComparison.stories.tsx index 9c643ea18bfd..3d7dea966278 100644 --- a/src/stories/MenuItemComparison.stories.tsx +++ b/src/stories/MenuItemComparison.stories.tsx @@ -8,8 +8,10 @@ import type {DisplayNameWithTooltip} from '@components/DisplayNames/types'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemAvatarNavigation from '@components/MenuItem/presets/MenuItemAvatarNavigation'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; import MenuItemWithLabel from '@components/MenuItem/presets/MenuItemWithLabel'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ReportActionAvatars from '@components/ReportActionAvatars'; import Text from '@components/Text'; @@ -140,6 +142,418 @@ function Comparison() { return ( + One card per prop shape, in frequency order. Every shape is the MenuItemField preset. + + + } + composable={ + + + + Sort by + Date + + + + } + /> + + + } + composable={ + + + + Name + Standard rate + + + + + + + } + preset={ + + } + /> + + + } + composable={ + + + + Country + United States + + + + } + preset={ + + } + /> + + + } + composable={ + + + + Display + Comfortable + + + + } + /> + + + } + composable={ + + + + Role + Admin + + + + } + /> + + + } + composable={ + + + + Member + John Doe + + + + + + + } + preset={ + + } + /> + + + } + composable={ + + + + Bank account + Chase ••••1234 + + + + + + + } + preset={ + + } + /> + + + + + + + + + + } + composable={ + <> + + + + + Share somewhere + + + Required + + + + + + + + + + Share somewhere + #announce + + + + + + + + + } + preset={ + <> + + + Required + + + + + + + } + /> + + + } + composable={ + + + + Account ending in 1234 + + + + + + + } + /> + + + } + composable={ + + + + A field name long enough to need a second line before it truncates + A value long enough that it has to truncate on a single line + + + + + + + } + preset={ + + } + /> + + + + + + + + + + + } + composable={ + <> + + + + + Sort by + Date + + + + + + + + + + + + Group by + + + + + + + + + } + preset={ + <> + + + + + + + + } + /> + + - Vacation delegate + Vacation delegate diff --git a/src/stories/MoneyRequestReportPreview.stories.tsx b/src/stories/MoneyRequestReportPreview.stories.tsx index 512d635f9034..934ddee08542 100755 --- a/src/stories/MoneyRequestReportPreview.stories.tsx +++ b/src/stories/MoneyRequestReportPreview.stories.tsx @@ -11,7 +11,7 @@ import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; import type {Transaction} from '@src/types/onyx'; -import type {ListRenderItem} from '@shopify/flash-list'; +import type {LegendListProps} from '@legendapp/list/react-native'; import type {LayoutChangeEvent} from 'react-native'; import type {StoryFn} from 'storybook-react-rsbuild'; @@ -135,7 +135,7 @@ function Template(props: MoneyRequestReportPreviewContentProps, {parameters}: {p const reportPreviewStyles = StyleUtils.getMoneyRequestReportPreviewStyle(false, transactions.length, widths.currentWidth, widths.currentWrapperWidth); const transactionPreviewContainerStyles = [styles.h100, reportPreviewStyles.transactionPreviewCarouselStyle]; - const renderItem: ListRenderItem = ({item}) => ( + const renderItem: NonNullable['renderItem']> = ({item}) => ( lineHeight: undefined, }, + condensedBadgeTextDefaultSize: { + fontSize: variables.fontSizeSmall, + }, + badgeDefaultText: { color: theme.text, }, diff --git a/src/styles/utils/sizing.ts b/src/styles/utils/sizing.ts index 68abde030840..2c2cedf56c26 100644 --- a/src/styles/utils/sizing.ts +++ b/src/styles/utils/sizing.ts @@ -88,6 +88,10 @@ export default { minHeight: 52, }, + mnh16: { + minHeight: 64, + }, + mnw0: { minWidth: 0, }, diff --git a/src/types/onyx/BetaOverrides.ts b/src/types/onyx/BetaOverrides.ts new file mode 100644 index 000000000000..0b5bdf7bb4c8 --- /dev/null +++ b/src/types/onyx/BetaOverrides.ts @@ -0,0 +1,6 @@ +import type Beta from './Beta'; + +/** Local overrides for beta feature flags, set from the Test Tool Menu. A `true`/`false` entry takes precedence over the server-provided betas; absent entries fall back to the server state */ +type BetaOverrides = Partial>; + +export default BetaOverrides; diff --git a/src/types/onyx/CorpayPayModal.ts b/src/types/onyx/CorpayPayModal.ts new file mode 100644 index 000000000000..49a2d70f2621 --- /dev/null +++ b/src/types/onyx/CorpayPayModal.ts @@ -0,0 +1,11 @@ +/** Model of the Corpay pay modal signal sent by the backend when a pay attempt fails because the workspace USD VBBA is not set up on Corpay */ +type CorpayPayModal = { + /** The bank account ID of the workspace USD VBBA that needs global reimbursement enabled */ + bankAccountID: number; + /** The country of the VBBA, sent so the client can render the business form without an extra BANK_ACCOUNT_LIST lookup */ + bankCountry: string; + /** The currency of the VBBA, sent so the client can render the business form without an extra BANK_ACCOUNT_LIST lookup */ + bankCurrency: string; +}; + +export default CorpayPayModal; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 403b166f98fc..37f34aa2f56e 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -18,6 +18,7 @@ import type BankAccount from './BankAccount'; import type BankAccountShareDetails from './BankAccountShareDetails'; import type Beta from './Beta'; import type BetaConfiguration from './BetaConfiguration'; +import type BetaOverrides from './BetaOverrides'; import type BillingGraceEndPeriod from './BillingGraceEndPeriod'; import type BillingReceiptDetails from './BillingReceiptDetails'; import type BillingStatus from './BillingStatus'; @@ -51,6 +52,7 @@ import type CopyPolicySettings from './CopyPolicySettings'; import type CopyPolicySettingsNVP from './CopyPolicySettingsNVP'; import type {CorpayFields, CorpayFormField} from './CorpayFields'; import type {CorpayOnboardingFields} from './CorpayOnboardingFields'; +import type CorpayPayModal from './CorpayPayModal'; import type Credentials from './Credentials'; import type Currency from './Currency'; import type {CurrencyList} from './Currency'; @@ -232,6 +234,7 @@ export type { Beta, AddNewPersonalCard, BetaConfiguration, + BetaOverrides, BlockedFromConcierge, Card, CardList, @@ -419,6 +422,7 @@ export type { JoinablePolicies, DismissedProductTraining, TravelProvisioning, + CorpayPayModal, SidePanel, SidePanelContext, LastPaymentMethodType, diff --git a/tests/actions/BetaOverridesTest.ts b/tests/actions/BetaOverridesTest.ts new file mode 100644 index 000000000000..66a71c9ca6e0 --- /dev/null +++ b/tests/actions/BetaOverridesTest.ts @@ -0,0 +1,77 @@ +import {clearBetaOverride, clearBetaOverrides, setBetaOverride} from '@userActions/User'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type BetaOverrides from '@src/types/onyx/BetaOverrides'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +function getBetaOverrides(): Promise { + return new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.BETA_OVERRIDES, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); +} + +describe('beta override actions', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('removes only the requested beta and leaves the others stored', async () => { + // Given Two stored overrides + setBetaOverride(CONST.BETAS.DEFAULT_ROOMS, true); + setBetaOverride(CONST.BETAS.ASAP_SUBMIT, false); + await waitForBatchedUpdates(); + + // When One of them is cleared + clearBetaOverride(CONST.BETAS.DEFAULT_ROOMS); + await waitForBatchedUpdates(); + + // Then That key is gone rather than stored as null, and the other is untouched + const betaOverrides = await getBetaOverrides(); + expect(betaOverrides?.[CONST.BETAS.DEFAULT_ROOMS]).toBeUndefined(); + expect(betaOverrides?.[CONST.BETAS.ASAP_SUBMIT]).toBe(false); + }); + + it('leaves nothing behind that reads as an override once the last one is cleared', async () => { + // Given A single stored override + setBetaOverride(CONST.BETAS.DEFAULT_ROOMS, true); + await waitForBatchedUpdates(); + + // When It is cleared + clearBetaOverride(CONST.BETAS.DEFAULT_ROOMS); + await waitForBatchedUpdates(); + + // Then Nothing resolves as an override, so the beta follows the account again + const betaOverrides = await getBetaOverrides(); + expect(betaOverrides?.[CONST.BETAS.DEFAULT_ROOMS]).toBeUndefined(); + }); + + it('drops every override when they are all cleared', async () => { + // Given Two stored overrides + setBetaOverride(CONST.BETAS.DEFAULT_ROOMS, true); + setBetaOverride(CONST.BETAS.ASAP_SUBMIT, false); + await waitForBatchedUpdates(); + + // When They are all cleared + clearBetaOverrides(); + await waitForBatchedUpdates(); + + // Then The whole key is gone + const betaOverrides = await getBetaOverrides(); + expect(betaOverrides).toBeUndefined(); + }); +}); diff --git a/tests/actions/IOUTest/PayMoneyRequestTest.ts b/tests/actions/IOUTest/PayMoneyRequestTest.ts index d8dea8fe342d..ceb3a0f5a3a6 100644 --- a/tests/actions/IOUTest/PayMoneyRequestTest.ts +++ b/tests/actions/IOUTest/PayMoneyRequestTest.ts @@ -391,6 +391,7 @@ describe('actions/IOU/PayMoneyRequest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); return waitForBatchedUpdates(); @@ -562,6 +563,7 @@ describe('actions/IOU/PayMoneyRequest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); return waitForBatchedUpdates(); @@ -1613,6 +1615,7 @@ describe('actions/IOU/PayMoneyRequest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); return waitForBatchedUpdates(); @@ -1746,6 +1749,7 @@ describe('actions/IOU/PayMoneyRequest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -2038,6 +2042,7 @@ describe('actions/IOU/PayMoneyRequest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, }); await waitForBatchedUpdates(); diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index d9599980e975..258f9938a9da 100644 --- a/tests/actions/IOUTest/ReportWorkflowTest.ts +++ b/tests/actions/IOUTest/ReportWorkflowTest.ts @@ -188,6 +188,7 @@ describe('actions/IOU/ReportWorkflow', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); @@ -801,6 +802,7 @@ describe('actions/IOU/ReportWorkflow', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); @@ -889,6 +891,7 @@ describe('actions/IOU/ReportWorkflow', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); return waitForBatchedUpdates(); @@ -1188,6 +1191,7 @@ describe('actions/IOU/ReportWorkflow', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); return waitForBatchedUpdates(); @@ -1417,6 +1421,7 @@ describe('actions/IOU/ReportWorkflow', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); return waitForBatchedUpdates() @@ -1658,6 +1663,7 @@ describe('actions/IOU/ReportWorkflow', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 367b8a4a6824..7197bfbcce7c 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -2207,6 +2207,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); @@ -2440,6 +2441,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, conciergeChat: undefined, }); @@ -3721,6 +3723,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { currency: undefined, isSelfTourViewed: false, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, betas: [], }); @@ -4328,6 +4331,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); @@ -4511,6 +4515,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); @@ -4698,6 +4703,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); @@ -4894,6 +4900,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); @@ -5172,6 +5179,7 @@ describe('updateSplitTransactions', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); @@ -5312,6 +5320,7 @@ describe('updateSplitTransactions', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); @@ -5454,6 +5463,7 @@ describe('updateSplitTransactions', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); @@ -5606,6 +5616,7 @@ describe('updateSplitTransactions', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); @@ -9802,6 +9813,28 @@ describe('createDistanceRequest', () => { expect(result.chatReportID).toBe(optimisticChatReportID); }); + it('ignores optimisticChatReportID when the page-level report already matches the participant, so an existing chat is never rebuilt at a different id', async () => { + // Given an existing 1:1 chat between the payer and payee that the confirmation page passes down + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + const existingChatReportID = rand64(); + const existingChat: Report = { + reportID: existingChatReportID, + type: CONST.REPORT.TYPE.CHAT, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${existingChatReportID}`, existingChat); + + // When the distance request is submitted with an unrelated optimistic chat id supplied + const result = createDistanceRequest({ + ...getDefaultDistanceRequestParams(existingChat, {amount: 1000}, recentWaypoints), + participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], + optimisticChatReportID: 'unused-optimistic-id', + }); + + // Then the existing chat is used and the optimistic id is ignored + expect(result.chatReportID).toBe(existingChatReportID); + }); + it('returns chatReportID with a null iouReport for a split distance request — the UI can only navigate via chatReportID', async () => { const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index e8bea82d2bd7..c9d5315d4ae2 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -3060,6 +3060,7 @@ describe('actions/IOU/UpdateMoneyRequest', () => { transactions: {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: fakeTransaction}, transactionViolations: {}, betas: undefined, + isASAPSubmitBetaEnabled: false, introSelected: undefined, currentUserAccountID: RORY_ACCOUNT_ID, currentUserEmail: RORY_EMAIL, diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index c86ec59da048..2f7b1858fc0e 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -102,6 +102,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -134,6 +135,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -173,6 +175,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: [CONST.BETAS.SUGGESTED_FOLLOWUPS], hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -982,6 +985,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1015,6 +1019,7 @@ describe('actions/Policy', () => { currency: undefined, isSelfTourViewed: false, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, betas: [CONST.BETAS.SUGGESTED_FOLLOWUPS], }); @@ -1052,6 +1057,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1093,6 +1099,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: true, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1122,6 +1129,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1156,6 +1164,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1187,6 +1196,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1219,6 +1229,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1249,6 +1260,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1279,6 +1291,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1309,6 +1322,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1339,6 +1353,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1367,6 +1382,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, type: CONST.POLICY.TYPE.SUBMIT, currency: 'USD', @@ -1418,6 +1434,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1456,6 +1473,7 @@ describe('actions/Policy', () => { isSelfTourViewed: true, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1494,6 +1512,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1538,6 +1557,7 @@ describe('actions/Policy', () => { isSelfTourViewed: true, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1587,6 +1607,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1635,6 +1656,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1671,6 +1693,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, adminParticipant: {participant: {login: adminEmail, accountID: adminAccountID}, doesPersonalDetailExist: true}, }); @@ -1713,6 +1736,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1769,6 +1793,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1805,6 +1830,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1841,6 +1867,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1880,6 +1907,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1911,6 +1939,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); @@ -1933,6 +1962,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: true, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); @@ -1959,6 +1989,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -1993,6 +2024,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, adminParticipant: {participant: {login: adminEmail, accountID: adminAccountID}, doesPersonalDetailExist: true}, activePolicy: undefined, }); @@ -2036,6 +2068,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, adminParticipant: {participant: {login: adminEmail, accountID: adminAccountID}, doesPersonalDetailExist: true}, activePolicy: undefined, }); @@ -2053,6 +2086,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, adminParticipant: {participant: {login: adminEmail, accountID: adminAccountID}, doesPersonalDetailExist: true}, activePolicy: undefined, }); @@ -2095,6 +2129,7 @@ describe('actions/Policy', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); await waitForBatchedUpdates(); @@ -7505,6 +7540,7 @@ describe('actions/Policy', () => { reportActionsList: {}, doesEmployeePersonalDetailExist: false, getCurrencyDecimals: TestHelper.getCurrencyDecimalsLocal, + hasOwnedPaidPolicy: false, }); await waitForBatchedUpdates(); @@ -7527,6 +7563,56 @@ describe('actions/Policy', () => { isIOUReportUsingReportSpy.mockRestore(); }); + it.each([ + ['leaves the #admins room unpinned when the payer already owns a paid workspace', true, false], + ['pins the #admins room when the payer owns no paid workspace yet', false, true], + ])('%s', async (_label, hasOwnedPaidPolicy, expectedIsPinned) => { + const employeeAccountID = 200; + const iouReport: Report = { + ...createRandomReport(1, undefined), + reportID: '700', + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: employeeAccountID, + chatReportID: '701', + policyID: 'oldPolicyID', + currency: CONST.CURRENCY.USD, + total: 1000, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); + await waitForBatchedUpdates(); + + const apiWriteSpy = jest.spyOn(APIModule, 'write').mockImplementation(() => Promise.resolve()); + const isIOUReportUsingReportSpy = jest.spyOn(ReportUtils, 'isIOUReportUsingReport').mockReturnValue(true); + + const result = Policy.createWorkspaceFromIOUPayment({ + iouReport, + reportPreviewAction: undefined, + currentUserAccountID: ESH_ACCOUNT_ID, + currentUserEmail: ESH_EMAIL, + iouReportOwnerEmail: 'owner@example.com', + currentUserLocalCurrency: CONST.CURRENCY.USD, + lastWorkspaceNumber: undefined, + localeTranslate: TestHelper.translateLocal, + reportActionsList: {}, + doesEmployeePersonalDetailExist: false, + getCurrencyDecimals: TestHelper.getCurrencyDecimalsLocal, + hasOwnedPaidPolicy, + currentUserDisplayName: undefined, + }); + await waitForBatchedUpdates(); + + const writeOptions = requireRecord(requireCallArgument(apiWriteSpy.mock.calls.at(0), 2)); + const adminsRoomUpdate = requireRecordArrayProperty(writeOptions, 'optimisticData').find( + (update) => requireStringProperty(update, 'key') === `${ONYXKEYS.COLLECTION.REPORT}${result?.adminsChatReportID}`, + ); + + expect(readProperty(requireRecord(readProperty(adminsRoomUpdate, 'value')), 'isPinned')).toBe(expectedIsPinned); + + apiWriteSpy.mockRestore(); + isIOUReportUsingReportSpy.mockRestore(); + }); + it('should return undefined for non-IOU reports', () => { const nonIOUReport: Report = { ...createRandomReport(1, undefined), @@ -7547,6 +7633,7 @@ describe('actions/Policy', () => { reportActionsList: {}, doesEmployeePersonalDetailExist: false, getCurrencyDecimals: TestHelper.getCurrencyDecimalsLocal, + hasOwnedPaidPolicy: false, }); expect(result).toBeUndefined(); }); @@ -7606,6 +7693,7 @@ describe('actions/Policy', () => { reportActionsList, doesEmployeePersonalDetailExist: true, getCurrencyDecimals: TestHelper.getCurrencyDecimalsLocal, + hasOwnedPaidPolicy: false, }); // Verify the function returns a valid result (not undefined) @@ -7661,6 +7749,7 @@ describe('actions/Policy', () => { reportActionsList: {}, doesEmployeePersonalDetailExist: false, getCurrencyDecimals: TestHelper.getCurrencyDecimalsLocal, + hasOwnedPaidPolicy: false, }); await waitForBatchedUpdates(); @@ -7732,6 +7821,7 @@ describe('actions/Policy', () => { reportActionsList: {}, doesEmployeePersonalDetailExist: false, getCurrencyDecimals: TestHelper.getCurrencyDecimalsLocal, + hasOwnedPaidPolicy: false, }); await waitForBatchedUpdates(); diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index a4fd788509bd..8902fd1e0c2d 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -19,7 +19,7 @@ import Navigation from '@libs/Navigation/Navigation'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; import {buildOptimisticNextStep} from '@libs/NextStepUtils'; import {getAccountIDsByLogins} from '@libs/PersonalDetailsUtils'; -import {getOriginalMessage, isActionOfType, isDeletedAction} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, getReportActionMessage, isActionOfType, isDeletedAction} from '@libs/ReportActionsUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import {appendParam} from '@libs/Url'; @@ -396,6 +396,7 @@ describe('actions/Report', () => { undefined, undefined, undefined, + undefined, ); return waitForBatchedUpdates(); }) @@ -429,7 +430,7 @@ describe('actions/Report', () => { return waitForBatchedUpdates() .then(() => { - Report.clearCreateChatError(REPORT, CONCIERGE_REPORT_ID, INTRO_SELECTED, TEST_USER_ACCOUNT_ID, undefined, false, undefined, undefined, undefined); + Report.clearCreateChatError(REPORT, CONCIERGE_REPORT_ID, INTRO_SELECTED, TEST_USER_ACCOUNT_ID, undefined, false, undefined, undefined, undefined, undefined); return waitForBatchedUpdates(); }) .then( @@ -449,6 +450,48 @@ describe('actions/Report', () => { ); }); + it('clearCreateChatError should only remove the optimistic personal details passed to it', () => { + const TEST_USER_ACCOUNT_ID = 1; + const OPTIMISTIC_PARTICIPANT_ACCOUNT_ID = 5001; + const SETTLED_PARTICIPANT_ACCOUNT_ID = 5002; + const REPORT: OnyxTypes.Report = { + ...createRandomReport(1, undefined), + errorFields: {createChat: {error: 'error'}}, + participants: { + [TEST_USER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [OPTIMISTIC_PARTICIPANT_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [SETTLED_PARTICIPANT_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }; + const REPORT_METADATA: OnyxTypes.ReportMetadata = {isOptimisticReport: true}; + const PERSONAL_DETAILS: OnyxTypes.PersonalDetailsList = { + [OPTIMISTIC_PARTICIPANT_ACCOUNT_ID]: {accountID: OPTIMISTIC_PARTICIPANT_ACCOUNT_ID, login: 'optimistic@test.com', isOptimisticPersonalDetail: true}, + [SETTLED_PARTICIPANT_ACCOUNT_ID]: {accountID: SETTLED_PARTICIPANT_ACCOUNT_ID, login: 'settled@test.com'}, + }; + + // Given an optimistic report that failed to be created, with one optimistic and one settled participant. + // The Onyx personal details list doesn't flag anyone as optimistic, so only the passed personal details can drive the clean up. + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT.reportID}`, REPORT); + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${REPORT.reportID}`, REPORT_METADATA); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [OPTIMISTIC_PARTICIPANT_ACCOUNT_ID]: {accountID: OPTIMISTIC_PARTICIPANT_ACCOUNT_ID, login: 'optimistic@test.com'}, + [SETTLED_PARTICIPANT_ACCOUNT_ID]: {accountID: SETTLED_PARTICIPANT_ACCOUNT_ID, login: 'settled@test.com'}, + }); + + return waitForBatchedUpdates() + .then(() => { + // When the create chat error is cleared with the personal details passed in + Report.clearCreateChatError(REPORT, undefined, undefined, TEST_USER_ACCOUNT_ID, undefined, false, undefined, undefined, undefined, PERSONAL_DETAILS); + return waitForBatchedUpdates(); + }) + .then(async () => { + // Then only the optimistic personal details are removed + const personalDetailsList = await getOnyxValue(ONYXKEYS.PERSONAL_DETAILS_LIST); + expect(personalDetailsList?.[OPTIMISTIC_PARTICIPANT_ACCOUNT_ID]).toBeUndefined(); + expect(personalDetailsList?.[SETTLED_PARTICIPANT_ACCOUNT_ID]).toBeDefined(); + }); + }); + it('clearCreateChatError should not delete the report with introSelected if it is not optimistic report', () => { const TEST_USER_ACCOUNT_ID = 1; const REPORT: OnyxTypes.Report = {...createRandomReport(1, undefined), errorFields: {createChat: {error: 'error'}}}; @@ -461,7 +504,7 @@ describe('actions/Report', () => { return waitForBatchedUpdates() .then(() => { - Report.clearCreateChatError(REPORT, CONCIERGE_REPORT_ID, INTRO_SELECTED, TEST_USER_ACCOUNT_ID, undefined, false, undefined, undefined, undefined); + Report.clearCreateChatError(REPORT, CONCIERGE_REPORT_ID, INTRO_SELECTED, TEST_USER_ACCOUNT_ID, undefined, false, undefined, undefined, undefined, undefined); return waitForBatchedUpdates(); }) .then( @@ -504,7 +547,7 @@ describe('actions/Report', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${REPORT.reportID}`, {isOptimisticReport: true}); await waitForBatchedUpdates(); - Report.clearCreateChatError(REPORT, undefined, INTRO_SELECTED, TEST_USER_ACCOUNT_ID, betas, false, undefined, undefined, undefined); + Report.clearCreateChatError(REPORT, undefined, INTRO_SELECTED, TEST_USER_ACCOUNT_ID, betas, false, undefined, undefined, undefined, undefined); await waitForBatchedUpdates(); TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); @@ -663,6 +706,7 @@ describe('actions/Report', () => { reportID: REPORT_ID, introSelected: TEST_INTRO_SELECTED, betas: undefined, + personalDetails: undefined, currentUserAccountID: USER_1_ACCOUNT_ID, }); Report.readNewestAction(REPORT_ID, true); @@ -1232,6 +1276,7 @@ describe('actions/Report', () => { newReportObject: { reportID: REPORT_ID, }, + personalDetails: undefined, currentUserAccountID: 1, }); } @@ -1258,6 +1303,7 @@ describe('actions/Report', () => { reportID: REPORT_ID, introSelected: undefined, betas: undefined, + personalDetails: undefined, currentUserAccountID: 1, }); await waitForBatchedUpdates(); @@ -1284,6 +1330,7 @@ describe('actions/Report', () => { newReportObject: { reportID: REPORT_ID, }, + personalDetails: undefined, currentUserAccountID: 1, }); await waitForBatchedUpdates(); @@ -1302,6 +1349,75 @@ describe('actions/Report', () => { await waitForBatchedUpdates(); }); + it('openReport builds the optimistic created action with the owner login from the personal details passed to it', async () => { + const REPORT_ID = 'openReport_createdActionOwner'; + const OWNER_ACCOUNT_ID = 4001; + const OWNER_LOGIN = 'owner@test.com'; + + setHasRadio(false); + await waitForBatchedUpdates(); + + // When a new report is created and the owner is only known by the personal details passed to openReport + Report.openReport({ + conciergeChat: undefined, + hasReportActions: true, + reportID: REPORT_ID, + introSelected: undefined, + betas: undefined, + newReportObject: { + reportID: REPORT_ID, + ownerAccountID: OWNER_ACCOUNT_ID, + }, + personalDetails: {[OWNER_ACCOUNT_ID]: {accountID: OWNER_ACCOUNT_ID, login: OWNER_LOGIN}}, + currentUserAccountID: 1, + }); + await waitForBatchedUpdates(); + + // Then the optimistic created action is attributed to that owner login + const reportActions = (await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const)) as OnyxTypes.ReportActions | undefined; + const createdAction = Object.values(reportActions ?? {}).find((reportAction) => isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CREATED)); + expect(getReportActionMessage(createdAction)?.text).toBe(OWNER_LOGIN); + + setHasRadio(true); + await waitForBatchedUpdates(); + }); + + it('openReport creates optimistic personal details for participants missing from the personal details passed to it', async () => { + const REPORT_ID = 'openReport_optimisticParticipants'; + const PARTICIPANT_ACCOUNT_ID = 4002; + const PARTICIPANT_LOGIN = 'participant@test.com'; + + // Given a participant whose personal details are stored in Onyx + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[PARTICIPANT_ACCOUNT_ID]: {accountID: PARTICIPANT_ACCOUNT_ID, login: PARTICIPANT_LOGIN}}); + await waitForBatchedUpdates(); + + setHasRadio(false); + await waitForBatchedUpdates(); + + // When a new report is created and the personal details passed to openReport don't contain that participant + Report.openReport({ + conciergeChat: undefined, + hasReportActions: true, + reportID: REPORT_ID, + introSelected: undefined, + betas: undefined, + participants: [{login: PARTICIPANT_LOGIN}], + newReportObject: { + reportID: REPORT_ID, + }, + personalDetails: {}, + currentUserAccountID: 1, + }); + await waitForBatchedUpdates(); + + // Then optimistic personal details are built for them, so openReport reads the passed value instead of the Onyx store + const personalDetailsList = await getOnyxValue(ONYXKEYS.PERSONAL_DETAILS_LIST); + expect(personalDetailsList?.[PARTICIPANT_ACCOUNT_ID]?.isOptimisticPersonalDetail).toBe(true); + + setHasRadio(true); + await waitForBatchedUpdates(); + }); + it('markLocalReportActionsAsLoaded settles the initial-load state for an optimistic report', async () => { const REPORT_ID = '96925001'; @@ -1368,6 +1484,9 @@ describe('actions/Report', () => { const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${TXN_ID}` as const); expect(transaction).toBeTruthy(); + // The legacy preview recovery builds the submitter's name and avatar from the personal details passed to openReport, so they have to be threaded here + const personalDetails = await getOnyxValue(ONYXKEYS.PERSONAL_DETAILS_LIST); + Report.openReport({ conciergeChat: undefined, hasReportActions: true, @@ -1376,6 +1495,7 @@ describe('actions/Report', () => { betas: undefined, transaction: transaction ?? undefined, parentReportID: SELF_DM_ID, + personalDetails, currentUserAccountID: TEST_USER_ACCOUNT_ID, }); await waitForBatchedUpdates(); @@ -1397,6 +1517,12 @@ describe('actions/Report', () => { const [parentReportActionID, createdAction] = createdEntry; expect(createdAction.childReportID).toBe(CHILD_REPORT_ID); + // The recovered preview is attributed to the submitter from the personal details passed to openReport + const submitterPersonalDetails = personalDetails?.[TEST_USER_ACCOUNT_ID]; + expect(createdAction.actorAccountID).toBe(TEST_USER_ACCOUNT_ID); + expect(createdAction.person?.at(0)?.text).toBe(submitterPersonalDetails?.displayName); + expect(createdAction.avatar).toBe(submitterPersonalDetails?.avatar); + // Ensure we did not create a stray concatenated key like reportActions_ const wrongKeyValue = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${SELF_DM_ID}${parentReportActionID}` as const); expect(wrongKeyValue).toBeUndefined(); @@ -1432,6 +1558,7 @@ describe('actions/Report', () => { newReportObject: { reportID: REPORT_ID, }, + personalDetails: undefined, currentUserAccountID: 1, }); } @@ -2415,6 +2542,7 @@ describe('actions/Report', () => { reportID: '2', }, parentReportActionID: reportActionID, + personalDetails: undefined, currentUserAccountID: TEST_USER_ACCOUNT_ID, }); @@ -5214,6 +5342,7 @@ describe('actions/Report', () => { reportID: REPORT_ID, introSelected: TEST_INTRO_SELECTED, betas: undefined, + personalDetails: undefined, currentUserAccountID: 1, }); await waitForBatchedUpdates(); @@ -5232,6 +5361,7 @@ describe('actions/Report', () => { reportID: REPORT_ID, introSelected: TEST_INTRO_SELECTED, betas: undefined, + personalDetails: undefined, currentUserAccountID: 1, }); await waitForBatchedUpdates(); @@ -5250,6 +5380,7 @@ describe('actions/Report', () => { reportID: REPORT_ID, introSelected: undefined, betas: undefined, + personalDetails: undefined, currentUserAccountID: 1, }); await waitForBatchedUpdates(); @@ -5269,6 +5400,7 @@ describe('actions/Report', () => { introSelected: undefined, betas: undefined, hasReportActions: true, + personalDetails: undefined, currentUserAccountID: 1, participants: [{login: 'other@test.com', accountID: 2}], }); diff --git a/tests/actions/TransactionTest.ts b/tests/actions/TransactionTest.ts index 6e0bf14a210f..3851ba75b3c3 100644 --- a/tests/actions/TransactionTest.ts +++ b/tests/actions/TransactionTest.ts @@ -697,6 +697,7 @@ describe('actions/Transaction', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); @@ -880,6 +881,7 @@ describe('actions/Transaction', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); @@ -1067,6 +1069,7 @@ describe('actions/Transaction', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); @@ -1263,6 +1266,7 @@ describe('actions/Transaction', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, activePolicy: undefined, }); diff --git a/tests/navigation/enableGlobalReimbursementsDynamicRouteTest.ts b/tests/navigation/enableGlobalReimbursementsDynamicRouteTest.ts new file mode 100644 index 000000000000..1002aa8c0028 --- /dev/null +++ b/tests/navigation/enableGlobalReimbursementsDynamicRouteTest.ts @@ -0,0 +1,89 @@ +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import findAllMatchingDynamicSuffixes from '@libs/Navigation/helpers/dynamicRoutesUtils/findAllMatchingDynamicSuffixes'; +import getPathWithoutDynamicSuffix from '@libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix'; +import isDynamicRouteSuffix from '@libs/Navigation/helpers/dynamicRoutesUtils/isDynamicRouteSuffix'; +import {getEnableGlobalReimbursementsBusinessNavigationRoute, shouldUseDynamicEnableGlobalReimbursementsBase} from '@libs/Navigation/helpers/enableGlobalReimbursementsNavigationUtils'; + +import {DYNAMIC_ROUTES} from '@src/ROUTES'; + +const BUSINESS_PATTERN = DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.path; + +describe('Enable global reimbursements dynamic routes', () => { + const fullPath = 'search/view/6546028296902751/enable-global-reimbursements/business/9053192/registration-number?bankCountry=US&bankCurrency=USD'; + + it('matches business suffix on search path', () => { + const matches = findAllMatchingDynamicSuffixes(fullPath); + const businessMatch = matches.find((m) => m.pattern === BUSINESS_PATTERN); + expect(businessMatch).toBeDefined(); + expect(businessMatch?.actualSuffix).toBe('enable-global-reimbursements/business/9053192/registration-number'); + }); + + it('strips business suffix for back path', () => { + const matches = findAllMatchingDynamicSuffixes(fullPath); + const businessMatch = matches.find((m) => m.pattern === BUSINESS_PATTERN); + expect(businessMatch).toBeDefined(); + if (!businessMatch) { + return; + } + + const backPath = getPathWithoutDynamicSuffix(businessMatch.pathUsedForMatching, businessMatch.actualSuffix, businessMatch.pattern); + expect(backPath).toBe('search/view/6546028296902751'); + }); + + it('strips business suffix when backTo query param is present', () => { + const pathWithBackTo = + 'search/view/6546028296902751/enable-global-reimbursements/business/9053192/registration-number?backTo=%2Fsearch%3Fq%3Dtype%253Aexpense&bankCountry=US&bankCurrency=USD'; + const matches = findAllMatchingDynamicSuffixes(pathWithBackTo); + const businessMatch = matches.find((m) => m.pattern === BUSINESS_PATTERN); + expect(businessMatch).toBeDefined(); + if (!businessMatch) { + return; + } + + const backPath = getPathWithoutDynamicSuffix(businessMatch.pathUsedForMatching, businessMatch.actualSuffix, businessMatch.pattern); + expect(backPath).toBe('search/view/6546028296902751?backTo=%2Fsearch%3Fq%3Dtype%253Aexpense'); + }); + + it('accepts type subpage suffix', () => { + const typeSuffix = 'enable-global-reimbursements/business/9053192/type?bankCountry=US&bankCurrency=USD'; + expect(isDynamicRouteSuffix(typeSuffix.split('?').at(0) ?? '')).toBe(true); + }); + + it('builds next step route from stripped base without duplicating suffix', () => { + const basePath = 'search/view/6546028296902751?backTo=%2Fsearch%3Fq%3Dtype%253Aexpense'; + const typeRoute = createDynamicRoute(DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute('9053192', 'type', undefined, {bankCountry: 'US', bankCurrency: 'USD'}), basePath); + + expect(typeRoute).toBe('search/view/6546028296902751/enable-global-reimbursements/business/9053192/type?backTo=%2Fsearch%3Fq%3Dtype%253Aexpense&bankCountry=US&bankCurrency=USD'); + expect(typeRoute.match(/enable-global-reimbursements/g)?.length).toBe(1); + }); + + it('builds sign route with query params on dynamic base', () => { + const basePath = 'search/view/6546028296902751'; + const signRoute = createDynamicRoute(DYNAMIC_ROUTES.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN.getRoute('9053192', {bankCountry: 'US', bankCurrency: 'USD'}), basePath); + + expect(signRoute).toBe('search/view/6546028296902751/enable-global-reimbursements/sign/9053192?bankCountry=US&bankCurrency=USD'); + }); + + it('uses search base path captured at signal time for pay modal navigation', () => { + const signalPath = 'search/view/6546028296902751?backTo=%2Fsearch%3Fq%3Dtype%253Aexpense'; + const route = getEnableGlobalReimbursementsBusinessNavigationRoute(9053192, 'registration-number', {bankCountry: 'US', bankCurrency: 'USD'}, signalPath); + + expect(route).toBe( + 'search/view/6546028296902751/enable-global-reimbursements/business/9053192/registration-number?backTo=%2Fsearch%3Fq%3Dtype%253Aexpense&bankCountry=US&bankCurrency=USD', + ); + }); + + it('accepts expense report and search root entry screens', () => { + expect(shouldUseDynamicEnableGlobalReimbursementsBase('e/12345')).toBe(true); + expect(shouldUseDynamicEnableGlobalReimbursementsBase('search')).toBe(true); + expect(shouldUseDynamicEnableGlobalReimbursementsBase('r/12345')).toBe(true); + }); + + it('falls back to wallet settings route for unsupported entry screens', () => { + expect(shouldUseDynamicEnableGlobalReimbursementsBase('settings/preferences')).toBe(false); + + const route = getEnableGlobalReimbursementsBusinessNavigationRoute(9053192, 'registration-number', {bankCountry: 'US', bankCurrency: 'USD'}, 'settings/preferences'); + + expect(route).toBe('settings/wallet/9053192/enable-global-reimbursements/business/registration-number?bankCountry=US&bankCurrency=USD'); + }); +}); diff --git a/tests/perf-test/SelectionList.perf-test.tsx b/tests/perf-test/SelectionList.perf-test.tsx index acfdd793a18d..45f2bbb07090 100644 --- a/tests/perf-test/SelectionList.perf-test.tsx +++ b/tests/perf-test/SelectionList.perf-test.tsx @@ -9,7 +9,6 @@ import type {ListItem} from '@components/SelectionList/ListItem/types'; import variables from '@styles/variables'; import type * as NativeNavigation from '@react-navigation/native'; -import type ReactNative from 'react-native'; import React, {useState} from 'react'; import {measureRenders} from 'reassure'; @@ -19,20 +18,6 @@ type SelectionListWrapperProps = { canSelectMultiple?: boolean; }; -// FlashList requires layout events to render items; mock it with FlatList for tests. -jest.mock('@shopify/flash-list', () => { - const RN = jest.requireActual('react-native'); - return { - FlashList: ({data, ...props}: React.ComponentProps) => ( - - ), - }; -}); - jest.mock('@hooks/useLocalize', () => jest.fn(() => ({ translate: jest.fn(), diff --git a/tests/ui/AmountFormTest.tsx b/tests/ui/AmountFormTest.tsx new file mode 100644 index 000000000000..50e7e3d28017 --- /dev/null +++ b/tests/ui/AmountFormTest.tsx @@ -0,0 +1,172 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import AmountForm from '@components/AmountForm'; +import ComposeProviders from '@components/ComposeProviders'; +import {CurrencyListContextProvider} from '@components/CurrencyListContextProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import type {NumberWithSymbolFormRef} from '@components/NumberWithSymbolForm'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import type * as NativeNavigation from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import currencyList from '../unit/currencyList.json'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + })), + useRoute: jest.fn(() => ({key: '', name: '', params: {}})), +})); + +type AmountFormProps = React.ComponentProps; + +function wrapForm(props: AmountFormProps = {}) { + return {}; +} + +function renderForm(props: AmountFormProps = {}) { + return render(wrapForm(props)); +} + +// AmountForm routes `displayAsTextInput` without a currency button to NumericField; other variants stay on the +// legacy NumberWithSymbolForm. These tests pin that routing and the behavior parity of the migrated path. +describe('AmountForm', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.CURRENCY_LIST, currencyList); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await act(async () => { + await Onyx.clear(); + }); + }); + + describe('displayAsTextInput without a currency button (NumericField path)', () => { + it('renders the currency symbol, validates with currency decimals, and reports changes', async () => { + // Given a text-input AmountForm for USD (2 decimal places) + const onInputChange = jest.fn(); + const {unmount} = renderForm({displayAsTextInput: true, value: '10', currency: 'USD', label: 'Amount', onInputChange}); + await waitForBatchedUpdatesWithAct(); + + // Then the value renders with the currency symbol as a prefix and no currency button + expect(screen.getByDisplayValue('10')).toBeOnTheScreen(); + expect(screen.getByText('$')).toBeOnTheScreen(); + expect(screen.queryByText('USD')).toBeNull(); + + // When the user enters a value within the currency precision + fireEvent.changeText(screen.getByDisplayValue('10'), '10.25'); + await waitForBatchedUpdatesWithAct(); + + // Then the change is accepted and reported + expect(onInputChange).toHaveBeenLastCalledWith('10.25'); + expect(screen.getByDisplayValue('10.25')).toBeOnTheScreen(); + + // When the user enters a value exceeding the currency precision + fireEvent.changeText(screen.getByDisplayValue('10.25'), '10.253'); + await waitForBatchedUpdatesWithAct(); + + // Then the change is rejected + expect(onInputChange).toHaveBeenCalledTimes(1); + expect(screen.getByDisplayValue('10.25')).toBeOnTheScreen(); + + unmount(); + + // Given a text-input AmountForm for JPY (0 decimal places) + const onInputChangeJPY = jest.fn(); + renderForm({displayAsTextInput: true, value: '10', currency: 'JPY', label: 'Amount', onInputChange: onInputChangeJPY}); + await waitForBatchedUpdatesWithAct(); + + // When the user enters a decimal value for a zero-decimal currency + fireEvent.changeText(screen.getByDisplayValue('10'), '10.5'); + await waitForBatchedUpdatesWithAct(); + + // Then the change is rejected + expect(onInputChangeJPY).not.toHaveBeenCalled(); + expect(screen.getByDisplayValue('10')).toBeOnTheScreen(); + }); + + it('renders the error text and forwards blur', async () => { + // Given a text-input AmountForm with an error and an onBlur callback + const onBlur = jest.fn(); + renderForm({displayAsTextInput: true, value: '10', errorText: 'Invalid amount', onBlur}); + await waitForBatchedUpdatesWithAct(); + + // Then the error text is displayed + expect(screen.getByText('Invalid amount')).toBeOnTheScreen(); + + // When the input blurs + fireEvent(screen.getByDisplayValue('10'), 'blur'); + + // Then onBlur is forwarded + expect(onBlur).toHaveBeenCalledTimes(1); + }); + + it('exposes the NumberWithSymbolFormRef imperative API through numberFormRef', async () => { + // Given a text-input AmountForm with a numberFormRef + const numberFormRef = React.createRef(); + const onInputChange = jest.fn(); + renderForm({displayAsTextInput: true, value: '10', numberFormRef, onInputChange}); + await waitForBatchedUpdatesWithAct(); + + expect(numberFormRef.current?.getNumber()).toBe('10'); + + // When updateNumber replaces the value imperatively + act(() => { + numberFormRef.current?.updateNumber('25'); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the value updates without notifying onInputChange, matching the legacy form + expect(numberFormRef.current?.getNumber()).toBe('25'); + expect(screen.getByDisplayValue('25')).toBeOnTheScreen(); + expect(onInputChange).not.toHaveBeenCalled(); + }); + }); + + describe('displayAsTextInput with a currency button (legacy path)', () => { + it('keeps rendering the legacy form with the currency button', async () => { + // Given a text-input AmountForm with the trailing currency button enabled + const onCurrencyButtonPress = jest.fn(); + renderForm({displayAsTextInput: true, value: '10', currency: 'USD', shouldShowCurrencyButton: true, onCurrencyButtonPress}); + await waitForBatchedUpdatesWithAct(); + + // Then the legacy currency button renders + const currencyButton = screen.getByText('USD'); + expect(currencyButton).toBeOnTheScreen(); + + // When the currency button is pressed + fireEvent.press(currencyButton); + await waitForBatchedUpdatesWithAct(); + + // Then the currency callback is invoked + expect(onCurrencyButtonPress).toHaveBeenCalledTimes(1); + }); + }); + + describe('default variant without displayAsTextInput (legacy path)', () => { + it('renders the legacy number pad instead of NumericField', async () => { + renderForm({value: '10', currency: 'USD'}); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByDisplayValue('10')).toBeOnTheScreen(); + expect(screen.getByTestId('button_1')).toBeOnTheScreen(); + }); + }); +}); diff --git a/tests/ui/BetaOverridesPageTest.tsx b/tests/ui/BetaOverridesPageTest.tsx new file mode 100644 index 000000000000..eaa96616e673 --- /dev/null +++ b/tests/ui/BetaOverridesPageTest.tsx @@ -0,0 +1,257 @@ +import {fireEvent, render, screen, waitFor, within} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import {navigationRef} from '@libs/Navigation/Navigation'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; + +import BetaOverridesPage from '@pages/settings/Troubleshoot/BetaOverridesPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +let mockIsProduction = false; +jest.mock('@hooks/useEnvironment', () => ({ + __esModule: true, + default: () => ({isProduction: mockIsProduction}), +})); + +// The page also checks the compiled environment, read through a getter so it stays settable per test +let mockConfigEnvironment: string = CONST.ENVIRONMENT.DEV; +jest.mock('@src/CONFIG', () => ({ + __esModule: true, + default: { + ...jest.requireActual<{default: Record}>('@src/CONFIG').default, + get ENVIRONMENT() { + return mockConfigEnvironment; + }, + }, +})); + +const mockSetBetaOverride = jest.fn(); +const mockClearBetaOverride = jest.fn(); +const mockClearBetaOverrides = jest.fn(); +jest.mock('@userActions/User', () => ({ + setBetaOverride: (beta: string, value: boolean): void => { + mockSetBetaOverride(beta, value); + }, + clearBetaOverride: (beta: string): void => { + mockClearBetaOverride(beta); + }, + clearBetaOverrides: (): void => { + mockClearBetaOverrides(); + }, +})); + +const Stack = createPlatformStackNavigator>(); + +function renderBetaOverridesPage() { + return render( + + + + + + + + + , + ); +} + +describe('BetaOverridesPage', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + mockSetBetaOverride.mockClear(); + mockClearBetaOverride.mockClear(); + mockClearBetaOverrides.mockClear(); + }); + + afterEach(() => { + mockIsProduction = false; + mockConfigEnvironment = CONST.ENVIRONMENT.DEV; + }); + + it('renders a switch for every beta except the "all" beta', async () => { + // When The page is opened + renderBetaOverridesPage(); + await waitForBatchedUpdatesWithAct(); + + // Then Every beta is listed except 'all' + expect(screen.getAllByRole(CONST.ROLE.SWITCH).length).toBe(Object.values(CONST.BETAS).length - 1); + expect(screen.queryByLabelText(CONST.BETAS.ALL)).toBeNull(); + }); + + it('stores an override when a beta that is off is toggled on', async () => { + // Given An account without the beta, so its switch starts off + renderBetaOverridesPage(); + await waitForBatchedUpdatesWithAct(); + + // When The switch is toggled + fireEvent.press(screen.getByRole(CONST.ROLE.SWITCH, {name: CONST.BETAS.DEFAULT_ROOMS})); + + // Then The override is stored, since it now differs from the account + await waitFor(() => expect(mockSetBetaOverride).toHaveBeenCalledWith(CONST.BETAS.DEFAULT_ROOMS, true)); + }); + + it('stores an override when a beta that is on is toggled off', async () => { + // Given An account with the beta, so its switch starts on + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.DEFAULT_ROOMS]); + renderBetaOverridesPage(); + await waitForBatchedUpdatesWithAct(); + + // When The switch is toggled + fireEvent.press(screen.getByRole(CONST.ROLE.SWITCH, {name: CONST.BETAS.DEFAULT_ROOMS})); + + // Then The override is stored, since it now differs from the account + await waitFor(() => expect(mockSetBetaOverride).toHaveBeenCalledWith(CONST.BETAS.DEFAULT_ROOMS, false)); + }); + + it('drops the override when a beta is toggled back to the value the account has', async () => { + // Given An account without the beta and an override pinning it on + await Onyx.set(ONYXKEYS.BETA_OVERRIDES, {[CONST.BETAS.DEFAULT_ROOMS]: true}); + renderBetaOverridesPage(); + await waitForBatchedUpdatesWithAct(); + + // When The switch is toggled back + fireEvent.press(screen.getByRole(CONST.ROLE.SWITCH, {name: CONST.BETAS.DEFAULT_ROOMS})); + + // Then The override is dropped rather than stored, so only betas that differ from the account keep one + await waitFor(() => expect(mockClearBetaOverride).toHaveBeenCalledWith(CONST.BETAS.DEFAULT_ROOMS)); + expect(mockSetBetaOverride).not.toHaveBeenCalled(); + }); + + it('stops badging an override the account has caught up with', async () => { + // Given An override forcing a beta on that the account has since been granted + await Onyx.multiSet({ + [ONYXKEYS.BETA_OVERRIDES]: {[CONST.BETAS.DEFAULT_ROOMS]: true}, + [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS], + }); + + // When The page is opened + renderBetaOverridesPage(); + await waitForBatchedUpdatesWithAct(); + + // Then Nothing is badged, since the stored value no longer differs from the account + expect(screen.queryByText('Overridden')).toBeNull(); + }); + + it('keeps badging a beta forced off while the account betas are unknown', async () => { + // Given An override forcing a beta off before the account betas have loaded, which Onyx.clear does not preserve + await Onyx.set(ONYXKEYS.BETA_OVERRIDES, {[CONST.BETAS.DEFAULT_ROOMS]: false}); + + // When The page is opened + renderBetaOverridesPage(); + await waitForBatchedUpdatesWithAct(); + + // Then It stays badged, since unknown account betas must not be read as every beta being off + expect(within(screen.getByTestId(`row-${CONST.BETAS.DEFAULT_ROOMS}`)).getByText('Overridden')).toBeOnTheScreen(); + }); + + it('badges a beta forced off that the account grants', async () => { + // Given An account with the beta and an override forcing it off + await Onyx.multiSet({ + [ONYXKEYS.BETA_OVERRIDES]: {[CONST.BETAS.DEFAULT_ROOMS]: false}, + [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS], + }); + + // When The page is opened + renderBetaOverridesPage(); + await waitForBatchedUpdatesWithAct(); + + // Then It is badged and off, since a false override is load-bearing when the account grants the beta + expect(screen.getByRole(CONST.ROLE.SWITCH, {name: CONST.BETAS.DEFAULT_ROOMS, checked: false})).toBeOnTheScreen(); + expect(within(screen.getByTestId(`row-${CONST.BETAS.DEFAULT_ROOMS}`)).getByText('Overridden')).toBeOnTheScreen(); + }); + + it('drops the override when a beta the account grants is toggled back on', async () => { + // Given An account with the beta and an override forcing it off + await Onyx.multiSet({ + [ONYXKEYS.BETA_OVERRIDES]: {[CONST.BETAS.DEFAULT_ROOMS]: false}, + [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS], + }); + renderBetaOverridesPage(); + await waitForBatchedUpdatesWithAct(); + + // When The switch is toggled back on + fireEvent.press(screen.getByRole(CONST.ROLE.SWITCH, {name: CONST.BETAS.DEFAULT_ROOMS})); + + // Then The override is dropped rather than rewritten to true + await waitFor(() => expect(mockClearBetaOverride).toHaveBeenCalledWith(CONST.BETAS.DEFAULT_ROOMS)); + expect(mockSetBetaOverride).not.toHaveBeenCalled(); + }); + + it('marks only the betas that differ from the account', async () => { + // Given Two stored overrides, one the account has caught up with and one it has not + await Onyx.multiSet({ + [ONYXKEYS.BETA_OVERRIDES]: {[CONST.BETAS.DEFAULT_ROOMS]: true, [CONST.BETAS.ASAP_SUBMIT]: true}, + [ONYXKEYS.BETAS]: [CONST.BETAS.ASAP_SUBMIT], + }); + + // When The page is opened + renderBetaOverridesPage(); + await waitForBatchedUpdatesWithAct(); + + // Then Only the differing beta is badged, so the badge tracks the account rather than the stored key + expect(screen.getAllByText('Overridden').length).toBe(1); + expect(screen.getByLabelText(CONST.BETAS.DEFAULT_ROOMS)).toBeOnTheScreen(); + expect(within(screen.getByTestId(`row-${CONST.BETAS.DEFAULT_ROOMS}`)).getByText('Overridden')).toBeOnTheScreen(); + }); + + it('clears every override when reset is pressed', async () => { + // Given A stored override + await Onyx.set(ONYXKEYS.BETA_OVERRIDES, {[CONST.BETAS.DEFAULT_ROOMS]: true}); + renderBetaOverridesPage(); + await waitForBatchedUpdatesWithAct(); + + // When Reset is pressed + fireEvent.press(screen.getByText('Reset all overrides')); + + // Then Every override is cleared, so each beta follows the backend again + expect(mockClearBetaOverrides).toHaveBeenCalled(); + }); + + it('shows the not found page in production, since the route can still be reached by a deep link', async () => { + // Given A production build, where overrides are ignored anyway + mockIsProduction = true; + mockConfigEnvironment = CONST.ENVIRONMENT.PRODUCTION; + + // When The page is opened, which a deep link still allows even though the row is hidden + renderBetaOverridesPage(); + await waitForBatchedUpdatesWithAct(); + + // Then The not found page is shown, so nobody can pin values that would never apply + expect(screen.queryAllByRole(CONST.ROLE.SWITCH).length).toBe(0); + expect(screen.queryByText('Reset all overrides')).toBeNull(); + }); + + it('renders outside production even before the environment context resolves', async () => { + // Given A staging build whose environment context has not resolved, so it still reports production + mockIsProduction = true; + mockConfigEnvironment = CONST.ENVIRONMENT.STAGING; + + // When The page is opened + renderBetaOverridesPage(); + await waitForBatchedUpdatesWithAct(); + + // Then The betas are listed rather than the not found page, so the page does not flash on open + expect(screen.getAllByRole(CONST.ROLE.SWITCH).length).toBe(Object.values(CONST.BETAS).length - 1); + }); +}); diff --git a/tests/ui/DomainAccessRestrictedPageTest.tsx b/tests/ui/DomainAccessRestrictedPageTest.tsx new file mode 100644 index 000000000000..b2649bb1aad2 --- /dev/null +++ b/tests/ui/DomainAccessRestrictedPageTest.tsx @@ -0,0 +1,155 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import * as API from '@libs/API'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; +import type {WorkspacesDomainModalNavigatorParamList} from '@libs/Navigation/types'; + +import DomainAccessRestrictedPage from '@pages/domain/DomainAccessRestrictedPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import type ReactNative from 'react-native'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@components/RenderHTML', () => { + const ReactMock = jest.requireActual('react'); + const {Text} = jest.requireActual('react-native'); + + return ({html}: {html: string}) => { + const plainText = html.replaceAll(/<[^>]*>/g, ''); + return ReactMock.createElement(Text, null, plainText); + }; +}); + +const DOMAIN_ACCOUNT_ID = 4242; +const CURRENT_USER_ACCOUNT_ID = 1; +const DOMAIN_EMAIL = 'admin@domain.com'; + +const apiWriteSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + +const Stack = createPlatformStackNavigator(); + +function getRequestAdminshipOnyxData() { + const calls = apiWriteSpy.mock.calls.filter(([command]) => command === WRITE_COMMANDS.REQUEST_DOMAIN_ADMINSHIP); + const [, , onyxData] = TestHelper.getRequiredWriteCall(calls, -1); + return onyxData; +} + +function renderDomainAccessRestrictedPage() { + return render( + + + + + + + + + , + ); +} + +describe('DomainAccessRestrictedPage', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, 'en'); + }); + await TestHelper.signInWithTestUser(CURRENT_USER_ACCOUNT_ID); + await act(async () => { + await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID, email: 'test@user.com'}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, {accountID: DOMAIN_ACCOUNT_ID, email: DOMAIN_EMAIL}); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + await waitForBatchedUpdatesWithAct(); + }); + + it('enables the request access button when there is no pending request, and pressing it sends the request without navigating away', async () => { + // Given no pending adminship request exists yet for a real, listed domain + renderDomainAccessRestrictedPage(); + await waitForBatchedUpdatesWithAct(); + + const button = screen.getByRole('button', {name: TestHelper.translateLocal('domain.accessRestricted.requestAdminAccess')}); + expect(button).not.toBeDisabled(); + + // When the user presses the button + fireEvent.press(button); + await waitForBatchedUpdatesWithAct(); + + // Then the request is sent as a non-transient entry, since this page is only reachable for a domain already in the list + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.REQUEST_DOMAIN_ADMINSHIP, {domainAccountID: DOMAIN_ACCOUNT_ID}, expect.anything()); + + const onyxData = getRequestAdminshipOnyxData(); + const pendingActionsKey = `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${DOMAIN_ACCOUNT_ID}` as const; + const optimisticPendingUpdate = TestHelper.getRequiredOnyxUpdate(onyxData, 'optimisticData', pendingActionsKey, Onyx.METHOD.MERGE); + expect(optimisticPendingUpdate.value).toEqual({requestAdminship: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); + }); + + it('rolls back only the requester on failure instead of dropping the whole domain entry', async () => { + // Given a real domain entry the user can see (set in beforeEach) + renderDomainAccessRestrictedPage(); + await waitForBatchedUpdatesWithAct(); + + // When the user presses the button + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('domain.accessRestricted.requestAdminAccess')})); + await waitForBatchedUpdatesWithAct(); + + // Then the failure data is a MERGE that only clears the requester, never a SET that drops the entry + const failureUpdate = TestHelper.getRequiredOnyxUpdate(getRequestAdminshipOnyxData(), 'failureData', `${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, Onyx.METHOD.MERGE); + expect(failureUpdate.value).toEqual({ + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[CURRENT_USER_ACCOUNT_ID]: null}, + }); + }); + + it('disables the button and shows "Request sent" when a request is already pending for the current user', async () => { + // Given a pending adminship request already exists for the current user + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, { + accountID: DOMAIN_ACCOUNT_ID, + email: DOMAIN_EMAIL, + // eslint-disable-next-line @typescript-eslint/naming-convention + domain_adminRequesters: {[CURRENT_USER_ACCOUNT_ID]: 'read'}, + }); + }); + renderDomainAccessRestrictedPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the secondary button is disabled and labelled "Request sent" + const button = screen.getByRole('button', {name: TestHelper.translateLocal('domain.requestSent')}); + expect(button).toBeDisabled(); + expect(screen.queryByRole('button', {name: TestHelper.translateLocal('domain.accessRestricted.requestAdminAccess')})).toBeNull(); + + // And the primary "Verify yourself" button remains available + expect(screen.getByRole('button', {name: TestHelper.translateLocal('domain.accessRestricted.verifyYourself')})).not.toBeDisabled(); + }); +}); diff --git a/tests/ui/DomainGroupPreferredWorkspacePageTest.tsx b/tests/ui/DomainGroupPreferredWorkspacePageTest.tsx new file mode 100644 index 000000000000..45c7786b2074 --- /dev/null +++ b/tests/ui/DomainGroupPreferredWorkspacePageTest.tsx @@ -0,0 +1,311 @@ +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import {navigationRef} from '@libs/Navigation/Navigation'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import DomainGroupCreatePreferredWorkspacePage from '@pages/domain/Groups/DomainGroupCreatePreferredWorkspacePage'; +import DomainGroupPreferredWorkspacePage from '@pages/domain/Groups/DomainGroupPreferredWorkspacePage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {DomainSecurityGroup, Policy} from '@src/types/onyx'; +import type Domain from '@src/types/onyx/Domain'; +import type {SecurityGroupKey} from '@src/types/onyx/Domain'; +import type DomainPendingAction from '@src/types/onyx/DomainPendingActions'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import getOnyxValue from '../utils/getOnyxValue'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@components/RenderHTML', () => () => null); + +const DOMAIN_ACCOUNT_ID = 123456; +const TEST_USER_ACCOUNT_ID = 1; +const GROUP_ID = 'group1'; + +// Makes the signed-in test user an admin of the domain, so DomainNotFoundPageWrapper renders the page +const DOMAIN_ADMIN_ACCESS = { + [`${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}0`]: TEST_USER_ACCOUNT_ID, +}; + +const Stack = createPlatformStackNavigator(); + +/** + * Builds `count` admin workspaces named "Workspace 01".."Workspace NN" whose `created` timestamps run in the + * opposite order to their names, so a name sort and a creation-date sort produce visibly different lists. + */ +function buildAdminPolicies(count: number) { + const policies: Record = {}; + for (let i = 1; i <= count; i++) { + const policyID = `policy${String(i).padStart(2, '0')}`; + policies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] = { + ...createRandomPolicy(i, CONST.POLICY.TYPE.TEAM, `Workspace ${String(i).padStart(2, '0')}`), + id: policyID, + role: CONST.POLICY.ROLE.ADMIN, + created: `2026-01-${String(count - i + 1).padStart(2, '0')} 00:00:00`, + }; + } + return policies; +} + +async function setUpDomainAdminWithPolicies(policyCount: number) { + await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, { + accountID: DOMAIN_ACCOUNT_ID, + email: 'user@test.com', + ...DOMAIN_ADMIN_ACCESS, + }); + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.POLICY, buildAdminPolicies(policyCount)); + }); + await waitForBatchedUpdatesWithAct(); +} + +/** Deletes the given workspaces, which is one of the ways the list can shrink while the page is already open. */ +async function removeWorkspaces(policyIDs: string[]) { + await act(async () => { + for (const policyID of policyIDs) { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, null); + } + }); + await waitForBatchedUpdatesWithAct(); +} + +/** Merges a security group entry for the domain, so the edit page under test finds an existing group. */ +async function setUpSecurityGroup(groupID: string, group: Partial) { + const securityGroupKey: SecurityGroupKey = `${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}${groupID}`; + const domainUpdate: Partial = {}; + domainUpdate[securityGroupKey] = { + enableRestrictedPrimaryLogin: false, + enableRestrictedPolicyCreation: false, + shared: {}, + ...group, + }; + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domainUpdate); + }); + await waitForBatchedUpdatesWithAct(); +} + +/** Marks the security group as pending deletion, which is one of the ways the edit page blocks access. */ +async function setGroupPendingDelete(groupID: string) { + const securityGroupKey: SecurityGroupKey = `${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}${groupID}`; + const pendingActionsUpdate: Partial = {}; + pendingActionsUpdate[securityGroupKey] = { + deleteGroup: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }; + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${DOMAIN_ACCOUNT_ID}`, pendingActionsUpdate); + }); + await waitForBatchedUpdatesWithAct(); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function renderPage(screenName: keyof SettingsNavigatorParamList, component: React.ComponentType, initialParams: Record) { + const result = render( + + + + + + + + + , + ); + return result; +} + +function renderCreatePreferredWorkspacePage() { + return renderPage(SCREENS.DOMAIN.GROUP_CREATE_PREFERRED_WORKSPACE, DomainGroupCreatePreferredWorkspacePage, {domainAccountID: DOMAIN_ACCOUNT_ID}); +} + +function renderEditPreferredWorkspacePage(groupID = GROUP_ID) { + return renderPage(SCREENS.DOMAIN.SECURITY_GROUPS_PREFERRED_WORKSPACE, DomainGroupPreferredWorkspacePage, {domainAccountID: DOMAIN_ACCOUNT_ID, groupID}); +} + +function getRenderedWorkspaceIDs() { + return screen.getAllByTestId(new RegExp(`^${CONST.BASE_LIST_ITEM_TEST_ID}`)).map((item) => String(item.props.testID).replace(CONST.BASE_LIST_ITEM_TEST_ID, '')); +} + +describe('Domain group preferred workspace pages', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + await waitForBatchedUpdatesWithAct(); + }); + + describe('DomainGroupCreatePreferredWorkspacePage', () => { + it('shows a search input and filters the workspaces by name once the list is long enough', async () => { + // Given a domain admin with more workspaces than the standard list item limit + await setUpDomainAdminWithPolicies(CONST.STANDARD_LIST_ITEM_LIMIT + 3); + + // When the preferred workspace selector is opened + renderCreatePreferredWorkspacePage(); + await waitForBatchedUpdatesWithAct(); + + // Then a search input is rendered + const input = screen.getByTestId('selection-list-text-input'); + expect(input).toBeTruthy(); + + // When a workspace name is searched for + fireEvent.changeText(input, 'Workspace 05'); + + // Then only the matching workspace is left in the list + await waitFor(() => { + expect(getRenderedWorkspaceIDs()).toEqual(['policy05']); + }); + + // When a query that matches nothing is searched for + fireEvent.changeText(input, 'nonexistent workspace'); + + // Then the list is empty and the no results message is shown (it is aria-hidden, hence includeHiddenElements) + await waitFor(() => { + expect(screen.getByText('No results found', {includeHiddenElements: true})).toBeTruthy(); + }); + expect(screen.queryByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}policy05`)).toBeNull(); + }); + + it('does not show a search input when the list is short', async () => { + // Given a domain admin with fewer workspaces than the standard list item limit + await setUpDomainAdminWithPolicies(CONST.STANDARD_LIST_ITEM_LIMIT - 1); + + // When the preferred workspace selector is opened + renderCreatePreferredWorkspacePage(); + await waitForBatchedUpdatesWithAct(); + + // Then no search input is rendered, but the workspaces still are + expect(screen.queryByTestId('selection-list-text-input')).toBeNull(); + expect(getRenderedWorkspaceIDs()).toHaveLength(CONST.STANDARD_LIST_ITEM_LIMIT - 1); + }); + + it('clears an active search when the list shrinks below the standard list item limit', async () => { + // Given a domain admin with more workspaces than the standard list item limit + await setUpDomainAdminWithPolicies(CONST.STANDARD_LIST_ITEM_LIMIT + 3); + renderCreatePreferredWorkspacePage(); + await waitForBatchedUpdatesWithAct(); + + // And a search that narrows the list down to a single workspace + fireEvent.changeText(screen.getByTestId('selection-list-text-input'), 'Workspace 05'); + await waitFor(() => { + expect(getRenderedWorkspaceIDs()).toEqual(['policy05']); + }); + + // When enough workspaces are deleted for the search input to be hidden again + await removeWorkspaces(['policy01', 'policy02', 'policy03', 'policy04']); + + // Then the search input is gone, and the query is cleared along with it instead of leaving the list filtered + expect(screen.queryByTestId('selection-list-text-input')).toBeNull(); + await waitFor(() => { + expect(getRenderedWorkspaceIDs()).toHaveLength(CONST.STANDARD_LIST_ITEM_LIMIT - 1); + }); + }); + + it('keeps sorting the workspaces by creation date, in parity with OldDot', async () => { + // Given a domain admin whose workspaces were created in the reverse order of their names + await setUpDomainAdminWithPolicies(3); + + // When the preferred workspace selector is opened + renderCreatePreferredWorkspacePage(); + await waitForBatchedUpdatesWithAct(); + + // Then the workspaces are listed oldest first, not alphabetically + expect(getRenderedWorkspaceIDs()).toEqual(['policy03', 'policy02', 'policy01']); + }); + + it('stores the picked workspace when a row is selected', async () => { + // Given a domain admin with a few workspaces + await setUpDomainAdminWithPolicies(3); + renderCreatePreferredWorkspacePage(); + await waitForBatchedUpdatesWithAct(); + + // When a workspace row is pressed + fireEvent.press(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}policy02`)); + await waitForBatchedUpdatesWithAct(); + + // Then the picked workspace is stored as the preferred one for the group being created + await expect(getOnyxValue(ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID)).resolves.toBe('policy02'); + }); + }); + + describe('DomainGroupPreferredWorkspacePage', () => { + it("preselects the group's current preferred workspace and stores the newly picked one via updateDomainSecurityGroup", async () => { + // Given a domain admin with an existing security group whose preferred workspace is already set + await setUpDomainAdminWithPolicies(3); + await setUpSecurityGroup(GROUP_ID, {restrictedPrimaryPolicyID: 'policy01'}); + + // When the preferred workspace selector is opened for that group + renderEditPreferredWorkspacePage(); + await waitForBatchedUpdatesWithAct(); + + // Then the page renders normally, with the group's current preferred workspace preselected + expect(getRenderedWorkspaceIDs()).toHaveLength(3); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}policy01`).props.accessibilityState).toMatchObject({selected: true}); + + // When a different workspace row is pressed + fireEvent.press(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}policy02`)); + await waitForBatchedUpdatesWithAct(); + + // Then the group's restrictedPrimaryPolicyID is updated in place, instead of a separate draft key + const domain = await getOnyxValue(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`); + expect(domain?.[`${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}${GROUP_ID}`]).toMatchObject({restrictedPrimaryPolicyID: 'policy02'}); + }); + + it('blocks access when the security group does not exist', async () => { + // Given a domain admin, but no security group matching the groupID in the route + await setUpDomainAdminWithPolicies(3); + + // When the preferred workspace selector is opened for that non-existent group + renderEditPreferredWorkspacePage(); + await waitForBatchedUpdatesWithAct(); + + // Then the not found page is shown instead of the workspace selector + expect(screen.getByTestId('NotFoundPage')).toBeTruthy(); + expect(screen.queryByTestId('DomainGroupPreferredWorkspacePage')).toBeNull(); + }); + + it('blocks access when the security group has a pending delete action', async () => { + // Given a domain admin whose security group is in the middle of being deleted + await setUpDomainAdminWithPolicies(3); + await setUpSecurityGroup(GROUP_ID, {restrictedPrimaryPolicyID: 'policy01'}); + await setGroupPendingDelete(GROUP_ID); + + // When the preferred workspace selector is opened for that group + renderEditPreferredWorkspacePage(); + await waitForBatchedUpdatesWithAct(); + + // Then the not found page is shown instead of the workspace selector + expect(screen.getByTestId('NotFoundPage')).toBeTruthy(); + expect(screen.queryByTestId('DomainGroupPreferredWorkspacePage')).toBeNull(); + }); + }); +}); diff --git a/tests/ui/IOURequestStartPageManualTabTest.tsx b/tests/ui/IOURequestStartPageManualTabTest.tsx index 9e92b7d9339f..35adba5dd944 100644 --- a/tests/ui/IOURequestStartPageManualTabTest.tsx +++ b/tests/ui/IOURequestStartPageManualTabTest.tsx @@ -98,17 +98,13 @@ describe('IOURequestStartPage manual tab content', () => { /** The flow the page is started for - this is what decides whether tabs are rendered. */ iouType?: IOUType; - - /** Whether the new manual expense flow beta is on. */ - isNewManualExpenseFlowEnabled?: boolean; }; /** - * Seeds the beta, the manual tab selection and a draft transaction of the given request type, then renders the page. + * Seeds the manual tab selection and a draft transaction of the given request type, then renders the page. */ - async function renderStartPage({iouRequestType, iouType = CONST.IOU.TYPE.SUBMIT, isNewManualExpenseFlowEnabled = true}: RenderStartPageOptions) { + async function renderStartPage({iouRequestType, iouType = CONST.IOU.TYPE.SUBMIT}: RenderStartPageOptions) { await act(async () => { - await Onyx.set(ONYXKEYS.BETAS, isNewManualExpenseFlowEnabled ? [CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW] : []); await Onyx.set(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.IOU_REQUEST_TYPE}`, CONST.TAB_REQUEST.MANUAL); await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { transactionID: TRANSACTION_ID, @@ -208,13 +204,4 @@ describe('IOURequestStartPage manual tab content', () => { expect(screen.getByTestId(AMOUNT_TEST_ID)).toBeOnTheScreen(); expect(screen.queryByTestId(CONFIRMATION_TEST_ID)).not.toBeOnTheScreen(); }); - - it('keeps the amount page as the landing page for the pay flow when the beta is off', async () => { - // Given a pay flow started without the new manual expense flow beta - await renderStartPage({iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, iouType: CONST.IOU.TYPE.PAY, isNewManualExpenseFlowEnabled: false}); - - // Then the legacy amount-first flow is preserved - expect(screen.getByTestId(AMOUNT_TEST_ID)).toBeOnTheScreen(); - expect(screen.queryByTestId(CONFIRMATION_TEST_ID)).not.toBeOnTheScreen(); - }); }); diff --git a/tests/ui/IOURequestStepDistanceRateTest.tsx b/tests/ui/IOURequestStepDistanceRateTest.tsx index 1b12830b6d7c..8ba7817dffea 100644 --- a/tests/ui/IOURequestStepDistanceRateTest.tsx +++ b/tests/ui/IOURequestStepDistanceRateTest.tsx @@ -11,6 +11,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type {Policy, Report, Transaction} from '@src/types/onyx'; +import type * as LegendListModule from '@legendapp/list/react-native'; import type ReactNative from 'react-native'; import React from 'react'; @@ -42,17 +43,18 @@ jest.mock('@hooks/useShowNotFoundPageInIOUStep', () => () => false); // The dynamic step derives its back path from the root navigation state, which isn't set up in this test. jest.mock('@hooks/useDynamicBackPath', () => jest.fn(() => '')); -// Render FlashList as a plain ScrollView that mounts every row, so the test can assert the full data +// Render LegendList as a plain ScrollView that mounts every row, so the test can assert the full data // order instead of only the virtualized window (the real list scrolls to the focused rate on mount). -jest.mock('@shopify/flash-list', () => { +jest.mock('@legendapp/list/react-native', () => { const ReactLocal = jest.requireActual('react'); const RN = jest.requireActual('react-native'); + const LegendListActual = jest.requireActual('@legendapp/list/react-native'); - const FlashList = ReactLocal.forwardRef< + const LegendList = ReactLocal.forwardRef< {scrollToIndex: (params: {index: number}) => void}, Omit, 'children'> & { data?: unknown[]; - renderItem?: (info: {item: unknown; index: number; target: string}) => React.ReactNode; + renderItem?: (info: {item: unknown; index: number}) => React.ReactNode; keyExtractor?: (item: unknown, index: number) => string; ListHeaderComponent?: React.ReactNode; ListFooterComponent?: React.ReactNode; @@ -87,15 +89,13 @@ jest.mock('@shopify/flash-list', () => { RN.ScrollView, scrollViewProps, ListHeaderComponent ?? null, - ...(data ?? []).map((item, index) => - ReactLocal.createElement(ReactLocal.Fragment, {key: keyExtractor?.(item, index) ?? String(index)}, renderItem?.({item, index, target: 'Cell'})), - ), + ...(data ?? []).map((item, index) => ReactLocal.createElement(ReactLocal.Fragment, {key: keyExtractor?.(item, index) ?? String(index)}, renderItem?.({item, index}))), ListFooterComponent ?? null, ); }, ); - return {FlashList}; + return {...LegendListActual, LegendList}; }); const ACCOUNT_ID = 1; diff --git a/tests/ui/MoneyRequestReportPreview.test.tsx b/tests/ui/MoneyRequestReportPreview.test.tsx index 535a63f4d80d..7b2071ac4193 100644 --- a/tests/ui/MoneyRequestReportPreview.test.tsx +++ b/tests/ui/MoneyRequestReportPreview.test.tsx @@ -28,7 +28,7 @@ import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; -import type {Report, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; +import type {Report, ReportAction, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; @@ -558,7 +558,6 @@ describe('MoneyRequestReportPreview', () => { await waitForBatchedUpdatesWithAct(); }; - // Both layouts open the report first and the pressed expense on a short timer; let that timer run. const settleCascade = async () => { await act(async () => { jest.advanceTimersByTime(400); @@ -688,18 +687,12 @@ describe('MoneyRequestReportPreview', () => { expect(navigateSpy).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: mockIOUReport.reportID, backTo: ''})); }); - it('opens the report and then the pressed expense on top of it (after a short delay) on narrow layouts', async () => { - jest.useRealTimers(); + it('opens the report and the pressed expense on top of it in the same tick on narrow layouts', async () => { mockResponsiveLayoutOverride = narrowResponsiveLayout; jest.spyOn(ReportActionUtils, 'getIOUActionForReportID').mockImplementation(buildActionWithThread); await renderAndPopulateCarousel(); await pressSecondTransaction(); - await act(async () => { - await new Promise((resolve) => { - setTimeout(resolve, 350); - }); - }); // Back returns to the report and back again to the chat, matching the wide layout's order. const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(mockIOUReport.reportID, undefined, undefined, ''); @@ -710,17 +703,11 @@ describe('MoneyRequestReportPreview', () => { it('keeps the pressed expense out of the split stack on narrow layouts', async () => { // Deploy blocker #97183: removeScreenByKey only filters the root navigator, so a nested split screen can never be removed. - jest.useRealTimers(); mockResponsiveLayoutOverride = narrowResponsiveLayout; jest.spyOn(ReportActionUtils, 'getIOUActionForReportID').mockImplementation(buildActionWithThread); await renderAndPopulateCarousel(); await pressSecondTransaction(); - await act(async () => { - await new Promise((resolve) => { - setTimeout(resolve, 350); - }); - }); const threadID = `thread_${mockSecondTransactionID}`; const threadAsReportScreen = navigateSpy.mock.calls.map(([route]) => String(route)).filter((route) => route.startsWith(`r/${threadID}`)); @@ -728,25 +715,6 @@ describe('MoneyRequestReportPreview', () => { expect(navigateSpy).toHaveBeenLastCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: threadID, backTo: narrowReportRoute()})); }); - it('does not open the pressed expense if the user leaves the report during the narrow cascade delay', async () => { - jest.useRealTimers(); - mockResponsiveLayoutOverride = narrowResponsiveLayout; - jest.spyOn(ReportActionUtils, 'getIOUActionForReportID').mockImplementation(buildActionWithThread); - jest.spyOn(Navigation, 'isActiveRoute').mockReturnValue(false); - - await renderAndPopulateCarousel(); - await pressSecondTransaction(); - await act(async () => { - await new Promise((resolve) => { - setTimeout(resolve, 350); - }); - }); - - const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(mockIOUReport.reportID, undefined, undefined, ''); - expect(navigateSpy).toHaveBeenCalledWith(reportRoute); - expect(navigateSpy).not.toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: `thread_${mockSecondTransactionID}`, backTo: reportRoute})); - }); - it('fetches the report actions when the thread resolved only from the transaction, so the carousel can resolve siblings', async () => { mockResponsiveLayoutOverride = narrowResponsiveLayout; const openReportSpy = jest.spyOn(ReportActions, 'openReport').mockImplementation(() => {}); @@ -950,6 +918,70 @@ describe('MoneyRequestReportPreview', () => { expect(navigateSpy).not.toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: `thread_${mockSecondTransactionID}`, backTo: ''})); }); + it('resolves the pressed expense through its live IOU action when the first match is one deleted by an offline split revert', async () => { + mockResponsiveLayoutOverride = narrowResponsiveLayout; + mockUseNetwork.mockReturnValue({isOffline: true}); + const deletedAction: ReportAction = { + ...mockAction, + reportActionID: 'deleted', + childReportID: 'dead_thread', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + originalMessage: {...mockAction, IOUTransactionID: mockSecondTransactionID}, + }; + const liveAction: ReportAction = { + ...mockAction, + reportActionID: 'live', + childReportID: `thread_${mockSecondTransactionID}`, + originalMessage: {...mockAction, IOUTransactionID: mockSecondTransactionID}, + }; + const getIOUActionSpy = jest.spyOn(ReportActionUtils, 'getIOUActionForReportID').mockImplementation(buildActionWithThread); + jest.spyOn(ReportActionUtils, 'getAllReportActions').mockReturnValue({deleted: deletedAction, live: liveAction}); + + await renderAndPopulateCarousel(); + getIOUActionSpy.mockImplementation((reportID, transactionID) => (transactionID === mockSecondTransactionID ? deletedAction : buildActionWithThread(reportID, transactionID))); + await pressSecondTransaction(); + await settleCascade(); + + expect(navigateSpy).toHaveBeenLastCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: `thread_${mockSecondTransactionID}`, backTo: narrowReportRoute()})); + expect(navigateSpy).not.toHaveBeenCalledWith(expect.stringContaining('dead_thread')); + }); + + it('opens the parent report instead of the not-found page when every IOU action for the pressed expense was deleted', async () => { + mockResponsiveLayoutOverride = wideResponsiveLayout; + mockUseNetwork.mockReturnValue({isOffline: true}); + const deletedAction: ReportAction = { + ...mockAction, + reportActionID: 'deleted', + childReportID: 'dead_thread', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + originalMessage: {...mockAction, IOUTransactionID: mockSecondTransactionID}, + }; + const getIOUActionSpy = jest.spyOn(ReportActionUtils, 'getIOUActionForReportID').mockImplementation(buildActionWithThread); + jest.spyOn(ReportActionUtils, 'getAllReportActions').mockReturnValue({deleted: deletedAction}); + + await renderAndPopulateCarousel(); + getIOUActionSpy.mockImplementation((reportID, transactionID) => (transactionID === mockSecondTransactionID ? deletedAction : buildActionWithThread(reportID, transactionID))); + await pressSecondTransaction(); + + expect(navigateSpy).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: mockIOUReport.reportID, backTo: ''})); + expect(navigateSpy).not.toHaveBeenCalledWith(expect.stringContaining('dead_thread')); + }); + + it('opens the parent report instead of the not-found page when the pressed expense thread was torn down', async () => { + mockResponsiveLayoutOverride = narrowResponsiveLayout; + mockUseNetwork.mockReturnValue({isOffline: true}); + jest.spyOn(ReportActionUtils, 'getIOUActionForReportID').mockImplementation(buildActionWithThread); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}thread_${mockSecondTransactionID}`, {reportID: null, statusNum: CONST.REPORT.STATUS_NUM.CLOSED}); + await waitForBatchedUpdatesWithAct(); + + await renderAndPopulateCarousel(); + await pressSecondTransaction(); + await settleCascade(); + + expect(navigateSpy).toHaveBeenLastCalledWith(narrowReportRoute()); + expect(navigateSpy).not.toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: `thread_${mockSecondTransactionID}`, backTo: narrowReportRoute()})); + }); + it('seeds the optimistic transaction thread before opening an existing (possibly uncached) expense', async () => { mockResponsiveLayoutOverride = wideResponsiveLayout; const seedSpy = jest.spyOn(ReportActions, 'setOptimisticTransactionThread').mockImplementation(() => {}); diff --git a/tests/ui/NumericFieldInputTest.tsx b/tests/ui/NumericFieldInputTest.tsx new file mode 100644 index 000000000000..8d930e35665b --- /dev/null +++ b/tests/ui/NumericFieldInputTest.tsx @@ -0,0 +1,420 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import NumericField from '@components/NumericField'; +import type {NumericFieldRef, NumericTextInputProps} from '@components/NumericField'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; + +import * as NativeNavigation from '@react-navigation/native'; +import React from 'react'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + })), +})); + +const mockUseIsFocused = jest.mocked(NativeNavigation.useIsFocused); + +type RootProps = { + value?: string; + allowNegative?: boolean; + decimals?: number; + maxLength?: number; + errorText?: string; + onInputChange?: jest.Mock; + ref?: React.Ref; +}; + +function renderTextInput(inputProps: Partial = {}, rootProps: RootProps = {}) { + return render( + + + + + , + ); +} + +const INPUT_TEST_ID = 'number-form-input'; + +// Only an empty external value resets editing state; clearing selection keeps the caret within the cleared text. +describe('NumericField external reset handling', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('clears the value and collapses the selection when the root value resets externally', async () => { + // Given a TextInput with value "1234" and the caret at the end + const {rerender} = renderTextInput({testID: INPUT_TEST_ID}, {value: '1234', decimals: 2}); + await waitForBatchedUpdatesWithAct(); + + const input = screen.getByTestId(INPUT_TEST_ID); + + fireEvent(input, 'selectionChange', { + nativeEvent: {selection: {start: 4, end: 4}}, + }); + await waitForBatchedUpdatesWithAct(); + + expect(input.props.selection).toEqual({start: 4, end: 4}); + + // When the root value resets externally to an empty string + rerender( + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the value clears and the selection collapses to the start + expect(screen.getByDisplayValue('')).toBeOnTheScreen(); + expect(screen.getByTestId(INPUT_TEST_ID).props.selection).toEqual({start: 0, end: 0}); + }); + + it('keeps the displayed value when the root value changes externally to another non-empty value', async () => { + // Given a TextInput with value "1234" + const {rerender} = renderTextInput({testID: INPUT_TEST_ID}, {value: '1234', decimals: 2}); + await waitForBatchedUpdatesWithAct(); + + // When the root value changes externally to "12" + rerender( + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the editing state is preserved, matching NumberWithSymbolForm; external pushes must use updateNumber + expect(screen.getByDisplayValue('1234')).toBeOnTheScreen(); + }); +}); + +describe('NumericField navigation focus selection handling', () => { + afterEach(() => { + jest.clearAllMocks(); + mockUseIsFocused.mockReturnValue(true); + }); + + it('collapses the selection onto the end when focus is regained', async () => { + // Given an input with a partial text selection + const {rerender} = renderTextInput({testID: INPUT_TEST_ID}, {value: '1234'}); + await waitForBatchedUpdatesWithAct(); + + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', { + nativeEvent: {selection: {start: 1, end: 3}}, + }); + await waitForBatchedUpdatesWithAct(); + + expect(input.props.selection).toEqual({start: 1, end: 3}); + + // When the screen loses focus and then regains it + mockUseIsFocused.mockReturnValue(false); + rerender( + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + mockUseIsFocused.mockReturnValue(true); + rerender( + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the selection collapses to the end of the value + expect(screen.getByTestId(INPUT_TEST_ID).props.selection).toEqual({start: 3, end: 3}); + }); +}); + +describe('NumericField.TextInput', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('adds a leading zero when the value begins with a decimal separator', async () => { + const onInputChange = jest.fn(); + + // Given an empty TextInput + renderTextInput({}, {decimals: 2, onInputChange}); + await waitForBatchedUpdatesWithAct(); + + // When the user enters a value starting with a decimal separator + fireEvent.changeText(screen.getByDisplayValue(''), '.5'); + await waitForBatchedUpdatesWithAct(); + + // Then a leading zero is added + expect(onInputChange).toHaveBeenLastCalledWith('0.5'); + expect(screen.getByDisplayValue('0.5')).toBeOnTheScreen(); + }); + + it('normalizes spaces and comma separators before notifying the root', async () => { + const onInputChange = jest.fn(); + + // Given an empty TextInput + renderTextInput({}, {decimals: 2, onInputChange}); + await waitForBatchedUpdatesWithAct(); + + // When the user enters a value with spaces and comma separators + fireEvent.changeText(screen.getByDisplayValue(''), '1 2,5'); + await waitForBatchedUpdatesWithAct(); + + // Then the value is normalized before notifying the root + expect(onInputChange).toHaveBeenLastCalledWith('12.5'); + expect(screen.getByDisplayValue('12.5')).toBeOnTheScreen(); + }); + + it('accepts a pasted value with thousands separators when a period is present', async () => { + const onInputChange = jest.fn(); + + // Given an empty TextInput with two decimal places + renderTextInput({testID: INPUT_TEST_ID}, {value: '', decimals: 2, onInputChange}); + await waitForBatchedUpdatesWithAct(); + + // When a value with thousands separators and a period is pasted + fireEvent.changeText(screen.getByTestId(INPUT_TEST_ID), '1,234.56'); + await waitForBatchedUpdatesWithAct(); + + // Then the commas are stripped as thousands separators + expect(onInputChange).toHaveBeenLastCalledWith('1234.56'); + expect(screen.getByDisplayValue('1234.56')).toBeOnTheScreen(); + }); + + it('rejects values that exceed the configured decimal precision', async () => { + const onInputChange = jest.fn(); + + // Given a TextInput with zero decimal places and value "12" + renderTextInput({}, {value: '12', decimals: 0, onInputChange}); + await waitForBatchedUpdatesWithAct(); + + // When the user enters a value with decimal places + fireEvent.changeText(screen.getByDisplayValue('12'), '12.5'); + await waitForBatchedUpdatesWithAct(); + + // Then the change is rejected and the value stays "12" + expect(onInputChange).not.toHaveBeenCalled(); + expect(screen.getByDisplayValue('12')).toBeOnTheScreen(); + }); + + it('rejects negative values when negative input is disabled', async () => { + const onInputChange = jest.fn(); + + // Given a TextInput with negative input disabled and value "12" + renderTextInput({}, {value: '12', decimals: 2, onInputChange}); + await waitForBatchedUpdatesWithAct(); + + // When the user enters a negative value + fireEvent.changeText(screen.getByDisplayValue('12'), '-12'); + await waitForBatchedUpdatesWithAct(); + + // Then the change is rejected and the value stays "12" + expect(onInputChange).not.toHaveBeenCalled(); + expect(screen.getByDisplayValue('12')).toBeOnTheScreen(); + }); + + it('accepts a signed value when negative input is enabled', async () => { + const onInputChange = jest.fn(); + + // Given a TextInput with negative input enabled and value "12" + renderTextInput({}, {value: '12', allowNegative: true, decimals: 2, onInputChange}); + await waitForBatchedUpdatesWithAct(); + + // When the user enters a signed value + fireEvent.changeText(screen.getByDisplayValue('12'), '-12'); + await waitForBatchedUpdatesWithAct(); + + // Then the canonical signed value is committed inline + expect(onInputChange).toHaveBeenLastCalledWith('-12'); + expect(screen.getByDisplayValue('-12')).toBeOnTheScreen(); + }); + + it('does not forward maxLength to the native input', async () => { + // Given maxLength set for integer validation on the root + renderTextInput({}, {value: '12345678.99', decimals: 2, maxLength: 8}); + await waitForBatchedUpdatesWithAct(); + + // Then maxLength is not forwarded to the native input + expect(screen.getByDisplayValue('12345678.99').props.maxLength).toBeUndefined(); + }); + + it('rejects a value with more integer digits than maxLength allows', async () => { + const onInputChange = jest.fn(); + + // Given a TextInput with maxLength set to 2 and value "12" + renderTextInput({testID: INPUT_TEST_ID}, {value: '12', decimals: 2, maxLength: 2, onInputChange}); + await waitForBatchedUpdatesWithAct(); + + // When the user enters a value longer than maxLength + fireEvent.changeText(screen.getByTestId(INPUT_TEST_ID), '123'); + await waitForBatchedUpdatesWithAct(); + + // Then the change is rejected and the value stays "12" + expect(onInputChange).not.toHaveBeenCalled(); + expect(screen.getByDisplayValue('12')).toBeOnTheScreen(); + }); + + it('accepts a value that fits within maxLength', async () => { + const onInputChange = jest.fn(); + + // Given a TextInput with maxLength set to 2 and value "1" + renderTextInput({testID: INPUT_TEST_ID}, {value: '1', decimals: 2, maxLength: 2, onInputChange}); + await waitForBatchedUpdatesWithAct(); + + // When the user enters a value within maxLength + fireEvent.changeText(screen.getByTestId(INPUT_TEST_ID), '12'); + await waitForBatchedUpdatesWithAct(); + + // Then the change is accepted + expect(onInputChange).toHaveBeenLastCalledWith('12'); + expect(screen.getByDisplayValue('12')).toBeOnTheScreen(); + }); + + it('strips decimals from the value when the decimals prop changes to a lower precision', async () => { + const onInputChange = jest.fn(); + + // Given a TextInput with two decimal places and value "1.25" + const {rerender} = renderTextInput({testID: INPUT_TEST_ID}, {value: '1.25', decimals: 2, onInputChange}); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByDisplayValue('1.25')).toBeOnTheScreen(); + + // When the decimals prop changes to zero + rerender( + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the decimals are stripped and the parent is notified + expect(screen.getByDisplayValue('1')).toBeOnTheScreen(); + expect(onInputChange).toHaveBeenLastCalledWith('1'); + }); + + it('strips decimals at mount when the value is invalid for the decimals prop', async () => { + const onInputChange = jest.fn(); + + // Given a value mounted with more decimal places than the root allows + renderTextInput({testID: INPUT_TEST_ID}, {value: '1.25', decimals: 0, onInputChange}); + await waitForBatchedUpdatesWithAct(); + + // Then the decimals are stripped at mount and the parent is notified, matching NumberWithSymbolForm + expect(screen.getByDisplayValue('1')).toBeOnTheScreen(); + expect(onInputChange).toHaveBeenLastCalledWith('1'); + }); + + it('renders the inline TextInput error and forwards blur and the text-input ref', async () => { + const inputRef = React.createRef(); + const onBlur = jest.fn(); + + // Given a TextInput with an error message, onBlur, and a ref + renderTextInput({prefixCharacter: '$', label: 'Amount', onBlur, ref: inputRef}, {value: '10', errorText: 'Invalid text number'}); + await waitForBatchedUpdatesWithAct(); + + expect(inputRef.current).toBeTruthy(); + expect(screen.getByText('Invalid text number')).toBeOnTheScreen(); + + // When the input blurs + fireEvent(screen.getByDisplayValue('10'), 'blur'); + + // Then onBlur is forwarded from the primitive + expect(onBlur).toHaveBeenCalledTimes(1); + }); + + it('exposes the imperative number API without notifying the root on updateNumber', async () => { + const ref = React.createRef(); + const onInputChange = jest.fn(); + + // Given a TextInput with value "10", zero decimal places, and a ref + renderTextInput({testID: INPUT_TEST_ID}, {value: '10', decimals: 0, ref, onInputChange}); + await waitForBatchedUpdatesWithAct(); + + expect(ref.current?.getNumber()).toBe('10'); + + // When updateNumber is called imperatively with a value that is invalid for decimals: 0 + act(() => { + ref.current?.updateNumber('1.5'); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the value is stored without validation or notifying onInputChange, matching NumberWithSymbolForm + expect(ref.current?.getNumber()).toBe('1.5'); + expect(onInputChange).not.toHaveBeenCalled(); + expect(screen.getByDisplayValue('1.5')).toBeOnTheScreen(); + + // And the caret moves to the end of the new value + expect(screen.getByTestId(INPUT_TEST_ID).props.selection).toEqual({start: 3, end: 3}); + }); + + it('forwards onSubmitEditing and onKeyPress from the primitive props', async () => { + const inputOnSubmitEditing = jest.fn(); + const inputOnKeyPress = jest.fn(); + + // Given a TextInput with submit and key-press callbacks on the primitive + renderTextInput({testID: INPUT_TEST_ID, onSubmitEditing: inputOnSubmitEditing, onKeyPress: inputOnKeyPress}, {value: '10'}); + await waitForBatchedUpdatesWithAct(); + + // When the user submits and presses a key + fireEvent(screen.getByTestId(INPUT_TEST_ID), 'submitEditing', {nativeEvent: {text: '10'}}); + fireEvent(screen.getByTestId(INPUT_TEST_ID), 'keyPress', {nativeEvent: {key: '5'}}); + await waitForBatchedUpdatesWithAct(); + + // Then both primitive callbacks are invoked + expect(inputOnSubmitEditing).toHaveBeenCalledTimes(1); + expect(inputOnKeyPress).toHaveBeenCalledTimes(1); + }); + + it('collapses the selection onto its end when clearSelection is called', async () => { + const ref = React.createRef(); + + // Given a TextInput with a range selection + renderTextInput({testID: INPUT_TEST_ID}, {value: '1234', decimals: 2, ref}); + await waitForBatchedUpdatesWithAct(); + + fireEvent(screen.getByTestId(INPUT_TEST_ID), 'selectionChange', { + nativeEvent: {selection: {start: 1, end: 3}}, + }); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId(INPUT_TEST_ID).props.selection).toEqual({start: 1, end: 3}); + + // When clearSelection is called imperatively + act(() => { + ref.current?.clearSelection(); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the selection collapses onto its end + expect(screen.getByTestId(INPUT_TEST_ID).props.selection).toEqual({start: 3, end: 3}); + }); +}); diff --git a/tests/ui/NumericFieldSelectionGuardTest.tsx b/tests/ui/NumericFieldSelectionGuardTest.tsx new file mode 100644 index 000000000000..c79991c9b0d5 --- /dev/null +++ b/tests/ui/NumericFieldSelectionGuardTest.tsx @@ -0,0 +1,206 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import NumericField from '@components/NumericField'; +import type {NumericFieldRef} from '@components/NumericField'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import type ShouldIgnoreSelectionWhenUpdatedManually from '@libs/shouldIgnoreSelectionWhenUpdatedManually/types'; + +import * as NativeNavigation from '@react-navigation/native'; +import React from 'react'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@libs/shouldIgnoreSelectionWhenUpdatedManually', () => ({ + ...jest.requireActual<{default: ShouldIgnoreSelectionWhenUpdatedManually}>('@libs/shouldIgnoreSelectionWhenUpdatedManually'), + __esModule: true, + default: true, +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + })), +})); + +const INPUT_TEST_ID = 'number-form-text-input'; +const mockUseIsFocused = jest.mocked(NativeNavigation.useIsFocused); + +function getInput() { + return screen.getByTestId(INPUT_TEST_ID); +} + +function renderTextInput(onInputChange: jest.Mock, ref?: React.Ref, value = '12') { + return render( + + + + + , + ); +} + +// Native sets this flag only on native platforms. Mock it here to cover the manual-update guard and late native echoes. +describe('NumericField.TextInput native selection guard', () => { + afterEach(() => { + jest.clearAllMocks(); + mockUseIsFocused.mockReturnValue(true); + }); + + // The same-batch variant of this scenario is covered by the useNumericSelection unit tests. + it('drops the stale selection event even when it arrives after the change has committed', async () => { + const onInputChange = jest.fn(); + + // Given a TextInput with value "12" + renderTextInput(onInputChange); + await waitForBatchedUpdatesWithAct(); + + // When the value changes to "123" + fireEvent.changeText(getInput(), '123'); + await waitForBatchedUpdatesWithAct(); + + expect(onInputChange).toHaveBeenCalledWith('123'); + expect(getInput().props.selection).toEqual({start: 3, end: 3}); + + // When the stale selection event arrives only after the update has committed (async native delivery) + fireEvent(getInput(), 'selectionChange', {nativeEvent: {selection: {start: 0, end: 0}}}); + await waitForBatchedUpdatesWithAct(); + + // Then it is still dropped and the caret stays at the manual position + expect(getInput().props.selection).toEqual({start: 3, end: 3}); + + // When the next selection event arrives (the native echo of the applied update, or a user tap) + fireEvent(getInput(), 'selectionChange', {nativeEvent: {selection: {start: 1, end: 1}}}); + await waitForBatchedUpdatesWithAct(); + + // Then it is applied + expect(getInput().props.selection).toEqual({start: 1, end: 1}); + }); + + it('keeps the caret and arms no guard when normalization resolves an edit back to the current value', async () => { + const onInputChange = jest.fn(); + + // Given a TextInput with value "1.2" and the caret after its first digit + renderTextInput(onInputChange, undefined, '1.2'); + await waitForBatchedUpdatesWithAct(); + + fireEvent(getInput(), 'selectionChange', {nativeEvent: {selection: {start: 1, end: 1}}}); + await waitForBatchedUpdatesWithAct(); + + // When a group separator is typed into a number that already has a decimal separator, + // which normalization strips back to the current value + fireEvent.changeText(getInput(), '1,.2'); + await waitForBatchedUpdatesWithAct(); + + // Then the caret stays where it was, because no character was committed + expect(getInput().props.selection).toEqual({start: 1, end: 1}); + expect(onInputChange).not.toHaveBeenCalled(); + + // And the next selection event is applied, because no guard was armed for an update + // that native never echoes a different position for + fireEvent(getInput(), 'selectionChange', {nativeEvent: {selection: {start: 0, end: 0}}}); + await waitForBatchedUpdatesWithAct(); + expect(getInput().props.selection).toEqual({start: 0, end: 0}); + }); + + it('arms no guard when updateNumber leaves the caret where it already is', async () => { + const onInputChange = jest.fn(); + const ref = React.createRef(); + + // Given a TextInput with value "12" and the caret already at its end + renderTextInput(onInputChange, ref); + await waitForBatchedUpdatesWithAct(); + + expect(getInput().props.selection).toEqual({start: 2, end: 2}); + + // When updateNumber is called with the same value "12", so nothing reaches the input + act(() => { + ref.current?.updateNumber('12'); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the next selection event is applied, because no event is pending to be dropped + fireEvent(getInput(), 'selectionChange', {nativeEvent: {selection: {start: 0, end: 0}}}); + await waitForBatchedUpdatesWithAct(); + expect(getInput().props.selection).toEqual({start: 0, end: 0}); + expect(onInputChange).not.toHaveBeenCalled(); + }); + + it('still drops the stale event when updateNumber moves the caret', async () => { + const onInputChange = jest.fn(); + const ref = React.createRef(); + + // Given a TextInput with value "12" and a ref + renderTextInput(onInputChange, ref); + await waitForBatchedUpdatesWithAct(); + + // When updateNumber is called with a longer value + act(() => { + ref.current?.updateNumber('1234'); + }); + await waitForBatchedUpdatesWithAct(); + + expect(getInput().props.selection).toEqual({start: 4, end: 4}); + + // Then the first selection event afterward is dropped as the stale one for that update + fireEvent(getInput(), 'selectionChange', {nativeEvent: {selection: {start: 0, end: 0}}}); + await waitForBatchedUpdatesWithAct(); + expect(getInput().props.selection).toEqual({start: 4, end: 4}); + + // And the next selection event is applied without onInputChange being called + fireEvent(getInput(), 'selectionChange', {nativeEvent: {selection: {start: 0, end: 0}}}); + await waitForBatchedUpdatesWithAct(); + expect(getInput().props.selection).toEqual({start: 0, end: 0}); + expect(onInputChange).not.toHaveBeenCalled(); + }); + + it('does not swallow a selection change when the value was rejected', async () => { + const onInputChange = jest.fn(); + + // Given a TextInput with value "12" and two decimal places + renderTextInput(onInputChange); + await waitForBatchedUpdatesWithAct(); + + // When the user enters a value that exceeds decimal precision + fireEvent.changeText(getInput(), '1.234'); + await waitForBatchedUpdatesWithAct(); + + expect(onInputChange).not.toHaveBeenCalled(); + + // When a selection change arrives afterward + fireEvent(getInput(), 'selectionChange', {nativeEvent: {selection: {start: 1, end: 1}}}); + await waitForBatchedUpdatesWithAct(); + + // Then the selection is still applied + expect(getInput().props.selection).toEqual({start: 1, end: 1}); + }); + + it('keeps the caret position when forward-delete removes a character', async () => { + const onInputChange = jest.fn(); + + // Given a TextInput with value "123" and the caret before the last two characters + renderTextInput(onInputChange, undefined, '123'); + await waitForBatchedUpdatesWithAct(); + + fireEvent(getInput(), 'selectionChange', {nativeEvent: {selection: {start: 1, end: 1}}}); + await waitForBatchedUpdatesWithAct(); + + // When forward-delete is pressed and the character after the caret is removed + fireEvent(getInput(), 'keyPress', {nativeEvent: {key: 'Delete'}}); + fireEvent.changeText(getInput(), '13'); + await waitForBatchedUpdatesWithAct(); + + // Then the caret stays before the remaining character instead of moving backward + expect(getInput().props.selection).toEqual({start: 1, end: 1}); + }); +}); diff --git a/tests/ui/NumericFieldTest.tsx b/tests/ui/NumericFieldTest.tsx new file mode 100644 index 000000000000..00591865e615 --- /dev/null +++ b/tests/ui/NumericFieldTest.tsx @@ -0,0 +1,224 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import NumericField, {useNumericFieldActions, useNumericFieldState} from '@components/NumericField'; +import type {NumericFieldRef} from '@components/NumericField'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; + +import type * as NativeNavigation from '@react-navigation/native'; + +import React from 'react'; +import {View} from 'react-native'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + })), +})); + +type NumericFieldProps = React.ComponentProps; + +function ContextReadout() { + const {value, allowNegative, errorText} = useNumericFieldState(); + const {setNumber} = useNumericFieldActions(); + + return ( + + {value} + {String(allowNegative)} + {errorText ?? ''} + { + setNumber('7'); + }} + /> + { + setNumber('7'); + setNumber('99'); + }} + /> + + ); +} + +function renderWithProviders(children: React.ReactNode) { + return render({children}); +} + +describe('NumericField', () => { + const onInputChange = jest.fn(); + const renderNumericField = (props: Partial = {}, children: React.ReactNode = ) => + renderWithProviders( + + {children} + , + ); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders children', () => { + // Given a NumericField with a child element + renderNumericField({}, hello); + + // Then the child is rendered + expect(screen.getByTestId('child')).toBeOnTheScreen(); + }); + }); + + describe('NumericFieldContext', () => { + it('provides default state with an empty value and negative input disabled', () => { + // Given a NumericField with no value or mode props + renderNumericField(); + + // Then the context exposes an empty value, negative input disabled, and no error + expect(screen.getByTestId('ctx-value')).toHaveTextContent(''); + expect(screen.getByTestId('ctx-allowNegative')).toHaveTextContent('false'); + expect(screen.getByTestId('ctx-errorText')).toHaveTextContent(''); + }); + + it('propagates value, allowNegative, and errorText from props', () => { + // Given a NumericField with value, allowNegative, and errorText props + renderNumericField({ + value: '12.50', + allowNegative: true, + decimals: 2, + errorText: 'Required', + }); + + // Then the context reflects those props + expect(screen.getByTestId('ctx-value')).toHaveTextContent('12.50'); + expect(screen.getByTestId('ctx-allowNegative')).toHaveTextContent('true'); + expect(screen.getByTestId('ctx-errorText')).toHaveTextContent('Required'); + }); + }); + + describe('external value synchronization', () => { + it('re-initializes the editing state when the value prop resets to an empty string', () => { + // Given a NumericField controlled with value "10" + const {rerender} = renderNumericField({value: '10'}); + + expect(screen.getByTestId('ctx-value')).toHaveTextContent('10'); + + // When the parent rerenders with an empty value + rerender( + + + + + , + ); + + // Then the editing state resets + expect(screen.getByTestId('ctx-value')).toHaveTextContent(''); + }); + + it('ignores an external change to another non-empty value, matching NumberWithSymbolForm', () => { + // Given a NumericField controlled with value "10" + const {rerender} = renderNumericField({value: '10'}); + + expect(screen.getByTestId('ctx-value')).toHaveTextContent('10'); + + // When the parent rerenders with value "20" + rerender( + + + + + , + ); + + // Then the editing state keeps the current value; external pushes must use the imperative ref + expect(screen.getByTestId('ctx-value')).toHaveTextContent('10'); + }); + + it('does not overwrite a local edit when the parent rerenders with the same external value', () => { + // Given a NumericField controlled with value "10" and a local edit to "7" + const {rerender} = renderNumericField({value: '10'}); + + fireEvent.press(screen.getByTestId('ctx-setNumber')); + + // When the parent rerenders with the same external value "10" + rerender( + + + + + , + ); + + // Then the local edit is preserved + expect(screen.getByTestId('ctx-value')).toHaveTextContent('7'); + }); + }); + + describe('value updates', () => { + it('updates context and notifies the parent when setNumber is called', () => { + // Given an uncontrolled NumericField + renderNumericField(); + + // When setNumber is called from a child + fireEvent.press(screen.getByTestId('ctx-setNumber')); + + // Then the context value updates and onInputChange is notified + expect(screen.getByTestId('ctx-value')).toHaveTextContent('7'); + expect(onInputChange).toHaveBeenCalledTimes(1); + expect(onInputChange).toHaveBeenCalledWith('7'); + }); + + it('updates the field without notifying the parent when updateNumber is called through the imperative ref', () => { + // Given an uncontrolled NumericField with an imperative ref + const ref = React.createRef(); + renderNumericField({ref}); + + // When updateNumber is called through the imperative ref + act(() => { + ref.current?.updateNumber('99'); + }); + + // Then the context value updates without calling onInputChange + expect(screen.getByTestId('ctx-value')).toHaveTextContent('99'); + expect(onInputChange).not.toHaveBeenCalled(); + }); + + it('commits the last value when setNumber is called more than once before a render', () => { + // Given a NumericField with value "1" + renderNumericField({value: '1'}); + + // When setNumber is called twice before the next render + fireEvent.press(screen.getByTestId('ctx-setNumbersRapidly')); + + // Then both edits are reported in order and the final context value is "99" + expect(onInputChange).toHaveBeenNthCalledWith(1, '7'); + expect(onInputChange).toHaveBeenNthCalledWith(2, '99'); + expect(screen.getByTestId('ctx-value')).toHaveTextContent('99'); + }); + }); +}); diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index fd23ffdab5bf..15af7615ab80 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -43,6 +43,7 @@ const LIST_CONTENT_SIZE = { width: 300, height: 600, }; +const LIST_END_OFFSET = LIST_CONTENT_SIZE.height - LIST_SIZE.height; const TEN_MINUTES_AGO = subMinutes(new Date(), 10); const REPORT_ID = '1'; @@ -84,7 +85,18 @@ function triggerListLayout(reportID?: string) { persist: () => {}, }); - fireEvent(within(report).getByTestId('report-actions-list'), 'onContentSizeChange', LIST_CONTENT_SIZE.width, LIST_CONTENT_SIZE.height); + fireEvent(within(report).getByTestId('report-actions-list-viewport'), 'onLayout', { + nativeEvent: { + layout: { + x: 0, + y: 0, + ...LIST_SIZE, + }, + }, + }); + + const reportActionsList = within(report).getByTestId('report-actions-list'); + fireEvent(reportActionsList, 'onContentSizeChange', LIST_CONTENT_SIZE.width, LIST_CONTENT_SIZE.height); } function getReportActions(reportID?: string) { @@ -172,26 +184,31 @@ function mockGetOlderActions(messageCount: number) { }, ] : [], - hasOlderActions: comments['1'] != null, + hasOlderActions: !comments['1'], }; }); } -function mockGetNewerActions(messageCount: number) { - fetchMock.mockAPICommand('GetNewerActions', ({reportID, reportActionID}) => ({ - onyxData: - reportID === REPORT_ID - ? [ - { - onyxMethod: 'merge', - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, - // The API also returns the action that was requested with the reportActionID. - value: buildReportComments(messageCount + 1, reportActionID, true), - }, - ] - : [], - hasNewerActions: messageCount > 0, - })); +function mockGetNewerActions(...messageCounts: number[]) { + let callIndex = 0; + fetchMock.mockAPICommand('GetNewerActions', ({reportID, reportActionID}) => { + const messageCount = messageCounts[Math.min(callIndex, messageCounts.length - 1)]; + callIndex += 1; + return { + onyxData: + reportID === REPORT_ID + ? [ + { + onyxMethod: 'merge', + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + // The API also returns the action that was requested with the reportActionID. + value: buildReportComments(messageCount + 1, reportActionID, true), + }, + ] + : [], + hasNewerActions: messageCount > 0, + }; + }); } async function fastSignInWithTestUser() { @@ -315,7 +332,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); // Scrolling here should not trigger a new network request. - scrollToOffset(LIST_CONTENT_SIZE.height); + scrollToOffset(LIST_END_OFFSET); await waitForBatchedUpdatesWithAct(); scrollToOffset(0); await waitForBatchedUpdatesWithAct(); @@ -339,7 +356,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); // Scrolling here should trigger a new network request. - scrollToOffset(LIST_CONTENT_SIZE.height); + scrollToOffset(0); await waitForBatchedUpdatesWithAct(); TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); @@ -359,7 +376,7 @@ describe('Pagination', () => { it('opens a chat and load newer messages', async () => { mockOpenReport(5, '5'); - mockGetNewerActions(5); + mockGetNewerActions(5, 0); await signInAndGetApp(); await navigateToSidebarOption(COMMENT_LINKING_REPORT_ID); @@ -369,34 +386,28 @@ describe('Pagination', () => { await waitFor(() => { jest.requireMock('@react-navigation/native').triggerTransitionEnd(); }); - // Due to https://github.com/facebook/react-native/commit/3485e9ed871886b3e7408f90d623da5c018da493 - // we need to scroll too to trigger `onStartReached` which triggers other updates - scrollToOffset(0); // ReportScreen relies on the onLayout event to receive updates from onyx. triggerListLayout(); + scrollToOffset(LIST_END_OFFSET); + await waitForNetworkPromises(); + await waitForBatchedUpdatesWithAct(); + await waitFor(() => TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2)); await waitForNetworkPromises(); await waitForBatchedUpdatesWithAct(); - // Here we have 5 messages from the initial OpenReport and 5 from the initial GetNewerActions. + // The first newer page advances the cursor while the viewport remains at the boundary, so pagination + // requests the next page and stops when the backend reports that there are no more newer actions. expect(getReportActions()).toHaveLength(10); - - // Simulate the backend returning no new messages to simulate reaching the start of the chat. - mockGetNewerActions(0); - - // There is 1 extra call here because of the comment linking report. - - // Simulate the backend returning no new messages to simulate reaching the start of the chat. - mockGetNewerActions(0); TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 1, {reportID: REPORT_ID, reportActionID: '5'}); TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); TestHelper.expectAPICommandToHaveBeenCalledWith('GetNewerActions', 0, {reportID: REPORT_ID, reportActionID: '5'}); // Simulate the maintainVisibleContentPosition scroll adjustment, so it is now possible to scroll down more. - scrollToOffset(500); - await waitForBatchedUpdatesWithAct(); scrollToOffset(0); await waitForBatchedUpdatesWithAct(); + scrollToOffset(LIST_END_OFFSET); + await waitForBatchedUpdatesWithAct(); // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); @@ -405,10 +416,10 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); - scrollToOffset(500); - await waitForBatchedUpdatesWithAct(); scrollToOffset(0); await waitForBatchedUpdatesWithAct(); + scrollToOffset(LIST_END_OFFSET); + await waitForBatchedUpdatesWithAct(); // When there are no newer actions, we don't want to trigger GetNewerActions again. TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 0b1aac4cf61e..3062803d5ca6 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,4 +1,4 @@ -import {render, screen} from '@testing-library/react-native'; +import {act, fireEvent, render, screen} from '@testing-library/react-native'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; @@ -20,6 +20,7 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import {useConciergeSessionActions, useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import ReportActionsList from '@pages/inbox/report/ReportActionsList'; +import ReportActionsPaginationLoadingIndicator, {PAGINATION_LOADING_INDICATOR_HEIGHT} from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -85,8 +86,27 @@ const mockUseConciergeDraftActions = useConciergeDraftActions as jest.MockedFunc const mockUseConciergeSessionState = useConciergeSessionState as jest.MockedFunction; const mockUseConciergeSessionActions = useConciergeSessionActions as jest.MockedFunction; -function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true) { - return selector === reportActionsListLoadingStateSelector ? {hasOnceLoadedReportActions, isLoadingInitialReportActions: false} : undefined; +function getMockReportLoadingState( + selector: unknown, + hasOnceLoadedReportActions = true, + paginationState: { + isLoadingOlderReportActions?: boolean; + hasLoadingOlderReportActionsError?: boolean; + isLoadingNewerReportActions?: boolean; + hasLoadingNewerReportActionsError?: boolean; + } = {}, +) { + return selector === reportActionsListLoadingStateSelector + ? { + hasOnceLoadedReportActions, + isLoadingInitialReportActions: false, + isLoadingOlderReportActions: false, + hasLoadingOlderReportActionsError: false, + isLoadingNewerReportActions: false, + hasLoadingNewerReportActionsError: false, + ...paginationState, + } + : undefined; } const defaultPaginatedReportActionsResult: ReturnType = { @@ -113,10 +133,14 @@ const defaultSidePanelState: ReturnType = { jest.mock('@hooks/useCopySelectionHelper', () => jest.fn()); jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn()); +const mockLoadOlderChats = jest.fn(); +const mockLoadNewerChats = jest.fn(); jest.mock('@hooks/useLoadReportActions', () => - jest.fn(() => ({ - loadOlderChats: jest.fn(), - loadNewerChats: jest.fn(), + jest.fn(({reportActions}: {reportActions: OnyxTypes.ReportAction[]}) => ({ + loadOlderChats: mockLoadOlderChats, + loadNewerChats: mockLoadNewerChats, + currentReportOldestActionID: reportActions.at(-1)?.reportActionID, + currentReportNewestActionID: reportActions.at(0)?.reportActionID, })), ); jest.mock('@hooks/usePrevious', () => jest.fn()); @@ -124,13 +148,34 @@ jest.mock('@hooks/usePrevious', () => jest.fn()); const mockUseCurrentUserPersonalDetails = useCurrentUserPersonalDetails as jest.MockedFunction; // We mount the public ReportActionsList (the skeleton guard + its content) and observe what the content -// feeds the list via InvertedFlashList's `data`. The heavy scroll/marker hooks have their own unit tests, +// feeds chronological data directly to LegendList. The heavy scroll/marker hooks have their own unit tests, // so they are stubbed here to isolate the skeleton logic. Because the guard only mounts the content when // the skeleton is not showing, these stubs double as a probe for dormancy: while a skeleton renders the // content is never mounted, so useMarkAsRead/useReportActionsScroll are never called. -jest.mock('@components/FlashList/InvertedFlashList', () => jest.fn(() => null)); +const mockLegendListMount = jest.fn(); +const mockLegendListUnmount = jest.fn(); +let mockShouldCallLegendListOnLoad = true; +jest.mock('@legendapp/list/react-native', () => { + const reactModule = jest.requireActual('react'); + return { + LegendList: jest.fn(({onLoad}: {onLoad?: () => void}) => { + reactModule.useEffect(() => { + mockLegendListMount(); + if (mockShouldCallLegendListOnLoad) { + onLoad?.(); + } + return () => { + mockLegendListUnmount(); + }; + }, []); + return null; + }), + }; +}); jest.mock('@hooks/useUnreadMarker', () => jest.fn(() => ({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}))); jest.mock('@hooks/useMarkAsRead', () => jest.fn(() => ({markNewestActionAsRead: jest.fn(), completeSkippedMarkAsRead: jest.fn()}))); +let mockInitialScrollIndex: number | undefined; +let mockInitialScrollIndexParams: {viewOffset?: number; viewPosition?: number} | undefined; jest.mock('@hooks/useReportActionsScroll', () => jest.fn(() => ({ listRef: {current: null}, @@ -140,11 +185,9 @@ jest.mock('@hooks/useReportActionsScroll', () => isActionBadgeAboveViewport: false, scrollToBottomAndMarkReportAsRead: jest.fn(), scrollToActionBadgeTarget: jest.fn(), - flushPendingScrollToBottom: jest.fn(), shouldBeAlignedToTop: false, - shouldFocusToTopOnMount: false, - initialScrollKey: undefined, - shouldAutoscrollToBottom: false, + initialScrollIndex: mockInitialScrollIndex, + initialScrollIndexParams: mockInitialScrollIndexParams, onLoad: jest.fn(), })), ); @@ -156,18 +199,61 @@ jest.mock('@pages/inbox/report/ReportActionsListPaddingView', () => { jest.mock('@pages/inbox/report/UserTypingEventListener', () => jest.fn(() => null)); jest.mock('@pages/inbox/report/ReportActionItemCreated', () => jest.fn(() => null)); -type MockInvertedFlashListProps = { +type MockLegendListProps = { + alignItemsAtEnd?: boolean; data?: OnyxTypes.ReportAction[]; + drawDistance?: number; extraData?: unknown; + getItemType?: (item: OnyxTypes.ReportAction) => string; + initialScrollAtEnd?: boolean; + initialScrollIndex?: {index: number; viewOffset?: number; viewPosition?: number}; + estimatedHeaderSize?: number; + maintainScrollAtEnd?: {animated: boolean} | false; + maintainScrollAtEndThreshold?: number; + maintainVisibleContentPosition?: boolean; + ListHeaderComponent?: React.ReactNode; + ListFooterComponent?: React.ReactNode; + ListFooterComponentStyle?: unknown; + onLoad?: () => void; + onContentSizeChange?: (width: number, height: number) => void; + recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; + onStartReached?: () => void; + onScroll?: (event: { + nativeEvent: { + contentOffset: {x: number; y: number}; + contentSize: {height: number; width: number}; + layoutMeasurement: {height: number; width: number}; + }; + }) => void; }; -const mockInvertedFlashList: jest.MockedFunction<(props: MockInvertedFlashListProps) => null> = jest.requireMock('@components/FlashList/InvertedFlashList'); +type PaginationLoadingIndicatorProps = React.ComponentProps; + +const {LegendList: mockLegendList} = jest.requireMock<{LegendList: jest.MockedFunction<(props: MockLegendListProps) => null>}>('@legendapp/list/react-native'); const mockReportActionItemCreated: jest.Mock = jest.requireMock('@pages/inbox/report/ReportActionItemCreated'); -/** Returns the report actions the body fed into the (mocked) inverted list on its latest render. */ -const getCapturedVisibleActions = (): OnyxTypes.ReportAction[] | undefined => mockInvertedFlashList.mock.calls.at(-1)?.at(0)?.data; -const getCapturedListProps = (): MockInvertedFlashListProps | undefined => mockInvertedFlashList.mock.calls.at(-1)?.at(0); +/** Returns the chronological report actions the body fed into the mocked LegendList on its latest render. */ +const getCapturedVisibleActions = (): OnyxTypes.ReportAction[] | undefined => mockLegendList.mock.calls.at(-1)?.at(0)?.data; +const getCapturedListProps = (): MockLegendListProps | undefined => mockLegendList.mock.calls.at(-1)?.at(0); + +function findPaginationLoadingIndicator(node: React.ReactNode): React.ReactElement | undefined { + if (!React.isValidElement<{children?: React.ReactNode}>(node)) { + return undefined; + } + if (node.type === ReportActionsPaginationLoadingIndicator) { + return node as React.ReactElement; + } + + for (const child of React.Children.toArray(node.props.children)) { + const loadingIndicator = findPaginationLoadingIndicator(child); + if (loadingIndicator) { + return loadingIndicator; + } + } + + return undefined; +} const getRenderedReportActionsListItemProps = (reportAction: OnyxTypes.ReportAction, index = 0): {shouldDisableContextMenuForConciergeDraft?: boolean} => { const renderedItem = getCapturedListProps()?.renderItem?.({item: reportAction, index}); @@ -191,6 +277,11 @@ const getRenderedReportActionsListItemProps = (reportAction: OnyxTypes.ReportAct const mockUseMarkAsRead: jest.Mock = jest.requireMock('@hooks/useMarkAsRead'); const mockUseReportActionsScroll: jest.Mock = jest.requireMock('@hooks/useReportActionsScroll'); const mockMarkOpenReportEnd: jest.Mock = jest.requireMock('@libs/telemetry/markOpenReportEnd'); +let mockHasOnceLoadedReportActions = true; +let mockIsLoadingOlderReportActions = false; +let mockHasLoadingOlderReportActionsError = false; +let mockIsLoadingNewerReportActions = false; +let mockHasLoadingNewerReportActionsError = false; jest.mock('@libs/actions/Report', () => ({ updateLoadingInitialReportAction: jest.fn(), @@ -233,14 +324,34 @@ const mockReportActions: OnyxTypes.ReportAction[] = [ }, ]; +const olderMockReportAction: OnyxTypes.ReportAction = { + reportActionID: '0', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + created: '2022-12-31', + actorAccountID: 125, + message: [{type: 'COMMENT', html: 'Older message', text: 'Older message'}], + originalMessage: {}, + shouldShow: true, + person: [{type: 'TEXT', style: 'strong', text: 'Older User'}], + pendingAction: null, + errors: {}, +}; + const renderReportActionsList = (props: {reportID?: string} = {}) => { const reportID = props.reportID ?? mockReport.reportID; - return render( + const view = render( , ); + const viewport = screen.queryByTestId('report-actions-list-viewport'); + if (viewport) { + fireEvent(viewport, 'onLayout', { + nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 500}}, + }); + } + return view; }; describe('ReportActionsList (body)', () => { @@ -252,6 +363,14 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); + mockHasOnceLoadedReportActions = true; + mockInitialScrollIndex = undefined; + mockInitialScrollIndexParams = undefined; + mockIsLoadingOlderReportActions = false; + mockHasLoadingOlderReportActionsError = false; + mockIsLoadingNewerReportActions = false; + mockHasLoadingNewerReportActionsError = false; + mockShouldCallLegendListOnLoad = true; mockUseIsReportLoadPending.mockReturnValue(false); mockUseCurrentUserPersonalDetails.mockReturnValue({ @@ -314,7 +433,15 @@ describe('ReportActionsList (body)', () => { return [false, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { - return [getMockReportLoadingState(options?.selector), {status: 'loaded'}]; + return [ + getMockReportLoadingState(options?.selector, mockHasOnceLoadedReportActions, { + isLoadingOlderReportActions: mockIsLoadingOlderReportActions, + hasLoadingOlderReportActionsError: mockHasLoadingOlderReportActionsError, + isLoadingNewerReportActions: mockIsLoadingNewerReportActions, + hasLoadingNewerReportActionsError: mockHasLoadingNewerReportActionsError, + }), + {status: 'loaded'}, + ]; } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; @@ -334,6 +461,336 @@ describe('ReportActionsList (body)', () => { await Onyx.clear(); }); + it('delegates end following and size corrections to LegendList without a full-viewport threshold', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); + expect(getCapturedListProps()?.maintainScrollAtEndThreshold).toBe(0.01); + expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); + }); + + it('initially aligns the seed page to the end', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + renderReportActionsList(); + + expect(getCapturedListProps()?.initialScrollAtEnd).toBe(true); + expect(getCapturedListProps()?.alignItemsAtEnd).toBe(true); + expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); + }); + + it('does not follow the end of a page that still has newer actions to load', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasNewerActions: true, + }); + renderReportActionsList(); + + expect(getCapturedListProps()?.maintainScrollAtEnd).toBe(false); + expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); + }); + + it('remounts the list when the initial report actions finish hydrating', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + const view = renderReportActionsList(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + + mockHasOnceLoadedReportActions = true; + // The mocked Onyx hook does not own state, so changing its return value cannot schedule the + // rerender that the real Onyx subscription causes. Change a prop to trigger that render. + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(2); + expect(mockLegendListUnmount).toHaveBeenCalledTimes(1); + expect(getCapturedListProps()?.initialScrollAtEnd).toBe(true); + expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); + }); + + it('keeps the initial viewport covered until the hydrated LegendList finishes rendering it', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockShouldCallLegendListOnLoad = false; + const view = renderReportActionsList(); + + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onLoad?.(); + }); + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + mockHasOnceLoadedReportActions = true; + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onLoad?.(); + }); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + }); + + it('keeps the initial actions visible until the hydrated page is complete', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + const groupingSpy = jest.spyOn(ReportActionsUtils, 'isConsecutiveActionMadeByPreviousActor'); + const view = renderReportActionsList(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [...mockReportActions, olderMockReportAction], + }); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === olderMockReportAction.reportActionID)).toBe(false); + + const snapshotActions = getCapturedVisibleActions(); + const oldestSnapshotAction = snapshotActions?.at(0); + if (!oldestSnapshotAction || !snapshotActions) { + throw new Error('Expected the initial action snapshot to remain visible'); + } + getRenderedReportActionsListItemProps(oldestSnapshotAction); + expect(groupingSpy).toHaveBeenLastCalledWith(snapshotActions.toReversed(), snapshotActions.length - 1, false); + groupingSpy.mockRestore(); + + mockHasOnceLoadedReportActions = true; + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length + 1); + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === olderMockReportAction.reportActionID)).toBe(true); + }); + + it('limits the render buffer and enables item recycling', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + const listProps = getCapturedListProps(); + + expect(listProps?.drawDistance).toBe(1500); + expect(listProps?.recycleItems).toBe(true); + }); + + it('groups comments by layout characteristics for measurement estimates', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + const getItemType = getCapturedListProps()?.getItemType; + const comment = mockReportActions.at(1); + if (!comment) { + throw new Error('Expected comment report action fixture'); + } + + expect(getItemType?.(comment)).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-short`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'medium-comment', + message: [{type: 'COMMENT', html: 'Medium comment', text: 'a'.repeat(200)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-medium`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'long-comment', + message: [{type: 'COMMENT', html: 'Long comment', text: 'a'.repeat(600)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-long`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'extra-long-comment', + message: [{type: 'COMMENT', html: 'Extra long comment', text: 'a'.repeat(1500)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-extra-long`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'attachment', + isAttachmentOnly: true, + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-attachment`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'link-preview', + linkMetadata: [{url: 'https://example.com'}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-link-preview-short`); + }); + + describe('pagination loading indicators', () => { + it('measures the chat viewport before mounting LegendList', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + + render( + , + ); + + expect(mockLegendList).not.toHaveBeenCalled(); + + fireEvent(screen.getByTestId('report-actions-list-viewport'), 'onLayout', { + nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 500}}, + }); + + expect(mockLegendList).toHaveBeenCalled(); + }); + + it('shows padded loading indicators only while requests are active', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + hasNewerActions: true, + }); + const view = renderReportActionsList(); + + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); + expect(getCapturedListProps()?.estimatedHeaderSize).toBe(0); + expect(getCapturedVisibleActions()).toEqual(mockReportActions.toReversed()); + + mockIsLoadingOlderReportActions = true; + mockIsLoadingNewerReportActions = true; + view.rerender( + , + ); + + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)?.props.direction).toBe('older'); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)?.props.direction).toBe('newer'); + expect(getCapturedListProps()?.estimatedHeaderSize).toBe(PAGINATION_LOADING_INDICATOR_HEIGHT); + expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); + + mockHasLoadingOlderReportActionsError = true; + mockHasLoadingNewerReportActionsError = true; + view.rerender( + , + ); + + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); + }); + + it('keeps an unanchored newer window aligned to the end when idle', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasNewerActions: true, + }); + + renderReportActionsList(); + + expect(getCapturedListProps()?.initialScrollAtEnd).toBe(true); + expect(getCapturedListProps()?.initialScrollIndex).toBeUndefined(); + }); + + it('preserves an explicit linked-action index when newer actions are available', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockInitialScrollIndex = 0; + mockInitialScrollIndexParams = {viewPosition: 0.5, viewOffset: 12}; + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasNewerActions: true, + }); + + renderReportActionsList(); + + expect(getCapturedListProps()?.initialScrollIndex).toEqual({index: 0, viewPosition: 0.5, viewOffset: 12}); + }); + + it('removes an exhausted edge indicator and hides loading UI offline', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockIsLoadingOlderReportActions = true; + mockIsLoadingNewerReportActions = true; + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + hasNewerActions: true, + }); + const view = renderReportActionsList(); + + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: false, + hasNewerActions: true, + }); + view.rerender( + , + ); + + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)?.props.direction).toBe('newer'); + + mockUseNetwork.mockReturnValue({isOffline: true}); + view.rerender( + , + ); + + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); + }); + }); + describe('Concierge Draft Context Menu', () => { const conciergeDraftReportAction: OnyxTypes.ReportAction = { reportID: mockReport.reportID, @@ -670,10 +1127,10 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.length).toBeGreaterThanOrEqual(1); - expect(passedActions?.at(0)?.reportActionID).toBe(CONST.CONCIERGE_GREETING_ACTION_ID); + expect(passedActions?.some((action) => action.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); }); it('should not show welcome state when not in side panel', () => { @@ -737,7 +1194,7 @@ describe('ReportActionsList (body)', () => { // Welcome should not be shown since user has sent a message expect(mockReportActionItemCreated).not.toHaveBeenCalled(); // ReportActionsList should be rendered with filtered actions - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); }); }); @@ -830,7 +1287,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); @@ -848,7 +1305,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(true); @@ -881,7 +1338,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); // After user sends a message, the greeting stays visible alongside session actions expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); @@ -899,7 +1356,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); // With no session, old messages should not be shown expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); @@ -945,7 +1402,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); // New user with no prior messages — onboarding messages pass through (no filtering) expect(passedActions?.some((a) => a.reportActionID === 'onboarding-msg')).toBe(true); @@ -968,9 +1425,10 @@ describe('ReportActionsList (body)', () => { expect(mockStartSession).toHaveBeenCalled(); }); - it('should render cached actions without a skeleton on refresh when hasOnceLoadedReportActions resets but actions are cached', () => { + it('should cover cached actions until the refreshed report finishes hydrating', () => { // Simulates a page refresh: hasOnceLoadedReportActions is RAM-only and resets to false, - // but report actions persist in Onyx cache. We should render them immediately (production behavior). + // but report actions persist in Onyx cache. Keep the cached list covered until OpenReport + // finishes so the final hydrated viewport is the first report content the user sees. setupMainDMConciergeMocks(SESSION_START, false, false); mockUsePaginatedReportActions.mockReturnValue({ @@ -981,8 +1439,8 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockLegendList).toHaveBeenCalled(); }); it('should show a skeleton on a cold load when hasOnceLoadedReportActions is false and there are no cached actions', () => { diff --git a/tests/ui/TableSelectionTest.tsx b/tests/ui/TableSelectionTest.tsx index c8348a5f1dd2..69a9e6f3600c 100644 --- a/tests/ui/TableSelectionTest.tsx +++ b/tests/ui/TableSelectionTest.tsx @@ -8,7 +8,7 @@ import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; import type Navigation from '@libs/Navigation/Navigation'; -import type {ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListRenderItemProps} from '@legendapp/list/react-native'; import React from 'react'; import {View} from 'react-native'; @@ -98,7 +98,7 @@ const mockData: TestItem[] = [ const mockColumns: Array> = [{key: 'name', label: 'Name', sortable: false}]; -const renderItem = ({item, index}: ListRenderItemInfo) => ( +const renderItem = ({item, index}: LegendListRenderItemProps) => ( ; type MockViewToken = { item: T; key: string; - index: number | null; + index: number; isViewable: boolean; - timestamp: number; }; type MockViewabilityInfo = { viewableItems: Array>; changed: Array>; + start: number; + end: number; + startBuffered: number; + endBuffered: number; }; -type MockFlashListProps = { +type MockLegendListProps = { data?: T[]; - renderItem?: (info: ListRenderItemInfo) => React.ReactElement | null; + extraData?: unknown; + renderItem?: (info: LegendListModule.LegendListRenderItemProps) => React.ReactElement | null; keyExtractor?: (item: T, index: number) => string; - initialScrollIndex?: number | null; + initialScrollIndex?: number | {index: number; viewOffset?: number; viewPosition?: number} | null; ListHeaderComponent?: React.ComponentType | React.ReactElement | null; ListEmptyComponent?: React.ComponentType | React.ReactElement | null; - ListEmptyComponentStyle?: React.ComponentProps['style']; ListFooterComponent?: React.ComponentType | React.ReactElement | null; ListFooterComponentStyle?: React.ComponentProps['style']; contentContainerStyle?: React.ComponentProps['style']; onEndReached?: () => void; - onChangeStickyIndex?: (current: number, previous: number) => void; onLoad?: (info: {elapsedTimeInMs: number}) => void; onScroll?: (event: {nativeEvent: {contentOffset: {y: number}}}) => void; onStartReached?: () => void; @@ -68,14 +70,13 @@ type MockFlashListProps = { }>; }; -const mockFlashListScrollToIndex = jest.fn(); -const mockFlashListScrollToItem = jest.fn(); -const mockFlashListScrollToOffset = jest.fn(); -const mockFlashListGetLayout = jest.fn(); -const mockFlashListComputeVisibleIndices = jest.fn(); -const mockFlashListGetFirstVisibleIndex = jest.fn(); -const mockFlashListMount = jest.fn(); -const mockFlashListUnmount = jest.fn(); +const mockLegendListScrollToIndex = jest.fn(); +const mockLegendListScrollIndexIntoView = jest.fn(); +const mockLegendListScrollToItem = jest.fn(); +const mockLegendListScrollToOffset = jest.fn(); +const mockLegendListGetState = jest.fn(); +const mockLegendListMount = jest.fn(); +const mockLegendListUnmount = jest.fn(); const mockTextInputFocus = jest.fn(); const mockTextInputBlur = jest.fn(); const mockTextInputMount = jest.fn(); @@ -83,8 +84,7 @@ const mockTextInputUnmount = jest.fn(); const mockTextInputNativeFocus = jest.fn(); const mockTextInputNativeBlur = jest.fn(); let mockNextTextInputInstanceID = 0; -let mockFlashListProps: Array> = []; -let mockFlashListMeasurementTargetIndexes: number[] = []; +let mockLegendListProps: Array> = []; let mockShouldUseNarrowLayout = false; // Mock navigation @@ -170,9 +170,10 @@ jest.mock('@userActions/Session', () => ({ callFunctionIfActionIsAllowed: unknown) | undefined>(callback: TCallback) => callback, })); -jest.mock('@shopify/flash-list', () => { +jest.mock('@legendapp/list/react-native', () => { const ReactLocal = jest.requireActual('react'); const {View: RNView} = jest.requireActual<{View: typeof View}>('react-native'); + const LegendListActual = jest.requireActual('@legendapp/list/react-native'); const renderComponent = (component: React.ComponentType | React.ReactElement | null | undefined) => { if (!component) { @@ -186,92 +187,64 @@ jest.mock('@shopify/flash-list', () => { return ReactLocal.createElement(component); }; - const FlashList = ReactLocal.forwardRef( + const LegendList = ReactLocal.forwardRef( ( - props: MockFlashListProps, + props: MockLegendListProps, ref: React.Ref<{ - scrollToIndex: typeof mockFlashListScrollToIndex; - scrollToItem: typeof mockFlashListScrollToItem; - scrollToOffset: typeof mockFlashListScrollToOffset; - getLayout: typeof mockFlashListGetLayout; - computeVisibleIndices: typeof mockFlashListComputeVisibleIndices; - getFirstVisibleIndex: typeof mockFlashListGetFirstVisibleIndex; + scrollToIndex: typeof mockLegendListScrollToIndex; + scrollIndexIntoView: typeof mockLegendListScrollIndexIntoView; + scrollToItem: typeof mockLegendListScrollToItem; + scrollToOffset: typeof mockLegendListScrollToOffset; + getState: () => unknown; }>, ) => { - mockFlashListProps.push(props); + mockLegendListProps.push(props); const data = props.data ?? []; - const stickyHeaderIndex = props.stickyHeaderIndices?.at(0); - const stickyHeaderItem = stickyHeaderIndex === undefined ? undefined : data.at(stickyHeaderIndex); const emptyComponent = renderComponent(props.ListEmptyComponent); - const renderedEmptyComponent = props.ListEmptyComponentStyle ? {emptyComponent} : emptyComponent; ReactLocal.useEffect(() => { - mockFlashListMount(); + mockLegendListMount(); return () => { - mockFlashListUnmount(); + mockLegendListUnmount(); }; }, []); ReactLocal.useImperativeHandle(ref, () => ({ - scrollToIndex: mockFlashListScrollToIndex, - scrollToItem: mockFlashListScrollToItem, - scrollToOffset: mockFlashListScrollToOffset, - getLayout: mockFlashListGetLayout, - computeVisibleIndices: mockFlashListComputeVisibleIndices, - getFirstVisibleIndex: mockFlashListGetFirstVisibleIndex, + scrollToIndex: mockLegendListScrollToIndex, + scrollIndexIntoView: mockLegendListScrollIndexIntoView, + scrollToItem: mockLegendListScrollToItem, + scrollToOffset: mockLegendListScrollToOffset, + getState: () => { + const state: unknown = mockLegendListGetState(data); + return state; + }, })); return ( - + {renderComponent(props.ListHeaderComponent)} {data.length === 0 - ? renderedEmptyComponent + ? emptyComponent : data.map((item, index) => { const key = props.keyExtractor?.(item, index) ?? String(index); return ( {props.renderItem?.({ + data, + extraData: undefined, item, index, - target: 'Cell', - } as ListRenderItemInfo)} + type: undefined, + })} ); })} - {mockFlashListMeasurementTargetIndexes.map((index) => { - const item = data.at(index); - if (item === undefined) { - return null; - } - - return ( - - {props.renderItem?.({ - item, - index, - target: 'Measurement', - } as ListRenderItemInfo)} - - ); - })} - {stickyHeaderItem !== undefined && stickyHeaderIndex !== undefined && ( - - {props.renderItem?.({ - item: stickyHeaderItem, - index: stickyHeaderIndex, - target: 'StickyHeader', - } as ListRenderItemInfo)} - - )} {!!props.ListFooterComponent && {renderComponent(props.ListFooterComponent)}} ); }, ); - return {FlashList}; + return {...LegendListActual, LegendList}; }); // Mock useLocalize hook @@ -520,7 +493,7 @@ const mockColumns: Array> = [ // Helper function to create default test props function createDefaultProps() { - const renderItem = ({item}: ListRenderItemInfo) => ( + const renderItem = ({item}: LegendListModule.LegendListRenderItemProps) => ( {item.name} {item.category} @@ -560,27 +533,6 @@ function createDefaultProps() { }; } -function activateStickyHeadersAfterListLoad() { - let animationFrameCallback: FrameRequestCallback | undefined; - const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { - animationFrameCallback = callback; - return 1; - }); - - act(() => { - mockFlashListProps.at(-1)?.onLoad?.({elapsedTimeInMs: 1}); - }); - - if (!animationFrameCallback) { - throw new Error('Expected sticky-header activation to be scheduled after FlashList load'); - } - - act(() => { - animationFrameCallback?.(0); - }); - requestAnimationFrameSpy.mockRestore(); -} - function getHostTableRows(): TestInstance[] { return screen.UNSAFE_getAllByProps({role: CONST.ROLE.ROW}).filter((row) => typeof row.type === 'string'); } @@ -594,9 +546,39 @@ function getHostTableRowsWithin(container: TestInstance): TestInstance[] { describe('Table', () => { beforeEach(() => { jest.clearAllMocks(); - mockFlashListProps = []; - mockFlashListMeasurementTargetIndexes = []; + mockLegendListProps = []; mockShouldUseNarrowLayout = false; + mockLegendListGetState.mockImplementation((data: unknown[] = []) => ({ + activeStickyIndex: -1, + contentLength: data.length * 40, + data, + elementAtIndex: (index: number) => `element-${index}`, + end: Math.min(2, data.length - 1), + endBuffered: Math.min(3, data.length - 1), + getAverageItemSizes: () => ({}), + indexByKey: (key: string) => { + const index = data.findIndex((item) => typeof item === 'object' && item !== null && 'keyForList' in item && item.keyForList === key); + return index < 0 ? undefined : index; + }, + isAtEnd: false, + isAtStart: true, + isEndReached: false, + isNearEnd: false, + isNearStart: true, + isStartReached: true, + isWithinMaintainScrollAtEndThreshold: false, + listen: () => () => {}, + listenToPosition: () => () => {}, + positionAtIndex: (index: number) => index * 40, + positionByKey: () => undefined, + scroll: 0, + scrollLength: 400, + scrollVelocity: 0, + sizeAtIndex: () => 40, + sizes: new Map(), + start: 0, + startBuffered: 0, + })); }); describe('rendering', () => { @@ -739,19 +721,15 @@ describe('Table', () => { ); expect(Table.Header.type).toBe('header'); - expect(within(screen.getByTestId('flash-list')).getByTestId('declared-table-header')).toBeTruthy(); + expect(within(screen.getByTestId('legend-list')).getByTestId('declared-table-header')).toBeTruthy(); expect(screen.getAllByTestId('table-header-component')).toHaveLength(1); - expect(mockFlashListProps.at(-1)?.data).toHaveLength(props.data.length + 1); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toBeUndefined(); - - activateStickyHeadersAfterListLoad(); - - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); + expect(mockLegendListProps.at(-1)?.data).toHaveLength(props.data.length + 1); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); }); it('should render ListHeader and keep row indexes aligned with data rows', () => { const props = createDefaultProps(); - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = ({item, index}: LegendListModule.LegendListRenderItemProps) => ( {index} {item.name} @@ -776,13 +754,13 @@ describe('Table', () => { expect(screen.getByTestId('table-header-component')).toBeTruthy(); expect(screen.getAllByLabelText('Name').length).toBeGreaterThan(0); expect(screen.getByTestId('row-index-1').props.children).toBe(0); - expect(mockFlashListProps.at(-1)?.ListHeaderComponent).toBeDefined(); - expect(mockFlashListProps.at(-1)?.data).toHaveLength(props.data.length + 1); + expect(mockLegendListProps.at(-1)?.ListHeaderComponent).toBeDefined(); + expect(mockLegendListProps.at(-1)?.data).toHaveLength(props.data.length + 1); }); it('should compose ListHeaderComponent and ListHeader as the persistent list header', () => { const props = createDefaultProps(); - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = ({item, index}: LegendListModule.LegendListRenderItemProps) => ( {index} {item.name} @@ -808,13 +786,9 @@ describe('Table', () => { expect(screen.getByTestId('table-list-header-component')).toBeTruthy(); expect(screen.getByTestId('table-header-component')).toBeTruthy(); expect(screen.getByTestId('row-index-1').props.children).toBe(0); - expect(mockFlashListProps.at(-1)?.ListHeaderComponent).toBeDefined(); - expect(mockFlashListProps.at(-1)?.data).toHaveLength(props.data.length + 1); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toBeUndefined(); - - activateStickyHeadersAfterListLoad(); - - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); + expect(mockLegendListProps.at(-1)?.ListHeaderComponent).toBeDefined(); + expect(mockLegendListProps.at(-1)?.data).toHaveLength(props.data.length + 1); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); }); it('should use ListHeaderComponent alone as page content for the declared table header', () => { @@ -836,13 +810,9 @@ describe('Table', () => { ); expect(screen.getByTestId('table-list-header-component')).toBeTruthy(); - expect(within(screen.getByTestId('flash-list')).getByTestId('declared-table-header')).toBeTruthy(); - expect(mockFlashListProps.at(-1)?.data).toHaveLength(props.data.length + 1); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toBeUndefined(); - - activateStickyHeadersAfterListLoad(); - - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); + expect(within(screen.getByTestId('legend-list')).getByTestId('declared-table-header')).toBeTruthy(); + expect(mockLegendListProps.at(-1)?.data).toHaveLength(props.data.length + 1); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); const tableHandle = tableRef.current; if (!tableHandle) { throw new Error('Expected table ref to be set'); @@ -853,7 +823,7 @@ describe('Table', () => { scrollToIndex({index: 0, animated: false}); }); - expect(mockFlashListScrollToIndex).toHaveBeenCalledWith({ + expect(mockLegendListScrollToIndex).toHaveBeenCalledWith({ index: 1, animated: false, }); @@ -861,7 +831,7 @@ describe('Table', () => { it('should keep page-header rows in a persistent physical table ancestor', () => { const props = createDefaultProps(); - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = ({item, index}: LegendListModule.LegendListRenderItemProps) => ( { it('should expose only data rows when a page-header table has no active column header', () => { const props = createDefaultProps(); - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = ({item, index}: LegendListModule.LegendListRenderItemProps) => ( { expect(rows.at(0)?.props['aria-rowindex']).toBe(1); }); - it('should expose only the active sticky semantic header', () => { + it('should expose one semantic header while LegendList moves it into the sticky position', () => { const props = createDefaultProps(); render( @@ -972,35 +942,15 @@ describe('Table', () => { , ); - const initialHeaders = getHostTableRows().filter((row) => row.props['aria-rowindex'] === 1 && row.props['aria-hidden'] !== true); - expect(initialHeaders).toHaveLength(1); - - activateStickyHeadersAfterListLoad(); - act(() => { - mockFlashListProps.at(-1)?.onChangeStickyIndex?.(0, -1); - }); - const allHeaders = getHostTableRows().filter((row) => row.props['aria-rowindex'] === 1); const accessibleHeaders = allHeaders.filter((row) => row.props['aria-hidden'] !== true); - const hiddenHeaders = allHeaders.filter((row) => row.props['aria-hidden'] === true); expect(accessibleHeaders).toHaveLength(1); - expect(hiddenHeaders).toHaveLength(1); + expect(allHeaders).toHaveLength(1); - const hiddenHeader = hiddenHeaders.at(0); const accessibleHeader = accessibleHeaders.at(0); - if (!hiddenHeader || !accessibleHeader) { - throw new Error('Expected one hidden and one accessible table header'); + if (!accessibleHeader) { + throw new Error('Expected one accessible table header'); } - expect( - within(hiddenHeader) - .UNSAFE_getAllByProps({accessibilityLabel: 'Name'}) - .some((node) => node.props.disabled === true && node.props.tabIndex === -1), - ).toBe(true); - expect( - within(hiddenHeader) - .UNSAFE_getAllByProps({accessibilityLabel: 'workspace.common.selectAll'}) - .some((node) => node.props.disabled === true && node.props.tabIndex === -1), - ).toBe(true); expect( within(accessibleHeader) .UNSAFE_getAllByProps({accessibilityLabel: 'Name'}) @@ -1011,12 +961,12 @@ describe('Table', () => { .UNSAFE_getAllByProps({accessibilityLabel: 'workspace.common.selectAll'}) .some((node) => node.props.disabled === false && node.props.tabIndex === undefined), ).toBe(true); - expect(screen.getByTestId('flash-list-sticky-header')).toBeTruthy(); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); }); - it('should keep FlashList measurement copies inert without remounting the focused search input', () => { + it('should preserve the focused search input while LegendList rows rerender', () => { const props = createDefaultProps(); - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = ({item, index}: LegendListModule.LegendListRenderItemProps) => ( { ); - // The page controls live in FlashList's persistent header. Only the column header and data rows are - // virtualized, at indexes 0 and 1 respectively. - mockFlashListMeasurementTargetIndexes = [0, 1]; const {rerender} = render(renderTable()); const table = screen.getByLabelText('Members'); - const visibleRows = getHostTableRows().filter((row) => row.props['aria-hidden'] !== true); expect(within(table).getByTestId('table-header-component')).toBeTruthy(); - expect(getHostTableRowsWithin(table).filter((row) => row.props['aria-hidden'] !== true)).toHaveLength(visibleRows.length); - expect(mockFlashListProps.at(-1)?.ListHeaderComponent).toBeDefined(); - expect(mockFlashListProps.at(-1)?.data).toHaveLength(props.data.length + 1); - - const virtualizedHeaderMeasurement = screen.getByTestId('flash-list-measurement-0'); - expect(within(virtualizedHeaderMeasurement).queryByTestId('table-header-component')).toBeNull(); - expect(within(virtualizedHeaderMeasurement).queryByTestId('search-input')).toBeNull(); - expect(within(virtualizedHeaderMeasurement).queryByRole(CONST.ROLE.TABLE)).toBeNull(); - const measurementHeader = getHostTableRowsWithin(screen.getByTestId('flash-list-measurement-0')).at(0); - const measurementDataRow = getHostTableRowsWithin(screen.getByTestId('flash-list-measurement-1')).at(0); - if (!measurementHeader || !measurementDataRow) { - throw new Error('Expected FlashList measurement copies for the header and first data row'); - } - expect(measurementHeader.props['aria-hidden']).toBe(true); - expect(measurementHeader.props.id).toBeUndefined(); - expect(measurementHeader.props.inert).toBe(true); - expect(measurementDataRow.props['aria-hidden']).toBe(true); - expect(measurementDataRow.props.id).toBeUndefined(); - expect(measurementDataRow.props.inert).toBe(true); - expect(measurementDataRow.props.tabIndex).toBe(-1); - expect(measurementDataRow.props.onPress).toBeUndefined(); - expect(within(screen.getByTestId('flash-list-measurement-1')).UNSAFE_queryAllByProps({tabIndex: 0})).toHaveLength(0); - expect( - within(measurementHeader) - .UNSAFE_getAllByProps({accessibilityLabel: 'Name'}) - .some((node) => node.props.disabled === true && node.props.tabIndex === -1), - ).toBe(true); - expect( - within(measurementHeader) - .UNSAFE_getAllByProps({accessibilityLabel: 'workspace.common.selectAll'}) - .some((node) => node.props.disabled === true && node.props.tabIndex === -1), - ).toBe(true); - expect( - within(measurementDataRow) - .UNSAFE_getAllByProps({accessibilityLabel: 'common.select'}) - .some((node) => node.props.disabled === true && node.props.tabIndex === -1), - ).toBe(true); + expect(mockLegendListProps.at(-1)?.ListHeaderComponent).toBeDefined(); + expect(mockLegendListProps.at(-1)?.data).toHaveLength(props.data.length + 1); const searchInput = screen.getByTestId('search-input'); const searchInputNativeID: unknown = searchInput.props.nativeID; fireEvent(searchInput, 'focus'); fireEvent.changeText(searchInput, 'a'); - mockFlashListMeasurementTargetIndexes = []; rerender(renderTable()); expect(screen.getByTestId('search-input').props.nativeID).toBe(searchInputNativeID); expect(screen.getByTestId('search-input').props.value).toBe('a'); - expect(mockFlashListMount).toHaveBeenCalledTimes(1); - expect(mockFlashListUnmount).not.toHaveBeenCalled(); + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); expect(mockTextInputMount).toHaveBeenCalledTimes(1); expect(mockTextInputUnmount).not.toHaveBeenCalled(); expect(mockTextInputNativeBlur).not.toHaveBeenCalled(); @@ -1208,7 +1118,7 @@ describe('Table', () => { expect(mockTextInputFocus).not.toHaveBeenCalled(); }); - it('should defer sticky table header activation until a remounted page-header list loads', () => { + it('should restore the sticky table header when a page-header list remounts', () => { const props = createDefaultProps(); const renderTable = (data: TestItem[]) => ( @@ -1227,38 +1137,15 @@ describe('Table', () => { ); const {rerender} = render(renderTable(props.data)); - activateStickyHeadersAfterListLoad(); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); rerender(renderTable([])); - expect(screen.queryByTestId('flash-list')).toBeNull(); - expect(mockFlashListUnmount).toHaveBeenCalledTimes(1); - - let animationFrameCallback: FrameRequestCallback | undefined; - const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { - animationFrameCallback = callback; - return 1; - }); + expect(screen.queryByTestId('legend-list')).toBeNull(); + expect(mockLegendListUnmount).toHaveBeenCalledTimes(1); rerender(renderTable(props.data)); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toBeUndefined(); - expect(mockFlashListProps.at(-1)?.data).toHaveLength(props.data.length + 1); - expect(animationFrameCallback).toBeUndefined(); - - act(() => { - mockFlashListProps.at(-1)?.onLoad?.({elapsedTimeInMs: 1}); - }); - - if (!animationFrameCallback) { - throw new Error('Expected sticky-header activation to be scheduled after the remounted list loads'); - } - - act(() => { - animationFrameCallback?.(0); - }); - requestAnimationFrameSpy.mockRestore(); - - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); + expect(mockLegendListProps.at(-1)?.data).toHaveLength(props.data.length + 1); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); }); it('should temporarily remove the sticky table header while search has no results', () => { @@ -1284,47 +1171,29 @@ describe('Table', () => { , ); - activateStickyHeadersAfterListLoad(); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); - expect(mockFlashListProps.at(-1)?.data).toHaveLength(props.data.length + 1); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); + expect(mockLegendListProps.at(-1)?.data).toHaveLength(props.data.length + 1); fireEvent.changeText(screen.getByTestId('search-input'), 'xyz123nonexistent'); - expect(screen.getByTestId('flash-list')).toBeTruthy(); + expect(screen.getByTestId('legend-list')).toBeTruthy(); expect(screen.getByTestId('generic-empty-state')).toBeTruthy(); expect(screen.queryByRole(CONST.ROLE.TABLE)).toBeNull(); expect(screen.queryByRole(CONST.ROLE.ROWGROUP)).toBeNull(); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toBeUndefined(); - expect(mockFlashListProps.at(-1)?.data).toHaveLength(0); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toBeUndefined(); + expect(mockLegendListProps.at(-1)?.data).toHaveLength(0); act(() => tableRef.current?.scrollToIndex({index: 0, animated: false})); - expect(mockFlashListScrollToIndex).toHaveBeenLastCalledWith({index: 0, animated: false}); - - let animationFrameCallback: FrameRequestCallback | undefined; - const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { - animationFrameCallback = callback; - return 1; - }); + expect(mockLegendListScrollToIndex).toHaveBeenLastCalledWith({index: 0, animated: false}); fireEvent.changeText(screen.getByTestId('search-input'), ''); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toBeUndefined(); - expect(mockFlashListProps.at(-1)?.data).toHaveLength(props.data.length + 1); - - if (!animationFrameCallback) { - throw new Error('Expected sticky-header activation to be rescheduled when rows return'); - } - - act(() => { - animationFrameCallback?.(0); - }); - requestAnimationFrameSpy.mockRestore(); - - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); + expect(mockLegendListProps.at(-1)?.data).toHaveLength(props.data.length + 1); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); act(() => tableRef.current?.scrollToIndex({index: 0, animated: false})); - expect(mockFlashListScrollToIndex).toHaveBeenLastCalledWith({index: 1, animated: false}); + expect(mockLegendListScrollToIndex).toHaveBeenLastCalledWith({index: 1, animated: false}); }); - it('should defer sticky table header activation again when the list remounts', () => { + it('should restore sticky table header activation when the list remounts', () => { const props = createDefaultProps(); const renderTable = (data: TestItem[]) => ( @@ -1342,17 +1211,13 @@ describe('Table', () => { ); const {rerender} = render(renderTable(props.data)); - activateStickyHeadersAfterListLoad(); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); rerender(renderTable([])); - expect(screen.queryByTestId('flash-list')).toBeNull(); + expect(screen.queryByTestId('legend-list')).toBeNull(); rerender(renderTable(props.data)); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toBeUndefined(); - - activateStickyHeadersAfterListLoad(); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); }); it('should preserve scrollToIndex when rows return after a page-header empty state', () => { @@ -1376,11 +1241,11 @@ describe('Table', () => { ); const {rerender} = render(renderTable([])); - expect(screen.queryByTestId('flash-list')).toBeNull(); + expect(screen.queryByTestId('legend-list')).toBeNull(); expect(screen.getByTestId('table-empty-state-scroll-view')).toBeTruthy(); rerender(renderTable(props.data)); - expect(screen.getByTestId('flash-list')).toBeTruthy(); + expect(screen.getByTestId('legend-list')).toBeTruthy(); const scrollToIndex = tableRef.current?.scrollToIndex; if (!scrollToIndex) { throw new Error('Expected table ref methods to be restored after rows return'); @@ -1390,13 +1255,13 @@ describe('Table', () => { scrollToIndex({index: 0, animated: false}); }); - expect(mockFlashListScrollToIndex).toHaveBeenCalledWith({ + expect(mockLegendListScrollToIndex).toHaveBeenCalledWith({ index: 1, animated: false, }); }); - it('should defer sticky table header activation when the declared header returns', () => { + it('should restore sticky table header activation when the declared header returns', () => { const props = createDefaultProps(); const renderTable = (shouldShowTableHeader: boolean) => ( @@ -1414,31 +1279,13 @@ describe('Table', () => { ); const {rerender} = render(renderTable(true)); - activateStickyHeadersAfterListLoad(); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); rerender(renderTable(false)); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toBeUndefined(); - - let animationFrameCallback: FrameRequestCallback | undefined; - const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { - animationFrameCallback = callback; - return 1; - }); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toBeUndefined(); rerender(renderTable(true)); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toBeUndefined(); - - if (!animationFrameCallback) { - throw new Error('Expected sticky-header activation to be rescheduled when sticky mode turns back on'); - } - - act(() => { - animationFrameCallback?.(0); - }); - requestAnimationFrameSpy.mockRestore(); - - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toEqual([0]); }); it('should keep the declared Table.Header inline without a page header', () => { @@ -1459,9 +1306,9 @@ describe('Table', () => { ); expect(screen.getByTestId('declared-table-header')).toBeTruthy(); - expect(within(screen.getByTestId('flash-list')).queryByTestId('declared-table-header')).toBeNull(); - expect(mockFlashListProps.at(-1)?.stickyHeaderIndices).toBeUndefined(); - expect(mockFlashListProps.at(-1)?.data).toHaveLength(props.data.length); + expect(within(screen.getByTestId('legend-list')).queryByTestId('declared-table-header')).toBeNull(); + expect(mockLegendListProps.at(-1)?.stickyHeaderIndices).toBeUndefined(); + expect(mockLegendListProps.at(-1)?.data).toHaveLength(props.data.length); const tableHandle = tableRef.current; if (!tableHandle) { throw new Error('Expected table ref to be set'); @@ -1472,7 +1319,7 @@ describe('Table', () => { scrollToIndex({index: 0, animated: false}); }); - expect(mockFlashListScrollToIndex).toHaveBeenCalledWith({ + expect(mockLegendListScrollToIndex).toHaveBeenCalledWith({ index: 0, animated: false, }); @@ -1507,14 +1354,15 @@ describe('Table', () => { scrollToIndex({index: 0, animated: false}); }); - expect(mockFlashListScrollToIndex).toHaveBeenCalledWith({ + expect(mockLegendListScrollToIndex).toHaveBeenCalledWith({ index: 1, animated: false, }); }); - it('should translate index-bearing FlashList props around the synthetic table-header row', () => { + it('should translate index-bearing LegendList props around the synthetic table-header row', () => { const props = createDefaultProps(); + const renderItem = jest.fn(props.renderItem); const onViewableItemsChanged = jest.fn(); const pairedOnViewableItemsChanged = jest.fn(); const overrideItemLayout = jest.fn(); @@ -1523,9 +1371,10 @@ describe('Table', () => { data={props.data} columns={props.columns} - renderItem={props.renderItem} + renderItem={renderItem} keyExtractor={props.keyExtractor} initialScrollIndex={2} + extraData="extra" onViewableItemsChanged={onViewableItemsChanged} overrideItemLayout={overrideItemLayout} viewabilityConfigCallbackPairs={[ @@ -1543,22 +1392,27 @@ describe('Table', () => { , ); - const flashListProps = mockFlashListProps.at(-1); - const syntheticHeader = flashListProps?.data?.at(0); - const firstDataRow = flashListProps?.data?.at(1); - if (!flashListProps || !syntheticHeader || !firstDataRow) { - throw new Error('Expected synthetic and data rows to be supplied to FlashList'); + const legendListProps = mockLegendListProps.at(-1); + const syntheticHeader = legendListProps?.data?.at(0); + const firstDataRow = legendListProps?.data?.at(1); + if (!legendListProps || !syntheticHeader || !firstDataRow) { + throw new Error('Expected synthetic and data rows to be supplied to LegendList'); } - expect(flashListProps.initialScrollIndex).toBe(3); + expect(legendListProps.initialScrollIndex).toBe(3); + expect(renderItem).toHaveBeenCalledWith(expect.objectContaining({extraData: 'extra', index: 0})); const layout = {}; - flashListProps.overrideItemLayout?.(layout, syntheticHeader, 0, 1); + legendListProps.overrideItemLayout?.(layout, syntheticHeader, 0, 1); expect(overrideItemLayout).not.toHaveBeenCalled(); - flashListProps.overrideItemLayout?.(layout, firstDataRow, 1, 1, 'extra'); + legendListProps.overrideItemLayout?.(layout, firstDataRow, 1, 1, legendListProps.extraData); expect(overrideItemLayout).toHaveBeenCalledWith(layout, firstDataRow, 0, 1, 'extra'); const viewabilityInfo = { + start: 0, + end: 1, + startBuffered: 0, + endBuffered: 2, viewableItems: [ { item: syntheticHeader, @@ -1586,6 +1440,10 @@ describe('Table', () => { ], }; const expectedViewabilityInfo = { + start: 0, + end: 0, + startBuffered: 0, + endBuffered: 1, viewableItems: [ { item: firstDataRow, @@ -1606,24 +1464,19 @@ describe('Table', () => { ], }; - flashListProps.onViewableItemsChanged?.(viewabilityInfo); - flashListProps.viewabilityConfigCallbackPairs?.at(0)?.onViewableItemsChanged?.(viewabilityInfo); + legendListProps.onViewableItemsChanged?.(viewabilityInfo); + legendListProps.viewabilityConfigCallbackPairs?.at(0)?.onViewableItemsChanged?.(viewabilityInfo); expect(onViewableItemsChanged).toHaveBeenCalledWith(expectedViewabilityInfo); expect(pairedOnViewableItemsChanged).toHaveBeenCalledWith(expectedViewabilityInfo); }); - it('should translate index-bearing FlashList ref methods and preserve the scroll promise', () => { + it('should translate index-bearing LegendList ref methods and state around the synthetic header', () => { const props = createDefaultProps(); const tableRef = React.createRef>(); const scrollPromise = Promise.resolve(); - const rowLayout = {x: 0, y: 100, width: 100, height: 40}; - mockFlashListScrollToIndex.mockReturnValueOnce(scrollPromise); - mockFlashListGetLayout.mockReturnValueOnce(rowLayout); - mockFlashListComputeVisibleIndices.mockReturnValue({ - startIndex: 0, - endIndex: 2, - }); + mockLegendListScrollToIndex.mockReturnValueOnce(scrollPromise); + mockLegendListScrollIndexIntoView.mockReturnValueOnce(scrollPromise); render( @@ -1642,27 +1495,26 @@ describe('Table', () => { ); expect(tableRef.current?.scrollToIndex({index: 0, animated: false})).toBe(scrollPromise); - expect(mockFlashListScrollToIndex).toHaveBeenCalledWith({ + expect(mockLegendListScrollToIndex).toHaveBeenCalledWith({ index: 1, animated: false, }); - expect(tableRef.current?.getLayout(0)).toBe(rowLayout); - expect(mockFlashListGetLayout).toHaveBeenCalledWith(1); - expect(tableRef.current?.computeVisibleIndices()).toEqual({ - startIndex: 0, - endIndex: 1, + expect(tableRef.current?.scrollIndexIntoView({index: 0, animated: false})).toBe(scrollPromise); + expect(mockLegendListScrollIndexIntoView).toHaveBeenCalledWith({ + index: 1, + animated: false, }); - expect(tableRef.current?.getFirstVisibleIndex()).toBe(0); - mockFlashListComputeVisibleIndices.mockReturnValue({ - startIndex: 0, - endIndex: 0, - }); - expect(tableRef.current?.computeVisibleIndices()).toEqual({ - startIndex: -1, - endIndex: -2, - }); - expect(tableRef.current?.getFirstVisibleIndex()).toBe(-1); + const state = tableRef.current?.getState(); + expect(state?.data).toEqual(props.data.map((item) => ({...item, selected: false}))); + expect(state?.start).toBe(0); + expect(state?.end).toBe(1); + expect(state?.startBuffered).toBe(0); + expect(state?.endBuffered).toBe(2); + expect(state?.elementAtIndex(0)).toBe('element-1'); + expect(state?.positionAtIndex(0)).toBe(40); + expect(state?.sizeAtIndex(0)).toBe(40); + expect(state?.indexByKey('1')).toBe(0); }); it('should forward scrollToIndex without offset when no synthetic rows are present', () => { @@ -1690,7 +1542,7 @@ describe('Table', () => { scrollToIndex({index: 0, animated: false}); }); - expect(mockFlashListScrollToIndex).toHaveBeenCalledWith({ + expect(mockLegendListScrollToIndex).toHaveBeenCalledWith({ index: 0, animated: false, }); @@ -1718,11 +1570,11 @@ describe('Table', () => { expect(screen.getByTestId('table-header-component')).toBeTruthy(); expect(screen.getByTestId('empty-state')).toBeTruthy(); expect(screen.getByTestId('table-empty-state-scroll-view')).toBeTruthy(); - expect(screen.queryByTestId('flash-list')).toBeNull(); - expect(mockFlashListProps).toHaveLength(0); + expect(screen.queryByTestId('legend-list')).toBeNull(); + expect(mockLegendListProps).toHaveLength(0); }); - it('should render ListEmptyComponent without mounting FlashList when the declared header renders null', () => { + it('should render ListEmptyComponent without mounting LegendList when the declared header renders null', () => { const props = createDefaultProps(); const EmptyState = No items found; @@ -1740,8 +1592,8 @@ describe('Table', () => { ); expect(screen.getAllByTestId('empty-state')).toHaveLength(1); - expect(screen.queryByTestId('flash-list')).toBeNull(); - expect(mockFlashListProps).toHaveLength(0); + expect(screen.queryByTestId('legend-list')).toBeNull(); + expect(mockLegendListProps).toHaveLength(0); }); it('should render Table.EmptyState below a page header in the centered standalone layout', () => { @@ -1766,8 +1618,8 @@ describe('Table', () => { expect(screen.getAllByTestId('generic-empty-state')).toHaveLength(1); expect(screen.getByTestId('table-header-component')).toBeTruthy(); expect(screen.getByTestId('table-empty-state-scroll-view')).toBeTruthy(); - expect(screen.queryByTestId('flash-list')).toBeNull(); - expect(mockFlashListProps).toHaveLength(0); + expect(screen.queryByTestId('legend-list')).toBeNull(); + expect(mockLegendListProps).toHaveLength(0); }); it('should center a truly empty table when its composed FilterBar renders null', () => { @@ -1787,7 +1639,7 @@ describe('Table', () => { ); expect(screen.queryByTestId('search-input')).toBeNull(); - expect(screen.queryByTestId('flash-list')).toBeNull(); + expect(screen.queryByTestId('legend-list')).toBeNull(); expect(screen.getByTestId('table-empty-state-scroll-view')).toBeTruthy(); const emptyStateAncestorStyles: unknown[] = []; let emptyStateAncestor = screen.getByTestId('generic-empty-state').parent; @@ -1841,7 +1693,7 @@ describe('Table', () => { }), ]), ); - expect(screen.queryByTestId('flash-list')).toBeNull(); + expect(screen.queryByTestId('legend-list')).toBeNull(); }); it('should keep the focused search input mounted when a page-header table changes to no results', () => { @@ -1876,8 +1728,8 @@ describe('Table', () => { const searchInput = screen.getByTestId('search-input'); const searchInputNativeID: unknown = searchInput.props.nativeID; expect(tableRef.current?.getActiveSearchString()).toBe(''); - expect(mockFlashListMount).toHaveBeenCalledTimes(1); - expect(mockFlashListUnmount).not.toHaveBeenCalled(); + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); expect(mockTextInputMount).toHaveBeenCalledTimes(1); expect(mockTextInputUnmount).not.toHaveBeenCalled(); fireEvent(searchInput, 'focus'); @@ -1886,30 +1738,24 @@ describe('Table', () => { expect(mockTextInputFocus).not.toHaveBeenCalled(); fireEvent.changeText(searchInput, 'no-match-search'); - const flashList = screen.getByTestId('flash-list'); + const legendList = screen.getByTestId('legend-list'); expect(screen.getAllByTestId('generic-empty-state')).toHaveLength(1); - expect(within(flashList).getByTestId('generic-empty-state')).toBeTruthy(); - expect(within(flashList).getByTestId('table-header-component')).toBeTruthy(); - expect(within(flashList).getByTestId('search-input').props.value).toBe('no-match-search'); - expect(within(flashList).getByTestId('search-input').props.nativeID).toBe(searchInputNativeID); + expect(within(legendList).getByTestId('generic-empty-state')).toBeTruthy(); + expect(within(legendList).getByTestId('table-header-component')).toBeTruthy(); + expect(within(legendList).getByTestId('search-input').props.value).toBe('no-match-search'); + expect(within(legendList).getByTestId('search-input').props.nativeID).toBe(searchInputNativeID); expect(tableRef.current?.getActiveSearchString()).toBe('no-match-search'); - expect(mockFlashListProps.at(-1)?.data).toHaveLength(0); - expect(StyleSheet.flatten(mockFlashListProps.at(-1)?.ListEmptyComponentStyle)).toEqual( - expect.objectContaining({ - flexGrow: 1, - justifyContent: 'center', - }), - ); - expect(StyleSheet.flatten(mockFlashListProps.at(-1)?.contentContainerStyle)).toEqual(expect.objectContaining({flexGrow: 1})); - expect(mockFlashListProps.at(-1)?.onEndReached).toBeUndefined(); - expect(mockFlashListProps.at(-1)?.onStartReached).toBeUndefined(); - expect(mockFlashListProps.at(-1)?.onViewableItemsChanged).toBeUndefined(); - expect(mockFlashListScrollToOffset).toHaveBeenCalledWith({ + expect(mockLegendListProps.at(-1)?.data).toHaveLength(0); + expect(StyleSheet.flatten(mockLegendListProps.at(-1)?.contentContainerStyle)).toEqual(expect.objectContaining({flexGrow: 1})); + expect(mockLegendListProps.at(-1)?.onEndReached).toBeUndefined(); + expect(mockLegendListProps.at(-1)?.onStartReached).toBeUndefined(); + expect(mockLegendListProps.at(-1)?.onViewableItemsChanged).toBeUndefined(); + expect(mockLegendListScrollToOffset).toHaveBeenCalledWith({ offset: 0, animated: false, }); - expect(mockFlashListMount).toHaveBeenCalledTimes(1); - expect(mockFlashListUnmount).not.toHaveBeenCalled(); + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); expect(mockTextInputMount).toHaveBeenCalledTimes(1); expect(mockTextInputUnmount).not.toHaveBeenCalled(); expect(mockTextInputNativeFocus).toHaveBeenCalledTimes(1); @@ -1919,16 +1765,16 @@ describe('Table', () => { fireEvent.changeText(searchInput, ''); - expect(screen.getByTestId('flash-list')).toBeTruthy(); + expect(screen.getByTestId('legend-list')).toBeTruthy(); expect(screen.queryByTestId('generic-empty-state')).toBeNull(); expect(screen.getByTestId('search-input').props.nativeID).toBe(searchInputNativeID); expect(tableRef.current?.getActiveSearchString()).toBe(''); - expect(mockFlashListProps.at(-1)?.data).toHaveLength(props.data.length + 1); - expect(mockFlashListProps.at(-1)?.onEndReached).toBe(onEndReached); - expect(mockFlashListProps.at(-1)?.onStartReached).toBe(onStartReached); - expect(mockFlashListProps.at(-1)?.onViewableItemsChanged).toEqual(expect.any(Function)); - expect(mockFlashListMount).toHaveBeenCalledTimes(1); - expect(mockFlashListUnmount).not.toHaveBeenCalled(); + expect(mockLegendListProps.at(-1)?.data).toHaveLength(props.data.length + 1); + expect(mockLegendListProps.at(-1)?.onEndReached).toBe(onEndReached); + expect(mockLegendListProps.at(-1)?.onStartReached).toBe(onStartReached); + expect(mockLegendListProps.at(-1)?.onViewableItemsChanged).toEqual(expect.any(Function)); + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); expect(mockTextInputMount).toHaveBeenCalledTimes(1); expect(mockTextInputUnmount).not.toHaveBeenCalled(); expect(mockTextInputNativeFocus).toHaveBeenCalledTimes(1); @@ -1968,8 +1814,8 @@ describe('Table', () => { expect(tableRef.current?.getActiveSearchString()).toBe(''); expect(mockTextInputUnmount).toHaveBeenCalledTimes(1); expect(mockTextInputNativeBlur).toHaveBeenCalledTimes(1); - expect(mockFlashListMount).toHaveBeenCalledTimes(1); - expect(mockFlashListUnmount).not.toHaveBeenCalled(); + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); }); it('should preserve a styled list footer without letting it displace the no-results content', () => { @@ -2006,22 +1852,22 @@ describe('Table', () => { ); expect(screen.getAllByTestId('list-footer')).toHaveLength(1); - expect(mockFlashListProps.at(-1)?.ListFooterComponentStyle).toEqual(listFooterComponentStyle); + expect(mockLegendListProps.at(-1)?.ListFooterComponentStyle).toEqual(listFooterComponentStyle); fireEvent.changeText(screen.getByTestId('search-input'), 'no-match-search'); - const flashList = screen.getByTestId('flash-list'); + const legendList = screen.getByTestId('legend-list'); expect(screen.getByTestId('generic-empty-state')).toBeTruthy(); expect(screen.getAllByTestId('list-footer')).toHaveLength(1); - expect(within(flashList).getByTestId('list-footer')).toBeTruthy(); - expect(StyleSheet.flatten(mockFlashListProps.at(-1)?.contentContainerStyle)).toEqual( + expect(within(legendList).getByTestId('list-footer')).toBeTruthy(); + expect(StyleSheet.flatten(mockLegendListProps.at(-1)?.contentContainerStyle)).toEqual( expect.objectContaining({ flexGrow: 1, minHeight: 600, paddingBottom: 12, }), ); - expect(StyleSheet.flatten(mockFlashListProps.at(-1)?.ListFooterComponentStyle)).toEqual( + expect(StyleSheet.flatten(mockLegendListProps.at(-1)?.ListFooterComponentStyle)).toEqual( expect.objectContaining({ flexGrow: 0, justifyContent: listFooterComponentStyle.justifyContent, @@ -2044,9 +1890,9 @@ describe('Table', () => { fireEvent.changeText(screen.getByTestId('search-input'), ''); - expect(screen.getByTestId('flash-list')).toBeTruthy(); + expect(screen.getByTestId('legend-list')).toBeTruthy(); expect(screen.getAllByTestId('list-footer')).toHaveLength(1); - expect(mockFlashListProps.at(-1)?.ListFooterComponentStyle).toEqual(listFooterComponentStyle); + expect(mockLegendListProps.at(-1)?.ListFooterComponentStyle).toEqual(listFooterComponentStyle); }); it('should render Table.EmptyState as a sibling when no page header is present', () => { @@ -2066,7 +1912,7 @@ describe('Table', () => { // Without a page header the body renders nothing and the empty state fills the table area. expect(screen.getByTestId('generic-empty-state')).toBeTruthy(); - expect(screen.queryByTestId('flash-list')).toBeNull(); + expect(screen.queryByTestId('legend-list')).toBeNull(); }); }); @@ -2969,7 +2815,7 @@ describe('Table', () => { }); describe('row selection (shift+click)', () => { - const renderSelectableRow = ({item, index}: ListRenderItemInfo) => ( + const renderSelectableRow = ({item, index}: LegendListModule.LegendListRenderItemProps) => ( ({ })); const mockDismissModal = jest.fn(); +const mockNavigate = jest.fn(); const mockGetActiveRoute = jest.fn(() => ''); jest.mock('@libs/Navigation/Navigation', () => ({ __esModule: true, default: { getActiveRoute: () => mockGetActiveRoute(), dismissModal: (...args: unknown[]) => mockDismissModal(...args), + navigate: (...args: unknown[]) => mockNavigate(...args), }, })); +let mockIsProduction = false; +jest.mock('@hooks/useEnvironment', () => ({ + __esModule: true, + default: () => ({isProduction: mockIsProduction}), +})); + jest.mock('@userActions/Network', () => ({ setShouldFailAllRequests: jest.fn(), setShouldForceOffline: jest.fn(), @@ -326,3 +334,60 @@ describe('TestToolMenu biometrics', () => { expect(screen.queryByText('multifactorAuthentication.biometricsTest.test')).toBeNull(); }); }); + +describe('TestToolMenu beta overrides', () => { + beforeEach(() => { + setBiometricStatus({registrationStatus: REGISTRATION_STATUS.NEVER_REGISTERED}); + }); + + afterEach(() => { + jest.clearAllMocks(); + mockIsProduction = false; + }); + + it('renders the beta overrides row outside production', () => { + // Given a build that is not production + // When the menu is rendered + render(); + + // Then the beta overrides row is offered + screen.getByText('initialSettingsPage.troubleshoot.betaOverrides'); + }); + + it('hides the beta overrides row in production', () => { + // Given a production build + mockIsProduction = true; + + // When the menu is rendered + render(); + + // Then the beta overrides row is not offered + expect(screen.queryByText('initialSettingsPage.troubleshoot.betaOverrides')).toBeNull(); + }); + + it('dismisses the Test Tools modal before opening the overrides page', () => { + // Given The menu rendered inside the Test Tools modal + mockGetActiveRoute.mockReturnValue(ROUTES.TEST_TOOLS_MODAL.route); + render(); + + // When The row is pressed + fireEvent.press(screen.getByText('common.view')); + + // Then The modal is dismissed first, because it and the overrides page cannot both be open + expect(mockDismissModal).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_TROUBLESHOOT_BETA_OVERRIDES); + }); + + it('does not dismiss any modal when opened inline on the Troubleshoot page', () => { + // Given The menu rendered inline on the Troubleshoot page + mockGetActiveRoute.mockReturnValue(ROUTES.SETTINGS_TROUBLESHOOT); + render(); + + // When The row is pressed + fireEvent.press(screen.getByText('common.view')); + + // Then Nothing is dismissed, because there is no modal open in this context + expect(mockDismissModal).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_TROUBLESHOOT_BETA_OVERRIDES); + }); +}); diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 84279c7cd873..f612d2cf68f5 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -115,7 +115,7 @@ function navigateToSidebar(): Promise { return waitForBatchedUpdates(); } -async function navigateToSidebarOptionWithoutAct(index: number): Promise { +async function navigateToSidebarOptionWithoutViewportLayout(index: number): Promise { const optionRow = screen.queryAllByAccessibilityHint(TestHelper.getNavigateToChatHintRegex()).at(index); if (!optionRow) { return; @@ -124,6 +124,16 @@ async function navigateToSidebarOptionWithoutAct(index: number): Promise { await waitForBatchedUpdates(); } +async function navigateToSidebarOptionWithoutAct(index: number): Promise { + await navigateToSidebarOptionWithoutViewportLayout(index); + + // React Native reports the viewport layout automatically, but View.onLayout does not fire in Jest. + fireEvent(screen.getByTestId('report-actions-list-viewport'), 'onLayout', { + nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 500}}, + }); + await waitForBatchedUpdates(); +} + function areYouOnChatListScreen(): boolean { const hintText = TestHelper.translateLocal('sidebarScreen.listOfChats'); const sidebarLinks = screen.queryAllByLabelText(hintText, {includeHiddenElements: true}); @@ -837,7 +847,8 @@ describe('Unread Indicators', () => { }, }); - await navigateToSidebarOptionWithoutAct(0); + // The self-DM cannot open its report while offline, so it never mounts a report-actions viewport. + await navigateToSidebarOptionWithoutViewportLayout(0); const fakeTransaction = { ...createRandomTransaction(1), diff --git a/tests/ui/WorkspaceTravelBillingSectionTest.tsx b/tests/ui/WorkspaceTravelBillingSectionTest.tsx index bb1af4384043..9fb9c7c7ef8e 100644 --- a/tests/ui/WorkspaceTravelBillingSectionTest.tsx +++ b/tests/ui/WorkspaceTravelBillingSectionTest.tsx @@ -6,7 +6,7 @@ import {CurrencyListContextProvider} from '@components/CurrencyListContextProvid import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import {payTravelBillingSpend} from '@libs/actions/TravelBilling'; +import {deactivateTravelBilling, payTravelBillingSpend} from '@libs/actions/TravelBilling'; import {getTravelBillingCardSettingsKey} from '@libs/TravelBillingUtils'; import WorkspaceTravelBillingSection from '@pages/workspace/travel/WorkspaceTravelBillingSection'; @@ -78,6 +78,7 @@ jest.mock('@libs/actions/TravelBilling', () => { return { ...actual, payTravelBillingSpend: jest.fn().mockResolvedValue(undefined), + deactivateTravelBilling: jest.fn(), }; }); @@ -437,23 +438,42 @@ describe('WorkspaceTravelBillingSection', () => { fireEvent.press(payButton); await waitForBatchedUpdatesWithAct(); - expect(payTravelBillingSpend).not.toHaveBeenCalled(); + // The confirmation modal is requested with the pay balance copy. Title uses the amount: "Pay balance of $50.00?" + expect(mockShowConfirmModal).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Pay balance of $50.00?', + confirmText: 'Pay balance', + }), + ); + + // The mocked modal resolves with CONFIRM, so the payment is triggered + expect(payTravelBillingSpend).toHaveBeenCalledWith(POLICY_ID, WORKSPACE_ACCOUNT_ID, 5000); + }); + + it('should not call payTravelBillingSpend when the confirmation modal is dismissed', async () => { + mockShowConfirmModal.mockResolvedValueOnce({action: 'CLOSE'}); - // The confirmation modal should be visible with the pay balance title - // Title uses the amount: "Pay balance of $50.00?" - expect(screen.getByText('Pay balance of $50.00?')).toBeTruthy(); - - // Confirm the modal — the confirm button reuses 'Pay balance' CTA text - // There are now two 'Pay balance' texts (the original button behind the modal and the modal's confirm button) - const payBalanceButtons = screen.getAllByText('Pay balance'); - const confirmButton = payBalanceButtons.at(-1); - // Press the last one which is the modal's confirm button - if (confirmButton) { - fireEvent.press(confirmButton); - } + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy); + await Onyx.merge(cardSettingsKey, { + TRAVEL_US: { + isEnabled: true, + paymentBankAccountID: 12345, + currentBalance: 5000, + monthlySettlementDate: new Date(), + }, + }); + await waitForBatchedUpdatesWithAct(); + }); + + renderWorkspaceTravelBillingSection(); await waitForBatchedUpdatesWithAct(); - expect(payTravelBillingSpend).toHaveBeenCalledWith(POLICY_ID, WORKSPACE_ACCOUNT_ID, 5000); + fireEvent.press(screen.getByText('Pay balance')); + await waitForBatchedUpdatesWithAct(); + + expect(mockShowConfirmModal).toHaveBeenCalled(); + expect(payTravelBillingSpend).not.toHaveBeenCalled(); }); it('should hide Pay Balance button and show queued message when settlement is pending', async () => { @@ -581,6 +601,99 @@ describe('WorkspaceTravelBillingSection', () => { }); }); + describe('Turning Travel Invoicing off', () => { + const cardSettingsKey = getTravelBillingCardSettingsKey(WORKSPACE_ACCOUNT_ID); + + it('should confirm before deactivating when there is no outstanding balance', async () => { + // Given an enabled workspace with nothing left to pay + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy); + await Onyx.merge(cardSettingsKey, { + TRAVEL_US: { + isEnabled: true, + paymentBankAccountID: 12345, + currentBalance: 0, + pendingSettlementAmount: 0, + }, + }); + await waitForBatchedUpdatesWithAct(); + }); + + renderWorkspaceTravelBillingSection(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByRole('switch')); + await waitForBatchedUpdatesWithAct(); + + expect(mockShowConfirmModal).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Turn off Consolidated Travel Billing?', + confirmText: 'Turn off', + }), + ); + + // The mocked modal resolves with CONFIRM, so the feature is deactivated + expect(deactivateTravelBilling).toHaveBeenCalledWith(POLICY_ID, WORKSPACE_ACCOUNT_ID); + }); + + it('should not deactivate when the confirmation modal is dismissed', async () => { + mockShowConfirmModal.mockResolvedValueOnce({action: 'CLOSE'}); + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy); + await Onyx.merge(cardSettingsKey, { + TRAVEL_US: { + isEnabled: true, + paymentBankAccountID: 12345, + currentBalance: 0, + pendingSettlementAmount: 0, + }, + }); + await waitForBatchedUpdatesWithAct(); + }); + + renderWorkspaceTravelBillingSection(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByRole('switch')); + await waitForBatchedUpdatesWithAct(); + + expect(mockShowConfirmModal).toHaveBeenCalled(); + expect(deactivateTravelBilling).not.toHaveBeenCalled(); + }); + + it('should show the blocker modal instead of deactivating when a balance is outstanding', async () => { + // Given an enabled workspace that still owes a travel balance + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy); + await Onyx.merge(cardSettingsKey, { + TRAVEL_US: { + isEnabled: true, + paymentBankAccountID: 12345, + currentBalance: 5000, + }, + }); + await waitForBatchedUpdatesWithAct(); + }); + + renderWorkspaceTravelBillingSection(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByRole('switch')); + await waitForBatchedUpdatesWithAct(); + + // The blocker is acknowledgement-only, so it has no cancel button and never deactivates + expect(mockShowConfirmModal).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Can't turn off Consolidated Travel Billing", + confirmText: 'Got it', + shouldShowCancelButton: false, + }), + ); + expect(deactivateTravelBilling).not.toHaveBeenCalled(); + }); + }); + describe('Currency Conversion Prompt', () => { const cardSettingsKey = getTravelBillingCardSettingsKey(WORKSPACE_ACCOUNT_ID); diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 5b25b9898e85..46cb27610d60 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -3,18 +3,27 @@ import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-na import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import * as ConfirmAction from '@components/MoneyRequestConfirmationList/confirmAction'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import type {ParticipantPickerProps} from '@components/ParticipantPicker/types'; import ScreenWrapper from '@components/ScreenWrapper'; import {startSplitBill} from '@libs/actions/IOU/Split'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import * as IOUUtils from '@libs/IOUUtils'; +import * as SubmitWithDismissFirst from '@libs/Navigation/helpers/submitWithDismissFirst'; +import Navigation from '@libs/Navigation/Navigation'; +// eslint-disable-next-line no-restricted-imports -- Namespace import is required to spy on getChatByParticipants without replacing the production module. +import * as ReportUtils from '@libs/ReportUtils'; import IOURequestStepConfirmationWithWritableReportOrNotFound, {IOURequestStepConfirmationContentWithWritableReportOrNotFound} from '@pages/iou/request/step/IOURequestStepConfirmation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type {Policy, TaxRatesWithDefault} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type Transaction from '@src/types/onyx/Transaction'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; @@ -29,7 +38,7 @@ import * as Split from '../../../src/libs/actions/IOU/Split'; import * as TrackExpense from '../../../src/libs/actions/IOU/TrackExpense'; import createRandomPolicy from '../../utils/collections/policies'; import createMockScreenNavigation from '../../utils/createMockScreenNavigation'; -import {signInWithTestUser, translateLocal} from '../../utils/TestHelper'; +import {setupGlobalFetchMock, signInWithTestUser, translateLocal} from '../../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; jest.mock('@rnmapbox/maps', () => { @@ -213,6 +222,14 @@ const TRANSACTION_ID = '1'; const POLICY_ID = 'test-policy-id'; const POLICY_CHAT_REPORT_ID = '595'; +const mockSendMoneyElsewhere = jest.fn(); +jest.mock('@userActions/IOU/SendMoney', () => ({ + sendMoneyElsewhere: (...args: unknown[]) => { + mockSendMoneyElsewhere(...args); + }, + sendMoneyWithWallet: jest.fn(), +})); + // Helper to create a policy with tax and distance enabled function createPolicyWithTaxAndDistance(): Policy { const taxRates: TaxRatesWithDefault = { @@ -320,6 +337,9 @@ const DEFAULT_SPLIT_TRANSACTION: Transaction = { }; describe('IOURequestStepConfirmationPageTest', () => { + // Writes fired during render (e.g. UpdatePreferredLocale) must not hit the real network and leave retry backoff across tests + setupGlobalFetchMock(); + beforeEach(() => { jest.clearAllMocks(); resetScreenFocusListeners(); @@ -1001,6 +1021,166 @@ describe('IOURequestStepConfirmationPageTest', () => { return /^Create .*expense/i; } + it('uses the transaction optimistic report ID for a brand-new P2P pre-mount and pay destination', async () => { + // Given a brand-new P2P recipient with no existing chat, so the screen must reuse the + // transaction's optimistic report ID rather than one a builder would otherwise mint + const optimisticP2PReportID = 'optimistic-p2p-report-1'; + const transactionID = 'tx-new-p2p'; + let sendMoney: ((paymentMethod: PaymentMethodType | undefined) => void) | undefined; + const originalBuildConfirmAction = ConfirmAction.default; + const buildConfirmActionSpy = jest.spyOn(ConfirmAction, 'default').mockImplementation((params) => { + sendMoney = params.onSendMoney; + return originalBuildConfirmAction(params); + }); + const submitWithDismissFirstSpy = jest.spyOn(SubmitWithDismissFirst, 'submitWithDismissFirst').mockImplementation((params) => { + params.executeWrite({shouldHandleNavigation: false}); + }); + const getChatByParticipantsSpy = jest.spyOn(ReportUtils, 'getChatByParticipants').mockReturnValue(undefined); + const getReusableP2PReportIDSpy = jest.spyOn(IOUUtils, 'getReusableP2PReportID').mockReturnValue(optimisticP2PReportID); + jest.mocked(getIsNarrowLayout).mockReturnValue(true); + + try { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + transactionID, + reportID: optimisticP2PReportID, + amount: 1000, + isAmountSet: true, + currency: 'USD', + merchant: 'Test', + created: '2025-01-15', + isFromGlobalCreate: true, + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + participants: [{accountID: PARTICIPANT_ACCOUNT_ID, selected: true}], + }); + }); + + render( + + + + + + + + + , + ); + + // When the screen renders and resolves the P2P destination + await waitForBatchedUpdatesWithAct(); + + expect(getChatByParticipantsSpy).toHaveBeenCalled(); + expect(getReusableP2PReportIDSpy).toHaveBeenCalledWith(expect.objectContaining({accountID: PARTICIPANT_ACCOUNT_ID}), optimisticP2PReportID); + // Then it pre-mounts the report at the transaction's own optimistic ID, not a different one + await waitFor( + () => + expect(Navigation.preInsertFullscreenUnderRHP).toHaveBeenCalledWith( + ROUTES.REPORT_WITH_ID.getRoute(optimisticP2PReportID, undefined, undefined, undefined, undefined, true), + ), + {timeout: 2000}, + ); + + // When the user sends money + act(() => sendMoney?.(CONST.IOU.PAYMENT_TYPE.ELSEWHERE)); + + // Then submission also uses that same optimistic report ID, so the pre-mounted screen ends up + // subscribed to the report that actually gets created + expect(submitWithDismissFirstSpy).toHaveBeenCalledWith(expect.objectContaining({destinationReportID: optimisticP2PReportID})); + expect(mockSendMoneyElsewhere).toHaveBeenCalledWith(expect.objectContaining({optimisticChatReportID: optimisticP2PReportID})); + } finally { + buildConfirmActionSpy.mockRestore(); + submitWithDismissFirstSpy.mockRestore(); + getChatByParticipantsSpy.mockRestore(); + getReusableP2PReportIDSpy.mockRestore(); + jest.mocked(getIsNarrowLayout).mockReturnValue(false); + } + }); + + it('keeps the IOU report as pre-mount destination when the flow starts from it, instead of the participant chat', async () => { + // Given an existing 1:1 chat and an IOU report under it, and a flow started from that IOU report to add another expense + const chatReportID = 'p2p-chat-1'; + const iouReportID = 'p2p-iou-report-1'; + const transactionID = 'tx-from-iou-report'; + const getChatByParticipantsSpy = jest.spyOn(ReportUtils, 'getChatByParticipants').mockReturnValue({reportID: chatReportID}); + jest.mocked(getIsNarrowLayout).mockReturnValue(true); + + try { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + participants: {[ACCOUNT_ID]: {}, [PARTICIPANT_ACCOUNT_ID]: {}}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + chatReportID, + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: ACCOUNT_ID, + managerID: PARTICIPANT_ACCOUNT_ID, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + transactionID, + reportID: iouReportID, + amount: 1000, + isAmountSet: true, + currency: 'USD', + merchant: 'Test', + created: '2025-01-15', + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + participants: [{accountID: PARTICIPANT_ACCOUNT_ID, reportID: chatReportID, selected: true}], + }); + }); + + render( + + + + + + + + + , + ); + + // When the screen renders and resolves the pre-mount destination + await waitForBatchedUpdatesWithAct(); + + // Then the IOU report the flow started from is pre-inserted, not the participant chat the lookup resolved + expect(getChatByParticipantsSpy).toHaveBeenCalled(); + await waitFor(() => expect(Navigation.preInsertFullscreenUnderRHP).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(iouReportID)), {timeout: 2000}); + expect(Navigation.preInsertFullscreenUnderRHP).not.toHaveBeenCalledWith(expect.stringContaining(chatReportID)); + } finally { + getChatByParticipantsSpy.mockRestore(); + jest.mocked(getIsNarrowLayout).mockReturnValue(false); + } + }); + it('should not fallback to route report when transaction report differs and is not usable', async () => { const routeReportID = '100'; const transactionReportID = '200'; @@ -1502,9 +1682,6 @@ describe('IOURequestStepConfirmationPageTest', () => { mockSelectedParticipants = []; mockSelectedPolicy = undefined; await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); - await act(async () => { - await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW]); - }); }); function confirmationScreen() { @@ -1652,7 +1829,6 @@ describe('IOURequestStepConfirmationPageTest', () => { mockSelectedPolicy = undefined; await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); await act(async () => { - await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW]); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${SOURCE_POLICY_ID}`, {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Source policy'), id: SOURCE_POLICY_ID}); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${DESTINATION_POLICY_ID}`, { ...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE, 'Destination policy'), @@ -1828,9 +2004,6 @@ describe('IOURequestStepConfirmationPageTest', () => { beforeEach(async () => { mockSelectedParticipants = []; await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); - await act(async () => { - await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW]); - }); }); /** diff --git a/tests/ui/components/MenuItem/MenuItemFieldTest.tsx b/tests/ui/components/MenuItem/MenuItemFieldTest.tsx new file mode 100644 index 000000000000..d6e08d53021c --- /dev/null +++ b/tests/ui/components/MenuItem/MenuItemFieldTest.tsx @@ -0,0 +1,201 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import Text from '@components/Text'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({ + ArrowRight: () => null, + })), +})); + +const CHEVRON_TEST_ID = 'menu-item-chevron'; +const pressEvent = {nativeEvent: {}}; +const NAME = 'Legal first name'; +const VALUE = 'John'; + +function Wrapper({children}: {children: React.ReactNode}) { + return {children}; +} + +describe('MenuItemField', () => { + describe('filled shape', () => { + it('renders the name and the value', () => { + render( + + + , + ); + + expect(screen.getByText(NAME)).toBeOnTheScreen(); + expect(screen.getByText(VALUE)).toBeOnTheScreen(); + }); + + it('announces the name first, then the value', async () => { + render( + + + , + ); + + expect(await screen.findByLabelText(`${NAME}, ${VALUE}`)).toBeOnTheScreen(); + }); + }); + + describe('empty shape', () => { + it.each([ + ['no value prop', undefined], + ['an empty value', ''], + ])('renders the name as a placeholder and nothing else given %s', async (_case, value) => { + render( + + + , + ); + + expect(screen.getByText(NAME)).toBeOnTheScreen(); + expect(screen.queryByText(VALUE)).not.toBeOnTheScreen(); + // The name stands in for the missing value, so it is the whole announced label + expect(await screen.findByLabelText(NAME)).toBeOnTheScreen(); + }); + }); + + describe('trailing cell', () => { + it('renders no chevron when the row is not pressable', () => { + render( + + + , + ); + + expect(screen.queryByTestId(CHEVRON_TEST_ID)).not.toBeOnTheScreen(); + }); + + it('renders a chevron when the row is pressable', () => { + render( + + {}} + /> + , + ); + + expect(screen.getByTestId(CHEVRON_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders children without a chevron when the row is not pressable', () => { + render( + + + Badge + + , + ); + + expect(screen.getByText('Badge')).toBeOnTheScreen(); + expect(screen.queryByTestId(CHEVRON_TEST_ID)).not.toBeOnTheScreen(); + }); + + it('renders children alongside the chevron when the row is pressable', () => { + render( + + {}} + > + Badge + + , + ); + + expect(screen.getByText('Badge')).toBeOnTheScreen(); + expect(screen.getByTestId(CHEVRON_TEST_ID)).toBeOnTheScreen(); + }); + }); + + describe('press handling', () => { + it('takes the button role only when pressable', async () => { + const {unmount} = render( + + + , + ); + + expect(await screen.findByLabelText(`${NAME}, ${VALUE}`)).not.toHaveProp('role', CONST.ROLE.BUTTON); + unmount(); + + render( + + {}} + /> + , + ); + + expect(await screen.findByRole(CONST.ROLE.BUTTON, {name: `${NAME}, ${VALUE}`})).toBeOnTheScreen(); + }); + + it('calls onPress when pressed', async () => { + const onPress = jest.fn(); + render( + + + , + ); + + fireEvent.press(await screen.findByLabelText(`${NAME}, ${VALUE}`), pressEvent); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('does not call onPress when disabled', async () => { + const onPress = jest.fn(); + render( + + + , + ); + + fireEvent.press(await screen.findByLabelText(`${NAME}, ${VALUE}`), pressEvent); + + expect(onPress).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/ActionListContextProviderTest.tsx b/tests/unit/ActionListContextProviderTest.tsx index 37033b6ad9e5..d255306e5cdc 100644 --- a/tests/unit/ActionListContextProviderTest.tsx +++ b/tests/unit/ActionListContextProviderTest.tsx @@ -1,8 +1,7 @@ import {act, renderHook} from '@testing-library/react-native'; -import type FlatListRefType from '@components/FlashList/types'; - import {ActionListContextProvider, useActionListContext} from '@pages/inbox/ActionListContext'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; /** * `ActionListContextProvider` owns a private holder and exposes `registerListRef(ref)` / `getListRef()` @@ -18,7 +17,7 @@ describe('ActionListContextProvider', () => { it('getListRef() returns the same ref object after registerListRef(ref)', () => { const {result} = renderHook(() => useActionListContext(), {wrapper: ActionListContextProvider}); - const listRef: FlatListRefType = {current: null}; + const listRef: ActionListRefType = {current: null}; act(() => { result.current.registerListRef(listRef); @@ -29,7 +28,7 @@ describe('ActionListContextProvider', () => { it('getListRef() returns null again after registerListRef(null) (unmount path)', () => { const {result} = renderHook(() => useActionListContext(), {wrapper: ActionListContextProvider}); - const listRef: FlatListRefType = {current: null}; + const listRef: ActionListRefType = {current: null}; act(() => { result.current.registerListRef(listRef); diff --git a/tests/unit/AttachmentPickerAssetProcessingTest.ts b/tests/unit/AttachmentPickerAssetProcessingTest.ts new file mode 100644 index 000000000000..f3d4056d2863 --- /dev/null +++ b/tests/unit/AttachmentPickerAssetProcessingTest.ts @@ -0,0 +1,237 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import processPickedAssetsSequentially from '@libs/fileDownload/processPickedAssets'; + +import type {Asset} from 'react-native-image-picker'; + +const mockVerifyFileFormat = jest.fn(); +const mockRenderAsync = jest.fn(); +const mockSaveAsync = jest.fn(); +const mockRelease = jest.fn(); +const mockImageRelease = jest.fn(); + +jest.mock('@libs/fileDownload/FileUtils', () => ({ + getFileName: (url: string) => url.split('/').pop()?.split('?').at(0) ?? '', + verifyFileFormat: () => mockVerifyFileFormat() as unknown, +})); + +jest.mock('expo-image-manipulator', () => ({ + ImageManipulator: { + manipulate: () => ({ + renderAsync: () => mockRenderAsync() as unknown, + release: () => { + mockRelease(); + }, + }), + }, + SaveFormat: {JPEG: 'jpeg'}, +})); + +jest.mock('@libs/Log', () => ({ + info: jest.fn(), + warn: jest.fn(), +})); + +const buildHeicAssets = (count: number): Asset[] => + Array.from({length: count}, (value, index) => ({ + uri: `file:///photo-${index}.heic`, + fileName: `photo-${index}.heic`, + type: 'image/heic', + })); + +const showGeneralAlert = jest.fn(); +// Returns the key itself so assertions can tell the different failure messages apart. +const translate: LocaleContextProps['translate'] = (path, ...parameters): string => (parameters.length > 0 ? `${path}:${parameters.length}` : path); + +describe('processPickedAssetsSequentially', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockVerifyFileFormat.mockResolvedValue(true); + mockSaveAsync.mockResolvedValue({uri: 'file:///photo.jpg', width: 100, height: 200}); + mockRenderAsync.mockImplementation(() => + Promise.resolve({ + saveAsync: () => mockSaveAsync() as unknown, + release: () => { + mockImageRelease(); + }, + }), + ); + }); + + it('decodes only one image at a time', async () => { + let inFlight = 0; + let peakInFlight = 0; + + // Hold each decode open long enough that any overlap would be observable in `peakInFlight`. + mockRenderAsync.mockImplementation(() => { + inFlight++; + peakInFlight = Math.max(peakInFlight, inFlight); + return new Promise((resolve) => { + setImmediate(() => { + inFlight--; + resolve({ + saveAsync: () => mockSaveAsync() as unknown, + release: () => { + mockImageRelease(); + }, + }); + }); + }); + }); + + const result = await processPickedAssetsSequentially(buildHeicAssets(30), showGeneralAlert, translate); + + expect(peakInFlight).toBe(1); + expect(mockRenderAsync).toHaveBeenCalledTimes(30); + expect(result).toHaveLength(30); + }); + + it('releases the native image resources for every converted asset', async () => { + await processPickedAssetsSequentially(buildHeicAssets(5), showGeneralAlert, translate); + + expect(mockRelease).toHaveBeenCalledTimes(5); + expect(mockImageRelease).toHaveBeenCalledTimes(5); + }); + + it('releases the manipulator context even when the conversion fails', async () => { + mockRenderAsync.mockRejectedValue(new Error('decode failed')); + + await processPickedAssetsSequentially(buildHeicAssets(3), showGeneralAlert, translate); + + expect(mockRelease).toHaveBeenCalledTimes(3); + }); + + it('skips assets that fail to convert instead of uploading the raw HEIC', async () => { + mockRenderAsync.mockRejectedValue(new Error('decode failed')); + + const result = await processPickedAssetsSequentially(buildHeicAssets(3), showGeneralAlert, translate); + + expect(result).toBeUndefined(); + }); + + it('shows a single alert when the whole selection fails the same way', async () => { + mockRenderAsync.mockRejectedValue(new Error('decode failed')); + + await processPickedAssetsSequentially(buildHeicAssets(30), showGeneralAlert, translate); + + expect(showGeneralAlert).toHaveBeenCalledTimes(1); + }); + + it('passes non-HEIC images through without decoding them', async () => { + mockVerifyFileFormat.mockResolvedValue(false); + + const result = await processPickedAssetsSequentially([{uri: 'file:///photo.jpg', fileName: 'photo.jpg', type: 'image/jpeg'}], showGeneralAlert, translate); + + expect(mockRenderAsync).not.toHaveBeenCalled(); + expect(result).toHaveLength(1); + }); + it('preserves selection order across mixed HEIC and non-HEIC assets', async () => { + mockVerifyFileFormat.mockResolvedValueOnce(true).mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockSaveAsync.mockResolvedValueOnce({uri: 'file:///a-converted.jpg', width: 1, height: 1}).mockResolvedValueOnce({uri: 'file:///c-converted.jpg', width: 1, height: 1}); + + const result = await processPickedAssetsSequentially( + [ + {uri: 'file:///a.heic', fileName: 'a.heic', type: 'image/heic'}, + {uri: 'file:///b.jpg', fileName: 'b.jpg', type: 'image/jpeg'}, + {uri: 'file:///c.heic', fileName: 'c.heic', type: 'image/heic'}, + ], + showGeneralAlert, + translate, + ); + + expect(result?.map((asset) => asset.fileName)).toEqual(['a-converted.jpg', 'b.jpg', 'c-converted.jpg']); + }); + + it('skips assets that have no uri', async () => { + const result = await processPickedAssetsSequentially([{fileName: 'no-uri.heic', type: 'image/heic'}], showGeneralAlert, translate); + + expect(mockVerifyFileFormat).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('passes non-image assets through without checking the file format', async () => { + const result = await processPickedAssetsSequentially([{uri: 'file:///doc.pdf', fileName: 'doc.pdf', type: 'application/pdf'}], showGeneralAlert, translate); + + expect(mockVerifyFileFormat).not.toHaveBeenCalled(); + expect(result?.at(0)?.fileName).toBe('doc.pdf'); + }); + + it('surfaces the underlying message when the format check fails', async () => { + mockVerifyFileFormat.mockRejectedValueOnce(new Error('format check failed')); + + await processPickedAssetsSequentially(buildHeicAssets(1), showGeneralAlert, translate); + + expect(showGeneralAlert).toHaveBeenCalledWith('format check failed'); + }); + + it('falls back to localized copy when the failure is not an Error', async () => { + mockVerifyFileFormat.mockRejectedValueOnce('not an error object'); + + await processPickedAssetsSequentially(buildHeicAssets(1), showGeneralAlert, translate); + + expect(showGeneralAlert).toHaveBeenCalledWith('attachmentPicker.errorWhileSelectingAttachment'); + }); + + it('shows one alert even when the selection fails in different ways', async () => { + mockVerifyFileFormat.mockRejectedValueOnce(new Error('format check failed')); + mockRenderAsync.mockRejectedValue(new Error('decode failed')); + + await processPickedAssetsSequentially(buildHeicAssets(2), showGeneralAlert, translate); + + expect(showGeneralAlert).toHaveBeenCalledTimes(1); + expect(showGeneralAlert).toHaveBeenCalledWith('format check failed\nattachmentPicker.errorWhileConvertingHeic'); + }); + it.each([ + ['the rendered image', () => mockImageRelease], + ['the manipulator context', () => mockRelease], + ])('keeps a converted asset even if releasing %s throws', async (name, getMock) => { + getMock().mockImplementation(() => { + throw new Error('release blew up'); + }); + + const result = await processPickedAssetsSequentially(buildHeicAssets(1), showGeneralAlert, translate); + + expect(result).toHaveLength(1); + expect(showGeneralAlert).not.toHaveBeenCalled(); + }); + it('derives fileName from the uri and defaults type when the picker omits them', async () => { + const result = await processPickedAssetsSequentially([{uri: 'file:///scan.pdf'}], showGeneralAlert, translate); + + expect(result?.at(0)?.fileName).toBe('scan.pdf'); + expect(result?.at(0)?.type).toBe('image/jpeg'); + }); + + it('releases the rendered image and skips the asset when saving fails', async () => { + mockSaveAsync.mockRejectedValue(new Error('encode failed')); + + const result = await processPickedAssetsSequentially(buildHeicAssets(1), showGeneralAlert, translate); + + expect(result).toBeUndefined(); + expect(mockImageRelease).toHaveBeenCalledTimes(1); + expect(mockRelease).toHaveBeenCalledTimes(1); + expect(showGeneralAlert).toHaveBeenCalledWith('attachmentPicker.errorWhileConvertingHeic'); + }); + + it('returns the successful assets and still alerts when some fail', async () => { + mockRenderAsync + .mockResolvedValueOnce({ + saveAsync: () => mockSaveAsync() as unknown, + release: () => { + mockImageRelease(); + }, + }) + .mockRejectedValueOnce(new Error('decode failed')) + .mockResolvedValueOnce({ + saveAsync: () => mockSaveAsync() as unknown, + release: () => { + mockImageRelease(); + }, + }); + + const result = await processPickedAssetsSequentially(buildHeicAssets(3), showGeneralAlert, translate); + + expect(result).toHaveLength(2); + expect(showGeneralAlert).toHaveBeenCalledTimes(1); + expect(showGeneralAlert).toHaveBeenCalledWith('attachmentPicker.errorWhileConvertingHeic'); + }); +}); diff --git a/tests/unit/BaseSelectionListSectionsTest.tsx b/tests/unit/BaseSelectionListSectionsTest.tsx index ab3eb17973e4..435d9b21efe3 100644 --- a/tests/unit/BaseSelectionListSectionsTest.tsx +++ b/tests/unit/BaseSelectionListSectionsTest.tsx @@ -10,6 +10,7 @@ import type Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; +import type * as LegendListModule from '@legendapp/list/react-native'; import type ReactNative from 'react-native'; import * as NativeNavigation from '@react-navigation/native'; @@ -18,16 +19,17 @@ import React, {useState} from 'react'; // Captures scrollToIndex calls so tests can assert on scroll behaviour const mockScrollToIndex = jest.fn(); -// Mock FlashList -jest.mock('@shopify/flash-list', () => { +// Mock LegendList +jest.mock('@legendapp/list/react-native', () => { const ReactLocal = jest.requireActual('react'); const RN = jest.requireActual('react-native'); + const LegendListActual = jest.requireActual('@legendapp/list/react-native'); - const FlashList = ReactLocal.forwardRef< + const LegendList = ReactLocal.forwardRef< {scrollToIndex: (params: {index: number}) => void}, Omit, 'children'> & { data?: unknown[]; - renderItem?: (info: {item: unknown; index: number; target: string}) => React.ReactNode; + renderItem?: (info: {item: unknown; index: number}) => React.ReactNode; keyExtractor?: (item: unknown, index: number) => string; ListHeaderComponent?: React.ReactNode; ListFooterComponent?: React.ReactNode; @@ -62,15 +64,13 @@ jest.mock('@shopify/flash-list', () => { RN.ScrollView, scrollViewProps, ListHeaderComponent ?? null, - ...(data ?? []).map((item, index) => - ReactLocal.createElement(ReactLocal.Fragment, {key: keyExtractor?.(item, index) ?? String(index)}, renderItem?.({item, index, target: 'Cell'})), - ), + ...(data ?? []).map((item, index) => ReactLocal.createElement(ReactLocal.Fragment, {key: keyExtractor?.(item, index) ?? String(index)}, renderItem?.({item, index}))), ListFooterComponent ?? null, ); }, ); - return {FlashList}; + return {...LegendListActual, LegendList}; }); type BaseSelectionListSections = { @@ -203,7 +203,7 @@ describe('BaseSelectionList', () => { />, ); - // FlashList renders all items (virtualization is handled internally) + // LegendList renders all items (virtualization is handled internally) expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}99`)).toBeTruthy(); }); @@ -229,7 +229,7 @@ describe('BaseSelectionList', () => { />, ); - // All items should be rendered with FlashList + // All items should be rendered with LegendList expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}49`)).toBeTruthy(); expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}99`)).toBeTruthy(); diff --git a/tests/unit/BaseSelectionListTest.tsx b/tests/unit/BaseSelectionListTest.tsx index 49f857d96047..65518696a359 100644 --- a/tests/unit/BaseSelectionListTest.tsx +++ b/tests/unit/BaseSelectionListTest.tsx @@ -10,6 +10,7 @@ import type * as NavigationFocusReturnModule from '@libs/NavigationFocusReturn'; import CONST from '@src/CONST'; +import type * as LegendListModule from '@legendapp/list/react-native'; import type ReactNative from 'react-native'; import * as NativeNavigation from '@react-navigation/native'; @@ -18,16 +19,17 @@ import React, {useState} from 'react'; // Captures scrollToIndex calls so tests can assert on scroll behaviour const mockScrollToIndex = jest.fn(); -// Mock FlashList -jest.mock('@shopify/flash-list', () => { +// Mock LegendList +jest.mock('@legendapp/list/react-native', () => { const ReactLocal = jest.requireActual('react'); const RN = jest.requireActual('react-native'); + const LegendListActual = jest.requireActual('@legendapp/list/react-native'); - const FlashList = ReactLocal.forwardRef< + const LegendList = ReactLocal.forwardRef< {scrollToIndex: (params: {index: number}) => void}, Omit, 'children'> & { data?: unknown[]; - renderItem?: (info: {item: unknown; index: number; target: string}) => React.ReactNode; + renderItem?: (info: {item: unknown; index: number}) => React.ReactNode; keyExtractor?: (item: unknown, index: number) => string; ListHeaderComponent?: React.ReactNode; ListFooterComponent?: React.ReactNode; @@ -62,15 +64,13 @@ jest.mock('@shopify/flash-list', () => { RN.ScrollView, scrollViewProps, ListHeaderComponent ?? null, - ...(data ?? []).map((item, index) => - ReactLocal.createElement(ReactLocal.Fragment, {key: keyExtractor?.(item, index) ?? String(index)}, renderItem?.({item, index, target: 'Cell'})), - ), + ...(data ?? []).map((item, index) => ReactLocal.createElement(ReactLocal.Fragment, {key: keyExtractor?.(item, index) ?? String(index)}, renderItem?.({item, index}))), ListFooterComponent ?? null, ); }, ); - return {FlashList}; + return {...LegendListActual, LegendList}; }); type BaseSelectionListTestProps = { diff --git a/tests/unit/BetaOverridesProductionTest.ts b/tests/unit/BetaOverridesProductionTest.ts new file mode 100644 index 000000000000..08abbee02f71 --- /dev/null +++ b/tests/unit/BetaOverridesProductionTest.ts @@ -0,0 +1,66 @@ +import defaultPermissions from '@libs/Permissions'; + +import CONST from '@src/CONST'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +type PermissionsModule = typeof defaultPermissions; + +/** + * The overrides key survives app resets and is included in exported Onyx state, so it can reach a production build. + * Permissions resolves the environment once on import, so each case loads it in a fresh module registry. `configEnvironment` + * is what the build was compiled with and `resolvedEnvironment` is what getEnvironment reports, which differ on TestFlight. + */ +async function loadPermissionsForEnvironment(resolvedEnvironment: string, configEnvironment = resolvedEnvironment) { + jest.resetModules(); + jest.doMock('@libs/Environment/getEnvironment', () => ({ + __esModule: true, + default: () => Promise.resolve(resolvedEnvironment), + })); + jest.doMock('@src/CONFIG', () => ({ + __esModule: true, + default: {...jest.requireActual<{default: Record}>('@src/CONFIG').default, ENVIRONMENT: configEnvironment}, + })); + + let permissions: PermissionsModule = defaultPermissions; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + permissions = require('@libs/Permissions').default; + }); + + // the environment is resolved through two promises before Permissions stores it + await waitForBatchedUpdates(); + + return permissions; +} + +describe('beta overrides in production', () => { + it('ignores overrides when the environment is production', async () => { + // Given a production build + const Permissions = await loadPermissionsForEnvironment(CONST.ENVIRONMENT.PRODUCTION); + + // When a beta is resolved with an override pinned against the server betas + // Then the server betas win + expect(Permissions.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS, [], undefined, {[CONST.BETAS.DEFAULT_ROOMS]: true})).toBe(false); + expect(Permissions.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS, [CONST.BETAS.DEFAULT_ROOMS], undefined, {[CONST.BETAS.DEFAULT_ROOMS]: false})).toBe(true); + }); + + it('applies overrides when the environment is staging', async () => { + // Given a staging build + const Permissions = await loadPermissionsForEnvironment(CONST.ENVIRONMENT.STAGING); + + // When a beta is resolved with an override pinned against the server betas + // Then the override wins + expect(Permissions.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS, [], undefined, {[CONST.BETAS.DEFAULT_ROOMS]: true})).toBe(true); + expect(Permissions.isBetaEnabled(CONST.BETAS.ASAP_SUBMIT, [CONST.BETAS.ASAP_SUBMIT], undefined, {[CONST.BETAS.ASAP_SUBMIT]: false})).toBe(false); + }); + + it('applies overrides on a TestFlight build, which is compiled as production but resolves to staging', async () => { + // Given a build compiled as production that the resolved environment downgrades to staging + const Permissions = await loadPermissionsForEnvironment(CONST.ENVIRONMENT.STAGING, CONST.ENVIRONMENT.PRODUCTION); + + // When a beta is resolved with an override pinned against the server betas + // Then the override wins, since only the resolved environment counts + expect(Permissions.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS, [], undefined, {[CONST.BETAS.DEFAULT_ROOMS]: true})).toBe(true); + }); +}); diff --git a/tests/unit/FlashListTest.tsx b/tests/unit/FlashListTest.tsx deleted file mode 100644 index f76e02aeefbf..000000000000 --- a/tests/unit/FlashListTest.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import {render} from '@testing-library/react-native'; - -import type {LayoutChangeEvent, View as RNView, ViewStyle} from 'react-native'; - -import {FlashList} from '@shopify/flash-list'; -import React from 'react'; -import {View} from 'react-native'; - -import createMock from '../utils/createMock'; - -type CellProps = { - index: number; - onLayout: (event: LayoutChangeEvent) => void; - style: ViewStyle; - children: React.ReactNode; -}; - -describe('FlashList - stale onLayout after rapid data change', () => { - it('does not throw when ViewHolder onLayout fires with a now out-of-bounds index', () => { - // Map of index -> captured onLayout handler from each ViewHolder render - const capturedLayoutHandlers = new Map void>(); - - const CapturingCell = React.forwardRef(({index, onLayout, style, children}, ref) => { - capturedLayoutHandlers.set(index, onLayout); - return ( - - {children} - - ); - }); - CapturingCell.displayName = 'CapturingCell'; - - const renderItem = ({item}: {item: {id: number}}) => ( - - ); - - const initialData = Array.from({length: 10}, (_, i) => ({id: i})); - const shrunkData = initialData.slice(0, 2); - - const {rerender} = render( - String(item.id)} - renderItem={renderItem} - CellRendererComponent={CapturingCell} - />, - ); - - // Quickly shrink data - rerender( - String(item.id)} - renderItem={renderItem} - CellRendererComponent={CapturingCell} - />, - ); - - // Pick a handler that was captured for an index now beyond the new - // data length. In production the browser would fire this from a - // queued ResizeObserver callback after the data shrunk. - const staleIndex = [...capturedLayoutHandlers.keys()].find((idx) => idx >= shrunkData.length); - expect(staleIndex).toBeDefined(); - const staleOnLayout = staleIndex ? capturedLayoutHandlers.get(staleIndex) : undefined; - expect(staleOnLayout).toBeDefined(); - - // Pre-fix: this throws "index out of bounds, not enough layouts" - // Post-fix: validateItemSize bails when tryGetLayout returns undefined - expect(() => { - staleOnLayout?.( - createMock({ - nativeEvent: { - layout: {x: 0, y: 0, width: 100, height: 50}, - }, - }), - ); - }).not.toThrow(); - }); -}); diff --git a/tests/unit/FormulaTest.ts b/tests/unit/FormulaTest.ts index 7243ccb48b89..7accd7859e60 100644 --- a/tests/unit/FormulaTest.ts +++ b/tests/unit/FormulaTest.ts @@ -537,6 +537,64 @@ describe('CustomFormula', () => { }); }); + describe('Debited and credited amount', () => { + const reimbursementContext: FormulaContext = { + getCurrencyDecimals: getCurrencyDecimalsLocal, + report: { + reportID: '123', + reportName: '', + type: 'expense', + policyID: 'policy1', + }, + policy: createMock({ + name: 'Test Policy', + }), + }; + + beforeEach(() => { + jest.clearAllMocks(); + reimbursementContext.report.debitedAmount = undefined; + reimbursementContext.report.debitedCurrency = undefined; + reimbursementContext.report.creditedAmount = undefined; + reimbursementContext.report.creditedCurrency = undefined; + }); + + test('should format debitedAmount in its bank-account currency', () => { + reimbursementContext.report.debitedAmount = 8250; + reimbursementContext.report.debitedCurrency = 'USD'; + + expect(compute('{report:debitedAmount}', reimbursementContext)).toBe('$82.50'); + expect(compute('{report:debitedAmount:nosymbol}', reimbursementContext)).toBe('82.50'); + expect(compute('{report:debitedAmount:USD}', reimbursementContext)).toBe('$82.50'); + }); + + test('should format creditedAmount in its bank-account currency', () => { + reimbursementContext.report.creditedAmount = 11000; + reimbursementContext.report.creditedCurrency = 'USD'; + + expect(compute('{report:creditedAmount}', reimbursementContext)).toBe('$110.00'); + }); + + test('should return empty when the amounts are missing', () => { + expect(compute('Debited {report:debitedAmount}', reimbursementContext)).toBe('Debited '); + expect(compute('Credited {report:creditedAmount}', reimbursementContext)).toBe('Credited '); + }); + + test('should keep the token when a currency conversion is needed', () => { + reimbursementContext.report.debitedAmount = 8250; + reimbursementContext.report.debitedCurrency = 'USD'; + + expect(compute('{report:debitedAmount:EUR}', reimbursementContext)).toBe('{report:debitedAmount:EUR}'); + }); + + test('should keep the token for an unrecognized display currency modifier', () => { + reimbursementContext.report.debitedAmount = 8250; + reimbursementContext.report.debitedCurrency = 'USD'; + + expect(compute('{report:debitedAmount:UNKNOWN}', reimbursementContext)).toBe('{report:debitedAmount:UNKNOWN}'); + }); + }); + describe('Function Modifiers', () => { const mockContext: FormulaContext = { getCurrencyDecimals: getCurrencyDecimalsLocal, diff --git a/tests/unit/GlobalReimbursementPayErrorTest.ts b/tests/unit/GlobalReimbursementPayErrorTest.ts new file mode 100644 index 000000000000..ff8213066656 --- /dev/null +++ b/tests/unit/GlobalReimbursementPayErrorTest.ts @@ -0,0 +1,177 @@ +import {WRITE_COMMANDS} from '@libs/API/types'; +import globalReimbursementPayError from '@libs/Middleware/GlobalReimbursementPayError'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {AnyOnyxUpdate} from '@src/types/onyx/Request'; +import type Request from '@src/types/onyx/Request'; +import type Response from '@src/types/onyx/Response'; + +import type {OnyxKey} from 'react-native-onyx'; + +const IOU_REPORT_ID = '12345'; +const REPORT_ACTION_ID = '67890'; +const OTHER_REPORT_ACTION_ID = '11111'; + +type PayRequestOverrides = Partial, 'failureData'>> & { + failureData?: AnyOnyxUpdate[]; +}; + +function buildPayRequest(overrides: PayRequestOverrides = {}): Request { + return { + command: WRITE_COMMANDS.PAY_MONEY_REQUEST, + data: { + iouReportID: IOU_REPORT_ID, + reportActionID: REPORT_ACTION_ID, + }, + failureData: [ + { + onyxMethod: 'merge', + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`, + value: { + [REPORT_ACTION_ID]: { + errors: {error: 'Failed to pay'}, + }, + [OTHER_REPORT_ACTION_ID]: { + errors: {error: 'Other error'}, + }, + }, + }, + { + onyxMethod: 'merge', + key: `${ONYXKEYS.COLLECTION.REPORT}${IOU_REPORT_ID}`, + value: { + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }, + }, + ], + ...overrides, + } as Request; +} + +function buildCorpayPayModalResponse(jsonCode: number | string = CONST.JSON_CODE.EXP_ERROR): Response { + return { + jsonCode, + onyxData: [ + { + onyxMethod: 'set', + key: ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL, + value: { + bankAccountID: 100, + bankCountry: CONST.COUNTRY.CA, + bankCurrency: CONST.CURRENCY.CAD, + }, + }, + ], + } as Response; +} + +function findFailureUpdate(request: Request, key: string) { + return request.failureData?.find((update) => update.key === key); +} + +describe('GlobalReimbursementPayError middleware', () => { + it('replaces optimistic PAY action error with null in failureData when payment fails with corpayPayModal', async () => { + const request = buildPayRequest(); + const response = buildCorpayPayModalResponse(); + + const result = await globalReimbursementPayError(Promise.resolve(response), request, false); + + expect(result).toBe(response); + const actionsUpdate = findFailureUpdate(request, `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`); + expect(actionsUpdate?.value).toEqual({ + [REPORT_ACTION_ID]: null, + [OTHER_REPORT_ACTION_ID]: {errors: {error: 'Other error'}}, + }); + }); + + it('works identically for PAY_MONEY_REQUEST_WITH_WALLET command', async () => { + const request = buildPayRequest({command: WRITE_COMMANDS.PAY_MONEY_REQUEST_WITH_WALLET}); + const response = buildCorpayPayModalResponse(); + + const result = await globalReimbursementPayError(Promise.resolve(response), request, false); + + expect(result).toBe(response); + const actionsUpdate = findFailureUpdate(request, `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`); + expect(actionsUpdate?.value).toEqual({ + [REPORT_ACTION_ID]: null, + [OTHER_REPORT_ACTION_ID]: {errors: {error: 'Other error'}}, + }); + }); + + it('leaves failureData untouched if response is successful (jsonCode 200)', async () => { + const request = buildPayRequest(); + const response = buildCorpayPayModalResponse(CONST.JSON_CODE.SUCCESS); + + await globalReimbursementPayError(Promise.resolve(response), request, false); + + const actionsUpdate = findFailureUpdate(request, `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`); + expect(actionsUpdate?.value).toEqual({ + [REPORT_ACTION_ID]: {errors: {error: 'Failed to pay'}}, + [OTHER_REPORT_ACTION_ID]: {errors: {error: 'Other error'}}, + }); + }); + + it('leaves failureData untouched for non-payment commands', async () => { + const request = buildPayRequest({command: WRITE_COMMANDS.APPROVE_MONEY_REQUEST}); + const response = buildCorpayPayModalResponse(); + + await globalReimbursementPayError(Promise.resolve(response), request, false); + + const actionsUpdate = findFailureUpdate(request, `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`); + expect(actionsUpdate?.value).toEqual({ + [REPORT_ACTION_ID]: {errors: {error: 'Failed to pay'}}, + [OTHER_REPORT_ACTION_ID]: {errors: {error: 'Other error'}}, + }); + }); + + it('leaves failureData untouched if response does not contain RAM_ONLY_CORPAY_PAY_MODAL', async () => { + const request = buildPayRequest(); + const response: Response = { + jsonCode: CONST.JSON_CODE.EXP_ERROR, + onyxData: [ + { + onyxMethod: 'merge', + key: `${ONYXKEYS.COLLECTION.REPORT}${IOU_REPORT_ID}`, + value: {statusNum: CONST.REPORT.STATUS_NUM.OPEN}, + }, + ], + } as Response; + + await globalReimbursementPayError(Promise.resolve(response), request, false); + + const actionsUpdate = findFailureUpdate(request, `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`); + expect(actionsUpdate?.value).toEqual({ + [REPORT_ACTION_ID]: {errors: {error: 'Failed to pay'}}, + [OTHER_REPORT_ACTION_ID]: {errors: {error: 'Other error'}}, + }); + }); + + it('handles missing or corrupt data fields gracefully', async () => { + const response = buildCorpayPayModalResponse(); + + // Missing iouReportID + const requestWithoutIou = buildPayRequest({data: {reportActionID: REPORT_ACTION_ID}}); + await expect(globalReimbursementPayError(Promise.resolve(response), requestWithoutIou, false)).resolves.toBe(response); + + // Missing reportActionID + const requestWithoutAction = buildPayRequest({data: {iouReportID: IOU_REPORT_ID}}); + await expect(globalReimbursementPayError(Promise.resolve(response), requestWithoutAction, false)).resolves.toBe(response); + + // Missing failureData + const requestWithoutFailureData = buildPayRequest({failureData: undefined}); + await expect(globalReimbursementPayError(Promise.resolve(response), requestWithoutFailureData, false)).resolves.toBe(response); + + // Non-object update value in failureData + const requestWithCorruptUpdate = buildPayRequest({ + failureData: [ + { + onyxMethod: 'merge', + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`, + value: 'invalid', + }, + ], + }); + await expect(globalReimbursementPayError(Promise.resolve(response), requestWithCorruptUpdate, false)).resolves.toBe(response); + }); +}); diff --git a/tests/unit/GoogleTagManagerTest.tsx b/tests/unit/GoogleTagManagerTest.tsx index 4e149a44fc5e..bb132b92b274 100644 --- a/tests/unit/GoogleTagManagerTest.tsx +++ b/tests/unit/GoogleTagManagerTest.tsx @@ -179,6 +179,7 @@ describe('GoogleTagManagerTest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, }); await waitForBatchedUpdatesWithAct(); createWorkspace({ @@ -192,6 +193,7 @@ describe('GoogleTagManagerTest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: true, + hasOwnedPaidPolicy: false, }); await waitForBatchedUpdatesWithAct(); createWorkspace({ @@ -205,6 +207,7 @@ describe('GoogleTagManagerTest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: true, + hasOwnedPaidPolicy: false, }); await waitForBatchedUpdatesWithAct(); @@ -226,6 +229,7 @@ describe('GoogleTagManagerTest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, companySize: CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM, }); @@ -249,6 +253,7 @@ describe('GoogleTagManagerTest', () => { isSelfTourViewed: false, betas: undefined, hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, companySize: CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM, }); diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index 988af802f64e..557a599683d5 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -16,6 +16,7 @@ import {hasAnyTransactionWithoutRTERViolation} from '@src/libs/TransactionUtils' import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Policy, Report, ReportAction, ReportMetadata, ReportNameValuePairs, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; import type {OnyxCollection} from 'react-native-onyx'; @@ -916,6 +917,27 @@ describe('getExistingTransactionID', () => { expect(result1.chatReportID).toBeDefined(); expect(result2.chatReportID).toBeDefined(); }); + + it('should use the preferred optimistic ID when no existing report is found', () => { + // Given a new chat whose caller already reserved an optimistic report ID + // When chat resolution cannot find an existing report + const result = IOUUtils.resolveOptimisticChatReportID([100001, 100002], undefined, 'preferred-123'); + + // Then the reserved ID is reused so related optimistic data stays aligned + expect(result.chatReportID).toBe('preferred-123'); + expect(result.optimisticChatReportID).toBe('preferred-123'); + }); + + it('should prefer an existing report over the preferred optimistic ID', () => { + // Given both an existing chat and a caller-reserved optimistic ID + const existingReport = {reportID: 'existing-123'} as Report; + // When chat resolution chooses the report identity + const result = IOUUtils.resolveOptimisticChatReportID([1, 2], existingReport, 'preferred-123'); + + // Then the persisted chat wins because no optimistic replacement is needed + expect(result.chatReportID).toBe('existing-123'); + expect(result.optimisticChatReportID).toBeUndefined(); + }); }); describe('resolveReportForMoneyRequest', () => { @@ -1234,6 +1256,36 @@ describe('isParticipantP2P', () => { }); }); +describe('getReusableP2PReportID', () => { + it('returns the transaction report ID for a brand-new P2P recipient', () => { + // Given a new P2P recipient without an existing chat + // When selecting an ID for its optimistic chat + // Then the transaction ID is reused so both optimistic records share an identity + expect(IOUUtils.getReusableP2PReportID({} as Participant, '123')).toBe('123'); + }); + + it('does not return the transaction report ID for an existing P2P chat', () => { + // Given a P2P recipient already linked to a persisted chat + // When selecting an ID for request creation + // Then no reusable ID is supplied because the existing chat remains authoritative + expect(IOUUtils.getReusableP2PReportID({reportID: '456'} as Participant, '123')).toBeUndefined(); + }); + + it('does not return the transaction report ID for a workspace chat', () => { + // Given a workspace recipient whose chat identity follows policy routing + // When selecting an optimistic P2P report ID + // Then reuse is rejected because workspace chats are not P2P destinations + expect(IOUUtils.getReusableP2PReportID({isPolicyExpenseChat: true} as Participant, '123')).toBeUndefined(); + }); + + it('does not return the unreported report ID', () => { + // Given a new recipient whose transaction still uses the unreported sentinel + // When selecting an optimistic chat identity + // Then the sentinel is rejected because it cannot identify a real chat + expect(IOUUtils.getReusableP2PReportID({} as Participant, CONST.REPORT.UNREPORTED_REPORT_ID)).toBeUndefined(); + }); +}); + describe('reportHasRealPolicy', () => { it('should return false for the placeholder self-DM policy', () => { expect(IOUUtils.reportHasRealPolicy({...createRandomReport(1), policyID: CONST.POLICY.ID_FAKE})).toBe(false); diff --git a/tests/unit/MiddlewareEntryPointTest.ts b/tests/unit/MiddlewareEntryPointTest.ts index 0dae8188c95c..0710bbc4a8cd 100644 --- a/tests/unit/MiddlewareEntryPointTest.ts +++ b/tests/unit/MiddlewareEntryPointTest.ts @@ -17,11 +17,11 @@ jest.mock('@src/setup/platformSetup', () => jest.fn()); jest.mock('@src/setup/telemetry', () => jest.fn()); describe('src/setup attaches the API middlewares', () => { - it('registers all 14 middlewares when the composition root runs', () => { + it('registers all 15 middlewares when the composition root runs', () => { expect(jest.mocked(addMiddleware)).not.toHaveBeenCalled(); appSetup(); - expect(jest.mocked(addMiddleware)).toHaveBeenCalledTimes(14); + expect(jest.mocked(addMiddleware)).toHaveBeenCalledTimes(15); }); }); diff --git a/tests/unit/MiddlewareRegistrationTest.ts b/tests/unit/MiddlewareRegistrationTest.ts index f6ce272e9399..593f06b99788 100644 --- a/tests/unit/MiddlewareRegistrationTest.ts +++ b/tests/unit/MiddlewareRegistrationTest.ts @@ -1,6 +1,7 @@ import { FailureTracking, FraudMonitoring, + GlobalReimbursementPayError, handleDeletedAccount, HandleMovedScanFailedExpenses, HandleUnusedOptimisticID, @@ -30,6 +31,7 @@ const EXPECTED_ORDER: RequestModule.Middleware[] = [ Reauthentication, handleDeletedAccount, SupportalPermission, + GlobalReimbursementPayError, HandleUnusedOptimisticID, Pagination, SentryServerTiming, @@ -52,9 +54,9 @@ describe('Middleware registration', () => { expect(registered).toEqual(EXPECTED_ORDER); }); - it('registers all 14 middlewares with no duplicates', () => { - expect(registered).toHaveLength(14); - expect(new Set(registered).size).toBe(14); + it('registers all 15 middlewares with no duplicates', () => { + expect(registered).toHaveLength(15); + expect(new Set(registered).size).toBe(15); }); it('keeps SaveResponseInOnyx after every other Onyx-writing middleware and before FraudMonitoring', () => { diff --git a/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts b/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts index de4991a376f6..591598d3e49c 100644 --- a/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts +++ b/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts @@ -14,6 +14,8 @@ import SCREENS from '@src/SCREENS'; import type {CommonActions, NavigationState, ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; +import {StackRouter} from '@react-navigation/native'; + import createMock from '../../../utils/createMock'; // Stub the linking parser so the test does not depend on the production linking config. @@ -108,13 +110,17 @@ function makeExistingState( return makeStackState([tabNavRoute, makeRHPRoute()]); } -function makeAction(): ReplaceFullscreenUnderRHPActionType { +function makeAction(shouldInsertPreMountBuffer?: boolean): ReplaceFullscreenUnderRHPActionType { return { type: CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP, - payload: {route: ROUTES.WORKSPACE_INITIAL.getRoute('NEW')}, + payload: {route: ROUTES.WORKSPACE_INITIAL.getRoute('NEW'), shouldInsertPreMountBuffer}, }; } +function getBufferRoute(result: StackNavigationState | null) { + return result?.routes.find((r) => r.name === SCREENS.PRE_MOUNT_BUFFER); +} + function makeReportsParsedState(reportID: string): PartialState { return { routes: [ @@ -141,10 +147,10 @@ function makeReportsAction(reportID: string): ReplaceFullscreenUnderRHPActionTyp }; } -function makeRemoveAction(): RemoveFullscreenUnderRHPActionType { +function makeRemoveAction(expectedRouteName: string = NAVIGATORS.TAB_NAVIGATOR): RemoveFullscreenUnderRHPActionType { return { type: CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP, - payload: {expectedRouteName: NAVIGATORS.TAB_NAVIGATOR}, + payload: {expectedRouteName}, }; } @@ -478,3 +484,85 @@ describe('handleReplaceFullscreenUnderRHP — WORKSPACE_NAVIGATOR seeding', () = expect(result).toBeNull(); }); }); + +describe('handleReplaceFullscreenUnderRHP / handleRemoveFullscreenUnderRHP — shouldInsertPreMountBuffer', () => { + it('inserts the buffer route directly under the RHP on the tab-switch path when shouldInsertPreMountBuffer is true', () => { + // Given a tab-switch pre-insert that needs protection from native RHP dismissal + mockStubbedParsedState = makeParsedState(INCOMING_SPLIT_ONLY); + // When the root state handler replaces the fullscreen destination + const result = handleReplaceFullscreenUnderRHP(makeExistingState(undefined), makeAction(true), CONFIG_OPTIONS, stackRouter); + + // Then the buffer sits next to the RHP so it intercepts an interrupted transition + expect(getBufferRoute(result)?.key).toBe(`pre-mount-buffer-${makeRHPRoute().key}`); + expect(result?.routes.at(-2)?.name).toBe(SCREENS.PRE_MOUNT_BUFFER); + expect(result?.routes.at(-1)?.name).toBe(NAVIGATORS.RIGHT_MODAL_NAVIGATOR); + }); + + it('does not insert a buffer route on the tab-switch path when shouldInsertPreMountBuffer is false/undefined', () => { + // Given a tab-switch pre-insert that does not need swipe-dismiss protection + mockStubbedParsedState = makeParsedState(INCOMING_SPLIT_ONLY); + // When the root state handler replaces the fullscreen destination + const result = handleReplaceFullscreenUnderRHP(makeExistingState(undefined), makeAction(false), CONFIG_OPTIONS, stackRouter); + + // Then no buffer is added because the caller opted out of recovery routing + expect(getBufferRoute(result)).toBeUndefined(); + expect(result?.routes.at(-1)?.name).toBe(NAVIGATORS.RIGHT_MODAL_NAVIGATOR); + }); + + it('inserts the buffer route directly under the RHP on the push path when shouldInsertPreMountBuffer is true', () => { + // Given a pushed fullscreen destination that needs protection from RHP dismissal + const routeNames = [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, NAVIGATORS.RIGHT_MODAL_NAVIGATOR]; + const realStackRouter = StackRouter({}); + const configOptions: RouterConfigOptions = {routeNames, routeParamList: {}, routeGetIdList: {}}; + mockStubbedParsedState = {routes: [{name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}]}; + const existing = makeStackState([ + makeRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, undefined, undefined, 'search-key'), + makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, undefined, undefined, 'rhp-key'), + ]); + + // When the root state handler inserts the pushed destination + const result = handleReplaceFullscreenUnderRHP(existing, makeAction(true), configOptions, realStackRouter); + + // Then the buffer is adjacent to the RHP so recovery can remove the speculative push + expect(getBufferRoute(result)?.key).toBe('pre-mount-buffer-rhp-key'); + expect(result?.routes.at(-2)?.name).toBe(SCREENS.PRE_MOUNT_BUFFER); + expect(result?.routes.at(-1)?.name).toBe(NAVIGATORS.RIGHT_MODAL_NAVIGATOR); + }); + + it('does not insert a buffer route on the push path when shouldInsertPreMountBuffer is false/undefined', () => { + // Given a pushed fullscreen destination without swipe-dismiss buffering enabled + const routeNames = [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, NAVIGATORS.RIGHT_MODAL_NAVIGATOR]; + const realStackRouter = StackRouter({}); + const configOptions: RouterConfigOptions = {routeNames, routeParamList: {}, routeGetIdList: {}}; + mockStubbedParsedState = {routes: [{name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}]}; + const existing = makeStackState([ + makeRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, undefined, undefined, 'search-key'), + makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, undefined, undefined, 'rhp-key'), + ]); + + // When the root state handler inserts the pushed destination + const result = handleReplaceFullscreenUnderRHP(existing, makeAction(false), configOptions, realStackRouter); + + // Then the route stack stays buffer-free because recovery was not requested + expect(getBufferRoute(result)).toBeUndefined(); + expect(result?.routes.at(-1)?.name).toBe(NAVIGATORS.RIGHT_MODAL_NAVIGATOR); + }); + + it('cancel (tab restore) drops any buffer route left between the restored tab and the RHP', () => { + // Given a tab-switch buffer route that's still around at cancel time (normally stripped earlier; + // this is a defensive check in case that ever stops happening) + mockStubbedParsedState = makeParsedState(INCOMING_SPLIT_ONLY); + const insertResult = handleReplaceFullscreenUnderRHP(makeExistingState(undefined), makeAction(true), CONFIG_OPTIONS, stackRouter); + expect(getBufferRoute(insertResult)).not.toBeUndefined(); + if (!insertResult) { + throw new Error('Expected handleReplaceFullscreenUnderRHP to return a state.'); + } + + // When the root handler restores the original tab after cancellation + const removeResult = handleRemoveFullscreenUnderRHP(insertResult, makeRemoveAction(NAVIGATORS.WORKSPACE_NAVIGATOR), CONFIG_OPTIONS, stackRouter); + + // Then the leftover buffer is discarded so it cannot surface after the RHP closes + expect(getBufferRoute(removeResult)).toBeUndefined(); + expect(removeResult?.routes.at(-1)?.name).toBe(NAVIGATORS.RIGHT_MODAL_NAVIGATOR); + }); +}); diff --git a/tests/unit/Navigation/preMountBufferTest.ts b/tests/unit/Navigation/preMountBufferTest.ts new file mode 100644 index 000000000000..b9e2f4bfc2fd --- /dev/null +++ b/tests/unit/Navigation/preMountBufferTest.ts @@ -0,0 +1,481 @@ +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationState, PartialState} from '@react-navigation/native'; + +import {DeviceEventEmitter} from 'react-native'; + +type MockRoute = {key: string; name: string; state?: PartialState; params?: {reportID?: string}}; +type MockAction = {type: string; payload?: {routes?: MockRoute[]; index?: number; shouldInsertPreMountBuffer?: boolean}}; + +let mockRootState: {key: string; routes: MockRoute[]; index: number; routeNames?: string[]; stale?: boolean} | undefined; +const mockDispatch = jest.fn(); +let mockStateListener: (() => void) | undefined; + +jest.mock('@libs/Navigation/navigationRef', () => ({ + __esModule: true, + default: { + isReady: () => true, + getRootState: () => mockRootState, + getState: () => mockRootState, + get current() { + return { + getRootState: () => mockRootState, + dispatch: mockDispatch, + addListener: (event: string, cb: () => void) => { + if (event !== 'state') { + return () => undefined; + } + mockStateListener = cb; + return () => { + mockStateListener = undefined; + }; + }, + }; + }, + }, +})); + +let mockIsNarrowLayout = true; +jest.mock('@libs/getIsNarrowLayout', () => ({ + __esModule: true, + default: () => mockIsNarrowLayout, +})); + +let mockStateFromPathRoutes: MockRoute[] = []; +jest.mock('@libs/Navigation/helpers/getStateFromPath', () => ({ + __esModule: true, + default: () => ({routes: mockStateFromPathRoutes}), +})); + +let mockOriginalTabRoute: MockRoute | undefined; +const mockClearPreInsertedOriginalTabRoute = jest.fn(() => { + mockOriginalTabRoute = undefined; +}); +jest.mock('@libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers', () => ({ + __esModule: true, + getPreInsertedOriginalTabRoute: () => mockOriginalTabRoute, + clearPreInsertedOriginalTabRoute: () => mockClearPreInsertedOriginalTabRoute(), +})); + +const RHP_KEY = 'rhp-1'; +const DEST_KEY = 'dest-1'; +const BUFFER_KEY = `pre-mount-buffer-${RHP_KEY}`; +const ORIGIN_KEY = 'origin-1'; + +function setRootState(routes: MockRoute[]) { + mockRootState = {key: 'root', routes, index: routes.length - 1, routeNames: routes.map((r) => r.name), stale: false}; +} + +describe('Navigation pre-mount buffer', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsNarrowLayout = true; + mockOriginalTabRoute = undefined; + mockStateFromPathRoutes = [{key: 'target', name: NAVIGATORS.WORKSPACE_NAVIGATOR}]; + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, + ]); + }); + + afterEach(() => { + // helpers/preMountBuffer.ts tracks the buffer transaction / pre-insert flag as module-level state with + // no exported reset, so a dirty flag from one test silently no-ops the next test's pre-insert. + if (!Navigation.getIsFullscreenPreInsertedUnderRHP()) { + return; + } + Navigation.clearFullscreenPreInsertedFlag(); + }); + + function preInsertAndCaptureBuffer() { + // Simulate the reducer's effect: destination pushed under a fresh Buffer, RHP stays on top. + mockDispatch.mockImplementationOnce(() => { + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, + ]); + }); + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + } + + it('inserts a pre-mount buffer directly under the RHP when pre-inserting a fullscreen destination', () => { + // Given a narrow layout with an RHP above the current fullscreen route + // When a fullscreen destination is speculatively inserted beneath the RHP + preInsertAndCaptureBuffer(); + + // Then a buffer protects the origin from becoming visible during swipe dismissal + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(true); + expect(mockRootState?.routes.map((r) => r.name)).toEqual([SCREENS.REPORT, NAVIGATORS.WORKSPACE_NAVIGATOR, SCREENS.PRE_MOUNT_BUFFER, NAVIGATORS.RIGHT_MODAL_NAVIGATOR]); + }); + + it('does not insert a pre-mount buffer when the focused RHP inner flow can handle the native swipe', () => { + // Given an RHP with inner navigation history that can consume the swipe gesture + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + { + key: RHP_KEY, + name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR, + state: { + index: 0, + routes: [ + { + key: 'rhp-stack', + name: 'RHPStack', + state: { + index: 1, + routes: [ + {key: 'inner-1', name: 'InnerOne'}, + {key: 'inner-2', name: 'InnerTwo'}, + ], + }, + }, + ], + }, + }, + ]); + mockDispatch.mockImplementationOnce((action) => { + expect(action).toEqual(expect.objectContaining({payload: expect.objectContaining({shouldInsertPreMountBuffer: false})})); + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, + ]); + }); + + // When a fullscreen destination is pre-inserted beneath that nested flow + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + + // Then no buffer is needed because the inner flow owns gesture recovery + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockRootState?.routes.some((route) => route.name === SCREENS.PRE_MOUNT_BUFFER)).toBe(false); + }); + + it('defaults to inserting a pre-mount buffer when the topmost route is not the RHP', () => { + // Given navigation state where the RHP topology cannot be inspected + setRootState([{key: ORIGIN_KEY, name: SCREENS.REPORT}]); + mockDispatch.mockImplementationOnce((action) => { + expect(action).toEqual(expect.objectContaining({payload: expect.objectContaining({shouldInsertPreMountBuffer: true})})); + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + ]); + }); + + // When a fullscreen destination is pre-inserted + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + + // Then the safer buffered behavior is requested because swipe handling is unknown + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + + it('confirm: clearFullscreenPreInsertedFlag strips only the Buffer route, keeping the destination', () => { + // Given a live speculative destination protected by a buffer + preInsertAndCaptureBuffer(); + mockDispatch.mockClear(); + + // When submission confirms that the destination should remain mounted + Navigation.clearFullscreenPreInsertedFlag(); + + // Then only the temporary buffer is removed because the destination is now valid + expect(mockDispatch).toHaveBeenCalledTimes(1); + const resetAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(resetAction?.type).toBe('RESET'); + expect(resetAction?.payload?.routes?.map((r) => r.key)).toEqual([ORIGIN_KEY, DEST_KEY, RHP_KEY]); + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(false); + }); + + it('cancel: removePreInsertedFullscreenIfNeeded strips both the Buffer and the speculative destination', () => { + // Given a live speculative destination protected by a buffer + preInsertAndCaptureBuffer(); + mockDispatch.mockClear(); + + // When submission is canceled before the destination becomes valid + Navigation.removePreInsertedFullscreenIfNeeded(); + + // Then all speculative routes are removed so navigation returns to its origin + // First dispatch strips the Buffer only (removeBufferRouteOnly), second removes the destination itself. + expect(mockDispatch).toHaveBeenCalledTimes(2); + const bufferStripAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(bufferStripAction?.payload?.routes?.map((r) => r.key)).toEqual([ORIGIN_KEY, DEST_KEY, RHP_KEY]); + + const removeFullscreenAction = mockDispatch.mock.calls.at(1)?.at(0); + expect(removeFullscreenAction?.type).toBe(CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP); + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(false); + }); + + it('dismissModalWithReport clears the live buffer when dismissing to the already topmost report', () => { + // Given a buffered destination above the report that dismissal targets + const reportID = 'report-1'; + preInsertAndCaptureBuffer(); + setRootState([ + { + key: ORIGIN_KEY, + name: NAVIGATORS.TAB_NAVIGATOR, + state: { + index: 0, + routes: [ + { + key: 'reports-split', + name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, + state: {index: 0, routes: [{key: 'report-screen', name: SCREENS.REPORT, params: {reportID}}]}, + }, + ], + }, + }, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, + ]); + mockDispatch.mockClear(); + + // When the modal dismisses to a report already visible in the tab state + Navigation.dismissModalWithReport({reportID}); + + // Then the stale buffer is cleared because no speculative transition remains + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(false); + const bufferStripAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(bufferStripAction?.payload?.routes?.map((route) => route.key)).toEqual([ORIGIN_KEY, DEST_KEY, RHP_KEY]); + }); + + it('native swipe-dismiss while the buffer is live atomically strips the destination and Buffer, restoring the origin', () => { + // Given a live buffered transaction whose RHP disappears outside confirm or cancel + preInsertAndCaptureBuffer(); + mockDispatch.mockClear(); + const restoreAnimationSpy = jest.spyOn(DeviceEventEmitter, 'emit'); + + // RHP got removed by something other than our own confirm/cancel path (native gesture, predictive-back). + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + ]); + // When the navigation listener observes the native swipe dismissal + mockStateListener?.(); + + // Then origin restoration is atomic so no speculative screen can flash + expect(restoreAnimationSpy).toHaveBeenCalledWith(CONST.MODAL_EVENTS.RESTORE_RHP_ANIMATION); + expect(mockDispatch).toHaveBeenCalledTimes(1); + const resetAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(resetAction?.payload?.routes?.map((r) => r.key)).toEqual([ORIGIN_KEY]); + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(false); + + restoreAnimationSpy.mockRestore(); + }); + + it('RHP-closed listener is a no-op while the RHP route is still present', () => { + // Given a buffered transaction whose RHP remains mounted + preInsertAndCaptureBuffer(); + mockDispatch.mockClear(); + + // When an unrelated navigation state update reaches the dismissal listener + mockStateListener?.(); + + // Then recovery is skipped because the protected transition is still active + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('tab-switch mode: RHP-closed listener restores the original tab route instead of popping a pushed destination', () => { + // Given a buffered tab switch that replaced the original tab route beneath the RHP + mockOriginalTabRoute = {key: 'tab-origin', name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.INBOX}]} as PartialState}; + mockStateFromPathRoutes = [{key: 'target', name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.SEARCH.ROOT}]} as PartialState}]; + + const TAB_KEY = 'tab-1'; + setRootState([ + {key: TAB_KEY, name: NAVIGATORS.TAB_NAVIGATOR}, + {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, + ]); + + mockDispatch.mockImplementationOnce(() => { + setRootState([ + {key: TAB_KEY, name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.SEARCH.ROOT}]} as PartialState}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, + ]); + }); + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + mockDispatch.mockClear(); + + // RHP dismissed externally while the tab-switch buffer transaction is still live. + setRootState([ + {key: TAB_KEY, name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.SEARCH.ROOT}]} as PartialState}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + ]); + // When the RHP is dismissed outside the normal completion paths + mockStateListener?.(); + + // Then the original tab is restored because there is no pushed route to pop + expect(mockDispatch).toHaveBeenCalledTimes(1); + const resetAction = mockDispatch.mock.calls.at(0)?.at(0); + const restoredTabRoute = resetAction?.payload?.routes?.find((r) => r.key === TAB_KEY); + expect(restoredTabRoute).toEqual(mockOriginalTabRoute); + expect(resetAction?.payload?.routes?.some((r) => r.key === BUFFER_KEY)).toBe(false); + }); + + it('tab-switch mode falls back to stripping the Buffer when the original tab route was cleared', () => { + // Given a buffered tab switch whose saved origin is no longer available + mockOriginalTabRoute = {key: 'tab-origin', name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.INBOX}]} as PartialState}; + mockStateFromPathRoutes = [{key: 'target', name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.SEARCH.ROOT}]} as PartialState}]; + + const TAB_KEY = 'tab-1'; + const switchedTabRoute = {key: TAB_KEY, name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.SEARCH.ROOT}]} as PartialState}; + setRootState([ + {key: TAB_KEY, name: NAVIGATORS.TAB_NAVIGATOR}, + {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, + ]); + mockDispatch.mockImplementationOnce(() => { + setRootState([switchedTabRoute, {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, {key: RHP_KEY, name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}]); + }); + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + mockDispatch.mockClear(); + mockOriginalTabRoute = undefined; + + setRootState([switchedTabRoute, {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}]); + // When external dismissal triggers recovery + mockStateListener?.(); + + // Then only the buffer is stripped because the current tab cannot be safely replaced + expect(mockDispatch).toHaveBeenCalledTimes(1); + const resetAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(resetAction?.payload?.routes).toEqual([switchedTabRoute]); + }); + + it('guard: preInsertFullscreenUnderRHP is a no-op on wide layout (buffer only guards a swipe gesture that exists on narrow)', () => { + // Given a wide layout without the narrow-screen swipe dismissal risk + mockIsNarrowLayout = false; + + // When speculative fullscreen insertion is requested + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + + // Then navigation stays unchanged because the buffer has no purpose on wide screens + expect(mockDispatch).not.toHaveBeenCalled(); + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(false); + }); + + it('guard: preInsertFullscreenUnderRHP is a no-op on a repeated call while already pre-inserted', () => { + // Given an active pre-insert transaction already tracking its recovery state + preInsertAndCaptureBuffer(); + mockDispatch.mockClear(); + + // When another pre-insert is requested before the first one finishes + // eslint-disable-next-line rulesdir/no-direct-pre-insert-fullscreen-under-rhp -- unit-testing the guarded function itself, not a production call site + Navigation.preInsertFullscreenUnderRHP(ROUTES.HOME); + + // Then it is ignored so the original recovery snapshot is not overwritten + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('guard: removePreInsertedFullscreenIfNeeded is a no-op when nothing was pre-inserted', () => { + // Given no active speculative navigation transaction + const restoreAnimationSpy = jest.spyOn(DeviceEventEmitter, 'emit'); + + // When cancellation cleanup is called defensively + Navigation.removePreInsertedFullscreenIfNeeded(); + + // Then navigation and animation stay untouched because there is nothing to restore + expect(mockDispatch).not.toHaveBeenCalled(); + expect(restoreAnimationSpy).not.toHaveBeenCalled(); + restoreAnimationSpy.mockRestore(); + }); + + it('guard: removePreInsertedFullscreenIfNeeded backs off when the RHP is gone and the buffer transaction is still live', () => { + // Given external dismissal has removed the RHP from a live buffered transaction + preInsertAndCaptureBuffer(); + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + ]); + mockDispatch.mockClear(); + + // When the normal cancellation path races with listener-based recovery + Navigation.removePreInsertedFullscreenIfNeeded(); + + // Then cancellation backs off so the listener remains the single recovery owner + expect(mockDispatch).not.toHaveBeenCalled(); + + mockStateListener?.(); + }); + + it('guard: recoverFromPreMountBuffer is a no-op when the topmost route is not the Buffer screen', () => { + // Given ordinary navigation state without a stranded topmost buffer + // When startup recovery checks for an interrupted transition + Navigation.recoverFromPreMountBuffer(); + + // Then navigation remains unchanged because no recovery evidence exists + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('recoverFromPreMountBuffer with a live transaction (app resumed while a buffer route was stranded on top) delegates to the same atomic reset as the native-swipe path', () => { + // Given app resume exposes a stranded buffer from a still-live transaction + preInsertAndCaptureBuffer(); + mockDispatch.mockClear(); + + // RHP already gone; Buffer is the new topmost route (e.g. app was backgrounded mid-transition). + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + ]); + + // When buffer recovery runs after the interrupted transition + Navigation.recoverFromPreMountBuffer(); + + // Then the saved origin is restored atomically to avoid showing speculative state + expect(mockDispatch).toHaveBeenCalledTimes(1); + const resetAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(resetAction?.payload?.routes?.map((r) => r.key)).toEqual([ORIGIN_KEY]); + expect(Navigation.getIsFullscreenPreInsertedUnderRHP()).toBe(false); + }); + + it('recoverFromPreMountBuffer falls back to stripping Buffer + the speculative destination when the transaction itself was lost (e.g. a cold restart)', () => { + // Given a cold restart retained routes but lost the in-memory recovery transaction + // No preceding preInsert call: bufferTransaction is not live, simulating a lost/never-captured transaction. + setRootState([ + {key: ORIGIN_KEY, name: SCREENS.REPORT}, + {key: DEST_KEY, name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + ]); + + // When startup detects the stranded buffer + Navigation.recoverFromPreMountBuffer(); + + // Then it removes both temporary routes because no richer snapshot survives + expect(mockDispatch).toHaveBeenCalledTimes(1); + const resetAction = mockDispatch.mock.calls.at(0)?.at(0); + expect(resetAction?.payload?.routes?.map((r) => r.key)).toEqual([ORIGIN_KEY]); + }); + + it('recoverFromPreMountBuffer fallback restores the original tab route when the stranded buffer came from a tab switch', () => { + // Given a stranded tab-switch buffer with its original tab snapshot still available + const TAB_KEY = 'tab-1'; + mockOriginalTabRoute = {key: 'tab-origin', name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.INBOX}]} as PartialState}; + setRootState([ + {key: TAB_KEY, name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{name: SCREENS.SEARCH.ROOT}]} as PartialState}, + {key: BUFFER_KEY, name: SCREENS.PRE_MOUNT_BUFFER}, + ]); + + // When startup recovery handles the interrupted tab switch + Navigation.recoverFromPreMountBuffer(); + + // Then it restores the saved tab because stripping a pushed destination is insufficient + expect(mockDispatch).toHaveBeenCalledTimes(1); + const resetAction = mockDispatch.mock.calls.at(0)?.at(0); + const restoredTabRoute = resetAction?.payload?.routes?.find((r) => r.key === TAB_KEY); + expect(restoredTabRoute).toEqual(mockOriginalTabRoute); + expect(resetAction?.payload?.routes?.some((r) => r.key === BUFFER_KEY)).toBe(false); + expect(mockClearPreInsertedOriginalTabRoute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/OnyxUpdatesPusherChainFailureTest.ts b/tests/unit/OnyxUpdatesPusherChainFailureTest.ts new file mode 100644 index 000000000000..72801c755bca --- /dev/null +++ b/tests/unit/OnyxUpdatesPusherChainFailureTest.ts @@ -0,0 +1,75 @@ +import PusherUtils from '@libs/PusherUtils'; + +import CONST from '@src/CONST'; +import {apply, doesClientNeedToBeUpdated} from '@src/libs/actions/OnyxUpdates'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxUpdatesFromServer} from '@src/types/onyx'; + +import type {OnyxKey} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const pusherUpdate = (previousUpdateID: number, lastUpdateID: number): OnyxUpdatesFromServer => ({ + type: CONST.ONYX_UPDATE_TYPES.PUSHER, + previousUpdateID, + lastUpdateID, + updates: [{eventType: 'onyxApiUpdate', data: []}], +}); + +// A rejected Pusher apply leaves the module-scoped pusherEventsPromise rejected for the rest of the module's life, +// so this lives in its own file rather than poisoning the chain for the other tests. +describe('OnyxUpdates, when a Pusher apply fails', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + + it('relies on pusherEventsPromise staying rejected to stop a follower whose gap check the failed update had suppressed', async () => { + // Given the client is caught up to update 10 and update 20 from Pusher is held mid-apply + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); + await waitForBatchedUpdates(); + + let failHeldApply: (error: Error) => void = () => {}; + let handlerCallCount = 0; + const handlerSpy = jest.spyOn(PusherUtils, 'triggerMultiEventHandler').mockImplementation(() => { + handlerCallCount += 1; + if (handlerCallCount > 1) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + failHeldApply = reject; + }); + }); + const heldApply = apply(pusherUpdate(10, 20)); + await waitForBatchedUpdates(); + + // When update 30 arrives chained on it, so the pending marker tells it there is no gap + expect(doesClientNeedToBeUpdated({previousUpdateID: 20, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(false); + const followerApply = apply(pusherUpdate(20, 30)); + await waitForBatchedUpdates(); + + // And update 20 then fails to apply + failHeldApply(new Error('storage write failed')); + await expect(heldApply).rejects.toThrow('storage write failed'); + await expect(followerApply).rejects.toThrow('storage write failed'); + await waitForBatchedUpdates(); + + // Then update 30 is never written either. Nothing checks that update IDs are contiguous before advancing the + // watermark, so were it written the watermark would move to 30 and updates 11 to 20 would be lost with no gap + // left to trigger recovery. Serializing on pusherEventsPromise is the only thing preventing that. + expect(handlerCallCount).toBe(1); + expect(await getOnyxValue(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT)).toBe(10); + + // And both updates are back in the gap, so recovery can refetch them + expect(doesClientNeedToBeUpdated({previousUpdateID: 20, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); + expect(doesClientNeedToBeUpdated({previousUpdateID: 30, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); + + handlerSpy.mockRestore(); + }); +}); diff --git a/tests/unit/OnyxUpdatesTest.ts b/tests/unit/OnyxUpdatesTest.ts index 0fee69e61e0a..271d0ba4571b 100644 --- a/tests/unit/OnyxUpdatesTest.ts +++ b/tests/unit/OnyxUpdatesTest.ts @@ -1,4 +1,5 @@ import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; +import PusherUtils from '@libs/PusherUtils'; import CONST from '@src/CONST'; import * as OnyxUpdates from '@src/libs/actions/OnyxUpdates'; @@ -24,6 +25,12 @@ describe('OnyxUpdatesTest', () => { beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + let releaseHeldApply: (() => void) | undefined; + afterEach(() => { + releaseHeldApply?.(); + releaseHeldApply = undefined; + }); + it('applies Airship Onyx updates correctly', () => { const reportID = NumberUtils.rand64(); const reportActionID = NumberUtils.rand64(); @@ -249,7 +256,7 @@ describe('OnyxUpdatesTest', () => { // Then a following response chained on update 20 is not treated as a gap, even though the // persisted watermark is still at 10 — otherwise every queued WRITE would pause the queue - expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 20})).toBe(false); + expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 20, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(false); // And once the flush applies the staged updates, the persisted watermark catches up await flushQueue(); @@ -302,19 +309,157 @@ describe('OnyxUpdatesTest', () => { }, }); await waitForBatchedUpdates(); - expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 20})).toBe(false); + expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 20, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(false); // When the user signs out, which clears Onyx storage await Onyx.clear(); await waitForBatchedUpdates(); // Then the pending watermark from the previous session no longer masks gaps in the new session - expect(OnyxUpdates.doesClientNeedToBeUpdated({clientLastUpdateID: 5, previousUpdateID: 15})).toBe(true); + expect(OnyxUpdates.doesClientNeedToBeUpdated({clientLastUpdateID: 5, previousUpdateID: 15, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); // Drain the staged updates so they don't leak into other tests await flushQueue(); }); + const applyHeldPusherUpdate = (previousUpdateID: number, lastUpdateID: number) => { + let releaseApply: () => void = () => {}; + const handlerSpy = jest.spyOn(PusherUtils, 'triggerMultiEventHandler').mockReturnValueOnce( + new Promise((resolve) => { + releaseApply = resolve; + releaseHeldApply = resolve; + }), + ); + const applyPromise = OnyxUpdates.apply({ + type: CONST.ONYX_UPDATE_TYPES.PUSHER, + previousUpdateID, + lastUpdateID, + updates: [{eventType: 'onyxApiUpdate', data: []}], + }); + + return { + release: () => { + releaseApply(); + handlerSpy.mockRestore(); + return applyPromise; + }, + }; + }; + + it('does not report a gap for a Pusher update that is still applying', async () => { + // Given the client is caught up to update 10 + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); + await waitForBatchedUpdates(); + + // When update 20 arrives over Pusher and its apply is held mid-flight + const heldApply = applyHeldPusherUpdate(10, 20); + await waitForBatchedUpdates(); + + // Then the next event, chained on update 20, is not treated as a gap even though the watermark is still at 10 + expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 20, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(false); + + await heldApply.release(); + }); + + it('reports a gap for an HTTPS response chained on a Pusher update that is still applying', async () => { + // Given the client is caught up to update 10 + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); + await waitForBatchedUpdates(); + + // When update 20 arrives over Pusher and its apply is held mid-flight + const heldApply = applyHeldPusherUpdate(10, 20); + await waitForBatchedUpdates(); + + // Then an HTTPS response chained on update 20 still reports the gap, because its apply runs on its own + // promise chain and would advance the watermark past the updates the held apply has not written yet + expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 20})).toBe(true); + + await heldApply.release(); + }); + + it('keeps a Pusher update that is still applying out of the catch-up fetch range', async () => { + // Given the client is caught up to update 10 + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); + await waitForBatchedUpdates(); + + // When update 20 arrives over Pusher and its apply is held mid-flight + const heldApply = applyHeldPusherUpdate(10, 20); + await waitForBatchedUpdates(); + + // Then a genuinely later gap is still detected + expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 30, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); + + // And it fetches from the persisted watermark, so a rejected apply cannot strand update 20 + expect(OnyxUpdates.getEffectiveLastUpdateID()).toBe(10); + + await heldApply.release(); + }); + + it('clears the pending apply watermark on sign-out', async () => { + // Given the client is caught up to update 10 and update 20 from Pusher is held mid-apply + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); + await waitForBatchedUpdates(); + const heldApply = applyHeldPusherUpdate(10, 20); + await waitForBatchedUpdates(); + + // When the user signs out, which clears Onyx storage + await Onyx.clear(); + await waitForBatchedUpdates(); + + // Then the pending marker from the previous session no longer masks gaps in the new session + expect(OnyxUpdates.doesClientNeedToBeUpdated({clientLastUpdateID: 5, previousUpdateID: 15, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); + + // And the held apply settling afterwards does not reintroduce it + await heldApply.release(); + await waitForBatchedUpdates(); + expect(OnyxUpdates.doesClientNeedToBeUpdated({clientLastUpdateID: 5, previousUpdateID: 15, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); + }); + + it('clears the Pusher pending apply marker when an unrelated apply fails below it, so a real gap is never masked', async () => { + // Given the client is caught up to update 10 and update 20 from Pusher is held mid-apply + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); + await waitForBatchedUpdates(); + const heldApply = applyHeldPusherUpdate(10, 20); + await waitForBatchedUpdates(); + + // When an unrelated READ request's apply fails at update 15, below the held Pusher update + const updateSpy = jest.spyOn(Onyx, 'update').mockRejectedValueOnce(new Error('storage write failed')); + await expect( + OnyxUpdates.apply({ + type: CONST.ONYX_UPDATE_TYPES.HTTPS, + previousUpdateID: 10, + lastUpdateID: 15, + request: {command: 'OpenReport', data: {apiRequestType: CONST.API_REQUEST_TYPE.READ}}, + response: { + jsonCode: 200, + onyxData: [{onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.REPORT}${NumberUtils.rand64()}`, value: {}}], + }, + }), + ).rejects.toThrow('storage write failed'); + await waitForBatchedUpdates(); + + // Then update 20 stops counting as applied, so the gap left by update 15 is detected instead of masked + expect(OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: 15, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); + + updateSpy.mockRestore(); + await heldApply.release(); + }); + + it('stops counting a Pusher update as in flight once its apply has settled', async () => { + // Given the client is caught up to update 10 + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); + await waitForBatchedUpdates(); + + // When update 20 arrives over Pusher and its apply finishes + const heldApply = applyHeldPusherUpdate(10, 20); + await waitForBatchedUpdates(); + await heldApply.release(); + await waitForBatchedUpdates(); + + // Then a caller that overrides the watermark with its own lower value is no longer told it is caught up + expect(OnyxUpdates.doesClientNeedToBeUpdated({clientLastUpdateID: 5, previousUpdateID: 15, updateType: CONST.ONYX_UPDATE_TYPES.PUSHER})).toBe(true); + }); + it('does not move the watermark backwards when a slower older update settles after a newer one', async () => { // Given the client is caught up to update 10 await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 10); diff --git a/tests/unit/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx index d8ce64ac8449..3da59cb457ff 100644 --- a/tests/unit/ReportActionsListThresholdTest.tsx +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -1,4 +1,4 @@ -import {act, render, waitFor} from '@testing-library/react-native'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; @@ -30,38 +30,52 @@ import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatch const THRESHOLD = CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; -type ScrollEvent = {nativeEvent: {contentOffset: {y: number}}}; +type ScrollEvent = { + nativeEvent: { + contentOffset: {x: number; y: number}; + contentSize: {height: number; width: number}; + layoutMeasurement: {height: number; width: number}; + }; +}; type CapturedListProps = { - maintainVisibleContentPosition?: {disabled: boolean}; + maintainVisibleContentPosition?: boolean | {data: boolean}; onScroll?: (event: ScrollEvent) => void; }; -// Capture the props the list is rendered with so we can observe `maintainVisibleContentPosition`, whose -// `disabled` flag is `!(hasScrolledOverThreshold || shouldFocusToTopOnMount)`. With no deep-link the latter -// is false, so `!disabled` mirrors the boolean under test. +// Capture the props the list is rendered with so we can verify data anchoring remains enabled while the +// list crosses the visible-action threshold. let capturedListProps: CapturedListProps = {}; -// Every value the maintain-visible-content-position flag has held (`!disabled`), in render order. `[0]` is the -// value on the list's very first render — the property that matters, since it must be right before any effect runs. -let mockMvcpHistory: Array = []; -// `!disabled` from the captured `maintainVisibleContentPosition`, or `undefined` before the list first renders. +// Whether the captured LegendList configuration enables data-based maintain-visible-content-position. function isMvcpEnabled() { const config = capturedListProps.maintainVisibleContentPosition; - return config ? !config.disabled : undefined; + return config === true || (typeof config === 'object' && config.data); } -jest.mock('@components/FlashList/InvertedFlashList', () => { +jest.mock('@legendapp/list/react-native', () => { const {forwardRef} = jest.requireActual('react'); return { - __esModule: true, - default: forwardRef((props) => { + // The second parameter is intentionally unused; forwardRef requires it to avoid a React development warning. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + LegendList: forwardRef((props, ref) => { capturedListProps = props; - mockMvcpHistory.push(props.maintainVisibleContentPosition ? !props.maintainVisibleContentPosition.disabled : undefined); return null; }), }; }); +function createScrollEvent(distanceFromBottom: number): ScrollEvent { + const contentHeight = 1000; + const viewportHeight = 500; + return { + nativeEvent: { + contentOffset: {x: 0, y: contentHeight - viewportHeight - distanceFromBottom}, + contentSize: {height: contentHeight, width: 300}, + layoutMeasurement: {height: viewportHeight, width: 300}, + }, + }; +} + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -114,13 +128,17 @@ async function renderList(initialOffset: number) { , ); + fireEvent(screen.getByTestId('report-actions-list-viewport'), 'onLayout', { + nativeEvent: { + layout: {x: 0, y: 0, width: 300, height: 500}, + }, + }); await waitFor(() => expect(capturedListProps.maintainVisibleContentPosition).toBeDefined()); return utils; } beforeEach(async () => { capturedListProps = {}; - mockMvcpHistory = []; setHasRadio(true); wrapOnyxWithWaitForBatchedUpdates(Onyx); await act(async () => { @@ -145,34 +163,31 @@ afterEach(async () => { await waitForBatchedUpdates(); }); -describe('ReportActionsList hasScrolledOverThreshold', () => { - it('enables maintainVisibleContentPosition on first render when mounted while scrolled past the threshold', async () => { +describe('ReportActionsList maintainVisibleContentPosition', () => { + it('enables data anchoring on first render when mounted while scrolled past the threshold', async () => { await renderList(THRESHOLD + 50); - // Must be true on the FIRST render, not merely after an effect settles — deferring this to an effect - // would let the mount-time mark-as-read path observe a wrong `isScrolledToEnd`. - expect(mockMvcpHistory.at(0)).toBe(true); expect(isMvcpEnabled()).toBe(true); }); - it('leaves maintainVisibleContentPosition off on first render when mounted at the bottom (offset below threshold)', async () => { + it('enables data anchoring at the bottom so initial hydration preserves the visible tail', async () => { await renderList(0); - expect(isMvcpEnabled()).toBe(false); + expect(isMvcpEnabled()).toBe(true); }); - it('flips the flag as the user scrolls across the threshold', async () => { + it('keeps data anchoring enabled as the user scrolls across the threshold', async () => { await renderList(0); - expect(isMvcpEnabled()).toBe(false); + expect(isMvcpEnabled()).toBe(true); act(() => { - capturedListProps.onScroll?.({nativeEvent: {contentOffset: {y: THRESHOLD + 50}}}); + capturedListProps.onScroll?.(createScrollEvent(THRESHOLD + 50)); }); expect(isMvcpEnabled()).toBe(true); act(() => { - capturedListProps.onScroll?.({nativeEvent: {contentOffset: {y: 0}}}); + capturedListProps.onScroll?.(createScrollEvent(0)); }); - expect(isMvcpEnabled()).toBe(false); + expect(isMvcpEnabled()).toBe(true); }); }); diff --git a/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx b/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx new file mode 100644 index 000000000000..d603ecdbf478 --- /dev/null +++ b/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx @@ -0,0 +1,60 @@ +import {render, screen} from '@testing-library/react-native'; + +import ReportActionsPaginationLoadingIndicator, { + PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, + PAGINATION_LOADING_INDICATOR_HEIGHT, + PAGINATION_LOADING_INDICATOR_TOP_PADDING, +} from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; + +import type {ComponentType} from 'react'; +import type {ViewProps} from 'react-native'; + +import React from 'react'; +import {StyleSheet} from 'react-native'; + +jest.mock('@components/ActivityIndicator', () => ({ + __esModule: true, + default: ({testID}: {testID?: string}) => { + const {View: MockView} = jest.requireActual<{View: ComponentType}>('react-native'); + return ; + }, +})); + +const OLDER_TEST_ID = 'report-actions-pagination-older'; +const NEWER_TEST_ID = 'report-actions-pagination-newer'; + +describe('ReportActionsPaginationLoadingIndicator', () => { + it('renders only a spinner with generous vertical padding', () => { + const view = render(); + + expect(StyleSheet.flatten(screen.getByTestId(OLDER_TEST_ID, {includeHiddenElements: true}).props.style)).toEqual( + expect.objectContaining({ + alignItems: 'center', + height: PAGINATION_LOADING_INDICATOR_HEIGHT, + justifyContent: 'center', + paddingBottom: PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, + paddingTop: PAGINATION_LOADING_INDICATOR_TOP_PADDING, + }), + ); + expect(PAGINATION_LOADING_INDICATOR_TOP_PADDING).toBe(24); + expect(PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING).toBe(24); + expect(screen.getByTestId(`${OLDER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); + expect(screen.queryByTestId(`${OLDER_TEST_ID}-skeleton`, {includeHiddenElements: true})).toBeNull(); + + view.rerender(); + + expect(screen.getByTestId(`${NEWER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); + }); + + it('keeps pagination loading UI out of interaction and accessibility', () => { + render(); + + expect(screen.getByTestId(NEWER_TEST_ID, {includeHiddenElements: true}).props).toEqual( + expect.objectContaining({ + accessibilityElementsHidden: true, + importantForAccessibility: 'no-hide-descendants', + pointerEvents: 'none', + }), + ); + }); +}); diff --git a/tests/unit/ReportFetchHandlerTest.tsx b/tests/unit/ReportFetchHandlerTest.tsx new file mode 100644 index 000000000000..72dd13959854 --- /dev/null +++ b/tests/unit/ReportFetchHandlerTest.tsx @@ -0,0 +1,127 @@ +import {render} from '@testing-library/react-native'; + +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import ReportFetchHandler from '@pages/inbox/ReportFetchHandler'; + +import type * as UserActionsReport from '@userActions/Report'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import type * as ReactNavigationNative from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const REPORT_ID = '1'; + +let mockRouteParams: Record = {reportID: REPORT_ID}; +const mockSetParams = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useRoute: () => ({key: 'report', name: 'Report', params: mockRouteParams}), + useNavigation: () => ({setParams: mockSetParams, addListener: jest.fn(() => jest.fn())}), + useIsFocused: () => true, +})); + +const mockOpenReport = jest.fn(); +jest.mock('@userActions/Report', () => ({ + ...jest.requireActual('@userActions/Report'), + openReport: (...args: Parameters) => { + mockOpenReport(...args); + }, +})); + +function renderHandler() { + return render( + + + , + ); +} + +/** Regression tests for the guards that suppress openReport for a client-generated report ID that doesn't exist on the server yet. */ +describe('ReportFetchHandler', () => { + beforeEach(async () => { + mockOpenReport.mockClear(); + mockSetParams.mockClear(); + mockRouteParams = {reportID: REPORT_ID}; + await Onyx.clear(); + await Onyx.multiSet({ + [ONYXKEYS.IS_LOADING_APP]: false, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + }); + await waitForBatchedUpdates(); + }); + + it('does NOT call openReport when isPendingCreation is set and the report does not exist locally yet', async () => { + // Given an optimistic destination that has not been created locally yet + mockRouteParams = {reportID: REPORT_ID, isPendingCreation: 'true'}; + + // When the pre-mounted destination starts handling report fetches + renderHandler(); + await waitForBatchedUpdates(); + + // Then fetching is deferred because the server cannot resolve the optimistic report ID + expect(mockOpenReport).not.toHaveBeenCalled(); + }); + + it('calls openReport again once the pre-mounted report exists locally and isPendingCreation clears', async () => { + // Given a pre-mounted report that has become locally available + mockRouteParams = {reportID: REPORT_ID}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID}); + await waitForBatchedUpdates(); + + // When report fetching resumes after creation completes + renderHandler(); + await waitForBatchedUpdates(); + + // Then the real report is fetched because its optimistic guard is no longer needed + expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({reportID: REPORT_ID})); + }); + + it('clears isPendingCreation once the report exists locally', async () => { + // Given an optimistic route whose report has just become locally available + mockRouteParams = {reportID: REPORT_ID, isPendingCreation: 'true'}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID}); + await waitForBatchedUpdates(); + + // When the handler observes the newly created report + renderHandler(); + await waitForBatchedUpdates(); + + // Then the temporary route guard is removed because future fetches are safe + expect(mockSetParams).toHaveBeenCalledWith({isPendingCreation: undefined}); + }); + + it('does NOT call openReport while the pre-mount marker is set, even though the report row exists', async () => { + // Given a draft report pre-mounted only for speculative pre-mounting + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${REPORT_ID}`, true); + await waitForBatchedUpdates(); + + // When the handler sees the speculative report row + renderHandler(); + await waitForBatchedUpdates(); + + // Then fetching stays blocked because the report is not committed on the server + expect(mockOpenReport).not.toHaveBeenCalled(); + }); + + it('calls openReport again once the pre-mount marker is cleared', async () => { + // Given a pre-mounted report that has completed its speculative lifecycle + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${REPORT_ID}`, null); + await waitForBatchedUpdates(); + + // When the handler observes that the pre-mount is complete + renderHandler(); + await waitForBatchedUpdates(); + + // Then normal fetching resumes because the report is now safe to request + expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({reportID: REPORT_ID})); + }); +}); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 206b9a223a18..244ad3454549 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -9212,6 +9212,34 @@ describe('ReportUtils', () => { expect(result.expenseChatReportID).toBe(providedExpenseReportID); expect(result.expenseChatData.reportID).toBe(providedExpenseReportID); }); + + describe('#admins room pinning', () => { + const ownerEmail = 'workspace-owner@expensifail.com'; + + it('should pin the #admins room when the account owns no paid workspace yet', () => { + const result = buildOptimisticWorkspaceChats(policyID, policyName, 909, ownerEmail, undefined, false); + + expect(result.adminsChatData.isPinned).toBe(true); + }); + + it('should not pin the #admins room when the account already owns a paid workspace', () => { + const result = buildOptimisticWorkspaceChats(policyID, policyName, 909, ownerEmail, undefined, true); + + expect(result.adminsChatData.isPinned).toBe(false); + }); + + it('should pin the #admins room when the flows that do not go through CreatePolicy omit the flag', () => { + const result = buildOptimisticWorkspaceChats(policyID, policyName, 909, ownerEmail); + + expect(result.adminsChatData.isPinned).toBe(true); + }); + + it('should never pin the #admins room for an Expensify employee', () => { + const result = buildOptimisticWorkspaceChats(policyID, policyName, 909, 'employee@expensify.com', undefined, false); + + expect(result.adminsChatData.isPinned).toBe(false); + }); + }); }); describe('getWorkspaceNameUpdatedMessage', () => { @@ -14929,6 +14957,21 @@ describe('ReportUtils', () => { it('should return true for CREATE iouType with expense report', () => { expect(shouldEnableNegative(expenseReport, personalPolicy, CONST.IOU.TYPE.CREATE)).toBe(true); }); + + it('should return false for CREATE iouType when a P2P recipient is selected', () => { + const participants = [{accountID: 1, isPolicyExpenseChat: false, isSender: false}]; + expect(shouldEnableNegative(undefined, undefined, CONST.IOU.TYPE.CREATE, participants)).toBe(false); + }); + + it('should return true for CREATE iouType when the only participant is a policy expense chat', () => { + const participants = [{accountID: 1, isPolicyExpenseChat: true, isSender: false}]; + expect(shouldEnableNegative(undefined, undefined, CONST.IOU.TYPE.CREATE, participants)).toBe(true); + }); + + it('should return true for CREATE iouType when the only participant is the sender', () => { + const participants = [{accountID: 1, isPolicyExpenseChat: false, isSender: true}]; + expect(shouldEnableNegative(undefined, undefined, CONST.IOU.TYPE.CREATE, participants)).toBe(true); + }); }); describe('exclusion cases for SPLIT and INVOICE iouTypes', () => { diff --git a/tests/unit/Search/SearchSingleSelectionPickerTest.tsx b/tests/unit/Search/SearchSingleSelectionPickerTest.tsx index 1d21ec51ee5e..6b21c691ff60 100644 --- a/tests/unit/Search/SearchSingleSelectionPickerTest.tsx +++ b/tests/unit/Search/SearchSingleSelectionPickerTest.tsx @@ -8,20 +8,22 @@ import type Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; +import type * as LegendListModule from '@legendapp/list/react-native'; import type ReactNative from 'react-native'; import * as NativeNavigation from '@react-navigation/native'; import React from 'react'; -jest.mock('@shopify/flash-list', () => { +jest.mock('@legendapp/list/react-native', () => { const ReactLocal = jest.requireActual('react'); const RN = jest.requireActual('react-native'); + const LegendListActual = jest.requireActual('@legendapp/list/react-native'); - const FlashList = ReactLocal.forwardRef< + const LegendList = ReactLocal.forwardRef< {scrollToIndex: (params: {index: number}) => void}, Omit, 'children'> & { data?: unknown[]; - renderItem?: (info: {item: unknown; index: number; target: string}) => React.ReactNode; + renderItem?: (info: {item: unknown; index: number}) => React.ReactNode; keyExtractor?: (item: unknown, index: number) => string; ListHeaderComponent?: React.ReactNode; ListFooterComponent?: React.ReactNode; @@ -56,15 +58,13 @@ jest.mock('@shopify/flash-list', () => { RN.ScrollView, scrollViewProps, ListHeaderComponent ?? null, - ...(data ?? []).map((item, index) => - ReactLocal.createElement(ReactLocal.Fragment, {key: keyExtractor?.(item, index) ?? String(index)}, renderItem?.({item, index, target: 'Cell'})), - ), + ...(data ?? []).map((item, index) => ReactLocal.createElement(ReactLocal.Fragment, {key: keyExtractor?.(item, index) ?? String(index)}, renderItem?.({item, index}))), ListFooterComponent ?? null, ); }, ); - return {FlashList}; + return {...LegendListActual, LegendList}; }); jest.mock('@src/components/ConfirmedRoute.tsx'); diff --git a/tests/unit/SearchAutocompleteListTest.tsx b/tests/unit/SearchAutocompleteListTest.tsx index d9565b9e49cf..494b5f5b6c1e 100644 --- a/tests/unit/SearchAutocompleteListTest.tsx +++ b/tests/unit/SearchAutocompleteListTest.tsx @@ -25,9 +25,10 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {PersonalDetails, Report, ReportAction} from '@src/types/onyx'; +import type {LegendListProps} from '@legendapp/list/react-native'; import type * as NativeNavigation from '@react-navigation/native'; -import type ReactNative from 'react-native'; +import {LegendList as LibraryLegendList} from '@legendapp/list/react-native'; import React from 'react'; import {StyleSheet} from 'react-native'; import Onyx from 'react-native-onyx'; @@ -40,23 +41,13 @@ import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; -const mockFlashListContentContainerStyles: Array['contentContainerStyle']> = []; +const mockLegendListContentContainerStyles: Array['contentContainerStyle']> = []; +const mockLibraryLegendList = jest.mocked(LibraryLegendList); +const libraryLegendListImplementation = mockLibraryLegendList.getMockImplementation(); -jest.mock('@shopify/flash-list', () => { - const RN = jest.requireActual('react-native'); - return { - FlashList: ({data, contentContainerStyle, ...props}: React.ComponentProps) => { - mockFlashListContentContainerStyles.push(contentContainerStyle); - return ( - - ); - }, - }; +mockLibraryLegendList.mockImplementation((props) => { + mockLegendListContentContainerStyles.push(props.contentContainerStyle); + return libraryLegendListImplementation?.(props) ?? null; }); jest.mock('lodash/debounce', () => @@ -310,13 +301,13 @@ describe('SearchAutocompleteList', () => { action: jest.fn(), }, ]); - mockFlashListContentContainerStyles.length = 0; + mockLegendListContentContainerStyles.length = 0; render(); await flushAllUpdates(); const getContentPaddingBottom = () => { - // FlashList records a style per render; use the latest numeric bottom padding. - return mockFlashListContentContainerStyles + // LegendList records a style per render; use the latest numeric bottom padding. + return mockLegendListContentContainerStyles .map((contentContainerStyle) => StyleSheet.flatten(contentContainerStyle)?.paddingBottom) .findLast((paddingBottom) => typeof paddingBottom === 'number'); }; diff --git a/tests/unit/SelectionListEnterConfirmGateTest.tsx b/tests/unit/SelectionListEnterConfirmGateTest.tsx new file mode 100644 index 000000000000..d96521425426 --- /dev/null +++ b/tests/unit/SelectionListEnterConfirmGateTest.tsx @@ -0,0 +1,264 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import BaseSelectionList from '@components/SelectionList/BaseSelectionList'; +import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; +import BaseSelectionListWithSections from '@components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections'; +import type {ConfirmButtonOptions, ListItem} from '@components/SelectionList/types'; + +import type Navigation from '@libs/Navigation/Navigation'; + +import type ReactNative from 'react-native'; + +import * as NativeNavigation from '@react-navigation/native'; +import React from 'react'; +import {View} from 'react-native'; + +// Records what BaseSelectionList / BaseSelectionListWithSections hand to the shortcuts hook, so the tests can assert +// on the gated `focusedIndex` (-1 disables the list's plain-Enter shortcut and lets the keypress reach the confirm button). +const mockUseSelectionListShortcuts = jest.fn(); +jest.mock('@components/SelectionList/hooks/useSelectionListShortcuts', () => ({ + __esModule: true, + default: (params: {focusedIndex: number}) => { + mockUseSelectionListShortcuts(params); + }, +})); + +// Mock FlashList so every row renders synchronously +jest.mock('@shopify/flash-list', () => { + const ReactLocal = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + + const FlashList = ReactLocal.forwardRef< + {scrollToIndex: (params: {index: number}) => void}, + Omit, 'children'> & { + data?: unknown[]; + renderItem?: (info: {item: unknown; index: number; target: string}) => React.ReactNode; + keyExtractor?: (item: unknown, index: number) => string; + ListHeaderComponent?: React.ReactNode; + ListFooterComponent?: React.ReactNode; + getItemType?: unknown; + extraData?: unknown; + initialScrollIndex?: number; + onEndReached?: unknown; + onEndReachedThreshold?: unknown; + ListFooterComponentStyle?: unknown; + } + >( + ( + { + data, + renderItem, + keyExtractor, + ListHeaderComponent, + ListFooterComponent, + getItemType: _getItemType, + extraData: _extraData, + initialScrollIndex: _initialScrollIndex, + onEndReached: _onEndReached, + onEndReachedThreshold: _onEndReachedThreshold, + ListFooterComponentStyle: _ListFooterComponentStyle, + ...scrollViewProps + }, + ref, + ) => { + ReactLocal.useImperativeHandle(ref, () => ({scrollToIndex: jest.fn()})); + + return ReactLocal.createElement( + RN.ScrollView, + scrollViewProps, + ListHeaderComponent ?? null, + ...(data ?? []).map((item, index) => + ReactLocal.createElement(ReactLocal.Fragment, {key: keyExtractor?.(item, index) ?? String(index)}, renderItem?.({item, index, target: 'Cell'})), + ), + ListFooterComponent ?? null, + ); + }, + ); + + return {FlashList}; +}); + +jest.mock('@src/components/ConfirmedRoute.tsx'); +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useIsFocused: jest.fn(), + useFocusEffect: jest.fn(), + useNavigation: jest.fn(() => ({ + isFocused: jest.fn(() => true), + })), + }; +}); + +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: jest.fn((key: string) => key), + numberFormat: jest.fn((num: number) => num.toString()), + })), +); + +jest.mock('@hooks/useKeyboardShortcut', () => jest.fn()); + +/** Mirrors the Workspace Members invite list: one member picked with the mouse, nothing typed in the search field. */ +const mouseSelectedItems: ListItem[] = [ + {text: 'Item 0', keyForList: '0', isSelected: true}, + {text: 'Item 1', keyForList: '1'}, + {text: 'Item 2', keyForList: '2'}, +]; + +const noSelectionItems: ListItem[] = mouseSelectedItems.map((item) => ({...item, isSelected: false})); + +/** A custom footer (like the invite pages' "Next" button) that owns Enter when it is enabled. */ +const footerContent = ; + +/** The `focusedIndex` the list handed to `useSelectionListShortcuts` on its latest render. */ +function getGatedFocusedIndex(): number { + return mockUseSelectionListShortcuts.mock.calls.at(-1)?.[0].focusedIndex ?? Number.NaN; +} + +type ListProps = { + data?: ListItem[]; + confirmButtonOptions?: ConfirmButtonOptions; + footerContent?: React.ReactNode; + searchText?: string; + shouldStopPropagation?: boolean; +}; + +function renderFlatList({data = mouseSelectedItems, confirmButtonOptions, footerContent: footer, searchText = '', shouldStopPropagation = false}: ListProps = {}) { + return render( + + + , + ); +} + +function renderSectionedList({data = mouseSelectedItems, confirmButtonOptions, footerContent: footer, searchText = '', shouldStopPropagation = false}: ListProps = {}) { + return render( + + + , + ); +} + +describe.each([ + ['BaseSelectionList', renderFlatList], + ['BaseSelectionListWithSections', renderSectionedList], +])('%s Enter/confirm-button gate', (_name, renderList) => { + beforeEach(() => { + mockUseSelectionListShortcuts.mockClear(); + jest.mocked(NativeNavigation.useIsFocused).mockReturnValue(true); + }); + + it('yields plain Enter to an enabled custom footer confirm when rows were selected with the mouse and the search field is empty', () => { + renderList({footerContent, confirmButtonOptions: {onConfirm: jest.fn()}}); + + expect(getGatedFocusedIndex()).toBe(-1); + }); + + it('yields plain Enter to an enabled built-in confirm button', () => { + renderList({confirmButtonOptions: {showButton: true, onConfirm: jest.fn(), text: 'Next'}}); + + expect(getGatedFocusedIndex()).toBe(-1); + }); + + it('keeps plain Enter on the focused row when a custom footer hides the built-in confirm button and the footer cannot handle Enter', () => { + // Footer renders footerContent instead of the built-in button, so the built-in button's Enter + // listener never mounts. The footer path must govern, otherwise Enter reaches nothing at all. + renderList({footerContent, confirmButtonOptions: {showButton: true, onConfirm: jest.fn(), text: 'Next', isFooterConfirmEnterKeyEnabled: false}}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('keeps plain Enter on the focused row while a search query is typed', () => { + renderList({footerContent, confirmButtonOptions: {onConfirm: jest.fn()}, searchText: 'Item 1'}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('treats a whitespace-only search query as empty and still yields plain Enter to the confirm', () => { + renderList({footerContent, confirmButtonOptions: {onConfirm: jest.fn()}, searchText: ' '}); + + expect(getGatedFocusedIndex()).toBe(-1); + }); + + it('keeps plain Enter on the focused row once the user navigates with the keyboard', () => { + renderList({footerContent, confirmButtonOptions: {onConfirm: jest.fn()}}); + expect(getGatedFocusedIndex()).toBe(-1); + + fireEvent(screen.getByTestId('selection-list-text-input'), 'keyPress', {nativeEvent: {key: 'Tab'}}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('keeps plain Enter on the focused row when the built-in confirm button is disabled', () => { + renderList({confirmButtonOptions: {showButton: true, onConfirm: jest.fn(), text: 'Next', isDisabled: true}}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('keeps plain Enter on the focused row when the footer confirm cannot handle Enter on this platform', () => { + renderList({footerContent, confirmButtonOptions: {onConfirm: jest.fn(), isFooterConfirmEnterKeyEnabled: false}}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('keeps plain Enter on the focused row when the owner reports the footer confirm as disabled', () => { + renderList({footerContent, confirmButtonOptions: {onConfirm: jest.fn(), isFooterConfirmEnabled: false}}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('yields plain Enter when the owner reports the footer confirm as enabled even though no rendered row is selected', () => { + renderList({data: noSelectionItems, footerContent, confirmButtonOptions: {onConfirm: jest.fn(), isFooterConfirmEnabled: true}}); + + expect(getGatedFocusedIndex()).toBe(-1); + }); + + it('keeps plain Enter on the focused row when nothing is selected, so the footer confirm is inferred to be disabled', () => { + renderList({data: noSelectionItems, footerContent, confirmButtonOptions: {onConfirm: jest.fn()}}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('keeps plain Enter on the focused row for option-driven onConfirm lists that render no confirm control', () => { + renderList({confirmButtonOptions: {onConfirm: jest.fn()}}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('keeps plain Enter on the focused row when the list is configured to stop propagation', () => { + renderList({footerContent, confirmButtonOptions: {onConfirm: jest.fn()}, shouldStopPropagation: true}); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); + + it('keeps plain Enter on the focused row when there is no confirm button at all', () => { + renderList(); + + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/tests/unit/WorkspaceReportFieldUtilsTest.ts b/tests/unit/WorkspaceReportFieldUtilsTest.ts index e6f5ace7f2a6..ea939ddf5633 100644 --- a/tests/unit/WorkspaceReportFieldUtilsTest.ts +++ b/tests/unit/WorkspaceReportFieldUtilsTest.ts @@ -67,6 +67,8 @@ describe('WorkspaceReportFieldUtils.getUnsupportedReportFieldFormulaParts', () = expect(getUnsupportedReportFieldFormulaParts('{report:submit:to}')).toEqual([]); expect(getUnsupportedReportFieldFormulaParts('{report:submit:from:firstname}')).toEqual([]); expect(getUnsupportedReportFieldFormulaParts('{report:autoreporting:start}')).toEqual([]); + expect(getUnsupportedReportFieldFormulaParts('{report:debitedAmount}')).toEqual([]); + expect(getUnsupportedReportFieldFormulaParts('{report:creditedAmount}')).toEqual([]); }); it('returns only unsupported parts in mixed formulas', () => { diff --git a/tests/unit/cleanupPreMountedDraftReportsOrchestrationTest.ts b/tests/unit/cleanupPreMountedDraftReportsOrchestrationTest.ts new file mode 100644 index 000000000000..71c63bae77a3 --- /dev/null +++ b/tests/unit/cleanupPreMountedDraftReportsOrchestrationTest.ts @@ -0,0 +1,79 @@ +import cleanupPreMountedDraftReports from '@libs/cleanupPreMountedDraftReports'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +describe('cleanupPreMountedDraftReports (connectWithoutView orchestration)', () => { + beforeEach(() => Onyx.clear()); + + it('removes an interrupted pre-mounted report and its stale marker when the draft still exists', async () => { + // Given a pre-mount interrupted before its draft was submitted + const reportID = '123'; + await Promise.all([ + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`, true), + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportID}`, {reportID} as Report), + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {reportID} as Report), + ]); + + // When startup cleanup reconciles stale pre-mount state + cleanupPreMountedDraftReports(); + await waitForBatchedUpdates(); + + // Then speculative data is removed because the draft remains authoritative + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toBeUndefined(); + }); + + it('preserves a submitted report and only clears the marker when its draft no longer exists', async () => { + // Given a pre-mounted report whose draft disappeared after successful submission + const reportID = '456'; + await Promise.all([Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`, true), Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {reportID} as Report)]); + + // When startup cleanup reconciles the leftover pre-mount marker + cleanupPreMountedDraftReports(); + await waitForBatchedUpdates(); + + // Then the real report survives because the missing draft signals completion + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toEqual({reportID}); + }); + + it('is a no-op when there are no stale pre-mount markers', async () => { + // Given startup state without interrupted markers + const multiSetSpy = jest.spyOn(Onyx, 'multiSet'); + + // When pre-mount cleanup runs + cleanupPreMountedDraftReports(); + await waitForBatchedUpdates(); + + // Then Onyx is not rewritten because there is nothing to reconcile + expect(multiSetSpy).not.toHaveBeenCalled(); + multiSetSpy.mockRestore(); + }); + + it('handles multiple interrupted markers independently in a single pass', async () => { + // Given interrupted markers with both pending-draft and submitted outcomes + await Promise.all([ + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}withDraft`, true), + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}withDraft`, {reportID: 'withDraft'} as Report), + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}withDraft`, {reportID: 'withDraft'} as Report), + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}submitted`, true), + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}submitted`, {reportID: 'submitted'} as Report), + ]); + + // When startup cleanup reconciles the collection in one pass + cleanupPreMountedDraftReports(); + await waitForBatchedUpdates(); + + // Then each report follows its own draft state instead of sharing one outcome + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}withDraft`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}withDraft`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}submitted`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}submitted`)).toEqual({reportID: 'submitted'}); + }); +}); diff --git a/tests/unit/cleanupPreMountedDraftReportsTest.ts b/tests/unit/cleanupPreMountedDraftReportsTest.ts new file mode 100644 index 000000000000..1e7d0a09d3f3 --- /dev/null +++ b/tests/unit/cleanupPreMountedDraftReportsTest.ts @@ -0,0 +1,34 @@ +import {getPreMountedDraftReportCleanupData} from '@libs/cleanupPreMountedDraftReports'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +describe('getPreMountedDraftReportCleanupData', () => { + it('removes an interrupted pre-mounted report and its marker when the draft still exists', () => { + // Given a stale pre-mount marker whose original draft still exists + const reportID = '123'; + + // When cleanup data is derived after an interrupted pre-mount + // Then both speculative records are cleared because submission never completed + expect( + getPreMountedDraftReportCleanupData( + {[`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`]: true}, + {[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportID}`]: {reportID} as Report}, + ), + ).toEqual({ + [`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`]: null, + [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: null, + }); + }); + + it('preserves a submitted report when its draft no longer exists', () => { + // Given a stale pre-mount marker whose draft was removed by successful submission + const reportID = '123'; + + // When cleanup data is derived after the pre-mount completes + // Then only the marker is cleared because the report is now authoritative + expect(getPreMountedDraftReportCleanupData({[`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`]: true}, {})).toEqual({ + [`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`]: null, + }); + }); +}); diff --git a/tests/unit/components/MoneyRequestReportView/ExternalScrollLegendListTableIntegrationTest.tsx b/tests/unit/components/MoneyRequestReportView/ExternalScrollLegendListTableIntegrationTest.tsx new file mode 100644 index 000000000000..05310638b775 --- /dev/null +++ b/tests/unit/components/MoneyRequestReportView/ExternalScrollLegendListTableIntegrationTest.tsx @@ -0,0 +1,107 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ExternalScrollLegendListTable, {createScrollOffsetStore} from '@components/MoneyRequestReportView/ExternalScrollLegendListTable'; + +import type * as LegendListModule from '@legendapp/list/react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +jest.mock('@legendapp/list/react-native', () => jest.requireActual('../../../../node_modules/@legendapp/list/react-native.js')); + +jest.mock('@components/ScrollView', () => { + const {View: RNView} = jest.requireActual<{View: typeof View}>('react-native'); + + function MockScrollView({children}: {children?: React.ReactNode}) { + return {children}; + } + + return {__esModule: true, default: MockScrollView}; +}); + +const ROW_HEIGHT = 40; +const VIEWPORT_HEIGHT = 200; +const TABLE_OFFSET_TOP = 80; +const HEADER_HEIGHT = 44; + +describe('ExternalScrollLegendListTable virtualization', () => { + it('refreshes mounted rows when their renderer changes without replacing the items', async () => { + const items = [{id: 'row-0'}]; + const store = createScrollOffsetStore(); + const renderTable = (label: string) => ( + item.id} + getItemType={() => 'row'} + renderItem={() => } + renderHeader={() => null} + estimatedRowHeight={ROW_HEIGHT} + contentWidth={800} + store={store} + viewportHeight={VIEWPORT_HEIGHT} + offsetTop={TABLE_OFFSET_TOP} + /> + ); + const {rerender} = render(renderTable('unselected-row')); + fireEvent(screen.getByTestId('external-scroll-legend-list-driver'), 'layout', { + nativeEvent: {layout: {height: ROW_HEIGHT, width: 800, x: 0, y: 0}}, + }); + expect(await screen.findByTestId('unselected-row')).toBeTruthy(); + + rerender(renderTable('selected-row')); + + expect(await screen.findByTestId('selected-row')).toBeTruthy(); + expect(screen.queryByTestId('unselected-row')).toBeNull(); + }); + + it('keeps a bounded real LegendList window and moves it with parent scroll', async () => { + const items = Array.from({length: 200}, (_value, index) => ({id: `row-${index}`})); + const store = createScrollOffsetStore(); + const tableRef = React.createRef<{getRowPageOffset: (index: number) => {top: number; height: number} | undefined}>(); + + render( + item.id} + getItemType={() => 'row'} + renderItem={(_item, index) => } + renderHeader={() => } + estimatedRowHeight={ROW_HEIGHT} + contentWidth={800} + store={store} + viewportHeight={VIEWPORT_HEIGHT} + offsetTop={TABLE_OFFSET_TOP} + />, + ); + + fireEvent(screen.getByTestId('external-scroll-legend-list-driver'), 'layout', { + nativeEvent: {layout: {height: 8000, width: 800, x: 0, y: 0}}, + }); + fireLayoutOnNearestAncestor(screen.getByTestId('table-column-header'), HEADER_HEIGHT); + + expect(await screen.findByTestId('row-0')).toBeTruthy(); + const firstWindow = screen.getAllByTestId(/^row-/); + expect(firstWindow.length).toBeLessThan(items.length / 2); + fireLayoutOnNearestAncestor(screen.getByTestId('row-0'), ROW_HEIGHT); + expect(tableRef.current?.getRowPageOffset(0)).toEqual({top: TABLE_OFFSET_TOP + HEADER_HEIGHT, height: ROW_HEIGHT}); + + act(() => store.setOffset(TABLE_OFFSET_TOP + ROW_HEIGHT * 100)); + + expect(await screen.findByTestId('row-100')).toBeTruthy(); + expect(screen.queryByTestId('row-0')).toBeNull(); + expect(screen.getAllByTestId(/^row-/).length).toBeLessThan(items.length / 2); + }); +}); + +function fireLayoutOnNearestAncestor(instance: ReturnType, height: number) { + let layoutInstance = instance.parent; + while (layoutInstance && typeof layoutInstance.props.onLayout !== 'function') { + layoutInstance = layoutInstance.parent; + } + if (!layoutInstance) { + throw new Error('Expected a measured LegendList container'); + } + + fireEvent(layoutInstance, 'layout', {nativeEvent: {layout: {height, width: 800, x: 0, y: 0}}}); +} diff --git a/tests/unit/components/MoneyRequestReportView/ExternalScrollLegendListTableTest.tsx b/tests/unit/components/MoneyRequestReportView/ExternalScrollLegendListTableTest.tsx new file mode 100644 index 000000000000..d8c165fbec25 --- /dev/null +++ b/tests/unit/components/MoneyRequestReportView/ExternalScrollLegendListTableTest.tsx @@ -0,0 +1,110 @@ +import {act, fireEvent, render} from '@testing-library/react-native'; + +import ExternalScrollLegendListTable, {createScrollOffsetStore} from '@components/MoneyRequestReportView/ExternalScrollLegendListTable'; + +import type * as LegendListModule from '@legendapp/list/react-native'; +import type {ScrollViewProps} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +type TestItem = {id: string}; + +const mockGetState = jest.fn(); +let mockLegendListProps: LegendListModule.LegendListProps | undefined; + +jest.mock('@legendapp/list/react-native', () => { + const ReactLocal = jest.requireActual('react'); + const LegendListActual = jest.requireActual('@legendapp/list/react-native'); + + return { + ...LegendListActual, + LegendList: ReactLocal.forwardRef((props: LegendListModule.LegendListProps, ref: React.Ref<{getState: typeof mockGetState}>) => { + mockLegendListProps = props; + ReactLocal.useImperativeHandle(ref, () => ({getState: mockGetState})); + return null; + }), + }; +}); + +jest.mock('@components/ScrollView', () => { + const {View: RNView} = jest.requireActual<{View: typeof View}>('react-native'); + + function MockScrollView({children}: {children?: React.ReactNode}) { + return {children}; + } + + return {__esModule: true, default: MockScrollView}; +}); + +describe('ExternalScrollLegendListTable', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockLegendListProps = undefined; + }); + + it('derives unmounted row page positions from LegendList state and its measured header', () => { + const tableRef = React.createRef<{getRowPageOffset: (index: number) => {top: number; height: number} | undefined}>(); + mockGetState.mockReturnValue({ + positionAtIndex: (index: number) => index * 40, + sizeAtIndex: (index: number) => (index === 2 ? 36 : undefined), + }); + + render( + item.id} + getItemType={() => 'row'} + renderItem={() => null} + renderHeader={() => } + estimatedRowHeight={48} + contentWidth={800} + store={createScrollOffsetStore()} + viewportHeight={600} + offsetTop={120} + />, + ); + + act(() => mockLegendListProps?.onMetricsChange?.({headerSize: 52, footerSize: 0})); + + expect(tableRef.current?.getRowPageOffset(2)).toEqual({top: 252, height: 36}); + expect(tableRef.current?.getRowPageOffset(1)).toEqual({top: 212, height: 48}); + }); + + it('drives the nested render window from the parent offset and substitutes the parent viewport height', () => { + const store = createScrollOffsetStore(); + render( + item.id} + getItemType={() => 'row'} + renderItem={() => null} + renderHeader={() => } + estimatedRowHeight={48} + contentWidth={800} + store={store} + viewportHeight={600} + offsetTop={120} + />, + ); + + const renderScrollComponent = mockLegendListProps?.renderScrollComponent; + if (!renderScrollComponent) { + throw new Error('Expected the external LegendList scroll driver'); + } + + const onScroll = jest.fn(); + const onLayout = jest.fn(); + const driver = renderScrollComponent({onScroll, onLayout} as ScrollViewProps); + const renderedDriver = render(driver); + + expect(onScroll).toHaveBeenLastCalledWith(expect.objectContaining({nativeEvent: {contentOffset: {x: 0, y: 0}}})); + + act(() => store.setOffset(260)); + expect(onScroll).toHaveBeenLastCalledWith(expect.objectContaining({nativeEvent: {contentOffset: {x: 0, y: 140}}})); + + fireEvent(renderedDriver.UNSAFE_getByType(View), 'layout', {nativeEvent: {layout: {height: 1, width: 800, x: 0, y: 0}}}); + expect(onLayout).toHaveBeenLastCalledWith(expect.objectContaining({nativeEvent: {layout: {height: 600, width: 800, x: 0, y: 0}}})); + }); +}); diff --git a/tests/unit/components/NumericEditingControllerUtilsTest.ts b/tests/unit/components/NumericEditingControllerUtilsTest.ts new file mode 100644 index 000000000000..b7089b321f2a --- /dev/null +++ b/tests/unit/components/NumericEditingControllerUtilsTest.ts @@ -0,0 +1,211 @@ +import type {NumericEditingKeyPressEvent} from '@components/NumericEditingController/types'; +import {clampSelection, collapseSelection, getSelectionAfterEdit, getSelectionAtOffset, isForwardDeleteKeyPress, normalizeNumericInput} from '@components/NumericEditingController/utils'; + +import {isMobileSafari} from '@libs/Browser'; +import getOperatingSystem from '@libs/getOperatingSystem'; + +import CONST from '@src/CONST'; + +jest.mock('@libs/Browser', () => ({isMobileSafari: jest.fn()})); +jest.mock('@libs/getOperatingSystem', () => jest.fn()); + +const mockedIsMobileSafari = jest.mocked(isMobileSafari); +const mockedGetOperatingSystem = jest.mocked(getOperatingSystem); + +const fromLatinDigit = (digit: string) => digit; + +const fromArabicIndicDigit = (digit: string) => { + const index = '٠١٢٣٤٥٦٧٨٩'.indexOf(digit); + + if (index === -1) { + throw new Error(`${digit} is not an Arabic-Indic digit`); + } + + return String(index); +}; + +const buildKeyPressEvent = (key: string, ctrlKey?: boolean): NumericEditingKeyPressEvent => ({nativeEvent: {key, ctrlKey}}); + +describe('NumericEditingController utils', () => { + describe('normalizeNumericInput', () => { + it('strips spaces added when pasting on iOS Safari', () => { + expect(normalizeNumericInput('1 234', {fromLocaleDigit: fromLatinDigit})).toBe('1234'); + }); + + it('converts the comma decimal separator to a period', () => { + expect(normalizeNumericInput('12,34', {fromLocaleDigit: fromLatinDigit})).toBe('12.34'); + }); + + it('drops commas used as thousand separators when a period is already present', () => { + expect(normalizeNumericInput('1,234.56', {fromLocaleDigit: fromLatinDigit})).toBe('1234.56'); + }); + + it('adds a leading zero when only the decimal separator was entered', () => { + expect(normalizeNumericInput('.5', {fromLocaleDigit: fromLatinDigit})).toBe('0.5'); + }); + + it('preserves a negative value that already has an integer part when negative input is allowed', () => { + expect(normalizeNumericInput('-1.5', {fromLocaleDigit: fromLatinDigit, allowNegative: true})).toBe('-1.5'); + }); + + it('prepends zero after the minus sign when negative input starts with only the decimal separator', () => { + // Preserve legacy `addLeadingZero` behavior: `-.5` becomes `-0-.5` and is rejected. + expect(normalizeNumericInput('-.5', {fromLocaleDigit: fromLatinDigit, allowNegative: true})).toBe('-0-.5'); + }); + + it('leaves a negative value untouched when negative values are not allowed', () => { + expect(normalizeNumericInput('-.5', {fromLocaleDigit: fromLatinDigit})).toBe('-.5'); + }); + + it('converts locale digits to their canonical counterparts', () => { + expect(normalizeNumericInput('١٢٣', {fromLocaleDigit: fromArabicIndicDigit})).toBe('123'); + }); + + it('preserves characters the locale conversion rejects', () => { + expect(normalizeNumericInput('١٢.٣', {fromLocaleDigit: fromArabicIndicDigit})).toBe('12.3'); + }); + + it('returns an empty string unchanged', () => { + expect(normalizeNumericInput('', {fromLocaleDigit: fromLatinDigit})).toBe(''); + }); + }); + + describe('isForwardDeleteKeyPress', () => { + beforeEach(() => { + mockedIsMobileSafari.mockReturnValue(false); + mockedGetOperatingSystem.mockReturnValue(CONST.OS.WINDOWS); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('detects the dedicated forward-delete key', () => { + expect(isForwardDeleteKeyPress(buildKeyPressEvent('Delete'))).toBe(true); + }); + + it('ignores backspace', () => { + expect(isForwardDeleteKeyPress(buildKeyPressEvent('Backspace'))).toBe(false); + }); + + it('detects Control-D on macOS', () => { + mockedGetOperatingSystem.mockReturnValue(CONST.OS.MAC_OS); + + expect(isForwardDeleteKeyPress(buildKeyPressEvent('d', true))).toBe(true); + }); + + it('detects Control-D on iOS', () => { + mockedGetOperatingSystem.mockReturnValue(CONST.OS.IOS); + + expect(isForwardDeleteKeyPress(buildKeyPressEvent('d', true))).toBe(true); + }); + + it('ignores Control-D on other operating systems', () => { + expect(isForwardDeleteKeyPress(buildKeyPressEvent('d', true))).toBe(false); + }); + + it('ignores the letter d pressed without the control key', () => { + mockedGetOperatingSystem.mockReturnValue(CONST.OS.MAC_OS); + + expect(isForwardDeleteKeyPress(buildKeyPressEvent('d'))).toBe(false); + }); + + it('anticipates forward-delete for the control key on iOS Safari', () => { + mockedIsMobileSafari.mockReturnValue(true); + + expect(isForwardDeleteKeyPress(buildKeyPressEvent(CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT))).toBe(true); + }); + + it('ignores the control key outside iOS Safari', () => { + expect(isForwardDeleteKeyPress(buildKeyPressEvent(CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT))).toBe(false); + }); + }); + + describe('getSelectionAtOffset', () => { + it('returns a collapsed selection at the offset', () => { + expect(getSelectionAtOffset(3)).toEqual({start: 3, end: 3}); + }); + }); + + describe('getSelectionAfterEdit', () => { + it('shifts the caret forward by the characters an insertion added', () => { + expect(getSelectionAfterEdit({start: 2, end: 2}, '12', '123', false)).toEqual({start: 3, end: 3}); + }); + + it('keeps the caret at the edit site when text is inserted before it', () => { + expect(getSelectionAfterEdit({start: 1, end: 1}, '13', '123', false)).toEqual({start: 2, end: 2}); + }); + + it('shifts the caret back by the characters a backspace removed', () => { + expect(getSelectionAfterEdit({start: 3, end: 3}, '123', '12', false)).toEqual({start: 2, end: 2}); + }); + + it('shifts the caret back past a repeated character a backspace removed', () => { + // The removed character is identical to the one before it, so only the reported caret tells them apart. + expect(getSelectionAfterEdit({start: 2, end: 2}, '111', '11', false)).toEqual({start: 1, end: 1}); + }); + + it('collapses a replaced range onto the end of what replaced it', () => { + expect(getSelectionAfterEdit({start: 0, end: 4}, '1234', '9', false)).toEqual({start: 1, end: 1}); + }); + + it('collapses a deleted range onto its start', () => { + expect(getSelectionAfterEdit({start: 1, end: 3}, '12345', '145', false)).toEqual({start: 1, end: 1}); + }); + + it('leaves the caret in place after a forward-delete, which removes the character after it', () => { + expect(getSelectionAfterEdit({start: 1, end: 1}, '123', '13', true)).toEqual({start: 1, end: 1}); + }); + + it('ignores a forward-delete key press when the edit added characters', () => { + expect(getSelectionAfterEdit({start: 2, end: 2}, '12', '123', true)).toEqual({start: 3, end: 3}); + }); + + it('ignores a forward-delete key press when a range was replaced by shorter text', () => { + // A stale flag from a forward-delete that removed nothing must not strand the caret before pasted text. + expect(getSelectionAfterEdit({start: 1, end: 4}, '12345', '195', true)).toEqual({start: 2, end: 2}); + }); + + it('ignores a forward-delete key press when the text before the caret changed', () => { + expect(getSelectionAfterEdit({start: 3, end: 3}, '123', '12', true)).toEqual({start: 2, end: 2}); + }); + + it('keeps the caret in place when the length is unchanged', () => { + expect(getSelectionAfterEdit({start: 1, end: 1}, '1.2', '1,2', false)).toEqual({start: 1, end: 1}); + }); + + it('clamps the caret to the start when an edit strips more than sits before it', () => { + // Reducing the accepted precision rewrites the value regardless of where the caret is. + expect(getSelectionAfterEdit({start: 0, end: 0}, '12.34', '12', false)).toEqual({start: 0, end: 0}); + }); + + it('clamps the caret to the end of the remaining text', () => { + // A forward-delete flag held over from a key press that removed nothing must not hold the offset in place. + expect(getSelectionAfterEdit({start: 5, end: 5}, '12.34', '12', true)).toEqual({start: 2, end: 2}); + }); + }); + + describe('collapseSelection', () => { + it('collapses a highlighted range onto its end', () => { + expect(collapseSelection({start: 0, end: 4})).toEqual({start: 4, end: 4}); + }); + + it('leaves an already collapsed selection unchanged', () => { + expect(collapseSelection({start: 2, end: 2})).toEqual({start: 2, end: 2}); + }); + }); + + describe('clampSelection', () => { + it('clamps offsets reported past the end of the displayed text', () => { + expect(clampSelection({start: 6, end: 8}, 4)).toEqual({start: 4, end: 4}); + }); + + it('leaves a selection within the displayed text unchanged', () => { + expect(clampSelection({start: 1, end: 3}, 4)).toEqual({start: 1, end: 3}); + }); + + it('clamps negative offsets onto the start of the text', () => { + expect(clampSelection({start: -2, end: -1}, 4)).toEqual({start: 0, end: 0}); + }); + }); +}); diff --git a/tests/unit/components/SelectionList/useSelectionListKeyboardFocus.test.ts b/tests/unit/components/SelectionList/useSelectionListKeyboardFocus.test.ts index 79c1feab1d42..52985a1581e1 100644 --- a/tests/unit/components/SelectionList/useSelectionListKeyboardFocus.test.ts +++ b/tests/unit/components/SelectionList/useSelectionListKeyboardFocus.test.ts @@ -35,7 +35,6 @@ function renderKeyboardFocus(overrides: Overrides = {}) { const scrollToIndex = jest.fn(); const debouncedScrollToIndex = jest.fn(); const setShouldDisableHoverStyle = jest.fn(); - const announceProgrammaticScroll = jest.fn(); const {result} = renderHook(() => useSelectionListKeyboardFocus({ @@ -48,13 +47,12 @@ function renderKeyboardFocus(overrides: Overrides = {}) { shouldDebounceScrolling: false, scrollToIndex, debouncedScrollToIndex, - announceProgrammaticScroll, setShouldDisableHoverStyle, ...overrides, }), ); - return {result, scrollToIndex, debouncedScrollToIndex, setShouldDisableHoverStyle, announceProgrammaticScroll}; + return {result, scrollToIndex, debouncedScrollToIndex, setShouldDisableHoverStyle}; } describe('useSelectionListKeyboardFocus', () => { @@ -101,11 +99,10 @@ describe('useSelectionListKeyboardFocus', () => { }); describe('onArrowUpDownCallback', () => { - it('disables hover styling and announces a programmatic scroll', () => { - const {setShouldDisableHoverStyle, announceProgrammaticScroll} = renderKeyboardFocus(); + it('disables hover styling during a programmatic scroll', () => { + const {setShouldDisableHoverStyle} = renderKeyboardFocus(); capturedConfig.onArrowUpDownCallback?.(); expect(setShouldDisableHoverStyle).toHaveBeenCalledWith(true); - expect(announceProgrammaticScroll).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/unit/components/SelectionList/useSelectionListScroll.test.ts b/tests/unit/components/SelectionList/useSelectionListScroll.test.ts index d4a4c0c53d6f..9ab9e4f156ac 100644 --- a/tests/unit/components/SelectionList/useSelectionListScroll.test.ts +++ b/tests/unit/components/SelectionList/useSelectionListScroll.test.ts @@ -4,12 +4,12 @@ import useSelectionListScroll from '@components/SelectionList/hooks/useSelection import Log from '@libs/Log'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; import type {RefObject} from 'react'; type MockItem = {keyForList: string}; -function createListRef(scrollToIndex: jest.Mock | null): RefObject, 'scrollToIndex'> | null> { +function createListRef(scrollToIndex: jest.Mock | null): RefObject | null> { if (scrollToIndex === null) { return {current: null}; } @@ -66,7 +66,7 @@ describe('useSelectionListScroll', () => { expect(() => result.current.scrollToIndex(0)).not.toThrow(); }); - it('logs a warning when FlashList throws, without rethrowing', () => { + it('logs a warning when scrolling throws, without rethrowing', () => { const warnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {}); const scrollToIndex = jest.fn(() => { throw new Error('layout not ready'); @@ -80,6 +80,19 @@ describe('useSelectionListScroll', () => { warnSpy.mockRestore(); }); + it('logs a rejected LegendList scroll without leaving an unhandled rejection', async () => { + const warnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {}); + const error = new Error('layout changed during scrolling'); + const listRef = createListRef(jest.fn().mockRejectedValue(error)); + const {result} = renderHook(() => useSelectionListScroll(listRef, data)); + + result.current.scrollToIndex(0); + await Promise.resolve(); + + expect(warnSpy).toHaveBeenCalledWith('SelectionList: error scrolling to index', {error}); + warnSpy.mockRestore(); + }); + it('debouncedScrollToIndex scrolls on the leading edge', () => { const scrollToIndex = jest.fn(); const listRef = createListRef(scrollToIndex); diff --git a/tests/unit/components/useNumericSelectionTest.ts b/tests/unit/components/useNumericSelectionTest.ts new file mode 100644 index 000000000000..a1be238ebed4 --- /dev/null +++ b/tests/unit/components/useNumericSelectionTest.ts @@ -0,0 +1,405 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useNumericSelection from '@components/NumericEditingController/hooks/useNumericSelection'; +import type {NumericEditingKeyPressEvent} from '@components/NumericEditingController/types'; + +import type ShouldIgnoreSelectionWhenUpdatedManually from '@libs/shouldIgnoreSelectionWhenUpdatedManually/types'; + +import * as NativeNavigation from '@react-navigation/native'; + +// Native sets this flag only on native platforms; mock it to exercise the guards owned by this hook. +jest.mock('@libs/shouldIgnoreSelectionWhenUpdatedManually', () => ({ + ...jest.requireActual<{default: ShouldIgnoreSelectionWhenUpdatedManually}>('@libs/shouldIgnoreSelectionWhenUpdatedManually'), + __esModule: true, + default: true, +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), +})); + +const mockUseIsFocused = jest.mocked(NativeNavigation.useIsFocused); + +const buildKeyPressEvent = (key: string): NumericEditingKeyPressEvent => ({nativeEvent: {key}}); + +const renderSelection = (displayText = '12') => renderHook((props: {displayText: string}) => useNumericSelection(props), {initialProps: {displayText}}); + +describe('useNumericSelection', () => { + beforeEach(() => { + mockUseIsFocused.mockReturnValue(true); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('starts with the caret at the end of the displayed text', () => { + const {result} = renderSelection('123'); + + expect(result.current.selection).toEqual({start: 3, end: 3}); + }); + + it('collapses a highlighted range onto its end', () => { + const {result} = renderSelection('123'); + + act(() => { + result.current.handleNativeSelectionChange(0, 3); + }); + act(() => { + result.current.collapse(); + }); + + expect(result.current.selection).toEqual({start: 3, end: 3}); + }); + + it('collapses the selection when the screen regains focus', () => { + mockUseIsFocused.mockReturnValue(false); + const {result, rerender} = renderSelection('123'); + + act(() => { + result.current.handleNativeSelectionChange(0, 3); + }); + mockUseIsFocused.mockReturnValue(true); + rerender({displayText: '123'}); + + expect(result.current.selection).toEqual({start: 3, end: 3}); + }); + + it('moves the caret to the start when the field is reset', () => { + const {result} = renderSelection('123'); + + act(() => { + result.current.reset(); + }); + + expect(result.current.selection).toEqual({start: 0, end: 0}); + }); + + it('moves the caret to the end of the given text', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncToEnd('1234'); + }); + + expect(result.current.selection).toEqual({start: 4, end: 4}); + }); + + describe('syncAfterEdit', () => { + it('shifts the caret forward by the characters the edit added', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncAfterEdit({previousText: '12', nextText: '123'}); + }); + + expect(result.current.selection).toEqual({start: 3, end: 3}); + }); + + it('shifts the caret back by the characters the edit removed', () => { + const {result} = renderSelection('123'); + + act(() => { + result.current.syncAfterEdit({previousText: '123', nextText: '12'}); + }); + + expect(result.current.selection).toEqual({start: 2, end: 2}); + }); + + it('leaves the caret in place after a forward-delete, which removes the character after it', () => { + const {result} = renderSelection('123'); + + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + act(() => { + result.current.handleKeyPress(buildKeyPressEvent('Delete')); + result.current.syncAfterEdit({previousText: '123', nextText: '12'}); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + + it('collapses a selection to its start after a forward-delete', () => { + const {result} = renderSelection('123'); + + act(() => { + result.current.handleNativeSelectionChange(1, 2); + }); + act(() => { + result.current.handleKeyPress(buildKeyPressEvent('Delete')); + result.current.syncAfterEdit({previousText: '123', nextText: '13'}); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + + it('shifts the caret back when backspace shrinks the text', () => { + const {result} = renderSelection('123'); + + act(() => { + result.current.handleKeyPress(buildKeyPressEvent('Backspace')); + result.current.syncAfterEdit({previousText: '123', nextText: '12'}); + }); + + expect(result.current.selection).toEqual({start: 2, end: 2}); + }); + + it('clamps the caret to the start when the edit strips more characters than sit before it', () => { + const {result} = renderSelection('12.34'); + + act(() => { + result.current.handleNativeSelectionChange(0, 0); + }); + act(() => { + // Reducing the accepted precision rewrites the value wherever the caret happens to be. + result.current.syncAfterEdit({previousText: '12.34', nextText: '12'}); + }); + + expect(result.current.selection).toEqual({start: 0, end: 0}); + }); + + it('keeps the caret before pasted text when a forward-delete key press removed nothing', () => { + const {result} = renderSelection('12345'); + + act(() => { + result.current.handleNativeSelectionChange(1, 4); + }); + act(() => { + result.current.handleKeyPress(buildKeyPressEvent('Delete')); + result.current.syncAfterEdit({previousText: '12345', nextText: '195'}); + }); + + expect(result.current.selection).toEqual({start: 2, end: 2}); + }); + + it('shifts the caret back past a repeated character a backspace removed', () => { + const {result} = renderSelection('111'); + + act(() => { + result.current.handleNativeSelectionChange(2, 2); + }); + act(() => { + result.current.handleKeyPress(buildKeyPressEvent('Backspace')); + result.current.syncAfterEdit({previousText: '111', nextText: '11'}); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + + it('shifts the caret when a forward-delete key was pressed but the edit added characters', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.handleKeyPress(buildKeyPressEvent('Delete')); + result.current.syncAfterEdit({previousText: '12', nextText: '123'}); + }); + + expect(result.current.selection).toEqual({start: 3, end: 3}); + }); + }); + + describe('syncAfterEdit with an unchanged value', () => { + it('keeps the caret where it was when normalization resolves the edit back to the current value', () => { + const {result} = renderSelection('1.2'); + + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + act(() => { + result.current.syncAfterEdit({previousText: '1.2', nextText: '1.2'}); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + + it('drops the event native emits for the character it kept, not the user event after it', () => { + const {result} = renderSelection('1.2'); + + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + act(() => { + result.current.syncAfterEdit({previousText: '1.2', nextText: '1.2'}); + result.current.handleNativeSelectionChange(2, 2); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + + act(() => { + result.current.handleNativeSelectionChange(2, 2); + }); + + expect(result.current.selection).toEqual({start: 2, end: 2}); + }); + + it('consumes the forward-delete key press so the next edit is not treated as one', () => { + const {result} = renderSelection('1.2'); + + act(() => { + result.current.handleNativeSelectionChange(3, 3); + }); + act(() => { + result.current.handleKeyPress(buildKeyPressEvent('Delete')); + result.current.syncAfterEdit({previousText: '1.2', nextText: '1.2'}); + }); + act(() => { + result.current.syncAfterEdit({previousText: '1.2', nextText: '1.'}); + }); + + expect(result.current.selection).toEqual({start: 2, end: 2}); + }); + }); + + describe('syncToEnd with the caret already at the end', () => { + it('arms no guard, so the next selection event is applied', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncToEnd('12'); + }); + act(() => { + result.current.handleNativeSelectionChange(0, 0); + }); + + expect(result.current.selection).toEqual({start: 0, end: 0}); + }); + }); + + describe('guards armed before the screen was left', () => { + it('are dropped when the screen regains focus', () => { + const {result, rerender} = renderSelection('12'); + + act(() => { + result.current.syncAfterEdit({previousText: '12', nextText: '123'}); + }); + + mockUseIsFocused.mockReturnValue(false); + rerender({displayText: '123'}); + mockUseIsFocused.mockReturnValue(true); + rerender({displayText: '123'}); + + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + }); + + describe('handleNativeSelectionChange', () => { + it('keeps the selection object when the reported offsets already match, so no render is triggered', () => { + const {result} = renderSelection('123'); + const selectionBefore = result.current.selection; + + act(() => { + result.current.handleNativeSelectionChange(3, 3); + }); + + expect(result.current.selection).toBe(selectionBefore); + }); + + it('applies the reported offsets', () => { + const {result} = renderSelection('123'); + + act(() => { + result.current.handleNativeSelectionChange(1, 2); + }); + + expect(result.current.selection).toEqual({start: 1, end: 2}); + }); + + it('clamps offsets reported past the end of the displayed text', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.handleNativeSelectionChange(5, 7); + }); + + expect(result.current.selection).toEqual({start: 2, end: 2}); + }); + + it('clamps to the pending text when the event arrives before the edit renders', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncToEnd('12345'); + }); + act(() => { + result.current.handleNativeSelectionChange(9, 9); + }); + + expect(result.current.selection).toEqual({start: 5, end: 5}); + }); + + it('drops the stale event emitted in the same batch as a manual update', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncAfterEdit({previousText: '12', nextText: '123'}); + result.current.handleNativeSelectionChange(0, 0); + }); + + expect(result.current.selection).toEqual({start: 3, end: 3}); + }); + + it('drops the stale event even when it arrives after the manual update committed', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncAfterEdit({previousText: '12', nextText: '123'}); + }); + act(() => { + result.current.handleNativeSelectionChange(0, 0); + }); + + expect(result.current.selection).toEqual({start: 3, end: 3}); + }); + + it('applies the event following the dropped stale one', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncAfterEdit({previousText: '12', nextText: '123'}); + }); + act(() => { + result.current.handleNativeSelectionChange(0, 0); + }); + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + }); + + describe('rejectEdit', () => { + it('keeps the caret at its last valid position', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + act(() => { + result.current.rejectEdit(); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + + it('drops the selection event native emits for the rejected character', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + act(() => { + result.current.rejectEdit(); + result.current.handleNativeSelectionChange(2, 2); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + }); +}); diff --git a/tests/unit/components/useNumericSelectionWebTest.ts b/tests/unit/components/useNumericSelectionWebTest.ts new file mode 100644 index 000000000000..ca7920be6698 --- /dev/null +++ b/tests/unit/components/useNumericSelectionWebTest.ts @@ -0,0 +1,56 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useNumericSelection from '@components/NumericEditingController/hooks/useNumericSelection'; + +import type ShouldIgnoreSelectionWhenUpdatedManually from '@libs/shouldIgnoreSelectionWhenUpdatedManually/types'; + +import type * as NativeNavigation from '@react-navigation/native'; + +// On web the flag is false: the browser does not echo a stale selection event after a controlled update, +// so selection events arriving after a manual update must be applied instead of dropped. +jest.mock('@libs/shouldIgnoreSelectionWhenUpdatedManually', () => ({ + ...jest.requireActual<{default: ShouldIgnoreSelectionWhenUpdatedManually}>('@libs/shouldIgnoreSelectionWhenUpdatedManually'), + __esModule: true, + default: false, +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), +})); + +const renderSelection = (displayText = '12') => renderHook((props: {displayText: string}) => useNumericSelection(props), {initialProps: {displayText}}); + +describe('useNumericSelection on web', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('applies the selection event that follows a manual update', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.syncAfterEdit({previousText: '12', nextText: '123'}); + }); + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); + + it('still drops the selection event native emits for a rejected edit', () => { + const {result} = renderSelection('12'); + + act(() => { + result.current.handleNativeSelectionChange(1, 1); + }); + act(() => { + result.current.rejectEdit(); + result.current.handleNativeSelectionChange(2, 2); + }); + + // The rejected-input guard is independent of the platform flag. + expect(result.current.selection).toEqual({start: 1, end: 1}); + }); +}); diff --git a/tests/unit/getSubmitExpensePreMountDestinationRouteTest.ts b/tests/unit/getSubmitExpensePreMountDestinationRouteTest.ts index 467306c9448f..eea3019207fd 100644 --- a/tests/unit/getSubmitExpensePreMountDestinationRouteTest.ts +++ b/tests/unit/getSubmitExpensePreMountDestinationRouteTest.ts @@ -42,8 +42,11 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { }); it('returns undefined on wide layout', () => { + // Given a wide layout where the RHP does not cover fullscreen content mockGetIsNarrowLayout.mockReturnValue(false); + // When submission evaluates speculative navigation + // Then no route is returned because wide layouts do not need pre-mounting expect( getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, @@ -54,6 +57,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }), @@ -61,6 +65,9 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { }); it('returns undefined when the transaction is not ready', () => { + // Given a transaction that is not ready to submit + // When submission evaluates its speculative destination + // Then no route is returned because navigation must wait for stable transaction data expect( getSubmitExpensePreMountDestinationRoute({ isTransactionReady: false, @@ -71,6 +78,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }), @@ -78,6 +86,8 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { }); it('returns Search route for global create expense flows', () => { + // Given a global create flow that will finish in expense Search + // When submission selects a destination to pre-mount const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: undefined, @@ -87,14 +97,18 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then expense Search is prepared so the post-submit transition is immediate expect(route).toEqual(ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense'})); }); it('returns report route when the destination is not the report the user is looking at', () => { + // Given submission targets a loaded report different from the visible report + // When submission selects a destination to pre-mount const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -104,20 +118,25 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then the destination report is prepared because navigation must move there expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('returns undefined when relocating a tracked expense over a different visible report', () => { + // Given relocation would replace another report in the same tab // The single-workspace "Submit to my employer" shape: the user is reading their self-DM while the expense is // bound to the workspace chat. Both are reports, so there is no tab to switch to and the pre-insert would // replace the report on screen - leaving the cancel path to rebuild it from a snapshot (#97437). mockIsReportTopmostSplitNavigator.mockReturnValue(true); jest.mocked(Navigation.getTopmostReportId).mockReturnValue('456'); + // When submission evaluates whether to pre-mount the workspace destination + // Then it declines because cancellation could not safely restore the visible report expect( getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, @@ -128,6 +147,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: true, }), @@ -135,11 +155,13 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { }); it('keeps the pre-insert for an in-place expense whose destination is a different visible report', () => { + // Given an in-place expense targets another report without relocating existing data // Same navigation topology as the case above, but the expense is created in place rather than relocated (e.g. the // per-diem chat-report destination), so it keeps the pre-mount instead of being caught by the track-expense guard. mockIsReportTopmostSplitNavigator.mockReturnValue(true); jest.mocked(Navigation.getTopmostReportId).mockReturnValue('456'); + // When submission evaluates whether to pre-mount the destination const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -149,20 +171,24 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then pre-mounting remains safe because cancellation does not need a report snapshot expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('stays eligible once it has pre-inserted, so the hook does not tear down its own insert', () => { + // Given a relocation destination that has already been pre-inserted // After the pre-insert the visible report *is* the destination, so the same-tab check reads as safe on its own. // Assert it through the explicit pre-inserted flag too, since that is what keeps the result stable. mockIsReportTopmostSplitNavigator.mockReturnValue(true); jest.mocked(Navigation.getTopmostReportId).mockReturnValue('456'); jest.mocked(Navigation.getIsFullscreenPreInsertedUnderRHP).mockReturnValue(true); + // When eligibility is recomputed against the updated navigation state const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -172,14 +198,18 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: true, }); + // Then the route stays stable so the hook does not undo its own transition expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('returns the report route for a global-create track expense (self-DM target)', () => { + // Given global track expense creation will finish in its self-DM report + // When submission selects a destination to pre-mount const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -189,14 +219,18 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.TRACK, isCreatingTrackExpense: true, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then the self-DM is prepared because tracked expenses are report-bound expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('returns the report route when the sole recipient is the self-DM (CREATE routed through track)', () => { + // Given a create flow that becomes tracking because the user is the sole recipient + // When submission selects a destination to pre-mount const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -206,16 +240,20 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.CREATE, isCreatingTrackExpense: false, isSelfDMDestination: true, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then the self-DM report is prepared because it owns the tracked expense expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('does NOT pre-insert the self-DM report for a LOOKING_AROUND user (they are routed to Search after submit)', () => { + // Given a restricted user whose self-DM submission will finish in Search mockIsReportTopmostSplitNavigator.mockReturnValue(true); + // When submission evaluates the self-DM as a speculative destination const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -225,14 +263,18 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.CREATE, isCreatingTrackExpense: false, isSelfDMDestination: true, + isOptimisticNewChatDestination: false, isLookingAroundUser: true, isMovingTransactionFromTrackExpense: false, }); + // Then it skips the report because pre-mounting would conflict with final navigation expect(route).toBeUndefined(); }); it('returns the report route for a report-bound global create (PAY)', () => { + // Given a global pay flow bound to a concrete destination report + // When submission selects a destination to pre-mount const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -242,16 +284,20 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.PAY, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then the report is prepared because the payment will land there expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('still pre-inserts the report for a LOOKING_AROUND user when the destination is a real report, not the self-DM (PAY)', () => { + // Given a restricted user paying into a real report rather than the self-DM // A LOOKING_AROUND user who later has a workspace and submits to a real report/friend must keep the report // pre-insert - the LOOKING_AROUND gate is scoped to isSelfDMDestination, so it does not fire here. + // When submission evaluates the destination for speculative navigation const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -261,16 +307,21 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.PAY, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: true, isMovingTransactionFromTrackExpense: false, }); + // Then the report remains eligible because only self-DM routing is restricted expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('returns undefined when the destination report is not loaded in Onyx', () => { + // Given a destination ID without a local report or optimistic-chat guarantee mockIsReportTopmostSplitNavigator.mockReturnValue(true); + // When submission evaluates whether the report can be pre-mounted + // Then it declines because fetching an unknown report could latch a not-found state expect( getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, @@ -281,16 +332,43 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }), ).toBeUndefined(); }); + it('returns a pending-creation route for an unloaded optimistic chat destination', () => { + // Given a new optimistic chat whose report row does not exist locally yet + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + + // When submission chooses a route to pre-mount before chat creation completes + const route = getSubmitExpensePreMountDestinationRoute({ + isTransactionReady: true, + destinationReportID: '123', + destinationReport: undefined, + isFromGlobalCreate: false, + canPreInsertSearch: false, + iouType: CONST.IOU.TYPE.CREATE, + isCreatingTrackExpense: false, + isSelfDMDestination: false, + isOptimisticNewChatDestination: true, + isLookingAroundUser: false, + isMovingTransactionFromTrackExpense: false, + }); + + // Then the route carries a creation guard so fetching waits for the local report + expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123', undefined, undefined, undefined, undefined, true)); + }); + it('returns undefined when report is already topmost', () => { + // Given the final destination report is already visible mockIsReportTopmostSplitNavigator.mockReturnValue(true); jest.mocked(Navigation.getTopmostReportId).mockReturnValue('123'); + // When submission evaluates speculative navigation + // Then no route is returned because navigation is already at the destination expect( getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, @@ -301,6 +379,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }), @@ -308,6 +387,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { }); it('keeps returning the report route once it has been pre-inserted, even though it is now the topmost report', () => { + // Given a destination report made topmost by the current pre-insert transaction // After this route is pre-inserted under the RHP, getTopmostReportId() reports the pre-inserted // destination. Without the pre-insert guard this flips hasValidDestination to false and the route // recomputes to undefined, tearing down the just-inserted route. The result must stay stable instead. @@ -315,6 +395,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { jest.mocked(Navigation.getTopmostReportId).mockReturnValue('123'); jest.mocked(Navigation.getIsFullscreenPreInsertedUnderRHP).mockReturnValue(true); + // When eligibility is recomputed after navigation state changes const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: '123', @@ -324,17 +405,22 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then the route remains stable so the active pre-insert is not torn down expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); it('returns undefined when report is open in RHP', () => { + // Given the destination report is already open inside the RHP mockIsReportTopmostSplitNavigator.mockReturnValue(true); mockIsReportOpenInRHP.mockReturnValue(true); + // When submission evaluates speculative fullscreen navigation + // Then no route is returned because duplicating the open report would be redundant expect( getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, @@ -345,6 +431,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }), @@ -352,11 +439,13 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { }); it('returns Search route when Search is topmost with a different query type', () => { + // Given Search is visible but does not show the expense query needed after submission const {buildSearchQueryJSON} = jest.requireActual('@libs/SearchQueryUtils'); mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); jest.mocked(getCurrentSearchQueryJSON).mockReturnValue(buildSearchQueryJSON('type:invoice')); + // When submission selects the Search destination to pre-mount const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: undefined, @@ -366,14 +455,17 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then expense Search is prepared because the current query cannot show the result expect(route).toEqual(ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense'})); }); it('keeps returning the Search route once it has been pre-inserted, even though Search is now topmost with the same query type', () => { + // Given expense Search is topmost only because the current transaction pre-inserted it // After the Search route is pre-inserted under the RHP, isSearchTopmostFullScreenRoute() reports the pre-inserted Search // as topmost with a matching query type. Without the `|| hasPreInsertedFullscreen` guard, shouldPreInsertSearch flips to // false and the route recomputes to undefined, tearing down the just-inserted route. The result must stay stable instead. @@ -383,6 +475,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { jest.mocked(getCurrentSearchQueryJSON).mockReturnValue(buildSearchQueryJSON('type:expense')); jest.mocked(Navigation.getIsFullscreenPreInsertedUnderRHP).mockReturnValue(true); + // When eligibility is recomputed against the updated Search state const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, destinationReportID: undefined, @@ -392,10 +485,12 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isOptimisticNewChatDestination: false, isLookingAroundUser: false, isMovingTransactionFromTrackExpense: false, }); + // Then the route remains stable so the active pre-insert is not torn down expect(route).toEqual(ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense'})); }); }); diff --git a/tests/unit/hooks/useConfirmationCtaText.test.tsx b/tests/unit/hooks/useConfirmationCtaText.test.tsx index 927fe215c522..a1f2d0036441 100644 --- a/tests/unit/hooks/useConfirmationCtaText.test.tsx +++ b/tests/unit/hooks/useConfirmationCtaText.test.tsx @@ -17,7 +17,6 @@ type Params = Parameters[0]; const baseParams: Params = { expensesNumber: 1, isTypeInvoice: false, - isTypeTrackExpense: false, isTypeSplit: false, isTypeRequest: false, iouAmount: 100, @@ -27,7 +26,6 @@ const baseParams: Params = { receiptPath: '', isDistanceRequestWithPendingRoute: false, isPerDiemRequest: false, - isNewManualExpenseFlowEnabled: false, }; function Wrapper({children}: {children: React.ReactNode}) { @@ -58,18 +56,14 @@ describe('useConfirmationCtaText', () => { }); it('uses createExpense copy for track expense with zero amount', () => { - const {result} = renderHook(() => useConfirmationCtaText({...baseParams, isTypeTrackExpense: true, iouType: CONST.IOU.TYPE.TRACK, iouAmount: 0}), {wrapper: Wrapper}); + const {result} = renderHook(() => useConfirmationCtaText({...baseParams, iouType: CONST.IOU.TYPE.TRACK, iouAmount: 0}), {wrapper: Wrapper}); expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); }); - it('uses createExpense copy when new manual expense flow is enabled', () => { - const {result} = renderHook(() => useConfirmationCtaText({...baseParams, isNewManualExpenseFlowEnabled: true}), {wrapper: Wrapper}); - expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); - }); - - it('includes formatted amount in createExpenseWithAmount copy', () => { + it('uses createExpense copy without the amount for a submit with a non-zero amount', () => { const {result} = renderHook(() => useConfirmationCtaText({...baseParams, formattedAmount: '$42.00'}), {wrapper: Wrapper}); - expect(result.current.at(0)?.text).toContain('$42.00'); + expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); + expect(result.current.at(0)?.text).not.toContain('$42.00'); }); it('uses next copy for invoice without invoicing details', () => { @@ -94,19 +88,19 @@ describe('useConfirmationCtaText', () => { expect(result.current.at(0)?.text).toContain('$50.00'); }); - it('includes formatted amount for track expense with non-zero amount', () => { + it('uses createExpense copy without the amount for track expense with non-zero amount', () => { const {result} = renderHook( () => useConfirmationCtaText({ ...baseParams, - isTypeTrackExpense: true, iouType: CONST.IOU.TYPE.TRACK, iouAmount: 100, formattedAmount: '$1.23', }), {wrapper: Wrapper}, ); - expect(result.current.at(0)?.text).toContain('$1.23'); + expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); + expect(result.current.at(0)?.text).not.toContain('$1.23'); }); it('uses createExpense for distance request with pending route', () => { @@ -123,7 +117,7 @@ describe('useConfirmationCtaText', () => { expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); }); - it('uses createExpenseWithAmount for per-diem request with non-zero amount', () => { + it('uses createExpense copy without the amount for per-diem request with non-zero amount', () => { const {result} = renderHook( () => useConfirmationCtaText({ @@ -134,10 +128,11 @@ describe('useConfirmationCtaText', () => { }), {wrapper: Wrapper}, ); - expect(result.current.at(0)?.text).toContain('$2.00'); + expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); + expect(result.current.at(0)?.text).not.toContain('$2.00'); }); - it('uses splitAmount with formatted amount for split with non-zero amount when manual flow is disabled', () => { + it('uses splitExpense copy for split with non-zero amount', () => { const {result} = renderHook( () => useConfirmationCtaText({ @@ -145,11 +140,11 @@ describe('useConfirmationCtaText', () => { isTypeSplit: true, iouAmount: 500, formattedAmount: '$5.00', - isNewManualExpenseFlowEnabled: false, }), {wrapper: Wrapper}, ); - expect(result.current.at(0)?.text).toContain('$5.00'); + expect(result.current.at(0)?.text.toLowerCase()).toContain('split'); + expect(result.current.at(0)?.text).not.toContain('$5.00'); }); it('uses createExpense for default zero-amount fallback', () => { diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index 09f271c7f414..dc39352a9ae4 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -101,7 +101,6 @@ const baseParams = { isMovingTransactionFromTrackExpense: false, isTimeRequest: false, routeError: undefined, - isNewManualExpenseFlowEnabled: false, isReadOnly: false, shouldShowDate: true, isTaxAmountEmpty: false, @@ -310,13 +309,12 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: null}); }); - it('returns fieldRequired for manual expense when amount is not set in new manual expense flow with a policy expense chat participant', () => { + it('returns fieldRequired for manual expense when amount is not set with a policy expense chat participant', () => { const {result} = renderHook(() => useConfirmationValidation( createValidationParamsForParticipant( POLICY_EXPENSE_CHAT_PARTICIPANT, { - isNewManualExpenseFlowEnabled: true, iouAmount: 0, }, {isAmountSet: false}, @@ -326,11 +324,10 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); }); - it('does not return fieldRequired for scan expense when amount is not set in new manual expense flow', () => { + it('does not return fieldRequired for scan expense when amount is not set', () => { const {result} = renderHook(() => useConfirmationValidation({ ...baseParams, - isNewManualExpenseFlowEnabled: true, transaction: createTransactionBase({ amount: 1000, iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, @@ -341,11 +338,10 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: null}); }); - it('does not return fieldRequired for per diem expense when amount is not set in new manual expense flow', () => { + it('does not return fieldRequired for per diem expense when amount is not set', () => { const {result} = renderHook(() => useConfirmationValidation({ ...baseParams, - isNewManualExpenseFlowEnabled: true, isPerDiemRequest: true, transaction: createTransactionBase({ amount: 5000, @@ -367,9 +363,8 @@ describe('useConfirmationValidation', () => { expect(result.current.validate(CONST.IOU.PAYMENT_TYPE.ELSEWHERE)).toEqual({errorKey: null}); }); - describe('amount validation — new manual expense flow (isAmountSet)', () => { - const newManualFlowParams = { - isNewManualExpenseFlowEnabled: true, + describe('amount validation — manual expense (isAmountSet)', () => { + const zeroAmountParams = { iouAmount: 0, }; @@ -380,7 +375,7 @@ describe('useConfirmationValidation', () => { createValidationParamsForParticipant( POLICY_EXPENSE_CHAT_PARTICIPANT, { - ...newManualFlowParams, + ...zeroAmountParams, iouType, }, {isAmountSet: false}, @@ -396,7 +391,7 @@ describe('useConfirmationValidation', () => { createValidationParamsForParticipant( POLICY_EXPENSE_CHAT_PARTICIPANT, { - ...newManualFlowParams, + ...zeroAmountParams, iouType: CONST.IOU.TYPE.PAY, }, {isAmountSet: false}, @@ -410,7 +405,7 @@ describe('useConfirmationValidation', () => { it('returns errorKey: null when manual amount is explicitly set to zero for submit', () => { const {result} = renderHook(() => useConfirmationValidation( - createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, iouType: CONST.IOU.TYPE.SUBMIT}, {amount: 0, isAmountSet: true}), + createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...zeroAmountParams, iouType: CONST.IOU.TYPE.SUBMIT}, {amount: 0, isAmountSet: true}), ), ); expect(result.current.validate()).toEqual({errorKey: null}); @@ -419,7 +414,7 @@ describe('useConfirmationValidation', () => { it('returns invalidAmount when manual amount is explicitly set to zero for invoice', () => { const {result} = renderHook(() => useConfirmationValidation( - createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, iouType: CONST.IOU.TYPE.INVOICE}, {amount: 0, isAmountSet: true}), + createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...zeroAmountParams, iouType: CONST.IOU.TYPE.INVOICE}, {amount: 0, isAmountSet: true}), ), ); expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); @@ -435,7 +430,7 @@ describe('useConfirmationValidation', () => { createValidationParamsForParticipant( P2P_PARTICIPANT, { - ...newManualFlowParams, + ...zeroAmountParams, iouType, }, {isAmountSet: false}, @@ -452,7 +447,7 @@ describe('useConfirmationValidation', () => { createValidationParamsForParticipant( P2P_PARTICIPANT, { - ...newManualFlowParams, + ...zeroAmountParams, iouAmount: 2500, }, {amount: 2500, isAmountSet: true}, @@ -463,15 +458,13 @@ describe('useConfirmationValidation', () => { }); it('returns invalidAmount when manual amount is explicitly set to zero', () => { - const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(P2P_PARTICIPANT, newManualFlowParams, {amount: 0, isAmountSet: true}))); + const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(P2P_PARTICIPANT, zeroAmountParams, {amount: 0, isAmountSet: true}))); expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); }); it('returns invalidAmount when manual amount is explicitly set to zero for invoice', () => { const {result} = renderHook(() => - useConfirmationValidation( - createValidationParamsForParticipant(P2P_PARTICIPANT, {...newManualFlowParams, iouType: CONST.IOU.TYPE.INVOICE}, {amount: 0, isAmountSet: true}), - ), + useConfirmationValidation(createValidationParamsForParticipant(P2P_PARTICIPANT, {...zeroAmountParams, iouType: CONST.IOU.TYPE.INVOICE}, {amount: 0, isAmountSet: true})), ); expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); }); @@ -479,35 +472,19 @@ describe('useConfirmationValidation', () => { describe('self-DM participant', () => { it('returns fieldRequired for unset manual amount', () => { - const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(SELF_DM_PARTICIPANT, newManualFlowParams, {isAmountSet: false}))); + const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(SELF_DM_PARTICIPANT, zeroAmountParams, {isAmountSet: false}))); expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); }); it('returns errorKey: null when manual amount is explicitly set to zero', () => { - const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(SELF_DM_PARTICIPANT, newManualFlowParams, {amount: 0, isAmountSet: true}))); + const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(SELF_DM_PARTICIPANT, zeroAmountParams, {amount: 0, isAmountSet: true}))); expect(result.current.validate()).toEqual({errorKey: null}); }); }); - - it('does not return fieldRequired when the new manual expense flow beta is disabled', () => { - const {result} = renderHook(() => - useConfirmationValidation( - createValidationParamsForParticipant( - P2P_PARTICIPANT, - { - isNewManualExpenseFlowEnabled: false, - iouAmount: 0, - }, - {isAmountSet: false}, - ), - ), - ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); - }); }); describe('amount validation — P2P zero amount guard', () => { - it('returns invalidAmount for P2P manual submit with zero amount when flow is disabled', () => { + it('returns invalidAmount for P2P manual submit with zero amount', () => { const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(P2P_PARTICIPANT, {iouAmount: 0}, {amount: 0, isAmountSet: true}))); expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); }); @@ -574,15 +551,10 @@ describe('useConfirmationValidation', () => { }); describe('amount validation — programmatic request types (scan, distance, time, per diem)', () => { - const newManualFlowParams = { - ...baseParams, - isNewManualExpenseFlowEnabled: true, - }; - it('does not return fieldRequired for scan expense when amount is not set', () => { const {result} = renderHook(() => useConfirmationValidation({ - ...newManualFlowParams, + ...baseParams, transaction: createTransactionBase({ amount: 1000, iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, @@ -598,7 +570,7 @@ describe('useConfirmationValidation', () => { it('does not return fieldRequired for distance expense when amount is not set', () => { const {result} = renderHook(() => useConfirmationValidation({ - ...newManualFlowParams, + ...baseParams, iouAmount: 5000, isDistanceRequest: true, transaction: createTransactionBase({ @@ -616,7 +588,7 @@ describe('useConfirmationValidation', () => { it('does not return fieldRequired for time expense when amount is not set', () => { const {result} = renderHook(() => useConfirmationValidation({ - ...newManualFlowParams, + ...baseParams, iouAmount: 3600, isTimeRequest: true, transaction: createTransactionBase({ @@ -634,7 +606,7 @@ describe('useConfirmationValidation', () => { it('does not return fieldRequired for per diem expense when amount is not set', () => { const {result} = renderHook(() => useConfirmationValidation({ - ...newManualFlowParams, + ...baseParams, isPerDiemRequest: true, transaction: createTransactionBase({ amount: 5000, @@ -750,7 +722,6 @@ describe('useConfirmationValidation', () => { P2P_PARTICIPANT, { iouType: CONST.IOU.TYPE.SPLIT, - isNewManualExpenseFlowEnabled: true, iouAmount: 0, selectedParticipants: splitParticipants, }, @@ -768,7 +739,6 @@ describe('useConfirmationValidation', () => { POLICY_EXPENSE_CHAT_PARTICIPANT, { iouType: CONST.IOU.TYPE.SPLIT, - isNewManualExpenseFlowEnabled: true, iouAmount: 0, }, {isAmountSet: false}, @@ -786,7 +756,6 @@ describe('useConfirmationValidation', () => { POLICY_EXPENSE_CHAT_PARTICIPANT, { iouType: CONST.IOU.TYPE.SPLIT, - isNewManualExpenseFlowEnabled: true, iouAmount: 0, selectedParticipants: splitParticipants, }, @@ -818,23 +787,16 @@ describe('useConfirmationValidation', () => { }); }); - describe('date validation — inline required date in new manual expense flow', () => { - const newManualFlowParams = { - ...baseParams, - isNewManualExpenseFlowEnabled: true, - }; - + describe('date validation — inline required date', () => { it('returns fieldRequired for manual expense when the date is removed', () => { - const {result} = renderHook(() => - useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, newManualFlowParams, {created: '', isAmountSet: true})), - ); + const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, baseParams, {created: '', isAmountSet: true}))); expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); }); it('returns fieldRequired for distance expense when the date is removed', () => { const {result} = renderHook(() => useConfirmationValidation({ - ...newManualFlowParams, + ...baseParams, isDistanceRequest: true, transaction: createTransactionBase({ amount: 1000, @@ -852,7 +814,7 @@ describe('useConfirmationValidation', () => { it('returns fieldRequired for time expense when the date is removed', () => { const {result} = renderHook(() => useConfirmationValidation({ - ...newManualFlowParams, + ...baseParams, isTimeRequest: true, transaction: createTransactionBase({ amount: 1000, @@ -870,7 +832,7 @@ describe('useConfirmationValidation', () => { it('returns fieldRequired for invoice when the date is removed', () => { const {result} = renderHook(() => useConfirmationValidation( - createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, iouType: CONST.IOU.TYPE.INVOICE}, {created: '', isAmountSet: true}), + createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...baseParams, iouType: CONST.IOU.TYPE.INVOICE}, {created: '', isAmountSet: true}), ), ); expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); @@ -878,44 +840,29 @@ describe('useConfirmationValidation', () => { it('does not return fieldRequired when the date is present', () => { const {result} = renderHook(() => - useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, newManualFlowParams, {created: '2025-01-15', isAmountSet: true})), + useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, baseParams, {created: '2025-01-15', isAmountSet: true})), ); expect(result.current.validate()).toEqual({errorKey: null}); }); it('does not return fieldRequired when the fields are read-only (date populated server-side)', () => { const {result} = renderHook(() => - useConfirmationValidation( - createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, isReadOnly: true}, {created: '', isAmountSet: true}), - ), + useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...baseParams, isReadOnly: true}, {created: '', isAmountSet: true})), ); expect(result.current.validate()).toEqual({errorKey: null}); }); it('does not return fieldRequired when the date field is not shown (pure scan flow)', () => { const {result} = renderHook(() => - useConfirmationValidation( - createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, shouldShowDate: false}, {created: '', isAmountSet: true}), - ), - ); - expect(result.current.validate()).toEqual({errorKey: null}); - }); - - it('does not return fieldRequired when the new manual expense flow beta is disabled', () => { - const {result} = renderHook(() => - useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {isNewManualExpenseFlowEnabled: false}, {created: '', isAmountSet: true})), + useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...baseParams, shouldShowDate: false}, {created: '', isAmountSet: true})), ); expect(result.current.validate()).toEqual({errorKey: null}); }); }); - describe('tax validation — inline tax amount in new manual expense flow', () => { + describe('tax validation — inline tax amount', () => { function createTaxValidationParams(overrides: ValidationParamsOverrides = {}): UseConfirmationValidationParams { - return createValidationParamsForParticipant( - POLICY_EXPENSE_CHAT_PARTICIPANT, - {isNewManualExpenseFlowEnabled: true, shouldShowTax: true, ...overrides}, - {amount: 100, isAmountSet: true}, - ); + return createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {shouldShowTax: true, ...overrides}, {amount: 100, isAmountSet: true}); } it('returns invalidAmount when the inline tax amount is left empty', () => { @@ -932,7 +879,6 @@ describe('useConfirmationValidation', () => { const {result} = renderHook(() => useConfirmationValidation({ ...baseParams, - isNewManualExpenseFlowEnabled: true, shouldShowTax: true, isTaxAmountEmpty: true, isDistanceRequest: true, @@ -953,10 +899,5 @@ describe('useConfirmationValidation', () => { const {result} = renderHook(() => useConfirmationValidation(createTaxValidationParams({shouldShowTax: false, isTaxAmountEmpty: true}))); expect(result.current.validate()).toEqual({errorKey: null}); }); - - it('does not block when the new manual expense flow beta is disabled', () => { - const {result} = renderHook(() => useConfirmationValidation(createTaxValidationParams({isNewManualExpenseFlowEnabled: false, isTaxAmountEmpty: true}))); - expect(result.current.validate()).toEqual({errorKey: null}); - }); }); }); diff --git a/tests/unit/hooks/useDefaultParticipants.test.ts b/tests/unit/hooks/useDefaultParticipants.test.ts index d08db5015173..7c15ff787357 100644 --- a/tests/unit/hooks/useDefaultParticipants.test.ts +++ b/tests/unit/hooks/useDefaultParticipants.test.ts @@ -50,19 +50,19 @@ jest.mock('@hooks/useSelfDMReport', () => ({ const globalCreateTransaction: Transaction = {...createRandomTransaction(1), isFromGlobalCreate: true}; -function renderDefaultParticipantsHook(iouType: IOUType, transaction: Transaction = globalCreateTransaction, isNewManualExpenseFlowEnabled = true) { - return renderHook(() => useDefaultParticipants({sourceReport: undefined, transaction, iouType, isNewManualExpenseFlowEnabled})); +function renderDefaultParticipantsHook(iouType: IOUType, transaction: Transaction = globalCreateTransaction) { + return renderHook(() => useDefaultParticipants({sourceReport: undefined, transaction, iouType})); } // The hook reads the billing NVPs through `useOnyx`, so the result is only settled once those subscriptions have. -async function renderDefaultParticipantsResult(iouType: IOUType, transaction: Transaction = globalCreateTransaction, isNewManualExpenseFlowEnabled = true) { - const {result} = renderDefaultParticipantsHook(iouType, transaction, isNewManualExpenseFlowEnabled); +async function renderDefaultParticipantsResult(iouType: IOUType, transaction: Transaction = globalCreateTransaction) { + const {result} = renderDefaultParticipantsHook(iouType, transaction); await act(waitForBatchedUpdates); return result.current; } -async function renderDefaultParticipants(iouType: IOUType, transaction: Transaction = globalCreateTransaction, isNewManualExpenseFlowEnabled = true) { - return (await renderDefaultParticipantsResult(iouType, transaction, isNewManualExpenseFlowEnabled)).participants; +async function renderDefaultParticipants(iouType: IOUType, transaction: Transaction = globalCreateTransaction) { + return (await renderDefaultParticipantsResult(iouType, transaction)).participants; } describe('useDefaultParticipants', () => { @@ -121,10 +121,4 @@ describe('useDefaultParticipants', () => { expect(participants).toEqual([]); }); - - it('should not seed anything when the new manual expense flow beta is disabled', async () => { - const participants = await renderDefaultParticipants(CONST.IOU.TYPE.TRACK, globalCreateTransaction, false); - - expect(participants).toEqual([]); - }); }); diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index aebd3cdc0186..ea9a5a836dde 100644 --- a/tests/unit/hooks/useEditMessage.test.ts +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -58,9 +58,10 @@ jest.mock('@hooks/useReportIsArchived', () => ({ default: () => false, })); +const mockScrollToBottom = jest.fn(); jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, - default: () => ({scrollToIndex: jest.fn()}), + default: () => ({scrollToBottom: mockScrollToBottom}), })); jest.mock('@libs/ReportUtils', () => { @@ -142,4 +143,17 @@ describe('useEditMessage', () => { const args = mockShowDeleteModal.mock.calls.at(0); expect(args?.[1]?.reportActionID).toBe(props.reportAction?.reportActionID); }); + + it('scrolls to the bottom after deleting the newest message draft', () => { + const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true}); + + act(() => { + hook.result.current.publishDraft(' '); + }); + act(() => { + mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); + }); + + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/unit/hooks/useExpenseSubmission.test.ts b/tests/unit/hooks/useExpenseSubmission.test.ts index 3a31f785fc48..b6ecf24e6711 100644 --- a/tests/unit/hooks/useExpenseSubmission.test.ts +++ b/tests/unit/hooks/useExpenseSubmission.test.ts @@ -1,7 +1,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ import {act, renderHook} from '@testing-library/react-native'; +import * as IOUUtils from '@libs/IOUUtils'; import Log from '@libs/Log'; +// eslint-disable-next-line no-restricted-imports -- Namespace import is required to spy on getChatByParticipants without replacing the production module. +import * as ReportUtils from '@libs/ReportUtils'; import useExpenseSubmission from '@pages/iou/request/step/confirmation/useExpenseSubmission'; @@ -244,6 +247,42 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { }); describe('requestMoney path', () => { + it('uses the transaction report ID for a brand-new P2P recipient optimistic chat', async () => { + // Given a new P2P recipient whose transaction already reserved a report ID + const optimisticP2PReportID = 'reused-p2p-report-1'; + const transaction = buildTransaction({reportID: optimisticP2PReportID}); + const getChatByParticipantsSpy = jest.spyOn(ReportUtils, 'getChatByParticipants').mockReturnValue(undefined); + const getReusableP2PReportIDSpy = jest.spyOn(IOUUtils, 'getReusableP2PReportID').mockReturnValue(optimisticP2PReportID); + + try { + const {result} = renderHook(() => + useExpenseSubmission( + buildParams({ + transaction, + transactions: [transaction], + report: undefined, + reportID: optimisticP2PReportID, + }), + ), + ); + await waitForBatchedUpdatesWithAct(); + + // When the request is created before a persisted chat can be resolved + await act(async () => { + result.current.createTransaction(false, false); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the reserved ID is forwarded so optimistic transaction and chat data align + expect(getChatByParticipantsSpy).toHaveBeenCalled(); + expect(getReusableP2PReportIDSpy).toHaveBeenCalledWith(expect.objectContaining({accountID: 42}), optimisticP2PReportID); + expect(mockRequestMoneyAction).toHaveBeenCalledWith(expect.objectContaining({optimisticChatReportID: optimisticP2PReportID})); + } finally { + getChatByParticipantsSpy.mockRestore(); + getReusableP2PReportIDSpy.mockRestore(); + } + }); + it('calls cleanupAfterExpenseCreate and skips cleanupAndNavigateAfterExpenseCreate when shouldHandleNavigation=false (orchestrator pre-navigated)', async () => { const {result} = renderHook(() => useExpenseSubmission(buildParams())); await waitForBatchedUpdatesWithAct(); @@ -419,6 +458,34 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { expect(transactionParams).not.toHaveProperty('modifiedMerchant'); } }); + + it('uses the transaction report ID for a brand-new P2P recipient even when a page-level report is still set', async () => { + // Given a distance expense whose brand-new P2P recipient already reserved an optimistic report ID, + // while the page-level report still points at the flow's origin report + const optimisticP2PReportID = 'reused-p2p-distance-1'; + const distanceTransaction = buildTransaction({reportID: optimisticP2PReportID, iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL}); + + const {result} = renderHook(() => + useExpenseSubmission( + buildParams({ + transaction: distanceTransaction, + transactions: [distanceTransaction], + requestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + isDistanceRequest: true, + isManualDistanceRequest: true, + }), + ), + ); + await waitForBatchedUpdatesWithAct(); + + // When the distance request is submitted + await act(async () => { + result.current.createTransaction(false, true); + }); + + // Then the reserved ID is forwarded, so the chat is built at the ID the screen subscribes to + expect(mockCreateDistanceRequestAction).toHaveBeenCalledWith(expect.objectContaining({optimisticChatReportID: optimisticP2PReportID})); + }); }); describe('trackExpense path', () => { diff --git a/tests/unit/hooks/useFormErrorManagement.test.tsx b/tests/unit/hooks/useFormErrorManagement.test.tsx index 4a4cf2674e8a..764ec1eef4ed 100644 --- a/tests/unit/hooks/useFormErrorManagement.test.tsx +++ b/tests/unit/hooks/useFormErrorManagement.test.tsx @@ -45,7 +45,6 @@ const baseParams: Params = { routeError: undefined, isTypeSplit: false, shouldShowReadOnlySplits: false, - isNewManualExpenseFlowEnabled: false, isDistanceRequest: false, shouldShowDate: false, isReadOnly: false, @@ -131,23 +130,23 @@ describe('useFormErrorManagement', () => { expect(result.current.errorMessage).toBeUndefined(); }); - it('errorMessage suppresses required/invalid amount errors in the new manual expense flow (surfaced inline)', () => { - const {result: required} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: true}), {wrapper: Wrapper}); + it('errorMessage suppresses required/invalid amount errors (surfaced inline)', () => { + const {result: required} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); act(() => required.current.setFormError('common.error.fieldRequired')); expect(required.current.errorMessage).toBeUndefined(); - const {result: invalid} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: true}), {wrapper: Wrapper}); + const {result: invalid} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); act(() => invalid.current.setFormError('common.error.invalidAmount')); expect(invalid.current.errorMessage).toBeUndefined(); }); - it('errorMessage still shows required/invalid amount errors when the new manual expense flow is disabled', () => { - const {result} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: false}), {wrapper: Wrapper}); + it('errorMessage still shows the invalid amount error for a distance request (no inline surface)', () => { + const {result} = renderHook(() => useFormErrorManagement({...baseParams, isDistanceRequest: true}), {wrapper: Wrapper}); act(() => result.current.setFormError('common.error.invalidAmount')); expect(result.current.errorMessage).toBeDefined(); }); - const splitParams: Params = {...baseParams, isNewManualExpenseFlowEnabled: true, isTypeSplit: true, shouldShowReadOnlySplits: false}; + const splitParams: Params = {...baseParams, isTypeSplit: true, shouldShowReadOnlySplits: false}; it('suppresses the duplicate footer invalid amount error on an editable split (#96565)', () => { jest.useFakeTimers(); @@ -201,24 +200,12 @@ describe('useFormErrorManagement', () => { } }); - it('errorMessage still shows the invalid amount error for a distance request in the new manual expense flow (no inline surface)', () => { - const {result} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: true, isDistanceRequest: true}), {wrapper: Wrapper}); - act(() => result.current.setFormError('common.error.invalidAmount')); - expect(result.current.errorMessage).toBeDefined(); - }); - - it('errorMessage suppresses the invalid merchant error in the new manual expense flow (surfaced inline)', () => { - const {result} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: true}), {wrapper: Wrapper}); + it('errorMessage suppresses the invalid merchant error (surfaced inline)', () => { + const {result} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); act(() => result.current.setFormError('iou.error.invalidMerchant')); expect(result.current.errorMessage).toBeUndefined(); }); - it('errorMessage still shows the invalid merchant error when the new manual expense flow is disabled', () => { - const {result} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: false}), {wrapper: Wrapper}); - act(() => result.current.setFormError('iou.error.invalidMerchant')); - expect(result.current.errorMessage).toBeDefined(); - }); - it('treats the placeholder merchant of an untouched draft as empty, so it is only invalid while a merchant is required', () => { const {result: required} = renderHook(() => useFormErrorManagement({...baseParams, ...placeholderMerchantParams, isPolicyExpenseChat: true}), {wrapper: Wrapper}); const {result: notRequired} = renderHook(() => useFormErrorManagement({...baseParams, ...placeholderMerchantParams, isPolicyExpenseChat: false}), {wrapper: Wrapper}); @@ -245,8 +232,7 @@ describe('useFormErrorManagement', () => { it('clears the invalid merchant error once the recipient changes from a workspace chat to a user (#96593)', () => { // Given an untouched manual draft (still carrying the placeholder merchant) headed for a workspace chat const {result, rerender} = renderHook( - ({isPolicyExpenseChat}: {isPolicyExpenseChat: boolean}) => - useFormErrorManagement({...baseParams, ...placeholderMerchantParams, isNewManualExpenseFlowEnabled: true, isPolicyExpenseChat}), + ({isPolicyExpenseChat}: {isPolicyExpenseChat: boolean}) => useFormErrorManagement({...baseParams, ...placeholderMerchantParams, isPolicyExpenseChat}), {wrapper: Wrapper, initialProps: {isPolicyExpenseChat: true}}, ); @@ -271,7 +257,6 @@ describe('useFormErrorManagement', () => { isReadOnly?: boolean; }): Params => ({ ...baseParams, - isNewManualExpenseFlowEnabled: true, shouldShowDate, isReadOnly, transaction: createMock({ diff --git a/tests/unit/inlineEditing/TransactionInlineEdit.test.ts b/tests/unit/inlineEditing/TransactionInlineEdit.test.ts index 21569de113c8..28acc040fbe4 100644 --- a/tests/unit/inlineEditing/TransactionInlineEdit.test.ts +++ b/tests/unit/inlineEditing/TransactionInlineEdit.test.ts @@ -647,6 +647,7 @@ describe('TransactionInlineEdit', () => { transactions: {[`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`]: snapshotTransaction}, transactionViolations: {}, betas: [], + isASAPSubmitBetaEnabled: false, introSelected: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, currentUserEmail: '', diff --git a/tests/unit/pages/ReimbursementAccountPagePendingRedirectTest.tsx b/tests/unit/pages/ReimbursementAccountPagePendingRedirectTest.tsx new file mode 100644 index 000000000000..46d2f845baac --- /dev/null +++ b/tests/unit/pages/ReimbursementAccountPagePendingRedirectTest.tsx @@ -0,0 +1,486 @@ +import {act, render} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReimbursementAccountNavigatorParamList} from '@libs/Navigation/types'; + +import ReimbursementAccountPage from '@pages/ReimbursementAccount/ReimbursementAccountPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Policy, ReimbursementAccount} from '@src/types/onyx'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import type * as ReimbursementAccountTestUtils from '../../utils/ReimbursementAccountTestUtils'; + +import createMock from '../../utils/createMock'; +import getOnyxValue from '../../utils/getOnyxValue'; +import {BACK_TO, buildAchData, OTHER_POLICY_ID, PENDING_ACCOUNT, POLICY_ID} from '../../utils/ReimbursementAccountTestUtils'; +import {getGlobalFetchMock} from '../../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +// Mutable so individual tests can simulate the validation step covering this page and the user coming back to it. +let mockIsFocused = true; + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useIsFocused: () => mockIsFocused, + usePreventRemove: jest.fn(), + }; +}); + +jest.mock('@src/hooks/useResponsiveLayout'); + +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: () => undefined, +})); + +jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ + __esModule: true, + default: () => ({didScreenTransitionEnd: true}), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: jest.requireActual('../../utils/ReimbursementAccountTestUtils').createNavigationMock(), +})); + +// Stub the terminal screens so the assertions are about which branch the page picked, not about their internals. +let mockLoaderBackPress: (() => void) | undefined; +const mockEntryPoint = jest.fn(() => null); +const mockLoadingIndicator = jest.fn((props: {onBackButtonPress: () => void}) => { + mockLoaderBackPress = props.onBackButtonPress; + return null; +}); + +jest.mock('@pages/ReimbursementAccount/VerifiedBankAccountFlowEntryPoint', () => ({ + __esModule: true, + default: () => mockEntryPoint(), +})); + +jest.mock('@components/ReimbursementAccountLoadingIndicator', () => ({ + __esModule: true, + default: (props: {onBackButtonPress: () => void}) => mockLoadingIndicator(props), +})); + +const USD_POLICY: Policy = { + id: POLICY_ID, + name: 'Test workspace', + outputCurrency: CONST.CURRENCY.USD, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.CORPORATE, + owner: 'admin@example.com', +}; + +const EUR_POLICY: Policy = {...USD_POLICY, outputCurrency: CONST.CURRENCY.EUR}; + +// A plain member. The USER role carries no WORKFLOWS_PAYMENTS write access, which is what canMemberWrite checks. +const MEMBER_POLICY: Policy = {...USD_POLICY, role: CONST.POLICY.ROLE.USER}; + +const PENDING_DELETE_POLICY: Policy = {...USD_POLICY, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; + +// A policy that has loaded without publishing a currency. `Policy` declares outputCurrency as required, so the mock +// helper is what lets this fixture describe the partially-loaded shape Onyx can actually hold. +const NO_CURRENCY_POLICY = createMock({...USD_POLICY, outputCurrency: undefined}); + +type RouteParams = ReimbursementAccountNavigatorParamList[typeof SCREENS.REIMBURSEMENT_ACCOUNT_ROOT]; +type PageProps = PlatformStackScreenProps; + +const buildRoute = (params: RouteParams): PageProps['route'] => ({ + key: 'reimbursement-account-root', + name: SCREENS.REIMBURSEMENT_ACCOUNT_ROOT, + params, +}); + +// The page does not read the navigation prop. This inert double only satisfies the navigator-provided prop. +const navigation = createMock({}); + +// The policy reaches the page through the real withPolicy HOC, which reads it from Onyx by the route's policyID. +const seedOnyx = async (account: ReimbursementAccount, policy: Policy | null = USD_POLICY) => { + await act(async () => { + await Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, account); + await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); + await Onyx.set(ONYXKEYS.HAS_LOADED_APP, true); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await waitForBatchedUpdatesWithAct(); + }); +}; + +const pageElement = (params: RouteParams) => ( + + + +); + +const renderPage = async (params: RouteParams = {policyID: POLICY_ID}) => { + const rendered = render(pageElement(params)); + await waitForBatchedUpdatesWithAct(); + return rendered; +}; + +const validationRoute = (backTo?: string) => + backTo ? `bank-account/new/us/validation?policyID=${POLICY_ID}&backTo=${encodeURIComponent(backTo)}` : `bank-account/new/us/validation?policyID=${POLICY_ID}`; + +/** + * Asserts the page neither navigated into the validation step nor parked itself on the redirect loader, which is the + * other way a wrongly-derived redirect condition shows up: the page stops painting the entry point and never leaves. + */ +const expectNoPendingRedirect = () => { + expect(Navigation.navigate).not.toHaveBeenCalledWith(expect.stringContaining('bank-account/new/us/validation')); + expect(mockEntryPoint).toHaveBeenCalled(); +}; + +const pressLoaderBackButton = () => { + expect(mockLoadingIndicator).toHaveBeenCalled(); + mockLoaderBackPress?.(); +}; + +const getReimbursementAccount = () => getOnyxValue(ONYXKEYS.REIMBURSEMENT_ACCOUNT); +const getReimbursementAccountDraft = () => getOnyxValue(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); + +describe('ReimbursementAccountPage pending USD redirect', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + global.fetch = getGlobalFetchMock(); + }); + + beforeEach(() => { + mockIsFocused = true; + mockLoaderBackPress = undefined; + }); + + afterEach(async () => { + jest.clearAllMocks(); + await act(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + }); + + describe('when the account is pending', () => { + it('redirects to the validation step instead of painting the Continue setup entry point', async () => { + // Given a USD bank account for this policy that is pending validation + await seedOnyx(PENDING_ACCOUNT); + + // When the page is opened for that policy + await renderPage(); + + // Then it pushes the validation step and never renders the entry point + expect(Navigation.navigate).toHaveBeenCalledWith(validationRoute()); + expect(mockEntryPoint).not.toHaveBeenCalled(); + expect(mockLoadingIndicator).toHaveBeenCalled(); + }); + + it('carries backTo through to the validation route', async () => { + // Given a pending account reached from a screen that passed backTo + await seedOnyx(PENDING_ACCOUNT); + + // When the page is opened with that backTo + await renderPage({policyID: POLICY_ID, backTo: BACK_TO}); + + // Then the redirect preserves it so the validation step can return there + expect(Navigation.navigate).toHaveBeenCalledWith(validationRoute(BACK_TO)); + }); + + it('redirects only once even when Onyx pushes another update for the same account', async () => { + // Given a pending account that has already redirected + await seedOnyx(PENDING_ACCOUNT); + await renderPage(); + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + + // When another update lands for the same account + await act(async () => { + await Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {bankAccountID: 5678}}); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the redirect is not dispatched a second time + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + }); + + it('keeps the account data in Onyx when it unmounts because it redirected', async () => { + // Given a pending account that redirected into the validation step + await seedOnyx(PENDING_ACCOUNT); + const {unmount} = await renderPage(); + expect(Navigation.navigate).toHaveBeenCalledWith(validationRoute()); + + // When this page unmounts behind the validation step + await act(async () => { + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the data ConnectBankAccount reads survives, instead of being reset to the blank-RHP default + expect(await getReimbursementAccount()).toEqual(PENDING_ACCOUNT); + }); + + it('still clears the draft on that unmount, so the next visit does not prefill the last attempt', async () => { + // Given a pending account whose micro-deposit amounts were saved to the draft + await act(async () => { + await Onyx.set(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, {amount1: '1.11', amount2: '2.22', amount3: '3.33'}); + await waitForBatchedUpdatesWithAct(); + }); + await seedOnyx(PENDING_ACCOUNT); + const {unmount} = await renderPage(); + expect(Navigation.navigate).toHaveBeenCalledWith(validationRoute()); + + // When this page unmounts behind the validation step + await act(async () => { + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + // Then only the account survives: the draft is wiped, so the stale amounts cannot be resubmitted + expect(await getReimbursementAccount()).toEqual(PENDING_ACCOUNT); + const draft = await getReimbursementAccountDraft(); + expect(draft?.amount1).toBeUndefined(); + expect(draft?.amount2).toBeUndefined(); + expect(draft?.amount3).toBeUndefined(); + }); + + it('clears the account once the user has left the flow from the loader', async () => { + // Given a pending account that redirected, which the user then backs out of + await seedOnyx(PENDING_ACCOUNT); + const {unmount} = await renderPage({policyID: POLICY_ID, backTo: BACK_TO}); + expect(Navigation.navigate).toHaveBeenCalledWith(validationRoute(BACK_TO)); + pressLoaderBackButton(); + + // When this page unmounts after that exit + await act(async () => { + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + // Then nothing is left to read the preserved data, so the usual wipe applies again + expect(await getReimbursementAccount()).toEqual(CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA); + }); + }); + + describe('loader back button', () => { + it('returns to backTo when one was passed', async () => { + // Given a pending account opened with a backTo + await seedOnyx(PENDING_ACCOUNT); + await renderPage({policyID: POLICY_ID, backTo: BACK_TO}); + + // When the user presses back on the loader + pressLoaderBackButton(); + + // Then it leaves the flow to where the user came from + expect(Navigation.goBack).toHaveBeenCalledWith(BACK_TO); + }); + + it('dismisses the modal when there is no backTo', async () => { + // Given a pending account opened without a backTo + await seedOnyx(PENDING_ACCOUNT); + await renderPage(); + + // When the user presses back on the loader + pressLoaderBackButton(); + + // Then it leaves the flow rather than stepping back into a page that redirects again + expect(Navigation.dismissModal).toHaveBeenCalled(); + expect(Navigation.goBack).not.toHaveBeenCalled(); + expect(Navigation.closeRHPFlow).not.toHaveBeenCalled(); + }); + }); + + describe('returning to the page after the redirect', () => { + it('leaves the flow when the user navigates back onto it', async () => { + // Given a pending account that redirected into the validation step + await seedOnyx(PENDING_ACCOUNT); + const {rerender} = await renderPage({policyID: POLICY_ID, backTo: BACK_TO}); + + const rerenderPage = async () => { + rerender(pageElement({policyID: POLICY_ID, backTo: BACK_TO})); + await waitForBatchedUpdatesWithAct(); + }; + + // When the validation step covers this page and the user then goes back onto it + mockIsFocused = false; + await rerenderPage(); + mockIsFocused = true; + await rerenderPage(); + + // Then it leaves the flow instead of sitting on the loader forever + expect(Navigation.goBack).toHaveBeenCalledWith(BACK_TO); + }); + + it('does not leave the flow while the redirect is still in flight', async () => { + // Given a pending account whose redirect has dispatched but has not covered this page yet + await seedOnyx(PENDING_ACCOUNT); + const {rerender} = await renderPage({policyID: POLICY_ID, backTo: BACK_TO}); + + // When the page re-renders while still focused + rerender(pageElement({policyID: POLICY_ID, backTo: BACK_TO})); + await waitForBatchedUpdatesWithAct(); + + // Then it does not race the navigation it is meant to follow + expect(Navigation.goBack).not.toHaveBeenCalled(); + }); + }); + + describe('cases that must not redirect', () => { + it.each([[CONST.BANK_ACCOUNT.STATE.OPEN], [CONST.BANK_ACCOUNT.STATE.VERIFYING], [CONST.BANK_ACCOUNT.STATE.SETUP], [CONST.BANK_ACCOUNT.STATE.LOCKED]])( + 'leaves a %s account on the normal flow', + async (state) => { + // Given a USD bank account for this policy that is not pending + await seedOnyx({...PENDING_ACCOUNT, achData: buildAchData({state})}); + + // When the page is opened + await renderPage(); + + // Then no redirect happens and the normal entry point is painted + expectNoPendingRedirect(); + }, + ); + + it('does not redirect a non-USD workspace', async () => { + // Given a pending account on a workspace that does not pay out in USD + await seedOnyx({...PENDING_ACCOUNT, achData: buildAchData({currency: CONST.CURRENCY.EUR})}, EUR_POLICY); + + // When the page is opened + await renderPage({policyID: POLICY_ID}); + + // Then the USD validation step is not opened + expectNoPendingRedirect(); + }); + + it('does not redirect a non-USD account when the policy has not loaded yet', async () => { + // Given a pending non-USD account whose policy is not in Onyx, so policyCurrency falls back to achData + await seedOnyx({...PENDING_ACCOUNT, achData: buildAchData({currency: CONST.CURRENCY.EUR})}, null); + + // When the page is opened + await renderPage(); + + // Then the fallback currency keeps the USD validation step closed + expect(Navigation.navigate).not.toHaveBeenCalledWith(expect.stringContaining('bank-account/new/us/validation')); + }); + + // The redirect fires from an effect, which runs even on the renders that return the not-found view, and the + // validation step has no authorization guard of its own. So these three are the difference between the + // not-authorized screen and the micro-deposit form. + it('does not redirect a member who cannot manage the workspace bank account', async () => { + // Given a matching pending account cached for a workspace the user only has member access to + await seedOnyx(PENDING_ACCOUNT, MEMBER_POLICY); + + // When the page is opened + await renderPage(); + + // Then the redirect does not carry the user past the not-authorized screen + expect(Navigation.navigate).not.toHaveBeenCalledWith(expect.stringContaining('bank-account/new/us/validation')); + }); + + it('does not redirect when the workspace is pending deletion', async () => { + // Given a matching pending account cached for a workspace the user has just deleted + await seedOnyx(PENDING_ACCOUNT, PENDING_DELETE_POLICY); + + // When the page is opened + await renderPage(); + + // Then the redirect does not reopen the flow for a workspace that is going away + expect(Navigation.navigate).not.toHaveBeenCalledWith(expect.stringContaining('bank-account/new/us/validation')); + }); + + it('waits for the policy instead of redirecting on a cached account alone', async () => { + // Given a pending USD account whose policy is not in Onyx, so nothing can grant access yet + await seedOnyx(PENDING_ACCOUNT, null); + + // When the page is opened for that policy + await renderPage(); + + // Then it holds, rather than authorizing the validation step from the persisted account + expect(Navigation.navigate).not.toHaveBeenCalledWith(expect.stringContaining('bank-account/new/us/validation')); + }); + + it('does not redirect when no currency can be resolved at all', async () => { + // Given a pending account carrying no currency, on a policy that has not published one either + await seedOnyx({...PENDING_ACCOUNT, achData: buildAchData({currency: undefined})}, NO_CURRENCY_POLICY); + + // When the page is opened + await renderPage(); + + // Then the redirect stays closed rather than reading an absent currency as USD. This is the one case the + // fix does not cover: getBankAccountConnectionStatus does treat absent as USD, so the Workflows row can + // still offer Confirm and land the user on the entry point. + expectNoPendingRedirect(); + }); + + it('does not redirect while changing the bank account', async () => { + // Given a pending account on a page opened to replace that account + await seedOnyx(PENDING_ACCOUNT); + + // When the page is opened with isChangingBankAccount + await renderPage({policyID: POLICY_ID, isChangingBankAccount: true}); + + // Then the replacement flow is left alone + expectNoPendingRedirect(); + }); + + it('does not redirect when the pending account belongs to another policy', async () => { + // Given persisted data describing a different policy's pending account + await seedOnyx({...PENDING_ACCOUNT, achData: buildAchData({policyID: OTHER_POLICY_ID})}); + + // When this policy's page is opened and its own fetch settles + await renderPage(); + await act(async () => { + await Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {isLoading: false}); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the stale account does not drag this policy into validation + expectNoPendingRedirect(); + }); + + it('does not redirect for an entry point that passes no policyID', async () => { + // Given the Wallet entry point, which passes only a bankAccountID + await seedOnyx(PENDING_ACCOUNT); + + // When the page is opened that way + await renderPage({bankAccountID: '1234'}); + + // Then there is nothing to match the persisted account against, so nothing is redirected + expectNoPendingRedirect(); + }); + + it('does not redirect before the account data has loaded', async () => { + // Given a pending account whose data is still being fetched + await seedOnyx({...PENDING_ACCOUNT, isLoading: true}); + + // When the page is opened + await renderPage(); + + // Then it waits instead of redirecting on half-loaded data + expect(Navigation.navigate).not.toHaveBeenCalledWith(expect.stringContaining('bank-account/new/us/validation')); + }); + + it('still clears the account on unmount when it did not redirect', async () => { + // Given an open account, which never redirects + await seedOnyx({...PENDING_ACCOUNT, achData: buildAchData({state: CONST.BANK_ACCOUNT.STATE.OPEN})}); + const {unmount} = await renderPage(); + + // When the page unmounts + await act(async () => { + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the existing cleanup still resets the account + expect(await getReimbursementAccount()).toEqual(CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA); + }); + }); +}); diff --git a/tests/unit/pages/USDVerifiedBankAccountFlowPageBackTest.tsx b/tests/unit/pages/USDVerifiedBankAccountFlowPageBackTest.tsx new file mode 100644 index 000000000000..0d125e083180 --- /dev/null +++ b/tests/unit/pages/USDVerifiedBankAccountFlowPageBackTest.tsx @@ -0,0 +1,180 @@ +import {act, render} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReimbursementAccountNavigatorParamList} from '@libs/Navigation/types'; + +import USDVerifiedBankAccountFlowPage from '@pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {ReimbursementAccount} from '@src/types/onyx'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import type * as ReimbursementAccountTestUtils from '../../utils/ReimbursementAccountTestUtils'; + +import createMock from '../../utils/createMock'; +import {BACK_TO, buildAchData, PENDING_ACCOUNT, POLICY_ID} from '../../utils/ReimbursementAccountTestUtils'; +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useIsFocused: () => true, + usePreventRemove: jest.fn(), + }; +}); + +jest.mock('@src/hooks/useResponsiveLayout'); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: jest.requireActual('../../utils/ReimbursementAccountTestUtils').createNavigationMock(), +})); + +// Stub the step screens: the test only needs the back callback each one is handed, not its UI. +let mockStepBackPress: (() => void) | undefined; +const mockConnectBankAccount = jest.fn((props: {onBackButtonPress: () => void}) => { + mockStepBackPress = props.onBackButtonPress; + return null; +}); +const mockCompleteVerification = jest.fn((props: {onBackButtonPress: () => void}) => { + mockStepBackPress = props.onBackButtonPress; + return null; +}); + +jest.mock('@pages/ReimbursementAccount/USD/ConnectBankAccount/ConnectBankAccount', () => ({ + __esModule: true, + default: (props: {onBackButtonPress: () => void}) => mockConnectBankAccount(props), +})); + +jest.mock('@pages/ReimbursementAccount/USD/CompleteVerification/CompleteVerification', () => ({ + __esModule: true, + default: (props: {onBackButtonPress: () => void}) => mockCompleteVerification(props), +})); + +// The remaining steps pull in native modules (Plaid, Onfido) that cannot load under Jest, and this test never renders +// them. Only the back-navigation targets they contribute to the step order matter. +jest.mock('@pages/ReimbursementAccount/USD/BankInfo/BankInfo', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/ReimbursementAccount/USD/Requestor/RequestorStep', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/ReimbursementAccount/USD/Requestor/VerifyIdentity/VerifyIdentity', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/ReimbursementAccount/USD/BusinessInfo/BusinessInfo', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnersStep', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/ReimbursementAccount/USD/KYBDocuments', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/ReimbursementAccount/USD/Country', () => ({__esModule: true, default: () => null})); + +type RouteParams = ReimbursementAccountNavigatorParamList[typeof SCREENS.REIMBURSEMENT_ACCOUNT_USD]; +type PageProps = PlatformStackScreenProps; + +// The page does not read the navigation prop. This inert double only satisfies the navigator-provided prop. +const navigation = createMock({}); + +const renderPage = async (params: RouteParams) => { + const rendered = render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + return rendered; +}; + +const seedAccount = async (account: ReimbursementAccount) => { + await act(async () => { + await Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, account); + await waitForBatchedUpdatesWithAct(); + }); +}; + +/** Fires the back press of the step that the flow page decided to render, after confirming it is the expected one. */ +const pressStepBackButton = (expectedStep: jest.Mock) => { + expect(expectedStep).toHaveBeenCalled(); + mockStepBackPress?.(); +}; + +describe('USDVerifiedBankAccountFlowPage back press', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => { + mockStepBackPress = undefined; + }); + + afterEach(async () => { + jest.clearAllMocks(); + await act(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + }); + + describe('on the validation step of a pending account', () => { + it('leaves the flow to backTo instead of returning to the page that redirects back here', async () => { + // Given a pending account sitting on the validation step, opened with a backTo + await seedAccount(PENDING_ACCOUNT); + await renderPage({policyID: POLICY_ID, page: CONST.BANK_ACCOUNT.PAGE_NAMES.VALIDATION, backTo: BACK_TO}); + + // When the user presses back + pressStepBackButton(mockConnectBankAccount); + + // Then it goes where the user came from, not to the setup entry point + expect(Navigation.goBack).toHaveBeenCalledWith(BACK_TO); + expect(Navigation.goBack).not.toHaveBeenCalledWith(expect.stringContaining('bank-account/new')); + }); + + it('dismisses the modal when there is no backTo', async () => { + // Given the same step reached without a backTo + await seedAccount(PENDING_ACCOUNT); + await renderPage({policyID: POLICY_ID, page: CONST.BANK_ACCOUNT.PAGE_NAMES.VALIDATION}); + + // When the user presses back + pressStepBackButton(mockConnectBankAccount); + + // Then it leaves the flow rather than popping onto a page that would redirect straight back + expect(Navigation.dismissModal).toHaveBeenCalled(); + expect(Navigation.goBack).not.toHaveBeenCalled(); + }); + }); + + describe('cases that keep stepping back through the flow', () => { + it('steps back to the previous page when the account is not pending', async () => { + // Given the validation step for an account that is still verifying + await seedAccount({...PENDING_ACCOUNT, achData: buildAchData({state: CONST.BANK_ACCOUNT.STATE.VERIFYING})}); + await renderPage({policyID: POLICY_ID, page: CONST.BANK_ACCOUNT.PAGE_NAMES.VALIDATION, backTo: BACK_TO}); + + // When the user presses back + pressStepBackButton(mockConnectBankAccount); + + // Then the existing step-by-step behaviour is unchanged: back over the skipped KYB step to complete verification + expect(Navigation.goBack).toHaveBeenCalledWith(expect.stringContaining(`bank-account/new/us/${CONST.BANK_ACCOUNT.PAGE_NAMES.ACH_CONTRACT}`)); + expect(Navigation.dismissModal).not.toHaveBeenCalled(); + }); + + it('steps back to the previous page on a non-validation step of a pending account', async () => { + // Given a pending account on an earlier step of the flow + await seedAccount(PENDING_ACCOUNT); + await renderPage({policyID: POLICY_ID, page: CONST.BANK_ACCOUNT.PAGE_NAMES.ACH_CONTRACT, backTo: BACK_TO}); + + // When the user presses back + pressStepBackButton(mockCompleteVerification); + + // Then the pending shortcut does not apply and the flow steps back as before + expect(Navigation.goBack).toHaveBeenCalledWith(expect.stringContaining('bank-account/new/us/')); + expect(Navigation.dismissModal).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/preMountDraftReportTest.ts b/tests/unit/preMountDraftReportTest.ts new file mode 100644 index 000000000000..d33baccaefea --- /dev/null +++ b/tests/unit/preMountDraftReportTest.ts @@ -0,0 +1,92 @@ +import {clearPreMountedDraftReport, clearPreMountedDraftReportMarker, preMountDraftReport} from '@libs/actions/Report/PreMountedDraftReport'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +describe('preMountDraftReport lifecycle', () => { + beforeEach(() => Onyx.clear()); + + it('writes the pre-mount marker and the speculative report together', async () => { + // Given a draft selected as a pre-mount destination + const reportID = '123'; + const draftReport = {reportID} as Report; + + // When the draft is pre-mounted for speculative rendering + await preMountDraftReport(reportID, draftReport); + + // Then the report and recovery marker coexist so startup can detect interruption + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBe(true); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toEqual(draftReport); + }); + + it('clearPreMountedDraftReportMarker (confirm path) clears only the marker, leaving the now-real report intact', async () => { + // Given a pre-mounted draft that completed submission successfully + const reportID = '123'; + const draftReport = {reportID} as Report; + await preMountDraftReport(reportID, draftReport); + + // When the confirmed pre-mount is finalized + await clearPreMountedDraftReportMarker(reportID); + + // Then only recovery state is cleared because the report is now real + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toEqual(draftReport); + }); + + it('clearPreMountedDraftReport (cancel path) removes both the speculative report and the marker', async () => { + // Given a pre-mounted draft whose submission is canceled + const reportID = '123'; + const draftReport = {reportID} as Report; + await preMountDraftReport(reportID, draftReport); + + // When the speculative pre-mount is rolled back + await clearPreMountedDraftReport(reportID); + + // Then neither temporary record remains because no real report was created + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toBeUndefined(); + }); + + it('clearPreMountedDraftReportMarker is a no-op on the report row when no pre-mount ever happened', async () => { + // Given a report ID without any pre-mount lifecycle state + const reportID = 'never-pre-mounted'; + + // When confirmation cleanup is called defensively + await clearPreMountedDraftReportMarker(reportID); + await waitForBatchedUpdates(); + + // Then no report row appears because marker cleanup must not create data + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBeUndefined(); + }); + + it('clearPreMountedDraftReport is safe when nothing was ever pre-mounted (does not create phantom rows)', async () => { + // Given a report ID without speculative pre-mount data + const reportID = 'never-pre-mounted'; + + // When cancellation cleanup is called defensively + await clearPreMountedDraftReport(reportID); + + // Then no phantom state is introduced because there is nothing to roll back + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBeUndefined(); + }); + + it('pre-mounting the same reportID twice overwrites the previous draft snapshot rather than merging it', async () => { + // Given a report ID whose draft snapshot changes before submission + const reportID = '123'; + await preMountDraftReport(reportID, {reportID, reportName: 'first'} as Report); + + // When the same destination is pre-mounted again with the latest snapshot + await preMountDraftReport(reportID, {reportID, reportName: 'second'} as Report); + + // Then stale fields are replaced so speculative rendering matches the latest draft + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT}${reportID}`)).toBe(true); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toEqual({reportID, reportName: 'second'}); + }); +}); diff --git a/tests/unit/shouldFollowActionBadgeTargetTest.ts b/tests/unit/shouldFollowActionBadgeTargetTest.ts index e0ab679a0ce4..2f2b9328b90c 100644 --- a/tests/unit/shouldFollowActionBadgeTargetTest.ts +++ b/tests/unit/shouldFollowActionBadgeTargetTest.ts @@ -4,17 +4,17 @@ const BASE_PARAMS = { isProduction: false, actionTargetReportActionID: '200', prevActionTargetReportActionID: '100', - actionBadgeTargetIndex: 2, + actionBadgeTargetIndex: 7, prevActionBadgeTargetIndex: 5, }; describe('shouldFollowActionBadgeTarget', () => { - it('follows the target when it advances to a newer (lower-index) preview', () => { + it('follows the target when it advances to a newer (higher-index) preview', () => { expect(shouldFollowActionBadgeTarget(BASE_PARAMS)).toBe(true); }); - it('does not follow when the target moves to an older (higher-index) preview, e.g. while paginating', () => { - expect(shouldFollowActionBadgeTarget({...BASE_PARAMS, actionBadgeTargetIndex: 7})).toBe(false); + it('does not follow when the target moves to an older (lower-index) preview, e.g. while paginating', () => { + expect(shouldFollowActionBadgeTarget({...BASE_PARAMS, actionBadgeTargetIndex: 2})).toBe(false); }); it('does not follow when the target index is unchanged', () => { diff --git a/tests/unit/useCreateNavigationSuggestionsTest.ts b/tests/unit/useCreateNavigationSuggestionsTest.ts index ae2aff207660..88a2804109db 100644 --- a/tests/unit/useCreateNavigationSuggestionsTest.ts +++ b/tests/unit/useCreateNavigationSuggestionsTest.ts @@ -33,14 +33,15 @@ const mockUseCreateReport = jest.fn<{createReport: typeof mockCreateReport; isVi isVisible: mockCreateReportIsVisible, })); const mockUseOnyx = jest.fn(); -const mockIsBetaEnabled = jest.fn(() => true); +// Enabling preventSpotnanaTravel blocks travel, so keep it off by default and let the cases that need it opt in +const isBetaEnabledByDefault = (beta: string) => beta !== CONST.BETAS.PREVENT_SPOTNANA_TRAVEL; +const mockIsBetaEnabled = jest.fn(isBetaEnabledByDefault); const mockCanSendInvoice = jest.fn(() => false); const mockGetDefaultChatEnabledPolicy = jest.fn((policies: unknown[]) => (policies.length === 1 ? policies.at(0) : undefined)); const mockGetGroupPoliciesWhereReportCanBeCreated = jest.fn(); const mockShouldShowPolicy = jest.fn(() => true); const mockHasAcceptedTravelTerms = jest.fn(() => false); const mockIsPaidGroupPolicy = jest.fn(() => false); -const mockIsPermissionsBetaEnabled = jest.fn(() => false); const mockIsOnSearchMoneyRequestReportPage = jest.fn(() => false); const mockGetCurrencyDecimals = jest.fn(); let mockIsRestrictedPolicyCreation = false; @@ -162,13 +163,6 @@ jest.mock('@libs/openTravelDotLink', () => ({ openTravelDotLink: jest.fn(), })); -jest.mock('@libs/Permissions', () => ({ - __esModule: true, - default: { - isBetaEnabled: () => mockIsPermissionsBetaEnabled(), - }, -})); - jest.mock('@libs/PolicyUtils', () => ({ canSendInvoice: (...args: unknown[]) => mockCanSendInvoice(...args), getDefaultChatEnabledPolicy: (policies: unknown[]) => mockGetDefaultChatEnabledPolicy(policies), @@ -225,7 +219,7 @@ describe('useCreateNavigationSuggestions', () => { mockShouldShowPolicy.mockReturnValue(true); mockHasAcceptedTravelTerms.mockReturnValue(false); mockIsPaidGroupPolicy.mockReturnValue(false); - mockIsPermissionsBetaEnabled.mockReturnValue(false); + mockIsBetaEnabled.mockImplementation(isBetaEnabledByDefault); mockGetGroupPoliciesWhereReportCanBeCreated.mockReturnValue([]); mockIsOnSearchMoneyRequestReportPage.mockReturnValue(false); mockIsRestrictedPolicyCreation = false; @@ -372,7 +366,7 @@ describe('useCreateNavigationSuggestions', () => { mockOnyxValues.set(`${ONYXKEYS.COLLECTION.POLICY}${submitPolicy.id}`, {...submitPolicy, isTravelEnabled: true}); mockOnyxValues.set(ONYXKEYS.ACCOUNT, {primaryLogin}); mockOnyxValues.set(ONYXKEYS.SESSION, {...session, email: sessionEmail}); - mockIsPermissionsBetaEnabled.mockReturnValue(isBlocked); + mockIsBetaEnabled.mockImplementation((beta: string) => (beta === CONST.BETAS.PREVENT_SPOTNANA_TRAVEL ? isBlocked : true)); mockIsPaidGroupPolicy.mockReturnValue(isPaid); mockHasAcceptedTravelTerms.mockReturnValue(hasAcceptedTerms); const {result} = renderHook(() => useCreateNavigationSuggestions()); diff --git a/tests/unit/usePermissionsTest.tsx b/tests/unit/usePermissionsTest.tsx index 86826805892e..3678bfd543a8 100644 --- a/tests/unit/usePermissionsTest.tsx +++ b/tests/unit/usePermissionsTest.tsx @@ -2,6 +2,8 @@ import {renderHook} from '@testing-library/react-native'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import Permissions from '@libs/Permissions'; + import CONST from '@src/CONST'; import usePermissions from '@src/hooks/usePermissions'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -135,4 +137,55 @@ describe('usePermissions', () => { // Then: The beta check should return false since neither beta is enabled expect(result.current.isBetaEnabled(exclusionBeta)).toBe(false); }); + + it('should let local beta overrides take precedence over the server-provided betas', async () => { + // Given: An account with one beta enabled and another disabled + Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.DEFAULT_ROOMS]); + await waitForBatchedUpdatesWithAct(); + + const {result} = renderHook(() => usePermissions(), {wrapper: Wrapper}); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS)).toBe(true); + expect(result.current.isBetaEnabled(CONST.BETAS.PER_DIEM)).toBe(false); + + // When: Overrides force-disable the enabled beta and force-enable the disabled one + Onyx.set(ONYXKEYS.BETA_OVERRIDES, {[CONST.BETAS.DEFAULT_ROOMS]: false, [CONST.BETAS.PER_DIEM]: true}); + await waitForBatchedUpdatesWithAct(); + + // Then: The overrides win over the server state, and untouched betas keep their server state + expect(result.current.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS)).toBe(false); + expect(result.current.isBetaEnabled(CONST.BETAS.PER_DIEM)).toBe(true); + expect(result.current.isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL)).toBe(false); + + expect(Permissions.isBetaEnabled(CONST.BETAS.PER_DIEM, [])).toBe(false); + expect(Permissions.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS, [CONST.BETAS.DEFAULT_ROOMS])).toBe(true); + + // When: The overrides are cleared + Onyx.set(ONYXKEYS.BETA_OVERRIDES, null); + await waitForBatchedUpdatesWithAct(); + + // Then: Everything falls back to the server state + expect(result.current.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS)).toBe(true); + expect(result.current.isBetaEnabled(CONST.BETAS.PER_DIEM)).toBe(false); + }); + + it('should force-disable a beta granted by the "all" beta when overridden off', async () => { + // Given: An account on the 'all' beta + Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.ALL]); + await waitForBatchedUpdatesWithAct(); + + const {result} = renderHook(() => usePermissions(), {wrapper: Wrapper}); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS)).toBe(true); + + // When: A single beta is overridden off + Onyx.set(ONYXKEYS.BETA_OVERRIDES, {[CONST.BETAS.DEFAULT_ROOMS]: false}); + await waitForBatchedUpdatesWithAct(); + + // Then: That beta is disabled while the others granted by 'all' stay enabled + expect(result.current.isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS)).toBe(false); + expect(result.current.isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL)).toBe(true); + }); }); diff --git a/tests/unit/usePersonalDetailSearchSelectorTest.tsx b/tests/unit/usePersonalDetailSearchSelectorTest.tsx index 37974fc712dc..a50ba4258fda 100644 --- a/tests/unit/usePersonalDetailSearchSelectorTest.tsx +++ b/tests/unit/usePersonalDetailSearchSelectorTest.tsx @@ -495,6 +495,76 @@ describe('usePersonalDetailSearchSelector selectedNonExistingOptions', () => { }); }); +describe('usePersonalDetailSearchSelector search term trimming', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await act(async () => { + await Onyx.clear(); + await Onyx.multiSet(MOCK_ONYX_STATE); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterAll(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + /** Renders the hook with the invite option enabled and types searchTerm into it, flushing the debounce. */ + const renderWithSearchTerm = async (searchTerm: string) => { + jest.useFakeTimers(); + const {result} = renderHook(() => + usePersonalDetailSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + includeUserToInvite: true, + includeRecentReports: false, + }), + ); + await waitForBatchedUpdatesWithAct(); + + act(() => { + result.current.setSearchTerm(searchTerm); + }); + // Advance past the debounce delay (300ms) + await act(async () => { + jest.advanceTimersByTime(400); + }); + await waitForBatchedUpdatesWithAct(); + jest.useRealTimers(); + + return result; + }; + + it('still shows the invite option when the email has a leading space', async () => { + const result = await renderWithSearchTerm(' invitee@gmail.com'); + + expect(result.current.availableOptions.userToInvite?.login).toBe('invitee@gmail.com'); + }); + + it('still shows the invite option when the email has a trailing space', async () => { + const result = await renderWithSearchTerm('invitee@gmail.com '); + + expect(result.current.availableOptions.userToInvite?.login).toBe('invitee@gmail.com'); + }); + + it('still shows the invite option when the phone number is space padded', async () => { + const result = await renderWithSearchTerm(' +1 (234) 567-8901 '); + + expect(result.current.availableOptions.userToInvite?.login).toBe('+12345678901'); + }); + + it('does not show an invite option for a search term that is only whitespace', async () => { + const result = await renderWithSearchTerm(' '); + + expect(result.current.availableOptions.userToInvite).toBeNull(); + }); +}); + describe('usePersonalDetailSearchSelector includeLoginsOnly', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index 87e435b002a5..cf372cf3122c 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -101,7 +101,7 @@ function buildParams(overrides: Partial = {}): HookParams { hasNewerActions: true, linkedReportActionID: undefined, hasNewestReportAction: false, - sortedVisibleReportActions: [], + renderedVisibleReportActions: [], sortedAllReportActionsForPagination: [], reportActionPages: undefined, setTreatAsNoPaginationAnchor: jest.fn(), @@ -120,6 +120,29 @@ describe('useReportActionsNewActionLiveTail', () => { mockIsInSidePanel = false; }); + it('requests one post-render scroll for a sent comment instead of also scrolling immediately', () => { + const {result} = renderHook(() => useReportActionsNewActionLiveTail(buildParams({hasNewerActions: false, hasNewestReportAction: true}))); + + act(() => { + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + }); + + expect(result.current.isScrollToBottomEnabled).toBe(true); + expect(reportScrollManager.scrollToBottom).not.toHaveBeenCalled(); + }); + + it('does not queue a bottom scroll that would compete with a report-preview target', () => { + const preview = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW}); + const {result} = renderHook(() => useReportActionsNewActionLiveTail(buildParams({hasNewerActions: false, hasNewestReportAction: true, renderedVisibleReportActions: [preview]}))); + + act(() => { + newActionHandler?.(true, preview); + }); + + expect(reportScrollManager.scrollToBottom).toHaveBeenCalledTimes(1); + expect(result.current.isScrollToBottomEnabled).toBe(false); + }); + it('threads the conciergeChat report through to the catch-up openReport call', () => { const conciergeChat = {reportID: 'concierge-live-tail-1'}; renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({conciergeChat})}); diff --git a/tests/unit/useReportActionsPaginationScrollTest.tsx b/tests/unit/useReportActionsPaginationScrollTest.tsx new file mode 100644 index 000000000000..1e5dd91b4c30 --- /dev/null +++ b/tests/unit/useReportActionsPaginationScrollTest.tsx @@ -0,0 +1,267 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useReportActionsPaginationScroll, {REPORT_ACTIONS_PAGINATION_THRESHOLD} from '@hooks/useReportActionsPaginationScroll'; + +const VIEWPORT_HEIGHT = 500; +const NEWER_PAGINATION_EXTENT = 168; +const OLDER_PAGINATION_EXTENT = 128; +const CONTENT_HEIGHT = 3000; +const BOUNDARY_DISTANCE = VIEWPORT_HEIGHT * 0.25; + +const mockLoadOlderActions = jest.fn(); +const mockLoadNewerActions = jest.fn(); +const mockAnimationFrames: FrameRequestCallback[] = []; +const mockTransitionCallbacks: Array<() => void> = []; +let mockIsSearchTopmostFullScreenRoute = false; + +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute); +jest.mock('@libs/Navigation/TransitionTracker', () => ({ + __esModule: true, + default: { + runAfterTransitions: ({callback}: {callback: () => void}) => { + mockTransitionCallbacks.push(callback); + return {cancel: jest.fn()}; + }, + }, +})); + +let listMetrics = { + contentLength: CONTENT_HEIGHT, + scroll: 0, + scrollLength: VIEWPORT_HEIGHT, +}; + +// The hook reads only these three values from LegendList's much larger diagnostic state. +const listRef = { + current: { + getState: () => listMetrics, + }, +}; + +type HookParams = Parameters[0]; + +function buildParams(overrides: Partial = {}): HookParams { + return { + reportID: 'report-1', + linkedReportActionID: undefined, + listRef, + viewportHeight: VIEWPORT_HEIGHT, + olderPaginationExtent: OLDER_PAGINATION_EXTENT, + newerPaginationExtent: NEWER_PAGINATION_EXTENT, + olderCursor: 'older-1', + newerCursor: 'newer-1', + hasOlderActions: true, + hasNewerActions: true, + isLoadingOlderReportActions: false, + isLoadingNewerReportActions: false, + hasLoadingOlderReportActionsError: false, + hasLoadingNewerReportActionsError: false, + isOffline: false, + canLoadOlder: true, + canLoadNewer: true, + loadOlderActions: mockLoadOlderActions, + loadNewerActions: mockLoadNewerActions, + ...overrides, + }; +} + +function setListMetrics(offset: number, contentHeight = CONTENT_HEIGHT, viewportHeight = VIEWPORT_HEIGHT) { + listMetrics = { + contentLength: contentHeight, + scroll: offset, + scrollLength: viewportHeight, + }; +} + +function flushAnimationFrames() { + while (mockAnimationFrames.length > 0) { + act(() => { + for (const callback of mockAnimationFrames.splice(0)) { + callback(0); + } + }); + } +} + +function flushTransitions() { + act(() => { + for (const callback of mockTransitionCallbacks.splice(0)) { + callback(); + } + }); +} + +describe('useReportActionsPaginationScroll', () => { + beforeAll(() => { + jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { + mockAnimationFrames.push(callback); + return mockAnimationFrames.length; + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockAnimationFrames.length = 0; + mockTransitionCallbacks.length = 0; + mockIsSearchTopmostFullScreenRoute = false; + listMetrics = { + contentLength: CONTENT_HEIGHT, + scroll: 0, + scrollLength: VIEWPORT_HEIGHT, + }; + }); + + it('triggers both directions at 25% of the real-message boundary after subtracting pagination extents', () => { + const {result} = renderHook(() => useReportActionsPaginationScroll(buildParams())); + const olderBoundaryOffset = OLDER_PAGINATION_EXTENT + BOUNDARY_DISTANCE; + const newerBoundaryOffset = CONTENT_HEIGHT - VIEWPORT_HEIGHT - NEWER_PAGINATION_EXTENT - BOUNDARY_DISTANCE; + + expect(REPORT_ACTIONS_PAGINATION_THRESHOLD).toBe(0.25); + + act(() => { + setListMetrics(olderBoundaryOffset + 1); + result.current.onScroll(); + setListMetrics(olderBoundaryOffset); + result.current.onScroll(); + }); + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + + act(() => { + setListMetrics(newerBoundaryOffset - 1); + result.current.onScroll(); + setListMetrics(newerBoundaryOffset); + result.current.onScroll(); + }); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + }); + + it('deduplicates repeated boundary events for the same actual request cursors', () => { + const {result} = renderHook(() => useReportActionsPaginationScroll(buildParams())); + + act(() => { + setListMetrics(0); + result.current.onScroll(); + result.current.onScroll(); + setListMetrics(CONTENT_HEIGHT); + result.current.onScroll(); + result.current.onScroll(); + }); + + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + }); + + it('rechecks stationary geometry after a cursor advances and after content size settles', () => { + const initialParams = buildParams({hasOlderActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(CONTENT_HEIGHT); + act(() => { + result.current.onScroll(); + }); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + + rerender({...initialParams, newerCursor: 'newer-2'}); + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(2); + + rerender({...initialParams, newerCursor: 'newer-3'}); + act(() => { + result.current.onContentSizeChange(); + }); + listMetrics = {...listMetrics, contentLength: CONTENT_HEIGHT + 100}; + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(3); + }); + + it('uses current availability when a scheduled content-size check runs', () => { + const initialParams = buildParams({hasNewerActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(0); + act(() => { + result.current.onScroll(); + }); + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + + act(() => { + result.current.onContentSizeChange(); + }); + rerender({...initialParams, hasOlderActions: false, olderCursor: 'older-2'}); + flushAnimationFrames(); + + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + }); + + it('cancels delayed Search requests when the pagination window changes or unmounts', () => { + mockIsSearchTopmostFullScreenRoute = true; + const initialParams = buildParams({hasOlderActions: false}); + const firstView = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(CONTENT_HEIGHT); + act(() => { + firstView.result.current.onScroll(); + }); + expect(mockTransitionCallbacks).toHaveLength(1); + + firstView.rerender({...initialParams, linkedReportActionID: 'linked-2'}); + flushTransitions(); + flushAnimationFrames(); + expect(mockLoadNewerActions).not.toHaveBeenCalled(); + + mockTransitionCallbacks.length = 0; + const secondView = renderHook(() => useReportActionsPaginationScroll(initialParams)); + act(() => { + secondView.result.current.onScroll(); + }); + expect(mockTransitionCallbacks).toHaveLength(1); + + secondView.unmount(); + flushTransitions(); + flushAnimationFrames(); + expect(mockLoadNewerActions).not.toHaveBeenCalled(); + }); + + it('blocks loading and stationary error loops but permits a deliberate leave and reentry retry', () => { + const loadingParams = buildParams({isLoadingOlderReportActions: true, hasNewerActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: loadingParams}); + + act(() => { + result.current.onScroll(); + }); + expect(mockLoadOlderActions).not.toHaveBeenCalled(); + + const failedParams = {...loadingParams, isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: true}; + rerender(failedParams); + flushAnimationFrames(); + expect(mockLoadOlderActions).not.toHaveBeenCalled(); + + act(() => { + setListMetrics(OLDER_PAGINATION_EXTENT + BOUNDARY_DISTANCE + 1); + result.current.onScroll(); + setListMetrics(0); + result.current.onScroll(); + }); + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + }); + + it('resets request guards after reconnecting or changing the linked-action window', () => { + const initialParams = buildParams({hasOlderActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(CONTENT_HEIGHT); + act(() => { + result.current.onScroll(); + }); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + + rerender({...initialParams, isOffline: true}); + rerender(initialParams); + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(2); + + rerender({...initialParams, linkedReportActionID: 'linked-2'}); + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(3); + }); +}); diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 0cf03c9ff5e6..118361255128 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -47,6 +47,7 @@ jest.mock('@hooks/useReportScrollManager', () => ({ const mockSetIsFloatingMessageCounterVisible = jest.fn(); const mockTrackVerticalScrolling = jest.fn(); const mockOnViewableItemsChanged = jest.fn(); +const mockUpdatePillVisibility = jest.fn(); let mockIsFloatingMessageCounterVisible = false; let mockIsActionBadgeAboveViewport = false; jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({ @@ -57,6 +58,7 @@ jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({ isActionBadgeAboveViewport: mockIsActionBadgeAboveViewport, trackVerticalScrolling: mockTrackVerticalScrolling, onViewableItemsChanged: mockOnViewableItemsChanged, + updatePillVisibility: mockUpdatePillVisibility, }), })); @@ -73,12 +75,6 @@ jest.mock('@pages/inbox/report/useReportActionsNewActionLiveTail', () => ({ }), })); -// --- useScrollToEndOnNewMessageReceived --- -jest.mock('@hooks/useScrollToEndOnNewMessageReceived', () => ({ - __esModule: true, - default: jest.fn(), -})); - // --- TransitionTracker --- const mockTransitionCallbacks: Array<() => void> = []; jest.mock('@libs/Navigation/TransitionTracker', () => ({ @@ -183,13 +179,11 @@ function buildParams(overrides: Partial = {}): ScrollParams { sortedVisibleReportActions: [makeAction('1')], renderedVisibleReportActions: [makeAction('1')], keyExtractor: (item: ReportAction) => item.reportActionID, - hasScrolledOverThreshold: false, markNewestActionAsRead: mockMarkNewestActionAsRead, completeSkippedMarkAsRead: mockCompleteSkippedMarkAsRead, unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1, hasNewerActions: false, - draftAutoScrollKey: '', actionBadgeTargetIndex: -1, sortedAllReportActionsForPagination: [], treatAsNoPaginationAnchor: false, @@ -221,10 +215,6 @@ function flushTransitions() { }); } -function setReportLoadingState(value: {isLoadingInitialReportActions?: boolean; hasOnceLoadedReportActions?: boolean}) { - return Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, value); -} - describe('useReportActionsScroll', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -240,6 +230,9 @@ describe('useReportActionsScroll', () => { mockIsFloatingMessageCounterVisible = false; mockIsActionBadgeAboveViewport = false; mockIsScrollToBottomEnabled = false; + mockSetIsScrollToBottomEnabled.mockImplementation((enabled: boolean) => { + mockIsScrollToBottomEnabled = enabled; + }); mockIsTransactionThread = false; mockIsSentMoneyReportAction = false; mockIsReportPreviewAction = false; @@ -259,9 +252,6 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(false); - expect(result.current.shouldFocusToTopOnMount).toBe(false); - expect(result.current.maintainVisibleContentPosition.disabled).toBe(true); - expect(result.current.maintainVisibleContentPosition.autoscrollToBottomThreshold).toBeUndefined(); }); it('is aligned to top and focuses to top on mount for a transaction thread report', async () => { @@ -270,8 +260,6 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(true); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); }); it('is aligned to top for a money request report', async () => { @@ -290,22 +278,25 @@ describe('useReportActionsScroll', () => { expect(result.current.shouldBeAlignedToTop).toBe(true); }); - it('uses the linked report action as the initial scroll key', async () => { + it('positions a linked report action at chronological index zero', async () => { mockRouteParams = {reportActionID: LINKED_ACTION_ID}; - const {result} = await renderScroll({sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); + const linkedAction = makeAction(LINKED_ACTION_ID); + const {result} = await renderScroll({sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]}); - expect(result.current.initialScrollKey).toBe(LINKED_ACTION_ID); - expect(result.current.shouldFocusToTopOnMount).toBe(false); + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); - it('falls back to the unread marker action as the initial scroll key', async () => { + it('positions an unread marker at chronological index zero', async () => { + const unreadAction = makeAction(UNREAD_ACTION_ID); const {result} = await renderScroll({ unreadMarkerReportActionID: UNREAD_ACTION_ID, - sortedVisibleReportActions: [makeAction(UNREAD_ACTION_ID)], + sortedVisibleReportActions: [unreadAction], + renderedVisibleReportActions: [unreadAction], }); - expect(result.current.initialScrollKey).toBe(UNREAD_ACTION_ID); + expect(result.current.initialScrollIndex).toBe(0); }); it('suppresses the initial scroll key for an aligned-to-top CREATED anchor action', async () => { @@ -316,9 +307,8 @@ describe('useReportActionsScroll', () => { sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID, {actionName: CONST.REPORT.ACTIONS.TYPE.CREATED})], }); - expect(result.current.initialScrollKey).toBeUndefined(); - // No key + aligned-to-top → focus to top. - expect(result.current.shouldFocusToTopOnMount).toBe(true); + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toBeUndefined(); }); it('does not focus to top for a single-expense money request report opened from the X Replies link', async () => { @@ -329,7 +319,6 @@ describe('useReportActionsScroll', () => { // Still aligned to top so short reports keep their layout, but the mount position is the latest message. expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(false); expect(result.current.initialScrollIndex).toBeUndefined(); }); @@ -340,7 +329,6 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(false); }); }); @@ -403,135 +391,48 @@ describe('useReportActionsScroll', () => { result.current.scrollToActionBadgeTarget(); }); - expect(mockScrollToIndex).toHaveBeenCalledWith(5, {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + expect(mockScrollToIndex).toHaveBeenCalledWith(5, {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); }); - describe('flushPendingScrollToBottom', () => { + describe('pending live-tail requests', () => { it('does nothing when scroll-to-bottom is not enabled', async () => { mockIsScrollToBottomEnabled = false; - const {result} = await renderScroll(); - act(() => { - result.current.flushPendingScrollToBottom(); - }); + await renderScroll(); expect(mockScrollToBottom).not.toHaveBeenCalled(); expect(mockSetIsScrollToBottomEnabled).not.toHaveBeenCalled(); expect(mockCompleteLiveTailPrune).not.toHaveBeenCalled(); }); - it('scrolls, disables itself and prunes when scroll-to-bottom is enabled', async () => { + it('consumes the request after render without waiting for a future viewport layout', async () => { mockIsScrollToBottomEnabled = true; - const {result} = await renderScroll(); - act(() => { - result.current.flushPendingScrollToBottom(); - }); + const {rerender} = await renderScroll(); expect(mockScrollToBottom).toHaveBeenCalledTimes(1); expect(mockSetIsScrollToBottomEnabled).toHaveBeenCalledWith(false); expect(mockCompleteLiveTailPrune).toHaveBeenCalledTimes(1); - }); - }); - - describe('onLoad', () => { - it('does nothing when the list is not configured to focus to top on mount', async () => { - const {result} = await renderScroll(); - act(() => { - result.current.onLoad(); - }); - - // Stays disabled with no autoscroll threshold for a regular chat. - expect(result.current.maintainVisibleContentPosition.disabled).toBe(true); - expect(result.current.maintainVisibleContentPosition.autoscrollToBottomThreshold).toBeUndefined(); - }); - - it('waits for the report actions to have loaded before disabling autoscroll-to-top', async () => { - mockIsTransactionThread = true; - // No loading state → onLoad bails. - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - act(() => { - result.current.onLoad(); - }); - - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - }); - - it('disables autoscroll-to-top after a frame once report actions have loaded', async () => { - mockIsTransactionThread = true; - await setReportLoadingState({isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}); - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - act(() => { - result.current.onLoad(); - }); - - // The threshold must drop to 0 (not undefined) so FlashList keeps clearing its internal pending-autoscroll flag. - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(0); - }); - - it('disables autoscroll-to-top when report actions finish loading after the list has mounted', async () => { - mockIsTransactionThread = true; - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - // Load completes after mount → companion effect turns autoscroll off. - await act(async () => { - await setReportLoadingState({isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}); - await waitForBatchedUpdates(); - }); - // The threshold must drop to 0 (not undefined) so FlashList keeps clearing its internal pending-autoscroll flag. - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(0); + mockScrollOffsetRef.current = 9999; + rerender(buildParams()); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); }); describe('effects', () => { - it('schedules an initial scroll-to-bottom on mount for a regular chat report', async () => { - await renderScroll(); - - expect(mockScrollToBottom).not.toHaveBeenCalled(); - flushTransitions(); - - expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(mockScrollToBottom).toHaveBeenCalledTimes(1); - }); - - it('does not scroll to bottom on mount when there is an initial scroll key', async () => { - mockRouteParams = {reportActionID: LINKED_ACTION_ID}; - - await renderScroll({sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); - flushTransitions(); - - expect(mockScrollToBottom).not.toHaveBeenCalled(); - }); + it('leaves incoming-message following to LegendList', async () => { + mockScrollOffsetRef.current = 0; - it('does not scroll to bottom on mount when the list focuses to top', async () => { - mockIsTransactionThread = true; + const {rerender} = await renderScroll(); - await renderScroll(); - flushTransitions(); + const actions = [makeAction('2'), makeAction('1')]; + rerender(buildParams({sortedVisibleReportActions: actions, renderedVisibleReportActions: actions.toReversed()})); expect(mockScrollToBottom).not.toHaveBeenCalled(); }); - it('scrolls to bottom on mount for a single-expense money request report opened from the X Replies link', async () => { - mockIsMoneyRequestReport = true; - mockRouteParams = {shouldScrollToLatest: 'true'}; - - await renderScroll(); - flushTransitions(); - - expect(mockScrollToBottom).toHaveBeenCalledTimes(1); - }); - it('clears the X Replies flag once it has been applied', async () => { mockIsMoneyRequestReport = true; mockRouteParams = {shouldScrollToLatest: 'true'}; @@ -541,32 +442,15 @@ describe('useReportActionsScroll', () => { expect(mockSetParams).toHaveBeenCalledWith({shouldScrollToLatest: undefined}); }); - it('does not clear the X Replies flag when it was never set', async () => { - mockIsMoneyRequestReport = true; - - await renderScroll(); - - expect(mockSetParams).not.toHaveBeenCalled(); - }); - - it('auto-scrolls to bottom when a new draft key arrives near the bottom and the newest action is present', async () => { + it('does not schedule a competing scroll when a streamed draft grows', async () => { mockScrollOffsetRef.current = 0; - const {rerender} = await renderScroll({draftAutoScrollKey: ''}); - - rerender(buildParams({draftAutoScrollKey: 'draft-1'})); - - expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(mockScrollToBottom).toHaveBeenCalled(); - }); - - it('does not auto-scroll on a new draft key when scrolled away from the bottom', async () => { - mockScrollOffsetRef.current = 9999; - - const {rerender} = await renderScroll({draftAutoScrollKey: ''}); + const draft = makeAction('2', {message: [{type: 'COMMENT', text: 'Hello', html: '

Hello

'}]}); + const {rerender} = await renderScroll({renderedVisibleReportActions: [makeAction('1'), draft]}); mockScrollToBottom.mockClear(); - rerender(buildParams({draftAutoScrollKey: 'draft-1'})); + const updatedDraft: ReportAction = {...draft, message: [{type: 'COMMENT', text: 'Hello, here is the rest of the reply.', html: '

Hello, here is the rest of the reply.

'}]}; + rerender(buildParams({renderedVisibleReportActions: [makeAction('1'), updatedDraft]})); expect(mockScrollToBottom).not.toHaveBeenCalled(); }); diff --git a/tests/unit/useReportScrollManagerTest.tsx b/tests/unit/useReportScrollManagerTest.tsx index 6fcd7cb5c64a..6435a8dea5e7 100644 --- a/tests/unit/useReportScrollManagerTest.tsx +++ b/tests/unit/useReportScrollManagerTest.tsx @@ -1,10 +1,9 @@ import {act, renderHook} from '@testing-library/react-native'; -import type FlatListRefType from '@components/FlashList/types'; - import useReportScrollManager from '@hooks/useReportScrollManager'; import {ActionListContext, useActionListContext} from '@pages/inbox/ActionListContext'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; import type {ReactNode} from 'react'; @@ -24,17 +23,17 @@ function buildMockListRef() { getNativeScrollRef: jest.fn(() => undefined), }; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const ref = {current: methods} as unknown as FlatListRefType; + const ref = {current: methods} as unknown as ActionListRefType; return {ref, methods}; } // Context value backed by a closure holder so registering a ref is visible to the manager's getListRef(). function buildContextValue() { - let held: FlatListRefType = null; + let held: ActionListRefType = null; return { scrollOffsetRef: {current: 0}, getScrollOffset: () => 0, - registerListRef: (ref: FlatListRefType) => { + registerListRef: (ref: ActionListRefType) => { held = ref; }, getListRef: () => held, diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index ecc1f04d688f..09c0d77e37d8 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -227,6 +227,32 @@ describe('useReportUnreadMessageScrollTracking', () => { expect(onUnreadActionVisibleLocalMockFn).toHaveBeenCalledTimes(1); expect(result.current.isFloatingMessageCounterVisible).toBe(false); }); + + it('tracks an unread marker in a chronological list', () => { + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + onUnreadActionVisible: onUnreadActionVisibleMockFn, + unreadMarkerReportActionIndex: 5, + isInverted: false, + onTrackScrolling: onTrackScrollingMockFn, + hasNewerActions: false, + }), + ); + + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 3, key: 'reportActions_3', isViewable: true, item: {}}], changed: []}); + }); + expect(result.current.isFloatingMessageCounterVisible).toBe(true); + + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 5, key: 'reportActions_5', isViewable: true, item: {}}], changed: []}); + }); + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + expect(onUnreadActionVisibleMockFn).toHaveBeenCalled(); + }); }); describe('action badge above viewport tracking', () => { @@ -281,6 +307,28 @@ describe('useReportUnreadMessageScrollTracking', () => { expect(result.current.isActionBadgeAboveViewport).toBe(true); }); + it('returns isActionBadgeAboveViewport as true for a lower index above a chronological viewport', () => { + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + onUnreadActionVisible: onUnreadActionVisibleMockFn, + onTrackScrolling: onTrackScrollingMockFn, + hasNewerActions: false, + unreadMarkerReportActionIndex: -1, + isInverted: false, + actionBadgeTargetIndex: 1, + }), + ); + + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 3, key: 'reportActions_3', isViewable: true, item: {}}], changed: []}); + }); + + expect(result.current.isActionBadgeAboveViewport).toBe(true); + }); + it('returns isActionBadgeAboveViewport as false when action badge target is visible in viewport', () => { const offsetRef = {current: 0}; const {result} = renderHook(() => diff --git a/tests/unit/useSearchSelectorTest.tsx b/tests/unit/useSearchSelectorTest.tsx index f204de5a881c..7e22c58df7dd 100644 --- a/tests/unit/useSearchSelectorTest.tsx +++ b/tests/unit/useSearchSelectorTest.tsx @@ -685,3 +685,82 @@ describe('useSearchSelector phone contact de-duplication', () => { expect(getPersonalDetailsPassedToGetValidOptions().map((option) => option.login)).toEqual(['alice@expensify.com', 'carol@gmail.com']); }); }); + +describe('useSearchSelector search term trimming', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockFilteredPersonalDetails.current = []; + mockGetValidOptions.mockReturnValue({options: EMPTY_OPTIONS, hasMore: false}); + mockGetSearchOptions.mockReturnValue({options: EMPTY_OPTIONS, hasMore: false}); + await act(async () => { + await Onyx.clear(); + await Onyx.multiSet( + createMock({ + [ONYXKEYS.SESSION]: {accountID: MOCK_ACCOUNT_ID, email: MOCK_EMAIL}, + [ONYXKEYS.BETAS]: [], + [ONYXKEYS.COUNTRY_CODE]: CONST.DEFAULT_COUNTRY_CODE, + }), + ); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterAll(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + /** Renders the hook for the given search context and types searchTerm into it, flushing the debounce. */ + async function renderWithSearchTerm(searchContext: typeof CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL | typeof CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SEARCH, searchTerm: string) { + jest.useFakeTimers(); + const {result} = renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, + searchContext, + includeUserToInvite: true, + }), + ); + await waitForBatchedUpdatesWithAct(); + + act(() => { + result.current.setSearchTerm(searchTerm); + }); + // Advance past the debounce delay (300ms) + await act(async () => { + jest.advanceTimersByTime(400); + }); + await waitForBatchedUpdatesWithAct(); + jest.useRealTimers(); + } + + it('trims a leading space out of the email search string passed to getValidOptions', async () => { + await renderWithSearchTerm(CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, ' user@example.com'); + + const config = mockGetValidOptions.mock.calls.at(-1)?.[7]; + expect(config?.searchString).toBe('user@example.com'); + expect(config?.searchInputValue).toBe('user@example.com'); + }); + + it('trims a trailing space out of the email search string passed to getValidOptions', async () => { + await renderWithSearchTerm(CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, 'user@example.com '); + + expect(mockGetValidOptions.mock.calls.at(-1)?.[7]?.searchString).toBe('user@example.com'); + }); + + it('trims the search query passed to getSearchOptions', async () => { + await renderWithSearchTerm(CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SEARCH, ' user@example.com '); + + expect(mockGetSearchOptions.mock.calls.at(-1)?.[0]?.searchQuery).toBe('user@example.com'); + }); + + it('still resolves a padded phone number to its e164 form', async () => { + await renderWithSearchTerm(CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, ' +1 (234) 567-8901 '); + + expect(mockGetValidOptions.mock.calls.at(-1)?.[7]?.searchString).toBe('+12345678901'); + }); +}); diff --git a/tests/unit/useSelectionListShortcutsTest.ts b/tests/unit/useSelectionListShortcutsTest.ts new file mode 100644 index 000000000000..e7278ffcd99c --- /dev/null +++ b/tests/unit/useSelectionListShortcutsTest.ts @@ -0,0 +1,86 @@ +import {renderHook} from '@testing-library/react-native'; + +import useSelectionListShortcuts from '@components/SelectionList/hooks/useSelectionListShortcuts'; +import type {ConfirmButtonOptions, ListItem} from '@components/SelectionList/types'; + +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; + +import CONST from '@src/CONST'; + +jest.mock('@hooks/useKeyboardShortcut', () => jest.fn()); + +const mockUseKeyboardShortcut = jest.mocked(useKeyboardShortcut); + +type Registration = {isActive?: boolean}; + +/** Returns the config of the most recent registration for the given shortcut, so tests can assert whether it is armed. */ +function getRegistration(shortcut: (typeof CONST.KEYBOARD_SHORTCUTS)['ENTER'] | (typeof CONST.KEYBOARD_SHORTCUTS)['CTRL_ENTER']): Registration | undefined { + return mockUseKeyboardShortcut.mock.calls.findLast(([registeredShortcut]) => registeredShortcut === shortcut)?.[2] as Registration | undefined; +} + +type ShortcutParams = Parameters>[0]; + +function renderShortcuts(overrides: Partial = {}) { + const params: ShortcutParams = { + selectFocusedItem: jest.fn(), + getFocusedOption: () => undefined, + confirmButtonOptions: undefined, + isActive: true, + focusedIndex: 0, + disableKeyboardShortcuts: false, + shouldStopPropagation: false, + shouldBubble: false, + ...overrides, + }; + return renderHook(() => useSelectionListShortcuts(params)); +} + +describe('useSelectionListShortcuts', () => { + beforeEach(() => { + mockUseKeyboardShortcut.mockClear(); + }); + + it('arms the plain Enter shortcut when a real focused index is passed', () => { + renderShortcuts({focusedIndex: 0}); + + expect(getRegistration(CONST.KEYBOARD_SHORTCUTS.ENTER)?.isActive).toBe(true); + }); + + it('disarms the plain Enter shortcut when the focused index is gated to -1', () => { + renderShortcuts({focusedIndex: -1}); + + expect(getRegistration(CONST.KEYBOARD_SHORTCUTS.ENTER)?.isActive).toBe(false); + }); + + it('leaves Ctrl+Enter armed when the focused index is gated to -1', () => { + const confirmButtonOptions: ConfirmButtonOptions = {onConfirm: jest.fn()}; + renderShortcuts({focusedIndex: -1, confirmButtonOptions}); + + expect(getRegistration(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER)?.isActive).toBe(true); + }); + + it('still passes the real focused option to onConfirm when the focused index is gated to -1', () => { + const onConfirm = jest.fn(); + const focusedOption: ListItem = {text: 'Item 0', keyForList: '0'}; + renderShortcuts({focusedIndex: -1, confirmButtonOptions: {onConfirm}, getFocusedOption: () => focusedOption}); + + const ctrlEnterCallback = mockUseKeyboardShortcut.mock.calls.findLast(([shortcut]) => shortcut === CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER)?.[1]; + ctrlEnterCallback?.(); + + expect(onConfirm).toHaveBeenCalledWith(undefined, focusedOption); + }); + + it('disarms both shortcuts when keyboard shortcuts are disabled', () => { + renderShortcuts({disableKeyboardShortcuts: true}); + + expect(getRegistration(CONST.KEYBOARD_SHORTCUTS.ENTER)?.isActive).toBe(false); + expect(getRegistration(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER)?.isActive).toBe(false); + }); + + it('disarms both shortcuts when the list is not active', () => { + renderShortcuts({isActive: false}); + + expect(getRegistration(CONST.KEYBOARD_SHORTCUTS.ENTER)?.isActive).toBe(false); + expect(getRegistration(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER)?.isActive).toBe(false); + }); +}); diff --git a/tests/unit/useUnreadMarkerTest.ts b/tests/unit/useUnreadMarkerTest.ts index 9eecbdf44d47..0ba1a1cb9ed5 100644 --- a/tests/unit/useUnreadMarkerTest.ts +++ b/tests/unit/useUnreadMarkerTest.ts @@ -2,6 +2,7 @@ import {act, renderHook} from '@testing-library/react-native'; import useUnreadMarker from '@hooks/useUnreadMarker'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -123,6 +124,117 @@ describe('useUnreadMarker', () => { expect(result.current.unreadMarkerReportActionIndex).toBe(-1); }); + it('does not push the read watermark on a bulk history reveal without a session boundary (marker appears on the next render)', () => { + const greeting = makeAction(CONST.CONCIERGE_GREETING_ACTION_ID, {created: LAST_READ_TIME}); + const createdAction = makeAction('created', {created: '2023-01-01 08:00:00.000', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED}); + const welcomeActions = [greeting, createdAction]; + + const {result, rerender} = renderHook( + (sortedVisibleReportActions: OnyxTypes.ReportAction[]) => + useUnreadMarker({ + reportID: REPORT_ID, + sortedVisibleReportActions, + sortedReportActions: sortedVisibleReportActions, + oldestUnreadReportActionID: undefined, + isScrolledOverThreshold: false, + hasOnceLoadedReportActions: true, + }), + {initialProps: welcomeActions}, + ); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + + const bumpedGreeting = makeAction(CONST.CONCIERGE_GREETING_ACTION_ID, {created: '2023-01-01 12:00:00.000'}); + rerender([bumpedGreeting, createdAction]); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + + const unreadMessage = makeAction('unread', {created: '2023-01-01 11:00:00.000'}); + const readMessage = makeAction('read', {created: '2023-01-01 09:00:00.000'}); + const fullHistory = [unreadMessage, readMessage, createdAction]; + rerender(fullHistory); + + rerender(fullHistory); + expect(result.current.unreadMarkerReportActionID).toBe('unread'); + expect(result.current.unreadMarkerReportActionIndex).toBe(0); + }); + + it('shows the marker immediately on the reveal render when a session boundary is provided ("Show history")', () => { + const sessionStartTime = '2023-01-01 11:30:00.000'; + const greeting = makeAction(CONST.CONCIERGE_GREETING_ACTION_ID, {created: LAST_READ_TIME}); + const createdAction = makeAction('created', {created: '2023-01-01 08:00:00.000', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED}); + + const {result, rerender} = renderHook( + (sortedVisibleReportActions: OnyxTypes.ReportAction[]) => + useUnreadMarker({ + reportID: REPORT_ID, + sortedVisibleReportActions, + sortedReportActions: sortedVisibleReportActions, + oldestUnreadReportActionID: undefined, + isScrolledOverThreshold: false, + hasOnceLoadedReportActions: true, + newMessageBoundaryTime: sessionStartTime, + }), + {initialProps: [greeting, createdAction]}, + ); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + + const unreadMessage = makeAction('unread', {created: '2023-01-01 11:00:00.000'}); + const readMessage = makeAction('read', {created: '2023-01-01 09:00:00.000'}); + rerender([unreadMessage, readMessage, createdAction]); + + expect(result.current.unreadMarkerReportActionID).toBe('unread'); + expect(result.current.unreadMarkerReportActionIndex).toBe(0); + }); + + it('still auto-reads a live message received while caught up when a session boundary is provided', () => { + const sessionStartTime = '2023-01-01 10:30:00.000'; + const oldMessage = makeAction('old', {created: '2023-01-01 09:00:00.000'}); + + const {result, rerender} = renderHook( + (sortedVisibleReportActions: OnyxTypes.ReportAction[]) => + useUnreadMarker({ + reportID: REPORT_ID, + sortedVisibleReportActions, + sortedReportActions: sortedVisibleReportActions, + oldestUnreadReportActionID: undefined, + isScrolledOverThreshold: false, + hasOnceLoadedReportActions: true, + newMessageBoundaryTime: sessionStartTime, + }), + {initialProps: [oldMessage]}, + ); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + + const incoming = makeAction('incoming', {created: '2023-01-01 11:00:00.000'}); + rerender([incoming, oldMessage]); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + rerender([incoming, oldMessage]); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + }); + + it('still pushes the watermark past a new message received while caught up', () => { + const oldMessage = makeAction('old', {created: '2023-01-01 09:00:00.000'}); + + const {result, rerender} = renderHook( + (sortedVisibleReportActions: OnyxTypes.ReportAction[]) => + useUnreadMarker({ + reportID: REPORT_ID, + sortedVisibleReportActions, + sortedReportActions: sortedVisibleReportActions, + oldestUnreadReportActionID: undefined, + isScrolledOverThreshold: false, + hasOnceLoadedReportActions: true, + }), + {initialProps: [oldMessage]}, + ); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + + const incoming = makeAction('incoming', {created: '2023-01-01 11:00:00.000'}); + rerender([incoming, oldMessage]); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + rerender([incoming, oldMessage]); + expect(result.current.unreadMarkerReportActionID).toBeNull(); + }); + it('seeds the marker from the switched-to report lastReadTime (one mount per report)', () => { // Setup: report 'A' was last read at 10:00 and 'B' at 12:00; one action from another user lands // at 11:00 — after A's read time (unread on A) but before B's read time (already read on B). diff --git a/tests/utils/ReimbursementAccountTestUtils.ts b/tests/utils/ReimbursementAccountTestUtils.ts new file mode 100644 index 000000000000..eb1cf4512761 --- /dev/null +++ b/tests/utils/ReimbursementAccountTestUtils.ts @@ -0,0 +1,56 @@ +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {ReimbursementAccount} from '@src/types/onyx'; +import type {ACHDataReimbursementAccount} from '@src/types/onyx/ReimbursementAccount'; + +import type {PartialDeep} from 'type-fest'; + +import createMock from './createMock'; + +const POLICY_ID = 'policy123'; +const OTHER_POLICY_ID = 'policy456'; +const BACK_TO = ROUTES.WORKSPACE_WORKFLOWS.getRoute(POLICY_ID); + +/** + * A pending USD account. Only the handful of fields the pages branch on are set. `createMock` keeps the partial + * type-checked against the real ACH shape, so renaming one of those fields fails the build instead of silently + * leaving the fixture describing an object production no longer emits. + */ +function buildAchData(overrides: PartialDeep = {}) { + return createMock({ + policyID: POLICY_ID, + state: CONST.BANK_ACCOUNT.STATE.PENDING, + currentStep: CONST.BANK_ACCOUNT.STEP.VALIDATION, + bankAccountID: 1234, + currency: CONST.CURRENCY.USD, + country: CONST.COUNTRY.US, + ...overrides, + }); +} + +const PENDING_ACCOUNT: ReimbursementAccount = { + achData: buildAchData(), + isLoading: false, + shouldShowResetModal: false, +}; + +/** + * The Navigation double both reimbursement-account suites assert against. `jest.mock` factories are hoisted above + * imports, so each suite calls this from inside its own factory via `jest.requireActual` rather than importing it. + */ +function createNavigationMock() { + return { + navigate: jest.fn(), + goBack: jest.fn(), + dismissModal: jest.fn(), + closeRHPFlow: jest.fn(), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), + isTopmostRouteModalScreen: jest.fn(() => false), + setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback?.()), + setParams: jest.fn(), + }; +} + +export {BACK_TO, buildAchData, createNavigationMock, OTHER_POLICY_ID, PENDING_ACCOUNT, POLICY_ID};