diff --git a/.eslintignore b/.eslintignore
index 162cc816ea80..26ecb1ae7cc7 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -12,4 +12,5 @@ docs/assets/**
web/gtm.js
**/.expo/**
src/libs/SearchParser/searchParser.js
+src/libs/SearchParser/autocompleteParser.js
help/_scripts/**
diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml
index 4031d6c0c119..34a5c356356e 100644
--- a/.github/workflows/cla.yml
+++ b/.github/workflows/cla.yml
@@ -4,39 +4,9 @@ on:
issue_comment:
types: [created]
pull_request_target:
- types: [opened, synchronize]
+ types: [opened, closed, synchronize]
jobs:
CLA:
- runs-on: ubuntu-latest
- # This job only runs for pull request comments or pull request target events (not issue comments)
- # It does not run for pull requests created by OSBotify
- if: ${{ github.event.issue.pull_request || (github.event_name == 'pull_request_target' && github.event.pull_request.user.login != 'OSBotify' && github.event.pull_request.user.login != 'imgbot[bot]') }}
- steps:
- - name: CLA comment check
- uses: actions-ecosystem/action-regex-match@9c35fe9ac1840239939c59e5db8839422eed8a73
- id: sign
- with:
- text: ${{ github.event.comment.body }}
- regex: '\s*I have read the CLA Document and I hereby sign the CLA\s*'
- - name: CLA comment re-check
- uses: actions-ecosystem/action-regex-match@9c35fe9ac1840239939c59e5db8839422eed8a73
- id: recheck
- with:
- text: ${{ github.event.comment.body }}
- regex: '\s*recheck\s*'
- - name: CLA Assistant
- if: ${{ steps.recheck.outputs.match != '' || steps.sign.outputs.match != '' || github.event_name == 'pull_request_target' }}
- # Version: 2.1.2-beta
- uses: cla-assistant/github-action@948230deb0d44dd38957592f08c6bd934d96d0cf
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- PERSONAL_ACCESS_TOKEN : ${{ secrets.CLA_BOTIFY_TOKEN }}
- with:
- path-to-signatures: '${{ github.repository }}/cla.json'
- path-to-document: 'https://github.com/${{ github.repository }}/blob/main/contributingGuides/CLA.md'
- branch: 'main'
- remote-organization-name: 'Expensify'
- remote-repository-name: 'CLA'
- lock-pullrequest-aftermerge: false
- allowlist: OSBotify,snyk-bot
+ uses: Expensify/GitHub-Actions/.github/workflows/cla.yml@main
+ secrets: inherit
diff --git a/.github/workflows/deployNewHelp.yml b/.github/workflows/deployNewHelp.yml
index 45a4ab7c3620..2d2f551482d2 100644
--- a/.github/workflows/deployNewHelp.yml
+++ b/.github/workflows/deployNewHelp.yml
@@ -55,7 +55,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v3
with:
- node-version: '20.15.1'
+ node-version: '20.18.0'
# Wil install the _help/package.js
- name: Install Node.js Dependencies
diff --git a/.nvmrc b/.nvmrc
index b8e593f5210c..2a393af592b8 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-20.15.1
+20.18.0
diff --git a/.prettierignore b/.prettierignore
index 98d06e8c5f71..c4c88bd11d3e 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -23,3 +23,4 @@ lib/**
# Automatically generated files
src/libs/SearchParser/searchParser.js
+src/libs/SearchParser/autocompleteParser.js
diff --git a/android/app/build.gradle b/android/app/build.gradle
index ffb9fa2b4c57..bf1a0c38de6d 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -110,8 +110,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1009005007
- versionName "9.0.50-7"
+ versionCode 1009005200
+ versionName "9.0.52-0"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/assets/images/companyCards/pending-bank.svg b/assets/images/companyCards/pending-bank.svg
new file mode 100644
index 000000000000..dc265466d53f
--- /dev/null
+++ b/assets/images/companyCards/pending-bank.svg
@@ -0,0 +1,263 @@
+
+
diff --git a/assets/images/gallery-not-found.svg b/assets/images/gallery-not-found.svg
new file mode 100644
index 000000000000..25da973ce9cb
--- /dev/null
+++ b/assets/images/gallery-not-found.svg
@@ -0,0 +1,18 @@
+
+
\ No newline at end of file
diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts
index 91fc4b1bf528..2d8e27fd453e 100644
--- a/config/webpack/webpack.common.ts
+++ b/config/webpack/webpack.common.ts
@@ -227,8 +227,6 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment):
'react-native-config': 'react-web-config',
// eslint-disable-next-line @typescript-eslint/naming-convention
'react-native$': 'react-native-web',
- // eslint-disable-next-line @typescript-eslint/naming-convention
- 'react-native-sound': 'react-native-web-sound',
// Module alias for web & desktop
// https://webpack.js.org/configuration/resolve/#resolvealias
// eslint-disable-next-line @typescript-eslint/naming-convention
diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md
index 82e368214223..be71cd4e115a 100644
--- a/contributingGuides/CONTRIBUTING.md
+++ b/contributingGuides/CONTRIBUTING.md
@@ -32,9 +32,9 @@ This project and everyone participating in it is governed by the Expensify [Code
At this time, we are not hiring contractors in Crimea, North Korea, Russia, Iran, Cuba, or Syria.
## Slack channels
-All contributors should be a member of a shared Slack channel called [#expensify-open-source](https://expensify.slack.com/archives/C01GTK53T8Q) -- this channel is used to ask **general questions**, facilitate **discussions**, and make **feature requests**.
+We have a shared Slack channel called #expensify-open-source — this channel is used to ask general questions, facilitate discussions, and make feature requests.
-Before requesting an invite to Slack, please ensure your Upwork account is active, since we only pay via Upwork (see [below](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#payment-for-contributions)). To request an invite to Slack, email contributors@expensify.com with the subject `Slack Channel Invites`. We'll send you an invite!
+That said, we have a small issue with adding users at the moment and we’re working with Slack to try and get this resolved. If you would like to join, [fill out this form](https://forms.gle/Q7hnhUJPnQCK7Fe56) with your email and Upwork profile link. Once resolved, we’ll add you.
Note: Do not send direct messages to the Expensify team in Slack or Expensify Chat, they will not be able to respond.
diff --git a/contributingGuides/OFFLINE_UX.md b/contributingGuides/OFFLINE_UX.md
index 47b2cf117a06..48d52af6f796 100644
--- a/contributingGuides/OFFLINE_UX.md
+++ b/contributingGuides/OFFLINE_UX.md
@@ -85,7 +85,7 @@ When the user is offline:
- In the event that `successData` and `failureData` are the same, you can use a single object `finallyData` in place of both.
**Handling errors:**
-- The [OfflineWithFeedback component](https://github.com/Expensify/App/blob/main/src/components/OfflineWithFeedback.js) already handles showing errors too, as long as you pass the error field in the [errors prop](https://github.com/Expensify/App/blob/128ea378f2e1418140325c02f0b894ee60a8e53f/src/components/OfflineWithFeedback.js#L29-L31)
+- The [OfflineWithFeedback component](https://github.com/Expensify/App/blob/main/src/components/OfflineWithFeedback.tsx) already handles showing errors too, as long as you pass the error field in the [errors prop](https://github.com/Expensify/App/blob/128ea378f2e1418140325c02f0b894ee60a8e53f/src/components/OfflineWithFeedback.js#L29-L31)
- The behavior for when something fails is:
- If you were adding new data, the failed to add data is displayed greyed out and with the button to dismiss the error
- If you were deleting data, the failed data is displayed regularly with the button to dismiss the error
diff --git a/contributingGuides/STYLE.md b/contributingGuides/STYLE.md
index 304811332916..e6660d848129 100644
--- a/contributingGuides/STYLE.md
+++ b/contributingGuides/STYLE.md
@@ -24,6 +24,7 @@
- [Refs](#refs)
- [Other Expensify Resources on TypeScript](#other-expensify-resources-on-typescript)
- [Default value for inexistent IDs](#default-value-for-inexistent-IDs)
+ - [Extract complex types](#extract-complex-types)
- [Naming Conventions](#naming-conventions)
- [Type names](#type-names)
- [Prop callbacks](#prop-callbacks)
@@ -492,6 +493,30 @@ const foo = report?.reportID ?? '-1';
report ? report.reportID : '-1';
```
+### Extract complex types
+
+Advanced data types, such as objects within function parameters, should be separated into their own type definitions. Callbacks in function parameters should be extracted if there's a possibility they could be reused somewhere else.
+
+```ts
+// BAD
+function foo(param1: string, param2: {id: string}) {...};
+
+// BAD
+function foo(param1: string, param2: (value: string) => void) {...};
+
+// GOOD
+type Data = {
+ id: string;
+};
+
+function foo(param1: string, param2: Data) {...};
+
+// GOOD
+type Callback = (value: string) => void
+
+function foo(param1: string, param2: Callback) {...};
+```
+
## Naming Conventions
### Type names
diff --git a/docs/articles/expensify-classic/connections/Additional-Travel-Integrations.md b/docs/articles/expensify-classic/connections/Additional-Travel-Integrations.md
deleted file mode 100644
index 7dcc8e5e9c29..000000000000
--- a/docs/articles/expensify-classic/connections/Additional-Travel-Integrations.md
+++ /dev/null
@@ -1,73 +0,0 @@
----
-title: Importing Receipts from Various Platforms to Expensify
-description: Detailed guide on how to import receipts from multiple travel platforms into Expensify.
----
-
-# Overview
-You can automatically import receipts from many travel platforms into Expensify, to make tracking expenses while traveling for business a breeze. Read on to learn how to import receipts from Bolt Work, Spot Hero, Trainline, Grab, HotelTonight, and Kayak for Business.
-
-## How to Connect to Bolt Work
-
-### Set Up Bolt Work Profile
-- Open the Bolt app, go to the side navigation menu, and select Payment.
-- At the bottom, select Set up work profile and follow the instructions, entering your work email for verification.
-
-### Link to Expensify
-- In the Bolt app, go to Work Rides.
-- Select Add expense provider, choose Expensify, and enter the associated email to receive a verification link.
-- Ensure you select your work ride profile as the payment method before booking.
-
-## How to Connect to SpotHero
-
-### Set up a Business Profile
-- Open the SpotHero app, click the hamburger icon, and go to Account Settings.
-- Click Set up Business Profile.
-- Specify the email connected to Expensify and set up your payment method.
-- Upon checkout, choose between Business and Personal Profiles in the "Payment Details" section.
-- If you want, you can set a weekly or monthly cadence for consolidated SpotHero expense reports in your Business Profile settings. This will batch all of your SpotHero expenses to import into Expensify at that cadence.
-
-## How to Connect to Trainline
-- To send a ticket receipt to Expensify:
- - In the Trainline app, navigate to the My Tickets tab.
- - Tap Manage my booking > Expense receipt > Send to Expensify.
-- That’s it!
-
-## How to Connect to Grab
-- In the Grab app, tap on your name, go to “Profiles”, and “Add a business profile”.
-- Follow instructions and enter your work email for verification.
-- In your profile, tap on Business > Expense Solution > Expensify > Save.
-- Before booking, select your Business profile and confirm.
-
-## How to Connect to HotelTonight
-- In HotelTonight, go to the Bookings tab and select your booking.
-- Select Receipt > Expensify, enter your Expensify email, and send.
-
-## How to Connect to Kayak for Business
-
-### Admin Setup
-- Admins should go to “Company Settings” and click on “Connect to Expensify”.
-- Bookings made by employees will automatically be sent to Expensify.
-
-### Traveler Setup
-- From your account settings, choose whether expenses should be sent to Expensify automatically or manually.
-- We recommend sending them automatically, so you can travel without even thinking about your expense reports.
-
-{% include faq-begin.md %}
-
-**Q: What if I don’t have the option for Send to Expensify in Trainline?**
-
-A: This can happen if the native iOS Mail app is not installed on an Apple device. However, you can still use the native iOS share to Expensify function for Trainline receipts.
-
-**Q: Why should I choose automatic mode in Kayak for Business?**
-
-A: Automatic mode is less effort as it’s easier to delete an expense in Expensify than to remember to forward a forgotten receipt.
-
-**Q: Can I receive consolidated reports from SpotHero?**
-
-A: Yes, you can set a weekly or monthly cadence for SpotHero expenses to be emailed in a consolidated report.
-
-**Q: Do I need to select a specific profile before booking in Bolt Work and Grab?**
-
-A: Yes, ensure you have selected your work or business profile as the payment method before booking.
-
-{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/connections/Travel-receipt-integrations.md b/docs/articles/expensify-classic/connections/Travel-receipt-integrations.md
new file mode 100644
index 000000000000..2e5b5065b3d5
--- /dev/null
+++ b/docs/articles/expensify-classic/connections/Travel-receipt-integrations.md
@@ -0,0 +1,121 @@
+---
+title: Travel Receipt Integrations
+description: How to use pre-built or custom integrations to track travel expenses
+---
+
+Expensify’s receipt integrations allow a merchant to upload receipts directly to a user’s Expensify account. A merchant just has to email a receipt to an Expensify user and Cc receipts@expensify.com. This automatically creates a transaction in the Expensify account for the user whose email address is in the To field.
+
+You can set up a receipt integration by using one of our existing pre-built integrations, or by building your own receipt integration.
+
+## Use a pre-built travel integration
+
+You can use our pre-built integrations to automatically import travel receipts from Bolt Work, Spot Hero, Grab, and Kayak for Business.
+
+### Bolt Work
+
+1. In the Bolt app, tap the menu icon in the top left and tap **Work trips**.
+2. Tap **Create profile**.
+3. Enter the email address that you use for Expensify, then tap **Next**.
+4. Enter your company details, then tap **Next**.
+5. Choose a payment method. If you don’t want to use the existing payment methods, you can create a new one by tapping **Add Payment Method**. Then tap **Next**.
+6. Tap **Done**.
+7. Tap Add expense provider, then tap **Expensify**.
+8. Tap **Verify**.
+9. Tap the menu icon on the top left and tap **Work trips** once more.
+10. Tap **Add expense provider** and select **Expensify** again.
+
+When booking a trip with Bolt Work, select your work trip profile as the payment method before booking. Then the receipt details will be automatically sent to Expensify.
+
+### SpotHero
+
+1. In the SpotHero app, tap the menu icon in the top left and tap **Account Settings**.
+2. Tap **Set up Business Profile**.
+3. Tap **Create Business Profile**.
+4. Enter the email address you use for Expensify and tap **Next**.
+5. Tap **Add a Payment Method** and enter your payment account details. Then tap **Next**.
+6. Tap **Expensify**.
+
+When reserving parking with SpotHero, select your business profile in the Payment Details section. Then the receipt will be automatically sent to Expensify. In your SpotHero Business Profile settings, you can also set a weekly or monthly cadence for SpotHero to send a batch of expenses to Expensify.
+
+### Grab
+
+1. In the Grab app, tap your profile picture in the top left.
+2. Tap your user icon again at the top of the settings menu.
+3. Tap **Add a business profile**.
+4. Tap Next twice, then tap **Let’s Get Started**.
+5. Enter the email address you use for Expensify and tap the next arrow in the bottom right.
+6. Check your email and copy the verification code you receive from Grab.
+7. Tap **Manage My Business Profile**.
+8. Under Preferences, tap **Expense Solution**.
+9. Tap **Expensify**, then tap **Save**.
+
+When booking a trip with Grab, tap **personal** and select **business** to ensure your business profile is selected. Then the receipt will be automatically sent to Expensify.
+
+### KAYAK for Business
+
+**Admin Setup**
+
+This process must be completed by a KAYAK for Business admin.
+
+1. On your KAYAK for Business homepage, click **Company Settings**.
+2. Click **Connect to Expensify**.
+
+KAYAK for Business will now forward bookings made by each employee into Expensify.
+
+**Traveler Setup**
+
+1. On your KAYAK for Business homepage, click **Profile Account Settings**.
+2. Enable the Expensify toggle to have your expenses automatically sent to Expensify. You also have the option to send them manually.
+
+## Build your own receipt integration
+
+1. Email receiptintegration@expensify.com and include:
+ - **Subject**: Use “Receipt Integration Request" as the subject line
+ - **Body**: List all email addresses the merchant sends email receipts from
+2. Once you receive your email confirmation (within approximately 2 weeks) that the email addresses have been whitelisted, you’ll then be able to Cc receipts@expensify.com on receipt emails to users, and transactions will be created in the users’ Expensify account.
+3. Test the integration by sending a receipt email to the email address you used to create your Expensify account and Cc receipts@expensify.com. Wait for the receipt to be SmartScanned. Then you will see the merchant, date, and amount added to the transaction.
+
+### Using the integration
+
+When sending an emailed receipt:
+
+- Attachments on an email (that are not an .ics file) will be SmartScanned. We recommend including the receipt as the only attachment.
+- You can only include one email address in the To field. In the Cc field, include only receipts@expensify.com.
+- Reservations for hotels and car rentals cannot be sent to Expensify as an expense because they are paid at the end of usage. You can only send transaction data for purchases that have already been made.
+- Use standardized three-letter currency codes (ISO 4217) where applicable.
+
+{% include faq-begin.md %}
+
+**In Trainline, what if I don’t have the option for Send to Expensify?**
+
+This can happen if the native iOS Mail app is not installed on an Apple device. However, you can still use the native iOS Share to Expensify function for Trainline receipts.
+
+**Why does it take 2 weeks to set up a custom integration?**
+
+Receipt integrations require our engineers to manually set them up on the backend. For that reason, it can take up to 2 weeks to set it up.
+
+**Is there a way to connect via API?**
+
+No, at this time there are no API receipt integrations. All receipt integrations are managed via receipt emails.
+
+**What is your Open API?**
+
+Our Open API is a self-serve tool meant to pull information out of Expensify. Typically, this tool is used to build integrations with accounting solutions that we don’t directly integrate with. If you wish to push data into Expensify, the only way to integrate is via the receipt integration options listed above in this article.
+
+**Are you able to split one email into separate receipts?**
+
+The receipt integration is unable to automatically split one email into separate receipts. However, once the receipt is SmartScanned, users can [split the expense](https://help.expensify.com/articles/expensify-classic/expenses/Split-an-expense) in their Expensify account.
+
+**Can we set up a (co-marketing) partnership?**
+
+We currently do not offer any co-marketing partnerships.
+
+**Can we announce or advertise our custom integration with Expensify?**
+
+Absolutely! You can promote the integration across your social media channels (tag @expensify and use the #expensify hashtag) and you can even create your own dedicated landing page on your website for your integration. At a minimum, we recommend including a brief overview of how the integration works, the benefits of using it, an integration setup guide, and guidance for how someone can contact you for support or integration setup if necessary.
+
+**How can I get help?**
+
+You can contact Concierge for ongoing support any time by clicking the green chat icon in the mobile or web app, or by emailing concierge@expensify.com. Concierge is a global team of highly trained product specialists focused on making our product as easy to use as possible and answering all your questions.
+
+{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md
index f926792ffd1f..aecf21acfc3f 100644
--- a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md
+++ b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md
@@ -2,70 +2,62 @@
title: Configure Netsuite
description: Configure NetSuite's export, coding, and advanced settings.
---
-By correctly configuring your NetSuite settings in Expensify, you can leverage the connection's settings to automate most of the tasks, making your workflow more efficient.
+Correctly configuring NetSuite settings in Expensify ensures seamless integration between your expense management and accounting processes, saving time and reducing manual errors. Aligning your workspace settings with NetSuite’s financial structure can automate data syncs, simplify reporting, and improve overall financial accuracy.
+
+# Best Practices Using NetSuite
+A connection to NetSuite lets you combine the power of Expensify’s expense management features with NetSuite’s accounting capabilities.
+
+By following the recommended best practices below, your finances will be automatically categorized and accounted for in NetSuite:
+- Configure your setup immediately after making the connection, and review each settings tab thoroughly.
+- Keep Auto Sync enabled:
+ - The daily sync will update Expensify with any changes to your chart of accounts, customers/projects, or bank accounts in NetSuite.
+ - Finalized reports will be exported to NetSuite automatically, saving your admin team time with every report.
+- Set your preferred exporter to someone who is both a workspace and domain admin.
+- Configure your coding settings and enforce them by [requiring categories and tags on expenses](https://help.expensify.com/articles/new-expensify/workspaces/Require-tags-and-categories-for-expenses).
# Step 1: Configure Export Settings
There are numerous options for exporting Expensify reports to NetSuite. Let's explore how to configure these settings to align with your business needs.
-To access these settings, head to **Settings > Workspace > Group > Connections** and select the **Configure** button.
+To access these settings, go to **Settings > Workspace > Group > Connections** and select the **Configure** button.
-## Export Options
-
-### Subsidiary
+## Subsidiary
The subsidiary selection will only appear if you use NetSuite OneWorld and have multiple subsidiaries active. If you add a new subsidiary to NetSuite, sync the workspace connection, and the new subsidiary should appear in the dropdown list under **Settings > Workspaces > _[Workspace Name]_ > Connections**.
-### Preferred Exporter
+## Preferred Exporter
This option allows any admin to export, but the preferred exporter will receive notifications in Expensify regarding the status of exports.
-### Date
+## Date
The three options for the date your report will export with are:
- Date of last expense: This will use the date of the previous expense on the report
- Submitted date: The date the employee submitted the report
- Exported date: The date you export the report to NetSuite
-## Reimbursable Expenses
-
-### Expense Reports
-
-Expensify transactions will export reimbursable expenses as expense reports by default, which will be posted to the payables account designated in NetSuite.
-
-### Vendor Bills
-
-Expensify transactions export as vendor bills in NetSuite and will be mapped to the subsidiary associated with the corresponding policy. Each report will be posted as payable to the vendor associated with the employee who submitted the report.
-You can also set an approval level in NetSuite for vendor bills.
+## Export Settings for Reimbursable Expenses
-### Journal Entries
+**Expense Reports:** Expensify transactions will export reimbursable expenses as expense reports by default, which will be posted to the payables account designated in NetSuite.
-Expensify transactions that are set to export as journal entries in NetSuite will be mapped to the subsidiary associated with this policy. All the transactions will be posted to the payable account specified in the policy.
+**Vendor Bills:** Expensify transactions export as vendor bills in NetSuite and will be mapped to the subsidiary associated with the corresponding policy. Each report will be posted as payable to the vendor associated with the employee who submitted the report. You can also set an approval level in NetSuite for vendor bills.
-You can also set an approval level in NetSuite for the journal entries.
+**Journal Entries:** Expensify transactions that are set to export as journal entries in NetSuite will be mapped to the subsidiary associated with this policy. All the transactions will be posted to the payable account specified in the policy. You can also set an approval level in NetSuite for the journal entries.
-**Important Notes:**
- Journal entry forms by default do not contain a customer column, so it is not possible to export customers or projects with this export option
- The credit line and header level classifications are pulled from the employee record
-## Non-Reimbursable Expenses
+## Export Settings for Non-Reimbursable Expenses
-### Vendor Bills
+**Vendor Bills:** Non-reimbursable expenses will be posted as a vendor bill payable to the default vendor specified in your policy's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific vendor in NetSuite. You can also set an approval level in NetSuite for the bills.
-Non-reimbursable expenses will be posted as a vendor bill payable to the default vendor specified in your policy's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific vendor in NetSuite. You can also set an approval level in NetSuite for the bills.
+**Journal Entries:** Non-reimbursable expenses will be posted to the Journal Entries posting account selected in your policy's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific account in NetSuite.
-### Journal Entries
-
-Non-reimbursable expenses will be posted to the Journal Entries posting account selected in your policy's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific account in NetSuite.
-
-**Important Notes:**
- Expensify Card expenses will always export as Journal Entries, even if you have Expense Reports or Vendor Bills configured for non-reimbursable expenses on the Export tab
- Journal entry forms do not contain a customer column, so it is not possible to export customers or projects with this export option
- The credit line and header level classifications are pulled from the employee record
-### Expense Reports
-
-To use the expense report option for your corporate card expenses, you will need to set up your default corporate cards in NetSuite.
+**Expense Reports:** To use the expense report option for your corporate card expenses, you will need to set up your default corporate cards in NetSuite.
To use a default corporate card for non-reimbursable expenses, you must select the correct card on the employee records (for individual accounts) or the subsidiary record (If you use a non-one world account, the default is found in your accounting preferences).
@@ -77,11 +69,11 @@ Add the corporate card option and corporate card main field to your expense repo
You can select the default account on your employee record to use individual corporate cards for each employee. Make sure you add this field to your employee entity form in NetSuite.
If you have multiple cards assigned to a single employee, you cannot export to each account. You can only have a single default per employee record.
-### Export Invoices
+## Export Invoices
Select the Accounts Receivable account you want your Invoice Reports to export. In NetSuite, the Invoices are linked to the customer, corresponding to the email address where the Invoice was sent.
-### Default Vendor Bills
+## Default Vendor Bills
When selecting the option to export non-reimbursable expenses as vendor bills, the list of vendors will be available in the dropdown menu.
@@ -169,7 +161,7 @@ From there, you should see the values for the Custom Segment under the Tag or Re
Don’t use the "Filtered by" feature available for Custom Segments. Expensify can’t make these dependent on other fields. If you do have a filter selected, we suggest switching that filter in NetSuite to "Subsidiary" and enabling all subsidiaries to ensure you don't receive any errors upon exporting reports.
-### Custom Records
+## Custom Records
Custom Records are added through the Custom Segments feature.
@@ -197,7 +189,7 @@ Lastly, head over to Expensify and do the following:
From there, you should see the values for the Custom Records under the Tag or Report Field settings in Expensify.
-### Custom Lists
+## Custom Lists
To add Custom Lists to your workspace, you’ll need to locate two fields in NetSuite:
- The name of the record
@@ -250,17 +242,11 @@ With this enabled, all submitters can add any newly imported Categories to an Ex
## Invite Employees & Set Approval Workflow
-### Invite Employees
-
-Use this option in Expensify to bring your employees from a specific NetSuite subsidiary into Expensify.
-Once imported, Expensify will send them an email letting them know they've been added to a workspace.
+**Invite Employees:** Use this option in Expensify to bring your employees from a specific NetSuite subsidiary into Expensify. Once imported, Expensify will send them an email letting them know they've been added to a workspace.
-### Set Approval Workflow
-
-Besides inviting employees, you can also establish an approval process in NetSuite.
-
-By doing this, the Approval Workflow in Expensify will automatically follow the same rules as NetSuite, typically starting with Manager Approval.
+**Set Approval Workflow:** In addition to inviting employees, you can establish an approval process in NetSuite. The Approval Workflow in Expensify will automatically follow the same rules as NetSuite, typically starting with Manager Approval.
+The available options are:
- **Basic Approval:** This is a single level of approval, where all users submit directly to a Final Approver. The Final Approver defaults to the workspace owner but can be edited on the people page.
- **Manager Approval (default):** Two levels of approval route reports first to an employee's NetSuite expense approver or supervisor, and second to a workspace-wide Final Approver. By NetSuite convention, Expensify will map to the supervisor if no expense approver exists. The Final Approver defaults to the workspace owner but can be edited on the people page.
- **Configure Manually:** Employees will be imported, but all levels of approval must be manually configured on the workspace's People settings page. If you enable this setting, it’s recommended you review the newly imported employees and managers on the **Settings > Workspaces > Group > _[Workspace Name]_ > People page**. You can set a user role for each new employee and enforce an approval workflow.
@@ -275,7 +261,7 @@ Using this feature allows you to send the original amount of the expense rather
## Cross-Subsidiary Customers/Projects
-This allows you to import Customers and Projects across all subsidiaries to a single group workspace. For this functionality, you must enable "Intercompany Time and Expense" in NetSuite.
+This allows you to import Customers and Projects across all subsidiaries to a single group workspace. To enable this functionality in NetSuite, you must enable "Intercompany Time and Expense."
That feature is found in NetSuite under _Setup > Company > Setup Tasks: Enable Features > Advanced Features_.
@@ -303,7 +289,7 @@ If you have Approval Routing selected in your accounting preference, this will o
If you do not wish to use Approval Routing in NetSuite, go to _Setup > Accounting > Accounting Preferences > Approval Routing_ and ensure Vendor Bills and Journal Entries are not selected.
-### Collection Account
+## Collection Account
When exporting invoices, once marked as Paid, the payment is marked against the account selected after enabling the Collection Account setting.
@@ -343,7 +329,7 @@ Add the corporate card option and the corporate card main field to configure you
If you prefer individual corporate cards for each employee, you can select the default account on the employee record. Add this field to your employee entity form in NetSuite (under _Customize > Customize Form_ from any employee record). Note that each employee can have only one corporate card account default.
-### Exporting Company Cards to GL Accounts in NetSuite
+## Exporting Company Cards to GL Accounts in NetSuite
If you need to export company card transactions to individual GL accounts, you can set that up at the domain level.
@@ -359,9 +345,7 @@ You’ll want to set up Tax Groups in Expensify if you're keeping track of taxes
Expensify can import "NetSuite Tax Groups" (not Tax Codes) from NetSuite. Tax Groups can contain one or more Tax Codes. If you have subsidiaries in the UK or Ireland, ensure your Tax Groups have only one Tax Code.
-You can locate these in NetSuite by setting up> Accounting > Tax Groups.
-
-You’ll want to name Tax Groups something that makes sense to your employees since both the name and the tax rate will appear in Expensify.
+You can locate these in NetSuite by setting up> Accounting > Tax Groups. Name the Tax Groups something that makes sense to your employees since both the name and the tax rate will appear in Expensify.
To bring NetSuite Tax Groups into Expensify, here's what you need to do:
1. Create your Tax Groups in NetSuite by going to _Setup > Accounting > Tax Groups_
@@ -386,7 +370,7 @@ Expensify. If you deactivate this group in NetSuite, it will lead to export erro
Additionally, some tax nexuses in NetSuite have specific settings that need to be configured in a certain way to work seamlessly with the Expensify integration:
- In the Tax Code Lists Include field, choose "Tax Groups" or "Tax Groups and Tax Codes." This setting determines how tax information is handled.
-- In the Tax Rounding Method field, select "Round Off." Although it won't cause connection errors, not using this setting can result in exported amounts differing from what NetSuite expects.
+- In the Tax Rounding Method field, select "Round Off." Although this setting won't cause connection errors, not using it can result in exported amounts differing from what NetSuite expects.
If your tax groups are importing into Expensify but not exporting to NetSuite, check that each tax group has the right subsidiaries enabled. That is crucial for proper data exchange.
@@ -408,7 +392,7 @@ Let's dive right in:
1. Access Configuration Settings: Go to **Settings > Workspace > Group > _[Workspace Name]_ > Connections > Configuration**
2. Choose Your Accounts Receivable Account: Scroll down to "Export Expenses to" and select the appropriate Accounts Receivable account from the dropdown list. If you don't see any options, try syncing your NetSuite connection by returning to the Connections page and clicking **Sync Now**
-### Exporting an Invoice to NetSuite
+## Exporting an Invoice to NetSuite
Invoices will be automatically sent to NetSuite when they are in the "Processing" or "Paid" status. This ensures you always have an up-to-date record of unpaid and paid invoices.
@@ -421,7 +405,7 @@ When exporting to NetSuite, we match the recipient's email address on the invoic
Once exported, the invoice will appear in the Accounts Receivable account you selected during your NetSuite Export configuration.
-### Updating the status of an invoice to "paid"
+## Updating the status of an invoice to "paid"
When you mark an invoice as "Paid" in Expensify, this status will automatically update in NetSuite. Similarly, if the invoice is marked as "Paid" in NetSuite, it will sync with Expensify. The payment will be reflected in the Collection account specified in your Advanced Settings Configuration.
diff --git a/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md b/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md
index 1f96d9b8a633..6cc69fccccc1 100644
--- a/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md
+++ b/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md
@@ -1,12 +1,11 @@
---
title: NetSuite
-description: Set up the direct connection from Expensify to NetSuite.
+description: Connect NetSuite to Expensify for streamlined expense reporting and accounting integration.
order: 1
---
-# Overview
-Expensify's integration with NetSuite allows you to automate report exports, tailor your coding preferences, and tap into NetSuite's array of advanced features. By correctly configuring your NetSuite settings in Expensify, you can leverage the connection's settings to automate most of the tasks, making your workflow more efficient.
+Expensify's direct integration with NetSuite allows you to automate report exports, tailor your coding preferences, and tap into NetSuite's array of advanced features.
-**Before connecting NetSuite to Expensify, a few things to note:**
+## Before connecting NetSuite to Expensify, review the following details:
- Token-based authentication works by ensuring that each request to NetSuite is accompanied by a signed token which is verified for authenticity
- You must be able to login to NetSuite as an administrator to initiate the connection
- You must have a Control Plan in Expensify to integrate with NetSuite
@@ -15,9 +14,7 @@ Expensify's integration with NetSuite allows you to automate report exports, tai
- Ensure that your workspace's report output currency setting matches the NetSuite Subsidiary default currency
- Make sure your page size is set to 1000 for importing your customers and vendors. You can check this in NetSuite under **Setup > Integration > Web Services Preferences > 'Search Page Size'**
-# Connect to NetSuite
-
-## Step 1: Install the Expensify Bundle in NetSuite
+# Step 1: Install the Expensify Bundle in NetSuite
1. While logged into NetSuite as an administrator, go to Customization > SuiteBundler > Search & Install Bundles, then search for "Expensify"
2. Click on the Expensify Connect bundle (Bundle ID 283395)
@@ -25,13 +22,13 @@ Expensify's integration with NetSuite allows you to automate report exports, tai
4. If you already have the Expensify Connect bundle installed, head to _Customization > SuiteBundler > Search & Install Bundles > List_ and update it to the latest version
5. Select **Show on Existing Custom Forms** for all available fields
-## Step 2: Enable Token-Based Authentication
+# Step 2: Enable Token-Based Authentication
1. Head to _Setup > Company > Enable Features > SuiteCloud > Manage Authentication_
2. Make sure “Token Based Authentication” is enabled
3. Click **Save**
-## Step 3: Add Expensify Integration Role to a User
+# Step 3: Add Expensify Integration Role to a User
The user you select must have access to at least the permissions included in the Expensify Integration Role, but they're not required to be an Admin.
1. In NetSuite, head to Lists > Employees, and find the user you want to add the Expensify Integration role to
@@ -40,7 +37,7 @@ The user you select must have access to at least the permissions included in the
Remember that Tokens are linked to a User and a Role, not solely to a User. It's important to note that you cannot establish a connection with tokens using one role and then switch to another role afterward. Once you've initiated a connection with tokens, you must continue using the same token/user/role combination for all subsequent sync or export actions.
-## Step 4: Create Access Tokens
+# Step 4: Create Access Tokens
1. Using Global Search in NetSuite, enter “page: tokens”
2. Click **New Access Token**
@@ -49,21 +46,20 @@ Remember that Tokens are linked to a User and a Role, not solely to a User. It's
5. Press **Save**
6. Copy and Paste the token and token ID to a saved location on your computer (this is the only time you will see these details)
-## Step 5: Confirm Expense Reports are Enabled in NetSuite.
+# Step 5: Confirm Expense Reports are Enabled in NetSuite.
Enabling Expense Reports is required as part of Expensify's integration with NetSuite:
1. Logged into NetSuite as an administrator, go to Setup > Company > Enable Features > Employees
2. Confirm the checkbox next to Expense Reports is checked
3. If not, click the checkbox and then Save to enable Expense Reports
-## Step 6: Confirm Expense Categories are set up in NetSuite.
+# Step 6: Confirm Expense Categories are set up in NetSuite.
Once Expense Reports are enabled, Expense Categories can be set up in NetSuite. Expense Categories are an alias for General Ledger accounts used to code expenses.
-
1. Logged into NetSuite as an administrator, go to Setup > Accounting > Expense Categories (a list of Expense Categories should show)
2. If no Expense Categories are visible, click **New** to create new ones
-## Step 7: Confirm Journal Entry Transaction Forms are Configured Properly
+# Step 7: Confirm Journal Entry Transaction Forms are Configured Properly
1. Logged into NetSuite as an administrator, go to _Customization > Forms > Transaction Forms_
2. Click **Customize** or **Edit** next to the Standard Journal Entry form
@@ -71,7 +67,7 @@ Once Expense Reports are enabled, Expense Categories can be set up in NetSuite.
4. Click the sub-header Lines and verify that the "Show" column for "Receipt URL" is checked
5. Go to _Customization > Forms > Transaction Forms_ and ensure all other transaction forms with the journal type have this same configuration
-## Step 8: Confirm Expense Report Transaction Forms are Configured Properly
+# Step 8: Confirm Expense Report Transaction Forms are Configured Properly
1. Logged into NetSuite as an administrator, go to _Customization > Forms > Transaction Forms_
2. Click **Customize** or **Edit** next to the Standard Expense Report form, then click **Screen Fields > Main**
@@ -79,7 +75,7 @@ Once Expense Reports are enabled, Expense Categories can be set up in NetSuite.
4. Click the second sub-header, Expenses, and verify that the 'Show' column for 'Receipt URL' is checked
5. Go to _Customization > Forms > Transaction Forms_ and ensure all other transaction forms with the expense report type have this same configuration
-## Step 9: Confirm Vendor Bill Transactions Forms are Configured Properly
+# Step 9: Confirm Vendor Bill Transactions Forms are Configured Properly
1. Logged into NetSuite as an administrator, go to _Customization > Forms > Transaction Forms_
2. Click **Customize** or **Edit** next to your preferred Vendor Bill form
@@ -87,20 +83,20 @@ Once Expense Reports are enabled, Expense Categories can be set up in NetSuite.
4. Under the Expenses sub-header (make sure to click the "Expenses" sub-header at the very bottom and not "Expenses & Items"), ensure "Show" is checked for Receipt URL, Department, Location, and Class
5. Go to _Customization > Forms > Transaction Forms_ and provide all other transaction forms with the vendor bill type have this same configuration
-## Step 10: Confirm Vendor Credit Transactions Forms are Configured Properly
+# Step 10: Confirm Vendor Credit Transactions Forms are Configured Properly
1. While logged in as an administrator, go to _Customization > Forms > Transaction Forms_
2. Click **Customize** or **Edit** next to your preferred Vendor Credit form, then click _Screen Fields > Main_ and verify that the "Created From" label has "Show" checked and that Departments, Classes, and Locations have the "Show" label unchecked
3. Under the Expenses sub-header (make sure to click the "Expenses" sub-header at the very bottom and not "Expenses & Items"), ensure "Show" is checked for Receipt URL, Department, Location, and Class
4. Go to _Customization > Forms > Transaction Forms_ and ensure all other transaction forms with the vendor credit type have this same configuration
-## Step 11: Set up Tax Groups (only applicable if tracking taxes)
+# Step 11: Set up Tax Groups (only applicable if tracking taxes)
Expensify imports NetSuite Tax Groups (not Tax Codes), which you can find in NetSuite under _Setup > Accounting > Tax Groups_.
Tax Groups are an alias for Tax Codes in NetSuite and can contain one or more Tax Codes (Please note: for UK and Ireland subsidiaries, please ensure your Tax Groups do not have more than one Tax Code). We recommend naming Tax Groups so your employees can easily understand them, as the name and rate will be displayed in Expensify.
-Before importing NetSuite Tax Groups into Expensify:
+## Before importing NetSuite Tax Groups into Expensify:
1. Create your Tax Groups in NetSuite by going to _Setup > Accounting > Tax Groups_
2. Click **New**
3. Select the country for your Tax Group
@@ -115,9 +111,9 @@ Ensure Tax Groups can be applied to expenses by going to _Setup > Accounting > S
If this field does not display, it’s not needed for that specific country.
-## Step 12: Connect Expensify and NetSuite
+# Step 12: Connect Expensify and NetSuite
-1. Log into Expensify as a Policy Admin and go to **Settings > Workspaces > _[Workspace Name]_ > Connections > NetSuite**
+1. Log into Expensify as a Workspace Admin and go to **Settings > Workspaces > _[Workspace Name]_ > Connections > NetSuite**
2. Click **Connect to NetSuite**
3. Enter your Account ID (Account ID can be found in NetSuite by going to _Setup > Integration > Web Services Preferences_)
4. Then, enter the token and token secret
diff --git a/docs/articles/new-expensify/billing-and-subscriptions/adding-payment-card-subscription-overview.md b/docs/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription.md
similarity index 63%
rename from docs/articles/new-expensify/billing-and-subscriptions/adding-payment-card-subscription-overview.md
rename to docs/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription.md
index d30fa06bc059..c181536d1174 100644
--- a/docs/articles/new-expensify/billing-and-subscriptions/adding-payment-card-subscription-overview.md
+++ b/docs/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription.md
@@ -1,15 +1,18 @@
-Subscription Management
+---
+title: Subscription Management
+description: How to manage your subscription
+---
Under the subscriptions section of your account, you can manage your payment card details, view your current plan, add a billing card, and adjust your subscription size and renewal date.
To view or manage your subscription in New Expensify:
-**Open the App**: Launch New Expensify on your device.
-**Go to Account Settings**: Click your profile icon in the bottom-left corner.
-**Find Workspaces**: Navigate to the Workspaces section.
-**Open Subscriptions**: Click Subscription under Workspaces to view your subscription.
+* **Open the App**: Launch New Expensify on your device.
+* **Go to Account Settings**: Click your profile icon in the bottom-left corner.
+* **Find Workspaces**: Navigate to the Workspaces section.
+* **Open Subscriptions**: Click Subscription under Workspaces to view your subscription.
## Add a Payment Card
Look for the option to **Add Payment Card**. Enter your payment card details securely to ensure uninterrupted service.
-[PLACEHOLDER for design image- default]
+![A screenshot of adding payment card]({{site.url}}/assets/images/ExpensifyHelp-Subscription-Default.png){:width="100%"}
## Subscription Overview
This is where you can view your current subscription plan and see details like the number of seats, billing information, and the next renewal date.
@@ -19,13 +22,13 @@ This is where you can view your current subscription plan and see details like t
- **Auto-increase annual seats**: Here you can see how much you could save by automatically increasing seats to accommodate team members who exceed the current subscription size.
**Note**: This will extend your annual subscription end date.
-[PLACEHOLDER for design image- your plan]
+![A screenshot of subscription details]({{site.url}}/assets/images/ExpensifyHelp-Subscription-Details.png){:width="100%"}
## Early Cancellation Requests
If you need to cancel your subscription early, you can find the **Request Early Cancellation** option in the same Subscriptions section.
**Note**: Not all customers are eligible to cancel their subscription early.
-[PLACEHOLDER for design image- billing]
+![A screenshot of cancellation button]({{site.url}}/assets/images/ExpensifyHelp-Subscription-Billing.png){:width="100%"}
## Pricing Information
For more details on pricing plans, visit Billing Page [coming soon!]
diff --git a/docs/articles/new-expensify/expenses-&-payments/Track-expenses.md b/docs/articles/new-expensify/expenses-&-payments/Track-expenses.md
index f6260b9f8f84..77256279b1d7 100644
--- a/docs/articles/new-expensify/expenses-&-payments/Track-expenses.md
+++ b/docs/articles/new-expensify/expenses-&-payments/Track-expenses.md
@@ -40,4 +40,6 @@ For an in-depth walkthrough on how to create an expense, check out the [create a
{% include end-selector.html %}
+![The New Expensify page is open with the FAB (big + button) clicked and the option to Track Expenses is highlighted.]({{site.url}}/assets/images/FAB_track_expense.png){:width="100%"}
+
diff --git a/docs/articles/new-expensify/workspaces/Create-expense-categories.md b/docs/articles/new-expensify/workspaces/Create-expense-categories.md
index 56557d449908..a6874ac0a2ef 100644
--- a/docs/articles/new-expensify/workspaces/Create-expense-categories.md
+++ b/docs/articles/new-expensify/workspaces/Create-expense-categories.md
@@ -110,6 +110,7 @@ GL codes and payroll codes can be exported to a CSV export. They are not display
6. To add or edit a GL code, click the GL code field, make the desired change, then click **Save**
7. To add or edit a payroll code, click the payroll code field, make the desired change, then click **Save**
+![In the Workspace > Categories setting, the right-hand panel is open and the GL and Payroll code setting is highlighted.]({{site.url}}/assets/images/workspace_gl_payroll_codes.png){:width="100%"}
# Apply categories to expenses automatically
diff --git a/docs/articles/new-expensify/workspaces/Require-tags-and-categories-for-expenses.md b/docs/articles/new-expensify/workspaces/Require-tags-and-categories-for-expenses.md
index 8f2cf0897ad0..df77ed3b5b01 100644
--- a/docs/articles/new-expensify/workspaces/Require-tags-and-categories-for-expenses.md
+++ b/docs/articles/new-expensify/workspaces/Require-tags-and-categories-for-expenses.md
@@ -29,6 +29,8 @@ To require workspace members to add tags and/or categories to their expenses,
{% include end-option.html %}
{% include end-selector.html %}
+
+![In the Workspace > Categories setting, the right-hand panel is open and the toggle to require categories on expenses is highlighted.]({{site.url}}/assets/images/workspace_category_toggle.png){:width="100%"}
This will highlight the tag and/or category field as required on all expenses.
diff --git a/docs/assets/images/FAB_track_expense.png b/docs/assets/images/FAB_track_expense.png
new file mode 100644
index 000000000000..6ee0cf5abba4
Binary files /dev/null and b/docs/assets/images/FAB_track_expense.png differ
diff --git a/docs/assets/images/NetSuite_Configure_06.png b/docs/assets/images/NetSuite_Configure_06.png
new file mode 100644
index 000000000000..cddfe2fabcd6
Binary files /dev/null and b/docs/assets/images/NetSuite_Configure_06.png differ
diff --git a/docs/assets/images/NetSuite_Configure_08.png b/docs/assets/images/NetSuite_Configure_08.png
new file mode 100644
index 000000000000..77690a2c3aa1
Binary files /dev/null and b/docs/assets/images/NetSuite_Configure_08.png differ
diff --git a/docs/assets/images/NetSuite_Configure_09.png b/docs/assets/images/NetSuite_Configure_09.png
new file mode 100644
index 000000000000..8da56f22838d
Binary files /dev/null and b/docs/assets/images/NetSuite_Configure_09.png differ
diff --git a/docs/assets/images/NetSuite_Configure_Advanced_10.png b/docs/assets/images/NetSuite_Configure_Advanced_10.png
new file mode 100644
index 000000000000..23fe99498052
Binary files /dev/null and b/docs/assets/images/NetSuite_Configure_Advanced_10.png differ
diff --git a/docs/assets/images/NetSuite_Connect_Bundle_02.png b/docs/assets/images/NetSuite_Connect_Bundle_02.png
new file mode 100644
index 000000000000..c015178873ad
Binary files /dev/null and b/docs/assets/images/NetSuite_Connect_Bundle_02.png differ
diff --git a/docs/assets/images/NetSuite_Connect_Categories_05.png b/docs/assets/images/NetSuite_Connect_Categories_05.png
new file mode 100644
index 000000000000..e71341170129
Binary files /dev/null and b/docs/assets/images/NetSuite_Connect_Categories_05.png differ
diff --git a/docs/assets/images/NetSuite_Connect_Customization_01.png b/docs/assets/images/NetSuite_Connect_Customization_01.png
new file mode 100644
index 000000000000..8a0c53b45d7f
Binary files /dev/null and b/docs/assets/images/NetSuite_Connect_Customization_01.png differ
diff --git a/docs/assets/images/NetSuite_Connect_Expense_Reports_03.png b/docs/assets/images/NetSuite_Connect_Expense_Reports_03.png
new file mode 100644
index 000000000000..44c8fe6c993d
Binary files /dev/null and b/docs/assets/images/NetSuite_Connect_Expense_Reports_03.png differ
diff --git a/docs/assets/images/NetSuite_Expense_Categories_04.png b/docs/assets/images/NetSuite_Expense_Categories_04.png
new file mode 100644
index 000000000000..d13e9f95cfea
Binary files /dev/null and b/docs/assets/images/NetSuite_Expense_Categories_04.png differ
diff --git a/docs/assets/images/NetSuite_HelpScreenshot_07.png b/docs/assets/images/NetSuite_HelpScreenshot_07.png
new file mode 100644
index 000000000000..55cfe532f890
Binary files /dev/null and b/docs/assets/images/NetSuite_HelpScreenshot_07.png differ
diff --git a/docs/assets/images/Workspace_category_toggle.png b/docs/assets/images/Workspace_category_toggle.png
new file mode 100644
index 000000000000..c6af6fe183c0
Binary files /dev/null and b/docs/assets/images/Workspace_category_toggle.png differ
diff --git a/docs/assets/images/workspace_gl_payroll_codes.png b/docs/assets/images/workspace_gl_payroll_codes.png
new file mode 100644
index 000000000000..6b7770dc01b0
Binary files /dev/null and b/docs/assets/images/workspace_gl_payroll_codes.png differ
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 90baeff59260..a7d4d94adb5d 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -571,6 +571,7 @@ https://community.expensify.com/discussion/4641/how-to-add-a-deposit-only-bank-a
https://community.expensify.com/discussion/5940/how-to-get-reimbursed-outside-the-us-with-wise-for-non-us-employees,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Get-reimbursed-faster-as-a-non-US-employee
https://help.expensify.com/articles/expensify-classic/spending-insights,https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates
https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-notifications,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-Notifications
+https://help.expensify.com/articles/expensify-classic/connections/Additional-Travel-Integrations,https://help.expensify.com/articles/expensify-classic/connections/Travel-receipt-integrations
https://help.expensify.com/articles/new-expensify/getting-started/Upgrade-to-a-Collect-Plan,https://help.expensify.com/Hidden/Upgrade-to-a-Collect-Plan
https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports
https://help.expensify.com/articles/new-expensify/expenses-&-payments/pay-an-invoice.html,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Pay-an-invoice
@@ -584,6 +585,7 @@ https://community.expensify.com/discussion/6699/faq-troubleshooting-known-bank-s
https://community.expensify.com/discussion/4730/faq-expenses-are-exporting-to-the-wrong-accounts-whys-that,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings
https://community.expensify.com/discussion/9000/how-to-integrate-with-deel,https://help.expensify.com/articles/expensify-classic/connections/Deel
https://community.expensify.com/categories/expensify-classroom,https://use.expensify.com
+https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/adding-payment-card-subscription-overview,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/add-a-payment-card-and-view-your-subscription
https://help.expensify.com/articles/expensify-classic/articles/expensify-classic/expenses/Send-Receive-for-Invoices,https://help.expensify.com/articles/expensify-classic/articles/expensify-classic/expenses/Send-and-Receive-Payment-for-Invoices.md
https://help.expensify.com/articles/expensify-classic/articles/expensify-classic/expenses/Bulk-Upload-Multiple-Invoices,https://help.expensify.com/articles/expensify-classic/articles/expensify-classic/expenses/Add-Invoices-in-Bulk
-https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills
+https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills
\ No newline at end of file
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 66cea060038a..d8d4618e46d9 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 9.0.50
+ 9.0.52
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.50.7
+ 9.0.52.0
FullStory
OrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index a7529222ff91..0adc90924712 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 9.0.50
+ 9.0.52
CFBundleSignature
????
CFBundleVersion
- 9.0.50.7
+ 9.0.52.0
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 89640c4c8f63..26248e6b96dc 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
- 9.0.50
+ 9.0.52
CFBundleVersion
- 9.0.50.7
+ 9.0.52.0
NSExtension
NSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index 5127987d8ec2..a6d9eefc160a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.50-7",
+ "version": "9.0.52-0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.50-7",
+ "version": "9.0.52-0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -58,6 +58,7 @@
"expo-image-manipulator": "12.0.5",
"fast-equals": "^4.0.3",
"focus-trap-react": "^10.2.3",
+ "howler": "^2.2.4",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"lodash-es": "4.17.21",
@@ -116,7 +117,6 @@
"react-native-view-shot": "3.8.0",
"react-native-vision-camera": "4.0.0-beta.13",
"react-native-web": "^0.19.12",
- "react-native-web-sound": "^0.1.3",
"react-native-webview": "13.8.6",
"react-plaid-link": "3.3.2",
"react-web-config": "^1.0.0",
@@ -174,6 +174,7 @@
"@types/base-64": "^1.0.2",
"@types/canvas-size": "^1.2.2",
"@types/concurrently": "^7.0.0",
+ "@types/howler": "^2.2.12",
"@types/jest": "^29.5.2",
"@types/jest-when": "^3.5.2",
"@types/js-yaml": "^4.0.5",
@@ -217,7 +218,7 @@
"electron-builder": "25.0.0",
"eslint": "^8.57.0",
"eslint-config-airbnb-typescript": "^18.0.0",
- "eslint-config-expensify": "^2.0.60",
+ "eslint-config-expensify": "^2.0.66",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-deprecation": "^3.0.0",
"eslint-plugin-jest": "^28.6.0",
@@ -273,8 +274,8 @@
"xlsx": "file:vendor/xlsx-0.20.3.tgz"
},
"engines": {
- "node": "20.15.1",
- "npm": "10.7.0"
+ "node": "20.18.0",
+ "npm": "10.8.2"
}
},
"lib/react-compiler-runtime": {
@@ -613,9 +614,10 @@
}
},
"node_modules/@babel/eslint-parser": {
- "version": "7.24.7",
+ "version": "7.25.8",
+ "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.25.8.tgz",
+ "integrity": "sha512-Po3VLMN7fJtv0nsOjBDSbO1J71UhzShE9MuOSkWEV9IZQXzhZklYtzKZ8ZD/Ij3a0JBv1AG3Ny2L3jvAHQVOGg==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
"eslint-visitor-keys": "^2.1.0",
@@ -15781,6 +15783,12 @@
"hoist-non-react-statics": "^3.3.0"
}
},
+ "node_modules/@types/howler": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.12.tgz",
+ "integrity": "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g==",
+ "dev": true
+ },
"node_modules/@types/html-minifier-terser": {
"version": "6.1.0",
"dev": true,
@@ -17975,33 +17983,6 @@
"@babel/core": "^7.0.0-0"
}
},
- "node_modules/babel-eslint": {
- "version": "10.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.0.0",
- "@babel/parser": "^7.7.0",
- "@babel/traverse": "^7.7.0",
- "@babel/types": "^7.7.0",
- "eslint-visitor-keys": "^1.0.0",
- "resolve": "^1.12.0"
- },
- "engines": {
- "node": ">=6"
- },
- "peerDependencies": {
- "eslint": ">= 4.12.1"
- }
- },
- "node_modules/babel-eslint/node_modules/eslint-visitor-keys": {
- "version": "1.3.0",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=4"
- }
- },
"node_modules/babel-jest": {
"version": "29.4.1",
"dev": true,
@@ -22824,16 +22805,16 @@
}
},
"node_modules/eslint-config-expensify": {
- "version": "2.0.60",
- "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.60.tgz",
- "integrity": "sha512-VlulvhEasWeX2g+AXC4P91KA9czzX+aI3VSdJlZwm99GLOdfv7mM0JyO8vbqomjWNUxvLyJeJjmI02t2+fL/5Q==",
+ "version": "2.0.66",
+ "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.66.tgz",
+ "integrity": "sha512-6L9EIAiOxZnqOcFEsIwEUmX0fvglvboyqQh7LTqy+1O2h2W3mmrMSx87ymXeyFMg1nJQtqkFnrLv5ENGS0QC3Q==",
"dev": true,
"dependencies": {
+ "@babel/eslint-parser": "^7.25.7",
"@lwc/eslint-plugin-lwc": "^1.7.2",
"@typescript-eslint/parser": "^7.12.0",
"@typescript-eslint/rule-tester": "^7.16.1",
"@typescript-eslint/utils": "^7.12.0",
- "babel-eslint": "^10.1.0",
"eslint": "^8.56.0",
"eslint-config-airbnb": "19.0.4",
"eslint-config-airbnb-base": "15.0.0",
@@ -25995,7 +25976,8 @@
},
"node_modules/howler": {
"version": "2.2.4",
- "license": "MIT"
+ "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz",
+ "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="
},
"node_modules/hpack.js": {
"version": "2.1.6",
@@ -35419,8 +35401,8 @@
"underscore": "^1.13.6"
},
"engines": {
- "node": ">=20.15.1",
- "npm": ">=10.7.0"
+ "node": ">=20.18.0",
+ "npm": ">=10.8.2"
},
"peerDependencies": {
"idb-keyval": "^6.2.1",
@@ -35747,16 +35729,6 @@
"react-dom": "^18.0.0"
}
},
- "node_modules/react-native-web-sound": {
- "version": "0.1.3",
- "license": "MIT",
- "dependencies": {
- "howler": "^2.2.1"
- },
- "peerDependencies": {
- "react-native-web": "*"
- }
- },
"node_modules/react-native-web/node_modules/memoize-one": {
"version": "6.0.0",
"license": "MIT"
diff --git a/package.json b/package.json
index 028812ae28d4..b5e950df4151 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.50-7",
+ "version": "9.0.52-0",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -61,7 +61,8 @@
"e2e-test-runner-build": "node --max-old-space-size=8192 node_modules/.bin/ncc build tests/e2e/testRunner.ts -o tests/e2e/dist/",
"react-compiler-healthcheck": "react-compiler-healthcheck --verbose",
"react-compiler-healthcheck-test": "react-compiler-healthcheck --verbose &> react-compiler-output.txt",
- "generate-search-parser": "peggy --format es -o src/libs/SearchParser/searchParser.js src/libs/SearchParser/searchParser.peggy ",
+ "generate-search-parser": "peggy --format es -o src/libs/SearchParser/searchParser.js src/libs/SearchParser/searchParser.peggy src/libs/SearchParser/baseRules.peggy",
+ "generate-autocomplete-parser": "peggy --format es -o src/libs/SearchParser/autocompleteParser.js src/libs/SearchParser/autocompleteParser.peggy src/libs/SearchParser/baseRules.peggy",
"web:prod": "http-server ./dist --cors"
},
"dependencies": {
@@ -113,6 +114,7 @@
"expo-image-manipulator": "12.0.5",
"fast-equals": "^4.0.3",
"focus-trap-react": "^10.2.3",
+ "howler": "^2.2.4",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"lodash-es": "4.17.21",
@@ -171,7 +173,6 @@
"react-native-view-shot": "3.8.0",
"react-native-vision-camera": "4.0.0-beta.13",
"react-native-web": "^0.19.12",
- "react-native-web-sound": "^0.1.3",
"react-native-webview": "13.8.6",
"react-plaid-link": "3.3.2",
"react-web-config": "^1.0.0",
@@ -229,6 +230,7 @@
"@types/base-64": "^1.0.2",
"@types/canvas-size": "^1.2.2",
"@types/concurrently": "^7.0.0",
+ "@types/howler": "^2.2.12",
"@types/jest": "^29.5.2",
"@types/jest-when": "^3.5.2",
"@types/js-yaml": "^4.0.5",
@@ -272,7 +274,7 @@
"electron-builder": "25.0.0",
"eslint": "^8.57.0",
"eslint-config-airbnb-typescript": "^18.0.0",
- "eslint-config-expensify": "^2.0.60",
+ "eslint-config-expensify": "^2.0.66",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-deprecation": "^3.0.0",
"eslint-plugin-jest": "^28.6.0",
@@ -374,7 +376,7 @@
]
},
"engines": {
- "node": "20.15.1",
- "npm": "10.7.0"
+ "node": "20.18.0",
+ "npm": "10.8.2"
}
}
diff --git a/src/CONST.ts b/src/CONST.ts
index 357fe9d10e54..440f942e1244 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -471,6 +471,19 @@ const CONST = {
PERSONAL: 'PERSONAL',
},
},
+ NON_USD_BANK_ACCOUNT: {
+ STEP: {
+ COUNTRY: 'CountryStep',
+ BANK_INFO: 'BankInfoStep',
+ BUSINESS_INFO: 'BusinessInfoStep',
+ BENEFICIAL_OWNER_INFO: 'BeneficialOwnerInfoStep',
+ SIGNER_INFO: 'SignerInfoStep',
+ AGREEMENTS: 'AgreementsStep',
+ FINISH: 'FinishStep',
+ },
+ STEP_NAMES: ['1', '2', '3', '4', '5', '6'],
+ STEP_HEADER_HEIGHT: 40,
+ },
INCORPORATION_TYPES: {
LLC: 'LLC',
CORPORATION: 'Corp',
@@ -1130,9 +1143,6 @@ const CONST = {
SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300,
RESIZE_DEBOUNCE_TIME: 100,
UNREAD_UPDATE_DEBOUNCE_TIME: 300,
- SEARCH_CONVERT_SEARCH_VALUES: 'search_convert_search_values',
- SEARCH_MAKE_TREE: 'search_make_tree',
- SEARCH_BUILD_TREE: 'search_build_tree',
SEARCH_FILTER_OPTIONS: 'search_filter_options',
USE_DEBOUNCED_STATE_DELAY: 300,
},
@@ -1494,14 +1504,18 @@ const CONST = {
EXPORTER: 'exporter',
MARK_CHECKS_TO_BE_PRINTED: 'markChecksToBePrinted',
REIMBURSABLE_ACCOUNT: 'reimbursableAccount',
+ NON_REIMBURSABLE_ACCOUNT: 'nonReimbursableAccount',
REIMBURSABLE: 'reimbursable',
+ NON_REIMBURSABLE: 'nonReimbursable',
+ SHOULD_AUTO_CREATE_VENDOR: 'shouldAutoCreateVendor',
+ NON_REIMBURSABLE_BILL_DEFAULT_VENDOR: 'nonReimbursableBillDefaultVendor',
AUTO_SYNC: 'autoSync',
ENABLE_NEW_CATEGORIES: 'enableNewCategories',
- SHOULD_AUTO_CREATE_VENDOR: 'shouldAutoCreateVendor',
MAPPINGS: {
CLASSES: 'classes',
CUSTOMERS: 'customers',
},
+ IMPORT_ITEMS: 'importItems',
},
QUICKBOOKS_CONFIG: {
@@ -1616,7 +1630,6 @@ const CONST = {
VENDOR_BILL: 'VENDOR_BILL',
CHECK: 'CHECK',
JOURNAL_ENTRY: 'JOURNAL_ENTRY',
- NOTHING: 'NOTHING',
},
SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE: {
@@ -1891,7 +1904,7 @@ const CONST = {
QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE: {
CREDIT_CARD: 'CREDIT_CARD_CHARGE',
- JOURNAL_ENTRY: 'JOURNAL_ENTRY',
+ CHECK: 'CHECK',
VENDOR_BILL: 'VENDOR_BILL',
},
@@ -2384,6 +2397,7 @@ const CONST = {
SYNC_STAGE_NAME: {
STARTING_IMPORT_QBO: 'startingImportQBO',
STARTING_IMPORT_XERO: 'startingImportXero',
+ STARTING_IMPORT_QBD: 'startingImportQBD',
QBO_IMPORT_MAIN: 'quickbooksOnlineImportMain',
QBO_IMPORT_CUSTOMERS: 'quickbooksOnlineImportCustomers',
QBO_IMPORT_EMPLOYEES: 'quickbooksOnlineImportEmployees',
@@ -2400,6 +2414,17 @@ const CONST = {
QBO_SYNC_APPLY_CUSTOMERS: 'quickbooksOnlineSyncApplyCustomers',
QBO_SYNC_APPLY_PEOPLE: 'quickbooksOnlineSyncApplyEmployees',
QBO_SYNC_APPLY_CLASSES_LOCATIONS: 'quickbooksOnlineSyncApplyClassesLocations',
+ QBD_IMPORT_TITLE: 'quickbooksDesktopImportTitle',
+ QBD_IMPORT_ACCOUNTS: 'quickbooksDesktopImportAccounts',
+ QBD_IMPORT_APPROVE_CERTIFICATE: 'quickbooksDesktopImportApproveCertificate',
+ QBD_IMPORT_DIMENSIONS: 'quickbooksDesktopImportDimensions',
+ QBD_IMPORT_CLASSES: 'quickbooksDesktopImportClasses',
+ QBD_IMPORT_CUSTOMERS: 'quickbooksDesktopImportCustomers',
+ QBD_IMPORT_VENDORS: 'quickbooksDesktopImportVendors',
+ QBD_IMPORT_EMPLOYEES: 'quickbooksDesktopImportEmployees',
+ QBD_IMPORT_MORE: 'quickbooksDesktopImportMore',
+ QBD_IMPORT_GENERIC: 'quickbooksDesktopImportSavePolicy',
+ QBD_WEB_CONNECTOR_REMINDER: 'quickbooksDesktopWebConnectorReminder',
JOB_DONE: 'jobDone',
XERO_SYNC_STEP: 'xeroSyncStep',
XERO_SYNC_XERO_REIMBURSED_REPORTS: 'xeroSyncXeroReimbursedReports',
@@ -2514,6 +2539,13 @@ const CONST = {
VISA: 'vcf',
AMEX: 'gl1025',
STRIPE: 'stripe',
+ CITIBANK: 'oauth.citibank.com',
+ CAPITAL_ONE: 'oauth.capitalone.com',
+ BANK_OF_AMERICA: 'oauth.bankofamerica.com',
+ CHASE: 'oauth.chase.com',
+ BREX: 'oauth.brex.com',
+ WELLS_FARGO: 'oauth.wellsfargo.com',
+ AMEX_DIRECT: 'oauth.americanexpressfdx.com',
},
STEP_NAMES: ['1', '2', '3', '4'],
STEP: {
@@ -2601,6 +2633,14 @@ const CONST = {
WELLS_FARGO: 'Wells Fargo',
OTHER: 'Other',
},
+ BANK_CONNECTIONS: {
+ WELLS_FARGO: 'wellsfargo',
+ CHASE: 'chase',
+ BREX: 'brex',
+ CAPITAL_ONE: 'capitalone',
+ CITI_BANK: 'citibank',
+ AMEX: 'americanexpressfdx',
+ },
AMEX_CUSTOM_FEED: {
CORPORATE: 'American Express Corporate Cards',
BUSINESS: 'American Express Business Cards',
@@ -5689,6 +5729,7 @@ const CONST = {
KEYWORD: 'keyword',
IN: 'in',
},
+ EMPTY_VALUE: 'none',
},
REFERRER: {
@@ -5773,6 +5814,14 @@ const CONST = {
description: `workspace.upgrade.${this.POLICY.CONNECTIONS.NAME.SAGE_INTACCT}.description` as const,
icon: 'IntacctSquare',
},
+ [this.POLICY.CONNECTIONS.NAME.QBD]: {
+ id: this.POLICY.CONNECTIONS.NAME.QBD,
+ alias: 'qbd',
+ name: this.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.quickbooksDesktop,
+ title: `workspace.upgrade.${this.POLICY.CONNECTIONS.NAME.QBD}.title` as const,
+ description: `workspace.upgrade.${this.POLICY.CONNECTIONS.NAME.QBD}.description` as const,
+ icon: 'QBDSquare',
+ },
approvals: {
id: 'approvals' as const,
alias: 'approvals' as const,
@@ -5882,6 +5931,21 @@ const CONST = {
// The timeout duration (1 minute) (in milliseconds) before the window reloads due to an error.
ERROR_WINDOW_RELOAD_TIMEOUT: 60000,
+ INDICATOR_STATUS: {
+ HAS_USER_WALLET_ERRORS: 'hasUserWalletErrors',
+ HAS_PAYMENT_METHOD_ERROR: 'hasPaymentMethodError',
+ HAS_POLICY_ERRORS: 'hasPolicyError',
+ HAS_CUSTOM_UNITS_ERROR: 'hasCustomUnitsError',
+ HAS_EMPLOYEE_LIST_ERROR: 'hasEmployeeListError',
+ HAS_SYNC_ERRORS: 'hasSyncError',
+ HAS_SUBSCRIPTION_ERRORS: 'hasSubscriptionError',
+ HAS_REIMBURSEMENT_ACCOUNT_ERRORS: 'hasReimbursementAccountErrors',
+ HAS_LOGIN_LIST_ERROR: 'hasLoginListError',
+ HAS_WALLET_TERMS_ERRORS: 'hasWalletTermsErrors',
+ HAS_LOGIN_LIST_INFO: 'hasLoginListInfo',
+ HAS_SUBSCRIPTION_INFO: 'hasSubscriptionInfo',
+ },
+
DEBUG: {
DETAILS: 'details',
JSON: 'json',
@@ -5909,6 +5973,12 @@ const CONST = {
HAS_CHILD_REPORT_AWAITING_ACTION: 'hasChildReportAwaitingAction',
HAS_MISSING_INVOICE_BANK_ACCOUNT: 'hasMissingInvoiceBankAccount',
},
+
+ RBR_REASONS: {
+ HAS_ERRORS: 'hasErrors',
+ HAS_VIOLATIONS: 'hasViolations',
+ HAS_TRANSACTION_THREAD_VIOLATIONS: 'hasTransactionThreadViolations',
+ },
} as const;
type Country = keyof typeof CONST.ALL_COUNTRIES;
diff --git a/src/Expensify.tsx b/src/Expensify.tsx
index 7822ec16b879..e07b03a6d405 100644
--- a/src/Expensify.tsx
+++ b/src/Expensify.tsx
@@ -89,12 +89,12 @@ function Expensify() {
const [lastRoute] = useOnyx(ONYXKEYS.LAST_ROUTE);
const [userMetadata] = useOnyx(ONYXKEYS.USER_METADATA);
const [shouldShowRequire2FAModal, setShouldShowRequire2FAModal] = useState(false);
- const [isCheckingPublicRoom] = useOnyx(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM);
- const [updateAvailable] = useOnyx(ONYXKEYS.UPDATE_AVAILABLE);
- const [updateRequired] = useOnyx(ONYXKEYS.UPDATE_REQUIRED);
+ const [isCheckingPublicRoom] = useOnyx(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, {initWithStoredValues: false});
+ const [updateAvailable] = useOnyx(ONYXKEYS.UPDATE_AVAILABLE, {initWithStoredValues: false});
+ const [updateRequired] = useOnyx(ONYXKEYS.UPDATE_REQUIRED, {initWithStoredValues: false});
const [isSidebarLoaded] = useOnyx(ONYXKEYS.IS_SIDEBAR_LOADED);
const [screenShareRequest] = useOnyx(ONYXKEYS.SCREEN_SHARE_REQUEST);
- const [focusModeNotification] = useOnyx(ONYXKEYS.FOCUS_MODE_NOTIFICATION);
+ const [focusModeNotification] = useOnyx(ONYXKEYS.FOCUS_MODE_NOTIFICATION, {initWithStoredValues: false});
const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH);
useEffect(() => {
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 14c0dc4abc50..d083a46d7760 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -441,9 +441,6 @@ const ONYXKEYS = {
/** Stores recently used currencies */
RECENTLY_USED_CURRENCIES: 'nvp_recentlyUsedCurrencies',
- /** States whether we transitioned from OldDot to show only certain group of screens. It should be undefined on pure NewDot. */
- IS_SINGLE_NEW_DOT_ENTRY: 'isSingleNewDotEntry',
-
/** Company cards custom names */
NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES: 'nvp_expensify_ccCustomNames',
@@ -1007,7 +1004,6 @@ type OnyxValuesMapping = {
[ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflowOnyx;
[ONYXKEYS.IMPORTED_SPREADSHEET]: OnyxTypes.ImportedSpreadsheet;
[ONYXKEYS.LAST_ROUTE]: string;
- [ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY]: boolean | undefined;
[ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean;
[ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP]: boolean;
[ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 33dd4da77532..cf15013fed9b 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -21,6 +21,7 @@ const PUBLIC_SCREENS_ROUTES = {
ROOT: '',
TRANSITION_BETWEEN_APPS: 'transition',
CONNECTION_COMPLETE: 'connection-complete',
+ BANK_CONNECTION_COMPLETE: 'bank-connection-complete',
VALIDATE_LOGIN: 'v/:accountID/:validateCode',
UNLINK_LOGIN: 'u/:accountID/:validateCode',
APPLE_SIGN_IN: 'sign-in-with-apple',
@@ -149,10 +150,6 @@ const ROUTES = {
route: 'settings/security/delegate/:login/role/:role/confirm',
getRoute: (login: string, role: string) => `settings/security/delegate/${encodeURIComponent(login)}/role/${role}/confirm` as const,
},
- SETTINGS_DELEGATE_MAGIC_CODE: {
- route: 'settings/security/delegate/:login/role/:role/magic-code',
- getRoute: (login: string, role: string) => `settings/security/delegate/${encodeURIComponent(login)}/role/${role}/magic-code` as const,
- },
SETTINGS_ABOUT: 'settings/about',
SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links',
SETTINGS_WALLET: 'settings/wallet',
@@ -231,7 +228,6 @@ const ROUTES = {
route: 'settings/profile/contact-methods/:contactMethod/details',
getRoute: (contactMethod: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/contact-methods/${encodeURIComponent(contactMethod)}/details`, backTo),
},
- SETINGS_CONTACT_METHOD_VALIDATE_ACTION: 'settings/profile/contact-methods/validate-action',
SETTINGS_NEW_CONTACT_METHOD: {
route: 'settings/profile/contact-methods/new',
getRoute: (backTo?: string) => getUrlWithBackToParam('settings/profile/contact-methods/new', backTo),
@@ -669,6 +665,22 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export/date-select',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export/date-select` as const,
},
+ POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT: {
+ route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export/company-card-expense-account/account-select',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export/company-card-expense-account/account-select` as const,
+ },
+ POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_SELECT: {
+ route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export/company-card-expense-account/card-select',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export/company-card-expense-account/card-select` as const,
+ },
+ POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT: {
+ route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export/company-card-expense-account/default-vendor-select',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export/company-card-expense-account/default-vendor-select` as const,
+ },
+ POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT: {
+ route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export/company-card-expense-account',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export/company-card-expense-account` as const,
+ },
WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED: {
route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/advanced',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/advanced` as const,
@@ -733,6 +745,10 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/import/customers/displayed_as',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/import/customers/displayed_as` as const,
},
+ POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_ITEMS: {
+ route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/import/items',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/import/items` as const,
+ },
WORKSPACE_PROFILE_NAME: {
route: 'settings/workspaces/:policyID/profile/name',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile/name` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 719c67f0365b..ff428edcd7eb 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -74,7 +74,6 @@ const SCREENS = {
DISPLAY_NAME: 'Settings_Display_Name',
CONTACT_METHODS: 'Settings_ContactMethods',
CONTACT_METHOD_DETAILS: 'Settings_ContactMethodDetails',
- CONTACT_METHOD_VALIDATE_ACTION: 'Settings_ValidateContactMethodAction',
NEW_CONTACT_METHOD: 'Settings_NewContactMethod',
STATUS_CLEAR_AFTER: 'Settings_Status_Clear_After',
STATUS_CLEAR_AFTER_DATE: 'Settings_Status_Clear_After_Date',
@@ -134,7 +133,6 @@ const SCREENS = {
ADD_DELEGATE: 'Settings_Delegate_Add',
DELEGATE_ROLE: 'Settings_Delegate_Role',
DELEGATE_CONFIRM: 'Settings_Delegate_Confirm',
- DELEGATE_MAGIC_CODE: 'Settings_Delegate_Magic_Code',
UPDATE_DELEGATE_ROLE: 'Settings_Delegate_Update_Role',
UPDATE_DELEGATE_ROLE_MAGIC_CODE: 'Settings_Delegate_Update_Magic_Code',
},
@@ -316,6 +314,10 @@ const SCREENS = {
QUICKBOOKS_ONLINE_ADVANCED: 'Policy_Accounting_Quickbooks_Online_Advanced',
QUICKBOOKS_ONLINE_ACCOUNT_SELECTOR: 'Policy_Accounting_Quickbooks_Online_Account_Selector',
QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR: 'Policy_Accounting_Quickbooks_Online_Invoice_Account_Selector',
+ QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Company_Card_Expense_Account_Select',
+ QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Company_Card_Expense_Select',
+ QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Company_Card_Expense',
+ QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Non_Reimbursable_Default_Vendor_Select',
QUICKBOOKS_DESKTOP_ADVANCED: 'Policy_Accounting_Quickbooks_Desktop_Advanced',
QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Date_Select',
QUICKBOOKS_DESKTOP_EXPORT_PREFERRED_EXPORTER: 'Workspace_Accounting_Quickbooks_Desktop_Export_Preferred_Exporter',
@@ -332,6 +334,7 @@ const SCREENS = {
QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS: 'Policy_Accounting_Quickbooks_Desktop_Import_Classes_Dipslayed_As',
QUICKBOOKS_DESKTOP_CUSTOMERS: 'Policy_Accounting_Quickbooks_Desktop_Import_Customers',
QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS: 'Policy_Accounting_Quickbooks_Desktop_Import_Customers_Dipslayed_As',
+ QUICKBOOKS_DESKTOP_ITEMS: 'Policy_Accounting_Quickbooks_Desktop_Import_Items',
XERO_IMPORT: 'Policy_Accounting_Xero_Import',
XERO_ORGANIZATION: 'Policy_Accounting_Xero_Customers',
XERO_CHART_OF_ACCOUNTS: 'Policy_Accounting_Xero_Import_Chart_Of_Accounts',
diff --git a/src/components/AttachmentOfflineIndicator.tsx b/src/components/AttachmentOfflineIndicator.tsx
index d425e6f18e0e..4ff1940ba004 100644
--- a/src/components/AttachmentOfflineIndicator.tsx
+++ b/src/components/AttachmentOfflineIndicator.tsx
@@ -37,7 +37,7 @@ function AttachmentOfflineIndicator({isPreview = false}: AttachmentOfflineIndica
return (
{
const categories = policyCategories ?? policyCategoriesDraft ?? {};
const validPolicyRecentlyUsedCategories = policyRecentlyUsedCategories?.filter?.((p) => !isEmptyObject(p));
- const {categoryOptions} = OptionsListUtils.getFilteredOptions(
- [],
- [],
- [],
- debouncedSearchValue,
+ const {categoryOptions} = OptionsListUtils.getFilteredOptions({
+ searchValue: debouncedSearchValue,
selectedOptions,
- [],
- false,
- false,
- true,
+ includeP2P: false,
+ includeCategories: true,
categories,
- validPolicyRecentlyUsedCategories,
- false,
- );
+ recentlyUsedCategories: validPolicyRecentlyUsedCategories,
+ });
const categoryData = categoryOptions?.at(0)?.data ?? [];
const header = OptionsListUtils.getHeaderMessageForNonUserList(categoryData.length > 0, debouncedSearchValue);
diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx
index e63b8bb91874..fd2013c6bde7 100755
--- a/src/components/ConfirmModal.tsx
+++ b/src/components/ConfirmModal.tsx
@@ -137,6 +137,7 @@ function ConfirmModal({
restoreFocusType,
}: ConfirmModalProps) {
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use the correct modal type
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
const styles = useThemeStyles();
diff --git a/src/components/ConnectToQuickbooksDesktopFlow/index.tsx b/src/components/ConnectToQuickbooksDesktopFlow/index.tsx
index bf1315b452c6..6f5a983e4250 100644
--- a/src/components/ConnectToQuickbooksDesktopFlow/index.tsx
+++ b/src/components/ConnectToQuickbooksDesktopFlow/index.tsx
@@ -1,19 +1,17 @@
import {useEffect} from 'react';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import Navigation from '@libs/Navigation/Navigation';
-import * as PolicyAction from '@userActions/Policy/Policy';
import ROUTES from '@src/ROUTES';
import type {ConnectToQuickbooksDesktopFlowProps} from './types';
function ConnectToQuickbooksDesktopFlow({policyID}: ConnectToQuickbooksDesktopFlowProps) {
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
useEffect(() => {
if (isSmallScreenWidth) {
Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_SETUP_REQUIRED_DEVICE_MODAL.getRoute(policyID));
} else {
- // Since QBD doesn't support Taxes, we should disable them from the LHN when connecting to QBD
- PolicyAction.enablePolicyTaxes(policyID, false);
Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_SETUP_MODAL.getRoute(policyID));
}
}, [isSmallScreenWidth, policyID]);
diff --git a/src/components/ConnectToXeroFlow/index.native.tsx b/src/components/ConnectToXeroFlow/index.native.tsx
index ab9fa3054261..fbf7bf01ab5c 100644
--- a/src/components/ConnectToXeroFlow/index.native.tsx
+++ b/src/components/ConnectToXeroFlow/index.native.tsx
@@ -40,14 +40,14 @@ function ConnectToXeroFlow({policyID}: ConnectToXeroFlowProps) {
return (
<>
- {isRequire2FAModalOpen && (
+ {!is2FAEnabled && (
{
setIsRequire2FAModalOpen(false);
Navigation.navigate(ROUTES.SETTINGS_2FA.getRoute(ROUTES.POLICY_ACCOUNTING.getRoute(policyID), getXeroSetupLink(policyID)));
}}
onCancel={() => setIsRequire2FAModalOpen(false)}
- isVisible
+ isVisible={isRequire2FAModalOpen}
description={translate('twoFactorAuth.twoFactorAuthIsRequiredDescription')}
/>
)}
diff --git a/src/components/ConnectToXeroFlow/index.tsx b/src/components/ConnectToXeroFlow/index.tsx
index 5d0e88e1512b..ad41ba8082b1 100644
--- a/src/components/ConnectToXeroFlow/index.tsx
+++ b/src/components/ConnectToXeroFlow/index.tsx
@@ -29,7 +29,7 @@ function ConnectToXeroFlow({policyID}: ConnectToXeroFlowProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- if (isRequire2FAModalOpen) {
+ if (!is2FAEnabled) {
return (
{
@@ -39,7 +39,7 @@ function ConnectToXeroFlow({policyID}: ConnectToXeroFlowProps) {
onCancel={() => {
setIsRequire2FAModalOpen(false);
}}
- isVisible
+ isVisible={isRequire2FAModalOpen}
description={translate('twoFactorAuth.twoFactorAuthIsRequiredDescription')}
/>
);
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx
index 2d52d26f9af6..f2d5d0d60568 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx
@@ -1,4 +1,4 @@
-import React, {memo} from 'react';
+import React, {memo, useState} from 'react';
import {useOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import type {CustomRendererProps, TBlock} from 'react-native-render-html';
@@ -8,6 +8,7 @@ import PressableWithoutFocus from '@components/Pressable/PressableWithoutFocus';
import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext';
import ThumbnailImage from '@components/ThumbnailImage';
import useLocalize from '@hooks/useLocalize';
+import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import Navigation from '@libs/Navigation/Navigation';
@@ -63,7 +64,10 @@ function ImageRenderer({tnode}: ImageRendererProps) {
const imagePreviewModalDisabled = htmlAttribs['data-expensify-preview-modal-disabled'] === 'true';
const fileType = FileUtils.getFileType(attachmentSourceAttribute);
- const fallbackIcon = fileType === CONST.ATTACHMENT_FILE_TYPE.FILE ? Expensicons.Document : Expensicons.Gallery;
+ const fallbackIcon = fileType === CONST.ATTACHMENT_FILE_TYPE.FILE ? Expensicons.Document : Expensicons.GalleryNotFound;
+ const [hasLoadFailed, setHasLoadFailed] = useState(true);
+ const theme = useTheme();
+
const thumbnailImageComponent = (
setHasLoadFailed(true)}
+ onMeasure={() => setHasLoadFailed(false)}
+ fallbackIconBackground={theme.highlightBG}
+ fallbackIconColor={theme.border}
/>
);
@@ -102,6 +110,7 @@ function ImageRenderer({tnode}: ImageRendererProps) {
shouldUseHapticsOnLongPress
accessibilityRole={CONST.ROLE.BUTTON}
accessibilityLabel={translate('accessibilityHints.viewAttachment')}
+ disabled={hasLoadFailed}
>
{thumbnailImageComponent}
diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx
index e1843ee506d5..891a68cb38c4 100755
--- a/src/components/HeaderWithBackButton/index.tsx
+++ b/src/components/HeaderWithBackButton/index.tsx
@@ -191,7 +191,7 @@ function HeaderWithBackButton({
/>
)}
{middleContent}
-
+
{children}
{shouldShowDownloadButton && (
@@ -263,7 +263,7 @@ function HeaderWithBackButton({
)}
- {shouldDisplaySearchRouter && }
+ {shouldDisplaySearchRouter && }
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index cd9c97105ff0..90f0e0d8a151 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -93,6 +93,7 @@ import FlagLevelTwo from '@assets/images/flag_level_02.svg';
import FlagLevelThree from '@assets/images/flag_level_03.svg';
import Folder from '@assets/images/folder.svg';
import Fullscreen from '@assets/images/fullscreen.svg';
+import GalleryNotFound from '@assets/images/gallery-not-found.svg';
import Gallery from '@assets/images/gallery.svg';
import Gear from '@assets/images/gear.svg';
import Globe from '@assets/images/globe.svg';
@@ -404,4 +405,5 @@ export {
Bookmark,
Star,
QBDSquare,
+ GalleryNotFound,
};
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index bae8f6af1ab2..18ae1792686f 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -12,6 +12,7 @@ import WellsFargoCompanyCardDetail from '@assets/images/companyCards/card-wellsf
import OtherCompanyCardDetail from '@assets/images/companyCards/card=-generic.svg';
import CompanyCardsEmptyState from '@assets/images/companyCards/emptystate__card-pos.svg';
import MasterCardCompanyCards from '@assets/images/companyCards/mastercard.svg';
+import PendingBank from '@assets/images/companyCards/pending-bank.svg';
import CompanyCardsPendingState from '@assets/images/companyCards/pendingstate_laptop-with-hourglass-and-cards.svg';
import VisaCompanyCards from '@assets/images/companyCards/visa.svg';
import EmptyCardState from '@assets/images/emptystate__expensifycard.svg';
@@ -207,6 +208,7 @@ export {
Approval,
WalletAlt,
Workflows,
+ PendingBank,
ThreeLeggedLaptopWoman,
House,
Alert,
diff --git a/src/components/ImportOnyxState/BaseImportOnyxState.tsx b/src/components/ImportOnyxState/BaseImportOnyxState.tsx
index 216a6ddf76e4..c6c80d03b58f 100644
--- a/src/components/ImportOnyxState/BaseImportOnyxState.tsx
+++ b/src/components/ImportOnyxState/BaseImportOnyxState.tsx
@@ -19,6 +19,9 @@ function BaseImportOnyxState({
}) {
const {translate} = useLocalize();
const styles = useThemeStyles();
+
+ // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
return (
diff --git a/src/components/ImportSpreadsheet.tsx b/src/components/ImportSpreadsheet.tsx
index b68c773bc12d..64f9a4445a0c 100644
--- a/src/components/ImportSpreadsheet.tsx
+++ b/src/components/ImportSpreadsheet.tsx
@@ -42,6 +42,8 @@ function ImportSpreedsheet({backTo, goTo}: ImportSpreedsheetProps) {
const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState();
const [attachmentInvalidReason, setAttachmentValidReason] = useState();
+ // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use different copies depending on the screen size
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
const [isDraggingOver, setIsDraggingOver] = useState(false);
diff --git a/src/components/Indicator.tsx b/src/components/Indicator.tsx
index 4d352b6a6cde..105399936b43 100644
--- a/src/components/Indicator.tsx
+++ b/src/components/Indicator.tsx
@@ -1,109 +1,17 @@
import React from 'react';
import {StyleSheet, View} from 'react-native';
-import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
-import {useOnyx, withOnyx} from 'react-native-onyx';
-import useTheme from '@hooks/useTheme';
+import useIndicatorStatus from '@hooks/useIndicatorStatus';
import useThemeStyles from '@hooks/useThemeStyles';
-import {isConnectionInProgress} from '@libs/actions/connections';
-import * as PolicyUtils from '@libs/PolicyUtils';
-import * as SubscriptionUtils from '@libs/SubscriptionUtils';
-import * as UserUtils from '@libs/UserUtils';
-import * as PaymentMethods from '@userActions/PaymentMethods';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {BankAccountList, FundList, LoginList, Policy, ReimbursementAccount, UserWallet, WalletTerms} from '@src/types/onyx';
-type CheckingMethod = () => boolean;
-
-type IndicatorOnyxProps = {
- /** All the user's policies (from Onyx via withFullPolicy) */
- policies: OnyxCollection;
-
- /** List of bank accounts */
- bankAccountList: OnyxEntry;
-
- /** List of user cards */
- fundList: OnyxEntry;
-
- /** The user's wallet (coming from Onyx) */
- userWallet: OnyxEntry;
-
- /** Bank account attached to free plan */
- reimbursementAccount: OnyxEntry;
-
- /** Information about the user accepting the terms for payments */
- walletTerms: OnyxEntry;
-
- /** Login list for the user that is signed in */
- loginList: OnyxEntry;
-};
-
-type IndicatorProps = IndicatorOnyxProps;
-
-function Indicator({reimbursementAccount, policies, bankAccountList, fundList, userWallet, walletTerms, loginList}: IndicatorOnyxProps) {
- const theme = useTheme();
+function Indicator() {
const styles = useThemeStyles();
- const [allConnectionSyncProgresses] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}`);
-
- // If a policy was just deleted from Onyx, then Onyx will pass a null value to the props, and
- // those should be cleaned out before doing any error checking
- const cleanPolicies = Object.fromEntries(Object.entries(policies ?? {}).filter(([, policy]) => policy?.id));
-
- // All of the error & info-checking methods are put into an array. This is so that using _.some() will return
- // early as soon as the first error / info condition is returned. This makes the checks very efficient since
- // we only care if a single error / info condition exists anywhere.
- const errorCheckingMethods: CheckingMethod[] = [
- () => Object.keys(userWallet?.errors ?? {}).length > 0,
- () => PaymentMethods.hasPaymentMethodError(bankAccountList, fundList),
- () => Object.values(cleanPolicies).some(PolicyUtils.hasPolicyError),
- () => Object.values(cleanPolicies).some(PolicyUtils.hasCustomUnitsError),
- () => Object.values(cleanPolicies).some(PolicyUtils.hasEmployeeListError),
- () =>
- Object.values(cleanPolicies).some((cleanPolicy) =>
- PolicyUtils.hasSyncError(
- cleanPolicy,
- isConnectionInProgress(allConnectionSyncProgresses?.[`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${cleanPolicy?.id}`], cleanPolicy),
- ),
- ),
- () => SubscriptionUtils.hasSubscriptionRedDotError(),
- () => Object.keys(reimbursementAccount?.errors ?? {}).length > 0,
- () => !!loginList && UserUtils.hasLoginListError(loginList),
-
- // Wallet term errors that are not caused by an IOU (we show the red brick indicator for those in the LHN instead)
- () => Object.keys(walletTerms?.errors ?? {}).length > 0 && !walletTerms?.chatReportID,
- ];
- const infoCheckingMethods: CheckingMethod[] = [() => !!loginList && UserUtils.hasLoginListInfo(loginList), () => SubscriptionUtils.hasSubscriptionGreenDotInfo()];
- const shouldShowErrorIndicator = errorCheckingMethods.some((errorCheckingMethod) => errorCheckingMethod());
- const shouldShowInfoIndicator = !shouldShowErrorIndicator && infoCheckingMethods.some((infoCheckingMethod) => infoCheckingMethod());
+ const {indicatorColor, status} = useIndicatorStatus();
- const indicatorColor = shouldShowErrorIndicator ? theme.danger : theme.success;
const indicatorStyles = [styles.alignItemsCenter, styles.justifyContentCenter, styles.statusIndicator(indicatorColor)];
- return (shouldShowErrorIndicator || shouldShowInfoIndicator) && ;
+ return !!status && ;
}
Indicator.displayName = 'Indicator';
-export default withOnyx({
- policies: {
- key: ONYXKEYS.COLLECTION.POLICY,
- },
- bankAccountList: {
- key: ONYXKEYS.BANK_ACCOUNT_LIST,
- },
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
- fundList: {
- key: ONYXKEYS.FUND_LIST,
- },
- userWallet: {
- key: ONYXKEYS.USER_WALLET,
- },
- walletTerms: {
- key: ONYXKEYS.WALLET_TERMS,
- },
- loginList: {
- key: ONYXKEYS.LOGIN_LIST,
- },
-})(Indicator);
+export default Indicator;
diff --git a/src/components/InitialURLContextProvider.tsx b/src/components/InitialURLContextProvider.tsx
index f026f2de53f9..85ad54ca6c94 100644
--- a/src/components/InitialURLContextProvider.tsx
+++ b/src/components/InitialURLContextProvider.tsx
@@ -31,10 +31,9 @@ function InitialURLContextProvider({children, url}: InitialURLContextProviderPro
useEffect(() => {
if (url) {
- signInAfterTransitionFromOldDot(url).then((route) => {
- setInitialURL(route);
- setSplashScreenState(CONST.BOOT_SPLASH_STATE.READY_TO_BE_HIDDEN);
- });
+ const route = signInAfterTransitionFromOldDot(url);
+ setInitialURL(route);
+ setSplashScreenState(CONST.BOOT_SPLASH_STATE.READY_TO_BE_HIDDEN);
return;
}
Linking.getInitialURL().then((initURL) => {
diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx
index fd681546c470..7d1e6614c716 100644
--- a/src/components/KYCWall/BaseKYCWall.tsx
+++ b/src/components/KYCWall/BaseKYCWall.tsx
@@ -1,8 +1,7 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {Dimensions} from 'react-native';
import type {EmitterSubscription, GestureResponderEvent, View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import AddPaymentMethodMenu from '@components/AddPaymentMethodMenu';
import * as BankAccounts from '@libs/actions/BankAccounts';
import getClickedTargetLocation from '@libs/getClickedTargetLocation';
@@ -16,7 +15,6 @@ import * as Wallet from '@userActions/Wallet';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {BankAccountList, FundList, ReimbursementAccount, UserWallet, WalletTerms} from '@src/types/onyx';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import viewRef from '@src/types/utils/viewRef';
import type {AnchorPosition, DomRect, KYCWallProps, PaymentMethod} from './types';
@@ -24,25 +22,6 @@ import type {AnchorPosition, DomRect, KYCWallProps, PaymentMethod} from './types
// This sets the Horizontal anchor position offset for POPOVER MENU.
const POPOVER_MENU_ANCHOR_POSITION_HORIZONTAL_OFFSET = 20;
-type BaseKYCWallOnyxProps = {
- /** The user's wallet */
- userWallet: OnyxEntry;
-
- /** Information related to the last step of the wallet activation flow */
- walletTerms: OnyxEntry;
-
- /** List of user's cards */
- fundList: OnyxEntry;
-
- /** List of bank accounts */
- bankAccountList: OnyxEntry;
-
- /** The reimbursement account linked to the Workspace */
- reimbursementAccount: OnyxEntry;
-};
-
-type BaseKYCWallProps = KYCWallProps & BaseKYCWallOnyxProps;
-
// This component allows us to block various actions by forcing the user to first add a default payment method and successfully make it through our Know Your Customer flow
// before continuing to take whatever action they originally intended to take. It requires a button as a child and a native event so we can get the coordinates and use it
// to render the AddPaymentMethodMenu in the correct location.
@@ -53,22 +32,23 @@ function KYCWall({
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
},
- bankAccountList = {},
chatReportID = '',
children,
enablePaymentsRoute,
- fundList,
iouReport,
onSelectPaymentMethod = () => {},
onSuccessfulKYC,
- reimbursementAccount,
shouldIncludeDebitCard = true,
shouldListenForResize = false,
source,
- userWallet,
- walletTerms,
shouldShowPersonalBankAccountOption = false,
-}: BaseKYCWallProps) {
+}: KYCWallProps) {
+ const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET);
+ const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS);
+ const [fundList] = useOnyx(ONYXKEYS.FUND_LIST);
+ const [bankAccountList = {}] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+
const anchorRef = useRef(null);
const transferBalanceButtonRef = useRef(null);
@@ -270,21 +250,4 @@ function KYCWall({
KYCWall.displayName = 'BaseKYCWall';
-export default withOnyx({
- userWallet: {
- key: ONYXKEYS.USER_WALLET,
- },
- walletTerms: {
- key: ONYXKEYS.WALLET_TERMS,
- },
- fundList: {
- key: ONYXKEYS.FUND_LIST,
- },
- bankAccountList: {
- key: ONYXKEYS.BANK_ACCOUNT_LIST,
- },
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
-})(KYCWall);
+export default KYCWall;
diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx
index f51fe7e37acd..85a2298f63d6 100644
--- a/src/components/Modal/BaseModal.tsx
+++ b/src/components/Modal/BaseModal.tsx
@@ -61,6 +61,7 @@ function BaseModal(
const StyleUtils = useStyleUtils();
const {windowWidth, windowHeight} = useWindowDimensions();
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct modal width
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
const keyboardStateContextValue = useKeyboardState();
diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx
index caa50abfca46..680a38843f24 100644
--- a/src/components/MoneyReportHeader.tsx
+++ b/src/components/MoneyReportHeader.tsx
@@ -7,6 +7,7 @@ import useNetwork from '@hooks/useNetwork';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import {getCurrentUserAccountID} from '@libs/actions/Report';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import isReportOpenInRHP from '@libs/Navigation/isReportOpenInRHP';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
@@ -61,6 +62,8 @@ type MoneyReportHeaderProps = {
};
function MoneyReportHeader({policy, report: moneyRequestReport, transactionThreadReportID, reportActions, onBackButtonPress}: MoneyReportHeaderProps) {
+ // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use a correct layout for the hold expense modal https://github.com/Expensify/App/pull/47990#issuecomment-2362382026
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID ?? '-1'}`);
const [nextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${moneyRequestReport?.reportID ?? '-1'}`);
@@ -135,9 +138,17 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea
const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(moneyRequestReport);
- const shouldShowSubmitButton = !!moneyRequestReport && isDraft && reimbursableSpend !== 0 && !hasAllPendingRTERViolations && !shouldShowBrokenConnectionViolation;
-
+ const currentUserAccountID = getCurrentUserAccountID();
const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN;
+
+ const shouldShowSubmitButton =
+ !!moneyRequestReport &&
+ isDraft &&
+ reimbursableSpend !== 0 &&
+ !hasAllPendingRTERViolations &&
+ !shouldShowBrokenConnectionViolation &&
+ (moneyRequestReport?.ownerAccountID === currentUserAccountID || isAdmin || moneyRequestReport?.managerID === currentUserAccountID);
+
const shouldShowExportIntegrationButton = !shouldShowPayButton && !shouldShowSubmitButton && connectedIntegration && isAdmin && ReportUtils.canBeExported(moneyRequestReport);
const shouldShowSettlementButton =
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
index b79d22f5c8cf..a46b9d62dfde 100755
--- a/src/components/MoneyRequestConfirmationList.tsx
+++ b/src/components/MoneyRequestConfirmationList.tsx
@@ -2,7 +2,7 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native';
import lodashIsEqual from 'lodash/isEqual';
import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {InteractionManager, View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useDebouncedState from '@hooks/useDebouncedState';
@@ -14,7 +14,6 @@ import useThemeStyles from '@hooks/useThemeStyles';
import blurActiveElement from '@libs/Accessibility/blurActiveElement';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
-import type {MileageRate} from '@libs/DistanceRequestUtils';
import * as IOUUtils from '@libs/IOUUtils';
import Log from '@libs/Log';
import * as MoneyRequestUtils from '@libs/MoneyRequestUtils';
@@ -49,33 +48,7 @@ import UserListItem from './SelectionList/UserListItem';
import SettlementButton from './SettlementButton';
import Text from './Text';
-type MoneyRequestConfirmationListOnyxProps = {
- /** Collection of categories attached to a policy */
- policyCategories: OnyxEntry;
-
- /** Collection of draft categories attached to a policy */
- policyCategoriesDraft: OnyxEntry;
-
- /** Collection of tags attached to a policy */
- policyTags: OnyxEntry;
-
- /** The policy of the report */
- policy: OnyxEntry;
-
- /** The draft policy of the report */
- policyDraft: OnyxEntry;
-
- /** Mileage rate default for the policy */
- defaultMileageRate: OnyxEntry;
-
- /** Last selected distance rates */
- lastSelectedDistanceRates: OnyxEntry>;
-
- /** List of currencies */
- currencyList: OnyxEntry;
-};
-
-type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & {
+type MoneyRequestConfirmationListProps = {
/** Callback to inform parent modal of success */
onConfirm?: (selectedParticipants: Participant[]) => void;
@@ -175,16 +148,11 @@ function MoneyRequestConfirmationList({
onConfirm,
iouType = CONST.IOU.TYPE.SUBMIT,
iouAmount,
- policyCategories: policyCategoriesReal,
- policyCategoriesDraft,
isDistanceRequest = false,
- policy: policyReal,
- policyDraft,
isPolicyExpenseChat = false,
iouCategory = '',
shouldShowSmartScanFields = true,
isEditingSplitBill,
- policyTags,
iouCurrencyCode,
iouMerchant,
selectedParticipants: selectedParticipantsProp,
@@ -201,14 +169,21 @@ function MoneyRequestConfirmationList({
onToggleBillable,
hasSmartScanFailed,
reportActionID,
- defaultMileageRate,
- lastSelectedDistanceRates,
action = CONST.IOU.ACTION.CREATE,
- currencyList,
shouldDisplayReceipt = false,
shouldPlaySound = true,
isConfirmed,
}: MoneyRequestConfirmationListProps) {
+ const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`);
+ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`);
+ const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
+ const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`);
+ const [defaultMileageRate] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, {
+ selector: (selectedPolicy) => DistanceRequestUtils.getDefaultMileageRate(selectedPolicy),
+ });
+ const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${policyID}`);
+ const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES);
+ const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST);
const policy = policyReal ?? policyDraft;
const policyCategories = policyCategoriesReal ?? policyCategoriesDraft;
@@ -740,6 +715,7 @@ function MoneyRequestConfirmationList({
}
if (selectedParticipants.length === 0) {
+ setFormError('iou.error.noParticipantSelected');
return;
}
if (!isEditingSplitBill && isMerchantRequired && (isMerchantEmpty || (shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction)))) {
@@ -826,12 +802,10 @@ function MoneyRequestConfirmationList({
}
const shouldShowSettlementButton = iouType === CONST.IOU.TYPE.PAY;
- const shouldDisableButton = selectedParticipants.length === 0;
const button = shouldShowSettlementButton ? (
confirm(value as PaymentMethodType)}
options={splitOrRequestOptions}
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
@@ -880,7 +853,6 @@ function MoneyRequestConfirmationList({
isReadOnly,
isTypeSplit,
iouType,
- selectedParticipants.length,
confirm,
bankAccountRoute,
iouCurrencyCode,
@@ -951,6 +923,7 @@ function MoneyRequestConfirmationList({
shouldSingleExecuteRowSelect
canSelectMultiple={false}
shouldPreventDefaultFocusOnSelectRow
+ shouldShowListEmptyContent={false}
footerContent={footerContent}
listFooterContent={listFooterContent}
containerStyle={[styles.flexBasisAuto]}
@@ -963,66 +936,35 @@ function MoneyRequestConfirmationList({
MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList';
-export default withOnyx({
- policyCategories: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
- },
- policyCategoriesDraft: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${policyID}`,
- },
- policyTags: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
- },
- defaultMileageRate: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- selector: DistanceRequestUtils.getDefaultMileageRate,
- },
- policy: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- },
- policyDraft: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`,
- },
- lastSelectedDistanceRates: {
- key: ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES,
- },
- currencyList: {
- key: ONYXKEYS.CURRENCY_LIST,
- },
-})(
- memo(
- MoneyRequestConfirmationList,
- (prevProps, nextProps) =>
- lodashIsEqual(prevProps.transaction, nextProps.transaction) &&
- prevProps.onSendMoney === nextProps.onSendMoney &&
- prevProps.onConfirm === nextProps.onConfirm &&
- prevProps.iouType === nextProps.iouType &&
- prevProps.iouAmount === nextProps.iouAmount &&
- prevProps.isDistanceRequest === nextProps.isDistanceRequest &&
- prevProps.isPolicyExpenseChat === nextProps.isPolicyExpenseChat &&
- prevProps.iouCategory === nextProps.iouCategory &&
- prevProps.shouldShowSmartScanFields === nextProps.shouldShowSmartScanFields &&
- prevProps.isEditingSplitBill === nextProps.isEditingSplitBill &&
- prevProps.iouCurrencyCode === nextProps.iouCurrencyCode &&
- prevProps.iouMerchant === nextProps.iouMerchant &&
- lodashIsEqual(prevProps.selectedParticipants, nextProps.selectedParticipants) &&
- lodashIsEqual(prevProps.payeePersonalDetails, nextProps.payeePersonalDetails) &&
- prevProps.isReadOnly === nextProps.isReadOnly &&
- prevProps.bankAccountRoute === nextProps.bankAccountRoute &&
- prevProps.policyID === nextProps.policyID &&
- prevProps.reportID === nextProps.reportID &&
- prevProps.receiptPath === nextProps.receiptPath &&
- prevProps.iouComment === nextProps.iouComment &&
- prevProps.receiptFilename === nextProps.receiptFilename &&
- prevProps.iouCreated === nextProps.iouCreated &&
- prevProps.iouIsBillable === nextProps.iouIsBillable &&
- prevProps.onToggleBillable === nextProps.onToggleBillable &&
- prevProps.hasSmartScanFailed === nextProps.hasSmartScanFailed &&
- prevProps.reportActionID === nextProps.reportActionID &&
- lodashIsEqual(prevProps.defaultMileageRate, nextProps.defaultMileageRate) &&
- lodashIsEqual(prevProps.lastSelectedDistanceRates, nextProps.lastSelectedDistanceRates) &&
- lodashIsEqual(prevProps.action, nextProps.action) &&
- lodashIsEqual(prevProps.currencyList, nextProps.currencyList) &&
- prevProps.shouldDisplayReceipt === nextProps.shouldDisplayReceipt,
- ),
+export default memo(
+ MoneyRequestConfirmationList,
+ (prevProps, nextProps) =>
+ lodashIsEqual(prevProps.transaction, nextProps.transaction) &&
+ prevProps.onSendMoney === nextProps.onSendMoney &&
+ prevProps.onConfirm === nextProps.onConfirm &&
+ prevProps.iouType === nextProps.iouType &&
+ prevProps.iouAmount === nextProps.iouAmount &&
+ prevProps.isDistanceRequest === nextProps.isDistanceRequest &&
+ prevProps.isPolicyExpenseChat === nextProps.isPolicyExpenseChat &&
+ prevProps.iouCategory === nextProps.iouCategory &&
+ prevProps.shouldShowSmartScanFields === nextProps.shouldShowSmartScanFields &&
+ prevProps.isEditingSplitBill === nextProps.isEditingSplitBill &&
+ prevProps.iouCurrencyCode === nextProps.iouCurrencyCode &&
+ prevProps.iouMerchant === nextProps.iouMerchant &&
+ lodashIsEqual(prevProps.selectedParticipants, nextProps.selectedParticipants) &&
+ lodashIsEqual(prevProps.payeePersonalDetails, nextProps.payeePersonalDetails) &&
+ prevProps.isReadOnly === nextProps.isReadOnly &&
+ prevProps.bankAccountRoute === nextProps.bankAccountRoute &&
+ prevProps.policyID === nextProps.policyID &&
+ prevProps.reportID === nextProps.reportID &&
+ prevProps.receiptPath === nextProps.receiptPath &&
+ prevProps.iouComment === nextProps.iouComment &&
+ prevProps.receiptFilename === nextProps.receiptFilename &&
+ prevProps.iouCreated === nextProps.iouCreated &&
+ prevProps.iouIsBillable === nextProps.iouIsBillable &&
+ prevProps.onToggleBillable === nextProps.onToggleBillable &&
+ prevProps.hasSmartScanFailed === nextProps.hasSmartScanFailed &&
+ prevProps.reportActionID === nextProps.reportActionID &&
+ lodashIsEqual(prevProps.action, nextProps.action) &&
+ prevProps.shouldDisplayReceipt === nextProps.shouldDisplayReceipt,
);
diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx
index a046ee6a62f3..377062d432ad 100644
--- a/src/components/MoneyRequestHeader.tsx
+++ b/src/components/MoneyRequestHeader.tsx
@@ -45,6 +45,8 @@ type MoneyRequestHeaderProps = {
};
function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPress}: MoneyRequestHeaderProps) {
+ // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use a correct layout for the hold expense modal https://github.com/Expensify/App/pull/47990#issuecomment-2362382026
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID ?? '-1'}`);
const [transaction] = useOnyx(
diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx
index 332b42e06119..67ecac27afbd 100644
--- a/src/components/Popover/index.tsx
+++ b/src/components/Popover/index.tsx
@@ -31,6 +31,7 @@ function Popover(props: PopoverProps) {
} = props;
// We need to use isSmallScreenWidth to apply the correct modal type and popoverAnchorPosition
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
const withoutOverlayRef = useRef(null);
const {close, popover} = React.useContext(PopoverContext);
diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx
index b1aa2fc28338..7c8c99d6305d 100644
--- a/src/components/PopoverMenu.tsx
+++ b/src/components/PopoverMenu.tsx
@@ -161,6 +161,7 @@ function PopoverMenu({
const styles = useThemeStyles();
const theme = useTheme();
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct popover styles
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
const [currentMenuItems, setCurrentMenuItems] = useState(menuItems);
const currentMenuItemsFocusedIndex = currentMenuItems?.findIndex((option) => option.isSelected);
diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx
similarity index 97%
rename from src/components/Pressable/GenericPressable/BaseGenericPressable.tsx
rename to src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx
index 5237ff486631..84a595a7bf05 100644
--- a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx
+++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx
@@ -3,6 +3,8 @@ import React, {forwardRef, useCallback, useEffect, useMemo, useState} from 'reac
import type {GestureResponderEvent, View} from 'react-native';
// eslint-disable-next-line no-restricted-imports
import {Pressable} from 'react-native';
+import type {PressableRef} from '@components/Pressable/GenericPressable/types';
+import type PressableProps from '@components/Pressable/GenericPressable/types';
import useSingleExecution from '@hooks/useSingleExecution';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -10,8 +12,6 @@ import Accessibility from '@libs/Accessibility';
import HapticFeedback from '@libs/HapticFeedback';
import KeyboardShortcut from '@libs/KeyboardShortcut';
import CONST from '@src/CONST';
-import type {PressableRef} from './types';
-import type PressableProps from './types';
function GenericPressable(
{
diff --git a/src/components/Pressable/GenericPressable/index.native.tsx b/src/components/Pressable/GenericPressable/implementation/index.native.tsx
similarity index 78%
rename from src/components/Pressable/GenericPressable/index.native.tsx
rename to src/components/Pressable/GenericPressable/implementation/index.native.tsx
index c17163677cbe..5ce313d21ea6 100644
--- a/src/components/Pressable/GenericPressable/index.native.tsx
+++ b/src/components/Pressable/GenericPressable/implementation/index.native.tsx
@@ -1,7 +1,7 @@
import React, {forwardRef} from 'react';
+import type {PressableRef} from '@components/Pressable/GenericPressable/types';
+import type PressableProps from '@components/Pressable/GenericPressable/types';
import GenericPressable from './BaseGenericPressable';
-import type {PressableRef} from './types';
-import type PressableProps from './types';
function NativeGenericPressable(props: PressableProps, ref: PressableRef) {
return (
diff --git a/src/components/Pressable/GenericPressable/implementation/index.tsx b/src/components/Pressable/GenericPressable/implementation/index.tsx
new file mode 100644
index 000000000000..b52eea83fdcb
--- /dev/null
+++ b/src/components/Pressable/GenericPressable/implementation/index.tsx
@@ -0,0 +1,33 @@
+import React, {forwardRef} from 'react';
+import type {Role} from 'react-native';
+import type {PressableRef} from '@components/Pressable/GenericPressable/types';
+import type PressableProps from '@components/Pressable/GenericPressable/types';
+import GenericPressable from './BaseGenericPressable';
+
+function WebGenericPressable({focusable = true, ...props}: PressableProps, ref: PressableRef) {
+ const accessible = props.accessible ?? props.accessible === undefined ? true : props.accessible;
+
+ return (
+
+ );
+}
+
+WebGenericPressable.displayName = 'WebGenericPressable';
+
+export default forwardRef(WebGenericPressable);
diff --git a/src/components/Pressable/GenericPressable/index.e2e.tsx b/src/components/Pressable/GenericPressable/index.e2e.tsx
new file mode 100644
index 000000000000..5d997977a7e0
--- /dev/null
+++ b/src/components/Pressable/GenericPressable/index.e2e.tsx
@@ -0,0 +1,34 @@
+import React, {forwardRef, useEffect} from 'react';
+import GenericPressable from './implementation';
+import type {PressableRef} from './types';
+import type PressableProps from './types';
+
+const pressableRegistry = new Map();
+
+function getPressableProps(nativeID: string): PressableProps | undefined {
+ return pressableRegistry.get(nativeID);
+}
+
+function E2EGenericPressableWrapper(props: PressableProps, ref: PressableRef) {
+ useEffect(() => {
+ const nativeId = props.nativeID;
+ if (!nativeId) {
+ return;
+ }
+ console.debug(`[E2E] E2EGenericPressableWrapper: Registering pressable with nativeID: ${nativeId}`);
+ pressableRegistry.set(nativeId, props);
+ }, [props]);
+
+ return (
+
+ );
+}
+
+E2EGenericPressableWrapper.displayName = 'E2EGenericPressableWrapper';
+
+export default forwardRef(E2EGenericPressableWrapper);
+export {getPressableProps};
diff --git a/src/components/Pressable/GenericPressable/index.tsx b/src/components/Pressable/GenericPressable/index.tsx
index 371b4d169714..c3d3a2b2c856 100644
--- a/src/components/Pressable/GenericPressable/index.tsx
+++ b/src/components/Pressable/GenericPressable/index.tsx
@@ -1,33 +1,3 @@
-import React, {forwardRef} from 'react';
-import type {Role} from 'react-native';
-import GenericPressable from './BaseGenericPressable';
-import type {PressableRef} from './types';
-import type PressableProps from './types';
+import GenericPressable from './implementation';
-function WebGenericPressable({focusable = true, ...props}: PressableProps, ref: PressableRef) {
- const accessible = props.accessible ?? props.accessible === undefined ? true : props.accessible;
-
- return (
-
- );
-}
-
-WebGenericPressable.displayName = 'WebGenericPressable';
-
-export default forwardRef(WebGenericPressable);
+export default GenericPressable;
diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx
index d140e71bceae..3d6ad9006dc5 100644
--- a/src/components/ProcessMoneyReportHoldMenu.tsx
+++ b/src/components/ProcessMoneyReportHoldMenu.tsx
@@ -60,7 +60,8 @@ function ProcessMoneyReportHoldMenu({
}: ProcessMoneyReportHoldMenuProps) {
const {translate} = useLocalize();
const isApprove = requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE;
- // We need to use shouldUseNarrowLayout instead of shouldUseNarrowLayout to apply the correct modal type
+ // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
const onSubmit = (full: boolean) => {
diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx
index 411f6be7252c..6bb70a275a30 100644
--- a/src/components/ReportActionItem/ReportPreview.tsx
+++ b/src/components/ReportActionItem/ReportPreview.tsx
@@ -21,6 +21,7 @@ import useNetwork from '@hooks/useNetwork';
import usePolicy from '@hooks/usePolicy';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import {getCurrentUserAccountID} from '@libs/actions/Report';
import ControlSelection from '@libs/ControlSelection';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
@@ -173,7 +174,15 @@ function ReportPreview({
formattedMerchant = null;
}
- const shouldShowSubmitButton = isOpenExpenseReport && reimbursableSpend !== 0 && !showRTERViolationMessage && !shouldShowBrokenConnectionViolation;
+ const currentUserAccountID = getCurrentUserAccountID();
+ const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN;
+ const shouldShowSubmitButton =
+ isOpenExpenseReport &&
+ reimbursableSpend !== 0 &&
+ !showRTERViolationMessage &&
+ !shouldShowBrokenConnectionViolation &&
+ (iouReport?.ownerAccountID === currentUserAccountID || isAdmin || iouReport?.managerID === currentUserAccountID);
+
const shouldDisableSubmitButton = shouldShowSubmitButton && !ReportUtils.isAllowedToSubmitDraftExpenseReport(iouReport);
// The submit button should be success green colour only if the user is submitter and the policy does not have Scheduled Submit turned on
@@ -406,7 +415,6 @@ function ReportPreview({
*/
const connectedIntegration = PolicyUtils.getConnectedIntegration(policy);
- const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN;
const shouldShowExportIntegrationButton = !shouldShowPayButton && !shouldShowSubmitButton && connectedIntegration && isAdmin && ReportUtils.canBeExported(iouReport);
useEffect(() => {
diff --git a/src/components/RequireTwoFactorAuthenticationModal.tsx b/src/components/RequireTwoFactorAuthenticationModal.tsx
index 229231e8ff25..ad4f2db28c1c 100644
--- a/src/components/RequireTwoFactorAuthenticationModal.tsx
+++ b/src/components/RequireTwoFactorAuthenticationModal.tsx
@@ -47,6 +47,7 @@ function RequireTwoFactorAuthenticationModal({onCancel = () => {}, description,
type={shouldUseNarrowLayout ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM}
innerContainerStyle={{...styles.pb5, ...styles.pt3, ...styles.boxShadowNone}}
shouldEnableNewFocusManagement={shouldEnableNewFocusManagement}
+ animationOutTiming={500}
>
{
- return !!route?.params && 'singleNewDotEntry' in route.params && route.params.singleNewDotEntry === 'true';
- }, [route]);
-
- UNSTABLE_usePreventRemove(shouldReturnToOldDot, () => {
- NativeModules.HybridAppModule?.closeReactNativeApp(false, false);
- });
-
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponderCapture: (_e, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS,
diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx
index a76ca767ae2a..e7a60a5dc212 100644
--- a/src/components/Search/SearchFiltersParticipantsSelector.tsx
+++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx
@@ -57,28 +57,13 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}:
return defaultListOptions;
}
- return OptionsListUtils.getFilteredOptions(
- options.reports,
- options.personalDetails,
- undefined,
- '',
+ return OptionsListUtils.getFilteredOptions({
+ reports: options.reports,
+ personalDetails: options.personalDetails,
selectedOptions,
- CONST.EXPENSIFY_EMAILS,
- false,
- true,
- false,
- {},
- [],
- false,
- {},
- [],
- true,
- false,
- false,
- 0,
- undefined,
- false,
- );
+ excludeLogins: CONST.EXPENSIFY_EMAILS,
+ maxRecentReportsToShow: 0,
+ });
}, [areOptionsInitialized, options.personalDetails, options.reports, selectedOptions]);
const chatOptions = useMemo(() => {
diff --git a/src/components/Search/SearchMultipleSelectionPicker.tsx b/src/components/Search/SearchMultipleSelectionPicker.tsx
index 558b89715b61..d76f2e76ab02 100644
--- a/src/components/Search/SearchMultipleSelectionPicker.tsx
+++ b/src/components/Search/SearchMultipleSelectionPicker.tsx
@@ -7,6 +7,7 @@ import useLocalize from '@hooks/useLocalize';
import localeCompare from '@libs/LocaleCompare';
import Navigation from '@libs/Navigation/Navigation';
import type {OptionData} from '@libs/ReportUtils';
+import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
type SearchMultipleSelectionPickerItem = {
@@ -28,6 +29,17 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit
const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
const [selectedItems, setSelectedItems] = useState(initiallySelectedItems ?? []);
+ const sortOptionsWithEmptyValue = (a: SearchMultipleSelectionPickerItem, b: SearchMultipleSelectionPickerItem) => {
+ // Always show `No category` and `No tag` as the first option
+ if (a.value === CONST.SEARCH.EMPTY_VALUE) {
+ return -1;
+ }
+ if (b.value === CONST.SEARCH.EMPTY_VALUE) {
+ return 1;
+ }
+ return localeCompare(a.name, b.name);
+ };
+
useEffect(() => {
setSelectedItems(initiallySelectedItems ?? []);
}, [initiallySelectedItems]);
@@ -35,7 +47,7 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit
const {sections, noResultsFound} = useMemo(() => {
const selectedItemsSection = selectedItems
.filter((item) => item?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()))
- .sort((a, b) => localeCompare(a.name, b.name))
+ .sort((a, b) => sortOptionsWithEmptyValue(a, b))
.map((item) => ({
text: item.name,
keyForList: item.name,
@@ -44,7 +56,7 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit
}));
const remainingItemsSection = items
.filter((item) => selectedItems.some((selectedItem) => selectedItem.value === item.value) === false && item?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()))
- .sort((a, b) => localeCompare(a.name, b.name))
+ .sort((a, b) => sortOptionsWithEmptyValue(a, b))
.map((item) => ({
text: item.name,
keyForList: item.name,
diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx
index 4c383021645f..0168916d0213 100644
--- a/src/components/Search/SearchPageHeader.tsx
+++ b/src/components/Search/SearchPageHeader.tsx
@@ -67,7 +67,7 @@ function HeaderWrapper({icon, children, text, value, isCannedQuery, onSubmit, se
/>
)}
{text}} />
- {children}
+ {children}
) : (
@@ -121,6 +121,8 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
const styles = useThemeStyles();
const {isOffline} = useNetwork();
const {activeWorkspaceID} = useActiveWorkspace();
+ // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
const {selectedTransactions, clearSelectedTransactions, selectedReports} = useSearchContext();
const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE);
diff --git a/src/components/Search/SearchRouter/SearchButton.tsx b/src/components/Search/SearchRouter/SearchButton.tsx
index 7ed22ec8162f..76eacd8b991d 100644
--- a/src/components/Search/SearchRouter/SearchButton.tsx
+++ b/src/components/Search/SearchRouter/SearchButton.tsx
@@ -3,6 +3,7 @@ import type {StyleProp, ViewStyle} from 'react-native';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import {PressableWithoutFeedback} from '@components/Pressable';
+import Tooltip from '@components/Tooltip';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -23,21 +24,24 @@ function SearchButton({style}: SearchButtonProps) {
const {openSearchRouter} = useSearchRouterContext();
return (
- {
- Timing.start(CONST.TIMING.SEARCH_ROUTER_RENDER);
- Performance.markStart(CONST.TIMING.SEARCH_ROUTER_RENDER);
+
+ {
+ Timing.start(CONST.TIMING.SEARCH_ROUTER_RENDER);
+ Performance.markStart(CONST.TIMING.SEARCH_ROUTER_RENDER);
- openSearchRouter();
- })}
- >
-
-
+ openSearchRouter();
+ })}
+ >
+
+
+
);
}
diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx
index c7ec22c5c4c2..1f6a3824d751 100644
--- a/src/components/Search/SearchRouter/SearchRouter.tsx
+++ b/src/components/Search/SearchRouter/SearchRouter.tsx
@@ -12,7 +12,6 @@ import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
-import FastSearch from '@libs/FastSearch';
import Log from '@libs/Log';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import {getAllTaxRates} from '@libs/PolicyUtils';
@@ -41,7 +40,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
const [recentSearches] = useOnyx(ONYXKEYS.RECENT_SEARCHES);
const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false});
- const {isSmallScreenWidth} = useResponsiveLayout();
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
const listRef = useRef(null);
const taxRates = getAllTaxRates();
@@ -64,49 +63,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
return OptionsListUtils.getSearchOptions(options, '', betas ?? []);
}, [areOptionsInitialized, betas, options]);
- /**
- * Builds a suffix tree and returns a function to search in it.
- */
- const findInSearchTree = useMemo(() => {
- const fastSearch = FastSearch.createFastSearch([
- {
- data: searchOptions.personalDetails,
- toSearchableString: (option) => {
- const displayName = option.participantsList?.[0]?.displayName ?? '';
- return [option.login ?? '', option.login !== displayName ? displayName : ''].join();
- },
- },
- {
- data: searchOptions.recentReports,
- toSearchableString: (option) => {
- const searchStringForTree = [option.text ?? '', option.login ?? ''];
-
- if (option.isThread) {
- if (option.alternateText) {
- searchStringForTree.push(option.alternateText);
- }
- } else if (!!option.isChatRoom || !!option.isPolicyExpenseChat) {
- if (option.subtitle) {
- searchStringForTree.push(option.subtitle);
- }
- }
-
- return searchStringForTree.join();
- },
- },
- ]);
- function search(searchInput: string) {
- const [personalDetails, recentReports] = fastSearch.search(searchInput);
-
- return {
- personalDetails,
- recentReports,
- };
- }
-
- return search;
- }, [searchOptions.personalDetails, searchOptions.recentReports]);
-
const filteredOptions = useMemo(() => {
if (debouncedInputValue.trim() === '') {
return {
@@ -117,31 +73,24 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
}
Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS);
- const newOptions = findInSearchTree(debouncedInputValue);
+ const newOptions = OptionsListUtils.filterOptions(searchOptions, debouncedInputValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true});
Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS);
- const recentReports = newOptions.recentReports.concat(newOptions.personalDetails);
-
- const userToInvite = OptionsListUtils.pickUserToInvite({
- canInviteUser: true,
+ return {
recentReports: newOptions.recentReports,
personalDetails: newOptions.personalDetails,
- searchValue: debouncedInputValue,
- optionsToExclude: [{login: CONST.EMAIL.NOTIFICATIONS}],
- });
-
- return {
- recentReports,
- personalDetails: [],
- userToInvite,
+ userToInvite: newOptions.userToInvite,
};
- }, [debouncedInputValue, findInSearchTree]);
+ }, [debouncedInputValue, searchOptions]);
const recentReports: OptionData[] = useMemo(() => {
- const currentSearchOptions = debouncedInputValue === '' ? searchOptions : filteredOptions;
- const reports: OptionData[] = [...currentSearchOptions.recentReports, ...currentSearchOptions.personalDetails];
- if (currentSearchOptions.userToInvite) {
- reports.push(currentSearchOptions.userToInvite);
+ if (debouncedInputValue === '') {
+ return searchOptions.recentReports.slice(0, 10);
+ }
+
+ const reports: OptionData[] = [...filteredOptions.recentReports, ...filteredOptions.personalDetails];
+ if (filteredOptions.userToInvite) {
+ reports.push(filteredOptions.userToInvite);
}
return reports.slice(0, 10);
}, [debouncedInputValue, filteredOptions, searchOptions]);
@@ -212,14 +161,14 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
closeAndClearRouter();
});
- const modalWidth = isSmallScreenWidth ? styles.w100 : {width: variables.searchRouterPopoverWidth};
+ const modalWidth = shouldUseNarrowLayout ? styles.w100 : {width: variables.searchRouterPopoverWidth};
return (
- {isSmallScreenWidth && (
+ {shouldUseNarrowLayout && (
onRouterClose()}
@@ -228,7 +177,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
{
onSearchSubmit(SearchUtils.buildSearchQueryJSON(textInputValue));
@@ -236,7 +185,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
routerListRef={listRef}
shouldShowOfflineMessage
wrapperStyle={[styles.border, styles.alignItemsCenter]}
- outerWrapperStyle={[isSmallScreenWidth ? styles.mv3 : styles.mv2, isSmallScreenWidth ? styles.mh5 : styles.mh2]}
+ outerWrapperStyle={[shouldUseNarrowLayout ? styles.mv3 : styles.mv2, shouldUseNarrowLayout ? styles.mh5 : styles.mh2]}
wrapperFocusedStyle={[styles.borderColorFocus]}
isSearchingForReports={isSearchingForReports}
/>
diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx
index d8a373183077..0207f7663b58 100644
--- a/src/components/Search/SearchRouter/SearchRouterList.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterList.tsx
@@ -32,7 +32,7 @@ type SearchRouterListProps = {
currentQuery: SearchQueryJSON | undefined;
/** Recent searches */
- recentSearches: ItemWithQuery[] | undefined;
+ recentSearches: Array | undefined;
/** Recent reports */
recentReports: OptionData[];
@@ -92,7 +92,7 @@ function SearchRouterList(
) {
const styles = useThemeStyles();
const {translate} = useLocalize();
- const {isSmallScreenWidth} = useResponsiveLayout();
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
const personalDetails = usePersonalDetails();
const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
@@ -129,13 +129,13 @@ function SearchRouterList(
});
}
- const recentSearchesData = recentSearches?.map(({query}) => {
+ const recentSearchesData = recentSearches?.map(({query, timestamp}) => {
const searchQueryJSON = SearchUtils.buildSearchQueryJSON(query);
return {
text: searchQueryJSON ? SearchUtils.getSearchHeaderTitle(searchQueryJSON, personalDetails, cardList, reports, taxRates) : query,
singleIcon: Expensicons.History,
query,
- keyForList: query,
+ keyForList: timestamp,
};
});
@@ -179,11 +179,11 @@ function SearchRouterList(
onSelectRow={onSelectRow}
ListItem={SearchRouterItem}
containerStyle={[styles.mh100]}
- sectionListStyle={[isSmallScreenWidth ? styles.ph5 : styles.ph2, styles.pb2]}
+ sectionListStyle={[shouldUseNarrowLayout ? styles.ph5 : styles.ph2, styles.pb2]}
listItemWrapperStyle={[styles.pr3, styles.pl3]}
onLayout={setPerformanceTimersEnd}
ref={ref}
- showScrollIndicator={!isSmallScreenWidth}
+ showScrollIndicator={!shouldUseNarrowLayout}
sectionTitleStyles={styles.mhn2}
shouldSingleExecuteRowSelect
/>
diff --git a/src/components/Search/SearchRouter/SearchRouterModal.tsx b/src/components/Search/SearchRouter/SearchRouterModal.tsx
index 62cdb38246b4..2626b8565e2a 100644
--- a/src/components/Search/SearchRouter/SearchRouterModal.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterModal.tsx
@@ -8,10 +8,10 @@ import SearchRouter from './SearchRouter';
import {useSearchRouterContext} from './SearchRouterContext';
function SearchRouterModal() {
- const {isSmallScreenWidth} = useResponsiveLayout();
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
const {isSearchRouterDisplayed, closeSearchRouter} = useSearchRouterContext();
- const modalType = isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE : CONST.MODAL.MODAL_TYPE.POPOVER;
+ const modalType = shouldUseNarrowLayout ? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE : CONST.MODAL.MODAL_TYPE.POPOVER;
return (
>();
const lastSearchResultsRef = useRef>();
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index 06bf8eb6434a..c7779001b38d 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -105,6 +105,7 @@ function BaseSelectionList(
shouldIgnoreFocus = false,
scrollEventThrottle,
contentContainerStyle,
+ shouldHighlightSelectedItem = false,
}: BaseSelectionListProps,
ref: ForwardedRef,
) {
@@ -476,6 +477,7 @@ function BaseSelectionList(
setFocusedIndex(normalizedIndex);
}}
shouldSyncFocus={!isTextInputFocusedRef.current}
+ shouldHighlightSelectedItem={shouldHighlightSelectedItem}
wrapperStyle={listItemWrapperStyle}
/>
{item.footerContent && item.footerContent}
@@ -718,7 +720,7 @@ function BaseSelectionList(
)}
{!!headerContent && headerContent}
- {flattenedSections.allOptions.length === 0 ? (
+ {flattenedSections.allOptions.length === 0 && (showLoadingPlaceholder || shouldShowListEmptyContent) ? (
renderListEmptyContent()
) : (
<>
diff --git a/src/components/SelectionList/InviteMemberListItem.tsx b/src/components/SelectionList/InviteMemberListItem.tsx
index 3e0ea0b3ef15..daeb513e3fa9 100644
--- a/src/components/SelectionList/InviteMemberListItem.tsx
+++ b/src/components/SelectionList/InviteMemberListItem.tsx
@@ -36,6 +36,7 @@ function InviteMemberListItem({
rightHandSideComponent,
onFocus,
shouldSyncFocus,
+ shouldHighlightSelectedItem,
}: InviteMemberListItemProps) {
const styles = useThemeStyles();
const theme = useTheme();
@@ -58,6 +59,7 @@ function InviteMemberListItem({
return (
= CommonListItemProps & {
/** Whether to show RBR */
shouldDisplayRBR?: boolean;
+
+ /** Whether we highlight all the selected items */
+ shouldHighlightSelectedItem?: boolean;
};
type BaseListItemProps = CommonListItemProps & {
@@ -584,6 +587,9 @@ type BaseSelectionListProps = Partial & {
/** Additional styles to apply to scrollable content */
contentContainerStyle?: StyleProp;
+
+ /** Whether we highlight all the selected items */
+ shouldHighlightSelectedItem?: boolean;
} & TRightHandSideComponent;
type SelectionListHandle = {
diff --git a/src/components/SelectionListWithModal/index.tsx b/src/components/SelectionListWithModal/index.tsx
index 46d6494d1d21..25123d5454d4 100644
--- a/src/components/SelectionListWithModal/index.tsx
+++ b/src/components/SelectionListWithModal/index.tsx
@@ -28,6 +28,7 @@ function SelectionListWithModal(
const {translate} = useLocalize();
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout here because there is a race condition that causes shouldUseNarrowLayout to change indefinitely in this component
// See https://github.com/Expensify/App/issues/48675 for more details
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
const isFocused = useIsFocused();
diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx
index 1bf753cd4aa4..df109763b647 100644
--- a/src/components/TabSelector/TabSelector.tsx
+++ b/src/components/TabSelector/TabSelector.tsx
@@ -1,6 +1,5 @@
import type {MaterialTopTabBarProps} from '@react-navigation/material-top-tabs/lib/typescript/src/types';
-import React, {useCallback, useEffect, useMemo, useState} from 'react';
-import type {Animated} from 'react-native';
+import React, {useEffect, useMemo, useState} from 'react';
import {View} from 'react-native';
import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement';
import * as Expensicons from '@components/Icon/Expensicons';
@@ -10,6 +9,8 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import type IconAsset from '@src/types/utils/IconAsset';
+import getBackgroundColor from './getBackground';
+import getOpacity from './getOpacity';
import TabSelectorItem from './TabSelectorItem';
type TabSelectorProps = MaterialTopTabBarProps & {
@@ -50,21 +51,6 @@ function getIconAndTitle(route: string, translate: LocaleContextProps['translate
}
}
-function getOpacity(position: Animated.AnimatedInterpolation, routesLength: number, tabIndex: number, active: boolean, affectedTabs: number[]) {
- const activeValue = active ? 1 : 0;
- const inactiveValue = active ? 0 : 1;
-
- if (routesLength > 1) {
- const inputRange = Array.from({length: routesLength}, (v, i) => i);
-
- return position.interpolate({
- inputRange,
- outputRange: inputRange.map((i) => (affectedTabs.includes(tabIndex) && i === tabIndex ? activeValue : inactiveValue)),
- });
- }
- return activeValue;
-}
-
function TabSelector({state, navigation, onTabPress = () => {}, position, onFocusTrapContainerElementChanged}: TabSelectorProps) {
const {translate} = useLocalize();
const theme = useTheme();
@@ -72,21 +58,6 @@ function TabSelector({state, navigation, onTabPress = () => {}, position, onFocu
const defaultAffectedAnimatedTabs = useMemo(() => Array.from({length: state.routes.length}, (v, i) => i), [state.routes.length]);
const [affectedAnimatedTabs, setAffectedAnimatedTabs] = useState(defaultAffectedAnimatedTabs);
- const getBackgroundColor = useCallback(
- (routesLength: number, tabIndex: number, affectedTabs: number[]) => {
- if (routesLength > 1) {
- const inputRange = Array.from({length: routesLength}, (v, i) => i);
-
- return position.interpolate({
- inputRange,
- outputRange: inputRange.map((i) => (affectedTabs.includes(tabIndex) && i === tabIndex ? theme.border : theme.appBG)),
- }) as unknown as Animated.AnimatedInterpolation;
- }
- return theme.border;
- },
- [theme, position],
- );
-
useEffect(() => {
// It is required to wait transition end to reset affectedAnimatedTabs because tabs style is still animating during transition.
setTimeout(() => {
@@ -98,10 +69,10 @@ function TabSelector({state, navigation, onTabPress = () => {}, position, onFocu
{state.routes.map((route, index) => {
- const activeOpacity = getOpacity(position, state.routes.length, index, true, affectedAnimatedTabs);
- const inactiveOpacity = getOpacity(position, state.routes.length, index, false, affectedAnimatedTabs);
- const backgroundColor = getBackgroundColor(state.routes.length, index, affectedAnimatedTabs);
const isActive = index === state.index;
+ const activeOpacity = getOpacity({routesLength: state.routes.length, tabIndex: index, active: true, affectedTabs: affectedAnimatedTabs, position, isActive});
+ const inactiveOpacity = getOpacity({routesLength: state.routes.length, tabIndex: index, active: false, affectedTabs: affectedAnimatedTabs, position, isActive});
+ const backgroundColor = getBackgroundColor({routesLength: state.routes.length, tabIndex: index, affectedTabs: affectedAnimatedTabs, theme, position, isActive});
const {icon, title} = getIconAndTitle(route.name, translate);
const onPress = () => {
diff --git a/src/components/TabSelector/getBackground/index.native.ts b/src/components/TabSelector/getBackground/index.native.ts
new file mode 100644
index 000000000000..09a9b3f347e6
--- /dev/null
+++ b/src/components/TabSelector/getBackground/index.native.ts
@@ -0,0 +1,17 @@
+import type {Animated} from 'react-native';
+import type GetBackgroudColor from './types';
+
+const getBackgroundColor: GetBackgroudColor = ({routesLength, tabIndex, affectedTabs, theme, position}) => {
+ if (routesLength > 1) {
+ const inputRange = Array.from({length: routesLength}, (v, i) => i);
+ return position?.interpolate({
+ inputRange,
+ outputRange: inputRange.map((i) => {
+ return affectedTabs.includes(tabIndex) && i === tabIndex ? theme.border : theme.appBG;
+ }),
+ }) as unknown as Animated.AnimatedInterpolation;
+ }
+ return theme.border;
+};
+
+export default getBackgroundColor;
diff --git a/src/components/TabSelector/getBackground/index.ts b/src/components/TabSelector/getBackground/index.ts
new file mode 100644
index 000000000000..2eb60a5115a1
--- /dev/null
+++ b/src/components/TabSelector/getBackground/index.ts
@@ -0,0 +1,9 @@
+import type GetBackgroudColor from './types';
+
+const getBackgroundColor: GetBackgroudColor = ({routesLength, tabIndex, affectedTabs, theme, isActive}) => {
+ if (routesLength > 1) {
+ return affectedTabs.includes(tabIndex) && isActive ? theme.border : theme.appBG;
+ }
+ return theme.border;
+};
+export default getBackgroundColor;
diff --git a/src/components/TabSelector/getBackground/types.ts b/src/components/TabSelector/getBackground/types.ts
new file mode 100644
index 000000000000..f66ee37e9b73
--- /dev/null
+++ b/src/components/TabSelector/getBackground/types.ts
@@ -0,0 +1,46 @@
+import type {Animated} from 'react-native';
+import type {ThemeColors} from '@styles/theme/types';
+
+/**
+ * Configuration for the getBackgroundColor function.
+ */
+type GetBackgroudColorConfig = {
+ /**
+ * The number of routes.
+ */
+ routesLength: number;
+
+ /**
+ * The index of the current tab.
+ */
+ tabIndex: number;
+
+ /**
+ * The indices of the affected tabs.
+ */
+ affectedTabs: number[];
+
+ /**
+ * The theme colors.
+ */
+ theme: ThemeColors;
+
+ /**
+ * The animated position interpolation.
+ */
+ position: Animated.AnimatedInterpolation;
+
+ /**
+ * Whether the tab is active.
+ */
+ isActive: boolean;
+};
+
+/**
+ * Function to get the background color.
+ * @param args - The configuration for the background color.
+ * @returns The interpolated background color or a string.
+ */
+type GetBackgroudColor = (args: GetBackgroudColorConfig) => Animated.AnimatedInterpolation | string;
+
+export default GetBackgroudColor;
diff --git a/src/components/TabSelector/getOpacity/index.native.ts b/src/components/TabSelector/getOpacity/index.native.ts
new file mode 100644
index 000000000000..0da5455214c9
--- /dev/null
+++ b/src/components/TabSelector/getOpacity/index.native.ts
@@ -0,0 +1,18 @@
+import type GetOpacity from './types';
+
+const getOpacity: GetOpacity = ({routesLength, tabIndex, active, affectedTabs, position, isActive}) => {
+ const activeValue = active ? 1 : 0;
+ const inactiveValue = active ? 0 : 1;
+
+ if (routesLength > 1) {
+ const inputRange = Array.from({length: routesLength}, (v, i) => i);
+
+ return position?.interpolate({
+ inputRange,
+ outputRange: inputRange.map((i) => (affectedTabs.includes(tabIndex) && i === tabIndex && isActive ? activeValue : inactiveValue)),
+ });
+ }
+ return activeValue;
+};
+
+export default getOpacity;
diff --git a/src/components/TabSelector/getOpacity/index.ts b/src/components/TabSelector/getOpacity/index.ts
new file mode 100644
index 000000000000..d9f3a2eb6167
--- /dev/null
+++ b/src/components/TabSelector/getOpacity/index.ts
@@ -0,0 +1,13 @@
+import type GetOpacity from './types';
+
+const getOpacity: GetOpacity = ({routesLength, tabIndex, active, affectedTabs, isActive}) => {
+ const activeValue = active ? 1 : 0;
+ const inactiveValue = active ? 0 : 1;
+
+ if (routesLength > 1) {
+ return affectedTabs.includes(tabIndex) && isActive ? activeValue : inactiveValue;
+ }
+ return activeValue;
+};
+
+export default getOpacity;
diff --git a/src/components/TabSelector/getOpacity/types.ts b/src/components/TabSelector/getOpacity/types.ts
new file mode 100644
index 000000000000..46e4568b2783
--- /dev/null
+++ b/src/components/TabSelector/getOpacity/types.ts
@@ -0,0 +1,45 @@
+import type {Animated} from 'react-native';
+
+/**
+ * Configuration for the getOpacity function.
+ */
+type GetOpacityConfig = {
+ /**
+ * The number of routes in the tab bar.
+ */
+ routesLength: number;
+
+ /**
+ * The index of the tab.
+ */
+ tabIndex: number;
+
+ /**
+ * Whether we are calculating the opacity for the active tab.
+ */
+ active: boolean;
+
+ /**
+ * The indexes of the tabs that are affected by the animation.
+ */
+ affectedTabs: number[];
+
+ /**
+ * Scene's position, value which we would like to interpolate.
+ */
+ position: Animated.AnimatedInterpolation;
+
+ /**
+ * Whether the tab is active.
+ */
+ isActive: boolean;
+};
+
+/**
+ * Function to get the opacity.
+ * @param args - The configuration for the opacity.
+ * @returns The interpolated opacity or a fixed value (1 or 0).
+ */
+type GetOpacity = (args: GetOpacityConfig) => 1 | 0 | Animated.AnimatedInterpolation;
+
+export default GetOpacity;
diff --git a/src/components/TagPicker/index.tsx b/src/components/TagPicker/index.tsx
index 1d72285be9a0..9d3a70d4d50c 100644
--- a/src/components/TagPicker/index.tsx
+++ b/src/components/TagPicker/index.tsx
@@ -1,6 +1,5 @@
import React, {useMemo, useState} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import SelectionList from '@components/SelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
@@ -10,7 +9,7 @@ import * as PolicyUtils from '@libs/PolicyUtils';
import type * as ReportUtils from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {PolicyTag, PolicyTagLists, PolicyTags, RecentlyUsedTags} from '@src/types/onyx';
+import type {PolicyTag, PolicyTags} from '@src/types/onyx';
import type {PendingAction} from '@src/types/onyx/OnyxCommon';
type SelectedTagOption = {
@@ -21,15 +20,7 @@ type SelectedTagOption = {
pendingAction?: PendingAction;
};
-type TagPickerOnyxProps = {
- /** Collection of tag list on a policy */
- policyTags: OnyxEntry;
-
- /** List of recently used tags */
- policyRecentlyUsedTags: OnyxEntry;
-};
-
-type TagPickerProps = TagPickerOnyxProps & {
+type TagPickerProps = {
/** The policyID we are getting tags for */
// It's used in withOnyx HOC.
// eslint-disable-next-line react/no-unused-prop-types
@@ -51,7 +42,9 @@ type TagPickerProps = TagPickerOnyxProps & {
tagListIndex: number;
};
-function TagPicker({selectedTag, tagListName, policyTags, tagListIndex, policyRecentlyUsedTags, shouldShowDisabledAndSelectedOption = false, onSubmit}: TagPickerProps) {
+function TagPicker({selectedTag, tagListName, policyID, tagListIndex, shouldShowDisabledAndSelectedOption = false, onSubmit}: TagPickerProps) {
+ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`);
+ const [policyRecentlyUsedTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`);
const styles = useThemeStyles();
const {translate} = useLocalize();
const [searchValue, setSearchValue] = useState('');
@@ -87,7 +80,16 @@ function TagPicker({selectedTag, tagListName, policyTags, tagListIndex, policyRe
}, [selectedOptions, policyTagList, shouldShowDisabledAndSelectedOption]);
const sections = useMemo(
- () => OptionsListUtils.getFilteredOptions([], [], [], searchValue, selectedOptions, [], false, false, false, {}, [], true, enabledTags, policyRecentlyUsedTagsList, false).tagOptions,
+ () =>
+ OptionsListUtils.getFilteredOptions({
+ searchValue,
+ selectedOptions,
+ includeP2P: false,
+ includeTags: true,
+ tags: enabledTags,
+ recentlyUsedTags: policyRecentlyUsedTagsList,
+ canInviteUser: false,
+ }).tagOptions,
[searchValue, enabledTags, selectedOptions, policyRecentlyUsedTagsList],
);
@@ -113,13 +115,6 @@ function TagPicker({selectedTag, tagListName, policyTags, tagListIndex, policyRe
TagPicker.displayName = 'TagPicker';
-export default withOnyx({
- policyTags: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
- },
- policyRecentlyUsedTags: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`,
- },
-})(TagPicker);
+export default TagPicker;
export type {SelectedTagOption};
diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx
index cea528e4537c..f283058042eb 100644
--- a/src/components/ThumbnailImage.tsx
+++ b/src/components/ThumbnailImage.tsx
@@ -55,6 +55,12 @@ type ThumbnailImageProps = {
/** The object position of image */
objectPosition?: ImageObjectPosition;
+
+ /** Callback fired when the image fails to load */
+ onLoadFailure?: () => void;
+
+ /** Callback fired when the image has been measured */
+ onMeasure?: () => void;
};
type UpdateImageSizeParams = {
@@ -75,6 +81,8 @@ function ThumbnailImage({
fallbackIconColor,
fallbackIconBackground,
objectPosition = CONST.IMAGE_OBJECT_POSITION.INITIAL,
+ onLoadFailure,
+ onMeasure,
}: ThumbnailImageProps) {
const styles = useThemeStyles();
const theme = useTheme();
@@ -137,8 +145,14 @@ function ThumbnailImage({
setFailedToLoad(true)}
+ onMeasure={(args) => {
+ updateImageSize(args);
+ onMeasure?.();
+ }}
+ onLoadFailure={() => {
+ setFailedToLoad(true);
+ onLoadFailure?.();
+ }}
isAuthTokenRequired={isAuthTokenRequired}
objectPosition={objectPosition}
/>
diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx
index f71b957387a8..9207b9158051 100644
--- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx
+++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx
@@ -62,6 +62,8 @@ type ValidateCodeFormProps = {
/** Function to clear error of the form */
clearError: () => void;
+
+ sendValidateCode: () => void;
};
function BaseValidateCodeForm({
@@ -73,6 +75,7 @@ function BaseValidateCodeForm({
validateError,
handleSubmitForm,
clearError,
+ sendValidateCode,
buttonStyles,
}: ValidateCodeFormProps) {
const {translate} = useLocalize();
@@ -125,10 +128,6 @@ function BaseValidateCodeForm({
}, []),
);
- useEffect(() => {
- clearError();
- }, [clearError]);
-
useEffect(() => {
if (!hasMagicCodeBeenSent) {
return;
@@ -140,7 +139,7 @@ function BaseValidateCodeForm({
* Request a validate code / magic code be sent to verify this contact method
*/
const resendValidateCode = () => {
- User.requestValidateCodeAction();
+ sendValidateCode();
inputValidateCodeRef.current?.clear();
};
@@ -189,7 +188,7 @@ function BaseValidateCodeForm({
errorText={formError?.validateCode ? translate(formError?.validateCode) : ErrorUtils.getLatestErrorMessage(account ?? {})}
hasError={!isEmptyObject(validateError)}
onFulfill={validateAndSubmitForm}
- autoFocus={false}
+ autoFocus
/>
(null);
@@ -30,15 +42,16 @@ function ValidateCodeActionModal({isVisible, title, description, onClose, valida
return;
}
firstRenderRef.current = false;
- User.requestValidateCodeAction();
- }, [isVisible]);
+
+ sendValidateCode();
+ }, [isVisible, sendValidateCode]);
return (
+ {footer?.()}
);
diff --git a/src/components/ValidateCodeActionModal/type.ts b/src/components/ValidateCodeActionModal/type.ts
index 3cbfe62513d1..5556287b370e 100644
--- a/src/components/ValidateCodeActionModal/type.ts
+++ b/src/components/ValidateCodeActionModal/type.ts
@@ -1,3 +1,4 @@
+import type React from 'react';
import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon';
type ValidateCodeActionModalProps = {
@@ -13,6 +14,9 @@ type ValidateCodeActionModalProps = {
/** Function to call when the user closes the modal */
onClose: () => void;
+ /** Function to be called when the modal is closed */
+ onModalHide?: () => void;
+
/** The pending action for submitting form */
validatePendingAction?: PendingAction | null;
@@ -24,6 +28,15 @@ type ValidateCodeActionModalProps = {
/** Function to clear error of the form */
clearError: () => void;
+
+ /** A component to be rendered inside the modal */
+ footer?: () => React.JSX.Element;
+
+ /** Function is called when validate code modal is mounted and on magic code resend */
+ sendValidateCode: () => void;
+
+ /** If the magic code has been resent previously */
+ hasMagicCodeBeenSent?: boolean;
};
// eslint-disable-next-line import/prefer-default-export
diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx
index 84eb988d0758..012537b75108 100644
--- a/src/components/VideoPlayer/BaseVideoPlayer.tsx
+++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx
@@ -136,6 +136,8 @@ function BaseVideoPlayer({
debouncedHideControl();
}, [isPlaying, debouncedHideControl, controlStatusState, isPopoverVisible, canUseTouchScreen]);
+ const stopWheelPropagation = useCallback((ev: WheelEvent) => ev.stopPropagation(), []);
+
const toggleControl = useCallback(() => {
if (controlStatusState === CONST.VIDEO_PLAYER.CONTROLS_STATUS.SHOW) {
hideControl();
@@ -233,7 +235,18 @@ function BaseVideoPlayer({
(event: VideoFullscreenUpdateEvent) => {
onFullscreenUpdate?.(event);
+ if (event.fullscreenUpdate === VideoFullscreenUpdate.PLAYER_DID_PRESENT) {
+ // When the video is in fullscreen, we don't want the scroll to be captured by the InvertedFlatList of report screen.
+ // This will also allow the user to scroll the video playback speed.
+ if (videoPlayerElementParentRef.current && 'addEventListener' in videoPlayerElementParentRef.current) {
+ videoPlayerElementParentRef.current.addEventListener('wheel', stopWheelPropagation);
+ }
+ }
+
if (event.fullscreenUpdate === VideoFullscreenUpdate.PLAYER_DID_DISMISS) {
+ if (videoPlayerElementParentRef.current && 'removeEventListener' in videoPlayerElementParentRef.current) {
+ videoPlayerElementParentRef.current.removeEventListener('wheel', stopWheelPropagation);
+ }
isFullScreenRef.current = false;
// Sync volume updates in full screen mode after leaving it
@@ -254,7 +267,7 @@ function BaseVideoPlayer({
}
}
},
- [isFullScreenRef, onFullscreenUpdate, pauseVideo, playVideo, videoResumeTryNumberRef, updateVolume, currentVideoPlayerRef],
+ [isFullScreenRef, onFullscreenUpdate, pauseVideo, playVideo, videoResumeTryNumberRef, updateVolume, currentVideoPlayerRef, stopWheelPropagation],
);
const bindFunctions = useCallback(() => {
diff --git a/src/hooks/useIndicatorStatus.ts b/src/hooks/useIndicatorStatus.ts
new file mode 100644
index 000000000000..b026bc52fd7b
--- /dev/null
+++ b/src/hooks/useIndicatorStatus.ts
@@ -0,0 +1,79 @@
+import {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
+import {isConnectionInProgress} from '@libs/actions/connections';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import * as SubscriptionUtils from '@libs/SubscriptionUtils';
+import * as UserUtils from '@libs/UserUtils';
+import * as PaymentMethods from '@userActions/PaymentMethods';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import useTheme from './useTheme';
+
+type IndicatorStatus = ValueOf;
+
+type IndicatorStatusResult = {
+ indicatorColor: string;
+ status: ValueOf | undefined;
+ policyIDWithErrors: string | undefined;
+};
+
+function useIndicatorStatus(): IndicatorStatusResult {
+ const theme = useTheme();
+ const [allConnectionSyncProgresses] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS);
+ const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
+ const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [fundList] = useOnyx(ONYXKEYS.FUND_LIST);
+ const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET);
+ const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS);
+ const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST);
+
+ // If a policy was just deleted from Onyx, then Onyx will pass a null value to the props, and
+ // those should be cleaned out before doing any error checking
+ const cleanPolicies = useMemo(() => Object.fromEntries(Object.entries(policies ?? {}).filter(([, policy]) => policy?.id)), [policies]);
+
+ const policyErrors = {
+ [CONST.INDICATOR_STATUS.HAS_POLICY_ERRORS]: Object.values(cleanPolicies).find(PolicyUtils.hasPolicyError),
+ [CONST.INDICATOR_STATUS.HAS_CUSTOM_UNITS_ERROR]: Object.values(cleanPolicies).find(PolicyUtils.hasCustomUnitsError),
+ [CONST.INDICATOR_STATUS.HAS_EMPLOYEE_LIST_ERROR]: Object.values(cleanPolicies).find(PolicyUtils.hasEmployeeListError),
+ [CONST.INDICATOR_STATUS.HAS_SYNC_ERRORS]: Object.values(cleanPolicies).find((cleanPolicy) =>
+ PolicyUtils.hasSyncError(
+ cleanPolicy,
+ isConnectionInProgress(allConnectionSyncProgresses?.[`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${cleanPolicy?.id}`], cleanPolicy),
+ ),
+ ),
+ };
+
+ // All of the error & info-checking methods are put into an array. This is so that using _.some() will return
+ // early as soon as the first error / info condition is returned. This makes the checks very efficient since
+ // we only care if a single error / info condition exists anywhere.
+ const errorChecking: Partial> = {
+ [CONST.INDICATOR_STATUS.HAS_USER_WALLET_ERRORS]: Object.keys(userWallet?.errors ?? {}).length > 0,
+ [CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR]: PaymentMethods.hasPaymentMethodError(bankAccountList, fundList),
+ ...(Object.fromEntries(Object.entries(policyErrors).map(([error, policy]) => [error, !!policy])) as Record),
+ [CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_ERRORS]: SubscriptionUtils.hasSubscriptionRedDotError(),
+ [CONST.INDICATOR_STATUS.HAS_REIMBURSEMENT_ACCOUNT_ERRORS]: Object.keys(reimbursementAccount?.errors ?? {}).length > 0,
+ [CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_ERROR]: !!loginList && UserUtils.hasLoginListError(loginList),
+ // Wallet term errors that are not caused by an IOU (we show the red brick indicator for those in the LHN instead)
+ [CONST.INDICATOR_STATUS.HAS_WALLET_TERMS_ERRORS]: Object.keys(walletTerms?.errors ?? {}).length > 0 && !walletTerms?.chatReportID,
+ };
+
+ const infoChecking: Partial> = {
+ [CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_INFO]: !!loginList && UserUtils.hasLoginListInfo(loginList),
+ [CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_INFO]: SubscriptionUtils.hasSubscriptionGreenDotInfo(),
+ };
+
+ const [error] = Object.entries(errorChecking).find(([, value]) => value) ?? [];
+ const [info] = Object.entries(infoChecking).find(([, value]) => value) ?? [];
+
+ const status = (error ?? info) as IndicatorStatus | undefined;
+ const policyIDWithErrors = Object.values(policyErrors).find(Boolean)?.id;
+ const indicatorColor = error ? theme.danger : theme.success;
+
+ return {indicatorColor, status, policyIDWithErrors};
+}
+
+export default useIndicatorStatus;
+
+export type {IndicatorStatus};
diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts
index 9e97c552a6e0..5ccd3bab9378 100644
--- a/src/hooks/useOnboardingFlow.ts
+++ b/src/hooks/useOnboardingFlow.ts
@@ -21,16 +21,14 @@ function useOnboardingFlowRouter() {
selector: hasCompletedHybridAppOnboardingFlowSelector,
});
- const [isSingleNewDotEntry, isSingleNewDotEntryMetadata] = useOnyx(ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY);
-
useEffect(() => {
- if (isLoadingOnyxValue(isOnboardingCompletedMetadata, isHybridAppOnboardingCompletedMetadata, isSingleNewDotEntryMetadata)) {
+ if (isLoadingOnyxValue(isOnboardingCompletedMetadata, isHybridAppOnboardingCompletedMetadata)) {
return;
}
if (NativeModules.HybridAppModule) {
// When user is transitioning from OldDot to NewDot, we usually show the explanation modal
- if (isHybridAppOnboardingCompleted === false && !isSingleNewDotEntry) {
+ if (isHybridAppOnboardingCompleted === false) {
Navigation.navigate(ROUTES.EXPLANATION_MODAL_ROOT);
}
@@ -45,7 +43,7 @@ function useOnboardingFlowRouter() {
if (!NativeModules.HybridAppModule && isOnboardingCompleted === false) {
OnboardingFlow.startOnboardingFlow();
}
- }, [isOnboardingCompleted, isHybridAppOnboardingCompleted, isOnboardingCompletedMetadata, isHybridAppOnboardingCompletedMetadata, isSingleNewDotEntryMetadata, isSingleNewDotEntry]);
+ }, [isOnboardingCompleted, isHybridAppOnboardingCompleted, isOnboardingCompletedMetadata, isHybridAppOnboardingCompletedMetadata]);
return {isOnboardingCompleted, isHybridAppOnboardingCompleted};
}
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 55fe80f8fba8..8b9569dc1267 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -39,6 +39,7 @@ import type {
ChangeTypeParams,
CharacterLengthLimitParams,
CharacterLimitParams,
+ CompanyCardBankName,
CompanyCardFeedNameParams,
ConfirmThatParams,
ConnectionNameParams,
@@ -955,6 +956,7 @@ const translations = {
invalidSplit: 'The sum of splits must equal the total amount.',
invalidSplitParticipants: 'Please enter an amount greater than zero for at least two participants.',
invalidSplitYourself: 'Please enter a non-zero amount for your split.',
+ noParticipantSelected: 'Please select a participant.',
other: 'Unexpected error. Please try again later.',
genericCreateFailureMessage: 'Unexpected error submitting this expense. Please try again later.',
genericCreateInvoiceFailureMessage: 'Unexpected error sending this invoice. Please try again later.',
@@ -2158,6 +2160,7 @@ const translations = {
companyAddress: 'Company address',
listOfRestrictedBusinesses: 'list of restricted businesses',
confirmCompanyIsNot: 'I confirm that this company is not on the',
+ businessInfoTitle: 'Business info',
},
beneficialOwnerInfoStep: {
doYouOwn25percent: 'Do you own 25% or more of',
@@ -2236,6 +2239,21 @@ const translations = {
enable2FAText: 'We take your security seriously. Please set up 2FA now to add an extra layer of protection to your account.',
secureYourAccount: 'Secure your account',
},
+ countryStep: {
+ confirmBusinessBank: 'Confirm business bank account currency and country',
+ confirmCurrency: 'Confirm currency and country',
+ },
+ signerInfoStep: {
+ signerInfo: 'Signer info',
+ },
+ agreementsStep: {
+ agreements: 'Agreements',
+ pleaseConfirm: 'Please confirm the agreements below',
+ accept: 'Accept and add bank account',
+ },
+ finishStep: {
+ connect: 'Connect bank account',
+ },
reimbursementAccountLoadingAnimation: {
oneMoment: 'One moment',
explanationLine: "We’re taking a look at your information. You'll be able to continue with next steps shortly.",
@@ -2422,6 +2440,8 @@ const translations = {
[CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY]: 'Journal entry',
[CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK]: 'Check',
+ [`${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CHECK}Description`]:
+ "We'll create an itemized check for each Expensify report and send it from the bank account below.",
[`${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}Description`]:
"We'll automatically match the merchant name on the credit card transaction to any corresponding vendors in QuickBooks. If no vendors exist, we'll create a 'Credit Card Misc.' vendor for association.",
[`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Description`]:
@@ -2429,6 +2449,7 @@ const translations = {
[`${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}AccountDescription`]: 'Choose where to export credit card transactions.',
[`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}AccountDescription`]: 'Choose a vendor to apply to all credit card transactions.',
+ [`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK}AccountDescription`]: 'Choose where to send checks from.',
[`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Error`]:
'Vendor bills are unavailable when locations are enabled. Please choose a different export option.',
@@ -2452,6 +2473,8 @@ const translations = {
classes: 'Classes',
items: 'Items',
customers: 'Customers/projects',
+ exportCompanyCardsDescription: 'Set how company card purchases export to QuickBooks Desktop.',
+ defaultVendorDescription: 'Set a default vendor that will apply to all credit card transactions upon export.',
accountsDescription: 'Your QuickBooks Desktop chart of accounts will import into Expensify as categories.',
accountsSwitchTitle: 'Choose to import new accounts as enabled or disabled categories.',
accountsSwitchDescription: 'Enabled categories will be available for members to select when creating their expenses.',
@@ -2462,9 +2485,9 @@ const translations = {
advancedConfig: {
autoSyncDescription: 'Expensify will automatically sync with QuickBooks Desktop every day.',
createEntities: 'Auto-create entities',
- createEntitiesDescription:
- "Expensify will automatically create vendors in QuickBooks Desktop if they don't exist already, and auto-create customers when exporting invoices.",
+ createEntitiesDescription: "Expensify will automatically create vendors in QuickBooks Desktop if they don't exist already.",
},
+ itemsDescription: 'Choose how to handle QuickBooks Desktop items in Expensify.',
},
qbo: {
importDescription: 'Choose which coding configurations to import from QuickBooks Online to Expensify.',
@@ -3308,6 +3331,9 @@ const translations = {
emptyAddedFeedDescription: 'Get started by assigning your first card to a member.',
pendingFeedTitle: `We're reviewing your request...`,
pendingFeedDescription: `We're currently reviewing your feed details. Once that's done we'll reach out to you via`,
+ pendingBankTitle: 'Check your browser window',
+ pendingBankDescription: ({bankName}: CompanyCardBankName) => `Please connect to ${bankName} via your browser window that just opened. If one didn’t open, `,
+ pendingBankLink: 'please click here.',
giveItNameInstruction: 'Give the card a name that sets it apart from the others.',
updating: 'Updating...',
noAccountsFound: 'No accounts found',
@@ -3583,6 +3609,7 @@ const translations = {
},
errorODIntegration: "There's an error with a connection that's been set up in Expensify Classic. ",
goToODToFix: 'Go to Expensify Classic to fix this issue.',
+ goToODToSettings: 'Go to Expensify Classic to manage your settings.',
setup: 'Connect',
lastSync: ({relativeDate}: LastSyncAccountingParams) => `Last synced ${relativeDate}`,
import: 'Import',
@@ -3591,6 +3618,7 @@ const translations = {
other: 'Other integrations',
syncNow: 'Sync now',
disconnect: 'Disconnect',
+ reinstall: 'Reinstall connector',
disconnectTitle: ({connectionName}: OptionalParam = {}) => {
const integrationName =
connectionName && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] : 'integration';
@@ -3639,14 +3667,18 @@ const translations = {
syncStageName: ({stage}: SyncStageNameConnectionsParams) => {
switch (stage) {
case 'quickbooksOnlineImportCustomers':
+ case 'quickbooksDesktopImportCustomers':
return 'Importing customers';
case 'quickbooksOnlineImportEmployees':
case 'netSuiteSyncImportEmployees':
case 'intacctImportEmployees':
+ case 'quickbooksDesktopImportEmployees':
return 'Importing employees';
case 'quickbooksOnlineImportAccounts':
+ case 'quickbooksDesktopImportAccounts':
return 'Importing accounts';
case 'quickbooksOnlineImportClasses':
+ case 'quickbooksDesktopImportClasses':
return 'Importing classes';
case 'quickbooksOnlineImportLocations':
return 'Importing locations';
@@ -3665,6 +3697,19 @@ const translations = {
return 'Importing Xero data';
case 'startingImportQBO':
return 'Importing QuickBooks Online data';
+ case 'startingImportQBD':
+ case 'quickbooksDesktopImportMore':
+ return 'Importing QuickBooks Desktop data';
+ case 'quickbooksDesktopImportTitle':
+ return 'Importing title';
+ case 'quickbooksDesktopImportApproveCertificate':
+ return 'Importing approve ceritificate';
+ case 'quickbooksDesktopImportDimensions':
+ return 'Importing dimensions';
+ case 'quickbooksDesktopImportSavePolicy':
+ return 'Importing save policy';
+ case 'quickbooksDesktopWebConnectorReminder':
+ return 'Still syncing data with QuickBooks... Please make sure the Web Connector is running';
case 'quickbooksOnlineSyncTitle':
return 'Syncing QuickBooks Online data';
case 'quickbooksOnlineSyncLoadData':
@@ -3738,6 +3783,7 @@ const translations = {
case 'netSuiteSyncImportSubsidiaries':
return 'Importing subsidiaries';
case 'netSuiteSyncImportVendors':
+ case 'quickbooksDesktopImportVendors':
return 'Importing vendors';
case 'intacctCheckConnection':
return 'Checking Sage Intacct connection';
@@ -3964,6 +4010,11 @@ const translations = {
description: `Enjoy automated syncing and reduce manual entries with the Expensify + Sage Intacct integration. Gain in-depth, real-time financial insights with user-defined dimensions, as well as expense coding by department, class, location, customer, and project (job).`,
onlyAvailableOnPlan: 'Our Sage Intacct integration is only available on the Control plan, starting at ',
},
+ [CONST.POLICY.CONNECTIONS.NAME.QBD]: {
+ title: 'QuickBooks Desktop',
+ description: `Enjoy automated syncing and reduce manual entries with the Expensify + QuickBooks Desktop integration. Gain ultimate efficiency with a realtime, two-way connection and expense coding by class, item, customer, and project.`,
+ onlyAvailableOnPlan: 'Our QuickBooks Desktop integration is only available on the Control plan, starting at ',
+ },
[CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: {
title: 'Advanced Approvals',
description: `If you want to add more layers of approval to the mix – or just make sure the largest expenses get another set of eyes – we’ve got you covered. Advanced approvals help you put the right checks in place at every level so you keep your team’s spend under control.`,
@@ -4298,6 +4349,8 @@ const translations = {
current: 'Current',
past: 'Past',
},
+ noCategory: 'No category',
+ noTag: 'No tag',
expenseType: 'Expense type',
recentSearches: 'Recent searches',
recentChats: 'Recent chats',
@@ -5097,6 +5150,27 @@ const translations = {
hasChildReportAwaitingAction: 'Has child report awaiting action',
hasMissingInvoiceBankAccount: 'Has missing invoice bank account',
},
+ reasonRBR: {
+ hasErrors: 'Has errors in report or report actions data',
+ hasViolations: 'Has violations',
+ hasTransactionThreadViolations: 'Has transaction thread violations',
+ },
+ indicatorStatus: {
+ theresAReportAwaitingAction: "There's a report awaiting action",
+ theresAReportWithErrors: "There's a report with errors",
+ theresAWorkspaceWithCustomUnitsErrors: "There's a workspace with custom units errors",
+ theresAProblemWithAWorkspaceMember: "There's a problem with a workspace member",
+ theresAProblemWithAContactMethod: "There's a problem with a contact method",
+ aContactMethodRequiresVerification: 'A contact method requires verification',
+ theresAProblemWithAPaymentMethod: "There's a problem with a payment method",
+ theresAProblemWithAWorkspace: "There's a problem with a workspace",
+ theresAProblemWithYourReimbursementAccount: "There's a problem with your reimbursement account",
+ theresABillingProblemWithYourSubscription: "There's a billing problem with your subscription",
+ yourSubscriptionHasBeenSuccessfullyRenewed: 'Your subscription has been successfully renewed',
+ theresWasAProblemDuringAWorkspaceConnectionSync: 'There was a problem during a workspace connection sync',
+ theresAProblemWithYourWallet: "There's a problem with your wallet",
+ theresAProblemWithYourWalletTerms: "There's a problem with your wallet terms",
+ },
},
emptySearchView: {
takeATour: 'Take a tour',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 2c8f0b9de722..b7f66ef2bec0 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -37,6 +37,7 @@ import type {
ChangeTypeParams,
CharacterLengthLimitParams,
CharacterLimitParams,
+ CompanyCardBankName,
CompanyCardFeedNameParams,
ConfirmThatParams,
ConnectionNameParams,
@@ -930,10 +931,12 @@ const translations = {
noReimbursableExpenses: 'El importe de este informe no es válido',
pendingConversionMessage: 'El total se actualizará cuando estés online',
changedTheExpense: 'cambió el gasto',
- setTheRequest: ({valueName, newValueToDisplay}: SetTheRequestParams) => `${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} a ${newValueToDisplay}`,
+ setTheRequest: ({valueName, newValueToDisplay}: SetTheRequestParams) =>
+ `${valueName === 'comerciante' || valueName === 'importe' || valueName === 'gasto' ? 'el' : 'la'} ${valueName} a ${newValueToDisplay}`,
setTheDistanceMerchant: ({translatedChangedField, newMerchant, newAmountToDisplay}: SetTheDistanceMerchantParams) =>
`estableció la ${translatedChangedField} a ${newMerchant}, lo que estableció el importe a ${newAmountToDisplay}`,
- removedTheRequest: ({valueName, oldValueToDisplay}: RemovedTheRequestParams) => `${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} (previamente ${oldValueToDisplay})`,
+ removedTheRequest: ({valueName, oldValueToDisplay}: RemovedTheRequestParams) =>
+ `${valueName === 'comerciante' || valueName === 'importe' || valueName === 'gasto' ? 'el' : 'la'} ${valueName} (previamente ${oldValueToDisplay})`,
updatedTheRequest: ({valueName, newValueToDisplay, oldValueToDisplay}: UpdatedTheRequestParams) =>
`${valueName === 'comerciante' || valueName === 'importe' || valueName === 'gasto' ? 'el' : 'la'} ${valueName} a ${newValueToDisplay} (previamente ${oldValueToDisplay})`,
updatedTheDistanceMerchant: ({translatedChangedField, newMerchant, oldMerchant, newAmountToDisplay, oldAmountToDisplay}: UpdatedTheDistanceMerchantParams) =>
@@ -950,6 +953,7 @@ const translations = {
invalidSplit: 'La suma de las partes debe ser igual al importe total.',
invalidSplitParticipants: 'Introduce un importe superior a cero para al menos dos participantes.',
invalidSplitYourself: 'Por favor, introduce una cantidad diferente de cero para tu parte.',
+ noParticipantSelected: 'Por favor, selecciona un participante.',
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.',
@@ -1543,7 +1547,6 @@ const translations = {
'Has introducido incorrectamente los 4 últimos dígitos de tu tarjeta Expensify demasiadas veces. Si estás seguro de que los números son correctos, ponte en contacto con Conserjería para solucionarlo. De lo contrario, inténtalo de nuevo más tarde.',
},
},
- // TODO: add translation
getPhysicalCard: {
header: 'Obtener tarjeta física',
nameMessage: 'Introduce tu nombre y apellido como aparecerá en tu tarjeta.',
@@ -2180,6 +2183,7 @@ const translations = {
companyAddress: 'Dirección de la empresa',
listOfRestrictedBusinesses: 'lista de negocios restringidos',
confirmCompanyIsNot: 'Confirmo que esta empresa no está en la',
+ businessInfoTitle: 'Información del negocio',
},
beneficialOwnerInfoStep: {
doYouOwn25percent: '¿Posees el 25% o más de',
@@ -2258,6 +2262,21 @@ const translations = {
enable2FAText: 'Tu seguridad es importante para nosotros. Por favor, configura ahora la autenticación de dos factores para añadir una capa adicional de protección a tu cuenta.',
secureYourAccount: 'Asegura tu cuenta',
},
+ countryStep: {
+ confirmBusinessBank: 'Confirmar moneda y país de la cuenta bancaria comercial',
+ confirmCurrency: 'Confirmar moneda y país',
+ },
+ signerInfoStep: {
+ signerInfo: 'Información del firmante',
+ },
+ agreementsStep: {
+ agreements: 'Acuerdos',
+ pleaseConfirm: 'Por favor confirme los acuerdos a continuación',
+ accept: 'Aceptar y añadir cuenta bancaria',
+ },
+ finishStep: {
+ connect: 'Conectar cuenta bancaria',
+ },
reimbursementAccountLoadingAnimation: {
oneMoment: 'Un momento',
explanationLine: 'Estamos verificando tu información y podrás continuar con los siguientes pasos en unos momentos.',
@@ -2444,6 +2463,8 @@ const translations = {
[CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY]: 'Asiento contable',
[CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK]: 'Cheque',
+ [`${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CHECK}Description`]:
+ 'Crearemos un cheque desglosado para cada informe de Expensify y lo enviaremos desde la cuenta bancaria a continuación.',
[`${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}Description`]:
"Automáticamente relacionaremos el nombre del comerciante de la transacción con tarjeta de crédito con cualquier proveedor correspondiente en QuickBooks. Si no existen proveedores, crearemos un proveedor asociado 'Credit Card Misc.'.",
[`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Description`]:
@@ -2452,6 +2473,7 @@ const translations = {
[`${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}AccountDescription`]: 'Elige dónde exportar las transacciones con tarjeta de crédito.',
[`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}AccountDescription`]:
'Selecciona el proveedor que se aplicará a todas las transacciones con tarjeta de crédito.',
+ [`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK}AccountDescription`]: 'Elige desde dónde enviar los cheques.',
[`${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Error`]:
'Las facturas de proveedores no están disponibles cuando las ubicaciones están habilitadas. Por favor, selecciona otra opción de exportación.',
@@ -2476,6 +2498,8 @@ const translations = {
classes: 'Clases',
items: 'Artículos',
customers: 'Clientes/proyectos',
+ exportCompanyCardsDescription: 'Establece cómo se exportan las compras con tarjeta de empresa a QuickBooks Desktop.',
+ defaultVendorDescription: 'Establece un proveedor predeterminado que se aplicará a todas las transacciones con tarjeta de crédito al momento de exportarlas.',
accountsDescription: 'Tu plan de cuentas de QuickBooks Desktop se importará a Expensify como categorías.',
accountsSwitchTitle: 'Elige importar cuentas nuevas como categorías activadas o desactivadas.',
accountsSwitchDescription: 'Las categorías activas estarán disponibles para ser escogidas cuando se crea un gasto.',
@@ -2486,8 +2510,9 @@ const translations = {
advancedConfig: {
autoSyncDescription: 'Expensify se sincronizará automáticamente con QuickBooks Desktop todos los días.',
createEntities: 'Crear entidades automáticamente',
- createEntitiesDescription: 'Expensify creará automáticamente proveedores en QuickBooks Desktop si aún no existen, y creará automáticamente clientes al exportar facturas.',
+ createEntitiesDescription: 'Expensify creará automáticamente proveedores en QuickBooks Desktop si aún no existen.',
},
+ itemsDescription: 'Elige cómo gestionar los elementos de QuickBooks Desktop en Expensify.',
},
qbo: {
importDescription: 'Elige que configuraciónes de codificación son importadas desde QuickBooks Online a Expensify.',
@@ -3350,6 +3375,9 @@ const translations = {
emptyAddedFeedDescription: 'Comienza asignando tu primera tarjeta a un miembro.',
pendingFeedTitle: `Estamos revisando tu solicitud...`,
pendingFeedDescription: `Actualmente estamos revisando los detalles de tu feed. Una vez hecho esto, nos pondremos en contacto contigo a través de`,
+ pendingBankTitle: 'Comprueba la ventana de tu navegador',
+ pendingBankDescription: ({bankName}: CompanyCardBankName) => `Conéctese a ${bankName} a través de la ventana del navegador que acaba de abrir. Si no se abrió, `,
+ pendingBankLink: 'por favor haga clic aquí.',
giveItNameInstruction: 'Nombra la tarjeta para distingirla de las demás.',
updating: 'Actualizando...',
noAccountsFound: 'No se han encontrado cuentas',
@@ -3589,6 +3617,7 @@ const translations = {
},
errorODIntegration: 'Hay un error con una conexión que se ha configurado en Expensify Classic. ',
goToODToFix: 'Ve a Expensify Classic para solucionar este problema.',
+ goToODToSettings: 'Ve a Expensify Classic para gestionar tus configuraciones.',
setup: 'Configurar',
lastSync: ({relativeDate}: LastSyncAccountingParams) => `Recién sincronizado ${relativeDate}`,
import: 'Importar',
@@ -3597,6 +3626,7 @@ const translations = {
other: 'Otras integraciones',
syncNow: 'Sincronizar ahora',
disconnect: 'Desconectar',
+ reinstall: 'Reinstalar el conector',
disconnectTitle: ({connectionName}: OptionalParam = {}) => {
const integrationName =
connectionName && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName] : 'integración';
@@ -3644,14 +3674,18 @@ const translations = {
syncStageName: ({stage}: SyncStageNameConnectionsParams) => {
switch (stage) {
case 'quickbooksOnlineImportCustomers':
+ case 'quickbooksDesktopImportCustomers':
return 'Importando clientes';
case 'quickbooksOnlineImportEmployees':
case 'netSuiteSyncImportEmployees':
case 'intacctImportEmployees':
+ case 'quickbooksDesktopImportEmployees':
return 'Importando empleados';
case 'quickbooksOnlineImportAccounts':
+ case 'quickbooksDesktopImportAccounts':
return 'Importando cuentas';
case 'quickbooksOnlineImportClasses':
+ case 'quickbooksDesktopImportClasses':
return 'Importando clases';
case 'quickbooksOnlineImportLocations':
return 'Importando localidades';
@@ -3670,6 +3704,19 @@ const translations = {
return 'Importando datos desde Xero';
case 'startingImportQBO':
return 'Importando datos desde QuickBooks Online';
+ case 'startingImportQBD':
+ case 'quickbooksDesktopImportMore':
+ return 'Importando datos desde QuickBooks Desktop';
+ case 'quickbooksDesktopImportTitle':
+ return 'Importando título';
+ case 'quickbooksDesktopImportApproveCertificate':
+ return 'Importando certificado de aprobación';
+ case 'quickbooksDesktopImportDimensions':
+ return 'Importando dimensiones';
+ case 'quickbooksDesktopImportSavePolicy':
+ return 'Importando política de guardado';
+ case 'quickbooksDesktopWebConnectorReminder':
+ return 'Aún sincronizando datos con QuickBooks... Por favor, asegúrate de que el Conector Web esté en funcionamiento';
case 'quickbooksOnlineSyncTitle':
return 'Sincronizando datos desde QuickBooks Online';
case 'quickbooksOnlineSyncLoadData':
@@ -3737,6 +3784,7 @@ const translations = {
case 'netSuiteSyncImportSubsidiaries':
return 'Importando subsidiarias';
case 'netSuiteSyncImportVendors':
+ case 'quickbooksDesktopImportVendors':
return 'Importando proveedores';
case 'netSuiteSyncExpensifyReimbursedReports':
return 'Marcando facturas y recibos de NetSuite como pagados';
@@ -4009,6 +4057,11 @@ const translations = {
description: `Disfruta de una sincronización automatizada y reduce las entradas manuales con la integración Expensify + Sage Intacct. Obtén información financiera en profundidad y en tiempo real con dimensiones definidas por el usuario, así como codificación de gastos por departamento, clase, ubicación, cliente y proyecto (trabajo).`,
onlyAvailableOnPlan: 'Nuestra integración Sage Intacct sólo está disponible en el plan Control, a partir de ',
},
+ [CONST.POLICY.CONNECTIONS.NAME.QBD]: {
+ title: 'QuickBooks Desktop',
+ description: `Disfruta de la sincronización automática y reduce las entradas manuales con la integración de Expensify + QuickBooks Desktop. Obtén la máxima eficiencia con una conexión bidireccional en tiempo real y la codificación de gastos por clase, artículo, cliente y proyecto.`,
+ onlyAvailableOnPlan: 'Nuestra integración con QuickBooks Desktop solo está disponible en el plan Control, que comienza en ',
+ },
[CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: {
title: 'Aprobaciones anticipadas',
description: `Si quieres añadir más niveles de aprobación, o simplemente asegurarte de que los gastos más importantes reciben otro vistazo, no hay problema. Las aprobaciones avanzadas ayudan a realizar las comprobaciones adecuadas a cada nivel para mantener los gastos de tu equipo bajo control.`,
@@ -4344,6 +4397,8 @@ const translations = {
current: 'Actual',
past: 'Anterior',
},
+ noCategory: 'Sin categoría',
+ noTag: 'Sin etiqueta',
expenseType: 'Tipo de gasto',
recentSearches: 'Búsquedas recientes',
recentChats: 'Chats recientes',
@@ -5611,6 +5666,27 @@ const translations = {
hasChildReportAwaitingAction: 'Informe secundario pendiente de acción',
hasMissingInvoiceBankAccount: 'Falta la cuenta bancaria de la factura',
},
+ reasonRBR: {
+ hasErrors: 'Tiene errores en los datos o las acciones del informe',
+ hasViolations: 'Tiene violaciones',
+ hasTransactionThreadViolations: 'Tiene violaciones de hilo de transacciones',
+ },
+ indicatorStatus: {
+ theresAReportAwaitingAction: 'Hay un informe pendiente de acción',
+ theresAReportWithErrors: 'Hay un informe con errores',
+ theresAWorkspaceWithCustomUnitsErrors: 'Hay un espacio de trabajo con errores en las unidades personalizadas',
+ theresAProblemWithAWorkspaceMember: 'Hay un problema con un miembro del espacio de trabajo',
+ theresAProblemWithAContactMethod: 'Hay un problema con un método de contacto',
+ aContactMethodRequiresVerification: 'Un método de contacto requiere verificación',
+ theresAProblemWithAPaymentMethod: 'Hay un problema con un método de pago',
+ theresAProblemWithAWorkspace: 'Hay un problema con un espacio de trabajo',
+ theresAProblemWithYourReimbursementAccount: 'Hay un problema con tu cuenta de reembolso',
+ theresABillingProblemWithYourSubscription: 'Hay un problema de facturación con tu suscripción',
+ yourSubscriptionHasBeenSuccessfullyRenewed: 'Tu suscripción se ha renovado con éxito',
+ theresWasAProblemDuringAWorkspaceConnectionSync: 'Hubo un problema durante la sincronización de la conexión del espacio de trabajo',
+ theresAProblemWithYourWallet: 'Hay un problema con tu billetera',
+ theresAProblemWithYourWalletTerms: 'Hay un problema con los términos de tu billetera',
+ },
},
emptySearchView: {
takeATour: 'Haz un tour',
diff --git a/src/languages/params.ts b/src/languages/params.ts
index 02dafa76a46d..9341b914d1d0 100644
--- a/src/languages/params.ts
+++ b/src/languages/params.ts
@@ -538,6 +538,10 @@ type ImportedTypesParams = {
importedTypes: string[];
};
+type CompanyCardBankName = {
+ bankName: string;
+};
+
export type {
AuthenticationErrorParams,
ImportMembersSuccessfullDescriptionParams,
@@ -729,6 +733,7 @@ export type {
DateParams,
FiltersAmountBetweenParams,
StatementPageTitleParams,
+ CompanyCardBankName,
DisconnectPromptParams,
DisconnectTitleParams,
CharacterLengthLimitParams,
diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts
index 0d1bab053182..ad0650374011 100644
--- a/src/libs/API/index.ts
+++ b/src/libs/API/index.ts
@@ -208,23 +208,32 @@ function paginate;
-function paginate>(
+function paginate>(
type: TRequestType,
command: TCommand,
apiCommandParameters: ApiRequestCommandParameters[TCommand],
onyxData: OnyxData,
config: PaginationConfig,
): void;
+function paginate>(
+ type: TRequestType,
+ command: TCommand,
+ apiCommandParameters: ApiRequestCommandParameters[TCommand],
+ onyxData: OnyxData,
+ config: PaginationConfig,
+ conflictResolver?: RequestConflictResolver,
+): void;
function paginate>(
type: TRequestType,
command: TCommand,
apiCommandParameters: ApiRequestCommandParameters[TCommand],
onyxData: OnyxData,
config: PaginationConfig,
+ conflictResolver: RequestConflictResolver = {},
): Promise | void {
Log.info('[API] Called API.paginate', false, {command, ...apiCommandParameters});
const request: PaginatedRequest = {
- ...prepareRequest(command, type, apiCommandParameters, onyxData),
+ ...prepareRequest(command, type, apiCommandParameters, onyxData, conflictResolver),
...config,
...{
isPaginated: true,
diff --git a/src/libs/API/parameters/RequestMoneyParams.ts b/src/libs/API/parameters/RequestMoneyParams.ts
index e3e600a4e367..27e1032d82a9 100644
--- a/src/libs/API/parameters/RequestMoneyParams.ts
+++ b/src/libs/API/parameters/RequestMoneyParams.ts
@@ -28,6 +28,7 @@ type RequestMoneyParams = {
transactionThreadReportID: string;
createdReportActionIDForThread: string;
reimbursible?: boolean;
+ policyID?: string;
};
export default RequestMoneyParams;
diff --git a/src/libs/API/parameters/SyncPolicyToQuickbooksDesktopParams.ts b/src/libs/API/parameters/SyncPolicyToQuickbooksDesktopParams.ts
new file mode 100644
index 000000000000..db6d9cd43437
--- /dev/null
+++ b/src/libs/API/parameters/SyncPolicyToQuickbooksDesktopParams.ts
@@ -0,0 +1,7 @@
+type SyncPolicyToQuickbooksDesktopParams = {
+ policyID: string;
+ idempotencyKey: string;
+ forceDataRefresh?: boolean;
+};
+
+export default SyncPolicyToQuickbooksDesktopParams;
diff --git a/src/libs/API/parameters/UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams.ts b/src/libs/API/parameters/UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams.ts
new file mode 100644
index 000000000000..6d43fe03670c
--- /dev/null
+++ b/src/libs/API/parameters/UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams.ts
@@ -0,0 +1,11 @@
+import type {QBDNonReimbursableExportAccountType} from '@src/types/onyx/Policy';
+
+type UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams = {
+ policyID: string;
+ nonReimbursableExpensesExportDestination: QBDNonReimbursableExportAccountType;
+ nonReimbursableExpensesAccount: string;
+ nonReimbursableBillDefaultVendor: string;
+ idempotencyKey: string;
+};
+
+export default UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 09bb4cea6a3a..32a1e01ff3da 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -19,6 +19,7 @@ export type {default as OpenPolicyInitialPageParams} from './OpenPolicyInitialPa
export type {default as SyncPolicyToQuickbooksOnlineParams} from './SyncPolicyToQuickbooksOnlineParams';
export type {default as SyncPolicyToXeroParams} from './SyncPolicyToXeroParams';
export type {default as SyncPolicyToNetSuiteParams} from './SyncPolicyToNetSuiteParams';
+export type {default as SyncPolicyToQuickbooksDesktopParams} from './SyncPolicyToQuickbooksDesktopParams';
export type {default as DeleteContactMethodParams} from './DeleteContactMethodParams';
export type {default as DeletePaymentBankAccountParams} from './DeletePaymentBankAccountParams';
export type {default as DeletePaymentCardParams} from './DeletePaymentCardParams';
@@ -339,3 +340,4 @@ export type {default as SetMissingPersonalDetailsAndShipExpensifyCardParams} fro
export type {default as SetInvoicingTransferBankAccountParams} from './SetInvoicingTransferBankAccountParams';
export type {default as ConnectPolicyToQuickBooksDesktopParams} from './ConnectPolicyToQuickBooksDesktopParams';
export type {default as UpdateQuickbooksDesktopExpensesExportDestinationTypeParams} from './UpdateQuickbooksDesktopExpensesExportDestinationTypeParams';
+export type {default as UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams} from './UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 9e9807c32640..929e709559b7 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -260,7 +260,10 @@ const WRITE_COMMANDS = {
UPDATE_QUICKBOOKS_ONLINE_EXPORT: 'UpdateQuickbooksOnlineExport',
UPDATE_QUICKBOOKS_DESKTOP_EXPORT_DATE: 'UpdateQuickbooksDesktopExportDate',
UPDATE_MANY_POLICY_CONNECTION_CONFIGS: 'UpdateManyPolicyConnectionConfigurations',
+ UPDATE_QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'UpdateQuickbooksDesktopNonReimbursableExpensesExportDestination',
+ UPDATE_QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPENSES_ACCOUNT: 'UpdateQuickbooksDesktopNonReimbursableExpensesAccount',
UPDATE_QUICKBOOKS_DESKTOP_AUTO_CREATE_VENDOR: 'UpdateQuickbooksDesktopAutoCreateVendor',
+ UPDATE_QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_BILL_DEFAULT_VENDOR: 'UpdateQuickbooksDesktopNonReimbursableBillDefaultVendor',
UPDATE_QUICKBOOKS_DESKTOP_AUTO_SYNC: 'UpdateQuickbooksDesktopAutoSync',
UPDATE_QUICKBOOKS_DESKTOP_EXPORT: 'UpdateQuickbooksDesktopExport',
UPDATE_QUICKBOOKS_DESKTOP_REIMBURSABLE_EXPENSES_ACCOUNT: 'UpdateQuickbooksDesktopReimbursableExpensesAccount',
@@ -269,6 +272,7 @@ const WRITE_COMMANDS = {
UPDATE_QUICKBOOKS_DESKTOP_ENABLE_NEW_CATEGORIES: 'UpdateQuickbooksDesktopEnableNewCategories',
UPDATE_QUICKBOOKS_DESKTOP_SYNC_CLASSES: 'UpdateQuickbooksDesktopSyncClasses',
UPDATE_QUICKBOOKS_DESKTOP_SYNC_CUSTOMERS: 'UpdateQuickbooksDesktopSyncCustomers',
+ UPDATE_QUICKBOOKS_DESKTOP_SYNC_ITEMS: 'UpdateQuickbooksDesktopSyncItems',
REMOVE_POLICY_CONNECTION: 'RemovePolicyConnection',
SET_POLICY_TAXES_ENABLED: 'SetPolicyTaxesEnabled',
DELETE_POLICY_TAXES: 'DeletePolicyTaxes',
@@ -707,12 +711,16 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_EXPORT_DATE]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_MARK_CHECKS_TO_BE_PRINTED]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_AUTO_CREATE_VENDOR]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
+ [WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPENSES_ACCOUNT]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
+ [WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: Parameters.UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams;
+ [WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_AUTO_SYNC]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_REIMBURSABLE_EXPENSES_ACCOUNT]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: Parameters.UpdateQuickbooksDesktopExpensesExportDestinationTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_ENABLE_NEW_CATEGORIES]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_SYNC_CLASSES]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_SYNC_CUSTOMERS]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
+ [WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_SYNC_ITEMS]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_EXPORT]: Parameters.UpdateQuickbooksDesktopGenericTypeParams;
[WRITE_COMMANDS.UPDATE_POLICY_CONNECTION_CONFIG]: Parameters.UpdatePolicyConnectionConfigParams;
[WRITE_COMMANDS.UPDATE_MANY_POLICY_CONNECTION_CONFIGS]: Parameters.UpdateManyPolicyConnectionConfigurationsParams;
@@ -869,6 +877,7 @@ const READ_COMMANDS = {
SYNC_POLICY_TO_XERO: 'SyncPolicyToXero',
SYNC_POLICY_TO_NETSUITE: 'SyncPolicyToNetSuite',
SYNC_POLICY_TO_SAGE_INTACCT: 'SyncPolicyToSageIntacct',
+ SYNC_POLICY_TO_QUICKBOOKS_DESKTOP: 'SyncPolicyToQuickbooksDesktop',
OPEN_REIMBURSEMENT_ACCOUNT_PAGE: 'OpenReimbursementAccountPage',
OPEN_WORKSPACE_VIEW: 'OpenWorkspaceView',
GET_MAPBOX_ACCESS_TOKEN: 'GetMapboxAccessToken',
@@ -928,6 +937,7 @@ type ReadCommandParameters = {
[READ_COMMANDS.SYNC_POLICY_TO_XERO]: Parameters.SyncPolicyToXeroParams;
[READ_COMMANDS.SYNC_POLICY_TO_NETSUITE]: Parameters.SyncPolicyToNetSuiteParams;
[READ_COMMANDS.SYNC_POLICY_TO_SAGE_INTACCT]: Parameters.SyncPolicyToNetSuiteParams;
+ [READ_COMMANDS.SYNC_POLICY_TO_QUICKBOOKS_DESKTOP]: Parameters.SyncPolicyToQuickbooksDesktopParams;
[READ_COMMANDS.OPEN_REIMBURSEMENT_ACCOUNT_PAGE]: Parameters.OpenReimbursementAccountPageParams;
[READ_COMMANDS.OPEN_WORKSPACE_VIEW]: Parameters.OpenWorkspaceViewParams;
[READ_COMMANDS.GET_MAPBOX_ACCESS_TOKEN]: null;
diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts
index 7c81c5c224c6..9fda616557a8 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -1,6 +1,6 @@
import groupBy from 'lodash/groupBy';
import Onyx from 'react-native-onyx';
-import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import ExpensifyCardImage from '@assets/images/expensify-card.svg';
import * as Illustrations from '@src/components/Icon/Illustrations';
@@ -9,7 +9,6 @@ import type {TranslationPaths} from '@src/languages/types';
import type {OnyxValues} from '@src/ONYXKEYS';
import ONYXKEYS from '@src/ONYXKEYS';
import type {BankAccountList, Card, CardFeeds, CardList, CompanyCardFeed, PersonalDetailsList, WorkspaceCardsList} from '@src/types/onyx';
-import type Policy from '@src/types/onyx/Policy';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type IconAsset from '@src/types/utils/IconAsset';
import localeCompare from './LocaleCompare';
@@ -193,13 +192,35 @@ function getCompanyCardNumber(cardList: Record, lastFourPAN?: st
return Object.keys(cardList).find((card) => card.endsWith(lastFourPAN)) ?? '';
}
-function getCardFeedIcon(cardFeed: string): IconAsset {
- if (cardFeed.startsWith(CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD)) {
- return Illustrations.MasterCardCompanyCards;
+function getCardFeedIcon(cardFeed: CompanyCardFeed | typeof CONST.EXPENSIFY_CARD.BANK): IconAsset {
+ const feedIcons = {
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.VISA]: Illustrations.VisaCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX]: Illustrations.AmexCardCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD]: Illustrations.MasterCardCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX_DIRECT]: Illustrations.AmexCardCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.BANK_OF_AMERICA]: Illustrations.BankOfAmericaCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.CAPITAL_ONE]: Illustrations.CapitalOneCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE]: Illustrations.ChaseCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.CITIBANK]: Illustrations.CitibankCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.WELLS_FARGO]: Illustrations.WellsFargoCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.BREX]: Illustrations.BrexCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.STRIPE]: Illustrations.StripeCompanyCardDetail,
+ [CONST.EXPENSIFY_CARD.BANK]: ExpensifyCardImage,
+ };
+
+ if (cardFeed.startsWith(CONST.EXPENSIFY_CARD.BANK)) {
+ return ExpensifyCardImage;
+ }
+
+ if (feedIcons[cardFeed]) {
+ return feedIcons[cardFeed];
}
- if (cardFeed.startsWith(CONST.COMPANY_CARD.FEED_BANK_NAME.VISA)) {
- return Illustrations.VisaCompanyCards;
+ // In existing OldDot setups other variations of feeds could exist, ex: vcf2, vcf3, cdfbmo
+ const feedKey = (Object.keys(feedIcons) as CompanyCardFeed[]).find((feed) => cardFeed.startsWith(feed));
+
+ if (feedKey) {
+ return feedIcons[feedKey];
}
return Illustrations.AmexCompanyCards;
@@ -211,46 +232,18 @@ function getCardFeedName(feedType: CompanyCardFeed): string {
[CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD]: 'Mastercard',
[CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX]: 'American Express',
[CONST.COMPANY_CARD.FEED_BANK_NAME.STRIPE]: 'Stripe',
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX_DIRECT]: 'American Express',
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.BANK_OF_AMERICA]: 'Bank of America',
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.CAPITAL_ONE]: 'Capital One',
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE]: 'Chase',
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.CITIBANK]: 'Citibank',
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.WELLS_FARGO]: 'Wells Fargo',
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.BREX]: 'Brex',
};
return feedNamesMapping[feedType];
}
-function getCardDetailsImage(cardFeed: string): IconAsset {
- if (cardFeed.startsWith(CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD)) {
- return Illustrations.MasterCardCompanyCardDetail;
- }
-
- if (cardFeed.startsWith(CONST.COMPANY_CARD.FEED_BANK_NAME.VISA)) {
- return Illustrations.VisaCompanyCardDetail;
- }
-
- if (cardFeed.startsWith(CONST.EXPENSIFY_CARD.BANK)) {
- return ExpensifyCardImage;
- }
-
- return Illustrations.AmexCardCompanyCardDetail;
-}
-
-function getMemberCards(policy: OnyxEntry, allCardsList: OnyxCollection, accountID?: number) {
- const workspaceId = policy?.workspaceAccountID ? policy.workspaceAccountID.toString() : '';
- const cards: WorkspaceCardsList = {};
- Object.keys(allCardsList ?? {})
- .filter((key) => key !== `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceId}_${CONST.EXPENSIFY_CARD.BANK}` && key.includes(workspaceId))
- .forEach((key) => {
- const feedCards = allCardsList?.[key];
- if (feedCards && Object.keys(feedCards).length > 0) {
- Object.keys(feedCards).forEach((feedCardKey) => {
- if (feedCards?.[feedCardKey].accountID !== accountID) {
- return;
- }
- cards[feedCardKey] = feedCards[feedCardKey];
- });
- }
- });
- return cards;
-}
-
const getBankCardDetailsImage = (bank: ValueOf): IconAsset => {
const iconMap: Record, IconAsset> = {
[CONST.COMPANY_CARDS.BANKS.AMEX]: Illustrations.AmexCardCompanyCardDetail,
@@ -322,8 +315,6 @@ export {
getCompanyCardNumber,
getCardFeedIcon,
getCardFeedName,
- getCardDetailsImage,
- getMemberCards,
getBankCardDetailsImage,
getSelectedFeed,
getCorrectStepForSelectedBank,
diff --git a/src/libs/ConnectionUtils.ts b/src/libs/ConnectionUtils.ts
index b3a5e38ffb8a..9708ef3451c7 100644
--- a/src/libs/ConnectionUtils.ts
+++ b/src/libs/ConnectionUtils.ts
@@ -1,5 +1,5 @@
import CONST from '@src/CONST';
-import type {QBONonReimbursableExportAccountType} from '@src/types/onyx/Policy';
+import type {QBDNonReimbursableExportAccountType, QBONonReimbursableExportAccountType} from '@src/types/onyx/Policy';
import {translateLocal} from './Localize';
function getQBONonReimbursableExportAccountType(exportDestination: QBONonReimbursableExportAccountType | undefined): string {
@@ -15,5 +15,17 @@ function getQBONonReimbursableExportAccountType(exportDestination: QBONonReimbur
}
}
-// eslint-disable-next-line import/prefer-default-export
-export {getQBONonReimbursableExportAccountType};
+function getQBDNonReimbursableExportAccountType(exportDestination: QBDNonReimbursableExportAccountType | undefined): string {
+ switch (exportDestination) {
+ case CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CHECK:
+ return translateLocal('workspace.qbd.bankAccount');
+ case CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD:
+ return translateLocal('workspace.qbd.creditCardAccount');
+ case CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL:
+ return translateLocal('workspace.qbd.accountsPayable');
+ default:
+ return translateLocal('workspace.qbd.account');
+ }
+}
+
+export {getQBONonReimbursableExportAccountType, getQBDNonReimbursableExportAccountType};
diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts
index e7ad63467781..d63758761c3c 100644
--- a/src/libs/DebugUtils.ts
+++ b/src/libs/DebugUtils.ts
@@ -7,6 +7,7 @@ import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Beta, Policy, Report, ReportAction, ReportActions, TransactionViolation} from '@src/types/onyx';
import * as ReportUtils from './ReportUtils';
+import SidebarUtils from './SidebarUtils';
class NumberError extends SyntaxError {
constructor() {
@@ -645,13 +646,22 @@ function getReasonAndReportActionForGBRInLHNRow(report: OnyxEntry): GBRR
return null;
}
+type RBRReasonAndReportAction = {
+ reason: TranslationPaths;
+ reportAction: OnyxEntry;
+};
+
/**
* Gets the report action that is causing the RBR to show up in LHN
*/
-function getRBRReportAction(report: OnyxEntry, reportActions: OnyxEntry): OnyxEntry {
- const {reportAction} = ReportUtils.getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions);
+function getReasonAndReportActionForRBRInLHNRow(report: Report, reportActions: OnyxEntry, hasViolations: boolean): RBRReasonAndReportAction | null {
+ const {reason, reportAction} = SidebarUtils.getReasonAndReportActionThatHasRedBrickRoad(report, reportActions, hasViolations, transactionViolations) ?? {};
- return reportAction;
+ if (reason) {
+ return {reason: `debug.reasonRBR.${reason}`, reportAction};
+ }
+
+ return null;
}
const DebugUtils = {
@@ -673,7 +683,7 @@ const DebugUtils = {
validateReportActionJSON,
getReasonForShowingRowInLHN,
getReasonAndReportActionForGBRInLHNRow,
- getRBRReportAction,
+ getReasonAndReportActionForRBRInLHNRow,
REPORT_ACTION_REQUIRED_PROPERTIES,
REPORT_REQUIRED_PROPERTIES,
};
diff --git a/src/libs/E2E/tests/openSearchRouterTest.e2e.ts b/src/libs/E2E/tests/openSearchRouterTest.e2e.ts
index 840af5acc2c9..48278aee536a 100644
--- a/src/libs/E2E/tests/openSearchRouterTest.e2e.ts
+++ b/src/libs/E2E/tests/openSearchRouterTest.e2e.ts
@@ -1,4 +1,5 @@
import Config from 'react-native-config';
+import * as E2EGenericPressableWrapper from '@components/Pressable/GenericPressable/index.e2e';
import E2ELogin from '@libs/E2E/actions/e2eLogin';
import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded';
import E2EClient from '@libs/E2E/client';
@@ -31,6 +32,29 @@ const test = () => {
Performance.subscribeToMeasurements((entry) => {
console.debug(`[E2E] Entry: ${JSON.stringify(entry)}`);
+ if (entry.name === CONST.TIMING.SIDEBAR_LOADED) {
+ const props = E2EGenericPressableWrapper.getPressableProps('searchButton');
+ if (!props) {
+ console.debug('[E2E] Search button not found, failing test!');
+ E2EClient.submitTestResults({
+ branch: Config.E2E_BRANCH,
+ error: 'Search button not found',
+ name: 'Open Search Router TTI',
+ }).then(() => E2EClient.submitTestDone());
+ return;
+ }
+ if (!props.onPress) {
+ console.debug('[E2E] Search button found but onPress prop was not present, failing test!');
+ E2EClient.submitTestResults({
+ branch: Config.E2E_BRANCH,
+ error: 'Search button found but onPress prop was not present',
+ name: 'Open Search Router TTI',
+ }).then(() => E2EClient.submitTestDone());
+ return;
+ }
+ // Open the search router
+ props.onPress();
+ }
if (entry.name === CONST.TIMING.SEARCH_ROUTER_RENDER) {
E2EClient.submitTestResults({
diff --git a/src/libs/FastSearch.ts b/src/libs/FastSearch.ts
deleted file mode 100644
index 59d28dedd449..000000000000
--- a/src/libs/FastSearch.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-/* eslint-disable rulesdir/prefer-at */
-import CONST from '@src/CONST';
-import Timing from './actions/Timing';
-import SuffixUkkonenTree from './SuffixUkkonenTree';
-
-type SearchableData = {
- /**
- * The data that should be searchable
- */
- data: T[];
- /**
- * A function that generates a string from a data entry. The string's value is used for searching.
- * If you have multiple fields that should be searchable, simply concat them to the string and return it.
- */
- toSearchableString: (data: T) => string;
-};
-
-// There are certain characters appear very often in our search data (email addresses), which we don't need to search for.
-const charSetToSkip = new Set(['@', '.', '#', '$', '%', '&', '*', '+', '-', '/', ':', ';', '<', '=', '>', '?', '_', '~', '!', ' ']);
-
-/**
- * Creates a new "FastSearch" instance. "FastSearch" uses a suffix tree to search for substrings in a list of strings.
- * You can provide multiple datasets. The search results will be returned for each dataset.
- *
- * Note: Creating a FastSearch instance with a lot of data is computationally expensive. You should create an instance once and reuse it.
- * Searches will be very fast though, even with a lot of data.
- */
-function createFastSearch(dataSets: Array>) {
- Timing.start(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES);
- const maxNumericListSize = 400_000;
- // The user might provide multiple data sets, but internally, the search values will be stored in this one list:
- let concatenatedNumericList = new Uint8Array(maxNumericListSize);
- // Here we store the index of the data item in the original data list, so we can map the found occurrences back to the original data:
- const occurrenceToIndex = new Uint32Array(maxNumericListSize * 4);
- // As we are working with ArrayBuffers, we need to keep track of the current offset:
- const offset = {value: 1};
- // We store the last offset for a dataSet, so we can map the found occurrences to the correct dataSet:
- const listOffsets: number[] = [];
-
- for (const {data, toSearchableString} of dataSets) {
- // Performance critical: the array parameters are passed by reference, so we don't have to create new arrays every time:
- dataToNumericRepresentation(concatenatedNumericList, occurrenceToIndex, offset, {data, toSearchableString});
- listOffsets.push(offset.value);
- }
- concatenatedNumericList[offset.value++] = SuffixUkkonenTree.END_CHAR_CODE;
- listOffsets[listOffsets.length - 1] = offset.value;
- Timing.end(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES);
-
- // The list might be larger than necessary, so we clamp it to the actual size:
- concatenatedNumericList = concatenatedNumericList.slice(0, offset.value);
-
- // Create & build the suffix tree:
- Timing.start(CONST.TIMING.SEARCH_MAKE_TREE);
- const tree = SuffixUkkonenTree.makeTree(concatenatedNumericList);
- Timing.end(CONST.TIMING.SEARCH_MAKE_TREE);
-
- Timing.start(CONST.TIMING.SEARCH_BUILD_TREE);
- tree.build();
- Timing.end(CONST.TIMING.SEARCH_BUILD_TREE);
-
- /**
- * Searches for the given input and returns results for each dataset.
- */
- function search(searchInput: string): T[][] {
- const cleanedSearchString = cleanString(searchInput);
- const {numeric} = SuffixUkkonenTree.stringToNumeric(cleanedSearchString, {
- charSetToSkip,
- // stringToNumeric might return a list that is larger than necessary, so we clamp it to the actual size
- // (otherwise the search could fail as we include in our search empty array values):
- clamp: true,
- });
- const result = tree.findSubstring(Array.from(numeric));
-
- const resultsByDataSet = Array.from({length: dataSets.length}, () => new Set());
- // eslint-disable-next-line @typescript-eslint/prefer-for-of
- for (let i = 0; i < result.length; i++) {
- const occurrenceIndex = result[i];
- const itemIndexInDataSet = occurrenceToIndex[occurrenceIndex];
- const dataSetIndex = listOffsets.findIndex((listOffset) => occurrenceIndex < listOffset);
-
- if (dataSetIndex === -1) {
- throw new Error(`[FastSearch] The occurrence index ${occurrenceIndex} is not in any dataset`);
- }
- const item = dataSets[dataSetIndex].data[itemIndexInDataSet];
- if (!item) {
- throw new Error(`[FastSearch] The item with index ${itemIndexInDataSet} in dataset ${dataSetIndex} is not defined`);
- }
- resultsByDataSet[dataSetIndex].add(item);
- }
-
- return resultsByDataSet.map((set) => Array.from(set));
- }
-
- return {
- search,
- };
-}
-
-/**
- * The suffix tree can only store string like values, and internally stores those as numbers.
- * This function converts the user data (which are most likely objects) to a numeric representation.
- * Additionally a list of the original data and their index position in the numeric list is created, which is used to map the found occurrences back to the original data.
- */
-function dataToNumericRepresentation(concatenatedNumericList: Uint8Array, occurrenceToIndex: Uint32Array, offset: {value: number}, {data, toSearchableString}: SearchableData): void {
- data.forEach((option, index) => {
- const searchStringForTree = toSearchableString(option);
- const cleanedSearchStringForTree = cleanString(searchStringForTree);
-
- if (cleanedSearchStringForTree.length === 0) {
- return;
- }
-
- SuffixUkkonenTree.stringToNumeric(cleanedSearchStringForTree, {
- charSetToSkip,
- out: {
- outArray: concatenatedNumericList,
- offset,
- outOccurrenceToIndex: occurrenceToIndex,
- index,
- },
- });
- // eslint-disable-next-line no-param-reassign
- occurrenceToIndex[offset.value] = index;
- // eslint-disable-next-line no-param-reassign
- concatenatedNumericList[offset.value++] = SuffixUkkonenTree.DELIMITER_CHAR_CODE;
- });
-}
-
-/**
- * Everything in the tree is treated as lowercase.
- */
-function cleanString(input: string) {
- return input.toLowerCase();
-}
-
-const FastSearch = {
- createFastSearch,
-};
-
-export default FastSearch;
diff --git a/src/libs/Firebase/types.ts b/src/libs/Firebase/types.ts
index cf17dd27e01c..4c970375c226 100644
--- a/src/libs/Firebase/types.ts
+++ b/src/libs/Firebase/types.ts
@@ -17,6 +17,8 @@ type FirebaseAttributes = {
transactionViolationsLength: string;
policiesLength: string;
transactionsLength: string;
+ policyType: string;
+ policyRole: string;
};
export type {StartTrace, StopTrace, TraceMap, Log, FirebaseAttributes};
diff --git a/src/libs/Firebase/utils.ts b/src/libs/Firebase/utils.ts
index 23e7c36ec36a..0235953bfcd3 100644
--- a/src/libs/Firebase/utils.ts
+++ b/src/libs/Firebase/utils.ts
@@ -1,6 +1,6 @@
import {getAllTransactions, getAllTransactionViolationsLength} from '@libs/actions/Transaction';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
-import {getAllPoliciesLength} from '@libs/PolicyUtils';
+import {getActivePolicy, getAllPoliciesLength} from '@libs/PolicyUtils';
import {getReportActionsLength} from '@libs/ReportActionsUtils';
import * as ReportConnection from '@libs/ReportConnection';
import * as SessionUtils from '@libs/SessionUtils';
@@ -16,6 +16,7 @@ function getAttributes(): FirebaseAttributes {
const transactionViolationsLength = getAllTransactionViolationsLength().toString();
const policiesLength = getAllPoliciesLength().toString();
const transactionsLength = getAllTransactions().toString();
+ const policy = getActivePolicy();
return {
accountId,
@@ -25,6 +26,8 @@ function getAttributes(): FirebaseAttributes {
transactionViolationsLength,
policiesLength,
transactionsLength,
+ policyType: policy?.type ?? '',
+ policyRole: policy?.role ?? '',
};
}
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index 7b8589c81e7f..626c5470e297 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -225,6 +225,8 @@ const modalScreenListenersWithCancelSearch = {
function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDAppliedToClient}: AuthScreensProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
+ // We need to use isSmallScreenWidth for the root stack navigator
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth} = useResponsiveLayout();
const screenOptions = getRootNavigatorScreenOptions(shouldUseNarrowLayout, styles, StyleUtils);
const {canUseDefaultRooms} = usePermissions();
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index fabf7fb78591..b15c5235ae75 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -204,7 +204,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/PersonalDetails/StateSelectionPage').default,
[SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: () => require('../../../../pages/settings/Profile/Contacts/ContactMethodsPage').default,
[SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: () => require('../../../../pages/settings/Profile/Contacts/ContactMethodDetailsPage').default,
- [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_VALIDATE_ACTION]: () => require('../../../../pages/settings/Profile/Contacts/ValidateContactActionPage').default,
[SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: () => require('../../../../pages/settings/Profile/Contacts/NewContactMethodPage').default,
[SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: () => require('../../../../pages/settings/Preferences/PriorityModePage').default,
[SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default,
@@ -312,6 +311,14 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_EXPORT_PREFERRED_EXPORTER]: () =>
require('../../../../pages/workspace/accounting/qbo/export/QuickbooksPreferredExporterConfigurationPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT]: () =>
+ require('../../../../pages/workspace/accounting/qbd/export/QuickbooksDesktopCompanyCardExpenseAccountSelectPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT]: () =>
+ require('../../../../pages/workspace/accounting/qbd/export/QuickbooksDesktopCompanyCardExpenseAccountSelectCardPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT]: () =>
+ require('../../../../pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT]: () =>
+ require('../../../../pages/workspace/accounting/qbd/export/QuickbooksDesktopCompanyCardExpenseAccountPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT]: () =>
require('../../../../pages/workspace/accounting/qbd/export/QuickbooksDesktopExportDateSelectPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_PREFERRED_EXPORTER]: () =>
@@ -340,6 +347,7 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/qbd/import/QuickbooksDesktopCustomersPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS]: () =>
require('../../../../pages/workspace/accounting/qbd/import/QuickbooksDesktopCustomersDisplayedAsPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_ITEMS]: () => require('../../../../pages/workspace/accounting/qbd/import/QuickbooksDesktopItemsPage').default,
[SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../../pages/ReimbursementAccount/ReimbursementAccountPage').default,
[SCREENS.GET_ASSISTANCE]: () => require('../../../../pages/GetAssistancePage').default,
[SCREENS.SETTINGS.TWO_FACTOR_AUTH]: () => require('../../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default,
@@ -523,7 +531,6 @@ const SettingsModalStackNavigator = createModalStackNavigator
require('../../../../pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage').default,
[SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM]: () => require('../../../../pages/settings/Security/AddDelegate/ConfirmDelegatePage').default,
- [SCREENS.SETTINGS.DELEGATE.DELEGATE_MAGIC_CODE]: () => require('../../../../pages/settings/Security/AddDelegate/DelegateMagicCodePage').default,
[SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE_MAGIC_CODE]: () =>
require('../../../../pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage').default,
[SCREENS.WORKSPACE.RULES_CUSTOM_NAME]: () => require('../../../../pages/workspace/rules/RulesCustomNamePage').default,
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
index da26d093f3ef..5a49d9cff993 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
@@ -26,6 +26,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
+import DebugTabView from './DebugTabView';
type BottomTabBarProps = {
selectedTab: string | undefined;
@@ -64,12 +65,15 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {activeWorkspaceID} = useActiveWorkspace();
- const transactionViolations = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
+ const [user] = useOnyx(ONYXKEYS.USER);
+ const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
+ const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS);
+ const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
const [chatTabBrickRoad, setChatTabBrickRoad] = useState(getChatTabBrickRoad(activeWorkspaceID));
useEffect(() => {
setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID));
- }, [activeWorkspaceID, transactionViolations]);
+ }, [activeWorkspaceID, transactionViolations, reports, reportActions]);
const navigateToChats = useCallback(() => {
if (selectedTab === SCREENS.HOME) {
@@ -108,59 +112,72 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
}, [activeWorkspaceID, selectedTab]);
return (
-
-
-
-
- {chatTabBrickRoad && }
-
-
- {translate('common.inbox')}
-
-
-
-
-
-
-
+ {user?.isDebugModeEnabled && (
+
+ )}
+
+
- {translate('common.search')}
-
-
-
-
-
+
+
+ {chatTabBrickRoad && (
+
+ )}
+
+
+ {translate('common.inbox')}
+
+
+
+
+
+
+
+ {translate('common.search')}
+
+
+
+
+
+
-
+ >
);
}
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx
new file mode 100644
index 000000000000..3e5803b797dc
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx
@@ -0,0 +1,172 @@
+import React, {useCallback, useMemo} from 'react';
+import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
+import Button from '@components/Button';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import Text from '@components/Text';
+import type {IndicatorStatus} from '@hooks/useIndicatorStatus';
+import useIndicatorStatus from '@hooks/useIndicatorStatus';
+import useLocalize from '@hooks/useLocalize';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import type {BrickRoad} from '@libs/WorkspacesSettingsUtils';
+import {getChatTabBrickRoadReport} from '@libs/WorkspacesSettingsUtils';
+import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Route} from '@src/ROUTES';
+import ROUTES from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
+import type {ReimbursementAccount} from '@src/types/onyx';
+
+type DebugTabViewProps = {
+ selectedTab?: string;
+ chatTabBrickRoad: BrickRoad;
+ activeWorkspaceID?: string;
+};
+
+function getSettingsMessage(status: IndicatorStatus | undefined): TranslationPaths | undefined {
+ switch (status) {
+ case CONST.INDICATOR_STATUS.HAS_CUSTOM_UNITS_ERROR:
+ return 'debug.indicatorStatus.theresAWorkspaceWithCustomUnitsErrors';
+ case CONST.INDICATOR_STATUS.HAS_EMPLOYEE_LIST_ERROR:
+ return 'debug.indicatorStatus.theresAProblemWithAWorkspaceMember';
+ case CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_ERROR:
+ return 'debug.indicatorStatus.theresAProblemWithAContactMethod';
+ case CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_INFO:
+ return 'debug.indicatorStatus.aContactMethodRequiresVerification';
+ case CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR:
+ return 'debug.indicatorStatus.theresAProblemWithAPaymentMethod';
+ case CONST.INDICATOR_STATUS.HAS_POLICY_ERRORS:
+ return 'debug.indicatorStatus.theresAProblemWithAWorkspace';
+ case CONST.INDICATOR_STATUS.HAS_REIMBURSEMENT_ACCOUNT_ERRORS:
+ return 'debug.indicatorStatus.theresAProblemWithYourReimbursementAccount';
+ case CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_ERRORS:
+ return 'debug.indicatorStatus.theresABillingProblemWithYourSubscription';
+ case CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_INFO:
+ return 'debug.indicatorStatus.yourSubscriptionHasBeenSuccessfullyRenewed';
+ case CONST.INDICATOR_STATUS.HAS_SYNC_ERRORS:
+ return 'debug.indicatorStatus.theresWasAProblemDuringAWorkspaceConnectionSync';
+ case CONST.INDICATOR_STATUS.HAS_USER_WALLET_ERRORS:
+ return 'debug.indicatorStatus.theresAProblemWithYourWallet';
+ case CONST.INDICATOR_STATUS.HAS_WALLET_TERMS_ERRORS:
+ return 'debug.indicatorStatus.theresAProblemWithYourWalletTerms';
+ default:
+ return undefined;
+ }
+}
+
+function getSettingsRoute(status: IndicatorStatus | undefined, reimbursementAccount: OnyxEntry, policyIDWithErrors = ''): Route | undefined {
+ switch (status) {
+ case CONST.INDICATOR_STATUS.HAS_CUSTOM_UNITS_ERROR:
+ return ROUTES.WORKSPACE_DISTANCE_RATES.getRoute(policyIDWithErrors);
+ case CONST.INDICATOR_STATUS.HAS_EMPLOYEE_LIST_ERROR:
+ return ROUTES.WORKSPACE_MEMBERS.getRoute(policyIDWithErrors);
+ case CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_ERROR:
+ return ROUTES.SETTINGS_CONTACT_METHODS.route;
+ case CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_INFO:
+ return ROUTES.SETTINGS_CONTACT_METHODS.route;
+ case CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR:
+ return ROUTES.SETTINGS_WALLET;
+ case CONST.INDICATOR_STATUS.HAS_POLICY_ERRORS:
+ return ROUTES.WORKSPACE_INITIAL.getRoute(policyIDWithErrors);
+ case CONST.INDICATOR_STATUS.HAS_REIMBURSEMENT_ACCOUNT_ERRORS:
+ return ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute(reimbursementAccount?.achData?.currentStep, reimbursementAccount?.achData?.policyID);
+ case CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_ERRORS:
+ return ROUTES.SETTINGS_SUBSCRIPTION;
+ case CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_INFO:
+ return ROUTES.SETTINGS_SUBSCRIPTION;
+ case CONST.INDICATOR_STATUS.HAS_SYNC_ERRORS:
+ return ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyIDWithErrors);
+ case CONST.INDICATOR_STATUS.HAS_USER_WALLET_ERRORS:
+ return ROUTES.SETTINGS_WALLET;
+ case CONST.INDICATOR_STATUS.HAS_WALLET_TERMS_ERRORS:
+ return ROUTES.SETTINGS_WALLET;
+ default:
+ return undefined;
+ }
+}
+
+function DebugTabView({selectedTab = '', chatTabBrickRoad, activeWorkspaceID}: DebugTabViewProps) {
+ const StyleUtils = useStyleUtils();
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const {status, indicatorColor, policyIDWithErrors} = useIndicatorStatus();
+
+ const message = useMemo((): TranslationPaths | undefined => {
+ if (selectedTab === SCREENS.HOME) {
+ if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) {
+ return 'debug.indicatorStatus.theresAReportAwaitingAction';
+ }
+ if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR) {
+ return 'debug.indicatorStatus.theresAReportWithErrors';
+ }
+ }
+ if (selectedTab === SCREENS.SETTINGS.ROOT) {
+ return getSettingsMessage(status);
+ }
+ }, [selectedTab, chatTabBrickRoad, status]);
+
+ const indicator = useMemo(() => {
+ if (selectedTab === SCREENS.HOME) {
+ if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) {
+ return theme.success;
+ }
+ if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR) {
+ return theme.danger;
+ }
+ }
+ if (selectedTab === SCREENS.SETTINGS.ROOT) {
+ if (status) {
+ return indicatorColor;
+ }
+ }
+ }, [selectedTab, chatTabBrickRoad, theme.success, theme.danger, status, indicatorColor]);
+
+ const navigateTo = useCallback(() => {
+ if (selectedTab === SCREENS.HOME && !!chatTabBrickRoad) {
+ const report = getChatTabBrickRoadReport(activeWorkspaceID);
+
+ if (report) {
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID));
+ }
+ }
+ if (selectedTab === SCREENS.SETTINGS.ROOT) {
+ const route = getSettingsRoute(status, reimbursementAccount, policyIDWithErrors);
+
+ if (route) {
+ Navigation.navigate(route);
+ }
+ }
+ }, [selectedTab, chatTabBrickRoad, activeWorkspaceID, status, reimbursementAccount, policyIDWithErrors]);
+
+ if (!([SCREENS.HOME, SCREENS.SETTINGS.ROOT] as string[]).includes(selectedTab) || !indicator) {
+ return null;
+ }
+
+ return (
+
+
+
+ {message && {translate(message)}}
+
+
+
+ );
+}
+
+DebugTabView.displayName = 'DebugTabView';
+
+export default DebugTabView;
diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts
index cec9e86c5be4..574f4d26a01c 100755
--- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts
@@ -6,7 +6,6 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial> =
SCREENS.SETTINGS.PROFILE.DISPLAY_NAME,
SCREENS.SETTINGS.PROFILE.CONTACT_METHODS,
SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS,
- SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_VALIDATE_ACTION,
SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD,
SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER,
SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE,
@@ -46,7 +45,6 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial> =
SCREENS.SETTINGS.DELEGATE.DELEGATE_ROLE,
SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE,
SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM,
- SCREENS.SETTINGS.DELEGATE.DELEGATE_MAGIC_CODE,
SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE_MAGIC_CODE,
],
[SCREENS.SETTINGS.ABOUT]: [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS],
diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
index fb13ecdf8459..39fa05cf87d4 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -45,6 +45,10 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_ADVANCED,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_ACCOUNT_SELECTOR,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR,
+ SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT,
+ SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT,
+ SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT,
+ SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_ADVANCED,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT_PREFERRED_EXPORTER,
@@ -61,6 +65,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS,
+ SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_ITEMS,
SCREENS.WORKSPACE.ACCOUNTING.XERO_IMPORT,
SCREENS.WORKSPACE.ACCOUNTING.XERO_CHART_OF_ACCOUNTS,
SCREENS.WORKSPACE.ACCOUNTING.XERO_ORGANIZATION,
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 65103945746a..72e5f398c1d8 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -256,9 +256,6 @@ const config: LinkingOptions['config'] = {
[SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: {
path: ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.route,
},
- [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_VALIDATE_ACTION]: {
- path: ROUTES.SETINGS_CONTACT_METHOD_VALIDATE_ACTION,
- },
[SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: {
path: ROUTES.SETTINGS_NEW_CONTACT_METHOD.route,
exact: true,
@@ -309,12 +306,6 @@ const config: LinkingOptions['config'] = {
login: (login: string) => decodeURIComponent(login),
},
},
- [SCREENS.SETTINGS.DELEGATE.DELEGATE_MAGIC_CODE]: {
- path: ROUTES.SETTINGS_DELEGATE_MAGIC_CODE.route,
- parse: {
- login: (login: string) => decodeURIComponent(login),
- },
- },
[SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE_MAGIC_CODE]: {
path: ROUTES.SETTINGS_UPDATE_DELEGATE_ROLE_MAGIC_CODE.route,
parse: {
@@ -392,6 +383,18 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR]: {
path: ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR.route,
},
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT]: {
+ path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT.route,
+ },
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT]: {
+ path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT.route,
+ },
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT]: {
+ path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_SELECT.route,
+ },
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT]: {
+ path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT.route,
+ },
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_ADVANCED]: {
path: ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED.route,
},
@@ -422,6 +425,7 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS.route},
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS.route},
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS.route},
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_ITEMS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_ITEMS.route},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_IMPORT]: {path: ROUTES.POLICY_ACCOUNTING_XERO_IMPORT.route},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_CHART_OF_ACCOUNTS]: {path: ROUTES.POLICY_ACCOUNTING_XERO_CHART_OF_ACCOUNTS.route},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_ORGANIZATION]: {path: ROUTES.POLICY_ACCOUNTING_XERO_ORGANIZATION.route},
diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
index a9ce45214e5f..fce13143f3fe 100644
--- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
+++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
@@ -379,7 +379,7 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldR
const state = getStateFromPath(pathWithoutPolicyID, options) as PartialState>;
if (shouldReplacePathInNestedState) {
- replacePathInNestedState(state, path);
+ replacePathInNestedState(state, normalizedPath);
}
if (state === undefined) {
throw new Error('Unable to parse path');
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 8493cb04ceed..0aa6e7474329 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -437,6 +437,18 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_EXPORT_PREFERRED_EXPORTER]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT]: {
+ policyID: string;
+ };
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_ADVANCED]: {
policyID: string;
};
@@ -482,6 +494,9 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_ITEMS]: {
+ policyID: string;
+ };
[SCREENS.WORKSPACE.ACCOUNTING.XERO_IMPORT]: {
policyID: string;
};
@@ -738,10 +753,6 @@ type SettingsNavigatorParamList = {
login: string;
role: string;
};
- [SCREENS.SETTINGS.DELEGATE.DELEGATE_MAGIC_CODE]: {
- login: string;
- role: string;
- };
[SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: {
/** cardID of selected card */
cardID: string;
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 6be6612efa72..36fd3fc4df20 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -40,6 +40,7 @@ import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import times from '@src/utils/times';
+import {createDraftReportForPolicyExpenseChat} from './actions/Report';
import Timing from './actions/Timing';
import filterArrayByMatch from './filterArrayByMatch';
import localeCompare from './LocaleCompare';
@@ -61,6 +62,7 @@ import * as UserUtils from './UserUtils';
type SearchOption = ReportUtils.OptionData & {
item: T;
+ isOptimisticReportOption?: boolean;
};
type OptionList = {
@@ -179,6 +181,7 @@ type GetOptionsConfig = {
includeDomainEmail?: boolean;
action?: IOUAction;
shouldBoldTitleByDefault?: boolean;
+ includePoliciesWithoutExpenseChats?: boolean;
};
type GetUserToInviteConfig = {
@@ -240,6 +243,13 @@ Onyx.connect({
},
});
+let allReportsDraft: OnyxCollection;
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_DRAFT,
+ waitForCollectionCallback: true,
+ callback: (value) => (allReportsDraft = value),
+});
+
let loginList: OnyxEntry;
Onyx.connect({
key: ONYXKEYS.LOGIN_LIST,
@@ -1499,6 +1509,7 @@ function isReportSelected(reportOption: ReportUtils.OptionData, selectedOptions:
function createOptionList(personalDetails: OnyxEntry, reports?: OnyxCollection) {
const reportMapForAccountIDs: Record = {};
const allReportOptions: Array> = [];
+ const policyToReportForPolicyExpenseChats: Record = {};
if (reports) {
Object.values(reports).forEach((report) => {
@@ -1514,6 +1525,10 @@ function createOptionList(personalDetails: OnyxEntry, repor
return;
}
+ if (ReportUtils.isPolicyExpenseChat(report) && report.policyID) {
+ policyToReportForPolicyExpenseChats[report.policyID] = report;
+ }
+
// Save the report in the map if this is a single participant so we can associate the reportID with the
// personal detail option later. Individuals should not be associated with single participant
// policyExpenseChats or chatRooms since those are not people.
@@ -1528,6 +1543,46 @@ function createOptionList(personalDetails: OnyxEntry, repor
});
}
+ const policiesWithoutExpenseChats = Object.values(policies ?? {}).filter((policy) => {
+ if (policy?.type === CONST.POLICY.TYPE.PERSONAL) {
+ return false;
+ }
+ return !policyToReportForPolicyExpenseChats[policy?.id ?? ''];
+ });
+
+ // go through each policy and create a optimistic report option for it
+ if (policiesWithoutExpenseChats && policiesWithoutExpenseChats.length > 0) {
+ policiesWithoutExpenseChats.forEach((policy) => {
+ // check for draft report exist in allreportDrafts for the policy
+ let draftReport = Object.values(allReportsDraft ?? {})?.find((reportDraft) => reportDraft?.policyID === policy?.id);
+ if (!draftReport) {
+ draftReport = ReportUtils.buildOptimisticChatReport(
+ [currentUserAccountID ?? -1],
+ '',
+ CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ policy?.id,
+ currentUserAccountID,
+ true,
+ policy?.name,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ );
+ createDraftReportForPolicyExpenseChat({...draftReport, isOptimisticReport: true});
+ }
+ const accountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(draftReport);
+ allReportOptions.push({
+ item: draftReport,
+ isOptimisticReportOption: true,
+ ...createOption(accountIDs, personalDetails, draftReport, {}),
+ });
+ });
+ }
const allPersonalDetailsOptions = Object.values(personalDetails ?? {}).map((personalDetail) => ({
item: personalDetail,
...createOption([personalDetail?.accountID ?? -1], personalDetails, reportMapForAccountIDs[personalDetail?.accountID ?? -1], {}, {showPersonalDetails: true}),
@@ -1723,6 +1778,7 @@ function getOptions(
includeDomainEmail = false,
action,
shouldBoldTitleByDefault = true,
+ includePoliciesWithoutExpenseChats = false,
}: GetOptionsConfig,
): Options {
if (includeCategories) {
@@ -1787,6 +1843,9 @@ function getOptions(
// Filter out all the reports that shouldn't be displayed
const filteredReportOptions = options.reports.filter((option) => {
+ if (option.isOptimisticReportOption && !includePoliciesWithoutExpenseChats) {
+ return;
+ }
const report = option.item;
const doesReportHaveViolations = ReportUtils.shouldShowViolations(report, transactionViolations);
@@ -1977,11 +2036,6 @@ function getOptions(
}
}
}
-
- // Add this login to the exclude list so it won't appear when we process the personal details
- if (reportOption.login) {
- optionsToExclude.push({login: reportOption.login});
- }
}
}
@@ -2114,34 +2168,77 @@ function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: OnyxEn
/**
* Build the options for the New Group view
*/
-function getFilteredOptions(
- reports: Array> = [],
- personalDetails: Array> = [],
- betas: OnyxEntry = [],
- searchValue = '',
- selectedOptions: Array> = [],
- excludeLogins: string[] = [],
- includeOwnedWorkspaceChats = false,
- includeP2P = true,
- includeCategories = false,
- categories: PolicyCategories = {},
- recentlyUsedCategories: string[] = [],
- includeTags = false,
- tags: PolicyTags | Array = {},
- recentlyUsedTags: string[] = [],
- canInviteUser = true,
- includeSelectedOptions = false,
- includeTaxRates = false,
- maxRecentReportsToShow: number = CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW,
- taxRates: TaxRatesWithDefault = {} as TaxRatesWithDefault,
- includeSelfDM = false,
- includePolicyReportFieldOptions = false,
- policyReportFieldOptions: string[] = [],
- recentlyUsedPolicyReportFieldOptions: string[] = [],
- includeInvoiceRooms = false,
- action: IOUAction | undefined = undefined,
- sortByReportTypeInSearch = false,
-) {
+type FilteredOptionsParams = {
+ reports?: Array>;
+ personalDetails?: Array>;
+ betas?: OnyxEntry;
+ searchValue?: string;
+ selectedOptions?: Array>;
+ excludeLogins?: string[];
+ includeOwnedWorkspaceChats?: boolean;
+ includeP2P?: boolean;
+ includeCategories?: boolean;
+ categories?: PolicyCategories;
+ recentlyUsedCategories?: string[];
+ includeTags?: boolean;
+ tags?: PolicyTags | Array;
+ recentlyUsedTags?: string[];
+ canInviteUser?: boolean;
+ includeSelectedOptions?: boolean;
+ includeTaxRates?: boolean;
+ taxRates?: TaxRatesWithDefault;
+ maxRecentReportsToShow?: number;
+ includeSelfDM?: boolean;
+ includePolicyReportFieldOptions?: boolean;
+ policyReportFieldOptions?: string[];
+ recentlyUsedPolicyReportFieldOptions?: string[];
+ includeInvoiceRooms?: boolean;
+ action?: IOUAction;
+ sortByReportTypeInSearch?: boolean;
+ includePoliciesWithoutExpenseChats?: boolean;
+};
+
+// It is not recommended to pass a search value to getFilteredOptions when passing reports and personalDetails.
+// If a search value is passed, the search value should be passed to filterOptions.
+// When it is necessary to pass a search value when passing reports and personalDetails, follow these steps:
+// 1. Use getFilteredOptions with reports and personalDetails only, without the search value.
+// 2. Pass the returned options from getFilteredOptions to filterOptions along with the search value.
+// The above constraints are enforced with TypeScript.
+
+type FilteredOptionsParamsWithDefaultSearchValue = Omit & {searchValue?: ''};
+
+type FilteredOptionsParamsWithoutOptions = Omit & {reports?: []; personalDetails?: []};
+
+function getFilteredOptions(params: FilteredOptionsParamsWithDefaultSearchValue | FilteredOptionsParamsWithoutOptions) {
+ const {
+ reports = [],
+ personalDetails = [],
+ betas = [],
+ searchValue = '',
+ selectedOptions = [],
+ excludeLogins = [],
+ includeOwnedWorkspaceChats = false,
+ includeP2P = true,
+ includeCategories = false,
+ categories = {},
+ recentlyUsedCategories = [],
+ includeTags = false,
+ tags = {},
+ recentlyUsedTags = [],
+ canInviteUser = true,
+ includeSelectedOptions = false,
+ includeTaxRates = false,
+ maxRecentReportsToShow = CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW,
+ taxRates = {} as TaxRatesWithDefault,
+ includeSelfDM = false,
+ includePolicyReportFieldOptions = false,
+ policyReportFieldOptions = [],
+ recentlyUsedPolicyReportFieldOptions = [],
+ includeInvoiceRooms = false,
+ action,
+ sortByReportTypeInSearch = false,
+ includePoliciesWithoutExpenseChats = false,
+ } = params;
return getOptions(
{reports, personalDetails},
{
@@ -2170,6 +2267,7 @@ function getFilteredOptions(
includeInvoiceRooms,
action,
sortByReportTypeInSearch,
+ includePoliciesWithoutExpenseChats,
},
);
}
@@ -2383,31 +2481,6 @@ function getPersonalDetailSearchTerms(item: Partial) {
function getCurrentUserSearchTerms(item: ReportUtils.OptionData) {
return [item.text ?? '', item.login ?? '', item.login?.replace(CONST.EMAIL_SEARCH_REGEX, '') ?? ''];
}
-
-type PickUserToInviteParams = {
- canInviteUser: boolean;
- recentReports: ReportUtils.OptionData[];
- personalDetails: ReportUtils.OptionData[];
- searchValue: string;
- config?: FilterOptionsConfig;
- optionsToExclude: Option[];
-};
-
-const pickUserToInvite = ({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}: PickUserToInviteParams) => {
- let userToInvite = null;
- if (canInviteUser) {
- if (recentReports.length === 0 && personalDetails.length === 0) {
- userToInvite = getUserToInviteOption({
- searchValue,
- selectedOptions: config?.selectedOptions,
- optionsToExclude,
- });
- }
- }
-
- return userToInvite;
-};
-
/**
* Filters options based on the search input value
*/
@@ -2421,8 +2494,19 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt
preferPolicyExpenseChat = false,
preferRecentExpenseReports = false,
} = config ?? {};
+ // Remove the personal details for the DMs that are already in the recent reports so that we don't show duplicates
+ function filteredPersonalDetailsOfRecentReports(recentReports: ReportUtils.OptionData[], personalDetails: ReportUtils.OptionData[]) {
+ const excludedLogins = new Set(recentReports.map((report) => report.login));
+ return personalDetails.filter((personalDetail) => !excludedLogins.has(personalDetail.login));
+ }
if (searchInputValue.trim() === '' && maxRecentReportsToShow > 0) {
- return {...options, recentReports: options.recentReports.slice(0, maxRecentReportsToShow)};
+ const recentReports = options.recentReports.slice(0, maxRecentReportsToShow);
+ const personalDetails = filteredPersonalDetailsOfRecentReports(recentReports, options.personalDetails);
+ return {
+ ...options,
+ recentReports,
+ personalDetails,
+ };
}
const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchInputValue)));
@@ -2464,7 +2548,6 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt
const currentUserOptionSearchText = items.currentUserOption ? uniqFast(getCurrentUserSearchTerms(items.currentUserOption)).join(' ') : '';
const currentUserOption = isSearchStringMatch(term, currentUserOptionSearchText) ? items.currentUserOption : null;
-
return {
recentReports: recentReports ?? [],
personalDetails: personalDetails ?? [],
@@ -2479,19 +2562,30 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt
let {recentReports, personalDetails} = matchResults;
if (sortByReportTypeInSearch) {
+ personalDetails = filteredPersonalDetailsOfRecentReports(recentReports, personalDetails);
recentReports = recentReports.concat(personalDetails);
personalDetails = [];
recentReports = orderOptions(recentReports, searchValue);
}
- const userToInvite = pickUserToInvite({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude});
+ let userToInvite = null;
+ if (canInviteUser) {
+ if (recentReports.length === 0 && personalDetails.length === 0) {
+ userToInvite = getUserToInviteOption({
+ searchValue,
+ selectedOptions: config?.selectedOptions,
+ optionsToExclude,
+ });
+ }
+ }
if (maxRecentReportsToShow > 0 && recentReports.length > maxRecentReportsToShow) {
recentReports.splice(maxRecentReportsToShow);
}
+ const filteredPersonalDetails = filteredPersonalDetailsOfRecentReports(recentReports, personalDetails);
return {
- personalDetails,
+ personalDetails: filteredPersonalDetails,
recentReports: orderOptions(recentReports, searchValue, {preferChatroomsOverThreads, preferPolicyExpenseChat, preferRecentExpenseReports}),
userToInvite,
currentUserOption: matchResults.currentUserOption,
@@ -2552,7 +2646,6 @@ export {
formatMemberForList,
formatSectionsFromSearchTerm,
getShareLogOptions,
- orderOptions,
filterOptions,
createOptionList,
createOptionFromReport,
@@ -2566,7 +2659,6 @@ export {
getEmptyOptions,
shouldUseBoldText,
getAlternateText,
- pickUserToInvite,
hasReportErrors,
};
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index 3e0ae7cba291..fae3d163b2e9 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -49,6 +49,7 @@ type ConnectionWithLastSyncData = {
};
let allPolicies: OnyxCollection;
+let activePolicyId: OnyxEntry;
Onyx.connect({
key: ONYXKEYS.COLLECTION.POLICY,
@@ -56,6 +57,11 @@ Onyx.connect({
callback: (value) => (allPolicies = value),
});
+Onyx.connect({
+ key: ONYXKEYS.NVP_ACTIVE_POLICY_ID,
+ callback: (value) => (activePolicyId = value),
+});
+
/**
* Filter out the active policies, which will exclude policies with pending deletion
* These are policies that we can use to create reports with in NewDot.
@@ -1054,6 +1060,10 @@ function getAllPoliciesLength() {
return Object.keys(allPolicies ?? {}).length;
}
+function getActivePolicy(): OnyxEntry {
+ return getPolicy(activePolicyId);
+}
+
export {
canEditTaxRate,
extractPolicyIDFromPath,
@@ -1170,6 +1180,7 @@ export {
getWorkflowApprovalsUnavailable,
getNetSuiteImportCustomFieldLabel,
getAllPoliciesLength,
+ getActivePolicy,
};
export type {MemberEmailsToAccountIDs};
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index beb4e591ae62..ca4166f8a707 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -384,15 +384,16 @@ function getSortedReportActions(reportActions: ReportAction[] | null, shouldSort
const invertedMultiplier = shouldSortInDescendingOrder ? -1 : 1;
const sortedActions = reportActions?.filter(Boolean).sort((first, second) => {
- // First sort by timestamp
+ // First sort by action type, ensuring that `CREATED` actions always come first if they have the same or even a later timestamp as another action type
+ if ((first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED || second.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) && first.actionName !== second.actionName) {
+ return (first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED ? -1 : 1) * invertedMultiplier;
+ }
+
+ // Then sort by timestamp
if (first.created !== second.created) {
return (first.created < second.created ? -1 : 1) * invertedMultiplier;
}
- // Then by action type, ensuring that `CREATED` actions always come first if they have the same timestamp as another action type
- if ((first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED || second.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) && first.actionName !== second.actionName) {
- return (first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED ? -1 : 1) * invertedMultiplier;
- }
// Ensure that `REPORT_PREVIEW` actions always come after if they have the same timestamp as another action type
if ((first.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW || second.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) && first.actionName !== second.actionName) {
return (first.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? 1 : -1) * invertedMultiplier;
@@ -1781,7 +1782,7 @@ function getCardIssuedMessage(reportAction: OnyxEntry, shouldRende
const isAssigneeCurrentUser = currentUserAccountID === assigneeAccountID;
- const shouldShowAddMissingDetailsButton = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS && missingDetails && isAssigneeCurrentUser;
+ const shouldShowAddMissingDetailsMessage = !isAssigneeCurrentUser || (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS && missingDetails);
switch (reportAction?.actionName) {
case CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED:
return Localize.translateLocal('workspace.expensifyCard.issuedCard', {assignee});
@@ -1790,7 +1791,7 @@ function getCardIssuedMessage(reportAction: OnyxEntry, shouldRende
case CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED:
return Localize.translateLocal('workspace.companyCards.assignedYouCard', {link: companyCardLink});
case CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS:
- return Localize.translateLocal(`workspace.expensifyCard.${shouldShowAddMissingDetailsButton ? 'issuedCardNoShippingDetails' : 'addedShippingDetails'}`, {assignee});
+ return Localize.translateLocal(`workspace.expensifyCard.${shouldShowAddMissingDetailsMessage ? 'issuedCardNoShippingDetails' : 'addedShippingDetails'}`, {assignee});
default:
return '';
}
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 4bae619d928e..1ef60b626ac7 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -300,6 +300,7 @@ type OptimisticChatReport = Pick<
| 'chatReportID'
| 'iouReportID'
| 'isOwnPolicyExpenseChat'
+ | 'isPolicyExpenseChat'
| 'isPinned'
| 'lastActorAccountID'
| 'lastMessageTranslationKey'
@@ -5322,6 +5323,7 @@ function buildOptimisticChatReport(
chatType,
isOwnPolicyExpenseChat,
isPinned: isNewlyCreatedWorkspaceChat,
+ isPolicyExpenseChat: chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
lastActorAccountID: 0,
lastMessageTranslationKey: '',
lastMessageHtml: '',
diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js
new file mode 100644
index 000000000000..a9c8870e2f79
--- /dev/null
+++ b/src/libs/SearchParser/autocompleteParser.js
@@ -0,0 +1,957 @@
+// @generated by Peggy 4.0.3.
+//
+// https://peggyjs.org/
+
+
+function peg$subclass(child, parent) {
+ function C() { this.constructor = child; }
+ C.prototype = parent.prototype;
+ child.prototype = new C();
+}
+
+function peg$SyntaxError(message, expected, found, location) {
+ var self = Error.call(this, message);
+ // istanbul ignore next Check is a necessary evil to support older environments
+ if (Object.setPrototypeOf) {
+ Object.setPrototypeOf(self, peg$SyntaxError.prototype);
+ }
+ self.expected = expected;
+ self.found = found;
+ self.location = location;
+ self.name = "SyntaxError";
+ return self;
+}
+
+peg$subclass(peg$SyntaxError, Error);
+
+function peg$padEnd(str, targetLength, padString) {
+ padString = padString || " ";
+ if (str.length > targetLength) { return str; }
+ targetLength -= str.length;
+ padString += padString.repeat(targetLength);
+ return str + padString.slice(0, targetLength);
+}
+
+peg$SyntaxError.prototype.format = function(sources) {
+ var str = "Error: " + this.message;
+ if (this.location) {
+ var src = null;
+ var k;
+ for (k = 0; k < sources.length; k++) {
+ if (sources[k].source === this.location.source) {
+ src = sources[k].text.split(/\r\n|\n|\r/g);
+ break;
+ }
+ }
+ var s = this.location.start;
+ var offset_s = (this.location.source && (typeof this.location.source.offset === "function"))
+ ? this.location.source.offset(s)
+ : s;
+ var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column;
+ if (src) {
+ var e = this.location.end;
+ var filler = peg$padEnd("", offset_s.line.toString().length, ' ');
+ var line = src[s.line - 1];
+ var last = s.line === e.line ? e.column : line.length + 1;
+ var hatLen = (last - s.column) || 1;
+ str += "\n --> " + loc + "\n"
+ + filler + " |\n"
+ + offset_s.line + " | " + line + "\n"
+ + filler + " | " + peg$padEnd("", s.column - 1, ' ')
+ + peg$padEnd("", hatLen, "^");
+ } else {
+ str += "\n at " + loc;
+ }
+ }
+ return str;
+};
+
+peg$SyntaxError.buildMessage = function(expected, found) {
+ var DESCRIBE_EXPECTATION_FNS = {
+ literal: function(expectation) {
+ return "\"" + literalEscape(expectation.text) + "\"";
+ },
+
+ class: function(expectation) {
+ var escapedParts = expectation.parts.map(function(part) {
+ return Array.isArray(part)
+ ? classEscape(part[0]) + "-" + classEscape(part[1])
+ : classEscape(part);
+ });
+
+ return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]";
+ },
+
+ any: function() {
+ return "any character";
+ },
+
+ end: function() {
+ return "end of input";
+ },
+
+ other: function(expectation) {
+ return expectation.description;
+ }
+ };
+
+ function hex(ch) {
+ return ch.charCodeAt(0).toString(16).toUpperCase();
+ }
+
+ function literalEscape(s) {
+ return s
+ .replace(/\\/g, "\\\\")
+ .replace(/"/g, "\\\"")
+ .replace(/\0/g, "\\0")
+ .replace(/\t/g, "\\t")
+ .replace(/\n/g, "\\n")
+ .replace(/\r/g, "\\r")
+ .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); })
+ .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); });
+ }
+
+ function classEscape(s) {
+ return s
+ .replace(/\\/g, "\\\\")
+ .replace(/\]/g, "\\]")
+ .replace(/\^/g, "\\^")
+ .replace(/-/g, "\\-")
+ .replace(/\0/g, "\\0")
+ .replace(/\t/g, "\\t")
+ .replace(/\n/g, "\\n")
+ .replace(/\r/g, "\\r")
+ .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); })
+ .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); });
+ }
+
+ function describeExpectation(expectation) {
+ return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation);
+ }
+
+ function describeExpected(expected) {
+ var descriptions = expected.map(describeExpectation);
+ var i, j;
+
+ descriptions.sort();
+
+ if (descriptions.length > 0) {
+ for (i = 1, j = 1; i < descriptions.length; i++) {
+ if (descriptions[i - 1] !== descriptions[i]) {
+ descriptions[j] = descriptions[i];
+ j++;
+ }
+ }
+ descriptions.length = j;
+ }
+
+ switch (descriptions.length) {
+ case 1:
+ return descriptions[0];
+
+ case 2:
+ return descriptions[0] + " or " + descriptions[1];
+
+ default:
+ return descriptions.slice(0, -1).join(", ")
+ + ", or "
+ + descriptions[descriptions.length - 1];
+ }
+ }
+
+ function describeFound(found) {
+ return found ? "\"" + literalEscape(found) + "\"" : "end of input";
+ }
+
+ return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found.";
+};
+
+function peg$parse(input, options) {
+ options = options !== undefined ? options : {};
+
+ var peg$FAILED = {};
+ var peg$source = options.grammarSource;
+
+ var peg$startRuleFunctions = { query: peg$parsequery };
+ var peg$startRuleFunction = peg$parsequery;
+
+ var peg$c0 = "in";
+ var peg$c1 = "currency";
+ var peg$c2 = "tag";
+ var peg$c3 = "category";
+ var peg$c4 = "to";
+ var peg$c5 = "taxRate";
+ var peg$c6 = "from";
+ var peg$c7 = "expenseType";
+ var peg$c8 = "type";
+ var peg$c9 = "status";
+ var peg$c10 = "!=";
+ var peg$c11 = ">=";
+ var peg$c12 = ">";
+ var peg$c13 = "<=";
+ var peg$c14 = "<";
+ var peg$c15 = "\"";
+
+ var peg$r0 = /^[:=]/;
+ var peg$r1 = /^[^"\r\n]/;
+ var peg$r2 = /^[A-Za-z0-9_@.\/#&+\-\\',;%]/;
+ var peg$r3 = /^[ \t\r\n]/;
+
+ var peg$e0 = peg$otherExpectation("key");
+ var peg$e1 = peg$literalExpectation("in", false);
+ var peg$e2 = peg$literalExpectation("currency", false);
+ var peg$e3 = peg$literalExpectation("tag", false);
+ var peg$e4 = peg$literalExpectation("category", false);
+ var peg$e5 = peg$literalExpectation("to", false);
+ var peg$e6 = peg$literalExpectation("taxRate", false);
+ var peg$e7 = peg$literalExpectation("from", false);
+ var peg$e8 = peg$literalExpectation("expenseType", false);
+ var peg$e9 = peg$literalExpectation("type", false);
+ var peg$e10 = peg$literalExpectation("status", false);
+ var peg$e11 = peg$otherExpectation("operator");
+ var peg$e12 = peg$classExpectation([":", "="], false, false);
+ var peg$e13 = peg$literalExpectation("!=", false);
+ var peg$e14 = peg$literalExpectation(">=", false);
+ var peg$e15 = peg$literalExpectation(">", false);
+ var peg$e16 = peg$literalExpectation("<=", false);
+ var peg$e17 = peg$literalExpectation("<", false);
+ var peg$e18 = peg$otherExpectation("quote");
+ var peg$e19 = peg$literalExpectation("\"", false);
+ var peg$e20 = peg$classExpectation(["\"", "\r", "\n"], true, false);
+ var peg$e21 = peg$otherExpectation("word");
+ var peg$e22 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ",", ";", "%"], false, false);
+ var peg$e23 = peg$otherExpectation("whitespace");
+ var peg$e24 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false);
+
+ var peg$f0 = function(ranges) { return { autocomplete, ranges }; };
+ var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); };
+ var peg$f2 = function(key, op, value) {
+ if (!value) {
+ autocomplete = {
+ key,
+ value: null,
+ start: location().end.offset,
+ length: 0,
+ };
+ return;
+ }
+
+ autocomplete = {
+ key,
+ ...value[value.length - 1],
+ };
+
+ return value.map(({ start, length }) => ({
+ key,
+ start,
+ length,
+ }));
+ };
+ var peg$f3 = function() { autocomplete = null; };
+ var peg$f4 = function(parts) {
+ const ends = location();
+ const value = parts.flat();
+ let count = ends.start.offset;
+ const result = [];
+ value.forEach((filter) => {
+ result.push({
+ value: filter,
+ start: count,
+ length: filter.length,
+ });
+ count += filter.length + 1;
+ });
+ return result;
+ };
+ var peg$f5 = function() { return "eq"; };
+ var peg$f6 = function() { return "neq"; };
+ var peg$f7 = function() { return "gte"; };
+ var peg$f8 = function() { return "gt"; };
+ var peg$f9 = function() { return "lte"; };
+ var peg$f10 = function() { return "lt"; };
+ var peg$f11 = function(chars) { return chars.join(""); };
+ var peg$f12 = function(chars) {
+ return chars.join("").trim().split(",").filter(Boolean);
+ };
+ var peg$f13 = function() { return "and"; };
+ var peg$currPos = options.peg$currPos | 0;
+ var peg$savedPos = peg$currPos;
+ var peg$posDetailsCache = [{ line: 1, column: 1 }];
+ var peg$maxFailPos = peg$currPos;
+ var peg$maxFailExpected = options.peg$maxFailExpected || [];
+ var peg$silentFails = options.peg$silentFails | 0;
+
+ var peg$result;
+
+ if (options.startRule) {
+ if (!(options.startRule in peg$startRuleFunctions)) {
+ throw new Error("Can't start parsing from rule \"" + options.startRule + "\".");
+ }
+
+ peg$startRuleFunction = peg$startRuleFunctions[options.startRule];
+ }
+
+ function text() {
+ return input.substring(peg$savedPos, peg$currPos);
+ }
+
+ function offset() {
+ return peg$savedPos;
+ }
+
+ function range() {
+ return {
+ source: peg$source,
+ start: peg$savedPos,
+ end: peg$currPos
+ };
+ }
+
+ function location() {
+ return peg$computeLocation(peg$savedPos, peg$currPos);
+ }
+
+ function expected(description, location) {
+ location = location !== undefined
+ ? location
+ : peg$computeLocation(peg$savedPos, peg$currPos);
+
+ throw peg$buildStructuredError(
+ [peg$otherExpectation(description)],
+ input.substring(peg$savedPos, peg$currPos),
+ location
+ );
+ }
+
+ function error(message, location) {
+ location = location !== undefined
+ ? location
+ : peg$computeLocation(peg$savedPos, peg$currPos);
+
+ throw peg$buildSimpleError(message, location);
+ }
+
+ function peg$literalExpectation(text, ignoreCase) {
+ return { type: "literal", text: text, ignoreCase: ignoreCase };
+ }
+
+ function peg$classExpectation(parts, inverted, ignoreCase) {
+ return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase };
+ }
+
+ function peg$anyExpectation() {
+ return { type: "any" };
+ }
+
+ function peg$endExpectation() {
+ return { type: "end" };
+ }
+
+ function peg$otherExpectation(description) {
+ return { type: "other", description: description };
+ }
+
+ function peg$computePosDetails(pos) {
+ var details = peg$posDetailsCache[pos];
+ var p;
+
+ if (details) {
+ return details;
+ } else {
+ if (pos >= peg$posDetailsCache.length) {
+ p = peg$posDetailsCache.length - 1;
+ } else {
+ p = pos;
+ while (!peg$posDetailsCache[--p]) {}
+ }
+
+ details = peg$posDetailsCache[p];
+ details = {
+ line: details.line,
+ column: details.column
+ };
+
+ while (p < pos) {
+ if (input.charCodeAt(p) === 10) {
+ details.line++;
+ details.column = 1;
+ } else {
+ details.column++;
+ }
+
+ p++;
+ }
+
+ peg$posDetailsCache[pos] = details;
+
+ return details;
+ }
+ }
+
+ function peg$computeLocation(startPos, endPos, offset) {
+ var startPosDetails = peg$computePosDetails(startPos);
+ var endPosDetails = peg$computePosDetails(endPos);
+
+ var res = {
+ source: peg$source,
+ start: {
+ offset: startPos,
+ line: startPosDetails.line,
+ column: startPosDetails.column
+ },
+ end: {
+ offset: endPos,
+ line: endPosDetails.line,
+ column: endPosDetails.column
+ }
+ };
+ if (offset && peg$source && (typeof peg$source.offset === "function")) {
+ res.start = peg$source.offset(res.start);
+ res.end = peg$source.offset(res.end);
+ }
+ return res;
+ }
+
+ function peg$fail(expected) {
+ if (peg$currPos < peg$maxFailPos) { return; }
+
+ if (peg$currPos > peg$maxFailPos) {
+ peg$maxFailPos = peg$currPos;
+ peg$maxFailExpected = [];
+ }
+
+ peg$maxFailExpected.push(expected);
+ }
+
+ function peg$buildSimpleError(message, location) {
+ return new peg$SyntaxError(message, null, null, location);
+ }
+
+ function peg$buildStructuredError(expected, found, location) {
+ return new peg$SyntaxError(
+ peg$SyntaxError.buildMessage(expected, found),
+ expected,
+ found,
+ location
+ );
+ }
+
+ function peg$parsequery() {
+ var s0, s1, s2, s3;
+
+ s0 = peg$currPos;
+ s1 = peg$parse_();
+ s2 = peg$parsefilterList();
+ s3 = peg$parse_();
+ peg$savedPos = s0;
+ s0 = peg$f0(s2);
+
+ return s0;
+ }
+
+ function peg$parsefilterList() {
+ var s0, s1, s2, s3;
+
+ s0 = peg$currPos;
+ s1 = [];
+ s2 = peg$parsefilter();
+ while (s2 !== peg$FAILED) {
+ s1.push(s2);
+ s2 = peg$currPos;
+ s3 = peg$parselogicalAnd();
+ s3 = peg$parsefilter();
+ if (s3 === peg$FAILED) {
+ peg$currPos = s2;
+ s2 = peg$FAILED;
+ } else {
+ s2 = s3;
+ }
+ }
+ peg$savedPos = s0;
+ s1 = peg$f1(s1);
+ s0 = s1;
+
+ return s0;
+ }
+
+ function peg$parsefilter() {
+ var s0, s1;
+
+ s0 = peg$currPos;
+ s1 = peg$parsedefaultFilter();
+ if (s1 === peg$FAILED) {
+ s1 = peg$parsefreeTextFilter();
+ }
+ if (s1 !== peg$FAILED) {
+ s0 = s1;
+ } else {
+ peg$currPos = s0;
+ s0 = peg$FAILED;
+ }
+
+ return s0;
+ }
+
+ function peg$parsedefaultFilter() {
+ var s0, s1, s2, s3, s4, s5, s6;
+
+ s0 = peg$currPos;
+ s1 = peg$parse_();
+ s2 = peg$parseautocompleteKey();
+ if (s2 !== peg$FAILED) {
+ s3 = peg$parse_();
+ s4 = peg$parseoperator();
+ if (s4 !== peg$FAILED) {
+ s5 = peg$parse_();
+ s6 = peg$parseidentifier();
+ if (s6 === peg$FAILED) {
+ s6 = null;
+ }
+ peg$savedPos = s0;
+ s0 = peg$f2(s2, s4, s6);
+ } else {
+ peg$currPos = s0;
+ s0 = peg$FAILED;
+ }
+ } else {
+ peg$currPos = s0;
+ s0 = peg$FAILED;
+ }
+
+ return s0;
+ }
+
+ function peg$parsefreeTextFilter() {
+ var s0, s1, s2, s3;
+
+ s0 = peg$currPos;
+ s1 = peg$parse_();
+ s2 = peg$parseidentifier();
+ if (s2 !== peg$FAILED) {
+ s3 = peg$parse_();
+ peg$savedPos = s0;
+ s0 = peg$f3();
+ } else {
+ peg$currPos = s0;
+ s0 = peg$FAILED;
+ }
+
+ return s0;
+ }
+
+ function peg$parseautocompleteKey() {
+ var s0, s1;
+
+ peg$silentFails++;
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 2) === peg$c0) {
+ s1 = peg$c0;
+ peg$currPos += 2;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e1); }
+ }
+ if (s1 === peg$FAILED) {
+ if (input.substr(peg$currPos, 8) === peg$c1) {
+ s1 = peg$c1;
+ peg$currPos += 8;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e2); }
+ }
+ if (s1 === peg$FAILED) {
+ if (input.substr(peg$currPos, 3) === peg$c2) {
+ s1 = peg$c2;
+ peg$currPos += 3;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e3); }
+ }
+ if (s1 === peg$FAILED) {
+ if (input.substr(peg$currPos, 8) === peg$c3) {
+ s1 = peg$c3;
+ peg$currPos += 8;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e4); }
+ }
+ if (s1 === peg$FAILED) {
+ if (input.substr(peg$currPos, 2) === peg$c4) {
+ s1 = peg$c4;
+ peg$currPos += 2;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e5); }
+ }
+ if (s1 === peg$FAILED) {
+ if (input.substr(peg$currPos, 7) === peg$c5) {
+ s1 = peg$c5;
+ peg$currPos += 7;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e6); }
+ }
+ if (s1 === peg$FAILED) {
+ if (input.substr(peg$currPos, 4) === peg$c6) {
+ s1 = peg$c6;
+ peg$currPos += 4;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e7); }
+ }
+ if (s1 === peg$FAILED) {
+ if (input.substr(peg$currPos, 11) === peg$c7) {
+ s1 = peg$c7;
+ peg$currPos += 11;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e8); }
+ }
+ if (s1 === peg$FAILED) {
+ if (input.substr(peg$currPos, 4) === peg$c8) {
+ s1 = peg$c8;
+ peg$currPos += 4;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e9); }
+ }
+ if (s1 === peg$FAILED) {
+ if (input.substr(peg$currPos, 6) === peg$c9) {
+ s1 = peg$c9;
+ peg$currPos += 6;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e10); }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ if (s1 !== peg$FAILED) {
+ s0 = s1;
+ } else {
+ peg$currPos = s0;
+ s0 = peg$FAILED;
+ }
+ peg$silentFails--;
+ if (s0 === peg$FAILED) {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e0); }
+ }
+
+ return s0;
+ }
+
+ function peg$parseidentifier() {
+ var s0, s1, s2;
+
+ s0 = peg$currPos;
+ s1 = [];
+ s2 = peg$parsequotedString();
+ if (s2 === peg$FAILED) {
+ s2 = peg$parsealphanumeric();
+ }
+ if (s2 !== peg$FAILED) {
+ while (s2 !== peg$FAILED) {
+ s1.push(s2);
+ s2 = peg$parsequotedString();
+ if (s2 === peg$FAILED) {
+ s2 = peg$parsealphanumeric();
+ }
+ }
+ } else {
+ s1 = peg$FAILED;
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f4(s1);
+ }
+ s0 = s1;
+
+ return s0;
+ }
+
+ function peg$parseoperator() {
+ var s0, s1;
+
+ peg$silentFails++;
+ s0 = peg$currPos;
+ s1 = input.charAt(peg$currPos);
+ if (peg$r0.test(s1)) {
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e12); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f5();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 2) === peg$c10) {
+ s1 = peg$c10;
+ peg$currPos += 2;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e13); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f6();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 2) === peg$c11) {
+ s1 = peg$c11;
+ peg$currPos += 2;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e14); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f7();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.charCodeAt(peg$currPos) === 62) {
+ s1 = peg$c12;
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e15); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f8();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 2) === peg$c13) {
+ s1 = peg$c13;
+ peg$currPos += 2;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e16); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f9();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.charCodeAt(peg$currPos) === 60) {
+ s1 = peg$c14;
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e17); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f10();
+ }
+ s0 = s1;
+ }
+ }
+ }
+ }
+ }
+ peg$silentFails--;
+ if (s0 === peg$FAILED) {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e11); }
+ }
+
+ return s0;
+ }
+
+ function peg$parsequotedString() {
+ var s0, s1, s2, s3;
+
+ peg$silentFails++;
+ s0 = peg$currPos;
+ if (input.charCodeAt(peg$currPos) === 34) {
+ s1 = peg$c15;
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e19); }
+ }
+ if (s1 !== peg$FAILED) {
+ s2 = [];
+ s3 = input.charAt(peg$currPos);
+ if (peg$r1.test(s3)) {
+ peg$currPos++;
+ } else {
+ s3 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e20); }
+ }
+ while (s3 !== peg$FAILED) {
+ s2.push(s3);
+ s3 = input.charAt(peg$currPos);
+ if (peg$r1.test(s3)) {
+ peg$currPos++;
+ } else {
+ s3 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e20); }
+ }
+ }
+ if (input.charCodeAt(peg$currPos) === 34) {
+ s3 = peg$c15;
+ peg$currPos++;
+ } else {
+ s3 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e19); }
+ }
+ if (s3 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s0 = peg$f11(s2);
+ } else {
+ peg$currPos = s0;
+ s0 = peg$FAILED;
+ }
+ } else {
+ peg$currPos = s0;
+ s0 = peg$FAILED;
+ }
+ peg$silentFails--;
+ if (s0 === peg$FAILED) {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e18); }
+ }
+
+ return s0;
+ }
+
+ function peg$parsealphanumeric() {
+ var s0, s1, s2;
+
+ peg$silentFails++;
+ s0 = peg$currPos;
+ s1 = [];
+ s2 = input.charAt(peg$currPos);
+ if (peg$r2.test(s2)) {
+ peg$currPos++;
+ } else {
+ s2 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e22); }
+ }
+ if (s2 !== peg$FAILED) {
+ while (s2 !== peg$FAILED) {
+ s1.push(s2);
+ s2 = input.charAt(peg$currPos);
+ if (peg$r2.test(s2)) {
+ peg$currPos++;
+ } else {
+ s2 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e22); }
+ }
+ }
+ } else {
+ s1 = peg$FAILED;
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f12(s1);
+ }
+ s0 = s1;
+ peg$silentFails--;
+ if (s0 === peg$FAILED) {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e21); }
+ }
+
+ return s0;
+ }
+
+ function peg$parselogicalAnd() {
+ var s0, s1;
+
+ s0 = peg$currPos;
+ s1 = peg$parse_();
+ peg$savedPos = s0;
+ s1 = peg$f13();
+ s0 = s1;
+
+ return s0;
+ }
+
+ function peg$parse_() {
+ var s0, s1;
+
+ peg$silentFails++;
+ s0 = [];
+ s1 = input.charAt(peg$currPos);
+ if (peg$r3.test(s1)) {
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e24); }
+ }
+ while (s1 !== peg$FAILED) {
+ s0.push(s1);
+ s1 = input.charAt(peg$currPos);
+ if (peg$r3.test(s1)) {
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e24); }
+ }
+ }
+ peg$silentFails--;
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e23); }
+
+ return s0;
+ }
+
+ let autocomplete = null;
+ peg$result = peg$startRuleFunction();
+
+ if (options.peg$library) {
+ return /** @type {any} */ ({
+ peg$result,
+ peg$currPos,
+ peg$FAILED,
+ peg$maxFailExpected,
+ peg$maxFailPos
+ });
+ }
+ if (peg$result !== peg$FAILED && peg$currPos === input.length) {
+ return peg$result;
+ } else {
+ if (peg$result !== peg$FAILED && peg$currPos < input.length) {
+ peg$fail(peg$endExpectation());
+ }
+
+ throw peg$buildStructuredError(
+ peg$maxFailExpected,
+ peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null,
+ peg$maxFailPos < input.length
+ ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1)
+ : peg$computeLocation(peg$maxFailPos, peg$maxFailPos)
+ );
+ }
+}
+
+const peg$allowedStartRules = [
+ "query"
+];
+
+export {
+ peg$allowedStartRules as StartRules,
+ peg$SyntaxError as SyntaxError,
+ peg$parse as parse
+};
diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy
new file mode 100644
index 000000000000..003d35485d69
--- /dev/null
+++ b/src/libs/SearchParser/autocompleteParser.peggy
@@ -0,0 +1,80 @@
+// This file defines the grammar that's used by [Peggy](https://peggyjs.org/) to generate the autocompleteParser.js file.
+// The autocompleteParser is setup to parse our custom search syntax and output information needed for autocomplete and syntax highlighting to work.
+//
+// Here's the general grammar structure:
+//
+// query: the entry point for the parser and rule to process the values returned by the filterList rule. It takes filters as an argument and returns the final ranges and autocomplete objects.
+// filterList: a rule to process information returned by filter rule.
+// filter: an abstract rule to simplify the filterList rule. It takes all filter types.
+// defaultFilter: a rule to process the default values returned by the defaultKey autocompleteKey. It updates the autocomplete field.
+// freeTextFilter: a rule to process text that isn't a filter that needs autocomplete. It resets the autocomplete field (it means the user started a new filter).
+// autocompleteKey: a rule to match pre-defined search syntax fields that need autocomplete, e.g. tag, currency, etc.
+//
+// filter, logicalAnd, operator, alphanumeric, quotedString are defined in baseRules.peggy grammar.
+//
+// per-parser initializer (code executed before every parse).
+{ let autocomplete = null; }
+
+query = _ ranges:filterList? _ { return { autocomplete, ranges }; }
+
+filterList
+ = filters:filter|.., logicalAnd| { return filters.filter(Boolean).flat(); }
+
+filter = @(defaultFilter / freeTextFilter)
+
+defaultFilter
+ = _ key:autocompleteKey _ op:operator _ value:identifier? {
+ if (!value) {
+ autocomplete = {
+ key,
+ value: null,
+ start: location().end.offset,
+ length: 0,
+ };
+ return;
+ }
+
+ autocomplete = {
+ key,
+ ...value[value.length - 1],
+ };
+
+ return value.map(({ start, length }) => ({
+ key,
+ start,
+ length,
+ }));
+ }
+
+freeTextFilter = _ identifier _ { autocomplete = null; }
+
+autocompleteKey "key"
+ = @(
+ "in"
+ / "currency"
+ / "tag"
+ / "category"
+ / "to"
+ / "taxRate"
+ / "from"
+ / "expenseType"
+ / "type"
+ / "status"
+ )
+
+identifier
+ = parts:(quotedString / alphanumeric)+ {
+ const ends = location();
+ const value = parts.flat();
+ let count = ends.start.offset;
+ const result = [];
+ value.forEach((filter) => {
+ result.push({
+ value: filter,
+ start: count,
+ length: filter.length,
+ });
+ count += filter.length + 1;
+ });
+ return result;
+ }
diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy
new file mode 100644
index 000000000000..62948fdb573b
--- /dev/null
+++ b/src/libs/SearchParser/baseRules.peggy
@@ -0,0 +1,28 @@
+// This file includes basic grammar rules that are used in both search parsers.
+// It is not a complete grammar.
+// Its main purpose is to remove duplicated rules and ensure similar behaviour in parsers.
+//
+// operator: rule to match pre-defined search syntax operators, e.g. !=, >, etc.
+// quotedString: rule to match a quoted string pattern, e.g. "this is a quoted string".
+// alphanumeric: rule to match unquoted alphanumeric characters, e.g. a-z, 0-9, _, @, etc.
+// logicalAnd: rule to match whitespace and return it as a logical 'and' operator.
+// whitespace: rule to match whitespaces.
+
+operator "operator"
+ = (":" / "=") { return "eq"; }
+ / "!=" { return "neq"; }
+ / ">=" { return "gte"; }
+ / ">" { return "gt"; }
+ / "<=" { return "lte"; }
+ / "<" { return "lt"; }
+
+quotedString "quote" = "\"" chars:[^"\r\n]* "\"" { return chars.join(""); }
+
+alphanumeric "word"
+ = chars:[A-Za-z0-9_@./#&+\-\\',;%]+ {
+ return chars.join("").trim().split(",").filter(Boolean);
+ }
+
+logicalAnd = _ { return "and"; }
+
+_ "whitespace" = [ \t\r\n]*
diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js
index 713996d0cf09..49e819ada3e5 100644
--- a/src/libs/SearchParser/searchParser.js
+++ b/src/libs/SearchParser/searchParser.js
@@ -180,31 +180,31 @@ function peg$parse(input, options) {
var peg$startRuleFunctions = { query: peg$parsequery };
var peg$startRuleFunction = peg$parsequery;
- var peg$c0 = "!=";
- var peg$c1 = ">=";
- var peg$c2 = ">";
- var peg$c3 = "<=";
- var peg$c4 = "<";
- var peg$c5 = "date";
- var peg$c6 = "amount";
- var peg$c7 = "merchant";
- var peg$c8 = "description";
- var peg$c9 = "reportID";
- var peg$c10 = "keyword";
- var peg$c11 = "in";
- var peg$c12 = "currency";
- var peg$c13 = "tag";
- var peg$c14 = "category";
- var peg$c15 = "to";
- var peg$c16 = "taxRate";
- var peg$c17 = "cardID";
- var peg$c18 = "from";
- var peg$c19 = "expenseType";
- var peg$c20 = "type";
- var peg$c21 = "status";
- var peg$c22 = "sortBy";
- var peg$c23 = "sortOrder";
- var peg$c24 = "policyID";
+ var peg$c0 = "date";
+ var peg$c1 = "amount";
+ var peg$c2 = "merchant";
+ var peg$c3 = "description";
+ var peg$c4 = "reportID";
+ var peg$c5 = "keyword";
+ var peg$c6 = "in";
+ var peg$c7 = "currency";
+ var peg$c8 = "tag";
+ var peg$c9 = "category";
+ var peg$c10 = "to";
+ var peg$c11 = "taxRate";
+ var peg$c12 = "cardID";
+ var peg$c13 = "from";
+ var peg$c14 = "expenseType";
+ var peg$c15 = "type";
+ var peg$c16 = "status";
+ var peg$c17 = "sortBy";
+ var peg$c18 = "sortOrder";
+ var peg$c19 = "policyID";
+ var peg$c20 = "!=";
+ var peg$c21 = ">=";
+ var peg$c22 = ">";
+ var peg$c23 = "<=";
+ var peg$c24 = "<";
var peg$c25 = "\"";
var peg$r0 = /^[:=]/;
@@ -212,35 +212,35 @@ function peg$parse(input, options) {
var peg$r2 = /^[A-Za-z0-9_@.\/#&+\-\\',;%]/;
var peg$r3 = /^[ \t\r\n]/;
- var peg$e0 = peg$otherExpectation("operator");
- var peg$e1 = peg$classExpectation([":", "="], false, false);
- var peg$e2 = peg$literalExpectation("!=", false);
- var peg$e3 = peg$literalExpectation(">=", false);
- var peg$e4 = peg$literalExpectation(">", false);
- var peg$e5 = peg$literalExpectation("<=", false);
- var peg$e6 = peg$literalExpectation("<", false);
- var peg$e7 = peg$otherExpectation("key");
- var peg$e8 = peg$literalExpectation("date", false);
- var peg$e9 = peg$literalExpectation("amount", false);
- var peg$e10 = peg$literalExpectation("merchant", false);
- var peg$e11 = peg$literalExpectation("description", false);
- var peg$e12 = peg$literalExpectation("reportID", false);
- var peg$e13 = peg$literalExpectation("keyword", false);
- var peg$e14 = peg$literalExpectation("in", false);
- var peg$e15 = peg$literalExpectation("currency", false);
- var peg$e16 = peg$literalExpectation("tag", false);
- var peg$e17 = peg$literalExpectation("category", false);
- var peg$e18 = peg$literalExpectation("to", false);
- var peg$e19 = peg$literalExpectation("taxRate", false);
- var peg$e20 = peg$literalExpectation("cardID", false);
- var peg$e21 = peg$literalExpectation("from", false);
- var peg$e22 = peg$literalExpectation("expenseType", false);
- var peg$e23 = peg$otherExpectation("default key");
- var peg$e24 = peg$literalExpectation("type", false);
- var peg$e25 = peg$literalExpectation("status", false);
- var peg$e26 = peg$literalExpectation("sortBy", false);
- var peg$e27 = peg$literalExpectation("sortOrder", false);
- var peg$e28 = peg$literalExpectation("policyID", false);
+ var peg$e0 = peg$otherExpectation("key");
+ var peg$e1 = peg$literalExpectation("date", false);
+ var peg$e2 = peg$literalExpectation("amount", false);
+ var peg$e3 = peg$literalExpectation("merchant", false);
+ var peg$e4 = peg$literalExpectation("description", false);
+ var peg$e5 = peg$literalExpectation("reportID", false);
+ var peg$e6 = peg$literalExpectation("keyword", false);
+ var peg$e7 = peg$literalExpectation("in", false);
+ var peg$e8 = peg$literalExpectation("currency", false);
+ var peg$e9 = peg$literalExpectation("tag", false);
+ var peg$e10 = peg$literalExpectation("category", false);
+ var peg$e11 = peg$literalExpectation("to", false);
+ var peg$e12 = peg$literalExpectation("taxRate", false);
+ var peg$e13 = peg$literalExpectation("cardID", false);
+ var peg$e14 = peg$literalExpectation("from", false);
+ var peg$e15 = peg$literalExpectation("expenseType", false);
+ var peg$e16 = peg$otherExpectation("default key");
+ var peg$e17 = peg$literalExpectation("type", false);
+ var peg$e18 = peg$literalExpectation("status", false);
+ var peg$e19 = peg$literalExpectation("sortBy", false);
+ var peg$e20 = peg$literalExpectation("sortOrder", false);
+ var peg$e21 = peg$literalExpectation("policyID", false);
+ var peg$e22 = peg$otherExpectation("operator");
+ var peg$e23 = peg$classExpectation([":", "="], false, false);
+ var peg$e24 = peg$literalExpectation("!=", false);
+ var peg$e25 = peg$literalExpectation(">=", false);
+ var peg$e26 = peg$literalExpectation(">", false);
+ var peg$e27 = peg$literalExpectation("<=", false);
+ var peg$e28 = peg$literalExpectation("<", false);
var peg$e29 = peg$otherExpectation("quote");
var peg$e30 = peg$literalExpectation("\"", false);
var peg$e31 = peg$classExpectation(["\"", "\r", "\n"], true, false);
@@ -286,19 +286,19 @@ function peg$parse(input, options) {
var peg$f4 = function(field, op, values) {
return buildFilter(op, field, values);
};
- var peg$f5 = function() { return "eq"; };
- var peg$f6 = function() { return "neq"; };
- var peg$f7 = function() { return "gte"; };
- var peg$f8 = function() { return "gt"; };
- var peg$f9 = function() { return "lte"; };
- var peg$f10 = function() { return "lt"; };
- var peg$f11 = function(parts) {
+ var peg$f5 = function(parts) {
const value = parts.flat();
if (value.length > 1) {
return value;
}
return value[0];
};
+ var peg$f6 = function() { return "eq"; };
+ var peg$f7 = function() { return "neq"; };
+ var peg$f8 = function() { return "gte"; };
+ var peg$f9 = function() { return "gt"; };
+ var peg$f10 = function() { return "lte"; };
+ var peg$f11 = function() { return "lt"; };
var peg$f12 = function(chars) { return chars.join(""); };
var peg$f13 = function(chars) {
return chars.join("").trim().split(",").filter(Boolean);
@@ -623,238 +623,137 @@ function peg$parse(input, options) {
return s0;
}
- function peg$parseoperator() {
- var s0, s1;
-
- peg$silentFails++;
- s0 = peg$currPos;
- s1 = input.charAt(peg$currPos);
- if (peg$r0.test(s1)) {
- peg$currPos++;
- } else {
- s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e1); }
- }
- if (s1 !== peg$FAILED) {
- peg$savedPos = s0;
- s1 = peg$f5();
- }
- s0 = s1;
- if (s0 === peg$FAILED) {
- s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c0) {
- s1 = peg$c0;
- peg$currPos += 2;
- } else {
- s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e2); }
- }
- if (s1 !== peg$FAILED) {
- peg$savedPos = s0;
- s1 = peg$f6();
- }
- s0 = s1;
- if (s0 === peg$FAILED) {
- s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c1) {
- s1 = peg$c1;
- peg$currPos += 2;
- } else {
- s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e3); }
- }
- if (s1 !== peg$FAILED) {
- peg$savedPos = s0;
- s1 = peg$f7();
- }
- s0 = s1;
- if (s0 === peg$FAILED) {
- s0 = peg$currPos;
- if (input.charCodeAt(peg$currPos) === 62) {
- s1 = peg$c2;
- peg$currPos++;
- } else {
- s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e4); }
- }
- if (s1 !== peg$FAILED) {
- peg$savedPos = s0;
- s1 = peg$f8();
- }
- s0 = s1;
- if (s0 === peg$FAILED) {
- s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c3) {
- s1 = peg$c3;
- peg$currPos += 2;
- } else {
- s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e5); }
- }
- if (s1 !== peg$FAILED) {
- peg$savedPos = s0;
- s1 = peg$f9();
- }
- s0 = s1;
- if (s0 === peg$FAILED) {
- s0 = peg$currPos;
- if (input.charCodeAt(peg$currPos) === 60) {
- s1 = peg$c4;
- peg$currPos++;
- } else {
- s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e6); }
- }
- if (s1 !== peg$FAILED) {
- peg$savedPos = s0;
- s1 = peg$f10();
- }
- s0 = s1;
- }
- }
- }
- }
- }
- peg$silentFails--;
- if (s0 === peg$FAILED) {
- s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e0); }
- }
-
- return s0;
- }
-
function peg$parsekey() {
var s0, s1;
peg$silentFails++;
s0 = peg$currPos;
- if (input.substr(peg$currPos, 4) === peg$c5) {
- s1 = peg$c5;
+ if (input.substr(peg$currPos, 4) === peg$c0) {
+ s1 = peg$c0;
peg$currPos += 4;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e8); }
+ if (peg$silentFails === 0) { peg$fail(peg$e1); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 6) === peg$c6) {
- s1 = peg$c6;
+ if (input.substr(peg$currPos, 6) === peg$c1) {
+ s1 = peg$c1;
peg$currPos += 6;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e9); }
+ if (peg$silentFails === 0) { peg$fail(peg$e2); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 8) === peg$c7) {
- s1 = peg$c7;
+ if (input.substr(peg$currPos, 8) === peg$c2) {
+ s1 = peg$c2;
peg$currPos += 8;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e10); }
+ if (peg$silentFails === 0) { peg$fail(peg$e3); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 11) === peg$c8) {
- s1 = peg$c8;
+ if (input.substr(peg$currPos, 11) === peg$c3) {
+ s1 = peg$c3;
peg$currPos += 11;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e11); }
+ if (peg$silentFails === 0) { peg$fail(peg$e4); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 8) === peg$c9) {
- s1 = peg$c9;
+ if (input.substr(peg$currPos, 8) === peg$c4) {
+ s1 = peg$c4;
peg$currPos += 8;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e12); }
+ if (peg$silentFails === 0) { peg$fail(peg$e5); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 7) === peg$c10) {
- s1 = peg$c10;
+ if (input.substr(peg$currPos, 7) === peg$c5) {
+ s1 = peg$c5;
peg$currPos += 7;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e13); }
+ if (peg$silentFails === 0) { peg$fail(peg$e6); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 2) === peg$c11) {
- s1 = peg$c11;
+ if (input.substr(peg$currPos, 2) === peg$c6) {
+ s1 = peg$c6;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e14); }
+ if (peg$silentFails === 0) { peg$fail(peg$e7); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 8) === peg$c12) {
- s1 = peg$c12;
+ if (input.substr(peg$currPos, 8) === peg$c7) {
+ s1 = peg$c7;
peg$currPos += 8;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e15); }
+ if (peg$silentFails === 0) { peg$fail(peg$e8); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 3) === peg$c13) {
- s1 = peg$c13;
+ if (input.substr(peg$currPos, 3) === peg$c8) {
+ s1 = peg$c8;
peg$currPos += 3;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e16); }
+ if (peg$silentFails === 0) { peg$fail(peg$e9); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 8) === peg$c14) {
- s1 = peg$c14;
+ if (input.substr(peg$currPos, 8) === peg$c9) {
+ s1 = peg$c9;
peg$currPos += 8;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e17); }
+ if (peg$silentFails === 0) { peg$fail(peg$e10); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 2) === peg$c15) {
- s1 = peg$c15;
+ if (input.substr(peg$currPos, 2) === peg$c10) {
+ s1 = peg$c10;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e18); }
+ if (peg$silentFails === 0) { peg$fail(peg$e11); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 7) === peg$c16) {
- s1 = peg$c16;
+ if (input.substr(peg$currPos, 7) === peg$c11) {
+ s1 = peg$c11;
peg$currPos += 7;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e19); }
+ if (peg$silentFails === 0) { peg$fail(peg$e12); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 6) === peg$c17) {
- s1 = peg$c17;
+ if (input.substr(peg$currPos, 6) === peg$c12) {
+ s1 = peg$c12;
peg$currPos += 6;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e20); }
+ if (peg$silentFails === 0) { peg$fail(peg$e13); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 4) === peg$c18) {
- s1 = peg$c18;
+ if (input.substr(peg$currPos, 4) === peg$c13) {
+ s1 = peg$c13;
peg$currPos += 4;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e21); }
+ if (peg$silentFails === 0) { peg$fail(peg$e14); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 11) === peg$c19) {
- s1 = peg$c19;
+ if (input.substr(peg$currPos, 11) === peg$c14) {
+ s1 = peg$c14;
peg$currPos += 11;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e22); }
+ if (peg$silentFails === 0) { peg$fail(peg$e15); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 2) === peg$c11) {
- s1 = peg$c11;
+ if (input.substr(peg$currPos, 2) === peg$c6) {
+ s1 = peg$c6;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e14); }
+ if (peg$silentFails === 0) { peg$fail(peg$e7); }
}
}
}
@@ -880,7 +779,7 @@ function peg$parse(input, options) {
peg$silentFails--;
if (s0 === peg$FAILED) {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e7); }
+ if (peg$silentFails === 0) { peg$fail(peg$e0); }
}
return s0;
@@ -891,44 +790,44 @@ function peg$parse(input, options) {
peg$silentFails++;
s0 = peg$currPos;
- if (input.substr(peg$currPos, 4) === peg$c20) {
- s1 = peg$c20;
+ if (input.substr(peg$currPos, 4) === peg$c15) {
+ s1 = peg$c15;
peg$currPos += 4;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e24); }
+ if (peg$silentFails === 0) { peg$fail(peg$e17); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 6) === peg$c21) {
- s1 = peg$c21;
+ if (input.substr(peg$currPos, 6) === peg$c16) {
+ s1 = peg$c16;
peg$currPos += 6;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e25); }
+ if (peg$silentFails === 0) { peg$fail(peg$e18); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 6) === peg$c22) {
- s1 = peg$c22;
+ if (input.substr(peg$currPos, 6) === peg$c17) {
+ s1 = peg$c17;
peg$currPos += 6;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e26); }
+ if (peg$silentFails === 0) { peg$fail(peg$e19); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 9) === peg$c23) {
- s1 = peg$c23;
+ if (input.substr(peg$currPos, 9) === peg$c18) {
+ s1 = peg$c18;
peg$currPos += 9;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e27); }
+ if (peg$silentFails === 0) { peg$fail(peg$e20); }
}
if (s1 === peg$FAILED) {
- if (input.substr(peg$currPos, 8) === peg$c24) {
- s1 = peg$c24;
+ if (input.substr(peg$currPos, 8) === peg$c19) {
+ s1 = peg$c19;
peg$currPos += 8;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e28); }
+ if (peg$silentFails === 0) { peg$fail(peg$e21); }
}
}
}
@@ -943,7 +842,7 @@ function peg$parse(input, options) {
peg$silentFails--;
if (s0 === peg$FAILED) {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e23); }
+ if (peg$silentFails === 0) { peg$fail(peg$e16); }
}
return s0;
@@ -971,9 +870,110 @@ function peg$parse(input, options) {
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
- s1 = peg$f11(s1);
+ s1 = peg$f5(s1);
+ }
+ s0 = s1;
+
+ return s0;
+ }
+
+ function peg$parseoperator() {
+ var s0, s1;
+
+ peg$silentFails++;
+ s0 = peg$currPos;
+ s1 = input.charAt(peg$currPos);
+ if (peg$r0.test(s1)) {
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e23); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f6();
}
s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 2) === peg$c20) {
+ s1 = peg$c20;
+ peg$currPos += 2;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e24); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f7();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 2) === peg$c21) {
+ s1 = peg$c21;
+ peg$currPos += 2;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e25); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f8();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.charCodeAt(peg$currPos) === 62) {
+ s1 = peg$c22;
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e26); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f9();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.substr(peg$currPos, 2) === peg$c23) {
+ s1 = peg$c23;
+ peg$currPos += 2;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e27); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f10();
+ }
+ s0 = s1;
+ if (s0 === peg$FAILED) {
+ s0 = peg$currPos;
+ if (input.charCodeAt(peg$currPos) === 60) {
+ s1 = peg$c24;
+ peg$currPos++;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e28); }
+ }
+ if (s1 !== peg$FAILED) {
+ peg$savedPos = s0;
+ s1 = peg$f11();
+ }
+ s0 = s1;
+ }
+ }
+ }
+ }
+ }
+ peg$silentFails--;
+ if (s0 === peg$FAILED) {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e22); }
+ }
return s0;
}
diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy
index 32d44f24d0d6..f775117b5f4e 100644
--- a/src/libs/SearchParser/searchParser.peggy
+++ b/src/libs/SearchParser/searchParser.peggy
@@ -9,14 +9,11 @@
// defaultFilter: rule to process the default values returned by the defaultKey rule. It updates the default values object.
// freeTextFilter: rule to process the free text search values returned by the identifier rule. It builds filter Object.
// standardFilter: rule to process the values returned by the key rule. It builds filter Object.
-// operator: rule to match pre-defined search syntax operators, e.g. !=, >, etc
// key: rule to match pre-defined search syntax fields, e.g. amount, merchant, etc
// defaultKey: rule to match pre-defined search syntax fields that are used to update default values, e.g. type, status, etc
// identifier: composite rule to match patterns defined by the quotedString and alphanumeric rules
-// quotedString: rule to match a quoted string pattern, e.g. "this is a quoted string"
-// alphanumeric: rule to match unquoted alphanumeric characters, e.g. a-z, 0-9, _, @, etc
-// logicalAnd: rule to match whitespace and return it as a logical 'and' operator
-// whitespace: rule to match whitespaces
+
+// filter, logicalAnd, operator, alphanumeric, quotedStrig are defined in baseRules.peggy grammar
// global initializer (code executed only once)
{{
@@ -94,14 +91,6 @@ standardFilter
return buildFilter(op, field, values);
}
-operator "operator"
- = (":" / "=") { return "eq"; }
- / "!=" { return "neq"; }
- / ">=" { return "gte"; }
- / ">" { return "gt"; }
- / "<=" { return "lte"; }
- / "<" { return "lt"; }
-
key "key"
= @(
"date"
@@ -133,14 +122,3 @@ identifier
}
return value[0];
}
-
-quotedString "quote" = "\"" chars:[^"\r\n]* "\"" { return chars.join(""); }
-
-alphanumeric "word"
- = chars:[A-Za-z0-9_@./#&+\-\\',;%]+ {
- return chars.join("").trim().split(",").filter(Boolean);
- }
-
-logicalAnd = _ { return "and"; }
-
-_ "whitespace" = [ \t\r\n]*
diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts
index ef9f237bd551..cd5af621ef81 100644
--- a/src/libs/SearchUtils.ts
+++ b/src/libs/SearchUtils.ts
@@ -1,7 +1,7 @@
import cloneDeep from 'lodash/cloneDeep';
import type {OnyxCollection} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
-import type {ASTNode, QueryFilter, QueryFilters, SearchColumnType, SearchQueryJSON, SearchQueryString, SearchStatus, SortOrder} from '@components/Search/types';
+import type {AdvancedFiltersKeys, ASTNode, QueryFilter, QueryFilters, SearchColumnType, SearchQueryJSON, SearchQueryString, SearchStatus, SortOrder} from '@components/Search/types';
import ChatListItem from '@components/SelectionList/ChatListItem';
import ReportListItem from '@components/SelectionList/Search/ReportListItem';
import TransactionListItem from '@components/SelectionList/Search/TransactionListItem';
@@ -406,8 +406,30 @@ function isSearchResultsEmpty(searchResults: SearchResults) {
return !Object.keys(searchResults?.data).some((key) => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION));
}
-function getQueryHashFromString(query: SearchQueryString): number {
- return UserUtils.hashText(query, 2 ** 32);
+function getQueryHash(query: SearchQueryJSON): number {
+ let orderedQuery = '';
+ if (query.policyID) {
+ orderedQuery += `${CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID}:${query.policyID} `;
+ }
+ orderedQuery += `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${query.type}`;
+ orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${query.status}`;
+ orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY}:${query.sortBy}`;
+ orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_ORDER}:${query.sortOrder}`;
+
+ Object.keys(query.flatFilters)
+ .sort()
+ .forEach((key) => {
+ const filterValues = query.flatFilters?.[key as AdvancedFiltersKeys];
+ const sortedFilterValues = filterValues?.sort((queryFilter1, queryFilter2) => {
+ if (queryFilter1.value > queryFilter2.value) {
+ return 1;
+ }
+ return -1;
+ });
+ orderedQuery += ` ${buildFilterString(key, sortedFilterValues ?? [])}`;
+ });
+
+ return UserUtils.hashText(orderedQuery, 2 ** 32);
}
function getExpenseTypeTranslationKey(expenseType: ValueOf): TranslationPaths {
@@ -545,8 +567,8 @@ function buildSearchQueryJSON(query: SearchQueryString) {
// Add the full input and hash to the results
result.inputQuery = query;
- result.hash = getQueryHashFromString(query);
result.flatFilters = flatFilters;
+ result.hash = getQueryHash(result);
return result;
} catch (e) {
console.error(`Error when parsing SearchQuery: "${query}"`, e);
@@ -592,6 +614,9 @@ function buildQueryStringFromFilterFormValues(filterValues: Partial !!item)
.map((tagList) => getTagNamesFromTagsLists(tagList ?? {}))
.flat();
+ tags.push(CONST.SEARCH.EMPTY_VALUE);
filtersForm[filterKey] = filters[filterKey]?.map((tag) => tag.value.toString()).filter((name) => tags.includes(name));
}
if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY) {
const categories = policyID
? Object.values(policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`] ?? {}).map((category) => category.name)
: Object.values(policyCategories ?? {})
- .map((xd) => Object.values(xd ?? {}).map((category) => category.name))
+ .map((item) => Object.values(item ?? {}).map((category) => category.name))
.flat();
+ categories.push(CONST.SEARCH.EMPTY_VALUE);
filtersForm[filterKey] = filters[filterKey]?.map((category) => category.value.toString()).filter((name) => categories.includes(name));
}
if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD) {
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index 944f703e96cb..a9cb79923a59 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -1,6 +1,7 @@
import {Str} from 'expensify-common';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import type {PolicySelector, ReportActionsSelector} from '@hooks/useReportIDs';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -222,10 +223,21 @@ function getOrderedReportIDs(
return LHNReports;
}
-function shouldShowRedBrickRoad(report: Report, reportActions: OnyxEntry, hasViolations: boolean, transactionViolations?: OnyxCollection) {
- const hasErrors = Object.keys(ReportUtils.getAllReportErrors(report, reportActions)).length !== 0;
+type ReasonAndReportActionThatHasRedBrickRoad = {
+ reason: ValueOf;
+ reportAction?: OnyxEntry;
+};
+function getReasonAndReportActionThatHasRedBrickRoad(
+ report: Report,
+ reportActions: OnyxEntry,
+ hasViolations: boolean,
+ transactionViolations?: OnyxCollection,
+): ReasonAndReportActionThatHasRedBrickRoad | null {
+ const {errors, reportAction} = ReportUtils.getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions);
+ const hasErrors = Object.keys(errors).length !== 0;
const oneTransactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, ReportActionsUtils.getAllReportActions(report.reportID));
+
if (oneTransactionThreadReportID) {
const oneTransactionThreadReport = ReportUtils.getReport(oneTransactionThreadReportID);
@@ -236,11 +248,30 @@ function shouldShowRedBrickRoad(report: Report, reportActions: OnyxEntry, hasViolations: boolean, transactionViolations?: OnyxCollection) {
+ return !!getReasonAndReportActionThatHasRedBrickRoad(report, reportActions, hasViolations, transactionViolations);
}
/**
@@ -618,5 +649,6 @@ export default {
getOptionData,
getOrderedReportIDs,
getWelcomeMessage,
+ getReasonAndReportActionThatHasRedBrickRoad,
shouldShowRedBrickRoad,
};
diff --git a/src/libs/Sound/BaseSound.ts b/src/libs/Sound/BaseSound.ts
new file mode 100644
index 000000000000..e7fc5fadd259
--- /dev/null
+++ b/src/libs/Sound/BaseSound.ts
@@ -0,0 +1,59 @@
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+let isMuted = false;
+
+Onyx.connect({
+ key: ONYXKEYS.USER,
+ callback: (val) => (isMuted = !!val?.isMutedAllSounds),
+});
+
+const SOUNDS = {
+ DONE: 'done',
+ SUCCESS: 'success',
+ ATTENTION: 'attention',
+ RECEIVE: 'receive',
+} as const;
+
+const getIsMuted = () => isMuted;
+
+/**
+ * Creates a version of the given function that, when called, queues the execution and ensures that
+ * calls are spaced out by at least the specified `minExecutionTime`, even if called more frequently. This allows
+ * for throttling frequent calls to a function, ensuring each is executed with a minimum `minExecutionTime` between calls.
+ * Each call returns a promise that resolves when the function call is executed, allowing for asynchronous handling.
+ */
+function withMinimalExecutionTime) => ReturnType>(func: F, minExecutionTime: number) {
+ const queue: Array<[() => ReturnType, (value?: unknown) => void]> = [];
+ let timerId: NodeJS.Timeout | null = null;
+
+ function processQueue() {
+ if (queue.length > 0) {
+ const next = queue.shift();
+
+ if (!next) {
+ return;
+ }
+
+ const [nextFunc, resolve] = next;
+ nextFunc();
+ resolve();
+ timerId = setTimeout(processQueue, minExecutionTime);
+ } else {
+ timerId = null;
+ }
+ }
+
+ return function (...args: Parameters) {
+ return new Promise((resolve) => {
+ queue.push([() => func(...args), resolve]);
+
+ if (!timerId) {
+ // If the timer isn't running, start processing the queue
+ processQueue();
+ }
+ });
+ };
+}
+
+export {SOUNDS, withMinimalExecutionTime, getIsMuted};
diff --git a/src/libs/Sound/index.native.ts b/src/libs/Sound/index.native.ts
new file mode 100644
index 000000000000..f9e0db31b9b0
--- /dev/null
+++ b/src/libs/Sound/index.native.ts
@@ -0,0 +1,19 @@
+import Sound from 'react-native-sound';
+import type {ValueOf} from 'type-fest';
+import {getIsMuted, SOUNDS, withMinimalExecutionTime} from './BaseSound';
+import config from './config';
+
+const playSound = (soundFile: ValueOf) => {
+ const sound = new Sound(`${config.prefix}${soundFile}.mp3`, Sound.MAIN_BUNDLE, (error) => {
+ if (error || getIsMuted()) {
+ return;
+ }
+
+ sound.play();
+ });
+};
+
+function clearSoundAssetsCache() {}
+
+export {SOUNDS, clearSoundAssetsCache};
+export default withMinimalExecutionTime(playSound, 300);
diff --git a/src/libs/Sound/index.ts b/src/libs/Sound/index.ts
index 4639887e831c..39ff567df68e 100644
--- a/src/libs/Sound/index.ts
+++ b/src/libs/Sound/index.ts
@@ -1,71 +1,94 @@
-import Onyx from 'react-native-onyx';
-import Sound from 'react-native-sound';
+import {Howl} from 'howler';
import type {ValueOf} from 'type-fest';
-import ONYXKEYS from '@src/ONYXKEYS';
+import Log from '@libs/Log';
+import {getIsMuted, SOUNDS, withMinimalExecutionTime} from './BaseSound';
import config from './config';
-let isMuted = false;
+function cacheSoundAssets() {
+ // Exit early if the Cache API is not available in the current browser.
+ if (!('caches' in window)) {
+ return;
+ }
-Onyx.connect({
- key: ONYXKEYS.USER,
- callback: (val) => (isMuted = !!val?.isMutedAllSounds),
-});
+ caches.open('sound-assets').then((cache) => {
+ const soundFiles = Object.values(SOUNDS).map((sound) => `${config.prefix}${sound}.mp3`);
-const SOUNDS = {
- DONE: 'done',
- SUCCESS: 'success',
- ATTENTION: 'attention',
- RECEIVE: 'receive',
-} as const;
+ // Cache each sound file if it's not already cached.
+ const cachePromises = soundFiles.map((soundFile) => {
+ return cache.match(soundFile).then((response) => {
+ if (response) {
+ return;
+ }
+ return cache.add(soundFile);
+ });
+ });
-/**
- * Creates a version of the given function that, when called, queues the execution and ensures that
- * calls are spaced out by at least the specified `minExecutionTime`, even if called more frequently. This allows
- * for throttling frequent calls to a function, ensuring each is executed with a minimum `minExecutionTime` between calls.
- * Each call returns a promise that resolves when the function call is executed, allowing for asynchronous handling.
- */
-function withMinimalExecutionTime) => ReturnType>(func: F, minExecutionTime: number) {
- const queue: Array<[() => ReturnType, (value?: unknown) => void]> = [];
- let timerId: NodeJS.Timeout | null = null;
+ return Promise.all(cachePromises);
+ });
+}
- function processQueue() {
- if (queue.length > 0) {
- const next = queue.shift();
+const initializeAndPlaySound = (src: string) => {
+ const sound = new Howl({
+ src: [src],
+ format: ['mp3'],
+ onloaderror: (_id: number, error: unknown) => {
+ Log.alert('[sound] Load error:', {message: (error as Error).message});
+ },
+ onplayerror: (_id: number, error: unknown) => {
+ Log.alert('[sound] Play error:', {message: (error as Error).message});
+ },
+ });
+ sound.play();
+};
- if (!next) {
+const playSound = (soundFile: ValueOf) => {
+ if (getIsMuted()) {
+ return;
+ }
+
+ const soundSrc = `${config.prefix}${soundFile}.mp3`;
+
+ if (!('caches' in window)) {
+ // Fallback to fetching from network if not in cache
+ initializeAndPlaySound(soundSrc);
+ return;
+ }
+
+ caches.open('sound-assets').then((cache) => {
+ cache.match(soundSrc).then((response) => {
+ if (response) {
+ response.blob().then((soundBlob) => {
+ const soundUrl = URL.createObjectURL(soundBlob);
+ initializeAndPlaySound(soundUrl);
+ });
return;
}
+ initializeAndPlaySound(soundSrc);
+ });
+ });
+};
- const [nextFunc, resolve] = next;
- nextFunc();
- resolve();
- timerId = setTimeout(processQueue, minExecutionTime);
- } else {
- timerId = null;
- }
+function clearSoundAssetsCache() {
+ // Exit early if the Cache API is not available in the current browser.
+ if (!('caches' in window)) {
+ return;
}
- return function (...args: Parameters) {
- return new Promise((resolve) => {
- queue.push([() => func(...args), resolve]);
-
- if (!timerId) {
- // If the timer isn't running, start processing the queue
- processQueue();
+ caches
+ .delete('sound-assets')
+ .then((success) => {
+ if (success) {
+ return;
}
+ Log.alert('[sound] Failed to clear sound assets cache.');
+ })
+ .catch((error) => {
+ Log.alert('[sound] Error clearing sound assets cache:', {message: (error as Error).message});
});
- };
}
-const playSound = (soundFile: ValueOf) => {
- const sound = new Sound(`${config.prefix}${soundFile}.mp3`, Sound.MAIN_BUNDLE, (error) => {
- if (error || isMuted) {
- return;
- }
-
- sound.play();
- });
-};
+// Cache sound assets on load
+cacheSoundAssets();
-export {SOUNDS};
+export {SOUNDS, clearSoundAssetsCache};
export default withMinimalExecutionTime(playSound, 300);
diff --git a/src/libs/SuffixUkkonenTree/index.ts b/src/libs/SuffixUkkonenTree/index.ts
deleted file mode 100644
index bcefd1008493..000000000000
--- a/src/libs/SuffixUkkonenTree/index.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-/* eslint-disable rulesdir/prefer-at */
-// .at() has a performance overhead we explicitly want to avoid here
-
-/* eslint-disable no-continue */
-import {ALPHABET_SIZE, DELIMITER_CHAR_CODE, END_CHAR_CODE, SPECIAL_CHAR_CODE, stringToNumeric} from './utils';
-
-/**
- * This implements a suffix tree using Ukkonen's algorithm.
- * A good visualization to learn about the algorithm can be found here: https://brenden.github.io/ukkonen-animation/
- * A good video explaining Ukkonen's algorithm can be found here: https://www.youtube.com/watch?v=ALEV0Hc5dDk
- * Note: This implementation is optimized for performance, not necessarily for readability.
- *
- * You probably don't want to use this directly, but rather use @libs/FastSearch.ts as a easy to use wrapper around this.
- */
-
-/**
- * Creates a new tree instance that can be used to build a suffix tree and search in it.
- * The input is a numeric representation of the search string, which can be created using {@link stringToNumeric}.
- * Separate search values must be separated by the {@link DELIMITER_CHAR_CODE}. The search string must end with the {@link END_CHAR_CODE}.
- *
- * The tree will be built using the Ukkonen's algorithm: https://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf
- */
-function makeTree(numericSearchValues: Uint8Array) {
- // Every leaf represents a suffix. There can't be more than n suffixes.
- // Every internal node has to have at least 2 children. So the total size of ukkonen tree is not bigger than 2n - 1.
- // + 1 is because an extra character at the beginning to offset the 1-based indexing.
- const maxNodes = 2 * numericSearchValues.length + 1;
- /*
- This array represents all internal nodes in the suffix tree.
- When building this tree, we'll be given a character in the string, and we need to be able to lookup in constant time
- if there's any edge connected to a node starting with that character. For example, given a tree like this:
-
- root
- / | \
- a b c
-
- and the next character in our string is 'd', we need to be able do check if any of the edges from the root node
- start with the letter 'd', without looping through all the edges.
-
- To accomplish this, each node gets an array matching the alphabet size.
- So you can imagine if our alphabet was just [a,b,c,d], then each node would get an array like [0,0,0,0].
- If we add an edge starting with 'a', then the root node would be [1,0,0,0]
- So given an arbitrary letter such as 'd', then we can take the position of that letter in its alphabet (position 3 in our example)
- and check whether that index in the array is 0 or 1. If it's a 1, then there's an edge starting with the letter 'd'.
-
- Note that for efficiency, all nodes are stored in a single flat array. That's how we end up with (maxNodes * alphabet_size).
- In the example of a 4-character alphabet, we'd have an array like this:
-
- root root.left root.right last possible node
- / \ / \ / \ / \
- [0,0,0,0, 0,0,0,0, 0,0,0,0, ................. 0,0,0,0]
- */
- const transitionNodes = new Uint32Array(maxNodes * ALPHABET_SIZE);
-
- // Storing the range of the original string that each node represents:
- const rangeStart = new Uint32Array(maxNodes);
- const rangeEnd = new Uint32Array(maxNodes);
-
- const parent = new Uint32Array(maxNodes);
- const suffixLink = new Uint32Array(maxNodes);
-
- let currentNode = 1;
- let currentPosition = 1;
- let nodeCounter = 3;
- let currentIndex = 1;
-
- function initializeTree() {
- rangeEnd.fill(numericSearchValues.length);
- rangeEnd[1] = 0;
- rangeEnd[2] = 0;
- suffixLink[1] = 2;
- for (let i = 0; i < ALPHABET_SIZE; ++i) {
- transitionNodes[ALPHABET_SIZE * 2 + i] = 1;
- }
- }
-
- function processCharacter(char: number) {
- // eslint-disable-next-line no-constant-condition
- while (true) {
- if (rangeEnd[currentNode] < currentPosition) {
- if (transitionNodes[currentNode * ALPHABET_SIZE + char] === 0) {
- createNewLeaf(char);
- continue;
- }
- currentNode = transitionNodes[currentNode * ALPHABET_SIZE + char];
- currentPosition = rangeStart[currentNode];
- }
- if (currentPosition === 0 || char === numericSearchValues[currentPosition]) {
- currentPosition++;
- } else {
- splitEdge(char);
- continue;
- }
- break;
- }
- }
-
- function createNewLeaf(c: number) {
- transitionNodes[currentNode * ALPHABET_SIZE + c] = nodeCounter;
- rangeStart[nodeCounter] = currentIndex;
- parent[nodeCounter++] = currentNode;
- currentNode = suffixLink[currentNode];
-
- currentPosition = rangeEnd[currentNode] + 1;
- }
-
- function splitEdge(c: number) {
- rangeStart[nodeCounter] = rangeStart[currentNode];
- rangeEnd[nodeCounter] = currentPosition - 1;
- parent[nodeCounter] = parent[currentNode];
-
- transitionNodes[nodeCounter * ALPHABET_SIZE + numericSearchValues[currentPosition]] = currentNode;
- transitionNodes[nodeCounter * ALPHABET_SIZE + c] = nodeCounter + 1;
- rangeStart[nodeCounter + 1] = currentIndex;
- parent[nodeCounter + 1] = nodeCounter;
- rangeStart[currentNode] = currentPosition;
- parent[currentNode] = nodeCounter;
-
- transitionNodes[parent[nodeCounter] * ALPHABET_SIZE + numericSearchValues[rangeStart[nodeCounter]]] = nodeCounter;
- nodeCounter += 2;
- handleDescent(nodeCounter);
- }
-
- function handleDescent(latestNodeIndex: number) {
- currentNode = suffixLink[parent[latestNodeIndex - 2]];
- currentPosition = rangeStart[latestNodeIndex - 2];
- while (currentPosition <= rangeEnd[latestNodeIndex - 2]) {
- currentNode = transitionNodes[currentNode * ALPHABET_SIZE + numericSearchValues[currentPosition]];
- currentPosition += rangeEnd[currentNode] - rangeStart[currentNode] + 1;
- }
- if (currentPosition === rangeEnd[latestNodeIndex - 2] + 1) {
- suffixLink[latestNodeIndex - 2] = currentNode;
- } else {
- suffixLink[latestNodeIndex - 2] = latestNodeIndex;
- }
- currentPosition = rangeEnd[currentNode] - (currentPosition - rangeEnd[latestNodeIndex - 2]) + 2;
- }
-
- function build() {
- initializeTree();
- for (currentIndex = 1; currentIndex < numericSearchValues.length; ++currentIndex) {
- const c = numericSearchValues[currentIndex];
- processCharacter(c);
- }
- }
-
- /**
- * Returns all occurrences of the given (sub)string in the input string.
- *
- * You can think of the tree that we create as a big string that looks like this:
- *
- * "banana$pancake$apple|"
- * The example delimiter character '$' is used to separate the different strings.
- * The end character '|' is used to indicate the end of our search string.
- *
- * This function will return the index(es) of found occurrences within this big string.
- * So, when searching for "an", it would return [1, 3, 8].
- */
- function findSubstring(searchValue: number[]) {
- const occurrences: number[] = [];
-
- function dfs(node: number, depth: number) {
- const leftRange = rangeStart[node];
- const rightRange = rangeEnd[node];
- const rangeLen = node === 1 ? 0 : rightRange - leftRange + 1;
-
- for (let i = 0; i < rangeLen && depth + i < searchValue.length && leftRange + i < numericSearchValues.length; i++) {
- if (searchValue[depth + i] !== numericSearchValues[leftRange + i]) {
- return;
- }
- }
-
- let isLeaf = true;
- for (let i = 0; i < ALPHABET_SIZE; ++i) {
- const tNode = transitionNodes[node * ALPHABET_SIZE + i];
-
- // Search speed optimization: don't go through the edge if it's different than the next char:
- const correctChar = depth + rangeLen >= searchValue.length || i === searchValue[depth + rangeLen];
-
- if (tNode !== 0 && tNode !== 1 && correctChar) {
- isLeaf = false;
- dfs(tNode, depth + rangeLen);
- }
- }
-
- if (isLeaf && depth + rangeLen >= searchValue.length) {
- occurrences.push(numericSearchValues.length - (depth + rangeLen) + 1);
- }
- }
-
- dfs(1, 0);
- return occurrences;
- }
-
- return {
- build,
- findSubstring,
- };
-}
-
-const SuffixUkkonenTree = {
- makeTree,
-
- // Re-exported from utils:
- DELIMITER_CHAR_CODE,
- SPECIAL_CHAR_CODE,
- END_CHAR_CODE,
- stringToNumeric,
-};
-
-export default SuffixUkkonenTree;
diff --git a/src/libs/SuffixUkkonenTree/utils.ts b/src/libs/SuffixUkkonenTree/utils.ts
deleted file mode 100644
index 96ee35b15796..000000000000
--- a/src/libs/SuffixUkkonenTree/utils.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-/* eslint-disable rulesdir/prefer-at */ // .at() has a performance overhead we explicitly want to avoid here
-/* eslint-disable no-continue */
-
-const CHAR_CODE_A = 'a'.charCodeAt(0);
-const ALPHABET = 'abcdefghijklmnopqrstuvwxyz';
-const LETTER_ALPHABET_SIZE = ALPHABET.length;
-const ALPHABET_SIZE = LETTER_ALPHABET_SIZE + 3; // +3: special char, delimiter char, end char
-const SPECIAL_CHAR_CODE = ALPHABET_SIZE - 3;
-const DELIMITER_CHAR_CODE = ALPHABET_SIZE - 2;
-const END_CHAR_CODE = ALPHABET_SIZE - 1;
-
-// Store the results for a char code in a lookup table to avoid recalculating the same values (performance optimization)
-const base26LookupTable = new Array();
-
-/**
- * Converts a number to a base26 representation.
- */
-function convertToBase26(num: number): number[] {
- if (base26LookupTable[num]) {
- return base26LookupTable[num];
- }
- if (num < 0) {
- throw new Error('convertToBase26: Input must be a non-negative integer');
- }
-
- const result: number[] = [];
-
- do {
- // eslint-disable-next-line no-param-reassign
- num--;
- result.unshift(num % 26);
- // eslint-disable-next-line no-bitwise, no-param-reassign
- num >>= 5; // Equivalent to Math.floor(num / 26), but faster
- } while (num > 0);
-
- base26LookupTable[num] = result;
- return result;
-}
-
-/**
- * Converts a string to an array of numbers representing the characters of the string.
- * Every number in the array is in the range [0, ALPHABET_SIZE-1] (0-28).
- *
- * The numbers are offset by the character code of 'a' (97).
- * - This is so that the numbers from a-z are in the range 0-28.
- * - 26 is for encoding special characters. Character numbers that are not within the range of a-z will be encoded as "specialCharacter + base26(charCode)"
- * - 27 is for the delimiter character
- * - 28 is for the end character
- *
- * Note: The string should be converted to lowercase first (otherwise uppercase letters get base26'ed taking more space than necessary).
- */
-function stringToNumeric(
- // The string we want to convert to a numeric representation
- input: string,
- options?: {
- // A set of characters that should be skipped and not included in the numeric representation
- charSetToSkip?: Set;
- // When out is provided, the function will write the result to the provided arrays instead of creating new ones (performance)
- out?: {
- outArray: Uint8Array;
- // As outArray is a ArrayBuffer we need to keep track of the current offset
- offset: {value: number};
- // A map of to map the found occurrences to the correct data set
- // As the search string can be very long for high traffic accounts (500k+), this has to be big enough, thus its a Uint32Array
- outOccurrenceToIndex?: Uint32Array;
- // The index that will be used in the outOccurrenceToIndex array (this is the index of your original data position)
- index?: number;
- };
- // By default false. By default the outArray may be larger than necessary. If clamp is set to true the outArray will be clamped to the actual size.
- clamp?: boolean;
- },
-): {
- numeric: Uint8Array;
- occurrenceToIndex: Uint32Array;
- offset: {value: number};
-} {
- // The out array might be longer than our input string length, because we encode special characters as multiple numbers using the base26 encoding.
- // * 6 is because the upper limit of encoding any char in UTF-8 to base26 is at max 6 numbers.
- const outArray = options?.out?.outArray ?? new Uint8Array(input.length * 6);
- const offset = options?.out?.offset ?? {value: 0};
- const occurrenceToIndex = options?.out?.outOccurrenceToIndex ?? new Uint32Array(input.length * 16 * 4);
- const index = options?.out?.index ?? 0;
-
- for (let i = 0; i < input.length; i++) {
- const char = input[i];
-
- if (options?.charSetToSkip?.has(char)) {
- continue;
- }
-
- if (char >= 'a' && char <= 'z') {
- // char is an alphabet character
- occurrenceToIndex[offset.value] = index;
- outArray[offset.value++] = char.charCodeAt(0) - CHAR_CODE_A;
- } else {
- const charCode = input.charCodeAt(i);
- occurrenceToIndex[offset.value] = index;
- outArray[offset.value++] = SPECIAL_CHAR_CODE;
- const asBase26Numeric = convertToBase26(charCode);
- // eslint-disable-next-line @typescript-eslint/prefer-for-of
- for (let j = 0; j < asBase26Numeric.length; j++) {
- occurrenceToIndex[offset.value] = index;
- outArray[offset.value++] = asBase26Numeric[j];
- }
- }
- }
-
- return {
- numeric: options?.clamp ? outArray.slice(0, offset.value) : outArray,
- occurrenceToIndex,
- offset,
- };
-}
-
-export {stringToNumeric, ALPHABET, ALPHABET_SIZE, SPECIAL_CHAR_CODE, DELIMITER_CHAR_CODE, END_CHAR_CODE};
diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts
index f2ce5113af81..b7f754f9cac6 100644
--- a/src/libs/TripReservationUtils.ts
+++ b/src/libs/TripReservationUtils.ts
@@ -1,6 +1,5 @@
import {Str} from 'expensify-common';
import type {Dispatch, SetStateAction} from 'react';
-import {NativeModules} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {LocaleContextProps} from '@components/LocaleContextProvider';
@@ -14,7 +13,6 @@ import type Transaction from '@src/types/onyx/Transaction';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type IconAsset from '@src/types/utils/IconAsset';
import * as Link from './actions/Link';
-import Log from './Log';
import Navigation from './Navigation/Navigation';
import * as PolicyUtils from './PolicyUtils';
@@ -42,14 +40,6 @@ Onyx.connect({
},
});
-let isSingleNewDotEntry: boolean | undefined;
-Onyx.connect({
- key: ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY,
- callback: (val) => {
- isSingleNewDotEntry = val;
- },
-});
-
function getTripReservationIcon(reservationType: ReservationType): IconAsset {
switch (reservationType) {
case CONST.RESERVATION_TYPE.FLIGHT:
@@ -101,17 +91,8 @@ function bookATrip(translate: LocaleContextProps['translate'], setCtaErrorMessag
if (ctaErrorMessage) {
setCtaErrorMessage('');
}
- Link.openTravelDotLink(activePolicyID)
- ?.then(() => {
- if (!NativeModules.HybridAppModule || !isSingleNewDotEntry) {
- return;
- }
-
- Log.info('[HybridApp] Returning to OldDot after opening TravelDot');
- NativeModules.HybridAppModule.closeReactNativeApp(false, false);
- })
- ?.catch(() => {
- setCtaErrorMessage(translate('travel.errorMessage'));
- });
+ Link.openTravelDotLink(activePolicyID)?.catch(() => {
+ setCtaErrorMessage(translate('travel.errorMessage'));
+ });
}
export {getTripReservationIcon, getReservationsFromTripTransactions, getTripEReceiptIcon, bookATrip};
diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts
index 2be641035be7..a27d518fe727 100644
--- a/src/libs/WorkspacesSettingsUtils.ts
+++ b/src/libs/WorkspacesSettingsUtils.ts
@@ -119,7 +119,7 @@ function hasWorkspaceSettingsRBR(policy: Policy) {
return Object.keys(reimbursementAccount?.errors ?? {}).length > 0 || hasPolicyError(policy) || hasCustomUnitsError(policy) || policyMemberError || taxRateError;
}
-function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined {
+function getChatTabBrickRoadReport(policyID?: string): OnyxEntry {
const allReports = ReportConnection.getAllReports();
if (!allReports) {
return undefined;
@@ -128,27 +128,33 @@ function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined {
// If policyID is undefined, then all reports are checked whether they contain any brick road
const policyReports = policyID ? Object.values(allReports).filter((report) => report?.policyID === policyID) : Object.values(allReports);
- let hasChatTabGBR = false;
+ let reportWithGBR: OnyxEntry;
- const hasChatTabRBR = policyReports.some((report) => {
+ const reportWithRBR = policyReports.find((report) => {
const brickRoad = report ? getBrickRoadForPolicy(report) : undefined;
- if (!hasChatTabGBR && brickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) {
- hasChatTabGBR = true;
+ if (!reportWithGBR && brickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) {
+ reportWithGBR = report;
+ return false;
}
return brickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
});
- if (hasChatTabRBR) {
- return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
+ if (reportWithRBR) {
+ return reportWithRBR;
}
- if (hasChatTabGBR) {
- return CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
+ if (reportWithGBR) {
+ return reportWithGBR;
}
return undefined;
}
+function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined {
+ const report = getChatTabBrickRoadReport(policyID);
+ return report ? getBrickRoadForPolicy(report) : undefined;
+}
+
/**
* @returns a map where the keys are policyIDs and the values are BrickRoads for each policy
*/
@@ -296,6 +302,7 @@ function getOwnershipChecksDisplayText(
}
export {
+ getChatTabBrickRoadReport,
getBrickRoadForPolicy,
getWorkspacesBrickRoads,
getWorkspacesUnreadStatuses,
diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts
index 71cb5f97e00e..77427b5f42cc 100644
--- a/src/libs/actions/App.ts
+++ b/src/libs/actions/App.ts
@@ -17,6 +17,7 @@ import Navigation from '@libs/Navigation/Navigation';
import Performance from '@libs/Performance';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as SessionUtils from '@libs/SessionUtils';
+import {clearSoundAssetsCache} from '@libs/Sound';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {OnyxKey} from '@src/ONYXKEYS';
@@ -257,7 +258,7 @@ function openApp() {
return getPolicyParamsForOpenOrReconnect().then((policyParams: PolicyParamsForOpenOrReconnect) => {
const params: OpenAppParams = {enablePriorityModeFilter: true, ...policyParams};
return API.write(WRITE_COMMANDS.OPEN_APP, params, getOnyxDataForOpenOrReconnect(true), {
- checkAndFixConflictingRequest: (persistedRequests) => resolveDuplicationConflictAction(persistedRequests, WRITE_COMMANDS.OPEN_APP),
+ checkAndFixConflictingRequest: (persistedRequests) => resolveDuplicationConflictAction(persistedRequests, (request) => request.command === WRITE_COMMANDS.OPEN_APP),
});
});
}
@@ -287,7 +288,7 @@ function reconnectApp(updateIDFrom: OnyxEntry = 0) {
}
API.write(WRITE_COMMANDS.RECONNECT_APP, params, getOnyxDataForOpenOrReconnect(), {
- checkAndFixConflictingRequest: (persistedRequests) => resolveDuplicationConflictAction(persistedRequests, WRITE_COMMANDS.RECONNECT_APP),
+ checkAndFixConflictingRequest: (persistedRequests) => resolveDuplicationConflictAction(persistedRequests, (request) => request.command === WRITE_COMMANDS.RECONNECT_APP),
});
});
}
@@ -546,6 +547,7 @@ function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) {
});
});
});
+ clearSoundAssetsCache();
}
export {
diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts
index 2a0ab6defa12..d588e3195588 100644
--- a/src/libs/actions/Card.ts
+++ b/src/libs/actions/Card.ts
@@ -223,7 +223,11 @@ function revealVirtualCardDetails(cardID: number, validateCode: string): Promise
];
// eslint-disable-next-line rulesdir/no-api-side-effects-method
- API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REVEAL_EXPENSIFY_CARD_DETAILS, parameters, {optimisticData, successData, failureData})
+ API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REVEAL_EXPENSIFY_CARD_DETAILS, parameters, {
+ optimisticData,
+ successData,
+ failureData,
+ })
.then((response) => {
if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) {
if (response?.jsonCode === CONST.JSON_CODE.INCORRECT_MAGIC_CODE) {
@@ -336,7 +340,7 @@ function getCardDefaultName(userName?: string) {
}
function setIssueNewCardStepAndData({data, isEditing, step}: IssueNewCardFlowData) {
- Onyx.merge(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {data, isEditing, currentStep: step});
+ Onyx.merge(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {data, isEditing, currentStep: step, errors: null});
}
function clearIssueNewCardFlow() {
@@ -625,6 +629,40 @@ function issueExpensifyCard(policyID: string, feedCountry: string, data?: IssueN
const {assigneeEmail, limit, limitType, cardTitle, cardType} = data;
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD,
+ value: {
+ isLoading: true,
+ errors: null,
+ isSuccessful: null,
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD,
+ value: {
+ isLoading: false,
+ isSuccessful: true,
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD,
+ value: {
+ isLoading: false,
+ errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
+ },
+ },
+ ];
+
const parameters = {
policyID,
assigneeEmail,
@@ -634,14 +672,30 @@ function issueExpensifyCard(policyID: string, feedCountry: string, data?: IssueN
};
if (cardType === CONST.EXPENSIFY_CARD.CARD_TYPE.PHYSICAL) {
- API.write(WRITE_COMMANDS.CREATE_EXPENSIFY_CARD, {...parameters, feedCountry});
+ API.write(
+ WRITE_COMMANDS.CREATE_EXPENSIFY_CARD,
+ {...parameters, feedCountry},
+ {
+ optimisticData,
+ successData,
+ failureData,
+ },
+ );
return;
}
const domainAccountID = PolicyUtils.getWorkspaceAccountID(policyID);
// eslint-disable-next-line rulesdir/no-multiple-api-calls
- API.write(WRITE_COMMANDS.CREATE_ADMIN_ISSUED_VIRTUAL_CARD, {...parameters, domainAccountID});
+ API.write(
+ WRITE_COMMANDS.CREATE_ADMIN_ISSUED_VIRTUAL_CARD,
+ {...parameters, domainAccountID},
+ {
+ optimisticData,
+ successData,
+ failureData,
+ },
+ );
}
function openCardDetailsPage(cardID: number) {
diff --git a/src/libs/actions/CompanyCards.ts b/src/libs/actions/CompanyCards.ts
index 3c3e239d90d7..a106fbeff510 100644
--- a/src/libs/actions/CompanyCards.ts
+++ b/src/libs/actions/CompanyCards.ts
@@ -1,7 +1,25 @@
import Onyx from 'react-native-onyx';
+import type {OnyxUpdate} from 'react-native-onyx';
+import * as API from '@libs/API';
+import type {
+ AssignCompanyCardParams,
+ OpenPolicyCompanyCardsFeedParams,
+ OpenPolicyExpensifyCardsPageParams,
+ RequestFeedSetupParams,
+ SetCompanyCardExportAccountParams,
+ UpdateCompanyCardNameParams,
+} from '@libs/API/parameters';
+import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import * as NetworkStore from '@libs/Network/NetworkStore';
+import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import * as ReportUtils from '@libs/ReportUtils';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {AssignCard} from '@src/types/onyx/AssignCard';
-import type {AddNewCardFeedData, AddNewCardFeedStep} from '@src/types/onyx/CardFeeds';
+import type {AssignCard, AssignCardData} from '@src/types/onyx/AssignCard';
+import type {AddNewCardFeedData, AddNewCardFeedStep, CompanyCardFeed} from '@src/types/onyx/CardFeeds';
+import type {OnyxData} from '@src/types/onyx/Request';
type AddNewCompanyCardFlowData = {
/** Step to be set in Onyx */
@@ -33,4 +51,542 @@ function clearAddNewCardFlow() {
});
}
-export {setAddNewCompanyCardStepAndData, clearAddNewCardFlow, setAssignCardStepAndData, clearAssignCardStepAndData};
+function addNewCompanyCardsFeed(policyID: string, feedType: string, feedDetails: string) {
+ const authToken = NetworkStore.getAuthToken();
+
+ if (!authToken) {
+ return;
+ }
+
+ const parameters: RequestFeedSetupParams = {
+ policyID,
+ authToken,
+ feedType,
+ feedDetails,
+ };
+
+ API.write(WRITE_COMMANDS.REQUEST_FEED_SETUP, parameters);
+}
+
+function setWorkspaceCompanyCardFeedName(policyID: string, workspaceAccountID: number, bankName: string, userDefinedName: string) {
+ const authToken = NetworkStore.getAuthToken();
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
+ value: {
+ settings: {
+ companyCardNicknames: {
+ [bankName]: userDefinedName,
+ },
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ authToken,
+ policyID,
+ bankName,
+ userDefinedName,
+ };
+
+ API.write(WRITE_COMMANDS.SET_COMPANY_CARD_FEED_NAME, parameters, onyxData);
+}
+
+function setWorkspaceCompanyCardTransactionLiability(workspaceAccountID: number, policyID: string, bankName: string, liabilityType: string) {
+ const authToken = NetworkStore.getAuthToken();
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
+ value: {
+ settings: {
+ companyCards: {
+ [bankName]: {liabilityType},
+ },
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ authToken,
+ policyID,
+ bankName,
+ liabilityType,
+ };
+
+ API.write(WRITE_COMMANDS.SET_COMPANY_CARD_TRANSACTION_LIABILITY, parameters, onyxData);
+}
+
+function deleteWorkspaceCompanyCardFeed(policyID: string, workspaceAccountID: number, bankName: string) {
+ const authToken = NetworkStore.getAuthToken();
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
+ value: {
+ settings: {
+ companyCards: {
+ [bankName]: null,
+ },
+ companyCardNicknames: {
+ [bankName]: null,
+ },
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ authToken,
+ policyID,
+ bankName,
+ };
+
+ API.write(WRITE_COMMANDS.DELETE_COMPANY_CARD_FEED, parameters, onyxData);
+}
+
+function assignWorkspaceCompanyCard(policyID: string, data?: Partial) {
+ if (!data) {
+ return;
+ }
+ const {bankName = '', email = '', encryptedCardNumber = '', startDate = ''} = data;
+ const assigneeDetails = PersonalDetailsUtils.getPersonalDetailByEmail(email);
+ const optimisticCardAssignedReportAction = ReportUtils.buildOptimisticCardAssignedReportAction(assigneeDetails?.accountID ?? -1);
+
+ const parameters: AssignCompanyCardParams = {
+ policyID,
+ bankName,
+ encryptedCardNumber,
+ email,
+ startDate,
+ reportActionID: optimisticCardAssignedReportAction.reportActionID,
+ };
+ const policy = PolicyUtils.getPolicy(policyID);
+ const policyExpenseChat = ReportUtils.getPolicyExpenseChat(policy?.ownerAccountID ?? -1, policyID);
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${policyExpenseChat?.reportID}`,
+ value: {
+ [optimisticCardAssignedReportAction.reportActionID]: optimisticCardAssignedReportAction,
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${policyExpenseChat?.reportID}`,
+ value: {[optimisticCardAssignedReportAction.reportActionID]: {pendingAction: null}},
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${policyExpenseChat?.reportID}`,
+ value: {
+ [optimisticCardAssignedReportAction.reportActionID]: {
+ pendingAction: null,
+ errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
+ },
+ },
+ },
+ ],
+ };
+
+ API.write(WRITE_COMMANDS.ASSIGN_COMPANY_CARD, parameters, onyxData);
+}
+
+function unassignWorkspaceCompanyCard(workspaceAccountID: number, cardID: string, bankName: string) {
+ const authToken = NetworkStore.getAuthToken();
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
+ value: {
+ [cardID]: null,
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ authToken,
+ cardID,
+ };
+
+ API.write(WRITE_COMMANDS.UNASSIGN_COMPANY_CARD, parameters, onyxData);
+}
+
+function updateWorkspaceCompanyCard(workspaceAccountID: number, cardID: string, bankName: string) {
+ const authToken = NetworkStore.getAuthToken();
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
+ value: {
+ [cardID]: {
+ isLoadingLastUpdated: true,
+ pendingFields: {
+ lastScrape: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ errorFields: {
+ lastScrape: null,
+ },
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.CARD_LIST,
+ value: {
+ [cardID]: {
+ isLoadingLastUpdated: true,
+ pendingFields: {
+ lastScrape: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ errorFields: {
+ lastScrape: null,
+ },
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
+ value: {
+ settings: {
+ companyCards: {
+ [bankName]: {
+ errors: null,
+ },
+ },
+ },
+ },
+ },
+ ];
+
+ const finallyData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
+ value: {
+ [cardID]: {
+ isLoadingLastUpdated: false,
+ pendingFields: {
+ lastScrape: null,
+ },
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.CARD_LIST,
+ value: {
+ [cardID]: {
+ isLoadingLastUpdated: false,
+ pendingFields: {
+ lastScrape: null,
+ },
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
+ value: {
+ [cardID]: {
+ isLoadingLastUpdated: false,
+ pendingFields: {
+ lastScrape: null,
+ },
+ errorFields: {
+ lastScrape: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
+ },
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.CARD_LIST,
+ value: {
+ [cardID]: {
+ isLoadingLastUpdated: false,
+ pendingFields: {
+ lastScrape: null,
+ },
+ errorFields: {
+ lastScrape: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
+ },
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
+ value: {
+ settings: {
+ companyCards: {
+ [bankName]: {
+ errors: {error: CONST.COMPANY_CARDS.CONNECTION_ERROR},
+ },
+ },
+ },
+ },
+ },
+ ];
+
+ const parameters = {
+ authToken,
+ cardID,
+ };
+
+ API.write(WRITE_COMMANDS.UPDATE_COMPANY_CARD, parameters, {optimisticData, finallyData, failureData});
+}
+
+function updateCompanyCardName(workspaceAccountID: number, cardID: string, newCardTitle: string, bankName: string, oldCardTitle?: string) {
+ const authToken = NetworkStore.getAuthToken();
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
+ value: {
+ [cardID]: {
+ nameValuePairs: {
+ cardTitle: newCardTitle,
+ pendingFields: {
+ cardTitle: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ errorFields: {
+ cardTitle: null,
+ },
+ },
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES,
+ value: {[cardID]: newCardTitle},
+ },
+ ];
+
+ const finallyData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
+ value: {
+ [cardID]: {
+ nameValuePairs: {
+ pendingFields: {
+ cardTitle: null,
+ },
+ },
+ },
+ },
+ },
+ ];
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
+ value: {
+ [cardID]: {
+ nameValuePairs: {
+ pendingFields: {
+ cardTitle: null,
+ },
+ errorFields: {
+ cardTitle: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
+ },
+ },
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES,
+ value: {[cardID]: oldCardTitle},
+ },
+ ];
+
+ const parameters: UpdateCompanyCardNameParams = {
+ authToken,
+ cardID: Number(cardID),
+ cardName: newCardTitle,
+ };
+
+ API.write(WRITE_COMMANDS.UPDATE_COMPANY_CARD_NAME, parameters, {optimisticData, finallyData, failureData});
+}
+
+function setCompanyCardExportAccount(workspaceAccountID: number, cardID: string, accountKey: string, newAccount: string, bankName: string) {
+ const authToken = NetworkStore.getAuthToken();
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
+ value: {
+ [cardID]: {
+ nameValuePairs: {
+ pendingFields: {
+ exportAccountDetails: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ errorFields: {
+ exportAccountDetails: null,
+ },
+ exportAccountDetails: {
+ [accountKey]: newAccount,
+ },
+ },
+ },
+ },
+ },
+ ];
+
+ const finallyData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
+ value: {
+ [cardID]: {
+ nameValuePairs: {
+ pendingFields: {
+ exportAccountDetails: null,
+ },
+ },
+ },
+ },
+ },
+ ];
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
+ value: {
+ [cardID]: {
+ nameValuePairs: {
+ pendingFields: {
+ exportAccountDetails: null,
+ },
+ errorFields: {
+ exportAccountDetails: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
+ },
+ },
+ },
+ },
+ },
+ ];
+
+ const parameters: SetCompanyCardExportAccountParams = {
+ authToken,
+ cardID: Number(cardID),
+ exportAccountDetails: {[accountKey]: newAccount},
+ };
+
+ API.write(WRITE_COMMANDS.SET_CARD_EXPORT_ACCOUNT, parameters, {optimisticData, finallyData, failureData});
+}
+
+function clearCompanyCardErrorField(workspaceAccountID: number, cardID: string, bankName: string, fieldName: string, isRootLevel?: boolean) {
+ if (isRootLevel) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, {
+ [cardID]: {
+ errorFields: {[fieldName]: null},
+ },
+ });
+ return;
+ }
+ Onyx.merge(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, {
+ [cardID]: {
+ nameValuePairs: {
+ errorFields: {[fieldName]: null},
+ },
+ },
+ });
+}
+
+function openPolicyCompanyCardsPage(policyID: string, workspaceAccountID: number) {
+ const authToken = NetworkStore.getAuthToken();
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
+ value: {
+ isLoading: true,
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
+ value: {
+ isLoading: false,
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
+ value: {
+ isLoading: false,
+ },
+ },
+ ];
+
+ const params: OpenPolicyExpensifyCardsPageParams = {
+ policyID,
+ authToken,
+ };
+
+ API.read(READ_COMMANDS.OPEN_POLICY_COMPANY_CARDS_PAGE, params, {optimisticData, successData, failureData});
+}
+
+function openPolicyCompanyCardsFeed(policyID: string, feed: CompanyCardFeed) {
+ const parameters: OpenPolicyCompanyCardsFeedParams = {
+ policyID,
+ feed,
+ };
+
+ API.read(READ_COMMANDS.OPEN_POLICY_COMPANY_CARDS_FEED, parameters);
+}
+
+export {
+ setWorkspaceCompanyCardFeedName,
+ deleteWorkspaceCompanyCardFeed,
+ setWorkspaceCompanyCardTransactionLiability,
+ openPolicyCompanyCardsPage,
+ openPolicyCompanyCardsFeed,
+ addNewCompanyCardsFeed,
+ assignWorkspaceCompanyCard,
+ unassignWorkspaceCompanyCard,
+ updateWorkspaceCompanyCard,
+ updateCompanyCardName,
+ setCompanyCardExportAccount,
+ clearCompanyCardErrorField,
+ setAddNewCompanyCardStepAndData,
+ clearAddNewCardFlow,
+ setAssignCardStepAndData,
+ clearAssignCardStepAndData,
+};
diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts
index 06d7093df385..28f2019bb231 100644
--- a/src/libs/actions/Delegate.ts
+++ b/src/libs/actions/Delegate.ts
@@ -219,6 +219,7 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) {
delegatedAccess: {
delegates: optimisticDelegateData(),
},
+ isLoading: true,
},
},
];
@@ -263,6 +264,7 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) {
delegatedAccess: {
delegates: successDelegateData(),
},
+ isLoading: false,
},
},
];
@@ -305,6 +307,7 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) {
delegatedAccess: {
delegates: failureDelegateData(),
},
+ isLoading: false,
},
},
];
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index fb8cd014ec7b..0d9a4887bd1d 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -3640,6 +3640,7 @@ function requestMoney(
transactionThreadReportID,
createdReportActionIDForThread,
reimbursible,
+ policyID: policy?.id,
};
// eslint-disable-next-line rulesdir/no-multiple-api-calls
@@ -8410,9 +8411,30 @@ function resolveDuplicates(params: TransactionMergeParams) {
const optimisticHoldActions: OnyxUpdate[] = [];
const failureHoldActions: OnyxUpdate[] = [];
const reportActionIDList: string[] = [];
+ const optimisticHoldTransactionActions: OnyxUpdate[] = [];
+ const failureHoldTransactionActions: OnyxUpdate[] = [];
transactionThreadReportIDList.forEach((transactionThreadReportID) => {
const createdReportAction = ReportUtils.buildOptimisticHoldReportAction();
reportActionIDList.push(createdReportAction.reportActionID);
+ const transactionID = TransactionUtils.getTransactionID(transactionThreadReportID ?? '-1');
+ optimisticHoldTransactionActions.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ comment: {
+ hold: createdReportAction.reportActionID,
+ },
+ },
+ });
+ failureHoldTransactionActions.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ comment: {
+ hold: null,
+ },
+ },
+ });
optimisticHoldActions.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
@@ -8456,8 +8478,8 @@ function resolveDuplicates(params: TransactionMergeParams) {
const optimisticData: OnyxUpdate[] = [];
const failureData: OnyxUpdate[] = [];
- optimisticData.push(optimisticTransactionData, ...optimisticTransactionViolations, ...optimisticHoldActions, optimisticReportActionData);
- failureData.push(failureTransactionData, ...failureTransactionViolations, ...failureHoldActions, failureReportActionData);
+ optimisticData.push(optimisticTransactionData, ...optimisticTransactionViolations, ...optimisticHoldActions, ...optimisticHoldTransactionActions, optimisticReportActionData);
+ failureData.push(failureTransactionData, ...failureTransactionViolations, ...failureHoldActions, ...failureHoldTransactionActions, failureReportActionData);
const {reportID, transactionIDList, receiptID, ...otherParams} = params;
const parameters: ResolveDuplicatesParams = {
diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts
index 4cda676d89e8..13fcea0df85d 100644
--- a/src/libs/actions/Link.ts
+++ b/src/libs/actions/Link.ts
@@ -111,7 +111,7 @@ function openTravelDotLink(policyID: OnyxEntry, postLoginPath?: string)
policyID,
};
- return new Promise((resolve, reject) => {
+ return new Promise((_, reject) => {
const error = new Error('Failed to generate spotnana token.');
asyncOpenURL(
@@ -122,9 +122,7 @@ function openTravelDotLink(policyID: OnyxEntry, postLoginPath?: string)
reject(error);
throw error;
}
- const travelURL = buildTravelDotURL(response.spotnanaToken, postLoginPath);
- resolve(undefined);
- return travelURL;
+ return buildTravelDotURL(response.spotnanaToken, postLoginPath);
})
.catch(() => {
reject(error);
diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts
index c517439aeda2..5728c671c8b1 100644
--- a/src/libs/actions/Policy/Policy.ts
+++ b/src/libs/actions/Policy/Policy.ts
@@ -9,7 +9,6 @@ import * as API from '@libs/API';
import type {
AddBillingCardAndRequestWorkspaceOwnerChangeParams,
AddPaymentCardParams,
- AssignCompanyCardParams,
CreateWorkspaceFromIOUPaymentParams,
CreateWorkspaceParams,
DeleteWorkspaceAvatarParams,
@@ -27,7 +26,6 @@ import type {
EnablePolicyWorkflowsParams,
LeavePolicyParams,
OpenDraftWorkspaceRequestParams,
- OpenPolicyCompanyCardsFeedParams,
OpenPolicyEditCardLimitTypePageParams,
OpenPolicyExpensifyCardsPageParams,
OpenPolicyInitialPageParams,
@@ -38,8 +36,6 @@ import type {
OpenWorkspaceInvitePageParams,
OpenWorkspaceParams,
RequestExpensifyCardLimitIncreaseParams,
- RequestFeedSetupParams,
- SetCompanyCardExportAccountParams,
SetPolicyAutomaticApprovalLimitParams,
SetPolicyAutomaticApprovalRateParams,
SetPolicyAutoReimbursementLimitParams,
@@ -53,7 +49,6 @@ import type {
SetWorkspaceAutoReportingMonthlyOffsetParams,
SetWorkspacePayerParams,
SetWorkspaceReimbursementParams,
- UpdateCompanyCardNameParams,
UpdatePolicyAddressParams,
UpdateWorkspaceAvatarParams,
UpdateWorkspaceDescriptionParams,
@@ -82,7 +77,6 @@ import * as PersistedRequests from '@userActions/PersistedRequests';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {
- CompanyCardFeed,
InvitedEmailsToAccountIDs,
PersonalDetailsList,
Policy,
@@ -94,7 +88,6 @@ import type {
TaxRatesWithDefault,
Transaction,
} from '@src/types/onyx';
-import type {AssignCardData} from '@src/types/onyx/AssignCard';
import type {Errors} from '@src/types/onyx/OnyxCommon';
import type {Attributes, CompanyAddress, CustomUnit, NetSuiteCustomList, NetSuiteCustomSegment, Rate, TaxRate} from '@src/types/onyx/Policy';
import type {OnyxData} from '@src/types/onyx/Request';
@@ -2004,56 +1997,6 @@ function openPolicyTaxesPage(policyID: string) {
API.read(READ_COMMANDS.OPEN_POLICY_TAXES_PAGE, params);
}
-function openPolicyCompanyCardsFeed(policyID: string, feed: CompanyCardFeed) {
- const parameters: OpenPolicyCompanyCardsFeedParams = {
- policyID,
- feed,
- };
-
- API.read(READ_COMMANDS.OPEN_POLICY_COMPANY_CARDS_FEED, parameters);
-}
-
-function openPolicyCompanyCardsPage(policyID: string, workspaceAccountID: number) {
- const authToken = NetworkStore.getAuthToken();
-
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
- value: {
- isLoading: true,
- },
- },
- ];
-
- const successData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
- value: {
- isLoading: false,
- },
- },
- ];
-
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
- value: {
- isLoading: false,
- },
- },
- ];
-
- const params: OpenPolicyExpensifyCardsPageParams = {
- policyID,
- authToken,
- };
-
- API.read(READ_COMMANDS.OPEN_POLICY_COMPANY_CARDS_PAGE, params, {optimisticData, successData, failureData});
-}
-
function openPolicyExpensifyCardsPage(policyID: string, workspaceAccountID: number) {
const authToken = NetworkStore.getAuthToken();
@@ -4435,477 +4378,6 @@ function enablePolicyAutoReimbursementLimit(policyID: string, enabled: boolean)
});
}
-function addNewCompanyCardsFeed(policyID: string, feedType: string, feedDetails: string) {
- const authToken = NetworkStore.getAuthToken();
-
- if (!authToken) {
- return;
- }
-
- const parameters: RequestFeedSetupParams = {
- policyID,
- authToken,
- feedType,
- feedDetails,
- };
-
- API.write(WRITE_COMMANDS.REQUEST_FEED_SETUP, parameters);
-}
-
-function setWorkspaceCompanyCardFeedName(policyID: string, workspaceAccountID: number, bankName: string, userDefinedName: string) {
- const authToken = NetworkStore.getAuthToken();
- const onyxData: OnyxData = {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
- value: {
- settings: {
- companyCardNicknames: {
- [bankName]: userDefinedName,
- },
- },
- },
- },
- ],
- };
-
- const parameters = {
- authToken,
- policyID,
- bankName,
- userDefinedName,
- };
-
- API.write(WRITE_COMMANDS.SET_COMPANY_CARD_FEED_NAME, parameters, onyxData);
-}
-
-function setWorkspaceCompanyCardTransactionLiability(workspaceAccountID: number, policyID: string, bankName: string, liabilityType: string) {
- const authToken = NetworkStore.getAuthToken();
- const onyxData: OnyxData = {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
- value: {
- settings: {
- companyCards: {
- [bankName]: {liabilityType},
- },
- },
- },
- },
- ],
- };
-
- const parameters = {
- authToken,
- policyID,
- bankName,
- liabilityType,
- };
-
- API.write(WRITE_COMMANDS.SET_COMPANY_CARD_TRANSACTION_LIABILITY, parameters, onyxData);
-}
-
-function deleteWorkspaceCompanyCardFeed(policyID: string, workspaceAccountID: number, bankName: string) {
- const authToken = NetworkStore.getAuthToken();
-
- const onyxData: OnyxData = {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
- value: {
- settings: {
- companyCards: {
- [bankName]: null,
- },
- companyCardNicknames: {
- [bankName]: null,
- },
- },
- },
- },
- ],
- };
-
- const parameters = {
- authToken,
- policyID,
- bankName,
- };
-
- API.write(WRITE_COMMANDS.DELETE_COMPANY_CARD_FEED, parameters, onyxData);
-}
-
-function assignWorkspaceCompanyCard(policyID: string, data?: Partial) {
- if (!data) {
- return;
- }
- const {bankName = '', email = '', encryptedCardNumber = '', startDate = ''} = data;
- const assigneeDetails = PersonalDetailsUtils.getPersonalDetailByEmail(email);
- const optimisticCardAssignedReportAction = ReportUtils.buildOptimisticCardAssignedReportAction(assigneeDetails?.accountID ?? -1);
-
- const parameters: AssignCompanyCardParams = {
- policyID,
- bankName,
- encryptedCardNumber,
- email,
- startDate,
- reportActionID: optimisticCardAssignedReportAction.reportActionID,
- };
- const policy = getPolicy(policyID);
- const policyExpenseChat = ReportUtils.getPolicyExpenseChat(policy?.ownerAccountID ?? -1, policyID);
-
- const onyxData: OnyxData = {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${policyExpenseChat?.reportID}`,
- value: {
- [optimisticCardAssignedReportAction.reportActionID]: optimisticCardAssignedReportAction,
- },
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${policyExpenseChat?.reportID}`,
- value: {[optimisticCardAssignedReportAction.reportActionID]: {pendingAction: null}},
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${policyExpenseChat?.reportID}`,
- value: {
- [optimisticCardAssignedReportAction.reportActionID]: {
- pendingAction: null,
- errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
- },
- },
- },
- ],
- };
-
- API.write(WRITE_COMMANDS.ASSIGN_COMPANY_CARD, parameters, onyxData);
-}
-
-function unassignWorkspaceCompanyCard(workspaceAccountID: number, cardID: string, bankName: string) {
- const authToken = NetworkStore.getAuthToken();
-
- const onyxData: OnyxData = {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
- value: {
- [cardID]: null,
- },
- },
- ],
- };
-
- const parameters = {
- authToken,
- cardID,
- };
-
- API.write(WRITE_COMMANDS.UNASSIGN_COMPANY_CARD, parameters, onyxData);
-}
-
-function updateWorkspaceCompanyCard(workspaceAccountID: number, cardID: string, bankName: string) {
- const authToken = NetworkStore.getAuthToken();
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
- value: {
- [cardID]: {
- isLoadingLastUpdated: true,
- pendingFields: {
- lastScrape: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
- },
- errorFields: {
- lastScrape: null,
- },
- },
- },
- },
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.CARD_LIST,
- value: {
- [cardID]: {
- isLoadingLastUpdated: true,
- pendingFields: {
- lastScrape: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
- },
- errorFields: {
- lastScrape: null,
- },
- },
- },
- },
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
- value: {
- settings: {
- companyCards: {
- [bankName]: {
- errors: null,
- },
- },
- },
- },
- },
- ];
-
- const finallyData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
- value: {
- [cardID]: {
- isLoadingLastUpdated: false,
- pendingFields: {
- lastScrape: null,
- },
- },
- },
- },
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.CARD_LIST,
- value: {
- [cardID]: {
- isLoadingLastUpdated: false,
- pendingFields: {
- lastScrape: null,
- },
- },
- },
- },
- ];
-
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
- value: {
- [cardID]: {
- isLoadingLastUpdated: false,
- pendingFields: {
- lastScrape: null,
- },
- errorFields: {
- lastScrape: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
- },
- },
- },
- },
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.CARD_LIST,
- value: {
- [cardID]: {
- isLoadingLastUpdated: false,
- pendingFields: {
- lastScrape: null,
- },
- errorFields: {
- lastScrape: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
- },
- },
- },
- },
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`,
- value: {
- settings: {
- companyCards: {
- [bankName]: {
- errors: {error: CONST.COMPANY_CARDS.CONNECTION_ERROR},
- },
- },
- },
- },
- },
- ];
-
- const parameters = {
- authToken,
- cardID,
- };
-
- API.write(WRITE_COMMANDS.UPDATE_COMPANY_CARD, parameters, {optimisticData, finallyData, failureData});
-}
-
-function updateCompanyCardName(workspaceAccountID: number, cardID: string, newCardTitle: string, bankName: string, oldCardTitle?: string) {
- const authToken = NetworkStore.getAuthToken();
-
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
- value: {
- [cardID]: {
- nameValuePairs: {
- cardTitle: newCardTitle,
- pendingFields: {
- cardTitle: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
- },
- errorFields: {
- cardTitle: null,
- },
- },
- },
- },
- },
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES,
- value: {[cardID]: newCardTitle},
- },
- ];
-
- const finallyData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
- value: {
- [cardID]: {
- nameValuePairs: {
- pendingFields: {
- cardTitle: null,
- },
- },
- },
- },
- },
- ];
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
- value: {
- [cardID]: {
- nameValuePairs: {
- pendingFields: {
- cardTitle: null,
- },
- errorFields: {
- cardTitle: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
- },
- },
- },
- },
- },
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES,
- value: {[cardID]: oldCardTitle},
- },
- ];
-
- const parameters: UpdateCompanyCardNameParams = {
- authToken,
- cardID: Number(cardID),
- cardName: newCardTitle,
- };
-
- API.write(WRITE_COMMANDS.UPDATE_COMPANY_CARD_NAME, parameters, {optimisticData, finallyData, failureData});
-}
-
-function setCompanyCardExportAccount(workspaceAccountID: number, cardID: string, accountKey: string, newAccount: string, bankName: string) {
- const authToken = NetworkStore.getAuthToken();
-
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
- value: {
- [cardID]: {
- nameValuePairs: {
- pendingFields: {
- exportAccountDetails: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
- },
- errorFields: {
- exportAccountDetails: null,
- },
- exportAccountDetails: {
- [accountKey]: newAccount,
- },
- },
- },
- },
- },
- ];
-
- const finallyData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
- value: {
- [cardID]: {
- nameValuePairs: {
- pendingFields: {
- exportAccountDetails: null,
- },
- },
- },
- },
- },
- ];
- const failureData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`,
- value: {
- [cardID]: {
- nameValuePairs: {
- pendingFields: {
- exportAccountDetails: null,
- },
- errorFields: {
- exportAccountDetails: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
- },
- },
- },
- },
- },
- ];
-
- const parameters: SetCompanyCardExportAccountParams = {
- authToken,
- cardID: Number(cardID),
- exportAccountDetails: {[accountKey]: newAccount},
- };
-
- API.write(WRITE_COMMANDS.SET_CARD_EXPORT_ACCOUNT, parameters, {optimisticData, finallyData, failureData});
-}
-
-function clearCompanyCardErrorField(workspaceAccountID: number, cardID: string, bankName: string, fieldName: string, isRootLevel?: boolean) {
- if (isRootLevel) {
- Onyx.merge(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, {
- [cardID]: {
- errorFields: {[fieldName]: null},
- },
- });
- return;
- }
- Onyx.merge(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, {
- [cardID]: {
- nameValuePairs: {
- errorFields: {[fieldName]: null},
- },
- },
- });
-}
-
function clearAllPolicies() {
if (!allPolicies) {
return;
@@ -5006,18 +4478,6 @@ export {
setPolicyBillableMode,
disableWorkspaceBillableExpenses,
setWorkspaceEReceiptsEnabled,
- setWorkspaceCompanyCardFeedName,
- deleteWorkspaceCompanyCardFeed,
- setWorkspaceCompanyCardTransactionLiability,
- openPolicyCompanyCardsPage,
- openPolicyCompanyCardsFeed,
- addNewCompanyCardsFeed,
- assignWorkspaceCompanyCard,
- unassignWorkspaceCompanyCard,
- updateWorkspaceCompanyCard,
- updateCompanyCardName,
- setCompanyCardExportAccount,
- clearCompanyCardErrorField,
verifySetupIntentAndRequestPolicyOwnerChange,
};
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 7071c96f8612..3384f41f27a6 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -79,7 +79,7 @@ import processReportIDDeeplink from '@libs/processReportIDDeeplink';
import * as Pusher from '@libs/Pusher/pusher';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportConnection from '@libs/ReportConnection';
-import type {OptimisticAddCommentReportAction} from '@libs/ReportUtils';
+import type {OptimisticAddCommentReportAction, OptimisticChatReport} from '@libs/ReportUtils';
import * as ReportUtils from '@libs/ReportUtils';
import {doesReportBelongToWorkspace} from '@libs/ReportUtils';
import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation';
@@ -111,6 +111,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject';
import * as CachedPDFPaths from './CachedPDFPaths';
import * as Modal from './Modal';
import navigateFromNotification from './navigateFromNotification';
+import resolveDuplicationConflictAction from './RequestConflictUtils';
import * as Session from './Session';
import * as Welcome from './Welcome';
import * as OnboardingFlow from './Welcome/OnboardingFlow';
@@ -977,7 +978,10 @@ function openReport(
});
} else {
// eslint-disable-next-line rulesdir/no-multiple-api-calls
- API.paginate(CONST.API_REQUEST_TYPE.WRITE, WRITE_COMMANDS.OPEN_REPORT, parameters, {optimisticData, successData, failureData}, paginationConfig);
+ API.paginate(CONST.API_REQUEST_TYPE.WRITE, WRITE_COMMANDS.OPEN_REPORT, parameters, {optimisticData, successData, failureData}, paginationConfig, {
+ checkAndFixConflictingRequest: (persistedRequests) =>
+ resolveDuplicationConflictAction(persistedRequests, (request) => request.command === WRITE_COMMANDS.OPEN_REPORT && request.data?.reportID === reportID),
+ });
}
}
@@ -1368,6 +1372,7 @@ function handleReportChanged(report: OnyxEntry) {
if (report?.reportID && report.preexistingReportID) {
let callback = () => {
Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, null);
+ Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, null);
Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report.reportID}`, null);
};
// Only re-route them if they are still looking at the optimistically created report
@@ -4125,6 +4130,10 @@ function markAsManuallyExported(reportID: string, connectionName: ConnectionName
API.write(WRITE_COMMANDS.MARK_AS_EXPORTED, params, {optimisticData, successData, failureData});
}
+function createDraftReportForPolicyExpenseChat(draftReport: OptimisticChatReport) {
+ Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${draftReport.reportID}`, draftReport);
+}
+
function exportReportToCSV({reportID, transactionIDList}: ExportReportCSVParams, onDownloadFailed: () => void) {
const finalParameters = enhanceParameters(WRITE_COMMANDS.EXPORT_REPORT_TO_CSV, {
reportID,
@@ -4232,4 +4241,5 @@ export {
updateReportName,
updateRoomVisibility,
updateWriteCapability,
+ createDraftReportForPolicyExpenseChat,
};
diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts
index 68c0860389b9..fcf9ff439b11 100644
--- a/src/libs/actions/RequestConflictUtils.ts
+++ b/src/libs/actions/RequestConflictUtils.ts
@@ -1,16 +1,17 @@
-import type {WriteCommand} from '@libs/API/types';
import type OnyxRequest from '@src/types/onyx/Request';
import type {ConflictActionData} from '@src/types/onyx/Request';
+type RequestMatcher = (request: OnyxRequest) => boolean;
+
/**
- * Resolves duplication conflicts between persisted requests and a given command.
+ * Determines the appropriate action for handling duplication conflicts in persisted requests.
*
- * This method checks if a specific command exists within a list of persisted requests.
- * - If the command is not found, it suggests adding the command to the list, indicating a 'push' action.
- * - If the command is found, it suggests updating the existing entry, indicating a 'replace' action at the found index.
+ * This method checks if any request in the list of persisted requests matches the criteria defined by the request matcher function.
+ * - If no match is found, it suggests adding the request to the list, indicating a 'push' action.
+ * - If a match is found, it suggests updating the existing entry, indicating a 'replace' action at the found index.
*/
-function resolveDuplicationConflictAction(persistedRequests: OnyxRequest[], commandToFind: WriteCommand): ConflictActionData {
- const index = persistedRequests.findIndex((request) => request.command === commandToFind);
+function resolveDuplicationConflictAction(persistedRequests: OnyxRequest[], requestMatcher: RequestMatcher): ConflictActionData {
+ const index = persistedRequests.findIndex(requestMatcher);
if (index === -1) {
return {
conflictAction: {
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index c93a263a8f74..37488442525d 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -36,6 +36,7 @@ import NetworkConnection from '@libs/NetworkConnection';
import * as Pusher from '@libs/Pusher/pusher';
import * as ReportUtils from '@libs/ReportUtils';
import * as SessionUtils from '@libs/SessionUtils';
+import {clearSoundAssetsCache} from '@libs/Sound';
import Timers from '@libs/Timers';
import {hideContextMenu} from '@pages/home/report/ContextMenu/ReportActionContextMenu';
import {KEYS_TO_PRESERVE, openApp} from '@userActions/App';
@@ -482,43 +483,28 @@ function signUpUser() {
function signInAfterTransitionFromOldDot(transitionURL: string) {
const [route, queryParams] = transitionURL.split('?');
- const {email, authToken, encryptedAuthToken, accountID, autoGeneratedLogin, autoGeneratedPassword, clearOnyxOnStart, completedHybridAppOnboarding, isSingleNewDotEntry, primaryLogin} =
- Object.fromEntries(
- queryParams.split('&').map((param) => {
- const [key, value] = param.split('=');
- return [key, value];
- }),
- );
-
- const clearOnyxForNewAccount = () => {
- if (clearOnyxOnStart !== 'true') {
- return Promise.resolve();
- }
-
- return Onyx.clear(KEYS_TO_PRESERVE);
+ const {email, authToken, encryptedAuthToken, accountID, autoGeneratedLogin, autoGeneratedPassword, clearOnyxOnStart, completedHybridAppOnboarding} = Object.fromEntries(
+ queryParams.split('&').map((param) => {
+ const [key, value] = param.split('=');
+ return [key, value];
+ }),
+ );
+
+ const setSessionDataAndOpenApp = () => {
+ Onyx.multiSet({
+ [ONYXKEYS.SESSION]: {email, authToken, encryptedAuthToken: decodeURIComponent(encryptedAuthToken), accountID: Number(accountID)},
+ [ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin, autoGeneratedPassword},
+ [ONYXKEYS.NVP_TRYNEWDOT]: {classicRedirect: {completedHybridAppOnboarding: completedHybridAppOnboarding === 'true'}},
+ }).then(App.openApp);
};
- const setSessionDataAndOpenApp = new Promise((resolve) => {
- clearOnyxForNewAccount()
- .then(() =>
- Onyx.multiSet({
- [ONYXKEYS.SESSION]: {email, authToken, encryptedAuthToken: decodeURIComponent(encryptedAuthToken), accountID: Number(accountID)},
- [ONYXKEYS.ACCOUNT]: {primaryLogin},
- [ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin, autoGeneratedPassword},
- [ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY]: isSingleNewDotEntry === 'true',
- [ONYXKEYS.NVP_TRYNEWDOT]: {classicRedirect: {completedHybridAppOnboarding: completedHybridAppOnboarding === 'true'}},
- }),
- )
- .then(App.openApp)
- .catch((error) => {
- Log.hmmm('[HybridApp] Initialization of HybridApp has failed. Forcing transition', {error});
- })
- .finally(() => {
- resolve(`${route}?singleNewDotEntry=${isSingleNewDotEntry}` as Route);
- });
- });
+ if (clearOnyxOnStart === 'true') {
+ Onyx.clear(KEYS_TO_PRESERVE).then(setSessionDataAndOpenApp);
+ } else {
+ setSessionDataAndOpenApp();
+ }
- return setSessionDataAndOpenApp;
+ return route as Route;
}
/**
@@ -776,6 +762,7 @@ function cleanupSession() {
clearCache().then(() => {
Log.info('Cleared all cache data', true, {}, true);
});
+ clearSoundAssetsCache();
Timing.clearData();
}
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index 9ea29506accc..754563b57429 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -315,11 +315,7 @@ function resetContactMethodValidateCodeSentState(contactMethod: string) {
* Clears unvalidated new contact method action
*/
function clearUnvalidatedNewContactMethodAction() {
- Onyx.merge(ONYXKEYS.PENDING_CONTACT_ACTION, {
- validateCodeSent: null,
- pendingFields: null,
- errorFields: null,
- });
+ Onyx.merge(ONYXKEYS.PENDING_CONTACT_ACTION, null);
}
/**
@@ -414,7 +410,6 @@ function addNewContactMethod(contactMethod: string, validateCode = '') {
[contactMethod]: {
partnerUserID: contactMethod,
validatedDate: '',
- validateCodeSent: true,
errorFields: {
addedLogin: null,
},
@@ -447,6 +442,7 @@ function addNewContactMethod(contactMethod: string, validateCode = '') {
key: ONYXKEYS.PENDING_CONTACT_ACTION,
value: {
validateCodeSent: null,
+ actionVerified: true,
errorFields: {
actionVerified: null,
},
diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts
index 3c11f3c440db..b052214fe78c 100644
--- a/src/libs/actions/Welcome/OnboardingFlow.ts
+++ b/src/libs/actions/Welcome/OnboardingFlow.ts
@@ -110,7 +110,11 @@ function startOnboardingFlow() {
if (focusedRoute?.name === currentRoute?.name) {
return;
}
- navigationRef.resetRoot(adaptedState);
+ navigationRef.resetRoot({
+ ...navigationRef.getRootState(),
+ ...adaptedState,
+ stale: true,
+ } as PartialState);
}
function getOnboardingInitialPath(): string {
diff --git a/src/libs/actions/connections/QuickbooksDesktop.ts b/src/libs/actions/connections/QuickbooksDesktop.ts
index 381143679431..1ab53e12b772 100644
--- a/src/libs/actions/connections/QuickbooksDesktop.ts
+++ b/src/libs/actions/connections/QuickbooksDesktop.ts
@@ -1,12 +1,17 @@
import type {OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import * as API from '@libs/API';
-import type {ConnectPolicyToQuickBooksDesktopParams, UpdateQuickbooksDesktopExpensesExportDestinationTypeParams, UpdateQuickbooksDesktopGenericTypeParams} from '@libs/API/parameters';
+import type {
+ ConnectPolicyToQuickBooksDesktopParams,
+ UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams,
+ UpdateQuickbooksDesktopExpensesExportDestinationTypeParams,
+ UpdateQuickbooksDesktopGenericTypeParams,
+} from '@libs/API/parameters';
import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import * as ErrorUtils from '@libs/ErrorUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {Connections, QBDReimbursableExportAccountType} from '@src/types/onyx/Policy';
+import type {Connections, QBDNonReimbursableExportAccountType, QBDReimbursableExportAccountType} from '@src/types/onyx/Policy';
function buildOnyxDataForMultipleQuickbooksExportConfigurations>(
policyID: string,
@@ -341,6 +346,22 @@ function updateQuickbooksDesktopExpensesExportDestination(policyID: string, configUpdate: TConfigUpdate, configCurrentData: Partial) {
+ const onyxData = buildOnyxDataForMultipleQuickbooksExportConfigurations(policyID, configUpdate, configCurrentData);
+
+ const parameters: UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams = {
+ policyID,
+ nonReimbursableExpensesExportDestination: configUpdate.nonReimbursable,
+ nonReimbursableExpensesAccount: configUpdate.nonReimbursableAccount,
+ nonReimbursableBillDefaultVendor: configUpdate.nonReimbursableBillDefaultVendor,
+ idempotencyKey: String(CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION),
+ };
+
+ API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION, parameters, onyxData);
+}
+
function updateQuickbooksDesktopShouldAutoCreateVendor(
policyID: string,
settingValue: TSettingValue,
@@ -424,6 +445,20 @@ function updateQuickbooksDesktopSyncCustomers(
+ policyID: string,
+ settingValue: TSettingValue,
+ oldSettingValue?: TSettingValue,
+) {
+ const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.IMPORT_ITEMS, settingValue, oldSettingValue);
+ const parameters: UpdateQuickbooksDesktopGenericTypeParams = {
+ policyID,
+ settingValue: JSON.stringify(settingValue),
+ idempotencyKey: String(CONST.QUICKBOOKS_DESKTOP_CONFIG.IMPORT_ITEMS),
+ };
+ API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_SYNC_ITEMS, parameters, onyxData);
+}
+
function updateQuickbooksDesktopPreferredExporter(
policyID: string,
settingValue: TSettingValue,
@@ -439,6 +474,36 @@ function updateQuickbooksDesktopPreferredExporter(
+ policyID: string,
+ settingValue: TSettingValue,
+ oldSettingValue?: TSettingValue,
+) {
+ const onyxData = buildOnyxDataForQuickbooksExportConfiguration(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE_ACCOUNT, settingValue, oldSettingValue);
+
+ const parameters: UpdateQuickbooksDesktopGenericTypeParams = {
+ policyID,
+ settingValue,
+ idempotencyKey: String(CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE_ACCOUNT),
+ };
+ API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPENSES_ACCOUNT, parameters, onyxData);
+}
+
+function updateQuickbooksDesktopNonReimbursableBillDefaultVendor(
+ policyID: string,
+ settingValue: TSettingValue,
+ oldSettingValue?: TSettingValue,
+) {
+ const onyxData = buildOnyxDataForQuickbooksExportConfiguration(policyID, CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR, settingValue, oldSettingValue);
+
+ const parameters: UpdateQuickbooksDesktopGenericTypeParams = {
+ policyID,
+ settingValue,
+ idempotencyKey: String(CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR),
+ };
+ API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_BILL_DEFAULT_VENDOR, parameters, onyxData);
+}
+
function updateQuickbooksDesktopExportDate(
policyID: string,
settingValue: TSettingValue,
@@ -469,12 +534,16 @@ export {
updateQuickbooksDesktopAutoSync,
updateQuickbooksDesktopPreferredExporter,
updateQuickbooksDesktopMarkChecksToBePrinted,
+ updateQuickbooksDesktopNonReimbursableBillDefaultVendor,
updateQuickbooksDesktopShouldAutoCreateVendor,
+ updateQuickbooksDesktopNonReimbursableExpensesAccount,
updateQuickbooksDesktopExpensesExportDestination,
updateQuickbooksDesktopReimbursableExpensesAccount,
getQuickbooksDesktopCodatSetupLink,
+ updateQuickbooksCompanyCardExpenseAccount,
updateQuickbooksDesktopEnableNewCategories,
updateQuickbooksDesktopExportDate,
updateQuickbooksDesktopSyncClasses,
updateQuickbooksDesktopSyncCustomers,
+ updateQuickbooksDesktopSyncItems,
};
diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts
index b93642a0fa5a..30ea8aefe806 100644
--- a/src/libs/actions/connections/index.ts
+++ b/src/libs/actions/connections/index.ts
@@ -3,7 +3,12 @@ import isObject from 'lodash/isObject';
import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import * as API from '@libs/API';
-import type {RemovePolicyConnectionParams, UpdateManyPolicyConnectionConfigurationsParams, UpdatePolicyConnectionConfigParams} from '@libs/API/parameters';
+import type {
+ RemovePolicyConnectionParams,
+ SyncPolicyToQuickbooksDesktopParams,
+ UpdateManyPolicyConnectionConfigurationsParams,
+ UpdatePolicyConnectionConfigParams,
+} from '@libs/API/parameters';
import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import * as ErrorUtils from '@libs/ErrorUtils';
import CONST from '@src/CONST';
@@ -163,6 +168,9 @@ function getSyncConnectionParameters(connectionName: PolicyConnectionName) {
case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: {
return {readCommand: READ_COMMANDS.SYNC_POLICY_TO_SAGE_INTACCT, stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.SAGE_INTACCT_SYNC_CHECK_CONNECTION};
}
+ case CONST.POLICY.CONNECTIONS.NAME.QBD: {
+ return {readCommand: READ_COMMANDS.SYNC_POLICY_TO_QUICKBOOKS_DESKTOP, stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.STARTING_IMPORT_QBD};
+ }
default:
return undefined;
}
@@ -173,8 +181,9 @@ function getSyncConnectionParameters(connectionName: PolicyConnectionName) {
*
* @param policyID - ID of the policy for which the sync is needed
* @param connectionName - Name of the connection, QBO/Xero
+ * @param forceDataRefresh - If true, it will trigger a full data refresh
*/
-function syncConnection(policyID: string, connectionName: PolicyConnectionName | undefined) {
+function syncConnection(policyID: string, connectionName: PolicyConnectionName | undefined, forceDataRefresh = false) {
if (!connectionName) {
return;
}
@@ -203,17 +212,19 @@ function syncConnection(policyID: string, connectionName: PolicyConnectionName |
},
];
- API.read(
- syncConnectionData.readCommand,
- {
- policyID,
- idempotencyKey: policyID,
- },
- {
- optimisticData,
- failureData,
- },
- );
+ const parameters: SyncPolicyToQuickbooksDesktopParams = {
+ policyID,
+ idempotencyKey: policyID,
+ };
+
+ if (connectionName === CONST.POLICY.CONNECTIONS.NAME.QBD) {
+ parameters.forceDataRefresh = forceDataRefresh;
+ }
+
+ API.read(syncConnectionData.readCommand, parameters, {
+ optimisticData,
+ failureData,
+ });
}
function updateManyPolicyConnectionConfigs>(
diff --git a/src/libs/actions/getCompanyCardBankConnection/index.tsx b/src/libs/actions/getCompanyCardBankConnection/index.tsx
new file mode 100644
index 000000000000..935c5d297cb0
--- /dev/null
+++ b/src/libs/actions/getCompanyCardBankConnection/index.tsx
@@ -0,0 +1,34 @@
+import {getApiRoot} from '@libs/ApiUtils';
+import * as NetworkStore from '@libs/Network/NetworkStore';
+import CONST from '@src/CONST';
+
+type CompanyCardBankConnection = {
+ authToken: string;
+ domainName: string;
+ scrapeMinDate: string;
+ isCorporate: string;
+};
+
+// TODO remove this when BE will support bank UI callbacks
+const bankUrl = 'https://secure.chase.com/web/auth/#/logon/logon/chaseOnline?redirect_url=';
+
+export default function getCompanyCardBankConnection(bankName?: string, domainName?: string, scrapeMinDate?: string) {
+ const bankConnection = Object.keys(CONST.COMPANY_CARDS.BANKS).find((key) => CONST.COMPANY_CARDS.BANKS[key as keyof typeof CONST.COMPANY_CARDS.BANKS] === bankName);
+
+ // TODO remove this when BE will support bank UI callbacks
+ if (!domainName) {
+ return bankUrl;
+ }
+
+ if (!bankName || !bankConnection) {
+ return null;
+ }
+ const authToken = NetworkStore.getAuthToken();
+ const params: CompanyCardBankConnection = {authToken: authToken ?? '', domainName: domainName ?? '', isCorporate: 'true', scrapeMinDate: scrapeMinDate ?? ''};
+ const commandURL = getApiRoot({
+ shouldSkipWebProxy: true,
+ command: '',
+ });
+ const bank = CONST.COMPANY_CARDS.BANK_CONNECTIONS[bankConnection as keyof typeof CONST.COMPANY_CARDS.BANK_CONNECTIONS];
+ return `${commandURL}partners/banks/${bank}/oauth_callback.php?${new URLSearchParams(params).toString()}`;
+}
diff --git a/src/pages/Debug/Report/DebugReportActions.tsx b/src/pages/Debug/Report/DebugReportActions.tsx
index e7c4059fffe7..3c08eb5bfdb1 100644
--- a/src/pages/Debug/Report/DebugReportActions.tsx
+++ b/src/pages/Debug/Report/DebugReportActions.tsx
@@ -37,10 +37,7 @@ function DebugReportActions({reportID}: DebugReportActionsProps) {
);
return (
-
+
);
diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx
index 28f4ddf3dc34..fe26fed0c9c0 100644
--- a/src/pages/Debug/Report/DebugReportPage.tsx
+++ b/src/pages/Debug/Report/DebugReportPage.tsx
@@ -17,7 +17,6 @@ import Navigation from '@libs/Navigation/Navigation';
import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator';
import type {DebugParamList} from '@libs/Navigation/types';
import * as ReportUtils from '@libs/ReportUtils';
-import SidebarUtils from '@libs/SidebarUtils';
import DebugDetails from '@pages/Debug/DebugDetails';
import DebugJSON from '@pages/Debug/DebugJSON';
import Debug from '@userActions/Debug';
@@ -61,11 +60,12 @@ function DebugReportPage({
const shouldDisplayViolations = ReportUtils.shouldDisplayTransactionThreadViolations(report, transactionViolations, parentReportAction);
const shouldDisplayReportViolations = ReportUtils.isReportOwner(report) && ReportUtils.hasReportViolations(reportID);
- const hasRBR = SidebarUtils.shouldShowRedBrickRoad(report, reportActions, !!shouldDisplayViolations || shouldDisplayReportViolations, transactionViolations);
- const reasonLHN = DebugUtils.getReasonForShowingRowInLHN(report, hasRBR);
+ const hasViolations = !!shouldDisplayViolations || shouldDisplayReportViolations;
const {reason: reasonGBR, reportAction: reportActionGBR} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(report) ?? {};
- const reportActionRBR = DebugUtils.getRBRReportAction(report, reportActions);
+ const {reason: reasonRBR, reportAction: reportActionRBR} = DebugUtils.getReasonAndReportActionForRBRInLHNRow(report, reportActions, hasViolations) ?? {};
+ const hasRBR = !!reasonRBR;
const hasGBR = !hasRBR && !!reasonGBR;
+ const reasonLHN = DebugUtils.getReasonForShowingRowInLHN(report, hasRBR);
return [
{
@@ -94,6 +94,7 @@ function DebugReportPage({
{
title: translate('debug.RBR'),
subtitle: translate(`debug.${hasRBR}`),
+ message: hasRBR ? translate(reasonRBR) : undefined,
action:
hasRBR && reportActionRBR
? {
diff --git a/src/pages/EditReportFieldDropdown.tsx b/src/pages/EditReportFieldDropdown.tsx
index a46a6db6f8a8..a6bccdf3fa12 100644
--- a/src/pages/EditReportFieldDropdown.tsx
+++ b/src/pages/EditReportFieldDropdown.tsx
@@ -1,6 +1,5 @@
import React, {useCallback, useMemo} from 'react';
-import {withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import SelectionList from '@components/SelectionList';
@@ -11,9 +10,7 @@ import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import localeCompare from '@libs/LocaleCompare';
import * as OptionsListUtils from '@libs/OptionsListUtils';
-import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {RecentlyUsedReportFields} from '@src/types/onyx';
type EditReportFieldDropdownPageComponentProps = {
/** Value of the policy report field */
@@ -33,13 +30,10 @@ type EditReportFieldDropdownPageComponentProps = {
onSubmit: (form: Record) => void;
};
-type EditReportFieldDropdownPageOnyxProps = {
- recentlyUsedReportFields: OnyxEntry;
-};
-
-type EditReportFieldDropdownPageProps = EditReportFieldDropdownPageComponentProps & EditReportFieldDropdownPageOnyxProps;
+type EditReportFieldDropdownPageProps = EditReportFieldDropdownPageComponentProps;
-function EditReportFieldDropdownPage({onSubmit, fieldKey, fieldValue, fieldOptions, recentlyUsedReportFields}: EditReportFieldDropdownPageProps) {
+function EditReportFieldDropdownPage({onSubmit, fieldKey, fieldValue, fieldOptions}: EditReportFieldDropdownPageProps) {
+ const [recentlyUsedReportFields] = useOnyx(ONYXKEYS.RECENTLY_USED_REPORT_FIELDS);
const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState('');
const theme = useTheme();
const {translate} = useLocalize();
@@ -64,37 +58,22 @@ function EditReportFieldDropdownPage({onSubmit, fieldKey, fieldValue, fieldOptio
const [sections, headerMessage] = useMemo(() => {
const validFieldOptions = fieldOptions?.filter((option) => !!option)?.sort(localeCompare);
- const {policyReportFieldOptions} = OptionsListUtils.getFilteredOptions(
- [],
- [],
- [],
- debouncedSearchValue,
- [
+ const {policyReportFieldOptions} = OptionsListUtils.getFilteredOptions({
+ searchValue: debouncedSearchValue,
+ selectedOptions: [
{
keyForList: fieldValue,
searchText: fieldValue,
text: fieldValue,
},
],
- [],
- false,
- false,
- false,
- {},
- [],
- false,
- {},
- [],
- false,
- false,
- undefined,
- CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW,
- undefined,
- undefined,
- true,
- validFieldOptions,
- recentlyUsedOptions,
- );
+
+ includeP2P: false,
+ canInviteUser: false,
+ includePolicyReportFieldOptions: true,
+ policyReportFieldOptions: validFieldOptions,
+ recentlyUsedPolicyReportFieldOptions: recentlyUsedOptions,
+ });
const policyReportFieldData = policyReportFieldOptions?.[0]?.data ?? [];
const header = OptionsListUtils.getHeaderMessageForNonUserList(policyReportFieldData.length > 0, debouncedSearchValue);
@@ -121,8 +100,4 @@ function EditReportFieldDropdownPage({onSubmit, fieldKey, fieldValue, fieldOptio
EditReportFieldDropdownPage.displayName = 'EditReportFieldDropdownPage';
-export default withOnyx({
- recentlyUsedReportFields: {
- key: () => ONYXKEYS.RECENTLY_USED_REPORT_FIELDS,
- },
-})(EditReportFieldDropdownPage);
+export default EditReportFieldDropdownPage;
diff --git a/src/pages/ErrorPage/NotFoundPage.tsx b/src/pages/ErrorPage/NotFoundPage.tsx
index 6a63a7204215..e118057b143e 100644
--- a/src/pages/ErrorPage/NotFoundPage.tsx
+++ b/src/pages/ErrorPage/NotFoundPage.tsx
@@ -13,6 +13,8 @@ type NotFoundPageProps = {
// eslint-disable-next-line rulesdir/no-negated-variables
function NotFoundPage({onBackButtonPress = () => Navigation.goBack(), isReportRelatedPage, ...fullPageNotFoundViewProps}: NotFoundPageProps) {
+ // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to go back to the not found page on large screens and to the home page on small screen
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
return (
diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx
index c406f7f3058c..13fbbc35b5da 100755
--- a/src/pages/NewChatPage.tsx
+++ b/src/pages/NewChatPage.tsx
@@ -52,28 +52,15 @@ function useOptions({isGroupChat}: NewChatPageProps) {
});
const defaultOptions = useMemo(() => {
- const filteredOptions = OptionsListUtils.getFilteredOptions(
- listOptions.reports ?? [],
- listOptions.personalDetails ?? [],
- betas ?? [],
- '',
+ const filteredOptions = OptionsListUtils.getFilteredOptions({
+ reports: listOptions.reports ?? [],
+ personalDetails: listOptions.personalDetails ?? [],
+ betas: betas ?? [],
selectedOptions,
- isGroupChat ? excludedGroupEmails : [],
- false,
- true,
- false,
- {},
- [],
- false,
- {},
- [],
- true,
- undefined,
- undefined,
- 0,
- undefined,
- true,
- );
+ excludeLogins: isGroupChat ? excludedGroupEmails : [],
+ maxRecentReportsToShow: 0,
+ includeSelfDM: true,
+ });
return filteredOptions;
}, [betas, isGroupChat, listOptions.personalDetails, listOptions.reports, selectedOptions]);
@@ -146,6 +133,7 @@ function NewChatPage({isGroupChat}: NewChatPageProps) {
const {translate} = useLocalize();
const {isOffline} = useNetwork();
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to show offline indicator on small screen only
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
const styles = useThemeStyles();
const personalData = useCurrentUserPersonalDetails();
diff --git a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx
index ad7e5d38698f..3ec616f12526 100644
--- a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx
+++ b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx
@@ -37,6 +37,9 @@ function BaseOnboardingAccounting({shouldUseNativeStyles, route}: BaseOnboarding
const theme = useTheme();
const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
+
+ // We need to use isSmallScreenWidth, see navigateAfterOnboarding function comment
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED);
const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID);
diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
index f1c79d7aa76b..18c78eca6e97 100644
--- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
+++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
@@ -35,6 +35,9 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat
const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED);
const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID);
const [onboardingAdminsChatReportID] = useOnyx(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID);
+
+ // We need to use isSmallScreenWidth, see navigateAfterOnboarding function comment
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
const {inputCallbackRef} = useAutoFocusInput();
const [shouldValidateOnChange, setShouldValidateOnChange] = useState(false);
diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
index a59042c572a1..3b05c6bb40a8 100644
--- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
+++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
@@ -51,7 +51,9 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro
const {translate} = useLocalize();
const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
const {windowHeight} = useWindowDimensions();
+
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to show offline indicator on small screen only
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
const theme = useTheme();
diff --git a/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx b/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx
index 814db3536973..7aadcfc56b95 100644
--- a/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx
+++ b/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx
@@ -1,8 +1,7 @@
import lodashPick from 'lodash/pick';
import React, {useCallback, useMemo} from 'react';
import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
import ScreenWrapper from '@components/ScreenWrapper';
@@ -11,14 +10,13 @@ import useSubStep from '@hooks/useSubStep';
import type {SubStepProps} from '@hooks/useSubStep/types';
import useThemeStyles from '@hooks/useThemeStyles';
import {parsePhoneNumber} from '@libs/PhoneNumber';
+import * as ValidationUtils from '@libs/ValidationUtils';
import getInitialSubstepForBusinessInfo from '@pages/ReimbursementAccount/utils/getInitialSubstepForBusinessInfo';
import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues';
import * as BankAccounts from '@userActions/BankAccounts';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {ReimbursementAccountForm} from '@src/types/form';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
-import type {ReimbursementAccount} from '@src/types/onyx';
import AddressBusiness from './substeps/AddressBusiness';
import ConfirmationBusiness from './substeps/ConfirmationBusiness';
import IncorporationDateBusiness from './substeps/IncorporationDateBusiness';
@@ -29,15 +27,7 @@ import TaxIdBusiness from './substeps/TaxIdBusiness';
import TypeBusiness from './substeps/TypeBusiness/TypeBusiness';
import WebsiteBusiness from './substeps/WebsiteBusiness';
-type BusinessInfoOnyxProps = {
- /** Reimbursement account from ONYX */
- reimbursementAccount: OnyxEntry;
-
- /** The draft values of the bank account being setup */
- reimbursementAccountDraft: OnyxEntry;
-};
-
-type BusinessInfoProps = BusinessInfoOnyxProps & {
+type BusinessInfoProps = {
/** Goes to the previous step */
onBackButtonPress: () => void;
};
@@ -56,9 +46,11 @@ const bodyContent: Array> = [
ConfirmationBusiness,
];
-function BusinessInfo({reimbursementAccount, reimbursementAccountDraft, onBackButtonPress}: BusinessInfoProps) {
+function BusinessInfo({onBackButtonPress}: BusinessInfoProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
const getBankAccountFields = useCallback(
(fieldNames: string[]) => ({
@@ -80,6 +72,7 @@ function BusinessInfo({reimbursementAccount, reimbursementAccountDraft, onBackBu
...getBankAccountFields(['routingNumber', 'accountNumber', 'bankName', 'plaidAccountID', 'plaidAccessToken', 'isSavings']),
companyTaxID: values.companyTaxID?.replace(CONST.REGEX.NON_NUMERIC, ''),
companyPhone: parsePhoneNumber(values.companyPhone ?? '', {regionCode: CONST.COUNTRY.US}).number?.significant,
+ website: ValidationUtils.isValidWebsite(values.website) ? values.website : undefined,
},
policyID,
isConfirmPage,
@@ -142,12 +135,4 @@ function BusinessInfo({reimbursementAccount, reimbursementAccountDraft, onBackBu
BusinessInfo.displayName = 'BusinessInfo';
-export default withOnyx({
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
- reimbursementAccountDraft: {
- key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT,
- },
-})(BusinessInfo);
+export default BusinessInfo;
diff --git a/src/pages/ReimbursementAccount/NonUSD/Agreements/Agreements.tsx b/src/pages/ReimbursementAccount/NonUSD/Agreements/Agreements.tsx
new file mode 100644
index 000000000000..605157e2fe33
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/Agreements/Agreements.tsx
@@ -0,0 +1,61 @@
+import type {ComponentType} from 'react';
+import React from 'react';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useSubStep from '@hooks/useSubStep';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import CONST from '@src/CONST';
+import Confirmation from './substeps/Confirmation';
+
+type AgreementsProps = {
+ /** Handles back button press */
+ onBackButtonPress: () => void;
+
+ /** Handles submit button press */
+ onSubmit: () => void;
+};
+
+const bodyContent: Array> = [Confirmation];
+
+function Agreements({onBackButtonPress, onSubmit}: AgreementsProps) {
+ const {translate} = useLocalize();
+
+ const submit = () => {
+ onSubmit();
+ };
+
+ const {componentToRender: SubStep, isEditing, screenIndex, nextScreen, prevScreen, moveTo, goToTheLastStep} = useSubStep({bodyContent, startFrom: 0, onFinished: submit});
+
+ const handleBackButtonPress = () => {
+ if (isEditing) {
+ goToTheLastStep();
+ return;
+ }
+
+ if (screenIndex === 0) {
+ onBackButtonPress();
+ } else {
+ prevScreen();
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+Agreements.displayName = 'Agreements';
+
+export default Agreements;
diff --git a/src/pages/ReimbursementAccount/NonUSD/Agreements/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/Agreements/substeps/Confirmation.tsx
new file mode 100644
index 000000000000..da828929b0da
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/Agreements/substeps/Confirmation.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import FormProvider from '@components/Form/FormProvider';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import useThemeStyles from '@hooks/useThemeStyles';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+function Confirmation({onNext}: SubStepProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+
+ {translate('agreementsStep.pleaseConfirm')}
+
+ );
+}
+
+Confirmation.displayName = 'Confirmation';
+
+export default Confirmation;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx
new file mode 100644
index 000000000000..d6a9267b4f94
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx
@@ -0,0 +1,61 @@
+import type {ComponentType} from 'react';
+import React from 'react';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useSubStep from '@hooks/useSubStep';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import CONST from '@src/CONST';
+import Confirmation from './substeps/Confirmation';
+
+type BankInfoProps = {
+ /** Handles back button press */
+ onBackButtonPress: () => void;
+
+ /** Handles submit button press */
+ onSubmit: () => void;
+};
+
+const bodyContent: Array> = [Confirmation];
+
+function BankInfo({onBackButtonPress, onSubmit}: BankInfoProps) {
+ const {translate} = useLocalize();
+
+ const submit = () => {
+ onSubmit();
+ };
+
+ const {componentToRender: SubStep, isEditing, screenIndex, nextScreen, prevScreen, moveTo, goToTheLastStep} = useSubStep({bodyContent, startFrom: 0, onFinished: submit});
+
+ const handleBackButtonPress = () => {
+ if (isEditing) {
+ goToTheLastStep();
+ return;
+ }
+
+ if (screenIndex === 0) {
+ onBackButtonPress();
+ } else {
+ prevScreen();
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+BankInfo.displayName = 'BankInfo';
+
+export default BankInfo;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx
new file mode 100644
index 000000000000..9ff2b0e57de9
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import SafeAreaConsumer from '@components/SafeAreaConsumer';
+import ScrollView from '@components/ScrollView';
+import useLocalize from '@hooks/useLocalize';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+function Confirmation({onNext}: SubStepProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+
+
+
+ )}
+
+ );
+}
+
+Confirmation.displayName = 'Confirmation';
+
+export default Confirmation;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo.tsx
new file mode 100644
index 000000000000..477bab90af45
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+
+type BeneficialOwnerInfoProps = {
+ /** Handles back button press */
+ onBackButtonPress: () => void;
+
+ /** Handles submit button press */
+ onSubmit: () => void;
+};
+
+function BeneficialOwnerInfo({onBackButtonPress, onSubmit}: BeneficialOwnerInfoProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+
+
+
+
+
+ );
+}
+
+BeneficialOwnerInfo.displayName = 'BeneficialOwnerInfo';
+
+export default BeneficialOwnerInfo;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/BusinessInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/BusinessInfo.tsx
new file mode 100644
index 000000000000..8d1781edefbd
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/BusinessInfo.tsx
@@ -0,0 +1,61 @@
+import type {ComponentType} from 'react';
+import React from 'react';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useSubStep from '@hooks/useSubStep';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import CONST from '@src/CONST';
+import Confirmation from './substeps/Confirmation';
+
+type BusinessInfoProps = {
+ /** Handles back button press */
+ onBackButtonPress: () => void;
+
+ /** Handles submit button press */
+ onSubmit: () => void;
+};
+
+const bodyContent: Array> = [Confirmation];
+
+function BusinessInfo({onBackButtonPress, onSubmit}: BusinessInfoProps) {
+ const {translate} = useLocalize();
+
+ const submit = () => {
+ onSubmit();
+ };
+
+ const {componentToRender: SubStep, isEditing, screenIndex, nextScreen, prevScreen, moveTo, goToTheLastStep} = useSubStep({bodyContent, startFrom: 0, onFinished: submit});
+
+ const handleBackButtonPress = () => {
+ if (isEditing) {
+ goToTheLastStep();
+ return;
+ }
+
+ if (screenIndex === 0) {
+ onBackButtonPress();
+ } else {
+ prevScreen();
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+BusinessInfo.displayName = 'BusinessInfo';
+
+export default BusinessInfo;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Confirmation.tsx
new file mode 100644
index 000000000000..9ff2b0e57de9
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Confirmation.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import SafeAreaConsumer from '@components/SafeAreaConsumer';
+import ScrollView from '@components/ScrollView';
+import useLocalize from '@hooks/useLocalize';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+function Confirmation({onNext}: SubStepProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+
+
+
+ )}
+
+ );
+}
+
+Confirmation.displayName = 'Confirmation';
+
+export default Confirmation;
diff --git a/src/pages/ReimbursementAccount/NonUSD/Country/Country.tsx b/src/pages/ReimbursementAccount/NonUSD/Country/Country.tsx
new file mode 100644
index 000000000000..2faf8ac082c4
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/Country/Country.tsx
@@ -0,0 +1,61 @@
+import type {ComponentType} from 'react';
+import React from 'react';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useSubStep from '@hooks/useSubStep';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import CONST from '@src/CONST';
+import Confirmation from './substeps/Confirmation';
+
+type CountryProps = {
+ /** Handles back button press */
+ onBackButtonPress: () => void;
+
+ /** Handles submit button press */
+ onSubmit: () => void;
+};
+
+const bodyContent: Array> = [Confirmation];
+
+function Country({onBackButtonPress, onSubmit}: CountryProps) {
+ const {translate} = useLocalize();
+
+ const submit = () => {
+ onSubmit();
+ };
+
+ const {componentToRender: SubStep, isEditing, screenIndex, nextScreen, prevScreen, moveTo, goToTheLastStep} = useSubStep({bodyContent, startFrom: 0, onFinished: submit});
+
+ const handleBackButtonPress = () => {
+ if (isEditing) {
+ goToTheLastStep();
+ return;
+ }
+
+ if (screenIndex === 0) {
+ onBackButtonPress();
+ } else {
+ prevScreen();
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+Country.displayName = 'Country';
+
+export default Country;
diff --git a/src/pages/ReimbursementAccount/NonUSD/Country/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/Country/substeps/Confirmation.tsx
new file mode 100644
index 000000000000..d35a6f4b124f
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/Country/substeps/Confirmation.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import FormProvider from '@components/Form/FormProvider';
+import SafeAreaConsumer from '@components/SafeAreaConsumer';
+import ScrollView from '@components/ScrollView';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import useThemeStyles from '@hooks/useThemeStyles';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+function Confirmation({onNext}: SubStepProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+ {translate('countryStep.confirmBusinessBank')}
+
+
+ )}
+
+ );
+}
+
+Confirmation.displayName = 'Confirmation';
+
+export default Confirmation;
diff --git a/src/pages/ReimbursementAccount/NonUSD/Finish/Finish.tsx b/src/pages/ReimbursementAccount/NonUSD/Finish/Finish.tsx
new file mode 100644
index 000000000000..69c0e9e77a45
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/Finish/Finish.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@navigation/Navigation';
+
+function Finish() {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const handleBackButtonPress = () => {
+ Navigation.goBack();
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+Finish.displayName = 'Finish';
+
+export default Finish;
diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/SignerInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/SignerInfo.tsx
new file mode 100644
index 000000000000..8e794f1f2f38
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/SignerInfo.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+
+type SignerInfoProps = {
+ /** Handles back button press */
+ onBackButtonPress: () => void;
+
+ /** Handles submit button press */
+ onSubmit: () => void;
+};
+
+function SignerInfo({onBackButtonPress, onSubmit}: SignerInfoProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+
+
+
+
+
+ );
+}
+
+SignerInfo.displayName = 'SignerInfo';
+
+export default SignerInfo;
diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx
index d28980626d4f..47c1aadf493a 100644
--- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx
+++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx
@@ -40,6 +40,13 @@ import CompanyStep from './CompanyStep';
import ConnectBankAccount from './ConnectBankAccount/ConnectBankAccount';
import ContinueBankAccountSetup from './ContinueBankAccountSetup';
import EnableBankAccount from './EnableBankAccount/EnableBankAccount';
+import Agreements from './NonUSD/Agreements/Agreements';
+import BankInfo from './NonUSD/BankInfo/BankInfo';
+import BeneficialOwnerInfo from './NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo';
+import BusinessInfo from './NonUSD/BusinessInfo/BusinessInfo';
+import Country from './NonUSD/Country/Country';
+import Finish from './NonUSD/Finish/Finish';
+import SignerInfo from './NonUSD/SignerInfo/SignerInfo';
import RequestorStep from './RequestorStep';
type ReimbursementAccountPageProps = WithPolicyOnyxProps & StackScreenProps;
@@ -159,6 +166,7 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps
const isPreviousPolicy = policyIDParam === achData?.policyID;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const currentStep = !isPreviousPolicy ? CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT : achData?.currentStep || CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT;
+ const [nonUSDBankAccountStep, setNonUSDBankAccountStep] = useState(CONST.NON_USD_BANK_ACCOUNT.STEP.COUNTRY);
/**
When this page is first opened, `reimbursementAccount` prop might not yet be fully loaded from Onyx.
@@ -195,6 +203,56 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps
return achData?.state === BankAccount.STATE.PENDING || [CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT, ''].includes(getStepToOpenFromRouteParams(route));
}
+ const handleNextNonUSDBankAccountStep = () => {
+ switch (nonUSDBankAccountStep) {
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.COUNTRY:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.BANK_INFO);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BANK_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.BUSINESS_INFO);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BUSINESS_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.BENEFICIAL_OWNER_INFO);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BENEFICIAL_OWNER_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.SIGNER_INFO);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.SIGNER_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.AGREEMENTS);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.AGREEMENTS:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.FINISH);
+ break;
+ default:
+ return null;
+ }
+ };
+
+ const nonUSDBankAccountsGoBack = () => {
+ switch (nonUSDBankAccountStep) {
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.COUNTRY:
+ Navigation.goBack();
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BANK_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.COUNTRY);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BUSINESS_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.BANK_INFO);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BENEFICIAL_OWNER_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.BUSINESS_INFO);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.SIGNER_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.BENEFICIAL_OWNER_INFO);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.AGREEMENTS:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.SIGNER_INFO);
+ break;
+ default:
+ return null;
+ }
+ };
+
/**
* Retrieve verified business bank account currently being set up.
*/
@@ -389,19 +447,54 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps
errorText = translate('bankAccount.hasBeenThrottledError');
} else if (hasUnsupportedCurrency) {
if (hasForeignCurrency) {
- // TODO This will be replaced with proper component in next issue - https://github.com/Expensify/App/issues/50893
- return (
-
- Navigation.goBack()}
- />
-
- Non USD flow
-
-
- );
+ switch (nonUSDBankAccountStep) {
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.COUNTRY:
+ return (
+
+ );
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BANK_INFO:
+ return (
+
+ );
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BUSINESS_INFO:
+ return (
+
+ );
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BENEFICIAL_OWNER_INFO:
+ return (
+
+ );
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.SIGNER_INFO:
+ return (
+
+ );
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.AGREEMENTS:
+ return (
+
+ );
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.FINISH:
+ return ;
+ default:
+ return null;
+ }
}
errorText = translate('bankAccount.hasCurrencyError');
}
diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx
index 18304878447d..7de12eeda892 100644
--- a/src/pages/ReportDetailsPage.tsx
+++ b/src/pages/ReportDetailsPage.tsx
@@ -94,6 +94,9 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) {
const {reportActions} = usePaginatedReportActions(report.reportID || '-1');
/* eslint-enable @typescript-eslint/prefer-nullish-coalescing */
const {currentSearchHash} = useSearchContext();
+
+ // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
const transactionThreadReportID = useMemo(
diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx
index b4eebb9b76f7..8029067f5026 100755
--- a/src/pages/ReportParticipantsPage.tsx
+++ b/src/pages/ReportParticipantsPage.tsx
@@ -50,6 +50,9 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
const {translate, formatPhoneNumber} = useLocalize();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
+
+ // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use the selection mode only on small screens
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
const selectionListRef = useRef(null);
const textInputRef = useRef(null);
diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx
index 8abdb371701c..1018b86083be 100644
--- a/src/pages/RoomMembersPage.tsx
+++ b/src/pages/RoomMembersPage.tsx
@@ -65,6 +65,8 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
const isFocusedScreen = useIsFocused();
const {isOffline} = useNetwork();
+ // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use the selection mode only on small screens
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE);
const canSelectMultiple = isSmallScreenWidth ? selectionMode?.isEnabled : true;
diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx
index 54b62a61724d..6e117a8baaab 100644
--- a/src/pages/Search/AdvancedSearchFilters.tsx
+++ b/src/pages/Search/AdvancedSearchFilters.tsx
@@ -139,6 +139,17 @@ function getFilterParticipantDisplayTitle(accountIDs: string[], personalDetails:
.join(', ');
}
+const sortOptionsWithEmptyValue = (a: string, b: string) => {
+ // Always show `No category` and `No tag` as the first option
+ if (a === CONST.SEARCH.EMPTY_VALUE) {
+ return -1;
+ }
+ if (b === CONST.SEARCH.EMPTY_VALUE) {
+ return 1;
+ }
+ return localeCompare(a, b);
+};
+
function getFilterDisplayTitle(filters: Partial, fieldName: AdvancedFiltersKeys, translate: LocaleContextProps['translate']) {
if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) {
// the value of date filter is a combination of dateBefore + dateAfter values
@@ -175,14 +186,27 @@ function getFilterDisplayTitle(filters: Partial, fiel
return;
}
- if (
- (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY || fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY || fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG) &&
- filters[fieldName]
- ) {
+ if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY && filters[fieldName]) {
const filterArray = filters[fieldName] ?? [];
return filterArray.sort(localeCompare).join(', ');
}
+ if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[fieldName]) {
+ const filterArray = filters[fieldName] ?? [];
+ return filterArray
+ .sort(sortOptionsWithEmptyValue)
+ .map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noCategory') : value))
+ .join(', ');
+ }
+
+ if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && filters[fieldName]) {
+ const filterArray = filters[fieldName] ?? [];
+ return filterArray
+ .sort(sortOptionsWithEmptyValue)
+ .map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noTag') : value))
+ .join(', ');
+ }
+
if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION) {
return filters[fieldName];
}
diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx
index 0aa6cea05c0e..d92554d42453 100644
--- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx
+++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCategoryPage.tsx
@@ -9,6 +9,7 @@ import useSafePaddingBottomStyle from '@hooks/useSafePaddingBottomStyle';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import * as SearchActions from '@userActions/Search';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -17,19 +18,27 @@ function SearchFiltersCategoryPage() {
const {translate} = useLocalize();
const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM);
- const selectedCategoriesItems = searchAdvancedFiltersForm?.category?.map((category) => ({name: category, value: category}));
+ const selectedCategoriesItems = searchAdvancedFiltersForm?.category?.map((category) => {
+ if (category === CONST.SEARCH.EMPTY_VALUE) {
+ return {name: translate('search.noCategory'), value: category};
+ }
+ return {name: category, value: category};
+ });
const policyID = searchAdvancedFiltersForm?.policyID ?? '-1';
const [allPolicyIDCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
const singlePolicyCategories = allPolicyIDCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`];
const categoryItems = useMemo(() => {
+ const items = [{name: translate('search.noCategory'), value: CONST.SEARCH.EMPTY_VALUE as string}];
if (!singlePolicyCategories) {
const uniqueCategoryNames = new Set();
Object.values(allPolicyIDCategories ?? {}).map((policyCategories) => Object.values(policyCategories ?? {}).forEach((category) => uniqueCategoryNames.add(category.name)));
- return Array.from(uniqueCategoryNames).map((categoryName) => ({name: categoryName, value: categoryName}));
+ items.push(...Array.from(uniqueCategoryNames).map((categoryName) => ({name: categoryName, value: categoryName})));
+ } else {
+ items.push(...Object.values(singlePolicyCategories ?? {}).map((category) => ({name: category.name, value: category.name})));
}
- return Object.values(singlePolicyCategories ?? {}).map((category) => ({name: category.name, value: category.name}));
- }, [allPolicyIDCategories, singlePolicyCategories]);
+ return items;
+ }, [allPolicyIDCategories, singlePolicyCategories, translate]);
const onSaveSelection = useCallback((values: string[]) => SearchActions.updateAdvancedFilters({category: values}), []);
const safePaddingBottomStyle = useSafePaddingBottomStyle();
diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTagPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTagPage.tsx
index 107d100254cb..76e2ca45144f 100644
--- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTagPage.tsx
+++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTagPage.tsx
@@ -9,6 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import {getTagNamesFromTagsLists} from '@libs/PolicyUtils';
import * as SearchActions from '@userActions/Search';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {PolicyTagLists} from '@src/types/onyx';
@@ -18,12 +19,18 @@ function SearchFiltersTagPage() {
const {translate} = useLocalize();
const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM);
- const selectedTagsItems = searchAdvancedFiltersForm?.tag?.map((tag) => ({name: tag, value: tag}));
+ const selectedTagsItems = searchAdvancedFiltersForm?.tag?.map((tag) => {
+ if (tag === CONST.SEARCH.EMPTY_VALUE) {
+ return {name: translate('search.noTag'), value: tag};
+ }
+ return {name: tag, value: tag};
+ });
const policyID = searchAdvancedFiltersForm?.policyID ?? '-1';
const [allPoliciesTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
const singlePolicyTagsList: PolicyTagLists | undefined = allPoliciesTagsLists?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`];
const tagItems = useMemo(() => {
+ const items = [{name: translate('search.noTag'), value: CONST.SEARCH.EMPTY_VALUE as string}];
if (!singlePolicyTagsList) {
const uniqueTagNames = new Set();
const tagListsUnpacked = Object.values(allPoliciesTagsLists ?? {}).filter((item) => !!item) as PolicyTagLists[];
@@ -33,10 +40,12 @@ function SearchFiltersTagPage() {
})
.flat()
.forEach((tag) => uniqueTagNames.add(tag));
- return Array.from(uniqueTagNames).map((tagName) => ({name: tagName, value: tagName}));
+ items.push(...Array.from(uniqueTagNames).map((tagName) => ({name: tagName, value: tagName})));
+ } else {
+ items.push(...getTagNamesFromTagsLists(singlePolicyTagsList).map((name) => ({name, value: name})));
}
- return getTagNamesFromTagsLists(singlePolicyTagsList).map((name) => ({name, value: name}));
- }, [allPoliciesTagsLists, singlePolicyTagsList]);
+ return items;
+ }, [allPoliciesTagsLists, singlePolicyTagsList, translate]);
const updateTagFilter = useCallback((values: string[]) => SearchActions.updateAdvancedFilters({tag: values}), []);
diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx
index 8e204894e5be..fe9645114810 100644
--- a/src/pages/Search/SearchTypeMenuNarrow.tsx
+++ b/src/pages/Search/SearchTypeMenuNarrow.tsx
@@ -208,6 +208,8 @@ function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, queryJSON, title,
onClose={closeMenu}
onItemSelected={closeMenu}
anchorRef={buttonRef}
+ innerContainerStyle={styles.pv0}
+ scrollContainerStyle={styles.pv4}
shouldUseScrollView
/>
diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx
index f78e98b51858..8bb06f8a1672 100644
--- a/src/pages/home/HeaderView.tsx
+++ b/src/pages/home/HeaderView.tsx
@@ -284,7 +284,7 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto
{isTaskReport && !shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && }
{canJoin && !shouldUseNarrowLayout && joinButton}
- {shouldDisplaySearchRouter && }
+ {shouldDisplaySearchRouter && }
(null);
diff --git a/src/pages/home/report/ReportActionCompose/SendButton.tsx b/src/pages/home/report/ReportActionCompose/SendButton.tsx
index b08670c78171..0056a024f549 100644
--- a/src/pages/home/report/ReportActionCompose/SendButton.tsx
+++ b/src/pages/home/report/ReportActionCompose/SendButton.tsx
@@ -24,6 +24,7 @@ function SendButton({isDisabled: isDisabledProp, handleSendMessage}: SendButtonP
const styles = useThemeStyles();
const {translate} = useLocalize();
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to manage GestureDetector correctly
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
const Tap = Gesture.Tap().onEnd(() => {
handleSendMessage();
diff --git a/src/pages/home/sidebar/AvatarWithDelegateAvatar.tsx b/src/pages/home/sidebar/AvatarWithDelegateAvatar.tsx
index 204b2255b8eb..9b9cc31df8e8 100644
--- a/src/pages/home/sidebar/AvatarWithDelegateAvatar.tsx
+++ b/src/pages/home/sidebar/AvatarWithDelegateAvatar.tsx
@@ -24,6 +24,9 @@ type AvatarWithDelegateAvatarProps = {
function AvatarWithDelegateAvatar({delegateEmail, isSelected = false, containerStyle}: AvatarWithDelegateAvatarProps) {
const styles = useThemeStyles();
+
+ // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use correct avatar size
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
const personalDetails = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
const delegatePersonalDetail = Object.values(personalDetails[0] ?? {}).find((personalDetail) => personalDetail?.login?.toLowerCase() === delegateEmail);
diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
index a49b474b185e..fd5eadd10b79 100644
--- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
+++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
@@ -1,10 +1,10 @@
import {useIsFocused as useIsFocusedOriginal, useNavigationState} from '@react-navigation/native';
import type {ImageContentFit} from 'expo-image';
-import type {ForwardedRef, RefAttributes} from 'react';
+import type {ForwardedRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
-import {useOnyx, withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import type {SvgProps} from 'react-native-svg';
import FloatingActionButton from '@components/FloatingActionButton';
import * as Expensicons from '@components/Icon/Expensicons';
@@ -39,6 +39,7 @@ import SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
import type {QuickActionName} from '@src/types/onyx/QuickAction';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import mapOnyxCollectionItems from '@src/utils/mapOnyxCollectionItems';
// On small screen we hide the search page from central pane to show the search bottom tab page with bottom tab bar.
// We need to take this in consideration when checking if the screen is focused.
@@ -51,33 +52,7 @@ const useIsFocused = () => {
type PolicySelector = Pick;
-type FloatingActionButtonAndPopoverOnyxProps = {
- /** The list of policies the user has access to. */
- allPolicies: OnyxCollection;
-
- /** Whether app is in loading state */
- isLoading: OnyxEntry;
-
- /** Information on the last taken action to display as Quick Action */
- quickAction: OnyxEntry;
-
- /** The report data of the quick action */
- quickActionReport: OnyxEntry;
-
- /** The policy data of the quick action */
- quickActionPolicy: OnyxEntry;
-
- /** The current session */
- session: OnyxEntry;
-
- /** Personal details of all the users */
- personalDetails: OnyxEntry;
-
- /** Has user seen track expense training interstitial */
- hasSeenTrackTraining: OnyxEntry;
-};
-
-type FloatingActionButtonAndPopoverProps = FloatingActionButtonAndPopoverOnyxProps & {
+type FloatingActionButtonAndPopoverProps = {
/* Callback function when the menu is shown */
onShowCreateMenu?: () => void;
@@ -161,21 +136,16 @@ const getQuickActionTitle = (action: QuickActionName): TranslationPaths => {
* Responsible for rendering the {@link PopoverMenu}, and the accompanying
* FAB that can open or close the menu.
*/
-function FloatingActionButtonAndPopover(
- {
- onHideCreateMenu,
- onShowCreateMenu,
- isLoading = false,
- allPolicies,
- quickAction,
- quickActionReport,
- quickActionPolicy,
- session,
- personalDetails,
- hasSeenTrackTraining,
- }: FloatingActionButtonAndPopoverProps,
- ref: ForwardedRef,
-) {
+function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: FloatingActionButtonAndPopoverProps, ref: ForwardedRef) {
+ const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (c) => mapOnyxCollectionItems(c, policySelector)});
+ const [isLoading = false] = useOnyx(ONYXKEYS.IS_LOADING_APP);
+ const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE);
+ const [quickActionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${quickAction?.chatReportID ?? '-1'}`);
+ const [quickActionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${quickActionReport?.policyID ?? '-1'}`);
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const [session] = useOnyx(ONYXKEYS.SESSION);
+ const [hasSeenTrackTraining] = useOnyx(ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING);
+
const styles = useThemeStyles();
const {translate} = useLocalize();
const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${quickActionReport?.reportID ?? -1}`);
@@ -510,32 +480,6 @@ function FloatingActionButtonAndPopover(
FloatingActionButtonAndPopover.displayName = 'FloatingActionButtonAndPopover';
-export default withOnyx, FloatingActionButtonAndPopoverOnyxProps>({
- allPolicies: {
- key: ONYXKEYS.COLLECTION.POLICY,
- selector: policySelector,
- },
- isLoading: {
- key: ONYXKEYS.IS_LOADING_APP,
- },
- quickAction: {
- key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE,
- },
- quickActionReport: {
- key: ({quickAction}) => `${ONYXKEYS.COLLECTION.REPORT}${quickAction?.chatReportID}`,
- },
- quickActionPolicy: {
- key: ({quickActionReport}) => `${ONYXKEYS.COLLECTION.POLICY}${quickActionReport?.policyID}`,
- },
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- hasSeenTrackTraining: {
- key: ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING,
- },
-})(forwardRef(FloatingActionButtonAndPopover));
+export default forwardRef(FloatingActionButtonAndPopover);
export type {PolicySelector};
diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx
index 0d124c783947..8a6ff75fee80 100644
--- a/src/pages/iou/request/IOURequestStartPage.tsx
+++ b/src/pages/iou/request/IOURequestStartPage.tsx
@@ -38,11 +38,12 @@ function IOURequestStartPage({
}: IOURequestStartPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const shouldUseTab = iouType !== CONST.IOU.TYPE.SEND && iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.INVOICE;
const [isDraggingOver, setIsDraggingOver] = useState(false);
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
const policy = usePolicy(report?.policyID);
const [selectedTab = CONST.TAB_REQUEST.SCAN, selectedTabResult] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.IOU_REQUEST_TYPE}`);
- const isLoadingSelectedTab = isLoadingOnyxValue(selectedTabResult);
+ const isLoadingSelectedTab = shouldUseTab ? isLoadingOnyxValue(selectedTabResult) : false;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${route?.params.transactionID || -1}`);
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
@@ -58,7 +59,10 @@ function IOURequestStartPage({
[CONST.IOU.TYPE.INVOICE]: translate('workspace.invoices.sendInvoice'),
[CONST.IOU.TYPE.CREATE]: translate('iou.createExpense'),
};
- const transactionRequestType = useMemo(() => transaction?.iouRequestType ?? selectedTab, [transaction?.iouRequestType, selectedTab]);
+ const transactionRequestType = useMemo(
+ () => (transaction?.iouRequestType ?? shouldUseTab ? selectedTab : CONST.IOU.REQUEST_TYPE.MANUAL),
+ [transaction?.iouRequestType, shouldUseTab, selectedTab],
+ );
const isFromGlobalCreate = isEmptyObject(report?.reportID);
// Clear out the temporary expense if the reportID in the URL has changed from the transaction's reportID
@@ -134,7 +138,7 @@ function IOURequestStartPage({
/>
- {iouType !== CONST.IOU.TYPE.SEND && iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.INVOICE ? (
+ {shouldUseTab ? (
;
-
- /** Whether the confirmation step should be skipped */
- skipConfirmation: OnyxEntry;
-
- /** The draft transaction object being modified in Onyx */
- draftTransaction: OnyxEntry;
-
- /** Personal details of all users */
- personalDetails: OnyxEntry;
-
- /** The policy which the user has access to and which the report is tied to */
- policy: OnyxEntry;
-};
-
-type IOURequestStepAmountProps = IOURequestStepAmountOnyxProps &
- WithCurrentUserPersonalDetailsProps &
+type IOURequestStepAmountProps = WithCurrentUserPersonalDetailsProps &
WithWritableReportOrNotFoundProps & {
/** The transaction object being modified in Onyx */
transaction: OnyxEntry;
@@ -65,14 +47,15 @@ function IOURequestStepAmount({
params: {iouType, reportID, transactionID, backTo, pageIndex, action, currency: selectedCurrency = ''},
},
transaction,
- policy,
- personalDetails,
currentUserPersonalDetails,
- splitDraftTransaction,
- skipConfirmation,
- draftTransaction,
shouldKeepUserInput = false,
}: IOURequestStepAmountProps) {
+ const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID ?? '-1'}`);
+ const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID ?? '0'}`);
+ const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? '-1'}`);
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '-1'}`);
+
const {translate} = useLocalize();
const textInput = useRef(null);
const focusTimeoutRef = useRef(null);
@@ -331,34 +314,7 @@ function IOURequestStepAmount({
IOURequestStepAmount.displayName = 'IOURequestStepAmount';
-const IOURequestStepAmountWithOnyx = withOnyx({
- splitDraftTransaction: {
- key: ({route}) => {
- const transactionID = route.params.transactionID ?? -1;
- return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`;
- },
- },
- draftTransaction: {
- key: ({route}) => {
- const transactionID = route.params.transactionID ?? 0;
- return `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`;
- },
- },
- skipConfirmation: {
- key: ({route}) => {
- const transactionID = route.params.transactionID ?? -1;
- return `${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`;
- },
- },
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- policy: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '-1'}`,
- },
-})(IOURequestStepAmount);
-
-const IOURequestStepAmountWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(IOURequestStepAmountWithOnyx);
+const IOURequestStepAmountWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(IOURequestStepAmount);
// eslint-disable-next-line rulesdir/no-negated-variables
const IOURequestStepAmountWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepAmountWithCurrentUserPersonalDetails, true);
// eslint-disable-next-line rulesdir/no-negated-variables
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
index e8fc138f6a52..e688c4a5825e 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
@@ -55,13 +55,17 @@ function IOURequestStepConfirmation({
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
- const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${IOU.getIOURequestPolicyID(transaction, reportDraft)}`);
- const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${IOU.getIOURequestPolicyID(transaction, reportReal)}`);
- const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${IOU.getIOURequestPolicyID(transaction, reportReal)}`);
- const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${IOU.getIOURequestPolicyID(transaction, reportDraft)}`);
- const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${IOU.getIOURequestPolicyID(transaction, reportReal)}`);
+ const policyIDForReal = IOU.getIOURequestPolicyID(transaction, reportReal ?? reportDraft);
+ const policyIDForDraft = IOU.getIOURequestPolicyID(transaction, reportDraft);
+
+ const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyIDForReal}`);
+ const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyIDForDraft}`);
+ const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyIDForReal}`);
+ const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyIDForDraft}`);
+ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyIDForReal}`);
const report = reportReal ?? reportDraft;
+ // Check if the real policy exists for either reportReal or reportDraft
const policy = policyReal ?? policyDraft;
const policyCategories = policyCategoriesReal ?? policyCategoriesDraft;
@@ -89,11 +93,11 @@ function IOURequestStepConfirmation({
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];
+ if (personalDetails?.[transaction?.splitPayerAccountIDs?.at(0) ?? -1]) {
+ return personalDetails?.[transaction?.splitPayerAccountIDs?.at(0) ?? -1];
}
- const participant = transaction?.participants?.find((val) => val.accountID === (transaction?.splitPayerAccountIDs?.[0] ?? -1));
+ const participant = transaction?.participants?.find((val) => val.accountID === (transaction?.splitPayerAccountIDs?.at(0) ?? -1));
return {
login: participant?.login ?? '',
diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx
index 581f8a9176a9..55fad403374a 100644
--- a/src/pages/iou/request/step/IOURequestStepDistance.tsx
+++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx
@@ -21,10 +21,12 @@ import usePolicy from '@hooks/usePolicy';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@hooks/useThemeStyles';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
+import type {MileageRate} from '@libs/DistanceRequestUtils';
import * as ErrorUtils from '@libs/ErrorUtils';
import * as IOUUtils from '@libs/IOUUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
import * as IOU from '@userActions/IOU';
@@ -36,6 +38,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
+import type {Participant} from '@src/types/onyx/IOU';
import type {Errors} from '@src/types/onyx/OnyxCommon';
import type {Waypoint, WaypointCollection} from '@src/types/onyx/Transaction';
import StepScreenWrapper from './StepScreenWrapper';
@@ -83,6 +86,7 @@ function IOURequestStepDistance({
const scrollViewRef = useRef(null);
const isLoadingRoute = transaction?.comment?.isLoading ?? false;
const isLoading = transaction?.isLoading ?? false;
+ const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT;
const hasRouteError = !!transaction?.errorFields?.route;
const [shouldShowAtLeastTwoDifferentWaypointsError, setShouldShowAtLeastTwoDifferentWaypointsError] = useState(false);
const isWaypointEmpty = (waypoint?: Waypoint) => {
@@ -104,7 +108,39 @@ function IOURequestStepDistance({
const isCreatingNewRequest = !(backTo || isEditing);
const [recentWaypoints, {status: recentWaypointsStatus}] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS);
const iouRequestType = TransactionUtils.getRequestType(transaction);
- const customUnitRateID = TransactionUtils.getRateID(transaction);
+ const customUnitRateID = TransactionUtils.getRateID(transaction) ?? '-1';
+
+ // Sets `amount` and `split` share data before moving to the next step to avoid briefly showing `0.00` as the split share for participants
+ const setDistanceRequestData = useCallback(
+ (participants: Participant[]) => {
+ // Get policy report based on transaction participants
+ const isPolicyExpenseChat = participants?.some((participant) => participant.isPolicyExpenseChat);
+ const selectedReportID = participants?.length === 1 ? participants.at(0)?.reportID ?? reportID : reportID;
+ const policyReport = participants.at(0) ? ReportUtils.getReport(selectedReportID) : report;
+
+ const IOUpolicyID = IOU.getIOURequestPolicyID(transaction, policyReport);
+ const IOUpolicy = PolicyUtils.getPolicy(report?.policyID ?? IOUpolicyID);
+ const policyCurrency = policy?.outputCurrency ?? PolicyUtils.getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD;
+
+ const mileageRates = DistanceRequestUtils.getMileageRates(IOUpolicy);
+ const defaultMileageRate = DistanceRequestUtils.getDefaultMileageRate(IOUpolicy);
+ const mileageRate: MileageRate = TransactionUtils.isCustomUnitRateIDForP2P(transaction)
+ ? DistanceRequestUtils.getRateForP2P(policyCurrency, transaction)
+ : mileageRates?.[customUnitRateID] ?? defaultMileageRate;
+
+ const {unit, rate} = mileageRate ?? {};
+ const distance = TransactionUtils.getDistanceInMeters(transaction, unit);
+ const currency = mileageRate?.currency ?? policyCurrency;
+ const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0);
+ IOU.setMoneyRequestAmount(transactionID, amount, currency);
+
+ const participantAccountIDs: number[] | undefined = participants?.map((participant) => Number(participant.accountID ?? -1));
+ if (isSplitRequest && amount && currency && !isPolicyExpenseChat) {
+ IOU.setSplitShares(transaction, amount, currency ?? '', participantAccountIDs ?? []);
+ }
+ },
+ [report, transaction, transactionID, isSplitRequest, policy?.outputCurrency, reportID, customUnitRateID],
+ );
// For quick button actions, we'll skip the confirmation page unless the report is archived or this is a workspace
// request and the workspace requires a category or a tag
@@ -245,6 +281,7 @@ function IOURequestStepDistance({
const participantAccountID = participant?.accountID ?? -1;
return participantAccountID ? OptionsListUtils.getParticipantsOption(participant, personalDetails) : OptionsListUtils.getReportOption(participant);
});
+ setDistanceRequestData(participants);
if (shouldSkipConfirmation) {
if (iouType === CONST.IOU.TYPE.SPLIT) {
IOU.splitBill({
@@ -349,6 +386,7 @@ function IOURequestStepDistance({
iouRequestType,
reportNameValuePairs,
customUnitRateID,
+ setDistanceRequestData,
]);
const getError = () => {
@@ -513,9 +551,7 @@ function IOURequestStepDistance({
IOURequestStepDistance.displayName = 'IOURequestStepDistance';
-const IOURequestStepDistanceWithOnyx = IOURequestStepDistance;
-
-const IOURequestStepDistanceWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(IOURequestStepDistanceWithOnyx);
+const IOURequestStepDistanceWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(IOURequestStepDistance);
// eslint-disable-next-line rulesdir/no-negated-variables
const IOURequestStepDistanceWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepDistanceWithCurrentUserPersonalDetails, true);
// eslint-disable-next-line rulesdir/no-negated-variables
diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
index e8f02f0c1975..5128762232a3 100644
--- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx
+++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
@@ -1,7 +1,6 @@
import {useIsFocused} from '@react-navigation/core';
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import FormHelpMessage from '@components/FormHelpMessage';
import useLocalize from '@hooks/useLocalize';
import usePermissions from '@hooks/usePermissions';
@@ -26,13 +25,7 @@ import withFullTransactionOrNotFound from './withFullTransactionOrNotFound';
import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound';
import withWritableReportOrNotFound from './withWritableReportOrNotFound';
-type IOURequestStepParticipantsOnyxProps = {
- /** Whether the confirmation step should be skipped */
- skipConfirmation: OnyxEntry;
-};
-
-type IOURequestStepParticipantsProps = IOURequestStepParticipantsOnyxProps &
- WithWritableReportOrNotFoundProps &
+type IOURequestStepParticipantsProps = WithWritableReportOrNotFoundProps &
WithFullTransactionOrNotFoundProps;
function IOURequestStepParticipants({
@@ -40,8 +33,9 @@ function IOURequestStepParticipants({
params: {iouType, reportID, transactionID, action},
},
transaction,
- skipConfirmation,
}: IOURequestStepParticipantsProps) {
+ const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? '-1'}`);
+
const participants = transaction?.participants;
const {translate} = useLocalize();
const styles = useThemeStyles();
@@ -207,13 +201,4 @@ function IOURequestStepParticipants({
IOURequestStepParticipants.displayName = 'IOURequestStepParticipants';
-const IOURequestStepParticipantsWithOnyx = withOnyx({
- skipConfirmation: {
- key: ({route}) => {
- const transactionID = route.params.transactionID ?? -1;
- return `${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`;
- },
- },
-})(IOURequestStepParticipants);
-
-export default withWritableReportOrNotFound(withFullTransactionOrNotFound(IOURequestStepParticipantsWithOnyx));
+export default withWritableReportOrNotFound(withFullTransactionOrNotFound(IOURequestStepParticipants));
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
index 5b868e21ca02..9d20aea6273b 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
@@ -683,9 +683,7 @@ function IOURequestStepScan({
IOURequestStepScan.displayName = 'IOURequestStepScan';
-const IOURequestStepScanWithOnyx = IOURequestStepScan;
-
-const IOURequestStepScanWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(IOURequestStepScanWithOnyx);
+const IOURequestStepScanWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(IOURequestStepScan);
// eslint-disable-next-line rulesdir/no-negated-variables
const IOURequestStepScanWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepScanWithCurrentUserPersonalDetails, true);
// eslint-disable-next-line rulesdir/no-negated-variables
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx
index 99ddd1d413e1..f30d54ec8055 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx
@@ -72,6 +72,7 @@ function IOURequestStepScan({
const [fileSource, setFileSource] = useState('');
const [receiptImageTopPosition, setReceiptImageTopPosition] = useState(0);
// we need to use isSmallScreenWidth instead of shouldUseNarrowLayout because drag and drop is not supported on mobile
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
const {translate} = useLocalize();
const {isDraggingOver} = useContext(DragAndDropContext);
@@ -795,9 +796,7 @@ function IOURequestStepScan({
IOURequestStepScan.displayName = 'IOURequestStepScan';
-const IOURequestStepScanWithOnyx = IOURequestStepScan;
-
-const IOURequestStepScanWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(IOURequestStepScanWithOnyx);
+const IOURequestStepScanWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(IOURequestStepScan);
// eslint-disable-next-line rulesdir/no-negated-variables
const IOURequestStepScanWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepScanWithCurrentUserPersonalDetails, true);
// eslint-disable-next-line rulesdir/no-negated-variables
diff --git a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx
index 66736dc80b52..0ddddf7ff878 100644
--- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx
+++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx
@@ -2,8 +2,8 @@ import type {RouteProp} from '@react-navigation/native';
import {useIsFocused} from '@react-navigation/native';
import type {ComponentType, ForwardedRef, RefAttributes} from 'react';
import React, {forwardRef} from 'react';
+import {useOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import getComponentDisplayName from '@libs/getComponentDisplayName';
import * as IOUUtils from '@libs/IOUUtils';
@@ -38,14 +38,25 @@ type MoneyRequestRouteName =
| typeof SCREENS.MONEY_REQUEST.STEP_SEND_FROM
| typeof SCREENS.MONEY_REQUEST.STEP_COMPANY_INFO;
-type Route = RouteProp;
+type Route = RouteProp;
-type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & {route: Route};
+type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & {
+ route: Route;
+};
-export default function , TRef>(WrappedComponent: ComponentType>) {
+export default function , TRef>(
+ WrappedComponent: ComponentType>,
+): React.ComponentType & RefAttributes> {
// eslint-disable-next-line rulesdir/no-negated-variables
- function WithFullTransactionOrNotFound(props: TProps, ref: ForwardedRef) {
- const transactionID = props.transaction?.transactionID;
+ function WithFullTransactionOrNotFound(props: Omit, ref: ForwardedRef) {
+ const {route} = props;
+ const transactionID = route.params.transactionID ?? -1;
+ const userAction = 'action' in route.params && route.params.action ? route.params.action : CONST.IOU.ACTION.CREATE;
+
+ const shouldUseTransactionDraft = IOUUtils.shouldUseTransactionDraft(userAction);
+
+ const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
+ const [transactionDraft] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`);
const isFocused = useIsFocused();
@@ -59,27 +70,16 @@ export default function
);
}
WithFullTransactionOrNotFound.displayName = `withFullTransactionOrNotFound(${getComponentDisplayName(WrappedComponent)})`;
- // eslint-disable-next-line deprecation/deprecation
- return withOnyx, WithFullTransactionOrNotFoundOnyxProps>({
- transaction: {
- key: ({route}) => {
- const transactionID = route.params.transactionID ?? -1;
- const userAction = 'action' in route.params && route.params.action ? route.params.action : CONST.IOU.ACTION.CREATE;
- if (IOUUtils.shouldUseTransactionDraft(userAction)) {
- return `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}` as `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}`;
- }
- return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`;
- },
- },
- })(forwardRef(WithFullTransactionOrNotFound));
+ return forwardRef(WithFullTransactionOrNotFound);
}
export type {WithFullTransactionOrNotFoundProps};
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx
index 9fcc28f51912..bd0151cda4ea 100644
--- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx
+++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx
@@ -1,11 +1,10 @@
import type {StackScreenProps} from '@react-navigation/stack';
import {Str} from 'expensify-common';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
-import {InteractionManager, Keyboard, View} from 'react-native';
+import {InteractionManager, Keyboard} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import ConfirmModal from '@components/ConfirmModal';
-import DotIndicatorMessage from '@components/DotIndicatorMessage';
import ErrorMessageRow from '@components/ErrorMessageRow';
import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
@@ -15,6 +14,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback';
import ScreenWrapper from '@components/ScreenWrapper';
import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
+import ValidateCodeActionModal from '@components/ValidateCodeActionModal';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import useTheme from '@hooks/useTheme';
@@ -23,6 +23,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities';
import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -30,7 +31,6 @@ import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
-import ValidateCodeForm from './ValidateCodeForm';
import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm';
type ContactMethodDetailsPageProps = StackScreenProps;
@@ -41,6 +41,7 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) {
const [myDomainSecurityGroups, myDomainSecurityGroupsResult] = useOnyx(ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS);
const [securityGroups, securityGroupsResult] = useOnyx(ONYXKEYS.COLLECTION.SECURITY_GROUP);
const [isLoadingReportData, isLoadingReportDataResult] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true});
+ const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(true);
const isLoadingOnyxValues = isLoadingOnyxValue(loginListResult, sessionResult, myDomainSecurityGroupsResult, securityGroupsResult, isLoadingReportDataResult);
@@ -71,10 +72,11 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) {
});
const afterAtSign = contactMethodParam.substring(lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, '%');
- return decodeURIComponent(beforeAtSign + afterAtSign);
+ return addSMSDomainIfPhoneNumber(decodeURIComponent(beforeAtSign + afterAtSign));
}, [route.params.contactMethod]);
const loginData = useMemo(() => loginList?.[contactMethod], [loginList, contactMethod]);
const isDefaultContactMethod = useMemo(() => session?.email === loginData?.partnerUserID, [session?.email, loginData?.partnerUserID]);
+ const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin');
/**
* Attempt to set this contact method as user's "Default contact method"
@@ -133,17 +135,29 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) {
User.deleteContactMethod(contactMethod, loginList ?? {}, backTo);
}, [contactMethod, loginList, toggleDeleteModal, backTo]);
+ const sendValidateCode = () => {
+ if (loginData?.validateCodeSent) {
+ return;
+ }
+
+ User.requestContactMethodValidateCode(contactMethod);
+ };
+
const prevValidatedDate = usePrevious(loginData?.validatedDate);
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- if (prevValidatedDate || !loginData?.validatedDate) {
+ if (prevValidatedDate || !loginData?.validatedDate || !loginData) {
return;
}
// Navigate to methods page on successful magic code verification
// validatedDate property is responsible to decide the status of the magic code verification
Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo));
- }, [prevValidatedDate, loginData?.validatedDate, isDefaultContactMethod, backTo]);
+ }, [prevValidatedDate, loginData?.validatedDate, isDefaultContactMethod, backTo, loginData]);
+
+ useEffect(() => {
+ setIsValidateCodeActionModalVisible(!loginData?.validatedDate);
+ }, [loginData?.validatedDate, loginData?.errorFields?.addedLogin]);
if (isLoadingOnyxValues || (isLoadingReportData && isEmptyObject(loginList))) {
return ;
@@ -168,6 +182,64 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) {
const isFailedAddContactMethod = !!loginData.errorFields?.addedLogin;
const isFailedRemovedContactMethod = !!loginData.errorFields?.deletedLogin;
+ const getMenuItems = () => (
+ <>
+ {canChangeDefaultContactMethod ? (
+ User.clearContactMethodErrors(contactMethod, 'defaultLogin')}
+ >
+
+
+ ) : null}
+ {isDefaultContactMethod ? (
+ User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')}
+ >
+ {translate('contacts.yourDefaultContactMethod')}
+
+ ) : (
+ User.clearContactMethodErrors(contactMethod, 'deletedLogin')}
+ >
+
+ )}
+
+ toggleDeleteModal(false)}
+ onModalHide={() => {
+ InteractionManager.runAfterInteractions(() => {
+ validateCodeFormRef.current?.focusLastSelected?.();
+ });
+ }}
+ prompt={translate('contacts.removeAreYouSure')}
+ confirmText={translate('common.yesContinue')}
+ cancelText={translate('common.cancel')}
+ isVisible={isDeleteModalOpen && !isDefaultContactMethod}
+ danger
+ />
+ >
+ );
+
return (
validateCodeFormRef.current?.focus?.()}
@@ -178,88 +250,38 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) {
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo))}
/>
- toggleDeleteModal(false)}
- onModalHide={() => {
- InteractionManager.runAfterInteractions(() => {
- validateCodeFormRef.current?.focusLastSelected?.();
- });
- }}
- prompt={translate('contacts.removeAreYouSure')}
- confirmText={translate('common.yesContinue')}
- cancelText={translate('common.cancel')}
- isVisible={isDeleteModalOpen && !isDefaultContactMethod}
- danger
- />
-
{isFailedAddContactMethod && (
{
User.clearContactMethod(contactMethod);
+ User.clearUnvalidatedNewContactMethodAction();
Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo));
}}
canDismissError
/>
)}
- {!loginData.validatedDate && !isFailedAddContactMethod && (
-
-
+ {}}
+ hasMagicCodeBeenSent={hasMagicCodeBeenSent}
+ isVisible={isValidateCodeActionModalVisible && !loginData.validatedDate && !!loginData}
+ validatePendingAction={loginData.pendingFields?.validateCodeSent}
+ handleSubmitForm={(validateCode) => User.validateSecondaryLogin(loginList, contactMethod, validateCode)}
+ validateError={!isEmptyObject(validateLoginError) ? validateLoginError : ErrorUtils.getLatestErrorField(loginData, 'validateCodeSent')}
+ clearError={() => User.clearContactMethodErrors(contactMethod, !isEmptyObject(validateLoginError) ? 'validateLogin' : 'validateCodeSent')}
+ onClose={() => {
+ Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo));
+ setIsValidateCodeActionModalVisible(false);
+ }}
+ sendValidateCode={sendValidateCode}
+ description={translate('contacts.enterMagicCode', {contactMethod})}
+ footer={() => getMenuItems()}
+ />
-
-
- )}
- {canChangeDefaultContactMethod ? (
- User.clearContactMethodErrors(contactMethod, 'defaultLogin')}
- >
-
-
- ) : null}
- {isDefaultContactMethod ? (
- User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')}
- >
- {translate('contacts.yourDefaultContactMethod')}
-
- ) : (
- User.clearContactMethodErrors(contactMethod, 'deletedLogin')}
- >
-
- )}
+ {!isValidateCodeActionModalVisible && getMenuItems()}
);
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx
index 893a54c5ccfd..6c6d4268eccd 100644
--- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx
+++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx
@@ -2,8 +2,7 @@ import type {StackScreenProps} from '@react-navigation/stack';
import {Str} from 'expensify-common';
import React, {useCallback, useState} from 'react';
import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {useOnyx, withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import CopyTextToClipboard from '@components/CopyTextToClipboard';
import DelegateNoAccessModal from '@components/DelegateNoAccessModal';
@@ -19,27 +18,19 @@ import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
-import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
-import type {LoginList, Session} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
-type ContactMethodsPageOnyxProps = {
- /** Login list for the user that is signed in */
- loginList: OnyxEntry;
+type ContactMethodsPageProps = StackScreenProps;
- /** Current user session */
- session: OnyxEntry;
-};
-
-type ContactMethodsPageProps = ContactMethodsPageOnyxProps & StackScreenProps;
-
-function ContactMethodsPage({loginList, session, route}: ContactMethodsPageProps) {
+function ContactMethodsPage({route}: ContactMethodsPageProps) {
const styles = useThemeStyles();
const {formatPhoneNumber, translate} = useLocalize();
+ const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST);
+ const [session] = useOnyx(ONYXKEYS.SESSION);
const loginNames = Object.keys(loginList ?? {});
const navigateBackTo = route?.params?.backTo;
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
@@ -87,12 +78,7 @@ function ContactMethodsPage({loginList, session, route}: ContactMethodsPageProps