diff --git a/.eslintrc.changed.js b/.eslintrc.changed.js index a72dd6a9250a..a1c2c452273e 100644 --- a/.eslintrc.changed.js +++ b/.eslintrc.changed.js @@ -10,7 +10,17 @@ module.exports = { }, overrides: [ { - files: ['src/libs/ReportUtils.ts', 'src/libs/actions/IOU.ts', 'src/libs/actions/Report.ts', 'src/libs/actions/Task.ts'], + files: [ + 'src/libs/ReportUtils.ts', + 'src/libs/actions/IOU.ts', + 'src/libs/actions/Report.ts', + 'src/libs/actions/Task.ts', + 'src/libs/OptionsListUtils.ts', + 'src/libs/ReportActionsUtils.ts', + 'src/libs/TransactionUtils/index.ts', + 'src/pages/home/ReportScreen.tsx', + 'src/pages/workspace/WorkspaceInitialPage.tsx', + ], rules: { 'rulesdir/no-default-id-values': 'off', }, diff --git a/.prettierignore b/.prettierignore index b428978a1563..8584ae14b917 100644 --- a/.prettierignore +++ b/.prettierignore @@ -22,3 +22,6 @@ src/libs/E2E/reactNativeLaunchingTest.ts # Automatically generated files src/libs/SearchParser/searchParser.js src/libs/SearchParser/autocompleteParser.js + +# Disable prettier in the submodule +Mobile-Expensify diff --git a/Mobile-Expensify b/Mobile-Expensify index 561b7bcc5a68..1d9a218bff07 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 561b7bcc5a68e84d611fa87257e7cdafaed02e33 +Subproject commit 1d9a218bff0706bbe75f50045c6d39eba1435f97 diff --git a/README.md b/README.md index e8a00927753c..455f2f61197d 100644 --- a/README.md +++ b/README.md @@ -472,7 +472,7 @@ At this point, the default behavior of some `npm` scripts will change to target - `npm run pod-install` - install pods for HybridApp - `npm run clean` - clean native code of HybridApp -If for some reason, you need to target the standalone NewDot application, you can append `*-standalone` to each of these scripts (eg. `npm run ios-standalone` will build NewDot instead of HybridApp). +If for some reason, you need to target the standalone NewDot application, you can append `*-standalone` to each of these scripts (eg. `npm run ios-standalone` will build NewDot instead of HybridApp). The same concept applies to the installation of standalone NewDot node modules. To skip the installation of HybridApp-specific patches and node modules, use `npm run i-standalone` or `npm run install-standalone`. ## Working with HybridApp Day-to-day work with HybridApp shouldn't differ much from the work on the standalone NewDot repo. @@ -500,7 +500,7 @@ It's important to emphasise that a git submodule is just a **regular git reposit > #### For external contributors > > If you'd like to modify the `Mobile-Expensify` source code, it is best that you create your own fork. Then, you can swap origin of the remote repository by executing this command: -> +> > `cd Mobile-Expensify && git remote set-url origin ` > > This way, you'll attach the submodule to your fork repository. diff --git a/android/app/build.gradle b/android/app/build.gradle index 1f5d09b62f00..20e86ae52073 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009007610 - versionName "9.0.76-10" + versionCode 1009007705 + versionName "9.0.77-5" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/images/companyCards/card-bofa.svg b/assets/images/companyCards/card-bofa.svg index 3cc7cf1de2cc..c58229f1b242 100644 --- a/assets/images/companyCards/card-bofa.svg +++ b/assets/images/companyCards/card-bofa.svg @@ -1 +1,27 @@ - \ No newline at end of file + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/card-capitalone.svg b/assets/images/companyCards/card-capitalone.svg index a7c54c7bf529..9f1402298683 100644 --- a/assets/images/companyCards/card-capitalone.svg +++ b/assets/images/companyCards/card-capitalone.svg @@ -1 +1,23 @@ - \ No newline at end of file + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-bofa-large.svg b/assets/images/companyCards/large/card-bofa-large.svg index a842bc93d80b..c83e06ffb65d 100644 --- a/assets/images/companyCards/large/card-bofa-large.svg +++ b/assets/images/companyCards/large/card-bofa-large.svg @@ -1,6 +1,6 @@ - + - - - - - - - + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-capital_one-large.svg b/assets/images/companyCards/large/card-capital_one-large.svg index b71e209a4c11..20f3bd442d9e 100644 --- a/assets/images/companyCards/large/card-capital_one-large.svg +++ b/assets/images/companyCards/large/card-capital_one-large.svg @@ -1,15 +1,15 @@ - + - - + + \ No newline at end of file diff --git a/assets/images/train.svg b/assets/images/train.svg new file mode 100644 index 000000000000..40d8c9d1af8a --- /dev/null +++ b/assets/images/train.svg @@ -0,0 +1,3 @@ + + + diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index c60670c72324..ac086d3a9bed 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -175,7 +175,7 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): // We are importing this worker as a string by using asset/source otherwise it will default to loading via an HTTPS request later. // This causes issues if we have gone offline before the pdfjs web worker is set up as we won't be able to load it from the server. { - test: new RegExp('node_modules/pdfjs-dist/legacy/build/pdf.worker.min.mjs'), + test: new RegExp('node_modules/pdfjs-dist/build/pdf.worker.min.mjs'), type: 'asset/source', }, diff --git a/docs/articles/expensify-classic/connections/Expensify-API.md b/docs/articles/expensify-classic/connections/Expensify-API.md index 52e86a3c9f14..e2fbdbfd7703 100644 --- a/docs/articles/expensify-classic/connections/Expensify-API.md +++ b/docs/articles/expensify-classic/connections/Expensify-API.md @@ -11,46 +11,50 @@ To begin, review our [Integration Server Manual](https://integrations.expensify. We've compiled answers to some frequently asked questions to help you get started. -**Should I give your support team my API credentials when I need help?** +## Should I give your support team my API credentials when I need help? If you’re seeking help with Expensify's API, do not share your partnerUserSecret. If you do, immediately rotate your credentials on [this page](https://www.expensify.com/tools/integrations/). -**Is there a rate limit?** +## Is there a rate limit? -Yes, the rate limit is currently 50 requests per minute. If you exceed this limit, you'll receive an error message. +To keep our platform stable and handle high traffic, Expensify limits how many API requests you can send: +- Up to 5 requests every 10 seconds +- Up to 20 requests every 60 seconds -**What is a Policy ID?** +Sending more requests than allowed may result in an error with status code `429`. + +## What is a Policy ID? This is also known as a Workspace ID. To find your Policy/Workspace ID, Hover over Settings and click Workspaces. Click the name of the Workspace. Copy the ID number from the URL. For example, if the URL is https://www.expensify.com/policy?param={"policyID":"0810E551A5F2A9C2”}, then your workspace ID is 0810E551A5F2A9C2. -**Can I use the parent type `file` to export workspace/policy data?** +## Can I use the parent type `file` to export workspace/policy data? No. The parent type `file` can only be used to export expense and report data — not policy information. To export policy data (e.g., categories, tags), you must use the `get` type with `inputSettings.type` set to `policy`. -**Can I use the API to create Domain Groups?** +## Can I use the API to create Domain Groups? No, you cannot create domain groups. You can only assign users to them. -**I’m exporting expense IDs `${expense.transactionID}` but when I open my CSV in Excel, it’s changing all the IDs and making them look the same. How can I prevent this?** +## I’m exporting expense IDs `${expense.transactionID}` but when I open my CSV in Excel, it’s changing all the IDs and making them look the same. How can I prevent this? Try prepending a non-numeric character like a quote to force Excel to interpret the value as a string and not a number (i.e., `'${expense.transactionID}`). -**How can we export the person who will approve a report while the reports are still processing?** +## How can we export the person who will approve a report while the reports are still processing? Use the field ${report.managerEmail}. -**Why won’t my boolean field return any data?** +## Why won’t my boolean field return any data? Boolean fields won't output values without a string. For example, instead of using `${expense.billable}`, use `${expense.billable?string("Yes", "No")}`. This will display "Yes" if the expense is billable and "No" if it is not. -**Can I export the reports for just one user?** +## Can I export the reports for just one user? Not in a quick convenient way, as you would need to include the user in your template. The simplest approach is to export data for all users and then apply a filter in your preferred spreadsheet program. -**Can I create expenses on behalf of users?** +## Can I create expenses on behalf of users? Yes. However, to access the Expense Creator API on behalf of employees, Expensify needs to verify the following setup: @@ -59,17 +63,17 @@ Verify you have internal authorization to add data to other accounts within your If you need this access, contact concierge@expensify.com and reference this help page. -## Using Postman +# Using Postman Many customers use Postman to help them build out their APIs. Below are some guides contributed by our customers. Please note, in all cases, you will need to first generate your authentication credentials, the steps for which can be found [here](https://integrations.expensify.com/Integration-Server/doc/#introduction) and have them ready: -### Download expenses from a report as a CSV file +## Download expenses from a report as a CSV file **Step 1: Get the ID of a report you want to export in Expensify** Find the ID by opening the expense report and clicking Details at the top right corner of the page. At the top of the menu, the ID is provided as the “Long ID.” -**Step 3: Export (generate) a "Report" as a CSV file** +**Step 2: Export (generate) a "Report" as a CSV file** {% include info.html %} For this you'll use the Documentation under [Report Exporter](https://integrations.expensify.com/Integration-Server/doc/#export). {% include end-info.html %} @@ -142,11 +146,11 @@ The template key will have the value like below: The template variable determines what information is saved in your CSV file. If you want more columns than merchant, amount, and transaction date, follow the syntax as defined in the export template format documentation. -**Step 4: Save your generated file name** +**Step 3: Save your generated file name** -Expensify currently supports only the "onReceive":{"immediateResponse":["returnRandomFileName"]} option in step 3, so you should receive a random filename back from the API like "exportc111111d-a1a1-a1a1-a1a1-d1111111f.csv". You will need to document this filename if you plan on running the download command after this one. +Expensify currently supports only the "onReceive":{"immediateResponse":["returnRandomFileName"]} option in step 2, so you should receive a random filename back from the API like "exportc111111d-a1a1-a1a1-a1a1-d1111111f.csv". You will need to document this filename if you plan on running the download command after this one. -**Step 5: Download your exported report** +**Step 4: Download your exported report** Set up another API call in almost the same way you did before. You don't need the template key in the Body anymore, so delete that and set the Body type to "none". Then modify your requestJobDescription to read like below, but with your own credentials and file name: @@ -166,7 +170,7 @@ Click Go and you should see the CSV in the response body. *Thank you to our customer Frederico Pettinella who originally wrote and shared this guide.* -### Use Advanced Employee Updater API with Postman +## Use Advanced Employee Updater API with Postman 1. Create a new request. 2. Select POST as the method. diff --git a/docs/articles/expensify-classic/expenses/Apply-Tax.md b/docs/articles/expensify-classic/expenses/Apply-Tax.md deleted file mode 100644 index 9360962cb2ba..000000000000 --- a/docs/articles/expensify-classic/expenses/Apply-Tax.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: Apply Tax -description: This is article shows you how to apply taxes to your expenses! ---- - - - -# About - -There are two types of tax in Expensify: Simple Tax (i.e. one tax rate) and Complex Tax (i.e. more than one tax rate). This article shows you how to apply both to your expenses! - - -# How-to Apply Tax - -When Tax Tracking is enabled on a Workspace, the default tax rate is selected under **Settings > Workspace > _Workspace Name_ > Tax**, with the default tax rate applied to all expenses automatically. - -There may be multiple tax rates set up within your Workspace, so if the tax on your receipt is different to the default tax that has been applied, you can select the appropriate rate from the tax drop-down on the web expense editor or the mobile app. - -If the tax amount on your receipt is different to the calculated amount or the tax rate doesn’t show up, you can always manually type in the correct tax amount. - - -{% include faq-begin.md %} - -## How do I set up multiple taxes (GST/PST/QST) on indirect connections? -Expenses sometimes have more than one tax applied to them - for example in Canada, expenses can have both a Federal GST and a provincial PST or QST. - -To handle these, you can create a single tax that combines both taxes into a single effective tax rate. For example, if you have a GST of 5% and PST of 7%, adding the two tax rates together gives you an effective tax rate of 12%. - -From the Reports page, you can select Reports and then click **Export To > Tax Report** to generate a CSV containing all the expense information, including the split-out taxes. - -## Why is the tax amount different than I expect? - -In Expensify, tax is *inclusive*, meaning it's already part of the total amount shown. - -To determine the inclusive tax from a total price that already includes tax, you can use the following formula: - -### **Tax amount = (Total price x Tax rate) ÷ (1 + Tax Rate)** - -For example, if an item costs $100 and the tax rate is 20%: -Tax amount = (**$100** x .20) ÷ (1 + .**20**) = **$16.67** -This means the tax amount $16.67 is included in the total. - -If you are simply trying to calculate the price before tax, you can use the formula: - -### **Price before tax = (Total price) ÷ (1 + Tax rate)** - -# Deep Dive - -If you have a receipt that has more than one tax rate (i.e. Complex Tax) on it, then there are two options for handling this in Expensify! - -Many tax authorities do not require the reporting of tax amounts by rate and the easiest approach is to apply the highest rate on the receipt and then modify the tax amount to reflect the amount shown on the receipt if this is less. Please check with your local tax advisor if this approach will be allowed. - -Alternatively, you can apply each specific tax rate by splitting the expense into the components that each rate will be applied to. To do this, click on **Split Expense** and apply the correct tax rate to each part. - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/workspaces/Tax-Tracking.md b/docs/articles/expensify-classic/workspaces/Tax-Tracking.md deleted file mode 100644 index c47e5ed51f32..000000000000 --- a/docs/articles/expensify-classic/workspaces/Tax-Tracking.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: Tax -description: How to track expense taxes ---- -# Overview -Expensify’s tax tracking feature allows you to: -- Add tax names, rates, and codes whether you’re connected to an accounting system or not. -- Enable/disable taxes you’d like to make available to users. -- Set a default tax for Workspace currency expenses and, optionally, another default tax (including exempt) for foreign currency expenses which - will automatically apply to all new expenses. - -# How to Enable Tax Tracking -Tax tracking can be enabled in the Tax section of the Workspace settings of any Workspace, whether group or individual. -## If Connected to an Accounting Integration -If your group Workspace is connected to Xero, QuickBooks Online, Sage Intacct, or NetSuite, make sure to first enable tax via the connection configuration page (Settings > Workspaces > Group > [Workspace Name] > Connections > Configure) and then sync the connection. Your tax rates will be imported from the accounting system and indicated by its logo. -## Not Connected to an Accounting Integration -If your Workspace is not connected to an accounting system, go to Settings > Workspaces > Group > [Workspace Name] > Tax to enable tax. - -# Tracking Tax by Expense Category -To set a different tax rate for a specific expense type in the Workspace currency, go to Settings > Workspaces > Group > [Workspace Name] > Categories page. Click "Edit Rules" next to the desired category and set the "Category default tax". This will be applied to new expenses, overriding the default Workspace currency tax rate. diff --git a/docs/articles/expensify-classic/workspaces/Track-Taxes.md b/docs/articles/expensify-classic/workspaces/Track-Taxes.md new file mode 100644 index 000000000000..c75058dc8447 --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Track-Taxes.md @@ -0,0 +1,76 @@ +--- +title: Track Taxes +description: How to track taxes and apply them to expenses +--- +Expensify's tax tracking allows you to create tax rates and codes for domestic and foreign currencies, and even for different expense categories. Once you've enabled tax tracking, your default tax rate is automatically applied to all expenses. + +# Tax Tracking - Connected to an accounting integration + +If your Workspace is connected to Xero, QuickBooks Online, Sage Intacct, or NetSuite, you can run through the following steps to set up tax tracking: +1. Hover over **Settings**, then click **Workspaces**. +2. Click the desired workspace name. +3. Click the **Connections** tab on the left. +4. Click **Configure**. +5. Click **Sync Connection**. + +Your tax rates will be imported from the accounting system and indicated by its logo. + +# Tax Tracking - Not connected to an accounting integration + +If your Workspace is not connected to an accounting system, you can run through the following steps to set up tax tracking: +1. Hover over **Settings**, then click **Workspaces**. +2. Click the desired workspace name. +3. Click the **Tax** tab on the left. +4. Enable the toggle to allow taxes to be added to expenses. +5. You can modify the existing tax rate, or you can click New Option to add a new tax rate. For each tax rate, you can enable/disable them individually, add a specific name for the rate, add a percent value, and (if desired) add a unique tax code. +6. Once you have your tax codes added, go to the top of the screen to enter the name that taxes will appear as on expenses. You'll also select which of your tax rates you will use as your defaults for expenses submitted under your workspace currency and foreign currency. + +## Track tax by expense category + +You can also set tax rates for specific expense categories: +1. Hover over **Settings**, then click **Workspaces**. +2. Click the desired workspace name. +3. Click the **Categories** tab on the left. +4. Click **Edit** next to the desired category. +5. Click the Default Tax dropdown and select the desired tax rate. + +This rate will be applied to all new expenses under this category, overriding the workspace's default currency tax rate. + +{% include faq-begin.md %} + +## How do I set up multiple taxes (GST/PST/QST) for indirect connections? + +Expenses sometimes have more than one tax applied to them (for example in Canada, expenses can have both a Federal GST and a provincial PST or QST). + +To handle multiple tax rates, you can create a new tax rate that combines both into a single rate. For example, if you have a GST of 5% and PST of 7%, you can add them together and create a new tax rate of 12%. + +From the Reports page, you can generate a CSV containing all the expense information, including the split-out taxes, by going to the Reports tab, clicking **Export To**, and selecting **Tax Report**. + +## How do I handle the taxes for a receipt that includes more than one tax rate? + +If your receipt includes more than one tax rate, there are two ways you can handle the tax rate: + +- Many tax authorities do not require the reporting of tax amounts by rate; therefore, you can apply the highest rate on the receipt and then modify the tax amount on the receipt if necessary. Please check with your tax advisor to determine if this approach is appropriate for you. +- Alternatively, you can apply each specific tax rate by splitting the expense by the applicable expenses for each rate. To do this, open the expense and click **Split Expense**. Then apply the correct tax rate to each. + +## What if my workspace has multiple tax rates? + +You'll have the option to change the tax rate from within the expense as needed. + +## What should I do if the tax amount for my expense does not show up, or is it showing as a different amount than what I expected? + +In Expensify, tax is *inclusive*, meaning it's already part of the total amount shown. If the tax amount doesn't show up on your receipt or is different than the calculated amount, you can manually type in the correct tax amount. + +To determine the inclusive tax from a total price that already includes tax, you can use the following formula: + +**Tax amount = (Total price x Tax rate) ÷ (1 + Tax Rate)** + +For example, if an item costs $100 and the tax rate is 20%: +Tax amount = (**$100** x .20) ÷ (1 + .**20**) = **$16.67** +This means the tax amount of $16.67 is included in the total. + +If you are simply trying to calculate the price before tax, you can use the formula: + +**Price before tax = (Total price) ÷ (1 + Tax rate)** + +{% include faq-end.md %} diff --git a/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md b/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md index ea058df9c1b1..1b1702c6fcc7 100644 --- a/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md +++ b/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md @@ -51,6 +51,11 @@ When an expense is submitted to a workspace, your approver will receive an email {% include end-selector.html %} +![Click Global Create]({{site.url}}/assets/images/ExpensifyHelp-CreateExpense-1.png){:width="100%"} +![Click Submit expense]({{site.url}}/assets/images/ExpensifyHelp-CreateExpense-2.png){:width="100%"} +![Click Scan]({{site.url}}/assets/images/ExpensifyHelp-CreateExpense-3.png){:width="100%"} +![Enter workspace or individual's name]({{site.url}}/assets/images/ExpensifyHelp-CreateExpense-4.png){:width="100%"} + {% include info.html %} You can also forward receipts to receipts@expensify.com using your primary or secondary email address. SmartScan will automatically extract all the details from the receipt and add them to your expenses. {% include end-info.html %} diff --git a/docs/assets/images/search-hold-01.png b/docs/assets/images/search-hold-01.png new file mode 100644 index 000000000000..04745c570367 Binary files /dev/null and b/docs/assets/images/search-hold-01.png differ diff --git a/docs/assets/images/search-hold-02.png b/docs/assets/images/search-hold-02.png new file mode 100644 index 000000000000..3c7c39defd66 Binary files /dev/null and b/docs/assets/images/search-hold-02.png differ diff --git a/docs/assets/images/search-hold-03.png b/docs/assets/images/search-hold-03.png new file mode 100644 index 000000000000..81fbddcf5d75 Binary files /dev/null and b/docs/assets/images/search-hold-03.png differ diff --git a/docs/assets/images/search-hold-04.png b/docs/assets/images/search-hold-04.png new file mode 100644 index 000000000000..e5c1b71c0e37 Binary files /dev/null and b/docs/assets/images/search-hold-04.png differ diff --git a/docs/assets/images/search-hold-05.png b/docs/assets/images/search-hold-05.png new file mode 100644 index 000000000000..2d111abecb65 Binary files /dev/null and b/docs/assets/images/search-hold-05.png differ diff --git a/docs/redirects.csv b/docs/redirects.csv index 751e072fb13f..04eba2e6152c 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -385,7 +385,7 @@ https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Vac https://community.expensify.com/discussion/5678/deep-dive-secondary-login-merge-accounts-what-does-this-mean,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts https://community.expensify.com/discussion/5103/how-to-create-and-use-custom-units/,https://help.expensify.com/ https://community.expensify.com/discussion/6530/how-to-set-your-time-zone-for-report-history-comments,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-time-zone -https://community.expensify.com/discussion/5651/deep-dive--practices-when-youre-running-into-trouble-receiving-emails-from-expensify,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-Notifications +https://community.expensify.com/discussion/5651/deep-dive-best-practices-when-youre-running-into-trouble-receiving-emails-from-expensify,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-Notifications https://community.expensify.com/discussion/5793/how-to-connect-your-personal-card-to-import-expenses/,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards https://community.expensify.com/discussion/5677/deep-dive-security-how-expensify-protects-your-information,https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security https://community.expensify.com/discussion/4641/how-to-add-a-u-s-deposit-account,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account @@ -608,3 +608,5 @@ https://help.expensify.com/articles/expensify-classic/travel/Edit-or-cancel-trav https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills https://help.expensify.com/articles/expensify-classic/settings/Set-Notifications,https://help.expensify.com/articles/expensify-classic/settings/Email-Notifications https://help.expensify.com/articles/new-expensify/expenses-&-payments/Export-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Export-download-expenses +https://help.expensify.com/articles/expensify-classic/expenses/Apply-Tax,https://help.expensify.com/articles/expensify-classic/workspaces/Track-Taxes +https://help.expensify.com/articles/expensify-classic/workspaces/Tax-Tracking,https://help.expensify.com/articles/expensify-classic/workspaces/Track-Taxes \ No newline at end of file diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 4be40aa649ac..4b9157afbcae 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.76 + 9.0.77 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.76.10 + 9.0.77.5 FullStory OrgId @@ -67,6 +67,8 @@ NSCameraUsageDescription Your camera is used to create chat attachments, documents, and facial capture. + NSContactsUsageDescription + Import contacts from your phone so your favorite people are always a tap away. NSLocationAlwaysAndWhenInUseUsageDescription Your location is used to determine your default currency and timezone. NSLocationWhenInUseUsageDescription diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index e34521b9561a..3e6cc8920b19 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.76 + 9.0.77 CFBundleSignature ???? CFBundleVersion - 9.0.76.10 + 9.0.77.5 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 76fb08c2fdb9..d3bc23571433 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.76 + 9.0.77 CFBundleVersion - 9.0.76.10 + 9.0.77.5 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d4fe34fe3699..e9532fc1ae30 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2410,7 +2410,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.207): + - RNLiveMarkdown (0.1.209): - DoubleConversion - glog - hermes-engine @@ -2430,10 +2430,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/newarch (= 0.1.207) + - RNLiveMarkdown/newarch (= 0.1.209) - RNReanimated/worklets - Yoga - - RNLiveMarkdown/newarch (0.1.207): + - RNLiveMarkdown/newarch (0.1.209): - DoubleConversion - glog - hermes-engine @@ -3292,7 +3292,7 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 8781e2529230a1bc3ea8d75e5c3cd071b6c6aed7 RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: 8f9d9b32a25969ddb5f59eb92136b73823bbd141 + RNLiveMarkdown: f19d3c962fba4fb87bb9bc27ce9119216d86d92e RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: 460d6ff97ae49c7d5708c3212c6521697c36a0c4 RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28 diff --git a/metro.config.js b/metro.config.js index c6e4ba6bb4ec..98bea7be80ed 100644 --- a/metro.config.js +++ b/metro.config.js @@ -4,6 +4,7 @@ const {getDefaultConfig: getReactNativeDefaultConfig} = require('@react-native/m const {mergeConfig} = require('@react-native/metro-config'); const defaultAssetExts = require('metro-config/src/defaults/defaults').assetExts; const defaultSourceExts = require('metro-config/src/defaults/defaults').sourceExts; +const {wrapWithReanimatedMetroConfig} = require('react-native-reanimated/metro-config'); require('dotenv').config(); const defaultConfig = getReactNativeDefaultConfig(__dirname); @@ -26,4 +27,4 @@ const config = { }, }; -module.exports = mergeConfig(defaultConfig, expoConfig, config); +module.exports = wrapWithReanimatedMetroConfig(mergeConfig(defaultConfig, expoConfig, config)); diff --git a/package-lock.json b/package-lock.json index 47310a40a55b..0ded98fa58eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "new.expensify", - "version": "9.0.76-10", + "version": "9.0.77-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.76-10", + "version": "9.0.77-5", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.207", + "@expensify/react-native-live-markdown": "0.1.209", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -73,7 +73,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.3.1", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "1.0.20", + "react-fast-pdf": "1.0.21", "react-map-gl": "^7.1.3", "react-native": "0.75.2", "react-native-android-location-enabler": "^2.0.1", @@ -3498,9 +3498,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.207", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.207.tgz", - "integrity": "sha512-8snKeruLuHJCecnwQ+ru6pJhrDeI2Y3EywmXf/keT4aMk2xcW1fyCAr925zikTWANMDghcKkeuR/JqLe2b3rkA==", + "version": "0.1.209", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.209.tgz", + "integrity": "sha512-u+RRY+Jog/llEu9T1v0okSLgRhG5jGlX9H1Je0A8HWv0439XFLnAWSvN2eQ2T7bvT8Yjdj5CcC0hkgJiB9oCQw==", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -13485,7 +13485,9 @@ "license": "MIT" }, "node_modules/@types/http-proxy": { - "version": "1.17.9", + "version": "1.17.15", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.15.tgz", + "integrity": "sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18467,7 +18469,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -18971,17 +18975,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/default-gateway": { - "version": "6.0.3", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "execa": "^5.0.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/defaults": { "version": "1.0.4", "license": "MIT", @@ -23755,7 +23748,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.6", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", + "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", "dev": true, "license": "MIT", "dependencies": { @@ -23779,6 +23774,8 @@ }, "node_modules/http-proxy-middleware/node_modules/is-plain-obj": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", "dev": true, "license": "MIT", "engines": { @@ -24186,9 +24183,10 @@ } }, "node_modules/internal-ip/node_modules/cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "license": "MIT", "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", @@ -25114,23 +25112,6 @@ "reflect.getprototypeof": "^1.0.3" } }, - "node_modules/jackspeak": { - "version": "2.3.6", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.8.7", "dev": true, @@ -29439,7 +29420,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.7", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -31771,9 +31754,9 @@ } }, "node_modules/react-fast-pdf": { - "version": "1.0.20", - "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.20.tgz", - "integrity": "sha512-E2PJOO5oEqi6eNPllNOlQ8y0DiLZ3AW8t+MCN7AgJPp5pY04SeDveXHWvPN0nPU4X5sRBZ7CejeYce2QMMQDyg==", + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.21.tgz", + "integrity": "sha512-8Uuz/jPHjHqElH+aUj3ldS/Hg/NoZ5ZS/VupGzDkVJST0UiGzxkvDxxFIQuYuiaI4NGwGmqtQGGYsjJKpyWnig==", "license": "MIT", "dependencies": { "react-pdf": "^9.1.1", @@ -36880,7 +36863,9 @@ } }, "node_modules/webpack-dev-server": { - "version": "5.0.4", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.0.tgz", + "integrity": "sha512-90SqqYXA2SK36KcT6o1bvwvZfJFcmoamqeJY7+boioffX9g9C0wjjJRGUrQIuh43pb0ttX7+ssavmj/WN2RHtA==", "dev": true, "license": "MIT", "dependencies": { @@ -36897,23 +36882,20 @@ "colorette": "^2.0.10", "compression": "^1.7.4", "connect-history-api-fallback": "^2.0.0", - "default-gateway": "^6.0.3", - "express": "^4.17.3", + "express": "^4.21.2", "graceful-fs": "^4.2.6", - "html-entities": "^2.4.0", - "http-proxy-middleware": "^2.0.3", + "http-proxy-middleware": "^2.0.7", "ipaddr.js": "^2.1.0", "launch-editor": "^2.6.1", "open": "^10.0.3", "p-retry": "^6.2.0", - "rimraf": "^5.0.5", "schema-utils": "^4.2.0", "selfsigned": "^2.4.1", "serve-index": "^1.9.1", "sockjs": "^0.3.24", "spdy": "^4.0.2", - "webpack-dev-middleware": "^7.1.0", - "ws": "^8.16.0" + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" }, "bin": { "webpack-dev-server": "bin/webpack-dev-server.js" @@ -36937,16 +36919,10 @@ } } }, - "node_modules/webpack-dev-server/node_modules/brace-expansion": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/webpack-dev-server/node_modules/colorette": { "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true, "license": "MIT" }, @@ -36961,27 +36937,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/webpack-dev-server/node_modules/glob": { - "version": "10.3.12", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.6", - "minimatch": "^9.0.1", - "minipass": "^7.0.4", - "path-scurry": "^1.10.2" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/webpack-dev-server/node_modules/ipaddr.js": { "version": "2.1.0", "dev": true, @@ -37004,28 +36959,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/webpack-dev-server/node_modules/minimatch": { - "version": "9.0.4", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/webpack-dev-server/node_modules/minipass": { - "version": "7.0.4", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/webpack-dev-server/node_modules/open": { "version": "10.1.0", "dev": true, @@ -37043,25 +36976,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/webpack-dev-server/node_modules/rimraf": { - "version": "5.0.5", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/webpack-dev-server/node_modules/schema-utils": { - "version": "4.2.0", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", + "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", "dev": true, "license": "MIT", "dependencies": { @@ -37071,7 +36989,7 @@ "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">= 12.13.0" + "node": ">= 10.13.0" }, "funding": { "type": "opencollective", @@ -37079,7 +36997,9 @@ } }, "node_modules/webpack-dev-server/node_modules/webpack-dev-middleware": { - "version": "7.2.1", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.2.tgz", + "integrity": "sha512-xOO8n6eggxnwYpy1NlzUKpvrjfJTvae5/D6WOK0S2LSo7vjmo5gCM1DbLUmFqrMTJP+W/0YZNctm7jasWvLuBA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 763d1ff8aa49..0896f83e10d0 100644 --- a/package.json +++ b/package.json @@ -1,28 +1,30 @@ { "name": "new.expensify", - "version": "9.0.76-10", + "version": "9.0.77-5", "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.", "license": "MIT", "private": true, "scripts": { + "i-standalone": "STANDALONE_NEW_DOT=true npm i", + "install-standalone": "STANDALONE_NEW_DOT=true npm install", "configure-mapbox": "./scripts/setup-mapbox-sdk-walkthrough.sh", "setupNewDotWebForEmulators": "./scripts/setup-newdot-web-emulators.sh", "startAndroidEmulator": "./scripts/start-android.sh", "postinstall": "./scripts/postInstall.sh", "clean": "./scripts/clean.sh", - "clean-standalone": "./scripts/clean.sh --new-dot", + "clean-standalone": "STANDALONE_NEW_DOT=true ./scripts/clean.sh", "android": "./scripts/set-pusher-suffix.sh && ./scripts/run-build.sh --android", - "android-standalone": "./scripts/set-pusher-suffix.sh && ./scripts/run-build.sh --android --new-dot", + "android-standalone": "./scripts/set-pusher-suffix.sh && STANDALONE_NEW_DOT=true ./scripts/run-build.sh --android", "ios": "./scripts/set-pusher-suffix.sh && ./scripts/run-build.sh --ios", - "ios-standalone": "./scripts/set-pusher-suffix.sh && ./scripts/run-build.sh --ios --new-dot", + "ios-standalone": "./scripts/set-pusher-suffix.sh && STANDALONE_NEW_DOT=true ./scripts/run-build.sh --ios", "pod-install": "./scripts/pod-install.sh", - "pod-install-standalone": "./scripts/pod-install.sh --new-dot", + "pod-install-standalone": "STANDALONE_NEW_DOT=true ./scripts/pod-install.sh", "ipad": "concurrently \"./scripts/run-build.sh --ipad\"", - "ipad-standalone": "concurrently \"./scripts/run-build.sh --ipad --new-dot\"", + "ipad-standalone": "concurrently \"STANDALONE_NEW_DOT=true ./scripts/run-build.sh --ipad\"", "ipad-sm": "concurrently \"./scripts/run-build.sh --ipad-sm\"", - "ipad-sm-standalone": "concurrently \"./scripts/run-build.sh --ipad-sm --new-dot\"", + "ipad-sm-standalone": "concurrently \"STANDALONE_NEW_DOT=true ./scripts/run-build.sh --ipad-sm\"", "start": "npx react-native start", "web": "./scripts/set-pusher-suffix.sh && concurrently npm:web-proxy npm:web-server", "web-proxy": "ts-node web/proxy.ts", @@ -74,7 +76,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.207", + "@expensify/react-native-live-markdown": "0.1.209", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -136,7 +138,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.3.1", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "1.0.20", + "react-fast-pdf": "1.0.21", "react-map-gl": "^7.1.3", "react-native": "0.75.2", "react-native-android-location-enabler": "^2.0.1", diff --git a/patches/react-native+0.75.2+026+fix-dropping-mutations-in-transactions.patch b/patches/react-native+0.75.2+026+fix-dropping-mutations-in-transactions.patch new file mode 100644 index 000000000000..974a0d090fb9 --- /dev/null +++ b/patches/react-native+0.75.2+026+fix-dropping-mutations-in-transactions.patch @@ -0,0 +1,67 @@ +diff --git a/node_modules/react-native/ReactAndroid/src/main/jni/react/fabric/Binding.cpp b/node_modules/react-native/ReactAndroid/src/main/jni/react/fabric/Binding.cpp +index 572fb3d..0efa1ed 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/jni/react/fabric/Binding.cpp ++++ b/node_modules/react-native/ReactAndroid/src/main/jni/react/fabric/Binding.cpp +@@ -468,7 +468,7 @@ void Binding::schedulerDidFinishTransaction( + mountingTransaction->getSurfaceId(); + }); + +- if (pendingTransaction != pendingTransactions_.end()) { ++ if (pendingTransaction != pendingTransactions_.end() && pendingTransaction->canMergeWith(*mountingTransaction)) { + pendingTransaction->mergeWith(std::move(*mountingTransaction)); + } else { + pendingTransactions_.push_back(std::move(*mountingTransaction)); +diff --git a/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.cpp b/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.cpp +index d7dd1bc..d95d779 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.cpp ++++ b/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.cpp +@@ -5,6 +5,8 @@ + * LICENSE file in the root directory of this source tree. + */ + ++#include ++ + #include "MountingTransaction.h" + + namespace facebook::react { +@@ -54,4 +56,21 @@ void MountingTransaction::mergeWith(MountingTransaction&& transaction) { + telemetry_ = std::move(transaction.telemetry_); + } + ++bool MountingTransaction::canMergeWith(MountingTransaction& transaction) { ++ std::set deletedTags; ++ for (const auto& mutation : mutations_) { ++ if (mutation.type == ShadowViewMutation::Type::Delete) { ++ deletedTags.insert(mutation.oldChildShadowView.tag); ++ } ++ } ++ ++ for (const auto& mutation : transaction.getMutations()) { ++ if (mutation.type == ShadowViewMutation::Type::Create && deletedTags.contains(mutation.newChildShadowView.tag)) { ++ return false; ++ } ++ } ++ ++ return true; ++} ++ + } // namespace facebook::react +diff --git a/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.h b/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.h +index 277e9f4..38629db 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.h ++++ b/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.h +@@ -85,6 +85,14 @@ class MountingTransaction final { + */ + void mergeWith(MountingTransaction&& transaction); + ++ /* ++ * Checks whether the two transactions can be safely merged. Due to ++ * reordering of mutations during mount, the sequence of ++ * REMOVE -> DELETE | CREATE -> INSERT (2 transactions) may get changed to ++ * INSERT -> REMOVE -> DELETE and the state will diverge from there. ++ */ ++ bool canMergeWith(MountingTransaction& transaction); ++ + private: + SurfaceId surfaceId_; + Number number_; diff --git a/patches/react-native-draggable-flatlist+4.0.1.patch b/patches/react-native-draggable-flatlist+4.0.1.patch index 348f1aa5de8a..a3d29b66de7a 100644 --- a/patches/react-native-draggable-flatlist+4.0.1.patch +++ b/patches/react-native-draggable-flatlist+4.0.1.patch @@ -12,7 +12,7 @@ index d7d98c2..2f59c7a 100644 runOnJS(onDragEnd)({ from: activeIndexAnim.value, diff --git a/node_modules/react-native-draggable-flatlist/src/context/refContext.tsx b/node_modules/react-native-draggable-flatlist/src/context/refContext.tsx -index ea21575..66c5eed 100644 +index ea21575..dc6b095 100644 --- a/node_modules/react-native-draggable-flatlist/src/context/refContext.tsx +++ b/node_modules/react-native-draggable-flatlist/src/context/refContext.tsx @@ -1,14 +1,14 @@ @@ -32,14 +32,13 @@ index ea21575..66c5eed 100644 cellDataRef: React.MutableRefObject>; keyToIndexRef: React.MutableRefObject>; containerRef: React.RefObject; -@@ -54,8 +54,8 @@ function useSetupRefs({ +@@ -54,8 +54,7 @@ function useSetupRefs({ ...DEFAULT_PROPS.animationConfig, ...animationConfig, } as WithSpringConfig; - const animationConfigRef = useRef(animConfig); - animationConfigRef.current = animConfig; + const animationConfigRef = useSharedValue(animConfig); -+ animationConfigRef.value = animConfig; const cellDataRef = useRef(new Map()); const keyToIndexRef = useRef(new Map()); @@ -57,7 +56,7 @@ index ce4ab68..efea240 100644 return translate; diff --git a/node_modules/react-native-draggable-flatlist/src/hooks/useOnCellActiveAnimation.ts b/node_modules/react-native-draggable-flatlist/src/hooks/useOnCellActiveAnimation.ts -index 7c20587..857c7d0 100644 +index 7c20587..33042e9 100644 --- a/node_modules/react-native-draggable-flatlist/src/hooks/useOnCellActiveAnimation.ts +++ b/node_modules/react-native-draggable-flatlist/src/hooks/useOnCellActiveAnimation.ts @@ -1,8 +1,9 @@ @@ -72,18 +71,17 @@ index 7c20587..857c7d0 100644 } from "react-native-reanimated"; import { DEFAULT_ANIMATION_CONFIG } from "../constants"; import { useAnimatedValues } from "../context/animatedValueContext"; -@@ -15,8 +16,8 @@ type Params = { +@@ -15,8 +16,7 @@ type Params = { export function useOnCellActiveAnimation( { animationConfig }: Params = { animationConfig: {} } ) { - const animationConfigRef = useRef(animationConfig); - animationConfigRef.current = animationConfig; + const animationConfigRef = useSharedValue(animationConfig); -+ animationConfigRef.value = animationConfig; const isActive = useIsActive(); -@@ -26,7 +27,7 @@ export function useOnCellActiveAnimation( +@@ -26,7 +26,7 @@ export function useOnCellActiveAnimation( const toVal = isActive && isTouchActiveNative.value ? 1 : 0; return withSpring(toVal, { ...DEFAULT_ANIMATION_CONFIG, diff --git a/patches/react-native-render-html+6.3.1+002+fix-console-warning.patch b/patches/react-native-render-html+6.3.1+002+fix-console-warning.patch deleted file mode 100644 index 4364052e08ec..000000000000 --- a/patches/react-native-render-html+6.3.1+002+fix-console-warning.patch +++ /dev/null @@ -1,695 +0,0 @@ -diff --git a/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js b/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js -index 9d16738..bbc66a0 100644 ---- a/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js -+++ b/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js -@@ -3,7 +3,7 @@ - Object.defineProperty(exports, "__esModule", { - value: true - }); --exports.default = exports.tchildrenRendererDefaultProps = void 0; -+exports.default = void 0; - - var _renderChildren = _interopRequireDefault(require("./renderChildren")); - -@@ -15,15 +15,6 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de - */ - const TChildrenRenderer = _renderChildren.default.bind(null); - --const tchildrenRendererDefaultProps = { -- propsForChildren: {} --}; --/** -- * @ignore -- */ -- --exports.tchildrenRendererDefaultProps = tchildrenRendererDefaultProps; --TChildrenRenderer.defaultProps = tchildrenRendererDefaultProps; - var _default = TChildrenRenderer; - exports.default = _default; - //# sourceMappingURL=TChildrenRenderer.js.map -\ No newline at end of file -diff --git a/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js b/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js -index 50b43ca..5ecf4a4 100644 ---- a/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js -+++ b/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js -@@ -8,8 +8,6 @@ exports.default = void 0; - - var _SharedPropsProvider = require("./context/SharedPropsProvider"); - --var _TChildrenRenderer = require("./TChildrenRenderer"); -- - var _renderChildren = _interopRequireDefault(require("./renderChildren")); - - function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } -@@ -78,12 +76,7 @@ function TNodeChildrenRenderer(props) { - - return (0, _renderChildren.default)(useTNodeChildrenProps(props)); - } --/** -- * @ignore -- */ -- - --TNodeChildrenRenderer.defaultProps = _TChildrenRenderer.tchildrenRendererDefaultProps; - var _default = TNodeChildrenRenderer; - exports.default = _default; - //# sourceMappingURL=TNodeChildrenRenderer.js.map -\ No newline at end of file -diff --git a/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js b/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js -index eafc942..e083941 100644 ---- a/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js -+++ b/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js -@@ -57,7 +57,11 @@ const TNodeRenderer = /*#__PURE__*/(0, _react.memo)(function MemoizedTNodeRender - const sharedProps = (0, _SharedPropsProvider.useSharedProps)(); - const renderRegistry = (0, _RenderRegistryProvider.useRendererRegistry)(); - const TNodeChildrenRenderer = (0, _TChildrenRendererContext.useTNodeChildrenRenderer)(); -- const tnodeProps = { ...props, -+ const tnodeProps = { -+ propsFromParent: { -+ collapsedMarginTop: null -+ }, -+ ...props, - TNodeChildrenRenderer, - sharedProps - }; -@@ -109,13 +113,6 @@ const TNodeRenderer = /*#__PURE__*/(0, _react.memo)(function MemoizedTNodeRender - const renderFn = tnode.type === 'block' || tnode.type === 'document' ? _renderBlockContent.default : _renderTextualContent.default; - return Renderer === null ? renderFn(assembledProps) : /*#__PURE__*/_react.default.createElement(Renderer, assembledProps); - }); --const defaultProps = { -- propsFromParent: { -- collapsedMarginTop: null -- } --}; // @ts-expect-error default props must be defined -- --TNodeRenderer.defaultProps = defaultProps; - var _default = TNodeRenderer; - exports.default = _default; - //# sourceMappingURL=TNodeRenderer.js.map -\ No newline at end of file -diff --git a/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js b/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js -index 3a700b6..89a4dd4 100644 ---- a/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js -+++ b/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js -@@ -5,89 +5,16 @@ Object.defineProperty(exports, "__esModule", { - }); - exports.useAmbientTRenderEngine = useAmbientTRenderEngine; - exports.default = TRenderEngineProvider; --exports.defaultTRenderEngineProviderProps = exports.defaultFallbackFonts = exports.tRenderEngineProviderPropTypes = void 0; - - var _react = _interopRequireDefault(require("react")); - --var _reactNative = require("react-native"); -- --var _propTypes = _interopRequireDefault(require("prop-types")); -- - var _useTRenderEngine = _interopRequireDefault(require("./hooks/useTRenderEngine")); - --var _defaultSystemFonts = _interopRequireDefault(require("./defaultSystemFonts")); -- - function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - - const defaultTRenderEngine = {}; - - const TRenderEngineContext = /*#__PURE__*/_react.default.createContext(defaultTRenderEngine); -- --const tRenderEngineProviderPropTypes = { -- customHTMLElementModels: _propTypes.default.object.isRequired, -- enableCSSInlineProcessing: _propTypes.default.bool, -- enableUserAgentStyles: _propTypes.default.bool, -- idsStyles: _propTypes.default.object, -- ignoredDomTags: _propTypes.default.array, -- ignoreDomNode: _propTypes.default.func, -- domVisitors: _propTypes.default.object, -- ignoredStyles: _propTypes.default.array.isRequired, -- allowedStyles: _propTypes.default.array, -- htmlParserOptions: _propTypes.default.object, -- tagsStyles: _propTypes.default.object, -- classesStyles: _propTypes.default.object, -- emSize: _propTypes.default.number.isRequired, -- baseStyle: _propTypes.default.object, -- systemFonts: _propTypes.default.arrayOf(_propTypes.default.string), -- fallbackFonts: _propTypes.default.shape({ -- serif: _propTypes.default.string, -- 'sans-serif': _propTypes.default.string, -- monospace: _propTypes.default.string -- }), -- setMarkersForTNode: _propTypes.default.func, -- dangerouslyDisableHoisting: _propTypes.default.bool, -- dangerouslyDisableWhitespaceCollapsing: _propTypes.default.bool, -- selectDomRoot: _propTypes.default.func --}; --/** -- * Default fallback font for special keys such as 'sans-serif', 'monospace', -- * 'serif', based on current platform. -- */ -- --exports.tRenderEngineProviderPropTypes = tRenderEngineProviderPropTypes; --const defaultFallbackFonts = { -- 'sans-serif': _reactNative.Platform.select({ -- ios: 'system', -- default: 'sans-serif' -- }), -- monospace: _reactNative.Platform.select({ -- ios: 'Menlo', -- default: 'monospace' -- }), -- serif: _reactNative.Platform.select({ -- ios: 'Times New Roman', -- default: 'serif' -- }) --}; --exports.defaultFallbackFonts = defaultFallbackFonts; --const defaultTRenderEngineProviderProps = { -- htmlParserOptions: { -- decodeEntities: true -- }, -- emSize: 14, -- ignoredDomTags: [], -- ignoredStyles: [], -- baseStyle: { -- fontSize: 14 -- }, -- tagsStyles: {}, -- classesStyles: {}, -- enableUserAgentStyles: true, -- enableCSSInlineProcessing: true, -- customHTMLElementModels: {}, -- fallbackFonts: defaultFallbackFonts, -- systemFonts: _defaultSystemFonts.default --}; - /** - * Use the ambient transient render engine. - * -@@ -96,7 +23,6 @@ const defaultTRenderEngineProviderProps = { - * @public - */ - --exports.defaultTRenderEngineProviderProps = defaultTRenderEngineProviderProps; - - function useAmbientTRenderEngine() { - const engine = _react.default.useContext(TRenderEngineContext); -@@ -126,15 +52,4 @@ function TRenderEngineProvider({ - value: engine - }, children); - } --/** -- * @ignore -- */ -- -- --TRenderEngineProvider.defaultProps = defaultTRenderEngineProviderProps; --/** -- * @ignore -- */ -- --TRenderEngineProvider.propTypes = tRenderEngineProviderPropTypes; - //# sourceMappingURL=TRenderEngineProvider.js.map -\ No newline at end of file -diff --git a/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js b/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js -index 1be151a..3a076d4 100644 ---- a/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js -+++ b/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js -@@ -7,8 +7,6 @@ exports.default = void 0; - - var _react = _interopRequireDefault(require("react")); - --var _propTypes = _interopRequireDefault(require("prop-types")); -- - var _useIMGElementState = _interopRequireDefault(require("./useIMGElementState")); - - var _IMGElementContentSuccess = _interopRequireDefault(require("./IMGElementContentSuccess")); -@@ -19,15 +17,10 @@ var _IMGElementContentLoading = _interopRequireDefault(require("./IMGElementCont - - var _IMGElementContentError = _interopRequireDefault(require("./IMGElementContentError")); - --var _defaultInitialImageDimensions = _interopRequireDefault(require("./defaultInitialImageDimensions")); -- - function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - - function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } - --function identity(arg) { -- return arg; --} - /** - * A component to render images based on an internal loading state. - * -@@ -37,8 +30,6 @@ function identity(arg) { - * {@link IMGElementContentSuccess}, {@link IMGElementContentLoading} - * and {@link IMGElementContentError} for customization. - */ -- -- - function IMGElement(props) { - const state = (0, _useIMGElementState.default)(props); - let content; -@@ -59,43 +50,6 @@ function IMGElement(props) { - }), content); - } - --const imgDimensionsType = _propTypes.default.shape({ -- width: _propTypes.default.number, -- height: _propTypes.default.number --}); -- --const propTypes = { -- source: _propTypes.default.object.isRequired, -- alt: _propTypes.default.string, -- altColor: _propTypes.default.string, -- height: _propTypes.default.oneOfType([_propTypes.default.string, _propTypes.default.number]), -- width: _propTypes.default.oneOfType([_propTypes.default.string, _propTypes.default.number]), -- style: _propTypes.default.oneOfType([_propTypes.default.object, _propTypes.default.array]), -- computeMaxWidth: _propTypes.default.func.isRequired, -- contentWidth: _propTypes.default.number, -- enableExperimentalPercentWidth: _propTypes.default.bool, -- initialDimensions: imgDimensionsType, -- onPress: _propTypes.default.func, -- testID: _propTypes.default.string, -- objectFit: _propTypes.default.string, -- cachedNaturalDimensions: imgDimensionsType, -- containerProps: _propTypes.default.object --}; --/** -- * @ignore -- */ -- --IMGElement.propTypes = propTypes; --/** -- * @ignore -- */ -- --IMGElement.defaultProps = { -- enableExperimentalPercentWidth: false, -- computeMaxWidth: identity, -- imagesInitialDimensions: _defaultInitialImageDimensions.default, -- style: {} --}; - var _default = IMGElement; - exports.default = _default; - //# sourceMappingURL=IMGElement.js.map -\ No newline at end of file -diff --git a/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx b/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx -index 0df5375..925062a 100644 ---- a/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx -+++ b/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx -@@ -1,5 +1,4 @@ - import React, { PropsWithChildren, ReactElement, useMemo } from 'react'; --import PropTypes from 'prop-types'; - import RenderersPropsProvider from './context/RenderersPropsProvider'; - import SharedPropsProvider from './context/SharedPropsProvider'; - import TChildrenRenderersContext from './context/TChildrenRendererContext'; -@@ -20,29 +19,6 @@ const childrenRendererContext = { - TNodeChildrenRenderer - }; - --export type RenderHTMLConfigPropTypes = Record; -- --export const renderHTMLConfigPropTypes: RenderHTMLConfigPropTypes = { -- bypassAnonymousTPhrasingNodes: PropTypes.bool, -- defaultTextProps: PropTypes.object, -- defaultViewProps: PropTypes.object, -- enableExperimentalBRCollapsing: PropTypes.bool, -- enableExperimentalGhostLinesPrevention: PropTypes.bool, -- enableExperimentalMarginCollapsing: PropTypes.bool, -- remoteErrorView: PropTypes.func, -- remoteLoadingView: PropTypes.func, -- debug: PropTypes.bool, -- computeEmbeddedMaxWidth: PropTypes.func, -- renderersProps: PropTypes.object, -- WebView: PropTypes.any, -- GenericPressable: PropTypes.any, -- defaultWebViewProps: PropTypes.object, -- pressableHightlightColor: PropTypes.string, -- customListStyleSpecs: PropTypes.object, -- renderers: PropTypes.object, -- provideEmbeddedHeaders: PropTypes.func --}; -- - /** - * A component to provide configuration for {@link RenderHTMLSource} - * descendants, to be used in conjunction with {@link TRenderEngineProvider}. -@@ -85,8 +61,3 @@ export default function RenderHTMLConfigProvider( - - ); - } -- --/** -- * @ignore -- */ --RenderHTMLConfigProvider.propTypes = renderHTMLConfigPropTypes; -diff --git a/node_modules/react-native-render-html/src/RenderHTMLSource.tsx b/node_modules/react-native-render-html/src/RenderHTMLSource.tsx -index c91da52..fd0e052 100644 ---- a/node_modules/react-native-render-html/src/RenderHTMLSource.tsx -+++ b/node_modules/react-native-render-html/src/RenderHTMLSource.tsx -@@ -1,7 +1,6 @@ - import equals from 'ramda/src/equals'; - import React, { memo, ReactElement, useMemo } from 'react'; - import { Dimensions } from 'react-native'; --import PropTypes from 'prop-types'; - import ttreeEventsContext from './context/ttreeEventsContext'; - import isUriSource from './helpers/isUriSource'; - import { SourceLoaderProps, TTreeEvents } from './internal-types'; -@@ -25,29 +24,6 @@ export type RenderHTMLSourcePropTypes = Record< - any - >; - --export const renderSourcePropTypes: RenderHTMLSourcePropTypes = { -- source: PropTypes.oneOfType([ -- PropTypes.shape({ -- html: PropTypes.string.isRequired, -- baseUrl: PropTypes.string -- }), -- PropTypes.shape({ -- dom: PropTypes.object.isRequired, -- baseUrl: PropTypes.string -- }), -- PropTypes.shape({ -- uri: PropTypes.string.isRequired, -- method: PropTypes.string, -- body: PropTypes.any, -- headers: PropTypes.object -- }) -- ]), -- onTTreeChange: PropTypes.func, -- onHTMLLoaded: PropTypes.func, -- onDocumentMetadataLoaded: PropTypes.func, -- contentWidth: PropTypes.number --}; -- - function isEmptySource(source: undefined | HTMLSource) { - return ( - !source || -@@ -136,9 +112,4 @@ const RenderHTMLSource = memo( - } - ); - --/** -- * @ignore -- */ --(RenderHTMLSource as any).propTypes = renderSourcePropTypes; -- - export default RenderHTMLSource; -diff --git a/node_modules/react-native-render-html/src/TChildrenRenderer.tsx b/node_modules/react-native-render-html/src/TChildrenRenderer.tsx -index 618a592..e12888e 100644 ---- a/node_modules/react-native-render-html/src/TChildrenRenderer.tsx -+++ b/node_modules/react-native-render-html/src/TChildrenRenderer.tsx -@@ -9,16 +9,4 @@ import renderChildren from './renderChildren'; - const TChildrenRenderer: FunctionComponent = - renderChildren.bind(null); - --export const tchildrenRendererDefaultProps: Pick< -- TChildrenRendererProps, -- 'propsForChildren' --> = { -- propsForChildren: {} --}; -- --/** -- * @ignore -- */ --TChildrenRenderer.defaultProps = tchildrenRendererDefaultProps; -- - export default TChildrenRenderer; -diff --git a/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx b/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx -index bf5aef6..b820de0 100644 ---- a/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx -+++ b/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx -@@ -1,7 +1,6 @@ - import { ReactElement } from 'react'; - import { TNode } from '@native-html/transient-render-engine'; - import { useSharedProps } from './context/SharedPropsProvider'; --import { tchildrenRendererDefaultProps } from './TChildrenRenderer'; - import { - TChildrenRendererProps, - TNodeChildrenRendererProps -@@ -73,9 +72,4 @@ function TNodeChildrenRenderer( - return renderChildren(useTNodeChildrenProps(props)); - } - --/** -- * @ignore -- */ --TNodeChildrenRenderer.defaultProps = tchildrenRendererDefaultProps; -- - export default TNodeChildrenRenderer; -diff --git a/node_modules/react-native-render-html/src/TNodeRenderer.tsx b/node_modules/react-native-render-html/src/TNodeRenderer.tsx -index d32140f..0804ba7 100644 ---- a/node_modules/react-native-render-html/src/TNodeRenderer.tsx -+++ b/node_modules/react-native-render-html/src/TNodeRenderer.tsx -@@ -49,6 +49,7 @@ const TNodeRenderer = memo(function MemoizedTNodeRenderer( - const renderRegistry = useRendererRegistry(); - const TNodeChildrenRenderer = useTNodeChildrenRenderer(); - const tnodeProps = { -+ propsFromParent: { collapsedMarginTop: null }, - ...props, - TNodeChildrenRenderer, - sharedProps -@@ -120,16 +121,6 @@ const TNodeRenderer = memo(function MemoizedTNodeRenderer( - : React.createElement(Renderer as any, assembledProps); - }); - --const defaultProps: Required, 'propsFromParent'>> = -- { -- propsFromParent: { -- collapsedMarginTop: null -- } -- }; -- --// @ts-expect-error default props must be defined --TNodeRenderer.defaultProps = defaultProps; -- - export { - TDefaultBlockRenderer, - TDefaultPhrasingRenderer, -diff --git a/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx b/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx -index 95b60df..96604c8 100644 ---- a/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx -+++ b/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx -@@ -1,73 +1,13 @@ - import TRenderEngine from '@native-html/transient-render-engine'; - import React, { PropsWithChildren, ReactElement } from 'react'; --import { Platform } from 'react-native'; --import PropTypes from 'prop-types'; - import useTRenderEngine from './hooks/useTRenderEngine'; - import { TRenderEngineConfig } from './shared-types'; --import defaultSystemFonts from './defaultSystemFonts'; - - const defaultTRenderEngine = {} as any; - - const TRenderEngineContext = - React.createContext(defaultTRenderEngine); - --export const tRenderEngineProviderPropTypes: Record< -- keyof TRenderEngineConfig, -- any --> = { -- customHTMLElementModels: PropTypes.object.isRequired, -- enableCSSInlineProcessing: PropTypes.bool, -- enableUserAgentStyles: PropTypes.bool, -- idsStyles: PropTypes.object, -- ignoredDomTags: PropTypes.array, -- ignoreDomNode: PropTypes.func, -- domVisitors: PropTypes.object, -- ignoredStyles: PropTypes.array.isRequired, -- allowedStyles: PropTypes.array, -- htmlParserOptions: PropTypes.object, -- tagsStyles: PropTypes.object, -- classesStyles: PropTypes.object, -- emSize: PropTypes.number.isRequired, -- baseStyle: PropTypes.object, -- systemFonts: PropTypes.arrayOf(PropTypes.string), -- fallbackFonts: PropTypes.shape({ -- serif: PropTypes.string, -- 'sans-serif': PropTypes.string, -- monospace: PropTypes.string -- }), -- setMarkersForTNode: PropTypes.func, -- dangerouslyDisableHoisting: PropTypes.bool, -- dangerouslyDisableWhitespaceCollapsing: PropTypes.bool, -- selectDomRoot: PropTypes.func --}; -- --/** -- * Default fallback font for special keys such as 'sans-serif', 'monospace', -- * 'serif', based on current platform. -- */ --export const defaultFallbackFonts = { -- 'sans-serif': Platform.select({ ios: 'system', default: 'sans-serif' }), -- monospace: Platform.select({ ios: 'Menlo', default: 'monospace' }), -- serif: Platform.select({ ios: 'Times New Roman', default: 'serif' }) --}; -- --export const defaultTRenderEngineProviderProps: TRenderEngineConfig = { -- htmlParserOptions: { -- decodeEntities: true -- }, -- emSize: 14, -- ignoredDomTags: [], -- ignoredStyles: [], -- baseStyle: { fontSize: 14 }, -- tagsStyles: {}, -- classesStyles: {}, -- enableUserAgentStyles: true, -- enableCSSInlineProcessing: true, -- customHTMLElementModels: {}, -- fallbackFonts: defaultFallbackFonts, -- systemFonts: defaultSystemFonts --}; -- - /** - * Use the ambient transient render engine. - * -@@ -106,13 +46,3 @@ export default function TRenderEngineProvider({ - - ); - } -- --/** -- * @ignore -- */ --TRenderEngineProvider.defaultProps = defaultTRenderEngineProviderProps; -- --/** -- * @ignore -- */ --TRenderEngineProvider.propTypes = tRenderEngineProviderPropTypes; -diff --git a/node_modules/react-native-render-html/src/elements/IMGElement.tsx b/node_modules/react-native-render-html/src/elements/IMGElement.tsx -index 573e7c1..a6fc90b 100644 ---- a/node_modules/react-native-render-html/src/elements/IMGElement.tsx -+++ b/node_modules/react-native-render-html/src/elements/IMGElement.tsx -@@ -1,19 +1,13 @@ - import React, { ReactElement, ReactNode } from 'react'; --import PropTypes from 'prop-types'; - import useIMGElementState from './useIMGElementState'; - import IMGElementContentSuccess from './IMGElementContentSuccess'; - import IMGElementContainer from './IMGElementContainer'; - import IMGElementContentLoading from './IMGElementContentLoading'; - import IMGElementContentError from './IMGElementContentError'; - import type { IMGElementProps } from './img-types'; --import defaultImageInitialDimensions from './defaultInitialImageDimensions'; - - export type { IMGElementProps } from './img-types'; - --function identity(arg: any) { -- return arg; --} -- - /** - * A component to render images based on an internal loading state. - * -@@ -44,42 +38,4 @@ function IMGElement(props: IMGElementProps): ReactElement { - ); - } - --const imgDimensionsType = PropTypes.shape({ -- width: PropTypes.number, -- height: PropTypes.number --}); -- --const propTypes: Record = { -- source: PropTypes.object.isRequired, -- alt: PropTypes.string, -- altColor: PropTypes.string, -- height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), -- width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), -- style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), -- computeMaxWidth: PropTypes.func.isRequired, -- contentWidth: PropTypes.number, -- enableExperimentalPercentWidth: PropTypes.bool, -- initialDimensions: imgDimensionsType, -- onPress: PropTypes.func, -- testID: PropTypes.string, -- objectFit: PropTypes.string, -- cachedNaturalDimensions: imgDimensionsType, -- containerProps: PropTypes.object --}; -- --/** -- * @ignore -- */ --IMGElement.propTypes = propTypes; -- --/** -- * @ignore -- */ --IMGElement.defaultProps = { -- enableExperimentalPercentWidth: false, -- computeMaxWidth: identity, -- imagesInitialDimensions: defaultImageInitialDimensions, -- style: {} --}; -- - export default IMGElement; -diff --git a/node_modules/react-native-render-html/src/elements/useIMGElementState.ts b/node_modules/react-native-render-html/src/elements/useIMGElementState.ts -index 6590d21..b603f26 100644 ---- a/node_modules/react-native-render-html/src/elements/useIMGElementState.ts -+++ b/node_modules/react-native-render-html/src/elements/useIMGElementState.ts -@@ -63,6 +63,10 @@ function useImageNaturalDimensions

(props: { - }; - } - -+function identity(arg: any) { -+ return arg; -+} -+ - function useFetchedNaturalDimensions(props: { - cachedNaturalDimensions?: ImageDimensions; - source: ImageURISource; -@@ -116,7 +120,7 @@ export default function useIMGElementState( - altColor, - source, - contentWidth, -- computeMaxWidth, -+ computeMaxWidth = identity, - objectFit, - initialDimensions = defaultImageInitialDimensions, - cachedNaturalDimensions -diff --git a/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts b/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts -index 5d6271b..710c73f 100644 ---- a/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts -+++ b/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts -@@ -71,8 +71,7 @@ function deriveSpecifiedDimensionsFromProps({ - export default function useImageSpecifiedDimensions( - props: UseIMGElementStateProps - ) { -- const { contentWidth, enableExperimentalPercentWidth, style, width, height } = -- props; -+ const { contentWidth, enableExperimentalPercentWidth = false, style = {}, width, height } = props - const flatStyle = useMemo(() => StyleSheet.flatten(style) || {}, [style]); - const specifiedDimensions = useMemo( - () => -diff --git a/node_modules/react-native-render-html/src/index.ts b/node_modules/react-native-render-html/src/index.ts -index 8569583..b59ec49 100644 ---- a/node_modules/react-native-render-html/src/index.ts -+++ b/node_modules/react-native-render-html/src/index.ts -@@ -128,7 +128,6 @@ export { - export { default as TNodeRenderer } from './TNodeRenderer'; - export { - default as TRenderEngineProvider, -- defaultFallbackFonts, - useAmbientTRenderEngine - } from './TRenderEngineProvider'; - export { default as RenderHTMLConfigProvider } from './RenderHTMLConfigProvider'; -diff --git a/node_modules/react-native-render-html/src/renderChildren.tsx b/node_modules/react-native-render-html/src/renderChildren.tsx -index a669402..be9ffd6 100644 ---- a/node_modules/react-native-render-html/src/renderChildren.tsx -+++ b/node_modules/react-native-render-html/src/renderChildren.tsx -@@ -4,8 +4,6 @@ import TNodeRenderer from './TNodeRenderer'; - import { TChildrenRendererProps } from './shared-types'; - import collapseTopMarginForChild from './helpers/collapseTopMarginForChild'; - --const empty = {}; -- - const mapCollapsibleChildren = ( - propsForChildren: TChildrenRendererProps['propsForChildren'], - renderChild: TChildrenRendererProps['renderChild'], -@@ -39,7 +37,7 @@ const mapCollapsibleChildren = ( - - export default function renderChildren({ - tchildren, -- propsForChildren = empty, -+ propsForChildren = {}, - disableMarginCollapsing, - renderChild - }: TChildrenRendererProps): ReactElement { diff --git a/patches/react-native-render-html+6.3.1+001+initial.patch b/patches/react-native-render-html+6.3.1.patch similarity index 100% rename from patches/react-native-render-html+6.3.1+001+initial.patch rename to patches/react-native-render-html+6.3.1.patch diff --git a/patches/react-native-screens+3.34.0+004+ios-custom-animations-native-transitions.patch b/patches/react-native-screens+3.34.0+004+ios-custom-animations-native-transitions.patch index 62cbf68f458d..52f8d76c4fe1 100644 --- a/patches/react-native-screens+3.34.0+004+ios-custom-animations-native-transitions.patch +++ b/patches/react-native-screens+3.34.0+004+ios-custom-animations-native-transitions.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-screens/ios/RNSScreenStackAnimator.mm b/node_modules/react-native-screens/ios/RNSScreenStackAnimator.mm -index abb2cf6..fb81d52 100644 +index abb2cf6..c21b3e9 100644 --- a/node_modules/react-native-screens/ios/RNSScreenStackAnimator.mm +++ b/node_modules/react-native-screens/ios/RNSScreenStackAnimator.mm @@ -5,13 +5,14 @@ @@ -32,7 +32,7 @@ index abb2cf6..fb81d52 100644 } @@ -129,6 +130,8 @@ - (void)animateSimplePushWithShadowEnabled:(BOOL)shadowEnabled } - + [UIView animateWithDuration:[self transitionDuration:transitionContext] + delay:0 + options:UIViewAnimationOptionCurveDefaultTransition @@ -66,25 +66,7 @@ index abb2cf6..fb81d52 100644 animations:animationBlock completion:completionBlock]; } else { -@@ -251,6 +260,8 @@ - (void)animateFadeWithTransitionContext:(id; replaceAnimation?: WithDefault; swipeDirection?: WithDefault; - hideKeyboardOnSwipe?: boolean; \ No newline at end of file + hideKeyboardOnSwipe?: boolean; diff --git a/scripts/applyPatches.sh b/scripts/applyPatches.sh index 29e121acc968..9ba8360ea39f 100755 --- a/scripts/applyPatches.sh +++ b/scripts/applyPatches.sh @@ -11,11 +11,13 @@ source "$SCRIPTS_DIR/shellUtils.sh" function patchPackage { # See if we're in the HybridApp repo IS_HYBRID_APP_REPO=$(scripts/is-hybrid-app.sh) + NEW_DOT_FLAG="${STANDALONE_NEW_DOT:-false}" OS="$(uname)" if [[ "$OS" == "Darwin" || "$OS" == "Linux" ]]; then npx patch-package --error-on-fail --color=always - if [[ "$IS_HYBRID_APP_REPO" == "true" ]]; then + if [[ "$IS_HYBRID_APP_REPO" == "true" && "$NEW_DOT_FLAG" == "false" ]]; then + echo -e "\n${GREEN}Applying HybridApp patches!${NC}" npx patch-package --patch-dir 'Mobile-Expensify/patches' --error-on-fail --color=always fi else diff --git a/scripts/clean.sh b/scripts/clean.sh index 1ecd73731b61..fbbfa070d442 100755 --- a/scripts/clean.sh +++ b/scripts/clean.sh @@ -7,7 +7,10 @@ NC='\033[0m' # See if we're in the HybridApp repo IS_HYBRID_APP_REPO=$(scripts/is-hybrid-app.sh) -if [[ "$IS_HYBRID_APP_REPO" == "true" && "$1" != "--new-dot" ]]; then +# See if we should force standalone NewDot build +NEW_DOT_FLAG="${STANDALONE_NEW_DOT:-false}" + +if [[ "$IS_HYBRID_APP_REPO" == "true" && "$NEW_DOT_FLAG" == "false" ]]; then echo -e "${BLUE}Cleaning HybridApp project...${NC}" # Navigate to Mobile-Expensify repository, and clean cd Mobile-Expensify diff --git a/scripts/pod-install.sh b/scripts/pod-install.sh index 8e38f1706d6f..77237bb207b4 100755 --- a/scripts/pod-install.sh +++ b/scripts/pod-install.sh @@ -45,11 +45,9 @@ fi # See if we're in the HybridApp repo IS_HYBRID_APP_REPO=$(scripts/is-hybrid-app.sh) -NEW_DOT_FLAG="false" -if [ "$1" == "--new-dot" ]; then - NEW_DOT_FLAG="true" -fi +# See if we should force standalone NewDot build +NEW_DOT_FLAG="${STANDALONE_NEW_DOT:-false}" if [[ "$IS_HYBRID_APP_REPO" == "true" && "$NEW_DOT_FLAG" == "false" ]]; then echo -e "${BLUE}Executing npm run pod-install for HybridApp...${NC}" diff --git a/scripts/postInstall.sh b/scripts/postInstall.sh index db24f04f8a6c..c2adcadc4f43 100755 --- a/scripts/postInstall.sh +++ b/scripts/postInstall.sh @@ -10,7 +10,11 @@ cd "$ROOT_DIR" || exit 1 # See if we're in the HybridApp repo IS_HYBRID_APP_REPO=$(scripts/is-hybrid-app.sh) -if [[ "$IS_HYBRID_APP_REPO" == "true" ]]; then +# See if we should force standalone NewDot build +NEW_DOT_FLAG="${STANDALONE_NEW_DOT:-false}" + +if [[ "$IS_HYBRID_APP_REPO" == "true" && "$NEW_DOT_FLAG" == "false" ]]; then + echo -e "\n${GREEN}Installing node modules in Mobile-Expensify submodule!${NC}" cd Mobile-Expensify || exit 1 npm i diff --git a/scripts/run-build.sh b/scripts/run-build.sh index 7689aabbbf59..fd38f3c98861 100755 --- a/scripts/run-build.sh +++ b/scripts/run-build.sh @@ -3,8 +3,6 @@ set -e export PROJECT_ROOT_PATH -BUILD="$1" -NEW_DOT_FLAG="false" IOS_MODE="DebugDevelopment" ANDROID_MODE="developmentDebug" SCHEME="New Expensify Dev" @@ -20,26 +18,19 @@ function print_error_and_exit { exit 1 } -# Assign the arguments to variables -if [ "$#" -eq 1 ]; then - BUILD="$1" -elif [ "$#" -eq 2 ]; then - if [ "$1" == "--new-dot" ]; then - BUILD="$2" - NEW_DOT_FLAG="true" - elif [ "$2" == "--new-dot" ]; then - BUILD="$1" - NEW_DOT_FLAG="true" - else - print_error_and_exit - fi -else +# Assign the arguments to variables if arguments are correct +if [ "$#" -ne 1 ] || [[ "$1" != "--ios" && "$1" != "--ipad" && "$1" != "--ipad-sm" && "$1" != "--android" ]]; then print_error_and_exit fi +BUILD="$1" + # See if we're in the HybridApp repo IS_HYBRID_APP_REPO=$(scripts/is-hybrid-app.sh) +# See if we should force standalone NewDot build +NEW_DOT_FLAG="${STANDALONE_NEW_DOT:-false}" + if [[ "$IS_HYBRID_APP_REPO" == "true" && "$NEW_DOT_FLAG" == "false" ]]; then # Set HybridApp-specific arguments IOS_MODE="Debug" diff --git a/src/CONST.ts b/src/CONST.ts index 4bfaad7b6d1b..e317c19d96d2 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -514,6 +514,7 @@ const CONST = { MAX_DATE: '9999-12-31', MIN_DATE: '0001-01-01', ORDINAL_DAY_OF_MONTH: 'do', + MONTH_DAY_YEAR_ORDINAL_FORMAT: 'MMMM do, yyyy', }, SMS: { DOMAIN: '@expensify.sms', @@ -900,6 +901,7 @@ const CONST = { DEEP_DIVE_EXPENSIFY_CARD: 'https://community.expensify.com/discussion/4848/deep-dive-expensify-card-and-quickbooks-online-auto-reconciliation-how-it-works', DEEP_DIVE_ERECEIPTS: 'https://community.expensify.com/discussion/5542/deep-dive-what-are-ereceipts/', DEEP_DIVE_PER_DIEM: 'https://community.expensify.com/discussion/4772/how-to-add-a-single-rate-per-diem', + SET_NOTIFICATION_LINK: 'https://community.expensify.com/discussion/5651/deep-dive--practices-when-youre-running-into-trouble-receiving-emails-from-expensify', GITHUB_URL: 'https://github.com/Expensify/App', HELP_LINK_URL: `${USE_EXPENSIFY_URL}/usa-patriot-act`, ELECTRONIC_DISCLOSURES_URL: `${USE_EXPENSIFY_URL}/esignagreement`, @@ -4452,7 +4454,7 @@ const CONST = { BOOK_TRAVEL_DEMO_URL: 'https://calendly.com/d/ck2z-xsh-q97/expensify-travel-demo-travel-page', TRAVEL_DOT_URL: 'https://travel.expensify.com', STAGING_TRAVEL_DOT_URL: 'https://staging.travel.expensify.com', - TRIP_ID_PATH: (tripID: string) => `trips/${tripID}`, + TRIP_ID_PATH: (tripID?: string) => (tripID ? `trips/${tripID}` : undefined), SPOTNANA_TMC_ID: '8e8e7258-1cf3-48c0-9cd1-fe78a6e31eed', STAGING_SPOTNANA_TMC_ID: '7a290c6e-5328-4107-aff6-e48765845b81', SCREEN_READER_STATES: { @@ -5953,6 +5955,7 @@ const CONST = { CAR: 'car', HOTEL: 'hotel', FLIGHT: 'flight', + TRAIN: 'train', }, DOT_SEPARATOR: '•', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 462ca9e22d2d..a43f1622ec9a 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -470,6 +470,9 @@ const ONYXKEYS = { /** The user's Concierge reportID */ CONCIERGE_REPORT_ID: 'conciergeReportID', + /** The user's session that will be preserved when using imported state */ + PRESERVED_USER_SESSION: 'preservedUserSession', + /** Collection Keys */ COLLECTION: { DOWNLOAD: 'download_', @@ -730,6 +733,8 @@ const ONYXKEYS = { RULES_MAX_EXPENSE_AGE_FORM_DRAFT: 'rulesMaxExpenseAgeFormDraft', DEBUG_DETAILS_FORM: 'debugDetailsForm', DEBUG_DETAILS_FORM_DRAFT: 'debugDetailsFormDraft', + WORKSPACE_PER_DIEM_FORM: 'workspacePerDiemForm', + WORKSPACE_PER_DIEM_FORM_DRAFT: 'workspacePerDiemFormDraft', }, } as const; @@ -823,6 +828,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AGE_FORM]: FormTypes.RulesMaxExpenseAgeForm; [ONYXKEYS.FORMS.SEARCH_SAVED_SEARCH_RENAME_FORM]: FormTypes.SearchSavedSearchRenameForm; [ONYXKEYS.FORMS.DEBUG_DETAILS_FORM]: FormTypes.DebugReportForm | FormTypes.DebugReportActionForm | FormTypes.DebugTransactionForm | FormTypes.DebugTransactionViolationForm; + [ONYXKEYS.FORMS.WORKSPACE_PER_DIEM_FORM]: FormTypes.WorkspacePerDiemForm; }; type OnyxFormDraftValuesMapping = { @@ -1038,6 +1044,7 @@ type OnyxValuesMapping = { [ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP]: boolean; [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; [ONYXKEYS.CONCIERGE_REPORT_ID]: string; + [ONYXKEYS.PRESERVED_USER_SESSION]: OnyxTypes.Session; [ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING]: OnyxTypes.DismissedProductTraining; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index b9544d81bece..58d28a46a7b8 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -716,6 +716,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/profile/address', getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/profile/address` as const, backTo), }, + WORKSPACE_PROFILE_PLAN: { + route: 'settings/workspaces/:policyID/profile/plan', + getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/profile/plan` as const, backTo), + }, WORKSPACE_ACCOUNTING: { route: 'settings/workspaces/:policyID/accounting', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting` as const, @@ -979,9 +983,9 @@ const ROUTES = { getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/category/${encodeURIComponent(categoryName)}` as const, }, WORKSPACE_UPGRADE: { - route: 'settings/workspaces/:policyID/upgrade/:featureName', - getRoute: (policyID: string, featureName: string, backTo?: string) => - getUrlWithBackToParam(`settings/workspaces/${policyID}/upgrade/${encodeURIComponent(featureName)}` as const, backTo), + route: 'settings/workspaces/:policyID/upgrade/:featureName?', + getRoute: (policyID: string, featureName?: string, backTo?: string) => + getUrlWithBackToParam(`settings/workspaces/${policyID}/upgrade/${encodeURIComponent(featureName ?? '')}` as const, backTo), }, WORKSPACE_DOWNGRADE: { route: 'settings/workspaces/:policyID/downgrade/', @@ -1162,16 +1166,16 @@ const ROUTES = { }, WORKSPACE_REPORT_FIELDS_LIST_VALUES: { route: 'settings/workspaces/:policyID/reportFields/listValues/:reportFieldID?', - getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportFields/listValues/${encodeURIComponent(reportFieldID ?? '')}` as const, + getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportFields/listValues/${reportFieldID ? encodeURIComponent(reportFieldID) : ''}` as const, }, WORKSPACE_REPORT_FIELDS_ADD_VALUE: { route: 'settings/workspaces/:policyID/reportFields/addValue/:reportFieldID?', - getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportFields/addValue/${encodeURIComponent(reportFieldID ?? '')}` as const, + getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportFields/addValue/${reportFieldID ? encodeURIComponent(reportFieldID) : ''}` as const, }, WORKSPACE_REPORT_FIELDS_VALUE_SETTINGS: { route: 'settings/workspaces/:policyID/reportFields/:valueIndex/:reportFieldID?', getRoute: (policyID: string, valueIndex: number, reportFieldID?: string) => - `settings/workspaces/${policyID}/reportFields/${valueIndex}/${encodeURIComponent(reportFieldID ?? '')}` as const, + `settings/workspaces/${policyID}/reportFields/${valueIndex}/${reportFieldID ? encodeURIComponent(reportFieldID) : ''}` as const, }, WORKSPACE_REPORT_FIELDS_EDIT_VALUE: { route: 'settings/workspaces/:policyID/reportFields/new/:valueIndex/edit', @@ -1321,6 +1325,26 @@ const ROUTES = { route: 'settings/workspaces/:policyID/per-diem/settings', getRoute: (policyID: string) => `settings/workspaces/${policyID}/per-diem/settings` as const, }, + WORKSPACE_PER_DIEM_DETAILS: { + route: 'settings/workspaces/:policyID/per-diem/details/:rateID/:subRateID', + getRoute: (policyID: string, rateID: string, subRateID: string) => `settings/workspaces/${policyID}/per-diem/details/${rateID}/${subRateID}` as const, + }, + WORKSPACE_PER_DIEM_EDIT_DESTINATION: { + route: 'settings/workspaces/:policyID/per-diem/edit/destination/:rateID/:subRateID', + getRoute: (policyID: string, rateID: string, subRateID: string) => `settings/workspaces/${policyID}/per-diem/edit/destination/${rateID}/${subRateID}` as const, + }, + WORKSPACE_PER_DIEM_EDIT_SUBRATE: { + route: 'settings/workspaces/:policyID/per-diem/edit/subrate/:rateID/:subRateID', + getRoute: (policyID: string, rateID: string, subRateID: string) => `settings/workspaces/${policyID}/per-diem/edit/subrate/${rateID}/${subRateID}` as const, + }, + WORKSPACE_PER_DIEM_EDIT_AMOUNT: { + route: 'settings/workspaces/:policyID/per-diem/edit/amount/:rateID/:subRateID', + getRoute: (policyID: string, rateID: string, subRateID: string) => `settings/workspaces/${policyID}/per-diem/edit/amount/${rateID}/${subRateID}` as const, + }, + WORKSPACE_PER_DIEM_EDIT_CURRENCY: { + route: 'settings/workspaces/:policyID/per-diem/edit/currency/:rateID/:subRateID', + getRoute: (policyID: string, rateID: string, subRateID: string) => `settings/workspaces/${policyID}/per-diem/edit/currency/${rateID}/${subRateID}` as const, + }, RULES_CUSTOM_NAME: { route: 'settings/workspaces/:policyID/rules/name', getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/name` as const, @@ -1365,6 +1389,15 @@ const ROUTES = { TRAVEL_MY_TRIPS: 'travel', TRAVEL_TCS: 'travel/terms', TRACK_TRAINING_MODAL: 'track-training', + TRAVEL_TRIP_SUMMARY: { + route: 'r/:reportID/trip/:transactionID', + getRoute: (reportID: string, transactionID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/trip/${transactionID}`, backTo), + }, + TRAVEL_TRIP_DETAILS: { + route: 'r/:reportID/trip/:transactionID/:reservationIndex', + getRoute: (reportID: string, transactionID: string, reservationIndex: number, backTo?: string) => + getUrlWithBackToParam(`r/${reportID}/trip/${transactionID}/${reservationIndex}`, backTo), + }, ONBOARDING_ROOT: { route: 'onboarding', getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding`, backTo), diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 6c4547e94c37..6274be1044b4 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -27,6 +27,8 @@ const SCREENS = { TRAVEL: { MY_TRIPS: 'Travel_MyTrips', TCS: 'Travel_TCS', + TRIP_SUMMARY: 'Travel_TripSummary', + TRIP_DETAILS: 'Travel_TripDetails', }, SEARCH: { CENTRAL_PANE: 'Search_Central_Pane', @@ -503,6 +505,7 @@ const SCREENS = { TAG_GL_CODE: 'Tag_GL_Code', CURRENCY: 'Workspace_Profile_Currency', ADDRESS: 'Workspace_Profile_Address', + PLAN: 'Workspace_Profile_Plan_Type', WORKFLOWS: 'Workspace_Workflows', WORKFLOWS_PAYER: 'Workspace_Workflows_Payer', WORKFLOWS_APPROVALS_NEW: 'Workspace_Approvals_New', @@ -555,6 +558,11 @@ const SCREENS = { PER_DIEM_IMPORT: 'Per_Diem_Import', PER_DIEM_IMPORTED: 'Per_Diem_Imported', PER_DIEM_SETTINGS: 'Per_Diem_Settings', + PER_DIEM_DETAILS: 'Per_Diem_Details', + PER_DIEM_EDIT_DESTINATION: 'Per_Diem_Edit_Destination', + PER_DIEM_EDIT_SUBRATE: 'Per_Diem_Edit_Subrate', + PER_DIEM_EDIT_AMOUNT: 'Per_Diem_Edit_Amount', + PER_DIEM_EDIT_CURRENCY: 'Per_Diem_Edit_Currency', }, EDIT_REQUEST: { diff --git a/src/components/AmountWithoutCurrencyForm.tsx b/src/components/AmountWithoutCurrencyForm.tsx index 0ac410013214..de65f40b3b4f 100644 --- a/src/components/AmountWithoutCurrencyForm.tsx +++ b/src/components/AmountWithoutCurrencyForm.tsx @@ -12,10 +12,13 @@ type AmountFormProps = { /** Callback to update the amount in the FormProvider */ onInputChange?: (value: string) => void; + + /** Should we allow negative number as valid input */ + shouldAllowNegative?: boolean; } & Partial; function AmountWithoutCurrencyForm( - {value: amount, onInputChange, inputID, name, defaultValue, accessibilityLabel, role, label, ...rest}: AmountFormProps, + {value: amount, onInputChange, shouldAllowNegative = false, inputID, name, defaultValue, accessibilityLabel, role, label, ...rest}: AmountFormProps, ref: ForwardedRef, ) { const {toLocaleDigit} = useLocalize(); @@ -32,13 +35,13 @@ function AmountWithoutCurrencyForm( // More info: https://github.com/Expensify/App/issues/16974 const newAmountWithoutSpaces = stripSpacesFromAmount(newAmount); const replacedCommasAmount = replaceCommasWithPeriod(newAmountWithoutSpaces); - const withLeadingZero = addLeadingZero(replacedCommasAmount); - if (!validateAmount(withLeadingZero, 2)) { + const withLeadingZero = addLeadingZero(replacedCommasAmount, shouldAllowNegative); + if (!validateAmount(withLeadingZero, 2, CONST.IOU.AMOUNT_MAX_LENGTH, shouldAllowNegative)) { return; } onInputChange?.(withLeadingZero); }, - [onInputChange], + [onInputChange, shouldAllowNegative], ); const formattedAmount = replaceAllDigits(currentAmount, toLocaleDigit); @@ -54,7 +57,7 @@ function AmountWithoutCurrencyForm( accessibilityLabel={accessibilityLabel} role={role} ref={ref} - keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} + keyboardType={!shouldAllowNegative ? CONST.KEYBOARD_TYPE.DECIMAL_PAD : undefined} // On android autoCapitalize="words" is necessary when keyboardType="decimal-pad" or inputMode="decimal" to prevent input lag. // See https://github.com/Expensify/App/issues/51868 for more information autoCapitalize="words" diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts index c443b1ab8093..c0010af468af 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts +++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts @@ -7,7 +7,7 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import CONST from '@src/CONST'; -import type {ReportAction, ReportActions} from '@src/types/onyx'; +import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; import type {Note} from '@src/types/onyx/Report'; /** @@ -20,12 +20,11 @@ function extractAttachments( accountID, parentReportAction, reportActions, - reportID, - }: {privateNotes?: Record; accountID?: number; parentReportAction?: OnyxEntry; reportActions?: OnyxEntry; reportID: string}, + report, + }: {privateNotes?: Record; accountID?: number; parentReportAction?: OnyxEntry; reportActions?: OnyxEntry; report: OnyxEntry}, ) { const targetNote = privateNotes?.[Number(accountID)]?.note ?? ''; const attachments: Attachment[] = []; - const report = ReportUtils.getReport(reportID); const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); // We handle duplicate image sources by considering the first instance as original. Selecting any duplicate diff --git a/src/components/Attachments/AttachmentCarousel/index.native.tsx b/src/components/Attachments/AttachmentCarousel/index.native.tsx index 9aa619eb1cda..68668ccc6ab0 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.native.tsx @@ -34,9 +34,9 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined; let newAttachments: Attachment[] = []; if (type === CONST.ATTACHMENT_TYPE.NOTE && accountID) { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID, reportID: report.reportID}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID, report}); } else { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions, reportID: report.reportID}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions, report}); } let newIndex = newAttachments.findIndex(compareImage); @@ -68,7 +68,7 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi } } // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [reportActions, compareImage]); + }, [reportActions, compareImage, report]); /** Updates the page state when the user navigates between attachments */ const updatePage = useCallback( diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index f169416f1812..50caaac3dd81 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -89,9 +89,9 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined; let newAttachments: Attachment[] = []; if (type === CONST.ATTACHMENT_TYPE.NOTE && accountID) { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID, reportID: report.reportID}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID, report}); } else { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions: reportActions ?? undefined, reportID: report.reportID}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions: reportActions ?? undefined, report}); } if (isEqual(attachments, newAttachments)) { @@ -130,19 +130,7 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi onNavigate(attachment); } } - }, [ - report.privateNotes, - reportActions, - parentReportActions, - compareImage, - report.parentReportActionID, - attachments, - setDownloadButtonVisibility, - onNavigate, - accountID, - type, - report.reportID, - ]); + }, [reportActions, parentReportActions, compareImage, attachments, setDownloadButtonVisibility, onNavigate, accountID, type, report]); // Scroll position is affected when window width is resized, so we readjust it on width changes useEffect(() => { diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 07edd148778d..84767c6347e7 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -122,6 +122,9 @@ type ButtonProps = Partial & { /** Id to use for this button */ id?: string; + /** Used to locate this button in ui tests */ + testID?: string; + /** Accessibility label for the component */ accessibilityLabel?: string; @@ -237,6 +240,7 @@ function Button( shouldShowRightIcon = false, id = '', + testID = undefined, accessibilityLabel = '', isSplitButton = false, link = false, @@ -405,6 +409,7 @@ function Button( ]} disabledStyle={disabledStyle} id={id} + testID={testID} accessibilityLabel={accessibilityLabel} role={CONST.ROLE.BUTTON} hoverDimmingValue={1} diff --git a/src/components/CategoryPicker.tsx b/src/components/CategoryPicker.tsx index 19bb98bff58e..f8e9e836c736 100644 --- a/src/components/CategoryPicker.tsx +++ b/src/components/CategoryPicker.tsx @@ -14,7 +14,7 @@ import RadioListItem from './SelectionList/RadioListItem'; import type {ListItem} from './SelectionList/types'; type CategoryPickerProps = { - policyID: string; + policyID: string | undefined; selectedCategory?: string; onSubmit: (item: ListItem) => void; }; diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index cea339de07e2..0cddb32f5aeb 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -16,6 +16,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as EmojiUtils from '@libs/EmojiUtils'; import * as FileUtils from '@libs/fileDownload/FileUtils'; +import getPlatform from '@libs/getPlatform'; import CONST from '@src/CONST'; const excludeNoStyles: Array = []; @@ -140,7 +141,11 @@ function Composer( textAlignVertical="center" style={[composerStyle, maxHeightStyle]} markdownStyle={markdownStyle} - autoFocus={autoFocus} + // /* + // There are cases in hybird app on android that screen goes up when there is autofocus on keyboard. (e.g. https://github.com/Expensify/App/issues/53185) + // Workaround for this issue is to maunally focus keyboard after it's acutally rendered which is done by useAutoFocusInput hook. + // */ + autoFocus={getPlatform() !== 'android' ? autoFocus : false} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} readOnly={isDisabled} diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 98ac9e00a98a..5af76a2406b5 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -1,4 +1,5 @@ import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; +import {useIsFocused} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; import type {BaseSyntheticEvent, ForwardedRef} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; @@ -252,7 +253,8 @@ function Composer( // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isComposerFullSize]); - useHtmlPaste(textInput, handlePaste, true); + const isActive = useIsFocused(); + useHtmlPaste(textInput, handlePaste, isActive); useEffect(() => { setIsRendered(true); diff --git a/src/components/ConfirmationPage.tsx b/src/components/ConfirmationPage.tsx index a95cf9bf87d2..7b55f2317d46 100644 --- a/src/components/ConfirmationPage.tsx +++ b/src/components/ConfirmationPage.tsx @@ -82,6 +82,7 @@ function ConfirmationPage({ success large text={buttonText} + testID="confirmation-button" style={styles.mt6} pressOnEnter onPress={onButtonPress} diff --git a/src/components/EmptySelectionListContent.tsx b/src/components/EmptySelectionListContent.tsx index a5ac2c84eb2b..67a9a2fc83f3 100644 --- a/src/components/EmptySelectionListContent.tsx +++ b/src/components/EmptySelectionListContent.tsx @@ -7,6 +7,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import BlockingView from './BlockingViews/BlockingView'; import * as Illustrations from './Icon/Illustrations'; +import ScrollView from './ScrollView'; import Text from './Text'; import TextLink from './TextLink'; @@ -39,17 +40,19 @@ function EmptySelectionListContent({contentType}: EmptySelectionListContentProps ); return ( - - - + + + + + ); } diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 6fa006cfb4fb..b4d097e90994 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -25,6 +25,9 @@ import type HeaderWithBackButtonProps from './types'; function HeaderWithBackButton({ icon, iconFill, + iconWidth, + iconHeight, + iconStyles, guidesCallTaskID = '', onBackButtonPress = () => Navigation.goBack(), onCloseButtonPress = () => Navigation.dismissModal(), @@ -45,6 +48,7 @@ function HeaderWithBackButton({ shouldSetModalVisibility = true, shouldShowThreeDotsButton = false, shouldDisableThreeDotsButton = false, + shouldUseHeadlineHeader = false, stepCounter, subtitle = '', title = '', @@ -72,9 +76,6 @@ function HeaderWithBackButton({ const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); const {translate} = useLocalize(); - // If the icon is present, the header bar should be taller and use different font. - const isCentralPaneSettings = !!icon; - const middleContent = useMemo(() => { if (progressBarPercentage) { return ( @@ -106,14 +107,14 @@ function HeaderWithBackButton({

); }, [ StyleUtils, subTitleLink, - isCentralPaneSettings, + shouldUseHeadlineHeader, policy, progressBarPercentage, report, @@ -138,7 +139,7 @@ function HeaderWithBackButton({ dataSet={{dragArea: false}} style={[ styles.headerBar, - isCentralPaneSettings && styles.headerBarDesktopHeight, + shouldUseHeadlineHeader && styles.headerBarDesktopHeight, shouldShowBorderBottom && styles.borderBottom, // progressBarPercentage can be 0 which would // be falsey, hence using !== undefined explicitly @@ -178,9 +179,10 @@ function HeaderWithBackButton({ {!!icon && ( )} {!!policyAvatar && ( diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 6eef2b072eee..d2d4ba9e4e0f 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -38,6 +38,15 @@ type HeaderWithBackButtonProps = Partial & { * */ icon?: IconAsset; + /** Icon Width */ + iconWidth?: number; + + /** Icon Height */ + iconHeight?: number; + + /** Any additional styles to pass to the icon container. */ + iconStyles?: StyleProp; + /** Method to trigger when pressing download button of the header */ onDownloadButtonPress?: () => void; @@ -119,6 +128,9 @@ type HeaderWithBackButtonProps = Partial & { /** Whether we should navigate to report page when the route have a topMostReport */ shouldNavigateToTopMostReport?: boolean; + /** Whether the header should use the headline header style */ + shouldUseHeadlineHeader?: boolean; + /** The fill color for the icon. Can be hex, rgb, rgba, or valid react-native named color such as 'red' or 'blue'. */ iconFill?: string; diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 51db1bc12c8e..4093b44743fe 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -187,6 +187,7 @@ import Task from '@assets/images/task.svg'; import Thread from '@assets/images/thread.svg'; import ThreeDots from '@assets/images/three-dots.svg'; import ThumbsUp from '@assets/images/thumbs-up.svg'; +import Train from '@assets/images/train.svg'; import Transfer from '@assets/images/transfer.svg'; import Trashcan from '@assets/images/trashcan.svg'; import Unlock from '@assets/images/unlock.svg'; @@ -413,5 +414,6 @@ export { Star, QBDSquare, GalleryNotFound, + Train, boltSlash, }; diff --git a/src/components/ImportOnyxState/index.native.tsx b/src/components/ImportOnyxState/index.native.tsx index 2258da4c8f6c..bdd805241c55 100644 --- a/src/components/ImportOnyxState/index.native.tsx +++ b/src/components/ImportOnyxState/index.native.tsx @@ -1,11 +1,12 @@ import React, {useState} from 'react'; import ReactNativeBlobUtil from 'react-native-blob-util'; -import Onyx from 'react-native-onyx'; +import Onyx, {useOnyx} from 'react-native-onyx'; import type {FileObject} from '@components/AttachmentModal'; -import {KEYS_TO_PRESERVE, setIsUsingImportedState} from '@libs/actions/App'; +import {KEYS_TO_PRESERVE, setIsUsingImportedState, setPreservedUserSession} from '@libs/actions/App'; import {setShouldForceOffline} from '@libs/actions/Network'; import Navigation from '@libs/Navigation/Navigation'; import type {OnyxValues} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import BaseImportOnyxState from './BaseImportOnyxState'; import type ImportOnyxStateProps from './types'; @@ -45,8 +46,9 @@ function applyStateInChunks(state: OnyxValues) { return promise; } -export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { +export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const [session] = useOnyx(ONYXKEYS.SESSION); const handleFileRead = (file: FileObject) => { if (!file.uri) { @@ -57,6 +59,8 @@ export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxSta readOnyxFile(file.uri) .then((fileContent: string) => { const transformedState = cleanAndTransformState(fileContent); + const currentUserSessionCopy = {...session}; + setPreservedUserSession(currentUserSessionCopy); setShouldForceOffline(true); Onyx.clear(KEYS_TO_PRESERVE).then(() => { applyStateInChunks(transformedState).then(() => { @@ -67,14 +71,7 @@ export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxSta }) .catch(() => { setIsErrorModalVisible(true); - }) - .finally(() => { - setIsLoading(false); }); - - if (isLoading) { - setIsLoading(false); - } }; return ( diff --git a/src/components/ImportOnyxState/index.tsx b/src/components/ImportOnyxState/index.tsx index 8add2d9172fd..2f9a2b70b65b 100644 --- a/src/components/ImportOnyxState/index.tsx +++ b/src/components/ImportOnyxState/index.tsx @@ -1,17 +1,19 @@ import React, {useState} from 'react'; -import Onyx from 'react-native-onyx'; +import Onyx, {useOnyx} from 'react-native-onyx'; import type {FileObject} from '@components/AttachmentModal'; -import {KEYS_TO_PRESERVE, setIsUsingImportedState} from '@libs/actions/App'; +import {KEYS_TO_PRESERVE, setIsUsingImportedState, setPreservedUserSession} from '@libs/actions/App'; import {setShouldForceOffline} from '@libs/actions/Network'; import Navigation from '@libs/Navigation/Navigation'; import type {OnyxValues} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import BaseImportOnyxState from './BaseImportOnyxState'; import type ImportOnyxStateProps from './types'; import {cleanAndTransformState} from './utils'; -export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { +export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const [session] = useOnyx(ONYXKEYS.SESSION); const handleFileRead = (file: FileObject) => { if (!file.uri) { @@ -27,26 +29,20 @@ export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxSta .then((text) => { const fileContent = text; const transformedState = cleanAndTransformState(fileContent); + const currentUserSessionCopy = {...session}; + setPreservedUserSession(currentUserSessionCopy); setShouldForceOffline(true); Onyx.clear(KEYS_TO_PRESERVE).then(() => { - Onyx.multiSet(transformedState) - .then(() => { - setIsUsingImportedState(true); - Navigation.navigate(ROUTES.HOME); - }) - .finally(() => { - setIsLoading(false); - }); + Onyx.multiSet(transformedState).then(() => { + setIsUsingImportedState(true); + Navigation.navigate(ROUTES.HOME); + }); }); }) .catch(() => { setIsErrorModalVisible(true); setIsLoading(false); }); - - if (isLoading) { - setIsLoading(false); - } }; return ( diff --git a/src/components/ImportOnyxState/types.ts b/src/components/ImportOnyxState/types.ts index 8e504c493529..2b4b56a3b20c 100644 --- a/src/components/ImportOnyxState/types.ts +++ b/src/components/ImportOnyxState/types.ts @@ -1,5 +1,4 @@ type ImportOnyxStateProps = { - isLoading: boolean; setIsLoading: (isLoading: boolean) => void; }; diff --git a/src/components/ImportOnyxState/utils.ts b/src/components/ImportOnyxState/utils.ts index a5f24fa80714..94779868384d 100644 --- a/src/components/ImportOnyxState/utils.ts +++ b/src/components/ImportOnyxState/utils.ts @@ -3,7 +3,7 @@ import type {UnknownRecord} from 'type-fest'; import ONYXKEYS from '@src/ONYXKEYS'; // List of Onyx keys from the .txt file we want to keep for the local override -const keysToOmit = [ONYXKEYS.ACTIVE_CLIENTS, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.NETWORK, ONYXKEYS.CREDENTIALS, ONYXKEYS.SESSION, ONYXKEYS.PREFERRED_THEME]; +const keysToOmit = [ONYXKEYS.ACTIVE_CLIENTS, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.NETWORK, ONYXKEYS.CREDENTIALS, ONYXKEYS.PREFERRED_THEME]; function isRecord(value: unknown): value is Record { return typeof value === 'object' && !Array.isArray(value) && value !== null; diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 19af05a1581b..00965d197937 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -177,14 +177,14 @@ function MoneyRequestConfirmationList({ shouldPlaySound = true, isConfirmed, }: MoneyRequestConfirmationListProps) { - const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID ?? '-1'}`); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID ?? '-1'}`); - const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID ?? '-1'}`); - const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID ?? '-1'}`); - const [defaultMileageRate] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID ?? '-1'}`, { + const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); + const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`); + const [defaultMileageRate] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, { selector: (selectedPolicy) => DistanceRequestUtils.getDefaultMileageRate(selectedPolicy), }); - const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${policyID ?? '-1'}`); + const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${policyID}`); const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); @@ -202,17 +202,22 @@ function MoneyRequestConfirmationList({ const isTypeInvoice = iouType === CONST.IOU.TYPE.INVOICE; const isScanRequest = useMemo(() => TransactionUtils.isScanRequest(transaction), [transaction]); - const transactionID = transaction?.transactionID ?? '-1'; - const customUnitRateID = TransactionUtils.getRateID(transaction) ?? '-1'; + const transactionID = transaction?.transactionID; + const customUnitRateID = TransactionUtils.getRateID(transaction); useEffect(() => { - if ((customUnitRateID && customUnitRateID !== '-1') || !isDistanceRequest) { + if (customUnitRateID !== '-1' || !isDistanceRequest || !transactionID || !policy?.id) { return; } - const defaultRate = defaultMileageRate?.customUnitRateID ?? ''; - const lastSelectedRate = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? defaultRate; + const defaultRate = defaultMileageRate?.customUnitRateID; + const lastSelectedRate = lastSelectedDistanceRates?.[policy.id] ?? defaultRate; const rateID = lastSelectedRate; + + if (!rateID) { + return; + } + IOU.setCustomUnitRateID(transactionID, rateID); }, [defaultMileageRate, customUnitRateID, lastSelectedDistanceRates, policy?.id, transactionID, isDistanceRequest]); @@ -242,6 +247,7 @@ function MoneyRequestConfirmationList({ if ( !shouldShowTax || !transaction || + !transactionID || (transaction.taxCode && previousTransactionModifiedCurrency === transaction.modifiedCurrency && previousTransactionCurrency === transaction.currency && @@ -296,7 +302,12 @@ function MoneyRequestConfirmationList({ return true; } - if (!participant.isInvoiceRoom && !participant.isPolicyExpenseChat && !participant.isSelfDM && ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1)) { + if ( + !participant.isInvoiceRoom && + !participant.isPolicyExpenseChat && + !participant.isSelfDM && + ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? CONST.DEFAULT_NUMBER_ID) + ) { return true; } @@ -325,7 +336,7 @@ function MoneyRequestConfirmationList({ if (isFirstUpdatedDistanceAmount.current) { return; } - if (!isDistanceRequest) { + if (!isDistanceRequest || !transactionID) { return; } const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0); @@ -334,7 +345,7 @@ function MoneyRequestConfirmationList({ }, [distance, rate, unit, transactionID, currency, isDistanceRequest]); useEffect(() => { - if (!shouldCalculateDistanceAmount) { + if (!shouldCalculateDistanceAmount || !transactionID) { return; } @@ -342,7 +353,7 @@ function MoneyRequestConfirmationList({ IOU.setMoneyRequestAmount(transactionID, amount, currency ?? ''); // If it's a split request among individuals, set the split shares - const participantAccountIDs: number[] = selectedParticipantsProp.map((participant) => participant.accountID ?? -1); + const participantAccountIDs: number[] = selectedParticipantsProp.map((participant) => participant.accountID ?? CONST.DEFAULT_NUMBER_ID); if (isTypeSplit && !isPolicyExpenseChat && amount && transaction?.currency) { IOU.setSplitShares(transaction, amount, currency, participantAccountIDs); } @@ -364,20 +375,25 @@ function MoneyRequestConfirmationList({ return; } - let taxableAmount: number; - let taxCode: string; + let taxableAmount: number | undefined; + let taxCode: string | undefined; if (isDistanceRequest) { - const customUnitRate = getDistanceRateCustomUnitRate(policy, customUnitRateID); - taxCode = customUnitRate?.attributes?.taxRateExternalID ?? ''; - taxableAmount = DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, distance); + if (customUnitRateID) { + const customUnitRate = getDistanceRateCustomUnitRate(policy, customUnitRateID); + taxCode = customUnitRate?.attributes?.taxRateExternalID; + taxableAmount = DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, distance); + } } else { taxableAmount = transaction.amount ?? 0; taxCode = transaction.taxCode ?? TransactionUtils.getDefaultTaxCode(policy, transaction) ?? ''; } - const taxPercentage = TransactionUtils.getTaxValue(policy, transaction, taxCode) ?? ''; - const taxAmount = TransactionUtils.calculateTaxAmount(taxPercentage, taxableAmount, transaction.currency); - const taxAmountInSmallestCurrencyUnits = CurrencyUtils.convertToBackendAmount(Number.parseFloat(taxAmount.toString())); - IOU.setMoneyRequestTaxAmount(transaction.transactionID ?? '', taxAmountInSmallestCurrencyUnits); + + if (taxCode && taxableAmount) { + const taxPercentage = TransactionUtils.getTaxValue(policy, transaction, taxCode) ?? ''; + const taxAmount = TransactionUtils.calculateTaxAmount(taxPercentage, taxableAmount, transaction.currency); + const taxAmountInSmallestCurrencyUnits = CurrencyUtils.convertToBackendAmount(Number.parseFloat(taxAmount.toString())); + IOU.setMoneyRequestTaxAmount(transaction.transactionID, taxAmountInSmallestCurrencyUnits); + } }, [ policy, shouldShowTax, @@ -522,7 +538,7 @@ function MoneyRequestConfirmationList({ rightElement: ( onSplitShareChange(participantOption.accountID ?? -1, Number(value))} + onAmountChange={(value: string) => onSplitShareChange(participantOption.accountID ?? CONST.DEFAULT_NUMBER_ID, Number(value))} maxLength={formattedTotalAmount.length} contentWidth={formattedTotalAmount.length * 8} /> @@ -637,7 +653,7 @@ function MoneyRequestConfirmationList({ }, [isTypeSplit, translate, payeePersonalDetails, getSplitSectionHeader, splitParticipants, selectedParticipants]); useEffect(() => { - if (!isDistanceRequest || isMovingTransactionFromTrackExpense) { + if (!isDistanceRequest || isMovingTransactionFromTrackExpense || !transactionID) { return; } @@ -669,16 +685,20 @@ function MoneyRequestConfirmationList({ // Auto select the category if there is only one enabled category and it is required useEffect(() => { const enabledCategories = Object.values(policyCategories ?? {}).filter((category) => category.enabled); - if (iouCategory || !shouldShowCategories || enabledCategories.length !== 1 || !isCategoryRequired) { + if (!transactionID || iouCategory || !shouldShowCategories || enabledCategories.length !== 1 || !isCategoryRequired) { return; } - IOU.setMoneyRequestCategory(transactionID, enabledCategories.at(0)?.name ?? ''); + IOU.setMoneyRequestCategory(transactionID, enabledCategories.at(0)?.name ?? '', policy?.id); // Keep 'transaction' out to ensure that we autoselect the option only once // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [shouldShowCategories, policyCategories, isCategoryRequired]); + }, [shouldShowCategories, policyCategories, isCategoryRequired, policy?.id]); // Auto select the tag if there is only one enabled tag and it is required useEffect(() => { + if (!transactionID) { + return; + } + let updatedTagsString = TransactionUtils.getTag(transaction); policyTagLists.forEach((tagList, index) => { const isTagListRequired = tagList.required ?? false; @@ -721,7 +741,7 @@ function MoneyRequestConfirmationList({ */ const confirm = useCallback( (paymentMethod: PaymentMethodType | undefined) => { - if (routeError) { + if (!!routeError || !transactionID) { return; } if (iouType === CONST.IOU.TYPE.INVOICE && !hasInvoicingDetails(policy)) { diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index e32c4eae410f..51cb2a6d6f39 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -163,7 +163,7 @@ type MoneyRequestConfirmationListFooterProps = { transaction: OnyxEntry; /** The transaction ID */ - transactionID: string; + transactionID: string | undefined; /** The unit */ unit: Unit | undefined; @@ -295,7 +295,7 @@ function MoneyRequestConfirmationListFooter({ description={translate('iou.amount')} interactive={!isReadOnly} onPress={() => { - if (isDistanceRequest) { + if (isDistanceRequest || !transactionID) { return; } @@ -326,6 +326,10 @@ function MoneyRequestConfirmationListFooter({ title={iouComment} description={translate('common.description')} onPress={() => { + if (!transactionID) { + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute(), reportActionID)); }} style={[styles.moneyRequestMenuItem]} @@ -349,7 +353,13 @@ function MoneyRequestConfirmationListFooter({ description={translate('common.distance')} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + onPress={() => { + if (!transactionID) { + return; + } + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} disabled={didConfirm} interactive={!isReadOnly} /> @@ -366,7 +376,13 @@ function MoneyRequestConfirmationListFooter({ description={translate('common.rate')} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + onPress={() => { + if (!transactionID) { + return; + } + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} disabled={didConfirm} interactive={!!rate && !isReadOnly && isPolicyExpenseChat} /> @@ -384,6 +400,10 @@ function MoneyRequestConfirmationListFooter({ style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => { + if (!transactionID) { + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute())); }} disabled={didConfirm} @@ -408,6 +428,10 @@ function MoneyRequestConfirmationListFooter({ style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => { + if (!transactionID) { + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute(), reportActionID)); }} disabled={didConfirm} @@ -427,12 +451,16 @@ function MoneyRequestConfirmationListFooter({ title={iouCategory} description={translate('common.category')} numberOfLinesTitle={2} - onPress={() => + onPress={() => { + if (!transactionID) { + return; + } + Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute(), reportActionID), CONST.NAVIGATION.ACTION_TYPE.PUSH, - ) - } + ); + }} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} disabled={didConfirm} @@ -454,9 +482,13 @@ function MoneyRequestConfirmationListFooter({ title={TransactionUtils.getTagForDisplay(transaction, index)} description={name} numberOfLinesTitle={2} - onPress={() => - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(action, iouType, index, transactionID, reportID, Navigation.getActiveRoute(), reportActionID)) - } + onPress={() => { + if (!transactionID) { + return; + } + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(action, iouType, index, transactionID, reportID, Navigation.getActiveRoute(), reportActionID)); + }} style={[styles.moneyRequestMenuItem]} disabled={didConfirm} interactive={!isReadOnly} @@ -476,7 +508,13 @@ function MoneyRequestConfirmationListFooter({ description={taxRates?.name} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute()))} + onPress={() => { + if (!transactionID) { + return; + } + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute())); + }} disabled={didConfirm} interactive={canModifyTaxFields} /> @@ -493,7 +531,13 @@ function MoneyRequestConfirmationListFooter({ description={translate('iou.taxAmount')} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute()))} + onPress={() => { + if (!transactionID) { + return; + } + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute())); + }} disabled={didConfirm} interactive={canModifyTaxFields} /> @@ -512,7 +556,13 @@ function MoneyRequestConfirmationListFooter({ }`} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + onPress={() => { + if (!transactionID) { + return; + } + + Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} interactive shouldRenderAsHTML /> @@ -557,7 +607,13 @@ function MoneyRequestConfirmationListFooter({ {isLocalFile && Str.isPDF(receiptFilename) ? ( Navigation.navigate(ROUTES.TRANSACTION_RECEIPT.getRoute(reportID ?? '', transactionID ?? ''))} + onPress={() => { + if (!transactionID) { + return; + } + + Navigation.navigate(ROUTES.TRANSACTION_RECEIPT.getRoute(reportID, transactionID)); + }} accessibilityRole={CONST.ROLE.BUTTON} accessibilityLabel={translate('accessibilityHints.viewAttachment')} disabled={!shouldDisplayReceipt} @@ -570,7 +626,13 @@ function MoneyRequestConfirmationListFooter({ ) : ( Navigation.navigate(ROUTES.TRANSACTION_RECEIPT.getRoute(reportID ?? '', transactionID ?? ''))} + onPress={() => { + if (!transactionID) { + return; + } + + Navigation.navigate(ROUTES.TRANSACTION_RECEIPT.getRoute(reportID, transactionID)); + }} disabled={!shouldDisplayReceipt || isThumbnail} accessibilityRole={CONST.ROLE.BUTTON} accessibilityLabel={translate('accessibilityHints.viewAttachment')} @@ -625,7 +687,10 @@ function MoneyRequestConfirmationListFooter({ isLabelHoverable={false} interactive={!isReadOnly && canUpdateSenderWorkspace} onPress={() => { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SEND_FROM.getRoute(iouType, transaction?.transactionID ?? '-1', reportID, Navigation.getActiveRouteWithoutParams())); + if (!transaction?.transactionID) { + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SEND_FROM.getRoute(iouType, transaction?.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); }} style={styles.moneyRequestMenuItem} labelStyle={styles.mt2} @@ -644,11 +709,15 @@ function MoneyRequestConfirmationListFooter({ ? receiptThumbnailContent : shouldShowReceiptEmptyState && ( + onPress={() => { + if (!transactionID) { + return; + } + Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()), - ) - } + ); + }} /> ))} {primaryFields} diff --git a/src/components/PDFThumbnail/index.tsx b/src/components/PDFThumbnail/index.tsx index 495c14ff76e1..6b83afe603c1 100644 --- a/src/components/PDFThumbnail/index.tsx +++ b/src/components/PDFThumbnail/index.tsx @@ -1,6 +1,6 @@ import 'core-js/proposals/promise-with-resolvers'; // eslint-disable-next-line import/extensions -import pdfWorkerSource from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs'; +import pdfWorkerSource from 'pdfjs-dist/build/pdf.worker.min.mjs'; import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import {Document, pdfjs, Thumbnail} from 'react-pdf'; diff --git a/src/components/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx index 6abf72e9e520..1896bc4f5f07 100644 --- a/src/components/ParentNavigationSubtitle.tsx +++ b/src/components/ParentNavigationSubtitle.tsx @@ -66,7 +66,9 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportAct {reportName} )} - {!!workspaceName && {` ${translate('threads.in')} ${workspaceName}`}} + {!!workspaceName && workspaceName !== reportName && ( + {` ${translate('threads.in')} ${workspaceName}`} + )} ); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index e3d4a8d31cf6..18750bfc7a29 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -33,7 +33,6 @@ import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import Navigation from '@navigation/Navigation'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; import * as IOU from '@userActions/IOU'; -import * as Link from '@userActions/Link'; import * as Transaction from '@userActions/Transaction'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -82,7 +81,6 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals const session = useSession(); const {isOffline} = useNetwork(); const {translate, toLocaleDigit} = useLocalize(); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const parentReportID = report?.parentReportID ?? '-1'; const policyID = report?.policyID ?? '-1'; const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); @@ -91,7 +89,8 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals }); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); - const targetPolicyID = updatedTransaction?.reportID ? ReportUtils.getReport(updatedTransaction?.reportID)?.policyID : policyID; + const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${updatedTransaction?.reportID}`); + const targetPolicyID = updatedTransaction?.reportID ? transactionReport?.policyID : policyID; const [policyTagList] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicyID}`); const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { canEvict: false, @@ -187,7 +186,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals const shouldShowAttendees = useMemo(() => TransactionUtils.shouldShowAttendees(iouType, policy), [iouType, policy]); const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest); - const tripID = ReportUtils.getTripIDFromTransactionParentReport(parentReport); + const tripID = ReportUtils.getTripIDFromTransactionParentReportID(parentReport?.parentReportID); const shouldShowViewTripDetails = TransactionUtils.hasReservationList(transaction) && !!tripID; const {getViolationsForField} = useViolations(transactionViolations ?? [], isReceiptBeingScanned || !ReportUtils.isPaidGroupPolicy(report)); @@ -698,10 +697,12 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals { - Link.openTravelDotLink(activePolicyID, CONST.TRIP_ID_PATH(tripID)); + const reservations = transaction?.receipt?.reservationList?.length ?? 0; + if (reservations > 1) { + Navigation.navigate(ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(report?.reportID ?? '-1', transaction?.transactionID ?? '-1', Navigation.getReportRHPActiveRoute())); + } + Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(report?.reportID ?? '-1', transaction?.transactionID ?? '-1', 0, Navigation.getReportRHPActiveRoute())); }} /> )} diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 617f2dc9e099..a4ade8d77aa8 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -364,7 +364,8 @@ function ReportPreview({ const shouldShowSettlementButton = (shouldShowPayButton || shouldShowApproveButton) && !showRTERViolationMessage && !shouldShowBrokenConnectionViolation; - const shouldPromptUserToAddBankAccount = ReportUtils.hasMissingPaymentMethod(userWallet, iouReportID) || ReportUtils.hasMissingInvoiceBankAccount(iouReportID); + const shouldPromptUserToAddBankAccount = + (ReportUtils.hasMissingPaymentMethod(userWallet, iouReportID) || ReportUtils.hasMissingInvoiceBankAccount(iouReportID)) && !ReportUtils.isSettled(iouReportID); const shouldShowRBR = hasErrors && !iouSettled; /* diff --git a/src/components/ReportActionItem/TripDetailsView.tsx b/src/components/ReportActionItem/TripDetailsView.tsx index 32cbc5dd853e..a7fdef547bf9 100644 --- a/src/components/ReportActionItem/TripDetailsView.tsx +++ b/src/components/ReportActionItem/TripDetailsView.tsx @@ -11,16 +11,18 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; +import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import * as Expensicons from '@src/components/Icon/Expensicons'; import CONST from '@src/CONST'; import * as ReportUtils from '@src/libs/ReportUtils'; import * as TripReservationUtils from '@src/libs/TripReservationUtils'; +import ROUTES from '@src/ROUTES'; import type {Reservation, ReservationTimeDetails} from '@src/types/onyx/Transaction'; type TripDetailsViewProps = { /** The active tripRoomReportID, used for Onyx subscription */ - tripRoomReportID?: string; + tripRoomReportID: string; /** Whether we should display the horizontal rule below the component */ shouldShowHorizontalRule: boolean; @@ -28,9 +30,12 @@ type TripDetailsViewProps = { type ReservationViewProps = { reservation: Reservation; + transactionID: string; + tripRoomReportID: string; + reservationIndex: number; }; -function ReservationView({reservation}: ReservationViewProps) { +function ReservationView({reservation, transactionID, tripRoomReportID, reservationIndex}: ReservationViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -75,11 +80,14 @@ function ReservationView({reservation}: ReservationViewProps) { const vendor = reservation.vendor ? `${reservation.vendor} • ` : ''; return `${vendor}${reservation.start.location}`; } + if (reservation.type === CONST.RESERVATION_TYPE.TRAIN) { + return reservation.route?.name; + } return reservation.start.address ?? reservation.start.location; }, [reservation]); const titleComponent = () => { - if (reservation.type === CONST.RESERVATION_TYPE.FLIGHT) { + if (reservation.type === CONST.RESERVATION_TYPE.FLIGHT || reservation.type === CONST.RESERVATION_TYPE.TRAIN) { return ( @@ -129,6 +137,7 @@ function ReservationView({reservation}: ReservationViewProps) { iconWidth={20} iconStyles={[StyleUtils.getTripReservationIconContainer(false), styles.mr3]} secondaryIconFill={theme.icon} + onPress={() => Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(tripRoomReportID, transactionID, reservationIndex, Navigation.getReportRHPActiveRoute()))} /> ); } @@ -138,7 +147,7 @@ function TripDetailsView({tripRoomReportID, shouldShowHorizontalRule}: TripDetai const {translate} = useLocalize(); const tripTransactions = ReportUtils.getTripTransactions(tripRoomReportID); - const reservations: Reservation[] = TripReservationUtils.getReservationsFromTripTransactions(tripTransactions); + const reservationsData: TripReservationUtils.ReservationData[] = TripReservationUtils.getReservationsFromTripTransactions(tripTransactions); return ( @@ -153,11 +162,18 @@ function TripDetailsView({tripRoomReportID, shouldShowHorizontalRule}: TripDetai <> - {reservations.map((reservation) => ( - - - - ))} + {reservationsData.map(({reservation, transactionID, reservationIndex}) => { + return ( + + + + ); + })} + {title} + + ); + + if (reservation.type === CONST.RESERVATION_TYPE.FLIGHT || reservation.type === CONST.RESERVATION_TYPE.TRAIN) { + const startName = reservation.type === CONST.RESERVATION_TYPE.FLIGHT ? reservation.start.shortName : reservation.start.longName; + const endName = reservation.type === CONST.RESERVATION_TYPE.FLIGHT ? reservation.end.shortName : reservation.end.longName; + + titleComponent = ( - {reservation.start.shortName} + {startName} - {reservation.end.shortName} + {endName} - ) : ( - - {title} - ); + } return ( ; +const renderItem = ({item}: {item: TripReservationUtils.ReservationData}) => ; function TripRoomPreview({action, chatReportID, containerStyles, contextMenuAnchor, isHovered = false, checkIfContextMenuActive = () => {}}: TripRoomPreviewProps) { const styles = useThemeStyles(); @@ -112,31 +117,22 @@ function TripRoomPreview({action, chatReportID, containerStyles, contextMenuAnch const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`); const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReport?.iouReportID}`); - const tripTransactions = ReportUtils.getTripTransactions(chatReport?.iouReportID, 'reportID'); - const reservations: Reservation[] = TripReservationUtils.getReservationsFromTripTransactions(tripTransactions); + const tripTransactions = ReportUtils.getTripTransactions(chatReport?.reportID); + const reservationsData: TripReservationUtils.ReservationData[] = TripReservationUtils.getReservationsFromTripTransactions(tripTransactions); const dateInfo = chatReport?.tripData ? DateUtils.getFormattedDateRange(new Date(chatReport.tripData.startDate), new Date(chatReport.tripData.endDate)) : ''; const {totalDisplaySpend} = ReportUtils.getMoneyRequestSpendBreakdown(chatReport); + const currency = iouReport?.currency ?? chatReport?.currency; const displayAmount = useMemo(() => { if (totalDisplaySpend) { - return CurrencyUtils.convertToDisplayString(totalDisplaySpend, iouReport?.currency); + return CurrencyUtils.convertToDisplayString(totalDisplaySpend, currency); } - // If iouReport is not available, get amount from the action message (Ex: "Domain20821's Workspace owes $33.00" or "paid ₫60" or "paid -₫60 elsewhere") - let displayAmountValue = ''; - const actionMessage = getReportActionText(action) ?? ''; - const splits = actionMessage.split(' '); - - splits.forEach((split) => { - if (!/\d/.test(split)) { - return; - } - - displayAmountValue = split; - }); - - return displayAmountValue; - }, [action, iouReport?.currency, totalDisplaySpend]); + return CurrencyUtils.convertToDisplayString( + tripTransactions.reduce((acc, transaction) => acc + Math.abs(transaction.amount), 0), + currency, + ); + }, [currency, totalDisplaySpend, tripTransactions]); return ( diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index ad69d27e7385..dabcaf90e4b2 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -118,6 +118,7 @@ function BaseSelectionList( shouldPreventActiveCellVirtualization = false, shouldScrollToFocusedIndex = true, onContentSizeChange, + listItemTitleStyles, }: BaseSelectionListProps, ref: ForwardedRef, ) { @@ -524,6 +525,7 @@ function BaseSelectionList( normalizedIndex={normalizedIndex} shouldSyncFocus={!isTextInputFocusedRef.current} wrapperStyle={listItemWrapperStyle} + titleStyles={listItemTitleStyles} shouldHighlightSelectedItem={shouldHighlightSelectedItem} singleExecution={singleExecution} /> diff --git a/src/components/SelectionList/BaseSelectionListItemRenderer.tsx b/src/components/SelectionList/BaseSelectionListItemRenderer.tsx index b08d2ae2cfbc..915e2c0fcf80 100644 --- a/src/components/SelectionList/BaseSelectionListItemRenderer.tsx +++ b/src/components/SelectionList/BaseSelectionListItemRenderer.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; import type useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import type useSingleExecution from '@hooks/useSingleExecution'; import * as SearchUIUtils from '@libs/SearchUIUtils'; @@ -11,6 +12,7 @@ type BaseSelectionListItemRendererProps = Omit[1]; normalizedIndex: number; singleExecution: ReturnType['singleExecution']; + titleStyles?: StyleProp; }; function BaseSelectionListItemRenderer({ @@ -37,6 +39,7 @@ function BaseSelectionListItemRenderer({ shouldSyncFocus, shouldHighlightSelectedItem, wrapperStyle, + titleStyles, singleExecution, }: BaseSelectionListItemRendererProps) { const handleOnCheckboxPress = () => { @@ -82,6 +85,7 @@ function BaseSelectionListItemRenderer({ shouldSyncFocus={shouldSyncFocus} shouldHighlightSelectedItem={shouldHighlightSelectedItem} wrapperStyle={wrapperStyle} + titleStyles={titleStyles} /> {item.footerContent && item.footerContent} diff --git a/src/components/SelectionList/RadioListItem.tsx b/src/components/SelectionList/RadioListItem.tsx index 3a8a4f0b57a6..256c3b0a876f 100644 --- a/src/components/SelectionList/RadioListItem.tsx +++ b/src/components/SelectionList/RadioListItem.tsx @@ -22,6 +22,7 @@ function RadioListItem({ onFocus, shouldSyncFocus, wrapperStyle, + titleStyles, }: RadioListItemProps) { const styles = useThemeStyles(); const fullTitle = isMultilineSupported ? item.text?.trimStart() : item.text; @@ -59,6 +60,7 @@ function RadioListItem({ item.alternateText ? styles.mb1 : null, isDisabled && styles.colorMuted, isMultilineSupported ? {paddingLeft} : null, + titleStyles, ]} numberOfLines={isMultilineSupported ? 2 : 1} /> diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 8e94b2f0069e..3774821ce35f 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -297,6 +297,9 @@ type ListItemProps = CommonListItemProps & { /** Whether we highlight all the selected items */ shouldHighlightSelectedItem?: boolean; + + /** Styles applied for the title */ + titleStyles?: StyleProp; }; type BaseListItemProps = CommonListItemProps & { @@ -563,6 +566,9 @@ type BaseSelectionListProps = Partial & { /** Styles for the section title */ sectionTitleStyles?: StyleProp; + /** Styles applid for the title of the list item */ + listItemTitleStyles?: StyleProp; + /** This may improve scroll performance for large lists */ removeClippedSubviews?: boolean; diff --git a/src/components/SubStepForms/ConfirmationStep.tsx b/src/components/SubStepForms/ConfirmationStep.tsx index c3832628a7ca..376a31872cff 100644 --- a/src/components/SubStepForms/ConfirmationStep.tsx +++ b/src/components/SubStepForms/ConfirmationStep.tsx @@ -38,9 +38,12 @@ type ConfirmationStepProps = SubStepProps & { /** The error message to display */ error?: string; + + /** Whether to apply safe area padding bottom */ + shouldApplySafeAreaPaddingBottom?: boolean; }; -function ConfirmationStep({pageTitle, summaryItems, showOnfidoLinks, onfidoLinksTitle, isLoading, error, onNext}: ConfirmationStepProps) { +function ConfirmationStep({pageTitle, summaryItems, showOnfidoLinks, onfidoLinksTitle, isLoading, error, onNext, shouldApplySafeAreaPaddingBottom = true}: ConfirmationStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); @@ -50,7 +53,7 @@ function ConfirmationStep({pageTitle, summaryItems, showOnfidoLinks, onfidoLinks {({safeAreaPaddingBottomStyle}) => ( {pageTitle} {summaryItems.map(({description, title, shouldShowRightIcon, onPress}) => ( diff --git a/src/components/TagPicker/index.tsx b/src/components/TagPicker/index.tsx index 0368f15a9752..08668c4aa38d 100644 --- a/src/components/TagPicker/index.tsx +++ b/src/components/TagPicker/index.tsx @@ -98,6 +98,7 @@ function TagPicker({selectedTag, tagListName, policyID, tagListIndex, shouldShow (null); const isLabelActive = useRef(initialActiveLabel); + useHtmlPaste(input, undefined, isMarkdownEnabled); + // AutoFocus which only works on mount: useEffect(() => { // We are manually managing focus to prevent this issue: https://github.com/Expensify/App/issues/4514 diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 00675ca4ccd6..45aa868ad219 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -1,7 +1,7 @@ import {Str} from 'expensify-common'; -import type {ForwardedRef} from 'react'; +import type {ForwardedRef, MutableRefObject} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, ViewStyle} from 'react-native'; +import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInput, TextInputFocusEventData, ViewStyle} from 'react-native'; import {ActivityIndicator, StyleSheet, View} from 'react-native'; import {useSharedValue, withSpring} from 'react-native-reanimated'; import Checkbox from '@components/Checkbox'; @@ -18,6 +18,7 @@ import Text from '@components/Text'; import * as styleConst from '@components/TextInput/styleConst'; import TextInputClearButton from '@components/TextInput/TextInputClearButton'; import TextInputLabel from '@components/TextInput/TextInputLabel'; +import useHtmlPaste from '@hooks/useHtmlPaste'; import useLocalize from '@hooks/useLocalize'; import useMarkdownStyle from '@hooks/useMarkdownStyle'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -107,6 +108,8 @@ function BaseTextInput( const isLabelActive = useRef(initialActiveLabel); const didScrollToEndRef = useRef(false); + useHtmlPaste(input as MutableRefObject, undefined, isMarkdownEnabled); + // AutoFocus which only works on mount: useEffect(() => { // We are manually managing focus to prevent this issue: https://github.com/Expensify/App/issues/4514 diff --git a/src/components/ValidateCodeActionModal/index.tsx b/src/components/ValidateCodeActionModal/index.tsx index 97c5687619bc..d0e00493e328 100644 --- a/src/components/ValidateCodeActionModal/index.tsx +++ b/src/components/ValidateCodeActionModal/index.tsx @@ -75,7 +75,7 @@ function ValidateCodeActionModal({ onBackButtonPress={hide} /> - + {descriptionPrimary} {!!descriptionSecondary && {descriptionSecondary}} ; }; -type WorkspaceSwitcherButtonProps = WorkspaceSwitcherButtonOnyxProps & { - /** - * Callback used to keep track of the workspace switching process in the BaseSidebarScreen. - */ - onSwitchWorkspace?: () => void; -}; +type WorkspaceSwitcherButtonProps = WorkspaceSwitcherButtonOnyxProps; -function WorkspaceSwitcherButton({policy, onSwitchWorkspace}: WorkspaceSwitcherButtonProps) { +function WorkspaceSwitcherButton({policy}: WorkspaceSwitcherButtonProps) { const {translate} = useLocalize(); const theme = useTheme(); @@ -41,7 +36,7 @@ function WorkspaceSwitcherButton({policy, onSwitchWorkspace}: WorkspaceSwitcherB source: avatar, name: policy?.name ?? '', type: CONST.ICON_TYPE_WORKSPACE, - id: policy?.id ?? '-1', + id: policy?.id ?? CONST.DEFAULT_NUMBER_ID, }; }, [policy]); @@ -54,7 +49,6 @@ function WorkspaceSwitcherButton({policy, onSwitchWorkspace}: WorkspaceSwitcherB accessible testID="WorkspaceSwitcherButton" onPress={() => { - onSwitchWorkspace?.(); pressableRef?.current?.blur(); interceptAnonymousUser(() => { Navigation.navigate(ROUTES.WORKSPACE_SWITCHER); diff --git a/src/hooks/useCleanupSelectedOptions/index.ts b/src/hooks/useCleanupSelectedOptions/index.ts new file mode 100644 index 000000000000..7451e85aef23 --- /dev/null +++ b/src/hooks/useCleanupSelectedOptions/index.ts @@ -0,0 +1,21 @@ +import {NavigationContainerRefContext, useIsFocused} from '@react-navigation/native'; +import {useContext, useEffect} from 'react'; +import NAVIGATORS from '@src/NAVIGATORS'; + +const useCleanupSelectedOptions = (cleanupFunction?: () => void) => { + const navigationContainerRef = useContext(NavigationContainerRefContext); + const state = navigationContainerRef?.getState(); + const lastRoute = state?.routes.at(-1); + const isRightModalOpening = lastRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; + + const isFocused = useIsFocused(); + + useEffect(() => { + if (isFocused || isRightModalOpening) { + return; + } + cleanupFunction?.(); + }, [isFocused, cleanupFunction, isRightModalOpening]); +}; + +export default useCleanupSelectedOptions; diff --git a/src/hooks/useHtmlPaste/index.ts b/src/hooks/useHtmlPaste/index.ts index ebffbf1b54b6..1a7e62f3141e 100644 --- a/src/hooks/useHtmlPaste/index.ts +++ b/src/hooks/useHtmlPaste/index.ts @@ -1,4 +1,3 @@ -import {useNavigation} from '@react-navigation/native'; import {useCallback, useEffect} from 'react'; import Parser from '@libs/Parser'; import CONST from '@src/CONST'; @@ -38,9 +37,7 @@ const insertAtCaret = (target: HTMLElement, insertedText: string, maxLength: num } }; -const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeListenerOnScreenBlur = false, maxLength = CONST.MAX_COMMENT_LENGTH + 1) => { - const navigation = useNavigation(); - +const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, isActive = false, maxLength = CONST.MAX_COMMENT_LENGTH + 1) => { /** * Set pasted text to clipboard * @param {String} text @@ -145,27 +142,16 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeLi ); useEffect(() => { - // we need to re-register listener on navigation focus/blur if the component (like Composer) is not unmounting - // when navigating away to different screen (report) to avoid paste event on other screen being wrongly handled - // by current screen paste listener - let unsubscribeFocus: () => void; - let unsubscribeBlur: () => void; - if (removeListenerOnScreenBlur) { - unsubscribeFocus = navigation.addListener('focus', () => document.addEventListener('paste', handlePaste, true)); - unsubscribeBlur = navigation.addListener('blur', () => document.removeEventListener('paste', handlePaste, true)); + if (!isActive) { + return; } - document.addEventListener('paste', handlePaste, true); return () => { - if (removeListenerOnScreenBlur) { - unsubscribeFocus(); - unsubscribeBlur(); - } document.removeEventListener('paste', handlePaste, true); }; // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, []); + }, [isActive]); }; export default useHtmlPaste; diff --git a/src/hooks/useHtmlPaste/types.ts b/src/hooks/useHtmlPaste/types.ts index 0aaa12a49ac9..65778463f80e 100644 --- a/src/hooks/useHtmlPaste/types.ts +++ b/src/hooks/useHtmlPaste/types.ts @@ -4,7 +4,7 @@ import type {TextInput} from 'react-native'; type UseHtmlPaste = ( textInputRef: MutableRefObject<(HTMLTextAreaElement & TextInput) | TextInput | null>, preHtmlPasteCallback?: (event: ClipboardEvent) => boolean, - removeListenerOnScreenBlur?: boolean, + isActive?: boolean, maxLength?: number, // Maximum length of the text input value after pasting ) => void; diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts index 7aff640aed94..81796dae851d 100644 --- a/src/hooks/useOnboardingFlow.ts +++ b/src/hooks/useOnboardingFlow.ts @@ -1,11 +1,11 @@ import {useEffect} from 'react'; import {NativeModules} from 'react-native'; import {useOnyx} from 'react-native-onyx'; -import * as LoginUtils from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@libs/onboardingSelectors'; import Permissions from '@libs/Permissions'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; +import * as Session from '@userActions/Session'; import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -27,8 +27,7 @@ function useOnboardingFlowRouter() { const [dismissedProductTraining, dismissedProductTrainingMetadata] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); - const [session] = useOnyx(ONYXKEYS.SESSION); - const isPrivateDomain = !!session?.email && !LoginUtils.isEmailPublicDomain(session?.email); + const isPrivateDomain = Session.isUserOnPrivateDomain(); const [isSingleNewDotEntry, isSingleNewDotEntryMetadata] = useOnyx(ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY); const [allBetas, allBetasMetadata] = useOnyx(ONYXKEYS.BETAS); diff --git a/src/hooks/useReportIDs.tsx b/src/hooks/useReportIDs.tsx index 284d80f737f2..878e83cf4d87 100644 --- a/src/hooks/useReportIDs.tsx +++ b/src/hooks/useReportIDs.tsx @@ -57,6 +57,7 @@ function ReportIDsContextProvider({ const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (c) => mapOnyxCollectionItems(c, policySelector)}); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [reportsDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); + const draftAmount = Object.keys(reportsDrafts ?? {}).length; const [betas] = useOnyx(ONYXKEYS.BETAS); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -70,9 +71,9 @@ function ReportIDsContextProvider({ const getOrderedReportIDs = useCallback( (currentReportID?: string) => SidebarUtils.getOrderedReportIDs(currentReportID ?? null, chatReports, betas, policies, priorityMode, transactionViolations, activeWorkspaceID, policyMemberAccountIDs), - // we need reports draft in deps array for reloading of list when reportsDrafts will change + // we need reports draft in deps array to reload the list when a draft is added or removed // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - [chatReports, betas, policies, priorityMode, transactionViolations, activeWorkspaceID, policyMemberAccountIDs, reportsDrafts], + [chatReports, betas, policies, priorityMode, transactionViolations, activeWorkspaceID, policyMemberAccountIDs, draftAmount], ); const orderedReportIDs = useMemo(() => getOrderedReportIDs(), [getOrderedReportIDs]); diff --git a/src/hooks/useWaitForNavigation.ts b/src/hooks/useWaitForNavigation.ts index 73c0eb2bb14c..05981ec3322b 100644 --- a/src/hooks/useWaitForNavigation.ts +++ b/src/hooks/useWaitForNavigation.ts @@ -1,5 +1,5 @@ -import {useNavigation} from '@react-navigation/native'; -import {useEffect, useRef} from 'react'; +import {useFocusEffect} from '@react-navigation/native'; +import {useCallback, useRef} from 'react'; type UseWaitForNavigation = (navigate: () => void) => () => Promise; @@ -8,21 +8,18 @@ type UseWaitForNavigation = (navigate: () => void) => () => Promise; * Only use when navigating by react-navigation */ export default function useWaitForNavigation(): UseWaitForNavigation { - const navigation = useNavigation(); const resolvePromises = useRef void>>([]); - useEffect(() => { - const unsubscribeBlur = navigation.addListener('blur', () => { - resolvePromises.current.forEach((resolve) => { - resolve(); - }); - resolvePromises.current = []; - }); - - return () => { - unsubscribeBlur(); - }; - }, [navigation]); + useFocusEffect( + useCallback(() => { + return () => { + resolvePromises.current.forEach((resolve) => { + resolve(); + }); + resolvePromises.current = []; + }; + }, []), + ); return (navigate: () => void) => () => { navigate(); diff --git a/src/languages/en.ts b/src/languages/en.ts index 2b73661a432c..80a75f9b9528 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -61,6 +61,7 @@ import type { DeleteConfirmationParams, DidSplitAmountMessageParams, EditActionParams, + EditDestinationSubtitleParams, ElectronicFundsParams, EnterMagicCodeParams, ExportAgainModalDescriptionParams, @@ -69,6 +70,7 @@ import type { FeatureNameParams, FileLimitParams, FiltersAmountBetweenParams, + FlightLayoverParams, FormattedMaxLengthParams, ForwardedAmountParams, GoBackMessageParams, @@ -192,6 +194,7 @@ import type { WelcomeNoteParams, WelcomeToRoomParams, WeSentYouMagicSignInLinkParams, + WorkspaceLockedPlanTypeParams, WorkspaceMemberList, WorkspaceOwnerWillNeedToAddOrUpdatePaymentCardParams, WorkspaceYouMayJoin, @@ -480,6 +483,9 @@ const translations = { links: 'Links', days: 'days', rename: 'Rename', + address: 'Address', + hourAbbreviation: 'h', + minuteAbbreviation: 'm', skip: 'Skip', chatWithAccountManager: ({accountManagerDisplayName}: ChatWithAccountManagerParams) => `Need something specific? Chat with your account manager, ${accountManagerDisplayName}.`, chatNow: 'Chat now', @@ -519,7 +525,7 @@ const translations = { attachmentImageResized: 'This image has been resized for previewing. Download for full resolution.', attachmentImageTooLarge: 'This image is too large to preview before uploading.', tooManyFiles: ({fileLimit}: FileLimitParams) => `You can only upload up to ${fileLimit} files at a time.`, - sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `Files exceeds ${maxUploadSizeInMB}MB. Please try again.`, + sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `Files exceeds ${maxUploadSizeInMB} MB. Please try again.`, }, filePicker: { fileError: 'File error', @@ -1092,7 +1098,7 @@ const translations = { viewPhoto: 'View photo', imageUploadFailed: 'Image upload failed', deleteWorkspaceError: 'Sorry, there was an unexpected problem deleting your workspace avatar', - sizeExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `The selected image exceeds the maximum upload size of ${maxUploadSizeInMB}MB.`, + sizeExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `The selected image exceeds the maximum upload size of ${maxUploadSizeInMB} MB.`, resolutionConstraints: ({minHeightInPx, minWidthInPx, maxHeightInPx, maxWidthInPx}: ResolutionConstraintsParams) => `Please upload an image larger than ${minHeightInPx}x${minWidthInPx} pixels and smaller than ${maxHeightInPx}x${maxWidthInPx} pixels.`, notAllowedExtension: ({allowedExtensions}: NotAllowedExtensionParams) => `Profile picture must be one of the following types: ${allowedExtensions.join(', ')}.`, @@ -2451,9 +2457,50 @@ const translations = { error: 'You must accept the Terms & Conditions for travel to continue', }, flight: 'Flight', + flightDetails: { + passenger: 'Passenger', + layover: ({layover}: FlightLayoverParams) => `You have a ${layover} layover before this flight`, + takeOff: 'Take-off', + landing: 'Landing', + seat: 'Seat', + class: 'Cabin Class', + recordLocator: 'Record locator', + }, hotel: 'Hotel', + hotelDetails: { + guest: 'Guest', + checkIn: 'Check-in', + checkOut: 'Check-out', + roomType: 'Room type', + cancellation: 'Cancellation policy', + cancellationUntil: 'Free cancellation until', + confirmation: 'Confirmation number', + }, car: 'Car', + carDetails: { + rentalCar: 'Car rental', + pickUp: 'Pick-up', + dropOff: 'Drop-off', + driver: 'Driver', + carType: 'Car type', + cancellation: 'Cancellation policy', + cancellationUntil: 'Free cancellation until', + confirmation: 'Confirmation number', + }, + train: 'Rail', + trainDetails: { + passenger: 'Passenger', + departs: 'Departs', + arrives: 'Arrives', + coachNumber: 'Coach number', + seat: 'Seat', + fareDetails: 'Fare details', + confirmation: 'Confirmation number', + }, viewTrip: 'View trip', + modifyTrip: 'Modify trip', + tripSupport: 'Trip support', + tripDetails: 'Trip details', viewTripDetails: 'View trip details', trip: 'Trip', trips: 'Trips', @@ -2556,6 +2603,7 @@ const translations = { return 'Member'; } }, + planType: 'Plan type', submitExpense: 'Submit expenses using your workspace chat below:', defaultCategory: 'Default category', }, @@ -2581,6 +2629,9 @@ const translations = { existingRateError: ({rate}: CustomUnitRateParams) => `A rate with value ${rate} already exists.`, }, importPerDiemRates: 'Import per diem rates', + editPerDiemRate: 'Edit per diem rate', + editDestinationSubtitle: ({destination}: EditDestinationSubtitleParams) => `Updating this destination will change it for all ${destination} per diem subrates.`, + editCurrencySubtitle: ({destination}: EditDestinationSubtitleParams) => `Updating this currency will change it for all ${destination} per diem subrates.`, }, qbd: { exportOutOfPocketExpensesDescription: 'Set how out-of-pocket expenses export to QuickBooks Desktop.', @@ -4310,6 +4361,19 @@ const translations = { moreDetails: 'for more details.', gotIt: 'Got it, thanks', }, + commonFeatures: { + title: 'Upgrade to the Control plan', + note: 'Unlock our most powerful features, including:', + benefits: { + note: 'The Control plan starts at $9 per active member per month.', + learnMore: 'Learn more', + pricing: 'about our plans and pricing.', + benefit1: 'Advanced accounting connections (NetSuite, Sage Intacct, and more)', + benefit2: 'Smart expense rules', + benefit3: 'Multi-level approval workflows', + benefit4: 'Enhanced security controls', + }, + }, }, restrictedAction: { restricted: 'Restricted', @@ -4413,6 +4477,25 @@ const translations = { andEnableWorkflows: 'and enable workflows, then add approvals to unlock this feature.', }, }, + planTypePage: { + planTypes: { + team: { + label: 'Collect', + description: 'For teams looking to automate their processes.', + }, + corporate: { + label: 'Control', + description: 'For organizations with advanced requirements.', + }, + }, + description: "Choose a plan that's right for you. For a detailed list of features and pricing, check out our", + subscriptionLink: 'plan types and pricing help page', + lockedPlanDescription: ({count, annualSubscriptionEndDate}: WorkspaceLockedPlanTypeParams) => ({ + one: `You've committed to 1 active member on the Control plan until your annual subscription ends on ${annualSubscriptionEndDate}. You can switch to pay-per-use subscription and downgrade to the Collect plan starting ${annualSubscriptionEndDate} by disabling auto-renew in`, + other: `You've committed to ${count} active members on the Control plan until your annual subscription ends on ${annualSubscriptionEndDate}. You can switch to pay-per-use subscription and downgrade to the Collect plan starting ${annualSubscriptionEndDate} by disabling auto-renew in`, + }), + subscriptions: 'Subscriptions', + }, }, getAssistancePage: { title: 'Get assistance', @@ -4550,7 +4633,7 @@ const translations = { searchResults: { emptyResults: { title: 'Nothing to show', - subtitle: 'Try creating something with the green + button.', + subtitle: 'Try adjusting your search criteria or creating something with the green + button.', }, emptyExpenseResults: { title: "You haven't created any expenses yet", diff --git a/src/languages/es.ts b/src/languages/es.ts index 529ee6442dad..54d80365e0fd 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -60,6 +60,7 @@ import type { DeleteConfirmationParams, DidSplitAmountMessageParams, EditActionParams, + EditDestinationSubtitleParams, ElectronicFundsParams, EnterMagicCodeParams, ExportAgainModalDescriptionParams, @@ -68,6 +69,7 @@ import type { FeatureNameParams, FileLimitParams, FiltersAmountBetweenParams, + FlightLayoverParams, FormattedMaxLengthParams, ForwardedAmountParams, GoBackMessageParams, @@ -192,6 +194,7 @@ import type { WelcomeNoteParams, WelcomeToRoomParams, WeSentYouMagicSignInLinkParams, + WorkspaceLockedPlanTypeParams, WorkspaceMemberList, WorkspaceOwnerWillNeedToAddOrUpdatePaymentCardParams, WorkspaceYouMayJoin, @@ -472,6 +475,9 @@ const translations = { sent: 'Enviado', links: 'Enlaces', days: 'días', + address: 'Dirección', + hourAbbreviation: 'h', + minuteAbbreviation: 'm', chatWithAccountManager: ({accountManagerDisplayName}: ChatWithAccountManagerParams) => `¿Necesitas algo específico? Habla con tu gerente de cuenta, ${accountManagerDisplayName}.`, chatNow: 'Chatear ahora', }, @@ -514,7 +520,7 @@ const translations = { attachmentImageResized: 'Se ha cambiado el tamaño de esta imagen para obtener una vista previa. Descargar para resolución completa.', attachmentImageTooLarge: 'Esta imagen es demasiado grande para obtener una vista previa antes de subirla.', tooManyFiles: ({fileLimit}: FileLimitParams) => `Solamente puedes suber ${fileLimit} archivos a la vez.`, - sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `El archivo supera los ${maxUploadSizeInMB}MB. Por favor, vuelve a intentarlo.`, + sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `El archivo supera los ${maxUploadSizeInMB} MB. Por favor, vuelve a intentarlo.`, }, filePicker: { fileError: 'Error de archivo', @@ -1090,7 +1096,7 @@ const translations = { viewPhoto: 'Ver foto', imageUploadFailed: 'Error al cargar la imagen', deleteWorkspaceError: 'Lo sentimos, hubo un problema eliminando el avatar de tu espacio de trabajo', - sizeExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `La imagen supera el tamaño máximo de ${maxUploadSizeInMB}MB.`, + sizeExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `La imagen supera el tamaño máximo de ${maxUploadSizeInMB} MB.`, resolutionConstraints: ({minHeightInPx, minWidthInPx, maxHeightInPx, maxWidthInPx}: ResolutionConstraintsParams) => `Por favor, elige una imagen más grande que ${minHeightInPx}x${minWidthInPx} píxeles y más pequeña que ${maxHeightInPx}x${maxWidthInPx} píxeles.`, notAllowedExtension: ({allowedExtensions}: NotAllowedExtensionParams) => `La foto de perfil debe ser de uno de los siguientes tipos: ${allowedExtensions.join(', ')}.`, @@ -2475,9 +2481,50 @@ const translations = { error: 'Debes aceptar los Términos y condiciones para que el viaje continúe', }, flight: 'Vuelo', + flightDetails: { + passenger: 'Pasajero', + layover: ({layover}: FlightLayoverParams) => `Tienes una escala de ${layover} antes de este vuelo`, + takeOff: 'Despegue', + landing: 'Aterrizaje', + seat: 'Asiento', + class: 'Clase de cabina', + recordLocator: 'Localizador de la reserva', + }, hotel: 'Hotel', + hotelDetails: { + guest: 'Cliente', + checkIn: 'Entrada', + checkOut: 'Salida', + roomType: 'Tipo de habitación', + cancellation: 'Política de cancelación', + cancellationUntil: 'Cancelación gratuita hasta el', + confirmation: 'Número de confirmación', + }, car: 'Auto', + carDetails: { + rentalCar: 'Coche de alquiler', + pickUp: 'Recogida', + dropOff: 'Devolución', + driver: 'Conductor', + carType: 'Tipo de coche', + cancellation: 'Política de cancelación', + cancellationUntil: 'Cancelación gratuita hasta el', + confirmation: 'Número de confirmación', + }, + train: 'Tren', + trainDetails: { + passenger: 'Pasajero', + departs: 'Sale', + arrives: 'Llega', + coachNumber: 'Número de vagón', + seat: 'Asiento', + fareDetails: 'Detalles de la tarifa', + confirmation: 'Número de confirmación', + }, viewTrip: 'Ver viaje', + modifyTrip: 'Modificar viaje', + tripSupport: 'Soporte de Viaje', + tripDetails: 'Detalles del viaje', viewTripDetails: 'Ver detalles del viaje', trip: 'Viaje', trips: 'Viajes', @@ -2580,6 +2627,7 @@ const translations = { return 'Miembro'; } }, + planType: 'Tipo de plan', submitExpense: 'Envíe los gastos utilizando el chat de su espacio de trabajo:', defaultCategory: 'Categoría predeterminada', }, @@ -2605,6 +2653,9 @@ const translations = { existingRateError: ({rate}: CustomUnitRateParams) => `Ya existe una tasa con el valor ${rate}.`, }, importPerDiemRates: 'Importar tasas de per diem', + editPerDiemRate: 'Editar la tasa de per diem', + editDestinationSubtitle: ({destination}: EditDestinationSubtitleParams) => `Actualizar este destino lo modificará para todas las subtasas per diem de ${destination}.`, + editCurrencySubtitle: ({destination}: EditDestinationSubtitleParams) => `Actualizar esta moneda la modificará para todas las subtasas per diem de ${destination}.`, }, qbd: { exportOutOfPocketExpensesDescription: 'Establezca cómo se exportan los gastos de bolsillo a QuickBooks Desktop.', @@ -4277,6 +4328,25 @@ const translations = { confirmText: 'Sí, exportar de nuevo', cancelText: 'Cancelar', }, + planTypePage: { + planTypes: { + team: { + label: 'Collect', + description: 'Para equipos que buscan automatizar sus procesos.', + }, + corporate: { + label: 'Recolectar', + description: 'Para organizaciones con requisitos avanzados.', + }, + }, + description: 'Elige el plan adecuado para ti. Para ver una lista detallada de funciones y precios, consulta nuestra', + subscriptionLink: 'página de ayuda sobre tipos de planes y precios', + lockedPlanDescription: ({count, annualSubscriptionEndDate}: WorkspaceLockedPlanTypeParams) => ({ + one: `Tienes un compromiso anual de 1 miembro activo en el plan Control hasta el ${annualSubscriptionEndDate}. Puedes cambiar a una suscripción de pago por uso y desmejorar al plan Recopilar a partir del ${annualSubscriptionEndDate} desactivando la renovación automática en`, + other: `Tienes un compromiso anual de ${count} miembros activos en el plan Control hasta el ${annualSubscriptionEndDate}. Puedes cambiar a una suscripción de pago por uso y desmejorar al plan Recopilar a partir del ${annualSubscriptionEndDate} desactivando la renovación automática en`, + }), + subscriptions: 'Suscripciones', + }, upgrade: { reportFields: { title: 'Los campos', @@ -4358,6 +4428,19 @@ const translations = { moreDetails: 'para obtener más información.', gotIt: 'Entendido, gracias.', }, + commonFeatures: { + title: 'Mejorar al plan Controlar', + note: 'Desbloquea nuestras funciones más potentes, incluyendo:', + benefits: { + note: 'El plan Controlar comienza desde $9 por miembro activo al mes.', + learnMore: 'Más información', + pricing: 'sobre nuestros planes y precios.', + benefit1: 'Conexiones avanzadas de contabilidad (NetSuite, Sage Intacct y más)', + benefit2: 'Reglas inteligentes de gastos', + benefit3: 'Flujos de aprobación de varios niveles', + benefit4: 'Controles de seguridad mejorados', + }, + }, }, restrictedAction: { restricted: 'Restringido', @@ -4599,7 +4682,7 @@ const translations = { searchResults: { emptyResults: { title: 'No hay nada que ver aquí', - subtitle: 'Por favor intenta crear algo con el botón verde.', + subtitle: 'Intenta ajustar tus criterios de búsqueda o crear algo con el botón verde +.', }, emptyExpenseResults: { title: 'Aún no has creado ningún gasto', diff --git a/src/languages/params.ts b/src/languages/params.ts index eb592d751116..f9ca26a3575a 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -569,6 +569,11 @@ type CurrencyCodeParams = { currencyCode: string; }; +type WorkspaceLockedPlanTypeParams = { + count: number; + annualSubscriptionEndDate: string; +}; + type CompanyNameParams = { companyName: string; }; @@ -581,6 +586,14 @@ type ChatWithAccountManagerParams = { accountManagerDisplayName: string; }; +type EditDestinationSubtitleParams = { + destination: string; +}; + +type FlightLayoverParams = { + layover: string; +}; + export type { AuthenticationErrorParams, ImportMembersSuccessfullDescriptionParams, @@ -785,7 +798,10 @@ export type { WorkspaceMemberList, ImportPerDiemRatesSuccessfullDescriptionParams, CurrencyCodeParams, + WorkspaceLockedPlanTypeParams, CompanyNameParams, CustomUnitRateParams, ChatWithAccountManagerParams, + EditDestinationSubtitleParams, + FlightLayoverParams, }; diff --git a/src/libs/API/parameters/OpenWorkspacePlanPage.ts b/src/libs/API/parameters/OpenWorkspacePlanPage.ts new file mode 100644 index 000000000000..6081d5b289e7 --- /dev/null +++ b/src/libs/API/parameters/OpenWorkspacePlanPage.ts @@ -0,0 +1,5 @@ +type OpenWorkspacePlanPageParams = { + policyID: string; +}; + +export default OpenWorkspacePlanPageParams; diff --git a/src/libs/API/parameters/TransactionMergeParams.ts b/src/libs/API/parameters/TransactionMergeParams.ts index 9e2516e2637f..ad718d37e6c8 100644 --- a/src/libs/API/parameters/TransactionMergeParams.ts +++ b/src/libs/API/parameters/TransactionMergeParams.ts @@ -1,5 +1,5 @@ type TransactionMergeParams = { - transactionID: string; + transactionID: string | undefined; transactionIDList: string[]; created: string; merchant: string; @@ -11,7 +11,7 @@ type TransactionMergeParams = { reimbursable: boolean; tag: string; receiptID: number; - reportID: string; + reportID: string | undefined; }; export default TransactionMergeParams; diff --git a/src/libs/API/parameters/UpdateWorkspaceCustomUnitParams.ts b/src/libs/API/parameters/UpdateWorkspaceCustomUnitParams.ts new file mode 100644 index 000000000000..fa1fc3d8c911 --- /dev/null +++ b/src/libs/API/parameters/UpdateWorkspaceCustomUnitParams.ts @@ -0,0 +1,6 @@ +type UpdateWorkspaceCustomUnitParams = { + policyID: string; + customUnit: string; +}; + +export default UpdateWorkspaceCustomUnitParams; diff --git a/src/libs/API/parameters/UpgradeToCorporateParams.ts b/src/libs/API/parameters/UpgradeToCorporateParams.ts index ee9d1359c4dd..7b7ff3e0adcc 100644 --- a/src/libs/API/parameters/UpgradeToCorporateParams.ts +++ b/src/libs/API/parameters/UpgradeToCorporateParams.ts @@ -1,6 +1,6 @@ type UpgradeToCorporateParams = { policyID: string; - featureName: string; + featureName?: string; }; export default UpgradeToCorporateParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 8e743777bdea..f31e53de07e3 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -355,4 +355,6 @@ export type {default as TogglePlatformMuteParams} from './TogglePlatformMutePara export type {default as JoinAccessiblePolicyParams} from './JoinAccessiblePolicyParams'; export type {default as ImportPerDiemRatesParams} from './ImportPerDiemRatesParams'; export type {default as ExportPerDiemCSVParams} from './ExportPerDiemCSVParams'; +export type {default as UpdateWorkspaceCustomUnitParams} from './UpdateWorkspaceCustomUnitParams'; export type {default as DismissProductTrainingParams} from './DismissProductTraining'; +export type {default as OpenWorkspacePlanPageParams} from './OpenWorkspacePlanPage'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 47c19c158ff6..96ff46bf8fbb 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -439,6 +439,7 @@ const WRITE_COMMANDS = { SELF_TOUR_VIEWED: 'SelfTourViewed', UPDATE_INVOICE_COMPANY_NAME: 'UpdateInvoiceCompanyName', UPDATE_INVOICE_COMPANY_WEBSITE: 'UpdateInvoiceCompanyWebsite', + UPDATE_WORKSPACE_CUSTOM_UNIT: 'UpdateWorkspaceCustomUnit', VALIDATE_USER_AND_GET_ACCESSIBLE_POLICIES: 'ValidateUserAndGetAccessiblePolicies', DISMISS_PRODUCT_TRAINING: 'DismissProductTraining', } as const; @@ -766,6 +767,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_ADD_NEW_USERS_AUTOMATICALLY]: Parameters.UpdateSubscriptionAddNewUsersAutomaticallyParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_SIZE]: Parameters.UpdateSubscriptionSizeParams; [WRITE_COMMANDS.REQUEST_TAX_EXEMPTION]: null; + [WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT]: Parameters.UpdateWorkspaceCustomUnitParams; [WRITE_COMMANDS.DELETE_MONEY_REQUEST_ON_SEARCH]: Parameters.DeleteMoneyRequestOnSearchParams; [WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.HoldMoneyRequestOnSearchParams; @@ -956,6 +958,7 @@ const READ_COMMANDS = { START_ISSUE_NEW_CARD_FLOW: 'StartIssueNewCardFlow', OPEN_CARD_DETAILS_PAGE: 'OpenCardDetailsPage', GET_ASSIGNED_SUPPORT_DATA: 'GetAssignedSupportData', + OPEN_WORKSPACE_PLAN_PAGE: 'OpenWorkspacePlanPage', } as const; type ReadCommand = ValueOf; @@ -1019,6 +1022,7 @@ type ReadCommandParameters = { [READ_COMMANDS.START_ISSUE_NEW_CARD_FLOW]: Parameters.StartIssueNewCardFlowParams; [READ_COMMANDS.OPEN_CARD_DETAILS_PAGE]: Parameters.OpenCardDetailsPageParams; [READ_COMMANDS.GET_ASSIGNED_SUPPORT_DATA]: Parameters.GetAssignedSupportDataParams; + [READ_COMMANDS.OPEN_WORKSPACE_PLAN_PAGE]: Parameters.OpenWorkspacePlanPageParams; }; const SIDE_EFFECT_REQUEST_COMMANDS = { diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 2cab87639d2f..5ea3fd605d6f 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -13,6 +13,7 @@ import { formatDistance, getDate, getDay, + intervalToDuration, isAfter, isBefore, isSameDay, @@ -36,6 +37,7 @@ import {es} from 'date-fns/locale/es'; import throttle from 'lodash/throttle'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {timezoneBackwardMap} from '@src/TIMEZONES'; @@ -798,8 +800,8 @@ function getFormattedReservationRangeDate(date1: Date, date2: Date): string { /** * Returns a formatted date of departure. * Dates are formatted as follows: - * 1. When the date refers to the current day: Departs on Sunday, Mar 17 at 8:00 - * 2. When the date refers not to the current day: Departs on Wednesday, Mar 17, 2023 at 8:00 + * 1. When the date refers to the current year: Departs on Sunday, Mar 17 at 8:00. + * 2. When the date refers not to the current year: Departs on Wednesday, Mar 17, 2023 at 8:00. */ function getFormattedTransportDate(date: Date): string { const {translateLocal} = Localize; @@ -809,6 +811,45 @@ function getFormattedTransportDate(date: Date): string { return `${translateLocal('travel.departs')} ${format(date, 'EEEE, MMM d, yyyy')} ${translateLocal('common.conjunctionAt')} ${format(date, 'HH:MM')}`; } +/** + * Returns a formatted flight date and hour. + * Dates are formatted as follows: + * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM + * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM + */ +function getFormattedTransportDateAndHour(date: Date): {date: string; hour: string} { + if (isThisYear(date)) { + return { + date: format(date, 'EEEE, MMM d'), + hour: format(date, 'h:mm a'), + }; + } + return { + date: format(date, 'EEEE, MMM d, yyyy'), + hour: format(date, 'h:mm a'), + }; +} + +/** + * Returns a formatted layover duration in format "2h 30m". + */ +function getFormattedDurationBetweenDates(translate: LocaleContextProps['translate'], start: Date, end: Date): string | undefined { + const {days, hours, minutes} = intervalToDuration({start, end}); + + if (days && days > 0) { + return; + } + + return `${hours ? `${hours}${translate('common.hourAbbreviation')} ` : ''}${minutes}${translate('common.minuteAbbreviation')}`; +} + +function getFormattedDuration(translate: LocaleContextProps['translate'], durationInSeconds: number): string { + const hours = Math.floor(durationInSeconds / 3600); + const minutes = Math.floor((durationInSeconds % 3600) / 60); + + return `${hours ? `${hours}${translate('common.hourAbbreviation')} ` : ''}${minutes}${translate('common.minuteAbbreviation')}`; +} + function doesDateBelongToAPastYear(date: string): boolean { const transactionYear = new Date(date).getFullYear(); return transactionYear !== new Date().getFullYear(); @@ -889,10 +930,13 @@ const DateUtils = { getFormattedDateRange, getFormattedReservationRangeDate, getFormattedTransportDate, + getFormattedTransportDateAndHour, doesDateBelongToAPastYear, isCardExpired, getDifferenceInDaysFromNow, isValidDateString, + getFormattedDurationBetweenDates, + getFormattedDuration, }; export default DateUtils; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 9343164db1e8..cfc56559ed35 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -921,6 +921,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) return validateString(value); case 'created': case 'modifiedCreated': + case 'inserted': case 'posted': return validateDate(value); case 'isLoading': @@ -1046,6 +1047,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) cardNumber: CONST.RED_BRICK_ROAD_PENDING_ACTION, managedCard: CONST.RED_BRICK_ROAD_PENDING_ACTION, posted: CONST.RED_BRICK_ROAD_PENDING_ACTION, + inserted: CONST.RED_BRICK_ROAD_PENDING_ACTION, }, 'string', ); diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index fe40ea67f905..c41b33873a8a 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -292,17 +292,25 @@ function convertToDistanceInMeters(distance: number, unit: Unit): number { function getCustomUnitRateID(reportID: string) { const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; - const policy = PolicyUtils.getPolicy(report?.policyID ?? parentReport?.policyID ?? '-1'); + const policy = PolicyUtils.getPolicy(report?.policyID ?? parentReport?.policyID); let customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID; + if (isEmptyObject(policy)) { + return customUnitRateID; + } + if (ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isPolicyExpenseChat(parentReport)) { - const distanceUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); - const lastSelectedDistanceRateID = lastSelectedDistanceRates?.[policy?.id ?? '-1'] ?? '-1'; - const lastSelectedDistanceRate = distanceUnit?.rates[lastSelectedDistanceRateID] ?? {}; - if (lastSelectedDistanceRate.enabled && lastSelectedDistanceRateID) { + const distanceUnit = Object.values(policy.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + const lastSelectedDistanceRateID = lastSelectedDistanceRates?.[policy.id]; + const lastSelectedDistanceRate = lastSelectedDistanceRateID ? distanceUnit?.rates[lastSelectedDistanceRateID] : undefined; + if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) { customUnitRateID = lastSelectedDistanceRateID; } else { - customUnitRateID = getDefaultMileageRate(policy)?.customUnitRateID ?? '-1'; + const defaultMileageRate = getDefaultMileageRate(policy); + if (!defaultMileageRate?.customUnitRateID) { + return customUnitRateID; + } + customUnitRateID = defaultMileageRate.customUnitRateID; } } diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts index 8dc46204db3c..9ed192b09233 100644 --- a/src/libs/GetPhysicalCardUtils.ts +++ b/src/libs/GetPhysicalCardUtils.ts @@ -1,3 +1,4 @@ +import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; @@ -6,16 +7,19 @@ import type {LoginList, PrivatePersonalDetails} from '@src/types/onyx'; import * as LoginUtils from './LoginUtils'; import Navigation from './Navigation/Navigation'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; +import * as PhoneNumberUtils from './PhoneNumber'; import * as UserUtils from './UserUtils'; function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry): Route { const {legalFirstName, legalLastName, phoneNumber} = privatePersonalDetails ?? {}; const address = PersonalDetailsUtils.getCurrentAddress(privatePersonalDetails); + const phoneNumberWithCountryCode = LoginUtils.appendCountryCode(phoneNumber ?? ''); + const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(phoneNumberWithCountryCode); if (!legalFirstName && !legalLastName) { return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain); } - if (!phoneNumber || !LoginUtils.validateNumber(phoneNumber)) { + if (!phoneNumber || !parsedPhoneNumber.possible || !Str.isValidE164Phone(phoneNumberWithCountryCode.slice(0))) { return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain); } if (!(address?.street && address?.city && address?.state && address?.country && address?.zip)) { diff --git a/src/libs/Middleware/SaveResponseInOnyx.ts b/src/libs/Middleware/SaveResponseInOnyx.ts index 85f95c146dac..12c1931b0199 100644 --- a/src/libs/Middleware/SaveResponseInOnyx.ts +++ b/src/libs/Middleware/SaveResponseInOnyx.ts @@ -10,7 +10,6 @@ const requestsToIgnoreLastUpdateID: string[] = [ SIDE_EFFECT_REQUEST_COMMANDS.RECONNECT_APP, WRITE_COMMANDS.CLOSE_ACCOUNT, WRITE_COMMANDS.DELETE_MONEY_REQUEST, - WRITE_COMMANDS.SUBMIT_REPORT, SIDE_EFFECT_REQUEST_COMMANDS.GET_MISSING_ONYX_MESSAGES, ]; diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 206bb8509af6..d76c9325cc0e 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -32,19 +32,23 @@ function stripDecimalsFromAmount(amount: string): string { * Adds a leading zero to the amount if user entered just the decimal separator * * @param amount - Changed amount from user input + * @param shouldAllowNegative - Should allow negative numbers */ -function addLeadingZero(amount: string): string { +function addLeadingZero(amount: string, shouldAllowNegative = false): string { + if (shouldAllowNegative && amount.startsWith('-.')) { + return `-0${amount}`; + } return amount.startsWith('.') ? `0${amount}` : amount; } /** * Check if amount is a decimal up to 3 digits */ -function validateAmount(amount: string, decimals: number, amountMaxLength: number = CONST.IOU.AMOUNT_MAX_LENGTH): boolean { +function validateAmount(amount: string, decimals: number, amountMaxLength: number = CONST.IOU.AMOUNT_MAX_LENGTH, shouldAllowNegative = false): boolean { const regexString = decimals === 0 - ? `^\\d{1,${amountMaxLength}}$` // Don't allow decimal point if decimals === 0 - : `^\\d{1,${amountMaxLength}}(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point + ? `^${shouldAllowNegative ? '-?' : ''}\\d{1,${amountMaxLength}}$` // Don't allow decimal point if decimals === 0 + : `^${shouldAllowNegative ? '-?' : ''}\\d{1,${amountMaxLength}}(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point const decimalNumberRegex = new RegExp(regexString, 'i'); return amount === '' || decimalNumberRegex.test(amount); } diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index e01d0fe3115f..e3f34ea3bea3 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -25,6 +25,7 @@ import Log from '@libs/Log'; import NavBarManager from '@libs/NavBarManager'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; import Presentation from '@libs/Navigation/PlatformStackNavigation/navigationOptions/presentation'; import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStackNavigation/types'; import shouldOpenOnAdminRoom from '@libs/Navigation/shouldOpenOnAdminRoom'; @@ -147,7 +148,7 @@ Onyx.connect({ return; } - currentAccountID = value.accountID ?? -1; + currentAccountID = value.accountID ?? CONST.DEFAULT_NUMBER_ID; if (Navigation.isActiveRoute(ROUTES.SIGN_IN_MODAL)) { // This means sign in in RHP was successful, so we can subscribe to user events @@ -249,7 +250,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie } const initialReport = ReportUtils.findLastAccessedReport(!canUseDefaultRooms, shouldOpenOnAdminRoom(), activeWorkspaceID); - return initialReport?.reportID ?? ''; + return initialReport?.reportID; }); useEffect(() => { @@ -464,7 +465,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie options={{ headerShown: false, presentation: Presentation.TRANSPARENT_MODAL, - animation: 'none', + animation: Animations.NONE, }} getComponent={loadProfileAvatar} listeners={modalScreenListeners} diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index edc32bb705b6..9622aca72c39 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -105,6 +105,8 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator({ [SCREENS.TRAVEL.MY_TRIPS]: () => require('../../../../pages/Travel/MyTripsPage').default, [SCREENS.TRAVEL.TCS]: () => require('../../../../pages/Travel/TravelTerms').default, + [SCREENS.TRAVEL.TRIP_SUMMARY]: () => require('../../../../pages/Travel/TripSummaryPage').default, + [SCREENS.TRAVEL.TRIP_DETAILS]: () => require('../../../../pages/Travel/TripDetailsPage').default, }); const SplitDetailsModalStackNavigator = createModalStackNavigator({ @@ -269,6 +271,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/WorkspaceProfileCurrencyPage').default, [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: () => require('../../../../pages/workspace/categories/CategorySettingsPage').default, [SCREENS.WORKSPACE.ADDRESS]: () => require('../../../../pages/workspace/WorkspaceProfileAddressPage').default, + [SCREENS.WORKSPACE.PLAN]: () => require('../../../../pages/workspace/WorkspaceProfilePlanTypePage').default, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: () => require('../../../../pages/workspace/categories/WorkspaceCategoriesSettingsPage').default, [SCREENS.WORKSPACE.CATEGORIES_IMPORT]: () => require('../../../../pages/workspace/categories/ImportCategoriesPage').default, [SCREENS.WORKSPACE.CATEGORIES_IMPORTED]: () => require('../../../../pages/workspace/categories/ImportedCategoriesPage').default, @@ -573,6 +576,11 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/perDiem/ImportPerDiemPage').default, [SCREENS.WORKSPACE.PER_DIEM_IMPORTED]: () => require('../../../../pages/workspace/perDiem/ImportedPerDiemPage').default, [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemSettingsPage').default, + [SCREENS.WORKSPACE.PER_DIEM_DETAILS]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemDetailsPage').default, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_DESTINATION]: () => require('../../../../pages/workspace/perDiem/EditPerDiemDestinationPage').default, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_SUBRATE]: () => require('../../../../pages/workspace/perDiem/EditPerDiemSubratePage').default, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_AMOUNT]: () => require('../../../../pages/workspace/perDiem/EditPerDiemAmountPage').default, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_CURRENCY]: () => require('../../../../pages/workspace/perDiem/EditPerDiemCurrencyPage').default, }); const EnablePaymentsStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx index 62cceee9f400..b4b71549f7ec 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx @@ -2,6 +2,7 @@ import {useNavigationState} from '@react-navigation/native'; import React from 'react'; import createCustomBottomTabNavigator from '@libs/Navigation/AppNavigator/createCustomBottomTabNavigator'; import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; +import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStackNavigation/types'; import type {BottomTabNavigatorParamList, CentralPaneName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; import SidebarScreen from '@pages/home/sidebar/SidebarScreen'; @@ -14,6 +15,7 @@ const loadInitialSettingsPage = () => require('../../../.. const Tab = createCustomBottomTabNavigator(); const screenOptions: PlatformStackNavigationOptions = { + animation: Animations.FADE, headerShown: false, }; diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx index 01caa79692f1..c72c4de01e4e 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx @@ -22,15 +22,9 @@ type TopBarProps = { activeWorkspaceID?: string; shouldDisplaySearch?: boolean; shouldDisplayCancelSearch?: boolean; - - /** - * Callback used to keep track of the workspace switching process in the BaseSidebarScreen. - * Passed to the WorkspaceSwitcherButton component. - */ - onSwitchWorkspace?: () => void; }; -function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true, shouldDisplayCancelSearch = false, onSwitchWorkspace}: TopBarProps) { +function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true, shouldDisplayCancelSearch = false}: TopBarProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const policy = usePolicy(activeWorkspaceID); @@ -53,10 +47,7 @@ function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true, dataSet={{dragArea: true}} > - + > = { [SCREENS.WORKSPACE.PROFILE]: [ SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.ADDRESS, + SCREENS.WORKSPACE.PLAN, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE, @@ -245,7 +246,16 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE, SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT, ], - [SCREENS.WORKSPACE.PER_DIEM]: [SCREENS.WORKSPACE.PER_DIEM_IMPORT, SCREENS.WORKSPACE.PER_DIEM_IMPORTED, SCREENS.WORKSPACE.PER_DIEM_SETTINGS], + [SCREENS.WORKSPACE.PER_DIEM]: [ + SCREENS.WORKSPACE.PER_DIEM_IMPORT, + SCREENS.WORKSPACE.PER_DIEM_IMPORTED, + SCREENS.WORKSPACE.PER_DIEM_SETTINGS, + SCREENS.WORKSPACE.PER_DIEM_DETAILS, + SCREENS.WORKSPACE.PER_DIEM_EDIT_DESTINATION, + SCREENS.WORKSPACE.PER_DIEM_EDIT_SUBRATE, + SCREENS.WORKSPACE.PER_DIEM_EDIT_AMOUNT, + SCREENS.WORKSPACE.PER_DIEM_EDIT_CURRENCY, + ], }; export default FULL_SCREEN_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 90de67f61f87..04ed0261a225 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -365,6 +365,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.ADDRESS]: { path: ROUTES.WORKSPACE_PROFILE_ADDRESS.route, }, + [SCREENS.WORKSPACE.PLAN]: { + path: ROUTES.WORKSPACE_PROFILE_PLAN.route, + }, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_IMPORT]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_IMPORT.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CHART_OF_ACCOUNTS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CHART_OF_ACCOUNTS.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CLASSES]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CLASSES.route}, @@ -971,6 +974,21 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: { path: ROUTES.WORKSPACE_PER_DIEM_SETTINGS.route, }, + [SCREENS.WORKSPACE.PER_DIEM_DETAILS]: { + path: ROUTES.WORKSPACE_PER_DIEM_DETAILS.route, + }, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_DESTINATION]: { + path: ROUTES.WORKSPACE_PER_DIEM_EDIT_DESTINATION.route, + }, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_SUBRATE]: { + path: ROUTES.WORKSPACE_PER_DIEM_EDIT_SUBRATE.route, + }, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_AMOUNT]: { + path: ROUTES.WORKSPACE_PER_DIEM_EDIT_AMOUNT.route, + }, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_CURRENCY]: { + path: ROUTES.WORKSPACE_PER_DIEM_EDIT_CURRENCY.route, + }, }, }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { @@ -1345,6 +1363,13 @@ const config: LinkingOptions['config'] = { screens: { [SCREENS.TRAVEL.MY_TRIPS]: ROUTES.TRAVEL_MY_TRIPS, [SCREENS.TRAVEL.TCS]: ROUTES.TRAVEL_TCS, + [SCREENS.TRAVEL.TRIP_SUMMARY]: ROUTES.TRAVEL_TRIP_SUMMARY.route, + [SCREENS.TRAVEL.TRIP_DETAILS]: { + path: ROUTES.TRAVEL_TRIP_DETAILS.route, + parse: { + reservationIndex: (reservationIndex: string) => parseInt(reservationIndex, 10), + }, + }, }, }, [SCREENS.RIGHT_MODAL.SEARCH_REPORT]: { diff --git a/src/libs/Navigation/switchPolicyID.ts b/src/libs/Navigation/switchPolicyID.ts index 144a56c7e522..ecb0a2f1220b 100644 --- a/src/libs/Navigation/switchPolicyID.ts +++ b/src/libs/Navigation/switchPolicyID.ts @@ -1,4 +1,4 @@ -import {CommonActions, getActionFromState} from '@react-navigation/core'; +import {getActionFromState} from '@react-navigation/core'; import type {NavigationAction, NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; import {getPathFromState} from '@react-navigation/native'; import type {Writable} from 'type-fest'; @@ -52,17 +52,6 @@ function getActionForBottomTabNavigator(action: StackNavigationAction, state: Na params.policyID = policyID; } - // If the last route in the BottomTabNavigator is already a 'Home' route, we want to change the params rather than pushing a new 'Home' route, - // so that the screen does not get re-mounted. This would cause an empty screen/white flash when navigating back from the workspace switcher. - const homeRoute = bottomTabNavigatorRoute.state.routes.at(-1); - if (homeRoute && homeRoute.name === SCREENS.HOME) { - return { - ...CommonActions.setParams(params), - source: homeRoute?.key, - }; - } - - // If there is no 'Home' route in the BottomTabNavigator or if we are updating a different navigator, we want to push a new route. return { type: CONST.NAVIGATION.ACTION_TYPE.PUSH, payload: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 462426d2de14..71f11113e84c 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -246,7 +246,7 @@ type SettingsNavigatorParamList = { }; [SCREENS.WORKSPACE.UPGRADE]: { policyID: string; - featureName: string; + featureName?: string; backTo?: Routes; categoryId?: string; }; @@ -908,6 +908,31 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: { policyID: string; }; + [SCREENS.WORKSPACE.PER_DIEM_DETAILS]: { + policyID: string; + rateID: string; + subRateID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_EDIT_DESTINATION]: { + policyID: string; + rateID: string; + subRateID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_EDIT_SUBRATE]: { + policyID: string; + rateID: string; + subRateID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_EDIT_AMOUNT]: { + policyID: string; + rateID: string; + subRateID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_EDIT_CURRENCY]: { + policyID: string; + rateID: string; + subRateID: string; + }; } & ReimbursementAccountNavigatorParamList; type NewChatNavigatorParamList = { @@ -1407,6 +1432,17 @@ type RightModalNavigatorParamList = { type TravelNavigatorParamList = { [SCREENS.TRAVEL.MY_TRIPS]: undefined; + [SCREENS.TRAVEL.TRIP_SUMMARY]: { + reportID: string; + transactionID: string; + backTo?: string; + }; + [SCREENS.TRAVEL.TRIP_DETAILS]: { + reportID: string; + transactionID: string; + reservationIndex: number; + backTo?: string; + }; }; type FullScreenNavigatorParamList = { diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index a7f738790f92..16d5bb03860c 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -214,6 +214,15 @@ Onyx.connect({ callback: (val) => (allPolicies = val), }); +let allReports: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, +}); + const lastReportActions: ReportActions = {}; const allSortedReportActions: Record = {}; let allReportActions: OnyxCollection; @@ -254,7 +263,7 @@ Onyx.connect({ lastReportActions[reportID] = firstReportAction; } - const report = ReportUtils.getReport(reportID); + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); // The report is only visible if it is the last action not deleted that @@ -767,7 +776,7 @@ function getPolicyExpenseReportOption(participant: Participant | ReportUtils.Opt const expenseReport = ReportUtils.isPolicyExpenseChat(participant) ? ReportUtils.getReportOrDraftReport(participant.reportID) : null; const visibleParticipantAccountIDs = Object.entries(expenseReport?.participants ?? {}) - .filter(([, reportParticipant]) => reportParticipant && reportParticipant.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) + .filter(([, reportParticipant]) => reportParticipant && !ReportUtils.isHiddenForCurrentUser(reportParticipant.notificationPreference)) .map(([accountID]) => Number(accountID)); const option = createOption( @@ -1746,6 +1755,9 @@ function filterAndOrderOptions(options: Options, searchInputValue: string, confi let {recentReports: filteredReports, personalDetails: filteredPersonalDetails} = filterResult; + // on staging server, in specific cases (see issue) BE returns duplicated personalDetails entries + filteredPersonalDetails = filteredPersonalDetails.filter((detail, index, array) => array.findIndex((i) => i.login === detail.login) === index); + if (typeof config?.maxRecentReportsToShow === 'number') { filteredReports = orderReportOptionsWithSearch(filteredReports, searchInputValue, config); filteredReports = filteredReports.slice(0, config.maxRecentReportsToShow); @@ -1787,7 +1799,7 @@ function getEmptyOptions(): Options { function shouldUseBoldText(report: ReportUtils.OptionData): boolean { const notificationPreference = report.notificationPreference ?? ReportUtils.getReportNotificationPreference(report); - return report.isUnread === true && notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE && notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + return report.isUnread === true && notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE && !ReportUtils.isHiddenForCurrentUser(notificationPreference); } export { diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index f77f992ede37..bebd54698288 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -28,7 +28,7 @@ function canUseCategoryAndTagApprovers(betas: OnyxEntry): boolean { function canUseCombinedTrackSubmit(): boolean { // We don't need to show this to all betas since this will be used for developing a feature for A/B testing. const session = SessionUtils.getSession(); - return isAccountIDEven(session?.accountID ?? -1); + return isAccountIDEven(session?.accountID ?? CONST.DEFAULT_NUMBER_ID); } function canUsePerDiem(betas: OnyxEntry): boolean { @@ -47,6 +47,14 @@ function canUseLinkPreviews(): boolean { return false; } +/** + * Workspace downgrade is temporarily disabled + * API is being integrated in this GH issue https://github.com/Expensify/App/issues/51494 + */ +function canUseWorkspaceDowngrade() { + return false; +} + export default { canUseDefaultRooms, canUseLinkPreviews, @@ -55,5 +63,6 @@ export default { canUseCombinedTrackSubmit, canUseCategoryAndTagApprovers, canUsePerDiem, + canUseWorkspaceDowngrade, shouldShowProductTrainingElements, }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 9bf68b2c5432..4982e8660dec 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -39,7 +39,7 @@ import * as Localize from './Localize'; import Navigation from './Navigation/Navigation'; import * as NetworkStore from './Network/NetworkStore'; import {getAccountIDsByLogins, getLoginsByAccountIDs, getPersonalDetailByEmail} from './PersonalDetailsUtils'; -import {getAllReportTransactions, getCategory, getTag} from './TransactionUtils'; +import {getAllSortedTransactions, getCategory, getTag} from './TransactionUtils'; type MemberEmailsToAccountIDs = Record; @@ -182,7 +182,7 @@ function getRateDisplayValue(value: number, toLocaleDigit: (arg: string) => stri return numValue.toString().replace('.', toLocaleDigit('.')).substring(0, value.toString().length); } -function getUnitRateValue(toLocaleDigit: (arg: string) => string, customUnitRate?: Rate, withDecimals?: boolean) { +function getUnitRateValue(toLocaleDigit: (arg: string) => string, customUnitRate?: Partial, withDecimals?: boolean) { return getRateDisplayValue((customUnitRate?.rate ?? 0) / CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET, toLocaleDigit, withDecimals); } @@ -197,7 +197,15 @@ function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry, isConnecti } function getPolicyRole(policy: OnyxInputOrEntry | SearchPolicy, currentUserLogin: string | undefined) { - return policy?.role ?? policy?.employeeList?.[currentUserLogin ?? '-1']?.role; + if (policy?.role) { + return policy.role; + } + + if (!currentUserLogin) { + return; + } + + return policy?.employeeList?.[currentUserLogin]?.role; } /** @@ -391,7 +399,7 @@ function isPaidGroupPolicy(policy: OnyxEntry | SearchPolicy): boolean { } function getOwnedPaidPolicies(policies: OnyxCollection | null, currentUserAccountID: number): Policy[] { - return Object.values(policies ?? {}).filter((policy): policy is Policy => isPolicyOwner(policy, currentUserAccountID ?? -1) && isPaidGroupPolicy(policy)); + return Object.values(policies ?? {}).filter((policy): policy is Policy => isPolicyOwner(policy, currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID) && isPaidGroupPolicy(policy)); } function isControlPolicy(policy: OnyxEntry): boolean { @@ -400,7 +408,7 @@ function isControlPolicy(policy: OnyxEntry): boolean { function isTaxTrackingEnabled(isPolicyExpenseChat: boolean, policy: OnyxEntry, isDistanceRequest: boolean): boolean { const distanceUnit = getDistanceRateCustomUnit(policy); - const customUnitID = distanceUnit?.customUnitID ?? 0; + const customUnitID = distanceUnit?.customUnitID ?? CONST.DEFAULT_NUMBER_ID; const isPolicyTaxTrackingEnabled = isPolicyExpenseChat && policy?.tax?.trackingEnabled; const isTaxEnabledForDistance = isPolicyTaxTrackingEnabled && policy?.customUnits?.[customUnitID]?.attributes?.taxEnabled; @@ -534,37 +542,35 @@ function getDefaultApprover(policy: OnyxEntry | SearchPolicy): string { return policy?.approver ?? policy?.owner ?? ''; } -/** - * Returns the accountID to whom the given expenseReport submits reports to in the given Policy. - */ -function getSubmitToAccountID(policy: OnyxEntry | SearchPolicy, expenseReport: OnyxEntry): number { - const employeeAccountID = expenseReport?.ownerAccountID ?? -1; - const employeeLogin = getLoginsByAccountIDs([employeeAccountID]).at(0) ?? ''; - const defaultApprover = getDefaultApprover(policy); - - let categoryAppover; - let tagApprover; - const allTransactions = getAllReportTransactions(expenseReport?.reportID).sort((transA, transB) => (transA.created < transB.created ? -1 : 1)); +function getRuleApprovers(policy: OnyxEntry | SearchPolicy, expenseReport: OnyxEntry) { + const categoryAppovers: string[] = []; + const tagApprovers: string[] = []; + const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID); // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. // Category approvers are prioritized, then tag approvers. - for (let i = 0; i < allTransactions.length; i++) { - const transaction = allTransactions.at(i); + for (let i = 0; i < allReportTransactions.length; i++) { + const transaction = allReportTransactions.at(i); const tag = getTag(transaction); const category = getCategory(transaction); - categoryAppover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category)?.approver; + const categoryAppover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category)?.approver; + const tagApprover = getTagApproverRule(policy, tag)?.approver; if (categoryAppover) { - return getAccountIDsByLogins([categoryAppover]).at(0) ?? -1; + categoryAppovers.push(categoryAppover); } - if (!tagApprover && getTagApproverRule(policy ?? '-1', tag)?.approver) { - tagApprover = getTagApproverRule(policy ?? '-1', tag)?.approver; + if (tagApprover) { + tagApprovers.push(tagApprover); } } - if (tagApprover) { - return getAccountIDsByLogins([tagApprover]).at(0) ?? -1; - } + return [...categoryAppovers, ...tagApprovers]; +} + +function getManagerAccountID(policy: OnyxEntry | SearchPolicy, expenseReport: OnyxEntry) { + const employeeAccountID = expenseReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID; + const employeeLogin = getLoginsByAccountIDs([employeeAccountID]).at(0) ?? ''; + const defaultApprover = getDefaultApprover(policy); // For policy using the optional or basic workflow, the manager is the policy default approver. if (([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy))) { @@ -579,9 +585,21 @@ function getSubmitToAccountID(policy: OnyxEntry | SearchPolicy, expenseR return getAccountIDsByLogins([employee.submitsTo ?? defaultApprover]).at(0) ?? -1; } -function getSubmitToEmail(policy: OnyxEntry, expenseReport: OnyxEntry): string { - const submitToAccountID = getSubmitToAccountID(policy, expenseReport); - return getLoginsByAccountIDs([submitToAccountID]).at(0) ?? ''; +/** + * Returns the accountID to whom the given expenseReport submits reports to in the given Policy. + */ +function getSubmitToAccountID(policy: OnyxEntry | SearchPolicy, expenseReport: OnyxEntry): number { + const ruleApprovers = getRuleApprovers(policy, expenseReport); + if (ruleApprovers.length > 0 && !isSubmitAndClose(policy)) { + return getAccountIDsByLogins([ruleApprovers.at(0) ?? '']).at(0) ?? -1; + } + + return getManagerAccountID(policy, expenseReport); +} + +function getManagerAccountEmail(policy: OnyxEntry, expenseReport: OnyxEntry): string { + const managerAccountID = getManagerAccountID(policy, expenseReport); + return getLoginsByAccountIDs([managerAccountID]).at(0) ?? ''; } /** @@ -709,7 +727,10 @@ function settingsPendingAction(settings?: string[], pendingFields?: PendingField } const key = Object.keys(pendingFields).find((setting) => settings.includes(setting)); - return pendingFields[key ?? '-1']; + if (!key) { + return; + } + return pendingFields[key]; } function findSelectedVendorWithDefaultSelect(vendors: NetSuiteVendor[] | undefined, selectedVendorId: string | undefined) { @@ -1078,7 +1099,7 @@ function getWorkspaceAccountID(policyID: string) { if (!policy) { return 0; } - return policy.workspaceAccountID ?? 0; + return policy.workspaceAccountID ?? CONST.DEFAULT_NUMBER_ID; } function hasVBBA(policyID: string) { @@ -1121,6 +1142,17 @@ function getActivePolicy(): OnyxEntry { return getPolicy(activePolicyId); } +function getUserFriendlyWorkspaceType(workspaceType: ValueOf) { + switch (workspaceType) { + case CONST.POLICY.TYPE.CORPORATE: + return Localize.translateLocal('workspace.type.control'); + case CONST.POLICY.TYPE.TEAM: + return Localize.translateLocal('workspace.type.collect'); + default: + return Localize.translateLocal('workspace.type.free'); + } +} + function isPolicyAccessible(policy: OnyxEntry): boolean { return !isEmptyObject(policy) && (Object.keys(policy).length !== 1 || isEmptyObject(policy.errors)) && !!policy?.id; } @@ -1241,7 +1273,6 @@ export { getCurrentTaxID, areSettingsInErrorFields, settingsPendingAction, - getSubmitToEmail, getForwardsToAccount, getSubmitToAccountID, getWorkspaceAccountID, @@ -1254,8 +1285,11 @@ export { getNetSuiteImportCustomFieldLabel, getAllPoliciesLength, getActivePolicy, + getUserFriendlyWorkspaceType, isPolicyAccessible, areAllGroupPoliciesExpenseChatDisabled, + getManagerAccountEmail, + getRuleApprovers, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 1153f638ceef..82136d6364a9 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -975,7 +975,11 @@ function getMostRecentReportActionLastModified(): string { /** * @returns The report preview action or `null` if one couldn't be found */ -function getReportPreviewAction(chatReportID: string, iouReportID: string): OnyxEntry> { +function getReportPreviewAction(chatReportID: string | undefined, iouReportID: string | undefined): OnyxEntry> { + if (!chatReportID || !iouReportID) { + return; + } + return Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`] ?? {}).find( (reportAction): reportAction is ReportAction => reportAction && isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) && getOriginalMessage(reportAction)?.linkedReportID === iouReportID, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 60ad80750752..ebee6689f6e2 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1294,12 +1294,9 @@ function getDefaultNotificationPreferenceForReport(report: OnyxEntry): V } /** - * Get the notification preference given a report + * Get the notification preference given a report. This should ALWAYS default to 'hidden'. Do not change this! */ -function getReportNotificationPreference(report: OnyxEntry, shouldDefaltToHidden = true): ValueOf { - if (!shouldDefaltToHidden) { - return report?.participants?.[currentUserAccountID ?? -1]?.notificationPreference ?? getDefaultNotificationPreferenceForReport(report); - } +function getReportNotificationPreference(report: OnyxEntry): ValueOf { return report?.participants?.[currentUserAccountID ?? -1]?.notificationPreference ?? CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; } @@ -1415,6 +1412,23 @@ function canCreateTaskInReport(report: OnyxEntry): boolean { return true; } +/** + * For all intents and purposes a report that has no notificationPreference at all should be considered "hidden". + * We will remove the 'hidden' field entirely once the backend changes for https://github.com/Expensify/Expensify/issues/450891 are done. + */ +function isHiddenForCurrentUser(notificationPreference: string | null | undefined): boolean; +function isHiddenForCurrentUser(report: OnyxEntry): boolean; +function isHiddenForCurrentUser(reportOrPreference: OnyxEntry | string | null | undefined): boolean { + if (typeof reportOrPreference === 'object' && reportOrPreference !== null) { + const notificationPreference = getReportNotificationPreference(reportOrPreference); + return isHiddenForCurrentUser(notificationPreference); + } + if (reportOrPreference === undefined || reportOrPreference === null || reportOrPreference === '') { + return true; + } + return reportOrPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; +} + /** * Returns true if there are any guides accounts (team.expensify.com) in a list of accountIDs * by cross-referencing the accountIDs with personalDetails since guides that are participants @@ -1426,7 +1440,7 @@ function hasExpensifyGuidesEmails(accountIDs: number[]): boolean { function getMostRecentlyVisitedReport(reports: Array>, reportMetadata: OnyxCollection): OnyxEntry { const filteredReports = reports.filter((report) => { - const shouldKeep = !isChatThread(report) || getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const shouldKeep = !isChatThread(report) || !isHiddenForCurrentUser(report); return shouldKeep && !!report?.reportID && !!(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]?.lastVisitTime ?? report?.lastReadTime); }); return lodashMaxBy(filteredReports, (a) => new Date(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${a?.reportID}`]?.lastVisitTime ?? a?.lastReadTime ?? '').valueOf()); @@ -2233,7 +2247,7 @@ function getParticipantsAccountIDsForDisplay(report: OnyxEntry, shouldEx return false; } - if (shouldExcludeHidden && reportParticipants[accountID]?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + if (shouldExcludeHidden && isHiddenForCurrentUser(reportParticipants[accountID]?.notificationPreference)) { return false; } @@ -2833,7 +2847,7 @@ function getReasonAndReportActionThatRequiresAttention( }; } - if (hasMissingInvoiceBankAccount(optionOrReport.reportID)) { + if (hasMissingInvoiceBankAccount(optionOrReport.reportID) && !isSettled(optionOrReport.reportID)) { return { reason: CONST.REQUIRES_ATTENTION_REASONS.HAS_MISSING_INVOICE_BANK_ACCOUNT, }; @@ -2841,7 +2855,11 @@ function getReasonAndReportActionThatRequiresAttention( if (isInvoiceRoom(optionOrReport)) { const reportAction = Object.values(reportActions).find( - (action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && action.childReportID && hasMissingInvoiceBankAccount(action.childReportID), + (action) => + action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && + action.childReportID && + hasMissingInvoiceBankAccount(action.childReportID) && + !isSettled(action.childReportID), ); return reportAction @@ -2880,8 +2898,8 @@ function hasNonReimbursableTransactions(iouReportID: string | undefined): boolea function getMoneyRequestSpendBreakdown(report: OnyxInputOrEntry, allReportsDict?: OnyxCollection): SpendBreakdown { const allAvailableReports = allReportsDict ?? allReports; - let moneyRequestReport; - if (isMoneyRequestReport(report) || isInvoiceReport(report)) { + let moneyRequestReport: OnyxEntry; + if (report && (isMoneyRequestReport(report) || isInvoiceReport(report))) { moneyRequestReport = report; } if (allAvailableReports && report?.iouReportID) { @@ -3900,7 +3918,7 @@ function getReportActionMessage(reportAction: OnyxEntry, reportID? if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) { return Localize.translateLocal('iou.unheldExpense'); } - if (ReportActionsUtils.isApprovedOrSubmittedReportAction(reportAction)) { + if (ReportActionsUtils.isApprovedOrSubmittedReportAction(reportAction) || ReportActionsUtils.isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REIMBURSED)) { return ReportActionsUtils.getReportActionMessageText(reportAction); } if (ReportActionsUtils.isReimbursementQueuedAction(reportAction)) { @@ -4074,11 +4092,7 @@ function getReportName( } if (isInvoiceReport(report)) { - if (!isInvoiceRoom(getReport(report?.chatReportID ?? ''))) { - return report?.reportName ?? getMoneyRequestReportName(report, policy, invoiceReceiverPolicy); - } - - formattedName = getMoneyRequestReportName(report, policy, invoiceReceiverPolicy); + formattedName = report?.reportName ?? getMoneyRequestReportName(report, policy, invoiceReceiverPolicy); } if (isInvoiceRoom(report)) { @@ -5193,7 +5207,7 @@ function buildOptimisticReportPreview( const hasReceipt = TransactionUtils.hasReceipt(transaction); const message = getReportPreviewMessage(iouReport); const created = DateUtils.getDBTime(); - const reportActorAccountID = (isInvoiceReport(iouReport) ? iouReport?.ownerAccountID : iouReport?.managerID) ?? -1; + const reportActorAccountID = (isInvoiceReport(iouReport) || isExpenseReport(iouReport) ? iouReport?.ownerAccountID : iouReport?.managerID) ?? -1; return { reportActionID: reportActionID ?? NumberUtils.rand64(), reportID: chatReport?.reportID, @@ -8084,8 +8098,8 @@ function getTripTransactions(tripRoomReportID: string | undefined, reportFieldTo return tripTransactionReportIDs.flatMap((reportID) => reportsTransactions[reportID ?? ''] ?? []); } -function getTripIDFromTransactionParentReport(transactionParentReport: OnyxEntry | undefined | null): string | undefined { - return getReportOrDraftReport(transactionParentReport?.parentReportID)?.tripData?.tripID; +function getTripIDFromTransactionParentReportID(transactionParentReportID: string | undefined): string | undefined { + return getReportOrDraftReport(transactionParentReportID)?.tripData?.tripID; } /** @@ -8119,7 +8133,7 @@ function canJoinChat(report: OnyxEntry, parentReportAction: OnyxInputOrE } // If the notification preference of the chat is not hidden that means we have already joined the chat - if (getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + if (!isHiddenForCurrentUser(report)) { return false; } @@ -8153,7 +8167,7 @@ function canLeaveChat(report: OnyxEntry, policy: OnyxEntry): boo return false; } - if (getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + if (isHiddenForCurrentUser(report)) { return false; } @@ -8472,20 +8486,41 @@ function isExported(reportActions: OnyxEntry) { function getApprovalChain(policy: OnyxEntry, expenseReport: OnyxEntry): string[] { const approvalChain: string[] = []; + const fullApprovalChain: string[] = []; const reportTotal = expenseReport?.total ?? 0; + const submitterEmail = PersonalDetailsUtils.getLoginsByAccountIDs([expenseReport?.ownerAccountID ?? -1]).at(0) ?? ''; - // If the policy is not on advanced approval mode, we should not use the approval chain even if it exists. - if (!PolicyUtils.isControlOnAdvancedApprovalMode(policy)) { + if (PolicyUtils.isSubmitAndClose(policy)) { return approvalChain; } - let nextApproverEmail = PolicyUtils.getSubmitToEmail(policy, expenseReport); + // Get category/tag approver list + const ruleApprovers = PolicyUtils.getRuleApprovers(policy, expenseReport); + + // Push rule approvers to approvalChain list before submitsTo/forwardsTo approvers + ruleApprovers.forEach((ruleApprover) => { + // Don't push submiiter to approve as a rule approver + if (fullApprovalChain.includes(ruleApprover) || ruleApprover === submitterEmail) { + return; + } + fullApprovalChain.push(ruleApprover); + }); + + let nextApproverEmail = PolicyUtils.getManagerAccountEmail(policy, expenseReport); while (nextApproverEmail && !approvalChain.includes(nextApproverEmail)) { approvalChain.push(nextApproverEmail); nextApproverEmail = PolicyUtils.getForwardsToAccount(policy, nextApproverEmail, reportTotal); } - return approvalChain; + + approvalChain.forEach((approver) => { + if (fullApprovalChain.includes(approver)) { + return; + } + + fullApprovalChain.push(approver); + }); + return fullApprovalChain; } /** @@ -8794,7 +8829,7 @@ export { updateReportPreview, temporary_getMoneyRequestOptions, getTripTransactions, - getTripIDFromTransactionParentReport, + getTripIDFromTransactionParentReportID, buildOptimisticInvoiceReport, getInvoiceChatByParticipants, shouldShowMerchantColumn, @@ -8817,7 +8852,6 @@ export { getReportLastMessage, getMostRecentlyVisitedReport, getSourceIDFromReportAction, - getReport, getReportNameValuePairs, hasReportViolations, isPayAtEndExpenseReport, @@ -8835,6 +8869,7 @@ export { getAllReportActionsErrorsAndReportActionThatRequiresAttention, hasInvoiceReports, getReportMetadata, + isHiddenForCurrentUser, }; export type { diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index b5ae73bed2e4..68b3ce60963a 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -305,9 +305,11 @@ function getAction(data: OnyxTypes.SearchResults['data'], key: string): SearchTr ) { return CONST.SEARCH.ACTION_TYPES.PAY; } + const hasOnlyPendingTransactions = + allReportTransactions.length > 0 && allReportTransactions.every((t) => TransactionUtils.isExpensifyCardTransaction(t) && TransactionUtils.isPending(t)); const isAllowedToApproveExpenseReport = ReportUtils.isAllowedToApproveExpenseReport(report, undefined, policy); - if (IOU.canApproveIOU(report, policy) && isAllowedToApproveExpenseReport) { + if (IOU.canApproveIOU(report, policy) && isAllowedToApproveExpenseReport && !hasOnlyPendingTransactions) { return CONST.SEARCH.ACTION_TYPES.APPROVE; } diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 01cbad41b128..626dc8d5ed68 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -35,6 +35,16 @@ Onyx.connect({ allPersonalDetails = value ?? {}; }, }); + +let allReports: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, +}); + Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, callback: (actions, key) => { @@ -42,7 +52,7 @@ Onyx.connect({ return; } const reportID = CollectionUtils.extractCollectionItemID(key); - const report = ReportUtils.getReport(reportID); + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); const actionsArray: ReportAction[] = ReportActionsUtils.getSortedReportActions(Object.values(actions)); @@ -91,7 +101,7 @@ function ensureSingleSpacing(text: string) { */ function getOrderedReportIDs( currentReportId: string | null, - allReports: OnyxCollection, + reports: OnyxCollection, betas: OnyxEntry, policies: OnyxCollection, priorityMode: OnyxEntry, @@ -102,7 +112,7 @@ function getOrderedReportIDs( Performance.markStart(CONST.TIMING.GET_ORDERED_REPORT_IDS); const isInFocusMode = priorityMode === CONST.PRIORITY_MODE.GSD; const isInDefaultMode = !isInFocusMode; - const allReportsDictValues = Object.values(allReports ?? {}); + const allReportsDictValues = Object.values(reports ?? {}); // Filter out all the reports that shouldn't be displayed let reportsToDisplay: Array = []; @@ -115,7 +125,7 @@ function getOrderedReportIDs( } const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1'); const doesReportHaveViolations = ReportUtils.shouldDisplayViolationsRBRInLHN(report, transactionViolations); - const isHidden = ReportUtils.getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const isHidden = ReportUtils.isHiddenForCurrentUser(report); const isFocused = report.reportID === currentReportId; const hasErrorsOtherThanFailedReceipt = ReportUtils.hasReportErrorsOtherThanFailedReceipt(report, doesReportHaveViolations, transactionViolations); const isReportInAccessible = report?.errorFields?.notFound; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 04c5d5519b5f..8ada596d7cdb 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -9,7 +9,8 @@ import type {ValueOf} from 'type-fest'; import {getPolicyCategoriesData} from '@libs/actions/Policy/Category'; import {getPolicyTagsData} from '@libs/actions/Policy/Tag'; import type {TransactionMergeParams} from '@libs/API/parameters'; -import {getCurrencyDecimals} from '@libs/CurrencyUtils'; +import {getCategoryDefaultTaxRate} from '@libs/CategoryUtils'; +import {convertToBackendAmount, getCurrencyDecimals} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {toLocaleDigit} from '@libs/LocaleDigitUtils'; @@ -77,7 +78,7 @@ Onyx.connect({ key: ONYXKEYS.SESSION, callback: (val) => { currentUserEmail = val?.email ?? ''; - currentUserAccountID = val?.accountID ?? -1; + currentUserAccountID = val?.accountID ?? CONST.DEFAULT_NUMBER_ID; }, }); @@ -189,6 +190,7 @@ function buildOptimisticTransaction( billable, reimbursable, attendees, + inserted: DateUtils.getDBTime(), }; } @@ -578,7 +580,7 @@ function getCategory(transaction: OnyxInputOrEntry): string { * Return the cardID from the transaction. */ function getCardID(transaction: Transaction): number { - return transaction?.cardID ?? -1; + return transaction?.cardID ?? CONST.DEFAULT_NUMBER_ID; } /** @@ -963,7 +965,7 @@ function getDefaultTaxCode(policy: OnyxEntry, transaction: OnyxEntry, transaction: OnyxEntry taxRate.code === (transaction?.taxCode ?? defaultTaxCode))?.modifiedName; } -function getTransaction(transactionID: string): OnyxEntry { +function getTransaction(transactionID: string | undefined): OnyxEntry { return allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; } @@ -1057,7 +1059,15 @@ function removeSettledAndApprovedTransactions(transactionIDs: string[]) { * 6. It returns the 'keep' and 'change' objects. */ -function compareDuplicateTransactionFields(reviewingTransactionID?: string, reportID?: string, selectedTransactionID?: string): {keep: Partial; change: FieldsToChange} { +function compareDuplicateTransactionFields( + reviewingTransactionID?: string | undefined, + reportID?: string | undefined, + selectedTransactionID?: string, +): {keep: Partial; change: FieldsToChange} { + if (!reviewingTransactionID || !reportID) { + return {change: {}, keep: {}}; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const keep: Record = {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1161,7 +1171,7 @@ function compareDuplicateTransactionFields(reviewingTransactionID?: string, repo } } else if (fieldName === 'category') { const differentValues = getDifferentValues(transactions, keys); - const policyCategories = getPolicyCategoriesData(report?.policyID ?? '-1'); + const policyCategories = report?.policyID ? getPolicyCategoriesData(report.policyID) : {}; const availableCategories = Object.values(policyCategories) .filter((category) => differentValues.includes(category.name) && category.enabled && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) .map((e) => e.name); @@ -1172,7 +1182,7 @@ function compareDuplicateTransactionFields(reviewingTransactionID?: string, repo keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]]; } } else if (fieldName === 'tag') { - const policyTags = getPolicyTagsData(report?.policyID ?? '-1'); + const policyTags = report?.policyID ? getPolicyTagsData(report?.policyID) : {}; const isMultiLevelTags = PolicyUtils.isMultiLevelTags(policyTags); if (isMultiLevelTags) { if (areAllFieldsEqualForKey || !policy?.areTagsEnabled) { @@ -1203,10 +1213,10 @@ function compareDuplicateTransactionFields(reviewingTransactionID?: string, repo return {keep, change}; } -function getTransactionID(threadReportID: string): string { +function getTransactionID(threadReportID: string): string | undefined { const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${threadReportID}`]; - const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? ''); - const IOUTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID ?? '-1' : '-1'; + const parentReportAction = ReportUtils.isThread(report) ? ReportActionsUtils.getReportAction(report.parentReportID, report.parentReportActionID) : undefined; + const IOUTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined; return IOUTransactionID; } @@ -1227,11 +1237,11 @@ function buildNewTransactionAfterReviewingDuplicates(reviewDuplicateTransaction: function buildTransactionsMergeParams(reviewDuplicates: OnyxEntry, originalTransaction: Partial): TransactionMergeParams { return { amount: -getAmount(originalTransaction as OnyxEntry, false), - reportID: originalTransaction?.reportID ?? '', - receiptID: originalTransaction?.receipt?.receiptID ?? 0, + reportID: originalTransaction?.reportID, + receiptID: originalTransaction?.receipt?.receiptID ?? CONST.DEFAULT_NUMBER_ID, currency: getCurrency(originalTransaction as OnyxEntry), created: getFormattedCreated(originalTransaction as OnyxEntry), - transactionID: reviewDuplicates?.transactionID ?? '', + transactionID: reviewDuplicates?.transactionID, transactionIDList: removeSettledAndApprovedTransactions(reviewDuplicates?.duplicates ?? []), billable: reviewDuplicates?.billable ?? false, reimbursable: reviewDuplicates?.reimbursable ?? false, @@ -1242,6 +1252,40 @@ function buildTransactionsMergeParams(reviewDuplicates: OnyxEntry, policy: OnyxEntry) { + const taxRules = policy?.rules?.expenseRules?.filter((rule) => rule.tax); + if (!taxRules || taxRules?.length === 0) { + return {categoryTaxCode: undefined, categoryTaxAmount: undefined}; + } + + const categoryTaxCode = getCategoryDefaultTaxRate(taxRules, category, policy?.taxRates?.defaultExternalID); + const categoryTaxPercentage = getTaxValue(policy, transaction, categoryTaxCode ?? ''); + let categoryTaxAmount; + + if (categoryTaxPercentage) { + categoryTaxAmount = convertToBackendAmount(calculateTaxAmount(categoryTaxPercentage, getAmount(transaction), getCurrency(transaction))); + } + + return {categoryTaxCode, categoryTaxAmount}; +} + +/** + * Return the sorted list transactions of an iou report + */ +function getAllSortedTransactions(iouReportID?: string): Array> { + return getAllReportTransactions(iouReportID).sort((transA, transB) => { + if (transA.created < transB.created) { + return -1; + } + + if (transA.created > transB.created) { + return 1; + } + + return (transA.inserted ?? '') < (transB.inserted ?? '') ? -1 : 1; + }); +} + export { buildOptimisticTransaction, calculateTaxAmount, @@ -1325,7 +1369,9 @@ export { getCardName, hasReceiptSource, shouldShowAttendees, + getAllSortedTransactions, getFormattedPostedDate, + getCategoryTaxCodeAndAmount, }; export type {TransactionChanges}; diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts index f2ce5113af81..2c774637b4a0 100644 --- a/src/libs/TripReservationUtils.ts +++ b/src/libs/TripReservationUtils.ts @@ -50,7 +50,7 @@ Onyx.connect({ }, }); -function getTripReservationIcon(reservationType: ReservationType): IconAsset { +function getTripReservationIcon(reservationType?: ReservationType): IconAsset { switch (reservationType) { case CONST.RESERVATION_TYPE.FLIGHT: return Expensicons.Plane; @@ -58,16 +58,27 @@ function getTripReservationIcon(reservationType: ReservationType): IconAsset { return Expensicons.Bed; case CONST.RESERVATION_TYPE.CAR: return Expensicons.CarWithKey; + case CONST.RESERVATION_TYPE.TRAIN: + return Expensicons.Train; default: return Expensicons.Luggage; } } -function getReservationsFromTripTransactions(transactions: Transaction[]): Reservation[] { +type ReservationData = {reservation: Reservation; transactionID: string; reportID: string; reservationIndex: number}; + +function getReservationsFromTripTransactions(transactions: Transaction[]): ReservationData[] { return transactions - .map((item) => item?.receipt?.reservationList ?? []) - .filter((item) => item.length > 0) - .flat(); + .flatMap( + (item) => + item?.receipt?.reservationList?.map((reservation, reservationIndex) => ({ + reservation, + transactionID: item.transactionID, + reportID: item.reportID, + reservationIndex, + })) ?? [], + ) + .sort((a, b) => new Date(a.reservation.start.date).getTime() - new Date(b.reservation.start.date).getTime()); } function getTripEReceiptIcon(transaction?: Transaction): IconAsset | undefined { @@ -115,3 +126,4 @@ function bookATrip(translate: LocaleContextProps['translate'], setCtaErrorMessag }); } export {getTripReservationIcon, getReservationsFromTripTransactions, getTripEReceiptIcon, bookATrip}; +export type {ReservationData}; diff --git a/src/libs/UnreadIndicatorUpdater/index.ts b/src/libs/UnreadIndicatorUpdater/index.ts index 4d38d410cbfd..1c85781806ac 100644 --- a/src/libs/UnreadIndicatorUpdater/index.ts +++ b/src/libs/UnreadIndicatorUpdater/index.ts @@ -40,7 +40,7 @@ function getUnreadReportsForUnreadIndicator(reports: OnyxCollection, cur * Furthermore, muted reports may or may not appear in the LHN depending on priority mode, * but they should not be considered in the unread indicator count. */ - notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN && + !ReportUtils.isHiddenForCurrentUser(notificationPreference) && notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE ); }); diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts index ee0a70d3b7fe..60dff1c97247 100644 --- a/src/libs/WorkspacesSettingsUtils.ts +++ b/src/libs/WorkspacesSettingsUtils.ts @@ -40,6 +40,15 @@ Onyx.connect({ }, }); +let reportsCollection: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + reportsCollection = value; + }, +}); + let allTransactionViolations: NonNullable> = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, @@ -74,7 +83,7 @@ const getBrickRoadForPolicy = (report: Report, altReportActions?: OnyxCollection } if (oneTransactionThreadReportID && !doesReportContainErrors) { - const oneTransactionThreadReport = ReportUtils.getReport(oneTransactionThreadReportID); + const oneTransactionThreadReport = reportsCollection?.[`${ONYXKEYS.COLLECTION.REPORT}${oneTransactionThreadReportID}`]; if (ReportUtils.shouldDisplayViolationsRBRInLHN(oneTransactionThreadReport, allTransactionViolations)) { doesReportContainErrors = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index 61ce04655ae5..931f9e226995 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -89,6 +89,14 @@ Onyx.connect({ }, }); +let preservedUserSession: OnyxTypes.Session | undefined; +Onyx.connect({ + key: ONYXKEYS.PRESERVED_USER_SESSION, + callback: (value) => { + preservedUserSession = value; + }, +}); + const KEYS_TO_PRESERVE: OnyxKey[] = [ ONYXKEYS.ACCOUNT, ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, @@ -102,6 +110,7 @@ const KEYS_TO_PRESERVE: OnyxKey[] = [ ONYXKEYS.PREFERRED_THEME, ONYXKEYS.NVP_PREFERRED_LOCALE, ONYXKEYS.CREDENTIALS, + ONYXKEYS.PRESERVED_USER_SESSION, ]; Onyx.connect({ @@ -524,6 +533,10 @@ function setIsUsingImportedState(usingImportedState: boolean) { Onyx.set(ONYXKEYS.IS_USING_IMPORTED_STATE, usingImportedState); } +function setPreservedUserSession(session: OnyxTypes.Session) { + Onyx.set(ONYXKEYS.PRESERVED_USER_SESSION, session); +} + function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) { // The value of isUsingImportedState will be lost once Onyx is cleared, so we need to store it const isStateImported = isUsingImportedState; @@ -538,6 +551,11 @@ function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) { Navigation.navigate(ROUTES.HOME); } + if (preservedUserSession) { + Onyx.set(ONYXKEYS.SESSION, preservedUserSession); + Onyx.set(ONYXKEYS.PRESERVED_USER_SESSION, null); + } + // Requests in a sequential queue should be called even if the Onyx state is reset, so we do not lose any pending data. // However, the OpenApp request must be called before any other request in a queue to ensure data consistency. // To do that, sequential queue is cleared together with other keys, and then it's restored once the OpenApp request is resolved. @@ -574,5 +592,6 @@ export { updateLastRoute, setIsUsingImportedState, clearOnyxAndResetApp, + setPreservedUserSession, KEYS_TO_PRESERVE, }; diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 1c60d49e9170..c8dce813c895 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -392,8 +392,8 @@ function clearIssueNewCardFlow() { }); } -function clearIssueNewCardError(issueNewCard: IssueNewCardFlowData) { - Onyx.set(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {...issueNewCard, errors: null}); +function clearIssueNewCardError() { + Onyx.merge(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {errors: null}); } function updateExpensifyCardLimit(workspaceAccountID: number, cardID: number, newLimit: number, newAvailableSpend: number, oldLimit?: number, oldAvailableSpend?: number) { diff --git a/src/libs/actions/Debug.ts b/src/libs/actions/Debug.ts index 4c3479ee9741..6fbf18505074 100644 --- a/src/libs/actions/Debug.ts +++ b/src/libs/actions/Debug.ts @@ -11,7 +11,12 @@ function setDebugData(on Onyx.set(onyxKey, onyxValue); } +function mergeDebugData(onyxKey: TKey, onyxValue: OnyxMergeInput) { + Onyx.merge(onyxKey, onyxValue); +} + export default { resetDebugDetailsDraftForm, setDebugData, + mergeDebugData, }; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 696853f49fd7..c8d6fb36f60d 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -590,8 +590,19 @@ function setMoneyRequestPendingFields(transactionID: string, pendingFields: Onyx Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {pendingFields}); } -function setMoneyRequestCategory(transactionID: string, category: string) { +function setMoneyRequestCategory(transactionID: string, category: string, policyID?: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {category}); + if (!policyID) { + setMoneyRequestTaxRate(transactionID, ''); + setMoneyRequestTaxAmount(transactionID, null); + return; + } + const transaction = allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`]; + const {categoryTaxCode, categoryTaxAmount} = TransactionUtils.getCategoryTaxCodeAndAmount(category, transaction, PolicyUtils.getPolicy(policyID)); + if (categoryTaxCode && categoryTaxAmount !== undefined) { + setMoneyRequestTaxRate(transactionID, categoryTaxCode); + setMoneyRequestTaxAmount(transactionID, categoryTaxAmount); + } } function setMoneyRequestTag(transactionID: string, tag: string) { @@ -1377,7 +1388,6 @@ function buildOnyxDataForInvoice( key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateInvoiceFailureMessage'), - pendingAction: null, pendingFields: clearedPendingFields, }, }, @@ -2097,7 +2107,14 @@ function getSendInvoiceInformation( } // STEP 2: Create a new optimistic invoice report. - const optimisticInvoiceReport = ReportUtils.buildOptimisticInvoiceReport(chatReport.reportID, senderWorkspaceID, receiverAccountID, receiver.displayName ?? '', amount, currency); + const optimisticInvoiceReport = ReportUtils.buildOptimisticInvoiceReport( + chatReport.reportID, + senderWorkspaceID, + receiverAccountID, + receiver.displayName ?? (receiverParticipant as Participant)?.login ?? '', + amount, + currency, + ); // STEP 3: Build optimistic receipt and transaction const receiptObject: Receipt = {}; @@ -3426,9 +3443,17 @@ function updateMoneyRequestCategory( policyTagList: OnyxEntry, policyCategories: OnyxEntry, ) { + const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const {categoryTaxCode, categoryTaxAmount} = TransactionUtils.getCategoryTaxCodeAndAmount(category, transaction, policy); const transactionChanges: TransactionChanges = { category, + ...(categoryTaxCode && + categoryTaxAmount !== undefined && { + taxCode: categoryTaxCode, + taxAmount: categoryTaxAmount, + }), }; + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); } @@ -5353,17 +5378,26 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA Report.notifyNewAction(chatReportID, sessionAccountID); } -function setDraftSplitTransaction(transactionID: string, transactionChanges: TransactionChanges = {}) { +function setDraftSplitTransaction(transactionID: string, transactionChanges: TransactionChanges = {}, policy?: OnyxEntry) { + const newTransactionChanges = {...transactionChanges}; let draftSplitTransaction = allDraftSplitTransactions[`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`]; if (!draftSplitTransaction) { draftSplitTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; } + if (transactionChanges.category) { + const {categoryTaxCode, categoryTaxAmount} = TransactionUtils.getCategoryTaxCodeAndAmount(transactionChanges.category, draftSplitTransaction, policy); + if (categoryTaxCode && categoryTaxAmount !== undefined) { + newTransactionChanges.taxCode = categoryTaxCode; + newTransactionChanges.taxAmount = categoryTaxAmount; + } + } + const updatedTransaction = draftSplitTransaction ? TransactionUtils.getUpdatedTransaction({ transaction: draftSplitTransaction, - transactionChanges, + transactionChanges: newTransactionChanges, isFromExpenseReport: false, shouldUpdateReceiptState: false, }) @@ -6883,7 +6917,6 @@ function getPayMoneyRequestParams( const optimisticChatReport = { ...chatReport, lastReadTime: DateUtils.getDBTime(), - lastVisibleActionCreated: optimisticIOUReportAction.created, hasOutstandingChildRequest: false, iouReportID: null, lastMessageText: ReportActionsUtils.getReportActionText(optimisticIOUReportAction), @@ -6919,6 +6952,7 @@ function getPayMoneyRequestParams( ...iouReport, lastMessageText: ReportActionsUtils.getReportActionText(optimisticIOUReportAction), lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticIOUReportAction), + lastVisibleActionCreated: optimisticIOUReportAction.created, hasOutstandingChildRequest: false, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, pendingFields: { @@ -8571,7 +8605,7 @@ function mergeDuplicates(params: TransactionMergeParams) { }, }; - const iouActionsToDelete = getIOUActionForTransactions(params.transactionIDList, params.reportID); + const iouActionsToDelete = params.reportID ? getIOUActionForTransactions(params.transactionIDList, params.reportID) : []; const deletedTime = DateUtils.getDBTime(); const expenseReportActionsOptimisticData: OnyxUpdate = { @@ -8638,6 +8672,10 @@ function updateLastLocationPermissionPrompt() { /** Instead of merging the duplicates, it updates the transaction we want to keep and puts the others on hold without deleting them */ function resolveDuplicates(params: TransactionMergeParams) { + if (!params.transactionID) { + return; + } + const originalSelectedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`]; const optimisticTransactionData: OnyxUpdate = { @@ -8685,7 +8723,7 @@ function resolveDuplicates(params: TransactionMergeParams) { }; }); - const iouActionList = getIOUActionForTransactions(params.transactionIDList, params.reportID); + const iouActionList = params.reportID ? getIOUActionForTransactions(params.transactionIDList, params.reportID) : []; const transactionThreadReportIDList = iouActionList.map((action) => action?.childReportID); const orderedTransactionIDList = iouActionList.map((action) => { const message = ReportActionsUtils.getOriginalMessage(action); @@ -8737,7 +8775,7 @@ function resolveDuplicates(params: TransactionMergeParams) { }); }); - const transactionThreadReportID = getIOUActionForTransactions([params.transactionID], params.reportID).at(0)?.childReportID; + const transactionThreadReportID = params.reportID ? getIOUActionForTransactions([params.transactionID], params.reportID).at(0)?.childReportID : undefined; const optimisticReportAction = ReportUtils.buildOptimisticDismissedViolationReportAction({ reason: 'manual', violationName: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, @@ -8768,6 +8806,7 @@ function resolveDuplicates(params: TransactionMergeParams) { const parameters: ResolveDuplicatesParams = { ...otherParams, + transactionID: params.transactionID, reportActionIDList, transactionIDList: orderedTransactionIDList, dismissedViolationReportActionID: optimisticReportAction.reportActionID, diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index 786e22ef91d9..2a4cf0cde1a7 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -129,34 +129,36 @@ function enablePolicyDistanceRates(policyID: string, enabled: boolean) { if (!enabled) { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; const customUnit = getDistanceRateCustomUnit(policy); - const customUnitID = customUnit?.customUnitID ?? ''; + if (customUnit) { + const customUnitID = customUnit.customUnitID; - const rateEntries = Object.entries(customUnit?.rates ?? {}); - // find the rate to be enabled after disabling the distance rate feature - const rateEntryToBeEnabled = rateEntries.at(0); + const rateEntries = Object.entries(customUnit.rates ?? {}); + // find the rate to be enabled after disabling the distance rate feature + const rateEntryToBeEnabled = rateEntries.at(0); - onyxData.optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [customUnitID]: { - rates: Object.fromEntries( - rateEntries.map((rateEntry) => { - const [rateID, rate] = rateEntry; - return [ - rateID, - { - ...rate, - enabled: rateID === rateEntryToBeEnabled?.at(0), - }, - ]; - }), - ), + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnitID]: { + rates: Object.fromEntries( + rateEntries.map((rateEntry) => { + const [rateID, rate] = rateEntry; + return [ + rateID, + { + ...rate, + enabled: rateID === rateEntryToBeEnabled?.at(0), + }, + ]; + }), + ), + }, }, }, - }, - }); + }); + } } const parameters: EnablePolicyDistanceRatesParams = {policyID, enabled}; @@ -177,7 +179,7 @@ function createPolicyDistanceRate(policyID: string, customUnitID: string, custom customUnits: { [customUnitID]: { rates: { - [customUnitRate.customUnitRateID ?? '']: { + [customUnitRate.customUnitRateID]: { ...customUnitRate, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, @@ -196,7 +198,7 @@ function createPolicyDistanceRate(policyID: string, customUnitID: string, custom customUnits: { [customUnitID]: { rates: { - [customUnitRate.customUnitRateID ?? '']: { + [customUnitRate.customUnitRateID]: { pendingAction: null, }, }, @@ -214,7 +216,7 @@ function createPolicyDistanceRate(policyID: string, customUnitID: string, custom customUnits: { [customUnitID]: { rates: { - [customUnitRate.customUnitRateID ?? '']: { + [customUnitRate.customUnitRateID]: { errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), }, }, @@ -339,9 +341,9 @@ function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomU function updatePolicyDistanceRateValue(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) { const currentRates = customUnit.rates; - const optimisticRates: Record = {}; - const successRates: Record = {}; - const failureRates: Record = {}; + const optimisticRates: Record> = {}; + const successRates: Record> = {}; + const failureRates: Record> = {}; const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID); for (const rateID of Object.keys(customUnit.rates)) { @@ -410,9 +412,9 @@ function updatePolicyDistanceRateValue(policyID: string, customUnit: CustomUnit, function setPolicyDistanceRatesEnabled(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) { const currentRates = customUnit.rates; - const optimisticRates: Record = {}; - const successRates: Record = {}; - const failureRates: Record = {}; + const optimisticRates: Record> = {}; + const successRates: Record> = {}; + const failureRates: Record> = {}; const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID); for (const rateID of Object.keys(currentRates)) { @@ -559,9 +561,9 @@ function deletePolicyDistanceRates(policyID: string, customUnit: CustomUnit, rat function updateDistanceTaxClaimableValue(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) { const currentRates = customUnit.rates; - const optimisticRates: Record = {}; - const successRates: Record = {}; - const failureRates: Record = {}; + const optimisticRates: Record> = {}; + const successRates: Record> = {}; + const failureRates: Record> = {}; const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID); for (const rateID of Object.keys(customUnit.rates)) { @@ -630,9 +632,9 @@ function updateDistanceTaxClaimableValue(policyID: string, customUnit: CustomUni function updateDistanceTaxRate(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) { const currentRates = customUnit.rates; - const optimisticRates: Record = {}; - const successRates: Record = {}; - const failureRates: Record = {}; + const optimisticRates: Record> = {}; + const successRates: Record> = {}; + const failureRates: Record> = {}; const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID); for (const rateID of Object.keys(customUnit.rates)) { diff --git a/src/libs/actions/Policy/PerDiem.ts b/src/libs/actions/Policy/PerDiem.ts index 81898dfb34e0..323045e49821 100644 --- a/src/libs/actions/Policy/PerDiem.ts +++ b/src/libs/actions/Policy/PerDiem.ts @@ -1,3 +1,4 @@ +import lodashDeepClone from 'lodash/cloneDeep'; import type {NullishDeep, OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; @@ -13,9 +14,10 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report} from '@src/types/onyx'; -import type {ErrorFields} from '@src/types/onyx/OnyxCommon'; -import type {Rate} from '@src/types/onyx/Policy'; +import type {ErrorFields, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {CustomUnit, Rate} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; const allPolicies: OnyxCollection = {}; Onyx.connect({ @@ -50,6 +52,16 @@ Onyx.connect({ }, }); +type SubRateData = { + pendingAction?: PendingAction; + destination: string; + subRateName: string; + rate: number; + currency: string; + rateID: string; + subRateID: string; +}; + /** * Returns a client generated 13 character hexadecimal value for a custom unit ID */ @@ -193,4 +205,208 @@ function clearPolicyPerDiemRatesErrorFields(policyID: string, customUnitID: stri }); } -export {generateCustomUnitID, enablePerDiem, openPolicyPerDiemPage, importPerDiemRates, downloadPerDiemCSV, clearPolicyPerDiemRatesErrorFields}; +type DeletePerDiemCustomUnitOnyxType = Omit & { + rates: Record | null>; +}; + +function prepareNewCustomUnit(customUnit: CustomUnit, subRatesToBeDeleted: SubRateData[]) { + const mappedDeletedSubRatesToRate = subRatesToBeDeleted.reduce((acc, subRate) => { + if (subRate.rateID in acc) { + acc[subRate.rateID].push(subRate); + } else { + acc[subRate.rateID] = [subRate]; + } + return acc; + }, {} as Record); + + // Copy the custom unit and remove the sub rates that are to be deleted + const newCustomUnit: CustomUnit = lodashDeepClone(customUnit); + const customUnitOnyxUpdate: DeletePerDiemCustomUnitOnyxType = lodashDeepClone(customUnit); + for (const rateID in mappedDeletedSubRatesToRate) { + if (!(rateID in newCustomUnit.rates)) { + // eslint-disable-next-line no-continue + continue; + } + const subRates = mappedDeletedSubRatesToRate[rateID]; + if (subRates.length === newCustomUnit.rates[rateID].subRates?.length) { + delete newCustomUnit.rates[rateID]; + customUnitOnyxUpdate.rates[rateID] = null; + } else { + const newSubRates = newCustomUnit.rates[rateID].subRates?.filter((subRate) => !subRates.some((subRateToBeDeleted) => subRateToBeDeleted.subRateID === subRate.id)); + newCustomUnit.rates[rateID].subRates = newSubRates; + customUnitOnyxUpdate.rates[rateID] = {...customUnitOnyxUpdate.rates[rateID], subRates: newSubRates}; + } + } + return {newCustomUnit, customUnitOnyxUpdate}; +} + +function deleteWorkspacePerDiemRates(policyID: string, customUnit: CustomUnit | undefined, subRatesToBeDeleted: SubRateData[]) { + if (!policyID || isEmptyObject(customUnit) || !subRatesToBeDeleted.length) { + return; + } + const {newCustomUnit, customUnitOnyxUpdate} = prepareNewCustomUnit(customUnit, subRatesToBeDeleted); + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: customUnitOnyxUpdate, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + customUnit: JSON.stringify(newCustomUnit), + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); +} + +function editPerDiemRateDestination(policyID: string, rateID: string, customUnit: CustomUnit | undefined, newDestination: string) { + if (!policyID || !rateID || isEmptyObject(customUnit) || !newDestination) { + return; + } + + const newCustomUnit: CustomUnit = lodashDeepClone(customUnit); + newCustomUnit.rates[rateID].name = newDestination; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: newCustomUnit, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + customUnit: JSON.stringify(newCustomUnit), + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); +} + +function editPerDiemRateSubrate(policyID: string, rateID: string, subRateID: string, customUnit: CustomUnit | undefined, newSubrate: string) { + if (!policyID || !rateID || isEmptyObject(customUnit) || !newSubrate) { + return; + } + + const newCustomUnit: CustomUnit = lodashDeepClone(customUnit); + newCustomUnit.rates[rateID].subRates = newCustomUnit.rates[rateID].subRates?.map((subRate) => { + if (subRate.id === subRateID) { + return {...subRate, name: newSubrate}; + } + return subRate; + }); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: newCustomUnit, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + customUnit: JSON.stringify(newCustomUnit), + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); +} + +function editPerDiemRateAmount(policyID: string, rateID: string, subRateID: string, customUnit: CustomUnit | undefined, newAmount: number) { + if (!policyID || !rateID || isEmptyObject(customUnit) || !newAmount) { + return; + } + + const newCustomUnit: CustomUnit = lodashDeepClone(customUnit); + newCustomUnit.rates[rateID].subRates = newCustomUnit.rates[rateID].subRates?.map((subRate) => { + if (subRate.id === subRateID) { + return {...subRate, rate: newAmount}; + } + return subRate; + }); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: newCustomUnit, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + customUnit: JSON.stringify(newCustomUnit), + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); +} + +function editPerDiemRateCurrency(policyID: string, rateID: string, customUnit: CustomUnit | undefined, newCurrency: string) { + if (!policyID || !rateID || isEmptyObject(customUnit) || !newCurrency) { + return; + } + + const newCustomUnit: CustomUnit = lodashDeepClone(customUnit); + newCustomUnit.rates[rateID].currency = newCurrency; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: newCustomUnit, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + customUnit: JSON.stringify(newCustomUnit), + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); +} + +export { + generateCustomUnitID, + enablePerDiem, + openPolicyPerDiemPage, + importPerDiemRates, + downloadPerDiemCSV, + clearPolicyPerDiemRatesErrorFields, + deleteWorkspacePerDiemRates, + editPerDiemRateDestination, + editPerDiemRateSubrate, + editPerDiemRateAmount, + editPerDiemRateCurrency, +}; diff --git a/src/libs/actions/Policy/Plan.ts b/src/libs/actions/Policy/Plan.ts new file mode 100644 index 000000000000..a30e8bb67704 --- /dev/null +++ b/src/libs/actions/Policy/Plan.ts @@ -0,0 +1,52 @@ +import type {OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import type {OpenWorkspacePlanPageParams} from '@libs/API/parameters'; +import {READ_COMMANDS} from '@libs/API/types'; +import Log from '@libs/Log'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function OpenWorkspacePlanPage(policyID: string) { + if (!policyID) { + Log.warn('OpenWorkspacePlanPage invalid params', {policyID}); + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isLoading: true, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isLoading: false, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isLoading: false, + }, + }, + ]; + + const params: OpenWorkspacePlanPageParams = { + policyID, + }; + + API.read(READ_COMMANDS.OPEN_WORKSPACE_PLAN_PAGE, params, {optimisticData, successData, failureData}); +} + +export default OpenWorkspacePlanPage; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index f81539d1e921..f855ea477856 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -196,7 +196,7 @@ Onyx.connect({ key: ONYXKEYS.SESSION, callback: (val) => { sessionEmail = val?.email ?? ''; - sessionAccountID = val?.accountID ?? -1; + sessionAccountID = val?.accountID ?? CONST.DEFAULT_NUMBER_ID; }, }); @@ -258,8 +258,8 @@ function hasInvoicingDetails(policy: OnyxEntry): boolean { * Returns a primary invoice workspace for the user */ function getInvoicePrimaryWorkspace(currentUserLogin: string | undefined): Policy | undefined { - if (PolicyUtils.canSendInvoiceFromWorkspace(activePolicyID ?? '-1')) { - return allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID ?? '-1'}`]; + if (PolicyUtils.canSendInvoiceFromWorkspace(activePolicyID)) { + return allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`]; } const activeAdminWorkspaces = PolicyUtils.getActiveAdminWorkspaces(allPolicies, currentUserLogin); return activeAdminWorkspaces.find((policy) => PolicyUtils.canSendInvoiceFromWorkspace(policy.id)); @@ -1700,6 +1700,7 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName outputCurrency: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, address: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, description: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + type: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, }, }, @@ -1772,6 +1773,7 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName outputCurrency: null, address: null, description: null, + type: null, }, }, }, @@ -1862,7 +1864,7 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName failureData.push({ onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, - value: activePolicyID ?? '', + value: activePolicyID, }); } @@ -2234,7 +2236,7 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF expenseCreatedReportActionID: workspaceChatCreatedReportActionID, } = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName); - if (!employeeAccountID) { + if (!employeeAccountID || !oldPersonalPolicyID) { return; } @@ -2512,17 +2514,18 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF const parentReportActionID = iouReport.parentReportActionID; const reportPreview = iouReport?.parentReportID && parentReportActionID ? parentReport?.[parentReportActionID] : undefined; - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, - value: {[reportPreview?.reportActionID ?? '-1']: null}, - }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, - value: {[reportPreview?.reportActionID ?? '-1']: reportPreview}, - }); - + if (reportPreview?.reportActionID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: {[reportPreview?.reportActionID]: null}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: {[reportPreview?.reportActionID]: reportPreview}, + }); + } // To optimistically remove the GBR from the DM we need to update the hasOutstandingChildRequest param to false optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -2559,14 +2562,16 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF }); } - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, - value: {[reportPreview?.reportActionID ?? '-1']: null}, - }); + if (reportPreview?.reportActionID) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, + value: {[reportPreview?.reportActionID]: null}, + }); + } // Create the MOVED report action and add it to the DM chat which indicates to the user where the report has been moved - const movedReportAction = ReportUtils.buildOptimisticMovedReportAction(oldPersonalPolicyID ?? '-1', policyID, memberData.workspaceChatReportID, iouReportID, workspaceName); + const movedReportAction = ReportUtils.buildOptimisticMovedReportAction(oldPersonalPolicyID, policyID, memberData.workspaceChatReportID, iouReportID, workspaceName); optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, @@ -3385,7 +3390,7 @@ function setForeignCurrencyDefault(policyID: string, taxCode: string) { API.write(WRITE_COMMANDS.SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT, parameters, onyxData); } -function upgradeToCorporate(policyID: string, featureName: string) { +function upgradeToCorporate(policyID: string, featureName?: string) { const policy = getPolicy(policyID); const optimisticData: OnyxUpdate[] = [ { @@ -3437,7 +3442,7 @@ function upgradeToCorporate(policyID: string, featureName: string) { }, ]; - const parameters: UpgradeToCorporateParams = {policyID, featureName}; + const parameters: UpgradeToCorporateParams = {policyID, ...(featureName ? {featureName} : {})}; API.write(WRITE_COMMANDS.UPGRADE_TO_CORPORATE, parameters, {optimisticData, successData, failureData}); } diff --git a/src/libs/actions/QuickActionNavigation.ts b/src/libs/actions/QuickActionNavigation.ts new file mode 100644 index 000000000000..b89d1a981417 --- /dev/null +++ b/src/libs/actions/QuickActionNavigation.ts @@ -0,0 +1,54 @@ +import {generateReportID} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import type {QuickActionName} from '@src/types/onyx/QuickAction'; +import type QuickAction from '@src/types/onyx/QuickAction'; +import * as IOU from './IOU'; +import * as Task from './Task'; + +function getQuickActionRequestType(action: QuickActionName | undefined): IOU.IOURequestType | undefined { + if (!action) { + return; + } + + let requestType; + if ([CONST.QUICK_ACTIONS.REQUEST_MANUAL, CONST.QUICK_ACTIONS.SPLIT_MANUAL, CONST.QUICK_ACTIONS.TRACK_MANUAL].some((a) => a === action)) { + requestType = CONST.IOU.REQUEST_TYPE.MANUAL; + } else if ([CONST.QUICK_ACTIONS.REQUEST_SCAN, CONST.QUICK_ACTIONS.SPLIT_SCAN, CONST.QUICK_ACTIONS.TRACK_SCAN].some((a) => a === action)) { + requestType = CONST.IOU.REQUEST_TYPE.SCAN; + } else if ([CONST.QUICK_ACTIONS.REQUEST_DISTANCE, CONST.QUICK_ACTIONS.SPLIT_DISTANCE, CONST.QUICK_ACTIONS.TRACK_DISTANCE].some((a) => a === action)) { + requestType = CONST.IOU.REQUEST_TYPE.DISTANCE; + } + + return requestType; +} + +function navigateToQuickAction(isValidReport: boolean, quickActionReportID: string, quickAction: QuickAction, selectOption: (onSelected: () => void, shouldRestrictAction: boolean) => void) { + const reportID = isValidReport ? quickActionReportID : generateReportID(); + const requestType = getQuickActionRequestType(quickAction?.action); + + switch (quickAction?.action) { + case CONST.QUICK_ACTIONS.REQUEST_MANUAL: + case CONST.QUICK_ACTIONS.REQUEST_SCAN: + case CONST.QUICK_ACTIONS.REQUEST_DISTANCE: + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, reportID, requestType, true), true); + return; + case CONST.QUICK_ACTIONS.SPLIT_MANUAL: + case CONST.QUICK_ACTIONS.SPLIT_SCAN: + case CONST.QUICK_ACTIONS.SPLIT_DISTANCE: + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, reportID, requestType, true), true); + return; + case CONST.QUICK_ACTIONS.SEND_MONEY: + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.PAY, reportID, undefined, true), false); + return; + case CONST.QUICK_ACTIONS.ASSIGN_TASK: + selectOption(() => Task.startOutCreateTaskQuickAction(isValidReport ? reportID : '', quickAction.targetAccountID ?? CONST.DEFAULT_NUMBER_ID), false); + break; + case CONST.QUICK_ACTIONS.TRACK_MANUAL: + case CONST.QUICK_ACTIONS.TRACK_SCAN: + case CONST.QUICK_ACTIONS.TRACK_DISTANCE: + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK, reportID, requestType, true), false); + break; + default: + } +} +export {navigateToQuickAction, getQuickActionRequestType}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 7baf66adc5c5..ab924906352e 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -545,9 +545,8 @@ function addActions(reportID: string, text = '', file?: FileObject) { }; const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - const shouldUpdateNotificationPrefernece = !isEmptyObject(report) && ReportUtils.getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; - - if (shouldUpdateNotificationPrefernece) { + const shouldUpdateNotificationPreference = !isEmptyObject(report) && ReportUtils.isHiddenForCurrentUser(report); + if (shouldUpdateNotificationPreference) { optimisticReport.participants = { [currentUserAccountID]: {notificationPreference: ReportUtils.getDefaultNotificationPreferenceForReport(report)}, }; @@ -965,7 +964,7 @@ function openReport( value: {[optimisticCreatedAction.reportActionID]: optimisticCreatedAction}, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, value: { isOptimisticReport: true, @@ -1209,7 +1208,11 @@ function navigateToAndOpenChildReport(childReportID = '-1', parentReportAction: * Gets the older actions that have not been read yet. * Normally happens when you scroll up on a chat, and the actions have not been read yet. */ -function getOlderActions(reportID: string, reportActionID: string) { +function getOlderActions(reportID: string | undefined, reportActionID: string | undefined) { + if (!reportID || !reportActionID) { + return; + } + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1263,7 +1266,11 @@ function getOlderActions(reportID: string, reportActionID: string) { * Gets the newer actions that have not been read yet. * Normally happens when you are not located at the bottom of the list and scroll down on a chat. */ -function getNewerActions(reportID: string, reportActionID: string) { +function getNewerActions(reportID: string | undefined, reportActionID: string | undefined) { + if (!reportID || !reportActionID) { + return; + } + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1932,7 +1939,7 @@ function toggleSubscribeToChildReport(childReportID = '-1', parentReportAction: if (childReportID !== '-1') { openReport(childReportID); const parentReportActionID = parentReportAction?.reportActionID ?? '-1'; - if (!prevNotificationPreference || prevNotificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + if (!prevNotificationPreference || ReportUtils.isHiddenForCurrentUser(prevNotificationPreference)) { updateNotificationPreference(childReportID, prevNotificationPreference, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, parentReportID, parentReportActionID); } else { updateNotificationPreference(childReportID, prevNotificationPreference, CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, parentReportID, parentReportActionID); @@ -1957,8 +1964,9 @@ function toggleSubscribeToChildReport(childReportID = '-1', parentReportAction: const participantLogins = PersonalDetailsUtils.getLoginsByAccountIDs(participantAccountIDs); openReport(newChat.reportID, '', participantLogins, newChat, parentReportAction.reportActionID); - const notificationPreference = - prevNotificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN ? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS : CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const notificationPreference = ReportUtils.isHiddenForCurrentUser(prevNotificationPreference) + ? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS + : CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; updateNotificationPreference(newChat.reportID, prevNotificationPreference, notificationPreference, parentReportID, parentReportAction?.reportActionID); } } @@ -3062,7 +3070,12 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, - value: {[report.parentReportActionID]: {childReportNotificationPreference: ReportUtils.getReportNotificationPreference(report, false)}}, + value: { + [report.parentReportActionID]: { + childReportNotificationPreference: + report?.participants?.[currentUserAccountID ?? -1]?.notificationPreference ?? ReportUtils.getDefaultNotificationPreferenceForReport(report), + }, + }, }); } diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 8ef5802b80dc..b5c76a6009ef 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -102,7 +102,7 @@ function getPayActionCallback(hash: number, item: TransactionListItemType | Repo goToItem(); } -function getOnyxLoadingData(hash: number): {optimisticData: OnyxUpdate[]; finallyData: OnyxUpdate[]} { +function getOnyxLoadingData(hash: number, queryJSON?: SearchQueryJSON): {optimisticData: OnyxUpdate[]; finallyData: OnyxUpdate[]; failureData: OnyxUpdate[]} { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -127,7 +127,21 @@ function getOnyxLoadingData(hash: number): {optimisticData: OnyxUpdate[]; finall }, ]; - return {optimisticData, finallyData}; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, + value: { + data: [], + search: { + status: queryJSON?.status, + type: queryJSON?.type, + }, + }, + }, + ]; + + return {optimisticData, finallyData, failureData}; } function saveSearch({queryJSON, newName}: {queryJSON: SearchQueryJSON; newName?: string}) { @@ -209,7 +223,7 @@ function deleteSavedSearch(hash: number) { } function search({queryJSON, offset}: {queryJSON: SearchQueryJSON; offset?: number}) { - const {optimisticData, finallyData} = getOnyxLoadingData(queryJSON.hash); + const {optimisticData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON); const {flatFilters, ...queryJSONWithoutFlatFilters} = queryJSON; const queryWithOffset = { ...queryJSONWithoutFlatFilters, @@ -217,7 +231,7 @@ function search({queryJSON, offset}: {queryJSON: SearchQueryJSON; offset?: numbe }; const jsonQuery = JSON.stringify(queryWithOffset); - API.write(WRITE_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, finallyData}); + API.write(WRITE_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, finallyData, failureData}); } /** diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index df8c0474fbaa..bd68d15caadc 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -252,7 +252,7 @@ function createTaskAndNavigate( }, ); - const shouldUpdateNotificationPreference = !isEmptyObject(parentReport) && ReportUtils.getReportNotificationPreference(parentReport) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const shouldUpdateNotificationPreference = !isEmptyObject(parentReport) && ReportUtils.isHiddenForCurrentUser(parentReport); if (shouldUpdateNotificationPreference) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index 5d610e93bee7..c743e18b23fb 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -290,7 +290,7 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { .sort((a, b) => a.localeCompare(b)) .at(0); const distanceRateCustomUnit = PolicyUtils.getDistanceRateCustomUnit(policy); - const customUnitID = distanceRateCustomUnit?.customUnitID ?? '-1'; + const customUnitID = distanceRateCustomUnit?.customUnitID; const ratesToUpdate = Object.values(distanceRateCustomUnit?.rates ?? {}).filter( (rate) => !!rate.attributes?.taxRateExternalID && taxesToDelete.includes(rate.attributes?.taxRateExternalID), ); @@ -303,11 +303,11 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { const isForeignTaxRemoved = foreignTaxDefault && taxesToDelete.includes(foreignTaxDefault); const optimisticRates: Record> = {}; - const successRates: Record = {}; - const failureRates: Record = {}; + const successRates: Record> = {}; + const failureRates: Record> = {}; ratesToUpdate.forEach((rate) => { - const rateID = rate.customUnitRateID ?? ''; + const rateID = rate.customUnitRateID; optimisticRates[rateID] = { attributes: { taxRateExternalID: null, @@ -343,11 +343,12 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { return acc; }, {}), }, - customUnits: distanceRateCustomUnit && { - [customUnitID]: { - rates: optimisticRates, + customUnits: distanceRateCustomUnit && + customUnitID && { + [customUnitID]: { + rates: optimisticRates, + }, }, - }, }, }, ], @@ -363,11 +364,12 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { return acc; }, {}), }, - customUnits: distanceRateCustomUnit && { - [customUnitID]: { - rates: successRates, + customUnits: distanceRateCustomUnit && + customUnitID && { + [customUnitID]: { + rates: successRates, + }, }, - }, }, }, ], @@ -387,11 +389,12 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { return acc; }, {}), }, - customUnits: distanceRateCustomUnit && { - [customUnitID]: { - rates: failureRates, + customUnits: distanceRateCustomUnit && + customUnitID && { + [customUnitID]: { + rates: failureRates, + }, }, - }, }, }, ], @@ -552,7 +555,7 @@ function setPolicyTaxCode(policyID: string, oldTaxCode: string, newTaxCode: stri }; } return rates; - }, {} as Record), + }, {} as Record>), }, }; const oldDefaultExternalID = policy?.taxRates?.defaultExternalID; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 8f8c416ceeb3..fd9d5f1820e6 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -32,6 +32,7 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as Pusher from '@libs/Pusher/pusher'; import PusherUtils from '@libs/PusherUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import playSoundExcludingMobile from '@libs/Sound/playSoundExcludingMobile'; import Visibility from '@libs/Visibility'; @@ -795,9 +796,7 @@ const isChannelMuted = (reportId: string) => Onyx.disconnect(connection); const notificationPreference = report?.participants?.[currentUserAccountID]?.notificationPreference; - resolve( - !notificationPreference || notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE || notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, - ); + resolve(!notificationPreference || notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE || ReportUtils.isHiddenForCurrentUser(notificationPreference)); }, }); }); diff --git a/src/libs/shouldFetchReport.ts b/src/libs/shouldFetchReport.ts index 3a29e95ecfb1..ae0384bf728d 100644 --- a/src/libs/shouldFetchReport.ts +++ b/src/libs/shouldFetchReport.ts @@ -1,9 +1,8 @@ import type {OnyxEntry} from 'react-native-onyx'; import type Report from '@src/types/onyx/Report'; -import * as ReportUtils from './ReportUtils'; +import type ReportMetadata from '@src/types/onyx/ReportMetadata'; -export default function shouldFetchReport(report: OnyxEntry) { - const reportMetadata = ReportUtils.getReportMetadata(report?.reportID); +export default function shouldFetchReport(report: OnyxEntry, reportMetadata: OnyxEntry) { // If the report is optimistic, there's no need to fetch it. The original action should create it. // If there is an error for creating the chat, there's no need to fetch it since it doesn't exist return !reportMetadata?.isOptimisticReport && !report?.errorFields?.createChat; diff --git a/src/pages/Debug/DebugDetails.tsx b/src/pages/Debug/DebugDetails.tsx index 60126ef1937a..c5b0b068e65d 100644 --- a/src/pages/Debug/DebugDetails.tsx +++ b/src/pages/Debug/DebugDetails.tsx @@ -15,8 +15,6 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {ObjectType, OnyxDataType} from '@libs/DebugUtils'; import DebugUtils from '@libs/DebugUtils'; -import * as PolicyUtils from '@libs/PolicyUtils'; -import * as TagsOptionsListUtils from '@libs/TagsOptionsListUtils'; import Debug from '@userActions/Debug'; import type CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -34,6 +32,13 @@ type DebugDetailsProps = { /** The report or report action data to be displayed and editted. */ data: OnyxEntry | OnyxEntry | OnyxEntry | OnyxEntry; + /** Whether the provided policy has enabled tags */ + policyHasEnabledTags?: boolean; + + /** ID of the provided policy */ + policyID?: string; + + /** Metadata UI */ children?: React.ReactNode; /** Callback to be called when user saves the debug data. */ @@ -47,13 +52,10 @@ type DebugDetailsProps = { validate: (key: any, value: string) => void; }; -function DebugDetails({formType, data, children, onSave, onDelete, validate}: DebugDetailsProps) { +function DebugDetails({formType, data, policyHasEnabledTags, policyID, children, onSave, onDelete, validate}: DebugDetailsProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [formDraftData] = useOnyx(ONYXKEYS.FORMS.DEBUG_DETAILS_FORM_DRAFT); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${(data as OnyxEntry)?.reportID ?? ''}`); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); - const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); const booleanFields = useMemo( () => Object.entries(data ?? {}) @@ -66,13 +68,13 @@ function DebugDetails({formType, data, children, onSave, onDelete, validate}: De Object.entries(data ?? {}) .filter((entry): entry is [string, string] => { // Tag picker needs to be hidden when the policy has no tags available to pick - if (entry[0] === TRANSACTION_FORM_INPUT_IDS.TAG && !TagsOptionsListUtils.hasEnabledTags(policyTagLists)) { + if (entry[0] === TRANSACTION_FORM_INPUT_IDS.TAG && !policyHasEnabledTags) { return false; } return DETAILS_CONSTANT_FIELDS[formType].some(({fieldName}) => fieldName === entry[0]); }) .sort((a, b) => a[0].localeCompare(b[0])), - [data, formType, policyTagLists], + [data, formType, policyHasEnabledTags], ); const numberFields = useMemo( () => @@ -209,7 +211,7 @@ function DebugDetails({formType, data, children, onSave, onDelete, validate}: De name={key} shouldSaveDraft defaultValue={String(value)} - policyID={report?.policyID} + policyID={policyID} /> ))} {constantFields.length === 0 && {translate('debug.none')}} @@ -248,9 +250,7 @@ function DebugDetails({formType, data, children, onSave, onDelete, validate}: De danger large text={translate('common.delete')} - onPress={() => { - onDelete(); - }} + onPress={onDelete} /> diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx index 16e23ed4c608..a31597bb59dd 100644 --- a/src/pages/Debug/Report/DebugReportPage.tsx +++ b/src/pages/Debug/Report/DebugReportPage.tsx @@ -144,7 +144,6 @@ function DebugReportPage({ Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, data); }} onDelete={() => { - Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); navigateToConciergeChatAndDeleteReport(reportID, true, true); }} validate={DebugUtils.validateReportDraftProperty} diff --git a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx index 2066ab71c639..1162146b8f4d 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; @@ -51,6 +51,29 @@ function DebugReportActionCreatePage({ const [draftReportAction, setDraftReportAction] = useState(() => getInitialReportAction(reportID, session, personalDetailsList)); const [error, setError] = useState(); + const createReportAction = useCallback(() => { + const parsedReportAction = JSON.parse(draftReportAction.replaceAll('\n', '')) as ReportAction; + Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [parsedReportAction.reportActionID]: parsedReportAction, + }); + Navigation.navigate(ROUTES.DEBUG_REPORT_TAB_ACTIONS.getRoute(reportID)); + }, [draftReportAction, reportID]); + + const editJSON = useCallback( + (updatedJSON: string) => { + try { + DebugUtils.validateReportActionJSON(updatedJSON); + setError(''); + } catch (e) { + const {cause, message} = e as SyntaxError; + setError(cause ? translate(message as TranslationPaths, cause as never) : message); + } finally { + setDraftReportAction(updatedJSON); + } + }, + [translate], + ); + return ( { - try { - DebugUtils.validateReportActionJSON(updatedJSON); - setError(''); - } catch (e) { - const {cause, message} = e as SyntaxError; - setError(cause ? translate(message as TranslationPaths, cause as never) : message); - } finally { - setDraftReportAction(updatedJSON); - } - }} + onChangeText={editJSON} textInputContainerStyles={[styles.border, styles.borderBottom, styles.p5]} /> @@ -112,11 +125,7 @@ function DebugReportActionCreatePage({ success text={translate('common.save')} isDisabled={!draftReportAction || !!error} - onPress={() => { - const parsedReportAction = JSON.parse(draftReportAction.replaceAll('\n', '')) as ReportAction; - Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[parsedReportAction.reportActionID]: parsedReportAction}); - Navigation.navigate(ROUTES.DEBUG_REPORT_TAB_ACTIONS.getRoute(reportID)); - }} + onPress={createReportAction} /> diff --git a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx index e072913c0c03..8c9e33af7f85 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -61,11 +61,15 @@ function DebugReportActionPage({ formType={CONST.DEBUG.FORMS.REPORT_ACTION} data={reportAction} onSave={(data) => { - Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: data}); + Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: data}); }} onDelete={() => { - Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: null}); Navigation.goBack(); + // We need to wait for navigation animations to finish before deleting an action, + // otherwise the user will see a not found page briefly. + InteractionManager.runAfterInteractions(() => { + Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: null}); + }); }} validate={DebugUtils.validateReportActionDraftProperty} > diff --git a/src/pages/Debug/Transaction/DebugTransactionPage.tsx b/src/pages/Debug/Transaction/DebugTransactionPage.tsx index b729d18374e9..86a8e3ded86a 100644 --- a/src/pages/Debug/Transaction/DebugTransactionPage.tsx +++ b/src/pages/Debug/Transaction/DebugTransactionPage.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import {View} from 'react-native'; +import React, {useMemo} from 'react'; +import {InteractionManager, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -14,6 +14,8 @@ import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {DebugParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as TagsOptionsListUtils from '@libs/TagsOptionsListUtils'; import DebugDetails from '@pages/Debug/DebugDetails'; import DebugJSON from '@pages/Debug/DebugJSON'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -32,6 +34,10 @@ function DebugTransactionPage({ }: DebugTransactionPageProps) { const {translate} = useLocalize(); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); + const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); + const styles = useThemeStyles(); if (!transaction) { @@ -60,12 +66,18 @@ function DebugTransactionPage({ { Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, data); }} onDelete={() => { - Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, null); Navigation.goBack(); + // We need to wait for navigation animations to finish before deleting a transaction, + // otherwise the user will see a not found page briefly. + InteractionManager.runAfterInteractions(() => { + Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, null); + }); }} validate={DebugUtils.validateTransactionDraftProperty} > diff --git a/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx b/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx index 2c44e936f10f..f615060ab6df 100644 --- a/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx +++ b/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx @@ -1,5 +1,5 @@ import React, {useCallback, useMemo} from 'react'; -import {View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -45,8 +45,12 @@ function DebugTransactionViolationPage({ const deleteTransactionViolation = useCallback(() => { const updatedTransactionViolations = [...(transactionViolations ?? [])]; updatedTransactionViolations.splice(Number(index), 1); - Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, updatedTransactionViolations); Navigation.goBack(); + // We need to wait for navigation animations to finish before deleting a violation, + // otherwise the user will see a not found page briefly. + InteractionManager.runAfterInteractions(() => { + Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, updatedTransactionViolations); + }); }, [index, transactionID, transactionViolations]); if (!transactionViolation) { diff --git a/src/pages/ErrorPage/NotFoundPage.tsx b/src/pages/ErrorPage/NotFoundPage.tsx index e118057b143e..26b6aae5bc0b 100644 --- a/src/pages/ErrorPage/NotFoundPage.tsx +++ b/src/pages/ErrorPage/NotFoundPage.tsx @@ -1,10 +1,11 @@ import React from 'react'; +import {useOnyx} from 'react-native-onyx'; import type {FullPageNotFoundViewProps} from '@components/BlockingViews/FullPageNotFoundView'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import ScreenWrapper from '@components/ScreenWrapper'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportUtils from '@libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; type NotFoundPageProps = { onBackButtonPress?: () => void; @@ -16,6 +17,8 @@ function NotFoundPage({onBackButtonPress = () => Navigation.goBack(), isReportRe // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to go back to the not found page on large screens and to the home page on small screen // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); + const topmostReportId = Navigation.getTopmostReportId(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${topmostReportId}`); return ( @@ -26,8 +29,7 @@ function NotFoundPage({onBackButtonPress = () => Navigation.goBack(), isReportRe onBackButtonPress(); return; } - const topmostReportId = Navigation.getTopmostReportId(); - const report = ReportUtils.getReport(topmostReportId ?? ''); + // detect the report is invalid if (topmostReportId && (!report || report.errorFields?.notFound)) { Navigation.dismissModal(); diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 2a7f1d0cb81b..8ca443a92a70 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -222,7 +222,6 @@ function InviteReportParticipantsPage({betas, report, didScreenTransitionEnd}: I (null); const focusTimeoutRef = useRef(null); - useHtmlPaste(privateNotesInput); - useFocusEffect( useCallback(() => { focusTimeoutRef.current = setTimeout(() => { diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index f0308301e142..4fa2fe25797b 100755 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -158,7 +158,7 @@ function ProfilePage({route}: ProfilePageProps) { const notificationPreferenceValue = ReportUtils.getReportNotificationPreference(report); - const shouldShowNotificationPreference = !isEmptyObject(report) && !isCurrentUser && notificationPreferenceValue !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const shouldShowNotificationPreference = !isEmptyObject(report) && !isCurrentUser && !ReportUtils.isHiddenForCurrentUser(notificationPreferenceValue); const notificationPreference = shouldShowNotificationPreference ? translate(`notificationPreferencesPage.notificationPreferences.${notificationPreferenceValue}` as TranslationPaths) : ''; diff --git a/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx index d882adedd6fb..894ed0b17686 100644 --- a/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx +++ b/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx @@ -68,6 +68,7 @@ function Confirmation({onNext, onMove, isEditing}: SubStepProps) { onfidoLinksTitle={`${translate('personalInfoStep.byAddingThisBankAccount')} `} isLoading={isLoading} error={error} + shouldApplySafeAreaPaddingBottom={false} /> ); } diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 9d2d7e4ada75..dc751bae7bff 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -288,7 +288,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta roomDescription = translate('newRoomPage.roomName'); } - const shouldShowNotificationPref = !isMoneyRequestReport && ReportUtils.getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const shouldShowNotificationPref = !isMoneyRequestReport && !ReportUtils.isHiddenForCurrentUser(report); const shouldShowWriteCapability = !isMoneyRequestReport; const shouldShowMenuItem = shouldShowNotificationPref || shouldShowWriteCapability || (!!report?.visibility && report.chatType !== CONST.REPORT.CHAT_TYPE.INVOICE); diff --git a/src/pages/RoomInvitePage.tsx b/src/pages/RoomInvitePage.tsx index 79d7cfe4acc5..4bcf12623d04 100644 --- a/src/pages/RoomInvitePage.tsx +++ b/src/pages/RoomInvitePage.tsx @@ -63,7 +63,7 @@ function RoomInvitePage({ // Any existing participants and Expensify emails should not be eligible for invitation const excludedUsers = useMemo(() => { const visibleParticipantAccountIDs = Object.entries(report.participants ?? {}) - .filter(([, participant]) => participant && participant.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) + .filter(([, participant]) => participant && !ReportUtils.isHiddenForCurrentUser(participant.notificationPreference)) .map(([accountID]) => Number(accountID)); return [...PersonalDetailsUtils.getLoginsByAccountIDs(visibleParticipantAccountIDs), ...CONST.EXPENSIFY_EMAILS].map((participant) => PhoneNumber.addSMSDomainIfPhoneNumber(participant), diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersInPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersInPage.tsx index cf7e50960b9e..7c5bc5e924eb 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersInPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersInPage.tsx @@ -24,7 +24,6 @@ function SearchFiltersInPage() { return ( Navigation.goBack()} icon={Illustrations.TeachersUnite} + shouldUseHeadlineHeader /> diff --git a/src/pages/TransactionDuplicate/Confirmation.tsx b/src/pages/TransactionDuplicate/Confirmation.tsx index 520a253469db..8d973a262186 100644 --- a/src/pages/TransactionDuplicate/Confirmation.tsx +++ b/src/pages/TransactionDuplicate/Confirmation.tsx @@ -39,8 +39,8 @@ function Confirmation() { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [reviewDuplicates, reviewDuplicatesResult] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES); const transaction = useMemo(() => TransactionUtils.buildNewTransactionAfterReviewingDuplicates(reviewDuplicates), [reviewDuplicates]); - const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); - const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1'); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID); const {goBack} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'confirmation', route.params.threadReportID, route.params.backTo); const [report, reportResult] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`); const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`); @@ -54,12 +54,15 @@ function Confirmation() { const mergeDuplicates = useCallback(() => { IOU.mergeDuplicates(transactionsMergeParams); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportAction?.childReportID ?? '-1')); + if (!reportAction?.childReportID) { + return; + } + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportAction?.childReportID)); }, [reportAction?.childReportID, transactionsMergeParams]); const resolveDuplicates = useCallback(() => { IOU.resolveDuplicates(transactionsMergeParams); - Navigation.dismissModal(reportAction?.childReportID ?? '-1'); + Navigation.dismissModal(reportAction?.childReportID); }, [transactionsMergeParams, reportAction?.childReportID]); const contextValue = useMemo( @@ -75,8 +78,8 @@ function Confirmation() { [report, reportAction], ); - const reportTransactionID = TransactionUtils.getTransactionID(report?.reportID ?? ''); - const doesTransactionBelongToReport = reviewDuplicates?.transactionID === reportTransactionID || reviewDuplicates?.duplicates.includes(reportTransactionID); + const reportTransactionID = report?.reportID ? TransactionUtils.getTransactionID(report.reportID) : undefined; + const doesTransactionBelongToReport = reviewDuplicates?.transactionID === reportTransactionID || (reportTransactionID && reviewDuplicates?.duplicates.includes(reportTransactionID)); // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = diff --git a/src/pages/Travel/CarTripDetails.tsx b/src/pages/Travel/CarTripDetails.tsx new file mode 100644 index 000000000000..09ffd3d2cad1 --- /dev/null +++ b/src/pages/Travel/CarTripDetails.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import DateUtils from '@libs/DateUtils'; +import CONST from '@src/CONST'; +import type {PersonalDetails} from '@src/types/onyx'; +import type {Reservation} from '@src/types/onyx/Transaction'; + +type CarTripDetailsProps = { + reservation: Reservation; + personalDetails: OnyxEntry; +}; + +function CarTripDetails({reservation, personalDetails}: CarTripDetailsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const pickUpDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date)); + const dropOffDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date)); + const cancellationText = reservation.cancellationDeadline + ? `${translate('travel.carDetails.cancellationUntil')} ${DateUtils.getFormattedTransportDateAndHour(new Date(reservation.cancellationDeadline)).date}` + : reservation.cancellationPolicy; + + const displayName = personalDetails?.displayName ?? reservation.travelerPersonalInfo?.name; + + return ( + <> + {reservation.vendor} + + {pickUpDate.date} {CONST.DOT_SEPARATOR} {pickUpDate.hour} + + } + interactive={false} + helperText={reservation.start.location} + helperTextStyle={[styles.pb3, styles.mtn2]} + /> + + {dropOffDate.date} {CONST.DOT_SEPARATOR} {dropOffDate.hour} + + } + interactive={false} + helperText={reservation.end.location} + helperTextStyle={[styles.pb3, styles.mtn2]} + /> + {!!reservation.carInfo?.name && ( + + )} + {!!cancellationText && ( + + )} + {!!reservation.reservationID && ( + + )} + {!!displayName && ( + + )} + + ); +} + +CarTripDetails.displayName = 'CarTripDetails'; + +export default CarTripDetails; diff --git a/src/pages/Travel/FlightTripDetails.tsx b/src/pages/Travel/FlightTripDetails.tsx new file mode 100644 index 000000000000..901286ef33b8 --- /dev/null +++ b/src/pages/Travel/FlightTripDetails.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import RenderHTML from '@components/RenderHTML'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import DateUtils from '@libs/DateUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type {PersonalDetails} from '@src/types/onyx'; +import type {Reservation} from '@src/types/onyx/Transaction'; + +type FlightTripDetailsProps = { + reservation: Reservation; + prevReservation: Reservation | undefined; + personalDetails: OnyxEntry; +}; + +function FlightTripDetails({reservation, prevReservation, personalDetails}: FlightTripDetailsProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + + const startDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date)); + const endDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date)); + + const prevFlightEndDate = prevReservation?.end.date; + const layover = prevFlightEndDate && DateUtils.getFormattedDurationBetweenDates(translate, new Date(prevFlightEndDate), new Date(reservation.start.date)); + const flightDuration = DateUtils.getFormattedDuration(translate, reservation.duration); + const flightRouteDescription = `${reservation.start.cityName} (${reservation.start.shortName}) ${translate('common.conjunctionTo')} ${reservation.end.cityName} (${ + reservation.end.shortName + })`; + + const displayName = personalDetails?.displayName ?? reservation.travelerPersonalInfo?.name; + + return ( + <> + {flightRouteDescription} + + {!!layover && ( + + + + + )} + + + + {startDate.hour}} + helperText={`${reservation.start.longName} (${reservation.start.shortName})${reservation.arrivalGate?.terminal ? `, ${reservation.arrivalGate?.terminal}` : ''}`} + helperTextStyle={[styles.pb3, styles.mtn2]} + interactive={false} + /> + {endDate.hour}} + helperText={`${reservation.end.longName} (${reservation.end.shortName})`} + helperTextStyle={[styles.pb3, styles.mtn2]} + interactive={false} + /> + + + {!!reservation.route?.number && ( + + + + )} + {!!reservation.route?.class && ( + + + + )} + {!!reservation.confirmations?.at(0)?.value && ( + + + + )} + + {!!displayName && ( + + )} + + ); +} + +FlightTripDetails.displayName = 'FlightTripDetails'; + +export default FlightTripDetails; diff --git a/src/pages/Travel/HotelTripDetails.tsx b/src/pages/Travel/HotelTripDetails.tsx new file mode 100644 index 000000000000..747dc3ceca70 --- /dev/null +++ b/src/pages/Travel/HotelTripDetails.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import DateUtils from '@libs/DateUtils'; +import CONST from '@src/CONST'; +import type {PersonalDetails} from '@src/types/onyx'; +import type {Reservation} from '@src/types/onyx/Transaction'; + +type HotelTripDetailsProps = { + reservation: Reservation; + personalDetails: OnyxEntry; +}; + +function HotelTripDetails({reservation, personalDetails}: HotelTripDetailsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const checkInDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date)); + const checkOutDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date)); + const cancellationText = reservation.cancellationDeadline + ? `${translate('travel.hotelDetails.cancellationUntil')} ${DateUtils.getFormattedTransportDateAndHour(new Date(reservation.cancellationDeadline)).date}` + : reservation.cancellationPolicy; + + const displayName = personalDetails?.displayName ?? reservation.travelerPersonalInfo?.name; + + return ( + <> + {reservation.start.longName} + + {checkInDate.date}} + interactive={false} + /> + {checkOutDate.date}} + interactive={false} + /> + + {!!reservation.roomClass && ( + + )} + {!!cancellationText && ( + + )} + {!!reservation.confirmations?.at(0)?.value && ( + + )} + {!!displayName && ( + + )} + + ); +} + +HotelTripDetails.displayName = 'HotelTripDetails'; + +export default HotelTripDetails; diff --git a/src/pages/Travel/TrainTripDetails.tsx b/src/pages/Travel/TrainTripDetails.tsx new file mode 100644 index 000000000000..c83245981321 --- /dev/null +++ b/src/pages/Travel/TrainTripDetails.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import DateUtils from '@libs/DateUtils'; +import CONST from '@src/CONST'; +import type {PersonalDetails} from '@src/types/onyx'; +import type {Reservation} from '@src/types/onyx/Transaction'; + +type TrainTripDetailsProps = { + reservation: Reservation; + personalDetails: OnyxEntry; +}; + +function TrainTripDetails({reservation, personalDetails}: TrainTripDetailsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const startDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date)); + const endDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date)); + const trainRouteDescription = `${reservation.start.longName} (${reservation.start.shortName}) ${translate('common.conjunctionTo')} ${reservation.end.longName} (${ + reservation.end.shortName + })`; + const trainDuration = DateUtils.getFormattedDurationBetweenDates(translate, new Date(reservation.start.date), new Date(reservation.end.date)); + + const displayName = personalDetails?.displayName ?? reservation.travelerPersonalInfo?.name; + + return ( + <> + {trainRouteDescription} + + + + + {startDate.hour}} + helperText={`${reservation.start.longName} (${reservation.start.shortName})`} + helperTextStyle={[styles.pb3, styles.mtn2]} + interactive={false} + /> + {endDate.hour}} + helperText={`${reservation.end.longName} (${reservation.end.shortName})`} + helperTextStyle={[styles.pb3, styles.mtn2]} + interactive={false} + /> + + + {!!reservation.coachNumber && ( + + + + )} + {!!reservation.seatNumber && ( + + + + )} + + {!!reservation.confirmations?.at(0)?.value && ( + + )} + + {!!displayName && ( + + )} + + ); +} + +TrainTripDetails.displayName = 'TrainTripDetails'; + +export default TrainTripDetails; diff --git a/src/pages/Travel/TripDetailsPage.tsx b/src/pages/Travel/TripDetailsPage.tsx new file mode 100644 index 000000000000..d7a93e7cdeba --- /dev/null +++ b/src/pages/Travel/TripDetailsPage.tsx @@ -0,0 +1,146 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useState} from 'react'; +import {NativeModules} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePermissions from '@hooks/usePermissions'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {TravelNavigatorParamList} from '@libs/Navigation/types'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as TripReservationUtils from '@libs/TripReservationUtils'; +import * as Link from '@userActions/Link'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {PersonalDetailsList} from '@src/types/onyx'; +import type {Reservation} from '@src/types/onyx/Transaction'; +import CarTripDetails from './CarTripDetails'; +import FlightTripDetails from './FlightTripDetails'; +import HotelTripDetails from './HotelTripDetails'; +import TrainTripDetails from './TrainTripDetails'; + +function pickTravelerPersonalDetails(personalDetails: OnyxEntry, reservation: Reservation | undefined) { + return Object.values(personalDetails ?? {})?.find((personalDetail) => personalDetail?.login === reservation?.travelerPersonalInfo?.email); +} + +type TripDetailsPageProps = StackScreenProps; + +function TripDetailsPage({route}: TripDetailsPageProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const {canUseSpotnanaTravel} = usePermissions(); + const {isOffline} = useNetwork(); + + const [isModifyTripLoading, setIsModifyTripLoading] = useState(false); + const [isTripSupportLoading, setIsTripSupportLoading] = useState(false); + + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${route.params.transactionID}`); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID ?? '-1'}`); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID ?? '-1'}`); + + const tripID = ReportUtils.getTripIDFromTransactionParentReportID(parentReport?.reportID); + const reservationType = transaction?.receipt?.reservationList?.at(route.params.reservationIndex ?? 0)?.type; + const reservation = transaction?.receipt?.reservationList?.at(route.params.reservationIndex ?? 0); + const reservationIcon = TripReservationUtils.getTripReservationIcon(reservation?.type); + const [travelerPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: (personalDetails) => pickTravelerPersonalDetails(personalDetails, reservation)}); + + return ( + + + + + {!!reservation && reservationType === CONST.RESERVATION_TYPE.FLIGHT && ( + 0 ? transaction?.receipt?.reservationList?.at(route.params.reservationIndex - 1) : undefined} + reservation={reservation} + personalDetails={travelerPersonalDetails} + /> + )} + {!!reservation && reservationType === CONST.RESERVATION_TYPE.HOTEL && ( + + )} + {!!reservation && reservationType === CONST.RESERVATION_TYPE.CAR && ( + + )} + {!!reservation && reservationType === CONST.RESERVATION_TYPE.TRAIN && ( + + )} + { + setIsModifyTripLoading(true); + Link.openTravelDotLink(activePolicyID, CONST.TRIP_ID_PATH(tripID))?.finally(() => { + setIsModifyTripLoading(false); + }); + }} + wrapperStyle={styles.mt3} + shouldShowLoadingSpinnerIcon={isModifyTripLoading} + disabled={isModifyTripLoading || isOffline} + /> + { + setIsTripSupportLoading(true); + Link.openTravelDotLink(activePolicyID, CONST.TRIP_ID_PATH(tripID))?.finally(() => { + setIsTripSupportLoading(false); + }); + }} + shouldShowLoadingSpinnerIcon={isTripSupportLoading} + disabled={isTripSupportLoading || isOffline} + /> + + + + ); +} + +TripDetailsPage.displayName = 'TripDetailsPage'; + +export default TripDetailsPage; diff --git a/src/pages/Travel/TripSummaryPage.tsx b/src/pages/Travel/TripSummaryPage.tsx new file mode 100644 index 000000000000..8a0a4f9c38b7 --- /dev/null +++ b/src/pages/Travel/TripSummaryPage.tsx @@ -0,0 +1,64 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {NativeModules} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import {ReservationView} from '@components/ReportActionItem/TripDetailsView'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import type {TravelNavigatorParamList} from '@libs/Navigation/types'; +import * as TripReservationUtils from '@src/libs/TripReservationUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; + +type TripSummaryPageProps = StackScreenProps; + +function TripSummaryPage({route}: TripSummaryPageProps) { + const {translate} = useLocalize(); + const {canUseSpotnanaTravel} = usePermissions(); + + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${route.params.transactionID}`); + const reservationsData: TripReservationUtils.ReservationData[] = TripReservationUtils.getReservationsFromTripTransactions(transaction ? [transaction] : []); + + return ( + + + + + {reservationsData.map(({reservation, transactionID, reservationIndex}) => { + return ( + + + + ); + })} + + + + ); +} + +TripSummaryPage.displayName = 'TripSummaryPage'; + +export default TripSummaryPage; diff --git a/src/pages/WorkspaceSwitcherPage/index.tsx b/src/pages/WorkspaceSwitcherPage/index.tsx index 87ba17b6504d..cb52c52cb64c 100644 --- a/src/pages/WorkspaceSwitcherPage/index.tsx +++ b/src/pages/WorkspaceSwitcherPage/index.tsx @@ -20,6 +20,7 @@ import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import switchPolicyAfterInteractions from './switchPolicyAfterInteractions'; import WorkspaceCardCreateAWorkspace from './WorkspaceCardCreateAWorkspace'; type WorkspaceListItem = { @@ -87,7 +88,9 @@ function WorkspaceSwitcherPage() { setActiveWorkspaceID(newPolicyID); Navigation.goBack(); if (newPolicyID !== activeWorkspaceID) { - Navigation.navigateWithSwitchPolicyID({policyID: newPolicyID}); + // On native platforms, we will see a blank screen if we navigate to a new HomeScreen route while navigating back at the same time. + // Therefore we delay switching the workspace until after back navigation, using the InteractionManager. + switchPolicyAfterInteractions(newPolicyID); } }, [activeWorkspaceID, setActiveWorkspaceID, isFocused], @@ -102,7 +105,7 @@ function WorkspaceSwitcherPage() { .filter((policy) => PolicyUtils.shouldShowPolicy(policy, !!isOffline, currentUserLogin) && !policy?.isJoinRequestPending) .map((policy) => ({ text: policy?.name ?? '', - policyID: policy?.id ?? '-1', + policyID: policy?.id, brickRoadIndicator: getIndicatorTypeForPolicy(policy?.id), icons: [ { diff --git a/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.native.tsx b/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.native.tsx new file mode 100644 index 000000000000..a3df127564b1 --- /dev/null +++ b/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.native.tsx @@ -0,0 +1,10 @@ +import {InteractionManager} from 'react-native'; +import Navigation from '@libs/Navigation/Navigation'; + +function switchPolicyAfterInteractions(newPolicyID: string | undefined) { + InteractionManager.runAfterInteractions(() => { + Navigation.navigateWithSwitchPolicyID({policyID: newPolicyID}); + }); +} + +export default switchPolicyAfterInteractions; diff --git a/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.tsx b/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.tsx new file mode 100644 index 000000000000..612759a8601c --- /dev/null +++ b/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.tsx @@ -0,0 +1,7 @@ +import Navigation from '@libs/Navigation/Navigation'; + +function switchPolicyAfterInteractions(newPolicyID: string | undefined) { + Navigation.navigateWithSwitchPolicyID({policyID: newPolicyID}); +} + +export default switchPolicyAfterInteractions; diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 97582f75b7b1..e9771189bed2 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -112,6 +112,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const {isOffline} = useNetwork(); const {shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout(); const {activeWorkspaceID} = useActiveWorkspace(); + const lastAccessedReportIDRef = useRef(false); const [modal] = useOnyx(ONYXKEYS.MODAL); const [isComposerFullSize] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportIDFromRoute}`, {initialValue: false}); @@ -151,6 +152,10 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro return; } + if (lastAccessedReportIDRef.current) { + return; + } + const lastAccessedReportID = ReportUtils.findLastAccessedReport(!canUseDefaultRooms, !!route.params.openOnAdminRoom, activeWorkspaceID)?.reportID; // It's possible that reports aren't fully loaded yet @@ -160,6 +165,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro } Log.info(`[ReportScreen] no reportID found in params, setting it to lastAccessedReportID: ${lastAccessedReportID}`); + lastAccessedReportIDRef.current = true; navigation.setParams({reportID: lastAccessedReportID}); }, [activeWorkspaceID, canUseDefaultRooms, navigation, route, finishedLoadingApp]); @@ -296,7 +302,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro Navigation.dismissModal(); return; } - Navigation.goBack(undefined, false, true); + Navigation.goBack(ROUTES.HOME, false, true); }, [isInNarrowPaneModal]); let headerView = ( @@ -484,7 +490,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro return; } - if (!shouldFetchReport(report)) { + if (!shouldFetchReport(report, reportMetadata)) { return; } // When creating an optimistic report that already exists, we need to skip openReport @@ -495,7 +501,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro } fetchReport(); - }, [report, fetchReport, reportIDFromRoute, isLoadingApp]); + }, [reportIDFromRoute, isLoadingApp, report, reportMetadata, fetchReport]); const dismissBanner = useCallback(() => { setIsBannerVisible(false); @@ -558,14 +564,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro // If a user has chosen to leave a thread, and then returns to it (e.g. with the back button), we need to call `openReport` again in order to allow the user to rejoin and to receive real-time updates useEffect(() => { - if ( - !shouldUseNarrowLayout || - !isFocused || - prevIsFocused || - !ReportUtils.isChatThread(report) || - ReportUtils.getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN || - isSingleTransactionView - ) { + if (!shouldUseNarrowLayout || !isFocused || prevIsFocused || !ReportUtils.isChatThread(report) || !ReportUtils.isHiddenForCurrentUser(report) || isSingleTransactionView) { return; } Report.openReport(reportID ?? ''); diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index b88a5a0bd33e..6877de271946 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -135,7 +135,8 @@ function BaseReportActionContextMenu({ const transactionID = ReportActionsUtils.getLinkedTransactionID(reportActionID, reportID); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); const [user] = useOnyx(ONYXKEYS.USER); - const policyID = ReportUtils.getReport(reportID)?.policyID; + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const policyID = report?.policyID; const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policyID ?? '-1'); const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`); @@ -151,7 +152,7 @@ function BaseReportActionContextMenu({ const [download] = useOnyx(`${ONYXKEYS.COLLECTION.DOWNLOAD}${sourceID}`); - const childReport = ReportUtils.getReport(reportAction?.childReportID ?? '-1'); + const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction?.childReportID}`); const parentReportAction = ReportActionsUtils.getReportAction(childReport?.parentReportID ?? '', childReport?.parentReportActionID ?? ''); const {reportActions: paginatedReportActions} = usePaginatedReportActions(childReport?.reportID ?? '-1'); @@ -178,7 +179,7 @@ function BaseReportActionContextMenu({ const moneyRequestAction = transactionThreadReportID ? requestParentReportAction : parentReportAction; const [parentReportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${childReport?.parentReportID ?? '-1'}`); - const parentReport = ReportUtils.getReport(childReport?.parentReportID ?? '-1'); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${childReport?.parentReportID}`); const isMoneyRequest = useMemo(() => ReportUtils.isMoneyRequest(childReport), [childReport]); const isTrackExpenseReport = ReportUtils.isTrackExpenseReport(childReport); @@ -323,6 +324,7 @@ function BaseReportActionContextMenu({ // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style reportAction: (reportAction ?? null) as ReportAction, reportID, + report, draftMessage, selection, close: () => setShouldKeepOpen(false), diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index a31137e53c6a..b8cdde2ecff3 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -29,7 +29,7 @@ import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; -import type {Beta, Download as DownloadOnyx, OnyxInputOrEntry, ReportAction, ReportActionReactions, Transaction, User} from '@src/types/onyx'; +import type {Beta, Download as DownloadOnyx, OnyxInputOrEntry, ReportAction, ReportActionReactions, Report as ReportType, Transaction, User} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; import type {ContextMenuAnchor} from './ReportActionContextMenu'; import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; @@ -75,6 +75,7 @@ type ContextMenuActionPayload = { reportAction: ReportAction; transaction?: OnyxEntry; reportID: string; + report: OnyxEntry; draftMessage: string; selection: string; close: () => void; @@ -374,7 +375,7 @@ const ContextMenuActions: ContextMenuAction[] = [ // If return value is true, we switch the `text` and `icon` on // `ContextMenuItem` with `successText` and `successIcon` which will fall back to // the `text` and `icon` - onPress: (closePopover, {reportAction, transaction, selection, reportID, hasCard}) => { + onPress: (closePopover, {reportAction, transaction, selection, report, reportID, hasCard}) => { const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction); const messageHtml = getActionHtml(reportAction); const messageText = ReportActionsUtils.getReportActionMessageText(reportAction); @@ -476,7 +477,6 @@ const ContextMenuActions: ContextMenuAction[] = [ const {label, errorMessage} = ReportActionsUtils.getOriginalMessage(reportAction) ?? {label: '', errorMessage: ''}; setClipboardMessage(Localize.translateLocal('report.actions.type.integrationSyncFailed', {label, errorMessage})); } else if (ReportActionsUtils.isCardIssuedAction(reportAction)) { - const report = ReportUtils.getReport(reportID); setClipboardMessage(ReportActionsUtils.getCardIssuedMessage(reportAction, true, report?.policyID, hasCard)); } else if (ReportActionsUtils.isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION)) { setClipboardMessage(ReportActionsUtils.getRemovedConnectionMessage(reportAction)); @@ -603,8 +603,7 @@ const ContextMenuActions: ContextMenuAction[] = [ successTextTranslateKey: 'reportActionContextMenu.copied', successIcon: Expensicons.Checkmark, shouldShow: ({type, isProduction}) => type === CONST.CONTEXT_MENU_TYPES.REPORT && !isProduction, - onPress: (closePopover, {reportID}) => { - const report = ReportUtils.getReport(reportID); + onPress: (closePopover, {report}) => { Clipboard.setString(JSON.stringify(report, null, 4)); hideContextMenu(true, ReportActionComposeFocusManager.focus); }, diff --git a/src/pages/home/report/ReportActionItemMessage.tsx b/src/pages/home/report/ReportActionItemMessage.tsx index da2f3dd151c8..647c17f70d88 100644 --- a/src/pages/home/report/ReportActionItemMessage.tsx +++ b/src/pages/home/report/ReportActionItemMessage.tsx @@ -37,6 +37,7 @@ type ReportActionItemMessageProps = { function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHidden = false}: ReportActionItemMessageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${ReportActionsUtils.getLinkedTransactionID(action) ?? -1}`); const fragments = ReportActionsUtils.getReportActionMessageFragments(action); @@ -122,7 +123,7 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid }; const openWorkspaceInvoicesPage = () => { - const policyID = ReportUtils.getReport(reportID)?.policyID; + const policyID = report?.policyID; if (!policyID) { return; @@ -131,12 +132,14 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid Navigation.navigate(ROUTES.WORKSPACE_INVOICES.getRoute(policyID)); }; + const shouldShowAddBankAccountButton = action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && ReportUtils.hasMissingInvoiceBankAccount(reportID) && !ReportUtils.isSettled(reportID); + return ( {!isHidden ? ( <> {renderReportActionItemFragments(isApprovedOrSubmittedReportAction)} - {action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && ReportUtils.hasMissingInvoiceBankAccount(reportID) && ( + {shouldShowAddBankAccountButton && (