diff --git a/.eslintignore b/.eslintignore index aa8b769dfede..3d966d096add 100644 --- a/.eslintignore +++ b/.eslintignore @@ -10,3 +10,4 @@ docs/vendor/** docs/assets/** web/gtm.js **/.expo/** +src/libs/SearchParser/searchParser.js diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index ffce73644263..0d5879217ea0 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -257,12 +257,12 @@ jobs: - name: Check if test failed, if so post the results and add the DeployBlocker label id: checkIfRegressionDetected run: | - if grep -q '๐Ÿ”ด' ./output.md; then + if grep -q '๐Ÿ”ด' "./Host_Machine_Files/\$WORKING_DIRECTORY/output.md"; then # Create an output to the GH action that the test failed: echo "performanceRegressionDetected=true" >> "$GITHUB_OUTPUT" gh pr edit ${{ inputs.PR_NUMBER }} --add-label DeployBlockerCash - gh pr comment ${{ inputs.PR_NUMBER }} -F ./output.md + gh pr comment ${{ inputs.PR_NUMBER }} -F "./Host_Machine_Files/\$WORKING_DIRECTORY/output.md" gh pr comment ${{ inputs.PR_NUMBER }} -b "@Expensify/mobile-deployers ๐Ÿ“ฃ Please look into this performance regression as it's a deploy blocker." else echo "performanceRegressionDetected=false" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/sendReassurePerfData.yml b/.github/workflows/sendReassurePerfData.yml index 53b3d3374a9e..30a30918f4f6 100644 --- a/.github/workflows/sendReassurePerfData.yml +++ b/.github/workflows/sendReassurePerfData.yml @@ -25,7 +25,7 @@ jobs: shell: bash run: | set -e - npx reassure --baseline + NODE_OPTIONS=--experimental-vm-modules npx reassure --baseline - name: Get merged pull request id: getMergedPullRequest diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 476b01f87b07..3bfc0ed28d1a 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -34,7 +34,7 @@ jobs: # - git diff is used to see the files that were added on this branch # - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main # - wc counts the words in the result of the intersection - count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'desktop/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' 'workflow_tests/*.js' '.github/libs/*.js' '.github/scripts/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) + count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'desktop/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' 'workflow_tests/*.js' '.github/libs/*.js' '.github/scripts/*.js' ':!src/libs/SearchParser/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) if [ "$count_new_js" -gt "0" ]; then echo "ERROR: Found new JavaScript files in the project; use TypeScript instead." exit 1 diff --git a/.prettierignore b/.prettierignore index 09de20ba30b0..a9f7e1464529 100644 --- a/.prettierignore +++ b/.prettierignore @@ -19,3 +19,6 @@ package-lock.json src/libs/E2E/reactNativeLaunchingTest.ts # Temporary while we keep react-compiler in our repo lib/** + +# Automatically generated files +src/libs/SearchParser/searchParser.js diff --git a/android/app/build.gradle b/android/app/build.gradle index d4cc7471a72b..f722d8426b7e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -107,8 +107,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009000600 - versionName "9.0.6-0" + versionCode 1009000602 + versionName "9.0.6-2" // 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/simple-illustrations/simple-illustration__empty-state.svg b/assets/images/simple-illustrations/simple-illustration__empty-state.svg new file mode 100644 index 000000000000..154b2269c285 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__empty-state.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__receipt-location-marker.svg b/assets/images/simple-illustrations/simple-illustration__receipt-location-marker.svg new file mode 100644 index 000000000000..01669d07c0f0 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__receipt-location-marker.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__tire.svg b/assets/images/simple-illustrations/simple-illustration__tire.svg new file mode 100644 index 000000000000..9107c88eb3e2 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__tire.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Pay-Bills.md deleted file mode 100644 index 81dcf3488462..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/Pay-Bills.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -title: Pay Bills -description: How to receive and pay company bills in Expensify ---- - - -# Overview -Simplify your back office by receiving bills from vendors and suppliers in Expensify. Anyone with or without an Expensify account can send you a bill, and Expensify will file it as a Bill and help you issue the payment. - -# How to Receive Vendor or Supplier Bills in Expensify - -There are three ways to get a vendor or supplier bill into Expensify: - -**Option 1:** Have vendors send bills to Expensify directly: Ask your vendors to email all bills to your Expensify billing intake email. - -**Option 2:** Forward bills to Expensify: If your bills are emailed to you, you can forward those bills to your Expensify billing intake email yourself. - -**Option 3:** Manually upload bills to Expensify: If you receive physical bills, you can manually create a Bill in Expensify on the web from the Reports page: -1. Click **New Report** and choose **Bill** -2. Add the expense details and vendor's email address to the pop-up window -3. Upload a pdf/image of the bill -4. Click **Submit** - -# How to Pay Bills - -There are multiple ways to pay Bills in Expensify. Letโ€™s go over each method below: - -## ACH bank-to-bank transfer - -To use this payment method, you must have a business bank account connected to your Expensify account. - -To pay with an ACH bank-to-bank transfer: - -1. Sign in to your Expensify account on the web at www.expensify.com. -2. Go to the Inbox or Reports page and locate the Bill that needs to be paid. -3. Click the **Pay** button to be redirected to the Bill. -4. Choose the ACH option from the drop-down list. -5. Follow the prompts to connect your business bank account to Expensify. - -**Fees:** None - -## Pay using a credit or debit card - -This option is available to all US and International customers receiving an bill from a US vendor with a US business bank account. - -To pay with a credit or debit card: -1. Sign-in to your Expensify account on the web app at www.expensify.com. -2, Click on the Bill youโ€™d like to pay to see the details. -3, Click the **Pay** button. -4. Youโ€™ll be prompted to enter your credit card or debit card details. - -**Fees:** Includes 2.9% credit card payment fee - -## Venmo - -If both you and the vendor have Venmo setup in their Expensify account, you can opt to pay the bill through Venmo. - -**Fees:** Venmo charges a 3% senderโ€™s fee - -## Pay Outside of Expensify - -If you are not able to pay using one of the above methods, then you can mark the Bill as paid manually in Expensify to update its status and indicate that you have made payment outside Expensify. - -To mark a Bill as paid outside of Expensify: - -1. Sign-in to your Expensify account on the web app at www.expensify.com. -2. Click on the Bill youโ€™d like to pay to see the details. -3. Click on the **Reimburse** button. -4. Choose **Iโ€™ll do it manually** - -**Fees:** None - -# Deep Dive: How company bills and vendor invoices are processed in Expensify - -Here is how a vendor or supplier bill goes from received to paid in Expensify: - -1. When a vendor or supplier bill is received in Expensify via, the document is SmartScanned automatically and a Bill is created. The Bill is owned by the primary domain contact, who will see the Bill on the Reports page on their default group policy. -2. When the Bill is ready for processing, it is submitted and follows the primary domain contactโ€™s approval workflow. Each time the Bill is approved, it is visible in the next approver's Inbox. -3. The final approver pays the Bill from their Expensify account on the web via one of the methods. -4. The Bill is coded with the relevant imported GL codes from a connected accounting software. After it has finished going through the approval workflow the Bill can be exported back to the accounting package connected to the policy. - - -{% include faq-begin.md %} - -## What is my company's billing intake email? -Your billing intake email is [yourdomain.com]@expensify.cash. Example, if your domain is `company.io` your billing email is `company.io@expensify.cash`. - -## When a vendor or supplier bill is sent to Expensify, who receives it? - -Bills are received by the Primary Contact for the domain. This is the email address listed at **Settings > Domains > Domain Admins**. - -## Who can view a Bill in Expensify? - -Only the primary contact of the domain can view a Bill. - -## Who can pay a Bill? - -Only the primary domain contact (owner of the bill) will be able to pay the Mill. - -## How can you share access to Bills? - -To give others the ability to view a Bill, the primary contact can manually โ€œshareโ€ the Bill under the Details section of the report via the Sharing Options button. -To give someone else the ability to pay Bills, the primary domain contact will need to grant those individuals Copilot access to the primary domain contact's account. - -## Is Bill Pay supported internationally? - -Payments are currently only supported for users paying in United States Dollars (USD). - -## Whatโ€™s the difference between a Bill and an Invoice in Expensify? - -A Bill is a payable which represents an amount owed to a payee (usually a vendor or supplier), and is usually created from a vendor invoice. An Invoice is a receivable, and indicates an amount owed to you by someone else. - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Reimbursements.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Reimbursements.md deleted file mode 100644 index a31c0a582fd7..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/Reimbursements.md +++ /dev/null @@ -1,42 +0,0 @@ -# Overview - -If you want to know more about how and when youโ€™ll be reimbursed through Expensify, weโ€™ve answered your questions below. - -# How to Get Reimbursed - -To get paid back after submitting a report for reimbursement, youโ€™ll want to be sure to connect your bank account. You can do that under **Settings** > **Account** > **Payments** > **Add a Deposit Account**. Once your employer has approved your report, the reimbursement will be paid into the account you added. - -# Deep Dive - -## Reimbursement Timing - -### US Bank Accounts - -If your company uses Expensify's ACH reimbursement we'll first check to see if the report is eligible for Rapid Reimbursement (next business day). For a report to be eligible for Rapid Reimbursement it must fall under two limits: - - - $100 per deposit bank account per day or less for the individuals being reimbursed or businesses receiving payments for bills. - - Less than $10,000 being disbursed in a 24-hour time period from the verified bank account being used to pay the reimbursement. - -If the request passes both checks, then you can expect to see funds deposited into your bank account on the next business day. - -If either limit has been reached, then you can expect to see funds deposited within your bank account within the typical ACH timeframe of 3-5 business days. - -### International Bank Accounts - -If receiving reimbursement to an international deposit account via Global Reimbursement, you should expect to see funds deposited in your bank account within 4 business days. - -## Bank Processing Timeframes - -Banks only process transactions and ACH activity on weekdays that are not bank holidays. These are considered business days. Additionally, the business day on which a transaction will be processed depends upon whether or not a request is created before or after the cutoff time, which is typically 3 pm PST. -For example, if your reimbursement is initiated at 4 pm on Wednesday, this is past the bank's cutoff time, and it will not begin processing until the next business day. -If that same reimbursement starts processing on Thursday, and it's estimated to take 3-5 business days, this will cover a weekend, and both days are not considered business days. So, assuming there are no bank holidays added into this mix, here is how that reimbursement timeline would play out: - -**Wednesday**: Reimbursement initiated after 3 pm PST; will be processed the next business day by your companyโ€™s bank. -**Thursday**: Your company's bank will begin processing the withdrawal request -**Friday**: Business day 1 -**Saturday**: Weekend -**Sunday**: Weekend -**Monday**: Business day 2 -**Tuesday**: Business day 3 -**Wednesday**: Business day 4 -**Thursday**: Business day 5 diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Reimbursing-Reports.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Reimbursing-Reports.md deleted file mode 100644 index 69b39bae2874..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/Reimbursing-Reports.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: Reimbursing Reports -description: How to reimburse employee expense reports ---- -# Overview - -One essential aspect of the Expensify workflow is the ability to reimburse reports. This process allows for the reimbursement of expenses that have been submitted for review to the person who made the request. Detailed explanations of the various methods for reimbursing reports within Expensify are provided below. - -# How to reimburse reports - -Reports can be reimbursed directly within Expensify by clicking the **Reimburse** button at the top of the report to reveal the available reimbursement options. - -## Direct Deposit - -To reimburse directly in Expensify, the following needs to be already configured: -- The employee that's receiving reimbursement needs to add a deposit bank account to their Expensify account (under **Settings > Account > Payments > Add a Deposit-only Bank Account**) -- The reimburser needs to add a business bank account to Expensify (under **Settings > Account > Payments > Add a Verified Business Bank Account**) -- The reimburser needs to ensure Expensify is whitelisted to withdraw funds from the bank account - -If all of those settings are in place, to reimburse a report, you will click **Reimburse** on the report and then select **Via Direct Deposit (ACH)**. - -![Reimbursing Reports Dropdown]({{site.url}}/assets/images/Reimbursing Reports Dropdown.png){:width="100%"} - -## Indirect or Manual Reimbursement - -If you don't have the option to utilize direct reimbursement, you can choose to mark a report as reimbursed by clicking the **Reimburse** button at the top of the report and then selecting **Iโ€™ll do it manually โ€“ just mark as reimbursed**. - -This will effectively mark the report as reimbursed within Expensify, but you'll handle the payment elsewhere, outside of the platform. - -# Best Practices -- Plan ahead! Consider sharing a business bank account with multiple workspace admins so they can reimburse employee reports if you're unavailable. We recommend having at least two workspace admins with reimbursement permissions. - -- Understand there is a verification process when sharing a business bank account. The new reimburser will need access to the business bank accountโ€™s transaction history (or access to someone who has access to it) to verify the set of test transactions sent from Expensify. - -- Get into the routine of having every new employee connect a deposit-only bank account to their Expensify account. This will ensure reimbursements happen in a timely manner. - -- Employees can see the expected date of their reimbursement at the top of and in the comments section of their report. - -# How to cancel a reimbursement - -Reimbursed a report by mistake? No worries! Any workspace admin with access to the same Verified Bank Account can cancel the reimbursement from within the report until it is withdrawn from the payment account. - -**Steps to Cancel an ACH Reimbursement:** -1. On your web account, navigate to the Reports page -2. Open the report -3. Click **Cancel Reimbursement** -4. After the prompt, "Are you sure you want to cancel the reimbursement?" click **Cancel Reimbursement**. - -It's important to note that there is a small window of time (roughly less than 24 hours) when a reimbursement can be canceled. If you don't see the **Cancel Reimbursement** button on a report, this means your bank has already begun withdrawing the funds from the reimbursement account and the withdrawal cannot be canceled. - -In that case, youโ€™ll want to contact your bank directly to see if they can cancel the reimbursement on their end - or manage the return of funds directly with your employee, outside of Expensify. - -If you cancel a reimbursement after the withdrawal has started, it will be automatically returned to your Verified Bank Account within 3-5 business days. - -# Deep Dive - -## Rapid Reimbursement -If your company uses Expensify's ACH reimbursement, we'll first check to see if the report is eligible for Rapid Reimbursement (next business day). For a report to be eligible for Rapid Reimbursement, it must fall under two limits: -- $100 per deposit only bank account per day for the individuals being reimbursed or businesses receiving payments for bills -- $10,000 per verified bank account for the company paying bills and reimbursing - -If neither limit is met, you can expect to see funds deposited into your bank account on the next business day. - -If either limit has been reached, you can expect funds deposited within your bank account within the typical ACH time frame of four to five business days. - -Rapid Reimbursement is not available for non-US-based reimbursement. If you are receiving a reimbursement to a non-US-based deposit account, you should expect to see the funds deposited in your bank account within four business days. - -{% include faq-begin.md %} - -## Who can reimburse reports? -Only a workspace admin who has added a verified business bank account to their Expensify account can reimburse employees. - -## Why canโ€™t I trigger direct ACH reimbursements in bulk? - -Instead of a bulk reimbursement option, you can set up automatic reimbursement. With this configured, reports below a certain threshold (defined by you) will be automatically reimbursed via ACH as soon as they're "final approved." - -To set your manual reimbursement threshold, head to **Settings > Workspace > Group > _[Workspace Name]_ > Reimbursement > Manual Reimbursement**. - -![Manual Reimbursement]({{site.url}}/assets/images/Reimbursing Manual.png){:width="100%"} - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Cancel-an-ACH-Reimbursement.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Cancel-an-ACH-Reimbursement.md new file mode 100644 index 000000000000..ab75067b1a7f --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Cancel-an-ACH-Reimbursement.md @@ -0,0 +1,17 @@ +--- +title: Cancel an ACH reimbursement +description: Cancel an ACH payment after it has been sent +--- +
+ +If a report was reimbursed with an ACH payment by mistake or otherwise needs to be canceled, a Workspace Admin with access to the verified bank account can cancel the reimbursement up until it is withdrawn from the payment account. + +To cancel an ACH reimbursement, + +1. Click the **Reports** tab. +2. Open the report. +3. Click **Cancel Reimbursement**. + - If you donโ€™t see the Cancel Reimbursement button, this means your bank has already begun transferring the funds and it cannot be canceled. In this case, youโ€™ll need to contact your bank for cancellation. +4. Click **Cancel Reimbursement** to confirm cancellation. + +
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-Payments.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-Payments.md new file mode 100644 index 000000000000..00fb236e1763 --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-Payments.md @@ -0,0 +1,36 @@ +--- +title: Receive payments +description: Receive reimbursements from an employer +--- +
+ +To get paid after submitting a report for reimbursement, you must first connect a personal U.S. bank account or a personal Australian bank account. Then once your employer approves your report or invoice, the reimbursement will be paid directly to your bank account. +Funds for U.S. and global payments are generally deposited within a maximum of four to five business days: 2 days for the funds to be debited from the business bank account, and 2-3 business days for the ACH or wire to deposit into the employee account. + +However, banks only process ACH transactions before the daily cutoff (generally 3 p.m. PST) and only on business weekdays that are not bank holidays. This may affect when the payment is disbursed. If the payment qualifies for Rapid Reimbursement, you may receive the payment sooner. + +{% include info.html %} +Companies also have the option to submit payments outside of Expensify via check, cash, or a third-party payment processor. Check with your Workspace Admin to know how you will be reimbursed. +{% include end-info.html %} + +# Rapid Reimbursement (U.S. only) + +With Expensifyโ€™s ACH reimbursement, payments may be eligible for reimbursement by the next business day with Rapid Reimbursement if they meet the following qualifications: +- **Deposit-only accounts**: Payment must not exceed $100 +- **Verified business bank accounts**: The account does not disburse more than $10,000 within a 24-hour time period. + +If the payment amount exceeds the limit, funds will be deposited within the typical ACH time frame of four to five business days. + +{% include faq-begin.md %} + +**Is there a way I can track my payment?** + +For U.S. ACH payments and global reimbursements, the expected date of reimbursement is provided at the top of the report and in the comments section of the report. Funds will be deposited within the typical ACH time frame of four to five business days unless the payment is eligible for Rapid Reimbursement. + +**For global payments, what currency is the payment provided in?** + +Global payments are reimbursed in the recipient's currency. + +{% include faq-end.md %} + +
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Australian-Reports.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Australian-Reports.md new file mode 100644 index 000000000000..90a89ff3c75e --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Australian-Reports.md @@ -0,0 +1,63 @@ +--- +title: Reimburse Australian reports +description: Send payment for Australian expense reports +--- +
+ +Workspace Admins can reimburse AUD expense reports by downloading an .aba file containing the accounts needing payment and uploading the file to the bank. This can be done for a single report or for a batch of payments at once. + +{% include info.html %} +Your financial institution may require .aba files to include a self-balancing transaction. If you are unsure, check with your bank. Otherwise, the .aba file may not work with your bankโ€™s internet banking platform. +{% include end-info.html %} + +# Reimburse a single report + +1. Open the report, invoice, or bill from the email or Concierge notification, or from the **Reports** tab. +2. Click the **Reimburse** dropdown and select **Via ABA File**. +3. Click **Generate ABA and Mark as Reimbursed**. +4. Click **Download**. +5. Upload the .aba file to your bank. For additional guidance, use any of the following bank guides: + - [ANZ Bank](https://www.anz.com.au/support/internet-banking/pay-transfer-business/payroll/import-file/) + - [CommBank](https://www.commbank.com.au/business/pds/003-279-importing-a-de-file.pdf) + - [Westpac](https://www.westpac.com.au/business-banking/online-banking/support-faqs/import-files/) + - [NAB](https://www.nab.com.au/business/online-banking/nab-connect/help) + - [Bendigo Bank](https://www.bendigobank.com.au/globalassets/documents/business/bulk-payments-user-guide.pdf) + - [Bank of Queensland](https://www.boq.com.au/help-and-support/online-banking/ob-faqs-and-support/faq-pfuf) + +# Send batch payments + +Once employees submit their expense reports, a Workspace Admin exports the reports (which contains the employeesโ€™ bank account information) and uploads the .aba file to the bank. + +## Step 1: Verify currency & reimbursement settings + +1. Hover over **Settings**, then click **Workspaces**. +2. Select the desired workspace. +3. Click the **Reports** tab on the left. +4. Click the Report Currency dropdown and select **AUD A$**. +5. Click the **Reimbursement** tab on the left. +6. Verify that **Indirect** is selected as the Reimbursement type or select it if not. + +## Step 2: Download and upload the ABA file + +1. Click the **Reports** tab. +2. Use the checkbox on the left to select all the reports needing payment. +3. Click **Bulk Actions** and select **Reimburse via ABA**. +5. Click **Generate ABA and Mark as Reimbursed**. +6. Click **Download Report**. +7. Upload the .aba file to your bank. For additional guidance, use any of the following bank guides: + - [ANZ Bank](https://www.anz.com.au/support/internet-banking/pay-transfer-business/payroll/import-file/) + - [CommBank](https://www.commbank.com.au/business/pds/003-279-importing-a-de-file.pdf) + - [Westpac](https://www.westpac.com.au/business-banking/online-banking/support-faqs/import-files/) + - [NAB](https://www.nab.com.au/business/online-banking/nab-connect/help) + - [Bendigo Bank](https://www.bendigobank.com.au/globalassets/documents/business/bulk-payments-user-guide.pdf) + - [Bank of Queensland](https://www.boq.com.au/help-and-support/online-banking/ob-faqs-and-support/faq-pfuf) + +{% include faq-begin.md %} + +**Can I use direct deposit for an AUD bank account?** + +No, AUD bank accounts do not rely on direct deposit or ACH. + +{% include faq-end.md %} + +
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md new file mode 100644 index 000000000000..b2cfbf833e13 --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md @@ -0,0 +1,108 @@ +--- +title: Reimburse reports, invoices, and bills +description: Use direct deposit or indirect reimbursement to pay reports, invoices, and bills +--- +
+ +Once a report, invoice, or bill has been submitted and approved for reimbursement, you can reimburse the expenses using direct deposit or an indirect reimbursement option outside of Expensify (like cash, a check, or a third-party payment processor). + +# Pay with direct deposit + +{% include info.html %} +Before a report can be reimbursed with direct deposit, the employee or vendor receiving the reimbursement must connect their personal U.S. bank account, and the reimburser must connect a verified business bank account. + +Direct deposit is available for U.S. and global reimbursements. It is not available for Australian bank accounts. For Australian accounts, review the process for reimbursing Australian expenses. +{% include end-info.html %} + +1. Open the report, invoice, or bill from the email or Concierge notification, or from the **Reports** tab. +2. Click the **Reimburse** (for reports) or **Pay** (for bills and invoices) dropdown and select **Via Direct Deposit (ACH)**. +3. Confirm that the correct VBA is selected or use the dropdown menu to select a different one. +4. Click **Accept Terms & Pay**. + +The reimbursement is now queued in the daily batch. + +# Pay with indirect reimbursement + +When payments are submitted through Expensify, the report is automatically labeled as Reimbursed after it has been paid. However, if you are reimbursing reports via paper check, payroll, or any other method that takes place outside of Expensify, youโ€™ll want to manually mark the bill as paid in Expensify to track the payment history. + +To label a report as Reimbursed after sending a payment outside of Expensify, + +1. Pay the report, invoice, or bill outside of Expensify. +2. Open the report, invoice, or bill from the email or Concierge notification, or from the **Reports** tab. +3. Click **Reimburse**. +4. Select **Iโ€™ll do it manually - just mark it as reimbursed**. This changes the report status to Reimbursed. + +Once the recipient has received the payment, the submitter can return to the report and click **Confirm** at the top of the report. This will change the report status to Reimbursed: CONFIRMED. + +{% include faq-begin.md %} + +**Is there a maximum total report total?** + +Expensify cannot process a reimbursement for any single report over $20,000. If you have a report with expenses exceeding $20,000 we recommend splitting the expenses into multiple reports. + +**Why is my account locked?** + +When you reimburse a report, you authorize Expensify to withdraw the funds from your account and send them to the person requesting reimbursement. If your bank rejects Expensifyโ€™s withdrawal request, your verified bank account is locked until the issue is resolved. + +Withdrawal requests can be rejected if the bank account has not been enabled for direct debit or due to insufficient funds. If you need to enable direct debits from your verified bank account, your bank will require the following details: +- The ACH CompanyIDs: 1270239450 and 4270239450 +- The ACH Originator Name: Expensify + +Once resolved, you can request to unlock the bank account by completing the following steps: + +1. Hover over **Settings**, then click **Account**. +2. Click the **Payments** tab. +3. Click **Bank Accounts**. +4. Next to the bank account, click **Fix**. + +Our support team will review and process the request within 4-5 business days. + +**How are bills and invoices processed in Expensify?** + +Here is the process a vendor or supplier bill goes through from receipt to payment: + +1. A vendor or supplier bill is received in Expensify. +2. Automatically, the document is SmartScanned and a bill is created for the primary domain contact. The bill will appear under the Reports tab on their default group policy. +3. When the bill is ready for processing, it is submitted and follows the primary domain contactโ€™s approval workflow until the bill has been fully approved. +4. The final approver pays the bill from their Expensify account using one of the methods outlined in the article above. +5. If the workspace is connected to an accounting integration, the bill is automatically coded with the relevant imported GL codes and can be exported back to the accounting software. + +**When a vendor or supplier bill is sent to Expensify, who receives it?** + +Bills are sent to the primary contact for the domain. Theyโ€™ll see a notification from Concierge on their Home page, and theyโ€™ll also receive an email. + +**How can I share access to bills?** + +By default, only the primary contact for the domain can view and pay the bill. However, you can allow someone else to view or pay bills. + +- **To allow someone to view a bill**: The primary contact can manually share the bill with others to allow them to view it. + 1. Click the **Reports** tab. + 2. Click the report. + 3. Click **Details** in the top right. + 4. Click the **Add Person** icon. + 5. Enter the email address or phone number of the person you will share the report with. + 6. Enter a message, if desired. + 7. Click **Share Report**. + +- **To allow someone to pay bills**: The primary domain contact can allow others to pay bills on their behalf by [assigning those individuals as Copilots](https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot). + +**Is Bill Pay supported internationally?** + +Payments are currently only supported for users paying in United States Dollars (USD). + +**Whatโ€™s the difference between a bill and an invoice?** + +- A **bill** is a payable that represents an amount owed to a payee (usually a vendor or supplier), and it is usually created from a vendor invoice. +- An **invoice** is a receivable that indicates an amount owed to you by someone else. + +**Who can reimburse reports?** + +Only a Workspace Admin who has added a verified business bank account to their Expensify account can reimburse employee reports. + +**Why canโ€™t I trigger direct ACH reimbursements in bulk?** + +Expensify does not offer bulk reimbursement, but you can set up automatic reimbursement to automatically reimburse approved reports via ACH that do not exceed the threshold that you define. + +{% include faq-end.md %} + +
diff --git a/docs/articles/expensify-classic/expensify-card/Unlimited-Virtual-Cards.md b/docs/articles/expensify-classic/expensify-card/Unlimited-Virtual-Cards.md index 239da6518be7..fdbc178737e1 100644 --- a/docs/articles/expensify-classic/expensify-card/Unlimited-Virtual-Cards.md +++ b/docs/articles/expensify-classic/expensify-card/Unlimited-Virtual-Cards.md @@ -66,6 +66,8 @@ There are two different limit types that are best suited for their intended purp - _Fixed limit_ spend cards are ideal for one-time expenses or providing employees access to a card for a designated purchase. - _Monthly_ limit spend cards are perfect for managing recurring expenses such as subscriptions and memberships. +A virtual card with either of these limit types doesn't share its limit with any other cards, including the cardholder's smart limit cards. + **Where can employees see their virtual cards?** Employees can see their assigned virtual cards by navigating to **Settings** > **Account** > [**Credit Cards Import**](https://www.expensify.com/settings?param=%7B%22section%22:%22creditcards%22%7D) in their account. diff --git a/docs/redirects.csv b/docs/redirects.csv index 67ca238c1aed..1a60d52c1749 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -203,6 +203,9 @@ https://help.expensify.com/articles/new-expensify/chat/Expensify-Chat-For-Admins https://help.expensify.com/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account.html,https://help.expensify.com/articles/new-expensify/expenses/Connect-a-Business-Bank-Account https://help.expensify.com/articles/expensify-classic/travel/Coming-Soon,https://help.expensify.com/expensify-classic/hubs/travel/ https://help.expensify.com/articles/new-expensify/expenses/Manually-submit-reports-for-approval,https://help.expensify.com/new-expensify/hubs/expenses/ +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Reimbursements.html,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-Payments +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Pay-Bills.html,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/Reimbursing-Reports.html,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills https://help.expensify.com/articles/expensify-classic/expensify-card/Auto-Reconciliation,https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Reconciliation.md https://help.expensify.com/articles/new-expensify/expenses/Approve-and-pay-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses https://help.expensify.com/articles/new-expensify/expenses/Connect-a-Business-Bank-Account,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg index 32ed6ba30059..c08a5aae1b73 100644 Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ diff --git a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg index 5712b0d86b19..61b6eeb84537 100644 Binary files a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg and b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 2826f7e51db9..3473c2bfcec8 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.6.0 + 9.0.6.2 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index cf3e420de4e7..f413f0d1ae99 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 9.0.6.0 + 9.0.6.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 58cdb65c40e9..212ae9c1aa43 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.0.6 CFBundleVersion - 9.0.6.0 + 9.0.6.2 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 29ab90c4b7db..50dfc65d07b2 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1871,7 +1871,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.91): + - RNLiveMarkdown (0.1.103): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -1889,9 +1889,9 @@ PODS: - React-utils - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/common (= 0.1.91) + - RNLiveMarkdown/common (= 0.1.103) - Yoga - - RNLiveMarkdown/common (0.1.91): + - RNLiveMarkdown/common (0.1.103): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -1935,7 +1935,7 @@ PODS: - ReactCommon/turbomodule/core - Turf - Yoga - - RNPermissions (3.9.3): + - RNPermissions (3.10.1): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -2614,10 +2614,10 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 74b7b3d06d667ba0bbf41da7718f2607ae0dfe8f RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: 24fbb7370eefee2f325fb64cfe904b111ffcd81b + RNLiveMarkdown: f12157fc91b72e19705c9cc8c98034c4c1669d5a RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: df8fe93dbd251f25022f4023d31bc04160d4d65c - RNPermissions: 0b61d30d21acbeafe25baaa47d9bae40a0c65216 + RNPermissions: d2392b754e67bc14491f5b12588bef2864e783f3 RNReactNativeHapticFeedback: 616c35bdec7d20d4c524a7949ca9829c09e35f37 RNReanimated: 323436b1a5364dca3b5f8b1a13458455e0de9efe RNScreens: abd354e98519ed267600b7ee64fdcb8e060b1218 diff --git a/package-lock.json b/package-lock.json index 64f5f7a6a94d..6a155219ba5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "new.expensify", - "version": "9.0.6-0", + "version": "9.0.6-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.6-0", + "version": "9.0.6-2", "hasInstallScript": true, "license": "MIT", "dependencies": { "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.91", + "@expensify/react-native-live-markdown": "0.1.103", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-listformat": "^7.2.2", @@ -55,7 +55,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.26", + "expensify-common": "2.0.35", "expo": "^50.0.3", "expo-av": "~13.10.4", "expo-image": "1.11.0", @@ -106,7 +106,7 @@ "react-native-pager-view": "6.2.3", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", - "react-native-permissions": "^3.9.3", + "react-native-permissions": "^3.10.0", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", "react-native-plaid-link-sdk": "11.5.0", "react-native-qrcode-svg": "^6.2.0", @@ -233,6 +233,7 @@ "memfs": "^4.6.0", "onchange": "^7.1.0", "patch-package": "^8.0.0", + "peggy": "^4.0.3", "portfinder": "^1.0.28", "prettier": "^2.8.8", "pusher-js-mock": "^0.3.3", @@ -3784,9 +3785,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.91", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.91.tgz", - "integrity": "sha512-6uQTgwhpvLqQKdtNqSgh45sRuQRXzv/WwyhdvQNge6EYtulyGFqT82GIP+LIGW8Xnl73nzFZTuMKwWxFFR/Cow==", + "version": "0.1.103", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.103.tgz", + "integrity": "sha512-w9jQoxBE9LghfL8UdYbG+8A+CApmER/XMH8N7/bINn7w57+FnnBa5ckPWx6/UYX7OYsmYxSaHJLQkJEXYlDRZg==", "workspaces": [ "parser", "example", @@ -7879,6 +7880,33 @@ "react-native": ">=0.70.0 <1.0.x" } }, + "node_modules/@peggyjs/from-mem": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@peggyjs/from-mem/-/from-mem-1.3.0.tgz", + "integrity": "sha512-kzGoIRJjkg3KuGI4bopz9UvF3KguzfxalHRDEIdqEZUe45xezsQ6cx30e0RKuxPUexojQRBfu89Okn7f4/QXsw==", + "dev": true, + "dependencies": { + "semver": "7.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@peggyjs/from-mem/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@perf-profiler/android": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/@perf-profiler/android/-/android-0.12.1.tgz", @@ -25974,9 +26002,9 @@ } }, "node_modules/expensify-common": { - "version": "2.0.26", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.26.tgz", - "integrity": "sha512-3GORs2xfx78SoKLDh4lXpk4Bx61sAVNnlo23VB803zs7qZz8/Oq3neKedtEJuRAmUps0C1Y5y9xZE8nrPO31nQ==", + "version": "2.0.35", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.35.tgz", + "integrity": "sha512-5Q4DK5pJIyPd3FmHrargm9fxiOMBdDLMb8rhwa2jN35DL9NiYX9mOTWc327PnjaFO07AeR6wNj8+BVgqOxqMGg==", "dependencies": { "awesome-phonenumber": "^5.4.0", "classnames": "2.5.0", @@ -35858,6 +35886,32 @@ "through2": "^2.0.3" } }, + "node_modules/peggy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/peggy/-/peggy-4.0.3.tgz", + "integrity": "sha512-v7/Pt6kGYsfXsCrfb52q7/yg5jaAwiVaUMAPLPvy4DJJU6Wwr72t6nDIqIDkGfzd1B4zeVuTnQT0RGeOhe/uSA==", + "dev": true, + "dependencies": { + "@peggyjs/from-mem": "1.3.0", + "commander": "^12.1.0", + "source-map-generator": "0.8.0" + }, + "bin": { + "peggy": "bin/peggy.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/peggy/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/pend": { "version": "1.2.0", "dev": true, @@ -37435,8 +37489,9 @@ } }, "node_modules/react-native-permissions": { - "version": "3.9.3", - "license": "MIT", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-3.10.1.tgz", + "integrity": "sha512-Gc5BxxpjZn4QNUDiVeHOO0vXh3AH7ToolmwTJozqC6DsxV7NAf3ttap+8BSmzDR8WxuAM3Cror+YNiBhHJx7/w==", "peerDependencies": { "react": ">=16.13.1", "react-native": ">=0.63.3", @@ -40226,6 +40281,15 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-generator": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map-generator/-/source-map-generator-0.8.0.tgz", + "integrity": "sha512-psgxdGMwl5MZM9S3FWee4EgsEaIjahYV5AzGnwUvPhWeITz/j6rKpysQHlQ4USdxvINlb8lKfWGIXwfkrgtqkA==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/source-map-js": { "version": "1.0.2", "license": "BSD-3-Clause", diff --git a/package.json b/package.json index 9f1c3ffca7a7..cadd22fd7332 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.6-0", + "version": "9.0.6-2", "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,13 +61,14 @@ "workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.ts", "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1", "e2e-test-runner-build": "ncc build tests/e2e/testRunner.ts -o tests/e2e/dist/", - "react-compiler-healthcheck": "react-compiler-healthcheck --verbose" + "react-compiler-healthcheck": "react-compiler-healthcheck --verbose", + "generate-search-parser": "peggy --format es -o src/libs/SearchParser/searchParser.js src/libs/SearchParser/searchParser.peggy " }, "dependencies": { "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.91", + "@expensify/react-native-live-markdown": "0.1.103", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-listformat": "^7.2.2", @@ -109,7 +110,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.26", + "expensify-common": "2.0.35", "expo": "^50.0.3", "expo-av": "~13.10.4", "expo-image": "1.11.0", @@ -160,7 +161,7 @@ "react-native-pager-view": "6.2.3", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", - "react-native-permissions": "^3.9.3", + "react-native-permissions": "^3.10.0", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", "react-native-plaid-link-sdk": "11.5.0", "react-native-qrcode-svg": "^6.2.0", @@ -287,6 +288,7 @@ "memfs": "^4.6.0", "onchange": "^7.1.0", "patch-package": "^8.0.0", + "peggy": "^4.0.3", "portfinder": "^1.0.28", "prettier": "^2.8.8", "pusher-js-mock": "^0.3.3", diff --git a/patches/@expensify+react-native-live-markdown+0.1.91.patch b/patches/@expensify+react-native-live-markdown+0.1.91.patch deleted file mode 100644 index c77e46accae3..000000000000 --- a/patches/@expensify+react-native-live-markdown+0.1.91.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts b/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts -index 1cda659..ba5c3c3 100644 ---- a/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts -+++ b/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts -@@ -66,7 +66,7 @@ function setCursorPosition(target: HTMLElement, start: number, end: number | nul - // 3. Caret at the end of whole input, when pressing enter - // 4. All other placements - if (prevChar === '\n' && prevTextLength !== undefined && prevTextLength < textCharacters.length) { -- if (nextChar !== '\n') { -+ if (nextChar && nextChar !== '\n' && i !== n - 1) { - range.setStart(textNodes[i + 1] as Node, 0); - } else if (i !== textNodes.length - 1) { - range.setStart(textNodes[i] as Node, 1); diff --git a/patches/@react-native-community+netinfo+11.2.1+002+turbomodule.patch b/patches/@react-native-community+netinfo+11.2.1+002+turbomodule.patch index f8e171008e14..bf6decac0450 100644 --- a/patches/@react-native-community+netinfo+11.2.1+002+turbomodule.patch +++ b/patches/@react-native-community+netinfo+11.2.1+002+turbomodule.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@react-native-community/netinfo/android/build.gradle b/node_modules/@react-native-community/netinfo/android/build.gradle -index 0d617ed..e93d64a 100644 +index 0d617ed..97439e6 100644 --- a/node_modules/@react-native-community/netinfo/android/build.gradle +++ b/node_modules/@react-native-community/netinfo/android/build.gradle @@ -3,9 +3,10 @@ buildscript { @@ -105,7 +105,6 @@ index 0d617ed..e93d64a 100644 + implementation 'com.facebook.react:react-native:+' + } } -\ No newline at end of file diff --git a/node_modules/@react-native-community/netinfo/android/src/main/java/com/reactnativecommunity/netinfo/NetInfoModuleImpl.java b/node_modules/@react-native-community/netinfo/android/src/main/java/com/reactnativecommunity/netinfo/NetInfoModuleImpl.java index 2c3280b..296bbfd 100644 --- a/node_modules/@react-native-community/netinfo/android/src/main/java/com/reactnativecommunity/netinfo/NetInfoModuleImpl.java @@ -1609,10 +1608,10 @@ index 095dd3b..596ace1 100644 +{"version":3,"names":["NetInfoStateType","exports","NetInfoCellularGeneration"],"sources":["types.ts"],"sourcesContent":["/**\n * Copyright (c) Facebook, Inc. and its affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n * @format\n */\n\nexport enum NetInfoStateType {\n unknown = 'unknown',\n none = 'none',\n cellular = 'cellular',\n wifi = 'wifi',\n bluetooth = 'bluetooth',\n ethernet = 'ethernet',\n wimax = 'wimax',\n vpn = 'vpn',\n other = 'other',\n}\n\nexport type NetInfoMethodType = 'HEAD' | 'GET';\n\nexport enum NetInfoCellularGeneration {\n '2g' = '2g',\n '3g' = '3g',\n '4g' = '4g',\n '5g' = '5g',\n}\n\nexport interface NetInfoConnectedDetails {\n isConnectionExpensive: boolean;\n}\n\ninterface NetInfoConnectedState<\n T extends NetInfoStateType,\n D extends Record = Record,\n> {\n type: T;\n isConnected: true;\n isInternetReachable: boolean | null;\n details: D & NetInfoConnectedDetails;\n isWifiEnabled?: boolean;\n}\n\ninterface NetInfoDisconnectedState {\n type: T;\n isConnected: false;\n isInternetReachable: false;\n details: null;\n isWifiEnabled?: boolean;\n}\n\nexport interface NetInfoUnknownState {\n type: NetInfoStateType.unknown;\n isConnected: boolean | null;\n isInternetReachable: null;\n details: null;\n isWifiEnabled?: boolean;\n}\n\nexport type NetInfoNoConnectionState =\n NetInfoDisconnectedState;\nexport type NetInfoDisconnectedStates =\n | NetInfoUnknownState\n | NetInfoNoConnectionState;\n\nexport type NetInfoCellularState = NetInfoConnectedState<\n NetInfoStateType.cellular,\n {\n cellularGeneration: NetInfoCellularGeneration | null;\n carrier: string | null;\n }\n>;\nexport type NetInfoWifiState = NetInfoConnectedState<\n NetInfoStateType.wifi,\n {\n ssid: string | null;\n bssid: string | null;\n strength: number | null;\n ipAddress: string | null;\n subnet: string | null;\n frequency: number | null;\n linkSpeed: number | null;\n rxLinkSpeed: number | null;\n txLinkSpeed: number | null;\n }\n>;\nexport type NetInfoBluetoothState =\n NetInfoConnectedState;\nexport type NetInfoEthernetState = NetInfoConnectedState<\n NetInfoStateType.ethernet,\n {\n ipAddress: string | null;\n subnet: string | null;\n }\n>;\nexport type NetInfoWimaxState = NetInfoConnectedState;\nexport type NetInfoVpnState = NetInfoConnectedState;\nexport type NetInfoOtherState = NetInfoConnectedState;\nexport type NetInfoConnectedStates =\n | NetInfoCellularState\n | NetInfoWifiState\n | NetInfoBluetoothState\n | NetInfoEthernetState\n | NetInfoWimaxState\n | NetInfoVpnState\n | NetInfoOtherState;\n\nexport type NetInfoState = NetInfoDisconnectedStates | NetInfoConnectedStates;\n\nexport type NetInfoChangeHandler = (state: NetInfoState) => void;\nexport type NetInfoSubscription = () => void;\n\nexport interface NetInfoConfiguration {\n reachabilityUrl: string;\n reachabilityMethod?: NetInfoMethodType;\n reachabilityHeaders?: Record;\n reachabilityTest: (response: Response) => Promise;\n reachabilityLongTimeout: number;\n reachabilityShortTimeout: number;\n reachabilityRequestTimeout: number;\n reachabilityShouldRun: () => boolean;\n shouldFetchWiFiSSID: boolean;\n useNativeReachability: boolean;\n}\n"],"mappings":";;;;;;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AAPA,IASYA,gBAAgB,GAAAC,OAAA,CAAAD,gBAAA,0BAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAhBA,gBAAgB;EAAA,OAAhBA,gBAAgB;AAAA;AAAA,IAchBE,yBAAyB,GAAAD,OAAA,CAAAC,yBAAA,0BAAzBA,yBAAyB;EAAzBA,yBAAyB;EAAzBA,yBAAyB;EAAzBA,yBAAyB;EAAzBA,yBAAyB;EAAA,OAAzBA,yBAAyB;AAAA"} \ No newline at end of file diff --git a/node_modules/@react-native-community/netinfo/lib/module/index.js b/node_modules/@react-native-community/netinfo/lib/module/index.js -index 147c72e..02aa0db 100644 +index 147c72e..5de4e7c 100644 --- a/node_modules/@react-native-community/netinfo/lib/module/index.js +++ b/node_modules/@react-native-community/netinfo/lib/module/index.js -@@ -6,20 +6,23 @@ +@@ -6,20 +6,26 @@ * * @format */ @@ -1635,11 +1634,14 @@ index 147c72e..02aa0db 100644 const createState = () => { return new State(_configuration); }; ++ ++// Track ongoing requests ++let isRequestInProgress = false; + /** * Configures the library with the given configuration. Note that calling this will stop all * previously added listeners from being called again. It is best to call this right when your -@@ -27,23 +30,20 @@ const createState = () => { +@@ -27,23 +33,20 @@ const createState = () => { * * @param configuration The new configuration to set. */ @@ -1666,7 +1668,7 @@ index 147c72e..02aa0db 100644 /** * Returns a `Promise` that resolves to a `NetInfoState` object. * This function operates on the global singleton instance configured using `configure()` -@@ -52,27 +52,25 @@ export function configure(configuration) { +@@ -52,27 +55,33 @@ export function configure(configuration) { * * @returns A Promise which contains the current connection state. */ @@ -1689,14 +1691,22 @@ index 147c72e..02aa0db 100644 if (!_state) { _state = createState(); } -- - return _state._fetchCurrentState(); + +- return _state._fetchCurrentState(); ++ if (isRequestInProgress) { ++ return _state.latest(); // Return the latest state if a request is already in progress ++ } ++ ++ isRequestInProgress = true; ++ return _state._fetchCurrentState().finally(() => { ++ isRequestInProgress = false; ++ }); } + /** * Subscribe to the global singleton's connection information. The callback is called with a parameter of type * [`NetInfoState`](README.md#netinfostate) whenever the connection state changes. Your listener -@@ -84,18 +82,16 @@ export function refresh() { +@@ -84,18 +93,16 @@ export function refresh() { * * @returns A function which can be called to unsubscribe. */ @@ -1716,7 +1726,7 @@ index 147c72e..02aa0db 100644 /** * A React Hook into this library's singleton which updates when the connection state changes. * -@@ -103,12 +99,10 @@ export function addEventListener(listener) { +@@ -103,12 +110,10 @@ export function addEventListener(listener) { * * @returns The connection state. */ @@ -1729,7 +1739,7 @@ index 147c72e..02aa0db 100644 const [netInfo, setNetInfo] = useState({ type: Types.NetInfoStateType.unknown, isConnected: null, -@@ -120,6 +114,7 @@ export function useNetInfo(configuration) { +@@ -120,6 +125,7 @@ export function useNetInfo(configuration) { }, []); return netInfo; } @@ -1737,7 +1747,7 @@ index 147c72e..02aa0db 100644 /** * A React Hook which manages an isolated instance of the network info manager. * This is not a hook into a singleton shared state. NetInfo.configure, NetInfo.addEventListener, -@@ -129,7 +124,6 @@ export function useNetInfo(configuration) { +@@ -129,7 +135,6 @@ export function useNetInfo(configuration) { * * @returns the netInfo state and a refresh function */ @@ -1745,7 +1755,7 @@ index 147c72e..02aa0db 100644 export function useNetInfoInstance(isPaused = false, configuration) { const [networkInfoManager, setNetworkInfoManager] = useState(); const [netInfo, setNetInfo] = useState({ -@@ -142,8 +136,8 @@ export function useNetInfoInstance(isPaused = false, configuration) { +@@ -142,8 +147,8 @@ export function useNetInfoInstance(isPaused = false, configuration) { if (isPaused) { return; } @@ -2609,32 +2619,6 @@ index 6982220..b515270 100644 + readonly eventEmitter: NativeEventEmitter; }; export default _default; -diff --git a/node_modules/@react-native-community/netinfo/package.json b/node_modules/@react-native-community/netinfo/package.json -index 3c80db2..61e6564 100644 ---- a/node_modules/@react-native-community/netinfo/package.json -+++ b/node_modules/@react-native-community/netinfo/package.json -@@ -48,6 +48,7 @@ - "network info" - ], - "peerDependencies": { -+ "react": "*", - "react-native": ">=0.59" - }, - "dependencies": {}, -@@ -121,5 +122,13 @@ - "yarn eslint --fix", - "git add" - ] -+ }, -+ "codegenConfig": { -+ "name": "RNCNetInfoSpec", -+ "type": "modules", -+ "jsSrcsDir": "src/internal", -+ "android": { -+ "javaPackageName": "com.reactnativecommunity.netinfo" -+ } - } - } diff --git a/node_modules/@react-native-community/netinfo/react-native-netinfo.podspec b/node_modules/@react-native-community/netinfo/react-native-netinfo.podspec index e34e728..9090eb1 100644 --- a/node_modules/@react-native-community/netinfo/react-native-netinfo.podspec @@ -2971,95 +2955,95 @@ index 878f7ba..0000000 --- a/node_modules/@react-native-community/netinfo/windows/.npmignore +++ /dev/null @@ -1,92 +0,0 @@ --*AppPackages* --*BundleArtifacts* -- --#OS junk files --[Tt]humbs.db --*.DS_Store -- --#Visual Studio files --*.[Oo]bj --*.user --*.aps --*.pch --*.vspscc --*.vssscc --*_i.c --*_p.c --*.ncb --*.suo --*.tlb --*.tlh --*.bak --*.[Cc]ache --*.ilk --*.log --*.lib --*.sbr --*.sdf --*.opensdf --*.opendb --*.unsuccessfulbuild --ipch/ --[Oo]bj/ --[Bb]in --[Dd]ebug*/ --[Rr]elease*/ --Ankh.NoLoad -- --# Visual C++ cache files --ipch/ --*.aps --*.ncb --*.opendb --*.opensdf --*.sdf --*.cachefile --*.VC.db --*.VC.VC.opendb -- --#MonoDevelop --*.pidb --*.userprefs -- --#Tooling --_ReSharper*/ --*.resharper --[Tt]est[Rr]esult* --*.sass-cache -- --#Project files --[Bb]uild/ -- --#Subversion files --.svn -- --# Office Temp Files --~$* -- --# vim Temp Files --*~ -- --#NuGet --packages/ --*.nupkg -- --#ncrunch --*ncrunch* --*crunch*.local.xml -- --# visual studio database projects --*.dbmdl -- --#Test files --*.testsettings -- --#Other files --*.DotSettings --.vs/ --*project.lock.json -- --#Files generated by the VS build --**/Generated Files/** -- +-*AppPackages* +-*BundleArtifacts* +- +-#OS junk files +-[Tt]humbs.db +-*.DS_Store +- +-#Visual Studio files +-*.[Oo]bj +-*.user +-*.aps +-*.pch +-*.vspscc +-*.vssscc +-*_i.c +-*_p.c +-*.ncb +-*.suo +-*.tlb +-*.tlh +-*.bak +-*.[Cc]ache +-*.ilk +-*.log +-*.lib +-*.sbr +-*.sdf +-*.opensdf +-*.opendb +-*.unsuccessfulbuild +-ipch/ +-[Oo]bj/ +-[Bb]in +-[Dd]ebug*/ +-[Rr]elease*/ +-Ankh.NoLoad +- +-# Visual C++ cache files +-ipch/ +-*.aps +-*.ncb +-*.opendb +-*.opensdf +-*.sdf +-*.cachefile +-*.VC.db +-*.VC.VC.opendb +- +-#MonoDevelop +-*.pidb +-*.userprefs +- +-#Tooling +-_ReSharper*/ +-*.resharper +-[Tt]est[Rr]esult* +-*.sass-cache +- +-#Project files +-[Bb]uild/ +- +-#Subversion files +-.svn +- +-# Office Temp Files +-~$* +- +-# vim Temp Files +-*~ +- +-#NuGet +-packages/ +-*.nupkg +- +-#ncrunch +-*ncrunch* +-*crunch*.local.xml +- +-# visual studio database projects +-*.dbmdl +- +-#Test files +-*.testsettings +- +-#Other files +-*.DotSettings +-.vs/ +-*project.lock.json +- +-#Files generated by the VS build +-**/Generated Files/** +- diff --git a/scripts/applyPatches.sh b/scripts/applyPatches.sh index a4be88984561..9145629015ee 100755 --- a/scripts/applyPatches.sh +++ b/scripts/applyPatches.sh @@ -8,24 +8,17 @@ SCRIPTS_DIR=$(dirname "${BASH_SOURCE[0]}") source "$SCRIPTS_DIR/shellUtils.sh" # Wrapper to run patch-package. -# We use `script` to preserve colorization when the output of patch-package is piped to tee -# and we provide /dev/null to discard the output rather than sending it to a file -# `script` has different syntax on macOS vs linux, so that's why we need a wrapper function function patchPackage { OS="$(uname)" - if [[ "$OS" == "Darwin" ]]; then - # macOS - script -q /dev/null npx patch-package --error-on-fail - elif [[ "$OS" == "Linux" ]]; then - # Ubuntu/Linux - script -q -c "npx patch-package --error-on-fail" /dev/null + if [[ "$OS" == "Darwin" || "$OS" == "Linux" ]]; then + npx patch-package --error-on-fail else error "Unsupported OS: $OS" + exit 1 fi } # Run patch-package and capture its output and exit code, while still displaying the original output to the terminal -# (we use `script -q /dev/null` to preserve colorization in the output) TEMP_OUTPUT="$(mktemp)" patchPackage 2>&1 | tee "$TEMP_OUTPUT" EXIT_CODE=${PIPESTATUS[0]} @@ -36,7 +29,7 @@ rm -f "$TEMP_OUTPUT" echo "$OUTPUT" | grep -q "Warning:" WARNING_FOUND=$? -printf "\n"; +printf "\n" # Determine the final exit code if [ "$EXIT_CODE" -eq 0 ]; then diff --git a/src/CONST.ts b/src/CONST.ts index b809bdaacaf6..3b879e10c345 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -668,6 +668,7 @@ const CONST = { LIMIT: 50, // OldDot Actions render getMessage from Web-Expensify/lib/Report/Action PHP files via getMessageOfOldDotReportAction in ReportActionsUtils.ts TYPE: { + ACTIONABLE_ADD_PAYMENT_CARD: 'ACTIONABLEADDPAYMENTCARD', ACTIONABLE_JOIN_REQUEST: 'ACTIONABLEJOINREQUEST', ACTIONABLE_MENTION_WHISPER: 'ACTIONABLEMENTIONWHISPER', ACTIONABLE_REPORT_MENTION_WHISPER: 'ACTIONABLEREPORTMENTIONWHISPER', @@ -840,6 +841,8 @@ const CONST = { IOU: 'iou', TASK: 'task', INVOICE: 'invoice', + PAYCHECK: 'paycheck', + BILL: 'bill', }, CHAT_TYPE: chatTypes, WORKSPACE_CHAT_ROOMS: { @@ -1123,8 +1126,6 @@ const CONST = { // around each header. EMOJI_NUM_PER_ROW: 8, - EMOJI_FREQUENT_ROW_COUNT: 3, - EMOJI_DEFAULT_SKIN_TONE: -1, // Amount of emojis to render ahead at the end of the update cycle @@ -1245,7 +1246,7 @@ const CONST = { MAX_AMOUNT_OF_SUGGESTIONS: 20, MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER: 5, HERE_TEXT: '@here', - SUGGESTION_BOX_MAX_SAFE_DISTANCE: 38, + SUGGESTION_BOX_MAX_SAFE_DISTANCE: 10, BIG_SCREEN_SUGGESTION_WIDTH: 300, }, COMPOSER_MAX_HEIGHT: 125, @@ -2145,6 +2146,7 @@ const CONST = { ACCESS_VARIANTS: { PAID: 'paid', ADMIN: 'admin', + CONTROL: 'control', }, DEFAULT_MAX_EXPENSE_AGE: 90, DEFAULT_MAX_EXPENSE_AMOUNT: 200000, @@ -5206,6 +5208,39 @@ const CONST = { APPROVE: 'approve', PAY: 'pay', }, + SYNTAX_OPERATORS: { + AND: 'and', + OR: 'or', + EQUAL_TO: 'eq', + NOT_EQUAL_TO: 'neq', + GREATER_THAN: 'gt', + GREATER_THAN_OR_EQUAL_TO: 'gte', + LOWER_THAN: 'lt', + LOWER_THAN_OR_EQUAL_TO: 'lte', + }, + SYNTAX_ROOT_KEYS: { + TYPE: 'type', + STATUS: 'status', + SORT_BY: 'sortBy', + SORT_ORDER: 'sortOrder', + OFFSET: 'offset', + }, + SYNTAX_FILTER_KEYS: { + DATE: 'date', + AMOUNT: 'amount', + EXPENSE_TYPE: 'expenseType', + CURRENCY: 'currency', + MERCHANT: 'merchant', + DESCRIPTION: 'description', + FROM: 'from', + TO: 'to', + CATEGORY: 'category', + TAG: 'tag', + TAX_RATE: 'taxRate', + CARD_ID: 'cardID', + REPORT_ID: 'reportID', + KEYWORD: 'keyword', + }, }, REFERRER: { @@ -5243,6 +5278,7 @@ const CONST = { }, }, + MAX_LENGTH_256: 256, WORKSPACE_CARDS_LIST_LABEL_TYPE: { CURRENT_BALANCE: 'currentBalance', REMAINING_LIMIT: 'remainingLimit', @@ -5250,6 +5286,13 @@ const CONST = { }, EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[], + + EMPTY_STATE_MEDIA: { + ANIMATION: 'animation', + ILLUSTRATION: 'illustration', + VIDEO: 'video', + }, + UPGRADE_FEATURE_INTRO_MAPPING: [ { id: 'reportFields', @@ -5260,15 +5303,12 @@ const CONST = { icon: 'Pencil', }, ], + REPORT_FIELD_TYPES: { TEXT: 'text', DATE: 'date', LIST: 'dropdown', }, - - NAVIGATION_ACTIONS: { - RESET: 'RESET', - }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index bd4b294a6d68..b06b05dac7e1 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -320,9 +320,6 @@ const ONYXKEYS = { /** Onboarding Purpose selected by the user during Onboarding flow */ ONBOARDING_PURPOSE_SELECTED: 'onboardingPurposeSelected', - /** Onboarding error message to be displayed to the user */ - ONBOARDING_ERROR_MESSAGE: 'onboardingErrorMessage', - /** Onboarding policyID selected by the user during Onboarding flow */ ONBOARDING_POLICY_ID: 'onboardingPolicyID', @@ -445,6 +442,9 @@ const ONYXKEYS = { * So for example: card_12345_Expensify Card */ WORKSPACE_CARDS_LIST: 'card_', + + /** The bank account that Expensify Card payments will be reconciled against */ + SHARED_NVP_EXPENSIFY_CARD_CONTINUOUS_RECONCILIATION_CONNECTION: 'sharedNVP_expensifyCard_continuousReconciliationConnection_', }, /** List of Form ids */ @@ -535,8 +535,8 @@ const ONYXKEYS = { REPORT_VIRTUAL_CARD_FRAUD_DRAFT: 'reportVirtualCardFraudFormDraft', GET_PHYSICAL_CARD_FORM: 'getPhysicalCardForm', GET_PHYSICAL_CARD_FORM_DRAFT: 'getPhysicalCardFormDraft', - REPORT_FIELD_EDIT_FORM: 'reportFieldEditForm', - REPORT_FIELD_EDIT_FORM_DRAFT: 'reportFieldEditFormDraft', + REPORT_FIELDS_EDIT_FORM: 'reportFieldsEditForm', + REPORT_FIELDS_EDIT_FORM_DRAFT: 'reportFieldsEditFormDraft', REIMBURSEMENT_ACCOUNT_FORM: 'reimbursementAccount', REIMBURSEMENT_ACCOUNT_FORM_DRAFT: 'reimbursementAccountDraft', PERSONAL_BANK_ACCOUNT_FORM: 'personalBankAccount', @@ -622,7 +622,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: FormTypes.ReportVirtualCardFraudForm; [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: FormTypes.ReportPhysicalCardForm; [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: FormTypes.GetPhysicalCardForm; - [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM]: FormTypes.ReportFieldEditForm; + [ONYXKEYS.FORMS.REPORT_FIELDS_EDIT_FORM]: FormTypes.ReportFieldsEditForm; [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: FormTypes.ReimbursementAccountForm; [ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM]: FormTypes.PersonalBankAccountForm; [ONYXKEYS.FORMS.WORKSPACE_DESCRIPTION_FORM]: FormTypes.WorkspaceDescriptionForm; @@ -692,6 +692,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END]: OnyxTypes.BillingGraceEndPeriod; [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_EXPENSIFY_CARD_SETTINGS]: OnyxTypes.ExpensifyCardSettings; [ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST]: OnyxTypes.WorkspaceCardsList; + [ONYXKEYS.COLLECTION.SHARED_NVP_EXPENSIFY_CARD_CONTINUOUS_RECONCILIATION_CONNECTION]: OnyxTypes.BankAccount; }; type OnyxValuesMapping = { @@ -797,7 +798,6 @@ type OnyxValuesMapping = { [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: string; - [ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string; [ONYXKEYS.ONBOARDING_POLICY_ID]: string; [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a54bb4f5cca5..56006d599d6f 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -676,6 +676,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/invoice-account-selector', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/invoice-account-selector` as const, }, + WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS: { + route: 'settings/workspaces/:policyID/accounting/:connection/card-reconciliation', + getRoute: (policyID: string, connection: ValueOf) => `settings/workspaces/${policyID}/accounting/${connection}/card-reconciliation` as const, + }, WORKSPACE_CATEGORIES: { route: 'settings/workspaces/:policyID/categories', getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories` as const, @@ -700,6 +704,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/categories/:categoryName/edit', getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/edit` as const, }, + WORKSPACE_CATEGORY_GL_CODE: { + route: 'settings/workspaces/:policyID/categories/:categoryName/gl-code', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/gl-code` as const, + }, WORKSPACE_MORE_FEATURES: { route: 'settings/workspaces/:policyID/more-features', getRoute: (policyID: string) => `settings/workspaces/${policyID}/more-features` as const, @@ -732,6 +740,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/tag-list/:orderWeight', getRoute: (policyID: string, orderWeight: number) => `settings/workspaces/${policyID}/tag-list/${orderWeight}` as const, }, + WORKSPACE_TAG_GL_CODE: { + route: 'settings/workspaces/:policyID/tag/:orderWeight/:tagName/gl-code', + getRoute: (policyID: string, orderWeight: number, tagName: string) => `settings/workspaces/${policyID}/tag/${orderWeight}/${encodeURIComponent(tagName)}/gl-code` as const, + }, WORKSPACE_TAXES: { route: 'settings/workspaces/:policyID/taxes', getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes` as const, @@ -797,30 +809,30 @@ const ROUTES = { route: 'settings/workspaces/:policyID/reportFields/new', getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new` as const, }, - WORKSPACE_REPORT_FIELD_SETTINGS: { - route: 'settings/workspaces/:policyID/reportField/:reportFieldID/edit', - getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportField/${encodeURIComponent(reportFieldID)}/edit` as const, + WORKSPACE_REPORT_FIELDS_SETTINGS: { + route: 'settings/workspaces/:policyID/reportFields/:reportFieldID/edit', + getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportFields/${encodeURIComponent(reportFieldID)}/edit` as const, }, - WORKSPACE_REPORT_FIELD_LIST_VALUES: { - route: 'settings/workspaces/:policyID/reportField/listValues/:reportFieldID?', - getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportField/listValues/${encodeURIComponent(reportFieldID ?? '')}` as const, + WORKSPACE_REPORT_FIELDS_LIST_VALUES: { + route: 'settings/workspaces/:policyID/reportFields/listValues/:reportFieldID?', + getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportFields/listValues/${encodeURIComponent(reportFieldID ?? '')}` as const, }, - WORKSPACE_REPORT_FIELD_ADD_VALUE: { - route: 'settings/workspaces/:policyID/reportField/addValue/:reportFieldID?', - getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportField/addValue/${encodeURIComponent(reportFieldID ?? '')}` as const, + WORKSPACE_REPORT_FIELDS_ADD_VALUE: { + route: 'settings/workspaces/:policyID/reportFields/addValue/:reportFieldID?', + getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportFields/addValue/${encodeURIComponent(reportFieldID ?? '')}` as const, }, - WORKSPACE_REPORT_FIELD_VALUE_SETTINGS: { - route: 'settings/workspaces/:policyID/reportField/:valueIndex/:reportFieldID?', + WORKSPACE_REPORT_FIELDS_VALUE_SETTINGS: { + route: 'settings/workspaces/:policyID/reportFields/:valueIndex/:reportFieldID?', getRoute: (policyID: string, valueIndex: number, reportFieldID?: string) => - `settings/workspaces/${policyID}/reportField/${valueIndex}/${encodeURIComponent(reportFieldID ?? '')}` as const, + `settings/workspaces/${policyID}/reportFields/${valueIndex}/${encodeURIComponent(reportFieldID ?? '')}` as const, }, - WORKSPACE_REPORT_FIELD_EDIT_VALUE: { - route: 'settings/workspaces/:policyID/reportField/new/:valueIndex/edit', - getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportField/new/${valueIndex}/edit` as const, + WORKSPACE_REPORT_FIELDS_EDIT_VALUE: { + route: 'settings/workspaces/:policyID/reportFields/new/:valueIndex/edit', + getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportFields/new/${valueIndex}/edit` as const, }, - WORKSPACE_EDIT_REPORT_FIELD_INITIAL_VALUE: { - route: 'settings/workspaces/:policyID/reportField/:reportFieldID/edit/initialValue', - getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportField/${encodeURIComponent(reportFieldID)}/edit/initialValue` as const, + WORKSPACE_EDIT_REPORT_FIELDS_INITIAL_VALUE: { + route: 'settings/workspaces/:policyID/reportFields/:reportFieldID/edit/initialValue', + getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportFields/${encodeURIComponent(reportFieldID)}/edit/initialValue` as const, }, WORKSPACE_EXPENSIFY_CARD: { route: 'settings/workspaces/:policyID/expensify-card', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index d2a6b7c19ddd..4790ac3c6a32 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -330,6 +330,7 @@ const SCREENS = { SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT: 'Policy_Accounting_Sage_Intacct_Non_Reimbursable_Credit_Card_Account', SAGE_INTACCT_ADVANCED: 'Policy_Accounting_Sage_Intacct_Advanced', SAGE_INTACCT_PAYMENT_ACCOUNT: 'Policy_Accounting_Sage_Intacct_Payment_Account', + RECONCILIATION_ACCOUNT_SETTINGS: 'Policy_Accounting_Reconciliation_Account_Settings', }, INITIAL: 'Workspace_Initial', PROFILE: 'Workspace_Profile', @@ -353,7 +354,7 @@ const SCREENS = { TAG_EDIT: 'Tag_Edit', TAXES: 'Workspace_Taxes', REPORT_FIELDS: 'Workspace_ReportFields', - REPORT_FIELD_SETTINGS: 'Workspace_ReportField_Settings', + REPORT_FIELDS_SETTINGS: 'Workspace_ReportFields_Settings', REPORT_FIELDS_CREATE: 'Workspace_ReportFields_Create', REPORT_FIELDS_LIST_VALUES: 'Workspace_ReportFields_ListValues', REPORT_FIELDS_ADD_VALUE: 'Workspace_ReportFields_AddValue', @@ -371,6 +372,7 @@ const SCREENS = { TAG_CREATE: 'Tag_Create', TAG_SETTINGS: 'Tag_Settings', TAG_LIST_VIEW: 'Tag_List_View', + TAG_GL_CODE: 'Tag_GL_Code', CURRENCY: 'Workspace_Profile_Currency', ADDRESS: 'Workspace_Profile_Address', WORKFLOWS: 'Workspace_Workflows', @@ -383,6 +385,7 @@ const SCREENS = { NAME: 'Workspace_Profile_Name', CATEGORY_CREATE: 'Category_Create', CATEGORY_EDIT: 'Category_Edit', + CATEGORY_GL_CODE: 'Category_GL_Code', CATEGORY_SETTINGS: 'Category_Settings', CATEGORIES_SETTINGS: 'Categories_Settings', MORE_FEATURES: 'Workspace_More_Features', diff --git a/src/components/AccountingListSkeletonView.tsx b/src/components/AccountingListSkeletonView.tsx index b977903d3adc..dbe8ada6c4b7 100644 --- a/src/components/AccountingListSkeletonView.tsx +++ b/src/components/AccountingListSkeletonView.tsx @@ -4,12 +4,14 @@ import ItemListSkeletonView from './Skeletons/ItemListSkeletonView'; type AccountingListSkeletonViewProps = { shouldAnimate?: boolean; + gradientOpacityEnabled?: boolean; }; -function AccountingListSkeletonView({shouldAnimate = true}: AccountingListSkeletonViewProps) { +function AccountingListSkeletonView({shouldAnimate = true, gradientOpacityEnabled = false}: AccountingListSkeletonViewProps) { return ( ( <> & { + contentHeight: number; + topInset: number; +}; +function isEnoughSpaceToRenderMenuAboveCursor({y, cursorCoordinates, scrollValue, contentHeight, topInset}: IsEnoughSpaceToRenderMenuAboveCursor): boolean { + return y + (cursorCoordinates.y - scrollValue) > contentHeight + topInset + CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE; } /** @@ -35,7 +43,7 @@ function isSuggestionRenderedAbove(isEnoughSpaceAboveForBig: boolean, isEnoughSp function AutoCompleteSuggestions({measureParentContainerAndReportCursor = () => {}, ...props}: AutoCompleteSuggestionsProps) { const containerRef = React.useRef(null); const isInitialRender = React.useRef(true); - const isSuggestionAboveRef = React.useRef(false); + const isSuggestionMenuAboveRef = React.useRef(false); const leftValue = React.useRef(0); const prevLeftValue = React.useRef(0); const {windowHeight, windowWidth, isSmallScreenWidth} = useWindowDimensions(); @@ -44,11 +52,12 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu width: 0, left: 0, bottom: 0, + cursorCoordinates: {x: 0, y: 0}, }); const StyleUtils = useStyleUtils(); const insets = useSafeAreaInsets(); const {keyboardHeight} = useKeyboardState(); - const {paddingBottom: bottomInset} = StyleUtils.getSafeAreaPadding(insets ?? undefined); + const {paddingBottom: bottomInset, paddingTop: topInset} = StyleUtils.getSafeAreaPadding(insets ?? undefined); useEffect(() => { const container = containerRef.current; @@ -73,51 +82,51 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu measureParentContainerAndReportCursor(({x, y, width, scrollValue, cursorCoordinates}: MeasureParentContainerAndCursor) => { const xCoordinatesOfCursor = x + cursorCoordinates.x; - const leftValueForBigScreen = + const bigScreenLeftOffset = xCoordinatesOfCursor + CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH > windowWidth ? windowWidth - CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH : xCoordinatesOfCursor; - - let bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - (keyboardHeight || bottomInset); - const widthValue = isSmallScreenWidth ? width : CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH; - const contentMaxHeight = measureHeightOfSuggestionRows(suggestionsLength, true); const contentMinHeight = measureHeightOfSuggestionRows(suggestionsLength, false); - const isEnoughSpaceAboveForBig = windowHeight - bottomValue - contentMaxHeight > CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE; - const isEnoughSpaceAboveForSmall = windowHeight - bottomValue - contentMinHeight > CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE; + let bottomValue = windowHeight - (cursorCoordinates.y - scrollValue + y) - keyboardHeight; + const widthValue = isSmallScreenWidth ? width : CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH; + + const isEnoughSpaceToRenderMenuAboveForBig = isEnoughSpaceToRenderMenuAboveCursor({y, cursorCoordinates, scrollValue, contentHeight: contentMaxHeight, topInset}); + const isEnoughSpaceToRenderMenuAboveForSmall = isEnoughSpaceToRenderMenuAboveCursor({y, cursorCoordinates, scrollValue, contentHeight: contentMinHeight, topInset}); - const newLeftValue = isSmallScreenWidth ? x : leftValueForBigScreen; + const newLeftOffset = isSmallScreenWidth ? x : bigScreenLeftOffset; // If the suggested word is longer than 150 (approximately half the width of the suggestion popup), then adjust a new position of popup - const isAdjustmentNeeded = Math.abs(prevLeftValue.current - leftValueForBigScreen) > 150; + const isAdjustmentNeeded = Math.abs(prevLeftValue.current - bigScreenLeftOffset) > 150; if (isInitialRender.current || isAdjustmentNeeded) { - isSuggestionAboveRef.current = isSuggestionRenderedAbove(isEnoughSpaceAboveForBig, isEnoughSpaceAboveForSmall); - leftValue.current = newLeftValue; + isSuggestionMenuAboveRef.current = isSuggestionMenuRenderedAbove(isEnoughSpaceToRenderMenuAboveForBig, isEnoughSpaceToRenderMenuAboveForSmall); + leftValue.current = newLeftOffset; isInitialRender.current = false; - prevLeftValue.current = newLeftValue; + prevLeftValue.current = newLeftOffset; } let measuredHeight = 0; - if (isSuggestionAboveRef.current && isEnoughSpaceAboveForBig) { + if (isSuggestionMenuAboveRef.current && isEnoughSpaceToRenderMenuAboveForBig) { // calculation for big suggestion box above the cursor measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true); - } else if (isSuggestionAboveRef.current && isEnoughSpaceAboveForSmall) { + } else if (isSuggestionMenuAboveRef.current && isEnoughSpaceToRenderMenuAboveForSmall) { // calculation for small suggestion box above the cursor measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, false); } else { // calculation for big suggestion box below the cursor measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true); - bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT - keyboardHeight; } setSuggestionHeight(measuredHeight); setContainerState({ left: leftValue.current, bottom: bottomValue, width: widthValue, + cursorCoordinates, }); }); - }, [measureParentContainerAndReportCursor, windowHeight, windowWidth, keyboardHeight, isSmallScreenWidth, suggestionsLength, bottomInset]); + }, [measureParentContainerAndReportCursor, windowHeight, windowWidth, keyboardHeight, isSmallScreenWidth, suggestionsLength, bottomInset, topInset]); - if (containerState.width === 0 && containerState.left === 0 && containerState.bottom === 0) { + if ((containerState.width === 0 && containerState.left === 0 && containerState.bottom === 0) || (containerState.cursorCoordinates.x === 0 && containerState.cursorCoordinates.y === 0)) { return null; } return ( diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index e641a0c2218a..4cbf85cb0014 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -36,10 +36,11 @@ function Breadcrumbs({breadcrumbs, style}: BreadcrumbsProps) { const theme = useTheme(); const styles = useThemeStyles(); const [primaryBreadcrumb, secondaryBreadcrumb] = breadcrumbs; + const isRootBreadcrumb = primaryBreadcrumb.type === CONST.BREADCRUMB_TYPE.ROOT; const fontScale = PixelRatio.getFontScale() > CONST.LOGO_MAX_SCALE ? CONST.LOGO_MAX_SCALE : PixelRatio.getFontScale(); return ( - {primaryBreadcrumb.type === CONST.BREADCRUMB_TYPE.ROOT ? ( + {isRootBreadcrumb ? (
/ {secondaryBreadcrumb.text} diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 4b3f0f70db24..0fd3cc0728ca 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -118,6 +118,9 @@ type ButtonProps = Partial & { /** Whether the button should use split style or not */ isSplitButton?: boolean; + + /** Whether button's content should be centered */ + isContentCentered?: boolean; }; type KeyboardShortcutComponentProps = Pick; @@ -202,6 +205,7 @@ function Button( id = '', accessibilityLabel = '', isSplitButton = false, + isContentCentered = false, ...rest }: ButtonProps, ref: ForwardedRef, @@ -239,7 +243,7 @@ function Button( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (icon || shouldShowRightIcon) { return ( - + {icon && ( diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index 26331f92401c..36f24c2a3477 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -14,8 +14,11 @@ import type IconAsset from '@src/types/utils/IconAsset'; import Button from './Button'; import Header from './Header'; import Icon from './Icon'; +import {Close} from './Icon/Expensicons'; import ImageSVG from './ImageSVG'; +import {PressableWithoutFeedback} from './Pressable'; import Text from './Text'; +import Tooltip from './Tooltip'; type ConfirmContentProps = { /** Title of the modal */ @@ -51,15 +54,36 @@ type ConfirmContentProps = { /** Icon to display above the title */ iconSource?: IconAsset; + /** Fill color for the Icon */ + iconFill?: string | false; + + /** Icon width */ + iconWidth?: number; + + /** Icon height */ + iconHeight?: number; + + /** Should the icon be centered? */ + shouldCenterIcon?: boolean; + /** Whether to center the icon / text content */ shouldCenterContent?: boolean; + /** Whether to show the dismiss icon */ + shouldShowDismissIcon?: boolean; + /** Whether to stack the buttons */ shouldStackButtons?: boolean; + /** Whether to reverse the order of the stacked buttons */ + shouldReverseStackedButtons?: boolean; + /** Styles for title */ titleStyles?: StyleProp; + /** Styles for title container */ + titleContainerStyles?: StyleProp; + /** Styles for prompt */ promptStyles?: StyleProp; @@ -85,13 +109,20 @@ function ConfirmContent({ shouldDisableConfirmButtonWhenOffline = false, shouldShowCancelButton = false, iconSource, + iconFill, shouldCenterContent = false, shouldStackButtons = true, titleStyles, promptStyles, contentStyles, iconAdditionalStyles, + iconWidth = variables.appModalAppIconSize, + iconHeight = variables.appModalAppIconSize, + shouldCenterIcon = false, + shouldShowDismissIcon = false, image, + titleContainerStyles, + shouldReverseStackedButtons = false, }: ConfirmContentProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -116,19 +147,35 @@ function ConfirmContent({ )} + {shouldShowDismissIcon && ( + + + + + + + + )} - {typeof iconSource === 'function' && ( - + {iconSource && ( + )} - +
+ {shouldShowCancelButton && shouldReverseStackedButtons && ( + + )} + + + + + ); +} + +EmptyStateComponent.displayName = 'EmptyStateComponent'; +export default EmptyStateComponent; diff --git a/src/components/EmptyStateComponent/types.ts b/src/components/EmptyStateComponent/types.ts new file mode 100644 index 000000000000..326b25542f42 --- /dev/null +++ b/src/components/EmptyStateComponent/types.ts @@ -0,0 +1,41 @@ +import type {ImageStyle} from 'expo-image'; +import type {StyleProp, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import type DotLottieAnimation from '@components/LottieAnimations/types'; +import type SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; +import type TableRowSkeleton from '@components/Skeletons/TableRowSkeleton'; +import type CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type ValidSkeletons = typeof SearchRowSkeleton | typeof TableRowSkeleton; +type MediaTypes = ValueOf; + +type SharedProps = { + SkeletonComponent: ValidSkeletons; + title: string; + subtitle: string; + buttonText?: string; + buttonAction?: () => void; + headerStyles?: StyleProp; + headerMediaType: T; + headerContentStyles?: StyleProp; +}; + +type MediaType = SharedProps & { + headerMedia: HeaderMedia; +}; + +type VideoProps = MediaType; +type IllustrationProps = MediaType; +type AnimationProps = MediaType; + +type EmptyStateComponentProps = VideoProps | IllustrationProps | AnimationProps; + +type VideoLoadedEventType = { + srcElement: { + videoWidth: number; + videoHeight: number; + }; +}; + +export type {EmptyStateComponentProps, VideoLoadedEventType}; diff --git a/src/components/Form/SafariFormWrapper.tsx b/src/components/Form/SafariFormWrapper.tsx new file mode 100644 index 000000000000..8ad411e547be --- /dev/null +++ b/src/components/Form/SafariFormWrapper.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import {isSafari} from '@libs/Browser'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +type SafariFormWrapperProps = ChildrenProps; + +/** + * If we used any without
wrapper, Safari 11+ would show the auto-fill suggestion popup. + */ +function SafariFormWrapper({children}: SafariFormWrapperProps) { + if (isSafari()) { + return {children}
; + } + + return children; +} + +export default SafariFormWrapper; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 7a8186d2f38e..b4dd8f254e25 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -53,6 +53,7 @@ import ConciergeNew from '@assets/images/simple-illustrations/simple-illustratio import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg'; import CreditCardEyes from '@assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg'; import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg'; +import EmptyState from '@assets/images/simple-illustrations/simple-illustration__empty-state.svg'; import FolderOpen from '@assets/images/simple-illustrations/simple-illustration__folder-open.svg'; import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg'; import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg'; @@ -78,6 +79,7 @@ import PiggyBank from '@assets/images/simple-illustrations/simple-illustration__ import Profile from '@assets/images/simple-illustrations/simple-illustration__profile.svg'; import QRCode from '@assets/images/simple-illustrations/simple-illustration__qr-code.svg'; import ReceiptEnvelope from '@assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg'; +import ReceiptLocationMarker from '@assets/images/simple-illustrations/simple-illustration__receipt-location-marker.svg'; import ReceiptWrangler from '@assets/images/simple-illustrations/simple-illustration__receipt-wrangler.svg'; import ReceiptUpload from '@assets/images/simple-illustrations/simple-illustration__receiptupload.svg'; import SanFrancisco from '@assets/images/simple-illustrations/simple-illustration__sanfrancisco.svg'; @@ -90,6 +92,7 @@ import SubscriptionPPU from '@assets/images/simple-illustrations/simple-illustra import Tag from '@assets/images/simple-illustrations/simple-illustration__tag.svg'; import TeachersUnite from '@assets/images/simple-illustrations/simple-illustration__teachers-unite.svg'; import ThumbsUpStars from '@assets/images/simple-illustrations/simple-illustration__thumbsupstars.svg'; +import Tire from '@assets/images/simple-illustrations/simple-illustration__tire.svg'; import TrackShoe from '@assets/images/simple-illustrations/simple-illustration__track-shoe.svg'; import TrashCan from '@assets/images/simple-illustrations/simple-illustration__trashcan.svg'; import TreasureChest from '@assets/images/simple-illustrations/simple-illustration__treasurechest.svg'; @@ -188,6 +191,7 @@ export { Pencil, Tag, CarIce, + ReceiptLocationMarker, Lightbulb, EmptyStateTravel, SubscriptionAnnual, @@ -198,6 +202,8 @@ export { CheckmarkCircle, CreditCardEyes, LockClosedOrange, + EmptyState, FolderWithPapers, VirtualCard, + Tire, }; diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index 8f3d78546dd3..bb5fdb580aa7 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -47,6 +47,7 @@ function OptionRowLHNData({ policy, parentReportAction, hasViolations: !!shouldDisplayViolations, + transactionViolations, }); if (deepEqual(item, optionItemRef.current)) { return optionItemRef.current; diff --git a/src/components/LocationPermissionModal/index.android.tsx b/src/components/LocationPermissionModal/index.android.tsx new file mode 100644 index 000000000000..811537e00e67 --- /dev/null +++ b/src/components/LocationPermissionModal/index.android.tsx @@ -0,0 +1,90 @@ +import React, {useEffect, useState} from 'react'; +import {Linking} from 'react-native'; +import {RESULTS} from 'react-native-permissions'; +import ConfirmModal from '@components/ConfirmModal'; +import * as Illustrations from '@components/Icon/Illustrations'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getLocationPermission, requestLocationPermission} from '@pages/iou/request/step/IOURequestStepScan/LocationPermission'; +import type {LocationPermissionModalProps} from './types'; + +function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDeny, onGrant}: LocationPermissionModalProps) { + const [hasError, setHasError] = useState(false); + const [showModal, setShowModal] = useState(false); + + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + useEffect(() => { + if (!startPermissionFlow) { + return; + } + + getLocationPermission().then((status) => { + if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) { + return onGrant(); + } + + setShowModal(true); + setHasError(status === RESULTS.BLOCKED); + }); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- We only want to run this effect when startPermissionFlow changes + }, [startPermissionFlow]); + + const handledBlockedPermission = (cb: () => void) => () => { + if (hasError && Linking.openSettings) { + Linking.openSettings(); + setShowModal(false); + setHasError(false); + resetPermissionFlow(); + return; + } + cb(); + }; + + const grantLocationPermission = handledBlockedPermission(() => { + requestLocationPermission().then((status) => { + if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) { + onGrant(); + } else if (status === RESULTS.BLOCKED) { + setHasError(true); + return; + } else { + onDeny(status); + } + setShowModal(false); + setHasError(false); + }); + }); + + const skipLocationPermission = () => { + onDeny(RESULTS.DENIED); + setShowModal(false); + setHasError(false); + }; + + return ( + + ); +} + +LocationPermissionModal.displayName = 'LocationPermissionModal'; + +export default LocationPermissionModal; diff --git a/src/components/LocationPermissionModal/index.tsx b/src/components/LocationPermissionModal/index.tsx new file mode 100644 index 000000000000..2bc4a7393822 --- /dev/null +++ b/src/components/LocationPermissionModal/index.tsx @@ -0,0 +1,90 @@ +import React, {useEffect, useState} from 'react'; +import {Linking} from 'react-native'; +import {RESULTS} from 'react-native-permissions'; +import ConfirmModal from '@components/ConfirmModal'; +import * as Illustrations from '@components/Icon/Illustrations'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getLocationPermission, requestLocationPermission} from '@pages/iou/request/step/IOURequestStepScan/LocationPermission'; +import type {LocationPermissionModalProps} from './types'; + +function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDeny, onGrant}: LocationPermissionModalProps) { + const [hasError, setHasError] = useState(false); + const [showModal, setShowModal] = useState(false); + + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + useEffect(() => { + if (!startPermissionFlow) { + return; + } + + getLocationPermission().then((status) => { + if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) { + return onGrant(); + } + + setShowModal(true); + setHasError(status === RESULTS.BLOCKED); + }); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- We only want to run this effect when startPermissionFlow changes + }, [startPermissionFlow]); + + const handledBlockedPermission = (cb: () => void) => () => { + if (hasError && Linking.openSettings) { + Linking.openSettings(); + setShowModal(false); + setHasError(false); + resetPermissionFlow(); + return; + } + cb(); + }; + + const grantLocationPermission = handledBlockedPermission(() => { + requestLocationPermission() + .then((status) => { + if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) { + onGrant(); + } else { + onDeny(status); + } + }) + .finally(() => { + setShowModal(false); + setHasError(false); + }); + }); + + const skipLocationPermission = () => { + onDeny(RESULTS.DENIED); + setShowModal(false); + setHasError(false); + }; + + return ( + + ); +} + +LocationPermissionModal.displayName = 'LocationPermissionModal'; + +export default LocationPermissionModal; diff --git a/src/components/LocationPermissionModal/types.ts b/src/components/LocationPermissionModal/types.ts new file mode 100644 index 000000000000..ec603bfdb8c1 --- /dev/null +++ b/src/components/LocationPermissionModal/types.ts @@ -0,0 +1,19 @@ +import type {PermissionStatus} from 'react-native-permissions'; + +type LocationPermissionModalProps = { + /** A callback to call when the permission has been granted */ + onGrant: () => void; + + /** A callback to call when the permission has been denied */ + onDeny: (permission: PermissionStatus) => void; + + /** Should start the permission flow? */ + startPermissionFlow: boolean; + + /** Reset the permission flow */ + resetPermissionFlow: () => void; +}; + +export default {}; + +export type {LocationPermissionModalProps}; diff --git a/src/components/Lottie/index.tsx b/src/components/Lottie/index.tsx index 6395a715f339..a9b223a87a54 100644 --- a/src/components/Lottie/index.tsx +++ b/src/components/Lottie/index.tsx @@ -1,7 +1,7 @@ -import type {LottieViewProps} from 'lottie-react-native'; +import type {AnimationObject, LottieViewProps} from 'lottie-react-native'; import LottieView from 'lottie-react-native'; import type {ForwardedRef} from 'react'; -import React, {forwardRef} from 'react'; +import React, {forwardRef, useEffect, useState} from 'react'; import {View} from 'react-native'; import type DotLottieAnimation from '@components/LottieAnimations/types'; import useAppState from '@hooks/useAppState'; @@ -19,6 +19,12 @@ function Lottie({source, webStyle, ...props}: Props, ref: ForwardedRef setIsError(false)}); + const [animationFile, setAnimationFile] = useState(); + + useEffect(() => { + setAnimationFile(source.file); + }, [setAnimationFile, source.file]); + const aspectRatioStyle = styles.aspectRatioLottie(source); // If the image fails to load or app is in background state, we'll just render an empty view @@ -28,17 +34,17 @@ function Lottie({source, webStyle, ...props}: Props, ref: ForwardedRef; } - return ( + return animationFile ? ( setIsError(true)} /> - ); + ) : null; } Lottie.displayName = 'Lottie'; diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 791b3e4d5e48..85bb79f85518 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -88,6 +88,18 @@ type MoneyRequestAmountInputProps = { * Autogrow input container length based on the entered text. */ autoGrow?: boolean; + + /** + * Determines whether the amount should be reset. + */ + shouldResetAmount?: boolean; + + /** + * Callback function triggered when the amount is reset. + * + * @param resetValue - A boolean indicating whether the amount should be reset. + */ + onResetAmount?: (resetValue: boolean) => void; }; type Selection = { @@ -123,6 +135,8 @@ function MoneyRequestAmountInput( hideFocusedState = true, shouldKeepUserInput = false, autoGrow = true, + shouldResetAmount, + onResetAmount, ...props }: MoneyRequestAmountInputProps, forwardedRef: ForwardedRef, @@ -202,10 +216,21 @@ function MoneyRequestAmountInput( })); useEffect(() => { + const frontendAmount = onFormatAmount(amount, currency); + setCurrentAmount(frontendAmount); + if (shouldResetAmount) { + setSelection({ + start: frontendAmount.length, + end: frontendAmount.length, + }); + onResetAmount?.(false); + return; + } + if ((!currency || typeof amount !== 'number' || (formatAmountOnBlur && isTextInputFocused(textInput))) ?? shouldKeepUserInput) { return; } - const frontendAmount = onFormatAmount(amount, currency); + setCurrentAmount(frontendAmount); // Only update selection if the amount prop was changed from the outside and is not the same as the current amount we just computed @@ -216,10 +241,7 @@ function MoneyRequestAmountInput( end: frontendAmount.length, }); } - - // we want to re-initialize the state only when the amount changes - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [amount, shouldKeepUserInput]); + }, [amount, currency, formatAmountOnBlur, shouldKeepUserInput, onFormatAmount, shouldResetAmount, onResetAmount, currentAmount]); // Modifies the amount to match the decimals for changed currency. useEffect(() => { diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 1fbd6a6b2630..98ed13a08363 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -162,6 +162,9 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & /** The action to take */ action?: IOUAction; + + /** Should play sound on confirmation */ + shouldPlaySound?: boolean; }; type MoneyRequestConfirmationListItem = Participant | ReportUtils.OptionData; @@ -204,6 +207,7 @@ function MoneyRequestConfirmationList({ action = CONST.IOU.ACTION.CREATE, currencyList, shouldDisplayReceipt = false, + shouldPlaySound = true, }: MoneyRequestConfirmationListProps) { const policy = policyReal ?? policyDraft; const policyCategories = policyCategoriesReal ?? policyCategoriesDraft; @@ -240,10 +244,7 @@ function MoneyRequestConfirmationList({ const {unit, rate} = mileageRate ?? {}; - const distance = TransactionUtils.getDistance(transaction); const prevRate = usePrevious(rate); - const prevDistance = usePrevious(distance); - const shouldCalculateDistanceAmount = isDistanceRequest && (iouAmount === 0 || prevRate !== rate || prevDistance !== distance); const currency = (mileageRate as MileageRate)?.currency ?? policyCurrency; @@ -257,6 +258,18 @@ function MoneyRequestConfirmationList({ const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest) && !isTypeInvoice; const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); + + const distance = useMemo(() => { + const value = TransactionUtils.getDistance(transaction); + if (canUseP2PDistanceRequests && isMovingTransactionFromTrackExpense && unit && !TransactionUtils.isFetchingWaypointsFromServer(transaction)) { + return DistanceRequestUtils.convertToDistanceInMeters(value, unit); + } + return value; + }, [isMovingTransactionFromTrackExpense, unit, transaction, canUseP2PDistanceRequests]); + const prevDistance = usePrevious(distance); + + const shouldCalculateDistanceAmount = isDistanceRequest && (iouAmount === 0 || prevRate !== rate || prevDistance !== distance); + const hasRoute = TransactionUtils.hasRoute(transaction, isDistanceRequest); const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate) && !isMovingTransactionFromTrackExpense; const formattedAmount = isDistanceRequestWithPendingRoute @@ -287,7 +300,7 @@ function MoneyRequestConfirmationList({ const isMerchantRequired = isPolicyExpenseChat && (!isScanRequest || isEditingSplitBill) && shouldShowMerchant; const isCategoryRequired = !!policy?.requiresCategory; - + const [shouldResetAmount, setShouldResetAmount] = useState(false); useEffect(() => { if (shouldDisplayFieldError && didConfirmSplit) { setFormError('iou.error.genericSmartscanFailureMessage'); @@ -466,6 +479,8 @@ function MoneyRequestConfirmationList({ onFormatAmount={CurrencyUtils.convertToDisplayStringWithoutCurrency} onAmountChange={(value: string) => onSplitShareChange(participantOption.accountID ?? -1, Number(value))} maxLength={formattedTotalAmount.length} + shouldResetAmount={shouldResetAmount} + onResetAmount={(resetValue) => setShouldResetAmount(resetValue)} /> ), })); @@ -488,6 +503,7 @@ function MoneyRequestConfirmationList({ transaction?.comment?.splits, transaction?.splitShares, onSplitShareChange, + shouldResetAmount, ]); const isSplitModified = useMemo(() => { @@ -505,6 +521,7 @@ function MoneyRequestConfirmationList({ { IOU.resetSplitShares(transaction); + setShouldResetAmount(true); }} accessibilityLabel={CONST.ROLE.BUTTON} role={CONST.ROLE.BUTTON} @@ -689,7 +706,9 @@ function MoneyRequestConfirmationList({ return; } - playSound(SOUNDS.DONE); + if (shouldPlaySound) { + playSound(SOUNDS.DONE); + } setDidConfirm(true); onConfirm?.(selectedParticipants); } else { @@ -723,6 +742,7 @@ function MoneyRequestConfirmationList({ isDistanceRequestWithPendingRoute, iouAmount, onConfirm, + shouldPlaySound, ], ); diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 8dfff6466ab9..48190fb3c759 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -256,7 +256,8 @@ function MoneyRequestConfirmationListFooter({ // Do not hide fields in case of paying someone const shouldShowAllFields = !!isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || !!isEditingSplitBill; // Calculate the formatted tax amount based on the transaction's tax amount and the IOU currency code - const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction?.taxAmount, iouCurrencyCode); + const taxAmount = TransactionUtils.getTaxAmount(transaction, false); + const formattedTaxAmount = CurrencyUtils.convertToDisplayString(taxAmount, iouCurrencyCode); // Get the tax rate title based on the policy and transaction const taxRateTitle = TransactionUtils.getTaxName(policy, transaction); // Determine if the merchant error should be displayed diff --git a/src/components/OptionsListSkeletonView.tsx b/src/components/OptionsListSkeletonView.tsx index a11077f95bb5..6dede512f405 100644 --- a/src/components/OptionsListSkeletonView.tsx +++ b/src/components/OptionsListSkeletonView.tsx @@ -18,16 +18,18 @@ function getLinedWidth(index: number): string { type OptionsListSkeletonViewProps = { shouldAnimate?: boolean; + gradientOpacityEnabled?: boolean; shouldStyleAsTable?: boolean; }; -function OptionsListSkeletonView({shouldAnimate = true, shouldStyleAsTable = false}: OptionsListSkeletonViewProps) { +function OptionsListSkeletonView({shouldAnimate = true, shouldStyleAsTable = false, gradientOpacityEnabled = false}: OptionsListSkeletonViewProps) { const styles = useThemeStyles(); return ( { const lineWidth = getLinedWidth(itemIndex); diff --git a/src/components/PopoverWithoutOverlay/index.tsx b/src/components/PopoverWithoutOverlay/index.tsx index bcead42a64f2..7d58ad6d22be 100644 --- a/src/components/PopoverWithoutOverlay/index.tsx +++ b/src/components/PopoverWithoutOverlay/index.tsx @@ -124,6 +124,7 @@ function PopoverWithoutOverlay( ref={viewRef(withoutOverlayRef)} // Prevent the parent element to capture a click. This is useful when the modal component is put inside a pressable. onClick={(e) => e.stopPropagation()} + dataSet={{dragArea: false}} > { + // If we're already navigating to these task editing pages, early return not to mark as completed, otherwise we would have not found page. + if (TaskUtils.isActiveTaskEditRoute(report.reportID)) { + return; + } if (isCompleted) { Task.reopenTask(report); } else { diff --git a/src/components/Search/SearchListWithHeader.tsx b/src/components/Search/SearchListWithHeader.tsx index 48d9a2b4ae3a..02da657609ba 100644 --- a/src/components/Search/SearchListWithHeader.tsx +++ b/src/components/Search/SearchListWithHeader.tsx @@ -1,7 +1,12 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useEffect, useMemo, useState} from 'react'; +import React, {forwardRef, useCallback, useEffect, useMemo, useState} from 'react'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import Modal from '@components/Modal'; import SelectionList from '@components/SelectionList'; import type {BaseSelectionListProps, ReportListItemType, SelectionListHandle, TransactionListItemType} from '@components/SelectionList/types'; +import useLocalize from '@hooks/useLocalize'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import * as SearchUtils from '@libs/SearchUtils'; import CONST from '@src/CONST'; import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults'; @@ -13,6 +18,8 @@ type SearchListWithHeaderProps = Omit void; }; function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] { @@ -33,7 +40,14 @@ function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListIt }; } -function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchType, ...props}: SearchListWithHeaderProps, ref: ForwardedRef) { +function SearchListWithHeader( + {ListItem, onSelectRow, query, hash, data, searchType, isMobileSelectionModeActive, setIsMobileSelectionModeActive, ...props}: SearchListWithHeaderProps, + ref: ForwardedRef, +) { + const {isSmallScreenWidth} = useWindowDimensions(); + const {translate} = useLocalize(); + const [isModalVisible, setIsModalVisible] = useState(false); + const [longPressedItem, setLongPressedItem] = useState(null); const [selectedItems, setSelectedItems] = useState({}); const clearSelectedItems = () => setSelectedItems({}); @@ -42,39 +56,72 @@ function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchT clearSelectedItems(); }, [hash]); - const toggleTransaction = (item: TransactionListItemType | ReportListItemType) => { - if (SearchUtils.isTransactionListItemType(item)) { - if (!item.keyForList) { + const toggleTransaction = useCallback( + (item: TransactionListItemType | ReportListItemType) => { + if (SearchUtils.isTransactionListItemType(item)) { + if (!item.keyForList) { + return; + } + + setSelectedItems((prev) => { + if (prev[item.keyForList]?.isSelected) { + const {[item.keyForList]: omittedTransaction, ...transactions} = prev; + return transactions; + } + return {...prev, [item.keyForList]: {isSelected: true, canDelete: item.canDelete, action: item.action}}; + }); + return; } - setSelectedItems((prev) => { - if (prev[item.keyForList]?.isSelected) { - const {[item.keyForList]: omittedTransaction, ...transactions} = prev; - return transactions; - } - return {...prev, [item.keyForList]: {isSelected: true, canDelete: item.canDelete, action: item.action}}; + if (item.transactions.every((transaction) => selectedItems[transaction.keyForList]?.isSelected)) { + const reducedSelectedItems: SelectedTransactions = {...selectedItems}; + + item.transactions.forEach((transaction) => { + delete reducedSelectedItems[transaction.keyForList]; + }); + + setSelectedItems(reducedSelectedItems); + return; + } + + setSelectedItems({ + ...selectedItems, + ...Object.fromEntries(item.transactions.map(mapTransactionItemToSelectedEntry)), }); + }, + [selectedItems], + ); + const openBottomModal = (item: TransactionListItemType | ReportListItemType | null) => { + if (!isSmallScreenWidth) { return; } - if (item.transactions.every((transaction) => selectedItems[transaction.keyForList]?.isSelected)) { - const reducedSelectedItems: SelectedTransactions = {...selectedItems}; + setLongPressedItem(item); + setIsModalVisible(true); + }; - item.transactions.forEach((transaction) => { - delete reducedSelectedItems[transaction.keyForList]; - }); + const turnOnSelectionMode = useCallback(() => { + setIsMobileSelectionModeActive?.(true); + setIsModalVisible(false); + + if (longPressedItem) { + toggleTransaction(longPressedItem); + } + }, [longPressedItem, setIsMobileSelectionModeActive, toggleTransaction]); - setSelectedItems(reducedSelectedItems); + const closeBottomModal = useCallback(() => { + setIsModalVisible(false); + }, []); + + useEffect(() => { + if (isMobileSelectionModeActive) { return; } - setSelectedItems({ - ...selectedItems, - ...Object.fromEntries(item.transactions.map(mapTransactionItemToSelectedEntry)), - }); - }; + setSelectedItems({}); + }, [setSelectedItems, isMobileSelectionModeActive]); const toggleAllTransactions = () => { const areItemsOfReportType = searchType === CONST.SEARCH.DATA_TYPES.REPORT; @@ -104,6 +151,8 @@ function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchT clearSelectedItems={clearSelectedItems} query={query} hash={hash} + isMobileSelectionModeActive={isMobileSelectionModeActive} + setIsMobileSelectionModeActive={setIsMobileSelectionModeActive} /> // eslint-disable-next-line react/jsx-props-no-spreading @@ -111,10 +160,24 @@ function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchT sections={[{data: sortedSelectedData, isDisabled: false}]} ListItem={ListItem} onSelectRow={onSelectRow} + onLongPressRow={openBottomModal} ref={ref} onCheckboxPress={toggleTransaction} onSelectAll={toggleAllTransactions} + isMobileSelectionModeActive={isMobileSelectionModeActive} /> + + + + ); } diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 8d42f9e6da36..b0f2acfb57d1 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react'; +import React, {useMemo} from 'react'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -10,6 +10,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as SearchActions from '@libs/actions/Search'; +import SearchSelectedNarrow from '@pages/Search/SearchSelectedNarrow'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {SearchQuery} from '@src/types/onyx/SearchResults'; @@ -22,11 +23,13 @@ type SearchHeaderProps = { selectedItems?: SelectedTransactions; clearSelectedItems?: () => void; hash: number; + isMobileSelectionModeActive?: boolean; + setIsMobileSelectionModeActive?: (isMobileSelectionModeActive: boolean) => void; }; type SearchHeaderOptionValue = DeepValueOf | undefined; -function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: SearchHeaderProps) { +function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems, isMobileSelectionModeActive, setIsMobileSelectionModeActive}: SearchHeaderProps) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); @@ -39,12 +42,13 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: finished: {icon: Illustrations.CheckmarkCircle, title: translate('common.finished')}, }; - const getHeaderButtons = useCallback(() => { + const selectedItemsKeys = Object.keys(selectedItems ?? []); + + const headerButtonsOptions = useMemo(() => { const options: Array> = []; - const selectedItemsKeys = Object.keys(selectedItems ?? []); if (selectedItemsKeys.length === 0) { - return null; + return options; } const itemsToDelete = selectedItemsKeys.filter((id) => selectedItems[id].canDelete); @@ -56,6 +60,9 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: value: CONST.SEARCH.BULK_ACTION_TYPES.DELETE, onSelected: () => { clearSelectedItems?.(); + if (isMobileSelectionModeActive) { + setIsMobileSelectionModeActive?.(false); + } SearchActions.deleteMoneyRequestOnSearch(hash, itemsToDelete); }, }); @@ -70,6 +77,9 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: value: CONST.SEARCH.BULK_ACTION_TYPES.HOLD, onSelected: () => { clearSelectedItems?.(); + if (isMobileSelectionModeActive) { + setIsMobileSelectionModeActive?.(false); + } SearchActions.holdMoneyRequestOnSearch(hash, itemsToHold, ''); }, }); @@ -84,6 +94,9 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: value: CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD, onSelected: () => { clearSelectedItems?.(); + if (isMobileSelectionModeActive) { + setIsMobileSelectionModeActive?.(false); + } SearchActions.unholdMoneyRequestOnSearch(hash, itemsToUnhold); }, }); @@ -107,21 +120,18 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: }); } - return ( - null} - shouldAlwaysShowDropdownMenu - pressOnEnter - buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {selectedNumber: selectedItemsKeys.length})} - options={options} - isSplitButton={false} - isDisabled={isOffline} - /> - ); - }, [clearSelectedItems, hash, isOffline, selectedItems, styles.colorMuted, styles.fontWeightNormal, theme.icon, translate]); + return options; + }, [clearSelectedItems, hash, selectedItems, selectedItemsKeys, styles, theme, translate, isMobileSelectionModeActive, setIsMobileSelectionModeActive]); if (isSmallScreenWidth) { + if (isMobileSelectionModeActive) { + return ( + + ); + } return null; } @@ -131,11 +141,23 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: icon={headerContent[query]?.icon} shouldShowBackButton={false} > - {getHeaderButtons()} + {headerButtonsOptions.length > 0 && ( + null} + shouldAlwaysShowDropdownMenu + pressOnEnter + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {selectedNumber: selectedItemsKeys.length})} + options={headerButtonsOptions} + isSplitButton={false} + isDisabled={isOffline} + /> + )} ); } SearchPageHeader.displayName = 'SearchPageHeader'; +export type {SearchHeaderOptionValue}; export default SearchPageHeader; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index fc5c23d5c9ec..78992496f031 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,11 +1,12 @@ import {useNavigation} from '@react-navigation/native'; import type {StackNavigationProp} from '@react-navigation/stack'; +import lodashMemoize from 'lodash/memoize'; import React, {useCallback, useEffect, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import SearchTableHeader from '@components/SelectionList/SearchTableHeader'; import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; -import TableListItemSkeleton from '@components/Skeletons/TableListItemSkeleton'; +import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -23,7 +24,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SearchResults from '@src/types/onyx/SearchResults'; import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults'; -import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {useSearchContext} from './SearchContext'; import SearchListWithHeader from './SearchListWithHeader'; import SearchPageHeader from './SearchPageHeader'; @@ -34,6 +34,8 @@ type SearchProps = { policyIDs?: string; sortBy?: SearchColumnType; sortOrder?: SortOrder; + isMobileSelectionModeActive?: boolean; + setIsMobileSelectionModeActive?: (isMobileSelectionModeActive: boolean) => void; }; const sortableSearchTabs: SearchQuery[] = [CONST.SEARCH.TAB.ALL]; @@ -41,15 +43,17 @@ const transactionItemMobileHeight = 100; const reportItemTransactionHeight = 52; const listItemPadding = 12; // this is equivalent to 'mb3' on every transaction/report list item const searchHeaderHeight = 54; - -function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { +function Search({query, policyIDs, sortBy, sortOrder, isMobileSelectionModeActive, setIsMobileSelectionModeActive}: SearchProps) { const {isOffline} = useNetwork(); const styles = useThemeStyles(); - const {isLargeScreenWidth} = useWindowDimensions(); + const {isLargeScreenWidth, isSmallScreenWidth} = useWindowDimensions(); const navigation = useNavigation>(); const lastSearchResultsRef = useRef>(); const {setCurrentSearchHash} = useSearchContext(); + const hash = SearchUtils.getQueryHash(query, policyIDs, sortBy, sortOrder); + const [currentSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`); + const getItemHeight = useCallback( (item: TransactionListItemType | ReportListItemType) => { if (SearchUtils.isTransactionListItemType(item)) { @@ -70,8 +74,15 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { [isLargeScreenWidth], ); - const hash = SearchUtils.getQueryHash(query, policyIDs, sortBy, sortOrder); - const [currentSearchResults, searchResultsMeta] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`); + const getItemHeightMemoized = lodashMemoize( + (item: TransactionListItemType | ReportListItemType) => getItemHeight(item), + (item) => { + // List items are displayed differently on "L"arge and "N"arrow screens so the height will differ + // in addition the same items might be displayed as part of different Search screens ("Expenses", "All", "Finished") + const screenSizeHash = isLargeScreenWidth ? 'L' : 'N'; + return `${hash}-${item.keyForList}-${screenSizeHash}`; + }, + ); // save last non-empty search results to avoid ugly flash of loading screen when hash changes and onyx returns empty data if (currentSearchResults?.data && currentSearchResults !== lastSearchResultsRef.current) { @@ -90,9 +101,8 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [hash, isOffline]); - const isLoadingItems = (!isOffline && isLoadingOnyxValue(searchResultsMeta)) || searchResults?.data === undefined; + const isLoadingItems = !isOffline && searchResults?.data === undefined; const isLoadingMoreItems = !isLoadingItems && searchResults?.search?.isLoading && searchResults?.search?.offset > 0; - const shouldShowEmptyState = !isLoadingItems && SearchUtils.isSearchResultsEmpty(searchResults); if (isLoadingItems) { return ( @@ -101,12 +111,14 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { query={query} hash={hash} /> - + ); } - if (shouldShowEmptyState) { + const shouldShowEmptyState = searchResults && SearchUtils.isSearchResultsEmpty(searchResults); + + if (shouldShowEmptyState ?? !searchResults) { return ( <> + !isLargeScreenWidth ? null : ( + + ) } - canSelectMultiple={isLargeScreenWidth} + canSelectMultiple={canSelectMultiple} customListHeaderHeight={searchHeaderHeight} // To enhance the smoothness of scrolling and minimize the risk of encountering blank spaces during scrolling, // we have configured a larger windowSize and a longer delay between batch renders. @@ -197,7 +213,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { updateCellsBatchingPeriod={200} ListItem={ListItem} onSelectRow={openReport} - getItemHeight={getItemHeight} + getItemHeight={getItemHeightMemoized} shouldDebounceRowSelect shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} listHeaderWrapperStyle={[styles.ph8, styles.pv3, styles.pb5]} @@ -205,9 +221,11 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { showScrollIndicator={false} onEndReachedThreshold={0.75} onEndReached={fetchMoreResults} + setIsMobileSelectionModeActive={setIsMobileSelectionModeActive} + isMobileSelectionModeActive={isMobileSelectionModeActive} listFooterContent={ isLoadingMoreItems ? ( - diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index cff74fe08a0a..2238b0f49855 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -26,4 +26,21 @@ type SearchContext = { setSelectedTransactionIds: (selectedTransactionIds: string[]) => void; }; -export type {SelectedTransactionInfo, SelectedTransactions, SearchColumnType, SortOrder, SearchContext}; +type ASTNode = { + operator: ValueOf; + left: ValueOf | ASTNode; + right: string | ASTNode; +}; + +type QueryFilter = { + operator: ValueOf; + value: string | number; +}; + +type AllFieldKeys = ValueOf | ValueOf; + +type QueryFilters = { + [K in AllFieldKeys]: QueryFilter | QueryFilter[]; +}; + +export type {SelectedTransactionInfo, SelectedTransactions, SearchColumnType, SortOrder, SearchContext, ASTNode, QueryFilter, QueryFilters, AllFieldKeys}; diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index 99330478c75f..5be228f0156e 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -32,6 +32,7 @@ function BaseListItem({ shouldSyncFocus = true, onFocus = () => {}, hoverStyle, + onLongPressRow, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -42,7 +43,7 @@ function BaseListItem({ // Sync focus on an item useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); - const handleMouseUp = (e: React.MouseEvent) => { + const handleMouseLeave = (e: React.MouseEvent) => { e.stopPropagation(); setMouseUp(); }; @@ -71,6 +72,9 @@ function BaseListItem({ // eslint-disable-next-line react/jsx-props-no-spreading {...bind} ref={pressableRef} + onLongPress={() => { + onLongPressRow?.(item); + }} onPress={(e) => { if (isMouseDownOnInput) { e?.stopPropagation(); // Preventing the click action @@ -92,8 +96,7 @@ function BaseListItem({ id={keyForList ?? ''} style={pressableStyle} onFocus={onFocus} - onMouseUp={handleMouseUp} - onMouseLeave={handleMouseUp} + onMouseLeave={handleMouseLeave} tabIndex={item.tabIndex} > diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 8b6ba790e6b0..eb2e66ad9a78 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -93,6 +93,8 @@ function BaseSelectionList( updateCellsBatchingPeriod = 50, removeClippedSubviews = true, shouldDelayFocus = true, + onLongPressRow, + isMobileSelectionModeActive, }: BaseSelectionListProps, ref: ForwardedRef, ) { @@ -447,6 +449,8 @@ function BaseSelectionList( isDisabled={isDisabled} showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} + onLongPressRow={onLongPressRow} + isMobileSelectionModeActive={isMobileSelectionModeActive} onSelectRow={() => selectRow(item)} onCheckboxPress={handleOnCheckboxPress()} onDismissError={() => onDismissError?.(item)} diff --git a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx index f634f84509b1..13e181264b25 100644 --- a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx +++ b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx @@ -2,15 +2,18 @@ import React, {memo} from 'react'; import {View} from 'react-native'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import {PressableWithFeedback} from '@components/Pressable'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import type {SearchAccountDetails, SearchTransactionAction} from '@src/types/onyx/SearchResults'; import ActionCell from './ActionCell'; import UserInfoCell from './UserInfoCell'; type ExpenseItemHeaderNarrowProps = { + text?: string; participantFrom: SearchAccountDetails; participantTo: SearchAccountDetails; participantFromDisplayName: string; @@ -18,9 +21,28 @@ type ExpenseItemHeaderNarrowProps = { action?: SearchTransactionAction; transactionID?: string; onButtonPress: () => void; + canSelectMultiple?: boolean; + isSelected?: boolean; + isDisabled?: boolean | null; + isDisabledCheckbox?: boolean; + handleCheckboxPress?: () => void; }; -function ExpenseItemHeaderNarrow({participantFrom, participantFromDisplayName, participantTo, participantToDisplayName, action, transactionID, onButtonPress}: ExpenseItemHeaderNarrowProps) { +function ExpenseItemHeaderNarrow({ + participantFrom, + participantFromDisplayName, + participantTo, + participantToDisplayName, + onButtonPress, + action, + canSelectMultiple, + isDisabledCheckbox, + isSelected, + isDisabled, + handleCheckboxPress, + text, + transactionID, +}: ExpenseItemHeaderNarrowProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); @@ -28,6 +50,26 @@ function ExpenseItemHeaderNarrow({participantFrom, participantFromDisplayName, p return ( + {canSelectMultiple && ( + handleCheckboxPress?.()} + style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), isDisabledCheckbox && styles.cursorDisabled, styles.mr1]} + > + + {isSelected && ( + + )} + + + )} ({ onSelectRow, onDismissError, onFocus, + onLongPressRow, shouldSyncFocus, }: ReportListItemProps) { const reportItem = item as unknown as ReportListItemType; @@ -110,6 +111,7 @@ function ReportListItem({ onSelectRow={() => openReportInRHP(transactionItem)} onDismissError={onDismissError} onFocus={onFocus} + onLongPressRow={onLongPressRow} shouldSyncFocus={shouldSyncFocus} /> ); @@ -126,6 +128,7 @@ function ReportListItem({ showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} onSelectRow={onSelectRow} + onLongPressRow={onLongPressRow} onDismissError={onDismissError} errors={item.errors} pendingAction={item.pendingAction} @@ -155,7 +158,7 @@ function ReportListItem({ containerStyle={[StyleUtils.getCheckboxContainerStyle(20), StyleUtils.getMultiselectListStyles(!!item.isSelected, !!item.isDisabled)]} disabled={!!isDisabled || item.isDisabledCheckbox} accessibilityLabel={item.text ?? ''} - style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled]} + style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, !isLargeScreenWidth && styles.mr3]} /> )} @@ -200,6 +203,7 @@ function ReportListItem({ isDisabled={!!isDisabled} canSelectMultiple={!!canSelectMultiple} isButtonSelected={item.isSelected} + shouldShowTransactionCheckbox /> ))} diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx index 6db308831baa..a10552ca9ad8 100644 --- a/src/components/SelectionList/Search/TransactionListItem.tsx +++ b/src/components/SelectionList/Search/TransactionListItem.tsx @@ -15,6 +15,7 @@ function TransactionListItem({ onCheckboxPress, onDismissError, onFocus, + onLongPressRow, shouldSyncFocus, }: TransactionListItemProps) { const transactionItem = item as unknown as TransactionListItemType; @@ -46,6 +47,7 @@ function TransactionListItem({ pendingAction={item.pendingAction} keyForList={item.keyForList} onFocus={onFocus} + onLongPressRow={onLongPressRow} shouldSyncFocus={shouldSyncFocus} hoverStyle={item.isSelected && styles.activeComponentBG} > @@ -59,6 +61,7 @@ function TransactionListItem({ isDisabled={!!isDisabled} canSelectMultiple={!!canSelectMultiple} isButtonSelected={item.isSelected} + shouldShowTransactionCheckbox={false} /> ); diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index f9ca70536e4b..6a8e4dc52bb7 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -4,6 +4,7 @@ import {View} from 'react-native'; import Checkbox from '@components/Checkbox'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import {PressableWithFeedback} from '@components/Pressable'; import ReceiptImage from '@components/ReceiptImage'; import type {TransactionListItemType} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; @@ -52,6 +53,7 @@ type TransactionListItemRowProps = { isDisabled: boolean; canSelectMultiple: boolean; isButtonSelected?: boolean; + shouldShowTransactionCheckbox?: boolean; }; const getTypeIcon = (type?: SearchTransactionType) => { @@ -240,27 +242,55 @@ function TransactionListItemRow({ containerStyle, isChildListItem = false, isButtonSelected = false, + shouldShowTransactionCheckbox, }: TransactionListItemRowProps) { const styles = useThemeStyles(); const {isLargeScreenWidth} = useWindowDimensions(); const StyleUtils = useStyleUtils(); + const theme = useTheme(); if (!isLargeScreenWidth) { return ( {showItemHeaderOnNarrowLayout && ( )} - + + {canSelectMultiple && shouldShowTransactionCheckbox && ( + + + {item.isSelected && ( + + )} + + + )} = { /** Handles what to do when the item is focused */ onFocus?: () => void; + + /** Callback to fire when the item is long pressed */ + onLongPressRow?: (item: TItem) => void; + + /** Whether Selection Mode is active - used only on small screens */ + isMobileSelectionModeActive?: boolean; } & TRightHandSideComponent; type ListItem = { @@ -465,6 +471,12 @@ type BaseSelectionListProps = Partial & { * https://reactnative.dev/docs/optimizing-flatlist-configuration#windowsize */ windowSize?: number; + + /** Callback to fire when the item is long pressed */ + onLongPressRow?: (item: TItem) => void; + + /** Whether Selection Mode is active - used only on small screens */ + isMobileSelectionModeActive?: boolean; } & TRightHandSideComponent; type SelectionListHandle = { diff --git a/src/components/Skeletons/ItemListSkeletonView.tsx b/src/components/Skeletons/ItemListSkeletonView.tsx index 1ee2da8a8019..046cdfffbee5 100644 --- a/src/components/Skeletons/ItemListSkeletonView.tsx +++ b/src/components/Skeletons/ItemListSkeletonView.tsx @@ -1,6 +1,6 @@ -import React, {useMemo, useState} from 'react'; -import {View} from 'react-native'; -import type {StyleProp, ViewStyle} from 'react-native'; +import React, {useCallback, useMemo, useState} from 'react'; +import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -10,22 +10,62 @@ type ListItemSkeletonProps = { shouldAnimate?: boolean; renderSkeletonItem: (args: {itemIndex: number}) => React.ReactNode; fixedNumItems?: number; + gradientOpacityEnabled?: boolean; itemViewStyle?: StyleProp; itemViewHeight?: number; }; -function ItemListSkeletonView({shouldAnimate = true, renderSkeletonItem, fixedNumItems, itemViewStyle = {}, itemViewHeight = CONST.LHN_SKELETON_VIEW_ITEM_HEIGHT}: ListItemSkeletonProps) { +const getVerticalMargin = (style: StyleProp): number => { + if (!style) { + return 0; + } + + const flattenStyle = StyleSheet.flatten(style); + const marginVertical = Number(flattenStyle?.marginVertical ?? 0); + const marginTop = Number(flattenStyle?.marginTop ?? 0); + const marginBottom = Number(flattenStyle?.marginBottom ?? 0); + + return marginVertical + marginTop + marginBottom; +}; + +function ItemListSkeletonView({ + shouldAnimate = true, + renderSkeletonItem, + fixedNumItems, + gradientOpacityEnabled = false, + itemViewStyle = {}, + itemViewHeight = CONST.LHN_SKELETON_VIEW_ITEM_HEIGHT, +}: ListItemSkeletonProps) { const theme = useTheme(); const themeStyles = useThemeStyles(); const [numItems, setNumItems] = useState(fixedNumItems ?? 0); + + const totalItemHeight = itemViewHeight + getVerticalMargin(itemViewStyle); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + if (fixedNumItems) { + return; + } + + const totalHeight = event.nativeEvent.layout.height; + const newNumItems = Math.ceil(totalHeight / totalItemHeight); + if (newNumItems !== numItems) { + setNumItems(newNumItems); + } + }, + [fixedNumItems, numItems, totalItemHeight], + ); + const skeletonViewItems = useMemo(() => { const items = []; for (let i = 0; i < numItems; i++) { + const opacity = gradientOpacityEnabled ? 1 - i / (numItems - 1) : 1; items.push( { - if (fixedNumItems) { - return; - } - - const newNumItems = Math.ceil(event.nativeEvent.layout.height / itemViewHeight); - if (newNumItems === numItems) { - return; - } - setNumItems(newNumItems); - }} + onLayout={handleLayout} > - {skeletonViewItems} + {skeletonViewItems} ); } diff --git a/src/components/Skeletons/TableListItemSkeleton.tsx b/src/components/Skeletons/SearchRowSkeleton.tsx similarity index 54% rename from src/components/Skeletons/TableListItemSkeleton.tsx rename to src/components/Skeletons/SearchRowSkeleton.tsx index 6ff3a3aedbb9..2359e47b7520 100644 --- a/src/components/Skeletons/TableListItemSkeleton.tsx +++ b/src/components/Skeletons/SearchRowSkeleton.tsx @@ -2,26 +2,41 @@ import React from 'react'; import {Circle, Rect} from 'react-native-svg'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ItemListSkeletonView from './ItemListSkeletonView'; -type TableListItemSkeletonProps = { +type SearchRowSkeletonProps = { shouldAnimate?: boolean; fixedNumItems?: number; + gradientOpacityEnabled?: boolean; }; -const barHeight = '10'; -const shortBarWidth = '40'; -const longBarWidth = '120'; +const barHeight = 8; +const longBarWidth = 120; +const leftPaneWidth = variables.sideBarWidth; -function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListItemSkeletonProps) { +// 12 is the gap between the element and the right button +const gapWidth = 12; + +// 80 is the width of the element itself +const rightSideElementWidth = 80; + +// 24 is the padding of the central pane summing two sides +const centralPanePadding = 40; + +// 80 is the width of the button on the right side +const rightButtonWidth = 80; + +function SearchRowSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEnabled = false}: SearchRowSkeletonProps) { const styles = useThemeStyles(); - const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); + const {windowWidth, isSmallScreenWidth, isLargeScreenWidth} = useWindowDimensions(); if (isSmallScreenWidth) { return ( ( @@ -51,7 +66,7 @@ function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListI height={4} /> ); } + return ( ( <> - + {isLargeScreenWidth && ( + <> + + + + + )} + + )} @@ -146,6 +181,6 @@ function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListI ); } -TableListItemSkeleton.displayName = 'TableListItemSkeleton'; +SearchRowSkeleton.displayName = 'SearchRowSkeleton'; -export default TableListItemSkeleton; +export default SearchRowSkeleton; diff --git a/src/components/Skeletons/TableRowSkeleton.tsx b/src/components/Skeletons/TableRowSkeleton.tsx new file mode 100644 index 000000000000..865bffc5842f --- /dev/null +++ b/src/components/Skeletons/TableRowSkeleton.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import {Circle, Rect} from 'react-native-svg'; +import useThemeStyles from '@hooks/useThemeStyles'; +import ItemListSkeletonView from './ItemListSkeletonView'; + +type TableListItemSkeletonProps = { + shouldAnimate?: boolean; + fixedNumItems?: number; + gradientOpacityEnabled?: boolean; +}; + +const barHeight = '8'; +const shortBarWidth = '60'; +const longBarWidth = '124'; + +function TableListItemSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEnabled = false}: TableListItemSkeletonProps) { + const styles = useThemeStyles(); + + return ( + ( + <> + + + + + )} + /> + ); +} + +TableListItemSkeleton.displayName = 'TableListItemSkeleton'; + +export default TableListItemSkeleton; diff --git a/src/components/TaskHeaderActionButton.tsx b/src/components/TaskHeaderActionButton.tsx index 0c7e603a4aa2..5e563ea99763 100644 --- a/src/components/TaskHeaderActionButton.tsx +++ b/src/components/TaskHeaderActionButton.tsx @@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportUtils from '@libs/ReportUtils'; +import * as TaskUtils from '@libs/TaskUtils'; import * as Session from '@userActions/Session'; import * as Task from '@userActions/Task'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -36,7 +37,17 @@ function TaskHeaderActionButton({report, session}: TaskHeaderActionButtonProps) isDisabled={!Task.canModifyTask(report, session?.accountID ?? -1)} medium text={translate(ReportUtils.isCompletedTaskReport(report) ? 'task.markAsIncomplete' : 'task.markAsComplete')} - onPress={Session.checkIfActionIsAllowed(() => (ReportUtils.isCompletedTaskReport(report) ? Task.reopenTask(report) : Task.completeTask(report)))} + onPress={Session.checkIfActionIsAllowed(() => { + // If we're already navigating to these task editing pages, early return not to mark as completed, otherwise we would have not found page. + if (TaskUtils.isActiveTaskEditRoute(report.reportID)) { + return; + } + if (ReportUtils.isCompletedTaskReport(report)) { + Task.reopenTask(report); + } else { + Task.completeTask(report); + } + })} style={styles.flex1} /> diff --git a/src/hooks/useActiveBottomTabRoute.ts b/src/hooks/useActiveBottomTabRoute.ts new file mode 100644 index 000000000000..434cca0cd815 --- /dev/null +++ b/src/hooks/useActiveBottomTabRoute.ts @@ -0,0 +1,8 @@ +import {useContext} from 'react'; +import ActiveBottomTabRouteContext from '@libs/Navigation/AppNavigator/Navigators/ActiveBottomTabRouteContext'; + +function useActiveBottomTabRoute() { + return useContext(ActiveBottomTabRouteContext); +} + +export default useActiveBottomTabRoute; diff --git a/src/hooks/useActiveRoute.ts b/src/hooks/useActiveRoute.ts deleted file mode 100644 index 812e7c634ee8..000000000000 --- a/src/hooks/useActiveRoute.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {useContext} from 'react'; -import ActiveRouteContext from '@libs/Navigation/AppNavigator/Navigators/ActiveRouteContext'; -import type {AuthScreensParamList, NavigationPartialRoute} from '@libs/Navigation/types'; - -function useActiveRoute(): NavigationPartialRoute | undefined { - return useContext(ActiveRouteContext); -} - -export default useActiveRoute; diff --git a/src/hooks/useHtmlPaste/index.ts b/src/hooks/useHtmlPaste/index.ts index 5888f96d1c15..022d6178877d 100644 --- a/src/hooks/useHtmlPaste/index.ts +++ b/src/hooks/useHtmlPaste/index.ts @@ -1,5 +1,6 @@ import {useNavigation} from '@react-navigation/native'; import {useCallback, useEffect} from 'react'; +import type {ClipboardEvent as PasteEvent} from 'react'; import Parser from '@libs/Parser'; import type UseHtmlPaste from './types'; @@ -20,8 +21,10 @@ const insertAtCaret = (target: HTMLElement, text: string) => { range.setEnd(node, node.length); selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); - // Dispatch paste event to simulate real browser behavior - target.dispatchEvent(new Event('paste', {bubbles: true})); + // Dispatch paste event to make Markdown Input properly set cursor position + const pasteEvent = new ClipboardEvent('paste', {bubbles: true, cancelable: true}); + (pasteEvent as unknown as PasteEvent).isDefaultPrevented = () => false; + target.dispatchEvent(pasteEvent); // Dispatch input event to trigger Markdown Input to parse the new text target.dispatchEvent(new Event('input', {bubbles: true})); } else { @@ -142,18 +145,18 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeLi let unsubscribeFocus: () => void; let unsubscribeBlur: () => void; if (removeListenerOnScreenBlur) { - unsubscribeFocus = navigation.addListener('focus', () => document.addEventListener('paste', handlePaste)); - unsubscribeBlur = navigation.addListener('blur', () => document.removeEventListener('paste', handlePaste)); + unsubscribeFocus = navigation.addListener('focus', () => document.addEventListener('paste', handlePaste, true)); + unsubscribeBlur = navigation.addListener('blur', () => document.removeEventListener('paste', handlePaste, true)); } - document.addEventListener('paste', handlePaste); + document.addEventListener('paste', handlePaste, true); return () => { if (removeListenerOnScreenBlur) { unsubscribeFocus(); unsubscribeBlur(); } - document.removeEventListener('paste', handlePaste); + document.removeEventListener('paste', handlePaste, true); }; // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts new file mode 100644 index 000000000000..b806c0dea95a --- /dev/null +++ b/src/hooks/usePaginatedReportActions.ts @@ -0,0 +1,39 @@ +import {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import PaginationUtils from '@libs/PaginationUtils'; +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; + +/** + * Get the longest continuous chunk of reportActions including the linked reportAction. If not linking to a specific action, returns the continuous chunk of newest reportActions. + */ +function usePaginatedReportActions(reportID?: string, reportActionID?: string) { + // Use `||` instead of `??` to handle empty string. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const reportIDWithDefault = reportID || '-1'; + + const [sortedAllReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDWithDefault}`, { + canEvict: false, + selector: (allReportActions) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true), + }); + const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${reportIDWithDefault}`); + + const reportActions = useMemo(() => { + if (!sortedAllReportActions?.length) { + return []; + } + return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionID); + }, [reportActionID, reportActionPages, sortedAllReportActions]); + + const linkedAction = useMemo( + () => sortedAllReportActions?.find((reportAction) => String(reportAction.reportActionID) === String(reportActionID)), + [reportActionID, sortedAllReportActions], + ); + + return { + reportActions, + linkedAction, + }; +} + +export default usePaginatedReportActions; diff --git a/src/hooks/useTackInputFocus/index.ts b/src/hooks/useTackInputFocus/index.ts index 124f8460127c..e6caa15f9dde 100644 --- a/src/hooks/useTackInputFocus/index.ts +++ b/src/hooks/useTackInputFocus/index.ts @@ -1,5 +1,6 @@ import {useCallback, useEffect} from 'react'; import useDebouncedState from '@hooks/useDebouncedState'; +import * as Browser from '@libs/Browser'; /** * Detects input or text area focus on browsers, to avoid scrolling on virtual viewports @@ -28,7 +29,13 @@ export default function useTackInputFocus(enable = false): boolean { ); const resetScrollPositionOnVisualViewport = useCallback(() => { - window.scrollTo({top: 0}); + if (Browser.isChromeIOS() && window.visualViewport?.offsetTop) { + // On Chrome iOS, the visual viewport triggers a scroll event when the keyboard is opened, but some time the scroll position is not correct. + // So this change is specific to Chrome iOS, helping to reset the viewport position correctly. + window.scrollTo({top: -window.visualViewport.offsetTop}); + } else { + window.scrollTo({top: 0}); + } }, []); useEffect(() => { diff --git a/src/hooks/useWindowDimensions/index.ts b/src/hooks/useWindowDimensions/index.ts index 25757fda17e5..b391e45a61aa 100644 --- a/src/hooks/useWindowDimensions/index.ts +++ b/src/hooks/useWindowDimensions/index.ts @@ -23,7 +23,7 @@ export default function (useCachedViewportHeight = false): WindowDimensions { unlockWindowDimensions: () => {}, }; - const isCachedViewportHeight = useCachedViewportHeight && Browser.isMobileSafari(); + const isCachedViewportHeight = useCachedViewportHeight && Browser.isMobileWebKit(); const cachedViewportHeightWithKeyboardRef = useRef(initalViewportHeight); const {width: windowWidth, height: windowHeight} = useWindowDimensions(); diff --git a/src/languages/en.ts b/src/languages/en.ts index 1ac9684ac22e..3170d0ebb777 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -135,6 +135,7 @@ export default { yes: 'Yes', no: 'No', ok: 'OK', + notNow: 'Not now', learnMore: 'Learn more', buttonConfirm: 'Got it', name: 'Name', @@ -521,6 +522,7 @@ export default { replyInThread: 'Reply in thread', joinThread: 'Join thread', leaveThread: 'Leave thread', + copyOnyxData: 'Copy Onyx data', flagAsOffensive: 'Flag as offensive', menu: 'Menu', }, @@ -605,7 +607,7 @@ export default { saveTheWorld: 'Save the world', }, allSettingsScreen: { - subscriptions: 'Subscriptions', + subscription: 'Subscription', cardsAndDomains: 'Cards & Domains', }, tabSelector: { @@ -625,6 +627,10 @@ export default { cameraAccess: 'Camera access is required to take pictures of receipts.', cameraErrorTitle: 'Camera error', cameraErrorMessage: 'An error occurred while taking a photo. Please try again.', + locationAccessTitle: 'Allow location access', + locationAccessMessage: 'Weโ€™ll use your location to accurately determine your default currency and timezone. You can edit access in your deviceโ€™s settings anytime.', + locationErrorTitle: 'Enable location in settings', + locationErrorMessage: 'Allowing location access is required to help accurately determine your default currency and timezone. Tap Settings to update permissions.', dropTitle: 'Let it go', dropMessage: 'Drop your file here', flash: 'flash', @@ -1153,9 +1159,9 @@ export default { deleteAccount: 'Delete account', deleteConfirmation: 'Are you sure you want to delete this account?', error: { - notOwnerOfBankAccount: 'There was an error setting this bank account as your default payment method.', + notOwnerOfBankAccount: 'An error occurred while setting this bank account as your default payment method.', invalidBankAccount: 'This bank account is temporarily suspended.', - notOwnerOfFund: 'There was an error setting this card as your default payment method.', + notOwnerOfFund: 'An error occurred while setting this card as your default payment method.', setDefaultFailure: 'Something went wrong. Please chat with Concierge for further assistance.', }, addBankAccountFailure: 'An unexpected error occurred while trying to add your bank account. Please try again.', @@ -1463,7 +1469,6 @@ export default { title: 'What do you want to do today?', errorSelection: 'Please make a selection to continue.', errorContinue: 'Please press continue to get set up.', - errorBackButton: 'Please finish the setup questions to start using the app.', [CONST.ONBOARDING_CHOICES.EMPLOYER]: 'Get paid back by my employer', [CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: "Manage my team's expenses", [CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]: 'Track and budget expenses', @@ -1619,7 +1624,7 @@ export default { phrase4: 'verify your account here', }, hasPhoneLoginError: 'To add a verified bank account please ensure your primary login is a valid email and try again. You can add your phone number as a secondary login.', - hasBeenThrottledError: 'There was an error adding your bank account. Please wait a few minutes and try again.', + hasBeenThrottledError: 'An error occurred while adding your bank account. Please wait a few minutes and try again.', hasCurrencyError: 'Oops! It appears that your workspace currency is set to a different currency than USD. To proceed, please set it to USD and try again.', error: { youNeedToSelectAnOption: 'You need to select an option to proceed.', @@ -1685,7 +1690,7 @@ export default { verifyIdentity: 'Verify identity', letsVerifyIdentity: "Let's verify your identity.", butFirst: `But first, the boring stuff. Read up on the legalese in the next step and click "Accept" when you're ready.`, - genericError: 'There was an error while processing this step. Please try again.', + genericError: 'An error occurred while processing this step. Please try again.', cameraPermissionsNotGranted: 'Enable camera access', cameraRequestMessage: 'We need access to your camera to complete bank account verification. Please enable via Settings > New Expensify.', microphonePermissionsNotGranted: 'Enable microphone access', @@ -2665,6 +2670,8 @@ export default { existingCategoryError: 'A category with this name already exists.', invalidCategoryName: 'Invalid category name.', importedFromAccountingSoftware: 'The categories below are imported from your', + glCode: 'GL code', + updateGLCodeFailureMessage: 'An error occurred while updating the GL code, please try again.', }, moreFeatures: { spendSection: { @@ -2802,6 +2809,8 @@ export default { existingTagError: 'A tag with this name already exists.', genericFailureMessage: 'An error occurred while updating the tag, please try again.', importedFromAccountingSoftware: 'The tags below are imported from your', + glCode: 'GL code', + updateGLCodeFailureMessage: 'An error occurred while updating the GL code, please try again.', }, taxes: { subtitle: 'Add tax names, rates, and set defaults.', @@ -3125,6 +3134,13 @@ export default { defaultVendor: 'Default vendor', autoSync: 'Auto-sync', reimbursedReports: 'Sync reimbursed reports', + reconciliationAccount: 'Reconciliation account', + chooseReconciliationAccount: { + chooseBankAccount: 'Choose the bank account that your Expensify Card payments will be reconciled against.', + accountMatches: 'Make sure this account matches your ', + settlementAccount: 'Expensify Card settlement account ', + reconciliationWorks: (lastFourPAN: string) => `(ending in ${lastFourPAN}) so Continuous Reconciliation works properly.`, + }, }, bills: { manageYourBills: 'Manage your bills', @@ -3167,7 +3183,7 @@ export default { member: 'Invite member', members: 'Invite members', invitePeople: 'Invite new members', - genericFailureMessage: 'An error occurred inviting the member to the workspace. Please try again.', + genericFailureMessage: 'An error occurred while inviting the member to the workspace. Please try again.', pleaseEnterValidLogin: `Please ensure the email or phone number is valid (e.g. ${CONST.EXAMPLE_PHONE_NUMBER}).`, user: 'user', users: 'users', @@ -3181,7 +3197,7 @@ export default { inviteMessageTitle: 'Add message', inviteMessagePrompt: 'Make your invitation extra special by adding a message below', personalMessagePrompt: 'Message', - genericFailureMessage: 'An error occurred inviting the member to the workspace. Please try again.', + genericFailureMessage: 'An error occurred while inviting the member to the workspace. Please try again.', inviteNoMembersError: 'Please select at least one member to invite.', }, distanceRates: { @@ -3215,7 +3231,7 @@ export default { currencyInputHelpText: 'All expenses on this workspace will be converted to this currency.', currencyInputDisabledText: "The default currency can't be changed because this workspace is linked to a USD bank account.", save: 'Save', - genericFailureMessage: 'An error occurred updating the workspace. Please try again.', + genericFailureMessage: 'An error occurred while updating the workspace. Please try again.', avatarUploadFailureMessage: 'An error occurred uploading the avatar. Please try again.', addressContext: 'A Workspace Address is required to enable Expensify Travel. Please enter an address associated with your business.', }, @@ -3410,7 +3426,7 @@ export default { }, markAsComplete: 'Mark as complete', markAsIncomplete: 'Mark as incomplete', - assigneeError: 'There was an error assigning this task. Please try another assignee.', + assigneeError: 'An error occurred while assigning this task. Please try another assignee.', genericCreateTaskFailureMessage: 'There was an error creating this task. Please try again later.', deleteTask: 'Delete task', deleteConfirmation: 'Are you sure you want to delete this task?', @@ -3436,6 +3452,7 @@ export default { screenShareRequest: 'Expensify is inviting you to a screen share', }, search: { + selectMultiple: 'Select multiple', resultsAreLimited: 'Search results are limited.', searchResults: { emptyResults: { @@ -3901,7 +3918,7 @@ export default { "You appear to be offline. Unfortunately, Expensify Classic doesn't work offline, but New Expensify does. If you prefer to use Expensify Classic, try again when you have an internet connection.", }, listBoundary: { - errorMessage: 'There was an error loading more messages.', + errorMessage: 'An error occurred while loading more messages.', tryAgain: 'Try again', }, systemMessage: { @@ -3971,6 +3988,10 @@ export default { title: ({numOfDays}) => `Free trial: ${numOfDays} ${numOfDays === 1 ? 'day' : 'days'} left!`, subtitle: 'Add a payment card to continue using all of your favorite features.', }, + trialEnded: { + title: 'Your free trial has ended', + subtitle: 'Add a payment card to continue using all of your favorite features.', + }, }, cardSection: { title: 'Payment', @@ -4050,7 +4071,7 @@ export default { }, paymentCard: { addPaymentCard: 'Add payment card', - enterPaymentCardDetails: 'Enter your payment card details.', + enterPaymentCardDetails: 'Enter your payment card details', security: 'Expensify is PCI-DSS compliant, uses bank-level encryption, and utilizes redundant infrastructure to protect your data.', learnMoreAboutSecurity: 'Learn more about our security.', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index ea9186daee78..75fb44a5c394 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -125,6 +125,7 @@ export default { yes: 'Sรญ', no: 'No', ok: 'OK', + notNow: 'Ahora no', learnMore: 'Mรกs informaciรณn', buttonConfirm: 'Ok, entendido', name: 'Nombre', @@ -372,8 +373,8 @@ export default { cameraPermissionRequired: 'Permiso para acceder a la cรกmara', expensifyDoesntHaveAccessToCamera: 'Expensify no puede tomar fotos sin acceso a la cรกmara. Haz click en configuraciรณn para actualizar los permisos.', attachmentError: 'Error al adjuntar archivo', - errorWhileSelectingAttachment: 'Ha ocurrido un error al seleccionar un archivo adjunto. Por favor, intรฉntalo de nuevo.', - errorWhileSelectingCorruptedAttachment: 'Ha ocurrido un error al seleccionar un archivo adjunto corrupto. Por favor, intรฉntalo con otro archivo.', + errorWhileSelectingAttachment: 'Se ha producido un error al seleccionar un archivo adjunto. Por favor, intรฉntalo de nuevo.', + errorWhileSelectingCorruptedAttachment: 'Se ha producido un error al seleccionar un archivo adjunto corrupto. Por favor, intรฉntalo con otro archivo.', takePhoto: 'Hacer una foto', chooseFromGallery: 'Elegir de la galerรญa', chooseDocument: 'Elegir un archivo', @@ -513,6 +514,7 @@ export default { replyInThread: 'Responder en el hilo', joinThread: 'Unirse al hilo', leaveThread: 'Dejar hilo', + copyOnyxData: 'Copiar datos de Onyx', flagAsOffensive: 'Marcar como ofensivo', menu: 'Menรบ', }, @@ -598,7 +600,7 @@ export default { saveTheWorld: 'Salvar el mundo', }, allSettingsScreen: { - subscriptions: 'Suscripciones', + subscription: 'Suscripcion', cardsAndDomains: 'Tarjetas y Dominios', }, tabSelector: { @@ -617,7 +619,13 @@ export default { takePhoto: 'Haz una foto', cameraAccess: 'Se requiere acceso a la cรกmara para hacer fotos de los recibos.', cameraErrorTitle: 'Error en la cรกmara', - cameraErrorMessage: 'Se produjo un error al hacer una foto. Por favor, intรฉntalo de nuevo.', + locationAccessTitle: 'Permitir acceso a la ubicaciรณn', + locationAccessMessage: + 'Usaremos tu ubicaciรณn para determinar con precisiรณn la moneda y zona horaria predeterminadas. Puedes editar el acceso en la configuraciรณn de tu dispositivo en cualquier momento.', + locationErrorTitle: 'Habilitar ubicaciรณn en la configuraciรณn', + locationErrorMessage: + 'Es necesario permitir el acceso a la ubicaciรณn para ayudar a determinar con precisiรณn su moneda y zona horaria predeterminadas. Haz click en Configuraciรณn para actualizar los permisos.', + cameraErrorMessage: 'Se ha producido un error al hacer una foto. Por favor, intรฉntalo de nuevo.', dropTitle: 'Suรฉltalo', dropMessage: 'Suelta tu archivo aquรญ', flash: 'flash', @@ -899,13 +907,13 @@ export default { 'Este es tu mรฉtodo de contacto predeterminado. Antes de poder eliminarlo, tendrรกs que elegir otro mรฉtodo de contacto y haz clic en "Establecer como predeterminado".', removeContactMethod: 'Eliminar mรฉtodo de contacto', removeAreYouSure: 'ยฟEstรกs seguro de que quieres eliminar este mรฉtodo de contacto? Esta acciรณn no se puede deshacer.', - failedNewContact: 'Hubo un error al aรฑadir este mรฉtodo de contacto.', + failedNewContact: 'Se ha producido un error al aรฑadir este mรฉtodo de contacto.', genericFailureMessages: { requestContactMethodValidateCode: 'No se ha podido enviar un nuevo cรณdigo mรกgico. Espera un rato y vuelve a intentarlo.', validateSecondaryLogin: 'Cรณdigo mรกgico incorrecto o no vรกlido. Intรฉntalo de nuevo o solicita otro cรณdigo.', deleteContactMethod: 'No se ha podido eliminar este mรฉtodo de contacto. Por favor, contacta con Concierge para obtener ayuda.', setDefaultContactMethod: 'No se pudo establecer un nuevo mรฉtodo de contacto predeterminado. Por favor contacta con Concierge para obtener ayuda.', - addContactMethod: 'Hubo un error al aรฑadir este mรฉtodo de contacto. Por favor, contacta con Concierge para obtener ayuda.', + addContactMethod: 'Se ha producido un error al aรฑadir este mรฉtodo de contacto. Por favor, contacta con Concierge para obtener ayuda.', enteredMethodIsAlreadySubmited: 'El mรฉtodo de contacto ingresado ya existe.', passwordRequired: 'Se requiere contraseรฑa', contactMethodRequired: 'Se requiere mรฉtodo de contacto.', @@ -1124,7 +1132,7 @@ export default { addressStreet: 'Por favor, introduce una direcciรณn de facturaciรณn vรกlida que no sea un apartado postal.', addressState: 'Por favor, selecciona un estado.', addressCity: 'Por favor, introduce una ciudad.', - genericFailureMessage: 'Se produjo un error al aรฑadir tu tarjeta. Por favor, vuelva a intentarlo.', + genericFailureMessage: 'Se ha producido un error al aรฑadir tu tarjeta. Por favor, vuelva a intentarlo.', password: 'Por favor, introduce tu contraseรฑa de Expensify.', }, }, @@ -1147,7 +1155,7 @@ export default { addressStreet: 'Por favor, introduce una direcciรณn de facturaciรณn vรกlida que no sea un apartado postal.', addressState: 'Por favor, selecciona un estado.', addressCity: 'Por favor, introduce una ciudad.', - genericFailureMessage: 'Se produjo un error al aรฑadir tu tarjeta. Por favor, vuelva a intentarlo.', + genericFailureMessage: 'Se ha producido un error al aรฑadir tu tarjeta. Por favor, vuelva a intentarlo.', password: 'Por favor, introduce tu contraseรฑa de Expensify.', }, }, @@ -1158,9 +1166,9 @@ export default { deleteAccount: 'Eliminar cuenta', deleteConfirmation: 'ยฟEstรกs seguro de que quieres eliminar esta cuenta?', error: { - notOwnerOfBankAccount: 'Ha ocurrido un error al establecer esta cuenta bancaria como mรฉtodo de pago predeterminado.', + notOwnerOfBankAccount: 'Se ha producido un error al establecer esta cuenta bancaria como mรฉtodo de pago predeterminado.', invalidBankAccount: 'Esta cuenta bancaria estรก temporalmente suspendida.', - notOwnerOfFund: 'Ha ocurrido un error al establecer esta tarjeta de crรฉdito como mรฉtodo de pago predeterminado.', + notOwnerOfFund: 'Se ha producido un error al establecer esta tarjeta de crรฉdito como mรฉtodo de pago predeterminado.', setDefaultFailure: 'No se ha podido configurar el mรฉtodo de pago.', }, addBankAccountFailure: 'Ocurriรณ un error inesperado al intentar aรฑadir la cuenta bancaria. Intรฉntalo de nuevo.', @@ -1471,7 +1479,6 @@ export default { title: 'ยฟQuรฉ quieres hacer hoy?', errorSelection: 'Por favor selecciona una opciรณn para continuar.', errorContinue: 'Por favor, haz click en continuar para configurar tu cuenta.', - errorBackButton: 'Por favor, finaliza las preguntas de configuraciรณn para empezar a utilizar la aplicaciรณn.', [CONST.ONBOARDING_CHOICES.EMPLOYER]: 'Cobrar de mi empresa', [CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: 'Gestionar los gastos de mi equipo', [CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]: 'Controlar y presupuestar gastos', @@ -1645,7 +1652,7 @@ export default { }, hasPhoneLoginError: 'Para aรฑadir una cuenta bancaria verificada, asegรบrate de que tu nombre de usuario principal sea un correo electrรณnico vรกlido y vuelve a intentarlo. Puedes aรฑadir tu nรบmero de telรฉfono como nombre de usuario secundario.', - hasBeenThrottledError: 'Se produjo un error al intentar aรฑadir tu cuenta bancaria. Por favor, espera unos minutos e intรฉntalo de nuevo.', + hasBeenThrottledError: 'Se ha producido un error al intentar aรฑadir tu cuenta bancaria. Por favor, espera unos minutos e intรฉntalo de nuevo.', hasCurrencyError: 'ยกUps! Parece que la moneda de tu espacio de trabajo estรก configurada en una moneda diferente a USD. Para continuar, por favor configรบrala en USD e intรฉntalo nuevamente.', error: { @@ -1691,7 +1698,7 @@ export default { unknownFilename: 'Archivo desconocido', passwordRequired: 'Por favor, introduce tu contraseรฑa', passwordIncorrect: 'Contraseรฑa incorrecta. Por favor, intรฉntalo de nuevo.', - failedToLoadPDF: 'Hubo un error al intentar cargar el PDF.', + failedToLoadPDF: 'Se ha producido un error al intentar cargar el PDF.', pdfPasswordForm: { title: 'PDF protegido con contraseรฑa', infoText: 'Este PDF esta protegido con contraseรฑa.', @@ -1713,7 +1720,7 @@ export default { verifyIdentity: 'Verificar identidad', letsVerifyIdentity: 'ยกVamos a verificar tu identidad!', butFirst: 'Pero primero, lo aburrido. Lee la jerga legal en el siguiente paso y haz clic en "Aceptar" cuando estรฉs listo.', - genericError: 'Hubo un error al procesar este paso. Intรฉntalo de nuevo.', + genericError: 'Se ha producido un error al procesar este paso. Intรฉntalo de nuevo.', cameraPermissionsNotGranted: 'Permiso para acceder a la cรกmara', cameraRequestMessage: 'Necesitamos acceso a tu cรกmara para completar la verificaciรณn de tu cuenta de banco. Por favor habilita los permisos en Configuraciรณn > Nuevo Expensify.', microphonePermissionsNotGranted: 'Permiso para acceder al micrรณfono', @@ -2711,6 +2718,8 @@ export default { existingCategoryError: 'Ya existe una categorรญa con este nombre.', invalidCategoryName: 'Lo nombre de la categorรญa es invalido.', importedFromAccountingSoftware: 'Categorรญas importadas desde', + glCode: 'Cรณdigo GL', + updateGLCodeFailureMessage: 'Se produjo un error al actualizar el cรณdigo GL. Intรฉntelo nuevamente.', }, moreFeatures: { spendSection: { @@ -2846,8 +2855,10 @@ export default { deleteFailureMessage: 'Se ha producido un error al intentar eliminar la etiqueta. Por favor, intรฉntalo mรกs tarde.', tagRequiredError: 'Lo nombre de la etiqueta es obligatorio.', existingTagError: 'Ya existe una etiqueta con este nombre.', - genericFailureMessage: 'Se produjo un error al actualizar la etiqueta. Por favor, intรฉntelo nuevamente.', + genericFailureMessage: 'Se ha producido un error al actualizar la etiqueta. Por favor, intรฉntelo nuevamente.', importedFromAccountingSoftware: 'Etiquetas importadas desde', + glCode: 'Cรณdigo GL', + updateGLCodeFailureMessage: 'Se produjo un error al actualizar el cรณdigo GL. Por favor, intรฉntelo nuevamente.', }, taxes: { subtitle: 'Aรฑade nombres, tasas y establezca valores por defecto para los impuestos.', @@ -3111,6 +3122,13 @@ export default { defaultVendor: 'Proveedor predeterminado', autoSync: 'Autosincronizaciรณn', reimbursedReports: 'Sincronizar informes reembolsados', + reconciliationAccount: 'Cuenta de conciliaciรณn', + chooseReconciliationAccount: { + chooseBankAccount: 'Elige la cuenta bancaria con la que se conciliarรกn los pagos de tu Tarjeta Expensify.', + accountMatches: 'Asegรบrate de que esta cuenta coincide con ', + settlementAccount: 'la cuenta de liquidaciรณn de tu Tarjeta Expensify ', + reconciliationWorks: (lastFourPAN: string) => `(que termina en ${lastFourPAN}) para que la conciliaciรณn continua funcione correctamente.`, + }, }, card: { header: 'Desbloquea Tarjetas Expensify gratis', @@ -3214,7 +3232,7 @@ export default { member: 'Invitar miembros', members: 'Invitar miembros', invitePeople: 'Invitar nuevos miembros', - genericFailureMessage: 'Se produjo un error al invitar al miembro al espacio de trabajo. Vuelva a intentarlo..', + genericFailureMessage: 'Se ha producido un error al invitar al miembro al espacio de trabajo. Vuelva a intentarlo..', pleaseEnterValidLogin: `Asegรบrese de que el correo electrรณnico o el nรบmero de telรฉfono sean vรกlidos (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`, user: 'miembro', users: 'miembros', @@ -3229,7 +3247,7 @@ export default { inviteMessagePrompt: 'Aรฑadir un mensaje para hacer tu invitaciรณn destacar', personalMessagePrompt: 'Mensaje', inviteNoMembersError: 'Por favor, selecciona al menos un miembro a invitar.', - genericFailureMessage: 'Se produjo un error al invitar al miembro al espacio de trabajo. Por favor, vuelva a intentarlo..', + genericFailureMessage: 'Se ha producido un error al invitar al miembro al espacio de trabajo. Por favor, vuelva a intentarlo..', }, distanceRates: { oopsNotSoFast: 'Ups! No tan rรกpido...', @@ -3262,7 +3280,7 @@ export default { currencyInputHelpText: 'Todas los gastos en este espacio de trabajo serรกn convertidos a esta moneda.', currencyInputDisabledText: 'La moneda predeterminada no se puede cambiar porque este espacio de trabajo estรก vinculado a una cuenta bancaria en USD.', save: 'Guardar', - genericFailureMessage: 'Se produjo un error al guardar el espacio de trabajo. Por favor, intรฉntalo de nuevo.', + genericFailureMessage: 'Se ha producido un error al guardar el espacio de trabajo. Por favor, intรฉntalo de nuevo.', avatarUploadFailureMessage: 'No se pudo subir el avatar. Por favor, intรฉntalo de nuevo.', addressContext: 'Se requiere una direcciรณn para habilitar Expensify Travel. Por favor, introduce una direcciรณn asociada con tu negocio.', }, @@ -3459,7 +3477,7 @@ export default { }, markAsComplete: 'Marcar como completada', markAsIncomplete: 'Marcar como incompleta', - assigneeError: 'Hubo un error al asignar esta tarea. Por favor, intรฉntalo con otro miembro.', + assigneeError: 'Se ha producido un error al asignar esta tarea. Por favor, intรฉntalo con otro miembro.', genericCreateTaskFailureMessage: 'Error inesperado al crear el tarea. Por favor, intรฉntalo mรกs tarde.', deleteTask: 'Eliminar tarea', deleteConfirmation: 'ยฟEstรกs seguro de que quieres eliminar esta tarea?', @@ -3485,6 +3503,7 @@ export default { screenShareRequest: 'Expensify te estรก invitando a compartir la pantalla', }, search: { + selectMultiple: 'Seleccionar mรบltiples', resultsAreLimited: 'Los resultados de bรบsqueda estรกn limitados.', searchResults: { emptyResults: { @@ -4414,7 +4433,7 @@ export default { 'Parece que estรกs desconectado. Desafortunadamente, Expensify Classic no funciona sin conexiรณn, pero New Expensify sรญ. Si prefieres utilizar Expensify Classic, intรฉntalo de nuevo cuando tengas conexiรณn a internet.', }, listBoundary: { - errorMessage: 'Se produjo un error al cargar mรกs mensajes.', + errorMessage: 'Se ha producido un error al cargar mรกs mensajes.', tryAgain: 'Intรฉntalo de nuevo', }, systemMessage: { @@ -4486,6 +4505,10 @@ export default { title: ({numOfDays}) => `Prueba gratuita: ยก${numOfDays === 1 ? `queda 1 dรญa` : `quedan ${numOfDays} dรญas`}!`, subtitle: 'Aรฑade una tarjeta de pago para seguir utilizando tus funciones favoritas.', }, + trialEnded: { + title: 'Tu prueba gratuita ha terminado', + subtitle: 'Aรฑade una tarjeta de pago para seguir utilizando tus funciones favoritas.', + }, }, cardSection: { title: 'Pago', @@ -4565,7 +4588,7 @@ export default { }, paymentCard: { addPaymentCard: 'Aรฑade tarjeta de pago', - enterPaymentCardDetails: 'Introduce los datos de tu tarjeta de pago.', + enterPaymentCardDetails: 'Introduce los datos de tu tarjeta de pago', security: 'Expensify es PCI-DSS obediente, utiliza cifrado a nivel bancario, y emplea infraestructura redundante para proteger tus datos.', learnMoreAboutSecurity: 'Conozca mรกs sobre nuestra seguridad.', }, diff --git a/src/libs/API/parameters/PolicyReportFieldsReplace.ts b/src/libs/API/parameters/DeletePolicyReportField.ts similarity index 66% rename from src/libs/API/parameters/PolicyReportFieldsReplace.ts rename to src/libs/API/parameters/DeletePolicyReportField.ts index c6d1834f0789..d79e9b07249e 100644 --- a/src/libs/API/parameters/PolicyReportFieldsReplace.ts +++ b/src/libs/API/parameters/DeletePolicyReportField.ts @@ -1,4 +1,4 @@ -type PolicyReportFieldsReplace = { +type DeletePolicyReportField = { policyID: string; /** * Stringified JSON object with type of following structure: @@ -7,4 +7,4 @@ type PolicyReportFieldsReplace = { reportFields: string; }; -export default PolicyReportFieldsReplace; +export default DeletePolicyReportField; diff --git a/src/libs/API/parameters/UpdateFrequentlyUsedEmojisParams.ts b/src/libs/API/parameters/UpdateFrequentlyUsedEmojisParams.ts deleted file mode 100644 index f790ada3aad9..000000000000 --- a/src/libs/API/parameters/UpdateFrequentlyUsedEmojisParams.ts +++ /dev/null @@ -1,3 +0,0 @@ -type UpdateFrequentlyUsedEmojisParams = {value: string}; - -export default UpdateFrequentlyUsedEmojisParams; diff --git a/src/libs/API/parameters/UpdatePolicyCategoryGLCodeParams.ts b/src/libs/API/parameters/UpdatePolicyCategoryGLCodeParams.ts new file mode 100644 index 000000000000..f5e4d4ab7eca --- /dev/null +++ b/src/libs/API/parameters/UpdatePolicyCategoryGLCodeParams.ts @@ -0,0 +1,7 @@ +type UpdatePolicyCategoryGLCodeParams = { + policyID: string; + categoryName: string; + glCode: string; +}; + +export default UpdatePolicyCategoryGLCodeParams; diff --git a/src/libs/API/parameters/UpdatePolicyTagGLCodeParams.ts b/src/libs/API/parameters/UpdatePolicyTagGLCodeParams.ts new file mode 100644 index 000000000000..f720864a8f68 --- /dev/null +++ b/src/libs/API/parameters/UpdatePolicyTagGLCodeParams.ts @@ -0,0 +1,9 @@ +type UpdatePolicyTagGLCodeParams = { + policyID: string; + tagListName: string; + tagListIndex: number; + tagName: string; + glCode: string; +}; + +export default UpdatePolicyTagGLCodeParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index ff8465cfeec7..6510be4940d4 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -64,7 +64,6 @@ export type {default as UpdateAutomaticTimezoneParams} from './UpdateAutomaticTi export type {default as UpdateChatPriorityModeParams} from './UpdateChatPriorityModeParams'; export type {default as UpdateDateOfBirthParams} from './UpdateDateOfBirthParams'; export type {default as UpdateDisplayNameParams} from './UpdateDisplayNameParams'; -export type {default as UpdateFrequentlyUsedEmojisParams} from './UpdateFrequentlyUsedEmojisParams'; export type {default as UpdateGroupChatNameParams} from './UpdateGroupChatNameParams'; export type {default as UpdateGroupChatMemberRolesParams} from './UpdateGroupChatMemberRolesParams'; export type {default as UpdateHomeAddressParams} from './UpdateHomeAddressParams'; @@ -170,6 +169,7 @@ export type {default as CreateWorkspaceCategoriesParams} from './CreateWorkspace export type {default as RenameWorkspaceCategoriesParams} from './RenameWorkspaceCategoriesParams'; export type {default as SetWorkspaceRequiresCategoryParams} from './SetWorkspaceRequiresCategoryParams'; export type {default as DeleteWorkspaceCategoriesParams} from './DeleteWorkspaceCategoriesParams'; +export type {default as UpdatePolicyCategoryGLCodeParams} from './UpdatePolicyCategoryGLCodeParams'; export type {default as SetWorkspaceAutoReportingFrequencyParams} from './SetWorkspaceAutoReportingFrequencyParams'; export type {default as SetWorkspaceAutoReportingMonthlyOffsetParams} from './SetWorkspaceAutoReportingMonthlyOffsetParams'; export type {default as SetWorkspaceApprovalModeParams} from './SetWorkspaceApprovalModeParams'; @@ -211,6 +211,7 @@ export type {default as DeletePolicyTaxesParams} from './DeletePolicyTaxesParams export type {default as UpdatePolicyTaxValueParams} from './UpdatePolicyTaxValueParams'; export type {default as RenamePolicyTagsParams} from './RenamePolicyTagsParams'; export type {default as DeletePolicyTagsParams} from './DeletePolicyTagsParams'; +export type {default as UpdatePolicyTagGLCodeParams} from './UpdatePolicyTagGLCodeParams'; export type {default as AddSubscriptionPaymentCardParams} from './AddSubscriptionPaymentCardParams'; export type {default as SetPolicyCustomTaxNameParams} from './SetPolicyCustomTaxNameParams'; export type {default as SetPolicyForeignCurrencyDefaultParams} from './SetPolicyForeignCurrencyDefaultParams'; @@ -242,7 +243,7 @@ export type {default as DeleteMoneyRequestOnSearchParams} from './DeleteMoneyReq export type {default as HoldMoneyRequestOnSearchParams} from './HoldMoneyRequestOnSearchParams'; export type {default as UnholdMoneyRequestOnSearchParams} from './UnholdMoneyRequestOnSearchParams'; export type {default as UpdateNetSuiteSubsidiaryParams} from './UpdateNetSuiteSubsidiaryParams'; -export type {default as PolicyReportFieldsReplace} from './PolicyReportFieldsReplace'; +export type {default as DeletePolicyReportField} from './DeletePolicyReportField'; export type {default as ConnectPolicyToNetSuiteParams} from './ConnectPolicyToNetSuiteParams'; export type {default as CreateWorkspaceReportFieldParams} from './CreateWorkspaceReportFieldParams'; export type {default as UpdateWorkspaceReportFieldInitialValueParams} from './UpdateWorkspaceReportFieldInitialValueParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index ca284321e3bb..341f7c5033e6 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -57,7 +57,6 @@ const WRITE_COMMANDS = { VALIDATE_LOGIN: 'ValidateLogin', VALIDATE_SECONDARY_LOGIN: 'ValidateSecondaryLogin', UPDATE_PREFERRED_EMOJI_SKIN_TONE: 'UpdatePreferredEmojiSkinTone', - UPDATE_FREQUENTLY_USED_EMOJIS: 'UpdateFrequentlyUsedEmojis', UPDATE_CHAT_PRIORITY_MODE: 'UpdateChatPriorityMode', SET_CONTACT_METHOD_AS_DEFAULT: 'SetContactMethodAsDefault', UPDATE_THEME: 'UpdateTheme', @@ -131,12 +130,14 @@ const WRITE_COMMANDS = { CREATE_POLICY_TAG: 'CreatePolicyTag', RENAME_POLICY_TAG: 'RenamePolicyTag', SET_WORKSPACE_REQUIRES_CATEGORY: 'SetWorkspaceRequiresCategory', + UPDATE_POLICY_CATEGORY_GL_CODE: 'UpdatePolicyCategoryGLCode', DELETE_WORKSPACE_CATEGORIES: 'DeleteWorkspaceCategories', DELETE_POLICY_REPORT_FIELD: 'DeletePolicyReportField', SET_POLICY_TAGS_REQUIRED: 'SetPolicyTagsRequired', SET_POLICY_REQUIRES_TAG: 'SetPolicyRequiresTag', RENAME_POLICY_TAG_LIST: 'RenamePolicyTaglist', DELETE_POLICY_TAGS: 'DeletePolicyTags', + UPDATE_POLICY_TAG_GL_CODE: 'UpdatePolicyTagGLCode', CREATE_TASK: 'CreateTask', CANCEL_TASK: 'CancelTask', EDIT_TASK_ASSIGNEE: 'EditTaskAssignee', @@ -351,7 +352,6 @@ type WriteCommandParameters = { [WRITE_COMMANDS.VALIDATE_LOGIN]: Parameters.ValidateLoginParams; [WRITE_COMMANDS.VALIDATE_SECONDARY_LOGIN]: Parameters.ValidateSecondaryLoginParams; [WRITE_COMMANDS.UPDATE_PREFERRED_EMOJI_SKIN_TONE]: Parameters.UpdatePreferredEmojiSkinToneParams; - [WRITE_COMMANDS.UPDATE_FREQUENTLY_USED_EMOJIS]: Parameters.UpdateFrequentlyUsedEmojisParams; [WRITE_COMMANDS.UPDATE_CHAT_PRIORITY_MODE]: Parameters.UpdateChatPriorityModeParams; [WRITE_COMMANDS.SET_CONTACT_METHOD_AS_DEFAULT]: Parameters.SetContactMethodAsDefaultParams; [WRITE_COMMANDS.UPDATE_THEME]: Parameters.UpdateThemeParams; @@ -426,12 +426,14 @@ type WriteCommandParameters = { [WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY]: Parameters.RenameWorkspaceCategoriesParams; [WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams; [WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES]: Parameters.DeleteWorkspaceCategoriesParams; - [WRITE_COMMANDS.DELETE_POLICY_REPORT_FIELD]: Parameters.PolicyReportFieldsReplace; + [WRITE_COMMANDS.UPDATE_POLICY_CATEGORY_GL_CODE]: Parameters.UpdatePolicyCategoryGLCodeParams; + [WRITE_COMMANDS.DELETE_POLICY_REPORT_FIELD]: Parameters.DeletePolicyReportField; [WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG]: Parameters.SetPolicyRequiresTag; [WRITE_COMMANDS.SET_POLICY_TAGS_REQUIRED]: Parameters.SetPolicyTagsRequired; [WRITE_COMMANDS.RENAME_POLICY_TAG_LIST]: Parameters.RenamePolicyTaglistParams; [WRITE_COMMANDS.CREATE_POLICY_TAG]: Parameters.CreatePolicyTagsParams; [WRITE_COMMANDS.RENAME_POLICY_TAG]: Parameters.RenamePolicyTagsParams; + [WRITE_COMMANDS.UPDATE_POLICY_TAG_GL_CODE]: Parameters.UpdatePolicyTagGLCodeParams; [WRITE_COMMANDS.SET_POLICY_TAGS_ENABLED]: Parameters.SetPolicyTagsEnabled; [WRITE_COMMANDS.DELETE_POLICY_TAGS]: Parameters.DeletePolicyTagsParams; [WRITE_COMMANDS.CREATE_TASK]: Parameters.CreateTaskParams; diff --git a/src/libs/Browser/index.ts b/src/libs/Browser/index.ts index 98ad449c3dd0..aeec4f4def4a 100644 --- a/src/libs/Browser/index.ts +++ b/src/libs/Browser/index.ts @@ -1,4 +1,4 @@ -import type {GetBrowser, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp} from './types'; +import type {GetBrowser, IsChromeIOS, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp} from './types'; const getBrowser: GetBrowser = () => ''; @@ -10,8 +10,10 @@ const isMobileChrome: IsMobileChrome = () => false; const isMobileWebKit: IsMobileWebKit = () => false; +const isChromeIOS: IsChromeIOS = () => false; + const isSafari: IsSafari = () => false; const openRouteInDesktopApp: OpenRouteInDesktopApp = () => {}; -export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isMobileChrome, openRouteInDesktopApp}; +export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isMobileChrome, isChromeIOS, openRouteInDesktopApp}; diff --git a/src/libs/Browser/index.website.ts b/src/libs/Browser/index.website.ts index a83fa1cac70e..b89190dc7f78 100644 --- a/src/libs/Browser/index.website.ts +++ b/src/libs/Browser/index.website.ts @@ -1,7 +1,7 @@ import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import type {GetBrowser, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp} from './types'; +import type {GetBrowser, IsChromeIOS, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp} from './types'; /** * Fetch browser name from UA string @@ -66,6 +66,14 @@ const isMobileWebKit: IsMobileWebKit = () => { return /iP(ad|od|hone)/i.test(userAgent) && /WebKit/i.test(userAgent); }; +/** + * Checks if the requesting user agent is a Chrome browser on an iOS mobile device. + */ +const isChromeIOS: IsChromeIOS = () => { + const userAgent = navigator.userAgent; + return /iP(ad|od|hone)/i.test(userAgent) && /CriOS/i.test(userAgent); +}; + const isSafari: IsSafari = () => getBrowser() === 'safari' || isMobileSafari(); /** @@ -109,4 +117,4 @@ const openRouteInDesktopApp: OpenRouteInDesktopApp = (shortLivedAuthToken = '', } }; -export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isMobileChrome, openRouteInDesktopApp}; +export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isMobileChrome, isChromeIOS, openRouteInDesktopApp}; diff --git a/src/libs/Browser/types.ts b/src/libs/Browser/types.ts index 25f305953c87..cb242d3729aa 100644 --- a/src/libs/Browser/types.ts +++ b/src/libs/Browser/types.ts @@ -8,8 +8,10 @@ type IsMobileChrome = () => boolean; type IsMobileWebKit = () => boolean; +type IsChromeIOS = () => boolean; + type IsSafari = () => boolean; type OpenRouteInDesktopApp = (shortLivedAuthToken?: string, email?: string) => void; -export type {GetBrowser, IsMobile, IsMobileSafari, IsMobileChrome, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp}; +export type {GetBrowser, IsMobile, IsMobileSafari, IsMobileChrome, IsMobileWebKit, IsSafari, IsChromeIOS, OpenRouteInDesktopApp}; diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index 862b0ae5e928..c3b80797d750 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -125,9 +125,11 @@ function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURR style: 'currency', currency: currencyWithFallback, - // We are forcing the number of decimals because we override the default number of decimals in the backend for RSD + // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies // See: https://github.com/Expensify/PHP-Libs/pull/834 - minimumFractionDigits: currency === 'RSD' ? getCurrencyDecimals(currency) : undefined, + minimumFractionDigits: getCurrencyDecimals(currency), + // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places. + maximumFractionDigits: 2, }); } @@ -175,9 +177,11 @@ function convertToDisplayStringWithoutCurrency(amountInCents: number, currency: style: 'currency', currency, - // We are forcing the number of decimals because we override the default number of decimals in the backend for RSD + // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies // See: https://github.com/Expensify/PHP-Libs/pull/834 - minimumFractionDigits: currency === 'RSD' ? getCurrencyDecimals(currency) : undefined, + minimumFractionDigits: getCurrencyDecimals(currency), + // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places. + maximumFractionDigits: 2, }) .filter((x) => x.type !== 'currency') .filter((x) => x.type !== 'literal' || x.value.trim().length !== 0) diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index 6fb5725addfc..007a892c048e 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -1,4 +1,3 @@ -import {getUnixTime} from 'date-fns'; import {Str} from 'expensify-common'; import memoize from 'lodash/memoize'; import Onyx from 'react-native-onyx'; @@ -235,37 +234,6 @@ function mergeEmojisWithFrequentlyUsedEmojis(emojis: PickerEmojis): EmojiPickerL return addSpacesToEmojiCategories(mergedEmojis); } -/** - * Get the updated frequently used emojis list by usage - */ -function getFrequentlyUsedEmojis(newEmoji: Emoji | Emoji[]): FrequentlyUsedEmoji[] { - let frequentEmojiList = [...frequentlyUsedEmojis]; - - const maxFrequentEmojiCount = CONST.EMOJI_FREQUENT_ROW_COUNT * CONST.EMOJI_NUM_PER_ROW - 1; - - const currentTimestamp = getUnixTime(new Date()); - (Array.isArray(newEmoji) ? [...newEmoji] : [newEmoji]).forEach((emoji) => { - let currentEmojiCount = 1; - const emojiIndex = frequentEmojiList.findIndex((e) => e.code === emoji.code); - if (emojiIndex >= 0) { - currentEmojiCount = frequentEmojiList[emojiIndex].count + 1; - frequentEmojiList.splice(emojiIndex, 1); - } - - const updatedEmoji = {...Emojis.emojiCodeTableWithSkinTones[emoji.code], count: currentEmojiCount, lastUpdatedAt: currentTimestamp}; - - // We want to make sure the current emoji is added to the list - // Hence, we take one less than the current frequent used emojis - frequentEmojiList = frequentEmojiList.slice(0, maxFrequentEmojiCount); - frequentEmojiList.push(updatedEmoji); - - // Sort the list by count and lastUpdatedAt in descending order - frequentEmojiList.sort((a, b) => b.count - a.count || b.lastUpdatedAt - a.lastUpdatedAt); - }); - - return frequentEmojiList; -} - /** * Given an emoji item object, return an emoji code based on its type. */ @@ -601,7 +569,6 @@ export { getLocalizedEmojiName, getHeaderEmojis, mergeEmojisWithFrequentlyUsedEmojis, - getFrequentlyUsedEmojis, containsOnlyEmojis, replaceEmojis, suggestEmojis, diff --git a/src/libs/Environment/Environment.ts b/src/libs/Environment/Environment.ts index 1f5a391d3b13..c343788bed05 100644 --- a/src/libs/Environment/Environment.ts +++ b/src/libs/Environment/Environment.ts @@ -38,6 +38,13 @@ function isDevelopment(): boolean { return (Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.DEV; } +/** + * Are we running the app in staging? + */ +function isStaging(): boolean { + return (Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.STAGING; +} + /** * Are we running the app in production? */ @@ -76,4 +83,4 @@ function getSpotnanaEnvironmentTMCID(): Promise { return getEnvironment().then((environment) => SPOTNANA_ENVIRONMENT_TMC_ID[environment]); } -export {getEnvironment, isInternalTestBuild, isDevelopment, isProduction, getEnvironmentURL, getOldDotEnvironmentURL, getTravelDotEnvironmentURL, getSpotnanaEnvironmentTMCID}; +export {getEnvironment, isInternalTestBuild, isDevelopment, isStaging, isProduction, getEnvironmentURL, getOldDotEnvironmentURL, getTravelDotEnvironmentURL, getSpotnanaEnvironmentTMCID}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 0b94972b2aa9..ce4028b87ea8 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -237,6 +237,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/members/WorkspaceOwnerChangeErrorPage').default, [SCREENS.WORKSPACE.CATEGORY_CREATE]: () => require('../../../../pages/workspace/categories/CreateCategoryPage').default, [SCREENS.WORKSPACE.CATEGORY_EDIT]: () => require('../../../../pages/workspace/categories/EditCategoryPage').default, + [SCREENS.WORKSPACE.CATEGORY_GL_CODE]: () => require('../../../../pages/workspace/categories/CategoryGLCodePage').default, [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: () => require('../../../../pages/workspace/distanceRates/CreateDistanceRatePage').default, [SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage').default, [SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRateDetailsPage').default, @@ -250,6 +251,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/tags/WorkspaceEditTagsPage').default, [SCREENS.WORKSPACE.TAG_CREATE]: () => require('../../../../pages/workspace/tags/WorkspaceCreateTagPage').default, [SCREENS.WORKSPACE.TAG_EDIT]: () => require('../../../../pages/workspace/tags/EditTagPage').default, + [SCREENS.WORKSPACE.TAG_GL_CODE]: () => require('../../../../pages/workspace/tags/TagGLCodePage').default, [SCREENS.WORKSPACE.TAXES_SETTINGS]: () => require('../../../../pages/workspace/taxes/WorkspaceTaxesSettingsPage').default, [SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME]: () => require('../../../../pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName').default, [SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT]: () => require('../../../../pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency').default, @@ -396,6 +398,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage').default, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT]: () => require('../../../../pages/workspace/accounting/intacct/advanced/SageIntacctPaymentAccountPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.RECONCILIATION_ACCOUNT_SETTINGS]: () => require('../../../../pages/workspace/accounting/ReconciliationAccountSettingsPage').default, [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: () => require('../../../../pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage').default, [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET]: () => require('../../../../pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage').default, [SCREENS.WORKSPACE.TAX_EDIT]: () => require('../../../../pages/workspace/taxes/WorkspaceEditTaxPage').default, @@ -408,13 +411,13 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Subscription/PaymentCard/ChangeBillingCurrency').default, [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: () => require('../../../../pages/settings/Subscription/PaymentCard').default, [SCREENS.SETTINGS.ADD_PAYMENT_CARD_CHANGE_CURRENCY]: () => require('../../../../pages/settings/PaymentCard/ChangeCurrency').default, - [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: () => require('../../../../pages/workspace/reportFields/CreateReportFieldPage').default, - [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ReportFieldSettingsPage').default, - [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: () => require('../../../../pages/workspace/reportFields/ReportFieldListValuesPage').default, - [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldAddListValuePage').default, - [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ReportFieldValueSettingsPage').default, - [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldInitialValuePage').default, - [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldEditValuePage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: () => require('../../../../pages/workspace/reportFields/CreateReportFieldsPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ReportFieldsSettingsPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: () => require('../../../../pages/workspace/reportFields/ReportFieldsListValuesPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldsAddListValuePage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ReportFieldsValueSettingsPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldsInitialValuePage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldsEditValuePage').default, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_IMPORT]: () => require('../../../../pages/workspace/accounting/intacct/import/SageIntacctImportPage').default, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_TOGGLE_MAPPING]: () => require('../../../../pages/workspace/accounting/intacct/import/SageIntacctToggleMappingsPage').default, diff --git a/src/libs/Navigation/AppNavigator/Navigators/ActiveBottomTabRouteContext.ts b/src/libs/Navigation/AppNavigator/Navigators/ActiveBottomTabRouteContext.ts new file mode 100644 index 000000000000..ce55da8e4bde --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/ActiveBottomTabRouteContext.ts @@ -0,0 +1,6 @@ +import React from 'react'; +import type {BottomTabScreensParamList, NavigationPartialRoute} from '@libs/Navigation/types'; + +const ActiveBottomTabRouteContext = React.createContext | undefined>(undefined); + +export default ActiveBottomTabRouteContext; diff --git a/src/libs/Navigation/AppNavigator/Navigators/ActiveRouteContext.ts b/src/libs/Navigation/AppNavigator/Navigators/ActiveRouteContext.ts deleted file mode 100644 index c319aeca3e04..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/ActiveRouteContext.ts +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react'; -import type {AuthScreensParamList, NavigationPartialRoute} from '@libs/Navigation/types'; - -const ActiveRouteContext = React.createContext | undefined>(undefined); - -export default ActiveRouteContext; diff --git a/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx index 46212f3bc41f..372c1ce478cc 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx @@ -2,13 +2,15 @@ import {useNavigationState} from '@react-navigation/native'; import type {StackNavigationOptions} from '@react-navigation/stack'; import React from 'react'; import createCustomBottomTabNavigator from '@libs/Navigation/AppNavigator/createCustomBottomTabNavigator'; +import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; -import type {BottomTabNavigatorParamList, CentralPaneName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; +import type {BottomTabNavigatorParamList, BottomTabScreensParamList, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; +import {isBottomTabName} from '@libs/NavigationUtils'; import SidebarScreen from '@pages/home/sidebar/SidebarScreen'; import SearchPageBottomTab from '@pages/Search/SearchPageBottomTab'; import SCREENS from '@src/SCREENS'; import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; -import ActiveRouteContext from './ActiveRouteContext'; +import ActiveBottomTabRouteContext from './ActiveBottomTabRouteContext'; const loadInitialSettingsPage = () => require('../../../../pages/settings/InitialSettingsPage').default; const Tab = createCustomBottomTabNavigator(); @@ -19,10 +21,22 @@ const screenOptions: StackNavigationOptions = { }; function BottomTabNavigator() { - const activeRoute = useNavigationState | undefined>(getTopmostCentralPaneRoute); + const activeRoute = useNavigationState | undefined>((state) => { + if (!state) { + return undefined; + } + let route: NavigationPartialRoute | undefined; + for (const selector of [getTopmostBottomTabRoute, getTopmostCentralPaneRoute]) { + const selectedRoute = selector(state); + if (isBottomTabName(selectedRoute?.name)) { + route = selectedRoute as NavigationPartialRoute; + } + } + return route; + }); return ( - + - + ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx index 61adcd77da76..29a2205b2e37 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx @@ -4,11 +4,9 @@ import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; import FocusTrapForScreens from '@components/FocusTrap/FocusTrapForScreen'; -import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useOnboardingLayout from '@hooks/useOnboardingLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector'; import OnboardingModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/OnboardingModalNavigatorScreenOptions'; import Navigation from '@libs/Navigation/Navigation'; import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types'; @@ -28,11 +26,15 @@ function OnboardingModalNavigator() { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useOnboardingLayout(); const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { - selector: hasCompletedGuidedSetupFlowSelector, + selector: (onboarding) => { + // onboarding is an array for old accounts and accounts created from olddot + if (Array.isArray(onboarding)) { + return true; + } + return onboarding?.hasCompletedGuidedSetupFlow; + }, }); - useDisableModalDismissOnEscape(); - useEffect(() => { if (!hasCompletedGuidedSetupFlow) { return; diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx index 2e1c4c012156..556365b473c3 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx @@ -3,10 +3,12 @@ import React, {useCallback, useEffect} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; +import type {TupleToUnion} from 'type-fest'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import {PressableWithFeedback} from '@components/Pressable'; import Tooltip from '@components/Tooltip'; +import useActiveBottomTabRoute from '@hooks/useActiveBottomTabRoute'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; @@ -15,11 +17,9 @@ import * as Session from '@libs/actions/Session'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; -import linkingConfig from '@libs/Navigation/linkingConfig'; -import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath'; -import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import Navigation from '@libs/Navigation/Navigation'; import type {RootStackParamList, State} from '@libs/Navigation/types'; -import {isCentralPaneName} from '@libs/NavigationUtils'; +import {isCentralPaneName, isHomeTabName, isSearchTabName, isSettingTabName} from '@libs/NavigationUtils'; import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils'; import BottomTabAvatar from '@pages/home/sidebar/BottomTabAvatar'; import BottomTabBarFloatingActionButton from '@pages/home/sidebar/BottomTabBarFloatingActionButton'; @@ -42,6 +42,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps const styles = useThemeStyles(); const {translate} = useLocalize(); const navigation = useNavigation(); + const HOME_SCREENS = [SCREENS.HOME, SCREENS.REPORT]; const {activeWorkspaceID: contextActiveWorkspaceID} = useActiveWorkspace(); const activeWorkspaceID = sessionStorage.getItem(CONST.SESSION_STORAGE_KEYS.ACTIVE_WORKSPACE_ID) ?? contextActiveWorkspaceID; @@ -55,12 +56,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps return; } - Welcome.isOnboardingFlowCompleted({ - onNotCompleted: () => { - const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT, linkingConfig.config); - navigationRef.resetRoot(adaptedState); - }, - }); + Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_ROOT)}); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isLoadingApp]); @@ -76,6 +72,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps return topmostBottomTabRoute?.name ?? SCREENS.HOME; }); + const activeBottomTabRoute = useActiveBottomTabRoute(); const chatTabBrickRoad = getChatTabBrickRoad(activeWorkspaceID); const navigateToChats = useCallback(() => { @@ -99,7 +96,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps ) ? theme.iconMenu : theme.icon} width={variables.iconBottomBar} height={variables.iconBottomBar} /> @@ -112,7 +109,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps { - if (currentTabName === SCREENS.SEARCH.BOTTOM_TAB || currentTabName === SCREENS.SEARCH.CENTRAL_PANE) { + if (isSearchTabName(activeBottomTabRoute?.name)) { return; } interceptAnonymousUser(() => Navigation.navigate(ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.ALL))); @@ -125,14 +122,14 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps - + diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts index 5b3cefb63a2d..a1768df5e0d6 100644 --- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts @@ -1,16 +1,13 @@ -import type {CommonActions, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; -import {findFocusedRoute, getPathFromState, StackRouter} from '@react-navigation/native'; +import type {RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; +import {getPathFromState, StackRouter} from '@react-navigation/native'; import type {ParamListBase} from '@react-navigation/routers'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; -import * as Localize from '@libs/Localize'; import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; import linkingConfig from '@libs/Navigation/linkingConfig'; import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath'; import type {NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; -import {isCentralPaneName, isOnboardingFlowName} from '@libs/NavigationUtils'; -import * as Welcome from '@userActions/Welcome'; -import CONST from '@src/CONST'; +import {isCentralPaneName} from '@libs/NavigationUtils'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import type {ResponsiveStackNavigatorRouterOptions} from './types'; @@ -100,23 +97,6 @@ function compareAndAdaptState(state: StackNavigationState) { } } -function shouldPreventReset(state: StackNavigationState, action: CommonActions.Action | StackActionType) { - if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) { - return false; - } - const currentFocusedRoute = findFocusedRoute(state); - const targetFocusedRoute = findFocusedRoute(action?.payload); - - // We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen - if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) { - Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton')); - // We reset the URL as the browser sets it in a way that doesn't match the navigation state - // eslint-disable-next-line no-restricted-globals - history.replaceState({}, '', getPathFromState(state, linkingConfig.config)); - return true; - } -} - function CustomRouter(options: ResponsiveStackNavigatorRouterOptions) { const stackRouter = StackRouter(options); @@ -127,12 +107,6 @@ function CustomRouter(options: ResponsiveStackNavigatorRouterOptions) { const state = stackRouter.getRehydratedState(partialState, {routeNames, routeParamList, routeGetIdList}); return state; }, - getStateForAction(state: StackNavigationState, action: CommonActions.Action | StackActionType, configOptions: RouterConfigOptions) { - if (shouldPreventReset(state, action)) { - return state; - } - return stackRouter.getStateForAction(state, action, configOptions); - }, }; } diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index a225831b56ff..63792be4f79f 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -1,7 +1,6 @@ import type {NavigationState} from '@react-navigation/native'; import {DefaultTheme, findFocusedRoute, NavigationContainer} from '@react-navigation/native'; import React, {useContext, useEffect, useMemo, useRef} from 'react'; -import {useOnyx} from 'react-native-onyx'; import HybridAppMiddleware from '@components/HybridAppMiddleware'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; @@ -9,14 +8,11 @@ import useCurrentReportID from '@hooks/useCurrentReportID'; import useTheme from '@hooks/useTheme'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {FSPage} from '@libs/Fullstory'; -import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector'; import Log from '@libs/Log'; import {getPathFromURL} from '@libs/Url'; import {updateLastVisitedPath} from '@userActions/App'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; -import ROUTES from '@src/ROUTES'; import AppNavigator from './AppNavigator'; import getPolicyIDFromState from './getPolicyIDFromState'; import linkingConfig from './linkingConfig'; @@ -81,37 +77,25 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N const {isSmallScreenWidth} = useWindowDimensions(); const {setActiveWorkspaceID} = useActiveWorkspace(); - const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { - selector: hasCompletedGuidedSetupFlowSelector, - }); + const initialState = useMemo( + () => { + if (!lastVisitedPath) { + return undefined; + } - const initialState = useMemo(() => { - // If the user haven't completed the flow, we want to always redirect them to the onboarding flow. - if (!hasCompletedGuidedSetupFlow) { - const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT, linkingConfig.config); - return adaptedState; - } - - // If there is no lastVisitedPath, we can do early return. We won't modify the default behavior. - if (!lastVisitedPath) { - return undefined; - } - - const path = initialUrl ? getPathFromURL(initialUrl) : null; + const path = initialUrl ? getPathFromURL(initialUrl) : null; - // If the user opens the root of app "/" it will be parsed to empty string "". - // If the path is defined and different that empty string we don't want to modify the default behavior. - if (path) { - return; - } - - // Otherwise we want to redirect the user to the last visited path. - const {adaptedState} = getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config); - return adaptedState; + // For non-nullable paths we don't want to set initial state + if (path) { + return; + } - // The initialState value is relevant only on the first render. + const {adaptedState} = getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config); + return adaptedState; + }, // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, []); + [], + ); // https://reactnavigation.org/docs/themes const navigationTheme = useMemo( 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 54804a495754..0833d6c9f195 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -112,6 +112,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT, SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADVANCED, SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT, + SCREENS.WORKSPACE.ACCOUNTING.RECONCILIATION_ACCOUNT_SETTINGS, ], [SCREENS.WORKSPACE.TAXES]: [ SCREENS.WORKSPACE.TAXES_SETTINGS, @@ -131,8 +132,15 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.TAG_SETTINGS, SCREENS.WORKSPACE.TAG_EDIT, SCREENS.WORKSPACE.TAG_LIST_VIEW, + SCREENS.WORKSPACE.TAG_GL_CODE, + ], + [SCREENS.WORKSPACE.CATEGORIES]: [ + SCREENS.WORKSPACE.CATEGORY_CREATE, + SCREENS.WORKSPACE.CATEGORY_SETTINGS, + SCREENS.WORKSPACE.CATEGORIES_SETTINGS, + SCREENS.WORKSPACE.CATEGORY_EDIT, + SCREENS.WORKSPACE.CATEGORY_GL_CODE, ], - [SCREENS.WORKSPACE.CATEGORIES]: [SCREENS.WORKSPACE.CATEGORY_CREATE, SCREENS.WORKSPACE.CATEGORY_SETTINGS, SCREENS.WORKSPACE.CATEGORIES_SETTINGS, SCREENS.WORKSPACE.CATEGORY_EDIT], [SCREENS.WORKSPACE.DISTANCE_RATES]: [ SCREENS.WORKSPACE.CREATE_DISTANCE_RATE, SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS, @@ -143,7 +151,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { ], [SCREENS.WORKSPACE.REPORT_FIELDS]: [ SCREENS.WORKSPACE.REPORT_FIELDS_CREATE, - SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS, + SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS, SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES, SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE, SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index c0d1a79f635f..9d5f1355b74c 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -447,6 +447,7 @@ const config: LinkingOptions['config'] = { }, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADVANCED]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ADVANCED.route}, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PAYMENT_ACCOUNT.route}, + [SCREENS.WORKSPACE.ACCOUNTING.RECONCILIATION_ACCOUNT_SETTINGS]: {path: ROUTES.WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS.route}, [SCREENS.WORKSPACE.DESCRIPTION]: { path: ROUTES.WORKSPACE_PROFILE_DESCRIPTION.route, }, @@ -519,6 +520,12 @@ const config: LinkingOptions['config'] = { categoryName: (categoryName: string) => decodeURIComponent(categoryName), }, }, + [SCREENS.WORKSPACE.CATEGORY_GL_CODE]: { + path: ROUTES.WORKSPACE_CATEGORY_GL_CODE.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: { path: ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.route, }, @@ -556,6 +563,13 @@ const config: LinkingOptions['config'] = { tagName: (tagName: string) => decodeURIComponent(tagName), }, }, + [SCREENS.WORKSPACE.TAG_GL_CODE]: { + path: ROUTES.WORKSPACE_TAG_GL_CODE.route, + parse: { + orderWeight: Number, + tagName: (tagName: string) => decodeURIComponent(tagName), + }, + }, [SCREENS.WORKSPACE.TAG_SETTINGS]: { path: ROUTES.WORKSPACE_TAG_SETTINGS.route, parse: { @@ -585,34 +599,34 @@ const config: LinkingOptions['config'] = { path: ROUTES.WORKSPACE_CREATE_REPORT_FIELD.route, }, [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: { - path: ROUTES.WORKSPACE_REPORT_FIELD_LIST_VALUES.route, + path: ROUTES.WORKSPACE_REPORT_FIELDS_LIST_VALUES.route, parse: { reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), }, }, [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: { - path: ROUTES.WORKSPACE_REPORT_FIELD_ADD_VALUE.route, + path: ROUTES.WORKSPACE_REPORT_FIELDS_ADD_VALUE.route, parse: { reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), }, }, [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: { - path: ROUTES.WORKSPACE_REPORT_FIELD_VALUE_SETTINGS.route, + path: ROUTES.WORKSPACE_REPORT_FIELDS_VALUE_SETTINGS.route, parse: { reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), }, }, [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: { - path: ROUTES.WORKSPACE_REPORT_FIELD_EDIT_VALUE.route, + path: ROUTES.WORKSPACE_REPORT_FIELDS_EDIT_VALUE.route, }, - [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: { - path: ROUTES.WORKSPACE_REPORT_FIELD_SETTINGS.route, + [SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS]: { + path: ROUTES.WORKSPACE_REPORT_FIELDS_SETTINGS.route, parse: { reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), }, }, [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: { - path: ROUTES.WORKSPACE_EDIT_REPORT_FIELD_INITIAL_VALUE.route, + path: ROUTES.WORKSPACE_EDIT_REPORT_FIELDS_INITIAL_VALUE.route, parse: { reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index fc67fe6b8cc0..a9bc53b01a2c 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -79,6 +79,12 @@ type CentralPaneScreensParamList = { [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: undefined; }; +type SearchNavigatorParamList = { + [SCREENS.SEARCH.BOTTOM_TAB]: undefined; + [SCREENS.SEARCH.CENTRAL_PANE]: undefined; + [SCREENS.SEARCH.REPORT_RHP]: undefined; +}; + type SettingsNavigatorParamList = { [SCREENS.SETTINGS.SHARE_CODE]: undefined; [SCREENS.SETTINGS.PROFILE.ROOT]: undefined; @@ -201,6 +207,10 @@ type SettingsNavigatorParamList = { categoryName: string; backTo?: Routes; }; + [SCREENS.WORKSPACE.CATEGORY_GL_CODE]: { + policyID: string; + categoryName: string; + }; [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: { policyID: string; categoryName: string; @@ -255,6 +265,11 @@ type SettingsNavigatorParamList = { orderWeight: number; tagName: string; }; + [SCREENS.WORKSPACE.TAG_GL_CODE]: { + policyID: string; + orderWeight: number; + tagName: string; + }; [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: undefined; [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: { canChangeSize: 0 | 1; @@ -294,7 +309,7 @@ type SettingsNavigatorParamList = { policyID: string; valueIndex: number; }; - [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: { + [SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS]: { policyID: string; reportFieldID: string; }; @@ -588,6 +603,10 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT]: { policyID: string; }; + [SCREENS.WORKSPACE.ACCOUNTING.RECONCILIATION_ACCOUNT_SETTINGS]: { + policyID: string; + connection: ValueOf; + }; [SCREENS.GET_ASSISTANCE]: { backTo: Routes; }; @@ -1106,6 +1125,8 @@ type ExplanationModalNavigatorParamList = { [SCREENS.EXPLANATION_MODAL.ROOT]: undefined; }; +type BottomTabScreensParamList = {[SCREENS.HOME]: undefined; [SCREENS.REPORT]: undefined} & SearchNavigatorParamList & SettingsNavigatorParamList; + type BottomTabNavigatorParamList = { [SCREENS.HOME]: {policyID?: string}; [SCREENS.SEARCH.BOTTOM_TAB]: { @@ -1204,12 +1225,12 @@ type RootStackParamList = PublicScreensParamList & AuthScreensParamList & LeftMo type BottomTabName = keyof BottomTabNavigatorParamList; +type BottomTabScreenName = keyof BottomTabScreensParamList; + type FullScreenName = keyof FullScreenNavigatorParamList; type CentralPaneName = keyof CentralPaneScreensParamList; -type OnboardingFlowName = keyof OnboardingModalNavigatorParamList; - type SwitchPolicyIDParams = { policyID?: string; route?: Routes; @@ -1223,6 +1244,8 @@ export type { CentralPaneName, BackToParams, BottomTabName, + BottomTabScreenName, + BottomTabScreensParamList, BottomTabNavigatorParamList, DetailsNavigatorParamList, EditRequestNavigatorParamList, @@ -1240,7 +1263,6 @@ export type { NewChatNavigatorParamList, NewTaskNavigatorParamList, OnboardingModalNavigatorParamList, - OnboardingFlowName, ParticipantsNavigatorParamList, PrivateNotesNavigatorParamList, ProfileNavigatorParamList, @@ -1254,6 +1276,7 @@ export type { RoomInviteNavigatorParamList, RoomMembersNavigatorParamList, RootStackParamList, + SearchNavigatorParamList, SettingsNavigatorParamList, SignInNavigatorParamList, FeatureTrainingNavigatorParamList, diff --git a/src/libs/NavigationUtils.ts b/src/libs/NavigationUtils.ts index aa26268977a2..34e9df954688 100644 --- a/src/libs/NavigationUtils.ts +++ b/src/libs/NavigationUtils.ts @@ -1,7 +1,9 @@ import cloneDeep from 'lodash/cloneDeep'; +import type {TupleToUnion} from 'type-fest'; +import {flattenObject} from '@src/languages/translations'; import SCREENS from '@src/SCREENS'; import getTopmostBottomTabRoute from './Navigation/getTopmostBottomTabRoute'; -import type {CentralPaneName, OnboardingFlowName, RootStackParamList, State} from './Navigation/types'; +import type {CentralPaneName, RootStackParamList, State} from './Navigation/types'; const CENTRAL_PANE_SCREEN_NAMES = new Set([ SCREENS.SETTINGS.WORKSPACES, @@ -17,8 +19,6 @@ const CENTRAL_PANE_SCREEN_NAMES = new Set([ SCREENS.REPORT, ]); -const ONBOARDING_SCREEN_NAMES = new Set([SCREENS.ONBOARDING.PERSONAL_DETAILS, SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK, SCREENS.ONBOARDING_MODAL.ONBOARDING]); - function isCentralPaneName(screen: string | undefined): screen is CentralPaneName { if (!screen) { return false; @@ -27,14 +27,6 @@ function isCentralPaneName(screen: string | undefined): screen is CentralPaneNam return CENTRAL_PANE_SCREEN_NAMES.has(screen as CentralPaneName); } -function isOnboardingFlowName(screen: string | undefined): screen is OnboardingFlowName { - if (!screen) { - return false; - } - - return ONBOARDING_SCREEN_NAMES.has(screen as OnboardingFlowName); -} - const removePolicyIDParamFromState = (state: State) => { const stateCopy = cloneDeep(state); const bottomTabRoute = getTopmostBottomTabRoute(stateCopy); @@ -44,4 +36,43 @@ const removePolicyIDParamFromState = (state: State) => { return stateCopy; }; -export {isCentralPaneName, removePolicyIDParamFromState, isOnboardingFlowName}; +const SETTINGS_SCREENS = Object.values(flattenObject(SCREENS.SETTINGS)); +const SEARCH_SCREENS = Object.values(flattenObject(SCREENS.SEARCH)); +const HOME_SCREENS = [SCREENS.HOME, SCREENS.REPORT]; +const BOTTOM_TAB_SCREEN_NAMES = new Set([...SETTINGS_SCREENS, ...SEARCH_SCREENS, ...HOME_SCREENS]); + +const SETTINGS_TAB_SCREEN_NAMES = new Set(SETTINGS_SCREENS); + +const SEARCH_TAB_SCREEN_NAMES = new Set(SEARCH_SCREENS); + +const HOME_SCREEN_NAMES = new Set(HOME_SCREENS); + +function isBottomTabName(screen: TupleToUnion | undefined) { + if (!screen) { + return false; + } + return BOTTOM_TAB_SCREEN_NAMES.has(screen); +} + +function isSettingTabName(screen: TupleToUnion | undefined) { + if (!screen) { + return false; + } + return SETTINGS_TAB_SCREEN_NAMES.has(screen); +} + +function isSearchTabName(screen: TupleToUnion | undefined) { + if (!screen) { + return false; + } + return SEARCH_TAB_SCREEN_NAMES.has(screen); +} + +function isHomeTabName(screen: TupleToUnion | undefined) { + if (!screen) { + return false; + } + return HOME_SCREEN_NAMES.has(screen); +} + +export {isCentralPaneName, isBottomTabName, isSearchTabName, isSettingTabName, isHomeTabName, removePolicyIDParamFromState}; diff --git a/src/libs/NetworkConnection.ts b/src/libs/NetworkConnection.ts index acdb982c729a..bc1b82b21bc4 100644 --- a/src/libs/NetworkConnection.ts +++ b/src/libs/NetworkConnection.ts @@ -11,7 +11,6 @@ import AppStateMonitor from './AppStateMonitor'; import Log from './Log'; let isOffline = false; -let hasPendingNetworkCheck = false; type NetworkStatus = ValueOf; type ResponseJSON = { @@ -191,13 +190,8 @@ function clearReconnectionCallbacks() { * Refresh NetInfo state. */ function recheckNetworkConnection() { - if (hasPendingNetworkCheck) { - return; - } - Log.info('[NetworkConnection] recheck NetInfo'); - hasPendingNetworkCheck = true; - NetInfo.refresh().finally(() => (hasPendingNetworkCheck = false)); + NetInfo.refresh(); } export default { diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 73b04742878a..97c9a0965f1d 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -725,6 +725,8 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails lastMessageTextFromReport = ReportUtils.getIOUSubmittedMessage(reportID); } else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED) { lastMessageTextFromReport = ReportUtils.getIOUApprovedMessage(reportID); + } else if (ReportActionUtils.isActionableAddPaymentCard(lastReportAction)) { + lastMessageTextFromReport = ReportActionUtils.getReportActionMessageText(lastReportAction); } return lastMessageTextFromReport || (report?.lastMessageText ?? ''); diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 140916349c53..cccf477d5550 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -318,6 +318,10 @@ function isPaidGroupPolicy(policy: OnyxEntry): boolean { return policy?.type === CONST.POLICY.TYPE.TEAM || policy?.type === CONST.POLICY.TYPE.CORPORATE; } +function isControlPolicy(policy: OnyxEntry): boolean { + return policy?.type === CONST.POLICY.TYPE.CORPORATE; +} + function isTaxTrackingEnabled(isPolicyExpenseChat: boolean, policy: OnyxEntry, isDistanceRequest: boolean): boolean { const distanceUnit = getCustomUnit(policy); const customUnitID = distanceUnit?.customUnitID ?? 0; @@ -806,6 +810,7 @@ export { getIntegrationLastSuccessfulDate, getCurrentConnectionName, getCustomersOrJobsLabelNetSuite, + isControlPolicy, isNetSuiteCustomSegmentRecord, getNameFromNetSuiteCustomField, isNetSuiteCustomFieldPropertyEditable, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 3f8acd0e06fe..01ca1c46b1fa 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -260,6 +260,18 @@ function isRoomChangeLogAction(reportAction: OnyxEntry): reportAct return isActionOfType(reportAction, ...Object.values(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG)); } +function isInviteOrRemovedAction( + reportAction: OnyxInputOrEntry, +): reportAction is ReportAction> { + return isActionOfType( + reportAction, + CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, + CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.REMOVE_FROM_ROOM, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INVITE_TO_ROOM, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_FROM_ROOM, + ); +} + /** * Returns whether the comment is a thread parent message/the first message in a thread */ @@ -1396,6 +1408,14 @@ function getTrackExpenseActionableWhisper(transactionID: string, chatReportID: s return Object.values(chatReportActions).find((action: ReportAction) => isActionableTrackExpense(action) && getOriginalMessage(action)?.transactionID === transactionID); } +/** + * Checks if a given report action corresponds to a add payment card action. + * @param reportAction + */ +function isActionableAddPaymentCard(reportAction: OnyxEntry): reportAction is ReportAction { + return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_ADD_PAYMENT_CARD; +} + export { extractLinksFromMessageHtml, getDismissedViolationMessageText, @@ -1474,6 +1494,7 @@ export { isClosedAction, isRenamedAction, isRoomChangeLogAction, + isInviteOrRemovedAction, isChronosOOOListAction, isAddCommentAction, isPolicyChangeLogAction, @@ -1481,6 +1502,7 @@ export { isTripPreview, getIOUActionForReportID, getFilteredForOneTransactionView, + isActionableAddPaymentCard, }; export type {LastVisibleMessage}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index da4db2155601..ad600fe43104 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -40,6 +40,7 @@ import type { UserWallet, } from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; +import type Onboarding from '@src/types/onyx/Onboarding'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {OriginalMessageChangeLog, PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type {Status} from '@src/types/onyx/PersonalDetails'; @@ -52,6 +53,7 @@ import AccountUtils from './AccountUtils'; import * as IOU from './actions/IOU'; import * as PolicyActions from './actions/Policy/Policy'; import * as store from './actions/ReimbursementAccount/store'; +import * as SessionUtils from './actions/Session'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; import {hasValidDraftComment} from './DraftCommentUtils'; @@ -435,6 +437,7 @@ type OptionData = { shouldShowAmountInput?: boolean; amountInputProps?: MoneyRequestAmountInputProps; tabIndex?: 0 | -1; + isConciergeChat?: boolean; } & Report; type OnyxDataTaskAssigneeChat = { @@ -569,6 +572,12 @@ Onyx.connect({ }, }); +let onboarding: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_ONBOARDING, + callback: (value) => (onboarding = value), +}); + function getCurrentUserAvatar(): AvatarSource | undefined { return currentUserPersonalDetails?.avatar; } @@ -605,6 +614,13 @@ function isDraftReport(reportID: string | undefined): boolean { return !!draftReport; } +/** + * Returns the report + */ +function getReport(reportID: string): OnyxEntry { + return ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; +} + /** * Returns the parentReport if the given report is a thread */ @@ -1063,7 +1079,7 @@ function isSystemChat(report: OnyxEntry): boolean { * Only returns true if this is our main 1:1 DM report with Concierge. */ function isConciergeChatReport(report: OnyxInputOrEntry): boolean { - const participantAccountIDs = Object.keys(report?.participants ?? {}); + const participantAccountIDs = Object.keys(report?.participants ?? {}).filter((accountID) => Number(accountID) !== currentUserAccountID); return participantAccountIDs.length === 1 && Number(participantAccountIDs[0]) === CONST.ACCOUNT_ID.CONCIERGE && !isChatThread(report); } @@ -1469,10 +1485,10 @@ function isOneTransactionReport(reportID: string): boolean { /** * Checks if a report is a transaction thread associated with a report that has only one transaction */ -function isOneTransactionThread(reportID: string, parentReportID: string): boolean { +function isOneTransactionThread(reportID: string, parentReportID: string, threadParentReportAction: OnyxEntry): boolean { const parentReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`] ?? ([] as ReportAction[]); const transactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(parentReportID, parentReportActions); - return reportID === transactionThreadReportID; + return reportID === transactionThreadReportID && !ReportActionsUtils.isSentMoneyReportAction(threadParentReportAction); } /** @@ -2796,7 +2812,9 @@ function canHoldUnholdReportAction(reportAction: OnyxInputOrEntry) const canHoldOrUnholdRequest = !isRequestSettled && !isApproved && !isDeletedParentAction; const canHoldRequest = canHoldOrUnholdRequest && !isOnHold && (isRequestHoldCreator || (!isRequestIOU && canModifyStatus)) && !isScanning && !!transaction?.reimbursable; - const canUnholdRequest = !!(canHoldOrUnholdRequest && isOnHold && (isRequestHoldCreator || (!isRequestIOU && canModifyStatus))) && !!transaction?.reimbursable; + const canUnholdRequest = + !!(canHoldOrUnholdRequest && isOnHold && !TransactionUtils.isDuplicate(transaction.transactionID, true) && (isRequestHoldCreator || (!isRequestIOU && canModifyStatus))) && + !!transaction?.reimbursable; return {canHoldRequest, canUnholdRequest}; } @@ -5392,6 +5410,8 @@ function shouldReportBeInOptionList({ // This can also happen for anyone accessing a public room or archived room for which they don't have access to the underlying policy. // Optionally exclude reports that do not belong to currently active workspace + const parentReportAction = ReportActionsUtils.getParentReportAction(report); + if ( !report?.reportID || !report?.type || @@ -5422,7 +5442,11 @@ function shouldReportBeInOptionList({ } // If this is a transaction thread associated with a report that only has one transaction, omit it - if (isOneTransactionThread(report.reportID, report.parentReportID ?? '-1')) { + if (isOneTransactionThread(report.reportID, report.parentReportID ?? '-1', parentReportAction)) { + return false; + } + + if (report?.type === CONST.REPORT.TYPE.PAYCHECK || report?.type === CONST.REPORT.TYPE.BILL) { return false; } @@ -5491,8 +5515,6 @@ function shouldReportBeInOptionList({ return false; } - const parentReportAction = ReportActionsUtils.getParentReportAction(report); - // Hide chat threads where the parent message is pending removal if ( !isEmptyObject(parentReportAction) && @@ -6897,6 +6919,10 @@ function canJoinChat(report: OnyxInputOrEntry, parentReportAction: OnyxI * Whether the user can leave a report */ function canLeaveChat(report: OnyxEntry, policy: OnyxEntry): boolean { + if (isPublicRoom(report) && SessionUtils.isAnonymousUser()) { + return false; + } + if (report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { return false; } @@ -7035,10 +7061,18 @@ function shouldShowMerchantColumn(transactions: Transaction[]) { } /** - * Whether the report is a system chat or concierge chat, depending on the user's account ID (used for A/B testing purposes). + * Whether the report is a system chat or concierge chat, depending on the onboarding report ID or fallbacking + * to the user's account ID (used for A/B testing purposes). */ -function isChatUsedForOnboarding(report: OnyxEntry): boolean { - return AccountUtils.isAccountIDOddNumber(currentUserAccountID ?? -1) ? isSystemChat(report) : isConciergeChatReport(report); +function isChatUsedForOnboarding(optionOrReport: OnyxEntry | OptionData): boolean { + // onboarding can be an array for old accounts and accounts created from olddot + if (!Array.isArray(onboarding) && onboarding?.chatReportID === optionOrReport?.reportID) { + return true; + } + + return AccountUtils.isAccountIDOddNumber(currentUserAccountID ?? -1) + ? isSystemChat(optionOrReport) + : (optionOrReport as OptionData).isConciergeChat ?? isConciergeChatReport(optionOrReport); } /** @@ -7333,6 +7367,7 @@ export { findPolicyExpenseChatByPolicyID, hasOnlyNonReimbursableTransactions, getMostRecentlyVisitedReport, + getReport, }; export type { diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js new file mode 100644 index 000000000000..28143edb40f7 --- /dev/null +++ b/src/libs/SearchParser/searchParser.js @@ -0,0 +1,1161 @@ +// @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 = "!="; + var peg$c1 = ">"; + var peg$c2 = ">="; + var peg$c3 = "<"; + var peg$c4 = "<="; + var peg$c5 = "type"; + var peg$c6 = "status"; + var peg$c7 = "date"; + var peg$c8 = "amount"; + var peg$c9 = "expenseType"; + var peg$c10 = "in"; + var peg$c11 = "currency"; + var peg$c12 = "merchant"; + var peg$c13 = "description"; + var peg$c14 = "from"; + var peg$c15 = "to"; + var peg$c16 = "category"; + var peg$c17 = "tag"; + var peg$c18 = "taxRate"; + var peg$c19 = "card"; + var peg$c20 = "reportID"; + var peg$c21 = "keyword"; + var peg$c22 = "sortBy"; + var peg$c23 = "sortOrder"; + var peg$c24 = "offset"; + var peg$c25 = "\""; + + 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$classExpectation([":", "="], false, false); + var peg$e1 = peg$literalExpectation("!=", 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("type", false); + var peg$e7 = peg$literalExpectation("status", false); + var peg$e8 = peg$literalExpectation("date", false); + var peg$e9 = peg$literalExpectation("amount", false); + var peg$e10 = peg$literalExpectation("expenseType", false); + var peg$e11 = peg$literalExpectation("in", false); + var peg$e12 = peg$literalExpectation("currency", false); + var peg$e13 = peg$literalExpectation("merchant", false); + var peg$e14 = peg$literalExpectation("description", false); + var peg$e15 = peg$literalExpectation("from", false); + var peg$e16 = peg$literalExpectation("to", false); + var peg$e17 = peg$literalExpectation("category", false); + var peg$e18 = peg$literalExpectation("tag", false); + var peg$e19 = peg$literalExpectation("taxRate", false); + var peg$e20 = peg$literalExpectation("card", false); + var peg$e21 = peg$literalExpectation("reportID", false); + var peg$e22 = peg$literalExpectation("keyword", false); + var peg$e23 = peg$literalExpectation("sortBy", false); + var peg$e24 = peg$literalExpectation("sortOrder", false); + var peg$e25 = peg$literalExpectation("offset", false); + var peg$e26 = peg$literalExpectation("\"", false); + var peg$e27 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e28 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ","], false, false); + var peg$e29 = peg$otherExpectation("whitespace"); + var peg$e30 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); + + var peg$f0 = function(filters) { return applyDefaults(filters); }; + var peg$f1 = function(head, tail) { + const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter(filter => filter !== null); + if (!allFilters.length) { + return null; + } + return allFilters.reduce((result, filter) => buildFilter("and", result, filter)); + }; + var peg$f2 = function(field, op, value) { + if (isDefaultField(field)) { + updateDefaultValues(field, value.trim()); + return null; + } + + if (!field && !op) { + return buildFilter('eq', 'keyword', value.trim()); + } + + const values = value.split(','); + const operatorValue = op ?? 'eq'; + + return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter(operatorValue, field, val.trim())), buildFilter(operatorValue, field, values[0])); + }; + var peg$f3 = function() { return "eq"; }; + var peg$f4 = function() { return "neq"; }; + var peg$f5 = function() { return "gt"; }; + var peg$f6 = function() { return "gte"; }; + var peg$f7 = function() { return "lt"; }; + var peg$f8 = function() { return "lte"; }; + var peg$f9 = function() { return "type"; }; + var peg$f10 = function() { return "status"; }; + var peg$f11 = function() { return "date"; }; + var peg$f12 = function() { return "amount"; }; + var peg$f13 = function() { return "expenseType"; }; + var peg$f14 = function() { return "in"; }; + var peg$f15 = function() { return "currency"; }; + var peg$f16 = function() { return "merchant"; }; + var peg$f17 = function() { return "description"; }; + var peg$f18 = function() { return "from"; }; + var peg$f19 = function() { return "to"; }; + var peg$f20 = function() { return "category"; }; + var peg$f21 = function() { return "tag"; }; + var peg$f22 = function() { return "taxRate"; }; + var peg$f23 = function() { return "card"; }; + var peg$f24 = function() { return "reportID"; }; + var peg$f25 = function() { return "keyword"; }; + var peg$f26 = function() { return "sortBy"; }; + var peg$f27 = function() { return "sortOrder"; }; + var peg$f28 = function() { return "offset"; }; + var peg$f29 = function(parts) { return parts.join(''); }; + var peg$f30 = function(chars) { return chars.join(''); }; + var peg$f31 = function(chars) { return chars.join(''); }; + var peg$f32 = 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(); + if (s2 === peg$FAILED) { + s2 = null; + } + s3 = peg$parse_(); + peg$savedPos = s0; + s0 = peg$f0(s2); + + return s0; + } + + function peg$parsefilterList() { + var s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + s1 = peg$parsefilter(); + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$currPos; + s4 = peg$parselogicalAnd(); + s5 = peg$parsefilter(); + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$parselogicalAnd(); + s5 = peg$parsefilter(); + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + peg$savedPos = s0; + s0 = peg$f1(s1, s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsefilter() { + var s0, s1, s2, s3, s4, s5, s6; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parsekey(); + if (s2 === peg$FAILED) { + s2 = null; + } + s3 = peg$parse_(); + s4 = peg$parseoperator(); + if (s4 === peg$FAILED) { + s4 = null; + } + s5 = peg$parse_(); + s6 = peg$parseidentifier(); + if (s6 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f2(s2, s4, s6); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseoperator() { + var s0, s1; + + 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$e0); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f3(); + } + 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$e1); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f4(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 62) { + s1 = peg$c1; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + 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$c2) { + s1 = peg$c2; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f6(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c3; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e4); } + } + 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$c4) { + s1 = peg$c4; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e5); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f8(); + } + s0 = s1; + } + } + } + } + } + + return s0; + } + + function peg$parsekey() { + var s0, s1; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c5) { + s1 = peg$c5; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e6); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f9(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 6) === peg$c6) { + s1 = peg$c6; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f10(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c7) { + s1 = peg$c7; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e8); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f11(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 6) === peg$c8) { + s1 = peg$c8; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e9); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f12(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 11) === peg$c9) { + s1 = peg$c9; + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e10); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f13(); + } + 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$e11); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f14(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 8) === peg$c11) { + s1 = peg$c11; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e12); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f15(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 8) === peg$c12) { + s1 = peg$c12; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e13); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f16(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 11) === peg$c13) { + s1 = peg$c13; + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e14); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f17(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c14) { + s1 = peg$c14; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e15); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f18(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c15) { + s1 = peg$c15; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e16); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f19(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 8) === peg$c16) { + s1 = peg$c16; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e17); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f20(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 3) === peg$c17) { + s1 = peg$c17; + peg$currPos += 3; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e18); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f21(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 7) === peg$c18) { + s1 = peg$c18; + peg$currPos += 7; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e19); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f22(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c19) { + s1 = peg$c19; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e20); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f23(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 8) === peg$c20) { + s1 = peg$c20; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e21); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f24(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 7) === peg$c21) { + s1 = peg$c21; + peg$currPos += 7; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e22); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f25(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 6) === peg$c22) { + s1 = peg$c22; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e23); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f26(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 9) === peg$c23) { + s1 = peg$c23; + peg$currPos += 9; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e24); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f27(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 6) === peg$c24) { + s1 = peg$c24; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e25); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f28(); + } + s0 = s1; + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + + 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$f29(s1); + } + s0 = s1; + + return s0; + } + + function peg$parsequotedString() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 34) { + s1 = peg$c25; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e26); } + } + 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$e27); } + } + 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$e27); } + } + } + if (input.charCodeAt(peg$currPos) === 34) { + s3 = peg$c25; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e26); } + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f30(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsealphanumeric() { + var s0, s1, s2; + + 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$e28); } + } + 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$e28); } + } + } + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f31(s1); + } + s0 = s1; + + return s0; + } + + function peg$parselogicalAnd() { + var s0, s1; + + s0 = peg$currPos; + s1 = peg$parse_(); + peg$savedPos = s0; + s1 = peg$f32(); + 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$e30); } + } + 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$e30); } + } + } + peg$silentFails--; + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e29); } + + return s0; + } + + + const defaultValues = { + "type": "expense", + "status": "all", + "sortBy": "date", + "sortOrder": "desc", + "offset": 0 + }; + + function buildFilter(operator, left, right) { + return { operator, left, right }; + } + + function applyDefaults(filters) { + return { + ...defaultValues, + filters + }; + } + + function updateDefaultValues(field, value) { + defaultValues[field] = value; + } + + function isDefaultField(field) { + return defaultValues.hasOwnProperty(field); + } + + 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/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy new file mode 100644 index 000000000000..0957239b6acb --- /dev/null +++ b/src/libs/SearchParser/searchParser.peggy @@ -0,0 +1,122 @@ +// This files defines the grammar that's used by [Peggy](https://peggyjs.org/) to generate the searchParser.js file. +// The searchParser is setup to parse our custom search syntax and output an AST with the filters. +// +// Here's a general grammar structure: +// +// start: entry point for the parser. It calls the query rule and return its value. +// query: rule to process the values returned by the filterList rule. Takes filters as an argument and returns the final AST output. +// filterList: rule to process the array of filters returned by the filter rule. It takes head and tail as arguments, filters it for null values and builds the AST. +// filter: rule to build the filter object. It takes field, operator and value as input and returns {operator, left: field, right: value} or null if the left value is a defaultValues +// 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 +// 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 + +{ + const defaultValues = { + "type": "expense", + "status": "all", + "sortBy": "date", + "sortOrder": "desc", + "offset": 0 + }; + + function buildFilter(operator, left, right) { + return { operator, left, right }; + } + + function applyDefaults(filters) { + return { + ...defaultValues, + filters + }; + } + + function updateDefaultValues(field, value) { + defaultValues[field] = value; + } + + function isDefaultField(field) { + return defaultValues.hasOwnProperty(field); + } +} + +query + = _ filters:filterList? _ { return applyDefaults(filters); } + +filterList + = head:filter tail:(logicalAnd filter)* { + const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter(filter => filter !== null); + if (!allFilters.length) { + return null; + } + return allFilters.reduce((result, filter) => buildFilter("and", result, filter)); + } + +filter + = _ field:key? _ op:operator? _ value:identifier { + if (isDefaultField(field)) { + updateDefaultValues(field, value.trim()); + return null; + } + + if (!field && !op) { + return buildFilter('eq', 'keyword', value.trim()); + } + + const values = value.split(','); + const operatorValue = op ?? 'eq'; + + return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter(operatorValue, field, val.trim())), buildFilter(operatorValue, field, values[0])); + } + +operator + = (":" / "=") { return "eq"; } + / "!=" { return "neq"; } + / ">" { return "gt"; } + / ">=" { return "gte"; } + / "<" { return "lt"; } + / "<=" { return "lte"; } + +key + = "type" { return "type"; } + / "status" { return "status"; } + / "date" { return "date"; } + / "amount" { return "amount"; } + / "expenseType" { return "expenseType"; } + / "in" { return "in"; } + / "currency" { return "currency"; } + / "merchant" { return "merchant"; } + / "description" { return "description"; } + / "from" { return "from"; } + / "to" { return "to"; } + / "category" { return "category"; } + / "tag" { return "tag"; } + / "taxRate" { return "taxRate"; } + / "card" { return "card"; } + / "reportID" { return "reportID"; } + / "keyword" { return "keyword"; } + / "sortBy" { return "sortBy"; } + / "sortOrder" { return "sortOrder"; } + / "offset" { return "offset"; } + +identifier + = parts:(quotedString / alphanumeric)+ { return parts.join(''); } + +quotedString + = '"' chars:[^"\r\n]* '"' { return chars.join(''); } + +alphanumeric + = chars:[A-Za-z0-9_@./#&+\-\\',]+ { return chars.join(''); } + +logicalAnd + = _ { return "and"; } + +_ "whitespace" + = [ \t\r\n]* + +start + = query diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 91d742f44e62..7085171a3ce8 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -1,4 +1,5 @@ -import type {SearchColumnType, SortOrder} from '@components/Search/types'; +import type {ValueOf} from 'type-fest'; +import type {AllFieldKeys, ASTNode, QueryFilter, QueryFilters, SearchColumnType, SortOrder} from '@components/Search/types'; import ReportListItem from '@components/SelectionList/Search/ReportListItem'; import TransactionListItem from '@components/SelectionList/Search/TransactionListItem'; import type {ListItem, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; @@ -11,6 +12,7 @@ import DateUtils from './DateUtils'; import getTopmostCentralPaneRoute from './Navigation/getTopmostCentralPaneRoute'; import navigationRef from './Navigation/navigationRef'; import type {AuthScreensParamList, RootStackParamList, State} from './Navigation/types'; +import * as searchParser from './SearchParser/searchParser'; import * as TransactionUtils from './TransactionUtils'; import * as UserUtils from './UserUtils'; @@ -301,7 +303,94 @@ function isSearchResultsEmpty(searchResults: SearchResults) { return !Object.keys(searchResults?.data).some((key) => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)); } +function getQueryHashFromString(query: string): number { + return UserUtils.hashText(query, 2 ** 32); +} + +type JSONQuery = { + input: string; + hash: number; + type: string; + status: string; + sortBy: string; + sortOrder: string; + offset: number; + filters: ASTNode; +}; + +function buildJSONQuery(query: string) { + try { + // Add the full input and hash to the results + const result = searchParser.parse(query) as JSONQuery; + result.input = query; + result.hash = getQueryHashFromString(query); + return result; + } catch (e) { + console.error(e); + } +} + +function getFilters(query: string, fields: Array>) { + let jsonQuery; + try { + jsonQuery = searchParser.parse(query) as JSONQuery; + } catch (e) { + console.error(e); + return; + } + + const filters = {} as QueryFilters; + + fields.forEach((field) => { + const rootFieldKey = field as ValueOf; + if (jsonQuery[rootFieldKey] === undefined) { + return; + } + + filters[field] = { + operator: 'eq', + value: jsonQuery[rootFieldKey], + }; + }); + + function traverse(node: ASTNode) { + if (!node.operator) { + return; + } + + if (typeof node?.left === 'object') { + traverse(node.left); + } + + if (typeof node?.right === 'object') { + traverse(node.right); + } + + const nodeKey = node.left as ValueOf; + if (!fields.includes(nodeKey)) { + return; + } + + if (!filters[nodeKey]) { + filters[nodeKey] = []; + } + + const filterArray = filters[nodeKey] as QueryFilter[]; + filterArray.push({ + operator: node.operator, + value: node.right as string | number, + }); + } + + if (jsonQuery.filters) { + traverse(jsonQuery.filters); + } + + return filters; +} + export { + buildJSONQuery, getListItem, getQueryHash, getSections, @@ -313,4 +402,5 @@ export { isReportListItemType, isTransactionListItemType, isSearchResultsEmpty, + getFilters, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 79ebb27719d3..1007ba455fa6 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -6,6 +6,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList, ReportActions, TransactionViolation} from '@src/types/onyx'; import type Beta from '@src/types/onyx/Beta'; +import type {OriginalMessageChangeLog} from '@src/types/onyx/OriginalMessage'; import type Policy from '@src/types/onyx/Policy'; import type PriorityMode from '@src/types/onyx/PriorityMode'; import type Report from '@src/types/onyx/Report'; @@ -18,6 +19,7 @@ import * as LocalePhoneNumber from './LocalePhoneNumber'; import * as Localize from './Localize'; import * as OptionsListUtils from './OptionsListUtils'; import Parser from './Parser'; +import Permissions from './Permissions'; import * as PolicyUtils from './PolicyUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; import * as ReportUtils from './ReportUtils'; @@ -25,6 +27,12 @@ import * as TaskUtils from './TaskUtils'; type WelcomeMessage = {showReportName: boolean; phrase1?: string; phrase2?: string; phrase3?: string; messageText?: string; messageHtml?: string}; +let allBetas: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.BETAS, + callback: (value) => (allBetas = value), +}); + const visibleReportActionItems: ReportActions = {}; let allPersonalDetails: OnyxEntry; Onyx.connect({ @@ -98,13 +106,23 @@ function getOrderedReportIDs( return; } const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? {}; + const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1'); const doesReportHaveViolations = OptionsListUtils.shouldShowViolations(report, betas ?? [], transactionViolations); const isHidden = report.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; const isFocused = report.reportID === currentReportId; const allReportErrors = OptionsListUtils.getAllReportErrors(report, reportActions) ?? {}; + const transactionReportActions = ReportActionsUtils.getAllReportActions(report.reportID); + const oneTransactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, transactionReportActions, undefined); + let doesTransactionThreadReportHasViolations = false; + if (oneTransactionThreadReportID) { + const transactionReport = ReportUtils.getReport(oneTransactionThreadReportID); + doesTransactionThreadReportHasViolations = !!transactionReport && OptionsListUtils.shouldShowViolations(transactionReport, betas ?? [], transactionViolations); + } const hasErrorsOtherThanFailedReceipt = - doesReportHaveViolations || Object.values(allReportErrors).some((error) => error?.[0] !== Localize.translateLocal('iou.error.genericSmartscanFailureMessage')); - if (ReportUtils.isOneTransactionThread(report.reportID, report.parentReportID ?? '0')) { + doesTransactionThreadReportHasViolations || + doesReportHaveViolations || + Object.values(allReportErrors).some((error) => error?.[0] !== Localize.translateLocal('iou.error.genericSmartscanFailureMessage')); + if (ReportUtils.isOneTransactionThread(report.reportID, report.parentReportID ?? '0', parentReportAction)) { return; } if (hasErrorsOtherThanFailedReceipt) { @@ -222,6 +240,7 @@ function getOptionData({ policy, parentReportAction, hasViolations, + transactionViolations, }: { report: OnyxEntry; reportActions: OnyxEntry; @@ -230,6 +249,7 @@ function getOptionData({ policy: OnyxEntry | undefined; parentReportAction: OnyxEntry | undefined; hasViolations: boolean; + transactionViolations?: OnyxCollection; }): ReportUtils.OptionData | undefined { // When a user signs out, Onyx is cleared. Due to the lazy rendering with a virtual list, it's possible for // this method to be called after the Onyx data has been cleared out. In that case, it's fine to do @@ -268,6 +288,7 @@ function getOptionData({ isWaitingOnBankAccount: false, isAllowedToComment: true, isDeletedParentAction: false, + isConciergeChat: false, }; const participantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report); @@ -289,6 +310,21 @@ function getOptionData({ result.shouldShowSubscript = ReportUtils.shouldReportShowSubscript(report); result.pendingAction = report.pendingFields?.addWorkspaceRoom ?? report.pendingFields?.createChat; result.brickRoadIndicator = hasErrors || hasViolations ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; + const oneTransactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, ReportActionsUtils.getAllReportActions(report.reportID)); + if (oneTransactionThreadReportID) { + const oneTransactionThreadReport = ReportUtils.getReport(oneTransactionThreadReportID); + + if ( + Permissions.canUseViolations(allBetas) && + ReportUtils.shouldDisplayTransactionThreadViolations( + oneTransactionThreadReport, + transactionViolations, + ReportActionsUtils.getAllReportActions(report.reportID)[oneTransactionThreadReport?.parentReportActionID ?? '-1'], + ) + ) { + result.brickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + } + } result.ownerAccountID = report.ownerAccountID; result.managerID = report.managerID; result.reportID = report.reportID; @@ -313,6 +349,7 @@ function getOptionData({ result.tooltipText = ReportUtils.getReportParticipantsTitle(visibleParticipantAccountIDs); result.hasOutstandingChildTask = report.hasOutstandingChildTask; result.hasParentAccess = report.hasParentAccess; + result.isConciergeChat = ReportUtils.isConciergeChatReport(report); const hasMultipleParticipants = participantPersonalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat || ReportUtils.isExpenseReport(report); const subtitle = ReportUtils.getChatRoomSubtitle(report); @@ -363,7 +400,7 @@ function getOptionData({ result.alternateText = Localize.translate(preferredLocale, 'newRoomPage.roomRenamedTo', {newName}); } else if (ReportActionsUtils.isTaskAction(lastAction)) { result.alternateText = ReportUtils.formatReportLastMessageText(TaskUtils.getTaskReportActionMessage(lastAction).text); - } else if (ReportActionsUtils.isRoomChangeLogAction(lastAction)) { + } else if (ReportActionsUtils.isInviteOrRemovedAction(lastAction)) { const lastActionOriginalMessage = lastAction?.actionName ? ReportActionsUtils.getOriginalMessage(lastAction) : null; const targetAccountIDs = lastActionOriginalMessage?.targetAccountIDs ?? []; const targetAccountIDsLength = targetAccountIDs.length !== 0 ? targetAccountIDs.length : report.lastMessageHtml?.match(/]*><\/mention-user>/g)?.length ?? 0; @@ -382,11 +419,9 @@ function getOptionData({ : ` ${Localize.translate(preferredLocale, 'workspace.invite.from')}`; result.alternateText += `${preposition} ${roomName}`; } - if (lastActionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) { - result.alternateText = `${lastActorDisplayName} ${Localize.translate(preferredLocale, 'roomChangeLog.updateRoomDescription')} ${ - lastActionOriginalMessage?.description - }`.trim(); - } + } else if (lastActionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) { + const lastActionOriginalMessage = lastAction?.actionName ? (ReportActionsUtils.getOriginalMessage(lastAction) as OriginalMessageChangeLog | undefined) : null; + result.alternateText = `${lastActorDisplayName} ${Localize.translate(preferredLocale, 'roomChangeLog.updateRoomDescription')} ${lastActionOriginalMessage?.description}`.trim(); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_POLICY) { result.alternateText = Localize.translateLocal('workspace.invite.leftWorkspace'); } else if (lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && lastActorDisplayName && lastMessageTextFromReport) { diff --git a/src/libs/TaskUtils.ts b/src/libs/TaskUtils.ts index bd0bd10cd83e..06745a49217b 100644 --- a/src/libs/TaskUtils.ts +++ b/src/libs/TaskUtils.ts @@ -1,12 +1,21 @@ import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type {Message} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; import * as Localize from './Localize'; +import Navigation from './Navigation/Navigation'; import {getReportActionHtml, getReportActionText} from './ReportActionsUtils'; import * as ReportConnection from './ReportConnection'; +/** + * Check if the active route belongs to task edit flow. + */ +function isActiveTaskEditRoute(reportID: string): boolean { + return [ROUTES.TASK_TITLE, ROUTES.TASK_ASSIGNEE, ROUTES.REPORT_DESCRIPTION].map((route) => route.getRoute(reportID)).some(Navigation.isActiveRoute); +} + /** * Given the Task reportAction name, return the appropriate message to be displayed and copied to clipboard. */ @@ -42,4 +51,4 @@ function getTaskCreatedMessage(reportAction: OnyxEntry) { return taskTitle ? Localize.translateLocal('task.messages.created', {title: taskTitle}) : ''; } -export {getTaskReportActionMessage, getTaskTitle, getTaskCreatedMessage}; +export {isActiveTaskEditRoute, getTaskReportActionMessage, getTaskTitle, getTaskCreatedMessage}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index b9a8a05ba046..d72e7d5c83ba 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -322,14 +322,31 @@ function getReportPreviewAction(chatReportID: string, iouReportID: string): Onyx * @param policy * @param isFromGlobalCreate * @param iouRequestType one of manual/scan/distance - * @param skipConfirmation if true, skip confirmation step */ function initMoneyRequest(reportID: string, policy: OnyxEntry, isFromGlobalCreate: boolean, iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL) { // Generate a brand new transactionID const newTransactionID = CONST.IOU.OPTIMISTIC_TRANSACTION_ID; + const currency = policy?.outputCurrency ?? currentUserPersonalDetails?.localCurrencyCode ?? CONST.CURRENCY.USD; // Disabling this line since currentDate can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const created = currentDate || format(new Date(), 'yyyy-MM-dd'); + + const currentTransaction = allTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`]; + + // in case we have to re-init money request, but the IOU request type is the same with the old draft transaction, + // we should keep most of the existing data by using the ONYX MERGE operation + if (currentTransaction?.iouRequestType === iouRequestType) { + // so, we just need to update the reportID, isFromGlobalCreate, created, currency + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`, { + reportID, + isFromGlobalCreate, + created, + currency, + transactionID: newTransactionID, + }); + return; + } + const comment: Comment = {}; // Add initial empty waypoints when starting a distance expense @@ -350,7 +367,7 @@ function initMoneyRequest(reportID: string, policy: OnyxEntry, amount: 0, comment, created, - currency: policy?.outputCurrency ?? currentUserPersonalDetails?.localCurrencyCode ?? CONST.CURRENCY.USD, + currency, iouRequestType, reportID, transactionID: newTransactionID, diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 5870d642d8cd..8e2fff3868ae 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -25,7 +25,6 @@ import ROUTES from '@src/ROUTES'; import type {DateOfBirthForm} from '@src/types/form'; import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; import type {SelectedTimezone, Timezone} from '@src/types/onyx/PersonalDetails'; -import * as Session from './Session'; let currentUserEmail = ''; let currentUserAccountID = -1; @@ -191,10 +190,6 @@ function updateAddress(street: string, street2: string, city: string, state: str * selected timezone if set to automatically update. */ function updateAutomaticTimezone(timezone: Timezone) { - if (Session.isAnonymousUser()) { - return; - } - if (!currentUserAccountID) { return; } diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 4f32eb0b81ba..3af4749569c9 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -2,7 +2,7 @@ import lodashUnion from 'lodash/union'; import type {NullishDeep, OnyxCollection, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; -import type {EnablePolicyCategoriesParams, OpenPolicyCategoriesPageParams, SetPolicyDistanceRatesDefaultCategoryParams} from '@libs/API/parameters'; +import type {EnablePolicyCategoriesParams, OpenPolicyCategoriesPageParams, SetPolicyDistanceRatesDefaultCategoryParams, UpdatePolicyCategoryGLCodeParams} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; @@ -331,6 +331,74 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string API.write(WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY, parameters, onyxData); } +function updatePolicyCategoryGLCode(policyID: string, categoryName: string, glCode: string) { + const policyCategoryToUpdate = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName] ?? {}; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + ...policyCategoryToUpdate, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': glCode, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + ...policyCategoryToUpdate, + pendingAction: null, + pendingFields: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': null, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': glCode, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + ...policyCategoryToUpdate, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.categories.updateGLCodeFailureMessage'), + pendingAction: null, + pendingFields: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': null, + }, + }, + }, + }, + ], + }; + + const parameters: UpdatePolicyCategoryGLCodeParams = { + policyID, + categoryName, + glCode, + }; + + API.write(WRITE_COMMANDS.UPDATE_POLICY_CATEGORY_GL_CODE, parameters, onyxData); +} + function setWorkspaceRequiresCategory(policyID: string, requiresCategory: boolean) { const onyxData: OnyxData = { optimisticData: [ @@ -618,6 +686,7 @@ export { setWorkspaceRequiresCategory, createPolicyCategory, renamePolicyCategory, + updatePolicyCategoryGLCode, clearCategoryErrors, enablePolicyCategories, setPolicyDistanceRatesDefaultCategory, diff --git a/src/libs/actions/Policy/ReportField.ts b/src/libs/actions/Policy/ReportField.ts index bccb08c47c18..27b67c9fe686 100644 --- a/src/libs/actions/Policy/ReportField.ts +++ b/src/libs/actions/Policy/ReportField.ts @@ -5,8 +5,8 @@ import * as API from '@libs/API'; import type { CreateWorkspaceReportFieldListValueParams, CreateWorkspaceReportFieldParams, + DeletePolicyReportField, EnableWorkspaceReportFieldListValueParams, - PolicyReportFieldsReplace, RemoveWorkspaceReportFieldListValueParams, UpdateWorkspaceReportFieldInitialValueParams, } from '@libs/API/parameters'; @@ -260,7 +260,7 @@ function deleteReportFields(policyID: string, reportFieldsToUpdate: string[]) { ], }; - const parameters: PolicyReportFieldsReplace = { + const parameters: DeletePolicyReportField = { policyID, reportFields: JSON.stringify(Object.values(updatedReportFields)), }; diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index 2558969be2f3..daefaed07f68 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -1,7 +1,15 @@ -import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; -import type {EnablePolicyTagsParams, OpenPolicyTagsPageParams, RenamePolicyTaglistParams, RenamePolicyTagsParams, SetPolicyTagsEnabled, SetPolicyTagsRequired} from '@libs/API/parameters'; +import type { + EnablePolicyTagsParams, + OpenPolicyTagsPageParams, + RenamePolicyTaglistParams, + RenamePolicyTagsParams, + SetPolicyTagsEnabled, + SetPolicyTagsRequired, + UpdatePolicyTagGLCodeParams, +} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; @@ -624,6 +632,9 @@ function renamePolicyTaglist(policyID: string, policyTagListName: {oldName: stri } function setPolicyRequiresTag(policyID: string, requiresTag: boolean) { + const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {}; + const isMultiLevelTags = PolicyUtils.isMultiLevelTags(policyTags); + const onyxData: OnyxData = { optimisticData: [ { @@ -667,6 +678,26 @@ function setPolicyRequiresTag(policyID: string, requiresTag: boolean) { ], }; + if (isMultiLevelTags) { + const getUpdatedTagsData = (required: boolean): OnyxUpdate => ({ + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + ...Object.keys(policyTags).reduce((acc, key) => { + acc[key] = { + ...acc[key], + required, + }; + return acc; + }, {}), + }, + }); + + onyxData.optimisticData?.push(getUpdatedTagsData(requiresTag)); + onyxData.failureData?.push(getUpdatedTagsData(!requiresTag)); + onyxData.successData?.push(getUpdatedTagsData(requiresTag)); + } + const parameters = { policyID, requiresTag, @@ -733,6 +764,81 @@ function setPolicyTagsRequired(policyID: string, requiresTag: boolean, tagListIn API.write(WRITE_COMMANDS.SET_POLICY_TAGS_REQUIRED, parameters, onyxData); } +function setPolicyTagGLCode(policyID: string, tagName: string, tagListIndex: number, glCode: string) { + const tagListName = Object.keys(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})[tagListIndex]; + const policyTagToUpdate = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]?.[tagListName]?.tags?.[tagName] ?? {}; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [tagName]: { + ...policyTagToUpdate, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': glCode, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [tagName]: { + errors: null, + pendingAction: null, + pendingFields: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': null, + }, + }, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [tagName]: { + ...policyTagToUpdate, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.updateGLCodeFailureMessage'), + }, + }, + }, + }, + }, + ], + }; + + const parameters: UpdatePolicyTagGLCodeParams = { + policyID, + tagName, + tagListName, + tagListIndex, + glCode, + }; + + API.write(WRITE_COMMANDS.UPDATE_POLICY_TAG_GL_CODE, parameters, onyxData); +} + export { buildOptimisticPolicyRecentlyUsedTags, setPolicyRequiresTag, @@ -747,6 +853,7 @@ export { renamePolicyTag, renamePolicyTaglist, setWorkspaceTagEnabled, + setPolicyTagGLCode, }; export type {NewCustomUnit}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 3060f53f12c3..026ce45146d3 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1,4 +1,3 @@ -import {findFocusedRoute} from '@react-navigation/native'; import {format as timezoneFormat, utcToZonedTime} from 'date-fns-tz'; import {Str} from 'expensify-common'; import isEmpty from 'lodash/isEmpty'; @@ -56,13 +55,11 @@ import {prepareDraftComment} from '@libs/DraftCommentUtils'; import * as EmojiUtils from '@libs/EmojiUtils'; import * as Environment from '@libs/Environment/Environment'; import * as ErrorUtils from '@libs/ErrorUtils'; -import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector'; import isPublicScreenRoute from '@libs/isPublicScreenRoute'; import * as Localize from '@libs/Localize'; import Log from '@libs/Log'; import {registerPaginationConfig} from '@libs/Middleware/Pagination'; -import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; -import {isOnboardingFlowName} from '@libs/NavigationUtils'; +import Navigation from '@libs/Navigation/Navigation'; import type {NetworkStatus} from '@libs/NetworkConnection'; import LocalNotification from '@libs/Notification/LocalNotification'; import Parser from '@libs/Parser'; @@ -2544,47 +2541,28 @@ function openReportFromDeepLink(url: string) { // Navigate to the report after sign-in/sign-up. InteractionManager.runAfterInteractions(() => { Session.waitForUserSignIn().then(() => { - Onyx.connect({ - key: ONYXKEYS.NVP_ONBOARDING, - callback: (onboarding) => { - Navigation.waitForProtectedRoutes().then(() => { - if (route && Session.isAnonymousUser() && !Session.canAnonymousUserAccessRoute(route)) { - Session.signOutAndRedirectToSignIn(true); - return; - } - - // We don't want to navigate to the exitTo route when creating a new workspace from a deep link, - // because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate, - // which is already called when AuthScreens mounts. - if (new URL(url).searchParams.get('exitTo') === ROUTES.WORKSPACE_NEW) { - return; - } - - if (shouldSkipDeepLinkNavigation(route)) { - return; - } - - const state = navigationRef.getRootState(); - const currentFocusedRoute = findFocusedRoute(state); - const hasCompletedGuidedSetupFlow = hasCompletedGuidedSetupFlowSelector(onboarding); - - // We need skip deeplinking if the user hasn't completed the guided setup flow. - if (!hasCompletedGuidedSetupFlow) { - return; - } - - if (isOnboardingFlowName(currentFocusedRoute?.name)) { - Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton')); - return; - } - - if (isAuthenticated) { - return; - } - - Navigation.navigate(route as Route, CONST.NAVIGATION.ACTION_TYPE.PUSH); - }); - }, + Navigation.waitForProtectedRoutes().then(() => { + if (route && Session.isAnonymousUser() && !Session.canAnonymousUserAccessRoute(route)) { + Session.signOutAndRedirectToSignIn(true); + return; + } + + // We don't want to navigate to the exitTo route when creating a new workspace from a deep link, + // because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate, + // which is already called when AuthScreens mounts. + if (new URL(url).searchParams.get('exitTo') === ROUTES.WORKSPACE_NEW) { + return; + } + + if (shouldSkipDeepLinkNavigation(route)) { + return; + } + + if (isAuthenticated) { + return; + } + + Navigation.navigate(route as Route, CONST.NAVIGATION.ACTION_TYPE.PUSH); }); }); }); diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 0a7244bde1e5..964f5be0129b 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -126,7 +126,7 @@ function createTaskAndNavigate( const currentTime = DateUtils.getDBTimeWithSkew(); const lastCommentText = ReportUtils.formatReportLastMessageText(ReportActionsUtils.getReportActionText(optimisticAddCommentReport.reportAction)); - const parentReport = getReport(parentReportID); + const parentReport = ReportUtils.getReport(parentReportID); const optimisticParentReport = { lastVisibleActionCreated: optimisticAddCommentReport.reportAction.created, lastMessageText: lastCommentText, @@ -906,13 +906,6 @@ function getParentReport(report: OnyxEntry): OnyxEntry { - return ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; -} - /** * Cancels a task by setting the report state to SUBMITTED and status to CLOSED */ diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 7acc79485f0c..7b3b1abd04ef 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -13,7 +13,6 @@ import type { SetContactMethodAsDefaultParams, SetNameValuePairParams, UpdateChatPriorityModeParams, - UpdateFrequentlyUsedEmojisParams, UpdateNewsletterSubscriptionParams, UpdatePreferredEmojiSkinToneParams, UpdateStatusParams, @@ -37,7 +36,7 @@ import Visibility from '@libs/Visibility'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {BlockedFromConcierge, CustomStatusDraft, FrequentlyUsedEmoji, Policy} from '@src/types/onyx'; +import type {BlockedFromConcierge, CustomStatusDraft, Policy} from '@src/types/onyx'; import type Login from '@src/types/onyx/Login'; import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; import type OnyxPersonalDetails from '@src/types/onyx/PersonalDetails'; @@ -655,23 +654,6 @@ function updatePreferredSkinTone(skinTone: number) { API.write(WRITE_COMMANDS.UPDATE_PREFERRED_EMOJI_SKIN_TONE, parameters, {optimisticData}); } -/** - * Sync frequentlyUsedEmojis with Onyx and Server - */ -function updateFrequentlyUsedEmojis(frequentlyUsedEmojis: FrequentlyUsedEmoji[]) { - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.FREQUENTLY_USED_EMOJIS, - value: frequentlyUsedEmojis, - }, - ]; - - const parameters: UpdateFrequentlyUsedEmojisParams = {value: JSON.stringify(frequentlyUsedEmojis)}; - - API.write(WRITE_COMMANDS.UPDATE_FREQUENTLY_USED_EMOJIS, parameters, {optimisticData}); -} - /** * Sync user chat priority mode with Onyx and Server * @param mode @@ -1045,7 +1027,6 @@ export { setShouldUseStagingServer, setMuteAllSounds, clearUserErrorMessage, - updateFrequentlyUsedEmojis, joinScreenShare, clearScreenShareRequest, generateStatementPDF, diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome.ts index b592424cfcdf..a90c386d02b6 100644 --- a/src/libs/actions/Welcome.ts +++ b/src/libs/actions/Welcome.ts @@ -11,8 +11,7 @@ import ROUTES from '@src/ROUTES'; import type Onboarding from '@src/types/onyx/Onboarding'; import type TryNewDot from '@src/types/onyx/TryNewDot'; -type OnboardingData = Onboarding | [] | undefined; - +let onboarding: Onboarding | [] | undefined; let isLoadingReportData = true; let tryNewDotData: TryNewDot | undefined; @@ -31,8 +30,8 @@ let isServerDataReadyPromise = new Promise((resolve) => { resolveIsReadyPromise = resolve; }); -let resolveOnboardingFlowStatus: (value?: OnboardingData) => void; -let isOnboardingFlowStatusKnownPromise = new Promise((resolve) => { +let resolveOnboardingFlowStatus: (value?: Promise) => void | undefined; +let isOnboardingFlowStatusKnownPromise = new Promise((resolve) => { resolveOnboardingFlowStatus = resolve; }); @@ -46,7 +45,7 @@ function onServerDataReady(): Promise { } function isOnboardingFlowCompleted({onCompleted, onNotCompleted}: HasCompletedOnboardingFlowProps) { - isOnboardingFlowStatusKnownPromise.then((onboarding) => { + isOnboardingFlowStatusKnownPromise.then(() => { if (Array.isArray(onboarding) || onboarding?.hasCompletedGuidedSetupFlow === undefined) { return; } @@ -103,7 +102,23 @@ function handleHybridAppOnboarding() { } /** - * Check if report data are loaded + * Check that a few requests have completed so that the welcome action can proceed: + * + * - Whether we are a first time new expensify user + * - Whether we have loaded all policies the server knows about + * - Whether we have loaded all reports the server knows about + * Check if onboarding data is ready in order to check if the user has completed onboarding or not + */ +function checkOnboardingDataReady() { + if (onboarding === undefined) { + return; + } + + resolveOnboardingFlowStatus?.(); +} + +/** + * Check if user dismissed modal and if report data are loaded */ function checkServerDataReady() { if (isLoadingReportData) { @@ -128,10 +143,6 @@ function setOnboardingPurposeSelected(value: OnboardingPurposeType) { Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null); } -function setOnboardingErrorMessage(value: string) { - Onyx.set(ONYXKEYS.ONBOARDING_ERROR_MESSAGE, value ?? null); -} - function setOnboardingAdminsChatReportID(adminsChatReportID?: string) { Onyx.set(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID, adminsChatReportID ?? null); } @@ -175,7 +186,9 @@ Onyx.connect({ return; } - resolveOnboardingFlowStatus(value); + onboarding = value; + + checkOnboardingDataReady(); }, }); @@ -200,9 +213,10 @@ function resetAllChecks() { isServerDataReadyPromise = new Promise((resolve) => { resolveIsReadyPromise = resolve; }); - isOnboardingFlowStatusKnownPromise = new Promise((resolve) => { + isOnboardingFlowStatusKnownPromise = new Promise((resolve) => { resolveOnboardingFlowStatus = resolve; }); + onboarding = undefined; isLoadingReportData = true; } @@ -215,5 +229,4 @@ export { setOnboardingPolicyID, completeHybridAppOnboarding, handleHybridAppOnboarding, - setOnboardingErrorMessage, }; diff --git a/src/libs/focusComposerWithDelay/types.ts b/src/libs/focusComposerWithDelay/types.ts index 4cd2f785f2bc..97a1298e8c7a 100644 --- a/src/libs/focusComposerWithDelay/types.ts +++ b/src/libs/focusComposerWithDelay/types.ts @@ -3,6 +3,8 @@ import type {TextInput} from 'react-native'; type Selection = { start: number; end: number; + positionX?: number; + positionY?: number; }; type FocusComposerWithDelay = (shouldDelay?: boolean, forcedSelectionRange?: Selection) => void; diff --git a/src/libs/hasCompletedGuidedSetupFlowSelector.ts b/src/libs/hasCompletedGuidedSetupFlowSelector.ts deleted file mode 100644 index 83cde0a0be8c..000000000000 --- a/src/libs/hasCompletedGuidedSetupFlowSelector.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type {OnyxValue} from 'react-native-onyx'; -import type ONYXKEYS from '@src/ONYXKEYS'; - -function hasCompletedGuidedSetupFlowSelector(onboarding: OnyxValue): boolean { - // onboarding is an array for old accounts and accounts created from olddot - if (Array.isArray(onboarding)) { - return true; - } - return onboarding?.hasCompletedGuidedSetupFlow ?? false; -} - -export default hasCompletedGuidedSetupFlowSelector; diff --git a/src/libs/shouldRenderAppPaymentCard/index.native.ts b/src/libs/shouldRenderAppPaymentCard/index.native.ts new file mode 100644 index 000000000000..74137a6f7cdc --- /dev/null +++ b/src/libs/shouldRenderAppPaymentCard/index.native.ts @@ -0,0 +1,5 @@ +import type ShouldRenderAddPaymentCard from './types'; + +const shouldRenderAddPaymentCard: ShouldRenderAddPaymentCard = () => false; + +export default shouldRenderAddPaymentCard; diff --git a/src/libs/shouldRenderAppPaymentCard/index.ts b/src/libs/shouldRenderAppPaymentCard/index.ts new file mode 100644 index 000000000000..9b2ee6082e02 --- /dev/null +++ b/src/libs/shouldRenderAppPaymentCard/index.ts @@ -0,0 +1,5 @@ +import type ShouldRenderAddPaymentCard from './types'; + +const shouldRenderAddPaymentCard: ShouldRenderAddPaymentCard = () => true; + +export default shouldRenderAddPaymentCard; diff --git a/src/libs/shouldRenderAppPaymentCard/types.ts b/src/libs/shouldRenderAppPaymentCard/types.ts new file mode 100644 index 000000000000..80e934badab2 --- /dev/null +++ b/src/libs/shouldRenderAppPaymentCard/types.ts @@ -0,0 +1,3 @@ +type ShouldRenderAddPaymentCard = () => boolean; + +export default ShouldRenderAddPaymentCard; diff --git a/src/libs/shouldShowSubscriptionsMenu/index.native.ts b/src/libs/shouldShowSubscriptionsMenu/index.native.ts deleted file mode 100644 index c98302e9a87d..000000000000 --- a/src/libs/shouldShowSubscriptionsMenu/index.native.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type ShouldShowSubscriptionsMenu from './types'; - -/** - * Indicates whether the subscription menu should show in the all settings screen - */ -const shouldShowSubscriptionsMenu: ShouldShowSubscriptionsMenu = false; - -export default shouldShowSubscriptionsMenu; diff --git a/src/libs/shouldShowSubscriptionsMenu/index.ts b/src/libs/shouldShowSubscriptionsMenu/index.ts deleted file mode 100644 index 2f2b7f17c2c5..000000000000 --- a/src/libs/shouldShowSubscriptionsMenu/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type ShouldShowSubscriptionsMenu from './types'; - -/** - * Indicates whether the subscription menu should show in the all settings screen - */ -const shouldShowSubscriptionsMenu: ShouldShowSubscriptionsMenu = true; - -export default shouldShowSubscriptionsMenu; diff --git a/src/libs/shouldShowSubscriptionsMenu/types.tsx b/src/libs/shouldShowSubscriptionsMenu/types.tsx deleted file mode 100644 index e72b55234639..000000000000 --- a/src/libs/shouldShowSubscriptionsMenu/types.tsx +++ /dev/null @@ -1,3 +0,0 @@ -type ShouldShowSubscriptionsMenu = boolean; - -export default ShouldShowSubscriptionsMenu; diff --git a/src/pages/EditReportFieldDate.tsx b/src/pages/EditReportFieldDate.tsx index 38209ba1083b..06ba24f780ec 100644 --- a/src/pages/EditReportFieldDate.tsx +++ b/src/pages/EditReportFieldDate.tsx @@ -24,7 +24,7 @@ type EditReportFieldDatePageProps = { isRequired: boolean; /** Callback to fire when the Save button is pressed */ - onSubmit: (form: FormOnyxValues) => void; + onSubmit: (form: FormOnyxValues) => void; }; function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, fieldKey}: EditReportFieldDatePageProps) { @@ -33,8 +33,8 @@ function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, f const inputRef = useRef(null); const validate = useCallback( - (value: FormOnyxValues) => { - const errors: FormInputErrors = {}; + (value: FormOnyxValues) => { + const errors: FormInputErrors = {}; if (isRequired && value[fieldKey].trim() === '') { errors[fieldKey] = translate('common.error.fieldRequired'); } @@ -46,7 +46,7 @@ function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, f return ( ) => { + const handleReportFieldChange = (form: FormOnyxValues) => { const value = form[fieldKey]; if (isReportFieldTitle) { ReportActions.updateReportName(report.reportID, value, report.reportName ?? ''); diff --git a/src/pages/EditReportFieldText.tsx b/src/pages/EditReportFieldText.tsx index d619eb52b695..b855acf3e1c0 100644 --- a/src/pages/EditReportFieldText.tsx +++ b/src/pages/EditReportFieldText.tsx @@ -24,7 +24,7 @@ type EditReportFieldTextPageProps = { isRequired: boolean; /** Callback to fire when the Save button is pressed */ - onSubmit: (form: FormOnyxValues) => void; + onSubmit: (form: FormOnyxValues) => void; }; function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, fieldKey}: EditReportFieldTextPageProps) { @@ -33,8 +33,8 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f const {inputCallbackRef} = useAutoFocusInput(); const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; + (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; if (isRequired && values[fieldKey].trim() === '') { errors[fieldKey] = translate('common.error.fieldRequired'); } @@ -46,7 +46,7 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f return ( { - Welcome.setOnboardingErrorMessage(''); - }, []); + useDisableModalDismissOnEscape(); const completeEngagement = useCallback( (values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => { diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 7304c1822ae9..03a4b790bc5f 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -2,7 +2,7 @@ import {useIsFocused} from '@react-navigation/native'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {ScrollView} from 'react-native-gesture-handler'; -import {useOnyx} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; @@ -13,6 +13,7 @@ import MenuItemList from '@components/MenuItemList'; import OfflineIndicator from '@components/OfflineIndicator'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; import Text from '@components/Text'; +import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape'; import useLocalize from '@hooks/useLocalize'; import useOnboardingLayout from '@hooks/useOnboardingLayout'; import useTheme from '@hooks/useTheme'; @@ -27,8 +28,7 @@ import type {OnboardingPurposeType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import type {BaseOnboardingPurposeProps} from './types'; +import type {BaseOnboardingPurposeOnyxProps, BaseOnboardingPurposeProps} from './types'; const menuIcons = { [CONST.ONBOARDING_CHOICES.EMPLOYER]: Illustrations.ReceiptUpload, @@ -38,15 +38,15 @@ const menuIcons = { [CONST.ONBOARDING_CHOICES.LOOKING_AROUND]: Illustrations.Binoculars, }; -function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight}: BaseOnboardingPurposeProps) { +function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, onboardingPurposeSelected}: BaseOnboardingPurposeProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useOnboardingLayout(); const [selectedPurpose, setSelectedPurpose] = useState(undefined); const {isSmallScreenWidth, windowHeight} = useWindowDimensions(); const theme = useTheme(); - const [onboardingPurposeSelected, onboardingPurposeSelectedResult] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); - const [onboardingErrorMessage, onboardingErrorMessageResult] = useOnyx(ONYXKEYS.ONBOARDING_ERROR_MESSAGE); + + useDisableModalDismissOnEscape(); const PurposeFooterInstance = ; @@ -83,6 +83,8 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight}: B Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS); }, [selectedPurpose]); + const [errorMessage, setErrorMessage] = useState(''); + const menuItems: MenuItemProps[] = Object.values(CONST.ONBOARDING_CHOICES).map((choice) => { const translationKey = `onboarding.purpose.${choice}` as const; const isSelected = selectedPurpose === choice; @@ -101,7 +103,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight}: B numberOfLinesTitle: 0, onPress: () => { Welcome.setOnboardingPurposeSelected(choice); - Welcome.setOnboardingErrorMessage(''); + setErrorMessage(''); }, }; }); @@ -109,18 +111,15 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight}: B const handleOuterClick = useCallback(() => { if (!selectedPurpose) { - Welcome.setOnboardingErrorMessage(translate('onboarding.purpose.errorSelection')); + setErrorMessage(translate('onboarding.purpose.errorSelection')); } else { - Welcome.setOnboardingErrorMessage(translate('onboarding.purpose.errorContinue')); + setErrorMessage(translate('onboarding.purpose.errorContinue')); } - }, [selectedPurpose, translate]); + }, [selectedPurpose, setErrorMessage, translate]); const onboardingLocalRef = useRef(null); useImperativeHandle(isFocused ? OnboardingRefManager.ref : onboardingLocalRef, () => ({handleOuterClick}), [handleOuterClick]); - if (isLoadingOnyxValue(onboardingPurposeSelectedResult, onboardingErrorMessageResult)) { - return null; - } return ( {({safeAreaPaddingBottomStyle}) => ( @@ -149,14 +148,14 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight}: B buttonText={translate('common.continue')} onSubmit={() => { if (!selectedPurpose) { - Welcome.setOnboardingErrorMessage(translate('onboarding.purpose.errorSelection')); + setErrorMessage(translate('onboarding.purpose.errorSelection')); return; } - Welcome.setOnboardingErrorMessage(''); + setErrorMessage(''); saveAndNavigate(); }} - message={onboardingErrorMessage} - isAlertVisible={!!onboardingErrorMessage} + message={errorMessage} + isAlertVisible={!!errorMessage} containerStyles={[styles.w100, styles.mb5, styles.mh0, paddingHorizontal]} /> @@ -167,6 +166,10 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight}: B BaseOnboardingPurpose.displayName = 'BaseOnboardingPurpose'; -export default BaseOnboardingPurpose; +export default withOnyx({ + onboardingPurposeSelected: { + key: ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, + }, +})(BaseOnboardingPurpose); export type {BaseOnboardingPurposeProps}; diff --git a/src/pages/OnboardingPurpose/types.ts b/src/pages/OnboardingPurpose/types.ts index 17970dbab9a6..8c8f11503f1a 100644 --- a/src/pages/OnboardingPurpose/types.ts +++ b/src/pages/OnboardingPurpose/types.ts @@ -1,11 +1,20 @@ -type OnboardingPurposeProps = Record; +import type {OnyxEntry} from 'react-native-onyx'; +import type {OnboardingPurposeType} from '@src/CONST'; -type BaseOnboardingPurposeProps = OnboardingPurposeProps & { - /* Whether to use native styles tailored for native devices */ - shouldUseNativeStyles: boolean; +type OnboardingPurposeProps = Record; - /** Whether to use the maxHeight (true) or use the 100% of the height (false) */ - shouldEnableMaxHeight: boolean; +type BaseOnboardingPurposeOnyxProps = { + /** Saved onboarding purpose selected by the user */ + onboardingPurposeSelected: OnyxEntry; }; -export type {BaseOnboardingPurposeProps, OnboardingPurposeProps}; +type BaseOnboardingPurposeProps = OnboardingPurposeProps & + BaseOnboardingPurposeOnyxProps & { + /* Whether to use native styles tailored for native devices */ + shouldUseNativeStyles: boolean; + + /** Whether to use the maxHeight (true) or use the 100% of the height (false) */ + shouldEnableMaxHeight: boolean; + }; + +export type {BaseOnboardingPurposeOnyxProps, BaseOnboardingPurposeProps, OnboardingPurposeProps}; diff --git a/src/pages/OnboardingWork/BaseOnboardingWork.tsx b/src/pages/OnboardingWork/BaseOnboardingWork.tsx index 14f9223f6c67..9b8824300d30 100644 --- a/src/pages/OnboardingWork/BaseOnboardingWork.tsx +++ b/src/pages/OnboardingWork/BaseOnboardingWork.tsx @@ -9,6 +9,7 @@ import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; import OfflineIndicator from '@components/OfflineIndicator'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape'; import useLocalize from '@hooks/useLocalize'; import useOnboardingLayout from '@hooks/useOnboardingLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -32,6 +33,8 @@ function BaseOnboardingWork({shouldUseNativeStyles, onboardingPurposeSelected, o const {isSmallScreenWidth} = useWindowDimensions(); const {shouldUseNarrowLayout} = useOnboardingLayout(); + useDisableModalDismissOnEscape(); + const completeEngagement = useCallback( (values: FormOnyxValues<'onboardingWorkForm'>) => { if (!onboardingPurposeSelected) { diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index a585f9c94d67..ea9746aaeb77 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -24,11 +24,11 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {ReportDetailsNavigatorParamList} from '@libs/Navigation/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; -import PaginationUtils from '@libs/PaginationUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -86,18 +86,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD // The app would crash due to subscribing to the entire report collection if parentReportID is an empty string. So we should have a fallback ID here. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID || '-1'}`); - const [sortedAllReportActions = []] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID || '-1'}`, { - canEvict: false, - selector: (allReportActions: OnyxEntry) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true), - }); - const [reportActionPages = []] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${report.reportID || '-1'}`); - - const reportActions = useMemo(() => { - if (!sortedAllReportActions.length) { - return []; - } - return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages, (reportAction) => reportAction.reportActionID); - }, [sortedAllReportActions, reportActionPages]); + const {reportActions} = usePaginatedReportActions(report.reportID || '-1'); const transactionThreadReportID = useMemo( () => ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, reportActions ?? [], isOffline), diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index bfeb46f06298..474cbfc0aff8 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -1,14 +1,22 @@ import React from 'react'; +import EmptyStateComponent from '@components/EmptyStateComponent'; import * as Illustrations from '@components/Icon/Illustrations'; -import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection'; +import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; function EmptySearchView() { const {translate} = useLocalize(); + const styles = useThemeStyles(); return ( - diff --git a/src/pages/Search/SearchPageBottomTab.tsx b/src/pages/Search/SearchPageBottomTab.tsx index eb662fd49046..9ae6423a6cc1 100644 --- a/src/pages/Search/SearchPageBottomTab.tsx +++ b/src/pages/Search/SearchPageBottomTab.tsx @@ -1,9 +1,10 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useMemo} from 'react'; +import React, {useMemo, useState} from 'react'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Search from '@components/Search'; -import useActiveRoute from '@hooks/useActiveRoute'; +import useActiveBottomTabRoute from '@hooks/useActiveBottomTabRoute'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -27,8 +28,9 @@ const defaultSearchProps = { function SearchPageBottomTab() { const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); - const activeRoute = useActiveRoute(); + const activeBottomTabRoute = useActiveBottomTabRoute(); const styles = useThemeStyles(); + const [isMobileSelectionModeActive, setIsMobileSelectionModeActive] = useState(false); const { query: rawQuery, @@ -36,11 +38,11 @@ function SearchPageBottomTab() { sortBy, sortOrder, } = useMemo(() => { - if (activeRoute?.name !== SCREENS.SEARCH.CENTRAL_PANE || !activeRoute.params) { + if (activeBottomTabRoute?.name !== SCREENS.SEARCH.CENTRAL_PANE || !activeBottomTabRoute.params) { return defaultSearchProps; } - return {...defaultSearchProps, ...activeRoute.params} as SearchPageProps['route']['params']; - }, [activeRoute]); + return {...defaultSearchProps, ...activeBottomTabRoute.params} as SearchPageProps['route']['params']; + }, [activeBottomTabRoute]); const query = rawQuery as SearchQuery; @@ -59,18 +61,29 @@ function SearchPageBottomTab() { onBackButtonPress={handleOnBackButtonPress} shouldShowLink={false} > - - + {!isMobileSelectionModeActive ? ( + <> + + + + ) : ( + setIsMobileSelectionModeActive(false)} + /> + )} {isSmallScreenWidth && ( )} diff --git a/src/pages/Search/SearchSelectedNarrow.tsx b/src/pages/Search/SearchSelectedNarrow.tsx new file mode 100644 index 000000000000..b90142ff5873 --- /dev/null +++ b/src/pages/Search/SearchSelectedNarrow.tsx @@ -0,0 +1,57 @@ +import React, {useRef, useState} from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import MenuItem from '@components/MenuItem'; +import Modal from '@components/Modal'; +import type {SearchHeaderOptionValue} from '@components/Search/SearchPageHeader'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as Expensicons from '@src/components/Icon/Expensicons'; +import CONST from '@src/CONST'; + +type SearchSelectedNarrowProps = {options: Array>; itemsLength: number}; + +function SearchSelectedNarrow({options, itemsLength}: SearchSelectedNarrowProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const [isModalVisible, setIsModalVisible] = useState(false); + const buttonRef = useRef(null); + + const openMenu = () => setIsModalVisible(true); + const closeMenu = () => setIsModalVisible(false); + + return ( + +