diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index 31bfdf963525..60ba16164eb6 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -200,9 +200,10 @@ jobs: if: failure() run: | echo ${{ steps.schedule-awsdf-main.outputs.data }} - cat "./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/Test spec output.txt" unzip "Customer Artifacts.zip" -d mainResults - cat ./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/debug.log + cat "./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/logcat.txt" || true + cat ./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/debug.log || true + cat "./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/Test spec output.txt" || true - name: Unzip AWS Device Farm results if: ${{ always() }} diff --git a/.github/workflows/failureNotifier.yml b/.github/workflows/failureNotifier.yml new file mode 100644 index 000000000000..17e18e6e53f0 --- /dev/null +++ b/.github/workflows/failureNotifier.yml @@ -0,0 +1,96 @@ +name: Notify on Workflow Failure + +on: + workflow_run: + workflows: ["Process new code merged to main"] + types: + - completed + +permissions: + issues: write + +jobs: + notifyFailure: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'failure' }} + steps: + - name: Fetch Workflow Run Jobs + id: fetch-workflow-jobs + uses: actions/github-script@v7 + with: + script: | + const runId = "${{ github.event.workflow_run.id }}"; + const jobsData = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId, + }); + return jobsData.data; + + - name: Process Each Failed Job + uses: actions/github-script@v7 + with: + script: | + const jobs = ${{ steps.fetch-workflow-jobs.outputs.result }}; + + const headCommit = "${{ github.event.workflow_run.head_commit.id }}"; + const prData = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: headCommit, + }); + + const pr = prData.data[0]; + const prLink = pr.html_url; + const prAuthor = pr.user.login; + const prMerger = "${{ github.event.workflow_run.actor.login }}"; + + const failureLabel = 'Workflow Failure'; + for (let i = 0; i < jobs.total_count; i++) { + if (jobs.jobs[i].conclusion == 'failure') { + const jobName = jobs.jobs[i].name; + const jobLink = jobs.jobs[i].html_url; + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + labels: failureLabel, + state: 'open' + }); + const existingIssue = issues.data.find(issue => issue.title.includes(jobName)); + if (!existingIssue) { + const annotations = await github.rest.checks.listAnnotations({ + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: jobs.jobs[i].id, + }); + let errorMessage = ""; + for(let j = 0; j < annotations.data.length; j++) { + errorMessage += annotations.data[j].annotation_level + ": "; + errorMessage += annotations.data[j].message + "\n"; + } + const issueTitle = `Investigate workflow job failing on main: ${ jobName }`; + const issueBody = `🚨 **Failure Summary** 🚨:\n\n` + + `- **📋 Job Name**: [${ jobName }](${ jobLink })\n` + + `- **🔧 Failure in Workflow**: Process new code merged to main\n` + + `- **🔗 Triggered by PR**: [PR Link](${ prLink })\n` + + `- **👤 PR Author**: @${ prAuthor }\n` + + `- **🤝 Merged by**: @${ prMerger }\n` + + `- **🐛 Error Message**: \n ${errorMessage}\n\n` + + `⚠️ **Action Required** ⚠️:\n\n` + + `🛠️ A recent merge appears to have caused a failure in the job named [${ jobName }](${ jobLink }).\n` + + `This issue has been automatically created and labeled with \`${ failureLabel }\` for investigation. \n\n` + + `👀 **Please look into the following**:\n` + + `1. **Why the PR caused the job to fail?**\n` + + `2. **Address any underlying issues.**\n\n` + + `🐛 We appreciate your help in squashing this bug!`; + github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: issueTitle, + body: issueBody, + labels: [failureLabel, 'Daily'], + assignees: [prMerger, prAuthor] + }); + } + } + } diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml index 8f9512062e9d..f09865de0194 100644 --- a/.github/workflows/preDeploy.yml +++ b/.github/workflows/preDeploy.yml @@ -1,3 +1,4 @@ +# Reminder: If this workflow's name changes, update the name in the dependent workflow at .github/workflows/failureNotifier.yml. name: Process new code merged to main on: diff --git a/__mocks__/@react-native-community/push-notification-ios.js b/__mocks__/@react-native-community/push-notification-ios.js deleted file mode 100644 index 0fe8354b9e08..000000000000 --- a/__mocks__/@react-native-community/push-notification-ios.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - addEventListener: jest.fn(), - requestPermissions: jest.fn(() => Promise.resolve()), - getInitialNotification: jest.fn(() => Promise.resolve()), -}; diff --git a/__mocks__/@react-native-firebase/crashlytics.js b/__mocks__/@react-native-firebase/crashlytics.js deleted file mode 100644 index cc7ff3f55e4a..000000000000 --- a/__mocks__/@react-native-firebase/crashlytics.js +++ /dev/null @@ -1,7 +0,0 @@ -// uses and we need to mock the imported crashlytics module -// due to an error that happens otherwise https://github.com/invertase/react-native-firebase/issues/2475 -export default () => ({ - log: jest.fn(), - recordError: jest.fn(), - setCrashlyticsCollectionEnabled: jest.fn(), -}); diff --git a/__mocks__/@react-native-firebase/crashlytics.ts b/__mocks__/@react-native-firebase/crashlytics.ts new file mode 100644 index 000000000000..2df845ba0c69 --- /dev/null +++ b/__mocks__/@react-native-firebase/crashlytics.ts @@ -0,0 +1,15 @@ +import type {FirebaseCrashlyticsTypes} from '@react-native-firebase/crashlytics'; + +type CrashlyticsModule = Pick; + +type CrashlyticsMock = () => CrashlyticsModule; + +// uses and we need to mock the imported crashlytics module +// due to an error that happens otherwise https://github.com/invertase/react-native-firebase/issues/2475 +const crashlyticsMock: CrashlyticsMock = () => ({ + log: jest.fn(), + recordError: jest.fn(), + setCrashlyticsCollectionEnabled: jest.fn(), +}); + +export default crashlyticsMock; diff --git a/__mocks__/@react-native-firebase/perf.js b/__mocks__/@react-native-firebase/perf.js deleted file mode 100644 index 2d1ec238274a..000000000000 --- a/__mocks__/@react-native-firebase/perf.js +++ /dev/null @@ -1 +0,0 @@ -export default () => {}; diff --git a/__mocks__/@react-native-firebase/perf.ts b/__mocks__/@react-native-firebase/perf.ts new file mode 100644 index 000000000000..e304b1a1f007 --- /dev/null +++ b/__mocks__/@react-native-firebase/perf.ts @@ -0,0 +1,5 @@ +type PerfMock = () => void; + +const perfMock: PerfMock = () => {}; + +export default perfMock; diff --git a/__mocks__/push-notification-ios.js b/__mocks__/push-notification-ios.js deleted file mode 100644 index 0fe8354b9e08..000000000000 --- a/__mocks__/push-notification-ios.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - addEventListener: jest.fn(), - requestPermissions: jest.fn(() => Promise.resolve()), - getInitialNotification: jest.fn(() => Promise.resolve()), -}; diff --git a/__mocks__/react-freeze.js b/__mocks__/react-freeze.js deleted file mode 100644 index 51294f40f9ca..000000000000 --- a/__mocks__/react-freeze.js +++ /dev/null @@ -1,6 +0,0 @@ -const Freeze = (props) => props.children; - -export { - // eslint-disable-next-line import/prefer-default-export - Freeze, -}; diff --git a/__mocks__/react-freeze.ts b/__mocks__/react-freeze.ts new file mode 100644 index 000000000000..d87abe01acfb --- /dev/null +++ b/__mocks__/react-freeze.ts @@ -0,0 +1,8 @@ +import type {Freeze as FreezeComponent} from 'react-freeze'; + +const Freeze: typeof FreezeComponent = (props) => props.children as JSX.Element; + +export { + // eslint-disable-next-line import/prefer-default-export + Freeze, +}; diff --git a/android/app/build.gradle b/android/app/build.gradle index 1301f18d7e8f..4b7bfca10fe7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001043707 - versionName "1.4.37-7" + versionCode 1001043802 + versionName "1.4.38-2" } flavorDimensions "default" diff --git a/assets/images/olddot-wireframe.svg b/assets/images/olddot-wireframe.svg new file mode 100644 index 000000000000..ee9aa93be255 --- /dev/null +++ b/assets/images/olddot-wireframe.svg @@ -0,0 +1,3422 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__gears.svg b/assets/images/simple-illustrations/simple-illustration__gears.svg new file mode 100644 index 000000000000..3b4cbc001e3b --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__gears.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__lockclosed.svg b/assets/images/simple-illustrations/simple-illustration__lockclosed.svg new file mode 100644 index 000000000000..3779b92b0b0f --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__lockclosed.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__palmtree.svg b/assets/images/simple-illustrations/simple-illustration__palmtree.svg new file mode 100644 index 000000000000..2aef4956cde9 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__palmtree.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__profile.svg b/assets/images/simple-illustrations/simple-illustration__profile.svg new file mode 100644 index 000000000000..85312f26e186 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__profile.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__qr-code.svg b/assets/images/simple-illustrations/simple-illustration__qr-code.svg new file mode 100644 index 000000000000..10268d747588 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__qr-code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/babel.config.js b/babel.config.js index 0a17f2b0f01c..d3bcecdae8cb 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,5 +1,7 @@ require('dotenv').config(); +const IS_E2E_TESTING = process.env.E2E_TESTING === 'true'; + const defaultPresets = ['@babel/preset-react', '@babel/preset-env', '@babel/preset-flow', '@babel/preset-typescript']; const defaultPlugins = [ // Adding the commonjs: true option to react-native-web plugin can cause styling conflicts @@ -72,7 +74,8 @@ const metro = { ], env: { production: { - plugins: [['transform-remove-console', {exclude: ['error', 'warn']}]], + // Keep console logs for e2e tests + plugins: IS_E2E_TESTING ? [] : [['transform-remove-console', {exclude: ['error', 'warn']}]], }, }, }; diff --git a/docs/articles/expensify-classic/getting-started/Using-The-App.md b/docs/articles/expensify-classic/getting-started/Using-The-App.md deleted file mode 100644 index f1bc31793ba8..000000000000 --- a/docs/articles/expensify-classic/getting-started/Using-The-App.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Using the app -description: Streamline expense management effortlessly with the Expensify mobile app. Learn how to install, enable push notifications, and use SmartScan to capture, categorize, and track expenses. Versatile for personal and business use, Expensify is a secure and automated solution for managing your finances on the go. ---- - -
-# Overview -The Expensify mobile app is the ultimate expense management solution that makes it effortless to track and submit your receipts and expenses. Use the app to snap a picture of your receipts, categorize and submit expenses, and even review and approve expense reports. -# How to install the Expensify app -To get started with Expensify on your mobile device, you need to download the app: -1. Visit the App Store (iOS) or Google Play Store (Android). -2. Search for "Expensify" and select the official Expensify app. -3. Tap "Download" or "Install." - -Once the app is installed, open it and log in with your Expensify credentials. If you don't have an Expensify account, you can create one during the sign-up process. -# How to enable on push notifications -Push notifications keep you informed about expense approvals, reimbursements, and more. To enable push notifications: -1. Open the Expensify app. -2. Go to "Settings" or "Preferences." -3. Find the "Receive realtime alerts" toggle -4. Toggle realtime alerts on to begin receiving notifications - -# Deep dive -## Using SmartScan on the App -### Capture receipts -1. Open the Expensify mobile app. -2. Tap the green camera button to take a photo of a receipt. -3. The receipt will be SmartScanned automatically. - -If you have multiple receipts tap the Rapid Fire Mode button in the bottom right hand corner to snap multiple pictures. You can also upload an existing photo from your gallery. -### SmartScan analysis -After capturing or uploading a receipt, Expensify's SmartScan technology goes to work. It analyzes the receipt to extract key details such as the merchant's name, transaction date, transaction currency, and total amount spent. SmartScan inputs all the data for you, so you don’t have to type a thing. -### Review and edit -Once SmartScan is finished, you can further categorize and code your expense based on your company’s policy. Review this data to ensure accuracy. If necessary, you can edit or add additional details, such as expense categories, tags, attendees, tax rates, or descriptions. -### Multi-Currency support -For businesses dealing with international expenses, SmartScan can handle multiple currencies and provide accurate exchange rate conversion based on your policies reporting currency. It's essential to set up and configure currency preferences for these scenarios. -### Custom expense categories -SmartScan can automatically categorize expenses based on vendor or merchant. Users can customize these categories to suit their specific accounting needs. This can be particularly useful for tracking expenses across different departments or projects. -### SmartScan outcomes -SmartScan's performance can vary depending on factors such as receipt quality, language, and handwriting. It's important to keep the following variables in mind: -**Receipt quality**: The clarity and condition of a receipt can impact SmartScan's accuracy. For best results, ensure your environment is well-lit and the receipt is straight and free of obstructions. -**Language support**: While SmartScan supports multiple languages, its accuracy may differ from one language to another. Users dealing with non-English receipts should be aware of potential variations in data extraction. -**Handwriting recognition**: Handwritten receipts might pose challenges for SmartScan. In such cases, manual verification may be necessary to ensure accurate data entry. - -{% include faq-begin.md %} - -## Can I use the mobile app for both personal and business expenses? -Yes, you can use Expensify for personal and business expenses. It's versatile and suitable for both individual and corporate use. Check out our personal and business plans [here](https://www.expensify.com/pricing) to see what might be right for you. -## Is it possible to categorize and tag expenses on the mobile app? -Yes, you can categorize and tag expenses on the mobile app. The app allows you to customize categories and tags to help organize and track your spending. -## What should I do if I encounter issues with the mobile app, such as login problems or crashes? -If you experience issues, first make sure you’re using the most recent version of the app. You can also try to restarting the app. If the issue persists, you can start a chat with Concierge in the app or write to [concierge@expensify.com](mailto:concierge@expensify.com). -## Is the mobile app secure for managing sensitive financial information? -Expensify takes security seriously and employs encryption and other security measures to protect your data. It's important to use strong, unique passwords and enable device security features like biometric authentication. -## Can I use the mobile app offline, and will my data sync when I'm back online? -Yes, you can use the mobile app offline to capture receipts and create expenses. The app will sync your data once you have an internet connection. - -{% include faq-end.md %} -
-
- -# Coming soon - - -
\ No newline at end of file diff --git a/docs/redirects.csv b/docs/redirects.csv index 2609f6665c8d..648f3ad6612f 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -50,3 +50,4 @@ https://community.expensify.com/discussion/4826/how-to-set-your-annual-subscript https://community.expensify.com/discussion/5667/deep-dive-how-does-the-annual-subscription-billing-work,https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/Annual-Subscription https://help.expensify.com/expensify-classic/hubs/getting-started/plan-types,https://use.expensify.com/ https://help.expensify.com/articles/expensify-classic/getting-started/Employees,https://help.expensify.com/articles/expensify-classic/getting-started/Join-your-company's-workspace +https://help.expensify.com/articles/expensify-classic/getting-started/Using-The-App,https://help.expensify.com/articles/expensify-classic/getting-started/Join-your-company's-workspace diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg index 454857981834..643c81bd0b9c 100644 Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ diff --git a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg index ba9258840638..8a5170cfe697 100644 Binary files a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg and b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index a2effb8de0b1..b265fa31c0c4 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.37 + 1.4.38 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.37.7 + 1.4.38.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 02b01e7153d0..402f463a66eb 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.37 + 1.4.38 CFBundleSignature ???? CFBundleVersion - 1.4.37.7 + 1.4.38.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 69e1e7d6e9d9..aab8170f1a87 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.37 + 1.4.38 CFBundleVersion - 1.4.37.7 + 1.4.38.2 NSExtension NSExtensionPointIdentifier diff --git a/jest.config.js b/jest.config.js index de7ed4b1f974..b347db593d83 100644 --- a/jest.config.js +++ b/jest.config.js @@ -22,8 +22,8 @@ module.exports = { doNotFake: ['nextTick'], }, testEnvironment: 'jsdom', - setupFiles: ['/jest/setup.js', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'], - setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.js', '/tests/perf-test/setupAfterEnv.js'], + setupFiles: ['/jest/setup.ts', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'], + setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.ts', '/tests/perf-test/setupAfterEnv.js'], cacheDirectory: '/.jest-cache', moduleNameMapper: { '\\.(lottie)$': '/__mocks__/fileMock.js', diff --git a/jest/setup.js b/jest/setup.ts similarity index 90% rename from jest/setup.js rename to jest/setup.ts index e82bf678941d..68d904fac5be 100644 --- a/jest/setup.js +++ b/jest/setup.ts @@ -1,6 +1,7 @@ import mockClipboard from '@react-native-clipboard/clipboard/jest/clipboard-mock'; import '@shopify/flash-list/jestSetup'; import 'react-native-gesture-handler/jestSetup'; +import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; import * as reanimatedJestUtils from 'react-native-reanimated/src/reanimated2/jestUtils'; import 'setimmediate'; import setupMockImages from './setupMockImages'; @@ -19,7 +20,7 @@ jest.mock('@react-native-clipboard/clipboard', () => mockClipboard); // Mock react-native-onyx storage layer because the SQLite storage layer doesn't work in jest. // Mocking this file in __mocks__ does not work because jest doesn't support mocking files that are not directly used in the testing project, // and we only want to mock the storage layer, not the whole Onyx module. -jest.mock('react-native-onyx/dist/storage', () => require('react-native-onyx/dist/storage/__mocks__')); +jest.mock('react-native-onyx/dist/storage', () => mockStorage); // Turn off the console logs for timing events. They are not relevant for unit tests and create a lot of noise jest.spyOn(console, 'debug').mockImplementation((...params) => { @@ -34,6 +35,6 @@ jest.spyOn(console, 'debug').mockImplementation((...params) => { // This mock is required for mocking file systems when running tests jest.mock('react-native-fs', () => ({ - unlink: jest.fn(() => new Promise((res) => res())), + unlink: jest.fn(() => new Promise((res) => res())), CachesDirectoryPath: jest.fn(), })); diff --git a/jest/setupAfterEnv.js b/jest/setupAfterEnv.ts similarity index 100% rename from jest/setupAfterEnv.js rename to jest/setupAfterEnv.ts diff --git a/jest/setupMockImages.js b/jest/setupMockImages.ts similarity index 87% rename from jest/setupMockImages.js rename to jest/setupMockImages.ts index 10925aca8736..c48797b3c07b 100644 --- a/jest/setupMockImages.js +++ b/jest/setupMockImages.ts @@ -1,14 +1,10 @@ import fs from 'fs'; import path from 'path'; -import _ from 'underscore'; -/** - * @param {String} imagePath - */ -function mockImages(imagePath) { +function mockImages(imagePath: string) { const imageFilenames = fs.readdirSync(path.resolve(__dirname, `../assets/${imagePath}/`)); // eslint-disable-next-line rulesdir/prefer-early-return - _.each(imageFilenames, (fileName) => { + imageFilenames.forEach((fileName) => { if (/\.svg/.test(fileName)) { jest.mock(`../assets/${imagePath}/${fileName}`, () => () => ''); } diff --git a/package-lock.json b/package-lock.json index 11099886bfb0..7e5eae30c680 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "new.expensify", - "version": "1.4.37-7", + "version": "1.4.38-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.37-7", + "version": "1.4.38-2", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#2ed4240336e50abb4a7fa9ff6a3c180f8bc9ce5b", + "@expensify/react-native-live-markdown": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#77f85a5265043c6100f1fa65edd58901724faf08", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", @@ -3352,8 +3352,8 @@ }, "node_modules/@expensify/react-native-live-markdown": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#2ed4240336e50abb4a7fa9ff6a3c180f8bc9ce5b", - "integrity": "sha512-i+HIsCFL9cdma+saH/KN2llTGqEb2DQttEJKozdm4fvcie9Ce2/q7XNDZo6nIYTbIVXPDLKPDmWLXqXTgLBKDQ==", + "resolved": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#77f85a5265043c6100f1fa65edd58901724faf08", + "integrity": "sha512-EpXjQ+JBR3pRuYuT5iFzQw45hrCcr5ZmX/lji4i3Un/BOQ14JbTkQjjwo4hYX3EdOvfrAUSJs0ZVqeCEIMo3YQ==", "license": "MIT", "workspaces": [ "example" diff --git a/package.json b/package.json index 71983e0e1679..b05a72143e33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.37-7", + "version": "1.4.38-2", "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.", @@ -59,7 +59,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#2ed4240336e50abb4a7fa9ff6a3c180f8bc9ce5b", + "@expensify/react-native-live-markdown": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#77f85a5265043c6100f1fa65edd58901724faf08", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", diff --git a/patches/@react-native+virtualized-lists+0.73.4+001+onStartReched.patch b/patches/@react-native+virtualized-lists+0.73.4+001+onStartReched.patch new file mode 100644 index 000000000000..b183124964f6 --- /dev/null +++ b/patches/@react-native+virtualized-lists+0.73.4+001+onStartReched.patch @@ -0,0 +1,32 @@ +diff --git a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js +index 0516679..e338d90 100644 +--- a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js ++++ b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js +@@ -1546,7 +1546,7 @@ class VirtualizedList extends StateSafePureComponent { + // Next check if the user just scrolled within the start threshold + // and call onStartReached only once for a given content length, + // and only if onEndReached is not being executed +- else if ( ++ if ( + onStartReached != null && + this.state.cellsAroundViewport.first === 0 && + isWithinStartThreshold && +@@ -1558,13 +1558,11 @@ class VirtualizedList extends StateSafePureComponent { + + // If the user scrolls away from the start or end and back again, + // cause onStartReached or onEndReached to be triggered again +- else { +- this._sentStartForContentLength = isWithinStartThreshold +- ? this._sentStartForContentLength +- : 0; +- this._sentEndForContentLength = isWithinEndThreshold +- ? this._sentEndForContentLength +- : 0; ++ if (!isWithinStartThreshold) { ++ this._sentStartForContentLength = 0; ++ } ++ if (!isWithinEndThreshold) { ++ this._sentEndForContentLength = 0; + } + } + diff --git a/src/CONST.ts b/src/CONST.ts index 6c726cde12f7..d086eed45a13 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -500,6 +500,7 @@ const CONST = { ADMIN_POLICIES_URL: 'admin_policies', ADMIN_DOMAINS_URL: 'admin_domains', INBOX: 'inbox', + DISMMISSED_REASON: '?dismissedReason=missingFeatures', }, SIGN_IN_FORM_WIDTH: 300, @@ -3174,6 +3175,14 @@ const CONST = { CHAT_SPLIT: 'newDotSplitChat', }, + MANAGE_TEAMS_CHOICE: { + MULTI_LEVEL: 'multiLevelApproval', + CUSTOM_EXPENSE: 'customExpenseCoding', + CARD_TRACKING: 'companyCardTracking', + ACCOUNTING: 'accountingIntegrations', + RULE: 'ruleEnforcement', + }, + MINI_CONTEXT_MENU_MAX_ITEMS: 4, WORKSPACE_SWITCHER: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e987c5b94d7d..28ffaa7d5865 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -281,10 +281,6 @@ const ROUTES = { route: ':iouType/new/currency/:reportID?', getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}` as const, }, - MONEY_REQUEST_DESCRIPTION: { - route: ':iouType/new/description/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/description/${reportID}` as const, - }, MONEY_REQUEST_CATEGORY: { route: ':iouType/new/category/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}` as const, @@ -347,9 +343,9 @@ const ROUTES = { getUrlWithBackToParam(`create/${iouType}/date/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_DESCRIPTION: { - route: 'create/:iouType/description/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`create/${iouType}/description/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/description/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/description/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_DISTANCE: { route: 'create/:iouType/distance/:transactionID/:reportID', @@ -413,6 +409,10 @@ const ROUTES = { NEW_TASK_TITLE: 'new/task/title', NEW_TASK_DESCRIPTION: 'new/task/description', + ONBOARD: 'onboard', + ONBOARD_MANAGE_EXPENSES: 'onboard/manage-expenses', + ONBOARD_EXPENSIFY_CLASSIC: 'onboard/expensify-classic', + TEACHERS_UNITE: 'teachersunite', I_KNOW_A_TEACHER: 'teachersunite/i-know-a-teacher', I_AM_A_TEACHER: 'teachersunite/i-am-a-teacher', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index e2f0e9745561..fe4fe3df628d 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -104,6 +104,7 @@ const SCREENS = { PARTICIPANTS: 'Participants', MONEY_REQUEST: 'MoneyRequest', NEW_TASK: 'NewTask', + ONBOARD_ENGAGEMENT: 'Onboard_Engagement', TEACHERS_UNITE: 'TeachersUnite', TASK_DETAILS: 'Task_Details', ENABLE_PAYMENTS: 'EnablePayments', @@ -150,7 +151,6 @@ const SCREENS = { CONFIRMATION: 'Money_Request_Confirmation', CURRENCY: 'Money_Request_Currency', DATE: 'Money_Request_Date', - DESCRIPTION: 'Money_Request_Description', CATEGORY: 'Money_Request_Category', MERCHANT: 'Money_Request_Merchant', WAYPOINT: 'Money_Request_Waypoint', @@ -230,6 +230,12 @@ const SCREENS = { EDIT_CURRENCY: 'SplitDetails_Edit_Currency', }, + ONBOARD_ENGAGEMENT: { + ROOT: 'Onboard_Engagement_Root', + MANAGE_TEAMS_EXPENSES: 'Manage_Teams_Expenses', + EXPENSIFY_CLASSIC: 'Expenisfy_Classic', + }, + I_KNOW_A_TEACHER: 'I_Know_A_Teacher', INTRO_SCHOOL_PRINCIPAL: 'Intro_School_Principal', I_AM_A_TEACHER: 'I_Am_A_Teacher', diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 05080fcdd21c..245aa2126d08 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -43,6 +43,7 @@ function AmountTextInput( disableKeyboard autoGrow hideFocusedState + shouldInterceptSwipe inputStyle={[styles.iouAmountTextInput, styles.p0, styles.noLeftBorderRadius, styles.noRightBorderRadius, style]} textInputContainerStyles={[styles.borderNone, styles.noLeftBorderRadius, styles.noRightBorderRadius]} onChangeText={onChangeAmount} diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index 6af3a4c6d477..34bc3f7e30c8 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -38,7 +38,7 @@ function Breadcrumbs({breadcrumbs, style}: BreadcrumbsProps) { const [primaryBreadcrumb, secondaryBreadcrumb] = breadcrumbs; return ( - + {primaryBreadcrumb.type === CONST.BREADCRUMB_TYPE.ROOT ? (
(null); const textInput = useRef(null); @@ -168,7 +170,7 @@ function Composer( // To make sure the composer does not capture paste events from other inputs, we check where the event originated // If it did originate in another input, we return early to prevent the composer from handling the paste - const isTargetInput = eventTarget?.nodeName === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true'; + const isTargetInput = ['INPUT', 'TEXTAREA', 'SPAN'].includes(eventTarget?.nodeName ?? '') || eventTarget?.contentEditable === 'true'; if (isTargetInput) { return true; } @@ -327,13 +329,14 @@ function Composer( return ( <> - (textInput.current = el)} + ref={(el) => (textInput.current = el as AnimatedTextInputRef)} selection={selection} style={inputStyleMemo} + markdownStyle={markdownStyle} value={value} defaultValue={defaultValue} autoFocus={autoFocus} diff --git a/src/components/DistanceEReceipt.js b/src/components/DistanceEReceipt.js index 3418aa55e22d..794c1c9aa53f 100644 --- a/src/components/DistanceEReceipt.js +++ b/src/components/DistanceEReceipt.js @@ -4,7 +4,6 @@ import {ScrollView, View} from 'react-native'; import _ from 'underscore'; import EReceiptBackground from '@assets/images/eReceipt_background.svg'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CurrencyUtils from '@libs/CurrencyUtils'; @@ -33,10 +32,9 @@ function DistanceEReceipt({transaction}) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const {isOffline} = useNetwork(); const {thumbnail} = TransactionUtils.hasReceipt(transaction) ? ReceiptUtils.getThumbnailAndImageURIs(transaction) : {}; const {amount: transactionAmount, currency: transactionCurrency, merchant: transactionMerchant, created: transactionDate} = ReportUtils.getTransactionDetails(transaction); - const formattedTransactionAmount = transactionAmount ? CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency) : translate('common.tbd'); + const formattedTransactionAmount = CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency); const thumbnailSource = tryResolveUrlFromApiRoot(thumbnail || ''); const waypoints = lodashGet(transaction, 'comment.waypoints', {}); const sortedWaypoints = useMemo( @@ -64,7 +62,7 @@ function DistanceEReceipt({transaction}) { /> - {isOffline || !thumbnailSource ? ( + {TransactionUtils.isFetchingWaypointsFromServer(transaction) || !thumbnailSource ? ( ) : ( - {formattedTransactionAmount} + {!!transactionAmount && {formattedTransactionAmount}} {transactionMerchant} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js index cd6ada90b58b..eb4f5f763dbe 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js @@ -49,7 +49,7 @@ function MentionUserRenderer(props) { } // If the emails are not in the same private domain, we also return the displayText - if (!LoginUtils.areEmailsFromSamePrivateDomain(displayText, props.currentUserPersonalDetails.login)) { + if (!LoginUtils.areEmailsFromSamePrivateDomain(displayText, lodashGet(props.currentUserPersonalDetails, 'login', ''))) { return displayText; } diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 64fe512eaff2..ece6295fc7f8 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -17,11 +17,13 @@ import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; import getButtonState from '@libs/getButtonState'; import Navigation from '@libs/Navigation/Navigation'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type HeaderWithBackButtonProps from './types'; function HeaderWithBackButton({ + icon, iconFill, guidesCallTaskID = '', onBackButtonPress = () => Navigation.goBack(ROUTES.HOME), @@ -65,12 +67,21 @@ function HeaderWithBackButton({ const {isKeyboardShown} = useKeyboardState(); const waitForNavigate = useWaitForNavigation(); + // If the icon is present, the header bar should be taller and use different font. + const isCentralPaneSettings = !!icon; + return ( {shouldShowBackButton && ( @@ -99,6 +110,14 @@ function HeaderWithBackButton({ )} + {icon && ( + + )} {shouldShowAvatarWithDisplay ? ( )} diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 88f7e717a44d..83afbad8636b 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -28,6 +28,13 @@ type HeaderWithBackButtonProps = Partial & { /** Title color */ titleColor?: string; + /** + * Icon displayed on the left of the title. + * If it is passed, the new styling is applied to the component: + * taller header on desktop and different font of the title. + * */ + icon?: IconAsset; + /** Method to trigger when pressing download button of the header */ onDownloadButtonPress?: () => void; diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index a6e7cc2882d6..139b6789e8d1 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -96,6 +96,7 @@ import NewWindow from '@assets/images/new-window.svg'; import NewWorkspace from '@assets/images/new-workspace.svg'; import OfflineCloud from '@assets/images/offline-cloud.svg'; import Offline from '@assets/images/offline.svg'; +import OldDotWireframe from '@assets/images/olddot-wireframe.svg'; import Paperclip from '@assets/images/paperclip.svg'; import Paycheck from '@assets/images/paycheck.svg'; import Pencil from '@assets/images/pencil.svg'; @@ -109,7 +110,6 @@ import QuestionMark from '@assets/images/question-mark-circle.svg'; import ReceiptSearch from '@assets/images/receipt-search.svg'; import Receipt from '@assets/images/receipt.svg'; import Rotate from '@assets/images/rotate-image.svg'; -import RotateLeft from '@assets/images/rotate-left.svg'; import Scan from '@assets/images/scan.svg'; import Send from '@assets/images/send.svg'; import Shield from '@assets/images/shield.svg'; @@ -238,6 +238,7 @@ export { NewWorkspace, Offline, OfflineCloud, + OldDotWireframe, Paperclip, Paycheck, Pencil, @@ -251,7 +252,6 @@ export { Receipt, ReceiptSearch, Rotate, - RotateLeft, Scan, Send, Shield, diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 954c8d0392fc..51422e7b4a49 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -37,11 +37,13 @@ import ConciergeBubble from '@assets/images/simple-illustrations/simple-illustra import ConciergeNew from '@assets/images/simple-illustrations/simple-illustration__concierge.svg'; import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg'; import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg'; +import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg'; import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg'; import HandEarth from '@assets/images/simple-illustrations/simple-illustration__handearth.svg'; import HotDogStand from '@assets/images/simple-illustrations/simple-illustration__hotdogstand.svg'; import Hourglass from '@assets/images/simple-illustrations/simple-illustration__hourglass.svg'; import InvoiceBlue from '@assets/images/simple-illustrations/simple-illustration__invoice.svg'; +import LockClosed from '@assets/images/simple-illustrations/simple-illustration__lockclosed.svg'; import LockOpen from '@assets/images/simple-illustrations/simple-illustration__lockopen.svg'; import Luggage from '@assets/images/simple-illustrations/simple-illustration__luggage.svg'; import Mailbox from '@assets/images/simple-illustrations/simple-illustration__mailbox.svg'; @@ -50,6 +52,9 @@ import MoneyBadge from '@assets/images/simple-illustrations/simple-illustration_ import MoneyIntoWallet from '@assets/images/simple-illustrations/simple-illustration__moneyintowallet.svg'; import MoneyWings from '@assets/images/simple-illustrations/simple-illustration__moneywings.svg'; import OpenSafe from '@assets/images/simple-illustrations/simple-illustration__opensafe.svg'; +import PalmTree from '@assets/images/simple-illustrations/simple-illustration__palmtree.svg'; +import Profile from '@assets/images/simple-illustrations/simple-illustration__profile.svg'; +import QrCode from '@assets/images/simple-illustrations/simple-illustration__qr-code.svg'; import SanFrancisco from '@assets/images/simple-illustrations/simple-illustration__sanfrancisco.svg'; import ShieldYellow from '@assets/images/simple-illustrations/simple-illustration__shield.svg'; import SmallRocket from '@assets/images/simple-illustrations/simple-illustration__smallrocket.svg'; @@ -118,4 +123,9 @@ export { CommentBubbles, TrashCan, TeleScope, + Profile, + PalmTree, + LockClosed, + Gears, + QrCode, }; diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index c1f4df8d4c99..7fc5013f58a0 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -176,6 +176,8 @@ function BaseModal( return ( e.stopPropagation()} onBackdropPress={handleBackdropPress} // Note: Escape key on web/desktop will trigger onBackButtonPress callback // eslint-disable-next-line react/jsx-props-no-multi-spaces diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index b6a91cf7a9c8..faa487887f22 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -255,9 +255,9 @@ function MoneyRequestConfirmationList(props) { const shouldShowBillable = !lodashGet(props.policy, 'disabledFields.defaultBillable', true); const hasRoute = TransactionUtils.hasRoute(transaction); - const isDistanceRequestWithoutRoute = props.isDistanceRequest && !hasRoute; - const formattedAmount = isDistanceRequestWithoutRoute - ? translate('common.tbd') + const isDistanceRequestWithPendingRoute = props.isDistanceRequest && (!hasRoute || !rate); + const formattedAmount = isDistanceRequestWithPendingRoute + ? '' : CurrencyUtils.convertToDisplayString( shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) : props.iouAmount, props.isDistanceRequest ? currency : props.iouCurrencyCode, @@ -332,7 +332,7 @@ function MoneyRequestConfirmationList(props) { let text; if (isSplitBill && props.iouAmount === 0) { text = translate('iou.split'); - } else if ((props.receiptPath && isTypeRequest) || isDistanceRequestWithoutRoute) { + } else if ((props.receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { text = translate('iou.request'); if (props.iouAmount !== 0) { text = translate('iou.requestAmount', {amount: formattedAmount}); @@ -347,7 +347,7 @@ function MoneyRequestConfirmationList(props) { value: props.iouType, }, ]; - }, [isSplitBill, isTypeRequest, props.iouType, props.iouAmount, props.receiptPath, formattedAmount, isDistanceRequestWithoutRoute, translate]); + }, [isSplitBill, isTypeRequest, props.iouType, props.iouAmount, props.receiptPath, formattedAmount, isDistanceRequestWithPendingRoute, translate]); const selectedParticipants = useMemo(() => _.filter(props.selectedParticipants, (participant) => participant.selected), [props.selectedParticipants]); const payeePersonalDetails = useMemo(() => props.payeePersonalDetails || props.currentUserPersonalDetails, [props.payeePersonalDetails, props.currentUserPersonalDetails]); @@ -426,9 +426,17 @@ function MoneyRequestConfirmationList(props) { if (!props.isDistanceRequest) { return; } + + /* + Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as: + When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page. + In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending. + */ + IOU.setMoneyRequestPendingFields(props.transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); + const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(hasRoute, distance, unit, rate, currency, translate, toLocaleDigit); IOU.setMoneyRequestMerchant_temporaryForRefactor(props.transactionID, distanceMerchant); - }, [hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, props.isDistanceRequest, props.transactionID]); + }, [isDistanceRequestWithPendingRoute, hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, props.isDistanceRequest, props.transactionID]); /** * @param {Object} option @@ -482,7 +490,7 @@ function MoneyRequestConfirmationList(props) { } else { // validate the amount for distance requests const decimals = CurrencyUtils.getCurrencyDecimals(props.iouCurrencyCode); - if (props.isDistanceRequest && !isDistanceRequestWithoutRoute && !MoneyRequestUtils.validateAmount(String(props.iouAmount), decimals)) { + if (props.isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(props.iouAmount), decimals)) { setFormError('common.error.invalidAmount'); return; } @@ -505,7 +513,7 @@ function MoneyRequestConfirmationList(props) { props.iouType, props.isDistanceRequest, props.iouCategory, - isDistanceRequestWithoutRoute, + isDistanceRequestWithPendingRoute, props.iouCurrencyCode, props.iouAmount, transaction, @@ -665,11 +673,15 @@ function MoneyRequestConfirmationList(props) { title={props.iouComment} description={translate('common.description')} onPress={() => { - if (props.isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_DESCRIPTION.getRoute(props.iouType, props.reportID)); + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( + CONST.IOU.ACTION.EDIT, + props.iouType, + transaction.transactionID, + props.reportID, + Navigation.getActiveRouteWithoutParams(), + ), + ); }} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index be5cec7a2c0d..8a61fe6daec5 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -284,9 +284,9 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ const shouldShowBillable = !lodashGet(policy, 'disabledFields.defaultBillable', true); const hasRoute = TransactionUtils.hasRoute(transaction); - const isDistanceRequestWithoutRoute = isDistanceRequest && !hasRoute; - const formattedAmount = isDistanceRequestWithoutRoute - ? translate('common.tbd') + const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate); + const formattedAmount = isDistanceRequestWithPendingRoute + ? '' : CurrencyUtils.convertToDisplayString( shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) : iouAmount, isDistanceRequest ? currency : iouCurrencyCode, @@ -376,7 +376,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ let text; if (isTypeSplit && iouAmount === 0) { text = translate('iou.split'); - } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithoutRoute) { + } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { text = translate('iou.request'); if (iouAmount !== 0) { text = translate('iou.requestAmount', {amount: formattedAmount}); @@ -391,7 +391,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ value: iouType, }, ]; - }, [isTypeSplit, isTypeRequest, iouType, iouAmount, receiptPath, formattedAmount, isDistanceRequestWithoutRoute, translate]); + }, [isTypeSplit, isTypeRequest, iouType, iouAmount, receiptPath, formattedAmount, isDistanceRequestWithPendingRoute, translate]); const selectedParticipants = useMemo(() => _.filter(pickedParticipants, (participant) => participant.selected), [pickedParticipants]); const personalDetailsOfPayee = useMemo(() => payeePersonalDetails || currentUserPersonalDetails, [payeePersonalDetails, currentUserPersonalDetails]); @@ -473,9 +473,17 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ if (!isDistanceRequest) { return; } + + /* + Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as: + When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page. + In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending. + */ + IOU.setMoneyRequestPendingFields(transaction.transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); + const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(hasRoute, distance, unit, rate, currency, translate, toLocaleDigit); IOU.setMoneyRequestMerchant_temporaryForRefactor(transaction.transactionID, distanceMerchant); - }, [hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, isDistanceRequest, transaction]); + }, [isDistanceRequestWithPendingRoute, hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, isDistanceRequest, transaction]); /** * @param {Object} option @@ -530,7 +538,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ } else { // validate the amount for distance requests const decimals = CurrencyUtils.getCurrencyDecimals(iouCurrencyCode); - if (isDistanceRequest && !isDistanceRequestWithoutRoute && !MoneyRequestUtils.validateAmount(String(iouAmount), decimals)) { + if (isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(iouAmount), decimals)) { setFormError('common.error.invalidAmount'); return; } @@ -555,7 +563,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ onSendMoney, iouCurrencyCode, isDistanceRequest, - isDistanceRequestWithoutRoute, + isDistanceRequestWithPendingRoute, iouAmount, isEditingSplitBill, onConfirm, @@ -693,11 +701,9 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ title={iouComment} description={translate('common.description')} onPress={() => { - if (isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ); }} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 345680e809f3..bb1732ceb2f8 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -41,9 +41,6 @@ const propTypes = { /** Whether referral CTA should be displayed */ shouldShowReferralCTA: PropTypes.bool, - /** A method triggered when the user closes the call to action banner */ - onCallToActionClosed: PropTypes.func, - /** Referral content type */ referralContentType: PropTypes.string, @@ -56,7 +53,6 @@ const propTypes = { const defaultProps = { shouldDelayFocus: false, shouldShowReferralCTA: false, - onCallToActionClosed: () => {}, referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND, safeAreaPaddingBottomStyle: {}, contentContainerStyles: [], @@ -72,7 +68,6 @@ class BaseOptionsSelector extends Component { this.updateFocusedIndex = this.updateFocusedIndex.bind(this); this.scrollToIndex = this.scrollToIndex.bind(this); this.selectRow = this.selectRow.bind(this); - this.closeReferralModal = this.closeReferralModal.bind(this); this.selectFocusedOption = this.selectFocusedOption.bind(this); this.addToSelection = this.addToSelection.bind(this); this.updateSearchValue = this.updateSearchValue.bind(this); @@ -95,7 +90,6 @@ class BaseOptionsSelector extends Component { allOptions, focusedIndex, shouldDisableRowSelection: false, - shouldShowReferralModal: this.props.shouldShowReferralCTA, errorMessage: '', paginationPage: 1, disableEnterShortCut: false, @@ -266,11 +260,6 @@ class BaseOptionsSelector extends Component { this.props.onChangeText(value); } - closeReferralModal() { - this.setState((prevState) => ({shouldShowReferralModal: !prevState.shouldShowReferralModal})); - this.props.onCallToActionClosed(this.props.referralContentType); - } - handleFocusIn() { const activeElement = document.activeElement; this.setState({ @@ -653,12 +642,9 @@ class BaseOptionsSelector extends Component { )} - {this.props.shouldShowReferralCTA && this.state.shouldShowReferralModal && ( + {this.props.shouldShowReferralCTA && ( - + )} diff --git a/src/components/PopoverWithoutOverlay/index.tsx b/src/components/PopoverWithoutOverlay/index.tsx index 58d022ef9d65..a87e1f1f0412 100644 --- a/src/components/PopoverWithoutOverlay/index.tsx +++ b/src/components/PopoverWithoutOverlay/index.tsx @@ -121,6 +121,8 @@ function PopoverWithoutOverlay( e.stopPropagation()} > {}}: ReceiptEmptyS > diff --git a/src/components/ReferralProgramCTA.tsx b/src/components/ReferralProgramCTA.tsx index 4e0ed1f573f9..83bdfd67fef1 100644 --- a/src/components/ReferralProgramCTA.tsx +++ b/src/components/ReferralProgramCTA.tsx @@ -33,7 +33,7 @@ function ReferralProgramCTA({referralContentType, dismissedReferralBanners}: Ref const theme = useTheme(); const handleDismissCallToAction = () => { - User.dismissReferralBanner(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND); + User.dismissReferralBanner(referralContentType); }; if (!referralContentType || dismissedReferralBanners[referralContentType]) { diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index e2021360c11a..133996fde41c 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -49,7 +49,7 @@ function MoneyReportView({report, policy, policyReportFields, shouldShowHorizont const {totalDisplaySpend, nonReimbursableSpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(report); const shouldShowBreakdown = nonReimbursableSpend && reimbursableSpend; - const formattedTotalAmount = CurrencyUtils.convertToDisplayString(totalDisplaySpend, report.currency, ReportUtils.hasOnlyDistanceRequestTransactions(report.reportID)); + const formattedTotalAmount = CurrencyUtils.convertToDisplayString(totalDisplaySpend, report.currency); const formattedOutOfPocketAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, report.currency); const formattedCompanySpendAmount = CurrencyUtils.convertToDisplayString(nonReimbursableSpend, report.currency); diff --git a/src/components/ReportActionItem/MoneyRequestPreview.tsx b/src/components/ReportActionItem/MoneyRequestPreview.tsx index 70a313c77e9e..e89193108d24 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview.tsx @@ -155,20 +155,27 @@ function MoneyRequestPreview({ const isScanning = hasReceipt && TransactionUtils.isReceiptBeingScanned(transaction); const hasFieldErrors = TransactionUtils.hasMissingSmartscanFields(transaction); const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction); + const isFetchingWaypointsFromServer = TransactionUtils.isFetchingWaypointsFromServer(transaction); const isCardTransaction = TransactionUtils.isCardTransaction(transaction); const isSettled = ReportUtils.isSettled(iouReport?.reportID); const isDeleted = action?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - // Show the merchant for IOUs and expenses only if they are custom or not related to scanning smartscan - const shouldShowMerchant = !!requestMerchant && requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; + /* + Show the merchant for IOUs and expenses only if: + - the merchant is not empty, is custom, or is not related to scanning smartscan; + - the request is not a distance request with a pending route and amount = 0 - in this case, + the merchant says: "Route pending...", which is already shown in the amount field; + */ + const shouldShowMerchant = + !!requestMerchant && + requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && + requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT && + !(isFetchingWaypointsFromServer && !requestAmount); const shouldShowDescription = !!description && !shouldShowMerchant && !isScanning; - const hasPendingWaypoints = transaction?.pendingFields?.waypoints; let merchantOrDescription = requestMerchant; if (!shouldShowMerchant) { merchantOrDescription = description || ''; - } else if (hasPendingWaypoints) { - merchantOrDescription = requestMerchant.replace(CONST.REGEX.FIRST_SPACE, translate('common.tbd')); } const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(transaction)] : []; @@ -217,14 +224,14 @@ function MoneyRequestPreview({ }; const getDisplayAmountText = (): string => { - if (isDistanceRequest) { - return requestAmount && !hasPendingWaypoints ? CurrencyUtils.convertToDisplayString(requestAmount, requestCurrency) : translate('common.tbd'); - } - if (isScanning) { return translate('iou.receiptScanning'); } + if (isFetchingWaypointsFromServer && !requestAmount) { + return translate('iou.routePending'); + } + if (!isSettled && TransactionUtils.hasMissingSmartscanFields(transaction)) { return Localize.translateLocal('iou.receiptMissingDetails'); } diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3a3aef6cabcd..df5be02ca64f 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -106,11 +106,7 @@ function MoneyRequestView({ } = ReportUtils.getTransactionDetails(transaction) ?? {}; const isEmptyMerchant = transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction); - let formattedTransactionAmount = transactionAmount ? CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency) : ''; - const hasPendingWaypoints = transaction?.pendingFields?.waypoints; - if (isDistanceRequest && (!formattedTransactionAmount || hasPendingWaypoints)) { - formattedTransactionAmount = translate('common.tbd'); - } + const formattedTransactionAmount = transactionAmount ? CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency) : ''; const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && CurrencyUtils.convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency); const isCardTransaction = TransactionUtils.isCardTransaction(transaction); const cardProgramName = isCardTransaction && transactionCardID !== undefined ? CardUtils.getCardDescription(transactionCardID) : ''; @@ -268,7 +264,17 @@ function MoneyRequestView({ interactive={canEdit} shouldShowRightIcon={canEdit} titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION))} + onPress={() => + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( + CONST.IOU.ACTION.EDIT, + CONST.IOU.TYPE.REQUEST, + transaction?.transactionID ?? '', + report.reportID, + Navigation.getActiveRouteWithoutParams(), + ), + ) + } wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} brickRoadIndicator={hasViolations('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} numberOfLinesTitle={0} @@ -279,7 +285,7 @@ function MoneyRequestView({ ({ hasMissingSmartscanFields: ReportUtils.hasMissingSmartscanFields(iouReportID), areAllRequestsBeingSmartScanned: ReportUtils.areAllRequestsBeingSmartScanned(iouReportID, action), - hasOnlyDistanceRequests: ReportUtils.hasOnlyDistanceRequestTransactions(iouReportID), + hasOnlyTransactionsWithPendingRoutes: ReportUtils.hasOnlyTransactionsWithPendingRoutes(iouReportID), hasNonReimbursableTransactions: ReportUtils.hasNonReimbursableTransactions(iouReportID), }), // When transactions get updated these status may have changed, so that is a case where we also want to run this. @@ -140,14 +140,11 @@ function ReportPreview({ const hasErrors = (hasReceipts && hasMissingSmartscanFields) || (canUseViolations && ReportUtils.hasViolations(iouReportID, transactionViolations)); const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3); const lastThreeReceipts = lastThreeTransactionsWithReceipts.map((transaction) => ReceiptUtils.getThumbnailAndImageURIs(transaction)); + let formattedMerchant = numberOfRequests === 1 && hasReceipts ? TransactionUtils.getMerchant(transactionsWithReceipts[0]) : null; if (TransactionUtils.isPartialMerchant(formattedMerchant ?? '')) { formattedMerchant = null; } - const hasPendingWaypoints = formattedMerchant && hasOnlyDistanceRequests && transactionsWithReceipts.every((transaction) => transaction.pendingFields?.waypoints); - if (formattedMerchant && hasPendingWaypoints) { - formattedMerchant = formattedMerchant.replace(CONST.REGEX.FIRST_SPACE, translate('common.tbd')); - } const previewSubtitle = // Formatted merchant can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -166,17 +163,14 @@ function ReportPreview({ ); const getDisplayAmount = (): string => { - if (hasPendingWaypoints) { - return translate('common.tbd'); - } if (totalDisplaySpend) { return CurrencyUtils.convertToDisplayString(totalDisplaySpend, iouReport?.currency); } if (isScanning) { return translate('iou.receiptScanning'); } - if (hasOnlyDistanceRequests) { - return translate('common.tbd'); + if (hasOnlyTransactionsWithPendingRoutes) { + return translate('iou.routePending'); } // If iouReport is not available, get amount from the action message (Ex: "Domain20821's Workspace owes $33.00" or "paid ₫60" or "paid -₫60 elsewhere") @@ -230,6 +224,18 @@ function ReportPreview({ return isCurrentUserManager && !isDraftExpenseReport && !isApproved && !iouSettled; }, [isPaidGroupPolicy, isCurrentUserManager, isDraftExpenseReport, isApproved, iouSettled]); const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton; + + /* + Show subtitle if at least one of the money requests is not being smart scanned, and either: + - There is more than one money request – in this case, the "X requests, Y scanning" subtitle is shown; + - There is only one money request, it has a receipt and is not being smart scanned – in this case, the request merchant is shown; + + * There is an edge case when there is only one distance request with a pending route and amount = 0. + In this case, we don't want to show the merchant because it says: "Pending route...", which is already displayed in the amount field. + */ + const shouldShowSingleRequestMerchant = numberOfRequests === 1 && !!formattedMerchant && !(hasOnlyTransactionsWithPendingRoutes && !totalDisplaySpend); + const shouldShowSubtitle = !isScanning && (shouldShowSingleRequestMerchant || numberOfRequests > 1); + return ( - {!isScanning && (numberOfRequests > 1 || (hasReceipts && numberOfRequests === 1 && formattedMerchant)) && ( + {shouldShowSubtitle && ( {previewSubtitle || moneyRequestComment} diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index a7d05a335d43..8b6a894cdd51 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -10,6 +10,7 @@ import useEnvironment from '@hooks/useEnvironment'; import useInitialDimensions from '@hooks/useInitialWindowDimensions'; import useKeyboardState from '@hooks/useKeyboardState'; import useNetwork from '@hooks/useNetwork'; +import useTackInputFocus from '@hooks/useTackInputFocus'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; @@ -79,6 +80,9 @@ type ScreenWrapperProps = { /** Whether to show offline indicator */ shouldShowOfflineIndicator?: boolean; + /** Whether to avoid scroll on virtual viewport */ + shouldAvoidScrollOnVirtualViewport?: boolean; + /** * The navigation prop is passed by the navigator. It is used to trigger the onEntryTransitionEnd callback * when the screen transition ends. @@ -109,6 +113,7 @@ function ScreenWrapper( onEntryTransitionEnd, testID, navigation: navigationProp, + shouldAvoidScrollOnVirtualViewport = true, shouldShowOfflineIndicatorInWideScreen = false, }: ScreenWrapperProps, ref: ForwardedRef, @@ -122,7 +127,7 @@ function ScreenWrapper( */ const navigationFallback = useNavigation>(); const navigation = navigationProp ?? navigationFallback; - const {windowHeight, isSmallScreenWidth} = useWindowDimensions(); + const {windowHeight, isSmallScreenWidth} = useWindowDimensions(shouldEnableMaxHeight); const {initialHeight} = useInitialDimensions(); const styles = useThemeStyles(); const keyboardState = useKeyboardState(); @@ -192,6 +197,8 @@ function ScreenWrapper( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const isAvoidingViewportScroll = useTackInputFocus(shouldEnableMaxHeight && shouldAvoidScrollOnVirtualViewport && Browser.isMobileSafari()); + return ( {({insets, paddingTop, paddingBottom, safeAreaPaddingBottomStyle}) => { @@ -220,12 +227,12 @@ function ScreenWrapper( {...keyboardDissmissPanResponder.panHandlers} > diff --git a/src/hooks/useTackInputFocus/index.native.ts b/src/hooks/useTackInputFocus/index.native.ts new file mode 100644 index 000000000000..683040d7421a --- /dev/null +++ b/src/hooks/useTackInputFocus/index.native.ts @@ -0,0 +1,6 @@ +/** + * Detects input or text area focus on browser. Native doesn't support DOM so default to false + */ +export default function useTackInputFocus(): boolean { + return false; +} diff --git a/src/hooks/useTackInputFocus/index.ts b/src/hooks/useTackInputFocus/index.ts new file mode 100644 index 000000000000..124f8460127c --- /dev/null +++ b/src/hooks/useTackInputFocus/index.ts @@ -0,0 +1,49 @@ +import {useCallback, useEffect} from 'react'; +import useDebouncedState from '@hooks/useDebouncedState'; + +/** + * Detects input or text area focus on browsers, to avoid scrolling on virtual viewports + */ +export default function useTackInputFocus(enable = false): boolean { + const [, isInputFocusDebounced, setIsInputFocus] = useDebouncedState(false); + + const handleFocusIn = useCallback( + (event: FocusEvent) => { + const targetElement = event.target as HTMLElement; + if (targetElement.tagName === 'INPUT' || targetElement.tagName === 'TEXTAREA') { + setIsInputFocus(true); + } + }, + [setIsInputFocus], + ); + + const handleFocusOut = useCallback( + (event: FocusEvent) => { + const targetElement = event.target as HTMLElement; + if (targetElement.tagName === 'INPUT' || targetElement.tagName === 'TEXTAREA') { + setIsInputFocus(false); + } + }, + [setIsInputFocus], + ); + + const resetScrollPositionOnVisualViewport = useCallback(() => { + window.scrollTo({top: 0}); + }, []); + + useEffect(() => { + if (!enable) { + return; + } + window.addEventListener('focusin', handleFocusIn); + window.addEventListener('focusout', handleFocusOut); + window.visualViewport?.addEventListener('scroll', resetScrollPositionOnVisualViewport); + return () => { + window.removeEventListener('focusin', handleFocusIn); + window.removeEventListener('focusout', handleFocusOut); + window.visualViewport?.removeEventListener('scroll', resetScrollPositionOnVisualViewport); + }; + }, [enable, handleFocusIn, handleFocusOut, resetScrollPositionOnVisualViewport]); + + return isInputFocusDebounced; +} diff --git a/src/hooks/useWindowDimensions/index.ts b/src/hooks/useWindowDimensions/index.ts index b0a29e9f901b..4ba2c4ad9b41 100644 --- a/src/hooks/useWindowDimensions/index.ts +++ b/src/hooks/useWindowDimensions/index.ts @@ -1,13 +1,21 @@ +import {useEffect, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import {Dimensions, useWindowDimensions} from 'react-native'; +import * as Browser from '@libs/Browser'; import variables from '@styles/variables'; import type WindowDimensions from './types'; +const initalViewportHeight = window.visualViewport?.height ?? window.innerHeight; +const tagNamesOpenKeyboard = ['INPUT', 'TEXTAREA']; + /** * A convenience wrapper around React Native's useWindowDimensions hook that also provides booleans for our breakpoints. */ -export default function (): WindowDimensions { +export default function (useCachedViewportHeight = false): WindowDimensions { + const isCachedViewportHeight = useCachedViewportHeight && Browser.isMobileSafari(); + const cachedViewportHeightWithKeyboardRef = useRef(initalViewportHeight); const {width: windowWidth, height: windowHeight} = useWindowDimensions(); + // When the soft keyboard opens on mWeb, the window height changes. Use static screen height instead to get real screenHeight. const screenHeight = Dimensions.get('screen').height; const isExtraSmallScreenHeight = screenHeight <= variables.extraSmallMobileResponsiveHeightBreakpoint; @@ -15,9 +23,61 @@ export default function (): WindowDimensions { const isMediumScreenWidth = windowWidth > variables.mobileResponsiveWidthBreakpoint && windowWidth <= variables.tabletResponsiveWidthBreakpoint; const isLargeScreenWidth = windowWidth > variables.tabletResponsiveWidthBreakpoint; + const [cachedViewportHeight, setCachedViewportHeight] = useState(windowHeight); + + const handleFocusIn = useRef((event: FocusEvent) => { + const targetElement = event.target as HTMLElement; + if (tagNamesOpenKeyboard.includes(targetElement.tagName)) { + setCachedViewportHeight(cachedViewportHeightWithKeyboardRef.current); + } + }); + + useEffect(() => { + if (!isCachedViewportHeight) { + return; + } + window.addEventListener('focusin', handleFocusIn.current); + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + window.removeEventListener('focusin', handleFocusIn.current); + }; + }, [isCachedViewportHeight]); + + const handleFocusOut = useRef((event: FocusEvent) => { + const targetElement = event.target as HTMLElement; + if (tagNamesOpenKeyboard.includes(targetElement.tagName)) { + setCachedViewportHeight(initalViewportHeight); + } + }); + + useEffect(() => { + if (!isCachedViewportHeight) { + return; + } + window.addEventListener('focusout', handleFocusOut.current); + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + window.removeEventListener('focusout', handleFocusOut.current); + }; + }, [isCachedViewportHeight]); + + useEffect(() => { + if (!isCachedViewportHeight && windowHeight >= cachedViewportHeightWithKeyboardRef.current) { + return; + } + setCachedViewportHeight(windowHeight); + }, [windowHeight, isCachedViewportHeight]); + + useEffect(() => { + if (!isCachedViewportHeight || !window.matchMedia('(orientation: portrait)').matches || windowHeight >= initalViewportHeight) { + return; + } + cachedViewportHeightWithKeyboardRef.current = windowHeight; + }, [isCachedViewportHeight, windowHeight]); + return { windowWidth, - windowHeight, + windowHeight: isCachedViewportHeight ? cachedViewportHeight : windowHeight, isExtraSmallScreenHeight, isSmallScreenWidth, isMediumScreenWidth, diff --git a/src/languages/en.ts b/src/languages/en.ts index fd5d7d0e7b78..aa9b9ca8c9a2 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -589,6 +589,7 @@ export default { canceled: 'Canceled', posted: 'Posted', deleteReceipt: 'Delete receipt', + routePending: 'Route pending...', receiptScanning: 'Scan in progress…', receiptMissingDetails: 'Receipt missing details', receiptStatusTitle: 'Scanning…', @@ -2111,6 +2112,20 @@ export default { welcomeMessage: 'Welcome to Expensify', welcomeSubtitle: 'What would you like to do?', }, + manageTeams: { + [CONST.MANAGE_TEAMS_CHOICE.MULTI_LEVEL]: 'Multi level approval', + [CONST.MANAGE_TEAMS_CHOICE.CUSTOM_EXPENSE]: 'Custom expense coding', + [CONST.MANAGE_TEAMS_CHOICE.CARD_TRACKING]: 'Company card tracking', + [CONST.MANAGE_TEAMS_CHOICE.ACCOUNTING]: 'Accounting integrations', + [CONST.MANAGE_TEAMS_CHOICE.RULE]: 'Rule enforcement', + title: 'Do you require any of the following features?', + }, + expensifyClassic: { + title: "Expensify Classic has everything you'll need", + firstDescription: "While we're busy working on New Expensify, it currently doesn't support some of the features you're looking for.", + secondDescription: "Don't worry, Expensify Classic has everything you need.", + buttonText: 'Take me to Expensify Classic', + }, violations: { allTagLevelsRequired: 'All tags required', autoReportedRejectedExpense: ({rejectReason, rejectedBy}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rejected this expense with the comment "${rejectReason}"`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 3675d4b25eab..39133892e684 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -581,6 +581,7 @@ export default { canceled: 'Canceló', posted: 'Contabilizado', deleteReceipt: 'Eliminar recibo', + routePending: 'Ruta pendiente...', receiptScanning: 'Escaneo en curso…', receiptMissingDetails: 'Recibo con campos vacíos', receiptStatusTitle: 'Escaneando…', @@ -2598,6 +2599,20 @@ export default { welcomeMessage: 'Bienvenido a Expensify', welcomeSubtitle: '¿Qué te gustaría hacer?', }, + manageTeams: { + [CONST.MANAGE_TEAMS_CHOICE.MULTI_LEVEL]: 'Aprobación multinivel', + [CONST.MANAGE_TEAMS_CHOICE.CUSTOM_EXPENSE]: 'Codificación personalizada de gastos', + [CONST.MANAGE_TEAMS_CHOICE.CARD_TRACKING]: 'Seguimiento de tarjetas corporativas', + [CONST.MANAGE_TEAMS_CHOICE.ACCOUNTING]: 'Integraciones de contaduría', + [CONST.MANAGE_TEAMS_CHOICE.RULE]: 'Aplicación de reglas', + title: '¿Necesitas alguna de las siguientes funciones?', + }, + expensifyClassic: { + title: 'Expensify Classic tiene todo lo que necesitas', + firstDescription: 'Aunque estamos ocupados trabajando en el Nuevo Expensify, actualmente no soporta algunas de las funciones que estás buscando.', + secondDescription: 'No te preocupes, Expensify Classic tiene todo lo que necesitas.', + buttonText: 'Llévame a Expensify Classic', + }, violations: { allTagLevelsRequired: 'Todas las etiquetas son obligatorias', autoReportedRejectedExpense: ({rejectedBy, rejectReason}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rechazó la solicitud y comentó "${rejectReason}"`, diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index 42387e03c80b..cec9d1e09088 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -2,7 +2,6 @@ import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; import type {OnyxValues} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; -import * as Localize from './Localize'; import BaseLocaleListener from './Localize/LocaleListener/BaseLocaleListener'; import * as NumberFormatUtils from './NumberFormatUtils'; @@ -98,13 +97,8 @@ function convertToFrontendAmount(amountAsInt: number): number { * * @param amountInCents – should be an integer. Anything after a decimal place will be dropped. * @param currency - IOU currency - * @param shouldFallbackToTbd - whether to return 'TBD' instead of a falsy value (e.g. 0.00) */ -function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD, shouldFallbackToTbd = false): string { - if (shouldFallbackToTbd && !amountInCents) { - return Localize.translateLocal('common.tbd'); - } - +function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD): string { const convertedAmount = convertToFrontendAmount(amountInCents); return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index c92e9bfd3f67..a42cb6a8f756 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -95,14 +95,17 @@ function getDistanceMerchant( translate: LocaleContextProps['translate'], toLocaleDigit: LocaleContextProps['toLocaleDigit'], ): string { - const distanceInUnits = hasRoute ? getRoundedDistanceInUnits(distanceInMeters, unit) : translate('common.tbd'); + if (!hasRoute || !rate) { + return translate('iou.routePending'); + } + const distanceInUnits = getRoundedDistanceInUnits(distanceInMeters, unit); const distanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.miles') : translate('common.kilometers'); const singularDistanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer'); const unitString = distanceInUnits === '1' ? singularDistanceUnit : distanceUnit; - const ratePerUnit = rate ? PolicyUtils.getUnitRateValue({rate}, toLocaleDigit) : translate('common.tbd'); + const ratePerUnit = PolicyUtils.getUnitRateValue({rate}, toLocaleDigit); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const currencySymbol = rate ? CurrencyUtils.getCurrencySymbol(currency) || `${currency} ` : ''; + const currencySymbol = CurrencyUtils.getCurrencySymbol(currency) || `${currency} `; return `${distanceInUnits} ${unitString} @ ${currencySymbol}${ratePerUnit} / ${singularDistanceUnit}`; } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index 110c13fa07bf..b57ff5d8bf60 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -12,6 +12,7 @@ import type { MoneyRequestNavigatorParamList, NewChatNavigatorParamList, NewTaskNavigatorParamList, + OnboardEngagementNavigatorParamList, ParticipantsNavigatorParamList, PrivateNotesNavigatorParamList, ProfileNavigatorParamList, @@ -99,7 +100,6 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../pages/iou/steps/MoneyRequestConfirmPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CURRENCY]: () => require('../../../pages/iou/IOUCurrencySelection').default as React.ComponentType, [SCREENS.MONEY_REQUEST.DATE]: () => require('../../../pages/iou/MoneyRequestDatePage').default as React.ComponentType, - [SCREENS.MONEY_REQUEST.DESCRIPTION]: () => require('../../../pages/iou/MoneyRequestDescriptionPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CATEGORY]: () => require('../../../pages/iou/MoneyRequestCategoryPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.MERCHANT]: () => require('../../../pages/iou/MoneyRequestMerchantPage').default as React.ComponentType, [SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: () => require('../../../pages/AddPersonalBankAccountPage').default as React.ComponentType, @@ -174,6 +174,12 @@ const NewTaskModalStackNavigator = createModalStackNavigator require('../../../pages/tasks/NewTaskDescriptionPage').default as React.ComponentType, }); +const OnboardEngagementModalStackNavigator = createModalStackNavigator({ + [SCREENS.ONBOARD_ENGAGEMENT.ROOT]: () => require('../../../pages/OnboardEngagement/PurposeForUsingExpensifyPage').default as React.ComponentType, + [SCREENS.ONBOARD_ENGAGEMENT.MANAGE_TEAMS_EXPENSES]: () => require('../../../pages/OnboardEngagement/ManageTeamsExpensesPage').default as React.ComponentType, + [SCREENS.ONBOARD_ENGAGEMENT.EXPENSIFY_CLASSIC]: () => require('../../../pages/OnboardEngagement/ExpensifyClassicPage').default as React.ComponentType, +}); + const NewTeachersUniteNavigator = createModalStackNavigator({ [SCREENS.SAVE_THE_WORLD.ROOT]: () => require('../../../pages/TeachersUnite/SaveTheWorldPage').default as React.ComponentType, [SCREENS.I_KNOW_A_TEACHER]: () => require('../../../pages/TeachersUnite/KnowATeacherPage').default as React.ComponentType, @@ -292,6 +298,7 @@ export { AccountSettingsModalStackNavigator, AddPersonalBankAccountModalStackNavigator, DetailsModalStackNavigator, + OnboardEngagementModalStackNavigator, EditRequestStackNavigator, EnablePaymentsStackNavigator, FlagCommentStackNavigator, diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 93d2f8fba989..c421bdc82028 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -85,6 +85,10 @@ function RightModalNavigator({navigation}: RightModalNavigatorProps) { name={SCREENS.RIGHT_MODAL.NEW_TASK} component={ModalStackNavigators.NewTaskModalStackNavigator} /> + ; +}; +type PurposeForUsingExpensifyModalProps = PurposeForUsingExpensifyModalOnyxProps; + +function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {activeWorkspaceID} = useActiveWorkspace(); + const navigation = useNavigation(); + + useEffect(() => { + const navigationState = navigation.getState(); + const routes = navigationState.routes; + const currentRoute = routes[navigationState.index]; + + if (currentRoute && currentRoute.name !== NAVIGATORS.BOTTOM_TAB_NAVIGATOR && currentRoute.name !== NAVIGATORS.CENTRAL_PANE_NAVIGATOR) { + return; + } + + Welcome.show(routes, () => Navigation.navigate(ROUTES.ONBOARD)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoadingApp]); + // Parent navigator of the bottom tab bar is the root navigator. const currentTabName = useNavigationState((state) => { const topmostBottomTabRoute = getTopmostBottomTabRoute(state); @@ -86,11 +110,14 @@ function BottomTabBar() { - ); } BottomTabBar.displayName = 'BottomTabBar'; -export default BottomTabBar; +export default withOnyx({ + isLoadingApp: { + key: ONYXKEYS.IS_LOADING_APP, + }, +})(BottomTabBar); diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 6f96642953af..0346dcfcf049 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -313,6 +313,13 @@ const config: LinkingOptions['config'] = { [SCREENS.NEW_TASK.DESCRIPTION]: ROUTES.NEW_TASK_DESCRIPTION, }, }, + [SCREENS.RIGHT_MODAL.ONBOARD_ENGAGEMENT]: { + screens: { + [SCREENS.ONBOARD_ENGAGEMENT.ROOT]: ROUTES.ONBOARD, + [SCREENS.ONBOARD_ENGAGEMENT.MANAGE_TEAMS_EXPENSES]: ROUTES.ONBOARD_MANAGE_EXPENSES, + [SCREENS.ONBOARD_ENGAGEMENT.EXPENSIFY_CLASSIC]: ROUTES.ONBOARD_EXPENSIFY_CLASSIC, + }, + }, [SCREENS.RIGHT_MODAL.TEACHERS_UNITE]: { screens: { [SCREENS.SAVE_THE_WORLD.ROOT]: ROUTES.TEACHERS_UNITE, @@ -404,7 +411,6 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route, [SCREENS.MONEY_REQUEST.DATE]: ROUTES.MONEY_REQUEST_DATE.route, [SCREENS.MONEY_REQUEST.CURRENCY]: ROUTES.MONEY_REQUEST_CURRENCY.route, - [SCREENS.MONEY_REQUEST.DESCRIPTION]: ROUTES.MONEY_REQUEST_DESCRIPTION.route, [SCREENS.MONEY_REQUEST.CATEGORY]: ROUTES.MONEY_REQUEST_CATEGORY.route, [SCREENS.MONEY_REQUEST.MERCHANT]: ROUTES.MONEY_REQUEST_MERCHANT.route, [SCREENS.MONEY_REQUEST.RECEIPT]: ROUTES.MONEY_REQUEST_RECEIPT.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index d544c2ffa3b6..5ef5c008e240 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -215,11 +215,12 @@ type MoneyRequestNavigatorParamList = { field: string; threadReportID: string; }; - [SCREENS.MONEY_REQUEST.DESCRIPTION]: { - iouType: string; + [SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: { + action: ValueOf; + iouType: ValueOf; + transactionID: string; reportID: string; - field: string; - threadReportID: string; + backTo: string; }; [SCREENS.MONEY_REQUEST.CATEGORY]: { iouType: string; @@ -278,6 +279,12 @@ type NewTaskNavigatorParamList = { [SCREENS.NEW_TASK.DESCRIPTION]: undefined; }; +type OnboardEngagementNavigatorParamList = { + [SCREENS.ONBOARD_ENGAGEMENT.ROOT]: undefined; + [SCREENS.ONBOARD_ENGAGEMENT.MANAGE_TEAMS_EXPENSES]: undefined; + [SCREENS.ONBOARD_ENGAGEMENT.EXPENSIFY_CLASSIC]: undefined; +}; + type TeachersUniteNavigatorParamList = { [SCREENS.SAVE_THE_WORLD.ROOT]: undefined; [SCREENS.I_KNOW_A_TEACHER]: undefined; @@ -374,6 +381,7 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.ROOM_INVITE]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.MONEY_REQUEST]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.NEW_TASK]: NavigatorScreenParams; + [SCREENS.RIGHT_MODAL.ONBOARD_ENGAGEMENT]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.TEACHERS_UNITE]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.TASK_DETAILS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.ENABLE_PAYMENTS]: NavigatorScreenParams; @@ -522,5 +530,6 @@ export type { ReimbursementAccountNavigatorParamList, State, WorkspaceSwitcherNavigatorParamList, + OnboardEngagementNavigatorParamList, SwitchPolicyIDParams, }; diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts index f31a1aa811a0..36479136c6ad 100644 --- a/src/libs/ReceiptUtils.ts +++ b/src/libs/ReceiptUtils.ts @@ -11,6 +11,7 @@ import ROUTES from '@src/ROUTES'; import type {Transaction} from '@src/types/onyx'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import * as FileUtils from './fileDownload/FileUtils'; +import * as TransactionUtils from './TransactionUtils'; type ThumbnailAndImageURI = { image: ImageSourcePropType | string; @@ -33,7 +34,7 @@ type FileNameAndExtension = { * @param receiptFileName */ function getThumbnailAndImageURIs(transaction: OnyxEntry, receiptPath: string | null = null, receiptFileName: string | null = null): ThumbnailAndImageURI { - if (Object.hasOwn(transaction?.pendingFields ?? {}, 'waypoints')) { + if (TransactionUtils.isFetchingWaypointsFromServer(transaction)) { return {thumbnail: null, image: ReceiptGeneric, isLocalFile: true}; } // URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 64d79a3cd812..50fcbac34c96 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1096,10 +1096,9 @@ function hasSingleParticipant(report: OnyxEntry): boolean { } /** - * Checks whether all the transactions linked to the IOU report are of the Distance Request type - * + * Checks whether all the transactions linked to the IOU report are of the Distance Request type with pending routes */ -function hasOnlyDistanceRequestTransactions(iouReportID: string | undefined): boolean { +function hasOnlyTransactionsWithPendingRoutes(iouReportID: string | undefined): boolean { const transactions = TransactionUtils.getAllReportTransactions(iouReportID); // Early return false in case not having any transaction @@ -1107,7 +1106,7 @@ function hasOnlyDistanceRequestTransactions(iouReportID: string | undefined): bo return false; } - return transactions.every((transaction) => TransactionUtils.isDistanceRequest(transaction)); + return transactions.every((transaction) => TransactionUtils.isFetchingWaypointsFromServer(transaction)); } /** @@ -1945,7 +1944,7 @@ function getMoneyRequestReportName(report: OnyxEntry, policy: OnyxEntry< } const moneyRequestTotal = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; - const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report?.currency, hasOnlyDistanceRequestTransactions(report?.reportID)); + const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report?.currency); const payerOrApproverName = isExpenseReport(report) && !hasNonReimbursableTransactions(report?.reportID ?? '') ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report?.managerID) ?? ''; const payerPaidAmountMessage = Localize.translateLocal('iou.payerPaidAmount', { @@ -2186,6 +2185,11 @@ function getTransactionReportName(reportAction: OnyxEntry): string // Transaction data might be empty on app's first load, if so we fallback to Request return Localize.translateLocal('iou.request'); } + + if (TransactionUtils.isFetchingWaypointsFromServer(transaction)) { + return Localize.translateLocal('iou.routePending'); + } + if (TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction)) { return Localize.translateLocal('iou.receiptScanning'); } @@ -2197,7 +2201,7 @@ function getTransactionReportName(reportAction: OnyxEntry): string const transactionDetails = getTransactionDetails(transaction); return Localize.translateLocal(ReportActionsUtils.isSentMoneyReportAction(reportAction) ? 'iou.threadSentMoneyReportName' : 'iou.threadRequestReportName', { - formattedAmount: CurrencyUtils.convertToDisplayString(transactionDetails?.amount ?? 0, transactionDetails?.currency, TransactionUtils.isDistanceRequest(transaction)) ?? '', + formattedAmount: CurrencyUtils.convertToDisplayString(transactionDetails?.amount ?? 0, transactionDetails?.currency) ?? '', comment: (!TransactionUtils.isMerchantMissing(transaction) ? transactionDetails?.merchant : transactionDetails?.comment) ?? '', }); } @@ -2210,7 +2214,7 @@ function getTransactionReportName(reportAction: OnyxEntry): string function getReportPreviewMessage( report: OnyxEntry | EmptyObject, reportAction: OnyxEntry | EmptyObject = {}, - shouldConsiderReceiptBeingScanned = false, + shouldConsiderScanningReceiptOrPendingRoute = false, isPreviewMessageForParentChatReport = false, policy: OnyxEntry = null, isForListPreview = false, @@ -2260,7 +2264,7 @@ function getReportPreviewMessage( } let linkedTransaction; - if (!isEmptyObject(reportAction) && shouldConsiderReceiptBeingScanned && reportAction && ReportActionsUtils.isMoneyRequestAction(reportAction)) { + if (!isEmptyObject(reportAction) && shouldConsiderScanningReceiptOrPendingRoute && reportAction && ReportActionsUtils.isMoneyRequestAction(reportAction)) { linkedTransaction = TransactionUtils.getLinkedTransaction(reportAction); } @@ -2268,6 +2272,10 @@ function getReportPreviewMessage( return Localize.translateLocal('iou.receiptScanning'); } + if (!isEmptyObject(linkedTransaction) && TransactionUtils.isFetchingWaypointsFromServer(linkedTransaction) && !TransactionUtils.getAmount(linkedTransaction)) { + return Localize.translateLocal('iou.routePending'); + } + const originalMessage = reportAction?.originalMessage as IOUMessage | undefined; // Show Paid preview message if it's settled or if the amount is paid & stuck at receivers end for only chat reports. @@ -4933,7 +4941,7 @@ export { buildTransactionThread, areAllRequestsBeingSmartScanned, getTransactionsWithReceipts, - hasOnlyDistanceRequestTransactions, + hasOnlyTransactionsWithPendingRoutes, hasNonReimbursableTransactions, hasMissingSmartscanFields, getIOUReportActionDisplayMessage, diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 8a814f311481..454b85cc3152 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -262,8 +262,8 @@ function getDescription(transaction: OnyxEntry): string { /** * Return the amount field from the transaction, return the modifiedAmount if present. */ -function getAmount(transaction: OnyxEntry, isFromExpenseReport?: boolean): number { - // IOU requests cannot have negative values but they can be stored as negative values, let's return absolute value +function getAmount(transaction: OnyxEntry, isFromExpenseReport = false): number { + // IOU requests cannot have negative values, but they can be stored as negative values, let's return absolute value if (!isFromExpenseReport) { const amount = transaction?.modifiedAmount ?? 0; if (amount) { @@ -311,6 +311,13 @@ function getOriginalAmount(transaction: Transaction): number { return Math.abs(amount); } +/** + * Verify if the transaction is expecting the distance to be calculated on the server + */ +function isFetchingWaypointsFromServer(transaction: OnyxEntry): boolean { + return !!transaction?.pendingFields?.waypoints; +} + /** * Return the merchant field from the transaction, return the modifiedMerchant if present. */ @@ -577,6 +584,7 @@ export { isReceiptBeingScanned, getValidWaypoints, isDistanceRequest, + isFetchingWaypointsFromServer, isExpensifyCardTransaction, isCardTransaction, isPending, diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index dd118c36a8a1..ca417f32cf90 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -49,7 +49,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {Participant, Split} from '@src/types/onyx/IOU'; -import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; +import type {ErrorFields, Errors, PendingFields} from '@src/types/onyx/OnyxCommon'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -286,9 +286,8 @@ function setMoneyRequestOriginalCurrency_temporaryForRefactor(transactionID: str Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {originalCurrency}); } -// eslint-disable-next-line @typescript-eslint/naming-convention -function setMoneyRequestDescription_temporaryForRefactor(transactionID: string, comment: string) { - Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {comment: {comment: comment.trim()}}); +function setMoneyRequestDescription(transactionID: string, comment: string, isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {comment: {comment: comment.trim()}}); } // eslint-disable-next-line @typescript-eslint/naming-convention @@ -296,6 +295,10 @@ function setMoneyRequestMerchant_temporaryForRefactor(transactionID: string, mer Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {merchant: merchant.trim()}); } +function setMoneyRequestPendingFields(transactionID: string, pendingFields: PendingFields) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {pendingFields}); +} + // eslint-disable-next-line @typescript-eslint/naming-convention function setMoneyRequestCategory_temporaryForRefactor(transactionID: string, category: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {category}); @@ -996,7 +999,7 @@ function getUpdateMoneyRequestParams( const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.parentReportID}`] ?? null; const isFromExpenseReport = ReportUtils.isExpenseReport(iouReport); const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); - const updatedTransaction = transaction ? TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport) : null; + let updatedTransaction = transaction ? TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport) : null; const transactionDetails = ReportUtils.getTransactionDetails(updatedTransaction); if (transactionDetails?.waypoints) { @@ -1014,12 +1017,41 @@ function getUpdateMoneyRequestParams( transactionID, }; + const hasPendingWaypoints = 'waypoints' in transactionChanges; + if (transaction && updatedTransaction && hasPendingWaypoints) { + updatedTransaction = { + ...updatedTransaction, + amount: CONST.IOU.DEFAULT_AMOUNT, + modifiedAmount: CONST.IOU.DEFAULT_AMOUNT, + modifiedMerchant: Localize.translateLocal('iou.routePending'), + }; + + // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors + successData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, + value: null, + }); + + // Revert the transaction's amount to the original value on failure. + // The IOU Report will be fully reverted in the failureData further below. + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + amount: transaction.amount, + modifiedAmount: transaction.modifiedAmount, + modifiedMerchant: transaction.modifiedMerchant, + }, + }); + } + // Step 3: Build the modified expense report actions // We don't create a modified report action if we're updating the waypoints, // since there isn't actually any optimistic data we can create for them and the report action is created on the server // with the response from the MapBox API - if (!('waypoints' in transactionChanges)) { - const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, isFromExpenseReport); + const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, isFromExpenseReport); + if (!hasPendingWaypoints) { params.reportActionID = updatedReportAction.reportActionID; optimisticData.push({ @@ -1046,33 +1078,33 @@ function getUpdateMoneyRequestParams( }, }, }); + } - // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. - // Should only update if the transaction matches the currency of the report, else we wait for the update - // from the server with the currency conversion - let updatedMoneyRequestReport = {...iouReport}; - if (updatedTransaction?.currency === iouReport?.currency && updatedTransaction?.modifiedAmount) { - const diff = TransactionUtils.getAmount(transaction, true) - TransactionUtils.getAmount(updatedTransaction, true); - if (ReportUtils.isExpenseReport(iouReport) && typeof updatedMoneyRequestReport.total === 'number') { - updatedMoneyRequestReport.total += diff; - } else { - updatedMoneyRequestReport = iouReport - ? IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false) - : {}; - } - - updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedTransaction.currency); - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: updatedMoneyRequestReport, - }); - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: {pendingAction: null}, - }); + // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. + // Should only update if the transaction matches the currency of the report, else we wait for the update + // from the server with the currency conversion + let updatedMoneyRequestReport = {...iouReport}; + if ((hasPendingWaypoints || updatedTransaction?.modifiedAmount) && updatedTransaction?.currency === iouReport?.currency) { + const diff = TransactionUtils.getAmount(transaction, true) - TransactionUtils.getAmount(updatedTransaction, true); + if (ReportUtils.isExpenseReport(iouReport) && typeof updatedMoneyRequestReport.total === 'number') { + updatedMoneyRequestReport.total += diff; + } else { + updatedMoneyRequestReport = iouReport + ? IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false) + : {}; } + + updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedTransaction?.currency); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: updatedMoneyRequestReport, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {pendingAction: null}, + }); } // Optimistically modify the transaction @@ -1082,7 +1114,7 @@ function getUpdateMoneyRequestParams( value: { ...updatedTransaction, pendingFields, - isLoading: 'waypoints' in transactionChanges, + isLoading: hasPendingWaypoints, errorFields: null, }, }); @@ -1145,15 +1177,6 @@ function getUpdateMoneyRequestParams( }, }); - if ('waypoints' in transactionChanges) { - // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors - successData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - value: null, - }); - } - // Clear out loading states, pending fields, and add the error fields failureData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -1166,7 +1189,7 @@ function getUpdateMoneyRequestParams( }); if (iouReport) { - // Reset the iouReport to it's original state + // Reset the iouReport to its original state failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, @@ -3554,10 +3577,6 @@ function setMoneyRequestCurrency(currency: string) { Onyx.merge(ONYXKEYS.IOU, {currency}); } -function setMoneyRequestDescription(comment: string) { - Onyx.merge(ONYXKEYS.IOU, {comment: comment.trim()}); -} - function setMoneyRequestMerchant(merchant: string) { Onyx.merge(ONYXKEYS.IOU, {merchant: merchant.trim()}); } @@ -3698,17 +3717,17 @@ export { setMoneyRequestCategory_temporaryForRefactor, setMoneyRequestCreated_temporaryForRefactor, setMoneyRequestCurrency_temporaryForRefactor, + setMoneyRequestDescription, setMoneyRequestOriginalCurrency_temporaryForRefactor, - setMoneyRequestDescription_temporaryForRefactor, setMoneyRequestMerchant_temporaryForRefactor, setMoneyRequestParticipants_temporaryForRefactor, + setMoneyRequestPendingFields, setMoneyRequestReceipt, setMoneyRequestAmount, setMoneyRequestBillable, setMoneyRequestCategory, setMoneyRequestCreated, setMoneyRequestCurrency, - setMoneyRequestDescription, setMoneyRequestId, setMoneyRequestMerchant, setMoneyRequestParticipantsFromReport, diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 03c5d18aabb4..1d9af01f2fa0 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -66,7 +66,9 @@ function saveWaypoint(transactionID: string, index: string, waypoint: RecentWayp [`waypoint${index}`]: waypoint, }, }, - amount: CONST.IOU.DEFAULT_AMOUNT, + // We want to reset the amount only for draft transactions (when creating the request). + // When modifying an existing transaction, the amount will be updated on the actual IOU update operation. + ...(isDraft && {amount: CONST.IOU.DEFAULT_AMOUNT}), // Empty out errors when we're saving a new waypoint as this indicates the user is updating their input errorFields: { route: null, @@ -137,7 +139,9 @@ function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft: ...transaction.comment, waypoints: reIndexedWaypoints, }, - amount: CONST.IOU.DEFAULT_AMOUNT, + // We want to reset the amount only for draft transactions (when creating the request). + // When modifying an existing transaction, the amount will be updated on the actual IOU update operation. + ...(isDraft && {amount: CONST.IOU.DEFAULT_AMOUNT}), }; if (!isRemovedWaypointEmpty) { @@ -247,7 +251,9 @@ function updateWaypoints(transactionID: string, waypoints: WaypointCollection, i comment: { waypoints, }, - amount: CONST.IOU.DEFAULT_AMOUNT, + // We want to reset the amount only for draft transactions (when creating the request). + // When modifying an existing transaction, the amount will be updated on the actual IOU update operation. + ...(isDraft && {amount: CONST.IOU.DEFAULT_AMOUNT}), // Empty out errors when we're saving new waypoints as this indicates the user is updating their input errorFields: { route: null, diff --git a/src/pages/EditRequestDescriptionPage.js b/src/pages/EditRequestDescriptionPage.js deleted file mode 100644 index 7c0431d6d8c3..000000000000 --- a/src/pages/EditRequestDescriptionPage.js +++ /dev/null @@ -1,92 +0,0 @@ -import {useFocusEffect} from '@react-navigation/native'; -import PropTypes from 'prop-types'; -import React, {useCallback, useRef} from 'react'; -import {View} from 'react-native'; -import FormProvider from '@components/Form/FormProvider'; -import INPUT_IDS from '@components/Form/inputs/MoneyRequestDescriptionForm'; -import InputWrapperWithRef from '@components/Form/InputWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import TextInput from '@components/TextInput'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as Browser from '@libs/Browser'; -import updateMultilineInputRange from '@libs/updateMultilineInputRange'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -const propTypes = { - /** Transaction default description value */ - defaultDescription: PropTypes.string.isRequired, - - /** Callback to fire when the Save button is pressed */ - onSubmit: PropTypes.func.isRequired, -}; - -function EditRequestDescriptionPage({defaultDescription, onSubmit}) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const descriptionInputRef = useRef(null); - const focusTimeoutRef = useRef(null); - - useFocusEffect( - useCallback(() => { - focusTimeoutRef.current = setTimeout(() => { - if (descriptionInputRef.current) { - descriptionInputRef.current.focus(); - } - return () => { - if (!focusTimeoutRef.current) { - return; - } - clearTimeout(focusTimeoutRef.current); - }; - }, CONST.ANIMATED_TRANSITION); - }, []), - ); - - return ( - - - - - { - if (!el) { - return; - } - descriptionInputRef.current = el; - updateMultilineInputRange(descriptionInputRef.current); - }} - autoGrowHeight - containerStyles={[styles.autoGrowHeightMultilineInput]} - submitOnEnter={!Browser.isMobile()} - /> - - - - ); -} - -EditRequestDescriptionPage.propTypes = propTypes; -EditRequestDescriptionPage.displayName = 'EditRequestDescriptionPage'; - -export default EditRequestDescriptionPage; diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 3eb9d88f1120..7e1a9f7d9b7b 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -22,7 +22,6 @@ import ROUTES from '@src/ROUTES'; import EditRequestAmountPage from './EditRequestAmountPage'; import EditRequestCategoryPage from './EditRequestCategoryPage'; import EditRequestCreatedPage from './EditRequestCreatedPage'; -import EditRequestDescriptionPage from './EditRequestDescriptionPage'; import EditRequestDistancePage from './EditRequestDistancePage'; import EditRequestMerchantPage from './EditRequestMerchantPage'; import EditRequestReceiptPage from './EditRequestReceiptPage'; @@ -74,7 +73,6 @@ function EditRequestPage({report, route, policyCategories, policyTags, parentRep const { amount: transactionAmount, currency: transactionCurrency, - comment: transactionDescription, merchant: transactionMerchant, category: transactionCategory, tag: transactionTag, @@ -180,26 +178,6 @@ function EditRequestPage({report, route, policyCategories, policyTags, parentRep [transactionCategory, transaction.transactionID, report.reportID], ); - const saveComment = useCallback( - ({comment: newComment}) => { - // Only update comment if it has changed - if (newComment.trim() !== transactionDescription) { - IOU.updateMoneyRequestDescription(transaction.transactionID, report.reportID, newComment.trim()); - } - Navigation.dismissModal(); - }, - [transactionDescription, transaction.transactionID, report.reportID], - ); - - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DESCRIPTION) { - return ( - - ); - } - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DATE) { return ( { - setDraftSplitTransaction({ - comment: transactionChanges.comment.trim(), - }); - }} - /> - ); - } - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DATE) { return ( { - User.dismissReferralBanner(referralContentType); - }; - const {inputCallbackRef} = useAutoFocusInput(); return ( @@ -276,7 +271,6 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i shouldShowConfirmButton shouldShowReferralCTA={!dismissedReferralBanners[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]} referralContentType={CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT} - onCallToActionClosed={dismissCallToAction} confirmButtonText={selectedOptions.length > 1 ? translate('newChatPage.createGroup') : translate('newChatPage.createChat')} textInputAlert={isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''} onConfirmSelection={createGroup} diff --git a/src/pages/OnboardEngagement/ExpensifyClassicPage.tsx b/src/pages/OnboardEngagement/ExpensifyClassicPage.tsx new file mode 100644 index 000000000000..ad704156435d --- /dev/null +++ b/src/pages/OnboardEngagement/ExpensifyClassicPage.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; +import HeaderPageLayout from '@components/HeaderPageLayout'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import Navigation from '@libs/Navigation/Navigation'; +import variables from '@styles/variables'; +import * as Link from '@userActions/Link'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +function ExpensifyClassicModal() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isExtraSmallScreenHeight} = useWindowDimensions(); + const theme = useTheme(); + + const navigateBack = () => { + Navigation.goBack(ROUTES.ONBOARD_MANAGE_EXPENSES); + }; + + const navigateToOldDot = () => { + Link.openOldDotLink(`${CONST.OLDDOT_URLS.INBOX}${CONST.OLDDOT_URLS.DISMMISSED_REASON}`); + }; + + return ( + + } + footer={ +