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/actions/composite/buildAndroidE2EAPK/action.yml b/.github/actions/composite/buildAndroidE2EAPK/action.yml index 47e13f6313a0..8995c7681d40 100644 --- a/.github/actions/composite/buildAndroidE2EAPK/action.yml +++ b/.github/actions/composite/buildAndroidE2EAPK/action.yml @@ -74,7 +74,7 @@ runs: shell: bash - name: Upload APK - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 + uses: actions/upload-artifact@v4 with: name: ${{ inputs.ARTIFACT_NAME }} path: ${{ inputs.APP_OUTPUT_PATH }} diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index ffce73644263..add4879d8de1 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -156,7 +156,7 @@ jobs: run: mkdir zip - name: Download baseline APK - uses: actions/download-artifact@348754975ef0295bfa2c111cba996120cfdf8a5d + uses: actions/download-artifact@v4 id: downloadBaselineAPK with: name: baseline-apk-${{ needs.buildBaseline.outputs.VERSION }} @@ -170,7 +170,7 @@ jobs: run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease.apk" - name: Download delta APK - uses: actions/download-artifact@348754975ef0295bfa2c111cba996120cfdf8a5d + uses: actions/download-artifact@v4 id: downloadDeltaAPK with: name: delta-apk-${{ needs.buildDelta.outputs.DELTA_REF }} @@ -184,7 +184,7 @@ jobs: - name: Copy e2e code into zip folder run: cp tests/e2e/dist/index.js zip/testRunner.ts - + - name: Copy profiler binaries into zip folder run: cp -r node_modules/@perf-profiler/android/cpp-profiler/bin zip/bin @@ -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/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 640d1eaa1172..e1534bfba1c7 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -94,14 +94,14 @@ jobs: VERSION: ${{ env.VERSION_CODE }} - name: Archive Android sourcemaps - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: android-sourcemap-${{ github.ref_name }} path: android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map - name: Upload Android version to GitHub artifacts if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: app-production-release.aab path: android/app/build/outputs/bundle/productionRelease/app-production-release.aab @@ -246,14 +246,14 @@ jobs: APPLE_DEMO_PASSWORD: ${{ secrets.APPLE_DEMO_PASSWORD }} - name: Archive iOS sourcemaps - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ios-sourcemap-${{ github.ref_name }} path: main.jsbundle.map - name: Upload iOS version to GitHub artifacts if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: New Expensify.ipa path: /Users/runner/work/App/App/New Expensify.ipa 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/testBuild.yml b/.github/workflows/testBuild.yml index 10912aaeb436..5d70c16c28e0 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -125,7 +125,7 @@ jobs: MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} - name: Upload Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: android path: ./android_paths.json @@ -217,7 +217,7 @@ jobs: S3_REGION: us-east-1 - name: Upload Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ios path: ./ios_paths.json @@ -321,7 +321,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} - name: Download Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} - name: Read JSONs with android paths 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 c53ad2cd3cc7..d6de6cf4fae0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -15,6 +15,7 @@ fullstory { org 'o-1WN56P-na1' enabledVariants 'all' logcatLevel 'debug' + recordOnStart false } react { @@ -107,8 +108,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009000512 - versionName "9.0.5-12" + versionCode 1009000603 + versionName "9.0.6-3" // 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/Request-the-Card.md b/docs/articles/expensify-classic/expensify-card/Request-the-Card.md index b65c66c986ad..1f412665fc2f 100644 --- a/docs/articles/expensify-classic/expensify-card/Request-the-Card.md +++ b/docs/articles/expensify-classic/expensify-card/Request-the-Card.md @@ -5,7 +5,8 @@ description: Details on requesting the Expensify Card as an employee To start using the Expensify Card, do the following: 1. **Enable Expensify Cards:** An admin must first enable the cards. Then, an admin can assign you a card by setting a limit, which allows access to the card. 2. **Request the Card:** - - If you haven’t been assigned a limit, look for the task on your account’s homepage that says, “Ask your admin for the card!” Use this task to message your admin team. + - If you haven’t been assigned a limit, look for the task on your account’s homepage that says, “Ask your admin for the card!” + - Completing that task will send an in-product notification to your admin team that you requested the card. - Once you’re assigned a card limit, you’ll receive an email notification. Click the link in the email to provide your shipping address on your account’s homepage. - Enter your address, and the physical card will be shipped within 3-5 business days. 3. **Activate the Card:** When your physical card arrives, activate it in Expensify by entering the last four digits of the card in the activation task on your homepage. 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/articles/expensify-classic/integrations/travel-integrations/Egencia.md b/docs/articles/expensify-classic/integrations/travel-integrations/Egencia.md index 178621a62d90..35d232d2df67 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Egencia.md +++ b/docs/articles/expensify-classic/integrations/travel-integrations/Egencia.md @@ -24,7 +24,7 @@ Egencia controls the feed, so to connect Expensify you will need to: # How to Connect to a Central Purchasing Account Once your Egencia account manager has established the feed, you can automatically forward all Egencia booking receipts to a single Expensify account. To do this: 1. Open a chat with Concierge. -2. Tell Concierge “Please enable Central Purchasing Account for our Egencia feed. The account email is: xxx@yourdomain.com”. +2. Tell Concierge the address of your central purchasing account, “Please enable Central Purchasing Account for our Egencia feed. The account email is: xxx@yourdomain.com”. -The receipt the traveler receives is a "reservation expense." Reservation expenses are non-reimbursable and won’t be included in any integrated accounting system exports. The reservation sent to the traveler's account is added to their mobile app Trips feature so that the traveler can easily keep tabs on upcoming travel and receive trip notifications. +A receipt will be sent to both the traveler and the central account. The receipt sent to the traveler is a "reservation expense." Reservation expenses are non-reimbursable and won’t be included in any integrated accounting system exports. diff --git a/docs/articles/new-expensify/travel/Expensify-Travel-demo-video.md b/docs/articles/new-expensify/travel/Expensify-Travel-demo-video.md new file mode 100644 index 000000000000..ceb40254c607 --- /dev/null +++ b/docs/articles/new-expensify/travel/Expensify-Travel-demo-video.md @@ -0,0 +1,8 @@ +--- +title: Expensify Travel demo video +description: Check out a demo of Expensify Travel +--- + +Check out a video of how Expensify Travel works below: + + 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 2ccce98e6a21..3e0b46a292a7 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.5 + 9.0.6 CFBundleSignature ???? CFBundleURLTypes @@ -40,11 +40,13 @@ CFBundleVersion - 9.0.5.12 + 9.0.6.3 FullStory OrgId o-1WN56P-na1 + RecordOnStart + ITSAppUsesNonExemptEncryption diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 8248e7db0454..d13eca4d1cad 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.5 + 9.0.6 CFBundleSignature ???? CFBundleVersion - 9.0.5.12 + 9.0.6.3 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 87cdb420af38..5125a598997f 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.5 + 9.0.6 CFBundleVersion - 9.0.5.12 + 9.0.6.3 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 c401dfe77198..45813001d07c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "new.expensify", - "version": "9.0.5-12", + "version": "9.0.6-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.5-12", + "version": "9.0.6-3", "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.39", "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.39", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.39.tgz", + "integrity": "sha512-HyW7MiS8+ZWO2xye5TSsiKJfIsaGl0M2RlI+txJNF9GWeroA6kaXybTY1Ppq+cS+a7+MU/KMQh7GNnIMrvkf+w==", "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 5420a3e886ef..1cd32c974031 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.5-12", + "version": "9.0.6-3", "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.39", "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 e56b19cee876..2dc0a0622349 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -335,6 +335,8 @@ const CONST = { VERIFICATION_MAX_ATTEMPTS: 7, STATE: { VERIFYING: 'VERIFYING', + VALIDATING: 'VALIDATING', + SETUP: 'SETUP', PENDING: 'PENDING', OPEN: 'OPEN', }, @@ -361,7 +363,6 @@ const CONST = { DEFAULT_ROOMS: 'defaultRooms', VIOLATIONS: 'violations', DUPE_DETECTION: 'dupeDetection', - REPORT_FIELDS: 'reportFields', P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests', WORKFLOWS_DELAYED_SUBMISSION: 'workflowsDelayedSubmission', SPOTNANA_TRAVEL: 'spotnanaTravel', @@ -668,6 +669,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 +842,8 @@ const CONST = { IOU: 'iou', TASK: 'task', INVOICE: 'invoice', + PAYCHECK: 'paycheck', + BILL: 'bill', }, CHAT_TYPE: chatTypes, WORKSPACE_CHAT_ROOMS: { @@ -1123,8 +1127,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 +1247,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, @@ -2146,6 +2148,7 @@ const CONST = { ACCESS_VARIANTS: { PAID: 'paid', ADMIN: 'admin', + CONTROL: 'control', }, DEFAULT_MAX_EXPENSE_AGE: 90, DEFAULT_MAX_EXPENSE_AMOUNT: 200000, @@ -5207,6 +5210,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: { @@ -5244,6 +5280,7 @@ const CONST = { }, }, + MAX_LENGTH_256: 256, WORKSPACE_CARDS_LIST_LABEL_TYPE: { CURRENT_BALANCE: 'currentBalance', REMAINING_LIMIT: 'remainingLimit', @@ -5251,6 +5288,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', @@ -5261,15 +5305,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/Expensify.tsx b/src/Expensify.tsx index f96c51961acc..6151f983e8d0 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -19,6 +19,7 @@ import * as Report from './libs/actions/Report'; import * as User from './libs/actions/User'; import * as ActiveClientManager from './libs/ActiveClientManager'; import BootSplash from './libs/BootSplash'; +import FS from './libs/Fullstory'; import * as Growl from './libs/Growl'; import Log from './libs/Log'; import migrateOnyx from './libs/migrateOnyx'; @@ -147,6 +148,9 @@ function Expensify({ // Initialize this client as being an active client ActiveClientManager.init(); + // Initialize Fullstory lib + FS.init(); + // Used for the offline indicator appearing when someone is offline const unsubscribeNetInfo = NetworkConnection.subscribeToNetInfo(); 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 cffa9051e859..4e94bb125c3d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -676,6 +676,15 @@ 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_CARD_RECONCILIATION: { + route: 'settings/workspaces/:policyID/accounting/:connection/card-reconciliation', + getRoute: (policyID: string, connection: ValueOf) => `settings/workspaces/${policyID}/accounting/${connection}/card-reconciliation` as const, + }, + WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS: { + route: 'settings/workspaces/:policyID/accounting/:connection/card-reconciliation/account', + getRoute: (policyID: string, connection: ValueOf) => + `settings/workspaces/${policyID}/accounting/${connection}/card-reconciliation/account` as const, + }, WORKSPACE_CATEGORIES: { route: 'settings/workspaces/:policyID/categories', getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories` as const, @@ -700,6 +709,14 @@ 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_PAYROLL_CODE: { + route: 'settings/workspaces/:policyID/categories/:categoryName/payroll-code', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/payroll-code` 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 +749,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 +818,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', @@ -830,6 +851,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/expensify-card/issue-new', getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card/issue-new` as const, }, + WORKSPACE_EXPENSIFY_CARD_BANK_ACCOUNT: { + route: 'settings/workspaces/:policyID/expensify-card/choose-bank-account', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card/choose-bank-account` as const, + }, WORKSPACE_DISTANCE_RATES: { route: 'settings/workspaces/:policyID/distance-rates', getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 50bc9ed6b63b..79dd38a653b7 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -331,6 +331,8 @@ 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', + CARD_RECONCILIATION: 'Policy_Accounting_Card_Reconciliation', + RECONCILIATION_ACCOUNT_SETTINGS: 'Policy_Accounting_Reconciliation_Account_Settings', }, INITIAL: 'Workspace_Initial', PROFILE: 'Workspace_Profile', @@ -341,6 +343,7 @@ const SCREENS = { RATE_AND_UNIT_UNIT: 'Workspace_RateAndUnit_Unit', EXPENSIFY_CARD: 'Workspace_ExpensifyCard', EXPENSIFY_CARD_ISSUE_NEW: 'Workspace_ExpensifyCard_New', + EXPENSIFY_CARD_BANK_ACCOUNT: 'Workspace_ExpensifyCard_BankAccount', BILLS: 'Workspace_Bills', INVOICES: 'Workspace_Invoices', TRAVEL: 'Workspace_Travel', @@ -354,7 +357,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', @@ -372,6 +375,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', @@ -384,6 +388,8 @@ const SCREENS = { NAME: 'Workspace_Profile_Name', CATEGORY_CREATE: 'Category_Create', CATEGORY_EDIT: 'Category_Edit', + CATEGORY_PAYROLL_CODE: 'Category_Payroll_Code', + 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..126c81961cee 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -1,6 +1,6 @@ import {useIsFocused} from '@react-navigation/native'; import type {ForwardedRef} from 'react'; -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import type {GestureResponderEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {ActivityIndicator, View} from 'react-native'; import Icon from '@components/Icon'; @@ -89,6 +89,9 @@ type ButtonProps = Partial & { /** Whether we should use the danger theme color */ danger?: boolean; + /** Whether we should display the button as a link */ + link?: boolean; + /** Should we remove the right border radius top + bottom? */ shouldRemoveRightBorderRadius?: boolean; @@ -118,6 +121,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 +208,8 @@ function Button( id = '', accessibilityLabel = '', isSplitButton = false, + link = false, + isContentCentered = false, ...rest }: ButtonProps, ref: ForwardedRef, @@ -209,6 +217,7 @@ function Button( const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const [isHovered, setIsHovered] = useState(false); const renderContent = () => { if ('children' in rest) { @@ -229,6 +238,10 @@ function Button( danger && styles.buttonDangerText, !!icon && styles.textAlignLeft, textStyles, + link && styles.link, + link && isHovered && StyleUtils.getColorStyle(theme.linkHover), + link && styles.fontWeightNormal, + link && styles.fontSizeLabel, ]} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} > @@ -239,7 +252,7 @@ function Button( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (icon || shouldShowRightIcon) { return ( - + {icon && ( @@ -339,6 +352,7 @@ function Button( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing text && shouldShowRightIcon ? styles.alignItemsStretch : undefined, innerStyles, + link && styles.bgTransparent, ]} hoverStyle={[ shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined, @@ -349,6 +363,8 @@ function Button( accessibilityLabel={accessibilityLabel} role={CONST.ROLE.BUTTON} hoverDimmingValue={1} + onHoverIn={() => setIsHovered(true)} + onHoverOut={() => setIsHovered(false)} > {renderContent()} {isLoading && ( diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index a41f983434d8..3889c8597843 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -116,12 +116,11 @@ function Composer( }, [shouldClear, onClear]); useEffect(() => { - setSelection((prevSelection) => { - if (!!prevSelection && selectionProp.start === prevSelection.start && selectionProp.end === prevSelection.end) { - return; - } - return selectionProp; - }); + if (!!selection && selectionProp.start === selection.start && selectionProp.end === selection.end) { + return; + } + setSelection(selectionProp); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [selectionProp]); /** 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/FlatList/index.tsx b/src/components/FlatList/index.tsx index d3e0459a11bb..b45a8418d9a3 100644 --- a/src/components/FlatList/index.tsx +++ b/src/components/FlatList/index.tsx @@ -150,10 +150,13 @@ function MVCPFlatList({maintainVisibleContentPosition, horizontal = false if (!isListRenderedRef.current) { return; } - requestAnimationFrame(() => { + const animationFrame = requestAnimationFrame(() => { prepareForMaintainVisibleContentPosition(); setupMutationObserver(); }); + return () => { + cancelAnimationFrame(animationFrame); + }; }, [prepareForMaintainVisibleContentPosition, setupMutationObserver]); const setMergedRef = useMergeRefs(scrollRef, ref); @@ -176,6 +179,7 @@ function MVCPFlatList({maintainVisibleContentPosition, horizontal = false const mutationObserver = mutationObserverRef.current; return () => { mutationObserver?.disconnect(); + mutationObserverRef.current = null; }; }, []); @@ -199,6 +203,10 @@ function MVCPFlatList({maintainVisibleContentPosition, horizontal = false ref={onRef} onLayout={(e) => { isListRenderedRef.current = true; + if (!mutationObserverRef.current) { + prepareForMaintainVisibleContentPosition(); + setupMutationObserver(); + } props.onLayout?.(e); }} /> 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/HybridAppMiddleware/index.ios.tsx b/src/components/HybridAppMiddleware/index.ios.tsx index 5b06e5626c6e..c348cc86c974 100644 --- a/src/components/HybridAppMiddleware/index.ios.tsx +++ b/src/components/HybridAppMiddleware/index.ios.tsx @@ -3,6 +3,7 @@ import {useContext, useEffect, useState} from 'react'; import {NativeEventEmitter, NativeModules} from 'react-native'; import type {NativeModule} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import {InitialURLContext} from '@components/InitialURLContextProvider'; import useExitTo from '@hooks/useExitTo'; import useSplashScreen from '@hooks/useSplashScreen'; @@ -14,12 +15,23 @@ import * as Welcome from '@userActions/Welcome'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {HybridAppRoute, Route} from '@src/ROUTES'; +import type {TryNewDot} from '@src/types/onyx'; type HybridAppMiddlewareProps = { authenticated: boolean; children: React.ReactNode; }; +const onboardingStatusSelector = (tryNewDot: OnyxEntry) => { + let completedHybridAppOnboarding = tryNewDot?.classicRedirect?.completedHybridAppOnboarding; + + if (typeof completedHybridAppOnboarding === 'string') { + completedHybridAppOnboarding = completedHybridAppOnboarding === 'true'; + } + + return completedHybridAppOnboarding; +}; + /* * HybridAppMiddleware is responsible for handling BootSplash visibility correctly. * It is crucial to make transitions between OldDot and NewDot look smooth. @@ -36,6 +48,20 @@ function HybridAppMiddleware({children, authenticated}: HybridAppMiddlewareProps const [isAccountLoading] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.isLoading ?? false}); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email}); + const [completedHybridAppOnboarding] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT, {selector: onboardingStatusSelector}); + + /** + * This useEffect tracks changes of `nvp_tryNewDot` value. + * We propagate it from OldDot to NewDot with native method due to limitations of old app. + */ + useEffect(() => { + if (completedHybridAppOnboarding === undefined) { + return; + } + + Log.info(`[HybridApp] Onboarding status has changed. Propagating new value to OldDot`, true, {completedHybridAppOnboarding}); + NativeModules.HybridAppModule.completeOnboarding(completedHybridAppOnboarding); + }, [completedHybridAppOnboarding]); // In iOS, the HybridApp defines the `onReturnToOldDot` event. // If we frequently transition from OldDot to NewDot during a single app lifecycle, diff --git a/src/components/HybridAppMiddleware/index.tsx b/src/components/HybridAppMiddleware/index.tsx index b8c72d9200ac..b3d346a1b65c 100644 --- a/src/components/HybridAppMiddleware/index.tsx +++ b/src/components/HybridAppMiddleware/index.tsx @@ -2,6 +2,7 @@ import type React from 'react'; import {useContext, useEffect, useState} from 'react'; import {NativeModules} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import {InitialURLContext} from '@components/InitialURLContextProvider'; import useExitTo from '@hooks/useExitTo'; import useSplashScreen from '@hooks/useSplashScreen'; @@ -13,12 +14,23 @@ import * as Welcome from '@userActions/Welcome'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {HybridAppRoute, Route} from '@src/ROUTES'; +import type {TryNewDot} from '@src/types/onyx'; type HybridAppMiddlewareProps = { authenticated: boolean; children: React.ReactNode; }; +const onboardingStatusSelector = (tryNewDot: OnyxEntry) => { + let completedHybridAppOnboarding = tryNewDot?.classicRedirect?.completedHybridAppOnboarding; + + if (typeof completedHybridAppOnboarding === 'string') { + completedHybridAppOnboarding = completedHybridAppOnboarding === 'true'; + } + + return completedHybridAppOnboarding; +}; + /* * HybridAppMiddleware is responsible for handling BootSplash visibility correctly. * It is crucial to make transitions between OldDot and NewDot look smooth. @@ -35,6 +47,20 @@ function HybridAppMiddleware({children, authenticated}: HybridAppMiddlewareProps const [isAccountLoading] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.isLoading ?? false}); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email}); + const [completedHybridAppOnboarding] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT, {selector: onboardingStatusSelector}); + + /** + * This useEffect tracks changes of `nvp_tryNewDot` value. + * We propagate it from OldDot to NewDot with native method due to limitations of old app. + */ + useEffect(() => { + if (completedHybridAppOnboarding === undefined) { + return; + } + + Log.info(`[HybridApp] Onboarding status has changed. Propagating new value to OldDot`, true, {completedHybridAppOnboarding}); + NativeModules.HybridAppModule.completeOnboarding(completedHybridAppOnboarding); + }, [completedHybridAppOnboarding]); // Save `exitTo` when we reach /transition route. // `exitTo` should always exist during OldDot -> NewDot transitions. 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/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 1fbd6a6b2630..772b1476b84a 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 @@ -689,7 +702,9 @@ function MoneyRequestConfirmationList({ return; } - playSound(SOUNDS.DONE); + if (shouldPlaySound) { + playSound(SOUNDS.DONE); + } setDidConfirm(true); onConfirm?.(selectedParticipants); } else { @@ -723,6 +738,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/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 0f97a3c4414f..25de3c2bba7a 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -1,7 +1,7 @@ import lodashIsEqual from 'lodash/isEqual'; import type {RefObject} from 'react'; import React, {useEffect, useRef, useState} from 'react'; -import {View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import type {ModalProps} from 'react-native-modal'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -227,7 +227,7 @@ function PopoverMenu({ iconFill={item.iconFill} contentFit={item.contentFit} title={item.text} - titleStyle={item.titleStyle} + titleStyle={StyleSheet.flatten([styles.flex1, item.titleStyle])} shouldCheckActionAllowedOnPress={false} description={item.description} numberOfLinesDescription={item.numberOfLinesDescription} 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}} > {!ReportUtils.isClosedExpenseReportWithNoExpenses(report) && ( <> - {ReportUtils.reportFieldsEnabled(report) && + {ReportUtils.isPaidGroupPolicyExpenseReport(report) && sortedPolicyReportFields.map((reportField) => { if (ReportUtils.isReportFieldOfTypeTitle(reportField)) { return null; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index a0d73a1b2844..618503e19c33 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -216,15 +216,14 @@ function MoneyRequestView({ merchantTitle = translate('iou.receiptStatusTitle'); amountTitle = translate('iou.receiptStatusTitle'); } + const saveBillable = useCallback( (newBillable: boolean) => { // If the value hasn't changed, don't request to save changes on the server and just close the modal if (newBillable === TransactionUtils.getBillable(transaction)) { - Navigation.dismissModal(); return; } IOU.updateMoneyRequestBillable(transaction?.transactionID ?? '-1', report?.reportID, newBillable, policy, policyTagList, policyCategories); - Navigation.dismissModal(); }, [transaction, report, policy, policyTagList, policyCategories], ); diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 4a145d4e79e9..a435a5723670 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -128,6 +128,7 @@ function ReportPreview({ const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [requestType, setRequestType] = useState(); const [nonHeldAmount, fullAmount] = ReportUtils.getNonHeldAndFullAmount(iouReport, policy); + const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(iouReport?.reportID ?? ''); const {isSmallScreenWidth} = useWindowDimensions(); const [paymentType, setPaymentType] = useState(); @@ -203,6 +204,18 @@ function ReportPreview({ } }; + const getSettlementAmount = () => { + if (hasOnlyHeldExpenses) { + return ''; + } + + if (ReportUtils.hasHeldExpenses(iouReport?.reportID) && canAllowSettlement) { + return nonHeldAmount; + } + + return CurrencyUtils.convertToDisplayString(reimbursableSpend, iouReport?.currency); + }; + const getDisplayAmount = (): string => { if (totalDisplaySpend) { return CurrencyUtils.convertToDisplayString(totalDisplaySpend, iouReport?.currency); @@ -405,7 +418,7 @@ function ReportPreview({ {shouldShowSettlementButton && ( {isHoldMenuVisible && iouReport && requestType !== undefined && ( { + // 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/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx index a5c46b82d94a..96f705ea2d52 100644 --- a/src/components/ReportWelcomeText.tsx +++ b/src/components/ReportWelcomeText.tsx @@ -7,6 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import SidebarUtils from '@libs/SidebarUtils'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -42,7 +43,7 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP const participantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report); const isMultipleParticipant = participantAccountIDs.length > 1; const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), isMultipleParticipant); - const roomWelcomeMessage = ReportUtils.getRoomWelcomeMessage(report); + const welcomeMessage = SidebarUtils.getWelcomeMessage(report, policy); const moneyRequestOptions = ReportUtils.temporary_getMoneyRequestOptions(report, policy, participantAccountIDs); const additionalText = moneyRequestOptions .filter((item): item is Exclude => item !== CONST.IOU.TYPE.INVOICE) @@ -86,47 +87,47 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP {isPolicyExpenseChat && - (policy?.description ? ( + (welcomeMessage?.messageHtml ? ( { if (!canEditPolicyDescription) { return; } - Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(policy.id)); + Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(policy?.id ?? '-1')); }} style={[styles.renderHTML, canEditPolicyDescription ? styles.cursorPointer : styles.cursorText]} accessibilityLabel={translate('reportDescriptionPage.roomDescription')} > - + ) : ( - {translate('reportActionsView.beginningOfChatHistoryPolicyExpenseChatPartOne')} + {welcomeMessage.phrase1} {ReportUtils.getDisplayNameForParticipant(report?.ownerAccountID)} - {translate('reportActionsView.beginningOfChatHistoryPolicyExpenseChatPartTwo')} + {welcomeMessage.phrase2} {ReportUtils.getPolicyName(report)} - {translate('reportActionsView.beginningOfChatHistoryPolicyExpenseChatPartThree')} + {welcomeMessage.phrase3} ))} {isChatRoom && - (report?.description ? ( + (welcomeMessage?.messageHtml ? ( { if (ReportUtils.canEditReportDescription(report, policy)) { - Navigation.navigate(ROUTES.REPORT_DESCRIPTION.getRoute(report.reportID)); + Navigation.navigate(ROUTES.REPORT_DESCRIPTION.getRoute(report?.reportID ?? '-1')); return; } - Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)); + Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report?.reportID ?? '-1')); }} style={styles.renderHTML} accessibilityLabel={translate('reportDescriptionPage.roomDescription')} > - + ) : ( - {roomWelcomeMessage.phrase1} - {roomWelcomeMessage.showReportName && ( + {welcomeMessage.phrase1} + {welcomeMessage.showReportName && ( )} - {roomWelcomeMessage.phrase2 !== undefined && {roomWelcomeMessage.phrase2}} + {welcomeMessage.phrase2 !== undefined && {welcomeMessage.phrase2}} ))} {isSelfDM && ( - {translate('reportActionsView.beginningOfChatHistorySelfDM')} + {welcomeMessage.phrase1} )} {isSystemChat && ( - {translate('reportActionsView.beginningOfChatHistorySystemDM')} + {welcomeMessage.phrase1} )} {isDefault && ( - {translate('reportActionsView.beginningOfChatHistory')} + {welcomeMessage.phrase1} {displayNamesWithTooltips.map(({displayName, accountID}, index) => ( // eslint-disable-next-line react/no-array-index-key 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/ActionCell.tsx b/src/components/SelectionList/Search/ActionCell.tsx index ad77070c1b99..7888a8b26114 100644 --- a/src/components/SelectionList/Search/ActionCell.tsx +++ b/src/components/SelectionList/Search/ActionCell.tsx @@ -31,9 +31,19 @@ type ActionCellProps = { isLargeScreenWidth?: boolean; isSelected?: boolean; goToItem: () => void; + isChildListItem?: boolean; + parentAction?: string; }; -function ActionCell({action = CONST.SEARCH.ACTION_TYPES.VIEW, transactionID, isLargeScreenWidth = true, isSelected = false, goToItem}: ActionCellProps) { +function ActionCell({ + action = CONST.SEARCH.ACTION_TYPES.VIEW, + transactionID, + isLargeScreenWidth = true, + isSelected = false, + goToItem, + isChildListItem = false, + parentAction = '', +}: ActionCellProps) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); @@ -53,13 +63,11 @@ function ActionCell({action = CONST.SEARCH.ACTION_TYPES.VIEW, transactionID, isL } }, [action, currentSearchHash, transactionID]); - if (!isLargeScreenWidth) { - return null; - } - const text = translate(actionTranslationsMap[action]); - if (action === CONST.SEARCH.ACTION_TYPES.PAID || action === CONST.SEARCH.ACTION_TYPES.DONE) { + const shouldUseViewAction = action === CONST.SEARCH.ACTION_TYPES.VIEW || (parentAction === CONST.SEARCH.ACTION_TYPES.PAID && action === CONST.SEARCH.ACTION_TYPES.PAID); + + if ((parentAction !== CONST.SEARCH.ACTION_TYPES.PAID && action === CONST.SEARCH.ACTION_TYPES.PAID) || action === CONST.SEARCH.ACTION_TYPES.DONE) { return ( + ) : null; + } + + if (action === CONST.SEARCH.ACTION_TYPES.REVIEW) { return (