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__/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 6ed326f9dee1..3f51c89336ff 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 1001043800 - versionName "1.4.38-0" + versionCode 1001043804 + versionName "1.4.38-4" } 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/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index 543b133fe62b..5bb6dfb85851 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -30,7 +30,7 @@ When creating RHP flows, you have to remember a couple things: - Since you can deeplink to different pages inside the RHP navigator, it is important to provide the possibility for the user to properly navigate back from any page with UP press (`HeaderWithBackButton` component). -- An example can be deeplinking to `/settings/profile/personal-details`. From there, when pressing the UP button, you should navigate to `/settings/profile`, so in order for it to work, you should provide the correct route in `onBackButtonPress` prop of `HeaderWithBackButton` (`Navigation.goBack(ROUTES.SETTINGS_PROFILE)` in this example). +- An example can be deeplinking to `/settings/profile/timezone/select`. From there, when pressing the UP button, you should navigate to `/settings/profile/timezone`, so in order for it to work, you should provide the correct route in `onBackButtonPress` prop of `HeaderWithBackButton` (`Navigation.goBack(ROUTES.SETTINGS_PROFILE)` in this example). - We use a custom `goBack` function to handle the browser and the `react-navigation` history stack. Under the hood, it resolves to either replacing the current screen with the one we navigate to (deeplinking scenario) or just going back if we reached the current page by navigating in App (pops the screen). It ensures the requested behaviors on web, which is navigating back to the place from where you deeplinked when going into the RHP flow by it. 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/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 5b280de0c195..46e6b0532d1c 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.38.0 + 1.4.38.4 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 8d17d0c0c572..2662db1192e3 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.4.38.0 + 1.4.38.4 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 7af26a2c647b..62a6b29e21ec 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 1.4.38 CFBundleVersion - 1.4.38.0 + 1.4.38.4 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 1825fa649ae9..9e2b54b28092 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "new.expensify", - "version": "1.4.38-0", + "version": "1.4.38-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.38-0", + "version": "1.4.38-4", "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 4a8a7ec5612a..37aed56f2f5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.38-0", + "version": "1.4.38-4", "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..e3a78cbff39d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -125,13 +125,12 @@ const ROUTES = { route: 'settings/wallet/card/:domain/activate', getRoute: (domain: string) => `settings/wallet/card/${domain}/activate` as const, }, - SETTINGS_PERSONAL_DETAILS: 'settings/profile/personal-details', - SETTINGS_PERSONAL_DETAILS_LEGAL_NAME: 'settings/profile/personal-details/legal-name', - SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH: 'settings/profile/personal-details/date-of-birth', - SETTINGS_PERSONAL_DETAILS_ADDRESS: 'settings/profile/personal-details/address', - SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY: { - route: 'settings/profile/personal-details/address/country', - getRoute: (country: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/personal-details/address/country?country=${country}`, backTo), + SETTINGS_LEGAL_NAME: 'settings/profile/legal-name', + SETTINGS_DATE_OF_BIRTH: 'settings/profile/date-of-birth', + SETTINGS_ADDRESS: 'settings/profile/address', + SETTINGS_ADDRESS_COUNTRY: { + route: 'settings/profile/address/country', + getRoute: (country: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/address/country?country=${country}`, backTo), }, SETTINGS_CONTACT_METHODS: { route: 'settings/profile/contact-methods', @@ -281,10 +280,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 +342,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 +408,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..cd80937a3864 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -49,14 +49,10 @@ const SCREENS = { PRONOUNS: 'Settings_Pronouns', TIMEZONE: 'Settings_Timezone', TIMEZONE_SELECT: 'Settings_Timezone_Select', - - PERSONAL_DETAILS: { - INITIAL: 'Settings_PersonalDetails_Initial', - LEGAL_NAME: 'Settings_PersonalDetails_LegalName', - DATE_OF_BIRTH: 'Settings_PersonalDetails_DateOfBirth', - ADDRESS: 'Settings_PersonalDetails_Address', - ADDRESS_COUNTRY: 'Settings_PersonalDetails_Address_Country', - }, + LEGAL_NAME: 'Settings_LegalName', + DATE_OF_BIRTH: 'Settings_DateOfBirth', + ADDRESS: 'Settings_Address', + ADDRESS_COUNTRY: 'Settings_Address_Country', }, PREFERENCES: { @@ -104,6 +100,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 +147,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 +226,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/Composer/index.tsx b/src/components/Composer/index.tsx index 516de55c73ba..a5c9e8952905 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -1,3 +1,4 @@ +import {MarkdownTextInput} from '@expensify/react-native-live-markdown'; import type {BaseSyntheticEvent, ForwardedRef} from 'react'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {flushSync} from 'react-dom'; @@ -5,10 +6,10 @@ import {flushSync} from 'react-dom'; import type {DimensionValue, NativeSyntheticEvent, Text as RNText, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native'; import {StyleSheet, View} from 'react-native'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; -import RNTextInput from '@components/RNTextInput'; import Text from '@components/Text'; import useHtmlPaste from '@hooks/useHtmlPaste'; import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible'; +import useMarkdownStyle from '@hooks/useMarkdownStyle'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -80,6 +81,7 @@ function Composer( const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const markdownStyle = useMarkdownStyle(); const {windowWidth} = useWindowDimensions(); const textRef = useRef(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/CountrySelector.tsx b/src/components/CountrySelector.tsx index 589530cd7879..50a789638c94 100644 --- a/src/components/CountrySelector.tsx +++ b/src/components/CountrySelector.tsx @@ -47,7 +47,7 @@ function CountrySelector({errorText = '', value: countryCode, onInputChange}: Co description={translate('common.country')} onPress={() => { const activeRoute = Navigation.getActiveRouteWithoutParams(); - Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute)); + Navigation.navigate(ROUTES.SETTINGS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute)); }} /> 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/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/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/ReceiptEmptyState.tsx b/src/components/ReceiptEmptyState.tsx index c846bf01a934..9884e97a3fa0 100644 --- a/src/components/ReceiptEmptyState.tsx +++ b/src/components/ReceiptEmptyState.tsx @@ -28,7 +28,7 @@ function ReceiptEmptyState({hasError = false, onPress = () => {}}: 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/components/Section/index.tsx b/src/components/Section/index.tsx index 58e89d5bff76..7737927e5307 100644 --- a/src/components/Section/index.tsx +++ b/src/components/Section/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Lottie from '@components/Lottie'; @@ -42,8 +42,8 @@ type SectionProps = ChildrenProps & { /** Customize the Section container */ containerStyles?: StyleProp; - /** Customize the Section container */ - titleStyles?: StyleProp; + /** Customize the Section title */ + titleStyles?: StyleProp; /** Customize the Section container */ subtitleStyles?: StyleProp; @@ -114,9 +114,9 @@ function Section({ )} - + - {title} + {title} {cardLayout === CARD_LAYOUT.ICON_ON_RIGHT && ( {!!subtitle && ( - + {subtitle} )} diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx index 058def7a34ad..cb84ab849b1f 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -182,7 +182,7 @@ function SettlementButton({ // Put the preferred payment method to the front of the array, so it's shown as default if (paymentMethod) { - return buttonOptions.sort((method) => (method.value === paymentMethod ? 0 : 1)); + return buttonOptions.sort((method) => (method.value === paymentMethod ? -1 : 0)); } return buttonOptions; // We don't want to reorder the options when the preferred payment method changes while the button is still visible diff --git a/src/hooks/usePrivatePersonalDetails.ts b/src/hooks/usePrivatePersonalDetails.ts index 89d9951cef11..f17600e9878f 100644 --- a/src/hooks/usePrivatePersonalDetails.ts +++ b/src/hooks/usePrivatePersonalDetails.ts @@ -15,6 +15,6 @@ export default function usePrivatePersonalDetails() { return; } - PersonalDetails.openPersonalDetailsPage(); + PersonalDetails.openPersonalDetails(); }, [network?.isOffline]); } 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..0781bb6b19f3 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…', @@ -712,6 +713,14 @@ export default { offline: 'Offline', syncing: 'Syncing', profileAvatar: 'Profile avatar', + publicSection: { + title: 'Public', + subtitle: 'These details are displayed on your public profile, available for people to see.', + }, + privateSection: { + title: 'Private', + subtitle: 'These details are used for travel and payments. They are never shown on your public profile.', + }, }, loungeAccessPage: { loungeAccess: 'Lounge access', @@ -2111,6 +2120,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..f31bc4c367fc 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…', @@ -706,6 +707,14 @@ export default { offline: 'Desconectado', syncing: 'Sincronizando', profileAvatar: 'Perfil avatar', + publicSection: { + title: 'Público', + subtitle: 'Estos detalles se muestran en tu perfil público, a disposición de los demás.', + }, + privateSection: { + title: 'Privada', + subtitle: 'Estos detalles se utilizan para viajes y pagos. Nunca se mostrarán en tu perfil público.', + }, }, loungeAccessPage: { loungeAccess: 'Acceso a la sala vip', @@ -2598,6 +2607,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/API/types.ts b/src/libs/API/types.ts index a4ab3db9a7cd..4b383bacddaa 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -287,7 +287,7 @@ const READ_COMMANDS = { OPEN_WORKSPACE_VIEW: 'OpenWorkspaceView', GET_MAPBOX_ACCESS_TOKEN: 'GetMapboxAccessToken', OPEN_PAYMENTS_PAGE: 'OpenPaymentsPage', - OPEN_PERSONAL_DETAILS_PAGE: 'OpenPersonalDetailsPage', + OPEN_PERSONAL_DETAILS: 'OpenPersonalDetailsPage', OPEN_PUBLIC_PROFILE_PAGE: 'OpenPublicProfilePage', OPEN_PLAID_BANK_LOGIN: 'OpenPlaidBankLogin', OPEN_PLAID_BANK_ACCOUNT_SELECTOR: 'OpenPlaidBankAccountSelector', @@ -321,7 +321,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_WORKSPACE_VIEW]: EmptyObject; [READ_COMMANDS.GET_MAPBOX_ACCESS_TOKEN]: EmptyObject; [READ_COMMANDS.OPEN_PAYMENTS_PAGE]: EmptyObject; - [READ_COMMANDS.OPEN_PERSONAL_DETAILS_PAGE]: EmptyObject; + [READ_COMMANDS.OPEN_PERSONAL_DETAILS]: EmptyObject; [READ_COMMANDS.OPEN_PUBLIC_PROFILE_PAGE]: Parameters.OpenPublicProfilePageParams; [READ_COMMANDS.OPEN_PLAID_BANK_LOGIN]: Parameters.OpenPlaidBankLoginParams; [READ_COMMANDS.OPEN_PLAID_BANK_ACCOUNT_SELECTOR]: Parameters.OpenPlaidBankAccountSelectorParams; 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..c7be135e8b57 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, @@ -202,11 +208,10 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Profile/DisplayNamePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.TIMEZONE]: () => require('../../../pages/settings/Profile/TimezoneInitialPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT]: () => require('../../../pages/settings/Profile/TimezoneSelectPage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.INITIAL]: () => require('../../../pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.LEGAL_NAME]: () => require('../../../pages/settings/Profile/PersonalDetails/LegalNamePage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.DATE_OF_BIRTH]: () => require('../../../pages/settings/Profile/PersonalDetails/DateOfBirthPage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS]: () => require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS_COUNTRY]: () => require('../../../pages/settings/Profile/PersonalDetails/CountrySelectionPage').default as React.ComponentType, + [SCREENS.SETTINGS.PROFILE.LEGAL_NAME]: () => require('../../../pages/settings/Profile/PersonalDetails/LegalNamePage').default as React.ComponentType, + [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: () => require('../../../pages/settings/Profile/PersonalDetails/DateOfBirthPage').default as React.ComponentType, + [SCREENS.SETTINGS.PROFILE.ADDRESS]: () => require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default as React.ComponentType, + [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: () => require('../../../pages/settings/Profile/PersonalDetails/CountrySelectionPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: () => require('../../../pages/settings/Profile/Contacts/ContactMethodsPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: () => require('../../../pages/settings/Profile/Contacts/ContactMethodDetailsPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: () => require('../../../pages/settings/Profile/Contacts/NewContactMethodPage').default as React.ComponentType, @@ -292,6 +297,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/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index 64174c7ab724..3dcffae95861 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -14,11 +14,10 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.SETTINGS.PROFILE.PRONOUNS, SCREENS.SETTINGS.PROFILE.TIMEZONE, SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.INITIAL, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.LEGAL_NAME, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.DATE_OF_BIRTH, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS_COUNTRY, + SCREENS.SETTINGS.PROFILE.LEGAL_NAME, + SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH, + SCREENS.SETTINGS.PROFILE.ADDRESS, + SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY, ], [SCREENS.SETTINGS.PREFERENCES.ROOT]: [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE, SCREENS.SETTINGS.PREFERENCES.LANGUAGE, SCREENS.SETTINGS.PREFERENCES.THEME], [SCREENS.SETTINGS.WALLET.ROOT]: [ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 6f96642953af..12577e360784 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -190,24 +190,20 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_NEW_CONTACT_METHOD.route, exact: true, }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.INITIAL]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS, + [SCREENS.SETTINGS.PROFILE.LEGAL_NAME]: { + path: ROUTES.SETTINGS_LEGAL_NAME, exact: true, }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.LEGAL_NAME]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS_LEGAL_NAME, + [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: { + path: ROUTES.SETTINGS_DATE_OF_BIRTH, exact: true, }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.DATE_OF_BIRTH]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH, + [SCREENS.SETTINGS.PROFILE.ADDRESS]: { + path: ROUTES.SETTINGS_ADDRESS, exact: true, }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS, - exact: true, - }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS_COUNTRY]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY.route, + [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: { + path: ROUTES.SETTINGS_ADDRESS_COUNTRY.route, exact: true, }, [SCREENS.SETTINGS.TWO_FACTOR_AUTH]: { @@ -313,6 +309,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 +407,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..d3df217c1342 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -88,11 +88,10 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: undefined; [SCREENS.SETTINGS.PROFILE.TIMEZONE]: undefined; [SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT]: undefined; - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.INITIAL]: undefined; - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.LEGAL_NAME]: undefined; - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.DATE_OF_BIRTH]: undefined; - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS]: undefined; - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS_COUNTRY]: undefined; + [SCREENS.SETTINGS.PROFILE.LEGAL_NAME]: undefined; + [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: undefined; + [SCREENS.SETTINGS.PROFILE.ADDRESS]: undefined; + [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: undefined; [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: undefined; [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: undefined; [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: undefined; @@ -215,11 +214,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 +278,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 +380,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 +529,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/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 26c8937de3aa..53491b386b8c 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -112,7 +112,7 @@ function updateLegalName(legalFirstName: string, legalLastName: string) { ], }); - Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); + Navigation.goBack(); } /** @@ -133,7 +133,7 @@ function updateDateOfBirth({dob}: DateOfBirthForm) { ], }); - Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); + Navigation.goBack(); } function updateAddress(street: string, street2: string, city: string, state: string, zip: string, country: string) { @@ -170,7 +170,7 @@ function updateAddress(street: string, street2: string, city: string, state: str ], }); - Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); + Navigation.goBack(); } /** @@ -241,7 +241,7 @@ function updateSelectedTimezone(selectedTimezone: SelectedTimezone) { /** * Fetches additional personal data like legal name, date of birth, address */ -function openPersonalDetailsPage() { +function openPersonalDetails() { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -272,7 +272,7 @@ function openPersonalDetailsPage() { }, ]; - API.read(READ_COMMANDS.OPEN_PERSONAL_DETAILS_PAGE, {}, {optimisticData, successData, failureData}); + API.read(READ_COMMANDS.OPEN_PERSONAL_DETAILS, {}, {optimisticData, successData, failureData}); } /** @@ -455,7 +455,7 @@ export { clearAvatarErrors, deleteAvatar, getPrivatePersonalDetails, - openPersonalDetailsPage, + openPersonalDetails, openPublicProfilePage, updateAddress, updateAutomaticTimezone, 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 9b2a9e465746..000000000000 --- a/src/pages/EditRequestDescriptionPage.js +++ /dev/null @@ -1,91 +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 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 ( ; +}; - /** Route params */ - route: PropTypes.shape({ - params: PropTypes.shape({ - /** Report ID passed via route r/:reportID/:reportActionID */ - reportID: PropTypes.string, +type FlagCommentPageNavigationProps = StackScreenProps; - /** ReportActionID passed via route r/:reportID/:reportActionID */ - reportActionID: PropTypes.string, - }), - }).isRequired, +type FlagCommentPageProps = FlagCommentPageNavigationProps & WithReportAndReportActionOrNotFoundProps & FlagCommentPageWithOnyxProps; - ...withLocalizePropTypes, +type Severity = ValueOf; - /* Onyx Props */ - /** All the report actions from the parent report */ - parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), +type SeverityItem = { + severity: Severity; + name: string; + icon: React.FC; + description: string; + furtherDetails: string; + furtherDetailsIcon: React.FC; }; -const defaultProps = { - reportActions: {}, - parentReportActions: {}, - report: {}, -}; +type SeverityItemList = SeverityItem[]; /** * Get the reportID for the associated chatReport - * - * @param {Object} route - * @param {Object} route.params - * @param {String} route.params.reportID - * @returns {String} */ -function getReportID(route) { +function getReportID(route: FlagCommentPageNavigationProps['route']) { return route.params.reportID.toString(); } -function FlagCommentPage(props) { +function FlagCommentPage({parentReportActions, route, report, reportActions}: FlagCommentPageProps) { const styles = useThemeStyles(); - const severities = [ + const {translate} = useLocalize(); + + const severities: SeverityItemList = [ { severity: CONST.MODERATION.FLAG_SEVERITY_SPAM, - name: props.translate('moderation.spam'), + name: translate('moderation.spam'), icon: Expensicons.FlagLevelOne, - description: props.translate('moderation.spamDescription'), - furtherDetails: props.translate('moderation.levelOneResult'), + description: translate('moderation.spamDescription'), + furtherDetails: translate('moderation.levelOneResult'), furtherDetailsIcon: Expensicons.FlagLevelOne, }, { severity: CONST.MODERATION.FLAG_SEVERITY_INCONSIDERATE, - name: props.translate('moderation.inconsiderate'), + name: translate('moderation.inconsiderate'), icon: Expensicons.FlagLevelOne, - description: props.translate('moderation.inconsiderateDescription'), - furtherDetails: props.translate('moderation.levelOneResult'), + description: translate('moderation.inconsiderateDescription'), + furtherDetails: translate('moderation.levelOneResult'), furtherDetailsIcon: Expensicons.FlagLevelOne, }, { severity: CONST.MODERATION.FLAG_SEVERITY_INTIMIDATION, - name: props.translate('moderation.intimidation'), + name: translate('moderation.intimidation'), icon: Expensicons.FlagLevelTwo, - description: props.translate('moderation.intimidationDescription'), - furtherDetails: props.translate('moderation.levelTwoResult'), + description: translate('moderation.intimidationDescription'), + furtherDetails: translate('moderation.levelTwoResult'), furtherDetailsIcon: Expensicons.FlagLevelTwo, }, { severity: CONST.MODERATION.FLAG_SEVERITY_BULLYING, - name: props.translate('moderation.bullying'), + name: translate('moderation.bullying'), icon: Expensicons.FlagLevelTwo, - description: props.translate('moderation.bullyingDescription'), - furtherDetails: props.translate('moderation.levelTwoResult'), + description: translate('moderation.bullyingDescription'), + furtherDetails: translate('moderation.levelTwoResult'), furtherDetailsIcon: Expensicons.FlagLevelTwo, }, { severity: CONST.MODERATION.FLAG_SEVERITY_HARASSMENT, - name: props.translate('moderation.harassment'), + name: translate('moderation.harassment'), icon: Expensicons.FlagLevelThree, - description: props.translate('moderation.harassmentDescription'), - furtherDetails: props.translate('moderation.levelThreeResult'), + description: translate('moderation.harassmentDescription'), + furtherDetails: translate('moderation.levelThreeResult'), furtherDetailsIcon: Expensicons.FlagLevelThree, }, { severity: CONST.MODERATION.FLAG_SEVERITY_ASSAULT, - name: props.translate('moderation.assault'), + name: translate('moderation.assault'), icon: Expensicons.FlagLevelThree, - description: props.translate('moderation.assaultDescription'), - furtherDetails: props.translate('moderation.levelThreeResult'), + description: translate('moderation.assaultDescription'), + furtherDetails: translate('moderation.levelThreeResult'), furtherDetailsIcon: Expensicons.FlagLevelThree, }, ]; - const getActionToFlag = useCallback(() => { - let reportAction = props.reportActions[`${props.route.params.reportActionID.toString()}`]; + const getActionToFlag = useCallback((): OnyxTypes.ReportAction | null => { + let reportAction = reportActions?.[`${route.params.reportActionID.toString()}`]; // Handle threads if needed - if (reportAction === undefined || reportAction.reportActionID === undefined) { - reportAction = props.parentReportActions[props.report.parentReportActionID] || {}; + if (reportAction?.reportActionID === undefined) { + reportAction = parentReportActions?.[report?.parentReportActionID ?? '']; + } + + if (!reportAction) { + return null; } return reportAction; - }, [props.report, props.reportActions, props.route.params.reportActionID, props.parentReportActions]); + }, [report, reportActions, route.params.reportActionID, parentReportActions]); - const flagComment = (severity) => { - let reportID = getReportID(props.route); + const flagComment = (severity: Severity) => { + let reportID: string | undefined = getReportID(route); const reportAction = getActionToFlag(); - const parentReportAction = props.parentReportActions[props.report.parentReportActionID] || {}; + const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '']; // Handle threads if needed - if (ReportUtils.isChatThread(props.report) && reportAction.reportActionID === parentReportAction.reportActionID) { - reportID = ReportUtils.getParentReport(props.report).reportID; + if (ReportUtils.isChatThread(report) && reportAction?.reportActionID === parentReportAction?.reportActionID) { + reportID = ReportUtils.getParentReport(report)?.reportID; } - if (ReportUtils.canFlagReportAction(reportAction, reportID)) { - Report.flagComment(reportID, reportAction, severity); + if (reportAction && ReportUtils.canFlagReportAction(reportAction, reportID)) { + Report.flagComment(reportID ?? '', reportAction, severity); } Navigation.dismissModal(); }; - const severityMenuItems = _.map(severities, (item, index) => ( + const severityMenuItems = severities.map((item) => ( {({safeAreaPaddingBottomStyle}) => ( - + { Navigation.goBack(); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.report.reportID)); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report?.reportID ?? '')); }} /> - {props.translate('moderation.flagDescription')} + {translate('moderation.flagDescription')} - {props.translate('moderation.chooseAReason')} + {translate('moderation.chooseAReason')} {severityMenuItems} @@ -193,17 +188,13 @@ function FlagCommentPage(props) { ); } -FlagCommentPage.propTypes = propTypes; -FlagCommentPage.defaultProps = defaultProps; FlagCommentPage.displayName = 'FlagCommentPage'; -export default compose( - withLocalize, - withReportAndReportActionOrNotFound, - withOnyx({ +export default withReportAndReportActionOrNotFound( + withOnyx({ parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID || report.reportID}`, + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID ?? report?.reportID}`, canEvict: false, }, - }), -)(FlagCommentPage); + })(FlagCommentPage), +); diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index 44131de01fa6..71f8797f1a34 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -21,7 +21,6 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; import variables from '@styles/variables'; import * as Report from '@userActions/Report'; -import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import personalDetailsPropType from './personalDetailsPropType'; @@ -235,10 +234,6 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i updateOptions(); }, [didScreenTransitionEnd, updateOptions]); - const dismissCallToAction = (referralContentType) => { - 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={ +