diff --git a/README.md b/README.md index 400260393bc1..7019567c7acb 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ These instructions should get you set up ready to work on New Expensify 🙌 1. Install `nvm` then `node` & `npm`: `brew install nvm && nvm install` 2. Install `watchman`: `brew install watchman` 3. Install dependencies: `npm install` -4. Install `mkcert`: `brew install mkcert` followed by `npm run setup-https`. If you are not using macOS, follow the instructions [here](https://github.com/FiloSottile/mkcert?tab=readme-ov-file#installation). +4. Install `mkcert`: `brew install mkcert` followed by `npm run setup-https`. If you are not using macOS, follow the instructions [here](https://github.com/FiloSottile/mkcert?tab=readme-ov-file#installation). 5. Create a host entry in your local hosts file, `/etc/hosts` for dev.new.expensify.com pointing to localhost: ``` 127.0.0.1 dev.new.expensify.com @@ -86,7 +86,7 @@ If you want to run the app on an actual physical iOS device, please follow the i 1. If you are having issues with **_Getting Started_**, please reference [React Native's Documentation](https://reactnative.dev/docs/environment-setup) 2. If you are running into CORS errors like (in the browser dev console) ```sh - Access to fetch at 'https://www.expensify.com/api?command=BeginSignIn' from origin 'http://localhost:8080' has been blocked by CORS policy + Access to fetch at 'https://www.expensify.com/api/BeginSignIn' from origin 'http://localhost:8080' has been blocked by CORS policy ``` You probably have a misconfigured `.env` file - remove it (`rm .env`) and try again @@ -113,7 +113,7 @@ variables referenced here get updated since your local `.env` file is ignored. see [PERFORMANCE.md](contributingGuides/PERFORMANCE.md#performance-metrics-opt-in-on-local-release-builds) for more information - `ONYX_METRICS` (optional) - Set this to `true` to capture even more performance metrics and see them in Flipper see [React-Native-Onyx#benchmarks](https://github.com/Expensify/react-native-onyx#benchmarks) for more information -- `E2E_TESTING` (optional) - This needs to be set to `true` when running the e2e tests for performance regression testing. +- `E2E_TESTING` (optional) - This needs to be set to `true` when running the e2e tests for performance regression testing. This happens usually automatically, read [this](tests/e2e/README.md) for more information ---- @@ -127,7 +127,7 @@ You create this certificate by following the instructions in [`Configuring HTTPS #### Pre-requisite for Android flow 1. Open any emulator using Android Studio 2. Use `adb push "$(mkcert -CAROOT)/rootCA.pem" /storage/emulated/0/Download/` to push certificate to install in Download folder. -3. Install the certificate as CA certificate from the settings. On the Android emulator, this option can be found in Settings > Security > Encryption & Credentials > Install a certificate > CA certificate. +3. Install the certificate as CA certificate from the settings. On the Android emulator, this option can be found in Settings > Security > Encryption & Credentials > Install a certificate > CA certificate. 4. Close the emulator. Note - If you want to run app on `https://127.0.0.1:8082`, then just install the certificate and use `adb reverse tcp:8082 tcp:8082` on every startup. @@ -196,7 +196,7 @@ Often, performance issue debugging occurs in debug builds, which can introduce e ### Getting Started with Source Maps To accurately profile your application, generating source maps for Android and iOS is crucial. Here's how to enable them: -1. Enable source maps on Android +1. Enable source maps on Android Ensure the following is set in your app's `android/app/build.gradle` file. ```jsx @@ -205,13 +205,13 @@ Ensure the following is set in your app's `android/app/build.gradle` file. hermesFlagsRelease: ["-O", "-output-source-map"], // <-- here, plus whichever flag was required to set this away from default ] ``` - -2. Enable source maps on IOS + +2. Enable source maps on IOS Within Xcode head to the build phase - `Bundle React Native code and images`. - + ```jsx export SOURCEMAP_FILE="$(pwd)/../main.jsbundle.map" // <-- here; - + export NODE_BINARY=node ../node_modules/react-native/scripts/react-native-xcode.sh ``` @@ -221,8 +221,8 @@ Within Xcode head to the build phase - `Bundle React Native code and images`. ``` 7. Depending on the platform you are targeting, run your Android/iOS app in production mode. 8. Upon completion, the generated source map can be found at: - Android: `android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map` - IOS: `main.jsbundle.map` + Android: `android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map` + IOS: `main.jsbundle.map` ### Recording a Trace: 1. Ensure you have generated the source map as outlined above. @@ -253,7 +253,7 @@ Build info: 4. Use the following commands to symbolicate the trace for Android and iOS, respectively: Android: `npm run symbolicate-release:android` -IOS: `npm run symbolicate-release:ios` +IOS: `npm run symbolicate-release:ios` 5. A new file named `Profile_trace_for_-converted.json` will appear in your project's root folder. 6. Open this file in your tool of choice: - SpeedScope ([https://www.speedscope.app](https://www.speedscope.app/)) @@ -482,8 +482,8 @@ Updated rules for managing members across all types of chats in New Expensify. - Members can't leave or be removed from the #announce room - Admins can't leave or be removed from #admins - Domain members can't leave or be removed from their domain chat - - Report submitters can't leave or be removed from their reports - - Report managers can't leave or be removed from their reports + - Report submitters can't leave or be removed from their reports + - Report managers can't leave or be removed from their reports - Group owners cannot be removed from their groups - they need to transfer ownership first - **Excepting the above, admins can remove anyone. For example:** - Group admins can remove other group admins, as well as group members @@ -494,17 +494,17 @@ Updated rules for managing members across all types of chats in New Expensify. 1. ### DM | | Member - | :---: | :---: - | **Invite** | ❌ - | **Remove** | ❌ - | **Leave** | ❌ + | :---: | :---: + | **Invite** | ❌ + | **Remove** | ❌ + | **Leave** | ❌ | **Can be removed** | ❌ - DM always has two participants. None of the participant can leave or be removed from the DM. Also no additional member can be invited to the chat. 2. ### Workspace 1. #### Workspace | | Creator | Member(Employee/User) | Admin | Auditor? - | :---: | :---: | :---: | :---: | :---: + | :---: | :---: | :---: | :---: | :---: | **Invite** | ✅ | ❌ | ✅ | ❌ | **Remove** | ✅ | ❌ | ✅ | ❌ | **Leave** | ❌ | ✅ | ❌ | ✅ @@ -518,7 +518,7 @@ Updated rules for managing members across all types of chats in New Expensify. 2. #### Workspace #announce room | | Member(Employee/User) | Admin | Auditor? - | :---: | :---: | :---: | :---: + | :---: | :---: | :---: | :---: | **Invite** | ❌ | ❌ | ❌ | **Remove** | ❌ | ❌ | ❌ | **Leave** | ❌ | ❌ | ❌ @@ -528,14 +528,14 @@ Updated rules for managing members across all types of chats in New Expensify. 3. #### Workspace #admin room | | Admin | - | :---: | :---: - | **Invite** | ❌ - | **Remove** | ❌ - | **Leave** | ❌ + | :---: | :---: + | **Invite** | ❌ + | **Remove** | ❌ + | **Leave** | ❌ | **Can be removed** | ❌ - Admins can't leave or be removed from #admins - + 4. #### Workspace rooms | | Creator | Member | Guest(outside of the workspace) | :---: | :---: | :---: | :---: @@ -548,10 +548,10 @@ Updated rules for managing members across all types of chats in New Expensify. - Guests are not able to remove anyone from the room 4. #### Workspace chats - | | Admin | Member(default) | Member(invited) + | | Admin | Member(default) | Member(invited) | :---: | :---: | :---: | :---: | **Invite** | ✅ | ✅ | ❌ - | **Remove** | ✅ | ✅ | ❌ + | **Remove** | ✅ | ✅ | ❌ | **Leave** | ❌ | ❌ | ✅ | **Can be removed** | ❌ | ❌ | ✅ @@ -563,16 +563,16 @@ Updated rules for managing members across all types of chats in New Expensify. 3. ### Domain chat | | Member - | :---: | :---: - | **Remove** | ❌ - | **Leave** | ❌ - | **Can be removed** | ❌ + | :---: | :---: + | **Remove** | ❌ + | **Leave** | ❌ + | **Can be removed** | ❌ - Domain members can't leave or be removed from their domain chat 4. ### Reports | | Submitter | Manager - | :---: | :---: | :---: + | :---: | :---: | :---: | **Remove** | ❌ | ❌ | **Leave** | ❌ | ❌ | **Can be removed** | ❌ | ❌ diff --git a/android/app/build.gradle b/android/app/build.gradle index c30938a6da5c..d687ccfb0cc3 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 1001045401 - versionName "1.4.54-1" + versionCode 1001045500 + versionName "1.4.55-0" } flavorDimensions "default" diff --git a/docs/articles/expensify-classic/expenses/Add-expenses-in-bulk.md b/docs/articles/expensify-classic/expenses/Add-expenses-in-bulk.md new file mode 100644 index 000000000000..6ee84e1ead15 --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Add-expenses-in-bulk.md @@ -0,0 +1,44 @@ +--- +title: Add expenses in bulk +description: Add multiple expenses at one time +--- +
+ +You can upload bulk receipt images or add receipt details in bulk. + +# SmartScan receipt images in bulk + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click the **Expenses** tab. +2. Drag and drop up to 10 images or PDF receipts at once from your computer’s files. You can drop them anywhere on the Expense page where you see a green plus icon next to your mouse cursor. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Open the mobile app and tap the camera icon in the bottom right corner. +2. Tap the camera icon in the right corner to select the Rapid Fire mode. +3. Take a clear photo of each receipt. +4. When all receipts are captured, tap the X in the left corner to close the camera. +{% include end-option.html %} + +{% include end-selector.html %} + +# Manually add receipt details in bulk + +*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* + +1. Click the **Expenses** tab. +2. Click **New Expense** and select **Create Multiple**. +3. Enter the expense details for up to 10 expenses and click **Save**. + +# Upload personal expenses via CSV, XLS, etc. + +*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* + +1. Hover over Settings, then click **Account**. +2. Click the **Credit Card Import** tab. +3. Under Personal Cards, click **Import Transactions from File**. +4. Click **Upload** and select a .csv, .xls, .ofx, or a .qfx file. + +
diff --git a/docs/articles/expensify-classic/expenses/Split-an-expense.md b/docs/articles/expensify-classic/expenses/Split-an-expense.md new file mode 100644 index 000000000000..997d81ef0729 --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Split-an-expense.md @@ -0,0 +1,49 @@ +--- +title: Split an expense +description: Divide expenses on a receipt into separate expenses +--- +
+ +You can break down a receipt into multiple expenses by splitting it. Each split expense is treated as an individual expense which can be categorized and tagged separately. The same receipt image will be attached to all of the split expenses. + +{% include info.html %} +Splitting an expense cannot be undone. +{% include end-info.html %} + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click the **Expenses** tab. +2. Click the expense you want to split. +3. Scroll down and click **Split Expense** in the bottom left corner. +4. Use the buttons in the bottom left corner to select how to split the expenses. + - Add additional splits by clicking **Add Split**. + - Split the expense for multiple days by clicking **Split by Days**. This option is good for expenses like hotel stays. + - Split the expense evenly by clicking **Split Even**. This option also works if you add more than two splits. + +{% include info.html %} +Each split must have a value greater than $0.00 and all splits must add up to the total expense amount. +{% include end-info.html %} + +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap the **Expenses** tab. +2. Tap the expense you want to split. +3. Scroll down to the bottom and tap **More Options**. +4. Tap **Split**. +5. Tap each split to add the expense details including the total and category, then click **Save**. To add any additional splits, click **Add Split**. + +{% include info.html %} +Each split must have a value greater than $0.00 and all splits must add up to the total expense amount. +{% include end-info.html %} + +6. Once all splits have been added and adjusted, click **Save**. + +{% include end-option.html %} + +{% include end-selector.html %} + + + +
diff --git a/docs/articles/expensify-classic/expenses/Track-group-expenses.md b/docs/articles/expensify-classic/expenses/Track-group-expenses.md new file mode 100644 index 000000000000..82921b0e8cd3 --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Track-group-expenses.md @@ -0,0 +1,41 @@ +--- +title: Track group expenses +description: Use Attendee Tracking to track group expenses +--- +
+ +Capture group and event expenses with Attendee Tracking by documenting who attended and the cost per attendee. The amount is always divided evenly between all attendees—different amounts cannot be allocated to specific attendees. To divide the amounts differently, you’ll first have to split the expense. + +{% include info.html %} +Attendees added to an expense will not be notified that they were added to an expense, nor will they share in the expense or be requested to pay for any portion of the expense. +{% include end-info.html %} + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click the **Expenses** tab. +2. Click the expense you want to add attendees to. +3. Click the attendees field and enter the name or email address of the attendee. + - If the attendee is a member of your workspace, you can select their name from the list. + - If the attendee is not a member of your workspace, enter their full name or email address and press Enter on your keyboard to add them as a new attendee. +4. Click **Save**. + +Once added, you’ll also see the list of attendees in the expense overview on the Expenses tab. To see the cost per employee, hover over the receipt total. These details are also available on any report that you add the expense to. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap the **Expenses** tab. +2. Tap the expense you want to add attendees to. +3. Scroll down to the bottom and tap **More Options**. +4. Tap the attendees field and enter the name or email address of the attendee. + - If the attendee is a member of your workspace, you can select their name from the list. + - If the attendee is not a member of your workspace, enter their full name or email address and press Enter on your keyboard to add them as a new attendee. +5. Tap **Save**. + +Attendees will also be listed on any report that you add the expense to. + +{% include end-option.html %} + +{% include end-selector.html %} + +
diff --git a/docs/articles/expensify-classic/expenses/expenses/Merge-Expenses.md b/docs/articles/expensify-classic/expenses/expenses/Merge-Expenses.md deleted file mode 100644 index a26146536e42..000000000000 --- a/docs/articles/expensify-classic/expenses/expenses/Merge-Expenses.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Merge Expenses -description: This article shows you all the ways that you can merge your expenses in Expensify! ---- - - -# About -The merge expense function helps combine two separate expenses into one. This is useful when the same expense has been accidentally entered more than once, or if you have a connected credit card and an imported expense didn’t automatically merge with a manual entry. - -# How-to merge expenses -It’s important to note that merging expenses doesn't add the two values together. Instead, merging them combines both expenses to create a single, consolidated expense. - -Keep in mind: -1. Merging expenses cannot be undone. -2. You can only merge two expenses at a time. -3. You can merge a cash expense with a credit card expense, or two cash expenses - but not two credit card expenses. -4. In order to merge, both expenses will need to be in a Personal or Draft status. - -# How to merge expenses on the web app -To merge two expenses from the Expenses page: -1. Sign into your Expensify account. -2. Navigate to the Expenses page on the left-hand navigation. -3. Click the checkboxes next to the two expenses you wish to merge. -4. Click **Merge**. -5. You'll be able to choose which aspect of each of the two expenses you would like to be used on the resulting expense, such as the receipt image, card, merchant, category, and more. - -To merge two expenses from the Reports page: -1. Sign into your Expensify account. -2. Navigate to the Reports page on the left-hand navigation. -3. Click the Report that contains the expenses that you wish to merge. -4. Click on the **Details** tab, then the Pencil icon. -5. Select the two expenses that you wish to merge. -6. You'll be able to choose which aspect of each of the two expenses you would like to be used on the resulting expense, such as the receipt image, card, merchant, category, and more. - -# How to merge expenses on the Expensify mobile app -On the mobile app, merging is prompted when you see the message _"Potential duplicate expense detected"_. Simply tap **Resolve Now** to take a closer look, then hit **Merge Expense**, and you're done! - -If the expenses exist on two different reports, you will be asked which report you'd like the newly created single expense to be reported onto. - -{% include faq-begin.md %} - -## Can you merge expenses across different reports? - -You cannot merge expenses across different reports. Expenses will only merge if they are on the same report. If you have expenses across different reports that you wish to merge, you’ll need to move both expenses onto the same report (and ensure they are in the Draft status) in order to merge them. - -## Can you merge expenses across different accounts? - -You cannot merge expenses across two separate accounts. You will need to choose one submitter and transfer the expense information to that user's account in order to merge the expense. - -## Can you merge expenses with different currencies? - -Yes, you can merge expenses with different currencies. The conversion amount will be based on the daily exchange rate for the date of the transaction, as long as the converted rates are within +/- 5%. If the currencies are the same, then the amounts must be an exact match to merge. - -## Can Expensify automatically merge a cash expense with a credit card expense? - -Yes, Expensify can merge a cash expense with a credit card expense. A receipt will need to be SmartScanned via the app or forwarded to [receipts@expensify.com](mailto:receipts@expensify.com) in order to merge with a card expense. Note that the SmartScan must be fully completed and not stopped or edited, otherwise the two won’t merge. - -## It doesn’t look like my cash and card expenses merged properly. What are some troubleshooting tips? -First, check the expense types - you can only merge a SmartScanned receipt (which will initially show with a cash icon) with a card transaction imported from a bank or via CSV. - -If the card expense in your Expensify account is older than the receipt you're trying to merge it with, they won't merge, and if the receipt is dated more than 7 days prior to the card expense, then they also will not merge. - -If you have any expenses that are more than 90 days old from the date they were incurred (not the date they were imported to Expensify), Expensify will not automatically merge them. This safeguard helps prevent the merging of very old expenses that might not align with recent transactions or receipts. - -Lastly, transactions imported with the Expensify API (via the Expense Importer) will not automatically merge with SmartScanned transactions. diff --git a/docs/articles/expensify-classic/expenses/expenses/Merge-expenses.md b/docs/articles/expensify-classic/expenses/expenses/Merge-expenses.md new file mode 100644 index 000000000000..8b1f573a64b4 --- /dev/null +++ b/docs/articles/expensify-classic/expenses/expenses/Merge-expenses.md @@ -0,0 +1,57 @@ +--- +title: Merge expenses +description: Combine two expenses into one +--- + +Combine two separate expenses by merging them into one single, consolidated expense. + +{% include info.html %} +Merging expenses cannot be undone, and you cannot merge two credit card expenses. +{% include end-info.html %} + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +To merge expenses from the Expenses tab, + +1. Click the **Expenses** tab. +2. Select the checkbox next to the two expenses you wish to merge. +3. Click **Merge**. +4. Choose which details to use from each of the expenses, such as the receipt image, card, merchant, category, etc. + +To merge expenses from the Reports tab, + +1. Click the **Reports** tab. +2. Click the Report that contains the expenses that you wish to merge. +3. Click the **Details** tab, then the Edit icon. +4. Select the two expenses that you wish to merge. +5. You’ll be able to choose which details to use from each of the two expenses, such as the receipt image, card, merchant, category, etc. +{% include end-option.html %} + +{% include option.html value="mobile" %} +On the mobile app, you’ll be notified of duplicate expenses and can click **Resolve Now** to review them. To merge the two expenses into one expense, tap **Merge Expense**. + +If the expenses exist on two different reports, you will be asked which report you’d like the newly created single expense to be reported onto. +{% include end-option.html %} + +{% include end-selector.html %} + +# FAQs + +**Why can’t I merge expenses that are on my submitted report?** +You cannot merge expenses that are on reports that have already been submitted. + +**Can I merge expenses that are under different accounts?** +No, you cannot merge expenses across two separate accounts. + +**Can you merge expenses with different currencies?** +Yes, you can merge expenses with different currencies. The conversion amount will be based on the daily exchange rate for the date of the transaction, as long as the converted rates are within +/- 5%. If the currencies are the same, then the amounts must be an exact match to merge. + +**Can Expensify automatically merge a cash expense with a credit card expense?** +Yes, Expensify merges a cash expense with a credit card expense if the receipt is SmartScanned or forwarded to receipts@expensify.com. However, these expenses will not merge if: +- The card expense added to your Expensify account is older than the receipt you’re trying to merge it with +- The receipt is dated older than 7 days of the card expense date +- Either expense date (the date the Expense was incurred, not the date it was added into Expensify) is older than 90 days +- The transaction was imported with the Expensify API + +However, if a receipt does not automatically merge with the card entry, you can complete this process manually. diff --git a/docs/articles/expensify-classic/expenses/expenses/Upload-Receipts.md b/docs/articles/expensify-classic/expenses/expenses/Upload-Receipts.md deleted file mode 100644 index b0e3ee1b9ade..000000000000 --- a/docs/articles/expensify-classic/expenses/expenses/Upload-Receipts.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: Upload-Receipts.md -description: This article shows you all the ways that you can upload your receipts to Expensify! ---- - - -# About -Need to get paid? Check out this guide to see all the ways that you can upload your receipts to Expensify - whether it’s by SmartScanning them by forwarding via email or manually by taking a picture of a receipt, we’ll cover it here! - -# How-to Upload Receipts -## SmartScan -The easiest way to upload your receipts to Expensify is to SmartScan them with Expensify’s mobile app or forward a receipt from your email inbox! - -When you SmartScan a receipt, we’ll read the Merchant, Date and Amount of the transaction, create an expense, and add it to your Expensify account automatically. The best practice is to take a picture of the receipt at the time of purchase or forward it to your Expensify account from the point of sale system. If you have a credit card connected and you upload a receipt that matches a card expense, the SmartScanned receipt will automatically merge with the imported card expense instead. - -## Email Receipts -To SmartScan a receipt on your mobile app, tap the green camera button, point and shoot! You can also forward your digital receipts (or photos of receipts) to receipts@expensify.com from the email address associated with your Expensify account, and they’ll be SmartScanned. This may take a few minutes because Expensify aims to have the most accurate OCR. - -## Manually Upload -To upload receipts on the web, simply navigate to the Expenses page and click on **New Expense**. Select **Scan Receipt** and choose the file you would like to upload, or drag-and-drop your image directly into the Expenses page, and that will start the SmartScanning process! - -{% include faq-begin.md %} -## How do you SmartScan multiple receipts? -You can utilize the Rapid Fire Mode to quickly SmartScan multiple receipts at once! - -To activate it, tap on the green camera button in the mobile app and then tap on the camera icon on the bottom right. When you see the little fire icon on the camera, Rapid Fire Mode has been activated - tap the camera icon again to disable Rapid Fire Mode. - -## How do you create an expense from an email address that is different from your Expensify login? -You can email a receipt from a different email address by adding it as a Secondary Login to your Expensify account - this ensures that any receipts sent from this email to receipts@expensify.com will be associated with your current Expensify account. - -Once that email address has been added as a Secondary Login, simply forward your receipt image or emails to receipts@expensify.com. - -## How do you crop or rotate a receipt image? -You can crop and rotate a receipt image on the web app, and you can only edit one expense at a time. - -Navigate to your Expenses page and locate the expense whose receipt image you'd like to edit, then click the expense to open the Edit screen. If there is an image file associated with the receipt, you will see the Rotate and Crop buttons. Alternatively, you can also navigate to your Reports page, click on a report, and locate the individual expense. - -{% include faq-end.md %} diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg index 2c5350cec2aa..c9b8286cf50f 100644 Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ diff --git a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg index bae3cd9f3e21..18fbfec9390f 100644 Binary files a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg and b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index eb51f4499583..1ea2673b92ee 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.54 + 1.4.55 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.54.1 + 1.4.55.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index faf2eea9f738..cc6c5cf4c86a 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.54 + 1.4.55 CFBundleSignature ???? CFBundleVersion - 1.4.54.1 + 1.4.55.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 8abab817dfb1..7c90acab4958 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.54 + 1.4.55 CFBundleVersion - 1.4.54.1 + 1.4.55.0 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index ba15feeacae6..61d6a27821cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.54-1", + "version": "1.4.55-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.54-1", + "version": "1.4.55-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -51,7 +51,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#7bfd55f0ce75a37423119029fde58cfbe57086d9", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#615f4a8662cd1abea9fdeee4d04847197c5e36ae", "expo": "^50.0.3", "expo-av": "~13.10.4", "expo-image": "1.10.1", @@ -232,6 +232,7 @@ "shellcheck": "^1.1.0", "style-loader": "^2.0.0", "time-analytics-webpack-plugin": "^0.1.17", + "ts-jest": "^29.1.2", "ts-node": "^10.9.2", "type-fest": "^4.10.2", "typescript": "^5.3.2", @@ -6400,22 +6401,31 @@ } }, "node_modules/@jest/types": { - "version": "26.6.2", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { + "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", - "@types/yargs": "^15.0.0", + "@types/yargs": "^17.0.8", "chalk": "^4.0.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/types/node_modules/ansi-styles": { "version": "4.3.0", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -6428,7 +6438,10 @@ }, "node_modules/@jest/types/node_modules/chalk": { "version": "4.1.2", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -6442,7 +6455,10 @@ }, "node_modules/@jest/types/node_modules/color-convert": { "version": "2.0.1", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -6452,18 +6468,27 @@ }, "node_modules/@jest/types/node_modules/color-name": { "version": "1.1.4", - "license": "MIT" + "dev": true, + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@jest/types/node_modules/has-flag": { "version": "4.0.0", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=8" } }, "node_modules/@jest/types/node_modules/supports-color": { "version": "7.2.0", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -8361,6 +8386,29 @@ "ws": "^7.5.1" } }, + "node_modules/@react-native-community/cli-server-api/node_modules/@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": ">= 10.14.2" + } + }, + "node_modules/@react-native-community/cli-server-api/node_modules/@types/yargs": { + "version": "15.0.19", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.19.tgz", + "integrity": "sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA==", + "dependencies": { + "@types/yargs-parser": "*" + } + }, "node_modules/@react-native-community/cli-server-api/node_modules/ansi-regex": { "version": "5.0.1", "license": "MIT", @@ -8381,6 +8429,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/@react-native-community/cli-server-api/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/@react-native-community/cli-server-api/node_modules/color-convert": { "version": "2.0.1", "license": "MIT", @@ -8395,6 +8458,14 @@ "version": "1.1.4", "license": "MIT" }, + "node_modules/@react-native-community/cli-server-api/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/@react-native-community/cli-server-api/node_modules/pretty-format": { "version": "26.6.2", "license": "MIT", @@ -8412,6 +8483,17 @@ "version": "17.0.2", "license": "MIT" }, + "node_modules/@react-native-community/cli-server-api/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@react-native-community/cli-server-api/node_modules/ws": { "version": "7.5.9", "license": "MIT", @@ -18837,14 +18919,21 @@ } }, "node_modules/@types/yargs": { - "version": "15.0.15", + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", + "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/yargs-parser": "*" } }, "node_modules/@types/yargs-parser": { - "version": "21.0.0", + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT" }, "node_modules/@types/yauzl": { @@ -22184,6 +22273,18 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "license": "Apache-2.0", @@ -27269,8 +27370,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#7bfd55f0ce75a37423119029fde58cfbe57086d9", - "integrity": "sha512-v6UnN9yAW6p2996Fvd4AZnMRnisVfjg6ijWzUQue/6JsjSY+MW10oP74hSjD6x32fRrNmMctjy6d5a79bQFdPA==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#615f4a8662cd1abea9fdeee4d04847197c5e36ae", + "integrity": "sha512-k/SmW3EBR+gxFkJP/59LJsmBKjnKR07XS30yk/GkQ0lIfyYkNmFJ0dWm/S/54ezFweezR7MDaQ3zGc45Mb/U5A==", "license": "MIT", "dependencies": { "classnames": "2.4.0", @@ -34443,6 +34544,12 @@ "license": "MIT", "peer": true }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "dev": true, @@ -39531,6 +39638,29 @@ "node": ">=8" } }, + "node_modules/react-native/node_modules/@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": ">= 10.14.2" + } + }, + "node_modules/react-native/node_modules/@types/yargs": { + "version": "15.0.19", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.19.tgz", + "integrity": "sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA==", + "dependencies": { + "@types/yargs-parser": "*" + } + }, "node_modules/react-native/node_modules/ansi-regex": { "version": "5.0.1", "license": "MIT", @@ -39551,6 +39681,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/react-native/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/react-native/node_modules/color-convert": { "version": "2.0.1", "license": "MIT", @@ -39577,6 +39722,14 @@ "node": ">=18" } }, + "node_modules/react-native/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/react-native/node_modules/mkdirp": { "version": "0.5.6", "license": "MIT", @@ -39625,6 +39778,17 @@ "loose-envify": "^1.1.0" } }, + "node_modules/react-native/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/react-native/node_modules/ws": { "version": "6.2.2", "license": "MIT", @@ -43820,6 +43984,58 @@ "version": "0.1.13", "license": "Apache-2.0" }, + "node_modules/ts-jest": { + "version": "29.1.2", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.2.tgz", + "integrity": "sha512-br6GJoH/WUX4pu7FbZXuWGKGNDuU7b8Uj77g/Sp7puZV6EXzuByl6JrECvm0MzVzSTkSHWTihsXt+5XYER5b+g==", + "dev": true, + "dependencies": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^29.0.0", + "json5": "^2.2.3", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "^7.5.3", + "yargs-parser": "^21.0.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/ts-node": { "version": "10.9.2", "devOptional": true, diff --git a/package.json b/package.json index d1974b99b91e..92a6b9cde5e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.54-1", + "version": "1.4.55-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -19,7 +19,7 @@ "ipad-sm": "concurrently \"npx react-native run-ios --simulator=\\\"iPad Pro (11-inch) (4th generation)\\\" --mode=\\\"DebugDevelopment\\\" --scheme=\\\"New Expensify Dev\\\"\"", "start": "npx react-native start", "web": "scripts/set-pusher-suffix.sh && concurrently npm:web-proxy npm:web-server", - "web-proxy": "ts-node web/proxy.js", + "web-proxy": "ts-node web/proxy.ts", "web-server": "webpack-dev-server --open --config config/webpack/webpack.dev.js", "build": "webpack --config config/webpack/webpack.common.js --env envFile=.env.production", "build-staging": "webpack --config config/webpack/webpack.common.js --env envFile=.env.staging", @@ -56,7 +56,7 @@ "test:e2e:dev": "ts-node tests/e2e/testRunner.ts --config ./config.dev.ts", "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", "workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh", - "workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.js", + "workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.ts", "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1", "e2e-test-runner-build": "ncc build tests/e2e/testRunner.ts -o tests/e2e/dist/" }, @@ -102,7 +102,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#7bfd55f0ce75a37423119029fde58cfbe57086d9", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#615f4a8662cd1abea9fdeee4d04847197c5e36ae", "expo": "^50.0.3", "expo-av": "~13.10.4", "expo-image": "1.10.1", @@ -283,6 +283,7 @@ "shellcheck": "^1.1.0", "style-loader": "^2.0.0", "time-analytics-webpack-plugin": "^0.1.17", + "ts-jest": "^29.1.2", "ts-node": "^10.9.2", "type-fest": "^4.10.2", "typescript": "^5.3.2", diff --git a/src/App.tsx b/src/App.tsx index 0e247d5faa53..a2d353a026af 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import CustomStatusBarAndBackground from './components/CustomStatusBarAndBackgro import CustomStatusBarAndBackgroundContextProvider from './components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContextProvider'; import ErrorBoundary from './components/ErrorBoundary'; import HTMLEngineProvider from './components/HTMLEngineProvider'; +import InitialURLContextProvider from './components/InitialURLContextProvider'; import {LocaleContextProvider} from './components/LocaleContextProvider'; import OnyxProvider from './components/OnyxProvider'; import PopoverContextProvider from './components/PopoverProvider'; @@ -30,12 +31,11 @@ import {WindowDimensionsProvider} from './components/withWindowDimensions'; import Expensify from './Expensify'; import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop'; import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; -import InitialUrlContext from './libs/InitialUrlContext'; import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext'; import type {Route} from './ROUTES'; type AppProps = { - /** If we have an authToken this is true */ + /** URL passed to our top-level React Native component by HybridApp. Will always be undefined in "pure" NewDot builds. */ url?: Route; }; @@ -52,7 +52,7 @@ function App({url}: AppProps) { useDefaultDragAndDrop(); OnyxUpdateManager(); return ( - + - + ); } diff --git a/src/CONST.ts b/src/CONST.ts index 4ce71e024fbd..215a8129ccb7 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -513,7 +513,7 @@ const CONST = { EUR: 'EUR', }, get DIRECT_REIMBURSEMENT_CURRENCIES() { - return [this.CURRENCY.USD, this.CURRENCY.AUD, this.CURRENCY.CAD, this.CURRENCY.GBP, this.CURRENCY.NZD, this.CURRENCY.EUR]; + return [this.CURRENCY.USD, this.CURRENCY.AUD, this.CURRENCY.CAD, this.CURRENCY.GBP, this.CURRENCY.EUR]; }, EXAMPLE_PHONE_NUMBER: '+15005550006', CONCIERGE_CHAT_NAME: 'Concierge', @@ -535,6 +535,10 @@ const CONST = { TERMS_URL: `${USE_EXPENSIFY_URL}/terms`, PRIVACY_URL: `${USE_EXPENSIFY_URL}/privacy`, LICENSES_URL: `${USE_EXPENSIFY_URL}/licenses`, + ACH_TERMS_URL: `${USE_EXPENSIFY_URL}/achterms`, + WALLET_AGREEMENT_URL: `${USE_EXPENSIFY_URL}/walletagreement`, + HELP_LINK_URL: `${USE_EXPENSIFY_URL}/usa-patriot-act`, + ELECTRONIC_DISCLOSURES_URL: `${USE_EXPENSIFY_URL}/esignagreement`, GITHUB_RELEASE_URL: 'https://api.github.com/repos/expensify/app/releases/latest', ADD_SECONDARY_LOGIN_URL: encodeURI('settings?param={"section":"account","openModal":"secondaryLogin"}'), MANAGE_CARDS_URL: 'domain_companycards', @@ -575,6 +579,21 @@ const CONST = { REPORT: 'report', PERSONAL_DETAIL: 'personalDetail', }, + + QUICK_ACTIONS: { + REQUEST_MANUAL: 'requestManual', + REQUEST_SCAN: 'requestScan', + REQUEST_DISTANCE: 'requestDistance', + SPLIT_MANUAL: 'splitManual', + SPLIT_SCAN: 'splitScan', + SPLIT_DISTANCE: 'splitDistance', + TRACK_MANUAL: 'trackManual', + TRACK_SCAN: 'trackScan', + TRACK_DISTANCE: 'trackDistance', + ASSIGN_TASK: 'assignTask', + SEND_MONEY: 'sendMoney', + }, + RECEIPT: { ICON_SIZE: 164, PERMISSION_GRANTED: 'granted', @@ -1453,16 +1472,45 @@ const CONST = { MAKE_MEMBER: 'makeMember', MAKE_ADMIN: 'makeAdmin', }, + MORE_FEATURES: { + ARE_CATEGORIES_ENABLED: 'areCategoriesEnabled', + ARE_TAGS_ENABLED: 'areTagsEnabled', + ARE_DISTANCE_RATES_ENABLED: 'areDistanceRatesEnabled', + ARE_WORKFLOWS_ENABLED: 'areWorkflowsEnabled', + ARE_REPORTFIELDS_ENABLED: 'areReportFieldsEnabled', + ARE_CONNECTIONS_ENABLED: 'areConnectionsEnabled', + ARE_TAXES_ENABLED: 'tax', + }, CATEGORIES_BULK_ACTION_TYPES: { DELETE: 'delete', DISABLE: 'disable', ENABLE: 'enable', }, + TAGS_BULK_ACTION_TYPES: { + DELETE: 'delete', + DISABLE: 'disable', + ENABLE: 'enable', + }, DISTANCE_RATES_BULK_ACTION_TYPES: { DELETE: 'delete', DISABLE: 'disable', ENABLE: 'enable', }, + TAX_RATES_BULK_ACTION_TYPES: { + DELETE: 'delete', + DISABLE: 'disable', + ENABLE: 'enable', + }, + COLLECTION_KEYS: { + DESCRIPTION: 'description', + REIMBURSER_EMAIL: 'reimburserEmail', + REIMBURSEMENT_CHOICE: 'reimbursementChoice', + APPROVAL_MODE: 'approvalMode', + AUTOREPORTING: 'autoReporting', + AUTOREPORTING_FREQUENCY: 'autoReportingFrequency', + AUTOREPORTING_OFFSET: 'autoReportingOffset', + GENERAL_SETTINGS: 'generalSettings', + }, }, CUSTOM_UNITS: { @@ -1527,6 +1575,11 @@ const CONST = { STATE_SUSPENDED: 7, }, ACTIVE_STATES: cardActiveStates, + LIMIT_TYPES: { + SMART: 'smart', + MONTHLY: 'monthly', + FIXED: 'fixed', + }, }, AVATAR_ROW_SIZE: { DEFAULT: 4, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index e91b4d491423..51fec780fc9f 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -16,9 +16,6 @@ const ONYXKEYS = { /** Holds the reportID for the report between the user and their account manager */ ACCOUNT_MANAGER_REPORT_ID: 'accountManagerReportID', - /** Boolean flag only true when first set */ - NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER: 'isFirstTimeNewExpensifyUser', - /** Holds an array of client IDs which is used for multi-tabs on web in order to know * which tab is the leader, and which ones are the followers */ ACTIVE_CLIENTS: 'activeClients', @@ -106,31 +103,59 @@ const ONYXKEYS = { STASHED_SESSION: 'stashedSession', BETAS: 'betas', - /** NVP keys + /** NVP keys */ + + /** Boolean flag only true when first set */ + NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER: 'nvp_isFirstTimeNewExpensifyUser', + /** Contains the user preference for the LHN priority mode */ NVP_PRIORITY_MODE: 'nvp_priorityMode', /** Contains the users's block expiration (if they have one) */ - NVP_BLOCKED_FROM_CONCIERGE: 'private_blockedFromConcierge', + NVP_BLOCKED_FROM_CONCIERGE: 'nvp_private_blockedFromConcierge', /** A unique identifier that each user has that's used to send notifications */ - NVP_PRIVATE_PUSH_NOTIFICATION_ID: 'private_pushNotificationID', + NVP_PRIVATE_PUSH_NOTIFICATION_ID: 'nvp_private_pushNotificationID', /** The NVP with the last payment method used per policy */ - NVP_LAST_PAYMENT_METHOD: 'nvp_lastPaymentMethod', + NVP_LAST_PAYMENT_METHOD: 'nvp_private_lastPaymentMethod', /** This NVP holds to most recent waypoints that a person has used when creating a distance request */ NVP_RECENT_WAYPOINTS: 'expensify_recentWaypoints', /** This NVP will be `true` if the user has ever dismissed the engagement modal on either OldDot or NewDot. If it becomes true it should stay true forever. */ - NVP_HAS_DISMISSED_IDLE_PANEL: 'hasDismissedIdlePanel', + NVP_HAS_DISMISSED_IDLE_PANEL: 'nvp_hasDismissedIdlePanel', /** This NVP contains the choice that the user made on the engagement modal */ - NVP_INTRO_SELECTED: 'introSelected', + NVP_INTRO_SELECTED: 'nvp_introSelected', + + /** This NVP contains the active policyID */ + NVP_ACTIVE_POLICY_ID: 'nvp_expensify_activePolicyID', + + /** This NVP contains the referral banners the user dismissed */ + NVP_DISMISSED_REFERRAL_BANNERS: 'nvp_dismissedReferralBanners', + + /** Indicates which locale should be used */ + NVP_PREFERRED_LOCALE: 'nvp_preferredLocale', + + /** Whether the user has tried focus mode yet */ + NVP_TRY_FOCUS_MODE: 'nvp_tryFocusMode', + + /** Whether the user has been shown the hold educational interstitial yet */ + NVP_HOLD_USE_EXPLAINED: 'holdUseExplained', + + /** Store preferred skintone for emoji */ + PREFERRED_EMOJI_SKIN_TONE: 'nvp_expensify_preferredEmojiSkinTone', + + /** Store frequently used emojis for this user */ + FREQUENTLY_USED_EMOJIS: 'nvp_expensify_frequentlyUsedEmojis', /** The NVP with the last distance rate used per policy */ NVP_LAST_SELECTED_DISTANCE_RATES: 'lastSelectedDistanceRates', + /** The NVP with the last action taken (for the Quick Action Button) */ + NVP_QUICK_ACTION_GLOBAL_CREATE: 'nvp_quickActionGlobalCreate', + /** Does this user have push notifications enabled for this device? */ PUSH_NOTIFICATIONS_ENABLED: 'pushNotificationsEnabled', @@ -150,9 +175,6 @@ const ONYXKEYS = { ONFIDO_TOKEN: 'onfidoToken', ONFIDO_APPLICANT_ID: 'onfidoApplicantID', - /** Indicates which locale should be used */ - NVP_PREFERRED_LOCALE: 'preferredLocale', - /** User's Expensify Wallet */ USER_WALLET: 'userWallet', @@ -174,12 +196,6 @@ const ONYXKEYS = { /** The user's cash card and imported cards (including the Expensify Card) */ CARD_LIST: 'cardList', - /** Whether the user has tried focus mode yet */ - NVP_TRY_FOCUS_MODE: 'tryFocusMode', - - /** Whether the user has been shown the hold educational interstitial yet */ - NVP_HOLD_USE_EXPLAINED: 'holdUseExplained', - /** Boolean flag used to display the focus mode notification */ FOCUS_MODE_NOTIFICATION: 'focusModeNotification', @@ -192,12 +208,6 @@ const ONYXKEYS = { /** Stores information about the active reimbursement account being set up */ REIMBURSEMENT_ACCOUNT: 'reimbursementAccount', - /** Store preferred skintone for emoji */ - PREFERRED_EMOJI_SKIN_TONE: 'preferredEmojiSkinTone', - - /** Store frequently used emojis for this user */ - FREQUENTLY_USED_EMOJIS: 'frequentlyUsedEmojis', - /** Stores Workspace ID that will be tied to reimbursement account during setup */ REIMBURSEMENT_ACCOUNT_WORKSPACE_ID: 'reimbursementAccountWorkspaceID', @@ -291,7 +301,8 @@ const ONYXKEYS = { POLICY_CATEGORIES: 'policyCategories_', POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_', POLICY_TAGS: 'policyTags_', - POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_', + POLICY_RECENTLY_USED_TAGS: 'nvp_recentlyUsedTags_', + OLD_POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_', POLICY_REPORT_FIELDS: 'policyReportFields_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', @@ -332,8 +343,8 @@ const ONYXKEYS = { WORKSPACE_SETTINGS_FORM: 'workspaceSettingsForm', WORKSPACE_CATEGORY_FORM: 'workspaceCategoryForm', WORKSPACE_CATEGORY_FORM_DRAFT: 'workspaceCategoryFormDraft', - WORKSPACE_TAG_CREATE_FORM: 'workspaceTagCreate', - WORKSPACE_TAG_CREATE_FORM_DRAFT: 'workspaceTagCreateDraft', + WORKSPACE_TAG_FORM: 'workspaceTagForm', + WORKSPACE_TAG_FORM_DRAFT: 'workspaceTagFormDraft', WORKSPACE_SETTINGS_FORM_DRAFT: 'workspaceSettingsFormDraft', WORKSPACE_DESCRIPTION_FORM: 'workspaceDescriptionForm', WORKSPACE_DESCRIPTION_FORM_DRAFT: 'workspaceDescriptionFormDraft', @@ -411,10 +422,16 @@ const ONYXKEYS = { EXIT_SURVEY_REASON_FORM_DRAFT: 'exitSurveyReasonFormDraft', EXIT_SURVEY_RESPONSE_FORM: 'exitSurveyResponseForm', EXIT_SURVEY_RESPONSE_FORM_DRAFT: 'exitSurveyResponseFormDraft', + WALLET_ADDITIONAL_DETAILS: 'walletAdditionalDetails', + WALLET_ADDITIONAL_DETAILS_DRAFT: 'walletAdditionalDetailsDraft', POLICY_TAG_NAME_FORM: 'policyTagNameForm', POLICY_TAG_NAME_FORM_DRAFT: 'policyTagNameFormDraft', WORKSPACE_NEW_TAX_FORM: 'workspaceNewTaxForm', WORKSPACE_NEW_TAX_FORM_DRAFT: 'workspaceNewTaxFormDraft', + WORKSPACE_TAX_NAME_FORM: 'workspaceTaxNameForm', + WORKSPACE_TAX_NAME_FORM_DRAFT: 'workspaceTaxNameFormDraft', + WORKSPACE_TAX_VALUE_FORM: 'workspaceTaxValueForm', + WORKSPACE_TAX_VALUE_FORM_DRAFT: 'workspaceTaxValueFormDraft', }, } as const; @@ -424,7 +441,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM]: FormTypes.AddDebitCardForm; [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: FormTypes.WorkspaceSettingsForm; [ONYXKEYS.FORMS.WORKSPACE_CATEGORY_FORM]: FormTypes.WorkspaceCategoryForm; - [ONYXKEYS.FORMS.WORKSPACE_TAG_CREATE_FORM]: FormTypes.WorkspaceTagCreateForm; + [ONYXKEYS.FORMS.WORKSPACE_TAG_FORM]: FormTypes.WorkspaceTagForm; [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: FormTypes.WorkspaceRateAndUnitForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_CUSTOM_NAME]: FormTypes.WorkspaceTaxCustomName; [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm; @@ -462,9 +479,12 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: FormTypes.ReimbursementAccountForm; [ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT]: FormTypes.PersonalBankAccountForm; [ONYXKEYS.FORMS.WORKSPACE_DESCRIPTION_FORM]: FormTypes.WorkspaceDescriptionForm; + [ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS]: FormTypes.AdditionalDetailStepForm; [ONYXKEYS.FORMS.POLICY_TAG_NAME_FORM]: FormTypes.PolicyTagNameForm; [ONYXKEYS.FORMS.WORKSPACE_NEW_TAX_FORM]: FormTypes.WorkspaceNewTaxForm; [ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM]: FormTypes.PolicyCreateDistanceRateForm; + [ONYXKEYS.FORMS.WORKSPACE_TAX_NAME_FORM]: FormTypes.WorkspaceTaxNameForm; + [ONYXKEYS.FORMS.WORKSPACE_TAX_VALUE_FORM]: FormTypes.WorkspaceTaxValueForm; }; type OnyxFormDraftValuesMapping = { @@ -500,6 +520,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolations; [ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT]: OnyxTypes.Transaction; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags; + [ONYXKEYS.COLLECTION.OLD_POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags; [ONYXKEYS.COLLECTION.SELECTED_TAB]: string; [ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT]: string; [ONYXKEYS.COLLECTION.NEXT_STEP]: OnyxTypes.ReportNextStep; @@ -555,6 +576,8 @@ type OnyxValuesMapping = { [ONYXKEYS.ONFIDO_TOKEN]: string; [ONYXKEYS.ONFIDO_APPLICANT_ID]: string; [ONYXKEYS.NVP_PREFERRED_LOCALE]: OnyxTypes.Locale; + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: string; + [ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS]: OnyxTypes.DismissedReferralBanners; [ONYXKEYS.USER_WALLET]: OnyxTypes.UserWallet; [ONYXKEYS.WALLET_ONFIDO]: OnyxTypes.WalletOnfido; [ONYXKEYS.WALLET_ADDITIONAL_DETAILS]: OnyxTypes.WalletAdditionalDetails; @@ -596,6 +619,7 @@ type OnyxValuesMapping = { [ONYXKEYS.LOGS]: Record; [ONYXKEYS.SHOULD_STORE_LOGS]: boolean; [ONYXKEYS.CACHED_PDF_PATHS]: Record; + [ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE]: OnyxTypes.QuickAction; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 5769b60a8284..9ce32835c8d7 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -37,7 +37,7 @@ const ROUTES = { }, PROFILE_AVATAR: { route: 'a/:accountID/avatar', - getRoute: (accountID: string) => `a/${accountID}/avatar` as const, + getRoute: (accountID: string | number) => `a/${accountID}/avatar` as const, }, TRANSITION_BETWEEN_APPS: 'transition', @@ -576,6 +576,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/tags/edit', getRoute: (policyID: string) => `settings/workspaces/${policyID}/tags/edit` as const, }, + WORKSPACE_TAG_EDIT: { + route: 'settings/workspace/:policyID/tag/:tagName/edit', + getRoute: (policyID: string, tagName: string) => `settings/workspace/${policyID}/tag/${encodeURIComponent(tagName)}/edit` as const, + }, WORKSPACE_TAG_SETTINGS: { route: 'settings/workspaces/:policyID/tag/:tagName', getRoute: (policyID: string, tagName: string) => `settings/workspaces/${policyID}/tag/${encodeURIComponent(tagName)}` as const, @@ -612,6 +616,18 @@ const ROUTES = { route: 'settings/workspaces/:policyID/taxes/new', getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/new` as const, }, + WORKSPACE_TAX_EDIT: { + route: 'settings/workspaces/:policyID/tax/:taxID', + getRoute: (policyID: string, taxID: string) => `settings/workspaces/${policyID}/tax/${encodeURI(taxID)}` as const, + }, + WORKSPACE_TAX_NAME: { + route: 'settings/workspaces/:policyID/tax/:taxID/name', + getRoute: (policyID: string, taxID: string) => `settings/workspaces/${policyID}/tax/${encodeURI(taxID)}/name` as const, + }, + WORKSPACE_TAX_VALUE: { + route: 'settings/workspaces/:policyID/tax/:taxID/value', + getRoute: (policyID: string, taxID: string) => `settings/workspaces/${policyID}/tax/${encodeURI(taxID)}/value` as const, + }, WORKSPACE_DISTANCE_RATES: { route: 'settings/workspaces/:policyID/distance-rates', getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates` as const, @@ -620,6 +636,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/distance-rates/new', getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates/new` as const, }, + WORKSPACE_DISTANCE_RATES_SETTINGS: { + route: 'settings/workspace/:policyID/distance-rates/settings', + getRoute: (policyID: string) => `settings/workspace/${policyID}/distance-rates/settings` as const, + }, // Referral program promotion REFERRAL_DETAILS_MODAL: { route: 'referral/:contentType', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 2fbd122f9972..4d4e9ea327c6 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -215,7 +215,11 @@ const SCREENS = { TAGS: 'Workspace_Tags', TAGS_SETTINGS: 'Tags_Settings', TAGS_EDIT: 'Tags_Edit', + TAG_EDIT: 'Tag_Edit', TAXES: 'Workspace_Taxes', + TAX_EDIT: 'Workspace_Tax_Edit', + TAX_NAME: 'Workspace_Tax_Name', + TAX_VALUE: 'Workspace_Tax_Value', TAXES_SETTINGS: 'Workspace_Taxes_Settings', TAXES_SETTINGS_CUSTOM_TAX_NAME: 'Workspace_Taxes_Settings_CustomTaxName', TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT: 'Workspace_Taxes_Settings_WorkspaceCurrency', @@ -241,6 +245,7 @@ const SCREENS = { MEMBER_DETAILS_ROLE_SELECTION: 'Workspace_Member_Details_Role_Selection', DISTANCE_RATES: 'Distance_Rates', CREATE_DISTANCE_RATE: 'Create_Distance_Rate', + DISTANCE_RATES_SETTINGS: 'Distance_Rates_Settings', }, EDIT_REQUEST: { diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index a2e3f5d9948e..9901ff9243f9 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -369,7 +369,7 @@ function AddressSearch( query={query} requestUrl={{ useOnPlatform: 'all', - url: isOffline ? '' : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), + url: isOffline ? '' : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces?proxyUrl='}), }} textInputProps={{ InputComp: TextInput, diff --git a/src/components/AmountPicker/index.tsx b/src/components/AmountPicker/index.tsx index 701c75175c02..45e511f24748 100644 --- a/src/components/AmountPicker/index.tsx +++ b/src/components/AmountPicker/index.tsx @@ -25,7 +25,8 @@ function AmountPicker({value, description, title, errorText = '', onInputChange, const updateInput = (updatedValue: string) => { if (updatedValue !== value) { - onInputChange?.(updatedValue); + // We cast the updatedValue to a number and then back to a string to remove any leading zeros and separating commas + onInputChange?.(String(Number(updatedValue))); } hidePickerModal(); }; diff --git a/src/components/AvatarSkeleton.tsx b/src/components/AvatarSkeleton.tsx index a6781448c3ba..273143f76098 100644 --- a/src/components/AvatarSkeleton.tsx +++ b/src/components/AvatarSkeleton.tsx @@ -6,12 +6,12 @@ import SkeletonViewContentLoader from './SkeletonViewContentLoader'; function AvatarSkeleton() { const theme = useTheme(); - const skeletonCircleRadius = variables.componentSizeSmall / 2; + const skeletonCircleRadius = variables.sidebarAvatarSize / 2; return ( diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 798369292958..83100788761f 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -12,6 +12,8 @@ type WorkspaceMemberBulkActionType = DeepValueOf; +type WorkspaceTaxRatesBulkActionType = DeepValueOf; + type DropdownOption = { value: TValueType; text: string; @@ -73,4 +75,4 @@ type ButtonWithDropdownMenuProps = { wrapperStyle?: StyleProp; }; -export type {PaymentType, WorkspaceMemberBulkActionType, WorkspaceDistanceRatesBulkActionType, DropdownOption, ButtonWithDropdownMenuProps}; +export type {PaymentType, WorkspaceMemberBulkActionType, WorkspaceDistanceRatesBulkActionType, DropdownOption, ButtonWithDropdownMenuProps, WorkspaceTaxRatesBulkActionType}; diff --git a/src/components/CheckboxWithLabel.tsx b/src/components/CheckboxWithLabel.tsx index 2919debe9cb1..dd169576186e 100644 --- a/src/components/CheckboxWithLabel.tsx +++ b/src/components/CheckboxWithLabel.tsx @@ -108,3 +108,5 @@ function CheckboxWithLabel( CheckboxWithLabel.displayName = 'CheckboxWithLabel'; export default React.forwardRef(CheckboxWithLabel); + +export type {CheckboxWithLabelProps}; diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index 6884966fb81a..f4e5bf4834e0 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -11,14 +11,19 @@ function Image({source: propsSource, isAuthTokenRequired = false, session, ...fo * to the source. */ const source = useMemo(() => { - const authToken = session?.encryptedAuthToken ?? null; - if (isAuthTokenRequired && typeof propsSource === 'object' && 'uri' in propsSource && authToken) { - return { - ...propsSource, - headers: { - [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken, - }, - }; + if (typeof propsSource === 'object' && 'uri' in propsSource) { + if (typeof propsSource.uri === 'number') { + return propsSource.uri; + } + const authToken = session?.encryptedAuthToken ?? null; + if (isAuthTokenRequired && authToken) { + return { + ...propsSource, + headers: { + [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken, + }, + }; + } } return propsSource; // The session prop is not required, as it causes the image to reload whenever the session changes. For more information, please refer to issue #26034. diff --git a/src/components/InitialURLContextProvider.tsx b/src/components/InitialURLContextProvider.tsx new file mode 100644 index 000000000000..a3df93844ca9 --- /dev/null +++ b/src/components/InitialURLContextProvider.tsx @@ -0,0 +1,33 @@ +import React, {createContext, useEffect, useState} from 'react'; +import type {ReactNode} from 'react'; +import {Linking} from 'react-native'; +import type {Route} from '@src/ROUTES'; + +/** Initial url that will be opened when NewDot is embedded into Hybrid App. */ +const InitialURLContext = createContext(undefined); + +type InitialURLContextProviderProps = { + /** URL passed to our top-level React Native component by HybridApp. Will always be undefined in "pure" NewDot builds. */ + url?: Route; + + /** Children passed to the context provider */ + children: ReactNode; +}; + +function InitialURLContextProvider({children, url}: InitialURLContextProviderProps) { + const [initialURL, setInitialURL] = useState(url); + useEffect(() => { + if (initialURL) { + return; + } + Linking.getInitialURL().then((initURL) => { + setInitialURL(initURL as Route); + }); + }, [initialURL]); + return {children}; +} + +InitialURLContextProvider.displayName = 'InitialURLContextProvider'; + +export default InitialURLContextProvider; +export {InitialURLContext}; diff --git a/src/components/MapView/responder/index.android.ts b/src/components/MapView/responder/index.android.ts deleted file mode 100644 index a0fce71d8ef5..000000000000 --- a/src/components/MapView/responder/index.android.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {PanResponder} from 'react-native'; - -const responder = PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onPanResponderTerminationRequest: () => false, -}); - -export default responder; diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 2b8d25d5d639..4604e831a2ae 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -41,6 +41,7 @@ function BaseModal( avoidKeyboard = false, children, shouldUseCustomBackdrop = false, + onBackdropPress, }: BaseModalProps, ref: React.ForwardedRef, ) { @@ -117,7 +118,11 @@ function BaseModal( return; } - onClose(); + if (onBackdropPress) { + onBackdropPress(); + } else { + onClose(); + } }; const handleDismissModal = () => { diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index a0cdb737d448..cfa4a28c4d87 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -23,6 +23,9 @@ type BaseModalProps = Partial & { /** Callback method fired when the user requests to close the modal */ onClose: () => void; + /** Function to call when the user presses on the modal backdrop */ + onBackdropPress?: () => void; + /** State that determines whether to display the modal or not */ isVisible: boolean; diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 6b562525258d..2e8f80175b56 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -527,7 +527,7 @@ function MoneyRequestConfirmationList({ } const shouldShowSettlementButton = iouType === CONST.IOU.TYPE.SEND; - const shouldDisableButton = selectedParticipants.length === 0 || shouldDisplayMerchantError; + const shouldDisableButton = selectedParticipants.length === 0; const button = shouldShowSettlementButton ? ( ); - }, [ - isReadOnly, - iouType, - bankAccountRoute, - iouCurrencyCode, - policyID, - selectedParticipants.length, - shouldDisplayMerchantError, - confirm, - splitOrRequestOptions, - formError, - styles.ph1, - styles.mb2, - ]); + }, [isReadOnly, iouType, bankAccountRoute, iouCurrencyCode, policyID, selectedParticipants.length, confirm, splitOrRequestOptions, formError, styles.ph1, styles.mb2]); const {image: receiptImage, thumbnail: receiptThumbnail} = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : ({} as ReceiptUtils.ThumbnailAndImageURI); diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx index 7b45fd963fe7..97ef6885c80f 100644 --- a/src/components/OptionRow.tsx +++ b/src/components/OptionRow.tsx @@ -340,3 +340,5 @@ export default React.memo( prevProps.option.pendingAction === nextProps.option.pendingAction && prevProps.option.customIcon === nextProps.option.customIcon, ); + +export type {OptionRowProps}; diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 4ee070e19893..44a446b56653 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -215,4 +215,4 @@ function PopoverMenu({ PopoverMenu.displayName = 'PopoverMenu'; export default React.memo(PopoverMenu); -export type {PopoverMenuItem}; +export type {PopoverMenuItem, PopoverMenuProps}; diff --git a/src/components/ReferralProgramCTA.tsx b/src/components/ReferralProgramCTA.tsx index 6db37ce1320a..c93b75bf11ad 100644 --- a/src/components/ReferralProgramCTA.tsx +++ b/src/components/ReferralProgramCTA.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; @@ -8,7 +9,7 @@ import CONST from '@src/CONST'; import Navigation from '@src/libs/Navigation/Navigation'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {DismissedReferralBanners} from '@src/types/onyx/Account'; +import type * as OnyxTypes from '@src/types/onyx'; import Icon from './Icon'; import {Close} from './Icon/Expensicons'; import {PressableWithoutFeedback} from './Pressable'; @@ -16,7 +17,7 @@ import Text from './Text'; import Tooltip from './Tooltip'; type ReferralProgramCTAOnyxProps = { - dismissedReferralBanners: DismissedReferralBanners; + dismissedReferralBanners: OnyxEntry; }; type ReferralProgramCTAProps = ReferralProgramCTAOnyxProps & { @@ -36,7 +37,7 @@ function ReferralProgramCTA({referralContentType, dismissedReferralBanners}: Ref User.dismissReferralBanner(referralContentType); }; - if (!referralContentType || dismissedReferralBanners[referralContentType]) { + if (!referralContentType || dismissedReferralBanners?.[referralContentType]) { return null; } @@ -82,7 +83,6 @@ function ReferralProgramCTA({referralContentType, dismissedReferralBanners}: Ref export default withOnyx({ dismissedReferralBanners: { - key: ONYXKEYS.ACCOUNT, - selector: (data) => data?.dismissedReferralBanners ?? {}, + key: ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS, }, })(ReferralProgramCTA); diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index f1aa1751dd84..d183d27fefb8 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -133,7 +133,8 @@ function ReportPreview({ const hasReceipts = transactionsWithReceipts.length > 0; const isScanning = hasReceipts && areAllRequestsBeingSmartScanned; - const hasErrors = hasMissingSmartscanFields || (canUseViolations && ReportUtils.hasViolations(iouReportID, transactionViolations)); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const hasErrors = hasMissingSmartscanFields || (canUseViolations && ReportUtils.hasViolations(iouReportID, transactionViolations)) || ReportUtils.hasActionsWithErrors(iouReportID); const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3); const lastThreeReceipts = lastThreeTransactionsWithReceipts.map((transaction) => ReceiptUtils.getThumbnailAndImageURIs(transaction)); diff --git a/src/components/RoomHeaderAvatars.js b/src/components/RoomHeaderAvatars.tsx similarity index 70% rename from src/components/RoomHeaderAvatars.js rename to src/components/RoomHeaderAvatars.tsx index 64cc9ac7abf3..9298062aa6f9 100644 --- a/src/components/RoomHeaderAvatars.js +++ b/src/components/RoomHeaderAvatars.tsx @@ -1,63 +1,60 @@ -import PropTypes from 'prop-types'; import React, {memo} from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +import type {Icon} from '@src/types/onyx/OnyxCommon'; import Avatar from './Avatar'; -import avatarPropTypes from './avatarPropTypes'; import PressableWithoutFocus from './Pressable/PressableWithoutFocus'; import Text from './Text'; -const propTypes = { - icons: PropTypes.arrayOf(avatarPropTypes), - reportID: PropTypes.string, +type RoomHeaderAvatarsProps = { + icons: Icon[]; + reportID: string; }; -const defaultProps = { - icons: [], - reportID: '', -}; - -function RoomHeaderAvatars(props) { - const navigateToAvatarPage = (icon) => { +function RoomHeaderAvatars({icons, reportID}: RoomHeaderAvatarsProps) { + const navigateToAvatarPage = (icon: Icon) => { if (icon.type === CONST.ICON_TYPE_WORKSPACE) { - Navigation.navigate(ROUTES.REPORT_AVATAR.getRoute(props.reportID)); + Navigation.navigate(ROUTES.REPORT_AVATAR.getRoute(reportID)); return; } - Navigation.navigate(ROUTES.PROFILE_AVATAR.getRoute(icon.id)); + + if (icon.id) { + Navigation.navigate(ROUTES.PROFILE_AVATAR.getRoute(icon.id)); + } }; const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - if (!props.icons.length) { + + if (!icons.length) { return null; } - if (props.icons.length === 1) { + if (icons.length === 1) { return ( navigateToAvatarPage(props.icons[0])} + style={styles.noOutline} + onPress={() => navigateToAvatarPage(icons[0])} accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} - accessibilityLabel={props.icons[0].name} + accessibilityLabel={icons[0].name ?? ''} > ); } - const iconsToDisplay = props.icons.slice(0, CONST.REPORT.MAX_PREVIEW_AVATARS); + const iconsToDisplay = icons.slice(0, CONST.REPORT.MAX_PREVIEW_AVATARS); const iconStyle = [ styles.roomHeaderAvatar, @@ -68,8 +65,9 @@ function RoomHeaderAvatars(props) { return ( - {_.map(iconsToDisplay, (icon, index) => ( + {iconsToDisplay.map((icon, index) => ( @@ -77,7 +75,7 @@ function RoomHeaderAvatars(props) { style={[styles.mln4, StyleUtils.getAvatarBorderRadius(CONST.AVATAR_SIZE.LARGE_BORDERED, icon.type)]} onPress={() => navigateToAvatarPage(icon)} accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} - accessibilityLabel={icon.name} + accessibilityLabel={icon.name ?? ''} > - {index === CONST.REPORT.MAX_PREVIEW_AVATARS - 1 && props.icons.length - CONST.REPORT.MAX_PREVIEW_AVATARS !== 0 && ( + {index === CONST.REPORT.MAX_PREVIEW_AVATARS - 1 && icons.length - CONST.REPORT.MAX_PREVIEW_AVATARS !== 0 && ( <> - {`+${props.icons.length - CONST.REPORT.MAX_PREVIEW_AVATARS}`} + {`+${icons.length - CONST.REPORT.MAX_PREVIEW_AVATARS}`} )} @@ -110,8 +108,6 @@ function RoomHeaderAvatars(props) { ); } -RoomHeaderAvatars.defaultProps = defaultProps; -RoomHeaderAvatars.propTypes = propTypes; RoomHeaderAvatars.displayName = 'RoomHeaderAvatars'; export default memo(RoomHeaderAvatars); diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index 4c1f208ce11d..596951374099 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -85,7 +85,7 @@ function BaseListItem({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing disabled={isDisabled || item.isDisabledCheckbox} onPress={handleCheckboxPress} - style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled]} + style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, styles.mr3]} > {item.isSelected && ( diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index ac48b0fa08a9..421e48dfc224 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -9,6 +9,7 @@ import Button from '@components/Button'; import Checkbox from '@components/Checkbox'; import FixedFooter from '@components/FixedFooter'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; +import {PressableWithFeedback} from '@components/Pressable'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; import SectionList from '@components/SectionList'; import ShowMoreButton from '@components/ShowMoreButton'; @@ -512,18 +513,30 @@ function BaseSelectionList( ) : ( <> {!headerMessage && canSelectMultiple && shouldShowSelectAll && ( - - - {customListHeader ?? ( - - {translate('workspace.people.selectAll')} - - )} + + + + {!customListHeader && ( + e.preventDefault() : undefined} + > + {translate('workspace.people.selectAll')} + + )} + + {customListHeader} )} {!headerMessage && !canSelectMultiple && customListHeader} diff --git a/src/components/SwipeInterceptPanResponder.tsx b/src/components/SwipeInterceptPanResponder.tsx index fe1545d2f14b..6a3d14b3b24b 100644 --- a/src/components/SwipeInterceptPanResponder.tsx +++ b/src/components/SwipeInterceptPanResponder.tsx @@ -1,6 +1,7 @@ import {PanResponder} from 'react-native'; const SwipeInterceptPanResponder = PanResponder.create({ + onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, onPanResponderTerminationRequest: () => false, }); diff --git a/src/components/TimePicker/TimePicker.js b/src/components/TimePicker/TimePicker.js index 95af2bc0c1b5..4d4520fedeea 100644 --- a/src/components/TimePicker/TimePicker.js +++ b/src/components/TimePicker/TimePicker.js @@ -452,7 +452,6 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) { > { lastPressedKey.current = e.nativeEvent.key; @@ -479,7 +478,6 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) { {CONST.COLON} { lastPressedKey.current = e.nativeEvent.key; diff --git a/src/components/UnitPicker.tsx b/src/components/UnitPicker.tsx new file mode 100644 index 000000000000..a9202a348e4d --- /dev/null +++ b/src/components/UnitPicker.tsx @@ -0,0 +1,50 @@ +import Str from 'expensify-common/lib/str'; +import React, {useMemo} from 'react'; +import useLocalize from '@hooks/useLocalize'; +import {getUnitTranslationKey} from '@libs/WorkspacesSettingsUtils'; +import CONST from '@src/CONST'; +import type {Unit} from '@src/types/onyx/Policy'; +import SelectionList from './SelectionList'; +import RadioListItem from './SelectionList/RadioListItem'; + +type UnitItemType = { + value: Unit; + text: string; + keyForList: string; + isSelected: boolean; +}; + +type UnitPickerProps = { + defaultValue: Unit; + onOptionSelected: (unit: UnitItemType) => void; +}; + +const units = [CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES]; + +function UnitPicker({defaultValue, onOptionSelected}: UnitPickerProps) { + const {translate} = useLocalize(); + + const unitOptions = useMemo( + () => + units.map((unit) => ({ + value: unit as Unit, + text: Str.recapitalize(translate(getUnitTranslationKey(unit))), + keyForList: unit, + isSelected: defaultValue === unit, + })), + [defaultValue, translate], + ); + + return ( + unit.isSelected)?.keyForList} + /> + ); +} + +export default UnitPicker; + +export type {UnitItemType}; diff --git a/src/components/ValuePicker/ValueSelectorModal.tsx b/src/components/ValuePicker/ValueSelectorModal.tsx index fda077898c78..5d4ced6fe291 100644 --- a/src/components/ValuePicker/ValueSelectorModal.tsx +++ b/src/components/ValuePicker/ValueSelectorModal.tsx @@ -8,7 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {ValueSelectorModalProps} from './types'; -function ValueSelectorModal({items = [], selectedItem, label = '', isVisible, onClose, onItemSelected, shouldShowTooltips = true}: ValueSelectorModalProps) { +function ValueSelectorModal({items = [], selectedItem, label = '', isVisible, onClose, onItemSelected, shouldShowTooltips = true, onBackdropPress}: ValueSelectorModalProps) { const styles = useThemeStyles(); const sections = useMemo( @@ -24,6 +24,7 @@ function ValueSelectorModal({items = [], selectedItem, label = '', isVisible, on onModalHide={onClose} hideModalContentWhileAnimating useNativeDriver + onBackdropPress={onBackdropPress} > ); diff --git a/src/components/ValuePicker/types.ts b/src/components/ValuePicker/types.ts index 23f0d78493a8..b9c2c89948d9 100644 --- a/src/components/ValuePicker/types.ts +++ b/src/components/ValuePicker/types.ts @@ -30,6 +30,9 @@ type ValueSelectorModalProps = { /** Function to call when the user closes the modal */ onClose?: () => void; + /** Function to call when the user presses on the modal backdrop */ + onBackdropPress?: () => void; + /** Whether to show the tooltip text */ shouldShowTooltips?: boolean; }; diff --git a/src/components/VideoPlayer/BaseVideoPlayer.js b/src/components/VideoPlayer/BaseVideoPlayer.js index 92d829e9d0db..2043f5620912 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.js +++ b/src/components/VideoPlayer/BaseVideoPlayer.js @@ -15,6 +15,7 @@ import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import CONST from '@src/CONST'; import {videoPlayerDefaultProps, videoPlayerPropTypes} from './propTypes'; import shouldReplayVideo from './shouldReplayVideo'; +import * as VideoUtils from './utils'; import VideoPlayerControls from './VideoPlayerControls'; const isMobileSafari = Browser.isMobileSafari(); @@ -49,7 +50,8 @@ function BaseVideoPlayer({ const [isPlaying, setIsPlaying] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isBuffering, setIsBuffering] = useState(true); - const [sourceURL] = useState(url.includes('blob:') || url.includes('file:///') ? url : addEncryptedAuthTokenToURL(url)); + // we add "#t=0.001" at the end of the URL to skip first milisecond of the video and always be able to show proper video preview when video is paused at the beginning + const [sourceURL] = useState(VideoUtils.addSkipTimeTagToURL(url.includes('blob:') || url.includes('file:///') ? url : addEncryptedAuthTokenToURL(url), 0.001)); const [isPopoverVisible, setIsPopoverVisible] = useState(false); const [popoverAnchorPosition, setPopoverAnchorPosition] = useState({horizontal: 0, vertical: 0}); const videoPlayerRef = useRef(null); diff --git a/src/components/VideoPlayer/VideoPlayerControls/index.js b/src/components/VideoPlayer/VideoPlayerControls/index.js index 5a926123feef..262613ce0797 100644 --- a/src/components/VideoPlayer/VideoPlayerControls/index.js +++ b/src/components/VideoPlayer/VideoPlayerControls/index.js @@ -6,7 +6,7 @@ import * as Expensicons from '@components/Icon/Expensicons'; import refPropTypes from '@components/refPropTypes'; import Text from '@components/Text'; import IconButton from '@components/VideoPlayer/IconButton'; -import convertMillisecondsToTime from '@components/VideoPlayer/utils'; +import {convertMillisecondsToTime} from '@components/VideoPlayer/utils'; import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/VideoPlayer/utils.ts b/src/components/VideoPlayer/utils.ts index c13af0f874d1..8e5c0a471976 100644 --- a/src/components/VideoPlayer/utils.ts +++ b/src/components/VideoPlayer/utils.ts @@ -1,11 +1,23 @@ -// Converts milliseconds to '[hours:]minutes:seconds' format -const convertMillisecondsToTime = (milliseconds: number) => { +/** + * Converts milliseconds to '[hours:]minutes:seconds' format + */ +function convertMillisecondsToTime(milliseconds: number) { const hours = Math.floor(milliseconds / 3600000); const minutes = Math.floor((milliseconds / 60000) % 60); const seconds = Math.floor((milliseconds / 1000) % 60) .toFixed(0) .padStart(2, '0'); return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${seconds}` : `${minutes}:${seconds}`; -}; +} -export default convertMillisecondsToTime; +/** + * Adds a #t=seconds tag to the end of the URL to skip first seconds of the video + */ +function addSkipTimeTagToURL(url: string, seconds: number) { + if (url.includes('#t=')) { + return url; + } + return `${url}#t=${seconds}`; +} + +export {convertMillisecondsToTime, addSkipTimeTagToURL}; diff --git a/src/components/withCurrentUserPersonalDetails.tsx b/src/components/withCurrentUserPersonalDetails.tsx index 313bcad74f35..8431ededcb56 100644 --- a/src/components/withCurrentUserPersonalDetails.tsx +++ b/src/components/withCurrentUserPersonalDetails.tsx @@ -43,4 +43,4 @@ export default function `split ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? ` for ${comment}` : ''}`, amountEach: ({amount}: AmountEachParams) => `${amount} each`, - payerOwesAmount: ({payer, amount}: PayerOwesAmountParams) => `${payer} owes ${amount}`, + payerOwesAmount: ({payer, amount, comment}: PayerOwesAmountParams) => `${payer} owes ${amount}${comment ? ` for ${comment}` : ''}`, payerOwes: ({payer}: PayerOwesParams) => `${payer} owes: `, payerPaidAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer ? `${payer} ` : ''}paid ${amount}`, payerPaid: ({payer}: PayerPaidParams) => `${payer} paid: `, @@ -657,8 +656,8 @@ export default { `changed the distance to ${newDistanceToDisplay} (previously ${oldDistanceToDisplay}), which updated the amount to ${newAmountToDisplay} (previously ${oldAmountToDisplay})`, threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${formattedAmount} ${comment ? `for ${comment}` : 'request'}`, threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`, - tagSelection: ({tagName}: TagSelectionParams) => `Select a ${tagName} to add additional organization to your money.`, - categorySelection: 'Select a category to add additional organization to your money.', + tagSelection: 'Select a tag to better organize your spend.', + categorySelection: 'Select a category to better organize your spend.', error: { invalidCategoryLength: 'The length of the category chosen exceeds the maximum allowed (255). Please choose a different or shorten the category name first.', invalidAmount: 'Please enter a valid amount before continuing.', @@ -1072,6 +1071,14 @@ export default { }, }, }, + workflowsDelayedSubmissionPage: { + autoReportingErrorMessage: 'The delayed submission parameter could not be changed. Please try again or contact support.', + autoReportingFrequencyErrorMessage: 'The submission frequency could not be changed. Please try again or contact support.', + monthlyOffsetErrorMessage: 'The monthly frequency could not be changed. Please try again or contact support.', + }, + workflowsApprovalPage: { + genericErrorMessage: 'The approver could not be changed. Please try again or contact support.', + }, workflowsPayerPage: { title: 'Authorized payer', genericErrorMessage: 'The authorized payer could not be changed. Please try again.', @@ -1768,6 +1775,8 @@ export default { moreFeatures: 'More features', requested: 'Requested', distanceRates: 'Distance rates', + welcomeNote: ({workspaceName}: WelcomeNoteParams) => + `You have been invited to ${workspaceName || 'a workspace'}! Download the Expensify mobile app at use.expensify.com/download to start tracking your expenses.`, }, type: { free: 'Free', @@ -1846,14 +1855,20 @@ export default { requiresTag: 'Members must tag all spend', customTagName: 'Custom tag name', enableTag: 'Enable tag', + enableTags: 'Enable tags', + disableTag: 'Disable tag', + disableTags: 'Disable tags', addTag: 'Add tag', + editTag: 'Edit tag', subtitle: 'Tags add more detailed ways to classify costs.', emptyTags: { title: "You haven't created any tags", subtitle: 'Add a tag to track projects, locations, departments, and more.', }, deleteTag: 'Delete tag', + deleteTags: 'Delete tags', deleteTagConfirmation: 'Are you sure that you want to delete this tag?', + deleteTagsConfirmation: 'Are you sure that you want to delete these tags?', deleteFailureMessage: 'An error occurred while deleting the tag, please try again.', tagRequiredError: 'Tag name is required.', existingTagError: 'A tag with this name already exists.', @@ -1869,7 +1884,19 @@ export default { errors: { taxRateAlreadyExists: 'This tax name is already in use.', valuePercentageRange: 'Please enter a valid percentage between 0 and 100.', - genericFailureMessage: 'An error occurred while updating the tax rate, please try again.', + deleteFailureMessage: 'An error occurred while deleting the tax rate. Please try again or ask Concierge for help.', + updateFailureMessage: 'An error occurred while updating the tax rate. Please try again or ask Concierge for help.', + createFailureMessage: 'An error occurred while creating the tax rate. Please try again or ask Concierge for help.', + }, + deleteTaxConfirmation: 'Are you sure you want to delete this tax?', + deleteMultipleTaxConfirmation: ({taxAmount}) => `Are you sure you want to delete ${taxAmount} taxes?`, + actions: { + delete: 'Delete rate', + deleteMultiple: 'Delete rates', + disable: 'Disable rate', + disableMultiple: 'Disable rates', + enable: 'Enable rate', + enableMultiple: 'Enable rates', }, }, emptyWorkspace: { @@ -1935,8 +1962,6 @@ export default { trackDistanceRate: 'Rate', trackDistanceUnit: 'Unit', trackDistanceChooseUnit: 'Choose a default unit to track.', - kilometers: 'Kilometers', - miles: 'Miles', unlockNextDayReimbursements: 'Unlock next-day reimbursements', captureNoVBACopyBeforeEmail: 'Ask your workspace members to forward receipts to ', captureNoVBACopyAfterEmail: ' and download the Expensify App to track cash expenses on the go.', @@ -1992,8 +2017,6 @@ export default { personalMessagePrompt: 'Message', genericFailureMessage: 'An error occurred inviting the user to the workspace, please try again.', inviteNoMembersError: 'Please select at least one member to invite', - welcomeNote: ({workspaceName}: WelcomeNoteParams) => - `You have been invited to ${workspaceName || 'a workspace'}! Download the Expensify mobile app at use.expensify.com/download to start tracking your expenses.`, }, distanceRates: { oopsNotSoFast: 'Oops! Not so fast...', @@ -2011,6 +2034,8 @@ export default { status: 'Status', enabled: 'Enabled', disabled: 'Disabled', + unit: 'Unit', + defaultCategory: 'Default category', }, editor: { descriptionInputLabel: 'Description', diff --git a/src/languages/es.ts b/src/languages/es.ts index 412d3b0b73ae..a344aca58168 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -67,7 +67,6 @@ import type { SizeExceededParams, SplitAmountParams, StepCounterParams, - TagSelectionParams, TaskCreatedActionParams, TermsParams, ThreadRequestReportNameParams, @@ -621,7 +620,7 @@ export default { splitAmount: ({amount}: SplitAmountParams) => `dividir ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `dividió ${formattedAmount}${comment ? ` para ${comment}` : ''}`, amountEach: ({amount}: AmountEachParams) => `${amount} cada uno`, - payerOwesAmount: ({payer, amount}: PayerOwesAmountParams) => `${payer} debe ${amount}`, + payerOwesAmount: ({payer, amount, comment}: PayerOwesAmountParams) => `${payer} debe ${amount}${comment ? ` para ${comment}` : ''}`, payerOwes: ({payer}: PayerOwesParams) => `${payer} debe: `, payerPaidAmount: ({payer, amount}: PayerPaidAmountParams) => `${payer ? `${payer} ` : ''}pagó ${amount}`, payerPaid: ({payer}: PayerPaidParams) => `${payer} pagó: `, @@ -652,7 +651,7 @@ export default { `cambió la distancia a ${newDistanceToDisplay} (previamente ${oldDistanceToDisplay}), lo que cambió el importe a ${newAmountToDisplay} (previamente ${oldAmountToDisplay})`, threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${comment ? `${formattedAmount} para ${comment}` : `Solicitud de ${formattedAmount}`}`, threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`, - tagSelection: ({tagName}: TagSelectionParams) => `Seleccione una ${tagName} para organizar mejor tu dinero.`, + tagSelection: 'Selecciona una etiqueta para organizar mejor tu dinero.', categorySelection: 'Seleccione una categoría para organizar mejor tu dinero.', error: { invalidCategoryLength: 'El largo de la categoría escogida excede el máximo permitido (255). Por favor, escoge otra categoría o acorta la categoría primero.', @@ -1068,6 +1067,14 @@ export default { }, }, }, + workflowsDelayedSubmissionPage: { + autoReportingErrorMessage: 'El parámetro de envío retrasado no pudo ser cambiado. Por favor, inténtelo de nuevo o contacte al soporte.', + autoReportingFrequencyErrorMessage: 'La frecuencia de envío no pudo ser cambiada. Por favor, inténtelo de nuevo o contacte al soporte.', + monthlyOffsetErrorMessage: 'La frecuencia mensual no pudo ser cambiada. Por favor, inténtelo de nuevo o contacte al soporte.', + }, + workflowsApprovalPage: { + genericErrorMessage: 'El aprobador no pudo ser cambiado. Por favor, inténtelo de nuevo o contacte al soporte.', + }, workflowsPayerPage: { title: 'Pagador autorizado', genericErrorMessage: 'El pagador autorizado no se pudo cambiar. Por favor, inténtalo mas tarde.', @@ -1792,6 +1799,8 @@ export default { moreFeatures: 'Más características', requested: 'Solicitado', distanceRates: 'Tasas de distancia', + welcomeNote: ({workspaceName}: WelcomeNoteParams) => + `¡Has sido invitado a ${workspaceName}! Descargue la aplicación móvil Expensify en use.expensify.com/download para comenzar a rastrear sus gastos.`, }, type: { free: 'Gratis', @@ -1870,14 +1879,20 @@ export default { requiresTag: 'Los miembros deben etiquetar todos los gastos', customTagName: 'Nombre de etiqueta personalizada', enableTag: 'Habilitar etiqueta', + enableTags: 'Habilitar etiquetas', + disableTag: 'Desactivar etiqueta', + disableTags: 'Desactivar etiquetas', addTag: 'Añadir etiqueta', + editTag: 'Editar etiqueta', subtitle: 'Las etiquetas añaden formas más detalladas de clasificar los costos.', emptyTags: { title: 'No has creado ninguna etiqueta', subtitle: 'Añade una etiqueta para realizar el seguimiento de proyectos, ubicaciones, departamentos y otros.', }, deleteTag: 'Eliminar etiqueta', + deleteTags: 'Eliminar etiquetas', deleteTagConfirmation: '¿Estás seguro de que quieres eliminar esta etiqueta?', + deleteTagsConfirmation: '¿Estás seguro de que quieres eliminar estas etiquetas?', deleteFailureMessage: 'Se ha producido un error al intentar eliminar la etiqueta. Por favor, inténtalo más tarde.', tagRequiredError: 'Lo nombre de la etiqueta es obligatorio.', existingTagError: 'Ya existe una etiqueta con este nombre.', @@ -1892,8 +1907,20 @@ export default { value: 'Valor', errors: { taxRateAlreadyExists: 'Ya existe un impuesto con este nombre', - valuePercentageRange: 'Introduzca un porcentaje válido entre 0 y 100', - genericFailureMessage: 'Se produjo un error al actualizar el tipo impositivo, inténtelo nuevamente.', + valuePercentageRange: 'Por favor, introduce un porcentaje entre 0 y 100', + deleteFailureMessage: 'Se ha producido un error al intentar eliminar la tasa de impuesto. Por favor, inténtalo más tarde.', + updateFailureMessage: 'Se ha producido un error al intentar modificar la tasa de impuesto. Por favor, inténtalo más tarde.', + createFailureMessage: 'Se ha producido un error al intentar crear la tasa de impuesto. Por favor, inténtalo más tarde.', + }, + deleteTaxConfirmation: '¿Estás seguro de que quieres eliminar este impuesto?', + deleteMultipleTaxConfirmation: ({taxAmount}) => `¿Estás seguro de que quieres eliminar ${taxAmount} impuestos?`, + actions: { + delete: 'Eliminar tasa', + deleteMultiple: 'Eliminar tasas', + disable: 'Desactivar tasa', + disableMultiple: 'Desactivar tasas', + enable: 'Activar tasa', + enableMultiple: 'Activar tasas', }, }, emptyWorkspace: { @@ -1960,8 +1987,6 @@ export default { trackDistanceRate: 'Tarifa', trackDistanceUnit: 'Unidad', trackDistanceChooseUnit: 'Elige una unidad predeterminada de medida.', - kilometers: 'Kilómetros', - miles: 'Millas', unlockNextDayReimbursements: 'Desbloquea reembolsos diarios', captureNoVBACopyBeforeEmail: 'Pide a los miembros de tu espacio de trabajo que envíen recibos a ', captureNoVBACopyAfterEmail: ' y descarga la App de Expensify para controlar tus gastos en efectivo sobre la marcha.', @@ -2017,8 +2042,6 @@ export default { personalMessagePrompt: 'Mensaje', inviteNoMembersError: 'Por favor, selecciona al menos un miembro a invitar', genericFailureMessage: 'Se produjo un error al invitar al usuario al espacio de trabajo. Vuelva a intentarlo..', - welcomeNote: ({workspaceName}: WelcomeNoteParams) => - `¡Has sido invitado a ${workspaceName}! Descargue la aplicación móvil Expensify en use.expensify.com/download para comenzar a rastrear sus gastos.`, }, distanceRates: { oopsNotSoFast: 'Ups! No tan rápido...', @@ -2036,6 +2059,8 @@ export default { status: 'Estado', enabled: 'Activada', disabled: 'Desactivada', + unit: 'Unidad', + defaultCategory: 'Categoría predeterminada', }, editor: { nameInputLabel: 'Nombre', diff --git a/src/languages/types.ts b/src/languages/types.ts index 2d04bdc156c9..93438d76885d 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -117,7 +117,7 @@ type DidSplitAmountMessageParams = {formattedAmount: string; comment: string}; type AmountEachParams = {amount: string}; -type PayerOwesAmountParams = {payer: string; amount: number | string}; +type PayerOwesAmountParams = {payer: string; amount: number | string; comment?: string}; type PayerOwesParams = {payer: string}; @@ -211,8 +211,6 @@ type UpdatedTheDistanceParams = {newDistanceToDisplay: string; oldDistanceToDisp type FormattedMaxLengthParams = {formattedMaxLength: string}; -type TagSelectionParams = {tagName: string}; - type WalletProgramParams = {walletProgram: string}; type ViolationsAutoReportedRejectedExpenseParams = {rejectedBy: string; rejectReason: string}; @@ -362,7 +360,6 @@ export type { SizeExceededParams, SplitAmountParams, StepCounterParams, - TagSelectionParams, TaskCreatedActionParams, TermsParams, ThreadRequestReportNameParams, diff --git a/src/libs/API/parameters/DeletePolicyTaxesParams.ts b/src/libs/API/parameters/DeletePolicyTaxesParams.ts new file mode 100644 index 000000000000..9e0963cdcb28 --- /dev/null +++ b/src/libs/API/parameters/DeletePolicyTaxesParams.ts @@ -0,0 +1,11 @@ +type DeletePolicyTaxesParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + * Each element is a tax name + */ + taxNames: string; +}; + +export default DeletePolicyTaxesParams; diff --git a/src/libs/API/parameters/OpenPlaidBankLoginParams.ts b/src/libs/API/parameters/OpenPlaidBankLoginParams.ts index f76e05423d03..d60596eeb08f 100644 --- a/src/libs/API/parameters/OpenPlaidBankLoginParams.ts +++ b/src/libs/API/parameters/OpenPlaidBankLoginParams.ts @@ -1,5 +1,6 @@ type OpenPlaidBankLoginParams = { redirectURI: string | undefined; + androidPackage?: string; allowDebit: boolean; bankAccountID: number; }; diff --git a/src/libs/API/parameters/RenamePolicyTagsParams.ts b/src/libs/API/parameters/RenamePolicyTagsParams.ts new file mode 100644 index 000000000000..bcf38384cf2c --- /dev/null +++ b/src/libs/API/parameters/RenamePolicyTagsParams.ts @@ -0,0 +1,7 @@ +type RenamePolicyTagsParams = { + policyID: string; + oldName: string; + newName: string; +}; + +export default RenamePolicyTagsParams; diff --git a/src/libs/API/parameters/RenamePolicyTaxParams.ts b/src/libs/API/parameters/RenamePolicyTaxParams.ts new file mode 100644 index 000000000000..b722f14e7b6e --- /dev/null +++ b/src/libs/API/parameters/RenamePolicyTaxParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCurrencyDefaultParams = { + policyID: string; + taxCode: string; + newName: string; +}; + +export default SetPolicyCurrencyDefaultParams; diff --git a/src/libs/API/parameters/SetPolicyDistanceRatesDefaultCategoryParams.ts b/src/libs/API/parameters/SetPolicyDistanceRatesDefaultCategoryParams.ts new file mode 100644 index 000000000000..d2d11993a172 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyDistanceRatesDefaultCategoryParams.ts @@ -0,0 +1,6 @@ +type SetPolicyDistanceRatesDefaultCategoryParams = { + policyID: string; + customUnit: string; +}; + +export default SetPolicyDistanceRatesDefaultCategoryParams; diff --git a/src/libs/API/parameters/SetPolicyDistanceRatesUnitParams.ts b/src/libs/API/parameters/SetPolicyDistanceRatesUnitParams.ts new file mode 100644 index 000000000000..c841f480d1bf --- /dev/null +++ b/src/libs/API/parameters/SetPolicyDistanceRatesUnitParams.ts @@ -0,0 +1,6 @@ +type SetPolicyDistanceRatesUnitParams = { + policyID: string; + customUnit: string; +}; + +export default SetPolicyDistanceRatesUnitParams; diff --git a/src/libs/API/parameters/SetPolicyTaxesEnabledParams.ts b/src/libs/API/parameters/SetPolicyTaxesEnabledParams.ts new file mode 100644 index 000000000000..4ed0a05cfdec --- /dev/null +++ b/src/libs/API/parameters/SetPolicyTaxesEnabledParams.ts @@ -0,0 +1,10 @@ +type SetPolicyTaxesEnabledParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array<{taxCode: string, enabled: bool}> + */ + taxFieldsArray: string; +}; + +export default SetPolicyTaxesEnabledParams; diff --git a/src/libs/API/parameters/UpdatePolicyTaxValueParams.ts b/src/libs/API/parameters/UpdatePolicyTaxValueParams.ts new file mode 100644 index 000000000000..1124755ea9ef --- /dev/null +++ b/src/libs/API/parameters/UpdatePolicyTaxValueParams.ts @@ -0,0 +1,7 @@ +type UpdatePolicyTaxValueParams = { + policyID: string; + taxCode: string; + taxAmount: number; +}; + +export default UpdatePolicyTaxValueParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index efb2f1fff7da..5b42f25d19be 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -181,8 +181,15 @@ export type {default as OpenPolicyTaxesPageParams} from './OpenPolicyTaxesPagePa export type {default as EnablePolicyTaxesParams} from './EnablePolicyTaxesParams'; export type {default as OpenPolicyMoreFeaturesPageParams} from './OpenPolicyMoreFeaturesPageParams'; export type {default as CreatePolicyDistanceRateParams} from './CreatePolicyDistanceRateParams'; +export type {default as SetPolicyDistanceRatesUnitParams} from './SetPolicyDistanceRatesUnitParams'; +export type {default as SetPolicyDistanceRatesDefaultCategoryParams} from './SetPolicyDistanceRatesDefaultCategoryParams'; export type {default as CreatePolicyTagsParams} from './CreatePolicyTagsParams'; +export type {default as SetPolicyTaxesEnabledParams} from './SetPolicyTaxesEnabledParams'; +export type {default as DeletePolicyTaxesParams} from './DeletePolicyTaxesParams'; +export type {default as UpdatePolicyTaxValueParams} from './UpdatePolicyTaxValueParams'; +export type {default as RenamePolicyTagsParams} from './RenamePolicyTagsParams'; export type {default as DeletePolicyTagsParams} from './DeletePolicyTagsParams'; export type {default as SetPolicyCustomTaxNameParams} from './SetPolicyCustomTaxNameParams'; export type {default as SetPolicyForeignCurrencyDefaultParams} from './SetPolicyForeignCurrencyDefaultParams'; export type {default as SetPolicyCurrencyDefaultParams} from './SetPolicyCurrencyDefaultParams'; +export type {default as RenamePolicyTaxParams} from './RenamePolicyTaxParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 132fbc60b967..c04702f38f6a 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -122,11 +122,12 @@ const WRITE_COMMANDS = { CREATE_WORKSPACE_CATEGORIES: 'CreateWorkspaceCategories', RENAME_WORKSPACE_CATEGORY: 'RenameWorkspaceCategory', CREATE_POLICY_TAG: 'CreatePolicyTag', + RENAME_POLICY_TAG: 'RenamePolicyTag', SET_WORKSPACE_REQUIRES_CATEGORY: 'SetWorkspaceRequiresCategory', DELETE_WORKSPACE_CATEGORIES: 'DeleteWorkspaceCategories', SET_POLICY_REQUIRES_TAG: 'SetPolicyRequiresTag', RENAME_POLICY_TAG_LIST: 'RenamePolicyTaglist', - DELETE_POLICY_TAGS: 'Policy_IndependentTaglist_Tags_Remove', + DELETE_POLICY_TAGS: 'DeletePolicyTags', CREATE_TASK: 'CreateTask', CANCEL_TASK: 'CancelTask', EDIT_TASK_ASSIGNEE: 'EditTaskAssignee', @@ -181,7 +182,13 @@ const WRITE_COMMANDS = { ACCEPT_JOIN_REQUEST: 'AcceptJoinRequest', DECLINE_JOIN_REQUEST: 'DeclineJoinRequest', CREATE_POLICY_TAX: 'CreatePolicyTax', + SET_POLICY_TAXES_ENABLED: 'SetPolicyTaxesEnabled', + DELETE_POLICY_TAXES: 'DeletePolicyTaxes', + UPDATE_POLICY_TAX_VALUE: 'UpdatePolicyTaxValue', + RENAME_POLICY_TAX: 'RenamePolicyTax', CREATE_POLICY_DISTANCE_RATE: 'CreatePolicyDistanceRate', + SET_POLICY_DISTANCE_RATES_UNIT: 'SetPolicyDistanceRatesUnit', + SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY: 'SetPolicyDistanceRatesDefaultCategory', } as const; type WriteCommand = ValueOf; @@ -298,6 +305,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG]: Parameters.SetPolicyRequiresTag; [WRITE_COMMANDS.RENAME_POLICY_TAG_LIST]: Parameters.RenamePolicyTaglist; [WRITE_COMMANDS.CREATE_POLICY_TAG]: Parameters.CreatePolicyTagsParams; + [WRITE_COMMANDS.RENAME_POLICY_TAG]: Parameters.RenamePolicyTagsParams; [WRITE_COMMANDS.SET_POLICY_TAGS_ENABLED]: Parameters.SetPolicyTagsEnabled; [WRITE_COMMANDS.DELETE_POLICY_TAGS]: Parameters.DeletePolicyTagsParams; [WRITE_COMMANDS.CREATE_TASK]: Parameters.CreateTaskParams; @@ -361,7 +369,13 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_CUSTOM_TAX_NAME]: Parameters.SetPolicyCustomTaxNameParams; [WRITE_COMMANDS.SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT]: Parameters.SetPolicyForeignCurrencyDefaultParams; [WRITE_COMMANDS.CREATE_POLICY_TAX]: Parameters.CreatePolicyTaxParams; + [WRITE_COMMANDS.SET_POLICY_TAXES_ENABLED]: Parameters.SetPolicyTaxesEnabledParams; + [WRITE_COMMANDS.DELETE_POLICY_TAXES]: Parameters.DeletePolicyTaxesParams; + [WRITE_COMMANDS.UPDATE_POLICY_TAX_VALUE]: Parameters.UpdatePolicyTaxValueParams; [WRITE_COMMANDS.CREATE_POLICY_DISTANCE_RATE]: Parameters.CreatePolicyDistanceRateParams; + [WRITE_COMMANDS.RENAME_POLICY_TAX]: Parameters.RenamePolicyTaxParams; + [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT]: Parameters.SetPolicyDistanceRatesUnitParams; + [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; }; const READ_COMMANDS = { diff --git a/src/libs/ApiUtils.ts b/src/libs/ApiUtils.ts index 67feb18b36fa..0c8fa3f53915 100644 --- a/src/libs/ApiUtils.ts +++ b/src/libs/ApiUtils.ts @@ -52,7 +52,8 @@ function getApiRoot(request?: Request): string { * @param - the name of the API command */ function getCommandURL(request: Request): string { - return `${getApiRoot(request)}api?command=${request.command}`; + // If request.command already contains ? then we don't need to append it + return `${getApiRoot(request)}api/${request.command}${request.command.includes('?') ? '' : '?'}`; } /** diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 784d339a4a0d..d38700efd53d 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -110,6 +110,21 @@ function getEarliestErrorField(onyxDa return {[key]: getErrorMessageWithTranslationData(errorsForField[key])}; } +/** + * Method used to get the latest error field for any field + */ +function getLatestErrorFieldForAnyField(onyxData: TOnyxData): Errors { + const errorFields = onyxData.errorFields ?? {}; + + if (Object.keys(errorFields).length === 0) { + return {}; + } + + const fieldNames = Object.keys(errorFields); + const latestErrorFields = fieldNames.map((fieldName) => getLatestErrorField(onyxData, fieldName)); + return latestErrorFields.reduce((acc, error) => ({...acc, ...error}), {}); +} + /** * Method used to attach already translated message with isTranslated property * @param errors - An object containing current errors in the form @@ -176,6 +191,7 @@ export { getLatestErrorField, getLatestErrorMessage, getLatestErrorMessageField, + getLatestErrorFieldForAnyField, getMicroSecondOnyxError, getMicroSecondOnyxErrorObject, isReceiptError, diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index f52fefe02386..842776d0c8dd 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -39,7 +39,7 @@ const addSkewList: string[] = [SIDE_EFFECT_REQUEST_COMMANDS.OPEN_REPORT, SIDE_EF /** * Regex to get API command from the command */ -const APICommandRegex = /[?&]command=([^&]+)/; +const APICommandRegex = /\/api\/([^&?]+)\??.*/; /** * Send an HTTP request, and attempt to resolve the json response. diff --git a/src/libs/InitialUrlContext/index.ts b/src/libs/InitialUrlContext/index.ts deleted file mode 100644 index a87417fe4cc6..000000000000 --- a/src/libs/InitialUrlContext/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {createContext} from 'react'; -import type {Route} from '@src/ROUTES'; - -/** Initial url that will be opened when NewDot is embedded into Hybrid App. */ -const InitialUrlContext = createContext(undefined); - -export default InitialUrlContext; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 585713243d3f..5835558e5de4 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -181,7 +181,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie Pusher.init({ appKey: CONFIG.PUSHER.APP_KEY, cluster: CONFIG.PUSHER.CLUSTER, - authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api?command=AuthenticatePusher`, + authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, }).then(() => { User.subscribeToUserEvents(); }); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index 69741141f4f3..bd5bfc46134a 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -263,11 +263,13 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/members/WorkspaceMemberDetailsRoleSelectionPage').default as React.ComponentType, [SCREENS.WORKSPACE.CATEGORY_CREATE]: () => require('../../../pages/workspace/categories/CreateCategoryPage').default as React.ComponentType, [SCREENS.WORKSPACE.CATEGORY_EDIT]: () => require('../../../pages/workspace/categories/EditCategoryPage').default as React.ComponentType, - [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: () => require('@pages/workspace/distanceRates/CreateDistanceRatePage').default as React.ComponentType, + [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: () => require('../../../pages/workspace/distanceRates/CreateDistanceRatePage').default as React.ComponentType, + [SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS]: () => require('../../../pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAGS_SETTINGS]: () => require('../../../pages/workspace/tags/WorkspaceTagsSettingsPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAG_SETTINGS]: () => require('../../../pages/workspace/tags/TagSettingsPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAGS_EDIT]: () => require('../../../pages/workspace/tags/WorkspaceEditTagsPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAG_CREATE]: () => require('../../../pages/workspace/tags/WorkspaceCreateTagPage').default as React.ComponentType, + [SCREENS.WORKSPACE.TAG_EDIT]: () => require('../../../pages/workspace/tags/EditTagPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAXES_SETTINGS]: () => require('../../../pages/workspace/taxes/WorkspaceTaxesSettingsPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME]: () => require('../../../pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName').default as React.ComponentType, [SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT]: () => require('../../../pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency').default as React.ComponentType, @@ -282,6 +284,9 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/ExitSurvey/ExitSurveyConfirmPage').default as React.ComponentType, [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: () => require('../../../pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage').default as React.ComponentType, [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET]: () => require('../../../pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage').default as React.ComponentType, + [SCREENS.WORKSPACE.TAX_EDIT]: () => require('../../../pages/workspace/taxes/WorkspaceEditTaxPage').default as React.ComponentType, + [SCREENS.WORKSPACE.TAX_NAME]: () => require('../../../pages/workspace/taxes/NamePage').default as React.ComponentType, + [SCREENS.WORKSPACE.TAX_VALUE]: () => require('../../../pages/workspace/taxes/ValuePage').default as React.ComponentType, [SCREENS.WORKSPACE.TAX_CREATE]: () => require('../../../pages/workspace/taxes/WorkspaceCreateTaxPage').default as React.ComponentType, }); diff --git a/src/libs/Navigation/AppNavigator/index.tsx b/src/libs/Navigation/AppNavigator/index.tsx index 24f0f42c5d64..9729a2f812ce 100644 --- a/src/libs/Navigation/AppNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/index.tsx @@ -1,6 +1,6 @@ import React, {useContext, useEffect} from 'react'; import {NativeModules} from 'react-native'; -import InitialUrlContext from '@libs/InitialUrlContext'; +import {InitialURLContext} from '@components/InitialURLContextProvider'; import Navigation from '@libs/Navigation/Navigation'; type AppNavigatorProps = { @@ -9,7 +9,7 @@ type AppNavigatorProps = { }; function AppNavigator({authenticated}: AppNavigatorProps) { - const initUrl = useContext(InitialUrlContext); + const initUrl = useContext(InitialURLContext); useEffect(() => { if (!NativeModules.HybridAppModule || !initUrl) { 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 6f06df9bf2ff..17f5049aab91 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -13,13 +13,18 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { ], [SCREENS.WORKSPACE.TAXES]: [ SCREENS.WORKSPACE.TAXES_SETTINGS, + SCREENS.WORKSPACE.TAX_CREATE, SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME, SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT, SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT, + SCREENS.WORKSPACE.TAX_CREATE, + SCREENS.WORKSPACE.TAX_EDIT, + SCREENS.WORKSPACE.TAX_NAME, + SCREENS.WORKSPACE.TAX_VALUE, ], - [SCREENS.WORKSPACE.TAGS]: [SCREENS.WORKSPACE.TAGS_SETTINGS, SCREENS.WORKSPACE.TAGS_EDIT, SCREENS.WORKSPACE.TAG_CREATE, SCREENS.WORKSPACE.TAG_SETTINGS, SCREENS.WORKSPACE.TAX_CREATE], + [SCREENS.WORKSPACE.TAGS]: [SCREENS.WORKSPACE.TAGS_SETTINGS, SCREENS.WORKSPACE.TAGS_EDIT, SCREENS.WORKSPACE.TAG_CREATE, SCREENS.WORKSPACE.TAG_SETTINGS, SCREENS.WORKSPACE.TAG_EDIT], [SCREENS.WORKSPACE.CATEGORIES]: [SCREENS.WORKSPACE.CATEGORY_CREATE, SCREENS.WORKSPACE.CATEGORY_SETTINGS, SCREENS.WORKSPACE.CATEGORIES_SETTINGS, SCREENS.WORKSPACE.CATEGORY_EDIT], - [SCREENS.WORKSPACE.DISTANCE_RATES]: [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE], + [SCREENS.WORKSPACE.DISTANCE_RATES]: [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE, SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS], }; export default FULL_SCREEN_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index ae8d47a69988..130fdf23732f 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -292,6 +292,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: { path: ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.route, }, + [SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS]: { + path: ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.route, + }, [SCREENS.WORKSPACE.TAGS_SETTINGS]: { path: ROUTES.WORKSPACE_TAGS_SETTINGS.route, }, @@ -301,6 +304,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.TAG_CREATE]: { path: ROUTES.WORKSPACE_TAG_CREATE.route, }, + [SCREENS.WORKSPACE.TAG_EDIT]: { + path: ROUTES.WORKSPACE_TAG_EDIT.route, + parse: { + tagName: (tagName: string) => decodeURIComponent(tagName), + }, + }, [SCREENS.WORKSPACE.TAG_SETTINGS]: { path: ROUTES.WORKSPACE_TAG_SETTINGS.route, parse: { @@ -345,6 +354,15 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.TAX_CREATE]: { path: ROUTES.WORKSPACE_TAX_CREATE.route, }, + [SCREENS.WORKSPACE.TAX_EDIT]: { + path: ROUTES.WORKSPACE_TAX_EDIT.route, + }, + [SCREENS.WORKSPACE.TAX_NAME]: { + path: ROUTES.WORKSPACE_TAX_NAME.route, + }, + [SCREENS.WORKSPACE.TAX_VALUE]: { + path: ROUTES.WORKSPACE_TAX_VALUE.route, + }, }, }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 4c9bdb579605..9b0d9ce4decc 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -193,6 +193,10 @@ type SettingsNavigatorParamList = { policyID: string; tagName: string; }; + [SCREENS.WORKSPACE.TAG_EDIT]: { + policyID: string; + tagName: string; + }; [SCREENS.WORKSPACE.TAXES_SETTINGS]: { policyID: string; }; @@ -218,6 +222,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: { policyID: string; }; + [SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS]: { + policyID: string; + }; [SCREENS.GET_ASSISTANCE]: { backTo: Routes; }; @@ -235,6 +242,18 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.TAX_CREATE]: { policyID: string; }; + [SCREENS.WORKSPACE.TAX_EDIT]: { + policyID: string; + taxID: string; + }; + [SCREENS.WORKSPACE.TAX_NAME]: { + policyID: string; + taxID: string; + }; + [SCREENS.WORKSPACE.TAX_VALUE]: { + policyID: string; + taxID: string; + }; } & ReimbursementAccountNavigatorParamList; type NewChatNavigatorParamList = { diff --git a/src/libs/NetworkConnection.ts b/src/libs/NetworkConnection.ts index 6839b290abdd..1cf37623ef2e 100644 --- a/src/libs/NetworkConnection.ts +++ b/src/libs/NetworkConnection.ts @@ -81,7 +81,7 @@ function subscribeToNetInfo(): void { // By default, NetInfo uses `/` for `reachabilityUrl` // When App is served locally (or from Electron) this address is always reachable - even offline // Using the API url ensures reachability is tested over internet - reachabilityUrl: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api?command=Ping`, + reachabilityUrl: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/Ping`, reachabilityMethod: 'GET', reachabilityTest: (response) => { if (!response.ok) { diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index 0a5cfad2d146..5a19e68afe72 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -258,7 +258,7 @@ function buildNextStep( text: 'Waiting for ', }, { - text: managerDisplayName, + text: 'you', type: 'strong', }, { @@ -281,7 +281,7 @@ function buildNextStep( text: 'Waiting for ', }, { - text: 'you', + text: managerDisplayName, type: 'strong', }, { diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index ff55343fa762..bacd019904a3 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -569,6 +569,7 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails ReportUtils.isChatReport(report), null, true, + lastReportAction, ); } else if (ReportActionUtils.isReimbursementQueuedAction(lastReportAction)) { lastMessageTextFromReport = ReportUtils.getReimbursementQueuedActionMessage(lastReportAction, report); @@ -1013,8 +1014,8 @@ function getCategoryListSections( } const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name); - const enabledWithoutSelectedCategories = sortedCategories.filter((category) => category.enabled && !selectedOptionNames.includes(category.name)); - const numberOfVisibleCategories = enabledWithoutSelectedCategories.length + selectedOptions.length; + const filteredCategories = enabledCategories.filter((category) => !selectedOptionNames.includes(category.name)); + const numberOfVisibleCategories = filteredCategories.length + selectedOptionNames.length; if (numberOfVisibleCategories < CONST.CATEGORY_LIST_THRESHOLD) { categorySections.push({ @@ -1022,7 +1023,7 @@ function getCategoryListSections( title: '', shouldShow: false, indexOffset, - data: getCategoryOptionTree(enabledWithoutSelectedCategories), + data: getCategoryOptionTree(filteredCategories, false, selectedOptionNames), }); return categorySections; @@ -1049,8 +1050,6 @@ function getCategoryListSections( indexOffset += filteredRecentlyUsedCategories.length; } - const filteredCategories = enabledCategories.filter((category) => !selectedOptionNames.includes(category.name)); - categorySections.push({ // "All" section when items amount more than the threshold title: Localize.translateLocal('common.all'), diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index c8107c22bb1a..c9ea65781117 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -1,6 +1,7 @@ import Str from 'expensify-common/lib/str'; import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {CurrentUserPersonalDetails} from '@components/withCurrentUserPersonalDetails'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; @@ -249,7 +250,7 @@ function createDisplayName(login: string, passedPersonalDetails: Pick): boolean * Check if the policy has any tax rate errors. */ function hasTaxRateError(policy: OnyxEntry): boolean { - return Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0); + return Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0 || Object.values(taxRate?.errorFields ?? {}).some(Boolean)); } /** @@ -276,6 +277,26 @@ function goBackFromInvalidPolicy() { Navigation.navigate(ROUTES.SETTINGS_WORKSPACES); } +/** Get a tax with given ID from policy */ +function getTaxByID(policy: OnyxEntry, taxID: string): TaxRate | undefined { + return policy?.taxRates?.taxes?.[taxID]; +} + +/** + * Whether the tax rate can be deleted and disabled + */ +function canEditTaxRate(policy: Policy, taxID: string): boolean { + return policy.taxRates?.defaultExternalID !== taxID; +} + +function isPolicyFeatureEnabled(policy: OnyxEntry | EmptyObject, featureName: PolicyFeatureName): boolean { + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED) { + return Boolean(policy?.tax?.trackingEnabled); + } + + return Boolean(policy?.[featureName]); +} + export { getActivePolicies, hasAccountingConnections, @@ -296,6 +317,7 @@ export { getIneligibleInvitees, getTagLists, getTagListName, + canEditTaxRate, getTagList, getCleanedTagName, getCountOfEnabledTagsOfList, @@ -306,7 +328,9 @@ export { getPathWithoutPolicyID, getPolicyMembersByIdWithoutCurrentUser, goBackFromInvalidPolicy, + isPolicyFeatureEnabled, hasTaxRateError, + getTaxByID, hasPolicyCategoriesError, }; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e39180db7fd0..b5351e13ea6c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2414,17 +2414,21 @@ function getTransactionReportName(reportAction: OnyxEntry | EmptyObject, - reportAction: OnyxEntry | EmptyObject = {}, + iouReportAction: OnyxEntry | EmptyObject = {}, shouldConsiderScanningReceiptOrPendingRoute = false, isPreviewMessageForParentChatReport = false, policy: OnyxEntry = null, isForListPreview = false, + originalReportAction: OnyxEntry | EmptyObject = iouReportAction, ): string { - const reportActionMessage = reportAction?.message?.[0].html ?? ''; + const reportActionMessage = iouReportAction?.message?.[0].html ?? ''; if (isEmptyObject(report) || !report?.reportID) { // The iouReport is not found locally after SignIn because the OpenApp API won't return iouReports if they're settled @@ -2432,9 +2436,9 @@ function getReportPreviewMessage( return reportActionMessage; } - if (!isEmptyObject(reportAction) && !isIOUReport(report) && reportAction && ReportActionsUtils.isSplitBillAction(reportAction)) { + if (!isEmptyObject(iouReportAction) && !isIOUReport(report) && iouReportAction && ReportActionsUtils.isSplitBillAction(iouReportAction)) { // This covers group chats where the last action is a split bill action - const linkedTransaction = getLinkedTransaction(reportAction); + const linkedTransaction = getLinkedTransaction(iouReportAction); if (isEmptyObject(linkedTransaction)) { return reportActionMessage; } @@ -2469,8 +2473,8 @@ function getReportPreviewMessage( } let linkedTransaction; - if (!isEmptyObject(reportAction) && shouldConsiderScanningReceiptOrPendingRoute && reportAction && ReportActionsUtils.isMoneyRequestAction(reportAction)) { - linkedTransaction = getLinkedTransaction(reportAction); + if (!isEmptyObject(iouReportAction) && shouldConsiderScanningReceiptOrPendingRoute && iouReportAction && ReportActionsUtils.isMoneyRequestAction(iouReportAction)) { + linkedTransaction = getLinkedTransaction(iouReportAction); } if (!isEmptyObject(linkedTransaction) && TransactionUtils.hasReceipt(linkedTransaction) && TransactionUtils.isReceiptBeingScanned(linkedTransaction)) { @@ -2481,7 +2485,7 @@ function getReportPreviewMessage( return Localize.translateLocal('iou.routePending'); } - const originalMessage = reportAction?.originalMessage as IOUMessage | undefined; + const originalMessage = iouReportAction?.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. if (isSettled(report.reportID) || (report.isWaitingOnBankAccount && isPreviewMessageForParentChatReport)) { @@ -2509,7 +2513,7 @@ function getReportPreviewMessage( return Localize.translateLocal('iou.waitingOnBankAccount', {submitterDisplayName}); } - const lastActorID = reportAction?.actorAccountID; + const lastActorID = iouReportAction?.actorAccountID; let amount = originalMessage?.amount; let currency = originalMessage?.currency ? originalMessage?.currency : report.currency; @@ -2518,16 +2522,29 @@ function getReportPreviewMessage( currency = TransactionUtils.getCurrency(linkedTransaction); } + if (isEmptyObject(linkedTransaction) && !isEmptyObject(iouReportAction)) { + linkedTransaction = getLinkedTransaction(iouReportAction); + } + + let comment = !isEmptyObject(linkedTransaction) ? TransactionUtils.getDescription(linkedTransaction) : undefined; + if (!isEmptyObject(originalReportAction) && ReportActionsUtils.isReportPreviewAction(originalReportAction) && ReportActionsUtils.getNumberOfMoneyRequests(originalReportAction) !== 1) { + comment = undefined; + } + // if we have the amount in the originalMessage and lastActorID, we can use that to display the preview message for the latest request if (amount !== undefined && lastActorID && !isPreviewMessageForParentChatReport) { const amountToDisplay = CurrencyUtils.convertToDisplayString(Math.abs(amount), currency); // We only want to show the actor name in the preview if it's not the current user who took the action const requestorName = lastActorID && lastActorID !== currentUserAccountID ? getDisplayNameForParticipant(lastActorID, !isPreviewMessageForParentChatReport) : ''; - return `${requestorName ? `${requestorName}: ` : ''}${Localize.translateLocal('iou.requestedAmount', {formattedAmount: amountToDisplay})}`; + return `${requestorName ? `${requestorName}: ` : ''}${Localize.translateLocal('iou.requestedAmount', {formattedAmount: amountToDisplay, comment})}`; } - return Localize.translateLocal(containsNonReimbursable ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', {payer: payerName ?? '', amount: formattedAmount}); + if (containsNonReimbursable) { + return Localize.translateLocal('iou.payerSpentAmount', {payer: payerName ?? '', amount: formattedAmount}); + } + + return Localize.translateLocal('iou.payerOwesAmount', {payer: payerName ?? '', amount: formattedAmount, comment}); } /** @@ -5314,6 +5331,14 @@ function shouldCreateNewMoneyRequestReport(existingIOUReport: OnyxEntry return !existingIOUReport || hasIOUWaitingOnCurrentUserBankAccount(chatReport) || !canAddOrDeleteTransactions(existingIOUReport); } +/** + * Checks if report contains actions with errors + */ +function hasActionsWithErrors(reportID: string): boolean { + const reportActions = ReportActionsUtils.getAllReportActions(reportID ?? ''); + return Object.values(reportActions ?? {}).some((action) => !isEmptyObject(action.errors)); +} + export { getReportParticipantsTitle, isReportMessageAttachment, @@ -5524,6 +5549,7 @@ export { isJoinRequestInAdminRoom, canAddOrDeleteTransactions, shouldCreateNewMoneyRequestReport, + hasActionsWithErrors, }; export type { diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 7bf163416054..4b4d71cd5cdc 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -352,25 +352,26 @@ function getOptionData({ if (!lastMessageText) { // Here we get the beginning of chat history message and append the display name for each user, adding pronouns if there are any. // We also add a fullstop after the final name, the word "and" before the final name and commas between all previous names. - lastMessageText = - Localize.translate(preferredLocale, 'reportActionsView.beginningOfChatHistory') + - displayNamesWithTooltips - .map(({displayName, pronouns}, index) => { - const formattedText = !pronouns ? displayName : `${displayName} (${pronouns})`; - - if (index === displayNamesWithTooltips.length - 1) { - return `${formattedText}.`; - } - if (index === displayNamesWithTooltips.length - 2) { - return `${formattedText} ${Localize.translate(preferredLocale, 'common.and')}`; - } - if (index < displayNamesWithTooltips.length - 2) { - return `${formattedText},`; - } - - return ''; - }) - .join(' '); + lastMessageText = ReportUtils.isSelfDM(report) + ? Localize.translate(preferredLocale, 'reportActionsView.beginningOfChatHistorySelfDM') + : Localize.translate(preferredLocale, 'reportActionsView.beginningOfChatHistory') + + displayNamesWithTooltips + .map(({displayName, pronouns}, index) => { + const formattedText = !pronouns ? displayName : `${displayName} (${pronouns})`; + + if (index === displayNamesWithTooltips.length - 1) { + return `${formattedText}.`; + } + if (index === displayNamesWithTooltips.length - 2) { + return `${formattedText} ${Localize.translate(preferredLocale, 'common.and')}`; + } + if (index < displayNamesWithTooltips.length - 2) { + return `${formattedText},`; + } + + return ''; + }) + .join(' '); } result.alternateText = ReportUtils.isGroupChat(report) && lastActorDisplayName ? `${lastActorDisplayName}: ${lastMessageText}` : lastMessageText || formattedLogin; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 5876ccf5d7d7..cacab8333868 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -471,8 +471,9 @@ function isValidPercentage(value: string): boolean { /** * Validates the given value if it is correct tax name. */ -function isExistingTaxName(value: string, taxRates: TaxRates): boolean { - return !!Object.values(taxRates).find((taxRate) => taxRate.name === value); +function isExistingTaxName(taxName: string, taxRates: TaxRates): boolean { + const trimmedTaxName = taxName.trim(); + return !!Object.values(taxRates).find((taxRate) => taxRate.name === trimmedTaxName); } export { diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts index 78a8edb4c73f..f808f602a1c6 100644 --- a/src/libs/WorkspacesSettingsUtils.ts +++ b/src/libs/WorkspacesSettingsUtils.ts @@ -2,8 +2,10 @@ import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyMembers, ReimbursementAccount, Report} from '@src/types/onyx'; +import type {Unit} from '@src/types/onyx/Policy'; import * as OptionsListUtils from './OptionsListUtils'; import {hasCustomUnitsError, hasPolicyError, hasPolicyMemberError, hasTaxRateError} from './PolicyUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; @@ -90,8 +92,9 @@ function hasGlobalWorkspaceSettingsRBR(policies: OnyxCollection, policyM function hasWorkspaceSettingsRBR(policy: Policy) { const policyMemberError = allPolicyMembers ? hasPolicyMemberError(allPolicyMembers[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policy.id}`]) : false; + const taxRateError = hasTaxRateError(policy); - return Object.keys(reimbursementAccount?.errors ?? {}).length > 0 || hasPolicyError(policy) || hasCustomUnitsError(policy) || policyMemberError; + return Object.keys(reimbursementAccount?.errors ?? {}).length > 0 || hasPolicyError(policy) || hasCustomUnitsError(policy) || policyMemberError || taxRateError; } function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined { @@ -196,6 +199,19 @@ function getWorkspacesUnreadStatuses(): Record { return workspacesUnreadStatuses; } +/** + * @param unit Unit + * @returns translation key for the unit + */ +function getUnitTranslationKey(unit: Unit): TranslationPaths { + const unitTranslationKeysStrategy: Record = { + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS]: 'common.kilometers', + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES]: 'common.miles', + }; + + return unitTranslationKeysStrategy[unit]; +} + export { getBrickRoadForPolicy, getWorkspacesBrickRoads, @@ -204,5 +220,6 @@ export { checkIfWorkspaceSettingsTabHasRBR, hasWorkspaceSettingsRBR, getChatTabBrickRoad, + getUnitTranslationKey, }; export type {BrickRoad}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 868bfc28d781..562ed501eebc 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -231,6 +231,14 @@ Onyx.connect({ }, }); +let quickAction: OnyxEntry = {}; +Onyx.connect({ + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + callback: (value) => { + quickAction = value; + }, +}); + /** * Initialize money request info * @param reportID to attach the transaction to @@ -457,11 +465,16 @@ function buildOnyxDataForMoneyRequest( policyTagList?: OnyxEntry, policyCategories?: OnyxEntry, optimisticNextStep?: OnyxTypes.ReportNextStep | null, + isOneOnOneSplit = false, ): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] { const isScanRequest = TransactionUtils.isScanRequest(transaction); const outstandingChildRequest = getOutstandingChildRequest(policy ?? {}, iouReport); const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null])); const optimisticData: OnyxUpdate[] = []; + let newQuickAction: ValueOf = isScanRequest ? CONST.QUICK_ACTIONS.REQUEST_SCAN : CONST.QUICK_ACTIONS.REQUEST_MANUAL; + if (TransactionUtils.isDistanceRequest(transaction)) { + newQuickAction = CONST.QUICK_ACTIONS.REQUEST_DISTANCE; + } if (chatReport) { optimisticData.push({ @@ -551,6 +564,18 @@ function buildOnyxDataForMoneyRequest( }, ); + if (!isOneOnOneSplit) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: { + action: newQuickAction, + reportID: chatReport?.reportID, + isFirstQuickAction: isEmptyObject(quickAction), + }, + }); + } + if (optimisticPolicyRecentlyUsedCategories.length) { optimisticData.push({ onyxMethod: Onyx.METHOD.SET, @@ -715,31 +740,6 @@ function buildOnyxDataForMoneyRequest( pendingFields: clearedPendingFields, }, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, - value: { - ...(isNewChatReport - ? { - [chatCreatedAction.reportActionID]: { - // Disabling this line since transaction.filename can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - errors: getReceiptError(transaction?.receipt, transaction.filename || transaction.receipt?.filename, isScanRequest), - }, - [reportPreviewAction.reportActionID]: { - errors: ErrorUtils.getMicroSecondOnyxError(null), - }, - } - : { - [reportPreviewAction.reportActionID]: { - created: reportPreviewAction.created, - // Disabling this line since transaction.filename can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - errors: getReceiptError(transaction?.receipt, transaction.filename || transaction.receipt?.filename, isScanRequest), - }, - }), - }, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, @@ -1649,6 +1649,7 @@ function createSplitsAndOnyxData( tag: string, existingSplitChatReportID = '', billable = false, + iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL, ): SplitsAndOnyxData { const currentUserEmailForIOUSplit = PhoneNumber.addSMSDomainIfPhoneNumber(currentUserLogin); const participantAccountIDs = participants.map((participant) => Number(participant.accountID)); @@ -1712,6 +1713,15 @@ function createSplitsAndOnyxData( key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, value: splitChatReport, }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: { + action: iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE ? CONST.QUICK_ACTIONS.SPLIT_DISTANCE : CONST.QUICK_ACTIONS.SPLIT_MANUAL, + reportID: splitChatReport.reportID, + isFirstQuickAction: isEmptyObject(quickAction), + }, + }, existingSplitChatReport ? { onyxMethod: Onyx.METHOD.MERGE, @@ -1943,6 +1953,11 @@ function createSplitsAndOnyxData( optimisticTransactionThread, optimisticCreatedActionForTransactionThread, shouldCreateNewOneOnOneIOUReport, + null, + null, + null, + null, + true, ); const individualSplit = { @@ -2001,6 +2016,7 @@ function splitBill( tag: string, existingSplitChatReportID = '', billable = false, + iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL, ) { const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); const {splitData, splits, onyxData} = createSplitsAndOnyxData( @@ -2016,6 +2032,7 @@ function splitBill( tag, existingSplitChatReportID, billable, + iouRequestType, ); const parameters: SplitBillParams = { @@ -2057,9 +2074,24 @@ function splitBillAndOpenReport( category: string, tag: string, billable: boolean, + iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL, ) { const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); - const {splitData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, currentCreated, category, tag); + const {splitData, splits, onyxData} = createSplitsAndOnyxData( + participants, + currentUserLogin, + currentUserAccountID, + amount, + comment, + currency, + merchant, + currentCreated, + category, + tag, + '', + billable, + iouRequestType, + ); const parameters: SplitBillParams = { reportID: splitData.chatReportID, @@ -2167,6 +2199,15 @@ function startSplitBill( key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, value: splitChatReport, }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: { + action: CONST.QUICK_ACTIONS.SPLIT_SCAN, + reportID: splitChatReport.reportID, + isFirstQuickAction: isEmptyObject(quickAction), + }, + }, existingSplitChatReport ? { onyxMethod: Onyx.METHOD.MERGE, @@ -2542,6 +2583,11 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA optimisticTransactionThread, optimisticCreatedActionForTransactionThread, shouldCreateNewOneOnOneIOUReport, + null, + null, + null, + null, + true, ); splits.push({ @@ -3274,6 +3320,15 @@ function getSendMoneyParams( lastVisibleActionCreated: reportPreviewAction.created, }, }; + const optimisticQuickActionData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: { + action: CONST.QUICK_ACTIONS.SEND_MONEY, + reportID: chatReport.reportID, + isFirstQuickAction: isEmptyObject(quickAction), + }, + }; const optimisticIOUReportData: OnyxUpdate = { onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticIOUReport.reportID}`, @@ -3440,6 +3495,7 @@ function getSendMoneyParams( const optimisticData: OnyxUpdate[] = [ optimisticChatReportData, + optimisticQuickActionData, optimisticIOUReportData, optimisticChatReportActionsData, optimisticIOUReportActionsData, diff --git a/src/libs/actions/Plaid.ts b/src/libs/actions/Plaid.ts index 78bc91618215..7f63b3920e01 100644 --- a/src/libs/actions/Plaid.ts +++ b/src/libs/actions/Plaid.ts @@ -11,10 +11,11 @@ import ONYXKEYS from '@src/ONYXKEYS'; */ function openPlaidBankLogin(allowDebit: boolean, bankAccountID: number) { // redirect_uri needs to be in kebab case convention because that's how it's passed to the backend - const {redirectURI} = getPlaidLinkTokenParameters(); + const {redirectURI, androidPackage} = getPlaidLinkTokenParameters(); const params: OpenPlaidBankLoginParams = { redirectURI, + androidPackage, allowDebit, bankAccountID, }; diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 213a03bca662..efd627e7ef93 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -34,6 +34,8 @@ import type { OpenWorkspaceMembersPageParams, OpenWorkspaceParams, OpenWorkspaceReimburseViewParams, + SetPolicyDistanceRatesDefaultCategoryParams, + SetPolicyDistanceRatesUnitParams, SetWorkspaceApprovalModeParams, SetWorkspaceAutoReportingFrequencyParams, SetWorkspaceAutoReportingMonthlyOffsetParams, @@ -80,7 +82,7 @@ import type { ReportAction, Transaction, } from '@src/types/onyx'; -import type {Errors, OnyxValueWithOfflineFeedback, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {ErrorFields, Errors, OnyxValueWithOfflineFeedback, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {OriginalMessageJoinPolicyChangeLog} from '@src/types/onyx/OriginalMessage'; import type {Attributes, CustomUnit, Rate, Unit} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -250,6 +252,14 @@ function updateLastAccessedWorkspace(policyID: OnyxEntry) { Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID); } +/** + * Checks if the currency is supported for direct reimbursement + * USD currency is the only one supported in NewDot for now + */ +function isCurrencySupportedForDirectReimbursement(currency: string) { + return currency === CONST.CURRENCY.USD; +} + /** * Check if the user has any active free policies (aka workspaces) */ @@ -322,6 +332,7 @@ function deleteWorkspace(policyID: string, policyName: string) { statusNum: CONST.REPORT.STATUS_NUM.CLOSED, hasDraft: false, oldPolicyName: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.name ?? '', + policyName: '', }, }); @@ -368,6 +379,7 @@ function deleteWorkspace(policyID: string, policyName: string) { statusNum, hasDraft, oldPolicyName, + policyName: report?.policyName, }, }); }); @@ -451,7 +463,7 @@ function setWorkspaceAutoReporting(policyID: string, enabled: boolean, frequency enabled, }, autoReportingFrequency: frequency, - pendingFields: {isAutoApprovalEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + pendingFields: {autoReporting: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, }, }, ]; @@ -466,7 +478,8 @@ function setWorkspaceAutoReporting(policyID: string, enabled: boolean, frequency enabled: policy.harvesting?.enabled ?? null, }, autoReportingFrequency: policy.autoReportingFrequency ?? null, - pendingFields: {isAutoApprovalEnabled: null, harvesting: null}, + pendingFields: {autoReporting: null}, + errorFields: {autoReporting: ErrorUtils.getMicroSecondOnyxError('workflowsDelayedSubmissionPage.autoReportingErrorMessage')}, }, }, ]; @@ -476,7 +489,7 @@ function setWorkspaceAutoReporting(policyID: string, enabled: boolean, frequency onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - pendingFields: {isAutoApprovalEnabled: null, harvesting: null}, + pendingFields: {autoReporting: null}, }, }, ]; @@ -507,6 +520,7 @@ function setWorkspaceAutoReportingFrequency(policyID: string, frequency: ValueOf value: { autoReportingFrequency: policy.autoReportingFrequency ?? null, pendingFields: {autoReportingFrequency: null}, + errorFields: {autoReportingFrequency: ErrorUtils.getMicroSecondOnyxError('workflowsDelayedSubmissionPage.autoReportingFrequencyErrorMessage')}, }, }, ]; @@ -547,6 +561,7 @@ function setWorkspaceAutoReportingMonthlyOffset(policyID: string, autoReportingO value: { autoReportingOffset: policy.autoReportingOffset ?? null, pendingFields: {autoReportingOffset: null}, + errorFields: {autoReportingOffset: ErrorUtils.getMicroSecondOnyxError('workflowsDelayedSubmissionPage.monthlyOffsetErrorMessage')}, }, }, ]; @@ -566,13 +581,11 @@ function setWorkspaceAutoReportingMonthlyOffset(policyID: string, autoReportingO } function setWorkspaceApprovalMode(policyID: string, approver: string, approvalMode: ValueOf) { - const isAutoApprovalEnabled = approvalMode === CONST.POLICY.APPROVAL_MODE.BASIC; const policy = ReportUtils.getPolicy(policyID); const value = { approver, approvalMode, - isAutoApprovalEnabled, }; const optimisticData: OnyxUpdate[] = [ @@ -593,8 +606,8 @@ function setWorkspaceApprovalMode(policyID: string, approver: string, approvalMo value: { approver: policy.approver ?? null, approvalMode: policy.approvalMode ?? null, - isAutoApprovalEnabled: policy.isAutoApprovalEnabled ?? null, pendingFields: {approvalMode: null}, + errorFields: {approvalMode: ErrorUtils.getMicroSecondOnyxError('workflowsApprovalPage.genericErrorMessage')}, }, }, ]; @@ -609,7 +622,14 @@ function setWorkspaceApprovalMode(policyID: string, approver: string, approvalMo }, ]; - const params: SetWorkspaceApprovalModeParams = {policyID, value: JSON.stringify(value)}; + const params: SetWorkspaceApprovalModeParams = { + policyID, + value: JSON.stringify({ + ...value, + // This property should now be set to false for all Collect policies + isAutoApprovalEnabled: false, + }), + }; API.write(WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE, params, {optimisticData, failureData, successData}); } @@ -658,8 +678,8 @@ function setWorkspacePayer(policyID: string, reimburserEmail: string, reimburser API.write(WRITE_COMMANDS.SET_WORKSPACE_PAYER, params, {optimisticData, failureData, successData}); } -function clearWorkspacePayerError(policyID: string) { - Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errorFields: {reimburserEmail: null}}); +function clearPolicyErrorField(policyID: string, fieldName: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errorFields: {[fieldName]: null}}); } function setWorkspaceReimbursement(policyID: string, reimbursementChoice: ValueOf, reimburserAccountID: number, reimburserEmail: string) { @@ -1360,6 +1380,7 @@ function updateGeneralSettings(policyID: string, name: string, currency: string) }, }, ]; + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -2165,7 +2186,7 @@ function setWorkspaceInviteMembersDraft(policyID: string, invitedEmailsToAccount Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, invitedEmailsToAccountIDs); } -function setWorkspaceInviteMessageDraft(policyID: string, message: string) { +function setWorkspaceInviteMessageDraft(policyID: string, message: string | null) { Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${policyID}`, message); } @@ -3082,6 +3103,72 @@ function clearPolicyTagErrors(policyID: string, tagName: string) { }); } +function renamePolicyTag(policyID: string, policyTag: {oldName: string; newName: string}) { + const tagListName = Object.keys(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})[0]; + const oldTag = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]?.[policyTag.oldName] ?? {}; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [policyTag.oldName]: null, + [policyTag.newName]: { + ...oldTag, + name: policyTag.newName, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [policyTag.newName]: { + errors: null, + pendingAction: null, + }, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [policyTag.newName]: null, + [policyTag.oldName]: { + ...oldTag, + errors: ErrorUtils.getMicroSecondOnyxError('workspace.tags.genericFailureMessage'), + }, + }, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + oldName: policyTag.oldName, + newName: policyTag.newName, + }; + + API.write(WRITE_COMMANDS.RENAME_POLICY_TAG, parameters, onyxData); +} + function setWorkspaceRequiresCategory(policyID: string, requiresCategory: boolean) { const onyxData: OnyxData = { optimisticData: [ @@ -3330,7 +3417,15 @@ function navigateWhenEnableFeature(policyID: string, featureRoute: Route) { return; } - Navigation.navigate(featureRoute); + /** + * The app needs to set a navigation action to the microtask queue, it guarantees to execute Onyx.update first, then the navigation action. + * More details - https://github.com/Expensify/App/issues/37785#issuecomment-1989056726. + */ + new Promise((resolve) => { + resolve(); + }).then(() => { + Navigation.navigate(featureRoute); + }); } function enablePolicyCategories(policyID: string, enabled: boolean) { @@ -3892,6 +3987,124 @@ function clearCreateDistanceRateItemAndError(policyID: string, customUnitID: str }); } +function clearPolicyDistanceRatesErrorFields(policyID: string, customUnitID: string, updatedErrorFields: ErrorFields) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + customUnits: { + [customUnitID]: { + errorFields: updatedErrorFields, + }, + }, + }); +} + +function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [newCustomUnit.customUnitID]: { + ...newCustomUnit, + pendingFields: {attributes: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [newCustomUnit.customUnitID]: { + pendingFields: null, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [currentCustomUnit.customUnitID]: { + ...currentCustomUnit, + errorFields: {attributes: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, + pendingFields: {attributes: null}, + }, + }, + }, + }, + ]; + + const params: SetPolicyDistanceRatesUnitParams = { + policyID, + customUnit: JSON.stringify(newCustomUnit), + }; + + API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT, params, {optimisticData, successData, failureData}); +} + +function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [newCustomUnit.customUnitID]: { + ...newCustomUnit, + pendingFields: {defaultCategory: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [newCustomUnit.customUnitID]: { + pendingFields: {defaultCategory: null}, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [currentCustomUnit.customUnitID]: { + ...currentCustomUnit, + errorFields: {defaultCategory: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, + pendingFields: {defaultCategory: null}, + }, + }, + }, + }, + ]; + + const params: SetPolicyDistanceRatesDefaultCategoryParams = { + policyID, + customUnit: JSON.stringify(newCustomUnit), + }; + + API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData}); +} + function setPolicyCustomTaxName(policyID: string, customTaxName: string) { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; const originalCustomTaxName = policy?.taxRates?.name; @@ -4106,7 +4319,6 @@ export { renamePolicyCategory, clearCategoryErrors, setWorkspacePayer, - clearWorkspacePayerError, setWorkspaceReimbursement, openPolicyWorkflowsPage, setPolicyRequiresTag, @@ -4123,7 +4335,10 @@ export { generateCustomUnitID, createPolicyDistanceRate, clearCreateDistanceRateItemAndError, + setPolicyDistanceRatesUnit, + setPolicyDistanceRatesDefaultCategory, createPolicyTag, + renamePolicyTag, clearPolicyTagErrors, clearWorkspaceReimbursementErrors, deleteWorkspaceCategories, @@ -4132,4 +4347,7 @@ export { setWorkspaceCurrencyDefault, setForeignCurrencyDefault, setPolicyCustomTaxName, + clearPolicyErrorField, + isCurrencySupportedForDirectReimbursement, + clearPolicyDistanceRatesErrorFields, }; diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 491a75f9c643..681ed0ec383f 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -59,6 +59,14 @@ Onyx.connect({ callback: (value) => (allPersonalDetails = value), }); +let quickAction: OnyxEntry = {}; +Onyx.connect({ + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + callback: (value) => { + quickAction = value; + }, +}); + const allReportActions: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, @@ -227,6 +235,18 @@ function createTaskAndNavigate( }, ); + // FOR QUICK ACTION NVP + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: { + action: CONST.QUICK_ACTIONS.ASSIGN_TASK, + reportID: parentReportID, + isFirstQuickAction: isEmptyObject(quickAction), + targetAccountID: assigneeAccountID, + }, + }); + // If needed, update optimistic data for parent report action of the parent report. const optimisticParentReportData = ReportUtils.getOptimisticDataForParentReportAction(parentReportID, currentTime, CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); if (!isEmptyObject(optimisticParentReportData)) { diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index 1bad1de0a9f5..3f2420c76f87 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -1,14 +1,25 @@ +import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {FormOnyxValues} from '@components/Form/types'; import * as API from '@libs/API'; -import type {CreatePolicyTaxParams} from '@libs/API/parameters'; +import type {CreatePolicyTaxParams, DeletePolicyTaxesParams, RenamePolicyTaxParams, SetPolicyTaxesEnabledParams, UpdatePolicyTaxValueParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; +import * as ValidationUtils from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import * as ErrorUtils from '@src/libs/ErrorUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {TaxRate, TaxRates} from '@src/types/onyx'; -import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import INPUT_IDS from '@src/types/form/WorkspaceNewTaxForm'; +import type {Policy, TaxRate, TaxRates} from '@src/types/onyx'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {OnyxData} from '@src/types/onyx/Request'; +let allPolicies: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (value) => (allPolicies = value), +}); + /** * Get tax value with percentage */ @@ -20,6 +31,34 @@ function covertTaxNameToID(name: string) { return `id_${name.toUpperCase().replaceAll(' ', '_')}`; } +/** + * Function to validate tax name + */ +const validateTaxName = (policy: Policy, values: FormOnyxValues) => { + const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.NAME]); + + const name = values[INPUT_IDS.NAME]; + if (policy?.taxRates?.taxes && ValidationUtils.isExistingTaxName(name, policy.taxRates.taxes)) { + errors[INPUT_IDS.NAME] = 'workspace.taxes.errors.taxRateAlreadyExists'; + } + + return errors; +}; + +/** + * Function to validate tax value + */ +const validateTaxValue = (values: FormOnyxValues) => { + const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.VALUE]); + + const value = values[INPUT_IDS.VALUE]; + if (!ValidationUtils.isValidPercentage(value)) { + errors[INPUT_IDS.VALUE] = 'workspace.taxes.errors.valuePercentageRange'; + } + + return errors; +}; + /** * Get new tax ID */ @@ -39,7 +78,8 @@ function getNextTaxCode(name: string, taxRates?: TaxRates): string { function createPolicyTax(policyID: string, taxRate: TaxRate) { if (!taxRate.code) { - throw new Error('Tax code is required when creating a new tax rate.'); + console.debug('Policy or tax rates not found'); + return; } const onyxData: OnyxData = { @@ -83,7 +123,7 @@ function createPolicyTax(policyID: string, taxRate: TaxRate) { taxRates: { taxes: { [taxRate.code]: { - errors: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.genericFailureMessage'), + errors: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.createFailureMessage'), }, }, }, @@ -105,7 +145,24 @@ function createPolicyTax(policyID: string, taxRate: TaxRate) { API.write(WRITE_COMMANDS.CREATE_POLICY_TAX, parameters, onyxData); } -function clearTaxRateError(policyID: string, taxID: string, pendingAction?: PendingAction) { +function clearTaxRateFieldError(policyID: string, taxID: string, field: keyof TaxRate) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + taxRates: { + taxes: { + [taxID]: { + pendingFields: { + [field]: null, + }, + errorFields: { + [field]: null, + }, + }, + }, + }, + }); +} + +function clearTaxRateError(policyID: string, taxID: string, pendingAction?: OnyxCommon.PendingAction) { if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { taxRates: { @@ -119,10 +176,288 @@ function clearTaxRateError(policyID: string, taxID: string, pendingAction?: Pend Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { taxRates: { taxes: { - [taxID]: {pendingAction: null, errors: null}, + [taxID]: {pendingAction: null, errors: null, errorFields: null}, }, }, }); } -export {createPolicyTax, clearTaxRateError, getNextTaxCode, getTaxValueWithPercentage}; +type TaxRateEnabledMap = Record>; + +function setPolicyTaxesEnabled(policyID: string, taxesIDsToUpdate: string[], isEnabled: boolean) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const originalTaxes = {...policy?.taxRates?.taxes}; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesIDsToUpdate.reduce((acc, taxID) => { + acc[taxID] = { + isDisabled: !isEnabled, + pendingFields: {isDisabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {isDisabled: null}, + }; + return acc; + }, {}), + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesIDsToUpdate.reduce((acc, taxID) => { + acc[taxID] = {pendingFields: {isDisabled: null}, errorFields: {isDisabled: null}}; + return acc; + }, {}), + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesIDsToUpdate.reduce((acc, taxID) => { + acc[taxID] = { + isDisabled: !!originalTaxes[taxID].isDisabled, + pendingFields: {isDisabled: null}, + errorFields: {isDisabled: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.updateFailureMessage')}, + }; + return acc; + }, {}), + }, + }, + }, + ], + }; + + const parameters = { + policyID, + taxFieldsArray: JSON.stringify(taxesIDsToUpdate.map((taxID) => ({taxCode: taxID, enabled: isEnabled}))), + } satisfies SetPolicyTaxesEnabledParams; + + API.write(WRITE_COMMANDS.SET_POLICY_TAXES_ENABLED, parameters, onyxData); +} + +type TaxRateDeleteMap = Record< + string, + | (Pick & { + errors: OnyxCommon.Errors | null; + }) + | null +>; + +function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const policyTaxRates = policy?.taxRates?.taxes; + + if (!policyTaxRates) { + console.debug('Policy or tax rates not found'); + return; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesToDelete.reduce((acc, taxID) => { + acc[taxID] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, errors: null}; + return acc; + }, {}), + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesToDelete.reduce((acc, taxID) => { + acc[taxID] = null; + return acc; + }, {}), + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesToDelete.reduce((acc, taxID) => { + acc[taxID] = { + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.deleteFailureMessage'), + }; + return acc; + }, {}), + }, + }, + }, + ], + }; + + const parameters = { + policyID, + taxNames: JSON.stringify(taxesToDelete.map((taxID) => policyTaxRates[taxID].name)), + } satisfies DeletePolicyTaxesParams; + + API.write(WRITE_COMMANDS.DELETE_POLICY_TAXES, parameters, onyxData); +} + +function updatePolicyTaxValue(policyID: string, taxID: string, taxValue: number) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const originalTaxRate = {...policy?.taxRates?.taxes[taxID]}; + const stringTaxValue = `${taxValue}%`; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: { + value: stringTaxValue, + pendingFields: {value: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {value: null}, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: {pendingFields: {value: null}, errorFields: {value: null}}, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: { + value: originalTaxRate.value, + pendingFields: {value: null}, + errorFields: {value: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.updateFailureMessage')}, + }, + }, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + taxCode: taxID, + taxAmount: Number(taxValue), + } satisfies UpdatePolicyTaxValueParams; + + API.write(WRITE_COMMANDS.UPDATE_POLICY_TAX_VALUE, parameters, onyxData); +} + +function renamePolicyTax(policyID: string, taxID: string, newName: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const originalTaxRate = {...policy?.taxRates?.taxes[taxID]}; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: { + name: newName, + pendingFields: {name: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {name: null}, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: {pendingFields: {name: null}, errorFields: {name: null}}, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: { + name: originalTaxRate.name, + pendingFields: {name: null}, + errorFields: {name: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.updateFailureMessage')}, + }, + }, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + taxCode: taxID, + newName, + } satisfies RenamePolicyTaxParams; + + API.write(WRITE_COMMANDS.RENAME_POLICY_TAX, parameters, onyxData); +} + +export { + createPolicyTax, + getNextTaxCode, + clearTaxRateError, + clearTaxRateFieldError, + getTaxValueWithPercentage, + setPolicyTaxesEnabled, + validateTaxName, + validateTaxValue, + deletePolicyTaxes, + updatePolicyTaxValue, + renamePolicyTax, +}; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 6655d78cb0a8..2d23edfba93f 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -30,6 +30,7 @@ import PusherUtils from '@libs/PusherUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import playSoundExcludingMobile from '@libs/Sound/playSoundExcludingMobile'; +import Visibility from '@libs/Visibility'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -489,80 +490,84 @@ const isChannelMuted = (reportId: string) => function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) { const reportActionsOnly = pushJSON.filter((update) => update.key?.includes('reportActions_')); // "reportActions_5134363522480668" -> "5134363522480668" - const reportIDs = reportActionsOnly.map((value) => value.key.split('_')[1]); + const reportID = reportActionsOnly + .map((value) => value.key.split('_')[1]) + .find((reportKey) => reportKey === Navigation.getTopmostReportId() && Visibility.isVisible() && Visibility.hasFocus()); - Promise.all(reportIDs.map((reportID) => isChannelMuted(reportID))) - .then((muted) => muted.every((isMuted) => isMuted)) - .then((isSoundMuted) => { - if (isSoundMuted) { - return; + if (!reportID) { + return; + } + + isChannelMuted(reportID).then((isSoundMuted) => { + if (isSoundMuted) { + return; + } + + try { + const flatten = reportActionsOnly.flatMap((update) => { + const value = update.value as OnyxCollection; + + if (!value) { + return []; + } + + return Object.values(value); + }) as ReportAction[]; + + for (const data of flatten) { + // Someone completes a task + if (data.actionName === 'TASKCOMPLETED') { + return playSound(SOUNDS.SUCCESS); + } } - try { - const flatten = reportActionsOnly.flatMap((update) => { - const value = update.value as OnyxCollection; + const types = flatten.map((data) => data?.originalMessage).filter(Boolean) as OriginalMessage[]; - if (!value) { - return []; - } + for (const message of types) { + // someone sent money + if ('IOUDetails' in message) { + return playSound(SOUNDS.SUCCESS); + } - return Object.values(value); - }) as ReportAction[]; + // mention user + if ('html' in message && typeof message.html === 'string' && message.html.includes(`@${currentEmail}`)) { + return playSoundExcludingMobile(SOUNDS.ATTENTION); + } + + // mention @here + if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { + return playSoundExcludingMobile(SOUNDS.ATTENTION); + } - for (const data of flatten) { - // Someone completes a task - if (data.actionName === 'TASKCOMPLETED') { - return playSound(SOUNDS.SUCCESS); - } + // assign a task + if ('taskReportID' in message) { + return playSound(SOUNDS.ATTENTION); } - const types = flatten.map((data) => data?.originalMessage).filter(Boolean) as OriginalMessage[]; - - for (const message of types) { - // someone sent money - if ('IOUDetails' in message) { - return playSound(SOUNDS.SUCCESS); - } - - // mention user - if ('html' in message && typeof message.html === 'string' && message.html.includes(`@${currentEmail}`)) { - return playSoundExcludingMobile(SOUNDS.ATTENTION); - } - - // mention @here - if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { - return playSoundExcludingMobile(SOUNDS.ATTENTION); - } - - // assign a task - if ('taskReportID' in message) { - return playSound(SOUNDS.ATTENTION); - } - - // request money - if ('IOUTransactionID' in message) { - return playSound(SOUNDS.ATTENTION); - } - - // Someone completes a money request - if ('IOUReportID' in message) { - return playSound(SOUNDS.SUCCESS); - } - - // plain message - if ('html' in message) { - return playSoundExcludingMobile(SOUNDS.RECEIVE); - } + // request money + if ('IOUTransactionID' in message) { + return playSound(SOUNDS.ATTENTION); } - } catch (e) { - let errorMessage = String(e); - if (e instanceof Error) { - errorMessage = e.message; + + // Someone completes a money request + if ('IOUReportID' in message) { + return playSound(SOUNDS.SUCCESS); } - Log.client(`Unexpected error occurred while parsing the data to play a sound: ${errorMessage}`); + // plain message + if ('html' in message) { + return playSoundExcludingMobile(SOUNDS.RECEIVE); + } } - }); + } catch (e) { + let errorMessage = String(e); + if (e instanceof Error) { + errorMessage = e.message; + } + + Log.client(`Unexpected error occurred while parsing the data to play a sound: ${errorMessage}`); + } + }); } /** @@ -960,11 +965,9 @@ function dismissReferralBanner(type: ValueOf we're either logged-in or shown 2FA screen + // !isSignedIn - confirms we're not signed-in yet as there's possible one last step (2FA validation) + const shouldPopToTop = (autoAuthState === CONST.AUTO_AUTH_STATE.NOT_STARTED || autoAuthState === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN) && !isSignedIn; + + if (shouldPopToTop) { + Navigation.isNavigationReady().then(() => Navigation.resetToHome()); + } +} + +export default desktopLoginRedirect; diff --git a/src/libs/desktopLoginRedirect/index.ts b/src/libs/desktopLoginRedirect/index.ts new file mode 100644 index 000000000000..14f5750c3de9 --- /dev/null +++ b/src/libs/desktopLoginRedirect/index.ts @@ -0,0 +1,5 @@ +import type {AutoAuthState} from '@src/types/onyx/Session'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function desktopLoginRedirect(autoAuthState: AutoAuthState, isSignedIn: boolean) {} +export default desktopLoginRedirect; diff --git a/src/libs/migrateOnyx.ts b/src/libs/migrateOnyx.ts index 536b912e69e0..e8c0b2bf3e10 100644 --- a/src/libs/migrateOnyx.ts +++ b/src/libs/migrateOnyx.ts @@ -1,6 +1,7 @@ import Log from './Log'; import CheckForPreviousReportActionID from './migrations/CheckForPreviousReportActionID'; import KeyReportActionsDraftByReportActionID from './migrations/KeyReportActionsDraftByReportActionID'; +import NVPMigration from './migrations/NVPMigration'; import RemoveEmptyReportActionsDrafts from './migrations/RemoveEmptyReportActionsDrafts'; import RenameReceiptFilename from './migrations/RenameReceiptFilename'; import TransactionBackupsToCollection from './migrations/TransactionBackupsToCollection'; @@ -17,6 +18,7 @@ export default function (): Promise { KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection, RemoveEmptyReportActionsDrafts, + NVPMigration, ]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the diff --git a/src/libs/migrations/NVPMigration.ts b/src/libs/migrations/NVPMigration.ts new file mode 100644 index 000000000000..9ab774328f78 --- /dev/null +++ b/src/libs/migrations/NVPMigration.ts @@ -0,0 +1,86 @@ +import after from 'lodash/after'; +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; + +// These are the oldKeyName: newKeyName of the NVPs we can migrate without any processing +const migrations = { + // eslint-disable-next-line @typescript-eslint/naming-convention + nvp_lastPaymentMethod: ONYXKEYS.NVP_LAST_PAYMENT_METHOD, + isFirstTimeNewExpensifyUser: ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER, + preferredLocale: ONYXKEYS.NVP_PREFERRED_LOCALE, + preferredEmojiSkinTone: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + frequentlyUsedEmojis: ONYXKEYS.FREQUENTLY_USED_EMOJIS, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_blockedFromConcierge: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_pushNotificationID: ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID, + tryFocusMode: ONYXKEYS.NVP_TRY_FOCUS_MODE, + introSelected: ONYXKEYS.NVP_INTRO_SELECTED, + hasDismissedIdlePanel: ONYXKEYS.NVP_HAS_DISMISSED_IDLE_PANEL, +}; + +// This migration changes the keys of all the NVP related keys so that they are standardized +export default function () { + return new Promise((resolve) => { + // Resolve the migration when all the keys have been migrated. The number of keys is the size of the `migrations` object in addition to the ACCOUNT and OLD_POLICY_RECENTLY_USED_TAGS keys (which is why there is a +2). + const resolveWhenDone = after(Object.entries(migrations).length + 2, () => resolve()); + + for (const [oldKey, newKey] of Object.entries(migrations)) { + const connectionID = Onyx.connect({ + // @ts-expect-error oldKey is a variable + key: oldKey, + callback: (value) => { + Onyx.disconnect(connectionID); + if (value === null) { + resolveWhenDone(); + return; + } + // @ts-expect-error These keys are variables, so we can't check the type + Onyx.multiSet({ + [newKey]: value, + [oldKey]: null, + }).then(resolveWhenDone); + }, + }); + } + const connectionIDAccount = Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (value) => { + Onyx.disconnect(connectionIDAccount); + // @ts-expect-error we are removing this property, so it is not in the type anymore + if (!value?.activePolicyID) { + resolveWhenDone(); + return; + } + // @ts-expect-error we are removing this property, so it is not in the type anymore + const activePolicyID = value.activePolicyID; + const newValue = {...value}; + // @ts-expect-error we are removing this property, so it is not in the type anymore + delete newValue.activePolicyID; + Onyx.multiSet({ + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: activePolicyID, + [ONYXKEYS.ACCOUNT]: newValue, + }).then(resolveWhenDone); + }, + }); + const connectionIDRecentlyUsedTags = Onyx.connect({ + key: ONYXKEYS.COLLECTION.OLD_POLICY_RECENTLY_USED_TAGS, + waitForCollectionCallback: true, + callback: (value) => { + Onyx.disconnect(connectionIDRecentlyUsedTags); + if (!value) { + resolveWhenDone(); + return; + } + const newValue = {}; + for (const key of Object.keys(value)) { + // @ts-expect-error We have no fixed types here + newValue[`nvp_${key}`] = value[key]; + // @ts-expect-error We have no fixed types here + newValue[key] = null; + } + Onyx.multiSet(newValue).then(resolveWhenDone); + }, + }); + }); +} diff --git a/src/libs/shouldSkipDeepLinkNavigation/index.web.ts b/src/libs/shouldSkipDeepLinkNavigation/index.web.ts new file mode 100644 index 000000000000..0a2d7f533e74 --- /dev/null +++ b/src/libs/shouldSkipDeepLinkNavigation/index.web.ts @@ -0,0 +1,12 @@ +import ROUTES from '@src/ROUTES'; + +export default function shouldSkipDeepLinkNavigation(route: string) { + // When deep-linking to desktop app with `transition` route we don't want to call navigate + // on the route because it will display an infinite loading indicator. + // See issue: https://github.com/Expensify/App/issues/33149 + if (route.includes(ROUTES.TRANSITION_BETWEEN_APPS)) { + return true; + } + + return false; +} diff --git a/src/pages/EditRequestTagPage.js b/src/pages/EditRequestTagPage.js index 74643afa347f..b64cb925a213 100644 --- a/src/pages/EditRequestTagPage.js +++ b/src/pages/EditRequestTagPage.js @@ -49,7 +49,7 @@ function EditRequestTagPage({defaultTag, policyID, tagName, tagIndex, onSubmit}) title={tagName || translate('common.tag')} onBackButtonPress={Navigation.goBack} /> - {translate('iou.tagSelection', {tagName: tagName || translate('common.tag')})} + {translate('iou.tagSelection')} - - PaymentMethods.continueSetup()} - /> - - ); -} - -ActivateStep.propTypes = propTypes; -ActivateStep.defaultProps = defaultProps; -ActivateStep.displayName = 'ActivateStep'; - -export default compose( - withLocalize, - withOnyx({ - walletTerms: { - key: ONYXKEYS.WALLET_TERMS, - }, - }), -)(ActivateStep); diff --git a/src/pages/EnablePayments/ActivateStep.tsx b/src/pages/EnablePayments/ActivateStep.tsx new file mode 100644 index 000000000000..e0bea7488140 --- /dev/null +++ b/src/pages/EnablePayments/ActivateStep.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import ConfirmationPage from '@components/ConfirmationPage'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import LottieAnimations from '@components/LottieAnimations'; +import useLocalize from '@hooks/useLocalize'; +import * as PaymentMethods from '@userActions/PaymentMethods'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {UserWallet, WalletTerms} from '@src/types/onyx'; + +type ActivateStepOnyxProps = { + /** Information about the user accepting the terms for payments */ + walletTerms: OnyxEntry; +}; + +type ActivateStepProps = ActivateStepOnyxProps & { + /** The user's wallet */ + userWallet: OnyxEntry; +}; + +function ActivateStep({userWallet, walletTerms}: ActivateStepProps) { + const {translate} = useLocalize(); + const isActivatedWallet = userWallet?.tierName && [CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM].some((name) => name === userWallet.tierName); + + const animation = isActivatedWallet ? LottieAnimations.Fireworks : LottieAnimations.ReviewingBankInfo; + let continueButtonText = ''; + + if (walletTerms?.chatReportID) { + continueButtonText = translate('activateStep.continueToPayment'); + } else if (walletTerms?.source === CONST.KYC_WALL_SOURCE.ENABLE_WALLET) { + continueButtonText = translate('common.continue'); + } else { + continueButtonText = translate('activateStep.continueToTransfer'); + } + + return ( + <> + + PaymentMethods.continueSetup()} + /> + + ); +} + +ActivateStep.displayName = 'ActivateStep'; + +export default withOnyx({ + walletTerms: { + key: ONYXKEYS.WALLET_TERMS, + }, +})(ActivateStep); diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.tsx similarity index 71% rename from src/pages/EnablePayments/AdditionalDetailsStep.js rename to src/pages/EnablePayments/AdditionalDetailsStep.tsx index cf769bf58fd3..57b9c7c6ade4 100644 --- a/src/pages/EnablePayments/AdditionalDetailsStep.js +++ b/src/pages/EnablePayments/AdditionalDetailsStep.tsx @@ -1,21 +1,21 @@ import {subYears} from 'date-fns'; -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; import DatePicker from '@components/DatePicker'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import TextLink from '@components/TextLink'; -import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import {parsePhoneNumber} from '@libs/PhoneNumber'; import * as ValidationUtils from '@libs/ValidationUtils'; @@ -23,51 +23,25 @@ import AddressForm from '@pages/ReimbursementAccount/AddressForm'; import * as Wallet from '@userActions/Wallet'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/AdditionalDetailStepForm'; +import type {WalletAdditionalDetails} from '@src/types/onyx'; import IdologyQuestions from './IdologyQuestions'; -const propTypes = { - ...withLocalizePropTypes, - ...withCurrentUserPersonalDetailsPropTypes, +const DEFAULT_WALLET_ADDITIONAL_DETAILS = { + errorFields: {}, + isLoading: false, + errors: {}, + questions: [], + idNumber: '', + errorCode: '', +}; +type AdditionalDetailsStepOnyxProps = { /** Stores additional information about the additional details step e.g. loading state and errors with fields */ - walletAdditionalDetails: PropTypes.shape({ - /** Are we waiting for a response? */ - isLoading: PropTypes.bool, - - /** Which field needs attention? */ - errorFields: PropTypes.objectOf(PropTypes.bool), - - /** Any additional error message to show */ - errors: PropTypes.objectOf(PropTypes.string), - - /** Questions returned by Idology */ - questions: PropTypes.arrayOf( - PropTypes.shape({ - prompt: PropTypes.string, - type: PropTypes.string, - answer: PropTypes.arrayOf(PropTypes.string), - }), - ), - - /** ExpectID ID number related to those questions */ - idNumber: PropTypes.string, - - /** Error code to determine additional behavior */ - errorCode: PropTypes.string, - }), + walletAdditionalDetails: OnyxEntry; }; -const defaultProps = { - walletAdditionalDetails: { - errorFields: {}, - isLoading: false, - errors: {}, - questions: [], - idNumber: '', - errorCode: '', - }, - ...withCurrentUserPersonalDetailsDefaultProps, -}; +type AdditionalDetailsStepProps = AdditionalDetailsStepOnyxProps & WithCurrentUserPersonalDetailsProps; const fieldNameTranslationKeys = { legalFirstName: 'additionalDetailsStep.legalFirstNameLabel', @@ -77,22 +51,28 @@ const fieldNameTranslationKeys = { dob: 'common.dob', ssn: 'common.ssnLast4', ssnFull9: 'common.ssnFull9', -}; - -function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserPersonalDetails}) { +} as const; +const STEP_FIELDS = [ + INPUT_IDS.LEGAL_FIRST_NAME, + INPUT_IDS.LEGAL_LAST_NAME, + INPUT_IDS.ADDRESS_STREET, + INPUT_IDS.ADDRESS_CITY, + INPUT_IDS.ADDRESS_ZIP_CODE, + INPUT_IDS.PHONE_NUMBER, + INPUT_IDS.DOB, + INPUT_IDS.ADDRESS_STATE, + INPUT_IDS.SSN, +]; +function AdditionalDetailsStep({walletAdditionalDetails = DEFAULT_WALLET_ADDITIONAL_DETAILS, currentUserPersonalDetails}: AdditionalDetailsStepProps) { + const {translate} = useLocalize(); const styles = useThemeStyles(); const currentDate = new Date(); const minDate = subYears(currentDate, CONST.DATE_BIRTH.MAX_AGE); const maxDate = subYears(currentDate, CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT); - const shouldAskForFullSSN = walletAdditionalDetails.errorCode === CONST.WALLET.ERROR.SSN; + const shouldAskForFullSSN = walletAdditionalDetails?.errorCode === CONST.WALLET.ERROR.SSN; - /** - * @param {Object} values The values object is passed from FormProvider and contains info for each form element that has an inputID - * @returns {Object} - */ - const validate = (values) => { - const requiredFields = ['legalFirstName', 'legalLastName', 'addressStreet', 'addressCity', 'addressZipCode', 'phoneNumber', 'dob', 'ssn', 'addressState']; - const errors = ValidationUtils.getFieldRequiredErrors(values, requiredFields); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS); if (values.dob) { if (!ValidationUtils.isValidPastDate(values.dob) || !ValidationUtils.meetsMaximumAgeRequirement(values.dob)) { @@ -116,7 +96,7 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP // walletAdditionalDetails stores errors returned by the server. If the server returns an SSN error // then the user needs to provide the full 9 digit SSN. - if (walletAdditionalDetails.errorCode === CONST.WALLET.ERROR.SSN) { + if (walletAdditionalDetails?.errorCode === CONST.WALLET.ERROR.SSN) { if (values.ssn && !ValidationUtils.isValidSSNFullNine(values.ssn)) { errors.ssn = 'additionalDetailsStep.ssnFull9Error'; } @@ -127,26 +107,23 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP return errors; }; - /** - * @param {Object} values The values object is passed from FormProvider and contains info for each form element that has an inputID - */ - const activateWallet = (values) => { + const activateWallet = (values: FormOnyxValues) => { const personalDetails = { - phoneNumber: parsePhoneNumber(values.phoneNumber, {regionCode: CONST.COUNTRY.US}).number.significant || '', - legalFirstName: values.legalFirstName || '', - legalLastName: values.legalLastName || '', - addressStreet: values.addressStreet || '', - addressCity: values.addressCity || '', - addressState: values.addressState || '', - addressZip: values.addressZipCode || '', - dob: values.dob || '', - ssn: values.ssn || '', + phoneNumber: (values.phoneNumber && parsePhoneNumber(values.phoneNumber, {regionCode: CONST.COUNTRY.US}).number?.significant) ?? '', + legalFirstName: values.legalFirstName ?? '', + legalLastName: values.legalLastName ?? '', + addressStreet: values.addressStreet ?? '', + addressCity: values.addressCity ?? '', + addressState: values.addressState ?? '', + addressZip: values.addressZipCode ?? '', + dob: values.dob ?? '', + ssn: values.ssn ?? '', }; // Attempt to set the personal details Wallet.updatePersonalDetails(personalDetails); }; - if (!_.isEmpty(walletAdditionalDetails.questions)) { + if (walletAdditionalDetails?.questions && walletAdditionalDetails.questions.length > 0) { return ( ); @@ -174,13 +151,13 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP {translate('additionalDetailsStep.helpText')} {translate('additionalDetailsStep.helpLink')} - InputComponent={DatePicker} inputID="dob" containerStyles={[styles.mt4]} @@ -256,16 +234,13 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP ); } -AdditionalDetailsStep.propTypes = propTypes; -AdditionalDetailsStep.defaultProps = defaultProps; AdditionalDetailsStep.displayName = 'AdditionalDetailsStep'; -export default compose( - withLocalize, - withCurrentUserPersonalDetails, - withOnyx({ +export default withCurrentUserPersonalDetails( + withOnyx({ + // @ts-expect-error: ONYXKEYS.WALLET_ADDITIONAL_DETAILS is conflicting with ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS walletAdditionalDetails: { key: ONYXKEYS.WALLET_ADDITIONAL_DETAILS, }, - }), -)(AdditionalDetailsStep); + })(AdditionalDetailsStep), +); diff --git a/src/pages/EnablePayments/EnablePaymentsPage.js b/src/pages/EnablePayments/EnablePaymentsPage.tsx similarity index 76% rename from src/pages/EnablePayments/EnablePaymentsPage.js rename to src/pages/EnablePayments/EnablePaymentsPage.tsx index 257eab1d38d3..1384875fe031 100644 --- a/src/pages/EnablePayments/EnablePaymentsPage.js +++ b/src/pages/EnablePayments/EnablePaymentsPage.tsx @@ -1,6 +1,6 @@ import React, {useEffect} from 'react'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -11,34 +11,34 @@ import * as Wallet from '@userActions/Wallet'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {UserWallet} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import ActivateStep from './ActivateStep'; import AdditionalDetailsStep from './AdditionalDetailsStep'; import FailedKYC from './FailedKYC'; // Steps import OnfidoStep from './OnfidoStep'; import TermsStep from './TermsStep'; -import userWalletPropTypes from './userWalletPropTypes'; -const propTypes = { +type EnablePaymentsPageOnyxProps = { /** The user's wallet */ - userWallet: userWalletPropTypes, + userWallet: OnyxEntry; }; -const defaultProps = { - userWallet: {}, -}; +type EnablePaymentsPageProps = EnablePaymentsPageOnyxProps; -function EnablePaymentsPage({userWallet}) { +function EnablePaymentsPage({userWallet}: EnablePaymentsPageProps) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const {isPendingOnfidoResult, hasFailedOnfido} = userWallet; + const {isPendingOnfidoResult, hasFailedOnfido} = userWallet ?? {}; useEffect(() => { if (isOffline) { return; } + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (isPendingOnfidoResult || hasFailedOnfido) { Navigation.navigate(ROUTES.SETTINGS_WALLET, CONST.NAVIGATION.TYPE.UP); return; @@ -47,18 +47,18 @@ function EnablePaymentsPage({userWallet}) { Wallet.openEnablePaymentsPage(); }, [isOffline, isPendingOnfidoResult, hasFailedOnfido]); - if (_.isEmpty(userWallet)) { + if (isEmptyObject(userWallet)) { return ; } return ( {() => { - if (userWallet.errorCode === CONST.WALLET.ERROR.KYC) { + if (userWallet?.errorCode === CONST.WALLET.ERROR.KYC) { return ( <> ({ userWallet: { key: ONYXKEYS.USER_WALLET, diff --git a/src/pages/EnablePayments/FailedKYC.js b/src/pages/EnablePayments/FailedKYC.tsx similarity index 65% rename from src/pages/EnablePayments/FailedKYC.js rename to src/pages/EnablePayments/FailedKYC.tsx index fc54ea9c1074..6b393229d62f 100644 --- a/src/pages/EnablePayments/FailedKYC.js +++ b/src/pages/EnablePayments/FailedKYC.tsx @@ -2,35 +2,31 @@ import React from 'react'; import {View} from 'react-native'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -const propTypes = { - ...withLocalizePropTypes, -}; - -function FailedKYC(props) { +function FailedKYC() { + const {translate} = useLocalize(); const styles = useThemeStyles(); return ( - {props.translate('additionalDetailsStep.failedKYCTextBefore')} + {translate('additionalDetailsStep.failedKYCTextBefore')} {CONST.EMAIL.CONCIERGE} - {props.translate('additionalDetailsStep.failedKYCTextAfter')} + {translate('additionalDetailsStep.failedKYCTextAfter')} ); } -FailedKYC.propTypes = propTypes; FailedKYC.displayName = 'FailedKYC'; -export default withLocalize(FailedKYC); +export default FailedKYC; diff --git a/src/pages/EnablePayments/IdologyQuestions.js b/src/pages/EnablePayments/IdologyQuestions.tsx similarity index 68% rename from src/pages/EnablePayments/IdologyQuestions.js rename to src/pages/EnablePayments/IdologyQuestions.tsx index a0c202b0bbbc..6baea2158613 100644 --- a/src/pages/EnablePayments/IdologyQuestions.js +++ b/src/pages/EnablePayments/IdologyQuestions.tsx @@ -1,64 +1,48 @@ -import PropTypes from 'prop-types'; import React, {useState} from 'react'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {WalletAdditionalQuestionDetails} from 'src/types/onyx'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import type {Choice} from '@components/RadioButtons'; import SingleChoiceQuestion from '@components/SingleChoiceQuestion'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as BankAccounts from '@userActions/BankAccounts'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; const MAX_SKIP = 1; const SKIP_QUESTION_TEXT = 'Skip Question'; -const propTypes = { +type IdologyQuestionsProps = { /** Questions returned by Idology */ /** example: [{"answer":["1251","6253","113","None of the above","Skip Question"],"prompt":"Which number goes with your address on MASONIC AVE?","type":"street.number.b"}, ...] */ - questions: PropTypes.arrayOf( - PropTypes.shape({ - prompt: PropTypes.string, - type: PropTypes.string, - answer: PropTypes.arrayOf(PropTypes.string), - }), - ), + questions: WalletAdditionalQuestionDetails[]; /** ID from Idology, referencing those questions */ - idNumber: PropTypes.string, - - walletAdditionalDetails: PropTypes.shape({ - /** Are we waiting for a response? */ - isLoading: PropTypes.bool, - - /** Any additional error message to show */ - errors: PropTypes.objectOf(PropTypes.string), - - /** What error do we need to handle */ - errorCode: PropTypes.string, - }), + idNumber: string; }; -const defaultProps = { - questions: [], - idNumber: '', - walletAdditionalDetails: {}, +type Answer = { + question: string; + answer: string; }; -function IdologyQuestions({questions, idNumber}) { +function IdologyQuestions({questions, idNumber}: IdologyQuestionsProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [shouldHideSkipAnswer, setShouldHideSkipAnswer] = useState(false); - const [userAnswers, setUserAnswers] = useState([]); + const [userAnswers, setUserAnswers] = useState([]); const currentQuestion = questions[currentQuestionIndex] || {}; - const possibleAnswers = _.filter( - _.map(currentQuestion.answer, (answer) => { + const possibleAnswers: Choice[] = currentQuestion.answer + .map((answer) => { if (shouldHideSkipAnswer && answer === SKIP_QUESTION_TEXT) { return; } @@ -67,15 +51,11 @@ function IdologyQuestions({questions, idNumber}) { label: answer, value: answer, }; - }), - ); + }) + .filter((answer): answer is Choice => answer !== undefined); - /** - * Put question answer in the state. - * @param {String} answer - */ - const chooseAnswer = (answer) => { - const tempAnswers = _.map(userAnswers, _.clone); + const chooseAnswer = (answer: string) => { + const tempAnswers: Answer[] = userAnswers.map((userAnswer) => ({...userAnswer})); tempAnswers[currentQuestionIndex] = {question: currentQuestion.type, answer}; @@ -90,11 +70,11 @@ function IdologyQuestions({questions, idNumber}) { return; } // Get the number of questions that were skipped by the user. - const skippedQuestionsCount = _.filter(userAnswers, (answer) => answer.answer === SKIP_QUESTION_TEXT).length; + const skippedQuestionsCount = userAnswers.filter((answer) => answer.answer === SKIP_QUESTION_TEXT).length; // We have enough answers, let's call expectID KBA to verify them if (userAnswers.length - skippedQuestionsCount >= questions.length - MAX_SKIP) { - const tempAnswers = _.map(userAnswers, _.clone); + const tempAnswers: Answer[] = userAnswers.map((answer) => ({...answer})); // Auto skip any remaining questions if (tempAnswers.length < questions.length) { @@ -112,8 +92,8 @@ function IdologyQuestions({questions, idNumber}) { } }; - const validate = (values) => { - const errors = {}; + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: Errors = {}; if (!values.answer) { errors.answer = translate('additionalDetailsStep.selectAnswer'); } @@ -126,13 +106,13 @@ function IdologyQuestions({questions, idNumber}) { {translate('additionalDetailsStep.helpTextIdologyQuestions')} {translate('additionalDetailsStep.helpLink')} { + chooseAnswer(String(value)); + }} + onInputChange={() => {}} /> @@ -155,11 +138,5 @@ function IdologyQuestions({questions, idNumber}) { } IdologyQuestions.displayName = 'IdologyQuestions'; -IdologyQuestions.propTypes = propTypes; -IdologyQuestions.defaultProps = defaultProps; - -export default withOnyx({ - walletAdditionalDetails: { - key: ONYXKEYS.WALLET_ADDITIONAL_DETAILS, - }, -})(IdologyQuestions); + +export default IdologyQuestions; diff --git a/src/pages/EnablePayments/OnfidoPrivacy.js b/src/pages/EnablePayments/OnfidoPrivacy.tsx similarity index 58% rename from src/pages/EnablePayments/OnfidoPrivacy.js rename to src/pages/EnablePayments/OnfidoPrivacy.tsx index 8de8bdb4bf07..cf6e6837df16 100644 --- a/src/pages/EnablePayments/OnfidoPrivacy.js +++ b/src/pages/EnablePayments/OnfidoPrivacy.tsx @@ -1,54 +1,57 @@ -import lodashGet from 'lodash/get'; import React, {useRef} from 'react'; import {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {ScrollView} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; import FixedFooter from '@components/FixedFooter'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import FormScrollView from '@components/FormScrollView'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as BankAccounts from '@userActions/BankAccounts'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import walletOnfidoDataPropTypes from './walletOnfidoDataPropTypes'; +import type {WalletOnfido} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; -const propTypes = { - /** Stores various information used to build the UI and call any APIs */ - walletOnfidoData: walletOnfidoDataPropTypes, - - ...withLocalizePropTypes, +const DEFAULT_WALLET_ONFIDO_DATA = { + applicantID: '', + sdkToken: '', + loading: false, + errors: {}, + fixableErrors: [], + hasAcceptedPrivacyPolicy: false, }; -const defaultProps = { - walletOnfidoData: { - applicantID: '', - sdkToken: '', - loading: false, - errors: {}, - fixableErrors: [], - hasAcceptedPrivacyPolicy: false, - }, +type OnfidoPrivacyOnyxProps = { + /** Stores various information used to build the UI and call any APIs */ + walletOnfidoData: OnyxEntry; }; -function OnfidoPrivacy({walletOnfidoData, translate, form}) { +type OnfidoPrivacyProps = OnfidoPrivacyOnyxProps; + +function OnfidoPrivacy({walletOnfidoData = DEFAULT_WALLET_ONFIDO_DATA}: OnfidoPrivacyProps) { + const {translate} = useLocalize(); + const formRef = useRef(null); const styles = useThemeStyles(); + if (!walletOnfidoData) { + return; + } const {isLoading = false, hasAcceptedPrivacyPolicy} = walletOnfidoData; - const formRef = useRef(null); - const openOnfidoFlow = () => { BankAccounts.openOnfidoFlow(); }; - const onfidoError = ErrorUtils.getLatestErrorMessage(walletOnfidoData) || ''; - const onfidoFixableErrors = lodashGet(walletOnfidoData, 'fixableErrors', []); - if (_.isArray(onfidoError)) { - onfidoError[0] += !_.isEmpty(onfidoFixableErrors) ? `\n${onfidoFixableErrors.join('\n')}` : ''; + const onfidoError = ErrorUtils.getLatestErrorMessage(walletOnfidoData) ?? ''; + const onfidoFixableErrors = walletOnfidoData?.fixableErrors ?? []; + if (Array.isArray(onfidoError)) { + onfidoError[0] += !isEmptyObject(onfidoFixableErrors) ? `\n${onfidoFixableErrors.join('\n')}` : ''; } return ( @@ -59,11 +62,11 @@ function OnfidoPrivacy({walletOnfidoData, translate, form}) { {translate('onfidoStep.acceptTerms')} - {translate('onfidoStep.facialScan')} + {translate('onfidoStep.facialScan')} {', '} - {translate('common.privacy')} + {translate('common.privacy')} {` ${translate('common.and')} `} - {translate('common.termsOfService')}. + {translate('common.termsOfService')}. @@ -72,7 +75,7 @@ function OnfidoPrivacy({walletOnfidoData, translate, form}) { isAlertVisible={Boolean(onfidoError)} onSubmit={openOnfidoFlow} onFixTheErrorsLinkPressed={() => { - form.scrollTo({y: 0, animated: true}); + formRef.current?.scrollTo({y: 0, animated: true}); }} message={onfidoError} isLoading={isLoading} @@ -87,18 +90,13 @@ function OnfidoPrivacy({walletOnfidoData, translate, form}) { ); } -OnfidoPrivacy.propTypes = propTypes; -OnfidoPrivacy.defaultProps = defaultProps; OnfidoPrivacy.displayName = 'OnfidoPrivacy'; -export default compose( - withLocalize, - withOnyx({ - walletOnfidoData: { - key: ONYXKEYS.WALLET_ONFIDO, +export default withOnyx({ + walletOnfidoData: { + key: ONYXKEYS.WALLET_ONFIDO, - // Let's get a new onfido token each time the user hits this flow (as it should only be once) - initWithStoredValues: false, - }, - }), -)(OnfidoPrivacy); + // Let's get a new onfido token each time the user hits this flow (as it should only be once) + initWithStoredValues: false, + }, +})(OnfidoPrivacy); diff --git a/src/pages/EnablePayments/OnfidoStep.js b/src/pages/EnablePayments/OnfidoStep.tsx similarity index 69% rename from src/pages/EnablePayments/OnfidoStep.js rename to src/pages/EnablePayments/OnfidoStep.tsx index d6279020bca9..f5a600cc8548 100644 --- a/src/pages/EnablePayments/OnfidoStep.js +++ b/src/pages/EnablePayments/OnfidoStep.tsx @@ -1,8 +1,10 @@ import React, {useCallback} from 'react'; import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Onfido from '@components/Onfido'; +import type {OnfidoData} from '@components/Onfido/types'; import useLocalize from '@hooks/useLocalize'; import Growl from '@libs/Growl'; import Navigation from '@libs/Navigation/Navigation'; @@ -10,25 +12,25 @@ import * as BankAccounts from '@userActions/BankAccounts'; import * as Wallet from '@userActions/Wallet'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {WalletOnfido} from '@src/types/onyx'; import OnfidoPrivacy from './OnfidoPrivacy'; -import walletOnfidoDataPropTypes from './walletOnfidoDataPropTypes'; -const propTypes = { - /** Stores various information used to build the UI and call any APIs */ - walletOnfidoData: walletOnfidoDataPropTypes, +const DEFAULT_WALLET_ONFIDO_DATA = { + loading: false, + hasAcceptedPrivacyPolicy: false, }; -const defaultProps = { - walletOnfidoData: { - loading: false, - hasAcceptedPrivacyPolicy: false, - }, +type OnfidoStepOnyxProps = { + /** Stores various information used to build the UI and call any APIs */ + walletOnfidoData: OnyxEntry; }; -function OnfidoStep({walletOnfidoData}) { +type OnfidoStepProps = OnfidoStepOnyxProps; + +function OnfidoStep({walletOnfidoData = DEFAULT_WALLET_ONFIDO_DATA}: OnfidoStepProps) { const {translate} = useLocalize(); - const shouldShowOnfido = walletOnfidoData.hasAcceptedPrivacyPolicy && !walletOnfidoData.isLoading && !walletOnfidoData.error && walletOnfidoData.sdkToken; + const shouldShowOnfido = walletOnfidoData?.hasAcceptedPrivacyPolicy && !walletOnfidoData.isLoading && !walletOnfidoData.errors && walletOnfidoData.sdkToken; const goBack = useCallback(() => { Navigation.goBack(); @@ -43,15 +45,15 @@ function OnfidoStep({walletOnfidoData}) { }, [translate]); const verifyIdentity = useCallback( - (data) => { + (data: OnfidoData) => { BankAccounts.verifyIdentity({ onfidoData: JSON.stringify({ ...data, - applicantID: walletOnfidoData.applicantID, + applicantID: walletOnfidoData?.applicantID, }), }); }, - [walletOnfidoData.applicantID], + [walletOnfidoData?.applicantID], ); return ( @@ -63,24 +65,22 @@ function OnfidoStep({walletOnfidoData}) { {shouldShowOnfido ? ( ) : ( - + )} ); } -OnfidoStep.propTypes = propTypes; -OnfidoStep.defaultProps = defaultProps; OnfidoStep.displayName = 'OnfidoStep'; -export default withOnyx({ +export default withOnyx({ walletOnfidoData: { key: ONYXKEYS.WALLET_ONFIDO, diff --git a/src/pages/EnablePayments/TermsPage/LongTermsForm.js b/src/pages/EnablePayments/TermsPage/LongTermsForm.tsx similarity index 98% rename from src/pages/EnablePayments/TermsPage/LongTermsForm.js rename to src/pages/EnablePayments/TermsPage/LongTermsForm.tsx index fad19c5ecf6f..81d18c5dfc44 100644 --- a/src/pages/EnablePayments/TermsPage/LongTermsForm.js +++ b/src/pages/EnablePayments/TermsPage/LongTermsForm.tsx @@ -1,6 +1,5 @@ import React from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; import CollapsibleSection from '@components/CollapsibleSection'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -66,7 +65,7 @@ function LongTermsForm() { ]; const getLongTermsSections = () => - _.map(termsData, (section, index) => ( + termsData.map((section, index) => ( // eslint-disable-next-line react/no-array-index-key @@ -105,7 +104,6 @@ function LongTermsForm() { ; }; -const defaultProps = { - userWallet: {}, -}; - -function ShortTermsForm(props) { +function ShortTermsForm(props: ShortTermsFormProps) { const styles = useThemeStyles(); const {translate, numberFormat} = useLocalize(); return ( @@ -25,7 +22,9 @@ function ShortTermsForm(props) { {translate('termsStep.shortTermsForm.expensifyPaymentsAccount', { walletProgram: - props.userWallet.walletProgramID === CONST.WALLET.MTL_WALLET_PROGRAM_ID ? CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS : CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK, + props.userWallet?.walletProgramID === CONST.WALLET.MTL_WALLET_PROGRAM_ID + ? CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS + : CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK, })} @@ -150,8 +149,6 @@ function ShortTermsForm(props) { ); } -ShortTermsForm.propTypes = propTypes; -ShortTermsForm.defaultProps = defaultProps; ShortTermsForm.displayName = 'ShortTermsForm'; export default ShortTermsForm; diff --git a/src/pages/EnablePayments/TermsStep.js b/src/pages/EnablePayments/TermsStep.tsx similarity index 69% rename from src/pages/EnablePayments/TermsStep.js rename to src/pages/EnablePayments/TermsStep.tsx index a55816d207be..916a5200a2e0 100644 --- a/src/pages/EnablePayments/TermsStep.js +++ b/src/pages/EnablePayments/TermsStep.tsx @@ -1,36 +1,30 @@ import React, {useEffect, useState} from 'react'; import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import CheckboxWithLabel from '@components/CheckboxWithLabel'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as BankAccounts from '@userActions/BankAccounts'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {UserWallet, WalletTerms} from '@src/types/onyx'; import LongTermsForm from './TermsPage/LongTermsForm'; import ShortTermsForm from './TermsPage/ShortTermsForm'; -import userWalletPropTypes from './userWalletPropTypes'; -import walletTermsPropTypes from './walletTermsPropTypes'; - -const propTypes = { - /** The user's wallet */ - userWallet: userWalletPropTypes, +type TermsStepOnyxProps = { /** Comes from Onyx. Information about the terms for the wallet */ - walletTerms: walletTermsPropTypes, - - ...withLocalizePropTypes, + walletTerms: OnyxEntry; }; -const defaultProps = { - userWallet: {}, - walletTerms: {}, +type TermsStepProps = TermsStepOnyxProps & { + /** The user's wallet */ + userWallet: OnyxEntry; }; function HaveReadAndAgreeLabel() { @@ -39,7 +33,7 @@ function HaveReadAndAgreeLabel() { return ( {`${translate('termsStep.haveReadAndAgree')}`} - {`${translate('termsStep.electronicDisclosures')}.`} + {`${translate('termsStep.electronicDisclosures')}.`} ); } @@ -50,20 +44,21 @@ function AgreeToTheLabel() { return ( {`${translate('termsStep.agreeToThe')} `} - {`${translate('common.privacy')} `} + {`${translate('common.privacy')} `} {`${translate('common.and')} `} - {`${translate('termsStep.walletAgreement')}.`} + {`${translate('termsStep.walletAgreement')}.`} ); } -function TermsStep(props) { +function TermsStep(props: TermsStepProps) { const styles = useThemeStyles(); const [hasAcceptedDisclosure, setHasAcceptedDisclosure] = useState(false); const [hasAcceptedPrivacyPolicyAndWalletAgreement, setHasAcceptedPrivacyPolicyAndWalletAgreement] = useState(false); const [error, setError] = useState(false); + const {translate} = useLocalize(); - const errorMessage = error ? 'common.error.acceptTerms' : ErrorUtils.getLatestErrorMessage(props.walletTerms) || ''; + const errorMessage = error ? 'common.error.acceptTerms' : ErrorUtils.getLatestErrorMessage(props.walletTerms ?? {}) ?? ''; const toggleDisclosure = () => { setHasAcceptedDisclosure(!hasAcceptedDisclosure); @@ -84,7 +79,7 @@ function TermsStep(props) { return ( <> - + { if (!hasAcceptedDisclosure || !hasAcceptedPrivacyPolicyAndWalletAgreement) { setError(true); @@ -114,12 +109,12 @@ function TermsStep(props) { setError(false); BankAccounts.acceptWalletTerms({ hasAcceptedTerms: hasAcceptedDisclosure && hasAcceptedPrivacyPolicyAndWalletAgreement, - reportID: props.walletTerms.chatReportID, + reportID: props.walletTerms?.chatReportID ?? '', }); }} message={errorMessage} isAlertVisible={error || Boolean(errorMessage)} - isLoading={!!props.walletTerms.isLoading} + isLoading={!!props.walletTerms?.isLoading} containerStyles={[styles.mh0, styles.mv4]} /> @@ -128,13 +123,9 @@ function TermsStep(props) { } TermsStep.displayName = 'TermsPage'; -TermsStep.propTypes = propTypes; -TermsStep.defaultProps = defaultProps; -export default compose( - withLocalize, - withOnyx({ - walletTerms: { - key: ONYXKEYS.WALLET_TERMS, - }, - }), -)(TermsStep); + +export default withOnyx({ + walletTerms: { + key: ONYXKEYS.WALLET_TERMS, + }, +})(TermsStep); diff --git a/src/pages/EnablePayments/walletAdditionalDetailsDraftPropTypes.js b/src/pages/EnablePayments/walletAdditionalDetailsDraftPropTypes.js deleted file mode 100644 index 747fa82f9fa3..000000000000 --- a/src/pages/EnablePayments/walletAdditionalDetailsDraftPropTypes.js +++ /dev/null @@ -1,13 +0,0 @@ -import PropTypes from 'prop-types'; - -export default PropTypes.shape({ - legalFirstName: PropTypes.string, - legalLastName: PropTypes.string, - addressStreet: PropTypes.string, - addressCity: PropTypes.string, - addressState: PropTypes.string, - addressZip: PropTypes.string, - phoneNumber: PropTypes.string, - dob: PropTypes.string, - ssn: PropTypes.string, -}); diff --git a/src/pages/EnablePayments/walletOnfidoDataPropTypes.js b/src/pages/EnablePayments/walletOnfidoDataPropTypes.js deleted file mode 100644 index cedc1f2777b5..000000000000 --- a/src/pages/EnablePayments/walletOnfidoDataPropTypes.js +++ /dev/null @@ -1,21 +0,0 @@ -import PropTypes from 'prop-types'; - -export default PropTypes.shape({ - /** Unique identifier returned from openOnfidoFlow then re-sent to ActivateWallet with Onfido response data */ - applicantID: PropTypes.string, - - /** Token used to initialize the Onfido SDK token */ - sdkToken: PropTypes.string, - - /** Loading state to provide feedback when we are waiting for a request to finish */ - loading: PropTypes.bool, - - /** Error message to inform the user of any problem that might occur */ - error: PropTypes.string, - - /** A list of Onfido errors that the user can fix in order to attempt the Onfido flow again */ - fixableErrors: PropTypes.arrayOf(PropTypes.string), - - /** Whether the user has accepted the privacy policy of Onfido or not */ - hasAcceptedPrivacyPolicy: PropTypes.bool, -}); diff --git a/src/pages/EnablePayments/walletTermsPropTypes.js b/src/pages/EnablePayments/walletTermsPropTypes.js deleted file mode 100644 index 4420a2dd0861..000000000000 --- a/src/pages/EnablePayments/walletTermsPropTypes.js +++ /dev/null @@ -1,18 +0,0 @@ -import PropTypes from 'prop-types'; -import _ from 'underscore'; -import CONST from '@src/CONST'; - -/** Prop types related to the Terms step of KYC flow */ -export default PropTypes.shape({ - /** Any error message to show */ - errors: PropTypes.objectOf(PropTypes.string), - - /** The source that triggered the KYC wall */ - source: PropTypes.oneOf(_.values(CONST.KYC_WALL_SOURCE)), - - /** When the user accepts the Wallet's terms in order to pay an IOU, this is the ID of the chatReport the IOU is linked to */ - chatReportID: PropTypes.string, - - /** Boolean to indicate whether the submission of wallet terms is being processed */ - isLoading: PropTypes.bool, -}); diff --git a/src/pages/LogOutPreviousUserPage.tsx b/src/pages/LogOutPreviousUserPage.tsx index 3df7a4cdb9f3..37402ea8b048 100644 --- a/src/pages/LogOutPreviousUserPage.tsx +++ b/src/pages/LogOutPreviousUserPage.tsx @@ -1,22 +1,27 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useEffect} from 'react'; -import {Linking} from 'react-native'; +import React, {useContext, useEffect} from 'react'; +import {NativeModules} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import Navigation from '@libs/Navigation/Navigation'; +import {InitialURLContext} from '@components/InitialURLContextProvider'; import * as SessionUtils from '@libs/SessionUtils'; +import Navigation from '@navigation/Navigation'; import type {AuthScreensParamList} from '@navigation/types'; import * as SessionActions from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {Session} from '@src/types/onyx'; type LogOutPreviousUserPageOnyxProps = { /** The data about the current session which will be set once the user is authenticated and we return to this component as an AuthScreen */ session: OnyxEntry; + + /** Is the account loading? */ + isAccountLoading: boolean; }; type LogOutPreviousUserPageProps = LogOutPreviousUserPageOnyxProps & StackScreenProps; @@ -25,42 +30,55 @@ type LogOutPreviousUserPageProps = LogOutPreviousUserPageOnyxProps & StackScreen // out if the transition is for another user. // // This component should not do any other navigation as that handled in App.setUpPoliciesAndNavigate -function LogOutPreviousUserPage({session, route}: LogOutPreviousUserPageProps) { +function LogOutPreviousUserPage({session, route, isAccountLoading}: LogOutPreviousUserPageProps) { + const initialURL = useContext(InitialURLContext); useEffect(() => { - Linking.getInitialURL().then((transitionURL) => { - const sessionEmail = session?.email; - const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL ?? undefined, sessionEmail); - const isSupportalLogin = route.params.authTokenType === CONST.AUTH_TOKEN_TYPES.SUPPORT; + const sessionEmail = session?.email; + const transitionURL = NativeModules.HybridAppModule ? `${CONST.DEEPLINK_BASE_URL}${initialURL ?? ''}` : initialURL; + const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL ?? undefined, sessionEmail); + const isSupportalLogin = route.params.authTokenType === CONST.AUTH_TOKEN_TYPES.SUPPORT; - if (isLoggingInAsNewUser) { - SessionActions.signOutAndRedirectToSignIn(false, isSupportalLogin); - } + if (isLoggingInAsNewUser) { + SessionActions.signOutAndRedirectToSignIn(false, isSupportalLogin); + return; + } - if (isSupportalLogin) { - SessionActions.signInWithSupportAuthToken(route.params.shortLivedAuthToken ?? ''); - Navigation.isNavigationReady().then(() => { - // We must call goBack() to remove the /transition route from history - Navigation.goBack(); - Navigation.navigate(ROUTES.HOME); - }); - return; - } + if (isSupportalLogin) { + SessionActions.signInWithSupportAuthToken(route.params.shortLivedAuthToken ?? ''); + Navigation.isNavigationReady().then(() => { + // We must call goBack() to remove the /transition route from history + Navigation.goBack(); + Navigation.navigate(ROUTES.HOME); + }); + return; + } - // We need to signin and fetch a new authToken, if a user was already authenticated in NewDot, and was redirected to OldDot - // and their authToken stored in Onyx becomes invalid. - // This workflow is triggered while setting up VBBA. User is redirected from NewDot to OldDot to set up 2FA, and then redirected back to NewDot - // On Enabling 2FA, authToken stored in Onyx becomes expired and hence we need to fetch new authToken - const shouldForceLogin = route.params.shouldForceLogin === 'true'; - if (shouldForceLogin) { - const email = route.params.email ?? ''; - const shortLivedAuthToken = route.params.shortLivedAuthToken ?? ''; - SessionActions.signInWithShortLivedAuthToken(email, shortLivedAuthToken); - } - }); + // We need to signin and fetch a new authToken, if a user was already authenticated in NewDot, and was redirected to OldDot + // and their authToken stored in Onyx becomes invalid. + // This workflow is triggered while setting up VBBA. User is redirected from NewDot to OldDot to set up 2FA, and then redirected back to NewDot + // On Enabling 2FA, authToken stored in Onyx becomes expired and hence we need to fetch new authToken + const shouldForceLogin = route.params.shouldForceLogin === 'true'; + if (shouldForceLogin) { + const email = route.params.email ?? ''; + const shortLivedAuthToken = route.params.shortLivedAuthToken ?? ''; + SessionActions.signInWithShortLivedAuthToken(email, shortLivedAuthToken); + } + const exitTo = route.params.exitTo as Route | null; + // We don't want to navigate to the exitTo route when creating a new workspace from a deep link, + // because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate, + // which is already called when AuthScreens mounts. + if (exitTo && exitTo !== ROUTES.WORKSPACE_NEW && !isAccountLoading && !isLoggingInAsNewUser) { + Navigation.isNavigationReady().then(() => { + // remove this screen and navigate to exit route + const exitUrl = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : exitTo; + Navigation.goBack(); + Navigation.navigate(exitUrl); + }); + } // We only want to run this effect once on mount (when the page first loads after transitioning from OldDot) // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [initialURL, isAccountLoading]); return ; } @@ -68,6 +86,10 @@ function LogOutPreviousUserPage({session, route}: LogOutPreviousUserPageProps) { LogOutPreviousUserPage.displayName = 'LogOutPreviousUserPage'; export default withOnyx({ + isAccountLoading: { + key: ONYXKEYS.ACCOUNT, + selector: (account) => account?.isLoading ?? false, + }, session: { key: ONYXKEYS.SESSION, }, diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index 72393e89ae1a..f4eccd52c78e 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -22,7 +22,6 @@ import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import type {DismissedReferralBanners} from '@src/types/onyx/Account'; type NewChatPageWithOnyxProps = { /** All reports shared with the user */ @@ -34,7 +33,7 @@ type NewChatPageWithOnyxProps = { betas: OnyxEntry; /** An object that holds data about which referral banners have been dismissed */ - dismissedReferralBanners: DismissedReferralBanners; + dismissedReferralBanners: OnyxEntry; /** Whether we are searching for reports in the server */ isSearchingForReports: OnyxEntry; @@ -265,7 +264,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingF shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} shouldShowOptions={isOptionsDataReady && didScreenTransitionEnd} shouldShowConfirmButton - shouldShowReferralCTA={!dismissedReferralBanners[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]} + shouldShowReferralCTA={!dismissedReferralBanners?.[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]} referralContentType={CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT} confirmButtonText={selectedOptions.length > 1 ? translate('newChatPage.createGroup') : translate('newChatPage.createChat')} textInputAlert={isOffline ? [`${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}`, {isTranslated: true}] : ''} @@ -287,8 +286,7 @@ NewChatPage.displayName = 'NewChatPage'; export default withOnyx({ dismissedReferralBanners: { - key: ONYXKEYS.ACCOUNT, - selector: (data) => data?.dismissedReferralBanners ?? {}, + key: ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS, }, reports: { key: ONYXKEYS.COLLECTION.REPORT, diff --git a/src/pages/ReimbursementAccount/BankAccountStep.js b/src/pages/ReimbursementAccount/BankAccountStep.js index 666e233196b6..e8aacadc6e1a 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.js +++ b/src/pages/ReimbursementAccount/BankAccountStep.js @@ -185,7 +185,7 @@ function BankAccountStep(props) { )} - {props.translate('common.privacy')} + {props.translate('common.privacy')} Link.openExternalLink('https://community.expensify.com/discussion/5677/deep-dive-how-expensify-protects-your-information/')} style={[styles.flexRow, styles.alignItemsCenter]} diff --git a/src/pages/ReimbursementAccount/CompleteVerification/substeps/ConfirmAgreements.tsx b/src/pages/ReimbursementAccount/CompleteVerification/substeps/ConfirmAgreements.tsx index 208f7a19ddf3..03a178f186ee 100644 --- a/src/pages/ReimbursementAccount/CompleteVerification/substeps/ConfirmAgreements.tsx +++ b/src/pages/ReimbursementAccount/CompleteVerification/substeps/ConfirmAgreements.tsx @@ -11,6 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import type {SubStepProps} from '@hooks/useSubStep/types'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ValidationUtils from '@libs/ValidationUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import type {ReimbursementAccount} from '@src/types/onyx'; @@ -62,7 +63,7 @@ function TermsAndConditionsLabel() { return ( {translate('common.iAcceptThe')} - {`${translate('completeVerificationStep.termsAndConditions')}`} + {`${translate('completeVerificationStep.termsAndConditions')}`} ); } diff --git a/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js b/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js index 5d5fa080ff92..6ce8356e7273 100644 --- a/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js +++ b/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js @@ -65,12 +65,13 @@ function ContinueBankAccountSetup(props) { > {props.translate('workspace.bankAccount.youreAlmostDone')}