diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index bb24a1be5e5..353a898a941 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -352,16 +352,6 @@ jobs: env: CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} - # Build a version of iOS and Android HybridApp if we are deploying to staging - hybridApp: - runs-on: ubuntu-latest - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} - steps: - - name: 'Deploy HybridApp' - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - uses: Expensify/Mobile-Deploy/.github/workflows/deploy.yml@main - postSlackMessageOnFailure: name: Post a Slack message when any platform fails to build or deploy runs-on: ubuntu-latest diff --git a/android/app/build.gradle b/android/app/build.gradle index 03a85f87101..2a1a4f9de0f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -107,8 +107,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001047805 - versionName "1.4.78-5" + versionCode 1001047907 + versionName "1.4.79-7" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/images/bed.svg b/assets/images/bed.svg new file mode 100644 index 00000000000..fd654c036a7 --- /dev/null +++ b/assets/images/bed.svg @@ -0,0 +1 @@ + diff --git a/assets/images/car-with-key.svg b/assets/images/car-with-key.svg new file mode 100644 index 00000000000..1586c0dfecf --- /dev/null +++ b/assets/images/car-with-key.svg @@ -0,0 +1 @@ + diff --git a/assets/images/plane.svg b/assets/images/plane.svg new file mode 100644 index 00000000000..bf4d5687523 --- /dev/null +++ b/assets/images/plane.svg @@ -0,0 +1 @@ + diff --git a/assets/images/receipt-slash.svg b/assets/images/receipt-slash.svg new file mode 100644 index 00000000000..2af3fcbc60e --- /dev/null +++ b/assets/images/receipt-slash.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__subscription-annual.svg b/assets/images/simple-illustrations/simple-illustration__subscription-annual.svg new file mode 100644 index 00000000000..e158bc5588c --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__subscription-annual.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__subscription-ppu.svg b/assets/images/simple-illustrations/simple-illustration__subscription-ppu.svg new file mode 100644 index 00000000000..d70d2d1ef55 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__subscription-ppu.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/assets/images/subscription-details__approvedlogo--light.svg b/assets/images/subscription-details__approvedlogo--light.svg new file mode 100644 index 00000000000..580ee60c597 --- /dev/null +++ b/assets/images/subscription-details__approvedlogo--light.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/subscription-details__approvedlogo.svg b/assets/images/subscription-details__approvedlogo.svg new file mode 100644 index 00000000000..7722e252665 --- /dev/null +++ b/assets/images/subscription-details__approvedlogo.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/articles/expensify-classic/travel/Approve-travel-expenses.md b/docs/articles/expensify-classic/travel/Approve-travel-expenses.md new file mode 100644 index 00000000000..ae0feb4efaa --- /dev/null +++ b/docs/articles/expensify-classic/travel/Approve-travel-expenses.md @@ -0,0 +1,37 @@ +--- +title: Approve travel expenses +description: Determine how travel expenses are approved +--- +
+ +Travel expenses follow the same approval workflow as other expenses. Admins can configure travel expenses to be approved as soft approval, hard approval or passive approval. The approval method for in-policy and out-of-policy bookings can be managed under the **Policies** section in the **Program** menu for Expensify Travel. + +- **Soft Approval**: Bookings are automatically approved as long as a manager does not decline them within 24 hours. However, this also means that if a manager does not decline the expenses, the arrangements will be booked even if they are out of policy. If a booking is declined, it is refunded based on the voiding/refund terms of the service provider. +- **Hard Approval**: Bookings are automatically canceled/voided and refunded if a manager does not approve them within 24 hours. +- **Passive Approval**: Managers are informed of out-of-policy travel, but there is no action to be taken. + +# Set approval method + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under General, select approval methods for Flights, Hotels, Cars and Rail. + +
+ +
+ +Travel expenses follow the same approval workflow as other expenses. Admins can configure travel expenses to be approved as soft approval, hard approval or passive approval. The approval method for in-policy and out-of-policy bookings can be managed under the **Policies** section in the **Program** menu for Expensify Travel. + +- **Soft Approval**: Bookings are automatically approved as long as a manager does not decline them within 24 hours. However, this also means that if a manager does not decline the expenses, the arrangements will be booked even if they are out of policy. If a booking is declined, it is refunded based on the voiding/refund terms of the service provider. +- **Hard Approval**: Bookings are automatically canceled/voided and refunded if a manager does not approve them within 24 hours. +- **Passive Approval**: Managers are informed of out-of-policy travel, but there is no action to be taken. + +# Set approval method + +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under General, select approval methods for Flights, Hotels, Cars and Rail. + +
diff --git a/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md b/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md new file mode 100644 index 00000000000..5d25670ac5a --- /dev/null +++ b/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md @@ -0,0 +1,89 @@ +--- +title: Book with Expensify Travel +description: Book flights, hotels, cars, trains, and more with Expensify Travel +--- +
+ +Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available. + +With Expensify Travel, you can: +- Search and book travel arrangements all in one place +- Book travel for yourself or for someone else +- Get real-time support by chat or phone +- Manage all your T&E expenses in Expensify +- Create specific rules for booking travel +- Enable approvals for out-of-policy trips +- Book with any credit card on the market +- Book with the Expensify Card to get cash back and automatically reconcile transactions + +There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental. + +# Book travel + +To book travel from the Expensify web app, + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +4. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +5. Select all the details for the arrangement you want to book. +6. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. + +The traveler is emailed an itinerary of the booking. Additionally, +- Their travel details are added to a Trip chat room under their primary workspace. +- An expense report for the trip is created. +- If booked with an Expensify Card, the trip is automatically reconciled. + +{% include info.html %} +The travel itinerary is also emailed to the traveler’s [copilots](https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot), if applicable. +{% include end-info.html %} + +
+ +
+Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available. + +With Expensify Travel, you can: +- Search and book travel arrangements all in one place +- Book travel for yourself or for someone else +- Get real-time support by chat or phone +- Manage all your T&E expenses in Expensify +- Create specific rules for booking travel +- Enable approvals for out-of-policy trips +- Book with any credit card on the market +- Book with the Expensify Card to get cash back and automatically reconcile transactions + +There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental. + +# Book travel + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Agree to the terms and conditions and click **Continue**. +4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +6. Select all the details for the arrangement you want to book. +7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap the + icon in the bottom menu and select **Book travel**. +2. Tap **Book or manage travel**. +3. Agree to the terms and conditions and tap **Continue**. +4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +6. Select all the details for the arrangement you want to book. +7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. +{% include end-option.html %} + +{% include end-selector.html %} + +The traveler is emailed an itinerary of the booking. Additionally, +- Their travel details are added to a Trip chat room under their primary workspace. +- An expense report for the trip is created. +- If booked with an Expensify Card, the trip is automatically reconciled. + +
diff --git a/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md b/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md new file mode 100644 index 00000000000..2e17af06773 --- /dev/null +++ b/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md @@ -0,0 +1,65 @@ +--- +title: Configure travel policy and preferences +description: Set and update travel policies and preferences for your Expensify Workspace +--- +
+ +As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees. + +# Create a travel policy + +When using Expensify Travel for the first time, you will need to create a new Travel Policy. + +To create a travel policy from the Expensify web app, + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy. +5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. +6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +7. Click the paperclip icon next to each setting to de-couple it from your default policy. +8. Update the desired settings. + +# Update travel policy preferences + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Select the appropriate policy in the left menu. +5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +6. Click the paperclip icon next to each setting to de-couple it from your default policy. +7. Update the desired policies. + +
+ +
+ +As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees. + +# Create a travel policy + +When using Expensify Travel for the first time, you will need to create a new Travel Policy. + +To create a travel policy from the Expensify web app, + +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy. +5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. +6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +7. Click the paperclip icon next to each setting to de-couple it from your default policy. +8. Update the desired settings. + +# Update travel policy preferences + +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Select the appropriate policy in the left menu. +5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +6. Click the paperclip icon next to each setting to de-couple it from your default policy. +7. Update the desired policies. + +
diff --git a/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md b/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md new file mode 100644 index 00000000000..7dc71c3220c --- /dev/null +++ b/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md @@ -0,0 +1,28 @@ +--- +title: Edit or cancel travel arrangements +description: Modify travel arrangements booked with Expensify Travel +--- +
+ +Click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee. + +
+ +
+ +You can review your travel arrangements any time by opening the Trip chat in your inbox. For example, if you booked a flight to San Francisco, a “Trip to San Francisco” chat will be automatically added to your chat inbox. + +To edit or cancel a travel arrangement, +1. Click your profile image or icon in the bottom left menu. +2. Scroll down and click **Workspaces** in the left menu. +3. Select the workspace the travel is booked under. +4. Tap into the booking to see more details. +5. Click **Trip Support**. + +If there is an unexpected change to the itinerary (for example, a flight cancellation), Expensify’s travel partner **Spotnana** will reach out to the traveler to provide updates on those changes. + +{% include info.html %} +You can click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee. +{% include end-info.html %} + +
diff --git a/docs/articles/expensify-classic/workspaces/reports/Report-Fields-And-Titles.md b/docs/articles/expensify-classic/workspaces/reports/Report-Fields-And-Titles.md deleted file mode 100644 index e79e30ce42c..00000000000 --- a/docs/articles/expensify-classic/workspaces/reports/Report-Fields-And-Titles.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: Report Fields & Titles -description: This article is about managing Report Fields and Report Titles in Expensify ---- -# Overview - -In this article, we'll go over how to use Report Titles and Report Fields. - -## How to use Report Titles - -Default report titles enable group workspace admins or individual workspace users to establish a standardized title format for reports associated with a specific workspace. Additionally, admins have the choice to enforce these report titles, preventing employees from altering them. This ensures uniformity in report naming conventions during the review process, eliminating the need for employees to manually input titles for each report they generate. - -- Group workspace admins can set the Default Report Title from **Settings > Workspaces > Group > *[Workspace Name]* > Reports**. -- Individual users can set the Default Report Title from **Settings > Workspaces > Individual > *[Workspace Name]* > Reports**. - -You can configure the title by using the formulas that we provide to populate the Report Title. Take a look at the help article on Custom Formulas to find all eligible formulas for your Report Titles. - -## Deep Dive on Report Titles - -Some formulas will automatically update the report title as changes are made to the report. For example, any formula related to dates, total amounts, workspace name, would adjust the title before the report is submitted for approval. Changes will not retroactively update report titles for reports which have been Approved or Reimbursed. - -To prevent report title editing by employees, simply enable "Enforce Default Report Title." - -## How to use Report Fields - -Report fields let you specify header-level details, distinct from tags which pertain to expenses on individual line items. These details can encompass specific project names, business trip information, locations, and more. Customize them according to your workspace's requirements. - -To set up Report Fields, follow these steps: -- Workspace Admins can create report fields for group workspaces from **Settings > Workspaces > Group > *[Workspace Name]* > Reports > Report and Invoice Fields**. For individual workspaces, follow **Settings > Workspaces > Individual > *[Workspace Name]* > Reports > Report and Invoice Fields**. -- Under "Add New Field," enter the desired field name in the "Field Title" to describe the type of information to be selected. -- Choose the appropriate input method under "Type": - - Text: Provides users with a free-text box to enter the requested information. - - Dropdown: Creates a selection of options for users to choose from. - - Date: Displays a clickable box that opens a calendar for users to select a date. - -## Deep Dive on Report Fields - -You cannot create these report fields directly in Expensify if you are connected to an accounting integration (QuickBooks Online, QuickBooks Desktop, Intacct, Xero, or NetSuite). Please refer to the relevant article for instructions on creating fields within that system. - -When report fields are configured on a workspace, they become mandatory information for associated reports. Leaving a report field empty or unselected will trigger a report violation, potentially blocking report submission or export. - -Report fields are "sticky," which means that any changes made by an employee will persist and be reflected in subsequent reports they create. - diff --git a/docs/articles/expensify-classic/workspaces/reports/Scheduled-Submit.md b/docs/articles/expensify-classic/workspaces/reports/Scheduled-Submit.md deleted file mode 100644 index 18ad693a1c5..00000000000 --- a/docs/articles/expensify-classic/workspaces/reports/Scheduled-Submit.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: Scheduled Submit -description: How to use the Scheduled Submit feature ---- -# Overview - -Scheduled Submit reduces the delay between the time an employee creates an expense to when it is submitted to the admin. This gives admins significantly faster visibility into employee spend. Without Scheduled Submit enabled, expenses can be left Unreported giving workspace admins no visibility into employee spend. - -The biggest delay in expense management is the time it takes for an employee to actually submit the expense after it is incurred. Scheduled Submit allows you to automatically collect employee expenses on a schedule of your choosing without delaying the process while you wait for employees to submit them. - -It works like this: Employee expenses are automatically gathered onto a report. If there is not an existing report, a new one will be created. This report is submitted automatically at the cadence you choose (daily, weekly, monthly, twice month, by trip). - -# How to enable Scheduled Submit - -**For workspace admins**: To enable Scheduled Submit on your group workspace, follow **Settings > Workspaces > Group > *[Workspace Name]* > Reports > Scheduled Submit**. From there, toggle Scheduled Submit to Enabled. Then, choose your desired frequency from the dropdown menu. -For individuals or employees: To enable Scheduled Submit on your individual workspace, follow **Settings > Workspaces > Individual > *[Workspace Name]* > Reports > Scheduled Submit**. From there, toggle Scheduled Submit to Enabled. Then, choose your desired frequency from the dropdown menu. - -## Scheduled Submit frequency options - -**Daily**: Each night, expenses without violations will be submitted. Expenses with violations will remain on an open report until the violations are corrected, after which they will be submitted in the evening (PDT). - -**Weekly**: Expenses that are free of violations will be submitted on a weekly basis. However, expenses with violations will be held in a new open report and combined with any new expenses. They will then be submitted at the end of the following weekly cycle, specifically on Sunday evening (PDT). - -**Twice a month**: Expenses that are violation-free will be submitted on both the 15th and the last day of each month, in the evening (PDT). Expenses with violations will not be submitted, but moved on to a new open report so the employee can resolve the violations and then will be submitted at the conclusion of the next cycle. - -**Monthly**: Expenses that are free from violations will be submitted on a monthly basis. Expenses with violations will be held back and moved to a new Open report so the violations can be resolved, and they will be submitted on the evening (PDT) of the specified date. - -**By trip**: Expenses are grouped by trip. This is calculated by grouping all expenses together that occur in a similar time frame. If two full days pass without any new expenses being created, the trip report will be submitted on the evening of the second day. Any expenses generated after this submission will initiate a new trip report. Please note that the "2-day" period refers to a date-based interval, not a 48-hour time frame. - -**Manually**: An open report will be created, and expenses will be added to it automatically. However, it's important to note that the report will not be submitted automatically; manual submission of reports will be required.This is a great option for automatically gathering all an employee’s expenses on a report for the employee’s convenience, but they will still need to review and submit the report. - -**Instantly**: Expenses are automatically added to a report in the Processing state, and all expenses will continue to accumulate on one report until it is Approved or Reimbursed. This removes the need to submit expenses, and Processing reports can either be Reimbursed right away (if Submit and Close is enabled), or Approved and then Reimbursed (if Submit and Approve is enabled) by a workspace admin. - -# Deep Dive - -## Schedule Submit Override -If Scheduled Submit is disabled at the group workspace level or configured the frequency as "Manually," the individual workspace settings of a user will take precedence and be applied. This means an employee can still set up Scheduled Submit for themselves even if the admin has not enabled it. We highly recommend Scheduled Submit as it helps put your expenses on auto-pilot! - -## Personal Card Transactions -Personal card transactions are handled differently compared to other expenses. If a user has added a card through Settings > Account > Credit Card Import, they need to make sure it is set as non-reimbursable and transactions must be automatically merged with a SmartScanned receipt. If transactions are set to come in as reimbursable or they aren’t merged with a SmartScanned receipt, Scheduled Submit settings will not apply. - -## A note on Instantly -Setting Scheduled Submit frequency to Instantly will limit some employee actions on reports, such as the ability to retract or self-close reports, or create multiple reports. When Instantly is selected, expenses are automatically added to a Processing expense report, and new expenses will continue to accumulate on a single report until the report is Closed or Reimbursed by a workspace admin. diff --git a/docs/articles/new-expensify/expenses/Set-up-your-wallet.md b/docs/articles/new-expensify/expenses/Set-up-your-wallet.md new file mode 100644 index 00000000000..de1ee61066b --- /dev/null +++ b/docs/articles/new-expensify/expenses/Set-up-your-wallet.md @@ -0,0 +1,52 @@ +--- +title: Set up your wallet +description: Send and receive payments by adding your payment account +--- +
+To send and receive money using Expensify, you’ll first need to set up your Expensify Wallet by adding your payment account. + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click your profile image or icon in the bottom left menu. +2. Click **Wallet** in the left menu. +3. Click **Enable wallet**. +4. If you haven’t already added your bank account, click **Continue** and follow the prompts to add your bank account details with Plaid. If you have already connected your bank account, you’ll skip to the next step. + +{% include info.html %} +Plaid is an encrypted third-party financial data platform that Expensify uses to securely verify your banking information. +{% include end-info.html %} + +{:start="5"} +5. Enter your personal details (including your name, address, date of birth, phone number, and the last 4 digits of your social security number). +6. Click **Save & continue**. +7. Review the Onfido terms and click **Accept**. +8. Use the prompts to continue the next steps on your mobile device where you will select which option you want to use to verify your device: a QR code, a link, or a text message. +9. Follow the prompts on your mobile device to submit your ID with Onfido. + +When your ID is uploaded successfully, Onfido closes automatically. You can return to your Expensify Wallet to verify that it is now enabled. Once enabled, you are ready to send and receive payments. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap your profile image or icon in the bottom menu. +2. Tap **Wallet**. +3. Tap **Enable wallet**. +4. If you haven’t already added your bank account, tap **Continue** and follow the prompts to add your bank account details with Plaid. If you have already connected your bank account, you’ll skip to the next step. + +{% include info.html %} +Plaid is an encrypted third-party financial data platform that Expensify uses to securely verify your banking information. +{% include end-info.html %} + +{:start="5"} +5. Enter your personal details (including your name, address, date of birth, phone number, and the last 4 digits of your social security number). +6. Tap **Save & continue**. +7. Review the Onfido terms and tap **Accept**. +8. Follow the prompts to submit your ID with Onfido. When your ID is uploaded successfully, Onfido closes automatically. +9. Tap **Enable wallet** again to enable payments for the wallet. + +Once enabled, you are ready to send and receive payments. +{% include end-option.html %} + +{% include end-selector.html %} + +
diff --git a/docs/articles/new-expensify/travel/Approve-travel-expenses.md b/docs/articles/new-expensify/travel/Approve-travel-expenses.md new file mode 100644 index 00000000000..ae0feb4efaa --- /dev/null +++ b/docs/articles/new-expensify/travel/Approve-travel-expenses.md @@ -0,0 +1,37 @@ +--- +title: Approve travel expenses +description: Determine how travel expenses are approved +--- +
+ +Travel expenses follow the same approval workflow as other expenses. Admins can configure travel expenses to be approved as soft approval, hard approval or passive approval. The approval method for in-policy and out-of-policy bookings can be managed under the **Policies** section in the **Program** menu for Expensify Travel. + +- **Soft Approval**: Bookings are automatically approved as long as a manager does not decline them within 24 hours. However, this also means that if a manager does not decline the expenses, the arrangements will be booked even if they are out of policy. If a booking is declined, it is refunded based on the voiding/refund terms of the service provider. +- **Hard Approval**: Bookings are automatically canceled/voided and refunded if a manager does not approve them within 24 hours. +- **Passive Approval**: Managers are informed of out-of-policy travel, but there is no action to be taken. + +# Set approval method + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under General, select approval methods for Flights, Hotels, Cars and Rail. + +
+ +
+ +Travel expenses follow the same approval workflow as other expenses. Admins can configure travel expenses to be approved as soft approval, hard approval or passive approval. The approval method for in-policy and out-of-policy bookings can be managed under the **Policies** section in the **Program** menu for Expensify Travel. + +- **Soft Approval**: Bookings are automatically approved as long as a manager does not decline them within 24 hours. However, this also means that if a manager does not decline the expenses, the arrangements will be booked even if they are out of policy. If a booking is declined, it is refunded based on the voiding/refund terms of the service provider. +- **Hard Approval**: Bookings are automatically canceled/voided and refunded if a manager does not approve them within 24 hours. +- **Passive Approval**: Managers are informed of out-of-policy travel, but there is no action to be taken. + +# Set approval method + +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under General, select approval methods for Flights, Hotels, Cars and Rail. + +
diff --git a/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md b/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md new file mode 100644 index 00000000000..2e17af06773 --- /dev/null +++ b/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md @@ -0,0 +1,65 @@ +--- +title: Configure travel policy and preferences +description: Set and update travel policies and preferences for your Expensify Workspace +--- +
+ +As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees. + +# Create a travel policy + +When using Expensify Travel for the first time, you will need to create a new Travel Policy. + +To create a travel policy from the Expensify web app, + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy. +5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. +6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +7. Click the paperclip icon next to each setting to de-couple it from your default policy. +8. Update the desired settings. + +# Update travel policy preferences + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Select the appropriate policy in the left menu. +5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +6. Click the paperclip icon next to each setting to de-couple it from your default policy. +7. Update the desired policies. + +
+ +
+ +As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees. + +# Create a travel policy + +When using Expensify Travel for the first time, you will need to create a new Travel Policy. + +To create a travel policy from the Expensify web app, + +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy. +5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. +6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +7. Click the paperclip icon next to each setting to de-couple it from your default policy. +8. Update the desired settings. + +# Update travel policy preferences + +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Click the **Program** tab at the top and select **Policies**. +4. Select the appropriate policy in the left menu. +5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +6. Click the paperclip icon next to each setting to de-couple it from your default policy. +7. Update the desired policies. + +
diff --git a/docs/articles/new-expensify/travel/Edit-or-cancel-travel-arrangements.md b/docs/articles/new-expensify/travel/Edit-or-cancel-travel-arrangements.md new file mode 100644 index 00000000000..7dc71c3220c --- /dev/null +++ b/docs/articles/new-expensify/travel/Edit-or-cancel-travel-arrangements.md @@ -0,0 +1,28 @@ +--- +title: Edit or cancel travel arrangements +description: Modify travel arrangements booked with Expensify Travel +--- +
+ +Click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee. + +
+ +
+ +You can review your travel arrangements any time by opening the Trip chat in your inbox. For example, if you booked a flight to San Francisco, a “Trip to San Francisco” chat will be automatically added to your chat inbox. + +To edit or cancel a travel arrangement, +1. Click your profile image or icon in the bottom left menu. +2. Scroll down and click **Workspaces** in the left menu. +3. Select the workspace the travel is booked under. +4. Tap into the booking to see more details. +5. Click **Trip Support**. + +If there is an unexpected change to the itinerary (for example, a flight cancellation), Expensify’s travel partner **Spotnana** will reach out to the traveler to provide updates on those changes. + +{% include info.html %} +You can click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee. +{% include end-info.html %} + +
diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 4d819804ed4..9e4880780e9 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -200,8 +200,10 @@ function selectNewExpensify(newExpensifyTab, newExpensifyContent, expensifyClass newExpensifyTab.classList.add('active'); newExpensifyContent.classList.remove('hidden'); - expensifyClassicTab.classList.remove('active'); - expensifyClassicContent.classList.add('hidden'); + if (expensifyClassicTab && expensifyClassicContent) { + expensifyClassicTab.classList.remove('active'); + expensifyClassicContent.classList.add('hidden'); + } window.tocbot.refresh({ ...tocbotOptions, contentSelector: '#new-expensify', @@ -212,8 +214,11 @@ function selectExpensifyClassic(newExpensifyTab, newExpensifyContent, expensifyC expensifyClassicTab.classList.add('active'); expensifyClassicContent.classList.remove('hidden'); - newExpensifyTab.classList.remove('active'); - newExpensifyContent.classList.add('hidden'); + if (newExpensifyTab && newExpensifyContent) { + newExpensifyTab.classList.remove('active'); + newExpensifyContent.classList.add('hidden'); + } + window.tocbot.refresh({ ...tocbotOptions, contentSelector: '#expensify-classic', diff --git a/docs/redirects.csv b/docs/redirects.csv index 3042dc79085..13463327d06 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -197,5 +197,7 @@ https://help.expensify.com/articles/new-expensify/workspaces/The-Free-Plan,https https://help.expensify.com/new-expensify/hubs/expenses/Connect-a-Bank-Account,https://help.expensify.com/articles/new-expensify/expenses/Connect-a-Business-Bank-Account https://help.expensify.com/articles/new-expensify/settings/Security,https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security https://help.expensify.com/articles/expensify-classic/workspaces/reports/Currency,https://help.expensify.com/articles/expensify-classic/workspaces/Currency +https://help.expensify.com/articles/expensify-classic/workspaces/reports/Report-Fields-And-Titles,https://help.expensify.com/expensify-classic/hubs/workspaces/ +https://help.expensify.com/articles/expensify-classic/workspaces/reports/Scheduled-Submit,https://help.expensify.com/articles/expensify-classic/reports/Automatically-submit-employee-reports https://help.expensify.com/articles/new-expensify/chat/Expensify-Chat-For-Admins,https://help.expensify.com/new-expensify/hubs/chat/ https://help.expensify.com/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account.html,https://help.expensify.com/articles/new-expensify/expenses/Connect-a-Business-Bank-Account diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 42526a93a44..ae542bcfd34 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.78 + 1.4.79 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.78.5 + 1.4.79.7 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 066d8328868..6c8590407b2 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.78 + 1.4.79 CFBundleSignature ???? CFBundleVersion - 1.4.78.5 + 1.4.79.7 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 0799a757855..89991861936 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.78 + 1.4.79 CFBundleVersion - 1.4.78.5 + 1.4.79.7 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 9679fb05a92..d6ac3627329 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.78-5", + "version": "1.4.79-7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.78-5", + "version": "1.4.79-7", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 9b87410580a..da5381c5da9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.78-5", + "version": "1.4.79-7", "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.", diff --git a/src/CONST.ts b/src/CONST.ts index 32c500962d8..bc8f627630a 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -50,6 +50,7 @@ const KEYBOARD_SHORTCUT_NAVIGATION_TYPE = 'NAVIGATION_SHORTCUT'; const chatTypes = { POLICY_ANNOUNCE: 'policyAnnounce', POLICY_ADMINS: 'policyAdmins', + TRIP_ROOM: 'tripRoom', GROUP: 'group', DOMAIN_ALL: 'domainAll', POLICY_ROOM: 'policyRoom', @@ -698,6 +699,7 @@ const CONST = { TASK_COMPLETED: 'TASKCOMPLETED', TASK_EDITED: 'TASKEDITED', TASK_REOPENED: 'TASKREOPENED', + TRIPPREVIEW: 'TRIPPREVIEW', UNAPPROVED: 'UNAPPROVED', // OldDot Action UNHOLD: 'UNHOLD', UNSHARE: 'UNSHARE', // OldDot Action @@ -4752,6 +4754,12 @@ const CONST = { INITIAL_URL: 'INITIAL_URL', }, + RESERVATION_TYPE: { + CAR: 'car', + HOTEL: 'hotel', + FLIGHT: 'flight', + }, + DOT_SEPARATOR: '•', DEFAULT_TAX: { diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index f6730f4b81d..f0cd69f2840 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -212,7 +212,7 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s * An attachment error dialog when user selected malformed images */ const showImageCorruptionAlert = useCallback(() => { - Alert.alert(translate('attachmentPicker.attachmentError'), translate('attachmentPicker.errorWhileSelectingCorruptedImage')); + Alert.alert(translate('attachmentPicker.attachmentError'), translate('attachmentPicker.errorWhileSelectingCorruptedAttachment')); }, [translate]); /** diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 4dfbd891d9f..5c181bfdb29 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -19,6 +19,7 @@ import NotificationsAvatar from '@assets/images/avatars/notifications-avatar.svg import ActiveRoomAvatar from '@assets/images/avatars/room.svg'; import BackArrow from '@assets/images/back-left.svg'; import Bank from '@assets/images/bank.svg'; +import Bed from '@assets/images/bed.svg'; import Bell from '@assets/images/bell.svg'; import BellSlash from '@assets/images/bellSlash.svg'; import Bill from '@assets/images/bill.svg'; @@ -28,6 +29,7 @@ import Bug from '@assets/images/bug.svg'; import Building from '@assets/images/building.svg'; import Calendar from '@assets/images/calendar.svg'; import Camera from '@assets/images/camera.svg'; +import CarWithKey from '@assets/images/car-with-key.svg'; import Car from '@assets/images/car.svg'; import CardsAndDomains from '@assets/images/cards-and-domains.svg'; import Cash from '@assets/images/cash.svg'; @@ -127,6 +129,7 @@ import Paycheck from '@assets/images/paycheck.svg'; import Pencil from '@assets/images/pencil.svg'; import Phone from '@assets/images/phone.svg'; import Pin from '@assets/images/pin.svg'; +import Plane from '@assets/images/plane.svg'; import Play from '@assets/images/play.svg'; import Plus from '@assets/images/plus.svg'; import Printer from '@assets/images/printer.svg'; @@ -136,6 +139,7 @@ import QuestionMark from '@assets/images/question-mark-circle.svg'; import ReceiptPlus from '@assets/images/receipt-plus.svg'; import ReceiptScan from '@assets/images/receipt-scan.svg'; import ReceiptSearch from '@assets/images/receipt-search.svg'; +import ReceiptSlash from '@assets/images/receipt-slash.svg'; import Receipt from '@assets/images/receipt.svg'; import RemoveMembers from '@assets/images/remove-members.svg'; import Rotate from '@assets/images/rotate-image.svg'; @@ -310,6 +314,7 @@ export { Receipt, ReceiptPlus, ReceiptScan, + ReceiptSlash, RemoveMembers, ReceiptSearch, Rotate, @@ -348,6 +353,9 @@ export { ChatBubbleUnread, ChatBubbleReply, Lightbulb, + Plane, + Bed, + CarWithKey, DocumentPlus, Clear, CheckCircle, diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index e59d73aee19..96104932c89 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -80,6 +80,8 @@ import SendMoney from '@assets/images/simple-illustrations/simple-illustration__ import ShieldYellow from '@assets/images/simple-illustrations/simple-illustration__shield.svg'; import SmallRocket from '@assets/images/simple-illustrations/simple-illustration__smallrocket.svg'; import SplitBill from '@assets/images/simple-illustrations/simple-illustration__splitbill.svg'; +import SubscriptionAnnual from '@assets/images/simple-illustrations/simple-illustration__subscription-annual.svg'; +import SubscriptionPPU from '@assets/images/simple-illustrations/simple-illustration__subscription-ppu.svg'; import Tag from '@assets/images/simple-illustrations/simple-illustration__tag.svg'; import TeachersUnite from '@assets/images/simple-illustrations/simple-illustration__teachers-unite.svg'; import ThumbsUpStars from '@assets/images/simple-illustrations/simple-illustration__thumbsupstars.svg'; @@ -88,6 +90,8 @@ import TrashCan from '@assets/images/simple-illustrations/simple-illustration__t import TreasureChest from '@assets/images/simple-illustrations/simple-illustration__treasurechest.svg'; import WalletAlt from '@assets/images/simple-illustrations/simple-illustration__wallet-alt.svg'; import Workflows from '@assets/images/simple-illustrations/simple-illustration__workflows.svg'; +import ExpensifyApprovedLogoLight from '@assets/images/subscription-details__approvedlogo--light.svg'; +import ExpensifyApprovedLogo from '@assets/images/subscription-details__approvedlogo.svg'; export { Abracadabra, @@ -178,6 +182,10 @@ export { Tag, CarIce, Lightbulb, + SubscriptionAnnual, + SubscriptionPPU, + ExpensifyApprovedLogo, + ExpensifyApprovedLogoLight, SendMoney, CheckmarkCircle, }; diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 0fcc12ec50e..6d42a854601 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -1,6 +1,6 @@ import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import type {ImageContentFit} from 'expo-image'; -import type {ReactNode} from 'react'; +import type {ReactElement, ReactNode} from 'react'; import React, {forwardRef, useContext, useMemo} from 'react'; import type {GestureResponderEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; @@ -105,6 +105,9 @@ type MenuItemBaseProps = { /** The fill color to pass into the secondary icon. */ secondaryIconFill?: string; + /** Whether the secondary icon should have hover style */ + isSecondaryIconHoverable?: boolean; + /** Icon Width */ iconWidth?: number; @@ -182,6 +185,12 @@ type MenuItemBaseProps = { /** Text to display for the item */ title?: string; + /** Component to display as the title */ + titleComponent?: ReactElement; + + /** Any additional styles to apply to the container for title components */ + titleContainerStyle?: StyleProp; + /** A right-aligned subtitle for this menu option */ subtitle?: string | number; @@ -300,6 +309,7 @@ function MenuItem( secondaryIcon, secondaryIconFill, iconType = CONST.ICON_TYPE_ICON, + isSecondaryIconHoverable = false, iconWidth, iconHeight, iconStyles, @@ -321,6 +331,8 @@ function MenuItem( focused = false, disabled = false, title, + titleComponent, + titleContainerStyle, subtitle, shouldShowBasicTitle, label, @@ -554,7 +566,7 @@ function MenuItem( )} {secondaryIcon && ( - + )} - + {!!description && shouldShowDescriptionOnTop && ( )} - - {!!title && (shouldRenderAsHTML || (shouldParseTitle && !!html.length)) && ( - - - - )} - {!shouldRenderAsHTML && !shouldParseTitle && !!title && ( - - {renderTitleContent()} - - )} - {shouldShowTitleIcon && titleIcon && ( - - - - )} - + {(!!title || !!shouldShowTitleIcon) && ( + + {!!title && (shouldRenderAsHTML || (shouldParseTitle && !!html.length)) && ( + + + + )} + {!shouldRenderAsHTML && !shouldParseTitle && !!title && ( + + {renderTitleContent()} + + )} + {shouldShowTitleIcon && titleIcon && ( + + + + )} + + )} {!!description && !shouldShowDescriptionOnTop && ( )} + {titleComponent} diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 071f2b8745c..1cccfdc720b 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -79,6 +79,9 @@ type MoneyRequestAmountInputProps = { /** Hide the focus styles on TextInput */ hideFocusedState?: boolean; + /** Whether the user input should be kept or not */ + shouldKeepUserInput?: boolean; + /** * Autogrow input container length based on the entered text. */ @@ -98,7 +101,7 @@ const getNewSelection = (oldSelection: Selection, prevLength: number, newLength: return {start: cursorPosition, end: cursorPosition}; }; -const defaultOnFormatAmount = (amount: number) => CurrencyUtils.convertToFrontendAmount(amount).toString(); +const defaultOnFormatAmount = (amount: number) => CurrencyUtils.convertToFrontendAmountAsString(amount); function MoneyRequestAmountInput( { @@ -116,6 +119,7 @@ function MoneyRequestAmountInput( formatAmountOnBlur, maxLength, hideFocusedState = true, + shouldKeepUserInput = false, autoGrow = true, ...props }: MoneyRequestAmountInputProps, @@ -192,7 +196,7 @@ function MoneyRequestAmountInput( })); useEffect(() => { - if (!currency || typeof amount !== 'number' || (formatAmountOnBlur && textInput.current?.isFocused())) { + if (!currency || typeof amount !== 'number' || (formatAmountOnBlur && textInput.current?.isFocused()) || shouldKeepUserInput) { return; } const frontendAmount = onFormatAmount(amount, currency); @@ -209,7 +213,7 @@ function MoneyRequestAmountInput( // we want to re-initialize the state only when the amount changes // eslint-disable-next-line react-hooks/exhaustive-deps - }, [amount]); + }, [amount, shouldKeepUserInput]); // Modifies the amount to match the decimals for changed currency. useEffect(() => { diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 98701c5cd36..7c473d0680c 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -335,6 +335,7 @@ function MoneyRequestConfirmationList({ const [didConfirmSplit, setDidConfirmSplit] = useState(false); const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); + const [invalidAttachmentPromt, setInvalidAttachmentPromt] = useState(translate('attachmentPicker.protectedPDFNotSupported')); const navigateBack = useCallback( () => Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID)), @@ -350,20 +351,21 @@ function MoneyRequestConfirmationList({ }, [isEditingSplitBill, hasSmartScanFailed, transaction, didConfirmSplit]); const isMerchantEmpty = useMemo(() => !iouMerchant || TransactionUtils.isMerchantMissing(transaction), [transaction, iouMerchant]); - const isMerchantRequired = isPolicyExpenseChat && (!isScanRequest || isEditingSplitBill) && shouldShowMerchant; + const isMerchantRequired = (isPolicyExpenseChat || isTypeInvoice) && (!isScanRequest || isEditingSplitBill) && shouldShowMerchant; const shouldDisplayMerchantError = isMerchantRequired && (shouldDisplayFieldError || formError === 'iou.error.invalidMerchant') && isMerchantEmpty; const isCategoryRequired = !!policy?.requiresCategory; useEffect(() => { - if (shouldDisplayFieldError && hasSmartScanFailed) { - setFormError('iou.receiptScanningFailed'); - return; - } if (shouldDisplayFieldError && didConfirmSplit) { setFormError('iou.error.genericSmartscanFailureMessage'); return; } + + if (shouldDisplayFieldError && hasSmartScanFailed) { + setFormError('iou.receiptScanningFailed'); + return; + } // reset the form error whenever the screen gains or loses focus setFormError(''); @@ -715,21 +717,7 @@ function MoneyRequestConfirmationList({ setFormError('iou.error.invalidCategoryLength'); return; } - - if (formError) { - return; - } - - if (iouType === CONST.IOU.TYPE.PAY) { - if (!paymentMethod) { - return; - } - - setDidConfirm(true); - - Log.info(`[IOU] Sending money via: ${paymentMethod}`); - onSendMoney?.(paymentMethod); - } else { + if (iouType !== CONST.IOU.TYPE.PAY) { // validate the amount for distance expenses const decimals = CurrencyUtils.getCurrencyDecimals(iouCurrencyCode); if (isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(iouAmount), decimals)) { @@ -746,6 +734,18 @@ function MoneyRequestConfirmationList({ playSound(SOUNDS.DONE); setDidConfirm(true); onConfirm?.(selectedParticipants); + } else { + if (formError) { + return; + } + if (!paymentMethod) { + return; + } + + setDidConfirm(true); + + Log.info(`[IOU] Sending money via: ${paymentMethod}`); + onSendMoney?.(paymentMethod); } }, [ @@ -1108,7 +1108,14 @@ function MoneyRequestConfirmationList({ previewSourceURL={resolvedReceiptImage as string} // We don't support scanning password protected PDF receipt enabled={!isAttachmentInvalid} - onPassword={() => setIsAttachmentInvalid(true)} + onPassword={() => { + setIsAttachmentInvalid(true); + setInvalidAttachmentPromt(translate('attachmentPicker.protectedPDFNotSupported')); + }} + onLoadError={() => { + setInvalidAttachmentPromt(translate('attachmentPicker.errorWhileSelectingCorruptedAttachment')); + setIsAttachmentInvalid(true); + }} /> ) : ( {shouldShowAllFields && supplementaryFields} @@ -1234,6 +1242,7 @@ function MoneyRequestConfirmationList({ transaction, transactionID, translate, + invalidAttachmentPromt, ], ); @@ -1272,7 +1281,7 @@ export default withOnyx `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - selector: DistanceRequestUtils.getMileageRates, + selector: (policy: OnyxEntry) => DistanceRequestUtils.getMileageRates(policy), }, policy: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 995ec125c4a..abd70753b46 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -93,7 +93,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); const isDeletedParentAction = ReportActionsUtils.isDeletedAction(parentReportAction); - const canHoldOrUnholdRequest = !isSettled && !isApproved && !isDeletedParentAction; + const canHoldOrUnholdRequest = !isSettled && !isApproved && !isDeletedParentAction && !ReportUtils.isArchivedRoom(parentReport); // If the report supports adding transactions to it, then it also supports deleting transactions from it. const canDeleteRequest = isActionOwner && (ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) || ReportUtils.isTrackExpenseReport(report)) && !isDeletedParentAction; diff --git a/src/components/OptionsPicker/OptionItem.tsx b/src/components/OptionsPicker/OptionItem.tsx new file mode 100644 index 00000000000..a787c20f515 --- /dev/null +++ b/src/components/OptionsPicker/OptionItem.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; +import SelectCircle from '@components/SelectCircle'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type OptionItemProps = { + /** Text to be rendered */ + title: TranslationPaths; + + /** Icon to be displayed above the title */ + icon: IconAsset; + + /** Press handler */ + onPress?: () => void; + + /** Indicates whether the option is currently selected (active) */ + isSelected?: boolean; + + /** Indicates whether the option is disabled */ + isDisabled?: boolean; + + /** Optional style prop */ + style?: StyleProp; +}; + +function OptionItem({title, icon, onPress, isSelected = false, isDisabled, style}: OptionItemProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return ( + + + + + + {!isDisabled && ( + + + + )} + + + {translate(title)} + + + + + ); +} + +OptionItem.displayName = 'OptionItem'; + +export default OptionItem; diff --git a/src/components/OptionsPicker/index.tsx b/src/components/OptionsPicker/index.tsx new file mode 100644 index 00000000000..621b8465adb --- /dev/null +++ b/src/components/OptionsPicker/index.tsx @@ -0,0 +1,61 @@ +import React, {Fragment} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {TranslationPaths} from '@src/languages/types'; +import type IconAsset from '@src/types/utils/IconAsset'; +import OptionItem from './OptionItem'; + +type OptionsPickerItem = { + /** A unique identifier for each option */ + key: TKey; + + /** Text to be displayed */ + title: TranslationPaths; + + /** Icon to be displayed above the title */ + icon: IconAsset; +}; + +type OptionsPickerProps = { + /** Options list */ + options: Array>; + + /** Selected option's identifier */ + selectedOption: TKey; + + /** Option select handler */ + onOptionSelected: (option: TKey) => void; + + /** Indicates whether the picker is disabled */ + isDisabled?: boolean; + + /** Optional style */ + style?: StyleProp; +}; + +function OptionsPicker({options, selectedOption, onOptionSelected, style, isDisabled}: OptionsPickerProps) { + const styles = useThemeStyles(); + + return ( + + {options.map((option, index) => ( + + onOptionSelected(option.key)} + /> + {index < options.length - 1 && } + + ))} + + ); +} + +OptionsPicker.displayName = 'OptionsPicker'; + +export default OptionsPicker; +export type {OptionsPickerItem}; diff --git a/src/components/PDFThumbnail/PDFThumbnailError.tsx b/src/components/PDFThumbnail/PDFThumbnailError.tsx new file mode 100644 index 00000000000..0598a995e03 --- /dev/null +++ b/src/components/PDFThumbnail/PDFThumbnailError.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; + +function PDFThumbnailError() { + const styles = useThemeStyles(); + const theme = useTheme(); + + return ( + + + + ); +} + +export default PDFThumbnailError; diff --git a/src/components/PDFThumbnail/index.native.tsx b/src/components/PDFThumbnail/index.native.tsx index 0232dba99f0..27d41ede326 100644 --- a/src/components/PDFThumbnail/index.native.tsx +++ b/src/components/PDFThumbnail/index.native.tsx @@ -1,19 +1,21 @@ -import React from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import Pdf from 'react-native-pdf'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import useThemeStyles from '@hooks/useThemeStyles'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import PDFThumbnailError from './PDFThumbnailError'; import type PDFThumbnailProps from './types'; -function PDFThumbnail({previewSourceURL, style, isAuthTokenRequired = false, enabled = true, onPassword}: PDFThumbnailProps) { +function PDFThumbnail({previewSourceURL, style, isAuthTokenRequired = false, enabled = true, onPassword, onLoadError}: PDFThumbnailProps) { const styles = useThemeStyles(); const sizeStyles = [styles.w100, styles.h100]; + const [failedToLoad, setFailedToLoad] = useState(false); return ( - - {enabled && ( + + {enabled && !failedToLoad && ( { - if (!('message' in error && typeof error.message === 'string' && error.message.match(/password/i))) { - return; + if (onLoadError) { + onLoadError(); } - if (!onPassword) { + if ('message' in error && typeof error.message === 'string' && error.message.match(/password/i) && onPassword) { + onPassword(); return; } - onPassword(); + setFailedToLoad(true); }} /> )} + {failedToLoad && } ); diff --git a/src/components/PDFThumbnail/index.tsx b/src/components/PDFThumbnail/index.tsx index ce631f3b611..8e79c027cf0 100644 --- a/src/components/PDFThumbnail/index.tsx +++ b/src/components/PDFThumbnail/index.tsx @@ -1,18 +1,20 @@ import pdfWorkerSource from 'pdfjs-dist/legacy/build/pdf.worker'; -import React, {useMemo} from 'react'; +import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import {Document, pdfjs, Thumbnail} from 'react-pdf'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import useThemeStyles from '@hooks/useThemeStyles'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import PDFThumbnailError from './PDFThumbnailError'; import type PDFThumbnailProps from './types'; if (!pdfjs.GlobalWorkerOptions.workerSrc) { pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(new Blob([pdfWorkerSource], {type: 'text/javascript'})); } -function PDFThumbnail({previewSourceURL, style, isAuthTokenRequired = false, enabled = true, onPassword}: PDFThumbnailProps) { +function PDFThumbnail({previewSourceURL, style, isAuthTokenRequired = false, enabled = true, onPassword, onLoadError}: PDFThumbnailProps) { const styles = useThemeStyles(); + const [failedToLoad, setFailedToLoad] = useState(false); const thumbnail = useMemo( () => ( @@ -25,18 +27,31 @@ function PDFThumbnail({previewSourceURL, style, isAuthTokenRequired = false, ena }} externalLinkTarget="_blank" onPassword={onPassword} + onLoad={() => { + setFailedToLoad(false); + }} + onLoadError={() => { + if (onLoadError) { + onLoadError(); + } + setFailedToLoad(true); + }} + error={() => null} > ), - [isAuthTokenRequired, previewSourceURL, onPassword], + [isAuthTokenRequired, previewSourceURL, onPassword, onLoadError], ); return ( - - {enabled && thumbnail} + + + {enabled && !failedToLoad && thumbnail} + {failedToLoad && } + ); } diff --git a/src/components/PDFThumbnail/types.ts b/src/components/PDFThumbnail/types.ts index 11253e462ac..349669ecc33 100644 --- a/src/components/PDFThumbnail/types.ts +++ b/src/components/PDFThumbnail/types.ts @@ -15,6 +15,9 @@ type PDFThumbnailProps = { /** Callback to call if PDF is password protected */ onPassword?: () => void; + + /** Callback to call if PDF can't be loaded(corrupted) */ + onLoadError?: () => void; }; export default PDFThumbnailProps; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3d0964a0d57..2987638c759 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -596,7 +596,7 @@ export default withOnyx `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, - selector: DistanceRequestUtils.getMileageRates, + selector: (policy: OnyxEntry) => DistanceRequestUtils.getMileageRates(policy, true), }, })( withOnyx({ diff --git a/src/components/ReportActionItem/TripDetailsView.tsx b/src/components/ReportActionItem/TripDetailsView.tsx new file mode 100644 index 00000000000..999ef4345e5 --- /dev/null +++ b/src/components/ReportActionItem/TripDetailsView.tsx @@ -0,0 +1,171 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import SpacerView from '@components/SpacerView'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import DateUtils from '@libs/DateUtils'; +import variables from '@styles/variables'; +import * as Expensicons from '@src/components/Icon/Expensicons'; +import CONST from '@src/CONST'; +import * as ReportUtils from '@src/libs/ReportUtils'; +import * as TripReservationUtils from '@src/libs/TripReservationUtils'; +import type {Reservation, ReservationTimeDetails} from '@src/types/onyx/Transaction'; + +type TripDetailsViewProps = { + /** The active tripRoomReportID, used for Onyx subscription */ + tripRoomReportID?: string; + + /** Whether we should display the horizontal rule below the component */ + shouldShowHorizontalRule: boolean; +}; + +type ReservationViewProps = { + reservation: Reservation; +}; + +function ReservationView({reservation}: ReservationViewProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const reservationIcon = TripReservationUtils.getTripReservationIcon(reservation.type); + + const formatAirportInfo = (reservationTimeDetails: ReservationTimeDetails) => { + const longName = reservationTimeDetails?.longName ? `${reservationTimeDetails?.longName} ` : ''; + let shortName = reservationTimeDetails?.shortName ? `${reservationTimeDetails?.shortName}` : ''; + + shortName = longName && shortName ? `(${shortName})` : shortName; + + return `${longName}${shortName}`; + }; + + const getFormattedDate = () => { + switch (reservation.type) { + case CONST.RESERVATION_TYPE.FLIGHT: + return DateUtils.getFormattedTransportDate(new Date(reservation.start.date)); + case CONST.RESERVATION_TYPE.HOTEL: + case CONST.RESERVATION_TYPE.CAR: + return DateUtils.getFormattedReservationRangeDate(new Date(reservation.start.date), new Date(reservation.end.date)); + default: + return DateUtils.formatToLongDateWithWeekday(new Date(reservation.start.date)); + } + }; + + const formattedDate = getFormattedDate(); + + const bottomDescription = useMemo(() => { + const code = `${reservation.confirmations && reservation.confirmations?.length > 0 ? `${reservation.confirmations[0].value} • ` : ''}`; + if (reservation.type === CONST.RESERVATION_TYPE.FLIGHT) { + const longName = reservation.company?.longName ? `${reservation.company?.longName} • ` : ''; + const shortName = reservation?.company?.shortName ? `${reservation?.company?.shortName} ` : ''; + return `${code}${longName}${shortName}${reservation.route?.number}`; + } + if (reservation.type === CONST.RESERVATION_TYPE.HOTEL) { + return `${code}${reservation.start.address}`; + } + if (reservation.type === CONST.RESERVATION_TYPE.CAR) { + const vendor = reservation.vendor ? `${reservation.vendor} • ` : ''; + return `${vendor}${reservation.start.location}`; + } + return reservation.start.address ?? reservation.start.location; + }, [reservation]); + + const titleComponent = () => { + if (reservation.type === CONST.RESERVATION_TYPE.FLIGHT) { + return ( + + + {formatAirportInfo(reservation.start)} + + {formatAirportInfo(reservation.end)} + + {bottomDescription && {bottomDescription}} + + ); + } + + return ( + + + {reservation.type === CONST.RESERVATION_TYPE.CAR ? reservation.carInfo?.name : reservation.start.longName} + + {bottomDescription && {bottomDescription}} + + ); + }; + + return ( + {}} + iconHeight={20} + iconWidth={20} + iconStyles={[styles.tripReservationIconContainer, styles.mr3]} + secondaryIconFill={theme.icon} + hoverAndPressStyle={styles.hoveredComponentBG} + /> + ); +} + +function TripDetailsView({tripRoomReportID, shouldShowHorizontalRule}: TripDetailsViewProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const tripTransactions = ReportUtils.getTripTransactions(tripRoomReportID); + const reservations: Reservation[] = TripReservationUtils.getReservationsFromTripTransactions(tripTransactions); + + return ( + + + + + {translate('travel.tripSummary')} + + + + <> + {reservations.map((reservation) => ( + + + + ))} + + + + ); +} + +TripDetailsView.displayName = 'TripDetailsView'; + +export default TripDetailsView; diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index c1a38cf6e7c..cfd047ffd11 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -52,7 +52,7 @@ function ReportListItem({ const totalCell = ( ); @@ -130,7 +130,7 @@ function ReportListItem({ onButtonPress={handleOnButtonPress} /> )} - + @@ -140,9 +140,12 @@ function ReportListItem({ {totalCell} - {/** styles.reportListItemActionButtonMargin added here to move the action button by the type column distance */} {isLargeScreenWidth && ( - {actionCell} + <> + {/** We add an empty view with type style to align the total with the table header */} + + {actionCell} + )} @@ -156,6 +159,7 @@ function ReportListItem({ showItemHeaderOnNarrowLayout={false} containerStyle={styles.mt3} isHovered={hovered} + isChildListItem /> ))} diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index cb1ef3fdc6e..7a1fa8709c4 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -1,4 +1,4 @@ -import React, {memo} from 'react'; +import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Button from '@components/Button'; @@ -26,8 +26,6 @@ type CellProps = { // eslint-disable-next-line react/no-unused-prop-types showTooltip: boolean; // eslint-disable-next-line react/no-unused-prop-types - keyForList: string; - // eslint-disable-next-line react/no-unused-prop-types isLargeScreenWidth: boolean; }; @@ -43,6 +41,10 @@ type ActionCellProps = { onButtonPress: () => void; } & CellProps; +type TotalCellProps = { + isChildListItem: boolean; +} & TransactionCellProps; + type TransactionListItemRowProps = { item: TransactionListItemType; showTooltip: boolean; @@ -50,6 +52,7 @@ type TransactionListItemRowProps = { showItemHeaderOnNarrowLayout?: boolean; containerStyle?: StyleProp; isHovered?: boolean; + isChildListItem?: boolean; }; const getTypeIcon = (type?: SearchTransactionType) => { @@ -65,15 +68,7 @@ const getTypeIcon = (type?: SearchTransactionType) => { } }; -function arePropsEqual(prevProps: CellProps, nextProps: CellProps) { - return prevProps.keyForList === nextProps.keyForList; -} - -function areReceiptPropsEqual(prevProps: ReceiptCellProps, nextProps: ReceiptCellProps) { - return prevProps.keyForList === nextProps.keyForList && prevProps.isHovered === nextProps.isHovered; -} - -const ReceiptCell = memo(({transactionItem, isHovered = false}: ReceiptCellProps) => { +function ReceiptCell({transactionItem, isHovered = false}: ReceiptCellProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -94,9 +89,9 @@ const ReceiptCell = memo(({transactionItem, isHovered = false}: ReceiptCellProps /> ); -}, areReceiptPropsEqual); +} -const DateCell = memo(({transactionItem, showTooltip, isLargeScreenWidth}: TransactionCellProps) => { +function DateCell({transactionItem, showTooltip, isLargeScreenWidth}: TransactionCellProps) { const styles = useThemeStyles(); const date = TransactionUtils.getCreated(transactionItem, CONST.DATE.MONTH_DAY_ABBR_FORMAT); @@ -107,9 +102,9 @@ const DateCell = memo(({transactionItem, showTooltip, isLargeScreenWidth}: Trans style={[styles.label, styles.pre, styles.justifyContentCenter, isLargeScreenWidth ? undefined : [styles.textMicro, styles.textSupporting]]} /> ); -}, arePropsEqual); +} -const MerchantCell = memo(({transactionItem, showTooltip}: TransactionCellProps) => { +function MerchantCell({transactionItem, showTooltip}: TransactionCellProps) { const styles = useThemeStyles(); const description = TransactionUtils.getDescription(transactionItem); @@ -120,9 +115,9 @@ const MerchantCell = memo(({transactionItem, showTooltip}: TransactionCellProps) style={[styles.label, styles.pre, styles.justifyContentCenter]} /> ); -}, arePropsEqual); +} -const TotalCell = memo(({showTooltip, isLargeScreenWidth, transactionItem}: TransactionCellProps) => { +function TotalCell({showTooltip, isLargeScreenWidth, transactionItem, isChildListItem}: TotalCellProps) { const styles = useThemeStyles(); const currency = TransactionUtils.getCurrency(transactionItem); @@ -130,12 +125,12 @@ const TotalCell = memo(({showTooltip, isLargeScreenWidth, transactionItem}: Tran ); -}, arePropsEqual); +} -const TypeCell = memo(({transactionItem, isLargeScreenWidth}: TransactionCellProps) => { +function TypeCell({transactionItem, isLargeScreenWidth}: TransactionCellProps) { const theme = useTheme(); const typeIcon = getTypeIcon(transactionItem.type); @@ -147,9 +142,9 @@ const TypeCell = memo(({transactionItem, isLargeScreenWidth}: TransactionCellPro width={isLargeScreenWidth ? 20 : 12} /> ); -}, arePropsEqual); +} -const ActionCell = memo(({onButtonPress}: ActionCellProps) => { +function ActionCell({onButtonPress}: ActionCellProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -162,9 +157,9 @@ const ActionCell = memo(({onButtonPress}: ActionCellProps) => { style={[styles.p0]} /> ); -}, arePropsEqual); +} -const CategoryCell = memo(({isLargeScreenWidth, showTooltip, transactionItem}: TransactionCellProps) => { +function CategoryCell({isLargeScreenWidth, showTooltip, transactionItem}: TransactionCellProps) { const styles = useThemeStyles(); return isLargeScreenWidth ? ( ); -}, arePropsEqual); +} -const TagCell = memo(({isLargeScreenWidth, showTooltip, transactionItem}: TransactionCellProps) => { +function TagCell({isLargeScreenWidth, showTooltip, transactionItem}: TransactionCellProps) { const styles = useThemeStyles(); return isLargeScreenWidth ? ( ); -}, arePropsEqual); +} -const TaxCell = memo(({transactionItem, showTooltip}: TransactionCellProps) => { +function TaxCell({transactionItem, showTooltip}: TransactionCellProps) { const styles = useThemeStyles(); const isFromExpenseReport = transactionItem.reportType === CONST.REPORT.TYPE.EXPENSE; @@ -212,9 +207,17 @@ const TaxCell = memo(({transactionItem, showTooltip}: TransactionCellProps) => { style={[styles.optionDisplayName, styles.label, styles.pre, styles.justifyContentCenter, styles.textAlignRight]} /> ); -}, arePropsEqual); +} -function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeaderOnNarrowLayout = true, containerStyle, isHovered = false}: TransactionListItemRowProps) { +function TransactionListItemRow({ + item, + showTooltip, + onButtonPress, + showItemHeaderOnNarrowLayout = true, + containerStyle, + isHovered = false, + isChildListItem = false, +}: TransactionListItemRowProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isLargeScreenWidth} = useWindowDimensions(); @@ -237,7 +240,6 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade @@ -269,20 +268,18 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade showTooltip={showTooltip} transactionItem={item} isLargeScreenWidth={isLargeScreenWidth} - keyForList={item.keyForList ?? ''} + isChildListItem={isChildListItem} /> @@ -297,7 +294,6 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade @@ -334,7 +328,6 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade {item.shouldShowCategory && ( @@ -367,21 +358,19 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade showTooltip={showTooltip} transactionItem={item} isLargeScreenWidth={isLargeScreenWidth} - keyForList={item.keyForList ?? ''} + isChildListItem={isChildListItem} /> diff --git a/src/components/SelectionList/UserListItem.tsx b/src/components/SelectionList/UserListItem.tsx index a517a9f1ca1..8fe9518c7d9 100644 --- a/src/components/SelectionList/UserListItem.tsx +++ b/src/components/SelectionList/UserListItem.tsx @@ -30,6 +30,7 @@ function UserListItem({ rightHandSideComponent, onFocus, shouldSyncFocus, + pressableStyle, }: UserListItemProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -63,6 +64,7 @@ function UserListItem({ rightHandSideComponent={rightHandSideComponent} errors={item.errors} pendingAction={item.pendingAction} + pressableStyle={pressableStyle} FooterComponent={ item.invitedSecondaryLogin ? ( diff --git a/src/hooks/useDeepCompareRef.ts b/src/hooks/useDeepCompareRef.ts new file mode 100644 index 00000000000..7511c1516a1 --- /dev/null +++ b/src/hooks/useDeepCompareRef.ts @@ -0,0 +1,24 @@ +import isEqual from 'lodash/isEqual'; +import {useRef} from 'react'; + +/** + * This hook returns a reference to the provided value, + * but only updates that reference if a deep comparison indicates that the value has changed. + * + * This is useful when working with objects or arrays as dependencies to other hooks like `useEffect` or `useMemo`, + * where you want the hook to trigger not just on reference changes, but also when the contents of the object or array change. + * + * @example + * const myArray = // some array + * const deepComparedArray = useDeepCompareRef(myArray); + * useEffect(() => { + * // This will run not just when myArray is a new array, but also when its contents change. + * }, [deepComparedArray]); + */ +export default function useDeepCompareRef(value: T): T | undefined { + const ref = useRef(); + if (!isEqual(value, ref.current)) { + ref.current = value; + } + return ref.current; +} diff --git a/src/hooks/useResponsiveLayout.ts b/src/hooks/useResponsiveLayout.ts index 18b29653479..a26d50bc56b 100644 --- a/src/hooks/useResponsiveLayout.ts +++ b/src/hooks/useResponsiveLayout.ts @@ -1,8 +1,8 @@ -import {useContext} from 'react'; +import {NavigationContainerRefContext, NavigationContext} from '@react-navigation/native'; +import {useContext, useMemo} from 'react'; import ModalContext from '@components/Modal/ModalContext'; -import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; -import useRootNavigationState from './useRootNavigationState'; +import NAVIGATORS from '@src/NAVIGATORS'; import useWindowDimensions from './useWindowDimensions'; type ResponsiveLayoutResult = { @@ -38,8 +38,20 @@ export default function useResponsiveLayout(): ResponsiveLayoutResult { // This means it will only be defined if the component calling this hook is a child of a modal component. See BaseModal for the provider. const {activeModalType} = useContext(ModalContext); - // This refers to the state of the root navigator, and is true if and only if the topmost navigator is the "left modal navigator" or the "right modal navigator" - const isDisplayedInModalNavigator = !!useRootNavigationState(Navigation.isModalNavigatorActive); + // We are using these contexts directly instead of useNavigation/useNavigationState, because those will throw an error if used outside a navigator. + // This hook can be used within or outside a navigator, so using useNavigationState does not work. + // Furthermore, wrapping useNavigationState in a try/catch does not work either, because that breaks the rules of hooks. + // Note that these three lines are copied closely from the internal implementation of useNavigation: https://github.com/react-navigation/react-navigation/blob/52a3234b7aaf4d4fcc9c0155f44f3ea2233f0f40/packages/core/src/useNavigation.tsx#L18-L28 + const navigationContainerRef = useContext(NavigationContainerRefContext); + const navigator = useContext(NavigationContext); + const currentNavigator = navigator ?? navigationContainerRef; + + const isDisplayedInNarrowModalNavigator = useMemo( + () => + !!currentNavigator?.getParent?.(NAVIGATORS.RIGHT_MODAL_NAVIGATOR as unknown as undefined) || + !!currentNavigator?.getParent?.(NAVIGATORS.LEFT_MODAL_NAVIGATOR as unknown as undefined), + [currentNavigator], + ); // The component calling this hook is in a "narrow pane modal" if: const isInNarrowPaneModal = @@ -47,7 +59,7 @@ export default function useResponsiveLayout(): ResponsiveLayoutResult { activeModalType === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED || // or there's a "right modal navigator" or "left modal navigator" on the top of the root navigation stack // and the component calling this hook is not the child of another modal type, such as a confirm modal - (isDisplayedInModalNavigator && !activeModalType); + (isDisplayedInNarrowModalNavigator && !activeModalType); const shouldUseNarrowLayout = isSmallScreenWidth || isInNarrowPaneModal; diff --git a/src/hooks/useRootNavigationState.ts b/src/hooks/useRootNavigationState.ts deleted file mode 100644 index 3f03e1ceb25..00000000000 --- a/src/hooks/useRootNavigationState.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type {EventListenerCallback, NavigationContainerEventMap} from '@react-navigation/native'; -import type {NavigationState} from '@react-navigation/routers'; -import {useCallback, useSyncExternalStore} from 'react'; -import {navigationRef} from '@libs/Navigation/Navigation'; - -/** - * This hook is a replacement for `useNavigationState` for nested navigators. - * If `useNavigationState` is used within a nested navigator then the state that's returned is the state of the nearest parent navigator, - * not the root navigator state representing the whole app's navigation tree. - * - * Use with caution, because re-rendering any component every time the root navigation state changes can be very costly for performance. - * That's why the selector is mandatory. - */ -function useRootNavigationState(selector: (state: NavigationState) => T): T | undefined { - const getSnapshot = useCallback(() => { - if (!navigationRef?.current) { - return; - } - return selector(navigationRef.current.getRootState()); - }, [selector]); - - const subscribeToRootState = useCallback((callback: EventListenerCallback) => { - const unsubscribe = navigationRef?.current?.addListener('state', callback); - return () => unsubscribe?.(); - }, []); - - return useSyncExternalStore(subscribeToRootState, getSnapshot); -} - -export default useRootNavigationState; diff --git a/src/languages/en.ts b/src/languages/en.ts index c69531a7ab1..8ed0ef8207f 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -225,6 +225,7 @@ export default { tomorrowAt: 'Tomorrow at', yesterdayAt: 'Yesterday at', conjunctionAt: 'at', + conjunctionTo: 'to', genericErrorMessage: 'Oops... something went wrong and your request could not be completed. Please try again later.', error: { invalidAmount: 'Invalid amount.', @@ -355,7 +356,7 @@ export default { expensifyDoesntHaveAccessToCamera: "Expensify can't take photos without access to your camera. Tap Settings to update permissions.", attachmentError: 'Attachment error', errorWhileSelectingAttachment: 'An error occurred while selecting an attachment, please try again.', - errorWhileSelectingCorruptedImage: 'An error occurred while selecting a corrupted attachment, please try another file.', + errorWhileSelectingCorruptedAttachment: 'An error occurred while selecting a corrupted attachment, please try another file.', takePhoto: 'Take photo', chooseFromGallery: 'Choose from gallery', chooseDocument: 'Choose document', @@ -734,6 +735,8 @@ export default { other: 'Unexpected error, please try again later.', genericCreateFailureMessage: 'Unexpected error submitting this expense. Please try again later.', genericCreateInvoiceFailureMessage: 'Unexpected error sending invoice, please try again later.', + genericHoldExpenseFailureMessage: 'Unexpected error while holding the expense. Please try again later.', + genericUnholdExpenseFailureMessage: 'Unexpected error while taking the expense off hold. Please try again later.', receiptDeleteFailureError: 'Unexpected error deleting this receipt. Please try again later.', // eslint-disable-next-line rulesdir/use-periods-for-error-messages receiptFailureMessage: "The receipt didn't upload. ", @@ -1888,6 +1891,13 @@ export default { agree: 'I agree to the travel ', error: 'You must accept the Terms & Conditions for travel to continue', }, + flight: 'Flight', + hotel: 'Hotel', + car: 'Car', + viewTrip: 'View trip', + trip: 'Trip', + tripSummary: 'Trip summary', + departs: 'Departs', }, workspace: { common: { @@ -3191,6 +3201,16 @@ export default { saveWithExpensifyDescription: 'Use our savings calculator to see how cash back from the Expensify Card can reduce your Expensify bill.', saveWithExpensifyButton: 'Learn more', }, + details: { + title: 'Subscription details', + annual: 'Annual subscription', + payPerUse: 'Pay-per-use', + subscriptionSize: 'Subscription size', + headsUpTitle: 'Heads up: ', + headsUpBody: + "If you don’t set your subscription size now, we’ll set it automatically to your first month's active member count. You’ll then be committed to paying for at least this number of members for the next 12 months. You can increase your subscription size at any time, but you can’t decrease it until your subscription is over.", + zeroCommitment: 'Zero commitment at the discounted annual subscription rate', + }, subscriptionSize: { title: 'Subscription size', yourSize: 'Your subscription size is the number of open seats that can be filled by any active member in a given month.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 3e8a4a00d4f..2316a5d09c9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -215,6 +215,7 @@ export default { tomorrowAt: 'Mañana a las', yesterdayAt: 'Ayer a las', conjunctionAt: 'a', + conjunctionTo: 'a', genericErrorMessage: 'Ups... algo no ha ido bien y la acción no se ha podido completar. Por favor, inténtalo más tarde.', error: { invalidAmount: 'Importe no válido.', @@ -325,9 +326,9 @@ export default { action: 'Acción', expenses: 'Gastos', tax: 'Impuesto', - shared: 'Compartido', + shared: 'Compartidos', drafts: 'Borradores', - finished: 'Finalizado', + finished: 'Finalizados', }, connectionComplete: { title: 'Conexión Completa', @@ -349,7 +350,7 @@ export default { expensifyDoesntHaveAccessToCamera: 'Expensify no puede tomar fotos sin acceso a la cámara. Haz click en Configuración para actualizar los permisos.', attachmentError: 'Error al adjuntar archivo', errorWhileSelectingAttachment: 'Ha ocurrido un error al seleccionar un archivo adjunto. Por favor, inténtalo de nuevo.', - errorWhileSelectingCorruptedImage: 'Ha ocurrido un error al seleccionar un archivo adjunto corrupto. Por favor, inténtalo con otro archivo.', + errorWhileSelectingCorruptedAttachment: 'Ha ocurrido un error al seleccionar un archivo adjunto corrupto. Por favor, inténtalo con otro archivo.', takePhoto: 'Hacer una foto', chooseFromGallery: 'Elegir de la galería', chooseDocument: 'Elegir documento', @@ -727,6 +728,8 @@ export default { invalidSplit: 'La suma de las partes debe ser igual al importe total.', invalidSplitParticipants: 'Introduce un importe superior a cero para al menos dos participantes.', other: 'Error inesperado, por favor inténtalo más tarde.', + genericHoldExpenseFailureMessage: 'Error inesperado al bloquear el gasto, por favor inténtalo de nuevo más tarde.', + genericUnholdExpenseFailureMessage: 'Error inesperado al desbloquear el gasto, por favor inténtalo de nuevo más tarde.', genericCreateFailureMessage: 'Error inesperado al enviar este gasto. Por favor, inténtalo más tarde.', genericCreateInvoiceFailureMessage: 'Error inesperado al enviar la factura, inténtalo de nuevo más tarde.', receiptDeleteFailureError: 'Error inesperado al borrar este recibo. Vuelve a intentarlo más tarde.', @@ -1912,6 +1915,13 @@ export default { agree: 'Acepto los ', error: 'Debes aceptar los Términos y condiciones para que el viaje continúe', }, + flight: 'Vuelo', + hotel: 'Hotel', + car: 'Auto', + viewTrip: 'Ver viaje', + trip: 'Viaje', + tripSummary: 'Resumen del viaje', + departs: 'Sale', }, workspace: { common: { @@ -3697,6 +3707,16 @@ export default { saveWithExpensifyDescription: 'Utiliza nuestra calculadora de ahorro para ver cómo el reembolso en efectivo de la Tarjeta Expensify puede reducir tu factura de Expensify', saveWithExpensifyButton: 'Más información', }, + details: { + title: 'Datos de suscripción', + annual: 'Suscripción anual', + payPerUse: 'Pago por uso', + subscriptionSize: 'Tamaño de suscripción', + headsUpTitle: 'Atención: ', + headsUpBody: + 'Si no estableces ahora el tamaño de tu suscripción, lo haremos automáticamente con el número de suscriptores activos del primer mes. A partir de ese momento, estarás suscrito para pagar al menos por ese número de afiliados durante los 12 meses siguientes. Puedes aumentar el tamaño de tu suscripción en cualquier momento, pero no puedes reducirlo hasta que finalice tu suscripción.', + zeroCommitment: 'Compromiso cero con la tarifa de suscripción anual reducida', + }, subscriptionSize: { title: 'Tamaño de suscripción', yourSize: 'El tamaño de tu suscripción es el número de plazas abiertas que puede ocupar cualquier miembro activo en un mes determinado.', diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index c91ea447540..6ca80d52568 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -529,7 +529,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE]: Parameters.OpenPolicyMoreFeaturesPageParams; [READ_COMMANDS.OPEN_POLICY_ACCOUNTING_PAGE]: Parameters.OpenPolicyAccountingPageParams; [READ_COMMANDS.SEARCH]: Parameters.SearchParams; - [READ_COMMANDS.OPEN_SUBSCRIPTION_PAGE]: EmptyObject; + [READ_COMMANDS.OPEN_SUBSCRIPTION_PAGE]: null; }; const SIDE_EFFECT_REQUEST_COMMANDS = { diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index ec242383308..d3660c5f16f 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -87,9 +87,22 @@ function convertToBackendAmount(amountAsFloat: number): number { * * @note we do not support any currencies with more than two decimal places. */ -function convertToFrontendAmount(amountAsInt: number): number { +function convertToFrontendAmountAsInteger(amountAsInt: number): number { return Math.trunc(amountAsInt) / 100.0; } + +/** + * Takes an amount in "cents" as an integer and converts it to a string amount used in the frontend. + * + * @note we do not support any currencies with more than two decimal places. + */ +function convertToFrontendAmountAsString(amountAsInt: number | null | undefined): string { + if (amountAsInt === null || amountAsInt === undefined) { + return ''; + } + return convertToFrontendAmountAsInteger(amountAsInt).toFixed(2); +} + /** * Given an amount in the "cents", convert it to a string for display in the UI. * The backend always handle things in "cents" (subunit equal to 1/100) @@ -98,7 +111,7 @@ function convertToFrontendAmount(amountAsInt: number): number { * @param currency - IOU currency */ function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD): string { - const convertedAmount = convertToFrontendAmount(amountInCents); + const convertedAmount = convertToFrontendAmountAsInteger(amountInCents); return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', currency, @@ -128,7 +141,7 @@ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRE * Acts the same as `convertAmountToDisplayString` but the result string does not contain currency */ function convertToDisplayStringWithoutCurrency(amountInCents: number, currency: string = CONST.CURRENCY.USD) { - const convertedAmount = convertToFrontendAmount(amountInCents); + const convertedAmount = convertToFrontendAmountAsInteger(amountInCents); return NumberFormatUtils.formatToParts(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', currency, @@ -158,7 +171,8 @@ export { getCurrencySymbol, isCurrencySymbolLTR, convertToBackendAmount, - convertToFrontendAmount, + convertToFrontendAmountAsInteger, + convertToFrontendAmountAsString, convertToDisplayString, convertAmountToDisplayString, convertToDisplayStringWithoutCurrency, diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 8bd37ddd698..50cb9a20dff 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -15,8 +15,10 @@ import { isAfter, isBefore, isSameDay, + isSameMonth, isSameSecond, isSameYear, + isThisYear, isValid, parse, set, @@ -728,6 +730,73 @@ function getLastBusinessDayOfMonth(inputDate: Date): number { return getDate(currentDate); } +/** + * Returns a formatted date range from date 1 to date 2. + * Dates are formatted as follows: + * 1. When both dates refer to the same day: Mar 17 + * 2. When both dates refer to the same month: Mar 17-20 + * 3. When both dates refer to the same year: Feb 28 to Mar 1 + * 4. When the dates are from different years: Dec 28, 2023 to Jan 5, 2024 + */ +function getFormattedDateRange(date1: Date, date2: Date): string { + const {translateLocal} = Localize; + + if (isSameDay(date1, date2)) { + // Dates are from the same day + return format(date1, 'MMM d'); + } + if (isSameMonth(date1, date2)) { + // Dates in the same month and year, differ by days + return `${format(date1, 'MMM d')}-${format(date2, 'd')}`; + } + if (isSameYear(date1, date2)) { + // Dates are in the same year, differ by months + return `${format(date1, 'MMM d')} ${translateLocal('common.to').toLowerCase()} ${format(date2, 'MMM d')}`; + } + // Dates differ by years, months, days + return `${format(date1, 'MMM d, yyyy')} ${translateLocal('common.to').toLowerCase()} ${format(date2, 'MMM d, yyyy')}`; +} + +/** + * Returns a formatted date range from date 1 to date 2 of a reservation. + * Dates are formatted as follows: + * 1. When both dates refer to the same day and the current year: Sunday, Mar 17 + * 2. When both dates refer to the same day but not the current year: Wednesday, Mar 17, 2023 + * 3. When both dates refer to the current year: Sunday, Mar 17 to Wednesday, Mar 20 + * 4. When the dates are from different years or from a year which is not current: Wednesday, Mar 17, 2023 to Saturday, Jan 20, 2024 + */ +function getFormattedReservationRangeDate(date1: Date, date2: Date): string { + const {translateLocal} = Localize; + if (isSameDay(date1, date2) && isThisYear(date1)) { + // Dates are from the same day + return format(date1, 'EEEE, MMM d'); + } + if (isSameDay(date1, date2)) { + // Dates are from the same day but not this year + return format(date1, 'EEEE, MMM d, yyyy'); + } + if (isSameYear(date1, date2) && isThisYear(date1)) { + // Dates are in the current year, differ by months + return `${format(date1, 'EEEE, MMM d')} ${translateLocal('common.conjunctionTo')} ${format(date2, 'EEEE, MMM d')}`; + } + // Dates differ by years, months, days or only by months but the year is not current + return `${format(date1, 'EEEE, MMM d, yyyy')} ${translateLocal('common.conjunctionTo')} ${format(date2, 'EEEE, MMM d, yyyy')}`; +} + +/** + * Returns a formatted date of departure. + * Dates are formatted as follows: + * 1. When the date refers to the current day: Departs on Sunday, Mar 17 at 8:00 + * 2. When the date refers not to the current day: Departs on Wednesday, Mar 17, 2023 at 8:00 + */ +function getFormattedTransportDate(date: Date): string { + const {translateLocal} = Localize; + if (isThisYear(date)) { + return `${translateLocal('travel.departs')} ${format(date, 'EEEE, MMM d')} ${translateLocal('common.conjunctionAt')} ${format(date, 'HH:MM')}`; + } + return `${translateLocal('travel.departs')} ${format(date, 'EEEE, MMM d, yyyy')} ${translateLocal('common.conjunctionAt')} ${format(date, 'HH:MM')}`; +} + const DateUtils = { formatToDayOfWeek, formatToLongDateWithWeekday, @@ -768,6 +837,9 @@ const DateUtils = { formatToSupportedTimezone, enrichMoneyRequestTimestamp, getLastBusinessDayOfMonth, + getFormattedDateRange, + getFormattedReservationRangeDate, + getFormattedTransportDate, }; export default DateUtils; diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 0ebcc486997..ed4a6a73c9b 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -8,6 +8,7 @@ import type {LastSelectedDistanceRates, Report} from '@src/types/onyx'; import type {Unit} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import * as CurrencyUtils from './CurrencyUtils'; import * as PolicyUtils from './PolicyUtils'; import * as ReportUtils from './ReportUtils'; @@ -38,6 +39,35 @@ Onyx.connect({ const METERS_TO_KM = 0.001; // 1 kilometer is 1000 meters const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 miles in a meter +function getMileageRates(policy: OnyxEntry, includeDisabledRates = false): Record { + const mileageRates: Record = {}; + + if (!policy || !policy?.customUnits) { + return mileageRates; + } + + const distanceUnit = PolicyUtils.getCustomUnit(policy); + if (!distanceUnit?.rates) { + return mileageRates; + } + + Object.entries(distanceUnit.rates).forEach(([rateID, rate]) => { + if (!includeDisabledRates && !rate.enabled) { + return; + } + + mileageRates[rateID] = { + rate: rate.rate, + currency: rate.currency, + unit: distanceUnit.attributes.unit, + name: rate.name, + customUnitRateID: rate.customUnitRateID, + }; + }); + + return mileageRates; +} + /** * Retrieves the default mileage rate based on a given policy. * @@ -49,7 +79,7 @@ const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 mile * @returns [unit] - The unit of measurement for the distance. */ function getDefaultMileageRate(policy: OnyxEntry | EmptyObject): MileageRate | null { - if (!policy?.customUnits) { + if (isEmptyObject(policy) || !policy?.customUnits) { return null; } @@ -57,8 +87,9 @@ function getDefaultMileageRate(policy: OnyxEntry | EmptyObject): Mileage if (!distanceUnit?.rates) { return null; } + const mileageRates = getMileageRates(policy); - const distanceRate = Object.values(distanceUnit.rates).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE) ?? Object.values(distanceUnit.rates)[0]; + const distanceRate = Object.values(mileageRates).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE) ?? Object.values(mileageRates)[0]; return { customUnitRateID: distanceRate.customUnitRateID, @@ -179,38 +210,6 @@ function getDistanceMerchant( return `${distanceInUnits} @ ${ratePerUnit}`; } -/** - * Retrieves the mileage rates for given policy. - * - * @param policy - The policy from which to extract the mileage rates. - * - * @returns An array of mileage rates or an empty array if not found. - */ -function getMileageRates(policy: OnyxEntry): Record { - const mileageRates: Record = {}; - - if (!policy || !policy?.customUnits) { - return mileageRates; - } - - const distanceUnit = PolicyUtils.getCustomUnit(policy); - if (!distanceUnit?.rates) { - return mileageRates; - } - - Object.entries(distanceUnit.rates).forEach(([rateID, rate]) => { - mileageRates[rateID] = { - rate: rate.rate, - currency: rate.currency, - unit: distanceUnit.attributes.unit, - name: rate.name, - customUnitRateID: rate.customUnitRateID, - }; - }); - - return mileageRates; -} - /** * Retrieves the rate and unit for a P2P distance expense for a given currency. * @@ -256,11 +255,17 @@ function getCustomUnitRateID(reportID: string) { const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? null; const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`] ?? null; const policy = PolicyUtils.getPolicy(report?.policyID ?? parentReport?.policyID ?? ''); - let customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID; if (ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isPolicyExpenseChat(parentReport)) { - customUnitRateID = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? getDefaultMileageRate(policy)?.customUnitRateID ?? ''; + const distanceUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + const lastSelectedDistanceRateID = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? ''; + const lastSelectedDistanceRate = distanceUnit?.rates[lastSelectedDistanceRateID] ?? {}; + if (lastSelectedDistanceRate.enabled && lastSelectedDistanceRateID) { + customUnitRateID = lastSelectedDistanceRateID; + } else { + customUnitRateID = getDefaultMileageRate(policy)?.customUnitRateID ?? ''; + } } return customUnitRateID; diff --git a/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx index 43241a431c3..b1cfb8e5d9a 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx @@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import ModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/ModalNavigatorScreenOptions'; import type {AuthScreensParamList, LeftModalNavigatorParamList} from '@libs/Navigation/types'; -import type NAVIGATORS from '@src/NAVIGATORS'; +import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import Overlay from './Overlay'; @@ -32,7 +32,10 @@ function LeftModalNavigator({navigation}: LeftModalNavigatorProps) { /> )} - + )} - + { + if (option.isSelfDM) { + return 0; + } if (preferChatroomsOverThreads && option.isThread) { return 4; } @@ -1695,7 +1697,6 @@ function getOptions( maxRecentReportsToShow = 0, excludeLogins = [], includeMultipleParticipantReports = false, - includePersonalDetails = false, includeRecentReports = false, // When sortByReportTypeInSearch flag is true, recentReports will include the personalDetails options as well. sortByReportTypeInSearch = false, @@ -1812,6 +1813,7 @@ function getOptions( // Sorting the reports works like this: // - Order everything by the last message timestamp (descending) + // - When searching, self DM is put at the top // - All archived reports should remain at the bottom const orderedReportOptions = lodashSortBy(filteredReportOptions, (option) => { const report = option.item; @@ -1819,6 +1821,10 @@ function getOptions( return CONST.DATE.UNIX_EPOCH; } + if (searchValue) { + return [option.isSelfDM, report?.lastVisibleActionCreated]; + } + return report?.lastVisibleActionCreated; }); orderedReportOptions.reverse(); @@ -1880,7 +1886,7 @@ function getOptions( return option; }); - const havingLoginPersonalDetails = options.personalDetails.filter((detail) => !!detail?.login && !!detail.accountID && !detail?.isOptimisticPersonalDetail); + const havingLoginPersonalDetails = includeP2P ? options.personalDetails.filter((detail) => !!detail?.login && !!detail.accountID && !detail?.isOptimisticPersonalDetail) : []; let allPersonalDetailsOptions = havingLoginPersonalDetails; if (sortPersonalDetailsByAlphaAsc) { @@ -1966,22 +1972,20 @@ function getOptions( } } - if (includePersonalDetails) { - const personalDetailsOptionsToExclude = [...optionsToExclude, {login: currentUserLogin}]; - // Next loop over all personal details removing any that are selectedUsers or recentChats - allPersonalDetailsOptions.forEach((personalDetailOption) => { - if (personalDetailsOptionsToExclude.some((optionToExclude) => optionToExclude.login === personalDetailOption.login)) { - return; - } - const {searchText, participantsList, isChatRoom} = personalDetailOption; - const participantNames = getParticipantNames(participantsList); - if (searchValue && !isSearchStringMatch(searchValue, searchText, participantNames, isChatRoom)) { - return; - } + const personalDetailsOptionsToExclude = [...optionsToExclude, {login: currentUserLogin}]; + // Next loop over all personal details removing any that are selectedUsers or recentChats + allPersonalDetailsOptions.forEach((personalDetailOption) => { + if (personalDetailsOptionsToExclude.some((optionToExclude) => optionToExclude.login === personalDetailOption.login)) { + return; + } + const {searchText, participantsList, isChatRoom} = personalDetailOption; + const participantNames = getParticipantNames(participantsList); + if (searchValue && !isSearchStringMatch(searchValue, searchText, participantNames, isChatRoom)) { + return; + } - personalDetailsOptions.push(personalDetailOption); - }); - } + personalDetailsOptions.push(personalDetailOption); + }); let currentUserOption = allPersonalDetailsOptions.find((personalDetailsOption) => personalDetailsOption.login === currentUserLogin); if (searchValue && currentUserOption && !isSearchStringMatch(searchValue, currentUserOption.searchText)) { @@ -2039,7 +2043,7 @@ function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] = maxRecentReportsToShow: 0, // Unlimited sortByReportTypeInSearch: true, showChatPreviewLine: true, - includePersonalDetails: true, + includeP2P: true, forcePolicyNamePreview: true, includeOwnedWorkspaceChats: true, includeThreads: true, @@ -2060,7 +2064,7 @@ function getShareLogOptions(options: OptionList, searchValue = '', betas: Beta[] includeRecentReports: true, includeMultipleParticipantReports: true, sortByReportTypeInSearch: true, - includePersonalDetails: true, + includeP2P: true, forcePolicyNamePreview: true, includeOwnedWorkspaceChats: true, includeSelfDM: true, @@ -2117,7 +2121,6 @@ function getFilteredOptions( includePolicyReportFieldOptions = false, policyReportFieldOptions: string[] = [], recentlyUsedPolicyReportFieldOptions: string[] = [], - includePersonalDetails = true, maxRecentReportsToShow = 5, ) { return getOptions( @@ -2127,7 +2130,6 @@ function getFilteredOptions( searchInputValue: searchValue.trim(), selectedOptions, includeRecentReports: true, - includePersonalDetails, maxRecentReportsToShow, excludeLogins, includeOwnedWorkspaceChats, @@ -2173,7 +2175,6 @@ function getShareDestinationOptions( maxRecentReportsToShow: 0, // Unlimited includeRecentReports: true, includeMultipleParticipantReports: true, - includePersonalDetails: false, showChatPreviewLine: true, forcePolicyNamePreview: true, includeThreads: true, @@ -2230,7 +2231,7 @@ function getMemberInviteOptions( { betas, searchInputValue: searchValue.trim(), - includePersonalDetails: true, + includeP2P: true, excludeLogins, sortPersonalDetailsByAlphaAsc: true, includeSelectedOptions, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 7acec4d8d8d..1cfec72d88e 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -320,13 +320,13 @@ function getCombinedReportActions(reportActions: ReportAction[], transactionThre const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const isSelfDM = report?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM; - // Filter out request and send money request actions because we don't want to show any preview actions for one transaction reports + // Filter out request money actions because we don't want to show any preview actions for one transaction reports const filteredReportActions = [...reportActions, ...filteredTransactionThreadReportActions].filter((action) => { const actionType = (action as OriginalMessageIOU).originalMessage?.type ?? ''; if (isSelfDM) { - return actionType !== CONST.IOU.REPORT_ACTION_TYPE.CREATE && !isSentMoneyReportAction(action); + return actionType !== CONST.IOU.REPORT_ACTION_TYPE.CREATE; } - return actionType !== CONST.IOU.REPORT_ACTION_TYPE.CREATE && actionType !== CONST.IOU.REPORT_ACTION_TYPE.TRACK && !isSentMoneyReportAction(action); + return actionType !== CONST.IOU.REPORT_ACTION_TYPE.CREATE && actionType !== CONST.IOU.REPORT_ACTION_TYPE.TRACK; }); return getSortedReportActions(filteredReportActions, true); diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7e99c60cb61..d82fb224784 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -905,6 +905,13 @@ function isInvoiceRoom(report: OnyxEntry | EmptyObject): boolean { return getChatType(report) === CONST.REPORT.CHAT_TYPE.INVOICE; } +/** + * Checks if a report is a completed task report. + */ +function isTripRoom(report: OnyxEntry): boolean { + return isChatReport(report) && getChatType(report) === CONST.REPORT.CHAT_TYPE.TRIP_ROOM; +} + function isCurrentUserInvoiceReceiver(report: OnyxEntry): boolean { if (report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { return currentUserAccountID === report.invoiceReceiver.accountID; @@ -1851,13 +1858,18 @@ function getDisplayNameForParticipant(accountID?: number, shouldUseShortForm = f return shouldUseShortForm ? shortName : longName; } -function getParticipantAccountIDs(reportID: string) { +function getParticipantAccountIDs(reportID: string, includeOnlyActiveMembers = false) { const report = getReport(reportID); if (!report || !report.participants) { return []; } - - const accountIDStrings = Object.keys(report.participants); + const accountIDStrings = Object.keys(report.participants).filter((accountID) => { + if (!includeOnlyActiveMembers) { + return true; + } + const pendingMember = report?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString()); + return !pendingMember || pendingMember.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + }); return accountIDStrings.map((accountID) => Number(accountID)); } @@ -3222,6 +3234,11 @@ function getReportName(report: OnyxEntry, policy: OnyxEntry = nu if (ReportActionsUtils.isModifiedExpenseAction(parentReportAction)) { return ModifiedExpenseMessage.getForReportAction(report?.reportID, parentReportAction); } + + if (isTripRoom(report)) { + return report?.reportName ?? ''; + } + return parentReportActionMessage; } @@ -6441,33 +6458,23 @@ function getAllAncestorReportActions(report: Report | null | undefined): Ancesto let parentReportID = report.parentReportID; let parentReportActionID = report.parentReportActionID; - // Store the child of parent report - let currentReport = report; - while (parentReportID) { const parentReport = getReport(parentReportID); const parentReportAction = ReportActionsUtils.getReportAction(parentReportID, parentReportActionID ?? '0'); - if (!parentReportAction || ReportActionsUtils.isTransactionThread(parentReportAction) || ReportActionsUtils.isReportPreviewAction(parentReportAction)) { + if (!parentReport || !parentReportAction || ReportActionsUtils.isTransactionThread(parentReportAction) || ReportActionsUtils.isReportPreviewAction(parentReportAction)) { break; } const isParentReportActionUnread = ReportActionsUtils.isCurrentActionUnread(parentReport ?? {}, parentReportAction); allAncestors.push({ - report: currentReport, + report: parentReport, reportAction: parentReportAction, shouldDisplayNewMarker: isParentReportActionUnread, }); - if (!parentReport) { - break; - } - parentReportID = parentReport?.parentReportID; parentReportActionID = parentReport?.parentReportActionID; - if (!isEmptyObject(parentReport)) { - currentReport = parentReport; - } } return allAncestors.reverse(); @@ -6624,6 +6631,13 @@ function shouldCreateNewMoneyRequestReport(existingIOUReport: OnyxEntry return !existingIOUReport || hasIOUWaitingOnCurrentUserBankAccount(chatReport) || !canAddOrDeleteTransactions(existingIOUReport); } +function getTripTransactions(tripRoomReportID: string | undefined): Transaction[] { + const tripTransactionReportIDs = Object.values(allReports ?? {}) + .filter((report) => report && report?.parentReportID === tripRoomReportID) + .map((report) => report?.reportID); + return tripTransactionReportIDs.flatMap((reportID) => TransactionUtils.getAllReportTransactions(reportID)); +} + /** * Checks if report contains actions with errors */ @@ -6969,6 +6983,7 @@ export { isCanceledTaskReport, isChatReport, isChatRoom, + isTripRoom, isChatThread, isChildReport, isClosedExpenseReportWithNoExpenses, @@ -7059,6 +7074,7 @@ export { updateOptimisticParentReportAction, updateReportPreview, temporary_getMoneyRequestOptions, + getTripTransactions, buildOptimisticInvoiceReport, getInvoiceChatByParticipants, shouldShowMerchantColumn, diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 219e66d8277..7abc8f22bb1 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -58,7 +58,7 @@ function isScanRequest(transaction: OnyxEntry): boolean { return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.SCAN; } - return Boolean(transaction?.receipt?.source); + return Boolean(transaction?.receipt?.source) && transaction?.amount === 0; } function getRequestType(transaction: OnyxEntry): IOURequestType { @@ -683,6 +683,10 @@ function isCustomUnitRateIDForP2P(transaction: OnyxEntry): boolean return transaction?.comment?.customUnit?.customUnitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID; } +function hasReservationList(transaction: Transaction | undefined | null): boolean { + return !!transaction?.receipt?.reservationList && transaction?.receipt?.reservationList.length > 0; +} + /** * Get rate ID from the transaction object */ @@ -811,6 +815,7 @@ export { getWaypointIndex, waypointHasValidAddress, getRecentTransactions, + hasReservationList, hasViolation, hasNoticeTypeViolation, isCustomUnitRateIDForP2P, diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts new file mode 100644 index 00000000000..ead786b8eaf --- /dev/null +++ b/src/libs/TripReservationUtils.ts @@ -0,0 +1,27 @@ +import * as Expensicons from '@src/components/Icon/Expensicons'; +import CONST from '@src/CONST'; +import type {Reservation, ReservationType} from '@src/types/onyx/Transaction'; +import type Transaction from '@src/types/onyx/Transaction'; +import type IconAsset from '@src/types/utils/IconAsset'; + +function getTripReservationIcon(reservationType: ReservationType): IconAsset { + switch (reservationType) { + case CONST.RESERVATION_TYPE.FLIGHT: + return Expensicons.Plane; + case CONST.RESERVATION_TYPE.HOTEL: + return Expensicons.Bed; + case CONST.RESERVATION_TYPE.CAR: + return Expensicons.CarWithKey; + default: + return Expensicons.Luggage; + } +} + +function getReservationsFromTripTransactions(transactions: Transaction[]): Reservation[] { + return transactions + .map((item) => item?.receipt?.reservationList ?? []) + .filter((item) => item.length > 0) + .flat(); +} + +export {getTripReservationIcon, getReservationsFromTripTransactions}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index a98a9c31517..014ed5da3d4 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -377,8 +377,8 @@ function startMoneyRequest(iouType: ValueOf, reportID: st } } -function setMoneyRequestAmount(transactionID: string, amount: number, currency: string) { - Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {amount, currency}); +function setMoneyRequestAmount(transactionID: string, amount: number, currency: string, shouldShowOriginalAmount = false) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {amount, currency, shouldShowOriginalAmount}); } function setMoneyRequestCreated(transactionID: string, created: string, isDraft: boolean) { @@ -2393,7 +2393,7 @@ function calculateAmountForUpdatedWaypoint( let updatedMerchant = Localize.translateLocal('iou.fieldPending'); if (!isEmptyObject(transactionChanges?.routes)) { const customUnitRateID = TransactionUtils.getRateID(transaction) ?? ''; - const mileageRates = DistanceRequestUtils.getMileageRates(policy); + const mileageRates = DistanceRequestUtils.getMileageRates(policy, true); const policyCurrency = policy?.outputCurrency ?? PolicyUtils.getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD; const mileageRate = TransactionUtils.isCustomUnitRateIDForP2P(transaction) ? DistanceRequestUtils.getRateForP2P(policyCurrency) @@ -6769,6 +6769,7 @@ function putOnHold(transactionID: string, comment: string, reportID: string) { comment: { hold: null, }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.genericHoldExpenseFailureMessage'), }, }, ]; @@ -6830,6 +6831,7 @@ function unholdRequest(transactionID: string, reportID: string) { key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, value: { pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxError('iou.genericUnholdExpenseFailureMessage'), }, }, ]; diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts new file mode 100644 index 00000000000..f48c3e9033a --- /dev/null +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -0,0 +1,537 @@ +import type {NullishDeep, OnyxCollection, OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import type { + CreatePolicyDistanceRateParams, + DeletePolicyDistanceRatesParams, + EnablePolicyDistanceRatesParams, + OpenPolicyDistanceRatesPageParams, + SetPolicyDistanceRatesEnabledParams, + SetPolicyDistanceRatesUnitParams, + UpdatePolicyDistanceRateValueParams, +} from '@libs/API/parameters'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import * as ReportUtils from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report} from '@src/types/onyx'; +import type {ErrorFields} from '@src/types/onyx/OnyxCommon'; +import type {Attributes, CustomUnit, Rate} from '@src/types/onyx/Policy'; +import type {OnyxData} from '@src/types/onyx/Request'; +import {navigateWhenEnableFeature, removePendingFieldsFromCustomUnit} from './Policy'; + +type NewCustomUnit = { + customUnitID: string; + name: string; + attributes: Attributes; + rates: Rate; +}; + +const allPolicies: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (val, key) => { + if (!key) { + return; + } + if (val === null || val === undefined) { + // If we are deleting a policy, we have to check every report linked to that policy + // and unset the draft indicator (pencil icon) alongside removing any draft comments. Clearing these values will keep the newly archived chats from being displayed in the LHN. + // More info: https://github.com/Expensify/App/issues/14260 + const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); + const policyReports = ReportUtils.getAllPolicyReports(policyID); + const cleanUpMergeQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, NullishDeep> = {}; + const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {}; + policyReports.forEach((policyReport) => { + if (!policyReport) { + return; + } + const {reportID} = policyReport; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null; + }); + Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, cleanUpMergeQueries); + Onyx.multiSet(cleanUpSetQueries); + delete allPolicies[key]; + return; + } + + allPolicies[key] = val; + }, +}); + +/** + * Takes array of customUnitRates and removes pendingFields and errorFields from each rate - we don't want to send those via API + */ +function prepareCustomUnitRatesArray(customUnitRates: Rate[]): Rate[] { + const customUnitRateArray: Rate[] = []; + customUnitRates.forEach((rate) => { + const cleanedRate = {...rate}; + delete cleanedRate.pendingFields; + delete cleanedRate.errorFields; + customUnitRateArray.push(cleanedRate); + }); + + return customUnitRateArray; +} + +function openPolicyDistanceRatesPage(policyID?: string) { + if (!policyID) { + return; + } + + const params: OpenPolicyDistanceRatesPageParams = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_DISTANCE_RATES_PAGE, params); +} + +function enablePolicyDistanceRates(policyID: string, enabled: boolean) { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areDistanceRatesEnabled: enabled, + pendingFields: { + areDistanceRatesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + areDistanceRatesEnabled: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areDistanceRatesEnabled: !enabled, + pendingFields: { + areDistanceRatesEnabled: null, + }, + }, + }, + ], + }; + + const parameters: EnablePolicyDistanceRatesParams = {policyID, enabled}; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_DISTANCE_RATES, parameters, onyxData); + + if (enabled && getIsNarrowLayout()) { + navigateWhenEnableFeature(policyID); + } +} + +function createPolicyDistanceRate(policyID: string, customUnitID: string, customUnitRate: Rate) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnitID]: { + rates: { + [customUnitRate.customUnitRateID ?? '']: { + ...customUnitRate, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnitID]: { + rates: { + [customUnitRate.customUnitRateID ?? '']: { + pendingAction: null, + }, + }, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnitID]: { + rates: { + [customUnitRate.customUnitRateID ?? '']: { + errors: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage'), + }, + }, + }, + }, + }, + }, + ]; + + const params: CreatePolicyDistanceRateParams = { + policyID, + customUnitID, + customUnitRate: JSON.stringify(customUnitRate), + }; + + API.write(WRITE_COMMANDS.CREATE_POLICY_DISTANCE_RATE, params, {optimisticData, successData, failureData}); +} + +function clearCreateDistanceRateItemAndError(policyID: string, customUnitID: string, customUnitRateIDToClear: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + customUnits: { + [customUnitID]: { + rates: { + [customUnitRateIDToClear]: null, + }, + }, + }, + }); +} + +function clearPolicyDistanceRatesErrorFields(policyID: string, customUnitID: string, updatedErrorFields: ErrorFields) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + customUnits: { + [customUnitID]: { + errorFields: updatedErrorFields, + }, + }, + }); +} + +function clearDeleteDistanceRateError(policyID: string, customUnitID: string, rateID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + customUnits: { + [customUnitID]: { + rates: { + [rateID]: { + errors: null, + }, + }, + }, + }, + }); +} + +function clearPolicyDistanceRateErrorFields(policyID: string, customUnitID: string, rateID: string, updatedErrorFields: ErrorFields) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + customUnits: { + [customUnitID]: { + rates: { + [rateID]: { + errorFields: updatedErrorFields, + }, + }, + }, + }, + }); +} + +function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [newCustomUnit.customUnitID]: { + ...newCustomUnit, + pendingFields: {attributes: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [newCustomUnit.customUnitID]: { + pendingFields: {attributes: null}, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [currentCustomUnit.customUnitID]: { + ...currentCustomUnit, + errorFields: {attributes: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, + pendingFields: {attributes: null}, + }, + }, + }, + }, + ]; + + const params: SetPolicyDistanceRatesUnitParams = { + policyID, + customUnit: JSON.stringify(removePendingFieldsFromCustomUnit(newCustomUnit)), + }; + + API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT, params, {optimisticData, successData, failureData}); +} + +function updatePolicyDistanceRateValue(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) { + const currentRates = customUnit.rates; + const optimisticRates: Record = {}; + const successRates: Record = {}; + const failureRates: Record = {}; + const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID); + + for (const rateID of Object.keys(customUnit.rates)) { + if (rateIDs.includes(rateID)) { + const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID); + optimisticRates[rateID] = {...foundRate, pendingFields: {rate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}; + successRates[rateID] = {...foundRate, pendingFields: {rate: null}}; + failureRates[rateID] = { + ...currentRates[rateID], + pendingFields: {rate: null}, + errorFields: {rate: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, + }; + } + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: optimisticRates, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: successRates, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: failureRates, + }, + }, + }, + }, + ]; + + const params: UpdatePolicyDistanceRateValueParams = { + policyID, + customUnitID: customUnit.customUnitID, + customUnitRateArray: JSON.stringify(prepareCustomUnitRatesArray(customUnitRates)), + }; + + API.write(WRITE_COMMANDS.UPDATE_POLICY_DISTANCE_RATE_VALUE, params, {optimisticData, successData, failureData}); +} + +function setPolicyDistanceRatesEnabled(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) { + const currentRates = customUnit.rates; + const optimisticRates: Record = {}; + const successRates: Record = {}; + const failureRates: Record = {}; + const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID); + + for (const rateID of Object.keys(currentRates)) { + if (rateIDs.includes(rateID)) { + const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID); + optimisticRates[rateID] = {...foundRate, pendingFields: {enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}; + successRates[rateID] = {...foundRate, pendingFields: {enabled: null}}; + failureRates[rateID] = { + ...currentRates[rateID], + pendingFields: {enabled: null}, + errorFields: {enabled: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, + }; + } + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: optimisticRates, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: successRates, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: failureRates, + }, + }, + }, + }, + ]; + + const params: SetPolicyDistanceRatesEnabledParams = { + policyID, + customUnitID: customUnit.customUnitID, + customUnitRateArray: JSON.stringify(prepareCustomUnitRatesArray(customUnitRates)), + }; + + API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_ENABLED, params, {optimisticData, successData, failureData}); +} + +function deletePolicyDistanceRates(policyID: string, customUnit: CustomUnit, rateIDsToDelete: string[]) { + const currentRates = customUnit.rates; + const optimisticRates: Record = {}; + const successRates: Record = {}; + const failureRates: Record = {}; + + for (const rateID of Object.keys(currentRates)) { + if (rateIDsToDelete.includes(rateID)) { + optimisticRates[rateID] = { + ...currentRates[rateID], + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }; + failureRates[rateID] = { + ...currentRates[rateID], + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage'), + }; + } else { + optimisticRates[rateID] = currentRates[rateID]; + successRates[rateID] = currentRates[rateID]; + } + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: optimisticRates, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: successRates, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: failureRates, + }, + }, + }, + }, + ]; + + const params: DeletePolicyDistanceRatesParams = { + policyID, + customUnitID: customUnit.customUnitID, + customUnitRateID: rateIDsToDelete, + }; + + API.write(WRITE_COMMANDS.DELETE_POLICY_DISTANCE_RATES, params, {optimisticData, successData, failureData}); +} + +export { + enablePolicyDistanceRates, + openPolicyDistanceRatesPage, + createPolicyDistanceRate, + clearCreateDistanceRateItemAndError, + clearDeleteDistanceRateError, + setPolicyDistanceRatesUnit, + clearPolicyDistanceRatesErrorFields, + clearPolicyDistanceRateErrorFields, + updatePolicyDistanceRateValue, + setPolicyDistanceRatesEnabled, + deletePolicyDistanceRates, +}; + +export type {NewCustomUnit}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 4d918352ba9..2625e7d60df 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -10,21 +10,17 @@ import * as API from '@libs/API'; import type { AddBillingCardAndRequestWorkspaceOwnerChangeParams, AddMembersToWorkspaceParams, - CreatePolicyDistanceRateParams, CreateWorkspaceFromIOUPaymentParams, CreateWorkspaceParams, DeleteMembersFromWorkspaceParams, - DeletePolicyDistanceRatesParams, DeleteWorkspaceAvatarParams, DeleteWorkspaceParams, EnablePolicyConnectionsParams, - EnablePolicyDistanceRatesParams, EnablePolicyReportFieldsParams, EnablePolicyTaxesParams, EnablePolicyWorkflowsParams, LeavePolicyParams, OpenDraftWorkspaceRequestParams, - OpenPolicyDistanceRatesPageParams, OpenPolicyMoreFeaturesPageParams, OpenPolicyTaxesPageParams, OpenPolicyWorkflowsPageParams, @@ -33,15 +29,12 @@ import type { OpenWorkspaceParams, OpenWorkspaceReimburseViewParams, RequestWorkspaceOwnerChangeParams, - SetPolicyDistanceRatesEnabledParams, - SetPolicyDistanceRatesUnitParams, SetWorkspaceApprovalModeParams, SetWorkspaceAutoReportingFrequencyParams, SetWorkspaceAutoReportingMonthlyOffsetParams, SetWorkspaceAutoReportingParams, SetWorkspacePayerParams, SetWorkspaceReimbursementParams, - UpdatePolicyDistanceRateValueParams, UpdateWorkspaceAvatarParams, UpdateWorkspaceCustomUnitAndRateParams, UpdateWorkspaceDescriptionParams, @@ -79,7 +72,7 @@ import type { TaxRatesWithDefault, Transaction, } from '@src/types/onyx'; -import type {ErrorFields, Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {OriginalMessageJoinPolicyChangeLog} from '@src/types/onyx/OriginalMessage'; import type {Attributes, CompanyAddress, CustomUnit, Rate, TaxRate, Unit} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -3215,16 +3208,6 @@ function declineJoinRequest(reportID: string, reportAction: OnyxEntry { Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policyID)); @@ -3275,54 +3258,6 @@ function enablePolicyConnections(policyID: string, enabled: boolean) { API.write(WRITE_COMMANDS.ENABLE_POLICY_CONNECTIONS, parameters, onyxData); } -function enablePolicyDistanceRates(policyID: string, enabled: boolean) { - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - areDistanceRatesEnabled: enabled, - pendingFields: { - areDistanceRatesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, - }, - }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - pendingFields: { - areDistanceRatesEnabled: null, - }, - }, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - areDistanceRatesEnabled: !enabled, - pendingFields: { - areDistanceRatesEnabled: null, - }, - }, - }, - ], - }; - - const parameters: EnablePolicyDistanceRatesParams = {policyID, enabled}; - - API.write(WRITE_COMMANDS.ENABLE_POLICY_DISTANCE_RATES, parameters, onyxData); - - if (enabled && getIsNarrowLayout()) { - navigateWhenEnableFeature(policyID); - } -} - function enablePolicyReportFields(policyID: string, enabled: boolean) { const onyxData: OnyxData = { optimisticData: [ @@ -3571,121 +3506,6 @@ function openPolicyMoreFeaturesPage(policyID: string) { API.read(READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE, params); } -function createPolicyDistanceRate(policyID: string, customUnitID: string, customUnitRate: Rate) { - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [customUnitID]: { - rates: { - [customUnitRate.customUnitRateID ?? '']: { - ...customUnitRate, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }, - }, - }, - }, - }, - }, - ]; - - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [customUnitID]: { - rates: { - [customUnitRate.customUnitRateID ?? '']: { - pendingAction: null, - }, - }, - }, - }, - }, - }, - ]; - - const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [customUnitID]: { - rates: { - [customUnitRate.customUnitRateID ?? '']: { - errors: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage'), - }, - }, - }, - }, - }, - }, - ]; - - const params: CreatePolicyDistanceRateParams = { - policyID, - customUnitID, - customUnitRate: JSON.stringify(customUnitRate), - }; - - API.write(WRITE_COMMANDS.CREATE_POLICY_DISTANCE_RATE, params, {optimisticData, successData, failureData}); -} - -function clearCreateDistanceRateItemAndError(policyID: string, customUnitID: string, customUnitRateIDToClear: string) { - Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { - customUnits: { - [customUnitID]: { - rates: { - [customUnitRateIDToClear]: null, - }, - }, - }, - }); -} - -function clearPolicyDistanceRatesErrorFields(policyID: string, customUnitID: string, updatedErrorFields: ErrorFields) { - Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { - customUnits: { - [customUnitID]: { - errorFields: updatedErrorFields, - }, - }, - }); -} - -function clearDeleteDistanceRateError(policyID: string, customUnitID: string, rateID: string) { - Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { - customUnits: { - [customUnitID]: { - rates: { - [rateID]: { - errors: null, - }, - }, - }, - }, - }); -} - -function clearPolicyDistanceRateErrorFields(policyID: string, customUnitID: string, rateID: string, updatedErrorFields: ErrorFields) { - Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { - customUnits: { - [customUnitID]: { - rates: { - [rateID]: { - errorFields: updatedErrorFields, - }, - }, - }, - }, - }); -} - /** * Takes removes pendingFields and errorFields from a customUnit */ @@ -3698,291 +3518,6 @@ function removePendingFieldsFromCustomUnit(customUnit: CustomUnit): CustomUnit { return cleanedCustomUnit; } -function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) { - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [newCustomUnit.customUnitID]: { - ...newCustomUnit, - pendingFields: {attributes: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, - }, - }, - }, - }, - ]; - - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [newCustomUnit.customUnitID]: { - pendingFields: {attributes: null}, - }, - }, - }, - }, - ]; - - const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [currentCustomUnit.customUnitID]: { - ...currentCustomUnit, - errorFields: {attributes: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, - pendingFields: {attributes: null}, - }, - }, - }, - }, - ]; - - const params: SetPolicyDistanceRatesUnitParams = { - policyID, - customUnit: JSON.stringify(removePendingFieldsFromCustomUnit(newCustomUnit)), - }; - - API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT, params, {optimisticData, successData, failureData}); -} - -/** - * Takes array of customUnitRates and removes pendingFields and errorFields from each rate - we don't want to send those via API - */ -function prepareCustomUnitRatesArray(customUnitRates: Rate[]): Rate[] { - const customUnitRateArray: Rate[] = []; - customUnitRates.forEach((rate) => { - const cleanedRate = {...rate}; - delete cleanedRate.pendingFields; - delete cleanedRate.errorFields; - customUnitRateArray.push(cleanedRate); - }); - - return customUnitRateArray; -} - -function updatePolicyDistanceRateValue(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) { - const currentRates = customUnit.rates; - const optimisticRates: Record = {}; - const successRates: Record = {}; - const failureRates: Record = {}; - const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID); - - for (const rateID of Object.keys(customUnit.rates)) { - if (rateIDs.includes(rateID)) { - const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID); - optimisticRates[rateID] = {...foundRate, pendingFields: {rate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}; - successRates[rateID] = {...foundRate, pendingFields: {rate: null}}; - failureRates[rateID] = { - ...currentRates[rateID], - pendingFields: {rate: null}, - errorFields: {rate: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, - }; - } - } - - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [customUnit.customUnitID]: { - rates: optimisticRates, - }, - }, - }, - }, - ]; - - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [customUnit.customUnitID]: { - rates: successRates, - }, - }, - }, - }, - ]; - - const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [customUnit.customUnitID]: { - rates: failureRates, - }, - }, - }, - }, - ]; - - const params: UpdatePolicyDistanceRateValueParams = { - policyID, - customUnitID: customUnit.customUnitID, - customUnitRateArray: JSON.stringify(prepareCustomUnitRatesArray(customUnitRates)), - }; - - API.write(WRITE_COMMANDS.UPDATE_POLICY_DISTANCE_RATE_VALUE, params, {optimisticData, successData, failureData}); -} - -function setPolicyDistanceRatesEnabled(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) { - const currentRates = customUnit.rates; - const optimisticRates: Record = {}; - const successRates: Record = {}; - const failureRates: Record = {}; - const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID); - - for (const rateID of Object.keys(currentRates)) { - if (rateIDs.includes(rateID)) { - const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID); - optimisticRates[rateID] = {...foundRate, pendingFields: {enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}; - successRates[rateID] = {...foundRate, pendingFields: {enabled: null}}; - failureRates[rateID] = { - ...currentRates[rateID], - pendingFields: {enabled: null}, - errorFields: {enabled: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, - }; - } - } - - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [customUnit.customUnitID]: { - rates: optimisticRates, - }, - }, - }, - }, - ]; - - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [customUnit.customUnitID]: { - rates: successRates, - }, - }, - }, - }, - ]; - - const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [customUnit.customUnitID]: { - rates: failureRates, - }, - }, - }, - }, - ]; - - const params: SetPolicyDistanceRatesEnabledParams = { - policyID, - customUnitID: customUnit.customUnitID, - customUnitRateArray: JSON.stringify(prepareCustomUnitRatesArray(customUnitRates)), - }; - - API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_ENABLED, params, {optimisticData, successData, failureData}); -} - -function deletePolicyDistanceRates(policyID: string, customUnit: CustomUnit, rateIDsToDelete: string[]) { - const currentRates = customUnit.rates; - const optimisticRates: Record = {}; - const successRates: Record = {}; - const failureRates: Record = {}; - - for (const rateID of Object.keys(currentRates)) { - if (rateIDsToDelete.includes(rateID)) { - optimisticRates[rateID] = { - ...currentRates[rateID], - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - }; - failureRates[rateID] = { - ...currentRates[rateID], - pendingAction: null, - errors: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage'), - }; - } else { - optimisticRates[rateID] = currentRates[rateID]; - successRates[rateID] = currentRates[rateID]; - } - } - - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [customUnit.customUnitID]: { - rates: optimisticRates, - }, - }, - }, - }, - ]; - - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [customUnit.customUnitID]: { - rates: successRates, - }, - }, - }, - }, - ]; - - const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [customUnit.customUnitID]: { - rates: failureRates, - }, - }, - }, - }, - ]; - - const params: DeletePolicyDistanceRatesParams = { - policyID, - customUnitID: customUnit.customUnitID, - customUnitRateID: rateIDsToDelete, - }; - - API.write(WRITE_COMMANDS.DELETE_POLICY_DISTANCE_RATES, params, {optimisticData, successData, failureData}); -} - function setPolicyCustomTaxName(policyID: string, customTaxName: string) { const policy = getPolicy(policyID); const originalCustomTaxName = policy?.taxRates?.name; @@ -4196,17 +3731,11 @@ export { setWorkspaceReimbursement, openPolicyWorkflowsPage, enablePolicyConnections, - enablePolicyDistanceRates, enablePolicyReportFields, enablePolicyTaxes, enablePolicyWorkflows, - openPolicyDistanceRatesPage, openPolicyMoreFeaturesPage, generateCustomUnitID, - createPolicyDistanceRate, - clearCreateDistanceRateItemAndError, - clearDeleteDistanceRateError, - setPolicyDistanceRatesUnit, clearQBOErrorField, clearXeroErrorField, clearWorkspaceReimbursementErrors, @@ -4215,11 +3744,6 @@ export { setPolicyCustomTaxName, clearPolicyErrorField, isCurrencySupportedForDirectReimbursement, - clearPolicyDistanceRatesErrorFields, - clearPolicyDistanceRateErrorFields, - updatePolicyDistanceRateValue, - setPolicyDistanceRatesEnabled, - deletePolicyDistanceRates, getPrimaryPolicy, createDraftWorkspace, buildPolicyData, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 3a1cd85b804..40f3c93a049 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2736,12 +2736,17 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmails ...newPersonalDetailsOnyxData.optimisticData, ]; + const successPendingChatMembers = report?.pendingChatMembers + ? report?.pendingChatMembers?.filter( + (pendingMember) => !(inviteeAccountIDs.includes(Number(pendingMember.accountID)) && pendingMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), + ) + : null; const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { - pendingChatMembers: report?.pendingChatMembers ?? null, + pendingChatMembers: successPendingChatMembers, }, }, ...newPersonalDetailsOnyxData.finallyData, diff --git a/src/libs/actions/Subscription.ts b/src/libs/actions/Subscription.ts index f28c395f1b4..c7732575aaa 100644 --- a/src/libs/actions/Subscription.ts +++ b/src/libs/actions/Subscription.ts @@ -5,7 +5,7 @@ import {READ_COMMANDS} from '@libs/API/types'; * Fetches data when the user opens the SubscriptionSettingsPage */ function openSubscriptionPage() { - API.read(READ_COMMANDS.OPEN_SUBSCRIPTION_PAGE, {}); + API.read(READ_COMMANDS.OPEN_SUBSCRIPTION_PAGE, null); } export { diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 74bb5bec96d..d9172864143 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -1,6 +1,5 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; import type {SectionListData} from 'react-native'; -import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; @@ -52,7 +51,10 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen const [userToInvite, setUserToInvite] = useState(null); // Any existing participants and Expensify emails should not be eligible for invitation - const excludedUsers = useMemo(() => [...PersonalDetailsUtils.getLoginsByAccountIDs(ReportUtils.getParticipantAccountIDs(report?.reportID ?? '')), ...CONST.EXPENSIFY_EMAILS], [report]); + const excludedUsers = useMemo( + () => [...PersonalDetailsUtils.getLoginsByAccountIDs(ReportUtils.getParticipantAccountIDs(report?.reportID ?? '', true)), ...CONST.EXPENSIFY_EMAILS], + [report], + ); useEffect(() => { const inviteOptions = OptionsListUtils.getMemberInviteOptions(options.personalDetails, betas ?? [], searchTerm, excludedUsers, false, options.reports, true); @@ -187,10 +189,25 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen return OptionsListUtils.getHeaderMessage(invitePersonalDetails.length !== 0, Boolean(userToInvite), searchValue); }, [searchTerm, userToInvite, excludedUsers, invitePersonalDetails, translate, reportName]); + const footerContent = useMemo( + () => ( + + ), + [selectedOptions.length, inviteUsers, translate, styles], + ); + return ( - - - - - - + + ); } diff --git a/src/pages/NewChatConfirmPage.tsx b/src/pages/NewChatConfirmPage.tsx index 8152a61d31e..9d0f65860a1 100644 --- a/src/pages/NewChatConfirmPage.tsx +++ b/src/pages/NewChatConfirmPage.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useRef} from 'react'; +import React, {useCallback, useMemo, useRef} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -35,6 +35,14 @@ type NewChatConfirmPageOnyxProps = { type NewChatConfirmPageProps = NewChatConfirmPageOnyxProps; +function navigateBack() { + Navigation.goBack(ROUTES.NEW_CHAT); +} + +function navigateToEditChatName() { + Navigation.navigate(ROUTES.NEW_CHAT_EDIT_NAME); +} + function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmPageProps) { const optimisticReportID = useRef(ReportUtils.generateReportID()); const fileRef = useRef(); @@ -79,30 +87,25 @@ function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmP /** * Removes a selected option from list if already selected. */ - const unselectOption = (option: ListItem) => { - if (!newGroupDraft) { - return; - } - const newSelectedParticipants = (newGroupDraft.participants ?? []).filter((participant) => participant.login !== option.login); - Report.setGroupDraft({participants: newSelectedParticipants}); - }; + const unselectOption = useCallback( + (option: ListItem) => { + if (!newGroupDraft) { + return; + } + const newSelectedParticipants = (newGroupDraft.participants ?? []).filter((participant) => participant.login !== option.login); + Report.setGroupDraft({participants: newSelectedParticipants}); + }, + [newGroupDraft], + ); - const createGroup = () => { + const createGroup = useCallback(() => { if (!newGroupDraft) { return; } const logins: string[] = (newGroupDraft.participants ?? []).map((participant) => participant.login); Report.navigateToAndOpenReport(logins, true, newGroupDraft.reportName ?? '', newGroupDraft.avatarUri ?? '', fileRef.current, optimisticReportID.current, true); - }; - - const navigateBack = () => { - Navigation.goBack(ROUTES.NEW_CHAT); - }; - - const navigateToEditChatName = () => { - Navigation.navigate(ROUTES.NEW_CHAT_EDIT_NAME); - }; + }, [newGroupDraft]); const stashedLocalAvatarImage = newGroupDraft?.avatarUri; return ( diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 02f97397e4c..3280aaa4a21 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -92,11 +92,12 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD const isGroupChat = useMemo(() => ReportUtils.isGroupChat(report), [report]); const isThread = useMemo(() => ReportUtils.isThread(report), [report]); const participants = useMemo(() => { - if (isGroupChat || isSystemChat) { - // Filter out the current user from the particpants of the systemChat - return ReportUtils.getParticipantAccountIDs(report.reportID ?? '').filter((accountID) => accountID !== session?.accountID && isSystemChat); + if (isGroupChat) { + return ReportUtils.getParticipantAccountIDs(report.reportID ?? ''); + } + if (isSystemChat) { + return ReportUtils.getParticipantAccountIDs(report.reportID ?? '').filter((accountID) => accountID !== session?.accountID); } - return ReportUtils.getVisibleChatMemberAccountIDs(report.reportID ?? ''); }, [report, session, isGroupChat, isSystemChat]); @@ -212,7 +213,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD icon: Expensicons.Exit, isAnonymousAction: true, action: () => { - if (Object.keys(report?.participants ?? {}).length === 1 && isGroupChat) { + if (ReportUtils.getParticipantAccountIDs(report.reportID, true).length === 1 && isGroupChat) { setIsLastMemberLeavingGroupModalVisible(true); return; } @@ -261,7 +262,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD /> ) : null; - const renderAvatar = useMemo(() => { + const renderedAvatar = useMemo(() => { if (isMoneyRequestReport || isInvoiceReport) { return ( @@ -318,7 +319,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD /> - {renderAvatar} + {renderedAvatar} ; + } + return ( + ; function WorkspaceSwitcherPage() { + const styles = useThemeStyles(); + const theme = useTheme(); const {isOffline} = useNetwork(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const {translate} = useLocalize(); @@ -39,7 +45,7 @@ function WorkspaceSwitcherPage() { const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [policies, fetchStatus] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const brickRoadsForPolicies = useMemo(() => getWorkspacesBrickRoads(reports, policies, reportActions), [reports, policies, reportActions]); const unreadStatusesForPolicies = useMemo(() => getWorkspacesUnreadStatuses(reports), [reports]); @@ -130,54 +136,67 @@ function WorkspaceSwitcherPage() { const sections = useMemo(() => { const options: Array> = [ { - title: translate('workspace.switcher.everythingSection'), + data: filteredAndSortedUserWorkspaces, shouldShow: true, - indexOffset: 0, - data: [ - { - text: CONST.WORKSPACE_SWITCHER.NAME, - icons: [{source: Expensicons.ExpensifyAppIcon, name: CONST.WORKSPACE_SWITCHER.NAME, type: CONST.ICON_TYPE_AVATAR}], - brickRoadIndicator: getIndicatorTypeForPolicy(undefined), - isSelected: activeWorkspaceID === undefined, - keyForList: CONST.WORKSPACE_SWITCHER.NAME, - }, - ], + indexOffset: 1, }, ]; - options.push({ - CustomSectionHeader: WorkspacesSectionHeader, - data: filteredAndSortedUserWorkspaces, - shouldShow: true, - indexOffset: 1, - }); return options; - }, [activeWorkspaceID, filteredAndSortedUserWorkspaces, getIndicatorTypeForPolicy, translate]); + }, [filteredAndSortedUserWorkspaces]); const headerMessage = filteredAndSortedUserWorkspaces.length === 0 && usersWorkspaces.length ? translate('common.noResultsFound') : ''; const shouldShowCreateWorkspace = usersWorkspaces.length === 0; + const defaultPolicy = { + text: CONST.WORKSPACE_SWITCHER.NAME, + icons: [{source: Expensicons.ExpensifyAppIcon, name: CONST.WORKSPACE_SWITCHER.NAME, type: CONST.ICON_TYPE_AVATAR}], + brickRoadIndicator: getIndicatorTypeForPolicy(undefined), + keyForList: CONST.WORKSPACE_SWITCHER.NAME, + isSelected: activeWorkspaceID === undefined, + }; + return ( - - - ListItem={UserListItem} - sections={sections} - onSelectRow={selectPolicy} - shouldDebounceRowSelect - textInputLabel={usersWorkspaces.length >= CONST.WORKSPACE_SWITCHER.MINIMUM_WORKSPACES_TO_SHOW_SEARCH ? translate('common.search') : undefined} - textInputValue={searchTerm} - onChangeText={setSearchTerm} - headerMessage={headerMessage} - listFooterContent={shouldShowCreateWorkspace ? WorkspaceCardCreateAWorkspaceInstance : null} - initiallyFocusedOptionKey={activeWorkspaceID ?? CONST.WORKSPACE_SWITCHER.NAME} - showLoadingPlaceholder - /> + {({didScreenTransitionEnd}) => ( + <> + + + + {translate('workspace.switcher.everythingSection')} + + + selectPolicy(defaultPolicy)} + pressableStyle={styles.flexRow} + shouldSyncFocus={false} + /> + + + ListItem={UserListItem} + sections={sections} + onSelectRow={selectPolicy} + textInputLabel={usersWorkspaces.length >= CONST.WORKSPACE_SWITCHER.MINIMUM_WORKSPACES_TO_SHOW_SEARCH ? translate('common.search') : undefined} + textInputValue={searchTerm} + onChangeText={setSearchTerm} + headerMessage={headerMessage} + listFooterContent={shouldShowCreateWorkspace ? WorkspaceCardCreateAWorkspaceInstance : null} + initiallyFocusedOptionKey={activeWorkspaceID ?? CONST.WORKSPACE_SWITCHER.NAME} + showLoadingPlaceholder={fetchStatus.status === 'loading' || !didScreenTransitionEnd} + /> + + )} ); } diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 5696f0b800f..414745270b6 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -21,6 +21,7 @@ import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; import type {CurrentReportIDContextValue} from '@components/withCurrentReportID'; import withCurrentReportID from '@components/withCurrentReportID'; import useAppFocusEvent from '@hooks/useAppFocusEvent'; +import useDeepCompareRef from '@hooks/useDeepCompareRef'; import useIsReportOpenInRHP from '@hooks/useIsReportOpenInRHP'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -153,6 +154,7 @@ function ReportScreen({ }); const isLoadingReportOnyx = isLoadingOnyxValue(reportResult); + const permissions = useDeepCompareRef(reportOnyx?.permissions); /** * Create a lightweight Report so as to keep the re-rendering as light as possible by @@ -201,7 +203,7 @@ function ReportScreen({ isOptimisticReport: reportOnyx?.isOptimisticReport, lastMentionedTime: reportOnyx?.lastMentionedTime, avatarUrl: reportOnyx?.avatarUrl, - permissions: reportOnyx?.permissions, + permissions, invoiceReceiver: reportOnyx?.invoiceReceiver, }), [ @@ -242,7 +244,7 @@ function ReportScreen({ reportOnyx?.isOptimisticReport, reportOnyx?.lastMentionedTime, reportOnyx?.avatarUrl, - reportOnyx?.permissions, + permissions, reportOnyx?.invoiceReceiver, ], ); @@ -711,6 +713,7 @@ function ReportScreen({ onComposerFocus={() => setIsComposerFocus(true)} onComposerBlur={() => setIsComposerFocus(false)} report={report} + reportMetadata={reportMetadata} reportNameValuePairs={reportNameValuePairs} pendingAction={reportPendingAction} isComposerFullSize={!!isComposerFullSize} diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index ea7a970d549..61cf14b89b3 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -29,6 +29,7 @@ import ReportPreview from '@components/ReportActionItem/ReportPreview'; import TaskAction from '@components/ReportActionItem/TaskAction'; import TaskPreview from '@components/ReportActionItem/TaskPreview'; import TaskView from '@components/ReportActionItem/TaskView'; +import TripDetailsView from '@components/ReportActionItem/TripDetailsView'; import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import SpacerView from '@components/SpacerView'; import Text from '@components/Text'; @@ -516,11 +517,11 @@ function ReportActionItem({ if ( isIOUReport(action) && action.originalMessage && - // For the pay flow, we only want to show MoneyRequestAction when sending money. When paying, we display a regular system message + // For the pay flow, we only want to show MoneyRequestAction when sending money and we're not in the combine report. Otherwise, we display a regular system message (action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT || action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK || - isSendingMoney) + (isSendingMoney && !transactionThreadReport?.reportID)) ) { // There is no single iouReport for bill splits, so only 1:1 requests require an iouReportID const iouReportID = action.originalMessage.IOUReportID ? action.originalMessage.IOUReportID.toString() : '0'; @@ -783,6 +784,19 @@ function ReportActionItem({ return {content}; }; + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.TRIPPREVIEW) { + if (ReportUtils.isTripRoom(report)) { + return ( + + + + ); + } + } + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { if (ReportActionsUtils.isTransactionThread(parentReportAction)) { const isReversedTransaction = ReportActionsUtils.isReversedTransaction(parentReportAction); @@ -849,6 +863,7 @@ function ReportActionItem({ ); } + if (ReportUtils.isExpenseReport(report) || ReportUtils.isIOUReport(report) || ReportUtils.isInvoiceReport(report)) { return ( diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index ac56fe916bc..decd8f1b8d5 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -40,6 +40,8 @@ type ReportFooterProps = ReportFooterOnyxProps & { /** Report object for the current report */ report?: OnyxTypes.Report; + reportMetadata?: OnyxEntry; + reportNameValuePairs?: OnyxEntry; /** The last report action */ @@ -72,6 +74,7 @@ function ReportFooter({ pendingAction, session, report = {reportID: '0'}, + reportMetadata, reportNameValuePairs, shouldShowComposeInput = false, isEmptyChat = true, @@ -90,7 +93,10 @@ function ReportFooter({ const isAnonymousUser = session?.authTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS; const isSmallSizeLayout = windowWidth - (isSmallScreenWidth ? 0 : variables.sideBarWidth) < variables.anonymousReportFooterBreakpoint; - const hideComposer = !ReportUtils.canUserPerformWriteAction(report, reportNameValuePairs) || blockedFromChat; + + // If a user just signed in and is viewing a public report, optimistically show the composer while loading the report, since they will have write access when the response comes back. + const showComposerOptimistically = !isAnonymousUser && ReportUtils.isPublicRoom(report) && reportMetadata?.isLoadingInitialReportActions; + const hideComposer = (!ReportUtils.canUserPerformWriteAction(report, reportNameValuePairs) && !showComposerOptimistically) || blockedFromChat; const canWriteInReport = ReportUtils.canWriteInReport(report); const isSystemChat = ReportUtils.isSystemChat(report); @@ -211,6 +217,7 @@ export default withOnyx({ prevProps.lastReportAction === nextProps.lastReportAction && prevProps.shouldShowComposeInput === nextProps.shouldShowComposeInput && prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay && - lodashIsEqual(prevProps.session, nextProps.session), + lodashIsEqual(prevProps.session, nextProps.session) && + lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata), ), ); diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index 46bd3400655..5bbc9d22a97 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -64,11 +64,14 @@ type MoneyRequestAmountFormProps = { /** The current tab we have navigated to in the expense modal. String that corresponds to the expense type. */ selectedTab?: SelectedTabRequest; + + /** Whether the user input should be kept or not */ + shouldKeepUserInput?: boolean; }; const isAmountInvalid = (amount: string) => !amount.length || parseFloat(amount) < 0.01; const isTaxAmountInvalid = (currentAmount: string, taxAmount: number, isTaxAmountForm: boolean) => - isTaxAmountForm && Number.parseFloat(currentAmount) > CurrencyUtils.convertToFrontendAmount(Math.abs(taxAmount)); + isTaxAmountForm && Number.parseFloat(currentAmount) > CurrencyUtils.convertToFrontendAmountAsInteger(Math.abs(taxAmount)); const AMOUNT_VIEW_ID = 'amountView'; const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView'; @@ -88,6 +91,7 @@ function MoneyRequestAmountForm( onCurrencyButtonPress, onSubmitButtonPress, selectedTab = CONST.TAB_REQUEST.MANUAL, + shouldKeepUserInput = false, }: MoneyRequestAmountFormProps, forwardedRef: ForwardedRef, ) { @@ -144,7 +148,7 @@ function MoneyRequestAmountForm( }, [isFocused, wasFocused]); const initializeAmount = useCallback((newAmount: number) => { - const frontendAmount = newAmount ? CurrencyUtils.convertToFrontendAmount(newAmount).toString() : ''; + const frontendAmount = newAmount ? CurrencyUtils.convertToFrontendAmountAsString(newAmount) : ''; moneyRequestAmountInput.current?.changeAmount(frontendAmount); moneyRequestAmountInput.current?.changeSelection({ start: frontendAmount.length, @@ -218,14 +222,9 @@ function MoneyRequestAmountForm( return; } - // Update display amount string post-edit to ensure consistency with backend amount - // Reference: https://github.com/Expensify/App/issues/30505 - const backendAmount = CurrencyUtils.convertToBackendAmount(Number.parseFloat(currentAmount)); - initializeAmount(backendAmount); - onSubmitButtonPress({amount: currentAmount, currency, paymentMethod: iouPaymentType}); }, - [taxAmount, onSubmitButtonPress, currency, formattedTaxAmount, initializeAmount], + [taxAmount, onSubmitButtonPress, currency, formattedTaxAmount], ); const buttonText: string = useMemo(() => { @@ -287,6 +286,7 @@ function MoneyRequestAmountForm( } textInput.current = ref; }} + shouldKeepUserInput={shouldKeepUserInput} moneyRequestAmountInputRef={moneyRequestAmountInput} inputStyle={[styles.iouAmountTextInput]} containerStyle={[styles.iouAmountTextInputContainer]} diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index c1d55516b43..83a11a2c405 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -151,7 +151,14 @@ function IOURequestStartPage({ onTabSelected={resetIOUTypeIfChanged} tabBar={TabSelector} > - {() => } + + {() => ( + + )} + {() => } {shouldDisplayDistanceRequest && {() => }} diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index 1d9aec1ea60..1e8e63ee2df 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -115,7 +115,6 @@ function MoneyRequestParticipantsSelector({participants = [], onFinish, onPartic undefined, undefined, undefined, - !isCategorizeOrShareAction, isCategorizeOrShareAction ? 0 : undefined, ); diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 18dc951f949..e74ed42454a 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -54,6 +54,9 @@ type IOURequestStepAmountProps = IOURequestStepAmountOnyxProps & WithWritableReportOrNotFoundProps & { /** The transaction object being modified in Onyx */ transaction: OnyxEntry; + + /** Whether the user input should be kept or not */ + shouldKeepUserInput?: boolean; }; function IOURequestStepAmount({ @@ -68,6 +71,7 @@ function IOURequestStepAmount({ splitDraftTransaction, skipConfirmation, draftTransaction, + shouldKeepUserInput = false, }: IOURequestStepAmountProps) { const {translate} = useLocalize(); const textInput = useRef(null); @@ -163,7 +167,7 @@ function IOURequestStepAmount({ const amountInSmallestCurrencyUnits = CurrencyUtils.convertToBackendAmount(Number.parseFloat(amount)); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - IOU.setMoneyRequestAmount(transactionID, amountInSmallestCurrencyUnits, currency || CONST.CURRENCY.USD); + IOU.setMoneyRequestAmount(transactionID, amountInSmallestCurrencyUnits, currency || CONST.CURRENCY.USD, shouldKeepUserInput); if (backTo) { Navigation.goBack(backTo); @@ -309,6 +313,7 @@ function IOURequestStepAmount({ policyID={policy?.id ?? ''} bankAccountRoute={ReportUtils.getBankAccountRoute(report)} ref={(e) => (textInput.current = e)} + shouldKeepUserInput={transaction?.shouldShowOriginalAmount} onCurrencyButtonPress={navigateToCurrencySelectionPage} onSubmitButtonPress={saveAmountAndCurrency} selectedTab={iouRequestType} diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 858c509baa7..402e7c0e0e9 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -94,6 +94,7 @@ function IOURequestStepConfirmation({ const isSharingTrackExpense = action === CONST.IOU.ACTION.SHARE; const isCategorizingTrackExpense = action === CONST.IOU.ACTION.CATEGORIZE; const isSubmittingFromTrackExpense = action === CONST.IOU.ACTION.SUBMIT; + const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); const payeePersonalDetails = useMemo(() => { if (personalDetails?.[transaction?.splitPayerAccountIDs?.[0] ?? -1]) { return personalDetails?.[transaction?.splitPayerAccountIDs?.[0] ?? -1]; @@ -469,7 +470,7 @@ function IOURequestStepConfirmation({ return; } - if (isDistanceRequest && !IOUUtils.isMovingTransactionFromTrackExpense(action)) { + if (isDistanceRequest && !isMovingTransactionFromTrackExpense) { createDistanceRequest(selectedParticipants, trimmedComment); return; } @@ -489,7 +490,7 @@ function IOURequestStepConfirmation({ createDistanceRequest, isSharingTrackExpense, isCategorizingTrackExpense, - action, + isMovingTransactionFromTrackExpense, policy, policyTags, policyCategories, @@ -538,7 +539,9 @@ function IOURequestStepConfirmation({ diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index 679ffb0c8eb..ec965e4beee 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -117,7 +117,7 @@ const IOURequestStepDistanceRateWithOnyx = withOnyx `${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '0'}`, - selector: DistanceRequestUtils.getMileageRates, + selector: (policy: OnyxEntry) => DistanceRequestUtils.getMileageRates(policy), }, })(IOURequestStepDistanceRate); diff --git a/src/pages/iou/request/step/IOURequestStepMerchant.tsx b/src/pages/iou/request/step/IOURequestStepMerchant.tsx index fc7d39b4908..99606eb50e5 100644 --- a/src/pages/iou/request/step/IOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/IOURequestStepMerchant.tsx @@ -60,10 +60,11 @@ function IOURequestStepMerchant({ // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value const isEditingSplitBill = iouType === CONST.IOU.TYPE.SPLIT && isEditing; + const isTypeInvoice = iouType === CONST.IOU.TYPE.INVOICE; const merchant = ReportUtils.getTransactionDetails(isEditingSplitBill && !isEmptyObject(splitDraftTransaction) ? splitDraftTransaction : transaction)?.merchant; const isEmptyMerchant = merchant === '' || merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; - const isMerchantRequired = ReportUtils.isReportInGroupPolicy(report) || transaction?.participants?.some((participant) => Boolean(participant.isPolicyExpenseChat)); + const isMerchantRequired = ReportUtils.isReportInGroupPolicy(report) || isTypeInvoice || transaction?.participants?.some((participant) => Boolean(participant.isPolicyExpenseChat)); const navigateBack = () => { Navigation.goBack(backTo); }; diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index ede79e009a4..0e74b7c392a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -209,7 +209,7 @@ function IOURequestStepScan({ return true; }) .catch(() => { - setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedImage'); + setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedAttachment'); return false; }); } diff --git a/src/pages/settings/Subscription/SaveWithExpensifyButton/index.native.tsx b/src/pages/settings/Subscription/SaveWithExpensifyButton/index.native.tsx index be12d66a4f9..6eef4331c30 100644 --- a/src/pages/settings/Subscription/SaveWithExpensifyButton/index.native.tsx +++ b/src/pages/settings/Subscription/SaveWithExpensifyButton/index.native.tsx @@ -3,4 +3,6 @@ function SaveWithExpensifyButton() { return null; } +SaveWithExpensifyButton.displayName = 'SaveWithExpensifyButton'; + export default SaveWithExpensifyButton; diff --git a/src/pages/settings/Subscription/SaveWithExpensifyButton/index.tsx b/src/pages/settings/Subscription/SaveWithExpensifyButton/index.tsx index f6aff02c801..ea12afe9044 100644 --- a/src/pages/settings/Subscription/SaveWithExpensifyButton/index.tsx +++ b/src/pages/settings/Subscription/SaveWithExpensifyButton/index.tsx @@ -20,4 +20,6 @@ function SaveWithExpensifyButton() { ); } +SaveWithExpensifyButton.displayName = 'SaveWithExpensifyButton'; + export default SaveWithExpensifyButton; diff --git a/src/pages/settings/Subscription/SubscriptionDetails/index.native.tsx b/src/pages/settings/Subscription/SubscriptionDetails/index.native.tsx new file mode 100644 index 00000000000..0eed6fd99ea --- /dev/null +++ b/src/pages/settings/Subscription/SubscriptionDetails/index.native.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import Icon from '@components/Icon'; +import * as Illustrations from '@components/Icon/Illustrations'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OptionItem from '@components/OptionsPicker/OptionItem'; +import Section from '@components/Section'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function SubscriptionDetails() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); + + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + + // This section is only shown when the subscription is annual + let subscriptionSizeSection: React.JSX.Element | null = null; + + if (privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL) { + subscriptionSizeSection = privateSubscription?.userCount ? ( + + ) : ( + + {translate('subscription.details.headsUpTitle')} + {translate('subscription.details.headsUpBody')} + + ); + } + + return ( +
+ {!!account?.isApprovedAccountant || !!account?.isApprovedAccountantClient ? ( + + + {translate('subscription.details.zeroCommitment')} + + ) : ( + <> + {privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.PAYPERUSE ? ( + + ) : ( + + )} + {subscriptionSizeSection} + + )} +
+ ); +} + +SubscriptionDetails.displayName = 'SubscriptionDetails'; + +export default SubscriptionDetails; diff --git a/src/pages/settings/Subscription/SubscriptionDetails/index.tsx b/src/pages/settings/Subscription/SubscriptionDetails/index.tsx new file mode 100644 index 00000000000..350d84d00a4 --- /dev/null +++ b/src/pages/settings/Subscription/SubscriptionDetails/index.tsx @@ -0,0 +1,107 @@ +import React, {useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import Icon from '@components/Icon'; +import * as Illustrations from '@components/Icon/Illustrations'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import type {OptionsPickerItem} from '@components/OptionsPicker'; +import OptionsPicker from '@components/OptionsPicker'; +import Section from '@components/Section'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type SubscriptionVariant = ValueOf; + +const options: Array> = [ + { + key: CONST.SUBSCRIPTION.TYPE.ANNUAL, + title: 'subscription.details.annual', + icon: Illustrations.SubscriptionAnnual, + }, + { + key: CONST.SUBSCRIPTION.TYPE.PAYPERUSE, + title: 'subscription.details.payPerUse', + icon: Illustrations.SubscriptionPPU, + }, +]; + +function SubscriptionDetails() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [preferredTheme] = useOnyx(ONYXKEYS.PREFERRED_THEME); + + const [selectedOption, setSelectedOption] = useState(privateSubscription?.type ?? CONST.SUBSCRIPTION.TYPE.ANNUAL); + + const onOptionSelected = (option: SubscriptionVariant) => { + setSelectedOption(option); + }; + + // This section is only shown when the subscription is annual + // An onPress action is going to be assigned to these buttons in phase 2 + let subscriptionSizeSection: React.JSX.Element | null = null; + + if (privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL) { + subscriptionSizeSection = privateSubscription?.userCount ? ( + + ) : ( + <> + + + {translate('subscription.details.headsUpTitle')} + {translate('subscription.details.headsUpBody')} + + + ); + } + + return ( +
+ {!!account?.isApprovedAccountant || !!account?.isApprovedAccountantClient ? ( + + + {translate('subscription.details.zeroCommitment')} + + ) : ( + <> + + {subscriptionSizeSection} + + )} +
+ ); +} + +SubscriptionDetails.displayName = 'SubscriptionDetails'; + +export default SubscriptionDetails; diff --git a/src/pages/settings/Subscription/SubscriptionPlan.tsx b/src/pages/settings/Subscription/SubscriptionPlan.tsx index 28da111f298..0834d2b89e1 100644 --- a/src/pages/settings/Subscription/SubscriptionPlan.tsx +++ b/src/pages/settings/Subscription/SubscriptionPlan.tsx @@ -69,6 +69,8 @@ function SubscriptionPlan() { {benefit}
@@ -91,4 +93,6 @@ function SubscriptionPlan() { ); } +SubscriptionPlan.displayName = 'SubscriptionPlan'; + export default SubscriptionPlan; diff --git a/src/pages/settings/Subscription/SubscriptionSettingsPage.tsx b/src/pages/settings/Subscription/SubscriptionSettingsPage.tsx index be65fb8aa35..932c83c1b7d 100644 --- a/src/pages/settings/Subscription/SubscriptionSettingsPage.tsx +++ b/src/pages/settings/Subscription/SubscriptionSettingsPage.tsx @@ -2,17 +2,21 @@ import React, {useEffect} from 'react'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Illustrations from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; +import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import * as Subscription from '@userActions/Subscription'; +import SubscriptionDetails from './SubscriptionDetails'; import SubscriptionPlan from './SubscriptionPlan'; function SubscriptionSettingsPage() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); + const styles = useThemeStyles(); const subscriptionPlan = useSubscriptionPlan(); useEffect(() => { @@ -31,7 +35,10 @@ function SubscriptionSettingsPage() { shouldShowBackButton={shouldUseNarrowLayout} icon={Illustrations.CreditCardsNew} /> - + + + +
); } diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx index 245b4e4a291..05f72ac3c80 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx @@ -17,6 +17,7 @@ import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {FullScreenNavigatorParamList} from '@libs/Navigation/types'; import * as Category from '@userActions/Policy/Category'; +import * as DistanceRate from '@userActions/Policy/DistanceRate'; import * as Policy from '@userActions/Policy/Policy'; import * as Tag from '@userActions/Policy/Tag'; import CONST from '@src/CONST'; @@ -71,7 +72,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro isActive: policy?.areDistanceRatesEnabled ?? false, pendingAction: policy?.pendingFields?.areDistanceRatesEnabled, action: (isEnabled: boolean) => { - Policy.enablePolicyDistanceRates(policy?.id ?? '', isEnabled); + DistanceRate.enablePolicyDistanceRates(policy?.id ?? '', isEnabled); }, }, { @@ -184,6 +185,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro { if (!rate?.enabled || canDisableOrDeleteRate) { - Policy.setPolicyDistanceRatesEnabled(policyID, customUnit, [{...rate, enabled: !rate?.enabled}]); + DistanceRate.setPolicyDistanceRatesEnabled(policyID, customUnit, [{...rate, enabled: !rate?.enabled}]); } else { setIsWarningModalVisible(true); } @@ -73,7 +73,7 @@ function PolicyDistanceRateDetailsPage({policy, route}: PolicyDistanceRateDetail const deleteRate = () => { Navigation.goBack(); - Policy.deletePolicyDistanceRates(policyID, customUnit, [rateID]); + DistanceRate.deletePolicyDistanceRates(policyID, customUnit, [rateID]); setIsDeleteModalVisible(false); }; @@ -95,7 +95,7 @@ function PolicyDistanceRateDetailsPage({policy, route}: PolicyDistanceRateDetail ]; const clearErrorFields = (fieldName: keyof Rate) => { - Policy.clearPolicyDistanceRateErrorFields(policyID, customUnit.customUnitID, rateID, {...errorFields, [fieldName]: null}); + DistanceRate.clearPolicyDistanceRateErrorFields(policyID, customUnit.customUnitID, rateID, {...errorFields, [fieldName]: null}); }; return ( diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx index 954f5af0cf4..229d8744350 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx @@ -17,7 +17,7 @@ import {validateRateValue} from '@libs/PolicyDistanceRatesUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import * as Policy from '@userActions/Policy/Policy'; +import * as DistanceRate from '@userActions/Policy/DistanceRate'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -45,7 +45,7 @@ function PolicyDistanceRateEditPage({policy, route}: PolicyDistanceRateEditPageP const currentRateValue = (rate?.rate ?? 0).toString(); const submitRate = (values: FormOnyxValues) => { - Policy.updatePolicyDistanceRateValue(policyID, customUnit, [{...rate, rate: Number(values.rate) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET}]); + DistanceRate.updatePolicyDistanceRateValue(policyID, customUnit, [{...rate, rate: Number(values.rate) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET}]); Keyboard.dismiss(); Navigation.goBack(); }; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index cecb081d2ea..e659750754a 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -26,7 +26,7 @@ import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import type {FullScreenNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import * as Policy from '@userActions/Policy/Policy'; +import * as DistanceRate from '@userActions/Policy/DistanceRate'; import ButtonWithDropdownMenu from '@src/components/ButtonWithDropdownMenu'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -68,17 +68,17 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps) ); const fetchDistanceRates = useCallback(() => { - Policy.openPolicyDistanceRatesPage(policyID); + DistanceRate.openPolicyDistanceRatesPage(policyID); }, [policyID]); const dismissError = useCallback( (item: RateForList) => { if (customUnitRates[item.value].errors) { - Policy.clearDeleteDistanceRateError(policyID, customUnit?.customUnitID ?? '', item.value); + DistanceRate.clearDeleteDistanceRateError(policyID, customUnit?.customUnitID ?? '', item.value); return; } - Policy.clearCreateDistanceRateItemAndError(policyID, customUnit?.customUnitID ?? '', item.value); + DistanceRate.clearCreateDistanceRateItemAndError(policyID, customUnit?.customUnitID ?? '', item.value); }, [customUnit?.customUnitID, customUnitRates, policyID], ); @@ -132,7 +132,7 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps) return; } - Policy.setPolicyDistanceRatesEnabled( + DistanceRate.setPolicyDistanceRatesEnabled( policyID, customUnit, selectedDistanceRates.filter((rate) => rate.enabled).map((rate) => ({...rate, enabled: false})), @@ -145,7 +145,7 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps) return; } - Policy.setPolicyDistanceRatesEnabled( + DistanceRate.setPolicyDistanceRatesEnabled( policyID, customUnit, selectedDistanceRates.filter((rate) => !rate.enabled).map((rate) => ({...rate, enabled: true})), @@ -158,7 +158,7 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps) return; } - Policy.deletePolicyDistanceRates( + DistanceRate.deletePolicyDistanceRates( policyID, customUnit, selectedDistanceRates.map((rate) => rate.customUnitRateID ?? ''), diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx index fae566a7485..15f3aabd76e 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx @@ -15,7 +15,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import * as Category from '@userActions/Policy/Category'; -import * as Policy from '@userActions/Policy/Policy'; +import * as DistanceRate from '@userActions/Policy/DistanceRate'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -48,7 +48,7 @@ function PolicyDistanceRatesSettingsPage({policy, policyCategories, route}: Poli const errorFields = customUnits[customUnitID]?.errorFields; const setNewUnit = (unit: UnitItemType) => { - Policy.setPolicyDistanceRatesUnit(policyID, customUnit, {...customUnit, attributes: {unit: unit.value}}); + DistanceRate.setPolicyDistanceRatesUnit(policyID, customUnit, {...customUnit, attributes: {unit: unit.value}}); }; const setNewCategory = (category: ListItem) => { @@ -63,7 +63,7 @@ function PolicyDistanceRatesSettingsPage({policy, policyCategories, route}: Poli }; const clearErrorFields = (fieldName: keyof CustomUnit) => { - Policy.clearPolicyDistanceRatesErrorFields(policyID, customUnitID, {...errorFields, [fieldName]: null}); + DistanceRate.clearPolicyDistanceRatesErrorFields(policyID, customUnitID, {...errorFields, [fieldName]: null}); }; return ( diff --git a/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx b/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx index 1301ad100d7..d64d8f5dac3 100644 --- a/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx @@ -2,6 +2,7 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React, {useCallback} from 'react'; import {View} from 'react-native'; import AmountPicker from '@components/AmountPicker'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; @@ -14,6 +15,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {createPolicyTax, getNextTaxCode, getTaxValueWithPercentage, validateTaxName, validateTaxValue} from '@libs/actions/TaxRate'; import Navigation from '@libs/Navigation/Navigation'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -71,46 +73,48 @@ function WorkspaceCreateTaxPage({ includeSafeAreaPaddingBottom={false} style={[styles.defaultModalContainer]} > - - - - - - (v ? getTaxValueWithPercentage(v) : '')} - description={translate('workspace.taxes.value')} - rightLabel={translate('common.required')} - hideCurrencySymbol - // The default currency uses 2 decimal places, so we substract it - extraDecimals={CONST.MAX_TAX_RATE_DECIMAL_PLACES - 2} - // We increase the amount max length to support the extra decimals. - amountMaxLength={CONST.MAX_TAX_RATE_DECIMAL_PLACES + CONST.MAX_TAX_RATE_INTEGER_PLACES} - extraSymbol={%} - /> - - - + + + + + + + (v ? getTaxValueWithPercentage(v) : '')} + description={translate('workspace.taxes.value')} + rightLabel={translate('common.required')} + hideCurrencySymbol + // The default currency uses 2 decimal places, so we substract it + extraDecimals={CONST.MAX_TAX_RATE_DECIMAL_PLACES - 2} + // We increase the amount max length to support the extra decimals. + amountMaxLength={CONST.MAX_TAX_RATE_DECIMAL_PLACES + CONST.MAX_TAX_RATE_INTEGER_PLACES} + extraSymbol={%} + /> + + + + ); diff --git a/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx b/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx index 913302fb227..f40f721bf4c 100644 --- a/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx +++ b/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx @@ -1,6 +1,6 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import Icon from '@components/Icon'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Switch from '@components/Switch'; @@ -22,6 +22,8 @@ type ToggleSettingOptionRowProps = { shouldPlaceSubtitleBelowSwitch?: boolean; /** Used to apply styles to the outermost container */ wrapperStyle?: StyleProp; + /** Used to apply styles to the Title */ + titleStyle?: StyleProp; /** Whether the option is enabled or not */ isActive: boolean; /** Callback to be called when the switch is toggled */ @@ -49,6 +51,7 @@ function ToggleSettingOptionRow({ switchAccessibilityLabel, shouldPlaceSubtitleBelowSwitch, wrapperStyle, + titleStyle, onToggle, subMenuItems, isActive, @@ -85,7 +88,7 @@ function ToggleSettingOptionRow({ /> )} - {title} + {title} {!shouldPlaceSubtitleBelowSwitch && subtitle && subTitleView}
diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 5b17a4e2605..aa779e6b9ea 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -261,6 +261,7 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr lineHeight: variables.lineHeightNormal, }, + textSmall: { + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, + fontSize: variables.fontSizeSmall, + }, + textMicro: { fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeSmall, @@ -1377,6 +1382,10 @@ const styles = (theme: ThemeColors) => color: theme.textSupporting, }, + lh14: { + lineHeight: variables.lineHeightSmall, + }, + lh16: { lineHeight: 16, }, @@ -2799,7 +2808,7 @@ const styles = (theme: ThemeColors) => borderedContentCard: { borderWidth: 1, borderColor: theme.border, - borderRadius: variables.componentBorderRadiusMedium, + borderRadius: variables.componentBorderRadiusNormal, }, sectionMenuItem: { @@ -2809,6 +2818,10 @@ const styles = (theme: ThemeColors) => alignItems: 'center', }, + sectionSelectCircle: { + backgroundColor: colors.productDark200, + }, + qrShareSection: { width: 264, }, @@ -4406,6 +4419,16 @@ const styles = (theme: ThemeColors) => maxWidth: 400, }, + pdfErrorPlaceholder: { + overflow: 'hidden', + borderWidth: 2, + borderColor: theme.cardBG, + borderRadius: variables.componentBorderRadiusLarge, + maxWidth: 400, + height: '100%', + backgroundColor: theme.highlightBG, + }, + moneyRequestAttachReceipt: { backgroundColor: theme.highlightBG, borderColor: theme.border, @@ -4932,6 +4955,15 @@ const styles = (theme: ThemeColors) => flex: 1, }, + tripReservationIconContainer: { + width: variables.avatarSizeNormal, + height: variables.avatarSizeNormal, + backgroundColor: theme.border, + borderRadius: variables.componentBorderRadiusXLarge, + alignItems: 'center', + justifyContent: 'center', + }, + textLineThrough: { textDecorationLine: 'line-through', }, @@ -4946,10 +4978,6 @@ const styles = (theme: ThemeColors) => fontSize: variables.fontSizeNormal, fontWeight: FontUtils.fontWeight.bold, }, - - reportListItemActionButtonMargin: { - marginLeft: variables.searchTypeColumnWidth, - }, } satisfies Styles); type ThemeStyles = ReturnType; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 6f1cac46d72..f81e2ad9fd5 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -190,6 +190,8 @@ export default { eReceiptBGHeight: 540, eReceiptBGHWidth: 335, eReceiptTextContainerWidth: 263, + receiptPlaceholderIconWidth: 80, + receiptPlaceholderIconHeight: 80, reportPreviewMaxWidth: 335, reportActionImagesSingleImageHeight: 147, reportActionImagesDoubleImageHeight: 138, diff --git a/src/types/onyx/Account.ts b/src/types/onyx/Account.ts index c53d7ea816f..46eac1e94aa 100644 --- a/src/types/onyx/Account.ts +++ b/src/types/onyx/Account.ts @@ -63,6 +63,12 @@ type Account = { /** Object containing all account information necessary to connect with Spontana */ travelSettings?: TravelSettings; + + /** Indicates whether the user is an approved accountant */ + isApprovedAccountant?: boolean; + + /** Indicates whether the user is a client of an approved accountant */ + isApprovedAccountantClient?: boolean; }; export default Account; diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index b079a64ebb4..c62bd8f34f2 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -27,6 +27,7 @@ type OriginalMessageActionName = | 'ACTIONABLEMENTIONWHISPER' | 'ACTIONABLEREPORTMENTIONWHISPER' | 'ACTIONABLETRACKEXPENSEWHISPER' + | 'TRIPPREVIEW' | ValueOf; type OriginalMessageApproved = { actionName: typeof CONST.REPORT.ACTIONS.TYPE.APPROVED; @@ -342,6 +343,15 @@ type OriginalMessageDismissedViolation = { }; }; +type OriginalMessageTripRoomPreview = { + actionName: typeof CONST.REPORT.ACTIONS.TYPE.TRIPPREVIEW; + originalMessage: { + linkedReportID: string; + lastModified?: string; + whisperedTo?: number[]; + }; +}; + type OriginalMessage = | OriginalMessageApproved | OriginalMessageIOU @@ -366,6 +376,7 @@ type OriginalMessage = | OriginalMessageReimbursementDequeued | OriginalMessageMoved | OriginalMessageMarkedReimbursed + | OriginalMessageTripRoomPreview | OriginalMessageActionableTrackedExpenseWhisper | OriginalMessageMergedWithCashTransaction | OriginalMessageDismissedViolation; diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index 73cb5007e39..c7d63e95fd3 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -60,6 +60,9 @@ type SearchReport = { /** The report currency */ currency?: string; + /** The report type */ + type?: string; + /** The action that can be performed for the report */ action?: string; }; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 460bd279048..1c2c32a9a64 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -82,6 +82,7 @@ type Receipt = { filename?: string; state?: ValueOf; type?: string; + reservationList?: Reservation[]; }; type Route = { @@ -110,6 +111,51 @@ type TaxRate = { data?: TaxRateData; }; +type Reservation = { + reservationID?: string; + start: ReservationTimeDetails; + end: ReservationTimeDetails; + type: ReservationType; + company?: Company; + confirmations?: ReservationConfirmation[]; + numPassengers?: number; + numberOfRooms?: number; + route?: { + airlineCode: string; + class?: string; + number: string; + }; + vendor?: string; + carInfo?: CarInfo; +}; + +type ReservationTimeDetails = { + date: string; + address?: string; + location?: string; + longName?: string; + shortName?: string; + timezoneOffset?: string; +}; + +type Company = { + longName: string; + shortName?: string; + phone?: string; +}; + +type ReservationConfirmation = { + name: string; + value: string; +}; + +type CarInfo = { + name?: string; + engine?: string; +}; + +type ReservationType = ValueOf; + type SplitShare = { amount: number; isModified?: boolean; @@ -241,6 +287,9 @@ type Transaction = OnyxCommon.OnyxValueWithOfflineFeedback< /** Holds the accountIDs of accounts who paid the split, for now only supports a single payer */ splitPayerAccountIDs?: number[]; + /** Whether the user input should be kept */ + shouldShowOriginalAmount?: boolean; + /** The actionable report action ID associated with the transaction */ actionableWhisperReportActionID?: string; @@ -278,6 +327,9 @@ export type { TransactionPendingFieldsKey, TransactionChanges, TaxRate, + Reservation, + ReservationTimeDetails, + ReservationType, ReceiptSource, TransactionCollectionDataSet, SplitShare, diff --git a/tests/unit/CurrencyUtilsTest.ts b/tests/unit/CurrencyUtilsTest.ts index a1e4b03fa71..089cdf8426a 100644 --- a/tests/unit/CurrencyUtilsTest.ts +++ b/tests/unit/CurrencyUtilsTest.ts @@ -105,15 +105,29 @@ describe('CurrencyUtils', () => { }); }); - describe('convertToFrontendAmount', () => { + describe('convertToFrontendAmountAsInteger', () => { test.each([ [2500, 25], [2550, 25.5], [25, 0.25], [2500, 25], [2500.5, 25], // The backend should never send a decimal .5 value - ])('Correctly converts %s to amount in units handled in frontend', (amount, expectedResult) => { - expect(CurrencyUtils.convertToFrontendAmount(amount)).toBe(expectedResult); + ])('Correctly converts %s to amount in units handled in frontend as an integer', (amount, expectedResult) => { + expect(CurrencyUtils.convertToFrontendAmountAsInteger(amount)).toBe(expectedResult); + }); + }); + + describe('convertToFrontendAmountAsString', () => { + test.each([ + [2500, '25.00'], + [2550, '25.50'], + [25, '0.25'], + [2500.5, '25.00'], + [null, ''], + [undefined, ''], + [0, '0.00'], + ])('Correctly converts %s to amount in units handled in frontend as a string', (input, expectedResult) => { + expect(CurrencyUtils.convertToFrontendAmountAsString(input)).toBe(expectedResult); }); }); diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index bcb569ba449..59fc3c9b3a1 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -477,6 +477,7 @@ describe('OptionsListUtils', () => { undefined, undefined, undefined, + false, undefined, undefined, undefined, @@ -491,8 +492,6 @@ describe('OptionsListUtils', () => { undefined, undefined, undefined, - undefined, - false, ); // Then no personal detail options will be returned diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 81d8bd8357f..1e822f9d2cb 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -812,10 +812,10 @@ describe('ReportUtils', () => { it('should return correctly all ancestors of a thread report', () => { const resultAncestors = [ - {report: reports[1], reportAction: reportActions[0], shouldDisplayNewMarker: false}, - {report: reports[2], reportAction: reportActions[1], shouldDisplayNewMarker: false}, - {report: reports[3], reportAction: reportActions[2], shouldDisplayNewMarker: false}, - {report: reports[4], reportAction: reportActions[3], shouldDisplayNewMarker: false}, + {report: reports[0], reportAction: reportActions[0], shouldDisplayNewMarker: false}, + {report: reports[1], reportAction: reportActions[1], shouldDisplayNewMarker: false}, + {report: reports[2], reportAction: reportActions[2], shouldDisplayNewMarker: false}, + {report: reports[3], reportAction: reportActions[3], shouldDisplayNewMarker: false}, ]; expect(ReportUtils.getAllAncestorReportActions(reports[4])).toEqual(resultAncestors); diff --git a/workflow_tests/assertions/platformDeployAssertions.ts b/workflow_tests/assertions/platformDeployAssertions.ts index c92d3dda97d..24bb91f001f 100644 --- a/workflow_tests/assertions/platformDeployAssertions.ts +++ b/workflow_tests/assertions/platformDeployAssertions.ts @@ -376,18 +376,6 @@ function assertPostGithubCommentJobExecuted(workflowResult: Step[], didExecute = }); } -function assertHybridAppJobExecuted(workflowResult: Step[], didExecute = true) { - const steps = [createStepAssertion('Deploy HybridApp', true, null, 'HYBRID_APP', 'Deploy HybridApp')] as const; - - steps.forEach((expectedStep) => { - if (didExecute) { - expect(workflowResult).toEqual(expect.arrayContaining([expectedStep])); - } else { - expect(workflowResult).not.toEqual(expect.arrayContaining([expectedStep])); - } - }); -} - export default { assertVerifyActorJobExecuted, assertDeployChecklistJobExecuted, @@ -395,7 +383,6 @@ export default { assertDesktopJobExecuted, assertIOSJobExecuted, assertWebJobExecuted, - assertHybridAppJobExecuted, assertPostSlackOnFailureJobExecuted, assertPostSlackOnSuccessJobExecuted, assertPostGithubCommentJobExecuted, diff --git a/workflow_tests/mocks/platformDeployMocks.ts b/workflow_tests/mocks/platformDeployMocks.ts index ca6e05b57f8..9d5727bbf5f 100644 --- a/workflow_tests/mocks/platformDeployMocks.ts +++ b/workflow_tests/mocks/platformDeployMocks.ts @@ -199,10 +199,6 @@ const PLATFORM_DEPLOY__WEB__STEP_MOCKS = [ PLATFORM_DEPLOY__WEB__PURGE_STAGING_CACHE__STEP_MOCK, ]; -// deploy hybridApp -const PLATFORM_DEPLOY__HYBRID_APP__STEP_MOCK = createMockStep('Deploy HybridApp', 'Deploy HybridApp', 'HYBRID_APP'); -const PLATFORM_DEPLOY__HYBRID_APP__STEP_MOCKS = [PLATFORM_DEPLOY__HYBRID_APP__STEP_MOCK]; - // post slack message on failure const PLATFORM_DEPLOY__POST_SLACK_FAIL__POST_SLACK__STEP_MOCK = createMockStep('Post Slack message on failure', 'Posting Slack message on platform deploy failure', 'POST_SLACK_FAIL', [ 'SLACK_WEBHOOK', @@ -278,7 +274,6 @@ export default { PLATFORM_DEPLOY__DESKTOP__STEP_MOCKS, PLATFORM_DEPLOY__IOS__STEP_MOCKS, PLATFORM_DEPLOY__WEB__STEP_MOCKS, - PLATFORM_DEPLOY__HYBRID_APP__STEP_MOCKS, PLATFORM_DEPLOY__POST_SLACK_FAIL__STEP_MOCKS, PLATFORM_DEPLOY__POST_SLACK_SUCCESS__STEP_MOCKS, PLATFORM_DEPLOY__POST_GITHUB_COMMENT__STEP_MOCKS, diff --git a/workflow_tests/platformDeploy.test.ts b/workflow_tests/platformDeploy.test.ts index 98fd9b933df..0ac68eb6d55 100644 --- a/workflow_tests/platformDeploy.test.ts +++ b/workflow_tests/platformDeploy.test.ts @@ -101,7 +101,6 @@ describe('test workflow platformDeploy', () => { desktop: mocks.PLATFORM_DEPLOY__DESKTOP__STEP_MOCKS, iOS: mocks.PLATFORM_DEPLOY__IOS__STEP_MOCKS, web: mocks.PLATFORM_DEPLOY__WEB__STEP_MOCKS, - hybridApp: mocks.PLATFORM_DEPLOY__HYBRID_APP__STEP_MOCKS, postSlackMessageOnFailure: mocks.PLATFORM_DEPLOY__POST_SLACK_FAIL__STEP_MOCKS, postSlackMessageOnSuccess: mocks.PLATFORM_DEPLOY__POST_SLACK_SUCCESS__STEP_MOCKS, postGithubComment: mocks.PLATFORM_DEPLOY__POST_GITHUB_COMMENT__STEP_MOCKS, @@ -126,7 +125,6 @@ describe('test workflow platformDeploy', () => { assertions.assertDesktopJobExecuted(result, true, false); assertions.assertIOSJobExecuted(result, true, false, true); assertions.assertWebJobExecuted(result, true, false); - assertions.assertHybridAppJobExecuted(result, true); assertions.assertPostSlackOnFailureJobExecuted(result, false); assertions.assertPostSlackOnSuccessJobExecuted(result, true, false); assertions.assertPostGithubCommentJobExecuted(result, true, false); @@ -187,7 +185,6 @@ describe('test workflow platformDeploy', () => { desktop: mocks.PLATFORM_DEPLOY__DESKTOP__STEP_MOCKS, iOS: mocks.PLATFORM_DEPLOY__IOS__STEP_MOCKS, web: mocks.PLATFORM_DEPLOY__WEB__STEP_MOCKS, - hybridApp: mocks.PLATFORM_DEPLOY__HYBRID_APP__STEP_MOCKS, postSlackMessageOnFailure: mocks.PLATFORM_DEPLOY__POST_SLACK_FAIL__STEP_MOCKS, postSlackMessageOnSuccess: mocks.PLATFORM_DEPLOY__POST_SLACK_SUCCESS__STEP_MOCKS, postGithubComment: mocks.PLATFORM_DEPLOY__POST_GITHUB_COMMENT__STEP_MOCKS, @@ -212,7 +209,6 @@ describe('test workflow platformDeploy', () => { assertions.assertDesktopJobExecuted(result, true, false); assertions.assertIOSJobExecuted(result, true, false, true); assertions.assertWebJobExecuted(result, true, false); - assertions.assertHybridAppJobExecuted(result, true); assertions.assertPostSlackOnFailureJobExecuted(result, false); assertions.assertPostSlackOnSuccessJobExecuted(result, true, false); assertions.assertPostGithubCommentJobExecuted(result, true, false); @@ -273,7 +269,6 @@ describe('test workflow platformDeploy', () => { desktop: mocks.PLATFORM_DEPLOY__DESKTOP__STEP_MOCKS, iOS: mocks.PLATFORM_DEPLOY__IOS__STEP_MOCKS, web: mocks.PLATFORM_DEPLOY__WEB__STEP_MOCKS, - hybridApp: mocks.PLATFORM_DEPLOY__HYBRID_APP__STEP_MOCKS, postSlackMessageOnFailure: mocks.PLATFORM_DEPLOY__POST_SLACK_FAIL__STEP_MOCKS, postSlackMessageOnSuccess: mocks.PLATFORM_DEPLOY__POST_SLACK_SUCCESS__STEP_MOCKS, postGithubComment: mocks.PLATFORM_DEPLOY__POST_GITHUB_COMMENT__STEP_MOCKS, @@ -298,7 +293,6 @@ describe('test workflow platformDeploy', () => { assertions.assertDesktopJobExecuted(result, false); assertions.assertIOSJobExecuted(result, false); assertions.assertWebJobExecuted(result, false); - assertions.assertHybridAppJobExecuted(result, false); assertions.assertPostSlackOnFailureJobExecuted(result, false); assertions.assertPostSlackOnSuccessJobExecuted(result, false); assertions.assertPostGithubCommentJobExecuted(result, true, false, false);