diff --git a/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md b/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md
index 03dd3d722d82..14b5225801d0 100644
--- a/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md
+++ b/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md
@@ -36,10 +36,6 @@ Once the member verifies their email address, all Domain Admins will be notified
3. Click the **Domain Members** tab on the left.
4. Under the Domain Members section, enter the first part of the memberās email address and click **Invite**.
-{% include info.html %}
-This can be any email addressāit does not have to be an email address under the domain. If someone who is not a Domain Admin invites a new member to a workspace, that member must validate their account via email before they will have access to it.
-{% include end-info.html %}
-
# Add Domain Admin
1. Hover over Settings, then click **Domains**.
diff --git a/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md b/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md
index 3927ec5b7a33..54314e0edb4d 100644
--- a/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md
+++ b/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md
@@ -6,6 +6,18 @@ description: Use 2FA for extra login security
Add an extra layer of security to help keep your financial data safe and secure by enabling two-factor authentication (2FA). This will require you to enter a code generated by your preferred authenticator app (like Google Authenticator or Microsoft Authenticator) when you log in.
+Expensify's Two-Factor Authentication (2FA) is implemented via a Time-based One-Time Password (TOTP) algorithm. This requires you to use an Authenticator app to generate a unique code each time you log in, adding a second āfactorā to your login.
+
+You can choose to use whichever authenticator you prefer, but here are a few we recommend:
+- [1Password](https://support.1password.com/one-time-passwords/)
+- [Authy](https://authy.com/)
+- [Google Authenticator](https://support.google.com/accounts/answer/1066447)
+- [Microsoft Authenticator](https://www.microsoft.com/en-us/security/mobile-authenticator-app)
+
+You will need to select an authenticator app to use before proceeding.
+
+## Enable and Set Up Two-factor authentication
+
1. Hover over Settings, then click **Account**.
2. Under the Account Details tab, scroll down to the Two Factor Authentication section and enable the toggle.
3. Save a copy of your backup codes.
@@ -19,8 +31,32 @@ This step is criticalāYou will lose access to your account if you cannot use y
4. Click **Continue**.
5. Download or open your authenticator app and either:
- Scan the QR code shown on your computer screen.
- - Enter the 6-digit code from your authenticator app into Expensify and click **Verify**.
+ - Enter the 6-digit code from your authenticator app into Expensify and click **Verify**.
When you log in to Expensify in the future, youāll be emailed a magic code that youāll use to log in with. Then youāll be prompted to open your authenticator app to get the 6-digit code and enter it into Expensify. A new code regenerates every few seconds, so the code is always different. If the code time runs out, you can generate a new code as needed.
+## Lost recovery codes and authenticator app
+
+If you have lost your mobile device and canāt find your recovery codes, you can have your Domain Admin complete the steps below to reset your 2FA **only if you use a company email address or an email address on a domain that you own**:
+
+Go to Settings > Domains > Domain Members and click **Edit Settings** for your email address.
+They then click **Reset** to reset two-factor authentication (2FA) on your account.
+
+This will allow you to gain access to your account on the web or mobile app and reconfigure 2FA again.
+
+{% include info.html %}
+If you use a public email address such as gmail, hotmail, or yahoo, we unfortunately canāt help you disable your 2FA setting. If you are unable to find your recovery codes, you may need to create a new Expensify account with a different email address.
+{% include end-info.html %}
+
+If you donāt have a Domain Admin, follow the steps in this [guide](https://help.expensify.com/articles/expensify-classic/domains/Claim-And-Verify-A-Domain) to verify the domain.
+
+## General troubleshooting
+
+Make sure your phoneās time is set to automatically update (a manual time thatās fractionally different can cause issues).
+Try disabling 2FA using a device that you are still logged into. For example, if youāre having trouble logging in with your computer, try to see if your mobile device is still logged in. If so,
+Hover over Settings, then click Account.
+Under the Account Details tab, scroll down to the Two Factor Authentication section and disable the toggle.
+Try logging in with your other device.
+Once youāve logged in again, you can re-enable 2FA.
+
diff --git a/docs/articles/expensify-classic/workspaces/Enable-per-diem-expenses.md b/docs/articles/expensify-classic/workspaces/Enable-per-diem-expenses.md
index 2d2f1b5afddc..87b03e2e69ee 100644
--- a/docs/articles/expensify-classic/workspaces/Enable-per-diem-expenses.md
+++ b/docs/articles/expensify-classic/workspaces/Enable-per-diem-expenses.md
@@ -16,6 +16,7 @@ To enable and set per diem rates,
6. Create a .csv, .txt, .xls, or .xlsx spreadsheet containing four columns: Destination, Sub-rate, Amount, and Currency. Youāll want a different row for each location that an employee may travel to, which may include states and/or countries to help account for cost differences across various locations. Here are some example templates you can use:
- [Germany rates]({{site.url}}/assets/Files/Germany-per-diem.csv)
- [Sweden rates]({{site.url}}/assets/Files/Sweden-per-diem.csv)
+ - [Finland rates]({{site.url}}/assets/Files/Finland-per-diem.csv)
- [South Africa single rates]({{site.url}}/assets/Files/South-Africa-per-diem.csv)
7. Click **Import from spreadsheet**.
8. Click **Upload** to select your spreadsheet.
diff --git a/docs/articles/new-expensify/billing-and-subscriptions/adding-payment-card-subscription-overview.md b/docs/articles/new-expensify/billing-and-subscriptions/adding-payment-card-subscription-overview.md
new file mode 100644
index 000000000000..d30fa06bc059
--- /dev/null
+++ b/docs/articles/new-expensify/billing-and-subscriptions/adding-payment-card-subscription-overview.md
@@ -0,0 +1,31 @@
+Subscription Management
+Under the subscriptions section of your account, you can manage your payment card details, view your current plan, add a billing card, and adjust your subscription size and renewal date.
+To view or manage your subscription in New Expensify:
+**Open the App**: Launch New Expensify on your device.
+**Go to Account Settings**: Click your profile icon in the bottom-left corner.
+**Find Workspaces**: Navigate to the Workspaces section.
+**Open Subscriptions**: Click Subscription under Workspaces to view your subscription.
+
+## Add a Payment Card
+
+Look for the option to **Add Payment Card**. Enter your payment card details securely to ensure uninterrupted service.
+[PLACEHOLDER for design image- default]
+## Subscription Overview
+
+This is where you can view your current subscription plan and see details like the number of seats, billing information, and the next renewal date.
+
+**Subscription Settings**:
+ - **Auto-renew**: See when your subscription will automatically renew (e.g., **Renews on Nov 1, 2024**).
+- **Auto-increase annual seats**: Here you can see how much you could save by automatically increasing seats to accommodate team members who exceed the current subscription size.
+
+**Note**: This will extend your annual subscription end date.
+[PLACEHOLDER for design image- your plan]
+## Early Cancellation Requests
+
+If you need to cancel your subscription early, you can find the **Request Early Cancellation** option in the same Subscriptions section.
+
+**Note**: Not all customers are eligible to cancel their subscription early.
+[PLACEHOLDER for design image- billing]
+## Pricing Information
+
+For more details on pricing plans, visit Billing Page [coming soon!]
diff --git a/docs/articles/new-expensify/workspaces/Set-up-workflows.md b/docs/articles/new-expensify/workspaces/Set-up-workflows.md
index 07d770d3ad50..7c44e3792122 100644
--- a/docs/articles/new-expensify/workspaces/Set-up-workflows.md
+++ b/docs/articles/new-expensify/workspaces/Set-up-workflows.md
@@ -17,6 +17,10 @@ Workflows are available for Collect and Control workspaces. Additionally, you mu
4. Click **More features** in the left menu.
5. Under the Spend section, enable the Workflows toggle.
+![Click Account Settings > Workspaces > click on the workspace]({{site.url}}/assets/images/ExpensifyHelp-Workflows-1.png){:width="100%"}
+
+![Click More Features > Enable Workflows]({{site.url}}/assets/images/ExpensifyHelp-Workflows-2.png){:width="100%"}
+
# Select workflows
You can choose to require additional approvals and/or allow delayed submissions.
@@ -29,6 +33,8 @@ You can choose to require additional approvals and/or allow delayed submissions.
-- With delayed submission **enabled**, all reimbursable and non-reimbursable expenses will be submitted at a designated frequency.
-- If delay submission is **disabled**, all reimbursable and non-reimbursable expenses are submitted instantly.
+![Enable workflow features]({{site.url}}/assets/images/ExpensifyHelp-Workflows-3.png){:width="100%"}
+
# Set up payment account
The payments section is where youāll set up your business bank account for payments of expenses and invoices.
diff --git a/docs/articles/new-expensify/workspaces/Track-taxes.md b/docs/articles/new-expensify/workspaces/Track-taxes.md
index fb4077679350..a8ea82873b9e 100644
--- a/docs/articles/new-expensify/workspaces/Track-taxes.md
+++ b/docs/articles/new-expensify/workspaces/Track-taxes.md
@@ -4,15 +4,13 @@ description: Set up tax rates in your Expensify workspace
---
-# Track taxes
-
Each Expensify workspace can be configured with one or more tax rates. Once tax rates are enabled on your workspace, all expenses will have a default tax rate applied based on the currency, and employees will be able to select the correct tax rate for each expense.
-Tax rates are only available on the Control plan. Collect plan users will need to upgrade to Control for access to tag tax codes.
+Tax rates are available on Collect and Control plans.
-## Enable taxes on a workspace
+# Enable taxes on a workspace
-Tax codes are only available on the Control plan. Taxes can be enabled on any workspace where the default currency is not USD. Please note that if you have a direct accounting integration, tax rates will be managed through the integration and cannot be manually enabled or disabled using the instructions below.
+Taxes can be enabled on any workspace where the default currency is not USD. Please note that if you have a direct accounting integration, tax rates will be managed through the integration and cannot be manually enabled or disabled using the instructions below.
**To enable taxes on your workspace:**
@@ -24,7 +22,7 @@ Tax codes are only available on the Control plan. Taxes can be enabled on any wo
After toggling on taxes, you will see a new **Taxes** option in the left menu.
-## Manually add, delete, or edit tax rates
+# Manually add, delete, or edit tax rates
**To manually add a tax rate:**
@@ -53,7 +51,7 @@ Please note: The workspace currency default rate cannot be deleted or disabled.
Please note: The workspace currency default rate cannot be deleted or disabled.
-## Change the default tax rates
+# Change the default tax rates
After enabling taxes in your workspace, you can set two default rates:
diff --git a/docs/assets/Files/Finland-per-diem.csv b/docs/assets/Files/Finland-per-diem.csv
new file mode 100644
index 000000000000..beb7abc5ef62
--- /dev/null
+++ b/docs/assets/Files/Finland-per-diem.csv
@@ -0,0 +1,1071 @@
+Destination,Amount,Currency,Subrate
+*Exceptional,12.75,EUR,1 meal (no destination)
+*Exceptional,15.5,EUR,2+ Meals (no destination)
+*Exceptional,18,EUR,Travel (no destination)
+*Finland,51,EUR,Full day (over 10 hours)
+*Finland,24,EUR,Partial day (over 6 hours)
+*Finland,51,EUR,Final day (over 6 hours)
+*Finland,24,EUR,Final day (over 2 hours)
+*Finland,16,EUR,Night Travel supplement
+*Finland,-24,EUR,1 meal
+*Finland,-51,EUR,2+ Meals
+Afghanistan,59,EUR,Full day (over 24 hours)
+Afghanistan,59,EUR,Final day (over 10 hours)
+Afghanistan,29.5,EUR,Final day (over 2 hours)
+Afghanistan,-29.5,EUR,2+ Meals
+Afghanistan,16,EUR,Night Travel supplement
+Albania,81,EUR,Full day (over 24 hours)
+Albania,81,EUR,Final day (over 10 hours)
+Albania,40.5,EUR,Final day (over 2 hours)
+Albania,-40.5,EUR,2+ Meals
+Albania,16,EUR,Night Travel supplement
+Algeria,78,EUR,Full day (over 24 hours)
+Algeria,78,EUR,Final day (over 10 hours)
+Algeria,39,EUR,Final day (over 2 hours)
+Algeria,-39,EUR,2+ Meals
+Algeria,16,EUR,Night Travel supplement
+Andorra,63,EUR,Full day (over 24 hours)
+Andorra,63,EUR,Final day (over 10 hours)
+Andorra,31.5,EUR,Final day (over 2 hours)
+Andorra,-31.5,EUR,2+ Meals
+Andorra,16,EUR,Night Travel supplement
+Angola,71,EUR,Full day (over 24 hours)
+Angola,71,EUR,Final day (over 10 hours)
+Angola,35.5,EUR,Final day (over 2 hours)
+Angola,-35.5,EUR,2+ Meals
+Angola,16,EUR,Night Travel supplement
+Antiqua and Barbuda,94,EUR,Full day (over 24 hours)
+Antiqua and Barbuda,94,EUR,Final day (over 10 hours)
+Antiqua and Barbuda,47,EUR,Final day (over 2 hours)
+Antiqua and Barbuda,-47,EUR,2+ Meals
+Antiqua and Barbuda,16,EUR,Night Travel supplement
+"Any other country, not specified above",52,EUR,Full day (over 24 hours)
+"Any other country, not specified above",52,EUR,Final day (over 10 hours)
+"Any other country, not specified above",26,EUR,Final day (over 2 hours)
+"Any other country, not specified above",-26,EUR,2+ Meals
+"Any other country, not specified above",16,EUR,Night Travel supplement
+Argentina,38,EUR,Full day (over 24 hours)
+Argentina,38,EUR,Final day (over 10 hours)
+Argentina,19,EUR,Final day (over 2 hours)
+Argentina,-19,EUR,2+ Meals
+Argentina,16,EUR,Night Travel supplement
+Armenia,61,EUR,Full day (over 24 hours)
+Armenia,61,EUR,Final day (over 10 hours)
+Armenia,30.5,EUR,Final day (over 2 hours)
+Armenia,-30.5,EUR,2+ Meals
+Armenia,16,EUR,Night Travel supplement
+Aruba,70,EUR,Full day (over 24 hours)
+Aruba,70,EUR,Final day (over 10 hours)
+Aruba,35,EUR,Final day (over 2 hours)
+Aruba,-35,EUR,2+ Meals
+Aruba,16,EUR,Night Travel supplement
+Australia,74,EUR,Full day (over 24 hours)
+Australia,74,EUR,Final day (over 10 hours)
+Australia,37,EUR,Final day (over 2 hours)
+Australia,-37,EUR,2+ Meals
+Australia,16,EUR,Night Travel supplement
+Austria,80,EUR,Full day (over 24 hours)
+Austria,80,EUR,Final day (over 10 hours)
+Austria,40,EUR,Final day (over 2 hours)
+Austria,-40,EUR,2+ Meals
+Austria,16,EUR,Night Travel supplement
+Azerbaidzhan,70,EUR,Full day (over 24 hours)
+Azerbaidzhan,70,EUR,Final day (over 10 hours)
+Azerbaidzhan,35,EUR,Final day (over 2 hours)
+Azerbaidzhan,-35,EUR,2+ Meals
+Azerbaidzhan,16,EUR,Night Travel supplement
+Azores,69,EUR,Full day (over 24 hours)
+Azores,69,EUR,Final day (over 10 hours)
+Azores,34.5,EUR,Final day (over 2 hours)
+Azores,-34.5,EUR,2+ Meals
+Azores,16,EUR,Night Travel supplement
+Bahamas,91,EUR,Full day (over 24 hours)
+Bahamas,91,EUR,Final day (over 10 hours)
+Bahamas,45.5,EUR,Final day (over 2 hours)
+Bahamas,-45.5,EUR,2+ Meals
+Bahamas,16,EUR,Night Travel supplement
+Bahrain,80,EUR,Full day (over 24 hours)
+Bahrain,80,EUR,Final day (over 10 hours)
+Bahrain,40,EUR,Final day (over 2 hours)
+Bahrain,-40,EUR,2+ Meals
+Bahrain,16,EUR,Night Travel supplement
+Bangladesh,57,EUR,Full day (over 24 hours)
+Bangladesh,57,EUR,Final day (over 10 hours)
+Bangladesh,28.5,EUR,Final day (over 2 hours)
+Bangladesh,-28.5,EUR,2+ Meals
+Bangladesh,16,EUR,Night Travel supplement
+Barbados,83,EUR,Full day (over 24 hours)
+Barbados,83,EUR,Final day (over 10 hours)
+Barbados,41.5,EUR,Final day (over 2 hours)
+Barbados,-41.5,EUR,2+ Meals
+Barbados,16,EUR,Night Travel supplement
+Belarus,63,EUR,Full day (over 24 hours)
+Belarus,63,EUR,Final day (over 10 hours)
+Belarus,31.5,EUR,Final day (over 2 hours)
+Belarus,-31.5,EUR,2+ Meals
+Belarus,16,EUR,Night Travel supplement
+Belgium,77,EUR,Full day (over 24 hours)
+Belgium,77,EUR,Final day (over 10 hours)
+Belgium,38.5,EUR,Final day (over 2 hours)
+Belgium,-38.5,EUR,2+ Meals
+Belgium,16,EUR,Night Travel supplement
+Belize,52,EUR,Full day (over 24 hours)
+Belize,52,EUR,Final day (over 10 hours)
+Belize,26,EUR,Final day (over 2 hours)
+Belize,-26,EUR,2+ Meals
+Belize,16,EUR,Night Travel supplement
+Benin,47,EUR,Full day (over 24 hours)
+Benin,47,EUR,Final day (over 10 hours)
+Benin,23.5,EUR,Final day (over 2 hours)
+Benin,-23.5,EUR,2+ Meals
+Benin,16,EUR,Night Travel supplement
+Bermuda,90,EUR,Full day (over 24 hours)
+Bermuda,90,EUR,Final day (over 10 hours)
+Bermuda,45,EUR,Final day (over 2 hours)
+Bermuda,-45,EUR,2+ Meals
+Bermuda,16,EUR,Night Travel supplement
+Bhutan,49,EUR,Full day (over 24 hours)
+Bhutan,49,EUR,Final day (over 10 hours)
+Bhutan,24.5,EUR,Final day (over 2 hours)
+Bhutan,-24.5,EUR,2+ Meals
+Bhutan,16,EUR,Night Travel supplement
+Bolivia,48,EUR,Full day (over 24 hours)
+Bolivia,48,EUR,Final day (over 10 hours)
+Bolivia,24,EUR,Final day (over 2 hours)
+Bolivia,-24,EUR,2+ Meals
+Bolivia,16,EUR,Night Travel supplement
+Bosnia and Hercegovina,54,EUR,Full day (over 24 hours)
+Bosnia and Hercegovina,54,EUR,Final day (over 10 hours)
+Bosnia and Hercegovina,27,EUR,Final day (over 2 hours)
+Bosnia and Hercegovina,-27,EUR,2+ Meals
+Bosnia and Hercegovina,16,EUR,Night Travel supplement
+Botswana,41,EUR,Full day (over 24 hours)
+Botswana,41,EUR,Final day (over 10 hours)
+Botswana,20.5,EUR,Final day (over 2 hours)
+Botswana,-20.5,EUR,2+ Meals
+Botswana,16,EUR,Night Travel supplement
+Brazil,80,EUR,Full day (over 24 hours)
+Brazil,80,EUR,Final day (over 10 hours)
+Brazil,40,EUR,Final day (over 2 hours)
+Brazil,-40,EUR,2+ Meals
+Brazil,16,EUR,Night Travel supplement
+Brunei,45,EUR,Full day (over 24 hours)
+Brunei,45,EUR,Final day (over 10 hours)
+Brunei,22.5,EUR,Final day (over 2 hours)
+Brunei,-22.5,EUR,2+ Meals
+Brunei,16,EUR,Night Travel supplement
+Bulgaria,64,EUR,Full day (over 24 hours)
+Bulgaria,64,EUR,Final day (over 10 hours)
+Bulgaria,32,EUR,Final day (over 2 hours)
+Bulgaria,-32,EUR,2+ Meals
+Bulgaria,16,EUR,Night Travel supplement
+Burkina Faso,40,EUR,Full day (over 24 hours)
+Burkina Faso,40,EUR,Final day (over 10 hours)
+Burkina Faso,20,EUR,Final day (over 2 hours)
+Burkina Faso,-20,EUR,2+ Meals
+Burkina Faso,16,EUR,Night Travel supplement
+Burundi,46,EUR,Full day (over 24 hours)
+Burundi,46,EUR,Final day (over 10 hours)
+Burundi,23,EUR,Final day (over 2 hours)
+Burundi,-23,EUR,2+ Meals
+Burundi,16,EUR,Night Travel supplement
+Cambodia,67,EUR,Full day (over 24 hours)
+Cambodia,67,EUR,Final day (over 10 hours)
+Cambodia,33.5,EUR,Final day (over 2 hours)
+Cambodia,-33.5,EUR,2+ Meals
+Cambodia,16,EUR,Night Travel supplement
+Cameroon,59,EUR,Full day (over 24 hours)
+Cameroon,59,EUR,Final day (over 10 hours)
+Cameroon,29.5,EUR,Final day (over 2 hours)
+Cameroon,-29.5,EUR,2+ Meals
+Cameroon,16,EUR,Night Travel supplement
+Canada,82,EUR,Full day (over 24 hours)
+Canada,82,EUR,Final day (over 10 hours)
+Canada,41,EUR,Final day (over 2 hours)
+Canada,-41,EUR,2+ Meals
+Canada,16,EUR,Night Travel supplement
+Canary Islands,71,EUR,Full day (over 24 hours)
+Canary Islands,71,EUR,Final day (over 10 hours)
+Canary Islands,35.5,EUR,Final day (over 2 hours)
+Canary Islands,-35.5,EUR,2+ Meals
+Canary Islands,16,EUR,Night Travel supplement
+Cape Verde,45,EUR,Full day (over 24 hours)
+Cape Verde,45,EUR,Final day (over 10 hours)
+Cape Verde,22.5,EUR,Final day (over 2 hours)
+Cape Verde,-22.5,EUR,2+ Meals
+Cape Verde,16,EUR,Night Travel supplement
+Central African Republic,101,EUR,Full day (over 24 hours)
+Central African Republic,101,EUR,Final day (over 10 hours)
+Central African Republic,50.5,EUR,Final day (over 2 hours)
+Central African Republic,-50.5,EUR,2+ Meals
+Central African Republic,16,EUR,Night Travel supplement
+Chad,47,EUR,Full day (over 24 hours)
+Chad,47,EUR,Final day (over 10 hours)
+Chad,23.5,EUR,Final day (over 2 hours)
+Chad,-23.5,EUR,2+ Meals
+Chad,16,EUR,Night Travel supplement
+Chile,56,EUR,Full day (over 24 hours)
+Chile,56,EUR,Final day (over 10 hours)
+Chile,28,EUR,Final day (over 2 hours)
+Chile,-28,EUR,2+ Meals
+Chile,16,EUR,Night Travel supplement
+China,74,EUR,Full day (over 24 hours)
+China,74,EUR,Final day (over 10 hours)
+China,37,EUR,Final day (over 2 hours)
+China,-37,EUR,2+ Meals
+China,16,EUR,Night Travel supplement
+Colombia,64,EUR,Full day (over 24 hours)
+Colombia,64,EUR,Final day (over 10 hours)
+Colombia,32,EUR,Final day (over 2 hours)
+Colombia,-32,EUR,2+ Meals
+Colombia,16,EUR,Night Travel supplement
+Comoros,42,EUR,Full day (over 24 hours)
+Comoros,42,EUR,Final day (over 10 hours)
+Comoros,21,EUR,Final day (over 2 hours)
+Comoros,-21,EUR,2+ Meals
+Comoros,16,EUR,Night Travel supplement
+Congo (Congo-Brazzaville),64,EUR,Full day (over 24 hours)
+Congo (Congo-Brazzaville),64,EUR,Final day (over 10 hours)
+Congo (Congo-Brazzaville),32,EUR,Final day (over 2 hours)
+Congo (Congo-Brazzaville),-32,EUR,2+ Meals
+Congo (Congo-Brazzaville),16,EUR,Night Travel supplement
+"Congo, Democratic Republic of (Congo-Kinshasa)",51,EUR,Full day (over 24 hours)
+"Congo, Democratic Republic of (Congo-Kinshasa)",51,EUR,Final day (over 10 hours)
+"Congo, Democratic Republic of (Congo-Kinshasa)",25.5,EUR,Final day (over 2 hours)
+"Congo, Democratic Republic of (Congo-Kinshasa)",-25.5,EUR,2+ Meals
+"Congo, Democratic Republic of (Congo-Kinshasa)",16,EUR,Night Travel supplement
+Cook Islands,70,EUR,Full day (over 24 hours)
+Cook Islands,70,EUR,Final day (over 10 hours)
+Cook Islands,35,EUR,Final day (over 2 hours)
+Cook Islands,-35,EUR,2+ Meals
+Cook Islands,16,EUR,Night Travel supplement
+Costa Rica,65,EUR,Full day (over 24 hours)
+Costa Rica,65,EUR,Final day (over 10 hours)
+Costa Rica,32.5,EUR,Final day (over 2 hours)
+Costa Rica,-32.5,EUR,2+ Meals
+Costa Rica,16,EUR,Night Travel supplement
+"CĆ“te dāIvoire, Ivory Coast",80,EUR,Full day (over 24 hours)
+"CĆ“te dāIvoire, Ivory Coast",80,EUR,Final day (over 10 hours)
+"CĆ“te dāIvoire, Ivory Coast",40,EUR,Final day (over 2 hours)
+"CĆ“te dāIvoire, Ivory Coast",-40,EUR,2+ Meals
+"CĆ“te dāIvoire, Ivory Coast",16,EUR,Night Travel supplement
+Croatia,69,EUR,Full day (over 24 hours)
+Croatia,69,EUR,Final day (over 10 hours)
+Croatia,34.5,EUR,Final day (over 2 hours)
+Croatia,-34.5,EUR,2+ Meals
+Croatia,16,EUR,Night Travel supplement
+Cuba,68,EUR,Full day (over 24 hours)
+Cuba,68,EUR,Final day (over 10 hours)
+Cuba,34,EUR,Final day (over 2 hours)
+Cuba,-34,EUR,2+ Meals
+Cuba,16,EUR,Night Travel supplement
+CuraƧao,58,EUR,Full day (over 24 hours)
+CuraƧao,58,EUR,Final day (over 10 hours)
+CuraƧao,29,EUR,Final day (over 2 hours)
+CuraƧao,-29,EUR,2+ Meals
+CuraƧao,16,EUR,Night Travel supplement
+Cyprus,65,EUR,Full day (over 24 hours)
+Cyprus,65,EUR,Final day (over 10 hours)
+Cyprus,32.5,EUR,Final day (over 2 hours)
+Cyprus,-32.5,EUR,2+ Meals
+Cyprus,16,EUR,Night Travel supplement
+Czech Republic,89,EUR,Full day (over 24 hours)
+Czech Republic,89,EUR,Final day (over 10 hours)
+Czech Republic,44.5,EUR,Final day (over 2 hours)
+Czech Republic,-44.5,EUR,2+ Meals
+Czech Republic,16,EUR,Night Travel supplement
+Denmark,79,EUR,Full day (over 24 hours)
+Denmark,79,EUR,Final day (over 10 hours)
+Denmark,39.5,EUR,Final day (over 2 hours)
+Denmark,-39.5,EUR,2+ Meals
+Denmark,16,EUR,Night Travel supplement
+Djibouti,83,EUR,Full day (over 24 hours)
+Djibouti,83,EUR,Final day (over 10 hours)
+Djibouti,41.5,EUR,Final day (over 2 hours)
+Djibouti,-41.5,EUR,2+ Meals
+Djibouti,16,EUR,Night Travel supplement
+Dominica,61,EUR,Full day (over 24 hours)
+Dominica,61,EUR,Final day (over 10 hours)
+Dominica,30.5,EUR,Final day (over 2 hours)
+Dominica,-30.5,EUR,2+ Meals
+Dominica,16,EUR,Night Travel supplement
+Dominican Republic,53,EUR,Full day (over 24 hours)
+Dominican Republic,53,EUR,Final day (over 10 hours)
+Dominican Republic,26.5,EUR,Final day (over 2 hours)
+Dominican Republic,-26.5,EUR,2+ Meals
+Dominican Republic,16,EUR,Night Travel supplement
+East Timor,46,EUR,Full day (over 24 hours)
+East Timor,46,EUR,Final day (over 10 hours)
+East Timor,23,EUR,Final day (over 2 hours)
+East Timor,-23,EUR,2+ Meals
+East Timor,16,EUR,Night Travel supplement
+Ecuador,63,EUR,Full day (over 24 hours)
+Ecuador,63,EUR,Final day (over 10 hours)
+Ecuador,31.5,EUR,Final day (over 2 hours)
+Ecuador,-31.5,EUR,2+ Meals
+Ecuador,16,EUR,Night Travel supplement
+Egypt,66,EUR,Full day (over 24 hours)
+Egypt,66,EUR,Final day (over 10 hours)
+Egypt,33,EUR,Final day (over 2 hours)
+Egypt,-33,EUR,2+ Meals
+Egypt,16,EUR,Night Travel supplement
+El Salvador,60,EUR,Full day (over 24 hours)
+El Salvador,60,EUR,Final day (over 10 hours)
+El Salvador,30,EUR,Final day (over 2 hours)
+El Salvador,-30,EUR,2+ Meals
+El Salvador,16,EUR,Night Travel supplement
+Eritrea,95,EUR,Full day (over 24 hours)
+Eritrea,95,EUR,Final day (over 10 hours)
+Eritrea,47.5,EUR,Final day (over 2 hours)
+Eritrea,-47.5,EUR,2+ Meals
+Eritrea,16,EUR,Night Travel supplement
+Estonia,75,EUR,Full day (over 24 hours)
+Estonia,75,EUR,Final day (over 10 hours)
+Estonia,37.5,EUR,Final day (over 2 hours)
+Estonia,-37.5,EUR,2+ Meals
+Estonia,16,EUR,Night Travel supplement
+Eswatini,37,EUR,Full day (over 24 hours)
+Eswatini,37,EUR,Final day (over 10 hours)
+Eswatini,18.5,EUR,Final day (over 2 hours)
+Eswatini,-18.5,EUR,2+ Meals
+Eswatini,16,EUR,Night Travel supplement
+Ethiopia,49,EUR,Full day (over 24 hours)
+Ethiopia,49,EUR,Final day (over 10 hours)
+Ethiopia,24.5,EUR,Final day (over 2 hours)
+Ethiopia,-24.5,EUR,2+ Meals
+Ethiopia,16,EUR,Night Travel supplement
+Faroe Islands,61,EUR,Full day (over 24 hours)
+Faroe Islands,61,EUR,Final day (over 10 hours)
+Faroe Islands,30.5,EUR,Final day (over 2 hours)
+Faroe Islands,-30.5,EUR,2+ Meals
+Faroe Islands,16,EUR,Night Travel supplement
+Fiji,52,EUR,Full day (over 24 hours)
+Fiji,52,EUR,Final day (over 10 hours)
+Fiji,26,EUR,Final day (over 2 hours)
+Fiji,-26,EUR,2+ Meals
+Fiji,16,EUR,Night Travel supplement
+France,78,EUR,Full day (over 24 hours)
+France,78,EUR,Final day (over 10 hours)
+France,39,EUR,Final day (over 2 hours)
+France,-39,EUR,2+ Meals
+France,16,EUR,Night Travel supplement
+Gabon,92,EUR,Full day (over 24 hours)
+Gabon,92,EUR,Final day (over 10 hours)
+Gabon,46,EUR,Final day (over 2 hours)
+Gabon,-46,EUR,2+ Meals
+Gabon,16,EUR,Night Travel supplement
+Gambia,46,EUR,Full day (over 24 hours)
+Gambia,46,EUR,Final day (over 10 hours)
+Gambia,23,EUR,Final day (over 2 hours)
+Gambia,-23,EUR,2+ Meals
+Gambia,16,EUR,Night Travel supplement
+Georgia,49,EUR,Full day (over 24 hours)
+Georgia,49,EUR,Final day (over 10 hours)
+Georgia,24.5,EUR,Final day (over 2 hours)
+Georgia,-24.5,EUR,2+ Meals
+Georgia,16,EUR,Night Travel supplement
+Germany,76,EUR,Full day (over 24 hours)
+Germany,76,EUR,Final day (over 10 hours)
+Germany,38,EUR,Final day (over 2 hours)
+Germany,-38,EUR,2+ Meals
+Germany,16,EUR,Night Travel supplement
+Ghana,47,EUR,Full day (over 24 hours)
+Ghana,47,EUR,Final day (over 10 hours)
+Ghana,23.5,EUR,Final day (over 2 hours)
+Ghana,-23.5,EUR,2+ Meals
+Ghana,16,EUR,Night Travel supplement
+Greece,68,EUR,Full day (over 24 hours)
+Greece,68,EUR,Final day (over 10 hours)
+Greece,34,EUR,Final day (over 2 hours)
+Greece,-34,EUR,2+ Meals
+Greece,16,EUR,Night Travel supplement
+Greenland,63,EUR,Full day (over 24 hours)
+Greenland,63,EUR,Final day (over 10 hours)
+Greenland,31.5,EUR,Final day (over 2 hours)
+Greenland,-31.5,EUR,2+ Meals
+Greenland,16,EUR,Night Travel supplement
+Grenada,73,EUR,Full day (over 24 hours)
+Grenada,73,EUR,Final day (over 10 hours)
+Grenada,36.5,EUR,Final day (over 2 hours)
+Grenada,-36.5,EUR,2+ Meals
+Grenada,16,EUR,Night Travel supplement
+Guadeloupe,53,EUR,Full day (over 24 hours)
+Guadeloupe,53,EUR,Final day (over 10 hours)
+Guadeloupe,26.5,EUR,Final day (over 2 hours)
+Guadeloupe,-26.5,EUR,2+ Meals
+Guadeloupe,16,EUR,Night Travel supplement
+Guatemala,76,EUR,Full day (over 24 hours)
+Guatemala,76,EUR,Final day (over 10 hours)
+Guatemala,38,EUR,Final day (over 2 hours)
+Guatemala,-38,EUR,2+ Meals
+Guatemala,16,EUR,Night Travel supplement
+Guinea,83,EUR,Full day (over 24 hours)
+Guinea,83,EUR,Final day (over 10 hours)
+Guinea,41.5,EUR,Final day (over 2 hours)
+Guinea,-41.5,EUR,2+ Meals
+Guinea,16,EUR,Night Travel supplement
+Guinea-Bissau,41,EUR,Full day (over 24 hours)
+Guinea-Bissau,41,EUR,Final day (over 10 hours)
+Guinea-Bissau,20.5,EUR,Final day (over 2 hours)
+Guinea-Bissau,-20.5,EUR,2+ Meals
+Guinea-Bissau,16,EUR,Night Travel supplement
+Guyana,51,EUR,Full day (over 24 hours)
+Guyana,51,EUR,Final day (over 10 hours)
+Guyana,25.5,EUR,Final day (over 2 hours)
+Guyana,-25.5,EUR,2+ Meals
+Guyana,16,EUR,Night Travel supplement
+Haiti,62,EUR,Full day (over 24 hours)
+Haiti,62,EUR,Final day (over 10 hours)
+Haiti,31,EUR,Final day (over 2 hours)
+Haiti,-31,EUR,2+ Meals
+Haiti,16,EUR,Night Travel supplement
+Honduras,58,EUR,Full day (over 24 hours)
+Honduras,58,EUR,Final day (over 10 hours)
+Honduras,29,EUR,Final day (over 2 hours)
+Honduras,-29,EUR,2+ Meals
+Honduras,16,EUR,Night Travel supplement
+Hong Kong,86,EUR,Full day (over 24 hours)
+Hong Kong,86,EUR,Final day (over 10 hours)
+Hong Kong,43,EUR,Final day (over 2 hours)
+Hong Kong,-43,EUR,2+ Meals
+Hong Kong,16,EUR,Night Travel supplement
+Hungary,69,EUR,Full day (over 24 hours)
+Hungary,69,EUR,Final day (over 10 hours)
+Hungary,34.5,EUR,Final day (over 2 hours)
+Hungary,-34.5,EUR,2+ Meals
+Hungary,16,EUR,Night Travel supplement
+Iceland,92,EUR,Full day (over 24 hours)
+Iceland,92,EUR,Final day (over 10 hours)
+Iceland,46,EUR,Final day (over 2 hours)
+Iceland,-46,EUR,2+ Meals
+Iceland,16,EUR,Night Travel supplement
+India,62,EUR,Full day (over 24 hours)
+India,62,EUR,Final day (over 10 hours)
+India,31,EUR,Final day (over 2 hours)
+India,-31,EUR,2+ Meals
+India,16,EUR,Night Travel supplement
+Indonesia,57,EUR,Full day (over 24 hours)
+Indonesia,57,EUR,Final day (over 10 hours)
+Indonesia,28.5,EUR,Final day (over 2 hours)
+Indonesia,-28.5,EUR,2+ Meals
+Indonesia,16,EUR,Night Travel supplement
+Iran,102,EUR,Full day (over 24 hours)
+Iran,102,EUR,Final day (over 10 hours)
+Iran,51,EUR,Final day (over 2 hours)
+Iran,-51,EUR,2+ Meals
+Iran,16,EUR,Night Travel supplement
+Iraq,70,EUR,Full day (over 24 hours)
+Iraq,70,EUR,Final day (over 10 hours)
+Iraq,35,EUR,Final day (over 2 hours)
+Iraq,-35,EUR,2+ Meals
+Iraq,16,EUR,Night Travel supplement
+Ireland,78,EUR,Full day (over 24 hours)
+Ireland,78,EUR,Final day (over 10 hours)
+Ireland,39,EUR,Final day (over 2 hours)
+Ireland,-39,EUR,2+ Meals
+Ireland,16,EUR,Night Travel supplement
+Israel,88,EUR,Full day (over 24 hours)
+Israel,88,EUR,Final day (over 10 hours)
+Israel,44,EUR,Final day (over 2 hours)
+Israel,-44,EUR,2+ Meals
+Israel,16,EUR,Night Travel supplement
+Istanbul,37,EUR,Full day (over 24 hours)
+Istanbul,37,EUR,Final day (over 10 hours)
+Istanbul,18.5,EUR,Final day (over 2 hours)
+Istanbul,-18.5,EUR,2+ Meals
+Istanbul,16,EUR,Night Travel supplement
+Italy,76,EUR,Full day (over 24 hours)
+Italy,76,EUR,Final day (over 10 hours)
+Italy,38,EUR,Final day (over 2 hours)
+Italy,-38,EUR,2+ Meals
+Italy,16,EUR,Night Travel supplement
+"Ivory Coast, CĆ“te dāIvoire",80,EUR,Full day (over 24 hours)
+"Ivory Coast, CĆ“te dāIvoire",80,EUR,Final day (over 10 hours)
+"Ivory Coast, CĆ“te dāIvoire",40,EUR,Final day (over 2 hours)
+"Ivory Coast, CĆ“te dāIvoire",-40,EUR,2+ Meals
+"Ivory Coast, CĆ“te dāIvoire",16,EUR,Night Travel supplement
+Jamaica,62,EUR,Full day (over 24 hours)
+Jamaica,62,EUR,Final day (over 10 hours)
+Jamaica,31,EUR,Final day (over 2 hours)
+Jamaica,-31,EUR,2+ Meals
+Jamaica,16,EUR,Night Travel supplement
+Japan,66,EUR,Full day (over 24 hours)
+Japan,66,EUR,Final day (over 10 hours)
+Japan,33,EUR,Final day (over 2 hours)
+Japan,-33,EUR,2+ Meals
+Japan,16,EUR,Night Travel supplement
+Jordania,90,EUR,Full day (over 24 hours)
+Jordania,90,EUR,Final day (over 10 hours)
+Jordania,45,EUR,Final day (over 2 hours)
+Jordania,-45,EUR,2+ Meals
+Jordania,16,EUR,Night Travel supplement
+Kazakhstan,59,EUR,Full day (over 24 hours)
+Kazakhstan,59,EUR,Final day (over 10 hours)
+Kazakhstan,29.5,EUR,Final day (over 2 hours)
+Kazakhstan,-29.5,EUR,2+ Meals
+Kazakhstan,16,EUR,Night Travel supplement
+Kenya,70,EUR,Full day (over 24 hours)
+Kenya,70,EUR,Final day (over 10 hours)
+Kenya,35,EUR,Final day (over 2 hours)
+Kenya,-35,EUR,2+ Meals
+Kenya,16,EUR,Night Travel supplement
+"Korea, Democratic People's Republic (North Korea)",70,EUR,Full day (over 24 hours)
+"Korea, Democratic People's Republic (North Korea)",70,EUR,Final day (over 10 hours)
+"Korea, Democratic People's Republic (North Korea)",35,EUR,Final day (over 2 hours)
+"Korea, Democratic People's Republic (North Korea)",-35,EUR,2+ Meals
+"Korea, Democratic People's Republic (North Korea)",16,EUR,Night Travel supplement
+"Korea, Republic of (South Korea)",87,EUR,Full day (over 24 hours)
+"Korea, Republic of (South Korea)",87,EUR,Final day (over 10 hours)
+"Korea, Republic of (South Korea)",43.5,EUR,Final day (over 2 hours)
+"Korea, Republic of (South Korea)",-43.5,EUR,2+ Meals
+"Korea, Republic of (South Korea)",16,EUR,Night Travel supplement
+Kosovo,58,EUR,Full day (over 24 hours)
+Kosovo,58,EUR,Final day (over 10 hours)
+Kosovo,29,EUR,Final day (over 2 hours)
+Kosovo,-29,EUR,2+ Meals
+Kosovo,16,EUR,Night Travel supplement
+Kuwait,84,EUR,Full day (over 24 hours)
+Kuwait,84,EUR,Final day (over 10 hours)
+Kuwait,42,EUR,Final day (over 2 hours)
+Kuwait,-42,EUR,2+ Meals
+Kuwait,16,EUR,Night Travel supplement
+Kyrgystan,41,EUR,Full day (over 24 hours)
+Kyrgystan,41,EUR,Final day (over 10 hours)
+Kyrgystan,20.5,EUR,Final day (over 2 hours)
+Kyrgystan,-20.5,EUR,2+ Meals
+Kyrgystan,16,EUR,Night Travel supplement
+Laos,32,EUR,Full day (over 24 hours)
+Laos,32,EUR,Final day (over 10 hours)
+Laos,16,EUR,Final day (over 2 hours)
+Laos,-16,EUR,2+ Meals
+Laos,16,EUR,Night Travel supplement
+Latvia,73,EUR,Full day (over 24 hours)
+Latvia,73,EUR,Final day (over 10 hours)
+Latvia,36.5,EUR,Final day (over 2 hours)
+Latvia,-36.5,EUR,2+ Meals
+Latvia,16,EUR,Night Travel supplement
+Lebanon,102,EUR,Full day (over 24 hours)
+Lebanon,102,EUR,Final day (over 10 hours)
+Lebanon,51,EUR,Final day (over 2 hours)
+Lebanon,-51,EUR,2+ Meals
+Lebanon,16,EUR,Night Travel supplement
+Lesotho,34,EUR,Full day (over 24 hours)
+Lesotho,34,EUR,Final day (over 10 hours)
+Lesotho,17,EUR,Final day (over 2 hours)
+Lesotho,-17,EUR,2+ Meals
+Lesotho,16,EUR,Night Travel supplement
+Liberia,60,EUR,Full day (over 24 hours)
+Liberia,60,EUR,Final day (over 10 hours)
+Liberia,30,EUR,Final day (over 2 hours)
+Liberia,-30,EUR,2+ Meals
+Liberia,16,EUR,Night Travel supplement
+Libya,52,EUR,Full day (over 24 hours)
+Libya,52,EUR,Final day (over 10 hours)
+Libya,26,EUR,Final day (over 2 hours)
+Libya,-26,EUR,2+ Meals
+Libya,16,EUR,Night Travel supplement
+Liechtenstein,79,EUR,Full day (over 24 hours)
+Liechtenstein,79,EUR,Final day (over 10 hours)
+Liechtenstein,39.5,EUR,Final day (over 2 hours)
+Liechtenstein,-39.5,EUR,2+ Meals
+Liechtenstein,16,EUR,Night Travel supplement
+Lithuania,72,EUR,Full day (over 24 hours)
+Lithuania,72,EUR,Final day (over 10 hours)
+Lithuania,36,EUR,Final day (over 2 hours)
+Lithuania,-36,EUR,2+ Meals
+Lithuania,16,EUR,Night Travel supplement
+London and Edinburgh,83,EUR,Full day (over 24 hours)
+London and Edinburgh,83,EUR,Final day (over 10 hours)
+London and Edinburgh,41.5,EUR,Final day (over 2 hours)
+London and Edinburgh,-41.5,EUR,2+ Meals
+London and Edinburgh,16,EUR,Night Travel supplement
+Luxembourg,77,EUR,Full day (over 24 hours)
+Luxembourg,77,EUR,Final day (over 10 hours)
+Luxembourg,38.5,EUR,Final day (over 2 hours)
+Luxembourg,-38.5,EUR,2+ Meals
+Luxembourg,16,EUR,Night Travel supplement
+Madagascar,45,EUR,Full day (over 24 hours)
+Madagascar,45,EUR,Final day (over 10 hours)
+Madagascar,22.5,EUR,Final day (over 2 hours)
+Madagascar,-22.5,EUR,2+ Meals
+Madagascar,16,EUR,Night Travel supplement
+Madeira,68,EUR,Full day (over 24 hours)
+Madeira,68,EUR,Final day (over 10 hours)
+Madeira,34,EUR,Final day (over 2 hours)
+Madeira,-34,EUR,2+ Meals
+Madeira,16,EUR,Night Travel supplement
+Malawi,77,EUR,Full day (over 24 hours)
+Malawi,77,EUR,Final day (over 10 hours)
+Malawi,38.5,EUR,Final day (over 2 hours)
+Malawi,-38.5,EUR,2+ Meals
+Malawi,16,EUR,Night Travel supplement
+Malaysia,50,EUR,Full day (over 24 hours)
+Malaysia,50,EUR,Final day (over 10 hours)
+Malaysia,25,EUR,Final day (over 2 hours)
+Malaysia,-25,EUR,2+ Meals
+Malaysia,16,EUR,Night Travel supplement
+Maldives,68,EUR,Full day (over 24 hours)
+Maldives,68,EUR,Final day (over 10 hours)
+Maldives,34,EUR,Final day (over 2 hours)
+Maldives,-34,EUR,2+ Meals
+Maldives,16,EUR,Night Travel supplement
+Mali,47,EUR,Full day (over 24 hours)
+Mali,47,EUR,Final day (over 10 hours)
+Mali,23.5,EUR,Final day (over 2 hours)
+Mali,-23.5,EUR,2+ Meals
+Mali,16,EUR,Night Travel supplement
+Malta,71,EUR,Full day (over 24 hours)
+Malta,71,EUR,Final day (over 10 hours)
+Malta,35.5,EUR,Final day (over 2 hours)
+Malta,-35.5,EUR,2+ Meals
+Malta,16,EUR,Night Travel supplement
+Marshall Islands,65,EUR,Full day (over 24 hours)
+Marshall Islands,65,EUR,Final day (over 10 hours)
+Marshall Islands,32.5,EUR,Final day (over 2 hours)
+Marshall Islands,-32.5,EUR,2+ Meals
+Marshall Islands,16,EUR,Night Travel supplement
+Martinique,55,EUR,Full day (over 24 hours)
+Martinique,55,EUR,Final day (over 10 hours)
+Martinique,27.5,EUR,Final day (over 2 hours)
+Martinique,-27.5,EUR,2+ Meals
+Martinique,16,EUR,Night Travel supplement
+Mauritania,52,EUR,Full day (over 24 hours)
+Mauritania,52,EUR,Final day (over 10 hours)
+Mauritania,26,EUR,Final day (over 2 hours)
+Mauritania,-26,EUR,2+ Meals
+Mauritania,16,EUR,Night Travel supplement
+Mauritius,53,EUR,Full day (over 24 hours)
+Mauritius,53,EUR,Final day (over 10 hours)
+Mauritius,26.5,EUR,Final day (over 2 hours)
+Mauritius,-26.5,EUR,2+ Meals
+Mauritius,16,EUR,Night Travel supplement
+Mexico,81,EUR,Full day (over 24 hours)
+Mexico,81,EUR,Final day (over 10 hours)
+Mexico,40.5,EUR,Final day (over 2 hours)
+Mexico,-40.5,EUR,2+ Meals
+Mexico,16,EUR,Night Travel supplement
+Micronesia,59,EUR,Full day (over 24 hours)
+Micronesia,59,EUR,Final day (over 10 hours)
+Micronesia,29.5,EUR,Final day (over 2 hours)
+Micronesia,-29.5,EUR,2+ Meals
+Micronesia,16,EUR,Night Travel supplement
+Moldova,73,EUR,Full day (over 24 hours)
+Moldova,73,EUR,Final day (over 10 hours)
+Moldova,36.5,EUR,Final day (over 2 hours)
+Moldova,-36.5,EUR,2+ Meals
+Moldova,16,EUR,Night Travel supplement
+Monaco,92,EUR,Full day (over 24 hours)
+Monaco,92,EUR,Final day (over 10 hours)
+Monaco,46,EUR,Final day (over 2 hours)
+Monaco,-46,EUR,2+ Meals
+Monaco,16,EUR,Night Travel supplement
+Mongolia,42,EUR,Full day (over 24 hours)
+Mongolia,42,EUR,Final day (over 10 hours)
+Mongolia,21,EUR,Final day (over 2 hours)
+Mongolia,-21,EUR,2+ Meals
+Mongolia,16,EUR,Night Travel supplement
+Montenegro,66,EUR,Full day (over 24 hours)
+Montenegro,66,EUR,Final day (over 10 hours)
+Montenegro,33,EUR,Final day (over 2 hours)
+Montenegro,-33,EUR,2+ Meals
+Montenegro,16,EUR,Night Travel supplement
+Morocco,71,EUR,Full day (over 24 hours)
+Morocco,71,EUR,Final day (over 10 hours)
+Morocco,35.5,EUR,Final day (over 2 hours)
+Morocco,-35.5,EUR,2+ Meals
+Morocco,16,EUR,Night Travel supplement
+Moscow,82,EUR,Full day (over 24 hours)
+Moscow,82,EUR,Final day (over 10 hours)
+Moscow,41,EUR,Final day (over 2 hours)
+Moscow,-41,EUR,2+ Meals
+Moscow,16,EUR,Night Travel supplement
+Mozambique,53,EUR,Full day (over 24 hours)
+Mozambique,53,EUR,Final day (over 10 hours)
+Mozambique,26.5,EUR,Final day (over 2 hours)
+Mozambique,-26.5,EUR,2+ Meals
+Mozambique,16,EUR,Night Travel supplement
+Myanmar (formerly Burma),58,EUR,Full day (over 24 hours)
+Myanmar (formerly Burma),58,EUR,Final day (over 10 hours)
+Myanmar (formerly Burma),29,EUR,Final day (over 2 hours)
+Myanmar (formerly Burma),-29,EUR,2+ Meals
+Myanmar (formerly Burma),16,EUR,Night Travel supplement
+Namibia,36,EUR,Full day (over 24 hours)
+Namibia,36,EUR,Final day (over 10 hours)
+Namibia,18,EUR,Final day (over 2 hours)
+Namibia,-18,EUR,2+ Meals
+Namibia,16,EUR,Night Travel supplement
+Nepal,51,EUR,Full day (over 24 hours)
+Nepal,51,EUR,Final day (over 10 hours)
+Nepal,25.5,EUR,Final day (over 2 hours)
+Nepal,-25.5,EUR,2+ Meals
+Nepal,16,EUR,Night Travel supplement
+Netherlands,83,EUR,Full day (over 24 hours)
+Netherlands,83,EUR,Final day (over 10 hours)
+Netherlands,41.5,EUR,Final day (over 2 hours)
+Netherlands,-41.5,EUR,2+ Meals
+Netherlands,16,EUR,Night Travel supplement
+"New York, Los Angeles, Washington",97,EUR,Full day (over 24 hours)
+"New York, Los Angeles, Washington",97,EUR,Final day (over 10 hours)
+"New York, Los Angeles, Washington",48.5,EUR,Final day (over 2 hours)
+"New York, Los Angeles, Washington",-48.5,EUR,2+ Meals
+"New York, Los Angeles, Washington",16,EUR,Night Travel supplement
+New Zealand,74,EUR,Full day (over 24 hours)
+New Zealand,74,EUR,Final day (over 10 hours)
+New Zealand,37,EUR,Final day (over 2 hours)
+New Zealand,-37,EUR,2+ Meals
+New Zealand,16,EUR,Night Travel supplement
+Nicaragua,51,EUR,Full day (over 24 hours)
+Nicaragua,51,EUR,Final day (over 10 hours)
+Nicaragua,25.5,EUR,Final day (over 2 hours)
+Nicaragua,-25.5,EUR,2+ Meals
+Nicaragua,16,EUR,Night Travel supplement
+Niger,50,EUR,Full day (over 24 hours)
+Niger,50,EUR,Final day (over 10 hours)
+Niger,25,EUR,Final day (over 2 hours)
+Niger,-25,EUR,2+ Meals
+Niger,16,EUR,Night Travel supplement
+Nigeria,78,EUR,Full day (over 24 hours)
+Nigeria,78,EUR,Final day (over 10 hours)
+Nigeria,39,EUR,Final day (over 2 hours)
+Nigeria,-39,EUR,2+ Meals
+Nigeria,16,EUR,Night Travel supplement
+North Macedonia,64,EUR,Full day (over 24 hours)
+North Macedonia,64,EUR,Final day (over 10 hours)
+North Macedonia,32,EUR,Final day (over 2 hours)
+North Macedonia,-32,EUR,2+ Meals
+North Macedonia,16,EUR,Night Travel supplement
+Norway,70,EUR,Full day (over 24 hours)
+Norway,70,EUR,Final day (over 10 hours)
+Norway,35,EUR,Final day (over 2 hours)
+Norway,-35,EUR,2+ Meals
+Norway,16,EUR,Night Travel supplement
+Oman,74,EUR,Full day (over 24 hours)
+Oman,74,EUR,Final day (over 10 hours)
+Oman,37,EUR,Final day (over 2 hours)
+Oman,-37,EUR,2+ Meals
+Oman,16,EUR,Night Travel supplement
+Pakistan,29,EUR,Full day (over 24 hours)
+Pakistan,29,EUR,Final day (over 10 hours)
+Pakistan,14.5,EUR,Final day (over 2 hours)
+Pakistan,-14.5,EUR,2+ Meals
+Pakistan,16,EUR,Night Travel supplement
+Palau,99,EUR,Full day (over 24 hours)
+Palau,99,EUR,Final day (over 10 hours)
+Palau,49.5,EUR,Final day (over 2 hours)
+Palau,-49.5,EUR,2+ Meals
+Palau,16,EUR,Night Travel supplement
+Palestinian territory,76,EUR,Full day (over 24 hours)
+Palestinian territory,76,EUR,Final day (over 10 hours)
+Palestinian territory,38,EUR,Final day (over 2 hours)
+Palestinian territory,-38,EUR,2+ Meals
+Palestinian territory,16,EUR,Night Travel supplement
+Panama,61,EUR,Full day (over 24 hours)
+Panama,61,EUR,Final day (over 10 hours)
+Panama,30.5,EUR,Final day (over 2 hours)
+Panama,-30.5,EUR,2+ Meals
+Panama,16,EUR,Night Travel supplement
+Papua New Guinea,76,EUR,Full day (over 24 hours)
+Papua New Guinea,76,EUR,Final day (over 10 hours)
+Papua New Guinea,38,EUR,Final day (over 2 hours)
+Papua New Guinea,-38,EUR,2+ Meals
+Papua New Guinea,16,EUR,Night Travel supplement
+Paraguay,36,EUR,Full day (over 24 hours)
+Paraguay,36,EUR,Final day (over 10 hours)
+Paraguay,18,EUR,Final day (over 2 hours)
+Paraguay,-18,EUR,2+ Meals
+Paraguay,16,EUR,Night Travel supplement
+Peru,52,EUR,Full day (over 24 hours)
+Peru,52,EUR,Final day (over 10 hours)
+Peru,26,EUR,Final day (over 2 hours)
+Peru,-26,EUR,2+ Meals
+Peru,16,EUR,Night Travel supplement
+Philippines,69,EUR,Full day (over 24 hours)
+Philippines,69,EUR,Final day (over 10 hours)
+Philippines,34.5,EUR,Final day (over 2 hours)
+Philippines,-34.5,EUR,2+ Meals
+Philippines,16,EUR,Night Travel supplement
+Poland,72,EUR,Full day (over 24 hours)
+Poland,72,EUR,Final day (over 10 hours)
+Poland,36,EUR,Final day (over 2 hours)
+Poland,-36,EUR,2+ Meals
+Poland,16,EUR,Night Travel supplement
+Portugal,70,EUR,Full day (over 24 hours)
+Portugal,70,EUR,Final day (over 10 hours)
+Portugal,35,EUR,Final day (over 2 hours)
+Portugal,-35,EUR,2+ Meals
+Portugal,16,EUR,Night Travel supplement
+Puerto Rico,70,EUR,Full day (over 24 hours)
+Puerto Rico,70,EUR,Final day (over 10 hours)
+Puerto Rico,35,EUR,Final day (over 2 hours)
+Puerto Rico,-35,EUR,2+ Meals
+Puerto Rico,16,EUR,Night Travel supplement
+Qatar,78,EUR,Full day (over 24 hours)
+Qatar,78,EUR,Final day (over 10 hours)
+Qatar,39,EUR,Final day (over 2 hours)
+Qatar,-39,EUR,2+ Meals
+Qatar,16,EUR,Night Travel supplement
+Romania,68,EUR,Full day (over 24 hours)
+Romania,68,EUR,Final day (over 10 hours)
+Romania,34,EUR,Final day (over 2 hours)
+Romania,-34,EUR,2+ Meals
+Romania,16,EUR,Night Travel supplement
+Russian Federation,66,EUR,Full day (over 24 hours)
+Russian Federation,66,EUR,Final day (over 10 hours)
+Russian Federation,33,EUR,Final day (over 2 hours)
+Russian Federation,-33,EUR,2+ Meals
+Russian Federation,16,EUR,Night Travel supplement
+Rwanda,37,EUR,Full day (over 24 hours)
+Rwanda,37,EUR,Final day (over 10 hours)
+Rwanda,18.5,EUR,Final day (over 2 hours)
+Rwanda,-18.5,EUR,2+ Meals
+Rwanda,16,EUR,Night Travel supplement
+Saint Kitts and Nevis,68,EUR,Full day (over 24 hours)
+Saint Kitts and Nevis,68,EUR,Final day (over 10 hours)
+Saint Kitts and Nevis,34,EUR,Final day (over 2 hours)
+Saint Kitts and Nevis,-34,EUR,2+ Meals
+Saint Kitts and Nevis,16,EUR,Night Travel supplement
+Saint Lucia,86,EUR,Full day (over 24 hours)
+Saint Lucia,86,EUR,Final day (over 10 hours)
+Saint Lucia,43,EUR,Final day (over 2 hours)
+Saint Lucia,-43,EUR,2+ Meals
+Saint Lucia,16,EUR,Night Travel supplement
+Saint Vincent and the Grenadines,85,EUR,Full day (over 24 hours)
+Saint Vincent and the Grenadines,85,EUR,Final day (over 10 hours)
+Saint Vincent and the Grenadines,42.5,EUR,Final day (over 2 hours)
+Saint Vincent and the Grenadines,-42.5,EUR,2+ Meals
+Saint Vincent and the Grenadines,16,EUR,Night Travel supplement
+Samoa,61,EUR,Full day (over 24 hours)
+Samoa,61,EUR,Final day (over 10 hours)
+Samoa,30.5,EUR,Final day (over 2 hours)
+Samoa,-30.5,EUR,2+ Meals
+Samoa,16,EUR,Night Travel supplement
+San Marino,59,EUR,Full day (over 24 hours)
+San Marino,59,EUR,Final day (over 10 hours)
+San Marino,29.5,EUR,Final day (over 2 hours)
+San Marino,-29.5,EUR,2+ Meals
+San Marino,16,EUR,Night Travel supplement
+Sao Tome and Principe,102,EUR,Full day (over 24 hours)
+Sao Tome and Principe,102,EUR,Final day (over 10 hours)
+Sao Tome and Principe,51,EUR,Final day (over 2 hours)
+Sao Tome and Principe,-51,EUR,2+ Meals
+Sao Tome and Principe,16,EUR,Night Travel supplement
+Saudi Arabia,80,EUR,Full day (over 24 hours)
+Saudi Arabia,80,EUR,Final day (over 10 hours)
+Saudi Arabia,40,EUR,Final day (over 2 hours)
+Saudi Arabia,-40,EUR,2+ Meals
+Saudi Arabia,16,EUR,Night Travel supplement
+Senegal,58,EUR,Full day (over 24 hours)
+Senegal,58,EUR,Final day (over 10 hours)
+Senegal,29,EUR,Final day (over 2 hours)
+Senegal,-29,EUR,2+ Meals
+Senegal,16,EUR,Night Travel supplement
+Serbia,75,EUR,Full day (over 24 hours)
+Serbia,75,EUR,Final day (over 10 hours)
+Serbia,37.5,EUR,Final day (over 2 hours)
+Serbia,-37.5,EUR,2+ Meals
+Serbia,16,EUR,Night Travel supplement
+Seychelles,87,EUR,Full day (over 24 hours)
+Seychelles,87,EUR,Final day (over 10 hours)
+Seychelles,43.5,EUR,Final day (over 2 hours)
+Seychelles,-43.5,EUR,2+ Meals
+Seychelles,16,EUR,Night Travel supplement
+Sierra Leone,47,EUR,Full day (over 24 hours)
+Sierra Leone,47,EUR,Final day (over 10 hours)
+Sierra Leone,23.5,EUR,Final day (over 2 hours)
+Sierra Leone,-23.5,EUR,2+ Meals
+Sierra Leone,16,EUR,Night Travel supplement
+Singapore,79,EUR,Full day (over 24 hours)
+Singapore,79,EUR,Final day (over 10 hours)
+Singapore,39.5,EUR,Final day (over 2 hours)
+Singapore,-39.5,EUR,2+ Meals
+Singapore,16,EUR,Night Travel supplement
+Slovakia,79,EUR,Full day (over 24 hours)
+Slovakia,79,EUR,Final day (over 10 hours)
+Slovakia,39.5,EUR,Final day (over 2 hours)
+Slovakia,-39.5,EUR,2+ Meals
+Slovakia,16,EUR,Night Travel supplement
+Slovenia,72,EUR,Full day (over 24 hours)
+Slovenia,72,EUR,Final day (over 10 hours)
+Slovenia,36,EUR,Final day (over 2 hours)
+Slovenia,-36,EUR,2+ Meals
+Slovenia,16,EUR,Night Travel supplement
+Solomon Islands,63,EUR,Full day (over 24 hours)
+Solomon Islands,63,EUR,Final day (over 10 hours)
+Solomon Islands,31.5,EUR,Final day (over 2 hours)
+Solomon Islands,-31.5,EUR,2+ Meals
+Solomon Islands,16,EUR,Night Travel supplement
+Somalia,86,EUR,Full day (over 24 hours)
+Somalia,86,EUR,Final day (over 10 hours)
+Somalia,43,EUR,Final day (over 2 hours)
+Somalia,-43,EUR,2+ Meals
+Somalia,16,EUR,Night Travel supplement
+South Africa,50,EUR,Full day (over 24 hours)
+South Africa,50,EUR,Final day (over 10 hours)
+South Africa,25,EUR,Final day (over 2 hours)
+South Africa,-25,EUR,2+ Meals
+South Africa,16,EUR,Night Travel supplement
+South Sudan,102,EUR,Full day (over 24 hours)
+South Sudan,102,EUR,Final day (over 10 hours)
+South Sudan,51,EUR,Final day (over 2 hours)
+South Sudan,-51,EUR,2+ Meals
+South Sudan,16,EUR,Night Travel supplement
+Spain,74,EUR,Full day (over 24 hours)
+Spain,74,EUR,Final day (over 10 hours)
+Spain,37,EUR,Final day (over 2 hours)
+Spain,-37,EUR,2+ Meals
+Spain,16,EUR,Night Travel supplement
+Sri Lanka,29,EUR,Full day (over 24 hours)
+Sri Lanka,29,EUR,Final day (over 10 hours)
+Sri Lanka,14.5,EUR,Final day (over 2 hours)
+Sri Lanka,-14.5,EUR,2+ Meals
+Sri Lanka,16,EUR,Night Travel supplement
+St. Petersburg,76,EUR,Full day (over 24 hours)
+St. Petersburg,76,EUR,Final day (over 10 hours)
+St. Petersburg,38,EUR,Final day (over 2 hours)
+St. Petersburg,-38,EUR,2+ Meals
+St. Petersburg,16,EUR,Night Travel supplement
+Sudan,83,EUR,Full day (over 24 hours)
+Sudan,83,EUR,Final day (over 10 hours)
+Sudan,41.5,EUR,Final day (over 2 hours)
+Sudan,-41.5,EUR,2+ Meals
+Sudan,16,EUR,Night Travel supplement
+Suriname,78,EUR,Full day (over 24 hours)
+Suriname,78,EUR,Final day (over 10 hours)
+Suriname,39,EUR,Final day (over 2 hours)
+Suriname,-39,EUR,2+ Meals
+Suriname,16,EUR,Night Travel supplement
+Sweden,64,EUR,Full day (over 24 hours)
+Sweden,64,EUR,Final day (over 10 hours)
+Sweden,32,EUR,Final day (over 2 hours)
+Sweden,-32,EUR,2+ Meals
+Sweden,16,EUR,Night Travel supplement
+Switzerland,93,EUR,Full day (over 24 hours)
+Switzerland,93,EUR,Final day (over 10 hours)
+Switzerland,46.5,EUR,Final day (over 2 hours)
+Switzerland,-46.5,EUR,2+ Meals
+Switzerland,16,EUR,Night Travel supplement
+Syria,91,EUR,Full day (over 24 hours)
+Syria,91,EUR,Final day (over 10 hours)
+Syria,45.5,EUR,Final day (over 2 hours)
+Syria,-45.5,EUR,2+ Meals
+Syria,16,EUR,Night Travel supplement
+Tadzhikistan,35,EUR,Full day (over 24 hours)
+Tadzhikistan,35,EUR,Final day (over 10 hours)
+Tadzhikistan,17.5,EUR,Final day (over 2 hours)
+Tadzhikistan,-17.5,EUR,2+ Meals
+Tadzhikistan,16,EUR,Night Travel supplement
+Taiwan,69,EUR,Full day (over 24 hours)
+Taiwan,69,EUR,Final day (over 10 hours)
+Taiwan,34.5,EUR,Final day (over 2 hours)
+Taiwan,-34.5,EUR,2+ Meals
+Taiwan,16,EUR,Night Travel supplement
+Tanzania,54,EUR,Full day (over 24 hours)
+Tanzania,54,EUR,Final day (over 10 hours)
+Tanzania,27,EUR,Final day (over 2 hours)
+Tanzania,-27,EUR,2+ Meals
+Tanzania,16,EUR,Night Travel supplement
+Thailand,63,EUR,Full day (over 24 hours)
+Thailand,63,EUR,Final day (over 10 hours)
+Thailand,31.5,EUR,Final day (over 2 hours)
+Thailand,-31.5,EUR,2+ Meals
+Thailand,16,EUR,Night Travel supplement
+Togo,58,EUR,Full day (over 24 hours)
+Togo,58,EUR,Final day (over 10 hours)
+Togo,29,EUR,Final day (over 2 hours)
+Togo,-29,EUR,2+ Meals
+Togo,16,EUR,Night Travel supplement
+Tonga,62,EUR,Full day (over 24 hours)
+Tonga,62,EUR,Final day (over 10 hours)
+Tonga,31,EUR,Final day (over 2 hours)
+Tonga,-31,EUR,2+ Meals
+Tonga,16,EUR,Night Travel supplement
+Trinidad and Tobago,83,EUR,Full day (over 24 hours)
+Trinidad and Tobago,83,EUR,Final day (over 10 hours)
+Trinidad and Tobago,41.5,EUR,Final day (over 2 hours)
+Trinidad and Tobago,-41.5,EUR,2+ Meals
+Trinidad and Tobago,16,EUR,Night Travel supplement
+Tunisia,61,EUR,Full day (over 24 hours)
+Tunisia,61,EUR,Final day (over 10 hours)
+Tunisia,30.5,EUR,Final day (over 2 hours)
+Tunisia,-30.5,EUR,2+ Meals
+Tunisia,16,EUR,Night Travel supplement
+Turkey,35,EUR,Full day (over 24 hours)
+Turkey,35,EUR,Final day (over 10 hours)
+Turkey,17.5,EUR,Final day (over 2 hours)
+Turkey,-17.5,EUR,2+ Meals
+Turkey,16,EUR,Night Travel supplement
+Turkmenistan,92,EUR,Full day (over 24 hours)
+Turkmenistan,92,EUR,Final day (over 10 hours)
+Turkmenistan,46,EUR,Final day (over 2 hours)
+Turkmenistan,-46,EUR,2+ Meals
+Turkmenistan,16,EUR,Night Travel supplement
+Uganda,49,EUR,Full day (over 24 hours)
+Uganda,49,EUR,Final day (over 10 hours)
+Uganda,24.5,EUR,Final day (over 2 hours)
+Uganda,-24.5,EUR,2+ Meals
+Uganda,16,EUR,Night Travel supplement
+Ukraine,64,EUR,Full day (over 24 hours)
+Ukraine,64,EUR,Final day (over 10 hours)
+Ukraine,32,EUR,Final day (over 2 hours)
+Ukraine,-32,EUR,2+ Meals
+Ukraine,16,EUR,Night Travel supplement
+United Arab Emirates,73,EUR,Full day (over 24 hours)
+United Arab Emirates,73,EUR,Final day (over 10 hours)
+United Arab Emirates,36.5,EUR,Final day (over 2 hours)
+United Arab Emirates,-36.5,EUR,2+ Meals
+United Arab Emirates,16,EUR,Night Travel supplement
+United Kingdom,79,EUR,Full day (over 24 hours)
+United Kingdom,79,EUR,Final day (over 10 hours)
+United Kingdom,39.5,EUR,Final day (over 2 hours)
+United Kingdom,-39.5,EUR,2+ Meals
+United Kingdom,16,EUR,Night Travel supplement
+United States,89,EUR,Full day (over 24 hours)
+United States,89,EUR,Final day (over 10 hours)
+United States,44.5,EUR,Final day (over 2 hours)
+United States,-44.5,EUR,2+ Meals
+United States,16,EUR,Night Travel supplement
+Uruguay,59,EUR,Full day (over 24 hours)
+Uruguay,59,EUR,Final day (over 10 hours)
+Uruguay,29.5,EUR,Final day (over 2 hours)
+Uruguay,-29.5,EUR,2+ Meals
+Uruguay,16,EUR,Night Travel supplement
+Uzbekistan,32,EUR,Full day (over 24 hours)
+Uzbekistan,32,EUR,Final day (over 10 hours)
+Uzbekistan,16,EUR,Final day (over 2 hours)
+Uzbekistan,-16,EUR,2+ Meals
+Uzbekistan,16,EUR,Night Travel supplement
+Vanuatu,70,EUR,Full day (over 24 hours)
+Vanuatu,70,EUR,Final day (over 10 hours)
+Vanuatu,35,EUR,Final day (over 2 hours)
+Vanuatu,-35,EUR,2+ Meals
+Vanuatu,16,EUR,Night Travel supplement
+Venezuela,102,EUR,Full day (over 24 hours)
+Venezuela,102,EUR,Final day (over 10 hours)
+Venezuela,51,EUR,Final day (over 2 hours)
+Venezuela,-51,EUR,2+ Meals
+Venezuela,16,EUR,Night Travel supplement
+Viet Nam,69,EUR,Full day (over 24 hours)
+Viet Nam,69,EUR,Final day (over 10 hours)
+Viet Nam,34.5,EUR,Final day (over 2 hours)
+Viet Nam,-34.5,EUR,2+ Meals
+Viet Nam,16,EUR,Night Travel supplement
+Virgin Islands (USA),64,EUR,Full day (over 24 hours)
+Virgin Islands (USA),64,EUR,Final day (over 10 hours)
+Virgin Islands (USA),32,EUR,Final day (over 2 hours)
+Virgin Islands (USA),-32,EUR,2+ Meals
+Virgin Islands (USA),16,EUR,Night Travel supplement
+Yemen,102,EUR,Full day (over 24 hours)
+Yemen,102,EUR,Final day (over 10 hours)
+Yemen,51,EUR,Final day (over 2 hours)
+Yemen,-51,EUR,2+ Meals
+Yemen,16,EUR,Night Travel supplement
+Zambia,55,EUR,Full day (over 24 hours)
+Zambia,55,EUR,Final day (over 10 hours)
+Zambia,27.5,EUR,Final day (over 2 hours)
+Zambia,-27.5,EUR,2+ Meals
+Zambia,16,EUR,Night Travel supplement
+Zimbabwe,102,EUR,Full day (over 24 hours)
+Zimbabwe,102,EUR,Final day (over 10 hours)
+Zimbabwe,51,EUR,Final day (over 2 hours)
+Zimbabwe,-51,EUR,2+ Meals
+Zimbabwe,16,EUR,Night Travel supplement
diff --git a/docs/assets/images/ExpensifyHelp-Subscription-Billing.png b/docs/assets/images/ExpensifyHelp-Subscription-Billing.png
new file mode 100644
index 000000000000..8a8c430e8020
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Subscription-Billing.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Subscription-Default.png b/docs/assets/images/ExpensifyHelp-Subscription-Default.png
new file mode 100644
index 000000000000..ae289a8f29f8
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Subscription-Default.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Subscription-Details.png b/docs/assets/images/ExpensifyHelp-Subscription-Details.png
new file mode 100644
index 000000000000..c96b39c4a3ec
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Subscription-Details.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Subscription-YourPlan.png b/docs/assets/images/ExpensifyHelp-Subscription-YourPlan.png
new file mode 100644
index 000000000000..3d958edefd3c
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Subscription-YourPlan.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Subscription.png b/docs/assets/images/ExpensifyHelp-Subscription.png
new file mode 100644
index 000000000000..403dd276743f
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Subscription.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Workflows-1.png b/docs/assets/images/ExpensifyHelp-Workflows-1.png
new file mode 100644
index 000000000000..b0841232f77c
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Workflows-1.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Workflows-2.png b/docs/assets/images/ExpensifyHelp-Workflows-2.png
new file mode 100644
index 000000000000..f7e845fbe81c
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Workflows-2.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Workflows-3.png b/docs/assets/images/ExpensifyHelp-Workflows-3.png
new file mode 100644
index 000000000000..dc3358ab484e
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Workflows-3.png differ
diff --git a/docs/redirects.csv b/docs/redirects.csv
index b47d6f2ae25c..783e13f8de07 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -412,7 +412,7 @@ https://community.expensify.com/discussion/5732/deep-dive-all-about-policy-categ
https://community.expensify.com/discussion/5469/deep-dive-auto-categorize-card-expenses-with-default-categories,https://help.expensify.com/articles/expensify-classic/workspaces/Set-up-category-automation
https://community.expensify.com/discussion/4708/how-to-set-up-and-add-single-tags,https://help.expensify.com/articles/expensify-classic/workspaces/Create-tags
https://community.expensify.com/discussion/5756/how-to-set-up-and-manage-multi-level-tagging/,https://help.expensify.com/articles/expensify-classic/workspaces/Create-tags#multi-level-tags
-https://community.expensify.com/discussion/5044/how-to-set-up-multiple-taxes-on-indirect-connections,https://help.expensify.com/articles/expensify-classic/workspaces/Tax-Tracking
+https://community.expensify.com/discussion/5044/how-to-set-up-multiple-taxes-on-indirect-connections,https://help.expensify.com/articles/expensify-classic/connections/Indirect-Accounting-Integrations
https://community.expensify.com/discussion/4643/how-to-invite-people-to-your-policy-using-a-join-link/,https://help.expensify.com/articles/expensify-classic/workspaces/Invite-members-and-assign-roles#invite-with-a-link
https://community.expensify.com/discussion/5700/deep-dive-approval-workflow-overview,https://help.expensify.com/articles/expensify-classic/reports/Create-a-report-approval-workflow
https://community.expensify.com/discussion/4804/how-to-set-up-concierge-report-approval,https://help.expensify.com/articles/expensify-classic/reports/Require-review-for-over-limit-expenses
@@ -498,7 +498,7 @@ https://community.expensify.com/discussion/6827/what-s-happening-to-my-expensify
https://community.expensify.com/discussion/6898/deep-dive-guide-to-billing,https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Overview
https://community.expensify.com/discussion/7231/how-to-export-invoices-to-netsuite,https://help.expensify.com/articles/new-expensify/connections/netsuite/Connect-to-NetSuite#export-invoices-to
https://community.expensify.com/discussion/7335/faq-what-is-the-expensify-card-auto-reconciliation-process,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Send-an-invoice
-https://community.expensify.com/discussion/7524/how-to-set-up-disable-2fa-for-your-domain,https://help.expensify.com/articles/expensify-classic/domains/Add-Domain-Members-and-Admins
+https://community.expensify.com/discussion/7524/how-to-set-up-disable-2fa-for-your-domain,https://help.expensify.com/articles/expensify-classic/settings/Enable-two-factor-authentication
https://community.expensify.com/discussion/7736/faq-troubleshooting-two-factor-authentication-issues,https://help.expensify.com/articles/expensify-classic/settings/Enable-two-factor-authentication
https://community.expensify.com/discussion/7862/introducing-expensify-cash-open-source-financial-group-chat-built-with-react-native,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills
https://community.expensify.com/discussion/7931/how-to-become-an-expensify-org-donor,https://www.expensify.org/donate
@@ -575,6 +575,11 @@ https://help.expensify.com/articles/new-expensify/getting-started/Upgrade-to-a-C
https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports
https://help.expensify.com/articles/new-expensify/expenses-&-payments/pay-an-invoice.html,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Pay-an-invoice
https://community.expensify.com/discussion/4707/how-to-set-up-your-mobile-app,https://help.expensify.com/articles/expensify-classic/getting-started/Join-your-company's-workspace#download-the-mobile-app
-https://community.expensify.com//discussion/6927/deep-dive-how-can-i-estimate-the-savings-applied-to-my-bill,https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Overview#savings-calculator
https://community.expensify.com/discussion/5179/faq-what-does-a-policy-for-which-you-are-an-admin-has-out-of-date-billing-information-mean,https://help.expensify.com/articles/expensify-classic/expensify-billing/Out-of-date-Billing
-https://community.expensify.com/discussion/6179/setting-up-a-receipt-or-travel-integration-with-expensify,https://help.expensify.com/articles/expensify-classic/connections/Additional-Travel-Integrations
\ No newline at end of file
+https://community.expensify.com/discussion/6179/setting-up-a-receipt-or-travel-integration-with-expensify,https://help.expensify.com/articles/expensify-classic/connections/Additional-Travel-Integrations
+https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account
+https://community.expensify.com//discussion/6927/deep-dive-how-can-i-estimate-the-savings-applied-to-my-bill,https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Overview#savings-calculator
+https://community.expensify.com/discussion/47/auto-sync-best-practices,https://help.expensify.com/expensify-classic/hubs/connections
+https://community.expensify.com/discussion/6699/faq-troubleshooting-known-bank-specific-issues,https://help.expensify.com/expensify-classic/hubs/bank-accounts-and-payments/bank-accounts
+https://community.expensify.com/discussion/4730/faq-expenses-are-exporting-to-the-wrong-accounts-whys-that,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings
+https://community.expensify.com/discussion/9000/how-to-integrate-with-deel,https://help.expensify.com/articles/expensify-classic/connections/Deel
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index 15eb36c819b5..1a7499a2a2c3 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -15,14 +15,15 @@ require 'ostruct'
skip_docs
opt_out_usage
-KEY_GRADLE_APK_PATH = "gradleAPKOutputPath"
-KEY_GRADLE_AAB_PATH = "gradleAABOutputPath"
+KEY_GRADLE_APK_PATH = "apkPath"
+KEY_S3_APK_PATH = "s3APKPath"
+KEY_GRADLE_AAB_PATH = "aabPath"
KEY_IPA_PATH = "ipaPath"
+KEY_S3_IPA_PATH = "s3IpaPath"
KEY_DSYM_PATH = "dsymPath"
-# Export environment variables in the parent shell.
-# In a GitHub Actions environment, it will save the environment variables in the GITHUB_ENV file.
-# In any other environment, it will save them to the current shell environment using the `export` command.
+# Export environment variables to GITHUB_ENV
+# If there's no GITHUB_ENV file set in the env, then this is a no-op
def exportEnvVars(env_vars)
github_env_path = ENV['GITHUB_ENV']
if github_env_path && File.exist?(github_env_path)
@@ -33,13 +34,6 @@ def exportEnvVars(env_vars)
file.puts "#{key}=#{value}"
end
end
- else
- puts "Saving environment variables in parent shell..."
- env_vars.each do |key, value|
- puts "#{key}=#{value}"
- command = "export #{key}=#{value}"
- system(command)
- end
end
end
@@ -102,7 +96,7 @@ platform :android do
setGradleOutputsInEnv()
end
- lane :build_e2edelta do
+ lane :build_e2eDelta do
ENV["ENVFILE"]="tests/e2e/.env.e2edelta"
ENV["ENTRY_FILE"]="src/libs/E2E/reactNativeLaunchingTest.ts"
ENV["E2E_TESTING"]="true"
@@ -139,7 +133,10 @@ platform :android do
apk: ENV[KEY_GRADLE_APK_PATH],
app_directory: "android/#{ENV['PULL_REQUEST_NUMBER']}",
)
- sh("echo '{\"apk_path\": \"#{lane_context[SharedValues::S3_APK_OUTPUT_PATH]}\",\"html_path\": \"#{lane_context[SharedValues::S3_HTML_OUTPUT_PATH]}\"}' > ../android_paths.json")
+ puts "Saving S3 outputs in env..."
+ exportEnvVars({
+ KEY_S3_APK_PATH => lane_context[SharedValues::S3_HTML_OUTPUT_PATH],
+ })
end
desc "Upload app to Google Play for internal testing"
@@ -219,7 +216,7 @@ platform :ios do
build_app(
workspace: "./ios/NewExpensify.xcworkspace",
scheme: "New Expensify",
- output_name: "New Expensify.ipa",
+ output_name: "NewExpensify.ipa",
export_options: {
provisioningProfiles: {
"com.chat.expensify.chat" => "(NewApp) AppStore",
@@ -260,6 +257,7 @@ platform :ios do
workspace: "./ios/NewExpensify.xcworkspace",
skip_profile_detection: true,
scheme: "New Expensify AdHoc",
+ output_name: "NewExpensify_AdHoc.ipa",
export_method: "ad-hoc",
export_options: {
method: "ad-hoc",
@@ -284,12 +282,16 @@ platform :ios do
ipa: ENV[KEY_IPA_PATH],
app_directory: "ios/#{ENV['PULL_REQUEST_NUMBER']}",
)
- sh("echo '{\"ipa_path\": \"#{lane_context[SharedValues::S3_IPA_OUTPUT_PATH]}\",\"html_path\": \"#{lane_context[SharedValues::S3_HTML_OUTPUT_PATH]}\"}' > ../ios_paths.json")
+ puts "Saving S3 outputs in env..."
+ exportEnvVars({
+ KEY_S3_IPA_PATH => lane_context[SharedValues::S3_HTML_OUTPUT_PATH],
+ })
end
desc "Upload app to TestFlight"
lane :upload_testflight do
upload_to_testflight(
+ ipa: ENV[KEY_IPA_PATH],
api_key_path: "./ios/ios-fastlane-json-key.json",
distribute_external: true,
notify_external_testers: true,
diff --git a/help/README.md b/help/README.md
new file mode 100644
index 000000000000..5145954923de
--- /dev/null
+++ b/help/README.md
@@ -0,0 +1,50 @@
+# Welcome to New Help!
+Here are some instructions on how to get started with New Help...
+
+## How to contribute
+Expensify is an open source app, with its public Github repo hosted at https://github.com/Expensify/App. The newhelp.expensify.com website is a part of that same open source project. You can contribute to this helpsite in one of two ways:
+
+### The hard way: local dev environment
+If you are a developer comfortable working on the command line, you can edit these files as follows:
+
+1. Fork https://github.com/Expensify/App repo
+ * `...tbd...`
+2. Install Homebrew: https://brew.sh/
+3. Install `rbenv` using brew:
+ * `brew install rbenv`
+4. Install ruby v3.3.4 using
+ * `rbenv install 3.3.4`
+5. Set the your default ruby version using
+ * `rbenv global 3.3.4`
+6. Install Jekyll and bundler gem
+ * `cd help`
+ * `gem install jekyll bundler`
+7. Create a branch for your changes
+8. Make your changes
+9. Locally build and test your changes:
+ * `bundle exec jekyll build`
+10. Push your changes
+
+### The easy way: edit on Github
+If you don't want to set up your own local dev environment, feel free to just edit the help materials directly from Github:
+
+1. Open whatever file you want.
+2. Replace `github.com` with `github.dev` in the URL
+3. Edit away!
+
+## How to add a page
+The current design of NewHelp.expensify.com is only to have a very small handful pages (one for each "product"), each of which is a markdown file stored in `/help` using the `product` template (defined in `/help/_layouts/product.html`). Accordingly, it's very unlikely you'll be adding a new page.
+
+The goal is to use a system named Jekyll to do the heavy lifting of not just converting that Markdown into HTML, but also allowing for deep linking of the headers, auto-linking mentions of those titles elsewhere, and a ton more. So, just write a basic Markdown file, and it should handle the rest.
+
+## How to preview the site online
+Every PR pushed by an authorized Expensify employee or representative will automatically trigger a "build" of the site using a Github Action. This will [follow these steps](../.github/workflows/deployNewHelp.yml) to:
+1. Start a new Ubuntu server
+2. Check out the repo
+3. Install Ruby and Jekyll
+4. Build the entire site using Jekyll
+5. Create a "preview" of the newly built site in Cloudflare
+6. Record a link to that preview in the PR.
+
+## How to deploy the site for real
+Whenever a PR that touches the `/help` directory is merged, it will re-run the build just like before. However, it will detect that this build is being run from the `main` branch, and thus push the changes to the `production` Cloudflare environment -- meaning, it will replace the contents hosted at https://newhelp.expensify.com
diff --git a/help/_config.yml b/help/_config.yml
index 9135a372964e..11091b1a8b7c 100644
--- a/help/_config.yml
+++ b/help/_config.yml
@@ -5,3 +5,6 @@ url: https://newhelp.expensify.com
twitter_username: expensify
github_username: expensify
+# Ignore what's only used for the Github repo
+exclude:
+ - README.md
diff --git a/help/index.md b/help/index.md
index e5d075402ecb..b198c5e20781 100644
--- a/help/index.md
+++ b/help/index.md
@@ -1,5 +1,7 @@
---
title: New Expensify Help
---
+
Pages:
-* [Expensify Superapp](/superapp.html)
+
+- [Expensify Superapp](/superapp.html)
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 35d71276f8ef..51caecb7c81a 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
-
9.0.41
+
9.0.44
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
-
9.0.41.3
+
9.0.44.8
FullStory
OrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index ca0c99bb87a2..c2ee6978f322 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 9.0.41
+ 9.0.44
CFBundleSignature
????
CFBundleVersion
- 9.0.41.3
+ 9.0.44.8
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 2d97209598e2..1f812f0eff46 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
-
9.0.41
+
9.0.44
CFBundleVersion
-
9.0.41.3
+
9.0.44.8
NSExtension
NSExtensionPointIdentifier
diff --git a/jest/setup.ts b/jest/setup.ts
index 51385ad19e45..6901ad3c66f3 100644
--- a/jest/setup.ts
+++ b/jest/setup.ts
@@ -35,7 +35,7 @@ jest.mock('react-native/Libraries/LogBox/LogBox', () => ({
// Turn off the console logs for timing events. They are not relevant for unit tests and create a lot of noise
jest.spyOn(console, 'debug').mockImplementation((...params: string[]) => {
- if (params[0].startsWith('Timing:')) {
+ if (params.at(0)?.startsWith('Timing:')) {
return;
}
diff --git a/package-lock.json b/package-lock.json
index a83d0550ad6f..cf9978bae510 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.41-3",
+ "version": "9.0.44-8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.41-3",
+ "version": "9.0.44-8",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -71,7 +71,7 @@
"react-content-loader": "^7.0.0",
"react-dom": "18.3.1",
"react-error-boundary": "^4.0.11",
- "react-fast-pdf": "1.0.14",
+ "react-fast-pdf": "1.0.15",
"react-map-gl": "^7.1.3",
"react-native": "0.75.2",
"react-native-android-location-enabler": "^2.0.1",
@@ -93,7 +93,7 @@
"react-native-launch-arguments": "^4.0.2",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "2.0.68",
+ "react-native-onyx": "2.0.71",
"react-native-pager-view": "6.4.1",
"react-native-pdf": "6.7.3",
"react-native-performance": "^5.1.0",
@@ -117,7 +117,6 @@
"react-native-web": "^0.19.12",
"react-native-web-sound": "^0.1.3",
"react-native-webview": "13.8.6",
- "react-pdf": "9.1.0",
"react-plaid-link": "3.3.2",
"react-web-config": "^1.0.0",
"react-webcam": "^7.1.1",
@@ -210,13 +209,14 @@
"copy-webpack-plugin": "^10.1.0",
"css-loader": "^6.7.2",
"csv-parse": "^5.5.5",
+ "csv-writer": "^1.6.0",
"diff-so-fancy": "^1.3.0",
"dotenv": "^16.0.3",
"electron": "^29.4.6",
"electron-builder": "25.0.0",
"eslint": "^8.57.0",
"eslint-config-airbnb-typescript": "^18.0.0",
- "eslint-config-expensify": "^2.0.58",
+ "eslint-config-expensify": "^2.0.60",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-deprecation": "^3.0.0",
"eslint-plugin-jest": "^28.6.0",
@@ -21225,6 +21225,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/csv-writer": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/csv-writer/-/csv-writer-1.6.0.tgz",
+ "integrity": "sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g==",
+ "dev": true
+ },
"node_modules/dag-map": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/dag-map/-/dag-map-1.0.2.tgz",
@@ -22788,9 +22794,9 @@
}
},
"node_modules/eslint-config-expensify": {
- "version": "2.0.58",
- "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.58.tgz",
- "integrity": "sha512-iLDJeXwMYLcBRDnInVReHWjMUsNrHMnWfyoQbvuDTChcJANc+QzuDU0gdsDpBx2xjxVF0vckwEXnzmWcUW1Bpw==",
+ "version": "2.0.60",
+ "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.60.tgz",
+ "integrity": "sha512-VlulvhEasWeX2g+AXC4P91KA9czzX+aI3VSdJlZwm99GLOdfv7mM0JyO8vbqomjWNUxvLyJeJjmI02t2+fL/5Q==",
"dev": true,
"dependencies": {
"@lwc/eslint-plugin-lwc": "^1.7.2",
@@ -34250,10 +34256,11 @@
}
},
"node_modules/react-fast-pdf": {
- "version": "1.0.14",
- "license": "MIT",
+ "version": "1.0.15",
+ "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.15.tgz",
+ "integrity": "sha512-xXrwIfRUD3KSRrBdfAeGnLZTf0kYUa+d6GGee1Hu0PFAv5QPBeF3tcV+DU+Cm/JMjSuR7s5g0KK9bePQ/xiQ+w==",
"dependencies": {
- "react-pdf": "^7.7.0",
+ "react-pdf": "^9.1.1",
"react-window": "^1.8.10"
},
"engines": {
@@ -34262,7 +34269,7 @@
},
"peerDependencies": {
"lodash": "4.x",
- "prop-types": "15.x",
+ "pdfjs-dist": "4.x",
"react": "18.x",
"react-dom": "18.x"
}
@@ -35374,9 +35381,9 @@
}
},
"node_modules/react-native-onyx": {
- "version": "2.0.68",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.68.tgz",
- "integrity": "sha512-KzcG8r6oIHRZhtiGu2XtHwYLm6eTp74r4NyhIawinfJEgcd1YMC6KdrVMqd1J7zFLTuBXPhtjiugTbUhXraFag==",
+ "version": "2.0.71",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.71.tgz",
+ "integrity": "sha512-LE3CYMdyRrXFrd+PbPpYFqQAQ5CE7EzibdM2ljhHrnTp3pDjtOjhXBjjVNV1rujgkvX56QXfX63ag/DRfqPMNw==",
"dependencies": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
@@ -36653,9 +36660,9 @@
}
},
"node_modules/react-pdf": {
- "version": "9.1.0",
- "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.1.0.tgz",
- "integrity": "sha512-KhPDQE3QshkLdS3b48S5Bldv0N5flob6qwvsiADWdZOS5TMDaIrkRtEs+Dyl6ubRf2jTf9jWmFb6RjWu46lSSg==",
+ "version": "9.1.1",
+ "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.1.1.tgz",
+ "integrity": "sha512-Cn3RTJZMqVOOCgLMRXDamLk4LPGfyB2Np3OwQAUjmHIh47EpuGW1OpAA1Z1GVDLoHx4d5duEDo/YbUkDbr4QFQ==",
"dependencies": {
"clsx": "^2.0.0",
"dequal": "^2.0.3",
diff --git a/package.json b/package.json
index 5c6a0635e819..baf05e92111b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.41-3",
+ "version": "9.0.44-8",
"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.",
@@ -30,10 +30,8 @@
"createDocsRoutes": "ts-node .github/scripts/createDocsRoutes.ts",
"detectRedirectCycle": "ts-node .github/scripts/detectRedirectCycle.ts",
"desktop-build-adhoc": "./scripts/build-desktop.sh adhoc",
- "ios-build": "fastlane ios build_unsigned",
- "android-build": "fastlane android build_local",
- "android-build-e2e": "bundle exec fastlane android build_e2e",
- "android-build-e2edelta": "bundle exec fastlane android build_e2edelta",
+ "ios-build": "bundle exec fastlane ios build_unsigned",
+ "android-build": "bundle exec fastlane android build_local",
"test": "TZ=utc NODE_OPTIONS=--experimental-vm-modules jest",
"typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc",
"lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=0 --cache --cache-location=node_modules/.cache/eslint",
@@ -128,7 +126,7 @@
"react-content-loader": "^7.0.0",
"react-dom": "18.3.1",
"react-error-boundary": "^4.0.11",
- "react-fast-pdf": "1.0.14",
+ "react-fast-pdf": "1.0.15",
"react-map-gl": "^7.1.3",
"react-native": "0.75.2",
"react-native-android-location-enabler": "^2.0.1",
@@ -150,7 +148,7 @@
"react-native-launch-arguments": "^4.0.2",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "2.0.68",
+ "react-native-onyx": "2.0.71",
"react-native-pager-view": "6.4.1",
"react-native-pdf": "6.7.3",
"react-native-performance": "^5.1.0",
@@ -174,22 +172,12 @@
"react-native-web": "^0.19.12",
"react-native-web-sound": "^0.1.3",
"react-native-webview": "13.8.6",
- "react-pdf": "9.1.0",
"react-plaid-link": "3.3.2",
"react-web-config": "^1.0.0",
"react-webcam": "^7.1.1",
"react-window": "^1.8.9"
},
"devDependencies": {
- "@fullstory/babel-plugin-react-native": "^1.2.1",
- "@kie/act-js": "^2.6.2",
- "@kie/mock-github": "2.0.1",
- "@vue/preload-webpack-plugin": "^2.0.0",
- "jest-expo": "51.0.4",
- "jest-when": "^3.5.2",
- "react-compiler-runtime": "file:./lib/react-compiler-runtime",
- "semver": "7.5.2",
- "xlsx": "file:vendor/xlsx-0.20.3.tgz",
"@actions/core": "1.10.0",
"@actions/github": "5.1.1",
"@babel/core": "^7.20.0",
@@ -198,6 +186,7 @@
"@babel/plugin-proposal-export-namespace-from": "^7.18.9",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
+ "@babel/plugin-transform-class-properties": "^7.25.4",
"@babel/preset-env": "^7.20.0",
"@babel/preset-flow": "^7.12.13",
"@babel/preset-react": "^7.10.4",
@@ -209,7 +198,10 @@
"@dword-design/eslint-plugin-import-alias": "^5.0.0",
"@electron/notarize": "^2.1.0",
"@fullstory/babel-plugin-annotate-react": "^2.3.0",
+ "@fullstory/babel-plugin-react-native": "^1.2.1",
"@jest/globals": "^29.5.0",
+ "@kie/act-js": "^2.6.2",
+ "@kie/mock-github": "2.0.1",
"@ngneat/falso": "^7.1.1",
"@octokit/core": "4.0.4",
"@octokit/plugin-paginate-rest": "3.1.0",
@@ -258,6 +250,7 @@
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
"@vercel/ncc": "0.38.1",
+ "@vue/preload-webpack-plugin": "^2.0.0",
"@welldone-software/why-did-you-render": "7.0.1",
"ajv-cli": "^5.0.0",
"babel-jest": "29.4.1",
@@ -265,37 +258,39 @@
"babel-plugin-module-resolver": "^5.0.0",
"babel-plugin-react-compiler": "0.0.0-experimental-334f00b-20240725",
"babel-plugin-react-native-web": "^0.18.7",
- "@babel/plugin-transform-class-properties": "^7.25.4",
"babel-plugin-transform-remove-console": "^6.9.4",
"clean-webpack-plugin": "^4.0.0",
"concurrently": "^8.2.2",
"copy-webpack-plugin": "^10.1.0",
"css-loader": "^6.7.2",
"csv-parse": "^5.5.5",
+ "csv-writer": "^1.6.0",
"diff-so-fancy": "^1.3.0",
"dotenv": "^16.0.3",
"electron": "^29.4.6",
"electron-builder": "25.0.0",
"eslint": "^8.57.0",
"eslint-config-airbnb-typescript": "^18.0.0",
- "eslint-config-expensify": "^2.0.58",
+ "eslint-config-expensify": "^2.0.60",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-deprecation": "^3.0.0",
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-jsdoc": "^46.2.6",
+ "eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-react-compiler": "0.0.0-experimental-9ed098e-20240725",
"eslint-plugin-react-native-a11y": "^3.3.0",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^6.2.2",
"eslint-plugin-you-dont-need-lodash-underscore": "^6.14.0",
- "eslint-plugin-lodash": "^7.4.0",
"html-webpack-plugin": "^5.5.0",
"http-server": "^14.1.1",
"jest": "29.4.1",
"jest-circus": "29.4.1",
"jest-cli": "29.4.1",
"jest-environment-jsdom": "^29.4.1",
+ "jest-expo": "51.0.4",
"jest-transformer-svg": "^2.0.1",
+ "jest-when": "^3.5.2",
"link": "^2.1.1",
"memfs": "^4.6.0",
"onchange": "^7.1.0",
@@ -306,10 +301,12 @@
"prettier": "^2.8.8",
"pusher-js-mock": "^0.3.3",
"react-compiler-healthcheck": "^0.0.0-experimental-ab3118d-20240725",
+ "react-compiler-runtime": "file:./lib/react-compiler-runtime",
"react-is": "^18.3.1",
"react-native-clean-project": "^4.0.0-alpha4.0",
"react-test-renderer": "18.3.1",
"reassure": "^1.0.0-rc.4",
+ "semver": "7.5.2",
"setimmediate": "^1.0.5",
"shellcheck": "^1.1.0",
"source-map": "^0.7.4",
@@ -326,7 +323,8 @@
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^5.0.4",
"webpack-dev-server": "^5.0.4",
- "webpack-merge": "^5.8.0"
+ "webpack-merge": "^5.8.0",
+ "xlsx": "file:vendor/xlsx-0.20.3.tgz"
},
"overrides": {
"react-native": "0.75.2",
@@ -338,7 +336,6 @@
"yargs-parser": "21.1.1",
"@expo/config-plugins": "8.0.4",
"ws": "8.17.1",
- "react-pdf": "9.1.0",
"micromatch": "4.0.8",
"json5": "2.2.2",
"loader-utils": "2.0.4",
diff --git a/patches/react-fast-pdf+1.0.14.patch b/patches/react-fast-pdf+1.0.14.patch
deleted file mode 100644
index 78a47bfb1b58..000000000000
--- a/patches/react-fast-pdf+1.0.14.patch
+++ /dev/null
@@ -1,13 +0,0 @@
-diff --git a/node_modules/react-fast-pdf/dist/PDFPreviewer.js b/node_modules/react-fast-pdf/dist/PDFPreviewer.js
-index 4407807..ea3964d 100644
---- a/node_modules/react-fast-pdf/dist/PDFPreviewer.js
-+++ b/node_modules/react-fast-pdf/dist/PDFPreviewer.js
-@@ -28,7 +28,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
- Object.defineProperty(exports, "__esModule", { value: true });
- // @ts-expect-error - This line imports a module from 'pdfjs-dist' package which lacks TypeScript typings.
- // eslint-disable-next-line import/no-extraneous-dependencies
--const pdf_worker_1 = __importDefault(require("pdfjs-dist/legacy/build/pdf.worker"));
-+const pdf_worker_1 = __importDefault(require("pdfjs-dist/legacy/build/pdf.worker.mjs"));
- const react_1 = __importStar(require("react"));
- const times_1 = __importDefault(require("lodash/times"));
- const prop_types_1 = __importDefault(require("prop-types"));
diff --git a/patches/react-native-pdf+6.7.3.patch b/patches/react-native-pdf+6.7.3+001+initial.patch
similarity index 100%
rename from patches/react-native-pdf+6.7.3.patch
rename to patches/react-native-pdf+6.7.3+001+initial.patch
diff --git a/patches/react-native-pdf+6.7.3+002+fix-incorrect-decoding.patch b/patches/react-native-pdf+6.7.3+002+fix-incorrect-decoding.patch
new file mode 100644
index 000000000000..1061335b85fe
--- /dev/null
+++ b/patches/react-native-pdf+6.7.3+002+fix-incorrect-decoding.patch
@@ -0,0 +1,13 @@
+diff --git a/node_modules/react-native-pdf/index.js b/node_modules/react-native-pdf/index.js
+index bea7af8..bf767c9 100644
+--- a/node_modules/react-native-pdf/index.js
++++ b/node_modules/react-native-pdf/index.js
+@@ -233,7 +233,7 @@ export default class Pdf extends Component {
+ } else {
+ if (this._mounted) {
+ this.setState({
+- path: unescape(uri.replace(/file:\/\//i, '')),
++ path: decodeURIComponent(uri.replace(/file:\/\//i, '')),
+ isDownloaded: true,
+ });
+ }
diff --git a/patches/react-native-vision-camera+4.0.0-beta.13.patch b/patches/react-native-vision-camera+4.0.0-beta.13+001+rn75-compatibility.patch
similarity index 100%
rename from patches/react-native-vision-camera+4.0.0-beta.13.patch
rename to patches/react-native-vision-camera+4.0.0-beta.13+001+rn75-compatibility.patch
diff --git a/patches/react-native-vision-camera+4.0.0-beta.13+002+native-stack-unmount-recycle-camera-session.patch b/patches/react-native-vision-camera+4.0.0-beta.13+002+native-stack-unmount-recycle-camera-session.patch
new file mode 100644
index 000000000000..ac9bda68f9d9
--- /dev/null
+++ b/patches/react-native-vision-camera+4.0.0-beta.13+002+native-stack-unmount-recycle-camera-session.patch
@@ -0,0 +1,55 @@
+diff --git a/node_modules/react-native-vision-camera/ios/RNCameraView.mm b/node_modules/react-native-vision-camera/ios/RNCameraView.mm
+index b90427e..0be4171 100644
+--- a/node_modules/react-native-vision-camera/ios/RNCameraView.mm
++++ b/node_modules/react-native-vision-camera/ios/RNCameraView.mm
+@@ -34,26 +34,43 @@ + (ComponentDescriptorProvider)componentDescriptorProvider
+ return concreteComponentDescriptorProvider();
+ }
+
+-- (instancetype)initWithFrame:(CGRect)frame
+-{
+- self = [super initWithFrame:frame];
+-if (self) {
+- static const auto defaultProps = std::make_shared();
++- (void) initCamera {
++ static const auto defaultProps = std::make_shared();
+ _props = defaultProps;
+
+- //The remaining part of the initializer is standard Objective-C code to create views and layout them with AutoLayout. Here we can change whatever we want to.
++ // The remaining part of the initializer is standard bjective-C code to create views and layout them with utoLayout. Here we can change whatever we want to.
+ _view = [[CameraView alloc] init];
+ _view.delegate = self;
+
+ self.contentView = _view;
+ }
+
+-return self;
++- (instancetype)initWithFrame:(CGRect)frame
++{
++ self = [super initWithFrame:frame];
++ if (self) {
++ [self initCamera];
++ }
++
++ return self;
++}
++
++- (void) prepareForRecycle {
++ [super prepareForRecycle];
++
++ self.contentView = _view;
++ _view.delegate = nil;
++ _view = nil;
++ self.contentView = nil;
+ }
+
+ // why we need this func -> https://reactnative.dev/docs/next/the-new-architecture/pillars-fabric-components#write-the-native-ios-code
+ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
+ {
++ if (_view == nil) {
++ [self initCamera];
++ }
++
+ const auto &newViewProps = *std::static_pointer_cast(props);
+ const auto &oldViewProps = *std::static_pointer_cast(_props);
+
diff --git a/patches/react-pdf+9.1.0.patch b/patches/react-pdf+9.1.0.patch
deleted file mode 100644
index f046202de9c2..000000000000
--- a/patches/react-pdf+9.1.0.patch
+++ /dev/null
@@ -1,24 +0,0 @@
-diff --git a/node_modules/react-pdf/dist/cjs/Document.js b/node_modules/react-pdf/dist/cjs/Document.js
-index ed7114d..43d648b 100644
---- a/node_modules/react-pdf/dist/cjs/Document.js
-+++ b/node_modules/react-pdf/dist/cjs/Document.js
-@@ -281,6 +281,7 @@ const Document = (0, react_1.forwardRef)(function Document(_a, ref) {
- pdfDispatch({ type: 'REJECT', error });
- });
- return () => {
-+ loadingTask._worker.destroy();
- loadingTask.destroy();
- };
- }, [options, pdfDispatch, source]);
-diff --git a/node_modules/react-pdf/dist/esm/Document.js b/node_modules/react-pdf/dist/esm/Document.js
-index 997a370..894e3c9 100644
---- a/node_modules/react-pdf/dist/esm/Document.js
-+++ b/node_modules/react-pdf/dist/esm/Document.js
-@@ -253,6 +253,7 @@ const Document = forwardRef(function Document(_a, ref) {
- pdfDispatch({ type: 'REJECT', error });
- });
- return () => {
-+ loadingTask._worker.destroy();
- loadingTask.destroy();
- };
- }, [options, pdfDispatch, source]);
diff --git a/scripts/aggregateGitHubDataFromUpwork.ts b/scripts/aggregateGitHubDataFromUpwork.ts
new file mode 100644
index 000000000000..f47b2b43e5cc
--- /dev/null
+++ b/scripts/aggregateGitHubDataFromUpwork.ts
@@ -0,0 +1,175 @@
+/**
+ * This script is used for categorizing upwork costs into cost buckets for accounting purposes.
+ *
+ * To run this script from the root of E/App:
+ *
+ * ts-node ./scripts/aggregateGitHubDataFromUpwork.js
+ *
+ * The input file must be a CSV with a single column containing just the GitHub issue number. The CSV must have a single header row.
+ */
+import {getOctokitOptions, GitHub} from '@actions/github/lib/utils';
+import {paginateRest} from '@octokit/plugin-paginate-rest';
+import {throttling} from '@octokit/plugin-throttling';
+import {createObjectCsvWriter} from 'csv-writer';
+import fs from 'fs';
+
+type OctokitOptions = {method: string; url: string; request: {retryCount: number}};
+type IssueType = 'bug' | 'feature' | 'other';
+
+if (process.argv.length < 3) {
+ throw new Error('Error: must provide filepath for CSV data');
+}
+
+if (process.argv.length < 4) {
+ throw new Error('Error: must provide GitHub token');
+}
+
+if (process.argv.length < 5) {
+ throw new Error('Error: must provide output file path');
+}
+
+// Get filepath for csv
+const inputFilepath = process.argv.at(2);
+if (!inputFilepath) {
+ throw new Error('Error: must provide filepath for CSV data');
+}
+
+// Get GitHub token
+const token = (process.argv.at(3) ?? '').trim();
+if (!token) {
+ throw new Error('Error: must provide GitHub token');
+}
+
+const Octokit = GitHub.plugin(throttling, paginateRest);
+const octokit = new Octokit(
+ getOctokitOptions(token, {
+ throttle: {
+ onRateLimit: (retryAfter: number, options: OctokitOptions) => {
+ console.warn(`Request quota exhausted for request ${options.method} ${options.url}`);
+
+ // Retry once after hitting a rate limit error, then give up
+ if (options.request.retryCount <= 1) {
+ console.log(`Retrying after ${retryAfter} seconds!`);
+ return true;
+ }
+ },
+ onAbuseLimit: (retryAfter: number, options: OctokitOptions) => {
+ // does not retry, only logs a warning
+ console.warn(`Abuse detected for request ${options.method} ${options.url}`);
+ },
+ },
+ }),
+);
+
+// Get output filepath
+const outputFilepath = process.argv.at(4);
+if (!outputFilepath) {
+ throw new Error('Error: must provide output file path');
+}
+
+// Get data from csv
+const issues = fs
+ .readFileSync(inputFilepath)
+ .toString()
+ .split('\n')
+ .reduce((acc, issue) => {
+ if (!issue) {
+ return acc;
+ }
+ const issueNum = Number(issue.trim());
+ if (!issueNum) {
+ return acc;
+ }
+ acc.push(issueNum);
+ return acc;
+ }, [] as number[]);
+
+const csvWriter = createObjectCsvWriter({
+ path: outputFilepath,
+ header: [
+ {id: 'number', title: 'number'},
+ {id: 'title', title: 'title'},
+ {id: 'labels', title: 'labels'},
+ {id: 'type', title: 'type'},
+ {id: 'capSWProjects', title: 'capSWProjects'},
+ ],
+});
+
+function getIssueTypeFromLabels(labels: string[]): IssueType {
+ if (labels.includes('NewFeature')) {
+ return 'feature';
+ }
+ if (labels.includes('Bug')) {
+ return 'bug';
+ }
+ return 'other';
+}
+
+/**
+ * Returns a comma-delimited string with all projects associated with the given issue.
+ */
+async function getProjectsForIssue(issueNumber: number): Promise {
+ const response = await octokit.graphql(
+ `
+ {
+ repository(owner: "Expensify", name: "App") {
+ issue(number: ${issueNumber}) {
+ projectsV2(last: 30) {
+ nodes {
+ title
+ }
+ }
+ }
+ }
+ }
+ `,
+ );
+ return (response as {repository: {issue: {projectsV2: {nodes: Array<{title: string}>}}}}).repository.issue.projectsV2.nodes.map((node) => node.title).join(',');
+}
+
+async function getGitHubData() {
+ const gitHubData = [];
+ // Note: we fetch issues in a loop rather than in parallel to help address rate limiting issues with a PAT
+ for (const issueNumber of issues) {
+ console.info(`Fetching ${issueNumber}`);
+ const result = await octokit.rest.issues
+ .get({
+ owner: 'Expensify',
+ repo: 'App',
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ issue_number: issueNumber,
+ })
+ .catch(() => {
+ console.warn(`Error getting issue ${issueNumber}`);
+ });
+ if (result) {
+ const issue = result.data;
+ const labels = issue.labels.reduce((acc, label) => {
+ if (typeof label === 'string') {
+ acc.push(label);
+ } else if (label.name) {
+ acc.push(label.name);
+ }
+ return acc;
+ }, [] as string[]);
+ const type = getIssueTypeFromLabels(labels);
+ let capSWProjects = '';
+ if (type === 'feature') {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+ capSWProjects = await getProjectsForIssue(issueNumber);
+ }
+ gitHubData.push({
+ number: issue.number,
+ title: issue.title,
+ labels,
+ type,
+ capSWProjects,
+ });
+ }
+ }
+ return gitHubData;
+}
+
+getGitHubData()
+ .then((gitHubData) => csvWriter.writeRecords(gitHubData))
+ .then(() => console.info(`Done ā
Wrote file to ${outputFilepath}`));
diff --git a/scripts/release-profile.ts b/scripts/release-profile.ts
index a83fb55fa5ff..615f009d743d 100755
--- a/scripts/release-profile.ts
+++ b/scripts/release-profile.ts
@@ -36,7 +36,7 @@ if (cpuProfiles.length === 0) {
process.exit(1);
} else {
// Construct the command
- const cpuprofileName = cpuProfiles[0];
+ const cpuprofileName = cpuProfiles.at(0);
const command = `npx react-native-release-profiler --local "${cpuprofileName}" --sourcemap-path "${sourcemapPath}"`;
console.log(`Executing: ${command}`);
diff --git a/src/CONST.ts b/src/CONST.ts
index 4ca9b45f13df..9a6bf21db303 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -84,6 +84,12 @@ const onboardingChoices = {
...backendOnboardingChoices,
} as const;
+const signupQualifiers = {
+ INDIVIDUAL: 'individual',
+ VSB: 'vsb',
+ SMB: 'smb',
+} as const;
+
const onboardingEmployerOrSubmitMessage: OnboardingMessageType = {
message: 'Getting paid back is as easy as sending a message. Letās go over the basics.',
video: {
@@ -171,7 +177,7 @@ const CONST = {
},
// Note: Group and Self-DM excluded as these are not tied to a Workspace
- WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT, chatTypes.INVOICE],
+ WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT],
ANDROID_PACKAGE_NAME,
WORKSPACE_ENABLE_FEATURE_REDIRECT_DELAY: 100,
ANIMATED_HIGHLIGHT_ENTRY_DELAY: 50,
@@ -839,6 +845,7 @@ const CONST = {
SHARE: 'SHARE', // OldDot Action
STRIPE_PAID: 'STRIPEPAID', // OldDot Action
SUBMITTED: 'SUBMITTED',
+ SUBMITTED_AND_CLOSED: 'SUBMITTEDCLOSED',
TAKE_CONTROL: 'TAKECONTROL', // OldDot Action
TASK_CANCELLED: 'TASKCANCELLED',
TASK_COMPLETED: 'TASKCOMPLETED',
@@ -1839,6 +1846,7 @@ const CONST = {
DATE_OF_BIRTH: 1,
ADDRESS: 2,
PHONE_NUMBER: 3,
+ CONFIRM: 4,
},
INDEX_LIST: ['1', '2', '3', '4'],
},
@@ -2058,6 +2066,7 @@ const CONST = {
INVOICE: 'invoice',
SUBMIT: 'submit',
TRACK: 'track',
+ CREATE: 'create',
},
REQUEST_TYPE: {
DISTANCE: 'distance',
@@ -2788,6 +2797,7 @@ const CONST = {
TITLE_CHARACTER_LIMIT: 100,
DESCRIPTION_LIMIT: 1000,
WORKSPACE_NAME_CHARACTER_LIMIT: 80,
+ STATE_CHARACTER_LIMIT: 32,
AVATAR_CROP_MODAL: {
// The next two constants control what is min and max value of the image crop scale.
@@ -4203,7 +4213,7 @@ const CONST = {
PADDING: 32,
DEFAULT_ZOOM: 15,
SINGLE_MARKER_ZOOM: 15,
- DEFAULT_COORDINATE: [-122.4021, 37.7911],
+ DEFAULT_COORDINATE: [-122.4021, 37.7911] as [number, number],
STYLE_URL: 'mapbox://styles/expensify/cllcoiqds00cs01r80kp34tmq',
ANIMATION_DURATION_ON_CENTER_ME: 1000,
CENTER_BUTTON_FADE_DURATION: 300,
@@ -4215,7 +4225,6 @@ const CONST = {
},
EVENTS: {
SCROLLING: 'scrolling',
- ON_RETURN_TO_OLD_DOT: 'onReturnToOldDot',
},
CHAT_HEADER_LOADER_HEIGHT: 36,
@@ -4459,9 +4468,11 @@ const CONST = {
WELCOME_VIDEO_URL: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`,
+ QUALIFIER_PARAM: 'signupQualifier',
ONBOARDING_INTRODUCTION: 'Letās get you set up š§',
ONBOARDING_CHOICES: {...onboardingChoices},
SELECTABLE_ONBOARDING_CHOICES: {...selectableOnboardingChoices},
+ ONBOARDING_SIGNUP_QUALIFIERS: {...signupQualifiers},
ONBOARDING_INVITE_TYPES: {...onboardingInviteTypes},
ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE: 'What would you like to do with this expense?',
ONBOARDING_CONCIERGE: {
@@ -4550,12 +4561,12 @@ const CONST = {
'Hereās how to set up categories:\n' +
'\n' +
'1. Click your profile picture.\n' +
- '2. Go to Workspaces.\n' +
+ '2. Go to *Workspaces*.\n' +
'3. Select your workspace.\n' +
'4. Click *Categories*.\n' +
- '5. Enable and disable default categories.\n' +
- '6. Click *Add categories* to make your own.\n' +
- '7. For more controls like requiring a category for every expense, click *Settings*.\n' +
+ '5. Add or import your own categories.\n' +
+ "6. Disable any default categories you don't need.\n" +
+ '7. Require a category for every expense in *Settings*.\n' +
'\n' +
`[Take me to workspace category settings](${workspaceCategoriesLink}).`,
},
diff --git a/src/Expensify.tsx b/src/Expensify.tsx
index 62e7839b21f0..f5d4655c4861 100644
--- a/src/Expensify.tsx
+++ b/src/Expensify.tsx
@@ -20,7 +20,6 @@ import {updateLastRoute} from './libs/actions/App';
import * as EmojiPickerAction from './libs/actions/EmojiPickerAction';
import * as Report from './libs/actions/Report';
import * as User from './libs/actions/User';
-import {handleHybridAppOnboarding} from './libs/actions/Welcome';
import * as ActiveClientManager from './libs/ActiveClientManager';
import FS from './libs/Fullstory';
import * as Growl from './libs/Growl';
@@ -99,7 +98,6 @@ function Expensify({
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
const [session] = useOnyx(ONYXKEYS.SESSION);
const [lastRoute] = useOnyx(ONYXKEYS.LAST_ROUTE);
- const [tryNewDotData] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT);
const [shouldShowRequire2FAModal, setShouldShowRequire2FAModal] = useState(false);
useEffect(() => {
@@ -118,14 +116,6 @@ function Expensify({
setAttemptedToOpenPublicRoom(true);
}, [isCheckingPublicRoom]);
- useEffect(() => {
- if (splashScreenState !== CONST.BOOT_SPLASH_STATE.HIDDEN || tryNewDotData === undefined) {
- return;
- }
-
- handleHybridAppOnboarding();
- }, [splashScreenState, tryNewDotData]);
-
const isAuthenticated = useMemo(() => !!(session?.authToken ?? null), [session]);
const autoAuthState = useMemo(() => session?.autoAuthState ?? '', [session]);
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index cb8bf2fdb5d3..df1413620c20 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -32,6 +32,7 @@ const ONYXKEYS = {
/** Note: These are Persisted Requests - not all requests in the main queue as the key name might lead one to believe */
PERSISTED_REQUESTS: 'networkRequestQueue',
+ PERSISTED_ONGOING_REQUESTS: 'networkOngoingRequestQueue',
/** Stores current date */
CURRENT_DATE: 'currentDate',
@@ -329,6 +330,9 @@ const ONYXKEYS = {
/** Onboarding Purpose selected by the user during Onboarding flow */
ONBOARDING_PURPOSE_SELECTED: 'onboardingPurposeSelected',
+ /** Onboarding customized choices to display to the user based on their profile when signing up */
+ ONBOARDING_CUSTOM_CHOICES: 'onboardingCustomChoices',
+
/** Onboarding error message to be displayed to the user */
ONBOARDING_ERROR_MESSAGE: 'onboardingErrorMessage',
@@ -419,9 +423,15 @@ const ONYXKEYS = {
/** Stores the route to open after changing app permission from settings */
LAST_ROUTE: 'lastRoute',
+ /** Stores the information if user loaded the Onyx state through Import feature */
+ IS_USING_IMPORTED_STATE: 'isUsingImportedState',
+
/** Stores the information about the saved searches */
SAVED_SEARCHES: 'nvp_savedSearches',
+ /** Stores the information about the recent searches */
+ RECENT_SEARCHES: 'nvp_recentSearches',
+
/** Stores recently used currencies */
RECENTLY_USED_CURRENCIES: 'nvp_recentlyUsedCurrencies',
@@ -849,12 +859,14 @@ type OnyxValuesMapping = {
// ONYXKEYS.NVP_TRYNEWDOT is HybridApp onboarding data
[ONYXKEYS.NVP_TRYNEWDOT]: OnyxTypes.TryNewDot;
+ [ONYXKEYS.RECENT_SEARCHES]: Record;
[ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch;
[ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[];
[ONYXKEYS.ACTIVE_CLIENTS]: string[];
[ONYXKEYS.DEVICE_ID]: string;
[ONYXKEYS.IS_SIDEBAR_LOADED]: boolean;
[ONYXKEYS.PERSISTED_REQUESTS]: OnyxTypes.Request[];
+ [ONYXKEYS.PERSISTED_ONGOING_REQUESTS]: OnyxTypes.Request;
[ONYXKEYS.CURRENT_DATE]: string;
[ONYXKEYS.CREDENTIALS]: OnyxTypes.Credentials;
[ONYXKEYS.STASHED_CREDENTIALS]: OnyxTypes.Credentials;
@@ -944,6 +956,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.MAX_CANVAS_HEIGHT]: number;
[ONYXKEYS.MAX_CANVAS_WIDTH]: number;
[ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: OnboardingPurposeType;
+ [ONYXKEYS.ONBOARDING_CUSTOM_CHOICES]: OnboardingPurposeType[] | [];
[ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string;
[ONYXKEYS.ONBOARDING_POLICY_ID]: string;
[ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string;
@@ -983,9 +996,9 @@ type OnyxValuesMapping = {
[ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflowOnyx;
[ONYXKEYS.IMPORTED_SPREADSHEET]: OnyxTypes.ImportedSpreadsheet;
[ONYXKEYS.LAST_ROUTE]: string;
+ [ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean;
[ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP]: boolean;
};
-
type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping;
type OnyxCollectionKey = keyof OnyxCollectionValuesMapping;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index dfcb42d3c4fe..9c429dd3e909 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -149,6 +149,7 @@ const ROUTES = {
SETTINGS_ABOUT: 'settings/about',
SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links',
SETTINGS_WALLET: 'settings/wallet',
+ SETTINGS_WALLET_VERIFY_ACCOUNT: {route: 'settings/wallet/verify', getRoute: (backTo?: string) => getUrlWithBackToParam('settings/wallet/verify', backTo)},
SETTINGS_WALLET_DOMAINCARD: {
route: 'settings/wallet/card/:cardID?',
getRoute: (cardID: string) => `settings/wallet/card/${cardID}` as const,
@@ -1274,10 +1275,7 @@ const ROUTES = {
route: 'restricted-action/workspace/:policyID',
getRoute: (policyID: string) => `restricted-action/workspace/${policyID}` as const,
},
- MISSING_PERSONAL_DETAILS: {
- route: 'missing-personal-details/workspace/:policyID',
- getRoute: (policyID: string) => `missing-personal-details/workspace/${policyID}` as const,
- },
+ MISSING_PERSONAL_DETAILS: 'missing-personal-details',
POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR: {
route: 'settings/workspaces/:policyID/accounting/netsuite/subsidiary-selector',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/subsidiary-selector` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 395f1c4d5fb1..9a94d612dc80 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -112,6 +112,7 @@ const SCREENS = {
CARD_ACTIVATE: 'Settings_Wallet_Card_Activate',
REPORT_VIRTUAL_CARD_FRAUD: 'Settings_Wallet_ReportVirtualCardFraud',
CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS: 'Settings_Wallet_Cards_Digital_Details_Update_Address',
+ VERIFY_ACCOUNT: 'Settings_Wallet_Verify_Account',
},
EXIT_SURVEY: {
diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx
index 8d3e311c7c61..9b5d21743bef 100644
--- a/src/components/AccountSwitcher.tsx
+++ b/src/components/AccountSwitcher.tsx
@@ -50,7 +50,7 @@ function AccountSwitcher() {
const canSwitchAccounts = canUseNewDotCopilot && (delegators.length > 0 || isActingAsDelegate);
const createBaseMenuItem = (personalDetails: PersonalDetails | undefined, errors?: Errors, additionalProps: MenuItemWithLink = {}): MenuItemWithLink => {
- const error = Object.values(errors ?? {})[0] ?? '';
+ const error = Object.values(errors ?? {}).at(0) ?? '';
return {
title: personalDetails?.displayName ?? personalDetails?.login,
description: Str.removeSMSDomain(personalDetails?.login ?? ''),
diff --git a/src/components/AddPaymentMethodMenu.tsx b/src/components/AddPaymentMethodMenu.tsx
index 5621c031f959..0057438e3913 100644
--- a/src/components/AddPaymentMethodMenu.tsx
+++ b/src/components/AddPaymentMethodMenu.tsx
@@ -2,26 +2,22 @@ import type {RefObject} from 'react';
import React, {useEffect, useState} from 'react';
import type {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import useLocalize from '@hooks/useLocalize';
+import {completePaymentOnboarding} from '@libs/actions/IOU';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {AnchorPosition} from '@src/styles';
-import type {Report, Session} from '@src/types/onyx';
+import type {Report} from '@src/types/onyx';
import type AnchorAlignment from '@src/types/utils/AnchorAlignment';
import * as Expensicons from './Icon/Expensicons';
import type {PaymentMethod} from './KYCWall/types';
import type BaseModalProps from './Modal/types';
import PopoverMenu from './PopoverMenu';
-type AddPaymentMethodMenuOnyxProps = {
- /** Session info for the currently logged-in user. */
- session: OnyxEntry;
-};
-
-type AddPaymentMethodMenuProps = AddPaymentMethodMenuOnyxProps & {
+type AddPaymentMethodMenuProps = {
/** Should the component be visible? */
isVisible: boolean;
@@ -58,11 +54,11 @@ function AddPaymentMethodMenu({
anchorRef,
iouReport,
onItemSelected,
- session,
shouldShowPersonalBankAccountOption = false,
}: AddPaymentMethodMenuProps) {
const {translate} = useLocalize();
const [restoreFocusType, setRestoreFocusType] = useState();
+ const [session] = useOnyx(ONYXKEYS.SESSION);
// Users can choose to pay with business bank account in case of Expense reports or in case of P2P IOU report
// which then starts a bottom up flow and creates a Collect workspace where the payer is an admin and payee is an employee.
@@ -80,6 +76,7 @@ function AddPaymentMethodMenu({
return;
}
+ completePaymentOnboarding(CONST.PAYMENT_SELECTED.PBA);
onItemSelected(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT);
}, [isPersonalOnlyOption, isVisible, onItemSelected]);
@@ -108,6 +105,7 @@ function AddPaymentMethodMenu({
text: translate('common.personalBankAccount'),
icon: Expensicons.Bank,
onSelected: () => {
+ completePaymentOnboarding(CONST.PAYMENT_SELECTED.PBA);
onItemSelected(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT);
},
},
@@ -118,7 +116,10 @@ function AddPaymentMethodMenu({
{
text: translate('common.businessBankAccount'),
icon: Expensicons.Building,
- onSelected: () => onItemSelected(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT),
+ onSelected: () => {
+ completePaymentOnboarding(CONST.PAYMENT_SELECTED.BBA);
+ onItemSelected(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT);
+ },
},
]
: []),
@@ -140,8 +141,4 @@ function AddPaymentMethodMenu({
AddPaymentMethodMenu.displayName = 'AddPaymentMethodMenu';
-export default withOnyx({
- session: {
- key: ONYXKEYS.SESSION,
- },
-})(AddPaymentMethodMenu);
+export default AddPaymentMethodMenu;
diff --git a/src/components/AddPlaidBankAccount.tsx b/src/components/AddPlaidBankAccount.tsx
index 4de286183ea8..11b0010ed253 100644
--- a/src/components/AddPlaidBankAccount.tsx
+++ b/src/components/AddPlaidBankAccount.tsx
@@ -173,7 +173,7 @@ function AddPlaidBankAccount({
const {icon, iconSize, iconStyles} = getBankIcon({styles});
const plaidErrors = plaidData?.errors;
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
- const plaidDataErrorMessage = !isEmptyObject(plaidErrors) ? (Object.values(plaidErrors)[0] as string) : '';
+ const plaidDataErrorMessage = !isEmptyObject(plaidErrors) ? (Object.values(plaidErrors).at(0) as string) : '';
const bankName = plaidData?.bankName;
/**
diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx
index 8ba50e395019..4470481d2be6 100644
--- a/src/components/AddressForm.tsx
+++ b/src/components/AddressForm.tsx
@@ -206,7 +206,7 @@ function AddressForm({
aria-label={translate('common.stateOrProvince')}
role={CONST.ROLE.PRESENTATION}
value={state}
- maxLength={CONST.FORM_CHARACTER_LIMIT}
+ maxLength={CONST.STATE_CHARACTER_LIMIT}
spellCheck={false}
onValueChange={onAddressChanged}
shouldSaveDraft={shouldSaveDraft}
diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx
index 366366423324..975ea6c548c0 100644
--- a/src/components/AttachmentPicker/index.native.tsx
+++ b/src/components/AttachmentPicker/index.native.tsx
@@ -22,12 +22,7 @@ import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import type IconAsset from '@src/types/utils/IconAsset';
import launchCamera from './launchCamera/launchCamera';
-import type BaseAttachmentPickerProps from './types';
-
-type AttachmentPickerProps = BaseAttachmentPickerProps & {
- /** If this value is true, then we exclude Camera option. */
- shouldHideCameraOption?: boolean;
-};
+import type AttachmentPickerProps from './types';
type Item = {
/** The icon associated with the item. */
@@ -112,7 +107,13 @@ const getDataForUpload = (fileData: FileResponse): Promise => {
* a callback. This is the ios/android implementation
* opening a modal with attachment options
*/
-function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, shouldHideCameraOption = false}: AttachmentPickerProps) {
+function AttachmentPicker({
+ type = CONST.ATTACHMENT_PICKER_TYPE.FILE,
+ children,
+ shouldHideCameraOption = false,
+ shouldHideGalleryOption = false,
+ shouldValidateImage = true,
+}: AttachmentPickerProps) {
const styles = useThemeStyles();
const [isVisible, setIsVisible] = useState(false);
@@ -177,7 +178,10 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s
const uri = manipResult.uri;
const convertedAsset = {
uri,
- name: uri.substring(uri.lastIndexOf('/') + 1).split('?')[0],
+ name: uri
+ .substring(uri.lastIndexOf('/') + 1)
+ .split('?')
+ .at(0),
type: 'image/jpeg',
width: manipResult.width,
height: manipResult.height,
@@ -218,17 +222,19 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s
const menuItemData: Item[] = useMemo(() => {
const data: Item[] = [
- {
- icon: Expensicons.Gallery,
- textTranslationKey: 'attachmentPicker.chooseFromGallery',
- pickAttachment: () => showImagePicker(launchImageLibrary),
- },
{
icon: Expensicons.Paperclip,
textTranslationKey: 'attachmentPicker.chooseDocument',
pickAttachment: showDocumentPicker,
},
];
+ if (!shouldHideGalleryOption) {
+ data.unshift({
+ icon: Expensicons.Gallery,
+ textTranslationKey: 'attachmentPicker.chooseFromGallery',
+ pickAttachment: () => showImagePicker(launchImageLibrary),
+ });
+ }
if (!shouldHideCameraOption) {
data.unshift({
icon: Expensicons.Camera,
@@ -238,7 +244,7 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s
}
return data;
- }, [showDocumentPicker, showImagePicker, shouldHideCameraOption]);
+ }, [showDocumentPicker, shouldHideGalleryOption, shouldHideCameraOption, showImagePicker]);
const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: menuItemData.length - 1, isActive: isVisible});
@@ -318,6 +324,26 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s
width: ('width' in fileData && fileData.width) || undefined,
height: ('height' in fileData && fileData.height) || undefined,
};
+
+ if (!shouldValidateImage && fileDataName && Str.isImage(fileDataName)) {
+ ImageSize.getSize(fileDataUri)
+ .then(({width, height}) => {
+ fileDataObject.width = width;
+ fileDataObject.height = height;
+ return fileDataObject;
+ })
+ .then((file) => {
+ getDataForUpload(file)
+ .then((result) => {
+ completeAttachmentSelection.current(result);
+ })
+ .catch((error: Error) => {
+ showGeneralAlert(error.message);
+ throw error;
+ });
+ });
+ return;
+ }
/* eslint-enable @typescript-eslint/prefer-nullish-coalescing */
if (fileDataName && Str.isImage(fileDataName)) {
ImageSize.getSize(fileDataUri)
@@ -331,7 +357,7 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s
return validateAndCompleteAttachmentSelection(fileDataObject);
}
},
- [validateAndCompleteAttachmentSelection, showImageCorruptionAlert],
+ [validateAndCompleteAttachmentSelection, showImageCorruptionAlert, shouldValidateImage, showGeneralAlert],
);
/**
@@ -363,8 +389,11 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s
if (focusedIndex === -1) {
return;
}
- selectItem(menuItemData[focusedIndex]);
- setFocusedIndex(-1); // Reset the focusedIndex on selecting any menu
+ const item = menuItemData.at(focusedIndex);
+ if (item) {
+ selectItem(item);
+ setFocusedIndex(-1); // Reset the focusedIndex on selecting any menu
+ }
},
{
isActive: isVisible,
diff --git a/src/components/AttachmentPicker/types.ts b/src/components/AttachmentPicker/types.ts
index 057ec72de27e..ee9d39aabef3 100644
--- a/src/components/AttachmentPicker/types.ts
+++ b/src/components/AttachmentPicker/types.ts
@@ -42,6 +42,13 @@ type AttachmentPickerProps = {
type?: ValueOf;
acceptedFileTypes?: Array>;
+
+ shouldHideCameraOption?: boolean;
+
+ shouldHideGalleryOption?: boolean;
+
+ /** Whether to validate the image and show the alert or not. */
+ shouldValidateImage?: boolean;
};
export default AttachmentPickerProps;
diff --git a/src/components/Attachments/AttachmentCarousel/index.native.tsx b/src/components/Attachments/AttachmentCarousel/index.native.tsx
index e0f7571af8c7..a8eb614202a7 100644
--- a/src/components/Attachments/AttachmentCarousel/index.native.tsx
+++ b/src/components/Attachments/AttachmentCarousel/index.native.tsx
@@ -1,6 +1,6 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {Keyboard, View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import type {Attachment, AttachmentSource} from '@components/Attachments/types';
import BlockingView from '@components/BlockingViews/BlockingView';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
@@ -15,46 +15,56 @@ import CarouselButtons from './CarouselButtons';
import extractAttachments from './extractAttachments';
import type {AttachmentCarouselPagerHandle} from './Pager';
import AttachmentCarouselPager from './Pager';
-import type {AttachmentCaraouselOnyxProps, AttachmentCarouselProps} from './types';
+import type {AttachmentCarouselProps} from './types';
import useCarouselArrows from './useCarouselArrows';
-function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, onClose, type, accountID}: AttachmentCarouselProps) {
+function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibility, onClose, type, accountID}: AttachmentCarouselProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const pagerRef = useRef(null);
+ const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, {canEvict: false});
+ const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {canEvict: false});
const [page, setPage] = useState();
const [attachments, setAttachments] = useState([]);
const {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows} = useCarouselArrows();
const [activeSource, setActiveSource] = useState(source);
-
const compareImage = useCallback((attachment: Attachment) => attachment.source === source, [source]);
useEffect(() => {
const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined;
- let targetAttachments: Attachment[] = [];
+ let newAttachments: Attachment[] = [];
if (type === CONST.ATTACHMENT_TYPE.NOTE && accountID) {
- targetAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID});
+ newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID});
} else {
- targetAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions});
+ newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions});
}
- const initialPage = targetAttachments.findIndex(compareImage);
+ let newIndex = newAttachments.findIndex(compareImage);
+ const index = attachments.findIndex(compareImage);
+
+ // If newAttachments includes an attachment with the same index, update newIndex to that index.
+ // Previously, uploading an attachment offline would dismiss the modal when the image was previewed and the connection was restored.
+ // Now, instead of dismissing the modal, we replace it with the new attachment that has the same index.
+ if (newIndex === -1 && index !== -1 && newAttachments.at(index)) {
+ newIndex = index;
+ }
- // Dismiss the modal when deleting an attachment during its display in preview.
- if (initialPage === -1 && attachments.find(compareImage)) {
+ // If no matching attachment with the same index, dismiss the modal
+ if (newIndex === -1 && index !== -1 && attachments.at(index)) {
Navigation.dismissModal();
} else {
- setPage(initialPage);
- setAttachments(targetAttachments);
+ setPage(newIndex);
+ setAttachments(newAttachments);
// Update the download button visibility in the parent modal
if (setDownloadButtonVisibility) {
- setDownloadButtonVisibility(initialPage !== -1);
+ setDownloadButtonVisibility(newIndex !== -1);
}
+ const attachment = newAttachments.at(newIndex);
// Update the parent modal's state with the source and name from the mapped attachments
- if (targetAttachments[initialPage] !== undefined && onNavigate) {
- onNavigate(targetAttachments[initialPage]);
+ if (newIndex !== -1 && attachment !== undefined && onNavigate) {
+ onNavigate(attachment);
}
}
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
@@ -66,13 +76,15 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
Keyboard.dismiss();
setShouldShowArrows(true);
- const item = attachments[newPageIndex];
+ const item = attachments.at(newPageIndex);
setPage(newPageIndex);
- setActiveSource(item.source);
-
- if (onNavigate) {
- onNavigate(item);
+ if (newPageIndex >= 0 && item) {
+ setActiveSource(item.source);
+ if (onNavigate) {
+ onNavigate(item);
+ }
+ onNavigate?.(item);
}
},
[setShouldShowArrows, attachments, onNavigate],
@@ -144,13 +156,4 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
AttachmentCarousel.displayName = 'AttachmentCarousel';
-export default withOnyx({
- parentReportActions: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`,
- canEvict: false,
- },
- reportActions: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
- canEvict: false,
- },
-})(AttachmentCarousel);
+export default AttachmentCarousel;
diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx
index 72e0f17aa310..a1408aaf400e 100644
--- a/src/components/Attachments/AttachmentCarousel/index.tsx
+++ b/src/components/Attachments/AttachmentCarousel/index.tsx
@@ -5,7 +5,7 @@ import type {ListRenderItemInfo} from 'react-native';
import {Keyboard, PixelRatio, View} from 'react-native';
import type {GestureType} from 'react-native-gesture-handler';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import Animated, {scrollTo, useAnimatedRef, useSharedValue} from 'react-native-reanimated';
import type {Attachment, AttachmentSource} from '@components/Attachments/types';
import BlockingView from '@components/BlockingViews/BlockingView';
@@ -26,7 +26,7 @@ import CarouselButtons from './CarouselButtons';
import CarouselItem from './CarouselItem';
import extractAttachments from './extractAttachments';
import AttachmentCarouselPagerContext from './Pager/AttachmentCarouselPagerContext';
-import type {AttachmentCaraouselOnyxProps, AttachmentCarouselProps, UpdatePageProps} from './types';
+import type {AttachmentCarouselProps, UpdatePageProps} from './types';
import useCarouselArrows from './useCarouselArrows';
import useCarouselContextEvents from './useCarouselContextEvents';
@@ -38,7 +38,7 @@ const viewabilityConfig = {
const MIN_FLING_VELOCITY = 500;
-function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose}: AttachmentCarouselProps) {
+function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose}: AttachmentCarouselProps) {
const theme = useTheme();
const {translate} = useLocalize();
const {windowWidth} = useWindowDimensions();
@@ -48,7 +48,8 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
const scrollRef = useAnimatedRef>>();
const nope = useSharedValue(false);
const pagerRef = useRef(null);
-
+ const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, {canEvict: false});
+ const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {canEvict: false});
const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();
const modalStyles = styles.centeredModalStyles(shouldUseNarrowLayout, true);
@@ -73,14 +74,14 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
useEffect(() => {
const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined;
- let targetAttachments: Attachment[] = [];
+ let newAttachments: Attachment[] = [];
if (type === CONST.ATTACHMENT_TYPE.NOTE && accountID) {
- targetAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID});
+ newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID});
} else {
- targetAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions: reportActions ?? undefined});
+ newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions: reportActions ?? undefined});
}
- if (isEqual(attachments, targetAttachments)) {
+ if (isEqual(attachments, newAttachments)) {
if (attachments.length === 0) {
setPage(-1);
setDownloadButtonVisibility?.(false);
@@ -88,23 +89,32 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
return;
}
- const initialPage = targetAttachments.findIndex(compareImage);
+ let newIndex = newAttachments.findIndex(compareImage);
+ const index = attachments.findIndex(compareImage);
+
+ // If newAttachments includes an attachment with the same index, update newIndex to that index.
+ // Previously, uploading an attachment offline would dismiss the modal when the image was previewed and the connection was restored.
+ // Now, instead of dismissing the modal, we replace it with the new attachment that has the same index.
+ if (newIndex === -1 && index !== -1 && newAttachments.at(index)) {
+ newIndex = index;
+ }
- // Dismiss the modal when deleting an attachment during its display in preview.
- if (initialPage === -1 && attachments.find(compareImage)) {
+ // If no matching attachment with the same index, dismiss the modal
+ if (newIndex === -1 && index !== -1 && attachments.at(index)) {
Navigation.dismissModal();
} else {
- setPage(initialPage);
- setAttachments(targetAttachments);
+ setPage(newIndex);
+ setAttachments(newAttachments);
// Update the download button visibility in the parent modal
if (setDownloadButtonVisibility) {
- setDownloadButtonVisibility(initialPage !== -1);
+ setDownloadButtonVisibility(newIndex !== -1);
}
+ const attachment = newAttachments.at(newIndex);
// Update the parent modal's state with the source and name from the mapped attachments
- if (targetAttachments[initialPage] !== undefined && onNavigate) {
- onNavigate(targetAttachments[initialPage]);
+ if (newIndex !== -1 && attachment !== undefined && onNavigate) {
+ onNavigate(attachment);
}
}
}, [report.privateNotes, reportActions, parentReportActions, compareImage, report.parentReportActionID, attachments, setDownloadButtonVisibility, onNavigate, accountID, type]);
@@ -131,7 +141,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
// Since we can have only one item in view at a time, we can use the first item in the array
// to get the index of the current page
- const entry = viewableItems[0];
+ const entry = viewableItems.at(0);
if (!entry) {
setActiveSource(null);
return;
@@ -158,9 +168,9 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
}
const nextIndex = page + deltaSlide;
- const nextItem = attachments[nextIndex];
+ const nextItem = attachments.at(nextIndex);
- if (!nextItem || !scrollRef.current) {
+ if (!nextItem || nextIndex < 0 || !scrollRef.current) {
return;
}
@@ -306,13 +316,4 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
AttachmentCarousel.displayName = 'AttachmentCarousel';
-export default withOnyx({
- parentReportActions: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`,
- canEvict: false,
- },
- reportActions: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
- canEvict: false,
- },
-})(AttachmentCarousel);
+export default AttachmentCarousel;
diff --git a/src/components/Attachments/AttachmentCarousel/types.ts b/src/components/Attachments/AttachmentCarousel/types.ts
index d31ebbd328cd..c77e7b0f79d5 100644
--- a/src/components/Attachments/AttachmentCarousel/types.ts
+++ b/src/components/Attachments/AttachmentCarousel/types.ts
@@ -1,23 +1,14 @@
import type {ViewToken} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import type {Attachment, AttachmentSource} from '@components/Attachments/types';
import type CONST from '@src/CONST';
-import type {Report, ReportActions} from '@src/types/onyx';
+import type {Report} from '@src/types/onyx';
type UpdatePageProps = {
viewableItems: ViewToken[];
};
-type AttachmentCaraouselOnyxProps = {
- /** Object of report actions for this report */
- reportActions: OnyxEntry;
-
- /** The report actions of the parent report */
- parentReportActions: OnyxEntry;
-};
-
-type AttachmentCarouselProps = AttachmentCaraouselOnyxProps & {
+type AttachmentCarouselProps = {
/** Source is used to determine the starting index in the array of attachments */
source: AttachmentSource;
@@ -40,4 +31,4 @@ type AttachmentCarouselProps = AttachmentCaraouselOnyxProps & {
onClose: () => void;
};
-export type {AttachmentCarouselProps, UpdatePageProps, AttachmentCaraouselOnyxProps};
+export type {AttachmentCarouselProps, UpdatePageProps};
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx
index 8c4af3275bd8..1e3cded92bd5 100644
--- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx
+++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx
@@ -33,8 +33,8 @@ function AttachmentViewPdf(props: AttachmentViewPdfProps) {
.manualActivation(true)
.onTouchesMove((evt) => {
if (offsetX.value !== 0 && offsetY.value !== 0 && isScrollEnabled) {
- const translateX = Math.abs(evt.allTouches[0].absoluteX - offsetX.value);
- const translateY = Math.abs(evt.allTouches[0].absoluteY - offsetY.value);
+ const translateX = Math.abs((evt.allTouches.at(0)?.absoluteX ?? 0) - offsetX.value);
+ const translateY = Math.abs((evt.allTouches.at(0)?.absoluteY ?? 0) - offsetY.value);
const allowEnablingScroll = !isPanGestureActive.value || isScrollEnabled.value;
// if the value of X is greater than Y and the pdf is not zoomed in,
@@ -49,8 +49,8 @@ function AttachmentViewPdf(props: AttachmentViewPdfProps) {
}
isPanGestureActive.value = true;
- offsetX.value = evt.allTouches[0].absoluteX;
- offsetY.value = evt.allTouches[0].absoluteY;
+ offsetX.value = evt.allTouches.at(0)?.absoluteX ?? 0;
+ offsetY.value = evt.allTouches.at(0)?.absoluteY ?? 0;
})
.onTouchesUp(() => {
isPanGestureActive.value = false;
diff --git a/src/components/AvatarSkeleton.tsx b/src/components/AvatarSkeleton.tsx
index a88304b15fc3..6e0a4f407d70 100644
--- a/src/components/AvatarSkeleton.tsx
+++ b/src/components/AvatarSkeleton.tsx
@@ -17,6 +17,7 @@ function AvatarSkeleton({size = CONST.AVATAR_SIZE.SMALL}: {size?: ValueOf
diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx
index 07845eca37ba..4f518452d3be 100644
--- a/src/components/AvatarWithDisplayName.tsx
+++ b/src/components/AvatarWithDisplayName.tsx
@@ -13,8 +13,10 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {PersonalDetailsList, Policy, Report, ReportActions} from '@src/types/onyx';
+import type {Icon} from '@src/types/onyx/OnyxCommon';
import CaretWrapper from './CaretWrapper';
import DisplayNames from './DisplayNames';
+import {FallbackAvatar} from './Icon/Expensicons';
import MultipleAvatars from './MultipleAvatars';
import ParentNavigationSubtitle from './ParentNavigationSubtitle';
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
@@ -46,6 +48,13 @@ type AvatarWithDisplayNameProps = AvatarWithDisplayNamePropsWithOnyx & {
shouldEnableDetailPageNavigation?: boolean;
};
+const fallbackIcon: Icon = {
+ source: FallbackAvatar,
+ type: CONST.ICON_TYPE_AVATAR,
+ name: '',
+ id: -1,
+};
+
function AvatarWithDisplayName({
policy,
report,
@@ -126,8 +135,8 @@ function AvatarWithDisplayName({
{shouldShowSubscriptAvatar ? (
) : (
diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx
index 011b7f510275..cdd43cb2555e 100644
--- a/src/components/AvatarWithImagePicker.tsx
+++ b/src/components/AvatarWithImagePicker.tsx
@@ -232,26 +232,31 @@ function AvatarWithImagePicker({
return;
}
- isValidResolution(image).then((isValid) => {
- if (!isValid) {
- setError('avatarWithImagePicker.resolutionConstraints', {
- minHeightInPx: CONST.AVATAR_MIN_HEIGHT_PX,
- minWidthInPx: CONST.AVATAR_MIN_WIDTH_PX,
- maxHeightInPx: CONST.AVATAR_MAX_HEIGHT_PX,
- maxWidthInPx: CONST.AVATAR_MAX_WIDTH_PX,
+ FileUtils.validateImageForCorruption(image)
+ .then(() => isValidResolution(image))
+ .then((isValid) => {
+ if (!isValid) {
+ setError('avatarWithImagePicker.resolutionConstraints', {
+ minHeightInPx: CONST.AVATAR_MIN_HEIGHT_PX,
+ minWidthInPx: CONST.AVATAR_MIN_WIDTH_PX,
+ maxHeightInPx: CONST.AVATAR_MAX_HEIGHT_PX,
+ maxWidthInPx: CONST.AVATAR_MAX_WIDTH_PX,
+ });
+ return;
+ }
+
+ setIsAvatarCropModalOpen(true);
+ setError(null, {});
+ setIsMenuVisible(false);
+ setImageData({
+ uri: image.uri ?? '',
+ name: image.name ?? '',
+ type: image.type ?? '',
});
- return;
- }
-
- setIsAvatarCropModalOpen(true);
- setError(null, {});
- setIsMenuVisible(false);
- setImageData({
- uri: image.uri ?? '',
- name: image.name ?? '',
- type: image.type ?? '',
+ })
+ .catch(() => {
+ setError('attachmentPicker.errorWhileSelectingCorruptedAttachment', {});
});
- });
},
[isValidExtension, isValidSize],
);
@@ -339,7 +344,11 @@ function AvatarWithImagePicker({
maybeIcon={isUsingDefaultAvatar}
>
{({show}) => (
-
+
{({openPicker}) => {
const menuItems = createMenuItems(openPicker);
@@ -383,7 +392,7 @@ function AvatarWithImagePicker({
{source ? (
& {
/** Whether button's content should be centered */
isContentCentered?: boolean;
+
+ /** Whether the Enter keyboard listening is active whether or not the screen that contains the button is focused */
+ isPressOnEnterActive?: boolean;
};
-type KeyboardShortcutComponentProps = Pick;
+type KeyboardShortcutComponentProps = Pick;
const accessibilityRoles: string[] = Object.values(CONST.ROLE);
-function KeyboardShortcutComponent({isDisabled = false, isLoading = false, onPress = () => {}, pressOnEnter, allowBubble, enterKeyEventListenerPriority}: KeyboardShortcutComponentProps) {
+function KeyboardShortcutComponent({
+ isDisabled = false,
+ isLoading = false,
+ onPress = () => {},
+ pressOnEnter,
+ allowBubble,
+ enterKeyEventListenerPriority,
+ isPressOnEnterActive = false,
+}: KeyboardShortcutComponentProps) {
const isFocused = useIsFocused();
const activeElementRole = useActiveElementRole();
@@ -163,7 +174,7 @@ function KeyboardShortcutComponent({isDisabled = false, isLoading = false, onPre
const config = useMemo(
() => ({
- isActive: pressOnEnter && !shouldDisableEnterShortcut && isFocused,
+ isActive: pressOnEnter && !shouldDisableEnterShortcut && (isFocused || isPressOnEnterActive),
shouldBubble: allowBubble,
priority: enterKeyEventListenerPriority,
shouldPreventDefault: false,
@@ -230,6 +241,7 @@ function Button(
isSplitButton = false,
link = false,
isContentCentered = false,
+ isPressOnEnterActive,
...rest
}: ButtonProps,
ref: ForwardedRef,
@@ -329,6 +341,7 @@ function Button(
onPress={onPress}
pressOnEnter={pressOnEnter}
enterKeyEventListenerPriority={enterKeyEventListenerPriority}
+ isPressOnEnterActive={isPressOnEnterActive}
/>
)}
({
const {windowWidth, windowHeight} = useWindowDimensions();
const dropdownAnchor = useRef(null);
const dropdownButtonRef = isSplitButton ? buttonRef : mergeRefs(buttonRef, dropdownAnchor);
- const selectedItem = options[selectedItemIndex] || options[0];
+ const selectedItem = options.at(selectedItemIndex) ?? options.at(0);
const innerStyleDropButton = StyleUtils.getDropDownButtonHeight(buttonSize);
const isButtonSizeLarge = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE;
const nullCheckRef = (ref: MutableRefObject) => ref ?? null;
@@ -86,9 +87,14 @@ function ButtonWithDropdownMenu({
setIsMenuVisible(!isMenuVisible);
return;
}
- onPress(e, selectedItem?.value);
+ if (selectedItem?.value) {
+ onPress(e, selectedItem.value);
+ }
} else {
- onPress(e, options[0]?.value);
+ const option = options.at(0);
+ if (option?.value) {
+ onPress(e, option.value);
+ }
}
},
{
@@ -99,6 +105,17 @@ function ButtonWithDropdownMenu({
);
const splitButtonWrapperStyle = isSplitButton ? [styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter] : {};
+ const handlePress = useCallback(
+ (event?: GestureResponderEvent | KeyboardEvent) => {
+ if (!isSplitButton) {
+ setIsMenuVisible(!isMenuVisible);
+ } else if (selectedItem?.value) {
+ onPress(event, selectedItem.value);
+ }
+ },
+ [isMenuVisible, isSplitButton, onPress, selectedItem?.value],
+ );
+
return (
{shouldAlwaysShowDropdownMenu || options.length > 1 ? (
@@ -107,8 +124,8 @@ function ButtonWithDropdownMenu({
success={success}
pressOnEnter={pressOnEnter}
ref={dropdownButtonRef}
- onPress={(event) => (!isSplitButton ? setIsMenuVisible(!isMenuVisible) : onPress(event, selectedItem.value))}
- text={customText ?? selectedItem.text}
+ onPress={handlePress}
+ text={customText ?? selectedItem?.text ?? ''}
isDisabled={isDisabled || !!selectedItem?.disabled}
isLoading={isLoading}
shouldRemoveRightBorderRadius
@@ -156,12 +173,15 @@ function ButtonWithDropdownMenu({
success={success}
ref={buttonRef}
pressOnEnter={pressOnEnter}
- isDisabled={isDisabled || !!options[0].disabled}
+ isDisabled={isDisabled || !!options.at(0)?.disabled}
style={[styles.w100, style]}
disabledStyle={disabledStyle}
isLoading={isLoading}
- text={selectedItem.text}
- onPress={(event) => onPress(event, options[0].value)}
+ text={selectedItem?.text}
+ onPress={(event) => {
+ const option = options.at(0);
+ return option ? onPress(event, option.value) : undefined;
+ }}
large={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE}
medium={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
small={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.SMALL}
diff --git a/src/components/CategoryPicker.tsx b/src/components/CategoryPicker.tsx
index e5c85a8f5f6d..73d07cfba229 100644
--- a/src/components/CategoryPicker.tsx
+++ b/src/components/CategoryPicker.tsx
@@ -62,7 +62,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC
false,
);
- const categoryData = categoryOptions?.[0]?.data ?? [];
+ const categoryData = categoryOptions?.at(0)?.data ?? [];
const header = OptionsListUtils.getHeaderMessageForNonUserList(categoryData.length > 0, debouncedSearchValue);
const categoriesCount = OptionsListUtils.getEnabledCategoriesCount(categories);
const isCategoriesCountBelowThreshold = categoriesCount < CONST.CATEGORY_LIST_THRESHOLD;
@@ -71,7 +71,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC
return [categoryOptions, header, showInput];
}, [policyRecentlyUsedCategories, debouncedSearchValue, selectedOptions, policyCategories, policyCategoriesDraft]);
- const selectedOptionKey = useMemo(() => (sections?.[0]?.data ?? []).filter((category) => category.searchText === selectedCategory)[0]?.keyForList, [sections, selectedCategory]);
+ const selectedOptionKey = useMemo(() => (sections?.at(0)?.data ?? []).filter((category) => category.searchText === selectedCategory).at(0)?.keyForList, [sections, selectedCategory]);
return (
) => {
- const clipboardContent = e.nativeEvent.items[0];
- if (clipboardContent.type === 'text/plain') {
+ const clipboardContent = e.nativeEvent.items.at(0);
+ if (clipboardContent?.type === 'text/plain') {
return;
}
- const mimeType = clipboardContent.type;
- const fileURI = clipboardContent.data;
- const baseFileName = fileURI.split('/').pop() ?? 'file';
+ const mimeType = clipboardContent?.type ?? '';
+ const fileURI = clipboardContent?.data;
+ const baseFileName = fileURI?.split('/').pop() ?? 'file';
const {fileName: stem, fileExtension: originalFileExtension} = FileUtils.splitExtensionFromFileName(baseFileName);
const fileExtension = originalFileExtension || (mimeDb[mimeType].extensions?.[0] ?? 'bin');
const fileName = `${stem}.${fileExtension}`;
diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx
index 55b1ec5aed3b..bda78b9b320d 100644
--- a/src/components/ConfirmContent.tsx
+++ b/src/components/ConfirmContent.tsx
@@ -95,6 +95,9 @@ type ConfirmContentProps = {
/** Image to display with content */
image?: IconAsset;
+
+ /** Whether the modal is visibile */
+ isVisible: boolean;
};
function ConfirmContent({
@@ -123,6 +126,7 @@ function ConfirmContent({
image,
titleContainerStyles,
shouldReverseStackedButtons = false,
+ isVisible,
}: ConfirmContentProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
@@ -200,6 +204,7 @@ function ConfirmContent({
style={shouldReverseStackedButtons ? styles.mt3 : styles.mt4}
onPress={onConfirm}
pressOnEnter
+ isPressOnEnterActive={isVisible}
large
text={confirmText || translate('common.yes')}
isDisabled={isOffline && shouldDisableConfirmButtonWhenOffline}
@@ -228,6 +233,7 @@ function ConfirmContent({
style={[styles.flex1]}
onPress={onConfirm}
pressOnEnter
+ isPressOnEnterActive={isVisible}
text={confirmText || translate('common.yes')}
isDisabled={isOffline && shouldDisableConfirmButtonWhenOffline}
/>
diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx
index 9d6bd3a0a76a..e63b8bb91874 100755
--- a/src/components/ConfirmModal.tsx
+++ b/src/components/ConfirmModal.tsx
@@ -164,6 +164,7 @@ function ConfirmModal({
prompt={prompt}
success={success}
danger={danger}
+ isVisible={isVisible}
shouldDisableConfirmButtonWhenOffline={shouldDisableConfirmButtonWhenOffline}
shouldShowCancelButton={shouldShowCancelButton}
shouldCenterContent={shouldCenterContent}
diff --git a/src/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx
index 4940a276cbf8..e1580dcae7d0 100644
--- a/src/components/ConfirmedRoute.tsx
+++ b/src/components/ConfirmedRoute.tsx
@@ -112,7 +112,7 @@ function ConfirmedRoute({mapboxAccessToken, transaction, isSmallerIcon, shouldHa
pitchEnabled={false}
initialState={{
zoom: CONST.MAPBOX.DEFAULT_ZOOM,
- location: waypointMarkers?.[0]?.coordinate ?? (CONST.MAPBOX.DEFAULT_COORDINATE as [number, number]),
+ location: waypointMarkers?.at(0)?.coordinate ?? CONST.MAPBOX.DEFAULT_COORDINATE,
}}
directionCoordinates={coordinates as Array<[number, number]>}
style={[styles.mapView, shouldHaveBorderRadius && styles.br4]}
diff --git a/src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts b/src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts
index 21e7ff752794..9a5cf7d7f741 100644
--- a/src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts
+++ b/src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts
@@ -51,8 +51,8 @@ export default function generateMonthMatrix(year: number, month: number) {
}
// Add null values for days before the first day of the month
- for (let i = matrix[0].length; i < 7; i++) {
- matrix[0].unshift(undefined);
+ for (let i = matrix.at(0)?.length ?? 0; i < 7; i++) {
+ matrix.at(0)?.unshift(undefined);
}
return matrix;
diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx
index 533586d4bdbf..287ec3359175 100644
--- a/src/components/DatePicker/CalendarPicker/index.tsx
+++ b/src/components/DatePicker/CalendarPicker/index.tsx
@@ -170,7 +170,7 @@ function CalendarPicker({
testID="currentMonthText"
accessibilityLabel={translate('common.currentMonth')}
>
- {monthNames[currentMonthView]}
+ {monthNames.at(currentMonthView)}
))}
- {calendarDaysMatrix.map((week) => (
+ {calendarDaysMatrix?.map((week) => (
;
-};
-
-type DeeplinkRedirectLoadingIndicatorProps = DeeplinkRedirectLoadingIndicatorOnyxProps & {
+type DeeplinkRedirectLoadingIndicatorProps = {
/** Opens the link in the browser */
openLinkInBrowser: (value: boolean) => void;
};
-function DeeplinkRedirectLoadingIndicator({openLinkInBrowser, session}: DeeplinkRedirectLoadingIndicatorProps) {
+function DeeplinkRedirectLoadingIndicator({openLinkInBrowser}: DeeplinkRedirectLoadingIndicatorProps) {
const {translate} = useLocalize();
const theme = useTheme();
const styles = useThemeStyles();
-
+ const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email});
return (
@@ -41,7 +34,7 @@ function DeeplinkRedirectLoadingIndicator({openLinkInBrowser, session}: Deeplink
{translate('deeplinkWrapper.launching')}
- {translate('deeplinkWrapper.loggedInAs', {email: session?.email ?? ''})}
+ {translate('deeplinkWrapper.loggedInAs', {email: currentUserLogin ?? ''})}
{translate('deeplinkWrapper.doNotSeePrompt')} openLinkInBrowser(true)}>{translate('deeplinkWrapper.tryAgain')}
{translate('deeplinkWrapper.or')} Navigation.goBack()}>{translate('deeplinkWrapper.continueInWeb')}.
@@ -62,8 +55,4 @@ function DeeplinkRedirectLoadingIndicator({openLinkInBrowser, session}: Deeplink
DeeplinkRedirectLoadingIndicator.displayName = 'DeeplinkRedirectLoadingIndicator';
-export default withOnyx({
- session: {
- key: ONYXKEYS.SESSION,
- },
-})(DeeplinkRedirectLoadingIndicator);
+export default DeeplinkRedirectLoadingIndicator;
diff --git a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx
index 0b0c3ddf27ca..86edbb3b4c5e 100644
--- a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx
+++ b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx
@@ -31,13 +31,13 @@ function DisplayNamesWithToolTip({shouldUseFullTitle, fullTitle, displayNamesWit
*/
const getTooltipShiftX = useCallback((index: number) => {
// Only shift the tooltip in case the containerLayout or Refs to the text node are available
- if (!containerRef.current || !childRefs.current[index]) {
+ if (!containerRef.current || index < 0 || !childRefs.current.at(index)) {
return 0;
}
const {width: containerWidth, left: containerLeft} = containerRef.current.getBoundingClientRect();
// We have to return the value as Number so we can't use `measureWindow` which takes a callback
- const {width: textNodeWidth, left: textNodeLeft} = childRefs.current[index].getBoundingClientRect();
+ const {width: textNodeWidth, left: textNodeLeft} = childRefs.current.at(index)?.getBoundingClientRect() ?? {width: 0, left: 0};
const tooltipX = textNodeWidth / 2 + textNodeLeft;
const containerRight = containerWidth + containerLeft;
const textNodeRight = textNodeWidth + textNodeLeft;
diff --git a/src/components/DistanceRequest/DistanceRequestFooter.tsx b/src/components/DistanceRequest/DistanceRequestFooter.tsx
index 43dd88f6e36c..8a4455e02bd6 100644
--- a/src/components/DistanceRequest/DistanceRequestFooter.tsx
+++ b/src/components/DistanceRequest/DistanceRequestFooter.tsx
@@ -107,7 +107,7 @@ function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navig
pitchEnabled={false}
initialState={{
zoom: CONST.MAPBOX.DEFAULT_ZOOM,
- location: waypointMarkers?.[0]?.coordinate ?? (CONST.MAPBOX.DEFAULT_COORDINATE as [number, number]),
+ location: waypointMarkers?.at(0)?.coordinate ?? CONST.MAPBOX.DEFAULT_COORDINATE,
}}
directionCoordinates={(transaction?.routes?.route0?.geometry?.coordinates as Array<[number, number]>) ?? []}
style={[styles.mapView, styles.mapEditView]}
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx
index 3ca4d3bb5545..ee4858bb0be0 100644
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx
@@ -91,7 +91,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r
);
}
- const emojiCode = typeof preferredSkinTone === 'number' && types?.[preferredSkinTone] ? types?.[preferredSkinTone] : code;
+ const emojiCode = typeof preferredSkinTone === 'number' && preferredSkinTone !== -1 && types?.at(preferredSkinTone) ? types.at(preferredSkinTone) : code;
const shouldEmojiBeHighlighted = !!activeEmoji && EmojiUtils.getRemovedSkinToneEmoji(emojiCode) === EmojiUtils.getRemovedSkinToneEmoji(activeEmoji);
return (
@@ -102,7 +102,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r
}
onEmojiSelected(emoji, item);
})}
- emoji={emojiCode}
+ emoji={emojiCode ?? ''}
isHighlighted={shouldEmojiBeHighlighted}
/>
);
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx
index d6c1e1f92551..afcea4f3856a 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx
@@ -175,13 +175,13 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r
indexToSelect = 0;
}
- const item = filteredEmojis[indexToSelect];
- if (!item) {
+ const item = filteredEmojis.at(indexToSelect);
+ if (indexToSelect === -1 || !item) {
return;
}
if ('types' in item || 'name' in item) {
- const emoji = typeof preferredSkinTone === 'number' && item?.types?.[preferredSkinTone] ? item?.types?.[preferredSkinTone] : item.code;
- onEmojiSelected(emoji, item);
+ const emoji = typeof preferredSkinTone === 'number' && preferredSkinTone !== -1 && item?.types?.at(preferredSkinTone) ? item.types.at(preferredSkinTone) : item.code;
+ onEmojiSelected(emoji ?? '', item);
}
},
{shouldPreventDefault: true, shouldStopPropagation: true},
@@ -266,7 +266,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r
);
}
- const emojiCode = typeof preferredSkinTone === 'number' && types?.[preferredSkinTone] ? types[preferredSkinTone] : code;
+ const emojiCode = typeof preferredSkinTone === 'number' && types?.at(preferredSkinTone) && preferredSkinTone !== -1 ? types.at(preferredSkinTone) : code;
const isEmojiFocused = index === focusedIndex && isUsingKeyboardMovement;
const shouldEmojiBeHighlighted =
@@ -289,7 +289,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r
}
setIsUsingKeyboardMovement(false);
}}
- emoji={emojiCode}
+ emoji={emojiCode ?? ''}
onFocus={() => setFocusedIndex(index)}
isFocused={isEmojiFocused}
isHighlighted={shouldFirstEmojiBeHighlighted || shouldEmojiBeHighlighted}
diff --git a/src/components/ExplanationModal.tsx b/src/components/ExplanationModal.tsx
index 73290c43d39a..d846dd4d28ba 100644
--- a/src/components/ExplanationModal.tsx
+++ b/src/components/ExplanationModal.tsx
@@ -1,30 +1,12 @@
-import React, {useCallback} from 'react';
+import React from 'react';
import useLocalize from '@hooks/useLocalize';
-import Navigation from '@libs/Navigation/Navigation';
-import variables from '@styles/variables';
import * as Welcome from '@userActions/Welcome';
-import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow';
import CONST from '@src/CONST';
import FeatureTrainingModal from './FeatureTrainingModal';
function ExplanationModal() {
const {translate} = useLocalize();
- const onClose = useCallback(() => {
- Welcome.completeHybridAppOnboarding();
-
- // We need to check if standard NewDot onboarding is completed.
- Welcome.isOnboardingFlowCompleted({
- onNotCompleted: () => {
- setTimeout(() => {
- Navigation.isNavigationReady().then(() => {
- OnboardingFlow.startOnboardingFlow();
- });
- }, variables.welcomeVideoDelay);
- },
- });
- }, []);
-
return (
);
}
diff --git a/src/components/FilePicker/index.native.tsx b/src/components/FilePicker/index.native.tsx
index b74a68432cab..cc9c73d72c56 100644
--- a/src/components/FilePicker/index.native.tsx
+++ b/src/components/FilePicker/index.native.tsx
@@ -112,7 +112,7 @@ function FilePicker({children}: FilePickerProps) {
onCanceled.current();
return Promise.resolve();
}
- const fileData = files[0];
+ const fileData = files.at(0);
if (!fileData) {
onCanceled.current();
diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx
index 80f52c8053da..1d66953c1070 100644
--- a/src/components/Form/FormProvider.tsx
+++ b/src/components/Form/FormProvider.tsx
@@ -237,8 +237,16 @@ function FormProvider(
},
[inputValues],
);
+
+ const resetErrors = useCallback(() => {
+ FormActions.clearErrors(formID);
+ FormActions.clearErrorFields(formID);
+ setErrors({});
+ }, [formID]);
+
useImperativeHandle(forwardedRef, () => ({
resetForm,
+ resetErrors,
}));
const registerInput = useCallback(
diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx
index e9f14315486d..d26276d0418b 100644
--- a/src/components/Form/FormWrapper.tsx
+++ b/src/components/Form/FormWrapper.tsx
@@ -3,8 +3,7 @@ import type {RefObject} from 'react';
// eslint-disable-next-line no-restricted-imports
import type {ScrollView as RNScrollView, StyleProp, View, ViewStyle} from 'react-native';
import {Keyboard} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import FormElement from '@components/FormElement';
import SafeAreaConsumer from '@components/SafeAreaConsumer';
@@ -13,18 +12,13 @@ import ScrollView from '@components/ScrollView';
import ScrollViewWithContext from '@components/ScrollViewWithContext';
import useThemeStyles from '@hooks/useThemeStyles';
import * as ErrorUtils from '@libs/ErrorUtils';
+import type {OnyxFormKey} from '@src/ONYXKEYS';
import type {Form} from '@src/types/form';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type {FormInputErrors, FormProps, InputRefs} from './types';
-type FormWrapperOnyxProps = {
- /** Contains the form state that must be accessed outside the component */
- formState: OnyxEntry
- }
- subtitle={
-
- {subtitle}
-
- }
- />
- );
- }, [styles.mutedTextLabel, styles.pre, styles.textLarge, subtitle, subtitleStyles, title]);
-
return (
-
- {icon && (
-
+ {icon && (
+
+ )}
+ {text}} />
+ {children}
+
+ ) : (
+
+ {}}
+ updateSearch={() => {}}
+ disabled
+ isFullWidth
+ wrapperStyle={[styles.searchRouterInputResults, styles.br2]}
+ wrapperFocusedStyle={styles.searchRouterInputResultsFocused}
+ rightComponent={children}
+ routerListRef={undefined}
/>
- )}
-
- {middleContent}
- {children}
-
+
+ )}
);
}
@@ -94,10 +88,6 @@ function HeaderWrapper({icon, title, subtitle, children, subtitleStyles = {}}: H
type SearchPageHeaderProps = {
queryJSON: SearchQueryJSON;
hash: number;
- onSelectDeleteOption?: (itemsToDelete: string[]) => void;
- setOfflineModalOpen?: () => void;
- setDownloadErrorModalOpen?: () => void;
- data?: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[];
};
type SearchHeaderOptionValue = DeepValueOf | undefined;
@@ -121,44 +111,45 @@ function getHeaderContent(type: SearchDataTypes): HeaderContent {
}
}
-function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModalOpen, setDownloadErrorModalOpen, data}: SearchPageHeaderProps) {
+function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
const {translate} = useLocalize();
const theme = useTheme();
const styles = useThemeStyles();
const {isOffline} = useNetwork();
const {activeWorkspaceID} = useActiveWorkspace();
- const {shouldUseNarrowLayout} = useResponsiveLayout();
- const {selectedTransactions} = useSearchContext();
+ const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
+ const {selectedTransactions, clearSelectedTransactions, selectedReports} = useSearchContext();
const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE);
const personalDetails = usePersonalDetails();
const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const taxRates = getAllTaxRates();
const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
+ const [isDeleteExpensesConfirmModalVisible, setIsDeleteExpensesConfirmModalVisible] = useState(false);
+ const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false);
+ const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false);
const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {});
- const selectedReports: Array = useMemo(
- () =>
- (data ?? [])
- .filter(
- (item) =>
- !SearchUtils.isTransactionListItemType(item) &&
- !SearchUtils.isReportActionListItemType(item) &&
- item.reportID &&
- item.transactions.every((transaction: {keyForList: string | number}) => selectedTransactions[transaction.keyForList]?.isSelected),
- )
- .map((item) => item.reportID),
- [data, selectedTransactions],
- );
const {status, type} = queryJSON;
-
const isCannedQuery = SearchUtils.isCannedSearchQuery(queryJSON);
- const headerSubtitle = isCannedQuery ? translate(getHeaderContent(type).titleText) : SearchUtils.getSearchHeaderTitle(queryJSON, personalDetails, cardList, reports, taxRates);
- const headerTitle = isCannedQuery ? '' : translate('search.filtersHeader');
- const headerIcon = isCannedQuery ? getHeaderContent(type).icon : Illustrations.Filters;
+ const headerIcon = getHeaderContent(type).icon;
+ const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : SearchUtils.getSearchHeaderTitle(queryJSON, personalDetails, cardList, reports, taxRates);
+
+ const handleDeleteExpenses = () => {
+ if (selectedTransactionsKeys.length === 0) {
+ return;
+ }
+
+ setIsDeleteExpensesConfirmModalVisible(false);
+ SearchActions.deleteMoneyRequestOnSearch(hash, selectedTransactionsKeys);
- const subtitleStyles = isCannedQuery ? styles.textHeadlineH2 : {};
+ // Translations copy for delete modal depends on amount of selected items,
+ // We need to wait for modal to fully disappear before clearing them to avoid translation flicker between singular vs plural
+ InteractionManager.runAfterInteractions(() => {
+ clearSelectedTransactions();
+ });
+ };
const headerButtonsOptions = useMemo(() => {
if (selectedTransactionsKeys.length === 0) {
@@ -174,7 +165,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa
shouldCloseModalOnSelect: true,
onSelected: () => {
if (isOffline) {
- setOfflineModalOpen?.();
+ setIsOfflineModalVisible(true);
return;
}
@@ -182,7 +173,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa
SearchActions.exportSearchItemsToCSV(
{query: status, jsonQuery: JSON.stringify(queryJSON), reportIDList, transactionIDList: selectedTransactionsKeys, policyIDs: [activeWorkspaceID ?? '']},
() => {
- setDownloadErrorModalOpen?.();
+ setIsDownloadErrorModalVisible(true);
},
);
},
@@ -198,7 +189,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa
shouldCloseModalOnSelect: true,
onSelected: () => {
if (isOffline) {
- setOfflineModalOpen?.();
+ setIsOfflineModalVisible(true);
return;
}
@@ -217,7 +208,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa
shouldCloseModalOnSelect: true,
onSelected: () => {
if (isOffline) {
- setOfflineModalOpen?.();
+ setIsOfflineModalVisible(true);
return;
}
@@ -236,11 +227,10 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa
shouldCloseModalOnSelect: true,
onSelected: () => {
if (isOffline) {
- setOfflineModalOpen?.();
+ setIsOfflineModalVisible(true);
return;
}
-
- onSelectDeleteOption?.(selectedTransactionsKeys);
+ setIsDeleteExpensesConfirmModalVisible(true);
},
});
}
@@ -270,14 +260,11 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa
selectedTransactionsKeys,
selectedTransactions,
translate,
- onSelectDeleteOption,
hash,
theme.icon,
styles.colorMuted,
styles.fontWeightNormal,
isOffline,
- setOfflineModalOpen,
- setDownloadErrorModalOpen,
activeWorkspaceID,
selectedReports,
styles.textWrap,
@@ -286,31 +273,61 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa
if (shouldUseNarrowLayout) {
if (selectionMode?.isEnabled) {
return (
-
+
+
+ {
+ setIsDeleteExpensesConfirmModalVisible(false);
+ }}
+ title={translate('iou.deleteExpense', {count: selectedTransactionsKeys.length})}
+ prompt={translate('iou.deleteConfirmation', {count: selectedTransactionsKeys.length})}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
+ />
+ setIsOfflineModalVisible(false)}
+ secondOptionText={translate('common.buttonConfirm')}
+ isVisible={isOfflineModalVisible}
+ onClose={() => setIsOfflineModalVisible(false)}
+ />
+ setIsDownloadErrorModalVisible(false)}
+ secondOptionText={translate('common.buttonConfirm')}
+ isVisible={isDownloadErrorModalVisible}
+ onClose={() => setIsDownloadErrorModalVisible(false)}
+ />
+
);
}
return null;
}
const onPress = () => {
- const values = SearchUtils.buildFilterFormValuesFromQuery(queryJSON);
- SearchActions.updateAdvancedFilters(values);
+ const filterFormValues = SearchUtils.buildFilterFormValuesFromQuery(queryJSON);
+ SearchActions.updateAdvancedFilters(filterFormValues);
+
Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS);
};
- const displaySearchRouter = SearchUtils.isCannedSearchQuery(queryJSON);
-
return (
-
- <>
+ <>
+
{headerButtonsOptions.length > 0 ? (
null}
@@ -324,14 +341,46 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa
/>
) : (
)}
- {displaySearchRouter && }
- >
-
+ {isCannedQuery && }
+
+ {
+ setIsDeleteExpensesConfirmModalVisible(false);
+ }}
+ title={translate('iou.deleteExpense', {count: selectedTransactionsKeys.length})}
+ prompt={translate('iou.deleteConfirmation', {count: selectedTransactionsKeys.length})}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
+ />
+ setIsOfflineModalVisible(false)}
+ secondOptionText={translate('common.buttonConfirm')}
+ isVisible={isOfflineModalVisible}
+ onClose={() => setIsOfflineModalVisible(false)}
+ />
+ setIsDownloadErrorModalVisible(false)}
+ secondOptionText={translate('common.buttonConfirm')}
+ isVisible={isDownloadErrorModalVisible}
+ onClose={() => setIsDownloadErrorModalVisible(false)}
+ />
+ >
);
}
diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx
index dfe2cbbe16c6..8f5ad55bc0c9 100644
--- a/src/components/Search/SearchRouter/SearchRouter.tsx
+++ b/src/components/Search/SearchRouter/SearchRouter.tsx
@@ -1,90 +1,194 @@
+import {useNavigationState} from '@react-navigation/native';
import debounce from 'lodash/debounce';
-import React, {useCallback, useState} from 'react';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
-import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal';
-import Modal from '@components/Modal';
+import {useOnyx} from 'react-native-onyx';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import {useOptionsList} from '@components/OptionListContextProvider';
import type {SearchQueryJSON} from '@components/Search/types';
+import type {SelectionListHandle} from '@components/SelectionList/types';
+import useDebouncedState from '@hooks/useDebouncedState';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
+import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
+import Log from '@libs/Log';
+import * as OptionsListUtils from '@libs/OptionsListUtils';
+import type {OptionData} from '@libs/ReportUtils';
import * as SearchUtils from '@libs/SearchUtils';
import Navigation from '@navigation/Navigation';
+import variables from '@styles/variables';
+import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import {useSearchRouterContext} from './SearchRouterContext';
import SearchRouterInput from './SearchRouterInput';
+import SearchRouterList from './SearchRouterList';
const SEARCH_DEBOUNCE_DELAY = 150;
function SearchRouter() {
const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const [betas] = useOnyx(ONYXKEYS.BETAS);
+ const [recentSearches] = useOnyx(ONYXKEYS.RECENT_SEARCHES);
+ const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false});
const {isSmallScreenWidth} = useResponsiveLayout();
const {isSearchRouterDisplayed, closeSearchRouter} = useSearchRouterContext();
+ const listRef = useRef(null);
+ const [textInputValue, debouncedInputValue, setTextInputValue] = useDebouncedState('', 500);
const [userSearchQuery, setUserSearchQuery] = useState(undefined);
-
- const clearUserQuery = () => {
- setUserSearchQuery(undefined);
- };
-
- const onSearchChange = debounce((userQuery: string) => {
- if (!userQuery) {
- clearUserQuery();
- return;
+ const contextualReportID = useNavigationState, string | undefined>((state) => {
+ return state?.routes.at(-1)?.params?.reportID;
+ });
+ const sortedRecentSearches = useMemo(() => {
+ return Object.values(recentSearches ?? {}).sort((a, b) => b.timestamp.localeCompare(a.timestamp));
+ }, [recentSearches]);
+
+ const {options, areOptionsInitialized} = useOptionsList();
+ const searchOptions = useMemo(() => {
+ if (!areOptionsInitialized) {
+ return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null, categoryOptions: [], tagOptions: [], taxRatesOptions: []};
+ }
+ return OptionsListUtils.getSearchOptions(options, '', betas ?? []);
+ }, [areOptionsInitialized, betas, options]);
+
+ const filteredOptions = useMemo(() => {
+ if (debouncedInputValue.trim() === '') {
+ return {
+ recentReports: [],
+ personalDetails: [],
+ userToInvite: null,
+ };
}
- const queryJSON = SearchUtils.buildSearchQueryJSON(userQuery);
+ const newOptions = OptionsListUtils.filterOptions(searchOptions, debouncedInputValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true});
- if (queryJSON) {
- // eslint-disable-next-line
- console.log('parsedQuery', queryJSON);
+ return {
+ recentReports: newOptions.recentReports,
+ personalDetails: newOptions.personalDetails,
+ userToInvite: newOptions.userToInvite,
+ };
+ }, [debouncedInputValue, searchOptions]);
- setUserSearchQuery(queryJSON);
- } else {
- // Handle query parsing error
+ const recentReports: OptionData[] = useMemo(() => {
+ const currentSearchOptions = debouncedInputValue === '' ? searchOptions : filteredOptions;
+ const reports: OptionData[] = [...currentSearchOptions.recentReports, ...currentSearchOptions.personalDetails];
+ if (currentSearchOptions.userToInvite) {
+ reports.push(currentSearchOptions.userToInvite);
}
- }, SEARCH_DEBOUNCE_DELAY);
+ return reports.slice(0, 10);
+ }, [debouncedInputValue, filteredOptions, searchOptions]);
- const onSearchSubmit = useCallback(() => {
- if (!userSearchQuery) {
+ useEffect(() => {
+ Report.searchInServer(debouncedInputValue.trim());
+ }, [debouncedInputValue]);
+
+ useEffect(() => {
+ if (!textInputValue && isSearchRouterDisplayed) {
return;
}
+ listRef.current?.updateAndScrollToFocusedIndex(0);
+ // eslint-disable-next-line react-compiler/react-compiler
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isSearchRouterDisplayed]);
- closeSearchRouter();
+ const contextualReportData = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined;
- const query = SearchUtils.buildSearchQueryString(userSearchQuery);
- Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query}));
+ const clearUserQuery = () => {
+ setTextInputValue('');
+ setUserSearchQuery(undefined);
+ };
+
+ const onSearchChange = useMemo(
+ // eslint-disable-next-line react-compiler/react-compiler
+ () =>
+ debounce((userQuery: string) => {
+ if (!userQuery) {
+ clearUserQuery();
+ listRef.current?.updateAndScrollToFocusedIndex(-1);
+ return;
+ }
+ listRef.current?.updateAndScrollToFocusedIndex(0);
+ const queryJSON = SearchUtils.buildSearchQueryJSON(userQuery);
+
+ if (queryJSON) {
+ setUserSearchQuery(queryJSON);
+ } else {
+ Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, userQuery, false);
+ }
+ }, SEARCH_DEBOUNCE_DELAY),
+ // eslint-disable-next-line react-compiler/react-compiler
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ );
+ const updateUserSearchQuery = (newSearchQuery: string) => {
+ setTextInputValue(newSearchQuery);
+ onSearchChange(newSearchQuery);
+ };
+
+ const closeAndClearRouter = useCallback(() => {
+ closeSearchRouter();
clearUserQuery();
- }, [closeSearchRouter, userSearchQuery]);
+ // eslint-disable-next-line react-compiler/react-compiler
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [closeSearchRouter]);
+
+ const onSearchSubmit = useCallback(
+ (query: SearchQueryJSON | undefined) => {
+ if (!query) {
+ return;
+ }
+ closeSearchRouter();
+ const queryString = SearchUtils.buildSearchQueryString(query);
+ Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: queryString}));
+ clearUserQuery();
+ },
+ // eslint-disable-next-line react-compiler/react-compiler
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [closeSearchRouter],
+ );
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => {
closeSearchRouter();
clearUserQuery();
});
- const modalType = isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.CENTERED : CONST.MODAL.MODAL_TYPE.POPOVER;
- const isFullWidth = isSmallScreenWidth;
+ const modalWidth = isSmallScreenWidth ? styles.w100 : {width: variables.popoverWidth};
return (
-
-
-
-
-
-
-
+
+ {isSmallScreenWidth && (
+ closeSearchRouter()}
+ />
+ )}
+
+
+
);
}
diff --git a/src/components/Search/SearchRouter/SearchRouterInput.tsx b/src/components/Search/SearchRouter/SearchRouterInput.tsx
index 860a46239d21..460ff37c88b2 100644
--- a/src/components/Search/SearchRouter/SearchRouterInput.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterInput.tsx
@@ -1,38 +1,97 @@
import React, {useState} from 'react';
-import BaseTextInput from '@components/TextInput/BaseTextInput';
+import type {ReactNode, RefObject} from 'react';
+import {View} from 'react-native';
+import type {StyleProp, ViewStyle} from 'react-native';
+import type {SelectionListHandle} from '@components/SelectionList/types';
+import TextInput from '@components/TextInput';
+import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
type SearchRouterInputProps = {
+ /** Value of TextInput */
+ value: string;
+
+ /** Setter to TextInput value */
+ setValue: (searchTerm: string) => void;
+
+ /** Callback to update search in SearchRouter */
+ updateSearch: (searchTerm: string) => void;
+
+ /** SearchRouterList ref for managing TextInput and SearchRouterList focus */
+ routerListRef?: RefObject;
+
+ /** Whether the input is full width */
isFullWidth: boolean;
- onChange: (searchTerm: string) => void;
- onSubmit: () => void;
+
+ /** Whether the input is disabled */
+ disabled?: boolean;
+
+ /** Any additional styles to apply */
+ wrapperStyle?: StyleProp;
+
+ /** Any additional styles to apply when input is focused */
+ wrapperFocusedStyle?: StyleProp;
+
+ /** Component to be displayed on the right */
+ rightComponent?: ReactNode;
+
+ /** Whether the search reports API call is running */
+ isSearchingForReports?: boolean;
};
-function SearchRouterInput({isFullWidth, onChange, onSubmit}: SearchRouterInputProps) {
+function SearchRouterInput({
+ value,
+ setValue,
+ updateSearch,
+ routerListRef,
+ isFullWidth,
+ disabled = false,
+ wrapperStyle,
+ wrapperFocusedStyle,
+ rightComponent,
+ isSearchingForReports,
+}: SearchRouterInputProps) {
const styles = useThemeStyles();
-
- const [value, setValue] = useState('');
+ const {translate} = useLocalize();
+ const [isFocused, setIsFocused] = useState(false);
const onChangeText = (text: string) => {
setValue(text);
- onChange(text);
+ updateSearch(text);
};
- const modalWidth = isFullWidth ? styles.w100 : {width: variables.popoverWidth};
+ const inputWidth = isFullWidth ? styles.w100 : {width: variables.popoverWidth};
return (
-
+
+
+ {
+ setIsFocused(true);
+ routerListRef?.current?.updateExternalTextInputFocus(true);
+ }}
+ onBlur={() => {
+ setIsFocused(false);
+ routerListRef?.current?.updateExternalTextInputFocus(false);
+ }}
+ isLoading={!!isSearchingForReports}
+ />
+
+ {rightComponent && {rightComponent}}
+
);
}
diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx
new file mode 100644
index 000000000000..7d86ce1150d5
--- /dev/null
+++ b/src/components/Search/SearchRouter/SearchRouterList.tsx
@@ -0,0 +1,185 @@
+import React, {forwardRef, useCallback} from 'react';
+import type {ForwardedRef} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import * as Expensicons from '@components/Icon/Expensicons';
+import {usePersonalDetails} from '@components/OnyxProvider';
+import type {SearchQueryJSON} from '@components/Search/types';
+import SelectionList from '@components/SelectionList';
+import SearchQueryListItem from '@components/SelectionList/Search/SearchQueryListItem';
+import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionList/Search/SearchQueryListItem';
+import type {SectionListDataType, SelectionListHandle, UserListItemProps} from '@components/SelectionList/types';
+import UserListItem from '@components/SelectionList/UserListItem';
+import useLocalize from '@hooks/useLocalize';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import {getAllTaxRates} from '@libs/PolicyUtils';
+import type {OptionData} from '@libs/ReportUtils';
+import * as SearchUtils from '@libs/SearchUtils';
+import * as Report from '@userActions/Report';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+
+type ItemWithQuery = {
+ query: string;
+};
+
+type SearchRouterListProps = {
+ /** currentQuery value computed coming from parsed TextInput value */
+ currentQuery: SearchQueryJSON | undefined;
+
+ /** Recent searches */
+ recentSearches: ItemWithQuery[] | undefined;
+
+ /** Recent reports */
+ recentReports: OptionData[];
+
+ /** Callback to submit query when selecting a list item */
+ onSearchSubmit: (query: SearchQueryJSON | undefined) => void;
+
+ /** Context present when opening SearchRouter from a report, invoice or workspace page */
+ reportForContextualSearch?: OptionData;
+
+ /** Callback to update search query when selecting contextual suggestion */
+ updateUserSearchQuery: (newSearchQuery: string) => void;
+
+ /** Callback to close and clear SearchRouter */
+ closeAndClearRouter: () => void;
+};
+
+function isSearchQueryItem(item: OptionData | SearchQueryItem): item is SearchQueryItem {
+ if ('singleIcon' in item && item.singleIcon && 'query' in item && item.query) {
+ return true;
+ }
+ return false;
+}
+
+function isSearchQueryListItem(listItem: UserListItemProps | SearchQueryListItemProps): listItem is SearchQueryListItemProps {
+ return isSearchQueryItem(listItem.item);
+}
+
+function SearchRouterItem(props: UserListItemProps | SearchQueryListItemProps) {
+ const styles = useThemeStyles();
+
+ if (isSearchQueryListItem(props)) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+function SearchRouterList(
+ {currentQuery, reportForContextualSearch, recentSearches, recentReports, onSearchSubmit, updateUserSearchQuery, closeAndClearRouter}: SearchRouterListProps,
+ ref: ForwardedRef,
+) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {isSmallScreenWidth} = useResponsiveLayout();
+
+ const personalDetails = usePersonalDetails();
+ const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
+ const taxRates = getAllTaxRates();
+ const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
+ const contextualQuery = `in:${reportForContextualSearch?.reportID}`;
+ const sections: Array> = [];
+
+ if (currentQuery?.inputQuery) {
+ sections.push({
+ data: [
+ {
+ text: currentQuery?.inputQuery,
+ singleIcon: Expensicons.MagnifyingGlass,
+ query: currentQuery?.inputQuery,
+ itemStyle: styles.activeComponentBG,
+ keyForList: 'findItem',
+ },
+ ],
+ });
+ }
+
+ if (reportForContextualSearch && !currentQuery?.inputQuery?.includes(contextualQuery)) {
+ sections.push({
+ data: [
+ {
+ text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`,
+ singleIcon: Expensicons.MagnifyingGlass,
+ query: SearchUtils.getContextualSuggestionQuery(reportForContextualSearch.reportID),
+ itemStyle: styles.activeComponentBG,
+ keyForList: 'contextualSearch',
+ isContextualSearchItem: true,
+ },
+ ],
+ });
+ }
+
+ const recentSearchesData = recentSearches?.map(({query}) => {
+ const searchQueryJSON = SearchUtils.buildSearchQueryJSON(query);
+ return {
+ text: searchQueryJSON ? SearchUtils.getSearchHeaderTitle(searchQueryJSON, personalDetails, cardList, reports, taxRates) : query,
+ singleIcon: Expensicons.History,
+ query,
+ keyForList: query,
+ };
+ });
+
+ if (!currentQuery?.inputQuery && recentSearchesData && recentSearchesData.length > 0) {
+ sections.push({title: translate('search.recentSearches'), data: recentSearchesData});
+ }
+
+ const styledRecentReports = recentReports.map((item) => ({...item, pressableStyle: styles.br2}));
+ sections.push({title: translate('search.recentChats'), data: styledRecentReports});
+
+ const onSelectRow = useCallback(
+ (item: OptionData | SearchQueryItem) => {
+ if (isSearchQueryItem(item)) {
+ if (item.isContextualSearchItem) {
+ // Handle selection of "Contextual search suggestion"
+ updateUserSearchQuery(`${item?.query} ${currentQuery?.inputQuery ?? ''}`);
+ return;
+ }
+
+ // Handle selection of "Recent search"
+ if (!item?.query) {
+ return;
+ }
+ onSearchSubmit(SearchUtils.buildSearchQueryJSON(item?.query));
+ }
+
+ // Handle selection of "Recent chat"
+ closeAndClearRouter();
+ if ('reportID' in item && item?.reportID) {
+ Navigation.closeAndNavigate(ROUTES.REPORT_WITH_ID.getRoute(item?.reportID));
+ } else if ('login' in item) {
+ Report.navigateToAndOpenReport(item?.login ? [item.login] : []);
+ }
+ },
+ [closeAndClearRouter, onSearchSubmit, currentQuery, updateUserSearchQuery],
+ );
+
+ return (
+
+ sections={sections}
+ onSelectRow={onSelectRow}
+ ListItem={SearchRouterItem}
+ containerStyle={[styles.mh100]}
+ sectionListStyle={[isSmallScreenWidth ? styles.ph5 : styles.ph2, styles.pb2]}
+ ref={ref}
+ showScrollIndicator={!isSmallScreenWidth}
+ />
+ );
+}
+
+export default forwardRef(SearchRouterList);
+export {SearchRouterItem};
+export type {ItemWithQuery};
diff --git a/src/components/Search/SearchRouter/SearchRouterModal.tsx b/src/components/Search/SearchRouter/SearchRouterModal.tsx
new file mode 100644
index 000000000000..1f438d254a5f
--- /dev/null
+++ b/src/components/Search/SearchRouter/SearchRouterModal.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal';
+import Modal from '@components/Modal';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import CONST from '@src/CONST';
+import SearchRouter from './SearchRouter';
+import {useSearchRouterContext} from './SearchRouterContext';
+
+function SearchRouterModal() {
+ const {isSmallScreenWidth} = useResponsiveLayout();
+ const {isSearchRouterDisplayed, closeSearchRouter} = useSearchRouterContext();
+
+ const modalType = isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE : CONST.MODAL.MODAL_TYPE.POPOVER;
+
+ return (
+
+ {isSearchRouterDisplayed && }
+
+ );
+}
+
+SearchRouterModal.displayName = 'SearchRouterModal';
+
+export default SearchRouterModal;
diff --git a/src/components/Search/SearchStatusBar.tsx b/src/components/Search/SearchStatusBar.tsx
index ea19ef5f4e99..674b2da14f22 100644
--- a/src/components/Search/SearchStatusBar.tsx
+++ b/src/components/Search/SearchStatusBar.tsx
@@ -4,6 +4,7 @@ import type {ScrollView as RNScrollView} from 'react-native';
import Button from '@components/Button';
import * as Expensicons from '@components/Icon/Expensicons';
import ScrollView from '@components/ScrollView';
+import SearchStatusSkeleton from '@components/Skeletons/SearchStatusSkeleton';
import useLocalize from '@hooks/useLocalize';
import useSingleExecution from '@hooks/useSingleExecution';
import useStyleUtils from '@hooks/useStyleUtils';
@@ -15,125 +16,127 @@ import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
import type IconAsset from '@src/types/utils/IconAsset';
-import type {ChatSearchStatus, ExpenseSearchStatus, InvoiceSearchStatus, SearchQueryString, SearchStatus, TripSearchStatus} from './types';
+import {useSearchContext} from './SearchContext';
+import type {ChatSearchStatus, ExpenseSearchStatus, InvoiceSearchStatus, SearchStatus, TripSearchStatus} from './types';
type SearchStatusBarProps = {
type: SearchDataTypes;
status: SearchStatus;
- resetOffset: () => void;
+ policyID: string | undefined;
+ onStatusChange?: () => void;
};
-const expenseOptions: Array<{key: ExpenseSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [
+const expenseOptions: Array<{status: ExpenseSearchStatus; type: SearchDataTypes; icon: IconAsset; text: TranslationPaths}> = [
{
- key: CONST.SEARCH.STATUS.EXPENSE.ALL,
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ status: CONST.SEARCH.STATUS.EXPENSE.ALL,
icon: Expensicons.All,
text: 'common.all',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.ALL),
},
{
- key: CONST.SEARCH.STATUS.EXPENSE.DRAFTS,
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ status: CONST.SEARCH.STATUS.EXPENSE.DRAFTS,
icon: Expensicons.Pencil,
text: 'common.drafts',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.DRAFTS),
},
{
- key: CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING,
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ status: CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING,
icon: Expensicons.Hourglass,
text: 'common.outstanding',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING),
},
{
- key: CONST.SEARCH.STATUS.EXPENSE.APPROVED,
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ status: CONST.SEARCH.STATUS.EXPENSE.APPROVED,
icon: Expensicons.ThumbsUp,
text: 'iou.approved',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.APPROVED),
},
{
- key: CONST.SEARCH.STATUS.EXPENSE.PAID,
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ status: CONST.SEARCH.STATUS.EXPENSE.PAID,
icon: Expensicons.MoneyBag,
text: 'iou.settledExpensify',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.PAID),
},
];
-const invoiceOptions: Array<{key: InvoiceSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [
+const invoiceOptions: Array<{type: SearchDataTypes; status: InvoiceSearchStatus; icon: IconAsset; text: TranslationPaths}> = [
{
- key: CONST.SEARCH.STATUS.INVOICE.ALL,
+ type: CONST.SEARCH.DATA_TYPES.INVOICE,
+ status: CONST.SEARCH.STATUS.INVOICE.ALL,
icon: Expensicons.All,
text: 'common.all',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.INVOICE, CONST.SEARCH.STATUS.INVOICE.ALL),
},
{
- key: CONST.SEARCH.STATUS.INVOICE.OUTSTANDING,
+ type: CONST.SEARCH.DATA_TYPES.INVOICE,
+ status: CONST.SEARCH.STATUS.INVOICE.OUTSTANDING,
icon: Expensicons.Hourglass,
text: 'common.outstanding',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.INVOICE, CONST.SEARCH.STATUS.INVOICE.OUTSTANDING),
},
{
- key: CONST.SEARCH.STATUS.INVOICE.PAID,
+ type: CONST.SEARCH.DATA_TYPES.INVOICE,
+ status: CONST.SEARCH.STATUS.INVOICE.PAID,
icon: Expensicons.MoneyBag,
text: 'iou.settledExpensify',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.INVOICE, CONST.SEARCH.STATUS.INVOICE.PAID),
},
];
-const tripOptions: Array<{key: TripSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [
+const tripOptions: Array<{type: SearchDataTypes; status: TripSearchStatus; icon: IconAsset; text: TranslationPaths}> = [
{
- key: CONST.SEARCH.STATUS.TRIP.ALL,
+ type: CONST.SEARCH.DATA_TYPES.TRIP,
+ status: CONST.SEARCH.STATUS.TRIP.ALL,
icon: Expensicons.All,
text: 'common.all',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.ALL),
},
{
- key: CONST.SEARCH.STATUS.TRIP.CURRENT,
+ type: CONST.SEARCH.DATA_TYPES.TRIP,
+ status: CONST.SEARCH.STATUS.TRIP.CURRENT,
icon: Expensicons.Calendar,
text: 'search.filters.current',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.CURRENT),
},
{
- key: CONST.SEARCH.STATUS.TRIP.PAST,
+ type: CONST.SEARCH.DATA_TYPES.TRIP,
+ status: CONST.SEARCH.STATUS.TRIP.PAST,
icon: Expensicons.History,
text: 'search.filters.past',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.PAST),
},
];
-const chatOptions: Array<{key: ChatSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [
+const chatOptions: Array<{type: SearchDataTypes; status: ChatSearchStatus; icon: IconAsset; text: TranslationPaths}> = [
{
- key: CONST.SEARCH.STATUS.CHAT.ALL,
+ type: CONST.SEARCH.DATA_TYPES.CHAT,
+ status: CONST.SEARCH.STATUS.CHAT.ALL,
icon: Expensicons.All,
text: 'common.all',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.ALL),
},
{
- key: CONST.SEARCH.STATUS.CHAT.UNREAD,
+ type: CONST.SEARCH.DATA_TYPES.CHAT,
+ status: CONST.SEARCH.STATUS.CHAT.UNREAD,
icon: Expensicons.ChatBubbleUnread,
text: 'common.unread',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.UNREAD),
},
{
- key: CONST.SEARCH.STATUS.CHAT.SENT,
+ type: CONST.SEARCH.DATA_TYPES.CHAT,
+ status: CONST.SEARCH.STATUS.CHAT.SENT,
icon: Expensicons.Send,
text: 'common.sent',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.SENT),
},
{
- key: CONST.SEARCH.STATUS.CHAT.ATTACHMENTS,
+ type: CONST.SEARCH.DATA_TYPES.CHAT,
+ status: CONST.SEARCH.STATUS.CHAT.ATTACHMENTS,
icon: Expensicons.Document,
text: 'common.attachments',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.ATTACHMENTS),
},
{
- key: CONST.SEARCH.STATUS.CHAT.LINKS,
+ type: CONST.SEARCH.DATA_TYPES.CHAT,
+ status: CONST.SEARCH.STATUS.CHAT.LINKS,
icon: Expensicons.Paperclip,
text: 'common.links',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.LINKS),
},
{
- key: CONST.SEARCH.STATUS.CHAT.PINNED,
+ type: CONST.SEARCH.DATA_TYPES.CHAT,
+ status: CONST.SEARCH.STATUS.CHAT.PINNED,
icon: Expensicons.Pin,
text: 'search.filters.pinned',
- query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.PINNED),
},
];
@@ -151,7 +154,7 @@ function getOptions(type: SearchDataTypes) {
}
}
-function SearchStatusBar({type, status, resetOffset}: SearchStatusBarProps) {
+function SearchStatusBar({type, status, policyID, onStatusChange}: SearchStatusBarProps) {
const {singleExecution} = useSingleExecution();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -160,26 +163,32 @@ function SearchStatusBar({type, status, resetOffset}: SearchStatusBarProps) {
const options = getOptions(type);
const scrollRef = useRef(null);
const isScrolledRef = useRef(false);
+ const {shouldShowStatusBarLoading} = useSearchContext();
+
+ if (shouldShowStatusBarLoading) {
+ return ;
+ }
return (
{options.map((item, index) => {
const onPress = singleExecution(() => {
- resetOffset();
- Navigation.setParams({q: item.query});
+ onStatusChange?.();
+ const query = SearchUtils.buildCannedSearchQuery({type: item.type, status: item.status, policyID});
+ Navigation.setParams({q: query});
});
- const isActive = status === item.key;
+ const isActive = status === item.status;
const isFirstItem = index === 0;
const isLastItem = index === options.length - 1;
return (
@@ -176,6 +152,17 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
height={variables.iconBottomBar}
/>
+
+ {translate('common.search')}
+
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx
index 89b83dae816c..7401c3368124 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx
@@ -19,7 +19,7 @@ function getStateToRender(state: StackNavigationState): StackNavi
// We need to render at least one HOME screen to make sure everything load properly. This may be not necessary after changing how IS_SIDEBAR_LOADED is handled.
// Currently this value will be switched only after the first HOME screen is rendered.
- if (routesToRender[0].name !== SCREENS.HOME) {
+ if (routesToRender.at(0)?.name !== SCREENS.HOME) {
const routeToRender = state.routes.find((route) => route.name === SCREENS.HOME);
if (routeToRender) {
routesToRender.unshift(routeToRender);
diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx
index 27e976d9be0c..7dc66d06fd4a 100644
--- a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx
@@ -40,7 +40,7 @@ function adaptStateIfNecessary(state: StackState) {
if (state.stale === true) {
state.routes.push({
name: SCREENS.WORKSPACE.PROFILE,
- params: state.routes[0]?.params,
+ params: state.routes.at(0)?.params,
});
}
}
diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx
index a37a102ff8a3..6c153b1b159e 100644
--- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx
@@ -64,20 +64,23 @@ function ResponsiveStackNavigator(props: ResponsiveStackNavigatorProps) {
if (shouldUseNarrowLayout) {
const isSearchCentralPane = (route: RouteProp) => getTopmostCentralPaneRoute({routes: [route]} as State)?.name === SCREENS.SEARCH.CENTRAL_PANE;
- const lastRoute = routes[routes.length - 1];
- const lastSearchCentralPane = isSearchCentralPane(lastRoute) ? lastRoute : undefined;
+ const lastRoute = routes.at(-1);
+ const lastSearchCentralPane = lastRoute && isSearchCentralPane(lastRoute) ? lastRoute : undefined;
const filteredRoutes = routes.filter((route) => !isSearchCentralPane(route));
// On narrow layout, if we are on /search route we want to hide all central pane routes and show only the bottom tab navigator.
if (lastSearchCentralPane) {
- return {
- stateToRender: {
- ...state,
- index: 0,
- routes: [filteredRoutes[0]],
- },
- searchRoute: lastSearchCentralPane,
- };
+ const filteredRoute = filteredRoutes.at(0);
+ if (filteredRoute) {
+ return {
+ stateToRender: {
+ ...state,
+ index: 0,
+ routes: [filteredRoute],
+ },
+ searchRoute: lastSearchCentralPane,
+ };
+ }
}
return {
diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx
index a1aa53bc0b7e..bb005fc6b763 100644
--- a/src/libs/Navigation/NavigationRoot.tsx
+++ b/src/libs/Navigation/NavigationRoot.tsx
@@ -3,7 +3,6 @@ import {DefaultTheme, findFocusedRoute, NavigationContainer} from '@react-naviga
import React, {useContext, useEffect, useMemo, useRef} from 'react';
import {NativeModules} from 'react-native';
import {useOnyx} from 'react-native-onyx';
-import HybridAppMiddleware from '@components/HybridAppMiddleware';
import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useCurrentReportID from '@hooks/useCurrentReportID';
@@ -11,8 +10,8 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import Firebase from '@libs/Firebase';
import {FSPage} from '@libs/Fullstory';
-import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector';
import Log from '@libs/Log';
+import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors';
import {getPathFromURL} from '@libs/Url';
import {updateLastVisitedPath} from '@userActions/App';
import {updateOnboardingLastVisitedPath} from '@userActions/Welcome';
@@ -92,7 +91,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh
const {setActiveWorkspaceID} = useActiveWorkspace();
const [user] = useOnyx(ONYXKEYS.USER);
- const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
+ const [isOnboardingCompleted = true] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
selector: hasCompletedGuidedSetupFlowSelector,
});
@@ -103,7 +102,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh
// If the user haven't completed the flow, we want to always redirect them to the onboarding flow.
// We also make sure that the user is authenticated.
- if (!NativeModules.HybridAppModule && !hasCompletedGuidedSetupFlow && authenticated && !shouldShowRequire2FAModal) {
+ if (!NativeModules.HybridAppModule && !isOnboardingCompleted && authenticated && !shouldShowRequire2FAModal) {
const {adaptedState} = getAdaptedStateFromPath(getOnboardingInitialPath(), linkingConfig.config);
return adaptedState;
}
@@ -186,10 +185,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh
enabled: false,
}}
>
- {/* HybridAppMiddleware needs to have access to navigation ref and SplashScreenHidden context */}
-
-
-
+
);
}
diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx
index 5c5bbe201ab4..a6a77403f148 100644
--- a/src/libs/Navigation/OnyxTabNavigator.tsx
+++ b/src/libs/Navigation/OnyxTabNavigator.tsx
@@ -123,8 +123,8 @@ function OnyxTabNavigator({
const state = event.data.state;
const index = state.index;
const routeNames = state.routeNames;
- Tab.setSelectedTab(id, routeNames[index] as SelectedTabRequest);
- onTabSelected(routeNames[index] as IOURequestType);
+ Tab.setSelectedTab(id, routeNames.at(index) as SelectedTabRequest);
+ onTabSelected(routeNames.at(index) as IOURequestType);
},
...(screenListeners ?? {}),
}}
diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts
index 609162bedd13..5d2a6b75c224 100755
--- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts
@@ -37,6 +37,8 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial> =
SCREENS.SETTINGS.WALLET.CARD_ACTIVATE,
SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD,
SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS,
+ SCREENS.SETTINGS.WALLET.VERIFY_ACCOUNT,
+ SCREENS.SETTINGS.ADD_BANK_ACCOUNT,
],
[SCREENS.SETTINGS.SECURITY]: [
SCREENS.SETTINGS.TWO_FACTOR_AUTH,
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 319ec60d143e..114e89ff2bf5 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -161,6 +161,10 @@ const config: LinkingOptions['config'] = {
path: ROUTES.SETTINGS_CLOSE,
exact: true,
},
+ [SCREENS.SETTINGS.WALLET.VERIFY_ACCOUNT]: {
+ path: ROUTES.SETTINGS_WALLET_VERIFY_ACCOUNT.route,
+ exact: true,
+ },
[SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: {
path: ROUTES.SETTINGS_WALLET_DOMAINCARD.route,
exact: true,
@@ -1178,7 +1182,7 @@ const config: LinkingOptions['config'] = {
},
[SCREENS.RIGHT_MODAL.MISSING_PERSONAL_DETAILS]: {
screens: {
- [SCREENS.MISSING_PERSONAL_DETAILS_ROOT]: ROUTES.MISSING_PERSONAL_DETAILS.route,
+ [SCREENS.MISSING_PERSONAL_DETAILS_ROOT]: ROUTES.MISSING_PERSONAL_DETAILS,
},
},
[SCREENS.RIGHT_MODAL.DEBUG]: {
diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
index f92b133d719a..c84213918f70 100644
--- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
+++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
@@ -117,7 +117,11 @@ function getMatchingRootRouteForRHPRoute(route: NavigationPartialRoute): Navigat
// If there is rhpNavigator in the state generated for backTo url, we want to get root route matching to this rhp screen.
const rhpNavigator = stateForBackTo.routes.find((rt) => rt.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR);
if (rhpNavigator && rhpNavigator.state) {
- return getMatchingRootRouteForRHPRoute(findFocusedRoute(stateForBackTo) as NavigationPartialRoute);
+ const isRHPinState = stateForBackTo.routes.at(0)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR;
+
+ if (isRHPinState) {
+ return getMatchingRootRouteForRHPRoute(findFocusedRoute(stateForBackTo) as NavigationPartialRoute);
+ }
}
// If we know that backTo targets the root route (full screen) we want to use it.
@@ -336,7 +340,7 @@ function getAdaptedState(state: PartialState
// - matching central pane on desktop layout
// We want to make sure that the bottom tab search page is always pushed with matching central pane page. Even on the narrow layout.
- if (isNarrowLayout && bottomTabNavigator.state?.routes[0].name !== SCREENS.SEARCH.BOTTOM_TAB) {
+ if (isNarrowLayout && bottomTabNavigator.state?.routes.at(0)?.name !== SCREENS.SEARCH.BOTTOM_TAB) {
return {
adaptedState: state,
metainfo,
diff --git a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts
index 0679bfa26285..cec00f705127 100644
--- a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts
+++ b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts
@@ -53,7 +53,10 @@ function getMatchingCentralPaneRouteForState(state: State, r
return;
}
- const centralPaneName = TAB_TO_CENTRAL_PANE_MAPPING[topmostBottomTabRoute.name][0];
+ const centralPaneName = TAB_TO_CENTRAL_PANE_MAPPING[topmostBottomTabRoute.name].at(0);
+ if (!centralPaneName) {
+ return;
+ }
if (topmostBottomTabRoute.name === SCREENS.SETTINGS.ROOT) {
// When we go back to the settings tab without switching the workspace id, we want to return to the previously opened screen
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 39053de521db..b698681966e2 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -168,6 +168,9 @@ type SettingsNavigatorParamList = {
[SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: undefined;
[SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT]: undefined;
[SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS]: undefined;
+ [SCREENS.SETTINGS.WALLET.VERIFY_ACCOUNT]: {
+ backTo?: Routes;
+ };
[SCREENS.SETTINGS.ADD_DEBIT_CARD]: undefined;
[SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: undefined;
[SCREENS.SETTINGS.PROFILE.STATUS]: undefined;
@@ -1493,9 +1496,7 @@ type RestrictedActionParamList = {
};
type MissingPersonalDetailsParamList = {
- [SCREENS.MISSING_PERSONAL_DETAILS_ROOT]: {
- policyID: string;
- };
+ [SCREENS.MISSING_PERSONAL_DETAILS_ROOT]: undefined;
};
type DebugParamList = {
diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts
index 9442ebda3cf0..35c7b2bf779a 100644
--- a/src/libs/Network/SequentialQueue.ts
+++ b/src/libs/Network/SequentialQueue.ts
@@ -25,7 +25,7 @@ let isReadyPromise = new Promise((resolve) => {
resolveIsReadyPromise?.();
let isSequentialQueueRunning = false;
-let currentRequest: Promise | null = null;
+let currentRequestPromise: Promise | null = null;
let isQueuePaused = false;
/**
@@ -80,10 +80,14 @@ function process(): Promise {
return Promise.resolve();
}
- const requestToProcess = persistedRequests[0];
+ const requestToProcess = PersistedRequests.processNextRequest();
+ if (!requestToProcess) {
+ Log.info('[SequentialQueue] Unable to process. No next request to handle.');
+ return Promise.resolve();
+ }
// Set the current request to a promise awaiting its processing so that getCurrentRequest can be used to take some action after the current request has processed.
- currentRequest = Request.processWithMiddleware(requestToProcess, true)
+ currentRequestPromise = Request.processWithMiddleware(requestToProcess, true)
.then((response) => {
// A response might indicate that the queue should be paused. This happens when a gap in onyx updates is detected between the client and the server and
// that gap needs resolved before the queue can continue.
@@ -91,6 +95,7 @@ function process(): Promise {
Log.info("[SequentialQueue] Handled 'shouldPauseQueue' in response. Pausing the queue.");
pause();
}
+
PersistedRequests.remove(requestToProcess);
RequestThrottle.clear();
return process();
@@ -103,6 +108,7 @@ function process(): Promise {
RequestThrottle.clear();
return process();
}
+ PersistedRequests.rollbackOngoingRequest();
return RequestThrottle.sleep(error, requestToProcess.command)
.then(process)
.catch(() => {
@@ -113,7 +119,7 @@ function process(): Promise {
});
});
- return currentRequest;
+ return currentRequestPromise;
}
function flush() {
@@ -161,7 +167,8 @@ function flush() {
if (NetworkStore.isOffline() || PersistedRequests.getAll().length === 0) {
resolveIsReadyPromise?.();
}
- currentRequest = null;
+ currentRequestPromise = null;
+
// The queue can be paused when we sync the data with backend so we should only update the Onyx data when the queue is empty
if (PersistedRequests.getAll().length === 0) {
flushOnyxUpdatesQueue();
@@ -181,7 +188,7 @@ function unpause() {
}
const numberOfPersistedRequests = PersistedRequests.getAll().length || 0;
- console.debug(`[SequentialQueue] Unpausing the queue and flushing ${numberOfPersistedRequests} requests`);
+ Log.info(`[SequentialQueue] Unpausing the queue and flushing ${numberOfPersistedRequests} requests`);
isQueuePaused = false;
flush();
}
@@ -197,9 +204,29 @@ function isPaused(): boolean {
// Flush the queue when the connection resumes
NetworkStore.onReconnection(flush);
-function push(request: OnyxRequest) {
- // Add request to Persisted Requests so that it can be retried if it fails
- PersistedRequests.save(request);
+function push(newRequest: OnyxRequest) {
+ const {checkAndFixConflictingRequest} = newRequest;
+
+ if (checkAndFixConflictingRequest) {
+ const requests = PersistedRequests.getAll();
+ const {conflictAction} = checkAndFixConflictingRequest(requests);
+ Log.info(`[SequentialQueue] Conflict action for command ${newRequest.command} - ${conflictAction.type}:`);
+
+ // don't try to serialize a function.
+ // eslint-disable-next-line no-param-reassign
+ delete newRequest.checkAndFixConflictingRequest;
+
+ if (conflictAction.type === 'push') {
+ PersistedRequests.save(newRequest);
+ } else if (conflictAction.type === 'replace') {
+ PersistedRequests.update(conflictAction.index, newRequest);
+ } else {
+ Log.info(`[SequentialQueue] No action performed to command ${newRequest.command} and it will be ignored.`);
+ }
+ } else {
+ // Add request to Persisted Requests so that it can be retried if it fails
+ PersistedRequests.save(newRequest);
+ }
// If we are offline we don't need to trigger the queue to empty as it will happen when we come back online
if (NetworkStore.isOffline()) {
@@ -216,10 +243,10 @@ function push(request: OnyxRequest) {
}
function getCurrentRequest(): Promise {
- if (currentRequest === null) {
+ if (currentRequestPromise === null) {
return Promise.resolve();
}
- return currentRequest;
+ return currentRequestPromise;
}
/**
@@ -229,5 +256,5 @@ function waitForIdle(): Promise {
return isReadyPromise;
}
-export {flush, getCurrentRequest, isRunning, isPaused, push, waitForIdle, pause, unpause};
+export {flush, getCurrentRequest, isRunning, isPaused, push, waitForIdle, pause, unpause, process};
export type {RequestError};
diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts
index ba435cc57b8f..4016e2418a50 100644
--- a/src/libs/NextStepUtils.ts
+++ b/src/libs/NextStepUtils.ts
@@ -43,8 +43,8 @@ function parseMessage(messages: Message[] | undefined) {
let tagType = part.type ?? 'span';
let content = Str.safeEscape(part.text);
- const previousPart = messages[index - 1];
- const nextPart = messages[index + 1];
+ const previousPart = index !== 0 ? messages.at(index - 1) : undefined;
+ const nextPart = messages.at(index + 1);
if (currentUserEmail === part.text || part.clickToCopyText === currentUserEmail) {
tagType = 'strong';
@@ -94,7 +94,6 @@ function buildNextStep(report: OnyxEntry, predictedNextStatus: ValueOf, predictedNextStatus: ValueOf {
- const reportID = reportActions[0].split('_')[1];
+ const reportID = reportActions[0].split('_').at(1);
+ if (!reportID) {
+ return;
+ }
+
const reportActionsArray = Object.values(reportActions[1] ?? {});
let sortedReportActions = ReportActionUtils.getSortedReportActions(reportActionsArray, true);
allSortedReportActions[reportID] = sortedReportActions;
@@ -311,7 +315,12 @@ Onyx.connect({
sortedReportActions = ReportActionUtils.getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, reportID, false);
}
- lastReportActions[reportID] = sortedReportActions[0];
+ const firstReportAction = sortedReportActions.at(0);
+ if (!firstReportAction) {
+ delete lastReportActions[reportID];
+ } else {
+ lastReportActions[reportID] = firstReportAction;
+ }
// The report is only visible if it is the last action not deleted that
// does not match a closed or created state.
@@ -323,7 +332,12 @@ Onyx.connect({
reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE &&
!ReportActionUtils.isResolvedActionTrackExpense(reportAction),
);
- lastVisibleReportActions[reportID] = reportActionsForDisplay[0];
+ const reportActionForDisplay = reportActionsForDisplay.at(0);
+ if (!reportActionForDisplay) {
+ delete lastVisibleReportActions[reportID];
+ return;
+ }
+ lastVisibleReportActions[reportID] = reportActionForDisplay;
});
},
});
@@ -532,9 +546,15 @@ function getAlternateText(option: ReportUtils.OptionData, {showChatPreviewLine =
const report = ReportUtils.getReportOrDraftReport(option.reportID);
const isAdminRoom = ReportUtils.isAdminRoom(report);
const isAnnounceRoom = ReportUtils.isAnnounceRoom(report);
+ const isGroupChat = ReportUtils.isGroupChat(report);
+ const isExpenseThread = ReportUtils.isMoneyRequest(report);
- if (!!option.isThread || !!option.isMoneyRequestReport) {
- return option.lastMessageText ? option.lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet');
+ if (isExpenseThread || option.isMoneyRequestReport) {
+ return showChatPreviewLine && option.lastMessageText ? option.lastMessageText : Localize.translate(preferredLocale, 'iou.expense');
+ }
+
+ if (option.isThread) {
+ return showChatPreviewLine && option.lastMessageText ? option.lastMessageText : Localize.translate(preferredLocale, 'threads.thread');
}
if (option.isChatRoom && !isAdminRoom && !isAnnounceRoom) {
@@ -546,12 +566,16 @@ function getAlternateText(option: ReportUtils.OptionData, {showChatPreviewLine =
}
if (option.isTaskReport) {
- return showChatPreviewLine && option.lastMessageText ? option.lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet');
+ return showChatPreviewLine && option.lastMessageText ? option.lastMessageText : Localize.translate(preferredLocale, 'task.task');
+ }
+
+ if (isGroupChat) {
+ return showChatPreviewLine && option.lastMessageText ? option.lastMessageText : Localize.translate(preferredLocale, 'common.group');
}
return showChatPreviewLine && option.lastMessageText
? option.lastMessageText
- : LocalePhoneNumber.formatPhoneNumber(option.participantsList && option.participantsList.length > 0 ? option.participantsList[0].login ?? '' : '');
+ : LocalePhoneNumber.formatPhoneNumber(option.participantsList && option.participantsList.length > 0 ? option.participantsList.at(0)?.login ?? '' : '');
}
function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchValue: string) {
@@ -660,8 +684,16 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails
lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(TaskUtils.getTaskReportActionMessage(lastReportAction).text);
} else if (ReportActionUtils.isCreatedTaskReportAction(lastReportAction)) {
lastMessageTextFromReport = TaskUtils.getTaskCreatedMessage(lastReportAction);
- } else if (ReportActionUtils.isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED)) {
- lastMessageTextFromReport = ReportUtils.getIOUSubmittedMessage(lastReportAction);
+ } else if (
+ ReportActionUtils.isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED) ||
+ ReportActionUtils.isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED)
+ ) {
+ const wasSubmittedViaHarvesting = ReportActionUtils.getOriginalMessage(lastReportAction)?.harvesting ?? false;
+ if (wasSubmittedViaHarvesting) {
+ lastMessageTextFromReport = ReportUtils.getReportAutomaticallySubmittedMessage(lastReportAction);
+ } else {
+ lastMessageTextFromReport = ReportUtils.getIOUSubmittedMessage(lastReportAction);
+ }
} else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED) {
lastMessageTextFromReport = ReportUtils.getIOUApprovedMessage(lastReportAction);
} else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.FORWARDED) {
@@ -725,7 +757,7 @@ function createOption(
const personalDetailMap = getPersonalDetailsForAccountIDs(accountIDs, personalDetails);
const personalDetailList = Object.values(personalDetailMap).filter((details): details is PersonalDetails => !!details);
- const personalDetail = personalDetailList[0];
+ const personalDetail = personalDetailList.at(0);
let hasMultipleParticipants = personalDetailList.length > 1;
let subtitle;
let reportName;
@@ -783,12 +815,12 @@ function createOption(
result.alternateText = showPersonalDetails && personalDetail?.login ? personalDetail.login : getAlternateText(result, {showChatPreviewLine, forcePolicyNamePreview});
reportName = showPersonalDetails
- ? ReportUtils.getDisplayNameForParticipant(accountIDs[0]) || LocalePhoneNumber.formatPhoneNumber(personalDetail?.login ?? '')
+ ? ReportUtils.getDisplayNameForParticipant(accountIDs.at(0)) || LocalePhoneNumber.formatPhoneNumber(personalDetail?.login ?? '')
: ReportUtils.getReportName(report);
} else {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- reportName = ReportUtils.getDisplayNameForParticipant(accountIDs[0]) || LocalePhoneNumber.formatPhoneNumber(personalDetail?.login ?? '');
- result.keyForList = String(accountIDs[0]);
+ reportName = ReportUtils.getDisplayNameForParticipant(accountIDs.at(0)) || LocalePhoneNumber.formatPhoneNumber(personalDetail?.login ?? '');
+ result.keyForList = String(accountIDs.at(0));
result.alternateText = LocalePhoneNumber.formatPhoneNumber(personalDetails?.[accountIDs[0]]?.login ?? '');
}
@@ -2084,7 +2116,7 @@ function getOptions(
/**
* Build the options for the Search view
*/
-function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] = []): Options {
+function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] = [], isUsedInChatFinder = true): Options {
Timing.start(CONST.TIMING.LOAD_SEARCH_OPTIONS);
Performance.markStart(CONST.TIMING.LOAD_SEARCH_OPTIONS);
const optionList = getOptions(options, {
@@ -2094,7 +2126,7 @@ function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] =
includeMultipleParticipantReports: true,
maxRecentReportsToShow: 0, // Unlimited
sortByReportTypeInSearch: true,
- showChatPreviewLine: true,
+ showChatPreviewLine: isUsedInChatFinder,
includeP2P: true,
forcePolicyNamePreview: true,
includeOwnedWorkspaceChats: true,
@@ -2102,7 +2134,7 @@ function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] =
includeMoneyRequests: true,
includeTasks: true,
includeSelfDM: true,
- shouldBoldTitleByDefault: false,
+ shouldBoldTitleByDefault: !isUsedInChatFinder,
});
Timing.end(CONST.TIMING.LOAD_SEARCH_OPTIONS);
Performance.markEnd(CONST.TIMING.LOAD_SEARCH_OPTIONS);
@@ -2408,9 +2440,9 @@ function getFirstKeyForList(data?: Option[] | null) {
return '';
}
- const firstNonEmptyDataObj = data[0];
+ const firstNonEmptyDataObj = data.at(0);
- return firstNonEmptyDataObj.keyForList ? firstNonEmptyDataObj.keyForList : '';
+ return firstNonEmptyDataObj?.keyForList ? firstNonEmptyDataObj?.keyForList : '';
}
function getPersonalDetailSearchTerms(item: Partial) {
diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts
index a08868f5dcc1..2a214aaae19b 100644
--- a/src/libs/PaginationUtils.ts
+++ b/src/libs/PaginationUtils.ts
@@ -46,12 +46,12 @@ function findFirstItem(sortedItems: TResource[], page: string[], getI
*/
function findLastItem(sortedItems: TResource[], page: string[], getID: (item: TResource) => string): ItemWithIndex | null {
for (let i = page.length - 1; i >= 0; i--) {
- const id = page[i];
+ const id = page.at(i);
if (id === CONST.PAGINATION_END_ID) {
return {id, index: sortedItems.length - 1};
}
const index = sortedItems.findIndex((item) => getID(item) === id);
- if (index !== -1) {
+ if (index !== -1 && id) {
return {id, index};
}
}
@@ -121,10 +121,15 @@ function mergeAndSortContinuousPages(sortedItems: TResource[], pages:
return b.lastIndex - a.lastIndex;
});
- const result = [sortedPages[0]];
+ const result = [sortedPages.at(0)];
for (let i = 1; i < sortedPages.length; i++) {
- const page = sortedPages[i];
- const prevPage = result[result.length - 1];
+ const page = sortedPages.at(i);
+ const prevPage = result.at(-1);
+
+ if (!page || !prevPage) {
+ // eslint-disable-next-line no-continue
+ continue;
+ }
// Current page is inside the previous page, skip
if (page.lastIndex <= prevPage.lastIndex && page.lastID !== CONST.PAGINATION_END_ID) {
@@ -151,7 +156,7 @@ function mergeAndSortContinuousPages(sortedItems: TResource[], pages:
result.push(page);
}
- return result.map((page) => page.ids);
+ return result.map((page) => page?.ids ?? []);
}
/**
@@ -167,7 +172,13 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g
const pagesWithIndexes = getPagesWithIndexes(sortedItems, pages, getID);
- let page: PageWithIndex;
+ let page: PageWithIndex = {
+ ids: [],
+ firstID: '',
+ firstIndex: 0,
+ lastID: '',
+ lastIndex: 0,
+ };
if (id) {
const index = sortedItems.findIndex((item) => getID(item) === id);
@@ -179,14 +190,20 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g
const linkedPage = pagesWithIndexes.find((pageIndex) => index >= pageIndex.firstIndex && index <= pageIndex.lastIndex);
+ const item = sortedItems.at(index);
// If we are linked to an action in a gap return it by itself
- if (!linkedPage) {
- return [sortedItems[index]];
+ if (!linkedPage && item) {
+ return [item];
}
- page = linkedPage;
+ if (linkedPage) {
+ page = linkedPage;
+ }
} else {
- page = pagesWithIndexes[0];
+ const pageAtIndex0 = pagesWithIndexes.at(0);
+ if (pageAtIndex0) {
+ page = pageAtIndex0;
+ }
}
return page ? sortedItems.slice(page.firstIndex, page.lastIndex + 1) : sortedItems;
diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts
index af48fd793b43..3d9aed117ca3 100644
--- a/src/libs/PersonalDetailsUtils.ts
+++ b/src/libs/PersonalDetailsUtils.ts
@@ -150,7 +150,7 @@ function getNewAccountIDsAndLogins(logins: string[], accountIDs: number[]) {
const newAccountIDs: number[] = [];
const newLogins: string[] = [];
logins.forEach((login, index) => {
- const accountID = accountIDs[index];
+ const accountID = accountIDs.at(index) ?? -1;
if (isEmptyObject(allPersonalDetails?.[accountID])) {
newAccountIDs.push(accountID);
newLogins.push(login);
@@ -169,7 +169,7 @@ function getPersonalDetailsOnyxDataForOptimisticUsers(newLogins: string[], newAc
const personalDetailsCleanup: PersonalDetailsList = {};
newLogins.forEach((login, index) => {
- const accountID = newAccountIDs[index];
+ const accountID = newAccountIDs.at(index) ?? -1;
personalDetailsNew[accountID] = {
login,
accountID,
@@ -233,7 +233,7 @@ function getFormattedStreet(street1 = '', street2 = '') {
*/
function getStreetLines(street = '') {
const streets = street.split('\n');
- return [streets[0], streets[1]];
+ return [streets.at(0), streets.at(1)];
}
/**
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index e7479920b260..f7c75d73f2c3 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -144,9 +144,11 @@ function getRateDisplayValue(value: number, toLocaleDigit: (arg: string) => stri
}
if (withDecimals) {
- const decimalPart = numValue.toString().split('.')[1];
- const fixedDecimalPoints = decimalPart.length > 2 && !decimalPart.endsWith('0') ? 3 : 2;
- return Number(numValue).toFixed(fixedDecimalPoints).toString().replace('.', toLocaleDigit('.'));
+ const decimalPart = numValue.toString().split('.').at(1);
+ if (decimalPart) {
+ const fixedDecimalPoints = decimalPart.length > 2 && !decimalPart.endsWith('0') ? 3 : 2;
+ return Number(numValue).toFixed(fixedDecimalPoints).toString().replace('.', toLocaleDigit('.'));
+ }
}
return numValue.toString().replace('.', toLocaleDigit('.')).substring(0, value.toString().length);
@@ -302,12 +304,12 @@ function getTagLists(policyTagList: OnyxEntry): Array, tagIndex: number): ValueOf {
const tagLists = getTagLists(policyTagList);
-
return (
- tagLists[tagIndex] ?? {
+ tagLists.at(tagIndex) ?? {
name: '',
required: false,
tags: {},
+ orderWeight: 0,
}
);
}
@@ -507,12 +509,12 @@ function getDefaultApprover(policy: OnyxEntry): string {
* Returns the accountID to whom the given employeeAccountID submits reports to in the given Policy.
*/
function getSubmitToAccountID(policy: OnyxEntry, employeeAccountID: number): number {
- const employeeLogin = getLoginsByAccountIDs([employeeAccountID])[0];
+ 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))) {
- return getAccountIDsByLogins([defaultApprover])[0];
+ return getAccountIDsByLogins([defaultApprover]).at(0) ?? -1;
}
const employee = policy?.employeeList?.[employeeLogin];
@@ -520,12 +522,12 @@ function getSubmitToAccountID(policy: OnyxEntry, employeeAccountID: numb
return -1;
}
- return getAccountIDsByLogins([employee.submitsTo ?? defaultApprover])[0];
+ return getAccountIDsByLogins([employee.submitsTo ?? defaultApprover]).at(0) ?? -1;
}
function getSubmitToEmail(policy: OnyxEntry, employeeAccountID: number): string {
const submitToAccountID = getSubmitToAccountID(policy, employeeAccountID);
- return getLoginsByAccountIDs([submitToAccountID])[0] ?? '';
+ return getLoginsByAccountIDs([submitToAccountID]).at(0) ?? '';
}
/**
@@ -550,11 +552,11 @@ function getForwardsToAccount(policy: OnyxEntry, employeeEmail: string,
}
/**
- * Returns the accountID of the policy reimburser, if not available ā falls back to the policy owner.
+ * Returns the accountID of the policy reimburser, if not available returns -1.
*/
function getReimburserAccountID(policy: OnyxEntry): number {
- const reimburserEmail = policy?.achAccount?.reimburser ?? policy?.owner ?? '';
- return getAccountIDsByLogins([reimburserEmail])[0];
+ const reimburserEmail = policy?.achAccount?.reimburser ?? '';
+ return reimburserEmail ? getAccountIDsByLogins([reimburserEmail]).at(0) ?? -1 : -1;
}
function getPersonalPolicy() {
@@ -834,6 +836,23 @@ function getCustomersOrJobsLabelNetSuite(policy: Policy | undefined, translate:
return importedValueLabel.charAt(0).toUpperCase() + importedValueLabel.slice(1);
}
+function getNetSuiteImportCustomFieldLabel(
+ policy: Policy | undefined,
+ importField: ValueOf,
+ translate: LocaleContextProps['translate'],
+): string | undefined {
+ const fieldData = policy?.connections?.netsuite?.options?.config.syncOptions?.[importField] ?? [];
+ if (fieldData.length === 0) {
+ return undefined;
+ }
+
+ const mappingSet = new Set(fieldData.map((item) => item.mapping));
+ const importedTypes = Array.from(mappingSet)
+ .sort((a, b) => b.localeCompare(a))
+ .map((mapping) => translate(`workspace.netsuite.import.importTypes.${mapping}.label`).toLowerCase());
+ return translate(`workspace.netsuite.import.importCustomFields.label`, {importedTypes});
+}
+
function isNetSuiteCustomSegmentRecord(customField: NetSuiteCustomList | NetSuiteCustomSegment): boolean {
return 'segmentName' in customField;
}
@@ -1133,6 +1152,7 @@ export {
getDomainNameForPolicy,
hasUnsupportedIntegration,
getWorkflowApprovalsUnavailable,
+ getNetSuiteImportCustomFieldLabel,
};
export type {MemberEmailsToAccountIDs};
diff --git a/src/libs/Queue/Queue.ts b/src/libs/Queue/Queue.ts
index 8b2011e64371..26586d8014a1 100644
--- a/src/libs/Queue/Queue.ts
+++ b/src/libs/Queue/Queue.ts
@@ -59,7 +59,7 @@ function createQueue(processItem: (item: T) => Promise): Queue {
// Function to get the item at the front of the queue without removing it
function peek(): T | undefined {
- return elements.length > 0 ? elements[0] : undefined;
+ return elements.length > 0 ? elements.at(0) : undefined;
}
// Function to get the number of items in the queue
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 15d7728ba35a..486943494854 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -58,7 +58,6 @@ Onyx.connect({
if (!actions) {
return;
}
-
allReportActions = actions;
},
});
@@ -130,13 +129,13 @@ function isDeletedAction(reportAction: OnyxInputOrEntry): boolean {
@@ -163,6 +162,10 @@ function isSubmittedAction(reportAction: OnyxInputOrEntry): report
return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED);
}
+function isSubmittedAndClosedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction {
+ return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED);
+}
+
function isApprovedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction {
return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.APPROVED);
}
@@ -521,8 +524,8 @@ function findPreviousAction(reportActions: ReportAction[] | undefined, actionInd
for (let i = actionIndex + 1; i < reportActions.length; i++) {
// Find the next non-pending deletion report action, as the pending delete action means that it is not displayed in the UI, but still is in the report actions list.
// If we are offline, all actions are pending but shown in the UI, so we take the previous action, even if it is a delete.
- if (isNetworkOffline || reportActions[i].pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
- return reportActions[i];
+ if (isNetworkOffline || reportActions.at(i)?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
+ return reportActions.at(i);
}
}
@@ -732,8 +735,12 @@ function replaceBaseURLInPolicyChangeLogAction(reportAction: ReportAction): Repo
return updatedReportAction;
}
- if (Array.isArray(updatedReportAction.message) && updatedReportAction.message[0]) {
- updatedReportAction.message[0].html = getReportActionHtml(reportAction)?.replace('%baseURL', environmentURL);
+ if (Array.isArray(updatedReportAction.message)) {
+ const message = updatedReportAction.message.at(0);
+
+ if (message) {
+ message.html = getReportActionHtml(reportAction)?.replace('%baseURL', environmentURL);
+ }
}
return updatedReportAction;
@@ -753,7 +760,7 @@ function getLastVisibleAction(reportID: string, actionsToMerge: Record !isDeletedAction(action) || (action?.childVisibleActionCount ?? 0) > 0 || isOffline);
- return sortedFilterReportActions.length > 1 ? sortedFilterReportActions[sortedFilterReportActions.length - 2].reportActionID : '';
+ return sortedFilterReportActions.length > 1 ? sortedFilterReportActions.at(sortedFilterReportActions.length - 2)?.reportActionID ?? '-1' : '';
}
/**
@@ -1009,20 +1016,20 @@ const iouRequestTypes = new Set>([
CONST.IOU.REPORT_ACTION_TYPE.TRACK,
]);
-function getMoneyRequestActions(
- reportID: string,
- reportActions: OnyxEntry | ReportAction[],
- isOffline: boolean | undefined = undefined,
-): Array> {
- // If the report is not an IOU, Expense report, or Invoice, it shouldn't have money request actions.
+/**
+ * Gets the reportID for the transaction thread associated with a report by iterating over the reportActions and identifying the IOU report actions.
+ * Returns a reportID if there is exactly one transaction thread for the report, and null otherwise.
+ */
+function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEntry | ReportAction[], isOffline: boolean | undefined = undefined): string | undefined {
+ // If the report is not an IOU, Expense report, or Invoice, it shouldn't be treated as one-transaction report.
const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
if (report?.type !== CONST.REPORT.TYPE.IOU && report?.type !== CONST.REPORT.TYPE.EXPENSE && report?.type !== CONST.REPORT.TYPE.INVOICE) {
- return [];
+ return;
}
const reportActionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {});
if (!reportActionsArray.length) {
- return [];
+ return;
}
const iouRequestActions = [];
@@ -1050,22 +1057,13 @@ function getMoneyRequestActions(
iouRequestActions.push(action);
}
}
- return iouRequestActions;
-}
-
-/**
- * Gets the reportID for the transaction thread associated with a report by iterating over the reportActions and identifying the IOU report actions.
- * Returns a reportID if there is exactly one transaction thread for the report, and null otherwise.
- */
-function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEntry | ReportAction[], isOffline: boolean | undefined = undefined): string | undefined {
- const iouRequestActions = getMoneyRequestActions(reportID, reportActions, isOffline);
// If we don't have any IOU request actions, or we have more than one IOU request actions, this isn't a oneTransaction report
if (!iouRequestActions.length || iouRequestActions.length > 1) {
return;
}
- const singleAction = iouRequestActions[0];
+ const singleAction = iouRequestActions.at(0);
const originalMessage = getOriginalMessage(singleAction);
// If there's only one IOU request action associated with the report but it's been deleted, then we don't consider this a oneTransaction report
@@ -1075,28 +1073,7 @@ function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEn
}
// Ensure we have a childReportID associated with the IOU report action
- return singleAction.childReportID;
-}
-
-/**
- * Returns true if all transactions on the report have the same ownerID
- */
-function hasSameActorForAllTransactions(reportID: string, reportActions: OnyxEntry | ReportAction[], isOffline: boolean | undefined = undefined): boolean {
- const iouRequestActions = getMoneyRequestActions(reportID, reportActions, isOffline);
- if (!iouRequestActions.length) {
- return true;
- }
-
- let actorID: number | undefined;
-
- for (const action of iouRequestActions) {
- if (actorID !== undefined && actorID !== action?.actorAccountID) {
- return false;
- }
- actorID = action?.actorAccountID;
- }
-
- return true;
+ return singleAction?.childReportID;
}
/**
@@ -1482,8 +1459,8 @@ function isCurrentActionUnread(report: OnyxEntry, reportAction: ReportAc
if (currentActionIndex === -1) {
return false;
}
- const prevReportAction = sortedReportActions[currentActionIndex - 1];
- return isReportActionUnread(reportAction, lastReadTime) && (!prevReportAction || !isReportActionUnread(prevReportAction, lastReadTime));
+ const prevReportAction = sortedReportActions.at(currentActionIndex - 1);
+ return isReportActionUnread(reportAction, lastReadTime) && (currentActionIndex === 0 || !prevReportAction || !isReportActionUnread(prevReportAction, lastReadTime));
}
/**
@@ -1633,7 +1610,7 @@ function getExportIntegrationActionFragments(reportAction: OnyxEntry): string {
+ if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION)) {
+ return '';
+ }
+ const originalMessage = getOriginalMessage(reportAction);
+ const connectionName = originalMessage?.connectionName;
+ return connectionName ? Localize.translateLocal('report.actions.type.removedConnection', {connectionName}) : '';
+}
+
function getRenamedAction(reportAction: OnyxEntry>) {
const originalMessage = getOriginalMessage(reportAction);
return Localize.translateLocal('newRoomPage.renamedRoomAction', {
@@ -1744,9 +1730,9 @@ function isCardIssuedAction(reportAction: OnyxEntry) {
function getCardIssuedMessage(reportAction: OnyxEntry, shouldRenderHTML = false) {
const assigneeAccountID = (getOriginalMessage(reportAction) as IssueNewCardOriginalMessage)?.assigneeAccountID;
- const assigneeDetails = PersonalDetailsUtils.getPersonalDetailsByIDs([assigneeAccountID], currentUserAccountID ?? -1)[0];
+ const assigneeDetails = PersonalDetailsUtils.getPersonalDetailsByIDs([assigneeAccountID], currentUserAccountID ?? -1).at(0);
- const assignee = shouldRenderHTML ? `` : assigneeDetails?.firstName ?? assigneeDetails.login ?? '';
+ const assignee = shouldRenderHTML ? `` : assigneeDetails?.firstName ?? assigneeDetails?.login ?? '';
const link = shouldRenderHTML
? `${Localize.translateLocal('cardPage.expensifyCard')}`
: Localize.translateLocal('cardPage.expensifyCard');
@@ -1860,6 +1846,7 @@ export {
isTripPreview,
isWhisperAction,
isSubmittedAction,
+ isSubmittedAndClosedAction,
isApprovedAction,
isForwardedAction,
isWhisperActionTargetedToOthers,
@@ -1881,7 +1868,7 @@ export {
getRenamedAction,
isCardIssuedAction,
getCardIssuedMessage,
- hasSameActorForAllTransactions,
+ getRemovedConnectionMessage,
};
export type {LastVisibleMessage};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 78ebdd92751e..a50bdd7a10ba 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -1287,7 +1287,7 @@ function shouldDisableDetailPage(report: OnyxEntry): boolean {
const participantAccountIDs = Object.keys(report?.participants ?? {})
.map(Number)
.filter((accountID) => accountID !== currentUserAccountID);
- return isOptimisticPersonalDetail(participantAccountIDs[0]);
+ return isOptimisticPersonalDetail(participantAccountIDs.at(0) ?? -1);
}
return false;
}
@@ -1637,20 +1637,12 @@ function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined): bo
return transactions.every((transaction) => !TransactionUtils.getReimbursable(transaction));
}
-/**
- * Checks if a report has only transactions with same ownerID
- */
-function isSingleActorMoneyReport(reportID: string): boolean {
- const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? ([] as ReportAction[]);
- return !!ReportActionsUtils.hasSameActorForAllTransactions(reportID, reportActions);
-}
-
/**
* Checks if a report has only one transaction associated with it
*/
function isOneTransactionReport(reportID: string): boolean {
const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? ([] as ReportAction[]);
- return !!ReportActionsUtils.getOneTransactionThreadReportID(reportID, reportActions);
+ return ReportActionsUtils.getOneTransactionThreadReportID(reportID, reportActions) !== null;
}
/*
@@ -1661,7 +1653,7 @@ function isPayAtEndExpenseReport(reportID: string, transactions: Transaction[] |
return false;
}
- return TransactionUtils.isPayAtEndExpense(transactions?.[0] ?? TransactionUtils.getAllReportTransactions(reportID)[0]);
+ return TransactionUtils.isPayAtEndExpense(transactions?.[0] ?? TransactionUtils.getAllReportTransactions(reportID).at(0));
}
/**
@@ -1691,7 +1683,7 @@ function isOneOnOneChat(report: OnyxEntry): boolean {
* Checks if the current user is a payer of the expense
*/
-function isPayer(session: OnyxEntry, iouReport: OnyxEntry) {
+function isPayer(session: OnyxEntry, iouReport: OnyxEntry, onlyShowPayElsewhere = false) {
const isApproved = isReportApproved(iouReport);
const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport?.policyID}`] ?? null;
const policyType = policy?.type;
@@ -1702,7 +1694,7 @@ function isPayer(session: OnyxEntry, iouReport: OnyxEntry) {
const isReimburser = session?.email === policy?.achAccount?.reimburser;
return (!policy?.achAccount?.reimburser || isReimburser) && (isApproved || isManager);
}
- if (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL) {
+ if (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL || onlyShowPayElsewhere) {
return isAdmin && (isApproved || isManager);
}
return false;
@@ -2030,7 +2022,7 @@ function getWorkspaceIcon(report: OnyxInputOrEntry, policy?: OnyxInputOr
* Gets the personal details for a login by looking in the ONYXKEYS.PERSONAL_DETAILS_LIST Onyx key (stored in the local variable, allPersonalDetails). If it doesn't exist in Onyx,
* then a default object is constructed.
*/
-function getPersonalDetailsForAccountID(accountID: number, personalDetailsData?: Partial): Partial {
+function getPersonalDetailsForAccountID(accountID: number | undefined, personalDetailsData?: Partial): Partial {
if (!accountID) {
return {};
}
@@ -2233,7 +2225,7 @@ function getGroupChatName(participants?: SelectedParticipant[], shouldApplyLimit
.join(', ');
}
- return Localize.translateLocal('groupChat.defaultReportName', {displayName: getDisplayNameForParticipant(participantAccountIDs[0], false)});
+ return Localize.translateLocal('groupChat.defaultReportName', {displayName: getDisplayNameForParticipant(participantAccountIDs.at(0), false)});
}
function getParticipants(reportID: string) {
@@ -2283,7 +2275,7 @@ function getIcons(
if (isChatThread(report)) {
const parentReportAction = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID];
- const actorAccountID = getReportActionActorAccountID(parentReportAction);
+ const actorAccountID = getReportActionActorAccountID(parentReportAction, report);
const actorDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(allPersonalDetails?.[actorAccountID ?? -1], '', false);
const actorIcon = {
id: actorAccountID,
@@ -2378,7 +2370,7 @@ function getIcons(
const isManager = currentUserAccountID === report?.managerID;
// For one transaction IOUs, display a simplified report icon
- if (isOneTransactionReport(report?.reportID ?? '-1') || isSingleActorMoneyReport(report?.reportID ?? '-1')) {
+ if (isOneTransactionReport(report?.reportID ?? '-1')) {
return [ownerIcon];
}
@@ -3446,6 +3438,9 @@ function getReportPreviewMessage(
report.isWaitingOnBankAccount
) {
translatePhraseKey = 'iou.paidWithExpensifyWithAmount';
+ if (originalMessage?.automaticAction) {
+ translatePhraseKey = 'iou.automaticallyPaidWithExpensify';
+ }
}
let actualPayerName = report.managerID === currentUserAccountID ? '' : getDisplayNameForParticipant(report.managerID, true);
@@ -3548,7 +3543,7 @@ function getModifiedExpenseOriginalMessage(
const didTaxCodeChange = 'taxCode' in transactionChanges;
if (didTaxCodeChange && !didAmountOrCurrencyChange) {
originalMessage.oldTaxRate = policy?.taxRates?.taxes[TransactionUtils.getTaxCode(oldTransaction)]?.value;
- originalMessage.taxRate = transactionChanges?.taxCode && policy?.taxRates?.taxes[transactionChanges?.taxCode].value;
+ originalMessage.taxRate = transactionChanges?.taxCode && policy?.taxRates?.taxes[transactionChanges?.taxCode]?.value;
}
// We only want to display a tax amount update system message when tax amount is updated by user.
@@ -3618,7 +3613,7 @@ function getAdminRoomInvitedParticipants(parentReportAction: OnyxEntry 1 ? participants.join(` ${Localize.translateLocal('common.and')} `) : participants[0];
+ const users = participants.length > 1 ? participants.join(` ${Localize.translateLocal('common.and')} `) : participants.at(0);
if (!users) {
return parentReportActionMessage;
}
@@ -3686,7 +3681,7 @@ function parseReportActionHtmlToText(reportAction: OnyxEntry, repo
const accountIDToName: Record = {};
const accountIDs = Array.from(html.matchAll(mentionUserRegex), (mention) => Number(mention[1]));
const logins = PersonalDetailsUtils.getLoginsByAccountIDs(accountIDs);
- accountIDs.forEach((id, index) => (accountIDToName[id] = logins[index]));
+ accountIDs.forEach((id, index) => (accountIDToName[id] = logins.at(index) ?? ''));
const textMessage = Str.removeSMSDomain(Parser.htmlToText(html, {reportIDToName, accountIDToName}));
parsedReportActionMessageCache[key] = textMessage;
@@ -3760,14 +3755,15 @@ function getReportName(
parentReportActionParam?: OnyxInputOrEntry,
personalDetails?: Partial,
invoiceReceiverPolicy?: OnyxEntry,
+ shouldIncludePolicyName = false,
): string {
const reportID = report?.reportID;
const cacheKey = getCacheKey(report);
- if (reportID) {
+ if (reportID && !isUserCreatedPolicyRoom(report) && !isDefaultRoom(report)) {
const reportNameFromCache = reportNameCache.get(cacheKey);
- if (reportNameFromCache?.reportName && reportNameFromCache.reportName === report?.reportName) {
+ if (reportNameFromCache?.reportName && reportNameFromCache.reportName === report?.reportName && reportNameFromCache.reportName !== CONST.REPORT.DEFAULT_REPORT_NAME) {
return reportNameFromCache.reportName;
}
}
@@ -3781,7 +3777,10 @@ function getReportName(
}
const parentReportActionMessage = ReportActionsUtils.getReportActionMessage(parentReportAction);
- if (ReportActionsUtils.isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED)) {
+ if (
+ ReportActionsUtils.isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED) ||
+ ReportActionsUtils.isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED)
+ ) {
return getIOUSubmittedMessage(parentReportAction);
}
if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.FORWARDED) {
@@ -3875,6 +3874,11 @@ function getReportName(
formattedName = getInvoicesChatName(report, invoiceReceiverPolicy);
}
+ if (shouldIncludePolicyName && (isUserCreatedPolicyRoom(report) || isDefaultRoom(report))) {
+ const policyName = getPolicyName(report, true);
+ formattedName = policyName ? `${policyName} ā¢ ${report?.reportName}` : report?.reportName;
+ }
+
if (isArchivedRoom(report, getReportNameValuePairs(report?.reportID))) {
formattedName += ` (${Localize.translateLocal('common.archived')})`;
}
@@ -3925,14 +3929,14 @@ function getPayeeName(report: OnyxEntry): string | undefined {
if (participantsWithoutCurrentUser.length === 0) {
return undefined;
}
- return getDisplayNameForParticipant(participantsWithoutCurrentUser[0], true);
+ return getDisplayNameForParticipant(participantsWithoutCurrentUser.at(0), true);
}
/**
* Get either the policyName or domainName the chat is tied to
*/
-function getChatRoomSubtitle(report: OnyxEntry): string | undefined {
- if (isChatThread(report)) {
+function getChatRoomSubtitle(report: OnyxEntry, isTitleIncludePolicyName = false): string | undefined {
+ if (isChatThread(report) || ((isUserCreatedPolicyRoom(report) || isDefaultRoom(report)) && isTitleIncludePolicyName)) {
return '';
}
if (isSelfDM(report)) {
@@ -4001,7 +4005,7 @@ function navigateToDetailsPage(report: OnyxEntry, backTo?: string) {
const participantAccountID = getParticipantsAccountIDsForDisplay(report);
if (isSelfDMReport || isOneOnOneChatReport) {
- Navigation.navigate(ROUTES.PROFILE.getRoute(participantAccountID[0], backTo));
+ Navigation.navigate(ROUTES.PROFILE.getRoute(participantAccountID.at(0), backTo));
return;
}
@@ -4018,7 +4022,7 @@ function goBackToDetailsPage(report: OnyxEntry, backTo?: string) {
const participantAccountID = getParticipantsAccountIDsForDisplay(report);
if (isOneOnOneChatReport) {
- Navigation.goBack(ROUTES.PROFILE.getRoute(participantAccountID[0], backTo));
+ Navigation.goBack(ROUTES.PROFILE.getRoute(participantAccountID.at(0), backTo));
return;
}
@@ -4054,7 +4058,7 @@ function goBackFromPrivateNotes(report: OnyxEntry, session: OnyxEntry) {
+function getReportAutomaticallySubmittedMessage(
+ reportAction: ReportAction | ReportAction,
+) {
return Localize.translateLocal('iou.automaticallySubmittedAmount', {formattedAmount: getFormattedAmount(reportAction)});
}
-function getIOUSubmittedMessage(reportAction: ReportAction) {
+function getIOUSubmittedMessage(reportAction: ReportAction | ReportAction) {
return Localize.translateLocal('iou.submittedAmount', {formattedAmount: getFormattedAmount(reportAction)});
}
@@ -4910,9 +4924,9 @@ function buildOptimisticReportPreview(
},
],
created,
- accountID: iouReport?.ownerAccountID ?? -1,
+ accountID: iouReport?.managerID ?? -1,
// The preview is initially whispered if created with a receipt, so the actor is the current user as well
- actorAccountID: hasReceipt ? currentUserAccountID : iouReport?.ownerAccountID ?? -1,
+ actorAccountID: hasReceipt ? currentUserAccountID : iouReport?.managerID ?? -1,
childReportID: childReportID ?? iouReport?.reportID,
childMoneyRequestCount: 1,
childLastMoneyRequestComment: comment,
@@ -4926,7 +4940,7 @@ function buildOptimisticReportPreview(
function buildOptimisticActionableTrackExpenseWhisper(iouAction: OptimisticIOUReportAction, transactionID: string): ReportAction {
const currentTime = DateUtils.getDBTime();
const targetEmail = CONST.EMAIL.CONCIERGE;
- const actorAccountID = PersonalDetailsUtils.getAccountIDsByLogins([targetEmail])[0];
+ const actorAccountID = PersonalDetailsUtils.getAccountIDsByLogins([targetEmail]).at(0);
const reportActionID = NumberUtils.rand64();
return {
actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER,
@@ -5218,7 +5232,7 @@ function buildOptimisticChatReport(
// TODO: update to support workspace as an invoice receiver when workspace-to-workspace invoice room implemented
optimisticChatReport.invoiceReceiver = {
type: 'individual',
- accountID: participantList[0],
+ accountID: participantList.at(0) ?? -1,
};
}
@@ -6443,7 +6457,9 @@ function getNewMarkerReportActionID(report: OnyxEntry, sortedAndFiltered
const newMarkerIndex = lodashFindLastIndex(sortedAndFilteredReportActions, (reportAction) => (reportAction.created ?? '') > (report?.lastReadTime ?? ''));
- return 'reportActionID' in sortedAndFilteredReportActions[newMarkerIndex] ? sortedAndFilteredReportActions[newMarkerIndex].reportActionID : '';
+ return newMarkerIndex !== -1 && 'reportActionID' in (sortedAndFilteredReportActions?.at(newMarkerIndex) ?? {})
+ ? sortedAndFilteredReportActions.at(newMarkerIndex)?.reportActionID ?? ''
+ : '';
}
/**
@@ -6500,8 +6516,8 @@ function parseReportRouteParams(route: string): ReportRouteParams {
const pathSegments = parsingRoute.split('/');
- const reportIDSegment = pathSegments[1];
- const hasRouteReportActionID = !Number.isNaN(Number(pathSegments[2]));
+ const reportIDSegment = pathSegments.at(1);
+ const hasRouteReportActionID = !Number.isNaN(Number(reportIDSegment));
// Check for "undefined" or any other unwanted string values
if (!reportIDSegment || reportIDSegment === 'undefined') {
@@ -6695,8 +6711,10 @@ function temporary_getMoneyRequestOptions(
report: OnyxEntry,
policy: OnyxEntry,
reportParticipants: number[],
-): Array> {
- return getMoneyRequestOptions(report, policy, reportParticipants, true) as Array>;
+): Array> {
+ return getMoneyRequestOptions(report, policy, reportParticipants, true) as Array<
+ Exclude
+ >;
}
/**
@@ -6794,7 +6812,7 @@ function canLeaveRoom(report: OnyxEntry, isPolicyEmployee: boolean): boo
}
function isCurrentUserTheOnlyParticipant(participantAccountIDs?: number[]): boolean {
- return !!(participantAccountIDs?.length === 1 && participantAccountIDs?.[0] === currentUserAccountID);
+ return !!(participantAccountIDs?.length === 1 && participantAccountIDs?.at(0) === currentUserAccountID);
}
/**
@@ -6832,7 +6850,7 @@ function shouldReportShowSubscript(report: OnyxEntry): boolean {
return true;
}
- if (isExpenseReport(report)) {
+ if (isExpenseReport(report) && isOneTransactionReport(report?.reportID ?? '-1')) {
return true;
}
@@ -6844,7 +6862,7 @@ function shouldReportShowSubscript(report: OnyxEntry): boolean {
return true;
}
- if (isInvoiceRoom(report) || isInvoiceReport(report)) {
+ if (isInvoiceRoom(report)) {
return true;
}
@@ -6937,10 +6955,17 @@ function getReportOfflinePendingActionAndErrors(report: OnyxEntry): Repo
*/
function canCreateRequest(report: OnyxEntry, policy: OnyxEntry, iouType: ValueOf): boolean {
const participantAccountIDs = Object.keys(report?.participants ?? {}).map(Number);
+
if (!canUserPerformWriteAction(report)) {
return false;
}
- return getMoneyRequestOptions(report, policy, participantAccountIDs).includes(iouType);
+
+ const requestOptions = getMoneyRequestOptions(report, policy, participantAccountIDs);
+ if (Permissions.canUseCombinedTrackSubmit(allBetas ?? [])) {
+ requestOptions.push(CONST.IOU.TYPE.CREATE);
+ }
+
+ return requestOptions.includes(iouType);
}
function getWorkspaceChats(policyID: string, accountIDs: number[]): Array> {
@@ -7134,7 +7159,7 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry,
return '';
}
const originalMessage = ReportActionsUtils.getOriginalMessage(reportAction);
- const {IOUReportID} = originalMessage ?? {};
+ const {IOUReportID, automaticAction} = originalMessage ?? {};
const iouReport = getReportOrDraftReport(IOUReportID);
let translationKey: TranslationPaths;
if (originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) {
@@ -7151,6 +7176,9 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry,
case CONST.IOU.PAYMENT_TYPE.EXPENSIFY:
case CONST.IOU.PAYMENT_TYPE.VBBA:
translationKey = 'iou.paidWithExpensifyWithAmount';
+ if (automaticAction) {
+ translationKey = 'iou.automaticallyPaidWithExpensify';
+ }
break;
default:
translationKey = 'iou.payerPaidAmount';
@@ -7536,13 +7564,13 @@ function getOptimisticDataForParentReportAction(reportID: string, lastVisibleAct
const totalAncestor = ancestors.reportIDs.length;
return Array.from(Array(totalAncestor), (_, index) => {
- const ancestorReport = getReportOrDraftReport(ancestors.reportIDs[index]);
+ const ancestorReport = getReportOrDraftReport(ancestors.reportIDs.at(index));
if (!ancestorReport || isEmptyObject(ancestorReport)) {
return null;
}
- const ancestorReportAction = ReportActionsUtils.getReportAction(ancestorReport.reportID, ancestors.reportActionsIDs[index]);
+ const ancestorReportAction = ReportActionsUtils.getReportAction(ancestorReport.reportID, ancestors.reportActionsIDs.at(index) ?? '');
if (!ancestorReportAction || isEmptyObject(ancestorReportAction)) {
return null;
@@ -7726,10 +7754,10 @@ function canLeaveChat(report: OnyxEntry, policy: OnyxEntry): boo
return (isChatThread(report) && !!getReportNotificationPreference(report)) || isUserCreatedPolicyRoom(report) || isNonAdminOrOwnerOfPolicyExpenseChat(report, policy);
}
-function getReportActionActorAccountID(reportAction: OnyxInputOrEntry): number | undefined {
+function getReportActionActorAccountID(reportAction: OnyxInputOrEntry, iouReport: OnyxInputOrEntry | undefined): number | undefined {
switch (reportAction?.actionName) {
case CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW:
- return reportAction?.childOwnerAccountID ?? reportAction?.actorAccountID;
+ return !isEmptyObject(iouReport) ? iouReport.managerID : reportAction?.childManagerAccountID;
case CONST.REPORT.ACTIONS.TYPE.SUBMITTED:
return reportAction?.adminAccountID ?? reportAction?.actorAccountID;
@@ -7995,7 +8023,7 @@ function hasMissingInvoiceBankAccount(iouReportID: string): boolean {
return false;
}
- return invoiceReport?.ownerAccountID === currentUserAccountID && isEmptyObject(getPolicy(invoiceReport?.policyID)?.invoice?.bankAccount ?? {}) && isSettled(iouReportID);
+ return invoiceReport?.ownerAccountID === currentUserAccountID && !getPolicy(invoiceReport?.policyID)?.invoice?.bankAccount?.transferBankAccountID && isSettled(iouReportID);
}
function isExpenseReportWithoutParentAccess(report: OnyxEntry) {
@@ -8312,7 +8340,6 @@ export {
isIndividualInvoiceRoom,
isAuditor,
hasMissingInvoiceBankAccount,
- isSingleActorMoneyReport,
};
export type {
diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js
index 622c3f5f3c4a..713996d0cf09 100644
--- a/src/libs/SearchParser/searchParser.js
+++ b/src/libs/SearchParser/searchParser.js
@@ -209,7 +209,7 @@ function peg$parse(input, options) {
var peg$r0 = /^[:=]/;
var peg$r1 = /^[^"\r\n]/;
- var peg$r2 = /^[A-Za-z0-9_@.\/#&+\-\\',;]/;
+ var peg$r2 = /^[A-Za-z0-9_@.\/#&+\-\\',;%]/;
var peg$r3 = /^[ \t\r\n]/;
var peg$e0 = peg$otherExpectation("operator");
@@ -245,7 +245,7 @@ function peg$parse(input, options) {
var peg$e30 = peg$literalExpectation("\"", false);
var peg$e31 = peg$classExpectation(["\"", "\r", "\n"], true, false);
var peg$e32 = peg$otherExpectation("word");
- var peg$e33 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ",", ";"], false, false);
+ var peg$e33 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ",", ";", "%"], false, false);
var peg$e34 = peg$otherExpectation("whitespace");
var peg$e35 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false);
diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy
index f9f681736c61..32d44f24d0d6 100644
--- a/src/libs/SearchParser/searchParser.peggy
+++ b/src/libs/SearchParser/searchParser.peggy
@@ -137,7 +137,7 @@ identifier
quotedString "quote" = "\"" chars:[^"\r\n]* "\"" { return chars.join(""); }
alphanumeric "word"
- = chars:[A-Za-z0-9_@./#&+\-\\',;]+ {
+ = chars:[A-Za-z0-9_@./#&+\-\\',;%]+ {
return chars.join("").trim().split(",").filter(Boolean);
}
diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts
index bd402b65d86d..47cee2c7c2b4 100644
--- a/src/libs/SearchUtils.ts
+++ b/src/libs/SearchUtils.ts
@@ -369,7 +369,7 @@ function getSortedTransactionData(data: TransactionListItemType[], sortBy?: Sear
}
function getReportNewestTransactionDate(report: ReportListItemType) {
- return report.transactions?.reduce((max, curr) => (curr.modifiedCreated ?? curr.created > max.created ? curr : max), report.transactions[0])?.created;
+ return report.transactions?.reduce((max, curr) => (curr.modifiedCreated ?? curr.created > (max?.created ?? '') ? curr : max), report.transactions.at(0))?.created;
}
function getSortedReportData(data: ReportListItemType[]) {
@@ -585,7 +585,7 @@ function buildSearchQueryString(queryJSON?: SearchQueryJSON) {
*/
function buildQueryStringFromFilterFormValues(filterValues: Partial) {
// We separate type and status filters from other filters to maintain hashes consistency for saved searches
- const {type, status, ...otherFilters} = filterValues;
+ const {type, status, policyID, ...otherFilters} = filterValues;
const filtersString: string[] = [];
if (type) {
@@ -598,6 +598,11 @@ function buildQueryStringFromFilterFormValues(filterValues: Partial {
if ((filterKey === FILTER_KEYS.MERCHANT || filterKey === FILTER_KEYS.DESCRIPTION || filterKey === FILTER_KEYS.REPORT_ID) && filterValue) {
@@ -694,6 +699,9 @@ function buildFilterFormValuesFromQuery(queryJSON: SearchQueryJSON) {
filtersForm[FILTER_KEYS.TYPE] = queryJSON.type;
filtersForm[FILTER_KEYS.STATUS] = queryJSON.status;
+ if (queryJSON.policyID) {
+ filtersForm[FILTER_KEYS.POLICY_ID] = queryJSON.policyID;
+ }
return filtersForm;
}
@@ -735,7 +743,10 @@ function buildFilterString(filterName: string, queryFilters: QueryFilter[], deli
let filterValueString = '';
queryFilters.forEach((queryFilter, index) => {
// If the previous queryFilter has the same operator (this rule applies only to eq and neq operators) then append the current value
- if ((queryFilter.operator === 'eq' && queryFilters[index - 1]?.operator === 'eq') || (queryFilter.operator === 'neq' && queryFilters[index - 1]?.operator === 'neq')) {
+ if (
+ index !== 0 &&
+ ((queryFilter.operator === 'eq' && queryFilters?.at(index - 1)?.operator === 'eq') || (queryFilter.operator === 'neq' && queryFilters.at(index - 1)?.operator === 'neq'))
+ ) {
filterValueString += `${delimiter}${sanitizeString(queryFilter.value.toString())}`;
} else {
filterValueString += ` ${filterName}${operatorToSignMap[queryFilter.operator]}${sanitizeString(queryFilter.value.toString())}`;
@@ -766,7 +777,7 @@ function getSearchHeaderTitle(
.filter(([, taxRateKeys]) => taxRateKeys.some((taxID) => taxRateIDs.includes(taxID)))
.map(([taxRate]) => taxRate);
displayQueryFilters = taxRateNames.map((taxRate) => ({
- operator: queryFilter[0].operator,
+ operator: queryFilter.at(0)?.operator ?? CONST.SEARCH.SYNTAX_OPERATORS.AND,
value: taxRate,
}));
} else {
@@ -781,8 +792,18 @@ function getSearchHeaderTitle(
return title;
}
-function buildCannedSearchQuery(type: SearchDataTypes = CONST.SEARCH.DATA_TYPES.EXPENSE, status: SearchStatus = CONST.SEARCH.STATUS.EXPENSE.ALL): SearchQueryString {
- return normalizeQuery(`type:${type} status:${status}`);
+function buildCannedSearchQuery({
+ type = CONST.SEARCH.DATA_TYPES.EXPENSE,
+ status = CONST.SEARCH.STATUS.EXPENSE.ALL,
+ policyID,
+}: {
+ type?: SearchDataTypes;
+ status?: SearchStatus;
+ policyID?: string;
+} = {}): SearchQueryString {
+ const queryString = policyID ? `type:${type} status:${status} policyID:${policyID}` : `type:${type} status:${status}`;
+
+ return normalizeQuery(queryString);
}
function getOverflowMenu(itemName: string, hash: number, inputQuery: string, showDeleteModal: (hash: number) => void, isMobileMenu?: boolean, closeMenu?: () => void) {
@@ -822,11 +843,16 @@ function isCannedSearchQuery(queryJSON: SearchQueryJSON) {
return !queryJSON.filters;
}
+function getContextualSuggestionQuery(reportID: string) {
+ return `type:chat in:${reportID}`;
+}
+
function isCorrectSearchUserName(displayName?: string) {
return displayName && displayName.toUpperCase() !== CONST.REPORT.OWNER_EMAIL_FAKE;
}
export {
+ getContextualSuggestionQuery,
buildQueryStringFromFilterFormValues,
buildSearchQueryJSON,
buildSearchQueryString,
diff --git a/src/libs/SelectionScraper/index.ts b/src/libs/SelectionScraper/index.ts
index 88726aa633b6..060cbc613acf 100644
--- a/src/libs/SelectionScraper/index.ts
+++ b/src/libs/SelectionScraper/index.ts
@@ -117,15 +117,16 @@ const replaceNodes = (dom: Node, isChildOfEditorElement: boolean): Node => {
}
} else if (dom instanceof Element) {
domName = dom.name;
+ const child = dom.children.at(0);
if (dom.attribs?.[tagAttribute]) {
// If it's a markdown element, rename it according to the value of data-testid, so ExpensiMark can parse it
if (markdownElements.includes(dom.attribs[tagAttribute])) {
domName = dom.attribs[tagAttribute];
}
- } else if (dom.name === 'div' && dom.children.length === 1 && isChildOfEditorElement) {
+ } else if (dom.name === 'div' && dom.children.length === 1 && isChildOfEditorElement && child) {
// We are excluding divs that are children of our editor element and have only one child to prevent
// additional newlines from being added in the HTML to Markdown conversion process.
- return replaceNodes(dom.children[0], isChildOfEditorElement);
+ return replaceNodes(child, isChildOfEditorElement);
}
// We need to preserve href attribute in order to copy links.
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index e120f7026fce..0496bc66fe5b 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -49,7 +49,12 @@ Onyx.connect({
(reportAction) => ReportActionsUtils.shouldReportActionBeVisibleAsLastAction(reportAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED,
);
- visibleReportActionItems[reportID] = reportActionsForDisplay[reportActionsForDisplay.length - 1];
+ const reportAction = reportActionsForDisplay.at(-1);
+ if (!reportAction) {
+ delete visibleReportActionItems[reportID];
+ return;
+ }
+ visibleReportActionItems[reportID] = reportAction;
},
});
@@ -300,7 +305,7 @@ function getOptionData({
const visibleParticipantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report, true);
const participantPersonalDetailList = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails));
- const personalDetail = participantPersonalDetailList[0] ?? {};
+ const personalDetail = participantPersonalDetailList.at(0) ?? ({} as PersonalDetails);
const hasErrors = Object.keys(result.allReportErrors ?? {}).length !== 0;
result.isThread = ReportUtils.isChatThread(report);
@@ -448,6 +453,8 @@ function getOptionData({
result.alternateText = ReportActionsUtils.getPolicyChangeLogDeleteMemberMessage(lastAction);
} else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT_RATE) {
result.alternateText = ReportActionsUtils.getReportActionMessageText(lastAction) ?? '';
+ } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION) {
+ result.alternateText = ReportActionsUtils.getRemovedConnectionMessage(lastAction);
} else {
result.alternateText =
lastMessageTextFromReport.length > 0
@@ -476,18 +483,18 @@ function getOptionData({
}
if (!hasMultipleParticipants) {
- result.accountID = personalDetail?.accountID;
- result.login = personalDetail?.login;
- result.phoneNumber = personalDetail?.phoneNumber;
+ result.accountID = personalDetail?.accountID ?? -1;
+ result.login = personalDetail?.login ?? '';
+ result.phoneNumber = personalDetail?.phoneNumber ?? '';
}
- const reportName = ReportUtils.getReportName(report, policy, undefined, undefined, invoiceReceiverPolicy);
+ const reportName = ReportUtils.getReportName(report, policy, undefined, undefined, invoiceReceiverPolicy, true);
result.text = reportName;
result.subtitle = subtitle;
result.participantsList = participantPersonalDetailList;
- result.icons = ReportUtils.getIcons(report, personalDetails, personalDetail?.avatar, personalDetail?.login, personalDetail?.accountID, policy, invoiceReceiverPolicy);
+ result.icons = ReportUtils.getIcons(report, personalDetails, personalDetail?.avatar, personalDetail?.login, personalDetail?.accountID ?? -1, policy, invoiceReceiverPolicy);
result.displayNamesWithTooltips = displayNamesWithTooltips;
if (status) {
diff --git a/src/libs/StringUtils.ts b/src/libs/StringUtils.ts
index 10730150489c..b3fcd247284e 100644
--- a/src/libs/StringUtils.ts
+++ b/src/libs/StringUtils.ts
@@ -94,7 +94,7 @@ function lineBreaksToSpaces(text = '') {
function getFirstLine(text = '') {
// Split the input string by newline characters and return the first element of the resulting array
const lines = text.split('\n');
- return lines[0];
+ return lines.at(0);
}
export default {sanitizeString, isEmptyString, removeInvisibleCharacters, normalizeAccents, normalizeCRLF, lineBreaksToSpaces, getFirstLine};
diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts
index f14703fabdc8..0db771eaa96b 100644
--- a/src/libs/TransactionUtils/index.ts
+++ b/src/libs/TransactionUtils/index.ts
@@ -492,7 +492,7 @@ function getTagArrayFromName(tagName: string): string[] {
function getTag(transaction: OnyxInputOrEntry, tagIndex?: number): string {
if (tagIndex !== undefined) {
const tagsArray = getTagArrayFromName(transaction?.tag ?? '');
- return tagsArray[tagIndex] ?? '';
+ return tagsArray.at(tagIndex) ?? '';
}
return transaction?.tag ?? '';
@@ -646,7 +646,10 @@ function getValidWaypoints(waypoints: WaypointCollection | undefined, reArrangeI
let waypointIndex = -1;
return waypointValues.reduce((acc, currentWaypoint, index) => {
- const previousWaypoint = waypointValues[lastWaypointIndex];
+ // Array.at(-1) returns the last element of the array
+ // If a user does a round trip, the last waypoint will be the same as the first waypoint
+ // We want to avoid comparing them as this will result in an incorrect duplicate waypoint error.
+ const previousWaypoint = lastWaypointIndex !== -1 ? waypointValues.at(lastWaypointIndex) : undefined;
// Check if the waypoint has a valid address
if (!waypointHasValidAddress(currentWaypoint)) {
@@ -987,7 +990,7 @@ function compareDuplicateTransactionFields(transactionID: string): {keep: Partia
// Helper function to check if all fields are equal for a given key
function areAllFieldsEqual(items: Array>, keyExtractor: (item: OnyxEntry) => string) {
- const firstTransaction = transactions[0];
+ const firstTransaction = transactions.at(0);
return items.every((item) => keyExtractor(item) === keyExtractor(firstTransaction));
}
@@ -1002,7 +1005,7 @@ function compareDuplicateTransactionFields(transactionID: string): {keep: Partia
for (const fieldName in fieldsToCompare) {
if (Object.prototype.hasOwnProperty.call(fieldsToCompare, fieldName)) {
const keys = fieldsToCompare[fieldName];
- const firstTransaction = transactions[0];
+ const firstTransaction = transactions.at(0);
const isFirstTransactionCommentEmptyObject = typeof firstTransaction?.comment === 'object' && firstTransaction?.comment?.comment === '';
if (fieldName === 'description') {
diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts
index b57774799315..b7f754f9cac6 100644
--- a/src/libs/TripReservationUtils.ts
+++ b/src/libs/TripReservationUtils.ts
@@ -14,6 +14,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type IconAsset from '@src/types/utils/IconAsset';
import * as Link from './actions/Link';
import Navigation from './Navigation/Navigation';
+import * as PolicyUtils from './PolicyUtils';
let travelSettings: OnyxEntry;
Onyx.connect({
@@ -78,7 +79,8 @@ function bookATrip(translate: LocaleContextProps['translate'], setCtaErrorMessag
setCtaErrorMessage(translate('travel.phoneError'));
return;
}
- if (isEmptyObject(travelSettings)) {
+ const policy = PolicyUtils.getPolicy(activePolicyID);
+ if (isEmptyObject(policy?.address)) {
Navigation.navigate(ROUTES.WORKSPACE_PROFILE_ADDRESS.getRoute(activePolicyID ?? '-1', Navigation.getActiveRoute()));
return;
}
diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts
index c5291a2864d7..3e309fc74021 100644
--- a/src/libs/UserUtils.ts
+++ b/src/libs/UserUtils.ts
@@ -1,10 +1,12 @@
import {Str} from 'expensify-common';
import type {OnyxEntry} from 'react-native-onyx';
+import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import * as defaultAvatars from '@components/Icon/DefaultAvatars';
import {ConciergeAvatar, NotificationsAvatar} from '@components/Icon/Expensicons';
import CONST from '@src/CONST';
-import type {LoginList} from '@src/types/onyx';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Account, LoginList, Session} from '@src/types/onyx';
import type Login from '@src/types/onyx/Login';
import type IconAsset from '@src/types/utils/IconAsset';
import hashCode from './hashCode';
@@ -15,6 +17,22 @@ type AvatarSource = IconAsset | string;
type LoginListIndicator = ValueOf | undefined;
+let account: OnyxEntry;
+Onyx.connect({
+ key: ONYXKEYS.ACCOUNT,
+ callback: (value) => {
+ account = value ?? {};
+ },
+});
+
+let session: OnyxEntry;
+Onyx.connect({
+ key: ONYXKEYS.SESSION,
+ callback: (value) => {
+ session = value ?? {};
+ },
+});
+
/**
* Searches through given loginList for any contact method / login with an error.
*
@@ -208,6 +226,13 @@ function getSecondaryPhoneLogin(loginList: OnyxEntry): string | undefined
return parsedLoginList.find((login) => Str.isValidE164Phone(login));
}
+/**
+ * Gets the contact method
+ */
+function getContactMethod(): string {
+ return account?.primaryLogin ?? session?.email ?? '';
+}
+
export {
generateAccountID,
getAvatar,
@@ -221,5 +246,6 @@ export {
hasLoginListInfo,
hashText,
isDefaultAvatar,
+ getContactMethod,
};
export type {AvatarSource, LoginListIndicator};
diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts
index c7ee0a0b0867..6eafec9f9528 100644
--- a/src/libs/Violations/ViolationsUtils.ts
+++ b/src/libs/Violations/ViolationsUtils.ts
@@ -18,7 +18,7 @@ function getTagViolationsForSingleLevelTags(
policyTagList: PolicyTagLists,
): TransactionViolation[] {
const policyTagKeys = Object.keys(policyTagList);
- const policyTagListName = policyTagKeys[0];
+ const policyTagListName = policyTagKeys.at(0) ?? '';
const policyTags = policyTagList[policyTagListName]?.tags;
const hasTagOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY);
const hasMissingTagViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_TAG);
@@ -92,8 +92,8 @@ function getTagViolationForIndependentTags(policyTagList: PolicyTagLists, transa
const errorIndexes = [];
for (let i = 0; i < policyTagKeys.length; i++) {
const isTagRequired = policyTagList[policyTagKeys[i]].required ?? true;
- const isTagSelected = !!selectedTags[i];
- if (isTagRequired && (!isTagSelected || (selectedTags.length === 1 && selectedTags[0] === ''))) {
+ const isTagSelected = !!selectedTags.at(i);
+ if (isTagRequired && (!isTagSelected || (selectedTags.length === 1 && selectedTags.at(0) === ''))) {
errorIndexes.push(i);
}
}
@@ -108,7 +108,7 @@ function getTagViolationForIndependentTags(policyTagList: PolicyTagLists, transa
} else {
let hasInvalidTag = false;
for (let i = 0; i < policyTagKeys.length; i++) {
- const selectedTag = selectedTags[i];
+ const selectedTag = selectedTags.at(i);
const tags = policyTagList[policyTagKeys[i]].tags;
const isTagInPolicy = Object.values(tags).some((tag) => tag.name === selectedTag && !!tag.enabled);
if (!isTagInPolicy && selectedTag) {
@@ -116,7 +116,7 @@ function getTagViolationForIndependentTags(policyTagList: PolicyTagLists, transa
name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY,
type: CONST.VIOLATION_TYPES.VIOLATION,
data: {
- tagName: policyTagKeys[i],
+ tagName: policyTagKeys.at(i),
},
});
hasInvalidTag = true;
diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts
index 7d936bff0b3b..67655e0f472b 100644
--- a/src/libs/WorkflowUtils.ts
+++ b/src/libs/WorkflowUtils.ts
@@ -157,7 +157,7 @@ function convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover,
return 1;
}
- return (a.approvers[0]?.displayName ?? '-1').localeCompare(b.approvers[0]?.displayName ?? '-1');
+ return (a.approvers.at(0)?.displayName ?? '-1').localeCompare(b.approvers.at(0)?.displayName ?? '-1');
});
// Add a default workflow if one doesn't exist (no employees submit to the default approver)
diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts
index 6b6f1a5f6dc6..d69bcf0e5761 100644
--- a/src/libs/actions/App.ts
+++ b/src/libs/actions/App.ts
@@ -24,6 +24,8 @@ import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import type * as OnyxTypes from '@src/types/onyx';
import type {OnyxData} from '@src/types/onyx/Request';
+import {setShouldForceOffline} from './Network';
+import * as PersistedRequests from './PersistedRequests';
import * as Policy from './Policy/Policy';
import * as Session from './Session';
import Timing from './Timing';
@@ -77,6 +79,14 @@ Onyx.connect({
},
});
+let isUsingImportedState: boolean | undefined;
+Onyx.connect({
+ key: ONYXKEYS.IS_USING_IMPORTED_STATE,
+ callback: (value) => {
+ isUsingImportedState = value ?? false;
+ },
+});
+
const KEYS_TO_PRESERVE: OnyxKey[] = [
ONYXKEYS.ACCOUNT,
ONYXKEYS.IS_CHECKING_PUBLIC_ROOM,
@@ -273,7 +283,25 @@ function reconnectApp(updateIDFrom: OnyxEntry = 0) {
params.updateIDFrom = updateIDFrom;
}
- API.write(WRITE_COMMANDS.RECONNECT_APP, params, getOnyxDataForOpenOrReconnect());
+ API.write(WRITE_COMMANDS.RECONNECT_APP, params, getOnyxDataForOpenOrReconnect(), {
+ checkAndFixConflictingRequest: (persistedRequests) => {
+ const index = persistedRequests.findIndex((request) => request.command === WRITE_COMMANDS.RECONNECT_APP);
+ if (index === -1) {
+ return {
+ conflictAction: {
+ type: 'push',
+ },
+ };
+ }
+
+ return {
+ conflictAction: {
+ type: 'replace',
+ index,
+ },
+ };
+ },
+ });
});
}
@@ -500,6 +528,39 @@ function updateLastRoute(screen: string) {
Onyx.set(ONYXKEYS.LAST_ROUTE, screen);
}
+function setIsUsingImportedState(usingImportedState: boolean) {
+ Onyx.set(ONYXKEYS.IS_USING_IMPORTED_STATE, usingImportedState);
+}
+
+function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) {
+ // The value of isUsingImportedState will be lost once Onyx is cleared, so we need to store it
+ const isStateImported = isUsingImportedState;
+ const sequentialQueue = PersistedRequests.getAll();
+ Onyx.clear(KEYS_TO_PRESERVE).then(() => {
+ // Network key is preserved, so when using imported state, we should stop forcing offline mode so that the app can re-fetch the network
+ if (isStateImported) {
+ setShouldForceOffline(false);
+ }
+
+ if (shouldNavigateToHomepage) {
+ Navigation.navigate(ROUTES.HOME);
+ }
+
+ // 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.
+ openApp().then(() => {
+ if (!sequentialQueue || isStateImported) {
+ return;
+ }
+
+ sequentialQueue.forEach((request) => {
+ PersistedRequests.save(request);
+ });
+ });
+ });
+}
+
export {
setLocale,
setLocaleAndNavigate,
@@ -518,5 +579,7 @@ export {
createWorkspaceWithPolicyDraftAndNavigateToIt,
updateLastVisitedPath,
updateLastRoute,
+ setIsUsingImportedState,
+ clearOnyxAndResetApp,
KEYS_TO_PRESERVE,
};
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 5262cc4dc4ff..6681388c26dd 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -17,7 +17,6 @@ import type {
PayMoneyRequestParams,
ReplaceReceiptParams,
RequestMoneyParams,
- ResolveDuplicatesParams,
SendInvoiceParams,
SendMoneyParams,
SetNameValuePairParams,
@@ -3873,7 +3872,7 @@ function trackExpense(
function getOrCreateOptimisticSplitChatReport(existingSplitChatReportID: string, participants: Participant[], participantAccountIDs: number[], currentUserAccountID: number) {
// The existing chat report could be passed as reportID or exist on the sole "participant" (in this case a report option)
- const existingChatReportID = existingSplitChatReportID || participants[0].reportID;
+ const existingChatReportID = existingSplitChatReportID || (participants.at(0)?.reportID ?? '-1');
// Check if the report is available locally if we do have one
let existingSplitChatReport = existingChatReportID ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${existingChatReportID}`] : null;
@@ -5151,7 +5150,7 @@ function createDistanceRequest(
chatType: splitData.chatType,
};
} else {
- const participant = participants[0] ?? {};
+ const participant = participants.at(0) ?? {};
const {
iouReport,
chatReport,
@@ -5264,8 +5263,8 @@ function editRegularMoneyRequest(
'',
false,
);
- updatedMoneyRequestReport.lastMessageText = ReportActionsUtils.getTextFromHtml(lastMessage[0].html);
- updatedMoneyRequestReport.lastMessageHtml = lastMessage[0].html;
+ updatedMoneyRequestReport.lastMessageText = ReportActionsUtils.getTextFromHtml(lastMessage.at(0)?.html);
+ updatedMoneyRequestReport.lastMessageHtml = lastMessage.at(0)?.html;
// Update the last message of the chat report
const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport?.reportID);
@@ -5638,9 +5637,12 @@ function prepareToCleanUpMoneyRequest(transactionID: string, reportAction: OnyxT
});
if (ReportActionsUtils.getReportActionMessage(updatedReportPreviewAction)) {
- if (Array.isArray(updatedReportPreviewAction?.message) && updatedReportPreviewAction.message?.[0]) {
- updatedReportPreviewAction.message[0].text = messageText;
- updatedReportPreviewAction.message[0].deleted = shouldDeleteIOUReport ? DateUtils.getDBTime() : '';
+ if (Array.isArray(updatedReportPreviewAction?.message)) {
+ const message = updatedReportPreviewAction.message.at(0);
+ if (message) {
+ message.text = messageText;
+ message.deleted = shouldDeleteIOUReport ? DateUtils.getDBTime() : '';
+ }
} else if (!Array.isArray(updatedReportPreviewAction.message) && updatedReportPreviewAction.message) {
updatedReportPreviewAction.message.text = messageText;
updatedReportPreviewAction.message.deleted = shouldDeleteIOUReport ? DateUtils.getDBTime() : '';
@@ -6442,7 +6444,7 @@ function getReportFromHoldRequestsOnyxData(
failureData: OnyxUpdate[];
} {
const {holdReportActions, holdTransactions} = getHoldReportActionsAndTransactions(iouReport?.reportID ?? '');
- const firstHoldTransaction = holdTransactions[0];
+ const firstHoldTransaction = holdTransactions.at(0);
const newParentReportActionID = rand64();
const optimisticExpenseReport = ReportUtils.buildOptimisticExpenseReport(
@@ -6928,6 +6930,7 @@ function canIOUBePaid(
chatReport: OnyxTypes.OnyxInputOrEntry,
policy: OnyxTypes.OnyxInputOrEntry,
transactions?: OnyxTypes.Transaction[],
+ onlyShowPayElsewhere = false,
) {
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport);
const reportNameValuePairs = ReportUtils.getReportNameValuePairs(chatReport?.reportID);
@@ -6939,7 +6942,12 @@ function canIOUBePaid(
}
if (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO) {
- return false;
+ if (!onlyShowPayElsewhere) {
+ return false;
+ }
+ if (iouReport?.statusNum !== CONST.REPORT.STATUS_NUM.SUBMITTED) {
+ return false;
+ }
}
if (ReportUtils.isInvoiceReport(iouReport)) {
@@ -6958,6 +6966,7 @@ function canIOUBePaid(
accountID: userAccountID,
},
iouReport,
+ onlyShowPayElsewhere,
);
const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport);
@@ -6967,7 +6976,6 @@ function canIOUBePaid(
const shouldBeApproved = canApproveIOU(iouReport, policy);
const isPayAtEndExpenseReport = ReportUtils.isPayAtEndExpenseReport(iouReport?.reportID, transactions);
-
return (
isPayer &&
!isOpenExpenseReport &&
@@ -6996,7 +7004,7 @@ function isLastApprover(approvalChain: string[]): boolean {
if (approvalChain.length === 0) {
return true;
}
- return approvalChain[approvalChain.length - 1] === currentUserEmail;
+ return approvalChain.at(-1) === currentUserEmail;
}
function getNextApproverAccountID(report: OnyxEntry) {
@@ -7009,12 +7017,12 @@ function getNextApproverAccountID(report: OnyxEntry) {
return submitToAccountID;
}
- const nextApproverEmail = approvalChain.length === 1 ? approvalChain[0] : approvalChain[approvalChain.indexOf(currentUserEmail) + 1];
+ const nextApproverEmail = approvalChain.length === 1 ? approvalChain.at(0) : approvalChain.at(approvalChain.indexOf(currentUserEmail) + 1);
if (!nextApproverEmail) {
return submitToAccountID;
}
- return PersonalDetailsUtils.getAccountIDsByLogins([nextApproverEmail])[0];
+ return PersonalDetailsUtils.getAccountIDsByLogins([nextApproverEmail]).at(0);
}
function approveMoneyRequest(expenseReport: OnyxEntry, full?: boolean) {
@@ -7514,7 +7522,7 @@ function completePaymentOnboarding(paymentSelected: ValueOf {
+ if (!parentActionData) {
+ return;
+ }
+ optimisticData.push(parentActionData);
+ });
+
const successData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -8069,21 +8124,6 @@ function getIOURequestPolicyID(transaction: OnyxEntry, re
return workspaceSender?.policyID ?? report?.policyID ?? '-1';
}
-function getIOUActionForTransactions(transactionIDList: string[], iouReportID: string): Array> {
- return Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`] ?? {})?.filter(
- (reportAction): reportAction is ReportAction => {
- if (!ReportActionsUtils.isMoneyRequestAction(reportAction)) {
- return false;
- }
- const message = ReportActionsUtils.getOriginalMessage(reportAction);
- if (!message?.IOUTransactionID) {
- return false;
- }
- return transactionIDList.includes(message.IOUTransactionID);
- },
- );
-}
-
/** Merge several transactions into one by updating the fields of the one we want to keep and deleting the rest */
function mergeDuplicates(params: TransactionMergeParams) {
const originalSelectedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`];
@@ -8168,28 +8208,39 @@ function mergeDuplicates(params: TransactionMergeParams) {
},
};
- const iouActionsToDelete = getIOUActionForTransactions(params.transactionIDList, params.reportID);
+ const iouActionsToDelete = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${params.reportID}`] ?? {})?.filter(
+ (reportAction): reportAction is ReportAction => {
+ if (!ReportActionsUtils.isMoneyRequestAction(reportAction)) {
+ return false;
+ }
+ const message = ReportActionsUtils.getOriginalMessage(reportAction);
+ if (!message?.IOUTransactionID) {
+ return false;
+ }
+ return params.transactionIDList.includes(message.IOUTransactionID);
+ },
+ );
const deletedTime = DateUtils.getDBTime();
const expenseReportActionsOptimisticData: OnyxUpdate = {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${params.reportID}`,
value: iouActionsToDelete.reduce>>>((val, reportAction) => {
+ const firstMessage = Array.isArray(reportAction.message) ? reportAction.message.at(0) : null;
// eslint-disable-next-line no-param-reassign
val[reportAction.reportActionID] = {
originalMessage: {
deleted: deletedTime,
},
- ...(Array.isArray(reportAction.message) &&
- !!reportAction.message[0] && {
- message: [
- {
- ...reportAction.message[0],
- deleted: deletedTime,
- },
- ...reportAction.message.slice(1),
- ],
- }),
+ ...(firstMessage && {
+ message: [
+ {
+ ...firstMessage,
+ deleted: deletedTime,
+ },
+ ...(Array.isArray(reportAction.message) ? reportAction.message.slice(1) : []),
+ ],
+ }),
...(!Array.isArray(reportAction.message) && {
message: {
deleted: deletedTime,
@@ -8229,125 +8280,6 @@ function mergeDuplicates(params: TransactionMergeParams) {
API.write(WRITE_COMMANDS.TRANSACTION_MERGE, params, {optimisticData, failureData});
}
-/** 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) {
- const originalSelectedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`];
-
- const optimisticTransactionData: OnyxUpdate = {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`,
- value: {
- ...originalSelectedTransaction,
- billable: params.billable,
- comment: {
- comment: params.comment,
- },
- category: params.category,
- created: params.created,
- currency: params.currency,
- modifiedMerchant: params.merchant,
- reimbursable: params.reimbursable,
- tag: params.tag,
- },
- };
-
- const failureTransactionData: OnyxUpdate = {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`,
- // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
- value: originalSelectedTransaction as OnyxTypes.Transaction,
- };
-
- const optimisticTransactionViolations: OnyxUpdate[] = [...params.transactionIDList, params.transactionID].map((id) => {
- const violations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`] ?? [];
- const newViolation = {name: CONST.VIOLATIONS.HOLD, type: CONST.VIOLATION_TYPES.VIOLATION};
- const updatedViolations = id === params.transactionID ? violations : [...violations, newViolation];
- return {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`,
- value: updatedViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION),
- };
- });
-
- const failureTransactionViolations: OnyxUpdate[] = [...params.transactionIDList, params.transactionID].map((id) => {
- const violations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`] ?? [];
- return {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`,
- value: violations,
- };
- });
-
- const iouActionList = getIOUActionForTransactions(params.transactionIDList, params.reportID);
- const transactionThreadReportIDList = iouActionList.map((action) => action?.childReportID);
- const orderedTransactionIDList = iouActionList.map((action) => {
- const message = ReportActionsUtils.getOriginalMessage(action);
- return message?.IOUTransactionID ?? '';
- });
-
- const optimisticHoldActions: OnyxUpdate[] = [];
- const failureHoldActions: OnyxUpdate[] = [];
- const reportActionIDList: string[] = [];
- transactionThreadReportIDList.forEach((transactionThreadReportID) => {
- const createdReportAction = ReportUtils.buildOptimisticHoldReportAction();
- reportActionIDList.push(createdReportAction.reportActionID);
- optimisticHoldActions.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
- value: {
- [createdReportAction.reportActionID]: createdReportAction,
- },
- });
- failureHoldActions.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
- value: {
- [createdReportAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericHoldExpenseFailureMessage'),
- },
- },
- });
- });
-
- const transactionThreadReportID = getIOUActionForTransactions([params.transactionID], params.reportID)?.[0]?.childReportID;
- const optimisticReportAction = ReportUtils.buildOptimisticDismissedViolationReportAction({
- reason: 'manual',
- violationName: CONST.VIOLATIONS.DUPLICATED_TRANSACTION,
- });
-
- const optimisticReportActionData: OnyxUpdate = {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
- value: {
- [optimisticReportAction.reportActionID]: optimisticReportAction,
- },
- };
-
- const failureReportActionData: OnyxUpdate = {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
- value: {
- [optimisticReportAction.reportActionID]: null,
- },
- };
-
- const optimisticData: OnyxUpdate[] = [];
- const failureData: OnyxUpdate[] = [];
-
- optimisticData.push(optimisticTransactionData, ...optimisticTransactionViolations, ...optimisticHoldActions, optimisticReportActionData);
- failureData.push(failureTransactionData, ...failureTransactionViolations, ...failureHoldActions, failureReportActionData);
- const {reportID, transactionIDList, receiptID, ...otherParams} = params;
-
- const parameters: ResolveDuplicatesParams = {
- ...otherParams,
- reportActionIDList,
- transactionIDList: orderedTransactionIDList,
- dismissedViolationReportActionID: optimisticReportAction.reportActionID,
- };
-
- API.write(WRITE_COMMANDS.RESOLVE_DUPLICATES, parameters, {optimisticData, failureData});
-}
-
export {
adjustRemainingSplitShares,
getNextApproverAccountID,
@@ -8419,7 +8351,5 @@ export {
updateMoneyRequestTaxAmount,
updateMoneyRequestTaxRate,
mergeDuplicates,
- resolveDuplicates,
- prepareToCleanUpMoneyRequest,
};
export type {GPSPoint as GpsPoint, IOURequestType};
diff --git a/src/libs/actions/ImportSpreadsheet.ts b/src/libs/actions/ImportSpreadsheet.ts
index 2f3edeb3b1cb..9a597cf99bf9 100644
--- a/src/libs/actions/ImportSpreadsheet.ts
+++ b/src/libs/actions/ImportSpreadsheet.ts
@@ -3,15 +3,16 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
function setSpreadsheetData(data: string[][]): Promise {
- if (!Array.isArray(data) || !Array.isArray(data[0])) {
+ if (!Array.isArray(data) || !Array.isArray(data.at(0))) {
return Promise.reject(new Error('Invalid data format'));
}
- const transposedData = data[0].map((_, colIndex) => data.map((row) => row[colIndex]));
- const columnNames: Record = data[0].reduce((acc: Record, _, colIndex) => {
- acc[colIndex] = CONST.CSV_IMPORT_COLUMNS.IGNORE;
- return acc;
- }, {});
+ const transposedData = data.at(0)?.map((_, colIndex) => data.map((row) => row.at(colIndex) ?? ''));
+ const columnNames: Record =
+ data.at(0)?.reduce((acc: Record, _, colIndex) => {
+ acc[colIndex] = CONST.CSV_IMPORT_COLUMNS.IGNORE;
+ return acc;
+ }, {}) ?? {};
return Onyx.merge(ONYXKEYS.IMPORTED_SPREADSHEET, {data: transposedData, columns: columnNames});
}
diff --git a/src/libs/actions/OnyxUpdateManager/utils/index.ts b/src/libs/actions/OnyxUpdateManager/utils/index.ts
index bb5dd599480e..9a527308034e 100644
--- a/src/libs/actions/OnyxUpdateManager/utils/index.ts
+++ b/src/libs/actions/OnyxUpdateManager/utils/index.ts
@@ -93,7 +93,7 @@ function detectGapsAndSplit(lastUpdateIDFromClient: number): DetectGapAndSplitRe
if (gapExists) {
// If there is a gap and we didn't detect two chained updates, "firstUpdateToBeAppliedAfterGap" will always be the the last deferred update.
// We will fetch all missing updates up to the previous update and can always apply the last deferred update.
- const firstUpdateToBeAppliedAfterGap = firstUpdateIDAfterGaps ?? Number(updateValues[updateValues.length - 1].lastUpdateID);
+ const firstUpdateToBeAppliedAfterGap = firstUpdateIDAfterGaps ?? Number(updateValues.at(-1)?.lastUpdateID);
// Add all deferred updates after the gap(s) to "updatesAfterGaps".
// If "firstUpdateToBeAppliedAfterGap" is set to the last deferred update, the array will be empty.
diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts
index bac3739af810..5276f56d59bd 100644
--- a/src/libs/actions/PaymentMethods.ts
+++ b/src/libs/actions/PaymentMethods.ts
@@ -10,6 +10,7 @@ import type {
DeletePaymentCardParams,
MakeDefaultPaymentMethodParams,
PaymentCardParams,
+ SetInvoicingTransferBankAccountParams,
TransferWalletBalanceParams,
UpdateBillingCurrencyParams,
} from '@libs/API/parameters';
@@ -24,6 +25,7 @@ import type {BankAccountList, FundList} from '@src/types/onyx';
import type {AccountData} from '@src/types/onyx/Fund';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import type PaymentMethod from '@src/types/onyx/PaymentMethod';
+import type {OnyxData} from '@src/types/onyx/Request';
import type {FilterMethodPaymentType} from '@src/types/onyx/WalletTransfer';
type KYCWallRef = {
@@ -254,15 +256,7 @@ function addSubscriptionPaymentCard(cardData: {
];
if (currency === CONST.PAYMENT_CARD_CURRENCY.GBP) {
- // eslint-disable-next-line rulesdir/no-api-side-effects-method
- API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.ADD_PAYMENT_CARD_GBP, parameters, {optimisticData, successData, failureData}).then((response) => {
- if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) {
- return;
- }
-
- // We are using this onyx key to open Modal and preview iframe. Potentially we can save the whole object which come from side effect
- Onyx.set(ONYXKEYS.VERIFY_3DS_SUBSCRIPTION, (response as {authenticationLink: string}).authenticationLink);
- });
+ addPaymentCardGBP(parameters, {optimisticData, successData, failureData});
} else {
// eslint-disable-next-line rulesdir/no-multiple-api-calls
API.write(WRITE_COMMANDS.ADD_PAYMENT_CARD, parameters, {
@@ -273,6 +267,21 @@ function addSubscriptionPaymentCard(cardData: {
}
}
+/**
+ * Calls the API to add a new GBP card.
+ * Updates verify3dsSubscription Onyx key with a new authentication link for 3DS.
+ */
+function addPaymentCardGBP(params: AddPaymentCardParams, onyxData: OnyxData = {}) {
+ // eslint-disable-next-line rulesdir/no-api-side-effects-method
+ API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.ADD_PAYMENT_CARD_GBP, params, onyxData).then((response) => {
+ if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) {
+ return;
+ }
+ // We are using this onyx key to open Modal and preview iframe. Potentially we can save the whole object which come from side effect
+ Onyx.set(ONYXKEYS.VERIFY_3DS_SUBSCRIPTION, (response as {authenticationLink: string}).authenticationLink);
+ });
+}
+
/**
* Resets the values for the add payment card form back to their initial states
*/
@@ -531,6 +540,50 @@ function setPaymentCardForm(values: AccountData) {
});
}
+/**
+ * Sets the default bank account to use for receiving payouts from
+ *
+ */
+function setInvoicingTransferBankAccount(bankAccountID: number, policyID: string, previousBankAccountID: number) {
+ const parameters: SetInvoicingTransferBankAccountParams = {
+ bankAccountID,
+ policyID,
+ };
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ invoice: {
+ bankAccount: {
+ transferBankAccountID: bankAccountID,
+ },
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ invoice: {
+ bankAccount: {
+ transferBankAccountID: previousBankAccountID,
+ },
+ },
+ },
+ },
+ ];
+
+ API.write(WRITE_COMMANDS.SET_INVOICING_TRANSFER_BANK_ACCOUNT, parameters, {
+ optimisticData,
+ failureData,
+ });
+}
+
export {
deletePaymentCard,
addPaymentCard,
@@ -555,4 +608,6 @@ export {
clearWalletTermsError,
setPaymentCardForm,
verifySetupIntent,
+ addPaymentCardGBP,
+ setInvoicingTransferBankAccount,
};
diff --git a/src/libs/actions/PersistedRequests.ts b/src/libs/actions/PersistedRequests.ts
index 851e53876508..fc14e8c2303b 100644
--- a/src/libs/actions/PersistedRequests.ts
+++ b/src/libs/actions/PersistedRequests.ts
@@ -5,24 +5,47 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type {Request} from '@src/types/onyx';
let persistedRequests: Request[] = [];
+let ongoingRequest: Request | null = null;
Onyx.connect({
key: ONYXKEYS.PERSISTED_REQUESTS,
- callback: (val) => (persistedRequests = val ?? []),
+ callback: (val) => {
+ persistedRequests = val ?? [];
+
+ if (ongoingRequest && persistedRequests.length > 0) {
+ const nextRequestToProcess = persistedRequests.at(0);
+
+ // We try to remove the next request from the persistedRequests if it is the same as ongoingRequest
+ // so we don't process it twice.
+ if (isEqual(nextRequestToProcess, ongoingRequest)) {
+ persistedRequests = persistedRequests.slice(1);
+ }
+ }
+ },
+});
+Onyx.connect({
+ key: ONYXKEYS.PERSISTED_ONGOING_REQUESTS,
+ callback: (val) => {
+ ongoingRequest = val ?? null;
+ },
});
/**
* This promise is only used by tests. DO NOT USE THIS PROMISE IN THE APPLICATION CODE
*/
function clear() {
+ ongoingRequest = null;
+ Onyx.set(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, null);
return Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, []);
}
function getLength(): number {
- return persistedRequests.length;
+ // Making it backwards compatible with the old implementation
+ return persistedRequests.length + (ongoingRequest ? 1 : 0);
}
function save(requestToPersist: Request) {
+ // If the command is not in the keepLastInstance array, add the new request as usual
const requests = [...persistedRequests, requestToPersist];
persistedRequests = requests;
Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests).then(() => {
@@ -31,18 +54,24 @@ function save(requestToPersist: Request) {
}
function remove(requestToRemove: Request) {
+ ongoingRequest = null;
/**
* We only remove the first matching request because the order of requests matters.
* If we were to remove all matching requests, we can end up with a final state that is different than what the user intended.
*/
const requests = [...persistedRequests];
const index = requests.findIndex((persistedRequest) => isEqual(persistedRequest, requestToRemove));
- if (index === -1) {
- return;
+
+ if (index !== -1) {
+ requests.splice(index, 1);
}
- requests.splice(index, 1);
+
persistedRequests = requests;
- Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests).then(() => {
+
+ Onyx.multiSet({
+ [ONYXKEYS.PERSISTED_REQUESTS]: persistedRequests,
+ [ONYXKEYS.PERSISTED_ONGOING_REQUESTS]: null,
+ }).then(() => {
Log.info(`[SequentialQueue] '${requestToRemove.command}' removed from the queue. Queue length is ${getLength()}`);
});
}
@@ -54,8 +83,52 @@ function update(oldRequestIndex: number, newRequest: Request) {
Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests);
}
+function updateOngoingRequest(newRequest: Request) {
+ ongoingRequest = newRequest;
+
+ if (newRequest.persistWhenOngoing) {
+ Onyx.set(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, newRequest);
+ }
+}
+
+function processNextRequest(): Request | null {
+ if (ongoingRequest) {
+ Log.info(`Ongoing Request already set returning same one ${ongoingRequest.commandName}`);
+ return ongoingRequest;
+ }
+
+ // You must handle the case where there are no requests to process
+ if (persistedRequests.length === 0) {
+ throw new Error('No requests to process');
+ }
+
+ ongoingRequest = persistedRequests.shift() ?? null;
+
+ if (ongoingRequest && ongoingRequest.persistWhenOngoing) {
+ Onyx.set(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, ongoingRequest);
+ }
+
+ return ongoingRequest;
+}
+
+function rollbackOngoingRequest() {
+ if (!ongoingRequest) {
+ return;
+ }
+
+ // Prepend ongoingRequest to persistedRequests
+ persistedRequests.unshift(ongoingRequest);
+
+ // Clear the ongoingRequest
+ ongoingRequest = null;
+}
+
function getAll(): Request[] {
return persistedRequests;
}
-export {clear, save, getAll, remove, update, getLength};
+function getOngoingRequest(): Request | null {
+ return ongoingRequest;
+}
+
+export {clear, save, getAll, remove, update, getLength, getOngoingRequest, processNextRequest, updateOngoingRequest, rollbackOngoingRequest};
diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts
index a9f6e376b80a..e237ed80e293 100644
--- a/src/libs/actions/Policy/Category.ts
+++ b/src/libs/actions/Policy/Category.ts
@@ -524,14 +524,26 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string
const policy = PolicyUtils.getPolicy(policyID);
const policyCategoryToUpdate = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[policyCategory.oldName];
- const policyCategoryRule = CategoryUtils.getCategoryApproverRule(policy?.rules?.approvalRules ?? [], policyCategory.oldName);
+ const policyCategoryApproverRule = CategoryUtils.getCategoryApproverRule(policy?.rules?.approvalRules ?? [], policyCategory.oldName);
+ const policyCategoryExpenseRule = CategoryUtils.getCategoryExpenseRule(policy?.rules?.expenseRules ?? [], policyCategory.oldName);
const approvalRules = policy?.rules?.approvalRules ?? [];
+ const expenseRules = policy?.rules?.expenseRules ?? [];
const updatedApprovalRules: ApprovalRule[] = lodashCloneDeep(approvalRules);
+ const updatedExpenseRules: ExpenseRule[] = lodashCloneDeep(expenseRules);
+
+ if (policyCategoryExpenseRule) {
+ const ruleIndex = updatedExpenseRules.findIndex((rule) => rule.id === policyCategoryExpenseRule.id);
+ policyCategoryExpenseRule.applyWhen = policyCategoryExpenseRule.applyWhen.map((applyWhen) => ({
+ ...applyWhen,
+ ...(applyWhen.field === CONST.POLICY.FIELDS.CATEGORY && applyWhen.value === policyCategory.oldName && {value: policyCategory.newName}),
+ }));
+ updatedExpenseRules[ruleIndex] = policyCategoryExpenseRule;
+ }
// Its related by name, so the corresponding rule has to be updated to handle offline scenario
- if (policyCategoryRule) {
- const indexToUpdate = updatedApprovalRules.findIndex((rule) => rule.id === policyCategoryRule.id);
- policyCategoryRule.applyWhen = policyCategoryRule.applyWhen.map((ruleCondition) => {
+ if (policyCategoryApproverRule) {
+ const indexToUpdate = updatedApprovalRules.findIndex((rule) => rule.id === policyCategoryApproverRule.id);
+ policyCategoryApproverRule.applyWhen = policyCategoryApproverRule.applyWhen.map((ruleCondition) => {
const {value, field, condition} = ruleCondition;
if (value === policyCategory.oldName && field === CONST.POLICY.FIELDS.CATEGORY && condition === CONST.POLICY.RULE_CONDITIONS.MATCHES) {
@@ -540,7 +552,7 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string
return ruleCondition;
});
- updatedApprovalRules[indexToUpdate] = policyCategoryRule;
+ updatedApprovalRules[indexToUpdate] = policyCategoryApproverRule;
}
const onyxData: OnyxData = {
@@ -568,6 +580,7 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string
value: {
rules: {
approvalRules: updatedApprovalRules,
+ expenseRules: updatedExpenseRules,
},
},
},
@@ -1212,7 +1225,10 @@ function setPolicyCategoryApprover(policyID: string, categoryName: string, appro
newApprover = '';
} else {
const indexToUpdate = updatedApprovalRules.indexOf(existingCategoryApproverRule);
- updatedApprovalRules[indexToUpdate].approver = approver;
+ const approvalRule = updatedApprovalRules.at(indexToUpdate);
+ if (approvalRule && indexToUpdate !== -1) {
+ approvalRule.approver = approver;
+ }
}
const onyxData: OnyxData = {
@@ -1274,7 +1290,11 @@ function setPolicyCategoryTax(policyID: string, categoryName: string, taxID: str
});
} else {
const indexToUpdate = updatedExpenseRules.indexOf(existingCategoryExpenseRule);
- updatedExpenseRules[indexToUpdate].tax.field_id_TAX.externalID = taxID;
+ const expenseRule = updatedExpenseRules.at(indexToUpdate);
+
+ if (expenseRule && indexToUpdate !== -1) {
+ expenseRule.tax.field_id_TAX.externalID = taxID;
+ }
}
const onyxData: OnyxData = {
diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts
index b81b180cca52..f446d2da6b52 100644
--- a/src/libs/actions/Policy/DistanceRate.ts
+++ b/src/libs/actions/Policy/DistanceRate.ts
@@ -128,12 +128,12 @@ function enablePolicyDistanceRates(policyID: string, enabled: boolean) {
if (!enabled) {
const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
- const customUnitID = Object.keys(policy?.customUnits ?? {})[0];
+ const customUnitID = Object.keys(policy?.customUnits ?? {}).at(0) ?? '';
const customUnit = customUnitID ? policy?.customUnits?.[customUnitID] : undefined;
const rateEntries = Object.entries(customUnit?.rates ?? {});
// find the rate to be enabled after disabling the distance rate feature
- const rateEntryToBeEnabled = rateEntries[0];
+ const rateEntryToBeEnabled = rateEntries.at(0);
onyxData.optimisticData?.push({
onyxMethod: Onyx.METHOD.MERGE,
@@ -148,7 +148,7 @@ function enablePolicyDistanceRates(policyID: string, enabled: boolean) {
rateID,
{
...rate,
- enabled: rateID === rateEntryToBeEnabled[0],
+ enabled: rateID === rateEntryToBeEnabled?.at(0),
},
];
}),
diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts
index f4d2287aca4c..d5b2adc54de3 100644
--- a/src/libs/actions/Policy/Member.ts
+++ b/src/libs/actions/Policy/Member.ts
@@ -433,7 +433,7 @@ function removeMembers(accountIDs: number[], policyID: string) {
optimisticClosedReportActions.forEach((reportAction, index) => {
failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.[index]?.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.at(index)?.reportID}`,
value: {[reportAction.reportActionID]: null},
});
});
@@ -522,7 +522,7 @@ function requestWorkspaceOwnerChange(policyID: string) {
const changeOwnerErrors = Object.keys(policy?.errorFields?.changeOwner ?? {});
if (changeOwnerErrors && changeOwnerErrors.length > 0) {
- const currentError = changeOwnerErrors[0];
+ const currentError = changeOwnerErrors.at(0);
if (currentError === CONST.POLICY.OWNERSHIP_ERRORS.AMOUNT_OWED) {
ownershipChecks.shouldClearOutstandingBalance = true;
}
diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts
index 60cab1787700..ec37b2c4e1fa 100644
--- a/src/libs/actions/Policy/Policy.ts
+++ b/src/libs/actions/Policy/Policy.ts
@@ -8,6 +8,7 @@ import type {ReportExportType} from '@components/ButtonWithDropdownMenu/types';
import * as API from '@libs/API';
import type {
AddBillingCardAndRequestWorkspaceOwnerChangeParams,
+ AddPaymentCardParams,
CreateWorkspaceFromIOUPaymentParams,
CreateWorkspaceParams,
DeleteWorkspaceAvatarParams,
@@ -73,6 +74,7 @@ import * as ReportConnection from '@libs/ReportConnection';
import * as ReportUtils from '@libs/ReportUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
import type {PolicySelector} from '@pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover';
+import * as PaymentMethods from '@userActions/PaymentMethods';
import * as PersistedRequests from '@userActions/PersistedRequests';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -227,7 +229,7 @@ function getPolicy(policyID: string | undefined): OnyxEntry {
function getPrimaryPolicy(activePolicyID: OnyxEntry, currentUserLogin: string | undefined): Policy | undefined {
const activeAdminWorkspaces = PolicyUtils.getActiveAdminWorkspaces(allPolicies, currentUserLogin);
const primaryPolicy: Policy | null | undefined = activeAdminWorkspaces.find((policy) => policy.id === activePolicyID);
- return primaryPolicy ?? activeAdminWorkspaces[0];
+ return primaryPolicy ?? activeAdminWorkspaces.at(0);
}
/** Check if the policy has invoicing company details */
@@ -836,18 +838,78 @@ function addBillingCardAndRequestPolicyOwnerChange(
},
];
- const params: AddBillingCardAndRequestWorkspaceOwnerChangeParams = {
- policyID,
- cardNumber,
- cardYear,
- cardMonth,
- cardCVV,
- addressName,
- addressZip,
- currency,
- };
+ if (currency === CONST.PAYMENT_CARD_CURRENCY.GBP) {
+ const params: AddPaymentCardParams = {
+ cardNumber,
+ cardYear,
+ cardMonth,
+ cardCVV,
+ addressName,
+ addressZip,
+ currency,
+ isP2PDebitCard: false,
+ };
+ PaymentMethods.addPaymentCardGBP(params);
+ } else {
+ const params: AddBillingCardAndRequestWorkspaceOwnerChangeParams = {
+ policyID,
+ cardNumber,
+ cardYear,
+ cardMonth,
+ cardCVV,
+ addressName,
+ addressZip,
+ currency,
+ };
+ // eslint-disable-next-line rulesdir/no-multiple-api-calls
+ API.write(WRITE_COMMANDS.ADD_BILLING_CARD_AND_REQUEST_WORKSPACE_OWNER_CHANGE, params, {optimisticData, successData, failureData});
+ }
+}
+
+/**
+ * Properly updates the nvp_privateStripeCustomerID onyx data for 3DS payment
+ *
+ */
+function verifySetupIntentAndRequestPolicyOwnerChange(policyID: string) {
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ errorFields: null,
+ isLoading: true,
+ isChangeOwnerSuccessful: false,
+ isChangeOwnerFailed: false,
+ },
+ },
+ ];
- API.write(WRITE_COMMANDS.ADD_BILLING_CARD_AND_REQUEST_WORKSPACE_OWNER_CHANGE, params, {optimisticData, successData, failureData});
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ isLoading: false,
+ isChangeOwnerSuccessful: true,
+ isChangeOwnerFailed: false,
+ owner: sessionEmail,
+ ownerAccountID: sessionAccountID,
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ isLoading: false,
+ isChangeOwnerSuccessful: false,
+ isChangeOwnerFailed: true,
+ },
+ },
+ ];
+ API.write(WRITE_COMMANDS.VERIFY_SETUP_INTENT_AND_REQUEST_POLICY_OWNER_CHANGE, {accountID: sessionAccountID, policyID}, {optimisticData, successData, failureData});
}
/**
@@ -1197,8 +1259,8 @@ function updateGeneralSettings(policyID: string, name: string, currencyValue?: s
(request) => request.data?.policyID === policyID && request.command === WRITE_COMMANDS.CREATE_WORKSPACE && request.data?.policyName !== name,
);
- const createWorkspaceRequest = persistedRequests[createWorkspaceRequestChangedIndex];
- if (createWorkspaceRequest) {
+ const createWorkspaceRequest = persistedRequests.at(createWorkspaceRequestChangedIndex);
+ if (createWorkspaceRequest && createWorkspaceRequestChangedIndex !== -1) {
const workspaceRequest: Request = {
...createWorkspaceRequest,
data: {
@@ -1386,13 +1448,13 @@ function generateDefaultWorkspaceName(email = ''): string {
if (!emailParts || emailParts.length !== 2) {
return defaultWorkspaceName;
}
- const username = emailParts[0];
- const domain = emailParts[1];
+ const username = emailParts.at(0) ?? '';
+ const domain = emailParts.at(1) ?? '';
const userDetails = PersonalDetailsUtils.getPersonalDetailByEmail(sessionEmail);
const displayName = userDetails?.displayName?.trim();
if (!PUBLIC_DOMAINS.some((publicDomain) => publicDomain === domain.toLowerCase())) {
- defaultWorkspaceName = `${Str.UCFirst(domain.split('.')[0])}'s Workspace`;
+ defaultWorkspaceName = `${Str.UCFirst(domain.split('.').at(0) ?? '')}'s Workspace`;
} else if (displayName) {
defaultWorkspaceName = `${Str.UCFirst(displayName)}'s Workspace`;
} else if (PUBLIC_DOMAINS.some((publicDomain) => publicDomain === domain.toLowerCase())) {
@@ -2248,7 +2310,7 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`,
value: {
- [Object.keys(adminsChatData)[0]]: {
+ [Object.keys(adminsChatData).at(0) ?? '']: {
pendingAction: null,
},
},
@@ -2267,7 +2329,7 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`,
value: {
- [Object.keys(workspaceChatData)[0]]: {
+ [Object.keys(workspaceChatData).at(0) ?? '']: {
pendingAction: null,
},
},
@@ -3074,10 +3136,9 @@ function enablePolicyInvoicing(policyID: string, enabled: boolean) {
API.write(WRITE_COMMANDS.ENABLE_POLICY_INVOICING, parameters, onyxData);
- // TODO: Uncomment the following line when the invoices screen is ready - https://github.com/Expensify/App/issues/45175.
- // if (enabled && getIsNarrowLayout()) {
- // navigateWhenEnableFeature(policyID);
- // }
+ if (enabled && getIsNarrowLayout()) {
+ navigateWhenEnableFeature(policyID);
+ }
}
function openPolicyMoreFeaturesPage(policyID: string) {
@@ -3330,6 +3391,7 @@ function setWorkspaceDefaultSpendCategory(policyID: string, groupID: string, cat
[groupID]: {
category,
groupID,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
},
},
},
@@ -3343,13 +3405,35 @@ function setWorkspaceDefaultSpendCategory(policyID: string, groupID: string, cat
onyxMethod: Onyx.METHOD.MERGE,
key: `policy_${policyID}`,
value: {
- mccGroup,
+ mccGroup: {
+ ...mccGroup,
+ [groupID]: {
+ ...mccGroup[groupID],
+ pendingAction: null,
+ },
+ },
+ },
+ },
+ ]
+ : [];
+
+ const successData: OnyxUpdate[] = mccGroup
+ ? [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `policy_${policyID}`,
+ value: {
+ mccGroup: {
+ [groupID]: {
+ pendingAction: null,
+ },
+ },
},
},
]
: [];
- API.write(WRITE_COMMANDS.SET_WORKSPACE_DEFAULT_SPEND_CATEGORY, {policyID, groupID, category}, {optimisticData, successData: [], failureData});
+ API.write(WRITE_COMMANDS.SET_WORKSPACE_DEFAULT_SPEND_CATEGORY, {policyID, groupID, category}, {optimisticData, successData, failureData});
}
/**
* Call the API to set the receipt required amount for the given policy
@@ -4755,6 +4839,7 @@ export {
updateCompanyCardName,
setCompanyCardExportAccount,
clearCompanyCardErrorField,
+ verifySetupIntentAndRequestPolicyOwnerChange,
};
export type {NewCustomUnit};
diff --git a/src/libs/actions/Policy/ReportField.ts b/src/libs/actions/Policy/ReportField.ts
index 9a56c719fd73..2ee1707c21d1 100644
--- a/src/libs/actions/Policy/ReportField.ts
+++ b/src/libs/actions/Policy/ReportField.ts
@@ -383,7 +383,7 @@ function updateReportFieldListValueEnabled(policyID: string, reportFieldID: stri
valueIndexes.forEach((valueIndex) => {
updatedReportField.disabledOptions[valueIndex] = !enabled;
- const shouldResetDefaultValue = !enabled && reportField.defaultValue === reportField.values[valueIndex];
+ const shouldResetDefaultValue = !enabled && reportField.defaultValue === reportField.values.at(valueIndex);
if (shouldResetDefaultValue) {
updatedReportField.defaultValue = '';
@@ -460,7 +460,7 @@ function removeReportFieldListValue(policyID: string, reportFieldID: string, val
valueIndexes
.sort((a, b) => b - a)
.forEach((valueIndex) => {
- const shouldResetDefaultValue = reportField.defaultValue === reportField.values[valueIndex];
+ const shouldResetDefaultValue = reportField.defaultValue === reportField.values.at(valueIndex);
if (shouldResetDefaultValue) {
updatedReportField.defaultValue = '';
diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts
index 9628b6ceda77..2a3080fc810f 100644
--- a/src/libs/actions/Policy/Tag.ts
+++ b/src/libs/actions/Policy/Tag.ts
@@ -25,6 +25,7 @@ import * as PolicyUtils from '@libs/PolicyUtils';
import {navigateWhenEnableFeature} from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
+import type {PolicyTagList} from '@pages/workspace/tags/types';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, PolicyTag, PolicyTagLists, PolicyTags, RecentlyUsedTags, Report} from '@src/types/onyx';
@@ -121,7 +122,7 @@ function buildOptimisticPolicyRecentlyUsedTags(policyID?: string, transactionTag
return;
}
- const tagListKey = policyTagKeys[index];
+ const tagListKey = policyTagKeys.at(index) ?? '';
newOptimisticPolicyRecentlyUsedTags[tagListKey] = [...new Set([tag, ...(policyRecentlyUsedTags[tagListKey] ?? [])])];
});
@@ -160,7 +161,7 @@ function updateImportSpreadsheetData(tagsLength: number): OnyxData {
}
function createPolicyTag(policyID: string, tagName: string) {
- const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[0] ?? {};
+ const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.at(0) ?? ({} as PolicyTagList);
const newTagName = PolicyUtils.escapeTagName(tagName);
const onyxData: OnyxData = {
@@ -237,7 +238,11 @@ function importPolicyTags(policyID: string, tags: PolicyTag[]) {
}
function setWorkspaceTagEnabled(policyID: string, tagsToUpdate: Record, tagListIndex: number) {
- const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {};
+ const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.at(tagListIndex);
+
+ if (!policyTag || tagListIndex === -1) {
+ return;
+ }
const optimisticPolicyTagsData = {
...Object.keys(tagsToUpdate).reduce((acc, key) => {
@@ -246,7 +251,7 @@ function setWorkspaceTagEnabled(policyID: string, tagsToUpdate: Record = {};
const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins(participantLoginList);
participantLoginList.forEach((login, index) => {
- const accountID = participantAccountIDs[index];
+ const accountID = participantAccountIDs.at(index) ?? -1;
const isOptimisticAccount = !allPersonalDetails?.[accountID];
if (!isOptimisticAccount) {
@@ -2072,7 +2076,7 @@ function updateDescription(reportID: string, previousValue: string, newValue: st
pendingFields: {description: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
lastActorAccountID: currentUserAccountID,
lastVisibleActionCreated: optimisticDescriptionUpdatedReportAction.created,
- lastMessageText: (optimisticDescriptionUpdatedReportAction?.message as Message[])?.[0]?.text,
+ lastMessageText: (optimisticDescriptionUpdatedReportAction?.message as Message[])?.at(0)?.text,
},
},
{
@@ -3579,6 +3583,11 @@ function completeOnboarding(
key: ONYXKEYS.NVP_INTRO_SELECTED,
value: {choice: engagementChoice},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.NVP_ONBOARDING,
+ value: {hasCompletedGuidedSetupFlow: true},
+ },
);
const successData: OnyxUpdate[] = [...tasksForSuccessData];
@@ -3634,6 +3643,11 @@ function completeOnboarding(
key: ONYXKEYS.NVP_INTRO_SELECTED,
value: {choice: null},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.NVP_ONBOARDING,
+ value: {hasCompletedGuidedSetupFlow: false},
+ },
);
const guidedSetupData: GuidedSetupData = [
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index ab209e9bf928..4d6ba6cfa774 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -482,7 +482,7 @@ function signUpUser() {
function signInAfterTransitionFromOldDot(transitionURL: string) {
const [route, queryParams] = transitionURL.split('?');
- const {email, authToken, encryptedAuthToken, accountID, autoGeneratedLogin, autoGeneratedPassword, clearOnyxOnStart} = Object.fromEntries(
+ const {email, authToken, encryptedAuthToken, accountID, autoGeneratedLogin, autoGeneratedPassword, clearOnyxOnStart, completedHybridAppOnboarding} = Object.fromEntries(
queryParams.split('&').map((param) => {
const [key, value] = param.split('=');
return [key, value];
@@ -493,6 +493,7 @@ function signInAfterTransitionFromOldDot(transitionURL: string) {
Onyx.multiSet({
[ONYXKEYS.SESSION]: {email, authToken, encryptedAuthToken: decodeURIComponent(encryptedAuthToken), accountID: Number(accountID)},
[ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin, autoGeneratedPassword},
+ [ONYXKEYS.NVP_TRYNEWDOT]: {classicRedirect: {completedHybridAppOnboarding: completedHybridAppOnboarding === 'true'}},
}).then(App.openApp);
};
diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts
index 08568b6d5a02..2f9ec060c1e8 100644
--- a/src/libs/actions/Task.ts
+++ b/src/libs/actions/Task.ts
@@ -113,6 +113,7 @@ function createTaskAndNavigate(
assigneeAccountID = 0,
assigneeChatReport?: OnyxEntry,
policyID: string = CONST.POLICY.OWNER_EMAIL_FAKE,
+ isCreatedUsingMarkdown = false,
) {
const optimisticTaskReport = ReportUtils.buildOptimisticTaskReport(currentUserAccountID, assigneeAccountID, parentReportID, title, description, policyID);
@@ -277,8 +278,6 @@ function createTaskAndNavigate(
},
});
- clearOutTaskInfo();
-
const parameters: CreateTaskParams = {
parentReportActionID: optimisticAddCommentReport.reportAction.reportActionID,
parentReportID,
@@ -295,7 +294,10 @@ function createTaskAndNavigate(
API.write(WRITE_COMMANDS.CREATE_TASK, parameters, {optimisticData, successData, failureData});
- Navigation.dismissModal(parentReportID);
+ if (!isCreatedUsingMarkdown) {
+ clearOutTaskInfo();
+ Navigation.dismissModal(parentReportID);
+ }
Report.notifyNewAction(parentReportID, currentUserAccountID);
}
@@ -897,7 +899,7 @@ function getShareDestination(reportID: string, reports: OnyxCollection a.localeCompare(b))[0];
+ const firstTaxID = Object.keys(policyTaxRates ?? {})
+ .sort((a, b) => a.localeCompare(b))
+ .at(0);
if (!policyTaxRates) {
console.debug('Policy or tax rates not found');
diff --git a/src/libs/actions/TeachersUnite.ts b/src/libs/actions/TeachersUnite.ts
index ba67e579e95a..1e550f22400d 100644
--- a/src/libs/actions/TeachersUnite.ts
+++ b/src/libs/actions/TeachersUnite.ts
@@ -143,7 +143,7 @@ function addSchoolPrincipal(firstName: string, partnerUserID: string, lastName:
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`,
value: {
- [Object.keys(expenseChatData)[0]]: {
+ [Object.keys(expenseChatData).at(0) ?? '']: {
pendingAction: null,
},
},
diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts
index e19251b62ce8..62ecf7397465 100644
--- a/src/libs/actions/Transaction.ts
+++ b/src/libs/actions/Transaction.ts
@@ -134,6 +134,9 @@ function saveWaypoint(transactionID: string, index: string, waypoint: RecentWayp
function removeWaypoint(transaction: OnyxEntry, currentIndex: string, isDraft?: boolean): Promise {
// Index comes from the route params and is a string
const index = Number(currentIndex);
+ if (index === -1) {
+ return Promise.resolve();
+ }
const existingWaypoints = transaction?.comment?.waypoints ?? {};
const totalWaypoints = Object.keys(existingWaypoints).length;
@@ -143,7 +146,7 @@ function removeWaypoint(transaction: OnyxEntry, currentIndex: strin
return Promise.resolve();
}
- const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed[0] ?? {});
+ const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed.at(0) ?? {});
// When there are only two waypoints we are adding empty waypoint back
if (totalWaypoints === 2 && (index === 0 || index === totalWaypoints - 1)) {
@@ -311,7 +314,7 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID ?? '-1'}`,
value: {
- [optimisticDissmidedViolationReportActions[index].reportActionID]: optimisticDissmidedViolationReportActions[index] as ReportAction,
+ [optimisticDissmidedViolationReportActions.at(index)?.reportActionID ?? '']: optimisticDissmidedViolationReportActions.at(index) as ReportAction,
},
}));
const optimisticDataTransactionViolations: OnyxUpdate[] = currentTransactionViolations.map((transactionViolations) => ({
@@ -359,7 +362,7 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID ?? '-1'}`,
value: {
- [optimisticDissmidedViolationReportActions[index].reportActionID]: null,
+ [optimisticDissmidedViolationReportActions.at(index)?.reportActionID ?? '']: null,
},
}));
@@ -371,7 +374,7 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID ?? '-1'}`,
value: {
- [optimisticDissmidedViolationReportActions[index].reportActionID]: {
+ [optimisticDissmidedViolationReportActions.at(index)?.reportActionID ?? '']: {
pendingAction: null,
},
},
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index 7ef7d6b94c9e..9ea29506accc 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -775,7 +775,7 @@ function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) {
const reportActionsOnly = pushJSON.filter((update) => update.key?.includes('reportActions_'));
// "reportActions_5134363522480668" -> "5134363522480668"
const reportID = reportActionsOnly
- .map((value) => value.key.split('_')[1])
+ .map((value) => value.key.split('_').at(1))
.find((reportKey) => reportKey === Navigation.getTopmostReportId() && Visibility.isVisible() && Visibility.hasFocus());
if (!reportID) {
diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts
index 4e780090299d..4ab3cda27c64 100644
--- a/src/libs/actions/Welcome/OnboardingFlow.ts
+++ b/src/libs/actions/Welcome/OnboardingFlow.ts
@@ -10,6 +10,7 @@ import NAVIGATORS from '@src/NAVIGATORS';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
+import type Onboarding from '@src/types/onyx/Onboarding';
let selectedPurpose: string | undefined = '';
Onyx.connect({
@@ -31,6 +32,17 @@ const onboardingLastVisitedPathConnection = Onyx.connect({
},
});
+let onboardingValues: Onboarding;
+Onyx.connect({
+ key: ONYXKEYS.NVP_ONBOARDING,
+ callback: (value) => {
+ if (value === undefined) {
+ return;
+ }
+ onboardingValues = value as Onboarding;
+ },
+});
+
/**
* Build the correct stack order for `onboardingModalNavigator`,
* based on onboarding data (currently from the selected purpose).
@@ -46,8 +58,8 @@ function adaptOnboardingRouteState() {
const rootState = navigationRef.getRootState();
const adaptedState = rootState;
const lastRouteIndex = (adaptedState?.routes?.length ?? 0) - 1;
- const onBoardingModalNavigatorState = adaptedState?.routes[lastRouteIndex]?.state;
- if (!onBoardingModalNavigatorState || onBoardingModalNavigatorState?.routes?.length > 1) {
+ const onBoardingModalNavigatorState = adaptedState?.routes.at(lastRouteIndex)?.state;
+ if (!onBoardingModalNavigatorState || onBoardingModalNavigatorState?.routes?.length > 1 || lastRouteIndex === -1) {
return;
}
@@ -80,7 +92,11 @@ function adaptOnboardingRouteState() {
} as Readonly>;
}
- adaptedState.routes[lastRouteIndex].state = adaptedOnboardingModalNavigatorState;
+ const route = adaptedState.routes.at(lastRouteIndex);
+
+ if (route) {
+ route.state = adaptedOnboardingModalNavigatorState;
+ }
navigationRef.resetRoot(adaptedState);
}
@@ -99,6 +115,11 @@ function startOnboardingFlow() {
function getOnboardingInitialPath(): string {
const state = getStateFromPath(onboardingInitialPath, linkingConfig.config);
+ const showBusinessModal = onboardingValues && CONST.QUALIFIER_PARAM in onboardingValues && onboardingValues.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB;
+
+ if (showBusinessModal) {
+ return `/${ROUTES.ONBOARDING_WORK.route}`;
+ }
if (state?.routes?.at(-1)?.name !== NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR) {
return `/${ROUTES.ONBOARDING_ROOT.route}`;
}
diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts
index fc921b16f4cf..d27a5e195a0e 100644
--- a/src/libs/actions/Welcome/index.ts
+++ b/src/libs/actions/Welcome/index.ts
@@ -4,12 +4,8 @@ import Onyx from 'react-native-onyx';
import * as API from '@libs/API';
import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types';
import Log from '@libs/Log';
-import Navigation from '@libs/Navigation/Navigation';
-import variables from '@styles/variables';
import type {OnboardingPurposeType} from '@src/CONST';
-import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
import type Onboarding from '@src/types/onyx/Onboarding';
import type TryNewDot from '@src/types/onyx/TryNewDot';
import * as OnboardingFlow from './OnboardingFlow';
@@ -26,11 +22,6 @@ type HasCompletedOnboardingFlowProps = {
onCanceled?: () => void;
};
-type HasOpenedForTheFirstTimeFromHybridAppProps = {
- onFirstTimeInHybridApp?: () => void;
- onSubsequentRuns?: () => void;
-};
-
let resolveIsReadyPromise: (value?: Promise) => void | undefined;
let isServerDataReadyPromise = new Promise((resolve) => {
resolveIsReadyPromise = resolve;
@@ -42,9 +33,6 @@ let isOnboardingFlowStatusKnownPromise = new Promise((resolve) => {
});
let resolveTryNewDotStatus: (value?: Promise) => void | undefined;
-const tryNewDotStatusPromise = new Promise((resolve) => {
- resolveTryNewDotStatus = resolve;
-});
function onServerDataReady(): Promise {
return isServerDataReadyPromise;
@@ -68,49 +56,6 @@ function isOnboardingFlowCompleted({onCompleted, onNotCompleted, onCanceled}: Ha
});
}
-/**
- * Determines whether the application is being launched for the first time by a hybrid app user,
- * and executes corresponding callback functions.
- */
-function isFirstTimeHybridAppUser({onFirstTimeInHybridApp, onSubsequentRuns}: HasOpenedForTheFirstTimeFromHybridAppProps) {
- tryNewDotStatusPromise.then(() => {
- let completedHybridAppOnboarding = tryNewDotData?.classicRedirect?.completedHybridAppOnboarding;
- // Backend might return strings instead of booleans
- if (typeof completedHybridAppOnboarding === 'string') {
- completedHybridAppOnboarding = completedHybridAppOnboarding === 'true';
- }
-
- if (NativeModules.HybridAppModule && !completedHybridAppOnboarding) {
- onFirstTimeInHybridApp?.();
- return;
- }
-
- onSubsequentRuns?.();
- });
-}
-
-/**
- * Handles HybridApp onboarding flow if it's possible and necessary.
- */
-function handleHybridAppOnboarding() {
- if (!NativeModules.HybridAppModule) {
- return;
- }
-
- isFirstTimeHybridAppUser({
- // When user opens New Expensify for the first time from HybridApp we always want to show explanation modal first.
- onFirstTimeInHybridApp: () => Navigation.navigate(ROUTES.EXPLANATION_MODAL_ROOT),
- // In other scenarios we need to check if onboarding was completed.
- onSubsequentRuns: () =>
- isOnboardingFlowCompleted({
- onNotCompleted: () =>
- setTimeout(() => {
- OnboardingFlow.startOnboardingFlow();
- }, variables.explanationModalDelay),
- }),
- });
-}
-
/**
* Check if report data are loaded
*/
@@ -144,6 +89,10 @@ function checkOnboardingDataReady() {
resolveOnboardingFlowStatus();
}
+function setOnboardingCustomChoices(value: OnboardingPurposeType[]) {
+ Onyx.set(ONYXKEYS.ONBOARDING_CUSTOM_CHOICES, value ?? []);
+}
+
function setOnboardingPurposeSelected(value: OnboardingPurposeType) {
Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null);
}
@@ -165,6 +114,10 @@ function updateOnboardingLastVisitedPath(path: string) {
}
function completeHybridAppOnboarding() {
+ if (!NativeModules.HybridAppModule) {
+ return;
+ }
+
const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -177,27 +130,15 @@ function completeHybridAppOnboarding() {
},
];
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.NVP_TRYNEWDOT,
- value: {
- classicRedirect: {
- completedHybridAppOnboarding: false,
- },
- },
- },
- ];
-
// eslint-disable-next-line rulesdir/no-api-side-effects-method
- API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.COMPLETE_HYBRID_APP_ONBOARDING, {}, {optimisticData, failureData}).then((response) => {
+ API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.COMPLETE_HYBRID_APP_ONBOARDING, {}, {optimisticData}).then((response) => {
if (!response) {
return;
}
- // if the call succeeded HybridApp onboarding is finished, otherwise it's not
- Log.info(`[HybridApp] Onboarding status has changed. Propagating new value to OldDot`, true, {completedHybridAppOnboarding: response?.jsonCode === CONST.JSON_CODE.SUCCESS});
- NativeModules.HybridAppModule.completeOnboarding(response?.jsonCode === CONST.JSON_CODE.SUCCESS);
+ // No matter what the response is, we want to mark the onboarding as completed (user saw the explanation modal)
+ Log.info(`[HybridApp] Onboarding status has changed. Propagating new value to OldDot`, true);
+ NativeModules.HybridAppModule.completeOnboarding(true);
});
}
@@ -241,12 +182,12 @@ function resetAllChecks() {
export {
onServerDataReady,
isOnboardingFlowCompleted,
+ setOnboardingCustomChoices,
setOnboardingPurposeSelected,
updateOnboardingLastVisitedPath,
resetAllChecks,
setOnboardingAdminsChatReportID,
setOnboardingPolicyID,
completeHybridAppOnboarding,
- handleHybridAppOnboarding,
setOnboardingErrorMessage,
};
diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts
index 5e4b0408f155..9731b62838f5 100644
--- a/src/libs/actions/Workflow.ts
+++ b/src/libs/actions/Workflow.ts
@@ -122,7 +122,7 @@ function updateApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork
}
const previousDefaultApprover = policy.approver ?? policy.owner;
- const newDefaultApprover = approvalWorkflow.isDefault ? approvalWorkflow.approvers[0].email : undefined;
+ const newDefaultApprover = approvalWorkflow.isDefault ? approvalWorkflow.approvers.at(0)?.email : undefined;
const previousEmployeeList = Object.fromEntries(Object.entries(policy.employeeList ?? {}).map(([key, value]) => [key, {...value, pendingAction: null}]));
const updatedEmployees = convertApprovalWorkflowToPolicyEmployees({
previousEmployeeList,
diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts
index 79875a161103..b93642a0fa5a 100644
--- a/src/libs/actions/connections/index.ts
+++ b/src/libs/actions/connections/index.ts
@@ -287,17 +287,6 @@ function updateManyPolicyConnectionConfigs, connectionName: PolicyConnectionName, isSyncInProgress: boolean): boolean {
- // NetSuite does not use the conventional lastSync object, so we need to check for lastErrorSyncDate
- if (connectionName === CONST.POLICY.CONNECTIONS.NAME.NETSUITE) {
- if (
- !isSyncInProgress &&
- (!!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.NETSUITE].lastErrorSyncDate || policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.NETSUITE]?.verified === false)
- ) {
- return true;
- }
- return false;
- }
-
const connection = policy?.connections?.[connectionName];
if (isSyncInProgress || isEmptyObject(connection?.lastSync) || connection?.lastSync?.isSuccessful !== false || !connection?.lastSync?.errorDate) {
diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts
index 05f29390ca14..9a54a3d6b14f 100644
--- a/src/libs/fileDownload/FileUtils.ts
+++ b/src/libs/fileDownload/FileUtils.ts
@@ -225,10 +225,10 @@ const readFileAsync: ReadFileAsync = (path, fileName, onSuccess, onFailure = ()
*/
function base64ToFile(base64: string, filename: string): File {
// Decode the base64 string
- const byteString = atob(base64.split(',')[1]);
+ const byteString = atob(base64.split(',').at(1) ?? '');
// Get the mime type from the base64 string
- const mimeString = base64.split(',')[0].split(':')[1].split(';')[0];
+ const mimeString = base64.split(',').at(0)?.split(':').at(1)?.split(';').at(0);
// Convert byte string to Uint8Array
const arrayBuffer = new ArrayBuffer(byteString.length);
@@ -255,8 +255,15 @@ function validateImageForCorruption(file: FileObject): Promise<{width: number; h
}
return new Promise((resolve, reject) => {
ImageSize.getSize(file.uri ?? '')
- .then(() => resolve())
- .catch(() => reject(new Error('Error reading file: The file is corrupted')));
+ .then((size) => {
+ if (size.height <= 0 || size.width <= 0) {
+ return reject(new Error('Error reading file: The file is corrupted'));
+ }
+ resolve();
+ })
+ .catch(() => {
+ return reject(new Error('Error reading file: The file is corrupted'));
+ });
});
}
diff --git a/src/libs/fileDownload/index.desktop.ts b/src/libs/fileDownload/index.desktop.ts
index 6a601a4af249..accd4905e53c 100644
--- a/src/libs/fileDownload/index.desktop.ts
+++ b/src/libs/fileDownload/index.desktop.ts
@@ -25,7 +25,7 @@ const fileDownload: FileDownload = (url, fileName, successMessage, shouldOpenExt
}, CONST.DOWNLOADS_TIMEOUT);
const handleDownloadStatus = (...args: unknown[]) => {
- const arg = Array.isArray(args) ? args[0] : null;
+ const arg = Array.isArray(args) ? args.at(0) : null;
const eventUrl = arg && typeof arg === 'object' && 'url' in arg ? arg.url : null;
if (eventUrl === url) {
diff --git a/src/libs/getIconForAction/index.ts b/src/libs/getIconForAction/index.ts
index ffe3dd8b76f2..18c8d7d8741e 100644
--- a/src/libs/getIconForAction/index.ts
+++ b/src/libs/getIconForAction/index.ts
@@ -12,6 +12,8 @@ const getIconForAction = (actionType: ValueOf) => {
return Expensicons.Cash;
case CONST.IOU.TYPE.SPLIT:
return Expensicons.Transfer;
+ case CONST.IOU.TYPE.CREATE:
+ return Expensicons.Receipt;
default:
return Expensicons.MoneyCircle;
}
diff --git a/src/libs/hasCompletedGuidedSetupFlowSelector.ts b/src/libs/hasCompletedGuidedSetupFlowSelector.ts
deleted file mode 100644
index 4934c83d7773..000000000000
--- a/src/libs/hasCompletedGuidedSetupFlowSelector.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import type {OnyxValue} from 'react-native-onyx';
-import type ONYXKEYS from '@src/ONYXKEYS';
-
-function hasCompletedGuidedSetupFlowSelector(onboarding: OnyxValue): boolean {
- // onboarding is an array for old accounts and accounts created from olddot
- if (Array.isArray(onboarding)) {
- return true;
- }
-
- return onboarding?.hasCompletedGuidedSetupFlow ?? true;
-}
-
-export default hasCompletedGuidedSetupFlowSelector;
diff --git a/src/libs/mapChildrenFlat.ts b/src/libs/mapChildrenFlat.ts
index 73009a3340d4..34527f4775c1 100644
--- a/src/libs/mapChildrenFlat.ts
+++ b/src/libs/mapChildrenFlat.ts
@@ -22,7 +22,7 @@ const mapChildrenFlat = (element: C, fn: (child: C, index: number) => T) =
const mappedChildren = React.Children.map(element, fn);
if (Array.isArray(mappedChildren) && mappedChildren.length === 1) {
- return mappedChildren[0];
+ return mappedChildren.at(0);
}
return mappedChildren;
diff --git a/src/libs/memoize/cache/ArrayCache.ts b/src/libs/memoize/cache/ArrayCache.ts
index 058efefdb1aa..209250e47675 100644
--- a/src/libs/memoize/cache/ArrayCache.ts
+++ b/src/libs/memoize/cache/ArrayCache.ts
@@ -16,7 +16,8 @@ function ArrayCache(config: CacheConfig): Cache {
*/
function getKeyIndex(key: K): number {
for (let i = cache.length - 1; i >= 0; i--) {
- if (keyComparator(cache[i][0], key)) {
+ const cacheItem = cache.at(i)?.at(0);
+ if (cacheItem && keyComparator(cacheItem as K, key)) {
return i;
}
}
diff --git a/src/libs/onboardingSelectors.ts b/src/libs/onboardingSelectors.ts
new file mode 100644
index 000000000000..efa67d2aed48
--- /dev/null
+++ b/src/libs/onboardingSelectors.ts
@@ -0,0 +1,38 @@
+import type {OnyxValue} from 'react-native-onyx';
+import type ONYXKEYS from '@src/ONYXKEYS';
+
+/**
+ * Selector to get the value of hasCompletedGuidedSetupFlow from the Onyx store
+ *
+ * `undefined` means the value is not loaded yet
+ * `true` means the user has completed the NewDot onboarding flow
+ * `false` means the user has not completed the NewDot onboarding flow
+ */
+function hasCompletedGuidedSetupFlowSelector(onboarding: OnyxValue): boolean | undefined {
+ // Onboarding is an array for old accounts and accounts created from OldDot
+ if (Array.isArray(onboarding)) {
+ return true;
+ }
+
+ return onboarding?.hasCompletedGuidedSetupFlow;
+}
+
+/**
+ * Selector to get the value of completedHybridAppOnboarding from the Onyx store
+ *
+ * `undefined` means the value is not loaded yet
+ * `true` means the user has completed the hybrid app onboarding flow
+ * `false` means the user has not completed the hybrid app onboarding flow
+ */
+function hasCompletedHybridAppOnboardingFlowSelector(tryNewDotData: OnyxValue): boolean | undefined {
+ let completedHybridAppOnboarding = tryNewDotData?.classicRedirect?.completedHybridAppOnboarding;
+
+ // Backend might return strings instead of booleans
+ if (typeof completedHybridAppOnboarding === 'string') {
+ completedHybridAppOnboarding = completedHybridAppOnboarding === 'true';
+ }
+
+ return completedHybridAppOnboarding;
+}
+
+export {hasCompletedGuidedSetupFlowSelector, hasCompletedHybridAppOnboardingFlowSelector};
diff --git a/src/libs/shouldRenderTransferOwnerButton/index.native.ts b/src/libs/shouldRenderTransferOwnerButton/index.native.ts
new file mode 100644
index 000000000000..5a1d46947f0e
--- /dev/null
+++ b/src/libs/shouldRenderTransferOwnerButton/index.native.ts
@@ -0,0 +1,24 @@
+import isEmpty from 'lodash/isEmpty';
+import Onyx from 'react-native-onyx';
+import type {OnyxEntry} from 'react-native-onyx';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {FundList} from '@src/types/onyx';
+import type ShouldRenderTransferOwnerButton from './types';
+
+let fundList: OnyxEntry;
+Onyx.connect({
+ key: ONYXKEYS.FUND_LIST,
+ callback: (value) => {
+ if (!value) {
+ return;
+ }
+
+ fundList = value;
+ },
+});
+
+const shouldRenderTransferOwnerButton: ShouldRenderTransferOwnerButton = () => {
+ return !isEmpty(fundList);
+};
+
+export default shouldRenderTransferOwnerButton;
diff --git a/src/libs/shouldRenderTransferOwnerButton/index.ts b/src/libs/shouldRenderTransferOwnerButton/index.ts
new file mode 100644
index 000000000000..12feef5c32b6
--- /dev/null
+++ b/src/libs/shouldRenderTransferOwnerButton/index.ts
@@ -0,0 +1,5 @@
+import type ShouldRenderTransferOwnerButton from './types';
+
+const shouldRenderTransferOwnerButton: ShouldRenderTransferOwnerButton = () => true;
+
+export default shouldRenderTransferOwnerButton;
diff --git a/src/libs/shouldRenderTransferOwnerButton/types.ts b/src/libs/shouldRenderTransferOwnerButton/types.ts
new file mode 100644
index 000000000000..22ae5cecdf32
--- /dev/null
+++ b/src/libs/shouldRenderTransferOwnerButton/types.ts
@@ -0,0 +1,3 @@
+type ShouldRenderTransferOwnerButton = () => boolean;
+
+export default ShouldRenderTransferOwnerButton;
diff --git a/src/pages/AddPersonalBankAccountPage.tsx b/src/pages/AddPersonalBankAccountPage.tsx
index 04cce885bd07..fdc200f45ad9 100644
--- a/src/pages/AddPersonalBankAccountPage.tsx
+++ b/src/pages/AddPersonalBankAccountPage.tsx
@@ -1,6 +1,5 @@
import React, {useCallback, useEffect, useState} from 'react';
-import {useOnyx, withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import AddPlaidBankAccount from '@components/AddPlaidBankAccount';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import ConfirmationPage from '@components/ConfirmationPage';
@@ -15,22 +14,16 @@ import Navigation from '@libs/Navigation/Navigation';
import * as BankAccounts from '@userActions/BankAccounts';
import * as PaymentMethods from '@userActions/PaymentMethods';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
-import type {PersonalBankAccount, PlaidData} from '@src/types/onyx';
-type AddPersonalBankAccountPageWithOnyxProps = {
- /** Contains plaid data */
- plaidData: OnyxEntry;
-
- /** The details about the Personal bank account we are adding saved in Onyx */
- personalBankAccount: OnyxEntry;
-};
-
-function AddPersonalBankAccountPage({personalBankAccount, plaidData}: AddPersonalBankAccountPageWithOnyxProps) {
+function AddPersonalBankAccountPage() {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [selectedPlaidAccountId, setSelectedPlaidAccountId] = useState('');
const [isUserValidated] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.validated});
+ const [personalBankAccount] = useOnyx(ONYXKEYS.PERSONAL_BANK_ACCOUNT);
+ const [plaidData] = useOnyx(ONYXKEYS.PLAID_DATA);
const shouldShowSuccess = personalBankAccount?.shouldShowSuccess ?? false;
const submitBankAccountForm = useCallback(() => {
@@ -52,7 +45,7 @@ function AddPersonalBankAccountPage({personalBankAccount, plaidData}: AddPersona
} else if (shouldContinue && onSuccessFallbackRoute) {
PaymentMethods.continueSetup(onSuccessFallbackRoute);
} else {
- Navigation.goBack();
+ Navigation.navigate(ROUTES.SETTINGS_WALLET);
}
},
[personalBankAccount],
@@ -97,7 +90,7 @@ function AddPersonalBankAccountPage({personalBankAccount, plaidData}: AddPersona
text={translate('walletPage.chooseAccountBody')}
plaidData={plaidData}
isDisplayedInWalletFlow
- onExitPlaid={() => Navigation.goBack()}
+ onExitPlaid={() => Navigation.navigate(ROUTES.SETTINGS_WALLET)}
receivedRedirectURI={getPlaidOAuthReceivedRedirectURI()}
selectedPlaidAccountID={selectedPlaidAccountId}
/>
@@ -109,12 +102,4 @@ function AddPersonalBankAccountPage({personalBankAccount, plaidData}: AddPersona
}
AddPersonalBankAccountPage.displayName = 'AddPersonalBankAccountPage';
-export default withOnyx({
- // @ts-expect-error: ONYXKEYS.PERSONAL_BANK_ACCOUNT is conflicting with ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM
- personalBankAccount: {
- key: ONYXKEYS.PERSONAL_BANK_ACCOUNT,
- },
- plaidData: {
- key: ONYXKEYS.PLAID_DATA,
- },
-})(AddPersonalBankAccountPage);
+export default AddPersonalBankAccountPage;
diff --git a/src/pages/Debug/DebugDetails.tsx b/src/pages/Debug/DebugDetails.tsx
index 28bcb4e0df8b..6ccafda974f4 100644
--- a/src/pages/Debug/DebugDetails.tsx
+++ b/src/pages/Debug/DebugDetails.tsx
@@ -74,7 +74,7 @@ function DebugDetails({data, onSave, onDelete, validate}: DebugDetailsProps) {
)
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
.map(([key, value]) => [key, DebugUtils.onyxDataToString(value)])
- .sort((a, b) => a[0].localeCompare(b[0])),
+ .sort((a, b) => (a.at(0) ?? '').localeCompare(b.at(0) ?? '')),
[data],
);
const dateTimeFields = useMemo(() => Object.entries(data ?? {}).filter(([key]) => DETAILS_DATETIME_FIELDS.includes(key as DetailsDatetimeFieldsKeys)) as Array<[string, string]>, [data]);
@@ -158,6 +158,7 @@ function DebugDetails({data, onSave, onDelete, validate}: DebugDetailsProps) {
multiline={numberOfLines > 1}
defaultValue={value}
disabled={DETAILS_DISABLED_KEYS.includes(key as DetailsDisabledKeys)}
+ shouldInterceptSwipe
/>
);
})}
@@ -175,6 +176,7 @@ function DebugDetails({data, onSave, onDelete, validate}: DebugDetailsProps) {
label={key}
defaultValue={String(value)}
disabled={DETAILS_DISABLED_KEYS.includes(key as DetailsDisabledKeys)}
+ shouldInterceptSwipe
/>
))}
{numberFields.length === 0 && None}
diff --git a/src/pages/Debug/DebugDetailsConstantPickerPage.tsx b/src/pages/Debug/DebugDetailsConstantPickerPage.tsx
index 7dff4f2fe2ff..a98ef9963542 100644
--- a/src/pages/Debug/DebugDetailsConstantPickerPage.tsx
+++ b/src/pages/Debug/DebugDetailsConstantPickerPage.tsx
@@ -64,7 +64,7 @@ function DebugDetailsConstantPickerPage({
Navigation.navigate(appendParam(backTo, fieldName, value));
}
};
- const selectedOptionKey = useMemo(() => sections.filter((option) => option.searchText === fieldValue)[0]?.keyForList, [sections, fieldValue]);
+ const selectedOptionKey = useMemo(() => sections.filter((option) => option.searchText === fieldValue).at(0)?.keyForList, [sections, fieldValue]);
return (
diff --git a/src/pages/Debug/DebugJSON.tsx b/src/pages/Debug/DebugJSON.tsx
index 14e1da9e029c..6da1296464eb 100644
--- a/src/pages/Debug/DebugJSON.tsx
+++ b/src/pages/Debug/DebugJSON.tsx
@@ -1,7 +1,9 @@
import React, {useMemo} from 'react';
+import {View} from 'react-native';
import Button from '@components/Button';
import * as Expensicons from '@components/Icon/Expensicons';
import ScrollView from '@components/ScrollView';
+import SwipeInterceptPanResponder from '@components/SwipeInterceptPanResponder';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -35,7 +37,12 @@ function DebugJSON({data}: DebugJSONProps) {
}}
icon={Expensicons.Copy}
/>
- {json}
+
+ {json}
+
);
}
diff --git a/src/pages/EditReportFieldDropdown.tsx b/src/pages/EditReportFieldDropdown.tsx
index ba6828eca782..a46a6db6f8a8 100644
--- a/src/pages/EditReportFieldDropdown.tsx
+++ b/src/pages/EditReportFieldDropdown.tsx
@@ -102,7 +102,7 @@ function EditReportFieldDropdownPage({onSubmit, fieldKey, fieldValue, fieldOptio
return [policyReportFieldOptions, header];
}, [recentlyUsedOptions, debouncedSearchValue, fieldValue, fieldOptions]);
- const selectedOptionKey = useMemo(() => (sections?.[0]?.data ?? []).filter((option) => option.searchText === fieldValue)?.[0]?.keyForList, [sections, fieldValue]);
+ const selectedOptionKey = useMemo(() => (sections?.[0]?.data ?? []).filter((option) => option.searchText === fieldValue)?.at(0)?.keyForList, [sections, fieldValue]);
return (
;
+type EditReportFieldPageProps = StackScreenProps;
- /** Policy to which the report belongs to */
- policy: OnyxEntry;
-};
-
-type EditReportFieldPageProps = EditReportFieldPageOnyxProps & StackScreenProps;
-
-function EditReportFieldPage({route, policy, report}: EditReportFieldPageProps) {
+function EditReportFieldPage({route}: EditReportFieldPageProps) {
const {windowWidth} = useWindowDimensions();
const styles = useThemeStyles();
- const backTo = route.params.backTo;
+ const {backTo, reportID, policyID} = route.params;
const fieldKey = ReportUtils.getReportFieldKey(route.params.fieldID);
+ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
const reportField = report?.fieldList?.[fieldKey] ?? policy?.fieldList?.[fieldKey];
const policyField = policy?.fieldList?.[fieldKey] ?? reportField;
const isDisabled = ReportUtils.isReportFieldDisabled(report, reportField, policy);
@@ -90,7 +83,7 @@ function EditReportFieldPage({route, policy, report}: EditReportFieldPageProps)
const menuItems: PopoverMenuItem[] = [];
- const isReportFieldDeletable = reportField.deletable && !isReportFieldTitle;
+ const isReportFieldDeletable = reportField.deletable && reportField?.fieldID !== CONST.REPORT_FIELD_TITLE_FIELD_ID;
if (isReportFieldDeletable) {
menuItems.push({icon: Expensicons.Trashcan, text: translate('common.delete'), onSelected: () => setIsDeleteModalVisible(true), shouldCallAfterModalHide: true});
@@ -149,7 +142,7 @@ function EditReportFieldPage({route, policy, report}: EditReportFieldPageProps)
policyID={report.policyID ?? '-1'}
fieldKey={fieldKey}
fieldValue={fieldValue}
- fieldOptions={policyField.values.filter((_value: string, index: number) => !policyField.disabledOptions[index])}
+ fieldOptions={policyField.values.filter((_value: string, index: number) => !policyField.disabledOptions.at(index))}
onSubmit={handleReportFieldChange}
/>
)}
@@ -159,11 +152,4 @@ function EditReportFieldPage({route, policy, report}: EditReportFieldPageProps)
EditReportFieldPage.displayName = 'EditReportFieldPage';
-export default withOnyx({
- report: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`,
- },
- policy: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`,
- },
-})(EditReportFieldPage);
+export default EditReportFieldPage;
diff --git a/src/pages/EnablePayments/AddBankAccount/SetupMethod.tsx b/src/pages/EnablePayments/AddBankAccount/SetupMethod.tsx
index ed06d1aa1ed6..e8ee6df7b63d 100644
--- a/src/pages/EnablePayments/AddBankAccount/SetupMethod.tsx
+++ b/src/pages/EnablePayments/AddBankAccount/SetupMethod.tsx
@@ -1,7 +1,6 @@
import React from 'react';
import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
@@ -15,23 +14,13 @@ import * as BankAccounts from '@userActions/BankAccounts';
import * as Link from '@userActions/Link';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {User} from '@src/types/onyx';
-
-type SetupMethodOnyxProps = {
- /** The user's data */
- user: OnyxEntry;
-
- /** Whether Plaid is disabled */
- isPlaidDisabled: OnyxEntry;
-};
-
-type SetupMethodProps = SetupMethodOnyxProps;
const plaidDesktopMessage = getPlaidDesktopMessage();
-function SetupMethod({isPlaidDisabled, user}: SetupMethodProps) {
+function SetupMethod() {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const [isPlaidDisabled] = useOnyx(ONYXKEYS.IS_PLAID_DISABLED);
return (
@@ -51,8 +40,10 @@ function SetupMethod({isPlaidDisabled, user}: SetupMethodProps) {
BankAccounts.openPersonalBankAccountSetupWithPlaid()}
- isDisabled={!!isPlaidDisabled || !user?.validated}
+ onPress={() => {
+ BankAccounts.openPersonalBankAccountSetupWithPlaid();
+ }}
+ isDisabled={!!isPlaidDisabled}
style={[styles.mt4, styles.mb2]}
iconStyles={styles.buttonCTAIcon}
shouldShowRightIcon
@@ -66,11 +57,4 @@ function SetupMethod({isPlaidDisabled, user}: SetupMethodProps) {
SetupMethod.displayName = 'SetupMethod';
-export default withOnyx({
- isPlaidDisabled: {
- key: ONYXKEYS.IS_PLAID_DISABLED,
- },
- user: {
- key: ONYXKEYS.USER,
- },
-})(SetupMethod);
+export default SetupMethod;
diff --git a/src/pages/EnablePayments/IdologyQuestions.tsx b/src/pages/EnablePayments/IdologyQuestions.tsx
index b9b0ac4eca34..602754bef556 100644
--- a/src/pages/EnablePayments/IdologyQuestions.tsx
+++ b/src/pages/EnablePayments/IdologyQuestions.tsx
@@ -44,7 +44,7 @@ function IdologyQuestions({questions, idNumber}: IdologyQuestionsProps) {
const [shouldHideSkipAnswer, setShouldHideSkipAnswer] = useState(false);
const [userAnswers, setUserAnswers] = useState([]);
- const currentQuestion = questions[currentQuestionIndex] || {};
+ const currentQuestion = questions.at(currentQuestionIndex) ?? ({} as WalletAdditionalQuestionDetails);
const possibleAnswers: Choice[] = currentQuestion.answer
.map((answer) => {
if (shouldHideSkipAnswer && answer === SKIP_QUESTION_TEXT) {
@@ -70,7 +70,7 @@ function IdologyQuestions({questions, idNumber}: IdologyQuestionsProps) {
* Show next question or send all answers for Idology verifications when we've answered enough
*/
const submitAnswers = () => {
- if (!userAnswers[currentQuestionIndex]) {
+ if (!userAnswers.at(currentQuestionIndex)) {
return;
}
// Get the number of questions that were skipped by the user.
@@ -83,7 +83,7 @@ function IdologyQuestions({questions, idNumber}: IdologyQuestionsProps) {
// Auto skip any remaining questions
if (tempAnswers.length < questions.length) {
for (let i = tempAnswers.length; i < questions.length; i++) {
- tempAnswers[i] = {question: questions[i].type, answer: SKIP_QUESTION_TEXT};
+ tempAnswers[i] = {question: questions.at(i)?.type ?? '', answer: SKIP_QUESTION_TEXT};
}
}
@@ -123,7 +123,7 @@ function IdologyQuestions({questions, idNumber}: IdologyQuestionsProps) {
{
diff --git a/src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx b/src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx
new file mode 100644
index 000000000000..79fd039a5d2a
--- /dev/null
+++ b/src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx
@@ -0,0 +1,120 @@
+import React, {useCallback, useMemo, useRef} from 'react';
+import type {ForwardedRef} from 'react';
+import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
+import type {InteractiveStepSubHeaderHandle} from '@components/InteractiveStepSubHeader';
+import ScreenWrapper from '@components/ScreenWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useSubStep from '@hooks/useSubStep';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as FormActions from '@libs/actions/FormActions';
+import Navigation from '@libs/Navigation/Navigation';
+import * as PersonalDetails from '@userActions/PersonalDetails';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {PersonalDetailsForm} from '@src/types/form';
+import type {CardList, PrivatePersonalDetails} from '@src/types/onyx';
+import Address from './substeps/Address';
+import Confirmation from './substeps/Confirmation';
+import DateOfBirth from './substeps/DateOfBirth';
+import LegalName from './substeps/LegalName';
+import PhoneNumber from './substeps/PhoneNumber';
+import type {CustomSubStepProps} from './types';
+import {getInitialSubstep, getSubstepValues} from './utils';
+
+type MissingPersonalDetailsContentProps = {
+ privatePersonalDetails: OnyxEntry;
+ cardList: OnyxEntry;
+ draftValues: OnyxEntry;
+};
+
+const formSteps = [LegalName, DateOfBirth, Address, PhoneNumber, Confirmation];
+
+function MissingPersonalDetailsContent({privatePersonalDetails, cardList, draftValues}: MissingPersonalDetailsContentProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const ref: ForwardedRef = useRef(null);
+
+ const values = useMemo(() => getSubstepValues(privatePersonalDetails, draftValues), [privatePersonalDetails, draftValues]);
+
+ const startFrom = useMemo(() => getInitialSubstep(values), [values]);
+
+ const firstUnissuedCard = useMemo(() => Object.values(cardList ?? {}).find((card) => card.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED), [cardList]);
+
+ const handleFinishStep = useCallback(() => {
+ if (!values) {
+ return;
+ }
+ PersonalDetails.updatePersonalDetailsAndShipExpensifyCard(values, firstUnissuedCard?.cardID ?? 0);
+ FormActions.clearDraftValues(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM);
+ Navigation.goBack();
+ }, [firstUnissuedCard?.cardID, values]);
+
+ const {
+ componentToRender: SubStep,
+ isEditing,
+ nextScreen,
+ prevScreen,
+ screenIndex,
+ moveTo,
+ goToTheLastStep,
+ } = useSubStep({bodyContent: formSteps, startFrom, onFinished: handleFinishStep});
+
+ const handleBackButtonPress = () => {
+ if (isEditing) {
+ goToTheLastStep();
+ return;
+ }
+
+ // Clicking back on the first screen should dismiss the modal
+ if (screenIndex === CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING.LEGAL_NAME) {
+ FormActions.clearDraftValues(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM);
+ Navigation.goBack();
+ return;
+ }
+ ref.current?.movePrevious();
+ prevScreen();
+ };
+
+ const handleNextScreen = useCallback(() => {
+ if (isEditing) {
+ goToTheLastStep();
+ return;
+ }
+ ref.current?.moveNext();
+ nextScreen();
+ }, [goToTheLastStep, isEditing, nextScreen]);
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+MissingPersonalDetailsContent.displayName = 'MissingPersonalDetailsContent';
+
+export default MissingPersonalDetailsContent;
diff --git a/src/pages/MissingPersonalDetails/index.tsx b/src/pages/MissingPersonalDetails/index.tsx
index 5220f25be981..5c4f94b09b28 100644
--- a/src/pages/MissingPersonalDetails/index.tsx
+++ b/src/pages/MissingPersonalDetails/index.tsx
@@ -1,211 +1,27 @@
-/* eslint-disable no-case-declarations */
-import {Str} from 'expensify-common';
-import React, {useCallback, useMemo, useRef} from 'react';
-import type {ForwardedRef} from 'react';
-import {View} from 'react-native';
+import React from 'react';
import {useOnyx} from 'react-native-onyx';
-import FormProvider from '@components/Form/FormProvider';
-import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
-import type {InteractiveStepSubHeaderHandle} from '@components/InteractiveStepSubHeader';
-import ScreenWrapper from '@components/ScreenWrapper';
-import useLocalize from '@hooks/useLocalize';
-import useSubStep from '@hooks/useSubStep';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as ErrorUtils from '@libs/ErrorUtils';
-import * as LoginUtils from '@libs/LoginUtils';
-import Navigation from '@libs/Navigation/Navigation';
-import * as PhoneNumberUtils from '@libs/PhoneNumber';
-import * as ValidationUtils from '@libs/ValidationUtils';
-import * as PersonalDetails from '@userActions/PersonalDetails';
-import CONST from '@src/CONST';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import ONYXKEYS from '@src/ONYXKEYS';
-import INPUT_IDS from '@src/types/form/PersonalDetailsForm';
-import Address from './substeps/Address';
-import DateOfBirth from './substeps/DateOfBirth';
-import LegalName from './substeps/LegalName';
-import PhoneNumber from './substeps/PhoneNumber';
-import type {CountryZipRegex, CustomSubStepProps} from './types';
-
-const formSteps = [LegalName, DateOfBirth, Address, PhoneNumber];
+import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
+import MissingPersonalDetailsContent from './MissingPersonalDetailsContent';
function MissingPersonalDetails() {
- const styles = useThemeStyles();
- const {translate} = useLocalize();
- const ref: ForwardedRef = useRef(null);
- const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS);
- const [cardList] = useOnyx(ONYXKEYS.CARD_LIST);
-
- const firstUnissuedCard = useMemo(() => Object.values(cardList ?? {}).find((card) => card.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED), [cardList]);
-
- const handleFinishStep = useCallback(() => {
- Navigation.goBack();
- }, []);
-
- const {
- componentToRender: SubStep,
- isEditing,
- nextScreen,
- prevScreen,
- screenIndex,
- moveTo,
- goToTheLastStep,
- } = useSubStep({bodyContent: formSteps, startFrom: CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING.LEGAL_NAME, onFinished: handleFinishStep});
-
- const handleBackButtonPress = () => {
- if (isEditing) {
- goToTheLastStep();
- return;
- }
-
- // Clicking back on the first screen should dismiss the modal
- if (screenIndex === CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING.LEGAL_NAME) {
- Navigation.goBack();
- return;
- }
- ref.current?.movePrevious();
- prevScreen();
- };
+ const [privatePersonalDetails, privatePersonalDetailsMetadata] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS);
+ const [cardList, cardListMetadata] = useOnyx(ONYXKEYS.CARD_LIST);
+ const [draftValues, draftValuesMetadata] = useOnyx(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM_DRAFT);
- const handleNextScreen = useCallback(() => {
- if (isEditing) {
- goToTheLastStep();
- return;
- }
- ref.current?.moveNext();
- nextScreen();
- }, [goToTheLastStep, isEditing, nextScreen]);
+ const isLoading = isLoadingOnyxValue(privatePersonalDetailsMetadata, cardListMetadata, draftValuesMetadata);
- const validate = useCallback(
- (values: FormOnyxValues): FormInputErrors => {
- const errors: FormInputErrors = {};
- switch (screenIndex) {
- case CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING.LEGAL_NAME:
- if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.LEGAL_FIRST_NAME])) {
- errors[INPUT_IDS.LEGAL_FIRST_NAME] = translate('common.error.fieldRequired');
- } else if (!ValidationUtils.isValidLegalName(values[INPUT_IDS.LEGAL_FIRST_NAME])) {
- errors[INPUT_IDS.LEGAL_FIRST_NAME] = translate('privatePersonalDetails.error.hasInvalidCharacter');
- } else if (values[INPUT_IDS.LEGAL_FIRST_NAME].length > CONST.LEGAL_NAME.MAX_LENGTH) {
- errors[INPUT_IDS.LEGAL_FIRST_NAME] = translate('common.error.characterLimitExceedCounter', {
- length: values[INPUT_IDS.LEGAL_FIRST_NAME].length,
- limit: CONST.LEGAL_NAME.MAX_LENGTH,
- });
- }
- if (ValidationUtils.doesContainReservedWord(values[INPUT_IDS.LEGAL_FIRST_NAME], CONST.DISPLAY_NAME.RESERVED_NAMES)) {
- ErrorUtils.addErrorMessage(errors, INPUT_IDS.LEGAL_FIRST_NAME, translate('personalDetails.error.containsReservedWord'));
- }
- if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.LEGAL_LAST_NAME])) {
- errors[INPUT_IDS.LEGAL_LAST_NAME] = translate('common.error.fieldRequired');
- } else if (!ValidationUtils.isValidLegalName(values[INPUT_IDS.LEGAL_LAST_NAME])) {
- errors[INPUT_IDS.LEGAL_LAST_NAME] = translate('privatePersonalDetails.error.hasInvalidCharacter');
- } else if (values[INPUT_IDS.LEGAL_LAST_NAME].length > CONST.LEGAL_NAME.MAX_LENGTH) {
- errors[INPUT_IDS.LEGAL_LAST_NAME] = translate('common.error.characterLimitExceedCounter', {
- length: values[INPUT_IDS.LEGAL_LAST_NAME].length,
- limit: CONST.LEGAL_NAME.MAX_LENGTH,
- });
- }
- if (ValidationUtils.doesContainReservedWord(values[INPUT_IDS.LEGAL_LAST_NAME], CONST.DISPLAY_NAME.RESERVED_NAMES)) {
- ErrorUtils.addErrorMessage(errors, INPUT_IDS.LEGAL_LAST_NAME, translate('personalDetails.error.containsReservedWord'));
- }
- return errors;
- case CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING.DATE_OF_BIRTH:
- if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.DATE_OF_BIRTH])) {
- errors[INPUT_IDS.DATE_OF_BIRTH] = translate('common.error.fieldRequired');
- } else if (!ValidationUtils.isValidPastDate(values[INPUT_IDS.DATE_OF_BIRTH]) || !ValidationUtils.meetsMaximumAgeRequirement(values[INPUT_IDS.DATE_OF_BIRTH])) {
- errors[INPUT_IDS.DATE_OF_BIRTH] = translate('bankAccount.error.dob');
- }
- return errors;
- case CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING.ADDRESS:
- const addressRequiredFields = [INPUT_IDS.ADDRESS_LINE_1, INPUT_IDS.CITY, INPUT_IDS.COUNTRY, INPUT_IDS.STATE] as const;
- addressRequiredFields.forEach((fieldKey) => {
- const fieldValue = values[fieldKey] ?? '';
- if (ValidationUtils.isRequiredFulfilled(fieldValue)) {
- return;
- }
- errors[fieldKey] = translate('common.error.fieldRequired');
- });
-
- // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object
- const countryRegexDetails = (values.country ? CONST.COUNTRY_ZIP_REGEX_DATA?.[values.country] : {}) as CountryZipRegex;
-
- // The postal code system might not exist for a country, so no regex either for them.
- const countrySpecificZipRegex = countryRegexDetails?.regex;
- const countryZipFormat = countryRegexDetails?.samples ?? '';
- if (countrySpecificZipRegex) {
- if (!countrySpecificZipRegex.test(values[INPUT_IDS.ZIP_POST_CODE]?.trim().toUpperCase())) {
- if (ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.ZIP_POST_CODE]?.trim())) {
- errors[INPUT_IDS.ZIP_POST_CODE] = translate('privatePersonalDetails.error.incorrectZipFormat', {zipFormat: countryZipFormat});
- } else {
- errors[INPUT_IDS.ZIP_POST_CODE] = translate('common.error.fieldRequired');
- }
- }
- } else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(values[INPUT_IDS.ZIP_POST_CODE]?.trim()?.toUpperCase() ?? '')) {
- errors[INPUT_IDS.ZIP_POST_CODE] = translate('privatePersonalDetails.error.incorrectZipFormat');
- }
- return errors;
- case CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING.PHONE_NUMBER:
- if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.PHONE_NUMBER])) {
- errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.fieldRequired');
- }
- const phoneNumber = LoginUtils.appendCountryCode(values[INPUT_IDS.PHONE_NUMBER]);
- const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(phoneNumber);
- if (!parsedPhoneNumber.possible || !Str.isValidE164Phone(phoneNumber.slice(0))) {
- errors[INPUT_IDS.PHONE_NUMBER] = translate('bankAccount.error.phoneNumber');
- }
- return errors;
- default:
- return errors;
- }
- },
- [screenIndex, translate],
- );
-
- const updatePersonalDetails = useCallback(
- (formValues: FormOnyxValues) => {
- PersonalDetails.updatePersonalDetailsAndShipExpensifyCard(formValues, firstUnissuedCard?.cardID ?? 0);
- nextScreen();
- },
- [nextScreen, firstUnissuedCard],
- );
+ if (isLoading) {
+ return ;
+ }
return (
-
-
-
-
-
-
-
-
-
-
-
+
);
}
diff --git a/src/pages/MissingPersonalDetails/substeps/Address.tsx b/src/pages/MissingPersonalDetails/substeps/Address.tsx
index 384a2648b307..c61dc8b2dcbe 100644
--- a/src/pages/MissingPersonalDetails/substeps/Address.tsx
+++ b/src/pages/MissingPersonalDetails/substeps/Address.tsx
@@ -1,43 +1,41 @@
-import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import React, {useCallback, useState} from 'react';
import {View} from 'react-native';
import AddressSearch from '@components/AddressSearch';
import CountryPicker from '@components/CountryPicker';
+import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
import StatePicker from '@components/StatePicker';
import type {State} from '@components/StateSelector';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
import useLocalize from '@hooks/useLocalize';
+import usePersonalDetailsFormSubmit from '@hooks/usePersonalDetailsFormSubmit';
import useThemeStyles from '@hooks/useThemeStyles';
-import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import * as ValidationUtils from '@libs/ValidationUtils';
import type {CountryZipRegex, CustomSubStepProps} from '@pages/MissingPersonalDetails/types';
import CONST from '@src/CONST';
import type {Country} from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/PersonalDetailsForm';
import type {Address} from '@src/types/onyx/PrivatePersonalDetails';
-function AddressStep({privatePersonalDetails}: CustomSubStepProps) {
+const STEP_FIELDS = [INPUT_IDS.ADDRESS_LINE_1, INPUT_IDS.ADDRESS_LINE_2, INPUT_IDS.CITY, INPUT_IDS.STATE, INPUT_IDS.ZIP_POST_CODE, INPUT_IDS.COUNTRY];
+
+function AddressStep({isEditing, onNext, personalDetailsValues}: CustomSubStepProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
- const address = useMemo(() => PersonalDetailsUtils.getCurrentAddress(privatePersonalDetails), [privatePersonalDetails]);
- const {street} = address ?? {};
- const [street1, street2] = street ? street.split('\n') : [undefined, undefined];
- const [currentCountry, setCurrentCountry] = useState(address?.country);
- const [state, setState] = useState(address?.state);
- const [city, setCity] = useState(address?.city);
- const [zipcode, setZipcode] = useState(address?.zip);
+ const [currentCountry, setCurrentCountry] = useState(personalDetailsValues[INPUT_IDS.COUNTRY]);
+ const [state, setState] = useState(personalDetailsValues[INPUT_IDS.STATE]);
+ const [city, setCity] = useState(personalDetailsValues[INPUT_IDS.CITY]);
+ const [zipcode, setZipcode] = useState(personalDetailsValues[INPUT_IDS.ZIP_POST_CODE]);
- useEffect(() => {
- if (!address) {
- return;
- }
- setState(address.state);
- setCurrentCountry(address.country);
- setCity(address.city);
- setZipcode(address.zip);
- // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- }, [address?.state, address?.country, address?.city, address?.zip]);
+ const handleSubmit = usePersonalDetailsFormSubmit({
+ fieldIds: STEP_FIELDS,
+ onNext,
+ shouldSaveDraft: true,
+ });
const handleAddressChange = useCallback((value: unknown, key: unknown) => {
const addressPart = value as string;
@@ -73,98 +71,141 @@ function AddressStep({privatePersonalDetails}: CustomSubStepProps) {
const zipFormat = translate('common.zipCodeExampleFormat', {zipSampleFormat});
+ const validate = useCallback(
+ (values: FormOnyxValues): FormInputErrors => {
+ const errors: FormInputErrors = {};
+ const addressRequiredFields = [INPUT_IDS.ADDRESS_LINE_1, INPUT_IDS.CITY, INPUT_IDS.COUNTRY, INPUT_IDS.STATE] as const;
+ addressRequiredFields.forEach((fieldKey) => {
+ const fieldValue = values[fieldKey] ?? '';
+ if (ValidationUtils.isRequiredFulfilled(fieldValue)) {
+ return;
+ }
+ errors[fieldKey] = translate('common.error.fieldRequired');
+ });
+
+ // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object
+ const countryRegexDetails = (values.country ? CONST.COUNTRY_ZIP_REGEX_DATA?.[values.country] : {}) as CountryZipRegex;
+
+ // The postal code system might not exist for a country, so no regex either for them.
+ const countrySpecificZipRegex = countryRegexDetails?.regex;
+ const countryZipFormat = countryRegexDetails?.samples ?? '';
+ if (countrySpecificZipRegex) {
+ if (!countrySpecificZipRegex.test(values[INPUT_IDS.ZIP_POST_CODE]?.trim().toUpperCase())) {
+ if (ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.ZIP_POST_CODE]?.trim())) {
+ errors[INPUT_IDS.ZIP_POST_CODE] = translate('privatePersonalDetails.error.incorrectZipFormat', {zipFormat: countryZipFormat});
+ } else {
+ errors[INPUT_IDS.ZIP_POST_CODE] = translate('common.error.fieldRequired');
+ }
+ }
+ } else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(values[INPUT_IDS.ZIP_POST_CODE]?.trim()?.toUpperCase() ?? '')) {
+ errors[INPUT_IDS.ZIP_POST_CODE] = translate('privatePersonalDetails.error.incorrectZipFormat');
+ }
+ return errors;
+ },
+ [translate],
+ );
+
return (
- <>
- {translate('privatePersonalDetails.enterAddress')}
-
- {
- handleAddressChange(data, key);
- }}
- defaultValue={street1}
- containerStyles={styles.mt3}
- renamedInputKeys={{
- street: INPUT_IDS.ADDRESS_LINE_1,
- street2: INPUT_IDS.ADDRESS_LINE_2,
- city: INPUT_IDS.CITY,
- state: INPUT_IDS.STATE,
- zipCode: INPUT_IDS.ZIP_POST_CODE,
- country: INPUT_IDS.COUNTRY as Country,
- }}
- maxInputLength={CONST.FORM_CHARACTER_LIMIT}
- />
-
-
-
+
+
+ {translate('privatePersonalDetails.enterAddress')}
+
+ {
+ handleAddressChange(data, key);
+ }}
+ defaultValue={personalDetailsValues[INPUT_IDS.ADDRESS_LINE_1]}
+ renamedInputKeys={{
+ street: INPUT_IDS.ADDRESS_LINE_1,
+ street2: INPUT_IDS.ADDRESS_LINE_2,
+ city: INPUT_IDS.CITY,
+ state: INPUT_IDS.STATE,
+ zipCode: INPUT_IDS.ZIP_POST_CODE,
+ country: INPUT_IDS.COUNTRY as Country,
+ }}
+ maxInputLength={CONST.FORM_CHARACTER_LIMIT}
+ />
+
-
- {isUSAForm ? (
-
+
- ) : (
+ {isUSAForm ? (
+
+
+
+ ) : (
+
+ )}
- )}
-
-
- >
+
+
+
);
}
diff --git a/src/pages/MissingPersonalDetails/substeps/Confirmation.tsx b/src/pages/MissingPersonalDetails/substeps/Confirmation.tsx
new file mode 100644
index 000000000000..b5c27912cd6a
--- /dev/null
+++ b/src/pages/MissingPersonalDetails/substeps/Confirmation.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import SafeAreaConsumer from '@components/SafeAreaConsumer';
+import ScrollView from '@components/ScrollView';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type {CustomSubStepProps} from '@pages/MissingPersonalDetails/types';
+import CONST from '@src/CONST';
+import INPUT_IDS from '@src/types/form/PersonalDetailsForm';
+
+const PERSONAL_DETAILS_STEP_INDEXES = CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING;
+
+function ConfirmationStep({personalDetailsValues: values, onNext, onMove}: CustomSubStepProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {isOffline} = useNetwork();
+
+ return (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+ {translate('personalInfoStep.letsDoubleCheck')}
+ {
+ onMove(PERSONAL_DETAILS_STEP_INDEXES.LEGAL_NAME);
+ }}
+ />
+ {
+ onMove(PERSONAL_DETAILS_STEP_INDEXES.DATE_OF_BIRTH);
+ }}
+ />
+ {
+ onMove(PERSONAL_DETAILS_STEP_INDEXES.ADDRESS);
+ }}
+ />
+ {
+ onMove(PERSONAL_DETAILS_STEP_INDEXES.PHONE_NUMBER);
+ }}
+ />
+
+
+
+
+ )}
+
+ );
+}
+
+ConfirmationStep.displayName = 'ConfirmationStep';
+
+export default ConfirmationStep;
diff --git a/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx b/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx
index 5aba7ed74ef8..f9b54c1b4758 100644
--- a/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx
+++ b/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx
@@ -1,34 +1,71 @@
import {subYears} from 'date-fns';
-import React from 'react';
+import React, {useCallback} from 'react';
+import {View} from 'react-native';
import DatePicker from '@components/DatePicker';
+import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
+import usePersonalDetailsFormSubmit from '@hooks/usePersonalDetailsFormSubmit';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as ValidationUtils from '@libs/ValidationUtils';
import type {CustomSubStepProps} from '@pages/MissingPersonalDetails/types';
import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/PersonalDetailsForm';
-function DateOfBirthStep({privatePersonalDetails}: CustomSubStepProps) {
+const STEP_FIELDS = [INPUT_IDS.DATE_OF_BIRTH];
+
+function DateOfBirthStep({isEditing, onNext, personalDetailsValues}: CustomSubStepProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
const minDate = subYears(new Date(), CONST.DATE_BIRTH.MAX_AGE);
- const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE);
+ const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT);
+
+ const handleSubmit = usePersonalDetailsFormSubmit({
+ fieldIds: STEP_FIELDS,
+ onNext,
+ shouldSaveDraft: true,
+ });
+
+ const validate = useCallback(
+ (values: FormOnyxValues): FormInputErrors => {
+ const errors: FormInputErrors = {};
+ if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.DATE_OF_BIRTH])) {
+ errors[INPUT_IDS.DATE_OF_BIRTH] = translate('common.error.fieldRequired');
+ } else if (!ValidationUtils.isValidPastDate(values[INPUT_IDS.DATE_OF_BIRTH]) || !ValidationUtils.meetsMaximumAgeRequirement(values[INPUT_IDS.DATE_OF_BIRTH])) {
+ errors[INPUT_IDS.DATE_OF_BIRTH] = translate('bankAccount.error.dob');
+ }
+ return errors;
+ },
+ [translate],
+ );
return (
- <>
- {translate('privatePersonalDetails.enterDateOfBirth')}
-
- >
+
+
+ {translate('privatePersonalDetails.enterDateOfBirth')}
+
+
+
);
}
diff --git a/src/pages/MissingPersonalDetails/substeps/LegalName.tsx b/src/pages/MissingPersonalDetails/substeps/LegalName.tsx
index 8b88bee4337d..01d4f52dee7d 100644
--- a/src/pages/MissingPersonalDetails/substeps/LegalName.tsx
+++ b/src/pages/MissingPersonalDetails/substeps/LegalName.tsx
@@ -1,46 +1,104 @@
-import React from 'react';
+import React, {useCallback} from 'react';
import {View} from 'react-native';
+import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
import useLocalize from '@hooks/useLocalize';
+import usePersonalDetailsFormSubmit from '@hooks/usePersonalDetailsFormSubmit';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import * as ValidationUtils from '@libs/ValidationUtils';
import type {CustomSubStepProps} from '@pages/MissingPersonalDetails/types';
import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/PersonalDetailsForm';
-function LegalNameStep({privatePersonalDetails}: CustomSubStepProps) {
+const STEP_FIELDS = [INPUT_IDS.LEGAL_FIRST_NAME, INPUT_IDS.LEGAL_LAST_NAME];
+
+function LegalNameStep({isEditing, onNext, personalDetailsValues}: CustomSubStepProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
+ const validate = useCallback(
+ (values: FormOnyxValues): FormInputErrors => {
+ const errors: FormInputErrors = {};
+ if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.LEGAL_FIRST_NAME])) {
+ errors[INPUT_IDS.LEGAL_FIRST_NAME] = translate('common.error.fieldRequired');
+ } else if (!ValidationUtils.isValidLegalName(values[INPUT_IDS.LEGAL_FIRST_NAME])) {
+ errors[INPUT_IDS.LEGAL_FIRST_NAME] = translate('privatePersonalDetails.error.hasInvalidCharacter');
+ } else if (values[INPUT_IDS.LEGAL_FIRST_NAME].length > CONST.LEGAL_NAME.MAX_LENGTH) {
+ errors[INPUT_IDS.LEGAL_FIRST_NAME] = translate('common.error.characterLimitExceedCounter', {
+ length: values[INPUT_IDS.LEGAL_FIRST_NAME].length,
+ limit: CONST.LEGAL_NAME.MAX_LENGTH,
+ });
+ }
+ if (ValidationUtils.doesContainReservedWord(values[INPUT_IDS.LEGAL_FIRST_NAME], CONST.DISPLAY_NAME.RESERVED_NAMES)) {
+ ErrorUtils.addErrorMessage(errors, INPUT_IDS.LEGAL_FIRST_NAME, translate('personalDetails.error.containsReservedWord'));
+ }
+ if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.LEGAL_LAST_NAME])) {
+ errors[INPUT_IDS.LEGAL_LAST_NAME] = translate('common.error.fieldRequired');
+ } else if (!ValidationUtils.isValidLegalName(values[INPUT_IDS.LEGAL_LAST_NAME])) {
+ errors[INPUT_IDS.LEGAL_LAST_NAME] = translate('privatePersonalDetails.error.hasInvalidCharacter');
+ } else if (values[INPUT_IDS.LEGAL_LAST_NAME].length > CONST.LEGAL_NAME.MAX_LENGTH) {
+ errors[INPUT_IDS.LEGAL_LAST_NAME] = translate('common.error.characterLimitExceedCounter', {
+ length: values[INPUT_IDS.LEGAL_LAST_NAME].length,
+ limit: CONST.LEGAL_NAME.MAX_LENGTH,
+ });
+ }
+ if (ValidationUtils.doesContainReservedWord(values[INPUT_IDS.LEGAL_LAST_NAME], CONST.DISPLAY_NAME.RESERVED_NAMES)) {
+ ErrorUtils.addErrorMessage(errors, INPUT_IDS.LEGAL_LAST_NAME, translate('personalDetails.error.containsReservedWord'));
+ }
+ return errors;
+ },
+ [translate],
+ );
+
+ const handleSubmit = usePersonalDetailsFormSubmit({
+ fieldIds: STEP_FIELDS,
+ onNext,
+ shouldSaveDraft: true,
+ });
+
return (
- <>
- {translate('privatePersonalDetails.enterLegalName')}
-
-
-
-
-
+
+
+ {translate('privatePersonalDetails.enterLegalName')}
+
+
+
+
+
+
- >
+
);
}
diff --git a/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx b/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx
index d336e15dd6c2..0ca220cf6e22 100644
--- a/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx
+++ b/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx
@@ -1,31 +1,73 @@
-import React from 'react';
+import {Str} from 'expensify-common';
+import React, {useCallback} from 'react';
+import {View} from 'react-native';
+import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
import useLocalize from '@hooks/useLocalize';
+import usePersonalDetailsFormSubmit from '@hooks/usePersonalDetailsFormSubmit';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as LoginUtils from '@libs/LoginUtils';
+import * as PhoneNumberUtils from '@libs/PhoneNumber';
+import * as ValidationUtils from '@libs/ValidationUtils';
import type {CustomSubStepProps} from '@pages/MissingPersonalDetails/types';
import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/PersonalDetailsForm';
-function PhoneNumberStep({privatePersonalDetails}: CustomSubStepProps) {
+const STEP_FIELDS = [INPUT_IDS.PHONE_NUMBER];
+
+function PhoneNumberStep({isEditing, onNext, personalDetailsValues}: CustomSubStepProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
+ const handleSubmit = usePersonalDetailsFormSubmit({
+ fieldIds: STEP_FIELDS,
+ onNext,
+ shouldSaveDraft: true,
+ });
+
+ const validate = useCallback(
+ (values: FormOnyxValues): FormInputErrors => {
+ const errors: FormInputErrors = {};
+ if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.PHONE_NUMBER])) {
+ errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.fieldRequired');
+ }
+ const phoneNumber = LoginUtils.appendCountryCode(values[INPUT_IDS.PHONE_NUMBER]);
+ const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(phoneNumber);
+ if (!parsedPhoneNumber.possible || !Str.isValidE164Phone(phoneNumber.slice(0))) {
+ errors[INPUT_IDS.PHONE_NUMBER] = translate('bankAccount.error.phoneNumber');
+ }
+ return errors;
+ },
+ [translate],
+ );
+
return (
- <>
- {translate('privatePersonalDetails.enterPhoneNumber')}
-
- >
+
+