diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index 831ec0c0b95e..d4577e112d59 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -46,6 +46,7 @@ jobs: - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca id: deploy + if: github.event_name != 'pull_request' || (github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork) with: apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 4d6597334447..91e244a0ed7c 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -157,6 +157,8 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_PRODUCTION }} + - name: Build staging desktop app if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} @@ -168,6 +170,7 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_STAGING }} iOS: name: Build and deploy iOS diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index 3f02430f3c1f..fc9e75e626d3 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -265,6 +265,7 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_STAGING }} web: name: Build and deploy Web 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..9c5db608a846 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 1001045503 + versionName "1.4.55-3" } flavorDimensions "default" diff --git a/assets/images/document-plus.svg b/assets/images/document-plus.svg new file mode 100644 index 000000000000..cce2e3027cea --- /dev/null +++ b/assets/images/document-plus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/desktop/main.ts b/desktop/main.ts index cbc12d9d2608..6e14d661b345 100644 --- a/desktop/main.ts +++ b/desktop/main.ts @@ -21,7 +21,7 @@ const {DESKTOP_SHORTCUT_ACCELERATOR, LOCALES} = CONST; // Setup google api key in process environment, we are setting it this way intentionally. It is required by the // geolocation api (window.navigator.geolocation.getCurrentPosition) to work on desktop. // Source: https://github.com/electron/electron/blob/98cd16d336f512406eee3565be1cead86514db7b/docs/api/environment-variables.md#google_api_key -process.env.GOOGLE_API_KEY = CONFIG.GOOGLE_GEOLOCATION_API_KEY; +process.env.GOOGLE_API_KEY = CONFIG.GCP_GEOLOCATION_API_KEY; app.setName('New Expensify'); 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/Add-expenses-to-a-report.md b/docs/articles/expensify-classic/expenses/Add-expenses-to-a-report.md new file mode 100644 index 000000000000..96427a60d87f --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Add-expenses-to-a-report.md @@ -0,0 +1,18 @@ +--- +title: Add expenses to a report +description: Add expenses to a report to submit them for approval +--- +
+ +To submit expenses for approval, they must be added to a report. + +1. Click the **Expenses** tab. +2. Find the expenses you want to add to the report by searching through the table of expenses and/or using the sort filters. +3. Select the expenses by checking the box to the left of each expense or selecting them all. +4. Click **Add to Report** in the right corner and select either: + - **Auto-Report**: Automatically adds the expenses to an open report, or creates a new report if there are no open reports + - **New Report**: Creates a new report for the expenses + - **None**: Ensures none of the selected expenses are attached to a report (as long as the report has not already been submitted) + - **Existing Report**: Adds the expenses to the selected report + +
diff --git a/docs/articles/expensify-classic/expenses/Export-expenses.md b/docs/articles/expensify-classic/expenses/Export-expenses.md new file mode 100644 index 000000000000..14c1532f84b5 --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Export-expenses.md @@ -0,0 +1,13 @@ +--- +title: Export expenses +description: Export expenses to a CSV +--- +
+ +1. Click the **Expenses** tab. +2. Select the expenses you want to export by checking the box to the left of each expense or selecting them all. +3. Click **Export To** in the right corner and select either: + - **Default CSV**: Use Expensify’s default template + - **Create new CSV export layout**: Create your own custom CSV template + +
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/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md b/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md new file mode 100644 index 000000000000..9037e58661d1 --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md @@ -0,0 +1,30 @@ +--- +title: Assign billing owner and payment account +description: Determine who will cover the cost of the workspace and link a payment method +--- +
+ +The person who creates a workspace will automatically be responsible for the billing for that workspace. However, the existing billing owner can transfer the workspace’s billing ownership to any Admin on the workspace. + +{% include info.html %} +There can only be one billing owner at a time. Assigning a new billing owner will automatically un-assign the existing billing owner. However, billing owners are also workspace admins by default, and the previous billing owner will remain a workspace admin unless manually updated. +{% include end-info.html %} + +# Assign a new billing owner + +To assign a new billing owner, **the person who will take over responsibility for the workspace billing must complete the following process**: + +1. Hover over Settings, then click **Workspaces**. +2. Click the desired workspace name. +3. Under Workspace Overview, click **Take Over Billing**. + +# Add or update payment account + +Once you take over billing for a workspace, you must add a payment method to your account. + +1. Hover over Settings, then click **Account**. +2. Click the **Payments** tab. +3. Scroll down to the Payment Details sections and click **Add Payment Card**. +4. Enter your credit or debit card information and click **Accept terms, add payment card, and pay $0.00** (the box will only show a balance if one is due). + +
diff --git a/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md b/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md new file mode 100644 index 000000000000..b0b016afbcbb --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md @@ -0,0 +1,24 @@ +--- +title: Create a group workspace +description: Create a workspace for your team's expense reports +--- +
+ +A workspace is the set of rules, settings, and spending limits for expense reports in your organization. This includes the unique expense categories and tags, budgets, currency and tax settings, etc. that all workspace members will use. A workspace also defines the approval workflow for your employees, as well as the accounting connection if using an accounting software integration. + +Here are a couple examples of when you’d want to create different workspaces: + +- You have employees with expense reports in different currencies. For example, you may have a workspace for employees who live in the US and submit their reports in USD and a workspace for employees who live in Canada and submit in CAD. +- You want to limit specific groups of people to their own set of expense coding options (categories/tags) then they can separate their employees by Sales, Marketing, Support, etc. + +To create a group workspace, + +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click **New Workspace**. +4. Enter the workspace name and select a workspace type. + - **Collect**: Ideal for small groups who only need basic features like expense approvals, reimbursement, corporate card management, and integration options. + - **Control**: For groups that need a deeper level of control and configurations, like multi-stage approval workflows, corporate card management, integrations, and more. This is the most popular option. +5. Set up your workspace details including the workspace name, expense rules, categories, and more. + +
diff --git a/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md b/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md new file mode 100644 index 000000000000..c8be9a2728d5 --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md @@ -0,0 +1,20 @@ +--- +title: Set up your individual workspace +description: Capture your personal expenses +--- +
+ +All Expensify accounts come with an individual workspace where you can track your personal expenses. If you want to connect your personal expenses to an accounting or travel integration, you can create a group workspace—even if you will be the only person in the group. + +To set up your individual workspace, + +1. Hover over Settings, then click **Workspaces**. +2. Click the **Individual** tab on the left. +3. Select the policy type that best fits your needs. +4. Set up your workspace details including the workspace name, expense rules, categories, and more. + +{% include info.html %} +You can create multiple group workspaces, but you can only create one individual workspace. +{% include end-info.html %} + +
diff --git a/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md index c7ae49e02292..096a3d1527be 100644 --- a/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md +++ b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md @@ -12,7 +12,7 @@ For a quick snapshot of how Expensify Chat works, and New Expensify in general, # What’s Expensify Chat? -Expensify Chat is an instant messaging and payment platform. You can manage all your payments, wether for business or personal, and discuss the transactions themselves. +Expensify Chat is an instant messaging and payment platform. You can manage all your payments, whether for business or personal, and discuss the transactions themselves. With Expensify Chat, you can start a conversation about that missing receipt your employee forgot to submit or chat about splitting that electric bill with your roommates. Expensify makes sending and receiving money as easy as sending and receiving messages. Chat with anyone directly, in groups, or in rooms. diff --git a/docs/redirects.csv b/docs/redirects.csv index df4e2a45dce3..7539a2777d92 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -66,3 +66,4 @@ https://help.expensify.com/articles/expensify-classic/expensify-billing/Individu https://help.expensify.com/articles/expensify-classic/settings/Merge-Accounts,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts https://help.expensify.com/articles/expensify-classic/settings/Preferences,https://help.expensify.com/expensify-classic/hubs/settings/account-settings https://help.expensify.com/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager,https://use.expensify.com/support +https://help.expensify.com/articles/expensify-classic/settings/Copilot,https://help.expensify.com/expensify-classic/hubs/copilots-and-delegates/ 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..5e2ba1fcd614 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.3 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index faf2eea9f738..69472200e46d 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.3 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 8abab817dfb1..008ca16909b0 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.3 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index dd2084b238fb..310003ee8adc 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -39,7 +39,7 @@ PODS: - React-Core - Expo (50.0.4): - ExpoModulesCore - - ExpoImage (1.10.1): + - ExpoImage (1.11.0): - ExpoModulesCore - SDWebImage (~> 5.17.0) - SDWebImageAVIFCoder (~> 0.10.1) @@ -1790,7 +1790,7 @@ SPEC CHECKSUMS: EXAV: 09a4d87fa6b113fbb0ada3aade6799f78271cb44 EXImageLoader: 55080616b2fe9da19ef8c7f706afd9814e279b6b Expo: 1e3bcf9dd99de57a636127057f6b488f0609681a - ExpoImage: 1cdaa65a6a70bb01067e21ad1347ff2d973885f5 + ExpoImage: 390c524542b258f8173f475c1cc71f016444a7be ExpoImageManipulator: c1d7cb865eacd620a35659f3da34c70531f10b59 ExpoModulesCore: 96d1751929ad10622773bb729ab28a8423f0dd0c FBLazyVector: fbc4957d9aa695250b55d879c1d86f79d7e69ab4 @@ -1921,7 +1921,7 @@ SPEC CHECKSUMS: SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 Turf: 13d1a92d969ca0311bbc26e8356cca178ce95da2 VisionCamera: 0a6794d1974aed5d653d0d0cb900493e2583e35a - Yoga: 13c8ef87792450193e117976337b8527b49e8c03 + Yoga: e64aa65de36c0832d04e8c7bd614396c77a80047 PODFILE CHECKSUM: a431c146e1501391834a2f299a74093bac53b530 diff --git a/package-lock.json b/package-lock.json index ba15feeacae6..4bff5eaf6eb8 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-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.54-1", + "version": "1.4.55-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -51,10 +51,10 @@ "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#4e020cfa13ffabde14313c92b341285aeb919f29", "expo": "^50.0.3", "expo-av": "~13.10.4", - "expo-image": "1.10.1", + "expo-image": "1.11.0", "expo-image-manipulator": "11.8.0", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", @@ -70,7 +70,7 @@ "react": "18.2.0", "react-beautiful-dnd": "^13.1.1", "react-collapse": "^5.1.0", - "react-content-loader": "^6.1.0", + "react-content-loader": "^7.0.0", "react-dom": "18.1.0", "react-error-boundary": "^4.0.11", "react-map-gl": "^7.1.3", @@ -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", @@ -22986,9 +23087,9 @@ } }, "node_modules/classnames": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.4.0.tgz", - "integrity": "sha512-lWxiIlphgAhTLN657pwU/ofFxsUTOWc2CRIFeoV5st0MGRJHStUnWIUJgDHxjUO/F0mXzGufXIM4Lfu/8h+MpA==" + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.0.tgz", + "integrity": "sha512-FQuRlyKinxrb5gwJlfVASbSrDlikDJ07426TrfPsdGLvtochowmkbnSFdQGJ2aoXrSetq5KqGV9emvWpy+91xA==" }, "node_modules/clean-css": { "version": "5.3.2", @@ -27269,11 +27370,11 @@ }, "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#4e020cfa13ffabde14313c92b341285aeb919f29", + "integrity": "sha512-sx3cIYkmiydNaXRe4kJebPyEje8HfssUbsoB6uW8vvMLwFheCZfkmF9fRMBNLo8BQsfWIstT5TApEhwuWPjqZg==", "license": "MIT", "dependencies": { - "classnames": "2.4.0", + "classnames": "2.5.0", "clipboard": "2.0.11", "html-entities": "^2.4.0", "jquery": "3.6.0", @@ -27282,7 +27383,7 @@ "prop-types": "15.8.1", "react": "16.12.0", "react-dom": "16.12.0", - "semver": "^7.5.2", + "semver": "^7.6.0", "simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", "ua-parser-js": "^1.0.37", "underscore": "1.13.6" @@ -27413,8 +27514,9 @@ } }, "node_modules/expo-image": { - "version": "1.10.1", - "license": "MIT", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-1.11.0.tgz", + "integrity": "sha512-CfkSGWIGidxxqzErke16bCS9aefS04qvgjk+T9Nc34LAb3ysBAqCv5hoCnvylHOvi/7jTCC/ouLm5oLLqkDSRQ==", "dependencies": { "@react-native/assets-registry": "~0.73.1" }, @@ -34443,6 +34545,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, @@ -38720,8 +38828,9 @@ } }, "node_modules/react-content-loader": { - "version": "6.2.0", - "license": "MIT", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/react-content-loader/-/react-content-loader-7.0.0.tgz", + "integrity": "sha512-xaBwpO7eiJyEc4ndym+g6wcruV9W2y3DKqbw4U48QFBsv0IeAVZO+aCUb8GptlDLWM8n5zi2HcFSGlj5r+53Tg==", "engines": { "node": ">=10" }, @@ -39531,6 +39640,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 +39683,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 +39724,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 +39780,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 +43986,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..53eb229d7b85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.54-1", + "version": "1.4.55-3", "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,10 +102,10 @@ "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#4e020cfa13ffabde14313c92b341285aeb919f29", "expo": "^50.0.3", "expo-av": "~13.10.4", - "expo-image": "1.10.1", + "expo-image": "1.11.0", "expo-image-manipulator": "11.8.0", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", @@ -121,7 +121,7 @@ "react": "18.2.0", "react-beautiful-dnd": "^13.1.1", "react-collapse": "^5.1.0", - "react-content-loader": "^6.1.0", + "react-content-loader": "^7.0.0", "react-dom": "18.1.0", "react-error-boundary": "^4.0.11", "react-map-gl": "^7.1.3", @@ -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/patches/expo-image+1.10.1+001+applyFill.patch b/patches/expo-image+1.10.1+001+applyFill.patch deleted file mode 100644 index 5f168040d04d..000000000000 --- a/patches/expo-image+1.10.1+001+applyFill.patch +++ /dev/null @@ -1,112 +0,0 @@ -diff --git a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt -index 619daf2..b58a0df 100644 ---- a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt -+++ b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt -@@ -1,5 +1,9 @@ - package com.caverock.androidsvg - -+import com.caverock.androidsvg.SVG.SPECIFIED_COLOR -+import com.caverock.androidsvg.SVG.SPECIFIED_FILL -+import com.caverock.androidsvg.SVG.SvgElementBase -+ - internal fun replaceColor(paint: SVG.SvgPaint?, newColor: Int) { - if (paint is SVG.Colour && paint !== SVG.Colour.TRANSPARENT) { - paint.colour = newColor -@@ -19,15 +23,83 @@ internal fun replaceStyles(style: SVG.Style?, newColor: Int) { - replaceColor(style.viewportFill, newColor) - } - --internal fun applyTintColor(element: SVG.SvgObject, newColor: Int) { -- if (element is SVG.SvgElementBase) { -+internal fun hasStyle(element: SvgElementBase): Boolean { -+ if (element.style == null && element.baseStyle == null) { -+ return false -+ } -+ -+ val style = element.style -+ val hasColorInStyle = style != null && -+ ( -+ style.color != null || style.fill != null || style.stroke != null || -+ style.stroke != null || style.stopColor != null || style.solidColor != null -+ ) -+ -+ if (hasColorInStyle) { -+ return true -+ } -+ -+ val baseStyle = element.baseStyle ?: return false -+ return baseStyle.color != null || baseStyle.fill != null || baseStyle.stroke != null || -+ baseStyle.viewportFill != null || baseStyle.stopColor != null || baseStyle.solidColor != null -+} -+ -+internal fun defineStyles(element: SvgElementBase, newColor: Int, hasStyle: Boolean) { -+ if (hasStyle) { -+ return -+ } -+ -+ val style = if (element.style != null) { -+ element.style -+ } else { -+ SVG.Style().also { -+ element.style = it -+ } -+ } -+ -+ val color = SVG.Colour(newColor) -+ when (element) { -+ is SVG.Path, -+ is SVG.Circle, -+ is SVG.Ellipse, -+ is SVG.Rect, -+ is SVG.SolidColor, -+ is SVG.Line, -+ is SVG.Polygon, -+ is SVG.PolyLine -> { -+ style.apply { -+ fill = color -+ -+ specifiedFlags = SPECIFIED_FILL -+ } -+ } -+ -+ is SVG.TextPath -> { -+ style.apply { -+ this.color = color -+ -+ specifiedFlags = SPECIFIED_COLOR -+ } -+ } -+ } -+} -+ -+internal fun applyTintColor(element: SVG.SvgObject, newColor: Int, parentDefinesStyle: Boolean) { -+ val definesStyle = if (element is SvgElementBase) { -+ val hasStyle = parentDefinesStyle || hasStyle(element) -+ - replaceStyles(element.baseStyle, newColor) - replaceStyles(element.style, newColor) -+ defineStyles(element, newColor, hasStyle) -+ -+ hasStyle -+ } else { -+ parentDefinesStyle - } - - if (element is SVG.SvgContainer) { - for (child in element.children) { -- applyTintColor(child, newColor) -+ applyTintColor(child, newColor, definesStyle) - } - } - } -@@ -36,8 +108,9 @@ fun applyTintColor(svg: SVG, newColor: Int) { - val root = svg.rootElement - - replaceStyles(root.style, newColor) -+ val hasStyle = hasStyle(root) - - for (child in root.children) { -- applyTintColor(child, newColor) -+ applyTintColor(child, newColor, hasStyle) - } - } diff --git a/patches/expo-image+1.10.1+002+TintFix.patch b/patches/expo-image+1.10.1+002+TintFix.patch deleted file mode 100644 index 92b56c039b43..000000000000 --- a/patches/expo-image+1.10.1+002+TintFix.patch +++ /dev/null @@ -1,38 +0,0 @@ -diff --git a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt -index b58a0df..6b8da3c 100644 ---- a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt -+++ b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt -@@ -107,6 +107,7 @@ internal fun applyTintColor(element: SVG.SvgObject, newColor: Int, parentDefines - fun applyTintColor(svg: SVG, newColor: Int) { - val root = svg.rootElement - -+ replaceStyles(root.baseStyle, newColor) - replaceStyles(root.style, newColor) - val hasStyle = hasStyle(root) - -diff --git a/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt b/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt -index 602b570..8becf72 100644 ---- a/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt -+++ b/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt -@@ -31,6 +31,7 @@ import expo.modules.image.records.ImageLoadEvent - import expo.modules.image.records.ImageProgressEvent - import expo.modules.image.records.ImageTransition - import expo.modules.image.records.SourceMap -+import expo.modules.image.svg.SVGPictureDrawable - import expo.modules.kotlin.AppContext - import expo.modules.kotlin.tracing.beginAsyncTraceBlock - import expo.modules.kotlin.tracing.trace -@@ -127,7 +128,12 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView( - internal var tintColor: Int? = null - set(value) { - field = value -- activeView.setTintColor(value) -+ // To apply the tint color to the SVG, we need to recreate the drawable. -+ if (activeView.drawable is SVGPictureDrawable) { -+ shouldRerender = true -+ } else { -+ activeView.setTintColor(value) -+ } - } - - internal var isFocusableProp: Boolean = false diff --git a/patches/react-native-web+0.19.9+006+fixPointerEventDown.patch b/patches/react-native-web+0.19.9+006+fixPointerEventDown.patch new file mode 100644 index 000000000000..e6a3822836f4 --- /dev/null +++ b/patches/react-native-web+0.19.9+006+fixPointerEventDown.patch @@ -0,0 +1,43 @@ +diff --git a/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js b/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js +index 0aec2d6..a71aec2 100644 +--- a/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js ++++ b/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js +@@ -133,7 +133,7 @@ to return true:wantsResponderID| | + + import createResponderEvent from './createResponderEvent'; + import { isCancelish, isEndish, isMoveish, isScroll, isSelectionChange, isStartish } from './ResponderEventTypes'; +-import { getLowestCommonAncestor, getResponderPaths, hasTargetTouches, hasValidSelection, isPrimaryPointerDown, setResponderId } from './utils'; ++import { getLowestCommonAncestor, getResponderPaths, hasTargetTouches, hasValidSelection, isPrimaryOrSecondaryPointerDown, setResponderId } from './utils'; + import { ResponderTouchHistoryStore } from './ResponderTouchHistoryStore'; + import canUseDOM from '../canUseDom'; + +@@ -225,7 +225,7 @@ function eventListener(domEvent) { + } + return; + } +- var isStartEvent = isStartish(eventType) && isPrimaryPointerDown(domEvent); ++ var isStartEvent = isStartish(eventType) && isPrimaryOrSecondaryPointerDown(domEvent); + var isMoveEvent = isMoveish(eventType); + var isEndEvent = isEndish(eventType); + var isScrollEvent = isScroll(eventType); +diff --git a/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js b/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js +index 7382cdd..d88f6c0 100644 +--- a/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js ++++ b/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js +@@ -148,14 +148,14 @@ export function hasValidSelection(domEvent) { + /** + * Events are only valid if the primary button was used without specific modifier keys. + */ +-export function isPrimaryPointerDown(domEvent) { ++export function isPrimaryOrSecondaryPointerDown(domEvent) { + var altKey = domEvent.altKey, + button = domEvent.button, + buttons = domEvent.buttons, + ctrlKey = domEvent.ctrlKey, + type = domEvent.type; + var isTouch = type === 'touchstart' || type === 'touchmove'; +- var isPrimaryMouseDown = type === 'mousedown' && (button === 0 || buttons === 1); ++ var isPrimaryMouseDown = type === 'mousedown' && (button === 0 || buttons === 1 || buttons === 2); + var isPrimaryMouseMove = type === 'mousemove' && buttons === 1; + var noModifiers = altKey === false && ctrlKey === false; + if (isTouch || isPrimaryMouseDown && noModifiers || isPrimaryMouseMove && noModifiers) { 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/CONFIG.ts b/src/CONFIG.ts index 37da65f0c305..76ea18d37d5f 100644 --- a/src/CONFIG.ts +++ b/src/CONFIG.ts @@ -21,7 +21,7 @@ const secureExpensifyUrl = Url.addTrailingForwardSlash(get(Config, 'SECURE_EXPEN const useNgrok = get(Config, 'USE_NGROK', 'false') === 'true'; const useWebProxy = get(Config, 'USE_WEB_PROXY', 'true') === 'true'; const expensifyComWithProxy = getPlatform() === 'web' && useWebProxy ? '/' : expensifyURL; -const googleGeolocationAPIKey = get(Config, 'GOOGLE_GEOLOCATION_API_KEY', 'AIzaSyBqg6bMvQU7cPWDKhhzpYqJrTEnSorpiLI'); +const googleGeolocationAPIKey = get(Config, 'GCP_GEOLOCATION_API_KEY', ''); // Throw errors on dev if config variables are not set correctly if (ENVIRONMENT === CONST.ENVIRONMENT.DEV) { @@ -94,5 +94,5 @@ export default { WEB_CLIENT_ID: '921154746561-gpsoaqgqfuqrfsjdf8l7vohfkfj7b9up.apps.googleusercontent.com', IOS_CLIENT_ID: '921154746561-s3uqn2oe4m85tufi6mqflbfbuajrm2i3.apps.googleusercontent.com', }, - GOOGLE_GEOLOCATION_API_KEY: googleGeolocationAPIKey, + GCP_GEOLOCATION_API_KEY: googleGeolocationAPIKey, } as const; diff --git a/src/CONST.ts b/src/CONST.ts index 873e1ca3b358..af4864c22a85 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -332,6 +332,7 @@ const CONST = { BETA_COMMENT_LINKING: 'commentLinking', VIOLATIONS: 'violations', REPORT_FIELDS: 'reportFields', + TRACK_EXPENSE: 'trackExpense', P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests', WORKFLOWS_DELAYED_SUBMISSION: 'workflowsDelayedSubmission', }, @@ -358,6 +359,7 @@ const CONST = { NOT_INSTALLED: 'not-installed', }, TAX_RATES: { + CUSTOM_NAME_MAX_LENGTH: 8, NAME_MAX_LENGTH: 50, }, PLATFORM: { @@ -513,7 +515,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', @@ -579,6 +581,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', @@ -613,6 +630,7 @@ const CONST = { EXPORTEDTOQUICKBOOKS: 'EXPORTEDTOQUICKBOOKS', // OldDot Action FORWARDED: 'FORWARDED', // OldDot Action HOLD: 'HOLD', + HOLDCOMMENT: 'HOLDCOMMENT', IOU: 'IOU', INTEGRATIONSMESSAGE: 'INTEGRATIONSMESSAGE', // OldDot Action MANAGERATTACHRECEIPT: 'MANAGERATTACHRECEIPT', // OldDot Action @@ -1329,6 +1347,7 @@ const CONST = { SEND: 'send', SPLIT: 'split', REQUEST: 'request', + TRACK_EXPENSE: 'track-expense', }, REQUEST_TYPE: { DISTANCE: 'distance', @@ -1343,6 +1362,7 @@ const CONST = { CANCEL: 'cancel', DELETE: 'delete', APPROVE: 'approve', + TRACK: 'track', }, AMOUNT_MAX_LENGTH: 10, RECEIPT_STATE: { @@ -1457,16 +1477,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: { @@ -1531,6 +1580,11 @@ const CONST = { STATE_SUSPENDED: 7, }, ACTIVE_STATES: cardActiveStates, + LIMIT_TYPES: { + SMART: 'smart', + MONTHLY: 'monthly', + FIXED: 'fixed', + }, }, AVATAR_ROW_SIZE: { DEFAULT: 4, @@ -3365,6 +3419,9 @@ const CONST = { REPORT_FIELD_TITLE_FIELD_ID: 'text_title', + MOBILE_PAGINATION_SIZE: 15, + WEB_PAGINATION_SIZE: 50, + /** Dimensions for illustration shown in Confirmation Modal */ CONFIRM_CONTENT_SVG_SIZE: { HEIGHT: 220, diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 5681be838ca8..026025593aef 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -25,12 +25,10 @@ import Navigation from './libs/Navigation/Navigation'; import NavigationRoot from './libs/Navigation/NavigationRoot'; import NetworkConnection from './libs/NetworkConnection'; import PushNotification from './libs/Notification/PushNotification'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars import './libs/Notification/PushNotification/subscribePushNotification'; import StartupTimer from './libs/StartupTimer'; // This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import UnreadIndicatorUpdater from './libs/UnreadIndicatorUpdater'; +import './libs/UnreadIndicatorUpdater'; import Visibility from './libs/Visibility'; import ONYXKEYS from './ONYXKEYS'; import PopoverReportActionContextMenu from './pages/home/report/ContextMenu/PopoverReportActionContextMenu'; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index e14a880fcfa6..d3fab1b9fcde 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,8 +301,8 @@ const ONYXKEYS = { POLICY_CATEGORIES: 'policyCategories_', POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_', POLICY_TAGS: 'policyTags_', - POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_', - POLICY_REPORT_FIELDS: 'policyReportFields_', + POLICY_RECENTLY_USED_TAGS: 'nvp_recentlyUsedTags_', + OLD_POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', REPORT: 'report_', @@ -332,8 +342,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', @@ -343,6 +353,8 @@ const ONYXKEYS = { WORKSPACE_TAX_CUSTOM_NAME_DRAFT: 'workspaceTaxCustomNameDraft', POLICY_CREATE_DISTANCE_RATE_FORM: 'policyCreateDistanceRateForm', POLICY_CREATE_DISTANCE_RATE_FORM_DRAFT: 'policyCreateDistanceRateFormDraft', + POLICY_DISTANCE_RATE_EDIT_FORM: 'policyDistanceRateEditForm', + POLICY_DISTANCE_RATE_EDIT_FORM_DRAFT: 'policyDistanceRateEditFormDraft', CLOSE_ACCOUNT_FORM: 'closeAccount', CLOSE_ACCOUNT_FORM_DRAFT: 'closeAccountDraft', PROFILE_SETTINGS_FORM: 'profileSettingsForm', @@ -417,6 +429,10 @@ const ONYXKEYS = { 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; @@ -426,7 +442,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; @@ -468,6 +484,9 @@ type OnyxFormValuesMapping = { [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.POLICY_DISTANCE_RATE_EDIT_FORM]: FormTypes.PolicyDistanceRateEditForm; + [ONYXKEYS.FORMS.WORKSPACE_TAX_NAME_FORM]: FormTypes.WorkspaceTaxNameForm; + [ONYXKEYS.FORMS.WORKSPACE_TAX_VALUE_FORM]: FormTypes.WorkspaceTaxValueForm; }; type OnyxFormDraftValuesMapping = { @@ -483,7 +502,6 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_MEMBERS]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS]: OnyxTypes.PolicyMember; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; - [ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS]: OnyxTypes.PolicyReportFields; [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: OnyxTypes.InvitedEmailsToAccountIDs; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT]: string; @@ -503,6 +521,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; @@ -558,6 +577,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; @@ -599,6 +620,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..c216d5ac288c 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', @@ -204,7 +204,7 @@ const ROUTES = { }, REPORT_ATTACHMENTS: { route: 'r/:reportID/attachment', - getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}` as const, + getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURIComponent(source)}` as const, }, REPORT_PARTICIPANTS: { route: 'r/:reportID/participants', @@ -304,13 +304,10 @@ const ROUTES = { route: ':iouType/new/receipt/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}` as const, }, - MONEY_REQUEST_DISTANCE: { - route: ':iouType/new/address/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}` as const, - }, MONEY_REQUEST_CREATE: { - route: 'create/:iouType/start/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}` as const, + route: ':action/:iouType/start/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => + `create/${iouType}/start/${transactionID}/${reportID}` as const, }, MONEY_REQUEST_STEP_CONFIRMATION: { route: 'create/:iouType/confirmation/:transactionID/:reportID', @@ -332,9 +329,9 @@ const ROUTES = { getUrlWithBackToParam(`create/${iouType}/taxAmount/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_CATEGORY: { - route: ':action/:iouType/category/:transactionID/:reportID', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`${action}/${iouType}/category/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/category/:transactionID/:reportID/:reportActionID?', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '', reportActionID?: string) => + getUrlWithBackToParam(`${action}/${iouType}/category/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), }, MONEY_REQUEST_STEP_CURRENCY: { route: 'create/:iouType/currency/:transactionID/:reportID/:pageIndex?', @@ -347,14 +344,14 @@ const ROUTES = { getUrlWithBackToParam(`${action}/${iouType}/date/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_DESCRIPTION: { - route: ':action/:iouType/description/:transactionID/:reportID', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`${action}/${iouType}/description/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/description/:transactionID/:reportID/:reportActionID?', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '', reportActionID?: string) => + getUrlWithBackToParam(`${action}/${iouType}/description/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), }, MONEY_REQUEST_STEP_DISTANCE: { - route: 'create/:iouType/distance/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`create/${iouType}/distance/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/distance/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/distance/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_MERCHANT: { route: ':action/:iouType/merchant/:transactionID/:reportID', @@ -395,16 +392,19 @@ const ROUTES = { getRoute: (iouType: ValueOf, iouRequestType: ValueOf) => `start/${iouType}/${iouRequestType}` as const, }, MONEY_REQUEST_CREATE_TAB_DISTANCE: { - route: 'create/:iouType/start/:transactionID/:reportID/distance', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}/distance` as const, + route: ':action/:iouType/start/:transactionID/:reportID/distance', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => + `create/${iouType}/start/${transactionID}/${reportID}/distance` as const, }, MONEY_REQUEST_CREATE_TAB_MANUAL: { - route: 'create/:iouType/start/:transactionID/:reportID/manual', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}/manual` as const, + route: ':action/:iouType/start/:transactionID/:reportID/manual', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => + `create/${iouType}/start/${transactionID}/${reportID}/manual` as const, }, MONEY_REQUEST_CREATE_TAB_SCAN: { - route: 'create/:iouType/start/:transactionID/:reportID/scan', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}/scan` as const, + route: ':action/:iouType/start/:transactionID/:reportID/scan', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => + `create/${iouType}/start/${transactionID}/${reportID}/scan` as const, }, IOU_REQUEST: 'request/new', @@ -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,18 @@ 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/workspaces/:policyID/distance-rates/settings', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates/settings` as const, + }, + WORKSPACE_DISTANCE_RATE_DETAILS: { + route: 'settings/workspaces/:policyID/distance-rates/:rateID', + getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}` as const, + }, + WORKSPACE_DISTANCE_RATE_EDIT: { + route: 'settings/workspaces/:policyID/distance-rates/:rateID/edit', + getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/edit` as const, + }, // Referral program promotion REFERRAL_DETAILS_MODAL: { route: 'referral/:contentType', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 2fbd122f9972..82fef0383918 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -155,7 +155,6 @@ const SCREENS = { CURRENCY: 'Money_Request_Currency', WAYPOINT: 'Money_Request_Waypoint', EDIT_WAYPOINT: 'Money_Request_Edit_Waypoint', - DISTANCE: 'Money_Request_Distance', RECEIPT: 'Money_Request_Receipt', }, @@ -215,7 +214,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 +244,9 @@ 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', + DISTANCE_RATE_DETAILS: 'Distance_Rate_Details', + DISTANCE_RATE_EDIT: 'Distance_Rate_Edit', }, 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/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js index b934bdfdd738..9524c5203110 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js +++ b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js @@ -15,10 +15,19 @@ import CONST from '@src/CONST'; function extractAttachmentsFromReport(parentReportAction, reportActions) { const actions = [parentReportAction, ...ReportActionsUtils.getSortedReportActions(_.values(reportActions))]; const attachments = []; + // We handle duplicate image sources by considering the first instance as original. Selecting any duplicate + // and navigating back (<) shows the image preceding the first instance, not the selected duplicate's position. + const uniqueSources = new Set(); const htmlParser = new HtmlParser({ onopentag: (name, attribs) => { if (name === 'video') { + const source = tryResolveUrlFromApiRoot(attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]); + if (uniqueSources.has(source)) { + return; + } + + uniqueSources.add(source); const splittedUrl = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE].split('/'); attachments.unshift({ reportActionID: null, @@ -35,7 +44,20 @@ function extractAttachmentsFromReport(parentReportAction, reportActions) { if (name === 'img' && attribs.src) { const expensifySource = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]; const source = tryResolveUrlFromApiRoot(expensifySource || attribs.src); - const fileName = attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || FileUtils.getFileName(`${source}`); + if (uniqueSources.has(source)) { + return; + } + + uniqueSources.add(source); + let fileName = attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || FileUtils.getFileName(`${source}`); + + // Public image URLs might lack a file extension in the source URL, without an extension our + // AttachmentView fails to recognize them as images and renders fallback content instead. + // We apply this small hack to add an image extension and ensure AttachmentView renders the image. + const fileInfo = FileUtils.splitExtensionFromFileName(fileName); + if (!fileInfo.fileExtension) { + fileName = `${fileInfo.fileName || 'image'}.jpg`; + } // By iterating actions in chronological order and prepending each attachment // we ensure correct order of attachments even across actions with multiple attachments. diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js index 461548f0d2b1..9fe37734e8ee 100755 --- a/src/components/Attachments/AttachmentView/index.js +++ b/src/components/Attachments/AttachmentView/index.js @@ -79,6 +79,7 @@ const defaultProps = { reportActionID: '', isHovered: false, optionalVideoDuration: 0, + fallbackSource: Expensicons.Gallery, }; function AttachmentView({ @@ -201,6 +202,21 @@ function AttachmentView({ // We also check for numeric source since this is how static images (used for preview) are represented in RN. const isImage = typeof source === 'number' || Str.isImage(source); if (isImage || (file && Str.isImage(file.name))) { + if (imageError) { + // AttachmentViewImage can't handle icon fallbacks, so we need to handle it here + if (typeof fallbackSource === 'number' || _.isFunction(fallbackSource)) { + return ( + + ); + } + } + return ( diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 16f31b9c7eba..396c10151fbf 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -60,7 +60,7 @@ function AvatarWithDisplayName({ const title = ReportUtils.getReportName(report); const subtitle = ReportUtils.getChatRoomSubtitle(report); const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(report); - const isMoneyRequestOrReport = ReportUtils.isMoneyRequestReport(report) || ReportUtils.isMoneyRequest(report); + const isMoneyRequestOrReport = ReportUtils.isMoneyRequestReport(report) || ReportUtils.isMoneyRequest(report) || ReportUtils.isTrackExpenseReport(report); const icons = ReportUtils.getIcons(report, personalDetails, null, '', -1, policy); const ownerPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails); const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails) as PersonalDetails[], false); diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx index 8bcda759d26c..e39e940ebf5c 100644 --- a/src/components/AvatarWithImagePicker.tsx +++ b/src/components/AvatarWithImagePicker.tsx @@ -287,11 +287,12 @@ function AvatarWithImagePicker({ return ( - + diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index 5f426f77b731..e89026137b67 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -116,7 +116,7 @@ function ButtonWithDropdownMenu({ success={success} ref={buttonRef} pressOnEnter={pressOnEnter} - isDisabled={isDisabled} + isDisabled={isDisabled || !!options[0].disabled} style={[styles.w100, style]} isLoading={isLoading} text={selectedItem.text} diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 798369292958..87db9a29d827 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; @@ -20,6 +22,7 @@ type DropdownOption = { iconHeight?: number; iconDescription?: string; onSelected?: () => void; + disabled?: boolean; }; type ButtonWithDropdownMenuProps = { @@ -73,4 +76,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/CustomStatusBarAndBackground/index.tsx b/src/components/CustomStatusBarAndBackground/index.tsx index 356fbd3726a3..524c8a3903e0 100644 --- a/src/components/CustomStatusBarAndBackground/index.tsx +++ b/src/components/CustomStatusBarAndBackground/index.tsx @@ -114,6 +114,11 @@ function CustomStatusBarAndBackground({isNested = false}: CustomStatusBarAndBack [prevIsRootStatusBarEnabled, isRootStatusBarEnabled, statusBarAnimation, statusBarStyle, theme.PAGE_THEMES, theme.appBG, theme.statusBarStyle], ); + useEffect(() => { + updateStatusBarAppearance({backgroundColor: theme.appBG}); + // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this to run on first render + }, []); + useEffect(() => { didForceUpdateStatusBarRef.current = false; }, [isRootStatusBarEnabled]); diff --git a/src/components/DistanceRequest/index.tsx b/src/components/DistanceRequest/index.tsx deleted file mode 100644 index f9e8c0be12ff..000000000000 --- a/src/components/DistanceRequest/index.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import type {RouteProp} from '@react-navigation/native'; -import lodashIsEqual from 'lodash/isEqual'; -import React, {useCallback, useEffect, useMemo, useRef, useState} 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 type {OnyxEntry} from 'react-native-onyx'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import Button from '@components/Button'; -import DotIndicatorMessage from '@components/DotIndicatorMessage'; -import DraggableList from '@components/DraggableList'; -import type {DraggableListData} from '@components/DraggableList/types'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import usePrevious from '@hooks/usePrevious'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import * as IOUUtils from '@libs/IOUUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import * as TransactionUtils from '@libs/TransactionUtils'; -import * as MapboxToken from '@userActions/MapboxToken'; -import * as TransactionUserActions from '@userActions/Transaction'; -import * as TransactionEdit from '@userActions/TransactionEdit'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {Report, Transaction} from '@src/types/onyx'; -import type {WaypointCollection} from '@src/types/onyx/Transaction'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import DistanceRequestFooter from './DistanceRequestFooter'; -import DistanceRequestRenderItem from './DistanceRequestRenderItem'; - -type DistanceRequestOnyxProps = { - transaction: OnyxEntry; -}; - -type DistanceRequestProps = DistanceRequestOnyxProps & { - /** The TransactionID of this request */ - transactionID?: string; - - /** The report to which the distance request is associated */ - report: OnyxEntry; - - /** Are we editing an existing distance request, or creating a new one? */ - isEditingRequest?: boolean; - - /** Are we editing the distance while creating a new distance request */ - isEditingNewRequest?: boolean; - - /** Called on submit of this page */ - onSubmit: (waypoints?: WaypointCollection) => void; - - /** React Navigation route */ - route: RouteProp<{ - /** Params from the route */ - params: { - /** The type of IOU report, i.e. bill, request, send */ - iouType: string; - /** The report ID of the IOU */ - reportID: string; - }; - }>; -}; - -function DistanceRequest({transactionID = '', report, transaction, route, isEditingRequest = false, isEditingNewRequest = false, onSubmit}: DistanceRequestProps) { - const styles = useThemeStyles(); - const {isOffline} = useNetwork(); - const {translate} = useLocalize(); - - const [optimisticWaypoints, setOptimisticWaypoints] = useState(); - const [hasError, setHasError] = useState(false); - const reportID = report?.reportID ?? ''; - const waypoints: WaypointCollection = useMemo(() => optimisticWaypoints ?? transaction?.comment?.waypoints ?? {waypoint0: {}, waypoint1: {}}, [optimisticWaypoints, transaction]); - const waypointsList = Object.keys(waypoints); - const iouType = route?.params?.iouType ?? ''; - const previousWaypoints = usePrevious(waypoints); - const numberOfWaypoints = Object.keys(waypoints).length; - const numberOfPreviousWaypoints = Object.keys(previousWaypoints).length; - const scrollViewRef = useRef(null); - - const isLoadingRoute = transaction?.comment?.isLoading ?? false; - const isLoading = transaction?.isLoading ?? false; - const hasRouteError = Boolean(transaction?.errorFields?.route); - const hasRoute = TransactionUtils.hasRoute((transaction ?? {}) as Transaction); - const validatedWaypoints = TransactionUtils.getValidWaypoints(waypoints); - const previousValidatedWaypoints = usePrevious(validatedWaypoints); - const haveValidatedWaypointsChanged = !lodashIsEqual(previousValidatedWaypoints, validatedWaypoints); - const isRouteAbsentWithoutErrors = !hasRoute && !hasRouteError; - const shouldFetchRoute = (isRouteAbsentWithoutErrors || haveValidatedWaypointsChanged) && !isLoadingRoute && Object.keys(validatedWaypoints).length > 1; - const transactionWasSaved = useRef(false); - - useEffect(() => { - MapboxToken.init(); - return MapboxToken.stop; - }, []); - - useEffect(() => { - if (!isEditingNewRequest && !isEditingRequest) { - return () => {}; - } - // This effect runs when the component is mounted and unmounted. It's purpose is to be able to properly - // discard changes if the user cancels out of making any changes. This is accomplished by backing up the - // original transaction, letting the user modify the current transaction, and then if the user ever - // cancels out of the modal without saving changes, the original transaction is restored from the backup. - - // On mount, create the backup transaction. - TransactionEdit.createBackupTransaction(transaction); - - return () => { - // If the user cancels out of the modal without without saving changes, then the original transaction - // needs to be restored from the backup so that all changes are removed. - if (transactionWasSaved.current) { - return; - } - TransactionEdit.restoreOriginalTransactionFromBackup(transaction?.transactionID ?? ''); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - const transactionWaypoints = transaction?.comment?.waypoints ?? {}; - if (!transaction?.transactionID || Object.keys(transactionWaypoints).length) { - return; - } - - // Create the initial start and stop waypoints - TransactionUserActions.createInitialWaypoints(transactionID); - return () => { - // Whenever we reset the transaction, we need to set errors as empty/false. - setHasError(false); - }; - }, [transaction, transactionID]); - - useEffect(() => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (isOffline || !shouldFetchRoute) { - return; - } - - TransactionUserActions.getRoute(transactionID, validatedWaypoints); - }, [shouldFetchRoute, transactionID, validatedWaypoints, isOffline]); - - useEffect(() => { - if (numberOfWaypoints <= numberOfPreviousWaypoints) { - return; - } - scrollViewRef.current?.scrollToEnd({animated: true}); - }, [numberOfPreviousWaypoints, numberOfWaypoints]); - - useEffect(() => { - // Whenever we change waypoints we need to remove the error or it will keep showing the error. - if (lodashIsEqual(previousWaypoints, waypoints)) { - return; - } - setHasError(false); - }, [waypoints, previousWaypoints]); - - const navigateBack = () => { - Navigation.goBack(isEditingNewRequest ? ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID) : ROUTES.HOME); - }; - - /** - * Takes the user to the page for editing a specific waypoint - */ - const navigateToWaypointEditPage = (index: number) => { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute( - CONST.IOU.ACTION.EDIT, - CONST.IOU.TYPE.REQUEST, - transactionID, - report?.reportID ?? '', - index.toString(), - Navigation.getActiveRouteWithoutParams(), - ), - ); - }; - - const getError = useCallback(() => { - // Get route error if available else show the invalid number of waypoints error. - if (hasRouteError) { - return ErrorUtils.getLatestErrorField((transaction ?? {}) as Transaction, 'route'); - } - - if (Object.keys(validatedWaypoints).length < 2) { - // eslint-disable-next-line @typescript-eslint/naming-convention - return {0: 'iou.error.atLeastTwoDifferentWaypoints'}; - } - - if (Object.keys(validatedWaypoints).length < Object.keys(waypoints).length) { - // eslint-disable-next-line @typescript-eslint/naming-convention - return {0: translate('iou.error.duplicateWaypointsErrorMessage')}; - } - }, [translate, transaction, hasRouteError, validatedWaypoints, waypoints]); - - const updateWaypoints = useCallback( - ({data}: DraggableListData) => { - if (lodashIsEqual(waypointsList, data)) { - return; - } - - const newWaypoints: WaypointCollection = {}; - let emptyWaypointIndex = -1; - data.forEach((waypoint, index) => { - newWaypoints[`waypoint${index}`] = waypoints?.[waypoint] ?? {}; - // Find waypoint that BECOMES empty after dragging - if (isEmptyObject(newWaypoints[`waypoint${index}`]) && !isEmptyObject(waypoints[`waypoint${index}`])) { - emptyWaypointIndex = index; - } - }); - - setOptimisticWaypoints(newWaypoints); - // eslint-disable-next-line rulesdir/no-thenable-actions-in-views - Promise.all([TransactionUserActions.removeWaypoint(transaction, emptyWaypointIndex.toString()), TransactionUserActions.updateWaypoints(transactionID, newWaypoints)]).then(() => { - setOptimisticWaypoints(undefined); - }); - }, - [transactionID, transaction, waypoints, waypointsList], - ); - - const submitWaypoints = useCallback(() => { - // If there is any error or loading state, don't let user go to next page. - if (!isEmptyObject(getError()) || isLoadingRoute || (isLoading && !isOffline)) { - setHasError(true); - return; - } - - if (isEditingNewRequest || isEditingRequest) { - transactionWasSaved.current = true; - } - - onSubmit(waypoints); - }, [onSubmit, setHasError, getError, isLoadingRoute, isLoading, waypoints, isEditingNewRequest, isEditingRequest, isOffline]); - - const content = ( - <> - - item} - shouldUsePortal - onDragEnd={updateWaypoints} - ref={scrollViewRef} - renderItem={({item, drag, isActive, getIndex}) => ( - number} - onPress={navigateToWaypointEditPage} - disabled={isLoadingRoute} - /> - )} - ListFooterComponent={ - - } - /> - - - {/* Show error message if there is route error or there are less than 2 routes and user has tried submitting, */} - {((hasError && !isEmptyObject(getError())) || hasRouteError) && ( - - )} -