diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index add4879d8de1..617a7a0abe05 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -278,7 +278,7 @@ jobs: status: custom custom_payload: | { - channel: '#newdot-performance', + channel: '#newdot-quality', attachments: [{ color: 'danger', text: `🔴 Performance regression detected in PR ${{ inputs.PR_NUMBER }}\nDetected in workflow.`, diff --git a/Gemfile b/Gemfile index 5227deb80865..d774392dbcb7 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ ruby ">= 2.6.10" gem "cocoapods", "= 1.15.2" gem "activesupport", ">= 6.1.7.3", "< 7.1.0" -gem "fastlane", "~> 2" +gem "fastlane", "~> 2", ">= 2.222.0" gem "xcpretty", "~> 0" diff --git a/android/app/build.gradle b/android/app/build.gradle index 2f83c52a1713..2491fb4cd7df 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -108,8 +108,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009001402 - versionName "9.0.14-2" + versionCode 1009001405 + versionName "9.0.14-5" // 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/filters.svg b/assets/images/filters.svg new file mode 100644 index 000000000000..ed0f1169d168 --- /dev/null +++ b/assets/images/filters.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__filters.svg b/assets/images/simple-illustrations/simple-illustration__filters.svg new file mode 100644 index 000000000000..c1d574f154f4 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__filters.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/_includes/section.html b/docs/_includes/section.html index cd48a40585be..6bb24adbb496 100644 --- a/docs/_includes/section.html +++ b/docs/_includes/section.html @@ -15,12 +15,10 @@

- {% if section.articles %} - {% assign sortedArticles = section.articles | sort: 'order', 'last' | default: 999 %} - {% for article in sortedArticles %} - {% assign article_href = section.href | append: '/' | append: article.href %} - {% include article-card.html hub=hub.href href=article_href title=article.title platform=activePlatform %} - {% endfor %} - {% endif %} + {% assign sortedArticles = section.articles %} + {% for article in sortedArticles %} + {% assign article_href = section.href | append: '/' | append: article.href %} + {% include article-card.html hub=hub.href href=article_href title=article.title platform=activePlatform %} + {% endfor %}
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Business-Bank-Accounts-AUD.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Business-Bank-Accounts-AUD.md deleted file mode 100644 index 8c5ead911da4..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/Business-Bank-Accounts-AUD.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: Add a Business Bank Account -description: This article provides insight on setting up and using an Australian Business Bank account in Expensify. ---- - -# How to add an Australian business bank account (for admins) -A withdrawal account is the business bank account that you want to use to pay your employee reimbursements. - -_Your policy currency must be set to AUD and reimbursement setting set to Indirect to continue. If your main policy is used for something other than AUD, then you will need to create a new one and set that policy to AUD._ - -To set this up, you’ll run through the following steps: - -1. Go to **Settings > Your Account > Payments** and click **Add Verified Bank Account** -![Click the Verified Bank Account button in the bottom right-hand corner of the screen](https://help.expensify.com/assets/images/add-vba-australian-account.png){:width="100%"} - -2. Enter the required information to connect to your business bank account. If you don't know your Bank User ID/Direct Entry ID/APCA Number, please contact your bank and they will be able to provide this. -![Enter your information in each of the required fields](https://help.expensify.com/assets/images/add-vba-australian-account-modal.png){:width="100%"} - -3. Link the withdrawal account to your policy by heading to **Settings > Policies > Group > [Policy name] > Reimbursement** -4. Click **Direct reimbursement** -5. Set the default withdrawal account for processing reimbursements -6. Tell your employees to add their deposit accounts and start reimbursing. - -# How to delete a bank account -If you’re no longer using a bank account you previously connected to Expensify, you can delete it by doing the following: - -1. Navigate to Settings > Accounts > Payments -2. Click **Delete** -![Click the Delete button](https://help.expensify.com/assets/images/delete-australian-bank-account.png){:width="100%"} - -You can complete this process either via the web app (on a computer), or via the mobile app. - -# Deep Dive -## Bank-specific batch payment support - -If you are new to using Batch Payments in Australia, to reimburse your staff or process payroll, you may want to check out these bank-specific instructions for how to upload your .aba file: - -- ANZ Bank - [Import a file for payroll payments](https://www.anz.com.au/support/internet-banking/pay-transfer-business/payroll/import-file/) -- CommBank - [Importing and using Direct Entry (EFT) files](https://www.commbank.com.au/business/pds/003-279-importing-a-de-file.pdf) -- Westpac - [Importing Payment Files](https://www.westpac.com.au/business-banking/online-banking/support-faqs/import-files/) -- NAB - [Quick Reference Guide - Upload a payment file](https://www.nab.com.au/business/online-banking/nab-connect/help) -- Bendigo Bank - [Bulk payments user guide](https://www.bendigobank.com.au/globalassets/documents/business/bulk-payments-user-guide.pdf) -- Bank of Queensland - [Payments file upload facility FAQ](https://www.boq.com.au/help-and-support/online-banking/ob-faqs-and-support/faq-pfuf) - -**Note:** Some financial institutions require an ABA file to include a *self-balancing transaction*. If you are unsure, please check with your bank to ensure whether to tick this option or not, as selecting an incorrect option will result in the ABA file not working with your bank's internet banking platform. - -## Enable Global Reimbursement - -If you have employees in other countries outside of Australia, you can now reimburse them directly using Global Reimbursement. - -To do this, you’ll first need to delete any existing Australian business bank accounts. Then, you’ll want to follow the instructions to enable Global Reimbursements diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Business-Bank-Accounts-USD.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Business-Bank-Accounts-USD.md deleted file mode 100644 index 4ae2c669561f..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/Business-Bank-Accounts-USD.md +++ /dev/null @@ -1,161 +0,0 @@ ---- -title: Business Bank Accounts - USD -description: How to add/remove Business Bank Accounts (US) ---- -# Overview -Adding a verified business bank account unlocks a myriad of features and automation in Expensify. -Once you connect your business bank account, you can: -- Pay employee expense reports via direct deposit (US) -- Settle company bills via direct transfer -- Accept invoice payments through direct transfer -- Access the Expensify Card - -# How to add a verified business bank account -To connect a business bank account to Expensify, follow the below steps: -1. Go to **Settings > Account > Payments** -2. Click **Add Verified Bank Account** -3. Click **Log into your bank** -4. Click **Continue** -5. When you hit the **Plaid** screen, you'll be shown a list of compatible banks that offer direct online login access -6. Login to the business bank account -- If the bank is not listed, click the X to go back to the connection type -- Here you’ll see the option to **Connect Manually** -- Enter your account and routing numbers -7. Enter your bank login credentials. -- If your bank requires additional security measures, you will be directed to obtain and enter a security code -- If you have more than one account available to choose from, you will be directed to choose the desired account -Next, to verify the bank account, you’ll enter some details about the business as well as some personal information. - -## Enter company information -This is where you’ll add the legal business name as well as several other company details. - -### Company address -The company address must: -- Be located in the US -- Be a physical location -If you input a maildrop address (PO box, UPS Store, etc.), the address will likely be flagged for review and adding the bank account to Expensify will be delayed. - -### Tax Identification Number -This is the identification number that was assigned to the business by the IRS. -### Company website -A company website is required to use most of Expensify’s payment features. When adding the website of the business, format it as, https://www.domain.com. -### Industry Classification Code -You can locate a list of Industry Classification Codes here. -## Enter personal information -Whoever is connecting the bank account to Expensify, must enter their details under the Requestor Information section: -- The address must be a physical address -- The address must be located in the US -- The SSN must be US-issued -This does not need to be a signor on the bank account. If someone other than the Expensify account holder enters their personal information in this section, the details will be flagged for review and adding the bank account to Expensify will be delayed. - -## Upload ID -After entering your personal details, you’ll be prompted to click a link or scan a QR code so that you can do the following: -1. Upload the front and back of your ID -2. Use your device to take a selfie and record a short video of yourself -It’s required that your ID is: -- Issued in the US -- Unexpired - -## Additional Information -Check the appropriate box under **Additional Information**, accept the agreement terms, and verify that all of the information is true and accurate: -- A Beneficial Owner refers to an **individual** who owns 25% or more of the business. -- If you or another **individual** owns 25% or more of the business, please check the appropriate box -- If someone else owns 25% or more of the business, you will be prompted to provide their personal information -If no individual owns more than 25% of the company you do not need to list any beneficial owners. In that case, be sure to leave both boxes unchecked under the Beneficial Owner Additional Information section. - -# How to validate the bank account -The account you set up can be found under **Settings > Account > Payment > Bank Accounts** section in either **Verifying** or **Pending** status. -If it is **Verifying**, then this means we sent you a message and need more information from you. Please check your Concierge chat which should include a message with specific details about what we require to move forward. -If it is **Pending**, then in 1-2 business days Expensify will administer 3 test transactions to your bank account. Please check your Concierge chat for further instructions. If you do not see these test transactions -After these transactions (2 withdrawals and 1 deposit) have been processed in your account, visit your Expensify Inbox, where you'll see a prompt to input the transaction amounts. -Once you've finished these steps, your business bank account is ready to use in Expensify! - -# How to share a verified bank account -Only admins with access to the verified bank account can reimburse employees or pay vendor bills. To grant another admin access to the bank account in Expensify, go to **Settings > Account > Payments > Bank Accounts** and click **"Share"**. Enter their email address, and they will receive instructions from us. Please note, they must be a policy admin on a policy you also have access to in order to share the bank account with them. -When a bank account is shared, it must be revalidated with three new microtransactions to ensure the shared admin has access. This process takes 1-2 business days. Once received, the shared admin can enter the transactions via their Expensify account's Inbox tab. - -Note: A report is shared with all individuals with access to the same business bank account in Expensify for audit purposes. - - -# How to remove access to a verified bank account -This step is important when accountants and staff leave your business. -To remove an admin's access to a shared bank account, go to **Settings > Account > Payments > Shared Business Bank Accounts**. -You'll find a list of individuals who have access to the bank account. Next to each user, you'll see the option to Unshare the bank account. - -# How to delete a verified bank account -If you need to delete a bank account from Expensify, run through the following steps: -1. Head to **Settings > Account > Payments** -2. Click the red **Delete** button under the corresponding bank account - -Be cautious, as if it hasn't been shared with someone else, the next user will need to set it up from the beginning. - -If the bank account is set as the settlement account for your Expensify Cards, you’ll need to designate another bank account as your settlement account under **Settings > Domains > Company Cards > Settings** before this account can be deleted. - -# Deep Dive - -## Verified bank account requirements - -To add a business bank account to issue reimbursements via ACH (US), to pay invoices (US) or utilize the Expensify Card: -- You must enter a physical address for yourself, any Beneficial Owner (if one exists), and the business associated with the bank account. We **cannot** accept a PO Box or MailDrop location. -- If you are adding the bank account to Expensify, you must add it from **your** Expensify account settings. -- If you are adding a bank account to Expensify, we are required by law to verify your identity. Part of this process requires you to verify a US issued photo ID. For utilizing features related to US ACH, your idea must be issued by the United States. You and any Beneficial Owner (if one exists), must also have a US address -- You must have a valid website for your business to utilize the Expensify Card, or to pay invoices with Expensify. - -## Locked bank account -When you reimburse a report, you authorize Expensify to withdraw the funds from your account. If your bank rejects Expensify’s withdrawal request, your verified bank account is locked until the issue is resolved. - -Withdrawal requests can be rejected due to insufficient funds, or if the bank account has not been enabled for direct debit. -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) -To request to unlock the bank account, click **Fix** on your bank account under **Settings > Account > Payments > Bank Accounts**. -This sends a request to our support team to review exactly why the bank account was locked. -Please note, unlocking a bank account can take 4-5 business days to process. - -## Error adding ID to Onfido -Expensify is required by both our sponsor bank and federal law to verify the identity of the individual that is initiating the movement of money. We use Onfido to confirm that the person adding a payment method is genuine and not impersonating someone else. - -If you get a generic error message that indicates, "Something's gone wrong", please go through the following steps: - -1. Ensure you are using either Safari (on iPhone) or Chrome (on Android) as your web browser. -2. Check your browser's permissions to make sure that the camera and microphone settings are set to "Allow" -3. Clear your web cache for Safari (on iPhone) or Chrome (on Android). -4. If using a corporate Wi-Fi network, confirm that your corporate firewall isn't blocking the website. -5. Make sure no other apps are overlapping your screen, such as the Facebook Messenger bubble, while recording the video. -6. On iPhone, if using iOS version 15 or later, disable the Hide IP address feature in Safari. -7. If possible, try these steps on another device -8. If you have another phone available, try to follow these steps on that device -If the issue persists, please contact your Account Manager or Concierge for further troubleshooting assistance. - -{% include faq-begin.md %} -## What is a Beneficial Owner? - -A Beneficial Owner refers to an **individual** who owns 25% or more of the business. If no individual owns 25% or more of the business, the company does not have a Beneficial Owner. - - -## What do I do if the Beneficial Owner section only asks for personal details, but our business is owned by another company? - - -Please only indicate you have a Beneficial Owner, if it is an individual that owns 25% or more of the business. - -## Why can’t I input my address or upload my ID? - - -Are you entering a US address? When adding a verified business bank account in Expensify, the individual adding the account, and any beneficial owner (if one exists) are required to have a US address, US photo ID, and a US SSN. If you do not meet these requirements, you’ll need to have another admin add the bank account, and then share access with you once verified. - - -## Why am I being asked for documentation when adding my bank account? -When a bank account is added to Expensify, we complete a series of checks to verify the information provided to us. We conduct these checks to comply with both our sponsor bank's requirements and federal government regulations, specifically the Bank Secrecy Act / Anti-Money Laundering (BSA / AML) laws. Expensify also has anti-fraud measures in place. -If automatic verification fails, we may request manual verification, which could involve documents such as address verification for your business, a letter from your bank confirming bank account ownership, etc. - -If you have any questions regarding the documentation request you received, please contact Concierge and they will be happy to assist. - - -## I don’t see all three microtransactions I need to validate my bank account. What should I do? - -It's a good idea to wait till the end of that second business day. If you still don’t see them, please reach out to your bank and ask them to whitelist our ACH ID's **1270239450** and **4270239450**. Expensify’s ACH Originator Name is "Expensify". - -Make sure to reach out to your Account Manager or to Concierge once you have done so and our team will be able to re-trigger those 3 transactions! - - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Deposit-Accounts-AUD.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Deposit-Accounts-AUD.md deleted file mode 100644 index e274cb3d5b60..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/Deposit-Accounts-AUD.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: Deposit Accounts (AUD) -description: Expensify allows you to add a personal bank account to receive reimbursements for your expenses. We never take money out of this account — it is only a place for us to deposit funds from your employer. This article covers deposit accounts for Australian banks. ---- - -## How-to add your Australian personal deposit account information -1. Confirm with your Policy Admin that they’ve set up Global Reimbursment -2. Set your default policy (by selecting the correct policy after clicking on your profile picture) before adding your deposit account. -3. Go to **Settings > Account > Payments** and click **Add Deposit-Only Bank Account** -![Click the Add Deposit-Only Bank Account button](https://help.expensify.com/assets/images/add-australian-deposit-only-account.png){:width="100%"} - -4. Enter your BSB, account number and name. If your screen looks different than the image below, that means your company hasn't enabled reimbursements through Expensify. Please contact your administrator and ask them to enable reimbursements. - -![Fill in the required fields](https://help.expensify.com/assets/images/add-australian-deposit-only-account-modal.png){:width="100%"} - -# How-to delete a bank account -Bank accounts are easy to delete! Simply click the red **Delete** button in the bank account under **Settings > Account > Payments**. - -![Click the Delete button](https://help.expensify.com/assets/images/delete-australian-bank-account.png){:width="100%"} - -You can complete this process on a computer or on the mobile app. diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Deposit-Accounts-USD.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Deposit-Accounts-USD.md deleted file mode 100644 index 0e195d5e3f1c..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/Deposit-Accounts-USD.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: Deposit Accounts - USD -description: How to add a deposit account to receive payments for yourself or your business (US) ---- -# Overview - -There are two types of deposit-only accounts: - -1. If you're an employee seeking reimbursement for expenses you’ve incurred, you’ll add a **Personal deposit-only bank account**. -2. If you're a vendor seeking payment for goods or services, you’ll add a **Business deposit-only account**. - -# How to connect a personal deposit-only bank account - -**Connect a personal deposit-only bank account if you are:** - -- An employee based in the US who gets reimbursed by their employer -- An employee based in Australia who gets reimbursed by their company via batch payments -- An international (non-US) employee whose US-based employers send international reimbursements - -**To establish the connection to a personal bank account, follow these steps:** - -1. Navigate to your **Settings > Account > Payments** and click the **Add Deposit-Only Bank Account** button. -2. Click **Log into your bank** button and click **Continue** on the Plaid connection pop-up window. -3. Search for your bank account in the list of banks and follow the prompts to sign-in to your bank account. -4. Enter your bank login credentials when prompted. - - If your bank doesn't appear, click the 'x' in the upper right corner of the Plaid pop-up window and click **Connect Manually**. - - Enter your account information, then click **Save & Continue**. - -You should be all set! You’ll receive reimbursement for your expense reports directly to this bank account. - -# How to connect a business deposit-only bank account - -**Connect a business deposit-only bank account if you are:** - -- A US-based vendor who wants to be paid directly for bills sent to customers/clients -- A US-based vendor who wants to pay invoices directly via Expensify - -**To establish the connection to a business bank account, follow these steps:** - -1. Navigate to your **Settings > Account > Payments and click the Add Deposit-Only Bank Account** button. -2. Click **Log into your bank** button and click **Continue** on the Plaid connection pop-up window. -3. Search for your bank account in the list of banks and follow the prompts to sign-in to your bank account. -4. Enter your bank login credentials when prompted. - - If your bank doesn't appear, click the 'x' in the upper right corner of the Plaid pop-up window and click **Connect Manually**. - - Enter your account information, then click **Save & Continue**. -5. If you see the option to “Switch to Business” after entering the account owner information, click that link. -6. Enter your Company Name and FEIN or TIN information. -7. Enter your company’s website formatted as https://www.domain.com. - -You should be all set! The bank account will display as a deposit-only business account, and you’ll be paid directly for any invoices you submit for payment. - -# How to delete a deposit-only bank account - -**To delete a deposit-only bank account, do the following:** - -1. Navigate to **Settings > Account > Payments > Bank Accounts** -2. Click the **Delete** next to the bank account you want to remove - -{% include faq-begin.md %} - -## **What happens if my bank requires an additional security check before adding it to a third-party?** - -If your bank account has 2FA enabled or another security step, you should be prompted to complete this when adding the account. If not, and you encounter an error, you can always select the option to “Connect Manually”. Either way, please double check that you are entering the correct bank account details to ensure successful payments. - -## **What if I also want to pay employees with my business bank account?** - -If you’ve added a business deposit-only account and also wish to also pay employees, vendors, or utilize the Expensify Card with this bank account, select “Verify” on the listed bank account. This will take you through the additional verification steps to use this account to issue payments. - -## **I connected my deposit-only bank account – Why haven’t I received my reimbursement?** - -There are a few reasons a reimbursement may be unsuccessful. The first step is to review the estimated deposit date on the report. If it’s after that date and you still haven’t seen the funds, it could have been unsuccessful because: - - The incorrect account was added. If you believe you may have entered the wrong account, please reach out to Concierge and provide the Report ID for the missing reimbursement. - - Your account wasn’t set up for Direct Deposit/ACH. You may want to contact your bank to confirm. - -If you aren’t sure, please reach out to Concierge and we can assist! - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Global-Reimbursements.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Global-Reimbursements.md deleted file mode 100644 index 0fd47f1341fa..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/Global-Reimbursements.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: International Reimbursements -description: International Reimbursements ---- -# Overview - -If your company’s business bank account is in the US, Canada, the UK, Europe, or Australia, you now have the option to send direct reimbursements to nearly any country across the globe! -The process to enable global reimbursements is dependent on the currency of your reimbursement bank account, so be sure to review the corresponding instructions below. - -# How to verify the bank account for sending international payments - -The steps for USD accounts and non-USD accounts differ slightly. - -## The reimbursement account is in USD - -First, confirm the workspace settings are set up correctly by doing the following: -1. Head to **Settings > Workspaces > Group > _[Workspace Name]_ > Reports** and check that the workspace currency is USD -2. Under **Settings > Workspaces > Group > _[Workspace Name]_ > Reimbursements**, set the reimbursement method to direct -3. Under **Settings > Workspaces > Group > _[Workspace Name]_ > Reimbursements**, set the USD bank account to the default account - -Once that’s all set, head to **Settings > Account > Payments**, and click **Enable Global Reimbursement** on the bank account. - -From there, you’ll fill out a form via DocuSign. Once the form is complete, it is automatically sent to our Compliance Team for review. If additional information is required, our Support Team will contact you with more details. - -## The reimbursement account is in AUD, CAD, GBP, EUR - -First, confirm the workspace currency corresponds with the currency of the reimbursement bank account. You can do this under **Settings > Workspaces > Group > _[Workspace Name]_ > Reports**. It should be AUD, CAD, GBP, or EUR. - -Next, add the bank account to Expensify: -1. Head to **Settings > Workspaces > Group > _[Workspace Name]_ > Reimbursements** and set the reimbursement method to direct (this button may not show for up to 60 minutes after the Expensify team confirms international reimbursements are available on your account) -2. Click **Add Business Bank Account** -3. If the incorrect country shows as the default, click **Switch Country** to select the correct country -4. Enter the bank account details -5. Click **Save & Continue** - -From there, you’ll fill out a form via DocuSign. Once the form is complete, it is automatically sent to our Compliance Team for review. If additional information is required, our Support Team will contact you with more details. - -# How to start reimbursing internationally - -After the bank account is verified for international payments, set the correct bank account as the reimbursement account. - -You can do this under **Settings > Workspaces > Group > _[Workspace Name]_ > Reimbursements** by selecting the reimbursement account as the default account. - -Finally, have your employees add their deposit-only bank accounts. They can do this by logging into their Expensify accounts, heading to **Settings > Account > Payments**, and clicking **Add Deposit-Only Bank Account**. - -# Deep Dive - -## Documents requested - -Our Compliance Team may ask for additional information depending on who initiates the verification or what information you provide on the DocuSign form. - -Examples of additional requested information: -- The reimburser’s proof of address and ID -- Company directors’ proofs of address and IDs -- An authorization letter -- An independently certified documentation such as shareholder agreement from a lawyer, notary, or public accountant if an individual owns more than 25% of the company - -{% include faq-begin.md %} - -## How many people can send reimbursements internationally? - -Once your company is authorized to send global payments, the individual who verified the bank account can share it with additional admins on the workspace. That way, multiple workspace members can send international reimbursements. - -## How long does it take to verify an account for international payments? - -It varies! The verification process can take a few business days to several weeks. It depends on whether or not the information in the DocuSign form is correct if our Compliance Team requires any additional information, and how responsive the employee verifying the company’s details is to our requests. - -## If I already have a USD bank account connected to Expensify, do I need to go through the verification process again to enable international payments? - -If you’ve already connected a US business bank account, you can request to enable global reimbursements by contacting Expensify Support immediately. However, additional steps are required to verify the bank account for international payments. - -## My employee says they don’t have the option to add their non-USD bank account as a deposit account – what should they do? - -Have the employee double-check that their default workspace is set as the workspace that's connected to the bank you're using to send international payments. - -An employee can confirm their default workspace is under **Settings > Workspaces > Group**. The default workspace has a green checkmark next to it. They can change their default workspace by clicking **Default Workspace** on the correct workspace. - -## Who is the “Authorized User” on the International Reimbursement DocuSign form? - -This is the person who will process international reimbursements. The authorized user should be the same person who manages the bank account connection in Expensify. - -## Who should I enter as the “User” on the International Reimbursement form? - -You can leave this form section blank since the “User” is Expensify. - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Add-Personal-Australian-Bank-Account.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Add-Personal-Australian-Bank-Account.md new file mode 100644 index 000000000000..84d8cb6b10a1 --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Add-Personal-Australian-Bank-Account.md @@ -0,0 +1,33 @@ +--- +title: Add personal Australian bank account +description: Receive AUD reimbursements from an Australian employer by adding your banking information +--- +
+ +{% include info.html %} +The workspace must be set to AUD to use ABA batch reimbursements. For businesses that will also be reimbursing employees in other countries outside of Australia, you’ll need to set up Global Reimbursement instead. +{% include end-info.html %} + +Australian employees can connect a personal deposit-only bank account to receive reimbursements for their expense reports. + +1. Click your profile picture and select the workspace you want to set as your default workspace. +2. Hover over **Settings**, then click **Account**. +3. Click the **Payments** tab on the left. +4. Click **Add Deposit-Only Bank Account**. +5. Enter the company information. + - Enter the account holder’s name, address, city, and country. + - Enter the swift code. + - Enter the bank’s name, address, and city. + - Enter the account number. + - Enter the BSB number. + +{% include info.html %} +If you don’t know your Bank User ID/Direct Entry ID/APCA Number, contact your bank for this information. + +If your screen does not contain the listed fields, your company hasn’t enabled reimbursements through Expensify. Contact your administrator for next steps. +{% include end-info.html %} + +{:start="6"} +6. Click **Save & Continue**. + +
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account.md new file mode 100644 index 000000000000..402337140419 --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account.md @@ -0,0 +1,37 @@ +--- +title: Connect personal U.S. bank account +description: Receive reimbursements for expense reports submitted to your employer +--- +
+ +Employees can connect a personal deposit-only bank account to receive reimbursements for their expense reports. + +To connect a deposit-only account, + +1. Hover over **Settings**, then click **Account**. +2. Click the **Payments** tab on the left. +3. Click **Add Deposit-Only Bank Account**, then click **Connect to your bank**. +4. Click **Continue**. +5. Search for your bank account in the list of banks and follow the prompts to sign in to your bank account. + - If your bank doesn’t appear, click the X in the right corner of the Plaid pop-up window, then click **Connect Manually**. You’ll then manually enter your account information and click **Save & Continue**. +6. Click **Save & Continue**. +7. Enter the name, address, and phone number associated with the account. Then click **Save & Continue**. + +You’ll now receive reimbursements for your expense reports and invoices directly to this bank account. + +{% include faq-begin.md %} + +**I connected my deposit-only bank account. Why haven’t I received my reimbursement?** + +There are a few reasons why you might not have received a reimbursement: +- The estimated deposit date on the report has not arrived yet. +- The bank account information is incorrect. If you believe you may have entered the wrong account, contact the Concierge and provide the Report ID for the missing reimbursement. +- Your account wasn’t set up for Direct Deposit/ACH. You can contact your bank to confirm. + +**What happens if my bank requires an additional security check before adding it to a third party?** + +If your bank account has two-factor authentication (2FA) or another security step enabled, you should be prompted to complete this authentication step when connecting the account to Expensify. However, if you encounter an error during this process, you can close the pop-up window and select Connect Manually to add the account manually. + +{% include faq-end.md %} + +
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md new file mode 100644 index 000000000000..99b904b23344 --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md @@ -0,0 +1,152 @@ +--- +title: Connect U.S. business bank account +description: Receive business payments for invoices by connecting a deposit-only business bank account +--- +
+ +You can choose to connect either a business deposit-only account that only receives payments or a verified business account that can send and receive payments: + +| Business deposit-only account | Verified business account | +|---------------------------------------------------|------------------------------------------------------| +| ✔ Receive reimbursements for invoices | ✔ Reimburse expenses via direct bank transfer | +| | ✔ Pay bills | +| | ✔ Issue Expensify Cards | + +# Connect a business deposit-only account + +1. Hover over **Settings**, then click **Account**. +2. Click the **Payments** tab on the left. +3. Click **Add Deposit-Only Bank Account**, then click **Connect to your bank**. +4. Click **Continue**. +5. Select your bank from the list of compatible banks and log in to your account. + - If your bank is not listed, click the X to go back to the connection type. Select **Connect Manually**, enter your account details, select the checkbox at the bottom, then click **Save & Continue**. +6. Click **Allow**. +7. Ensure the correct account is selected and click **Save & Continue**. +8. Add your bank account information, select the checkbox at the bottom, then click **Save & Continue**. + +{% include info.html %} +If the default account type is incorrect, you'll need to change from personal to business or vice versa by clicking "Switch to Business" or "Switch to Personal." The account type cannot be changed once the account is added. +{% include end-info.html %} + +{:start="9"} +9. Enter the name, address, and phone number associated with the account. Then click **Save & Continue**. + +You’ll now receive reimbursements for invoices directly to this bank account. + +# Connect a verified business account + +{% include info.html %} +The person who completes this process does not need to be a signer on the account, however, they will be required to enter their own personal information as well. If someone other than the Expensify account holder enters their personal information in this section, the details will be flagged for review, and adding the bank account to Expensify will be delayed. +{% include end-info.html %} + +## Step 1: Connect a bank account + +1. Click your profile picture and select the workspace you want to set as your default workspace. +2. Hover over **Settings**, then click **Account**. +3. Click the **Payments** tab on the left. +4. Click **Add Verified Bank Account**. +5. Click **Connect to your bank**. +6. Click **Continue**. +7. Enter your phone number and click **Continue**. +8. Select your bank from the list of compatible banks and log in to your account. + - If your bank is not listed, click the X to go back to the connection type. Select **Connect Manually**, enter your account details, select the checkbox at the bottom, then click **Save & Continue**. +9. Click **Allow**. +10. Ensure the correct account is selected and click **Save & Continue**. +11. Enter the company information. + - Enter the legal business name. + - Enter the physical company address. It must be in the U.S. and cannot be a maildrop address or P.O. boxes. + - Enter a company phone number. + - Enter the company website, formatted like https://www.expensify.com. Your business must have a valid website in order to use the + - Expensify Card or pay invoices with Expensify. + - Enter the Tax Identification Number (TIN). + - Enter the company incorporation type, date, and state. + - Enter the Industry Classification Code. You can locate a list of Industry Classification Codes [here](https://www.sec.gov/corpfin/division-of-corporation-finance-standard-industrial-classification-sic-code-list). + +{% include info.html %} +If the default account type is incorrect, you'll need to change from personal to business or vice versa by clicking "Switch to Business" or "Switch to Personal." The account type cannot be changed once the account is added. +{% include end-info.html %} + +{:start="12"} + +12. Enter your personal information into the Requestor Information section, including your physical U.S. address and SSN issued from the U.S. Then click **Save & Continue**. +13. Verify your identity by uploading a photo of your ID, passport, or identity card. It must be issued by the U.S. and be current (i.e., the expiration date must be in the future). +14. Upload a short video of yourself. +15. If applicable, check the appropriate checkboxes under Additional Information. + - A Beneficial Owner is an individual who owns 25% or more of the business. If you or someone else is a Beneficial Owner, check the appropriate box. If someone else is a Beneficial Owner, their personal information will need to be provided as well. + - If you are a non-profit organization and no individuals own more than 25% of the company, you do not need to list any beneficial owners. In that case, leave both boxes unchecked. +16. Select the checkboxes under Agreement. +17. Click **Save & Continue**. + +## Step 2: Verify the bank account + +Within 1-2 business days, Expensify will send three test transactions to your bank account that you’ll enter into Expensify to validate your bank account. You can confirm these amounts by doing either of the following: + - Click the validate task from Concierge on your Home page. + - Hover over **Settings** and click **Account**. Then click the **Payments** tab and click Enter test transactions. Then enter the three amounts and click **Validate**. + +{% include info.html %} +If you do not see these test transactions after two business days, click the green chat bubble in the right corner to get support from the Concierge. +{% include end-info.html %} + +## Step 3: Add the bank account to your Workspace + +1. Hover over **Settings**, then click **Workspaces**. +2. Click the desired workspace name. +3. Click the **Reimbursement** tab. +4. Click the **Direct** box, then click **Add Business Bank Account** and select the account. + +{% include faq-begin.md %} + +**I received a “something’s gone wrong” error while trying to add my ID to Onfido.** + +If you receive an error message during this process, check all of the following: +- Ensure you are using either the Safari (on iPhone) or Chrome (on Android) browser. +- Check the browser’s permissions to ensure the camera and microphone settings are set to Allow. +- Clear your web cache. +- If using a corporate Wi-Fi network, confirm that your corporate firewall isn’t blocking the website. +- While recording your video, ensure no other apps are overlapping your screen (such as the Facebook Messenger bubble). +- If using iOS version 15 or later on iPhone, disable the Hide IP address feature in Safari. + +If the issue persists, follow these steps on a different device, if possible. Contact your Account Manager or Concierge for further troubleshooting assistance. + +**Should I add a Beneficial Owner if our business is owned by another company?** + +No, you should only indicate that you have a Beneficial Owner if an individual owns 25% or more of the business. + +**Why can’t I input my address or upload my ID?** + +Ensure that the address you’re entering is in the United States. When adding a verified business bank account in Expensify, the individual adding the account and any beneficial owners are required to have a U.S. address, photo ID, and SSN. + +If you do not meet these requirements, you’ll need to have another admin add the bank account and share access with you once the account is verified. + +**Why am I being asked for documentation when adding my bank account?** + +When a bank account is added to Expensify, we conduct a series of checks to comply with both our sponsor bank’s requirements and federal government regulations for the Bank Secrecy Act (BSA), Anti-Money Laundering (AML) laws, and anti-fraud. + +If automatic verification fails, we may request manual verification, which could require documents such as address verification for your business, a letter from your bank confirming bank account ownership, etc. + +If you have any questions regarding the documentation request you received, contact Concierge for additional assistance. + +**I don’t see all three microtransactions I need to validate my bank account.** + +If you do not see the three microtransactions by the end of the second business day, contact your bank and ask them to whitelist Expensify’s ACH IDs 1270239450 and 4270239450. Expensify’s ACH Originator Name is “Expensify.” + +Once you are whitelisted, contact your Account Manager or Concierge, and our team will re-trigger the three transactions. + +**What happens if my bank requires an additional security check before adding it to a third party?** + +If your bank account has two-factor authentication (2FA) or another security step enabled, you should be prompted to complete this authentication step when connecting the account to Expensify. However, if you encounter an error during this process, you can close the pop-up window and select Connect Manually to add the account manually. + +**I added a business deposit account. Can I also pay employees from this account?** + +To pay employees from a business deposit account, click **Verify** next to the bank account. This will take you through the additional verification steps required to make this account a verified business bank account that you can use to issue payments. + +**I connected my business deposit account. Why haven’t I received my reimbursement?** + +There are a few reasons why you might not have received a reimbursement: +- The estimated deposit date on the report has not arrived yet. +- The bank account information is incorrect. If you believe you may have entered the wrong account, contact the Concierge and provide the Report ID for the missing reimbursement. +- Your account wasn’t set up for Direct Deposit/ACH. You can contact your bank to confirm. + +{% include faq-end.md %} + +
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Disconnect-Bank-Account.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Disconnect-Bank-Account.md new file mode 100644 index 000000000000..eeb9718a4bbe --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Disconnect-Bank-Account.md @@ -0,0 +1,26 @@ +--- +title: Disconnect bank account +description: Remove a bank account from Expensify +--- +
+ +{% include info.html %} +If the bank account is set as the settlement account for your Expensify Cards, you must designate another bank account as your settlement account (under Settings > Domains > Company Cards > Settings) before this account can be deleted. +{% include end-info.html %} + +To disconnect a bank account from Expensify, + +1. Hover over **Settings**, then click **Account**. +2. Click the **Payments** tab on the left. +3. To the right of the bank account, click **Delete**. + +{% include faq-begin.md %} + +**Why can't I delete a bank account?** + +You may be unable to delete your bank account if: +- The bank account is set as your Expensify Card settlement account +- Your account is locked due to a failed payment +{% include faq-end.md %} + +
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Australian-Reimbursements.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Australian-Reimbursements.md new file mode 100644 index 000000000000..0418d982f440 --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Australian-Reimbursements.md @@ -0,0 +1,25 @@ +--- +title: Enable Australian reimbursements +description: Reimburse AUD expenses using ABA files +--- +
+ +{% include info.html %} +AUD bank accounts do not rely on direct deposit or ACH. + +For businesses that will also be reimbursing employees in other countries outside of Australia, you’ll need to set up Global Reimbursement instead. +{% include end-info.html %} + +You can reimburse AUD expenses using indirect reimbursements. This allows your Australian employees to submit their personal bank account information to Expensify where it will be provided to you for payment via an .aba file. A Workspace Admin can then upload the .aba file to the bank to have payments issued. + +To enable ABA payments, + +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. Select **Indirect** as the Reimbursement type. +7. Enable the toggle. + +
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md new file mode 100644 index 000000000000..b0c767fce277 --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md @@ -0,0 +1,95 @@ +--- +title: Enable Global Reimbursements +description: Send international payments +--- +
+ +Enabling global reimbursements allows you to send direct reimbursements to countries worldwide if your company’s bank account is in the US, UK, Canada, Europe, or Australia. + +# For USD accounts + +{% include info.html %} +Before you can complete this process, you must first connect a **verified** U.S. bank account, and your employees receiving payments from this account must also connect their **deposit-only** U.S. bank account. +{% include end-info.html %} + +## Step 1: Request global reimbursements + +Once your verified U.S. bank account has been added and verified, you can request that global reimbursements be enabled on your account. + +Click the support icon in your Expensify account to inform your Setup Specialist, Account Manager, or Concierge that you’d like to enable global reimbursements. They will ask you to confirm the currencies of the bank accounts and determine if your account meets the criteria for global reimbursements. + +## Step 2: Re-verify the bank account + +1. Hover over **Settings**, then click **Workspaces**. +2. Select the workspace. +3. Click the **Reports** tab on the left. +4. Ensure that the workspace currency is set to **USD**. +5. Click the **Reimbursements** tab on the left. +6. Ensure that the reimbursement method is set to **Direct** and that the right bank account is selected. +7. Click the **Payments** tab on the left. +8. Click **Enable Global Reimbursement** next to the bank account. + +{% include info.html %} +This button may not appear for up to 60 minutes after the Expensify team confirms global reimbursements for your account. +{% include end-info.html %} + +{:start="9"} +9. Complete the International Reimbursement DocuSign form. + +Once the form is complete, it is automatically sent to our Compliance Team for review. Our Support Team will contact you with more details if additional information is required, which may include: +- An authorization letter +- Proof of address and ID for the reimburser and/or company directors +- Independently certified documentation, such as a shareholder agreement from a lawyer, notary, or public accountant if an individual owns more than 25% of the company + +# For AUD, CAD, GBP, and EUR accounts + +## Step 1: Request global reimbursements + +Click the support icon in your Expensify account to inform your Setup Specialist, Account Manager, or Concierge that you’d like to enable global reimbursements. They will ask you to confirm the currencies of the bank accounts and determine if your account meets the criteria for global reimbursements. + +## Step 2: Add the bank account + +1. Hover over **Settings**, then click **Workspaces**. +2. Select the workspace. +3. Click the **Reports** tab on the left. +4. Ensure that the selected workspace currency matches your reimbursement bank account currency. +5. Click the **Reimbursements** tab on the left. +6. Set the reimbursement method to **Direct**. + +{% include info.html %} +This button may not appear for up to 60 minutes after the Expensify team confirms global reimbursements for your account. +{% include end-info.html %} + +{:start="7"} +7. Click **Add Business Bank Account**. +8. If necessary, click **Switch Country** to select the correct country if not automatically selected. +9. Enter the bank account details, then click **Save & Continue**. +10. Complete the International Reimbursement DocuSign form. + +Once the form is complete, it is automatically sent to our Compliance Team for review. Our Support Team will contact you with more details if additional information is required, which may include: +- An authorization letter +- Proof of address and ID for the reimburser and/or company directors +- Independently certified documentation, such as a shareholder agreement from a lawyer, notary, or public accountant if an individual owns more than 25% of the company + +{% include faq-begin.md %} + +**Can multiple people send reimbursements internationally?** + +Once your company is authorized to send global payments, the individual who verified the bank account can share it with additional admins on the workspace. This will enable them to be able to send global reimbursements. + +**How long does it take to verify an account for global payments?** + +The verification process can take anywhere from a few business days to several weeks depending on the information provided in the DocuSign form, if additional information is required for compliance. + +**My employee doesn’t have the option to add their non-USD bank account as a deposit account. What should they do?** + +Have the employee double-check that their [default workspace](https://help.expensify.com/articles/expensify-classic/workspaces/Navigate-multiple-workspaces) is set as the workspace that’s connected to the bank you’re using to send global payments. + +**Who is the “Authorized User” and the “User” on the International Reimbursement DocuSign form?** + +- **Authorized User**: The person who will process global reimbursements. The Authorized User should be the same person who manages the bank account connection in Expensify. +- **User**: You can leave this section blank because the “User” is Expensify. + +{% include faq-end.md %} + +
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Revoke-Access-to-Verified-Business-Bank-Account.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Revoke-Access-to-Verified-Business-Bank-Account.md new file mode 100644 index 000000000000..198579b981e8 --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Revoke-Access-to-Verified-Business-Bank-Account.md @@ -0,0 +1,13 @@ +--- +title: Revoke access to verified business bank account +description: Revoke an admins bank account access +--- +
+ +You can revoke access if an admin or accountant leaves the company or otherwise needs to have their access removed from the workspace bank account. + +1. Hover over **Settings**, then click **Account**. +2. Click the **Payments** tab on the left. +3. Under Shared Business Bank Accounts, click **Unshare** next to the admin. + +
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Share-Verified-Business-Bank-Account.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Share-Verified-Business-Bank-Account.md new file mode 100644 index 000000000000..b667dc8f3845 --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Share-Verified-Business-Bank-Account.md @@ -0,0 +1,22 @@ +--- +title: Share verified business bank account +description: Allow admins to reimburse employees or pay vendor bills by sharing access to the business bank account +--- +
+ +To allow an admin to reimburse employees or pay vendor bills, they must be given access to the workspace’s verified bank account. + +{% include info.html %} +Bank accounts can only be shared with someone who is a Workspace Admin for a workspace you also have access to. +{% include end-info.html %} + +To grant another admin access to the bank account in Expensify, + +1. Hover over **Settings**, then click **Account**. +2. Click the **Payments** tab on the left. +3. Next to the bank account, click **Share**. +4. Enter the email address of the person you will share bank account access with. + +The added admin will receive an email with additional instructions to revalidate the bank account. When a bank account is shared, it must be revalidated to ensure the newly added admin has access. It can take 1-2 business days to receive the three microtransactions, then the admin can validate the transaction amounts. + +
diff --git a/docs/articles/expensify-classic/connections/certinia/Certinia-Troubleshooting.md b/docs/articles/expensify-classic/connections/certinia/Certinia-Troubleshooting.md index 82a2762ee99a..de7e4ad7f364 100644 --- a/docs/articles/expensify-classic/connections/certinia/Certinia-Troubleshooting.md +++ b/docs/articles/expensify-classic/connections/certinia/Certinia-Troubleshooting.md @@ -1,6 +1,78 @@ --- title: Certinia Troubleshooting -description: Certinia Troubleshooting +description: Troubleshoot common Certinia sync and export errors. --- +# Overview of Certinia Troubleshooting +Occasionally, users may encounter errors that prevent reports from exporting or the connection from syncing successfully. These errors often arise from discrepancies in settings, missing data, or configuration issues within Certinia or Expensify. -# Coming soon +This troubleshooting guide aims to help you identify and resolve common sync and export errors, ensuring a seamless connection between your financial management systems. + +By following the step-by-step solutions provided for each specific error, you can quickly address issues and maintain accurate and efficient expense reporting and data management. + +# ExpensiError FF0047: You must have an Ops Edit permission to edit approved records. +This error indicates that the permission control setup between the connected user and the report submitter or region is missing Ops Edit permission. + +In Certinia go to Permission Controls and click the one you need to edit. Make sure that Expense Ops Edit is selected under Permissions. + +# ExpensiError FF0061: Object validation has failed. The credit terms… +To resolve the error "Object validation has failed. The credit terms for the selected account on this document are not correctly defined," follow these steps: + +1. Identify the account used for the report being exported. This could be the account on the project for PSA/SRP or the account linked to the resource for FFA. +2. In Certinia, under this account: + - Ensure that Base Date 1 is configured to invoice. + - Set Days Offset to 1 or more. + - Verify that a currency is selected for the account. + +By following these guidelines, you can correct the issue and ensure proper validation of the credit terms for the selected account. + +# ExpensiError FF0074: You do not have permissions for this resource +This error message indicates a requirement to establish permission controls for the report creator/submitter within Certinia. + +To set this up: +1. Navigate to Permission Controls in Certinia. +2. Select "New" to create a new permission control. +3. Enter the User and Resource Fields. +4. Ensure all necessary permission fields are checked or configured appropriately. + +By completing these steps, you can effectively manage and grant the required permissions to the report creator/submitter in Certinia. + +# ExpensiError FF0076: Could not find employee in Certinia +Go to Contacts in Certinia and add the report creator/submitter's Expensify email address to their employee record, or create a record with that email listed. + +If a record already exists then search for their email address to confirm it is not associated with multiple records. + +# ExpensiError FF0089: Expense Reports for this Project require an Assignment +This error indicates that the project needs to have the permissions adjusted in Certinia + +Go to Projects > [project name] > Project Attributes and check Allow Expense Without Assignment. + +# ExpensiError FF0091: Bad Field Name — [field] is invalid for [object] +This means the field in question is not accessible to the user profile in Certinia for the user whose credentials were used to make the connection within Expensify. + +To correct this: +* Go to Setup > Build > expand Create > Object within Certinia +* Then go to Payable Invoice > Custom Fields and Relationships +* Click View Field Accessibility +* Find the employee profile in the list and select Hidden +* Make sure both checkboxes for Visible are selected + +Once this step has been completed, sync the connection within Expensify by going to **Settings** > **Workspaces** > **Groups** > _[Workspace Name]_ > **Connections** > **Sync Now** and then attempt to export the report again. + +# ExpensiError FF0132: Insufficient access. Make sure you are connecting to Certinia with a user that has the 'Modify All Data' permission + +Log into Certinia, go to Setup > Manage Users > Users and find the user whose credentials made the connection. + +* Click on their profile on the far right side of the page +* Go to System > System Permissions +* Enable Modify All Data and save + +Sync the connection within Expensify by going to **Settings** > **Workspaces** > **Groups** > _[Workspace Name]_ > **Connections** > **Sync Now** and then attempt to export the report again + +# Error: Certinia PSA: Duplicate Value on Record +When exporting multiple projects from Expensify to Certinia, each project generates its own Expense Report in Certinia. If any project fails during the initial export, all subsequent projects will also fail. In such cases, if you attempt to export again, an error may indicate that some projects were already successfully exported, potentially causing duplicates. + +To resolve this issue, delete any existing expense reports associated with the Expensify report ID in Certinia. + +Then, sync the connection within Expensify by going to **Settings** > **Workspaces** > **Groups** > _[Workspace Name]_ > **Connections** > **Sync Now** and then attempt to export the report again + +Following these steps ensures that any duplicate entries are cleared and that the export process can proceed without errors. diff --git a/docs/articles/expensify-classic/connections/xero/Xero-Troubleshooting.md b/docs/articles/expensify-classic/connections/xero/Xero-Troubleshooting.md index f1bb398dbecf..f23ec515cde4 100644 --- a/docs/articles/expensify-classic/connections/xero/Xero-Troubleshooting.md +++ b/docs/articles/expensify-classic/connections/xero/Xero-Troubleshooting.md @@ -5,7 +5,7 @@ description: Xero Troubleshooting # Overview of Xero Troubleshooting -Synchronizing and exporting data between Expensify and NetSuite can streamline your financial processes, but occasionally, users may encounter errors that prevent a smooth integration. These errors often arise from discrepancies in settings, missing data, or configuration issues within NetSuite or Expensify. +Synchronizing and exporting data between Expensify and Xero can streamline your financial processes, but occasionally, users may encounter errors that prevent a smooth integration. These errors often arise from discrepancies in settings, missing data, or configuration issues within Xero or Expensify. This troubleshooting guide aims to help you identify and resolve common sync and export errors, ensuring a seamless connection between your financial management systems. By following the step-by-step solutions provided for each specific error, you can quickly address issues and maintain accurate and efficient expense reporting and data management. diff --git a/docs/articles/expensify-classic/domains/Domain-Migration.md b/docs/articles/expensify-classic/domains/Domain-Migration.md new file mode 100644 index 000000000000..275146faf58d --- /dev/null +++ b/docs/articles/expensify-classic/domains/Domain-Migration.md @@ -0,0 +1,45 @@ +--- +title: Domain Migration +description: Change the email address and domain for all users in Expensify +--- + +If your business changes its email domain and needs to move everyone to new email addresses, you may be able to automatically migrate your Expensify accounts over to the new domain, or our team can help you manually complete the process. + +{% include info.html %} +If your company uses Expensify Cards, do not create your new domain or request Expensify cards on the new domain. The Expensify Team must complete the migration for you in this case. Message Concierge or your Account Manager to start the process. +{% include end-info.html %} + +## Automatic Migration +If certain conditions are met, Expensify can simply rename your domain. This means that your Domain settings will stay the same, but the name will change and all users will get a new email address as their primary login. Their old email address will remain on their account as a secondary login. + +If the answer to each of these questions is “yes,” we can automatically migrate your domain and users: + +- Is your current domain, or the new domain, verified? +- Do the emails map 1:1 (i.e., name@olddomain.com becomes name@new.com and not firstname.lastename@new.com)? +- Have your users refrained from creating Expensify accounts under their new email addresses? + +If all of these answers are “yes,” message Concierge to request a “Domain Migration.” This process will usually take a few business days. If you answered “no” for any of the questions, continue to the manual migration process below. + +## Manual Migration +If we can’t automatically migrate your domain, you can leverage your users to migrate everyone manually. + +{% include info.html %} +Follow the steps below in the exact order they are presented. +{% include end-info.html %} + +1. Have all employees add their new email address as a [Secondary Login](https://help.expensify.com/articles/expensify-classic/settings/account-settings/Change-or-add-email-address) to their current account. +2. [Claim and verify](https://help.expensify.com/articles/expensify-classic/domains/Claim-And-Verify-A-Domain) your new Domain. +3. If you use [Domain Groups](https://help.expensify.com/articles/expensify-classic/domains/Create-A-Group) on your old domain, disable “Restrict Primary Login Selection” on all groups. +4. Have each user switch their Primary and Secondary Login email addresses so the new email address is the primary login. +5. Add any card feeds. If you need to move a commercial card feed, reach out to concierge@expensify.com, as we’ll need to do this for you. + +{% include info.html %} +Before completing this process, you must resolve any unprocessed transactions, as your cards will be unassigned from the previous domain and assigned to the new one. That means that any unprocessed transactions on the previous domain will be deleted when the cards are unassigned. +{% include end-info.html %} + +6. Enter the transaction start date so that your transactions will not overlap with the previous import but will continue from the previous assignment. +7. Reset your old domain to remove any domain restrictions: + - Remove all domain admins + - The last admin left will see a Reset button to click +8. If your employees have already set up an additional account under the new email address, they will need to [merge their accounts](https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts). +9. Set up your [domain groups](https://www.google.com/url?q=https://help.expensify.com/articles/expensify-classic/domains/Create-A-Group) on the new domain and add your users to the relevant group. diff --git a/docs/articles/expensify-classic/expenses/Expense-Types.md b/docs/articles/expensify-classic/expenses/Expense-Types.md index 9d19dbb4f9ba..faf670469362 100644 --- a/docs/articles/expensify-classic/expenses/Expense-Types.md +++ b/docs/articles/expensify-classic/expenses/Expense-Types.md @@ -3,42 +3,41 @@ title: Expense Types description: Details of the different Expense filters and Expense Types --- -# Overview -Expense types help categorize different expenses for better financial management. While reimbursable and non-reimbursable expenses are common, Expensify offers various other options to suit your needs. Let's explore the available expense types. +## Organize a Report by Expense Type +Organizing a report by expense type can make it easier to review expenses on a report. -# How To -## Filtering a Report by Expense Type -Organizing a report by expense type can make it easier to review expenses on a report. -- Open the report you're interested in. -- Click the **Details** icon in the upper right corner of the report, -- Change the “View” to **Detailed** and “Split by” **Reimbursable** or **Billable**. -- You’ll also see the option to **Group by Category** or **Tags**. +1. Open the desired report. +2. Click Details in the upper right corner of the report. +3. Click the View dropdown and select Detailed. +4. Click the Split by dropdown and select Reimbursable or Billable. +To group the expenses by category or tag, you can also click the Group by dropdown and select Category or Tags. -# Deep Dive -Each report will show the total amount for all expenses in the upper right. Under that total, there will be a breakdown of amounts that are reimbursable, billable, and non-reimbursable (depending on which of those expense types exist on the report). +## Identify Expense Types +The right side of every report provides the total for all the expenses. Under the total, there is a breakdown of reimbursable, billable, and non-reimbursable amounts (depending on the expense types that exist on the report). -## Expense Types -- **Reimbursable Expenses:** Employees pay for these expenses out of their pockets on behalf of the business and are usually reimbursed. They often come from cash, debit cards, or personal credit card purchases. -- **Non-reimbursable Expenses:** The business directly covers these expenses, so there's no need to reimburse the employee. Typically, these expenses are company card expenses. -- **Billable Expenses:** Business or employee expenses must be billed to a specific client or vendor. Choose this option if you need to track expenses for invoicing to customers, clients, or other departments. -- **Per Diem Expenses:** These expenses involve a daily or partial daily rate you can configure in your expense Workspace. -- **Time Expenses:** Employees or jobs are billed based on an hourly rate that you can set within Expensify. -- **Distance Expenses:** These expenses are related to travel for work. +- Reimbursable expenses: Expenses paid to the employee, including: + - Cash & personal card: Expenses paid for by the employee on behalf of the business. + - Per diem: Expenses for a daily or partial daily rate [configured in your Workspace](https://help.expensify.com/articles/expensify-classic/workspaces/Enable-per-diem-expenses). + - Time: An hourly rate for your employees or jobs as [set for your workspace](https://help.expensify.com/articles/expensify-classic/workspaces/Set-time-and-distance-rates). This expense type is usually used by contractors or small businesses billing the customer via [Expensify Invoicing](https://help.expensify.com/articles/expensify-classic/workspaces/Set-Up-Invoicing). + - Distance: Expenses related to business travel. +- Non-reimbursable expenses: Expenses directly covered by the business, typically on company cards. +- Billable expenses: Business or employee expenses that must be billed to a specific client or vendor. This option is for tracking expenses for invoicing to customers, clients, or other departments. Any kind of expense can be billable, in _addition_ to being either reimbursable or non-reimbursable. + +![Image of a report showing multiple expense totals]({{site.url}}/assets/images/amounts.png){:width="100%"} {% include faq-begin.md %} -## What’s the difference between a receipt, an expense, and a report attachment? +**What’s the difference between an expense, a receipt, and a report attachment?** - **Expense:** Created when you SmartScan or manually upload a receipt from a purchase. -- **Receipt:** Automatically attached to the expense during the SmartScan process. -- **Report Attachments:** Additional documents that need to be submitted to your approver (e.g., supplemental documents to the purchase) can be added to a report anytime by clicking the paperclip icon in the Reports Comments. +- **Receipt:** A picture file that is automatically attached to the expense during the SmartScan process. +- **Report Attachments:** Additional documents that need to be submitted to your approver (e.g., supplemental documents to the purchase) can be added to a report any time by clicking the paperclip icon in the comments at the bottom of the report. + +**How are credits or refunds displayed in Expensify?** -## How are credits or refunds displayed in Expensify? -In Expensify, a credit is displayed as an expense with a minus (ex. -$1.00) in front of it. That’s because Expensify defaults all expenses as something that needs to be paid by the company. So a credit that is returned to the company is displayed as a negative expense. +In Expensify, a credit is displayed as an expense with a minus in front of it (e.g., -$1.00). Expensify defaults all expenses as something that needs to be paid by the company. So a credit that is returned to the company is displayed as a negative expense. -If a report includes a credit or a refund expense, it will offset the total amount on the report. -For example, the report has two reimbursable expenses, $400 and $500. The total Reimbursable is $900. -Conversely, a -$400 and $500 will be a total Reimbursable amount of $500 +If a report includes a credit or a refund expense, it will offset the total amount on the report. For example, if the report has two reimbursable expenses, one for $400 and one for $500, then the total reimbursable amount is $900. Conversely, an expense for -$400 and one for $500 will be a total reimbursable amount of $500. {% include faq-end.md %} diff --git a/docs/articles/expensify-classic/expenses/Merge-expenses.md b/docs/articles/expensify-classic/expenses/Merge-expenses.md index e1430ee67543..e78d035a3174 100644 --- a/docs/articles/expensify-classic/expenses/Merge-expenses.md +++ b/docs/articles/expensify-classic/expenses/Merge-expenses.md @@ -47,7 +47,7 @@ If the expenses exist on two different reports, you will be asked which report y **How can the icons and receipt images help me diagnose my issue?** Look carefully at your expenses. Each expense has an icon that denotes where the expense came from: -- Cash (bill & coins) icon: Added manually or by SmartScanning an expense +- Cash (banknotes) icon: Added manually or by SmartScanning an expense - Credit Card icon: Imported from a connected personal credit card - Spreadsheet icon: Imported from a personal CSV import - Locked Credit Card icon: Imported from a company card feed or CSV upload diff --git a/docs/articles/expensify-classic/reports/How-Complex-Approval-Workflows-Work.md b/docs/articles/expensify-classic/reports/How-Complex-Approval-Workflows-Work.md index 372a4783378f..0a093b5af0a4 100644 --- a/docs/articles/expensify-classic/reports/How-Complex-Approval-Workflows-Work.md +++ b/docs/articles/expensify-classic/reports/How-Complex-Approval-Workflows-Work.md @@ -4,26 +4,26 @@ description: Examples of how Advanced Approval Workflows apply in real life --- Approval workflows can get complex. Let’s look at the lifecycle of an expense report from submission to final approval. -## 1.Submission +## 1. Submission The approval workflow for all reports starts as soon as the report is submitted. Reports can be submitted manually or set to [submit automatically](https://help.expensify.com/articles/expensify-classic/reports/Automatically-submit-employee-reports) by Concierge. If you change part of your workflow after a report has been submitted, the workflow for that report will not change unless it is retracted and resubmitted. -## 2.Category & tag approvers +## 2. Category & tag approvers If you have [special approvers for categories or tags](https://help.expensify.com/articles/expensify-classic/reports/Assign-tag-and-category-approvers) that are added to a report, the report will go to these people for approval first. They will be notified about the report via email or an in-app notification. -## 3.Approval mode +## 3. Approval mode The report will now travel through the approval workflow for the workspace: - Submit & Close: If you use a [Submit & Close](https://help.expensify.com/articles/expensify-classic/reports/Create-a-report-approval-workflow) workflow, the report will now close and notify the set person. - Submit & Approve: If you use a [Submit & Approve](https://help.expensify.com/articles/expensify-classic/reports/Create-a-report-approval-workflow) workflow, the report will now go to the set person for their approval. - Advanced Approval: If you use an [Advanced Approval](https://help.expensify.com/articles/expensify-classic/reports/Create-a-report-approval-workflow) workflow, the report will now go to the person in the submitter’s “Submits to” column of the Workspace Members table. Once that person approves the report, - a. The report then goes to the person in the approver’s Approves To column. - b. If the approver has an [approval value limit](https://help.expensify.com/articles/expensify-classic/reports/Require-review-for-over-limit-expenses) (i.e. they have approval restrictions based on the total amount of the report), the report will go to the set person. - c. The report continues through the approval workflow until it reaches someone who does not have anyone in their Approves To column. This person is the final approver. + - The report then goes to the person in the approver’s Approves To column. + - If the approver has an [approval value limit](https://help.expensify.com/articles/expensify-classic/reports/Require-review-for-over-limit-expenses) (i.e. they have approval restrictions based on the total amount of the report), the report will go to the set person. + - The report continues through the approval workflow until it reaches someone who does not have anyone in their Approves To column. This person is the final approver. Once the report receives final approval, it may be exported to a [connected accounting software](https://help.expensify.com/expensify-classic/hubs/connections/) and/or reimbursed. -## 4.Concierge approval +## 4. Concierge approval If you’ve chosen to [require manual approval for expenses that exceed a set limit](https://help.expensify.com/articles/expensify-classic/reports/Require-review-for-over-limit-expenses), any report that doesn’t contain a single expense over this amount will be approved by Concierge. ## Workflow examples diff --git a/docs/articles/expensify-classic/travel/Approve-travel-expenses.md b/docs/articles/expensify-classic/travel/Approve-travel-expenses.md index ae0feb4efaa6..585e930a3dde 100644 --- a/docs/articles/expensify-classic/travel/Approve-travel-expenses.md +++ b/docs/articles/expensify-classic/travel/Approve-travel-expenses.md @@ -17,6 +17,40 @@ Travel expenses follow the same approval workflow as other expenses. Admins can 3. Click the **Program** tab at the top and select **Policies**. 4. Under General, select approval methods for Flights, Hotels, Cars and Rail. +![Screenshot of Expensify Travel policy approval settings](https://help.expensify.com/assets/images/Travel_Policy.png){:width="100%"} + +# Approve travel + +![Screenshot of Expensify Travel approval email](https://help.expensify.com/assets/images/Travel_Email.png){:width="100%"} + +## Soft approval + +Once an employee has booked a trip, their approver will receive an email notifying them of the booking with a prompt to decline it if needed. + +- To approve the booking, no action is required. +- To decline the booking, click **Decline booking** within 24 hours. Then click **Deny Booking**. + +## Hard approval + +Once an employee has booked a trip, their approver will receive an email notifying them of the booking with a prompt to accept or decline the booking. + +To approve the booking, click **Approve booking**. Then click **Approve**. +To decline the booking, click **Decline booking**. Then click **Deny**. + +# FAQs + +## Are extended approval windows given for trips booked over the weekend or during company holidays? + +No, the approval window will always be 24 hours from when the trip is booked. + +## How does Expensify Travel handle approvals when the assigned approver is out of office? + +It is recommended to have multiple approvers for travel, as there is no delegated approval for out-of-office approvers. + +## Can travelers upload a document when submitting a trip for approval? + +Travelers are unable to add a document when submitting a trip for approval, but the company can add a ‘reason code’ in the Out of Policy rules that the traveler can complete at checkout. The traveler can then add the document to the expense report in Expensify when submitting the report. +
@@ -34,4 +68,38 @@ Travel expenses follow the same approval workflow as other expenses. Admins can 3. Click the **Program** tab at the top and select **Policies**. 4. Under General, select approval methods for Flights, Hotels, Cars and Rail. +![Screenshot of Expensify Travel policy approval settings](https://help.expensify.com/assets/images/Travel_Policy.png){:width="100%"} + +# Approve travel + +![Screenshot of Expensify Travel approval email](https://help.expensify.com/assets/images/Travel_Email.png){:width="100%"} + +## Soft approval + +Once an employee has booked a trip, their approver will receive an email notifying them of the booking with a prompt to decline it if needed. + +- To approve the booking, no action is required. +- To decline the booking, click **Decline booking** within 24 hours. Then click **Deny Booking**. + +## Hard approval + +Once an employee has booked a trip, their approver will receive an email notifying them of the booking with a prompt to accept or decline the booking. + +To approve the booking, click **Approve booking**. Then click **Approve**. +To decline the booking, click **Decline booking**. Then click **Deny**. + +# FAQs + +## Are extended approval windows given for trips booked over the weekend or during company holidays? + +No, the approval window will always be 24 hours from when the trip is booked. + +## How does Expensify Travel handle approvals when the assigned approver is out of office? + +It is recommended to have multiple approvers for travel, as there is no delegated approval for out-of-office approvers. + +## Can travelers upload a document when submitting a trip for approval? + +Travelers are unable to add a document when submitting a trip for approval, but the company can add a ‘reason code’ in the Out of Policy rules that the traveler can complete at checkout. The traveler can then add the document to the expense report in Expensify when submitting the report. +
diff --git a/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md b/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md index 2e17af06773c..7e9a217b34ec 100644 --- a/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md +++ b/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md @@ -4,32 +4,78 @@ description: Set and update travel policies and preferences for your Expensify W ---
-As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees. +As a Workspace Admin, you can set travel policies for all travel booked under a workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees. -# Create a travel policy +# Create or update a travel policy -When using Expensify Travel for the first time, you will need to create a new Travel Policy. +Workspace admins can create different travel policies that provide travel rules for different groups of travelers. When using Expensify Travel for the first time, you will need to create a new Travel Policy. -To create a travel policy from the Expensify web app, +To create or update a travel policy, 1. Click the **Travel** tab. 2. Click **Book or manage travel**. 3. Click the **Program** tab at the top and select **Policies**. 4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy. -5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. -6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. -7. Click the paperclip icon next to each setting to de-couple it from your default policy. -8. Update the desired settings. +5. Use the **Edit members** section to select the group of employees that belong to this policy. + 1. **To select an existing policy:** Select the policy in the left menu. + 2. **To add a new policy:** Click **Add new** under Employee or Non-employee in the left menu. Then under the Edit members section, select the group of employees that belong to this policy. -# Update travel policy preferences +{% include info.html %} +A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. +{% include end-info.html %} -1. Click the **Travel** tab. -2. Click **Book or manage travel**. -3. Click the **Program** tab at the top and select **Policies**. -4. Select the appropriate policy in the left menu. -5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. -6. Click the paperclip icon next to each setting to de-couple it from your default policy. -7. Update the desired policies. +10. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +11. Click the paperclip icon next to each setting to de-couple it from your default policy. +12. Update the desired settings. + +# General + +Determine the currency and [approval type](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses#set-approval-method) for different types of travel bookings. You can also upload a PDF of your company travel policy for employees to access when booking travel. + +![Screenshot of Expensify Travel policy approval settings](https://help.expensify.com/assets/images/Travel_Policy.png){:width="100%"} + +# Flight + +Flight preferences include multiple sections with different settings: + +- **Flights not allowed to be booked:** Restrict specific flight classes from being booked by employees. +- **Cabin class settings:** Set the highest cabin class allowed for booking and whether employees can accept cabin upgrades. +- **Budget:** Set a percentage or fare cap for the median or cheapest fares on flights, and a maximum capped price. Flight fares above this cap will be considered out of policy. You can also add customization to allow for different caps for different flight durations. +- **Lowest logical fare settings:** Set a preference for layovers, number of stops, flight time window, and airport connection changes. Expensify automatically takes these preferences into account when showing the lowest logical travel fare. +- **Add Ons and Preferences:** Select what types of add-ons your employees can book, including: + - Additional baggage + - Early check-in + - Seat preference + - No add-ons allowed +- **Booking windows:** Add a time limit to prevent employees from booking less than a certain number of days in advance to prohibit bookings too close to the flight time. +- **Refundable/changeable tickets:** Allow employees to book fully or partially refundable fares. To allow all options, you can leave this as the default option. +- **Maximum CO2 per kilometer:** If the CO2 per passenger km exceeds this threshold, the flight will be marked as out of policy. You can leave the entry blank if you do not wish to use this policy. +- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy flight booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. + +# Hotel + +- **Restrict booking by keyword:** Keywords entered here will be compared to the hotel rate description. If any of the keywords are found, the hotel rate will be restricted. You also have the option to provide a reason why each keyword is restricted. +- **Hotel rates not allowed to be booked:** Restrict specific hotel rates (such as non-refundable, prepaid, and requires deposit) from being booked by employees. This overrides all other hotel policy rules. You also have the option to add a reason why these options aren’t available. +- **Maximum price:** Set a maximum price per night for bookings. You can also select the Customizations by location option to set a maximum price for each location. +- **Booking window:** Add a time limit to prevent employees from booking less than a certain number of days in advance to prohibit bookings too close to the flight time. +- **Cancellation policy:** Allow your employees to book fully or partially refundable rooms. To allow all options, you can leave this as the default option. +- **Experience:** Set hotel ratings that are in and out of policy. +- **Nightly median rate:** Determine which hotels to consider when calculating the median price. You can set a radius around the search location and include/disclude hotels above or below a certain rating. +- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy hotel booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. + +# Car + +- **Car categories not allowed:** Restrict specific car types from being booked by employees. This overrides any other car policy. +- **Car categories in policy:** Define the types of cars that are in policy. If you do not list a specific car category, it will still be booked as long as it isn’t included in the Car categories not allowed setting. However, the booking will be classed as out of policy. +- **Car engine types not allowed:** Restrict specific engine types from being booked by employees regardless of other policy settings. +- **Maximum price:** Set a daily price cap per car (not including taxes and fees). +- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy car booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. + +# FAQ + +How do travel policy rules interact with Expensify’s [approval flows](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses)? + +Travel policy rules define what can and can’t be booked by your employees while they’re making the booking. Once a booking is placed and the travel itself is [approved](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses), the expense will appear in Expensify. It will then be coded, submitted, pushed through the existing expense approval process as defined by your workspace, and exported to your preferred accounting platform (if applicable).
@@ -39,27 +85,73 @@ As a Workspace Admin, you can set travel policies for all travel booked under yo # Create a travel policy -When using Expensify Travel for the first time, you will need to create a new Travel Policy. +Workspace admins can create different travel policies that provide travel rules for different groups of travelers. When using Expensify Travel for the first time, you will need to create a new Travel Policy. -To create a travel policy from the Expensify web app, +To create or update a travel policy, 1. Click the + icon in the bottom left menu and select **Book travel**. 2. Click **Book or manage travel**. 3. Click the **Program** tab at the top and select **Policies**. 4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy. -5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. -6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. -7. Click the paperclip icon next to each setting to de-couple it from your default policy. -8. Update the desired settings. +5. Use the **Edit members** section to select the group of employees that belong to this policy. + 1. **To select an existing policy:** Select the policy in the left menu. + 2. **To add a new policy:** Click **Add new** under Employee or Non-employee in the left menu. Then under the Edit members section, select the group of employees that belong to this policy. -# Update travel policy preferences +{% include info.html %} +A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. +{% include end-info.html %} -1. Click the + icon in the bottom left menu and select **Book travel**. -2. Click **Book or manage travel**. -3. Click the **Program** tab at the top and select **Policies**. -4. Select the appropriate policy in the left menu. -5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. -6. Click the paperclip icon next to each setting to de-couple it from your default policy. -7. Update the desired policies. +10. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +11. Click the paperclip icon next to each setting to de-couple it from your default policy. +12. Update the desired settings. + +# General + +Determine the currency and [approval type](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses#set-approval-method) for different types of travel bookings. You can also upload a PDF of your company travel policy for employees to access when booking travel. + +![Screenshot of Expensify Travel policy approval settings](https://help.expensify.com/assets/images/Travel_Policy.png){:width="100%"} + +# Flight + +Flight preferences include multiple sections with different settings: + +- **Flights not allowed to be booked:** Restrict specific flight classes from being booked by employees. +- **Cabin class settings:** Set the highest cabin class allowed for booking and whether employees can accept cabin upgrades. +- **Budget:** Set a percentage or fare cap for the median or cheapest fares on flights, and a maximum capped price. Flight fares above this cap will be considered out of policy. You can also add customization to allow for different caps for different flight durations. +- **Lowest logical fare settings:** Set a preference for layovers, number of stops, flight time window, and airport connection changes. Expensify automatically takes these preferences into account when showing the lowest logical travel fare. +- **Add Ons and Preferences:** Select what types of add-ons your employees can book, including: + - Additional baggage + - Early check-in + - Seat preference + - No add-ons allowed +- **Booking windows:** Add a time limit to prevent employees from booking less than a certain number of days in advance to prohibit bookings too close to the flight time. +- **Refundable/changeable tickets:** Allow employees to book fully or partially refundable fares. To allow all options, you can leave this as the default option. +- **Maximum CO2 per kilometer:** If the CO2 per passenger km exceeds this threshold, the flight will be marked as out of policy. You can leave the entry blank if you do not wish to use this policy. +- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy flight booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. + +# Hotel + +- **Restrict booking by keyword:** Keywords entered here will be compared to the hotel rate description. If any of the keywords are found, the hotel rate will be restricted. You also have the option to provide a reason why each keyword is restricted. +- **Hotel rates not allowed to be booked:** Restrict specific hotel rates (such as non-refundable, prepaid, and requires deposit) from being booked by employees. This overrides all other hotel policy rules. You also have the option to add a reason why these options aren’t available. +- **Maximum price:** Set a maximum price per night for bookings. You can also select the Customizations by location option to set a maximum price for each location. +- **Booking window:** Add a time limit to prevent employees from booking less than a certain number of days in advance to prohibit bookings too close to the flight time. +- **Cancellation policy:** Allow your employees to book fully or partially refundable rooms. To allow all options, you can leave this as the default option. +- **Experience:** Set hotel ratings that are in and out of policy. +- **Nightly median rate:** Determine which hotels to consider when calculating the median price. You can set a radius around the search location and include/disclude hotels above or below a certain rating. +- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy hotel booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. + +# Car + +- **Car categories not allowed:** Restrict specific car types from being booked by employees. This overrides any other car policy. +- **Car categories in policy:** Define the types of cars that are in policy. If you do not list a specific car category, it will still be booked as long as it isn’t included in the Car categories not allowed setting. However, the booking will be classed as out of policy. +- **Car engine types not allowed:** Restrict specific engine types from being booked by employees regardless of other policy settings. +- **Maximum price:** Set a daily price cap per car (not including taxes and fees). +- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy car booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. + +# FAQ + +How do travel policy rules interact with Expensify’s [approval flows](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses)? + +Travel policy rules define what can and can’t be booked by your employees while they’re making the booking. Once a booking is placed and the travel itself is [approved](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses), the expense will appear in Expensify. It will then be coded, submitted, pushed through the existing expense approval process as defined by your workspace, and exported to your preferred accounting platform (if applicable). diff --git a/docs/articles/expensify-classic/travel/Track-Travel-Analytics.md b/docs/articles/expensify-classic/travel/Track-Travel-Analytics.md new file mode 100644 index 000000000000..18506215635e --- /dev/null +++ b/docs/articles/expensify-classic/travel/Track-Travel-Analytics.md @@ -0,0 +1,45 @@ +--- +title: Track Travel Analytics +description: Get insight into company travel bookings to ensure real-time duty of care reporting and travel policy compliance. +--- +
+ +Expensify Travel provides insights into company travel bookings to ensure real-time duty of care reporting and travel policy compliance. These analytics help Workspace Admins: + +- See global employee locations with a real-time employee location map +- Analyze travel spend based on details such as trip, traveler, or carrier +- Monitor booking trends and adherence to travel policy compliance +- Generate environmental, social, and governance (ESG) reporting + +To view your analytics, + +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book travel**. +3. Click the **Analytics** tab at the top of the screen. + +From here, you can see a variety of reports, including the Duty of Care report, Spend, and ESG metrics. + +## Duty of Care report + +Duty of care is a legal obligation for employers to safeguard the health, safety, and well-being of their employees both in the office and during business trips. With Expensify’s Duty of Care analytics, you can view a global map showing real-time employee locations. + +1. Click the **Analytics** tab at the top and select Travelers. +2. Use the map to see employee locations. If desired, you can use the filters above the map to show past and future trips, or travel booked to specific locations. + +## Spend and compliance report + +Workspace Admins can analyze travel data based on a variety of trip, traveler, and compliance attributes. + +1. Click the **Analytics** tab at the top and select Company Reports. +2. Review the overview data, or select a specific report from the left menu. +3. Click the three dot menu on the right of the screen to download the report as a PDF. + +## ESG report + +Expensify Travel provides various ESG metrics, including carbon footprint analysis, sustainability scores, and ethical travel spending. + +1. Click the **Analytics** tab at the top and select Company Reports. +2. Click **Air Manifest** in the left menu. +3. Review the CO2 Emissions column in the table. + +
diff --git a/docs/articles/expensify-classic/workspaces/Admin-offboarding-checklist.md b/docs/articles/expensify-classic/workspaces/Admin-offboarding-checklist.md new file mode 100644 index 000000000000..ddf8077be00c --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Admin-offboarding-checklist.md @@ -0,0 +1,64 @@ +--- +title: Admin offboarding checklist +description: What to alter when your main Expensify person leaves the business +--- +Many Expensify customers have one person who handles all the main roles in Expensify Classic: the Billing Owner, Workspace Admin, Domain Admin, Technical Contact, and Bank Account Owner. That means that if this person leaves the company or needs to be offboarded from their current position, you’ll need to assign these roles to another employee. + +{% include info.html %} +Your current admin and the person who will be your new admin should complete the following checklist _before_ your admin leaves the company. +{% include end-info.html %} + +## Checklist for the current admin +### 1. Assign a new admin +{% include info.html %} +The current admin must add the new admin to all company workspaces they own, even if they are not in use. When someone takes over ownership of all workspaces, they also take over ownership of the existing Annual Subscription. If the new admin does not take ownership of all company workspaces, the previous owner will continue to be charged for the other workspaces they still own, along with their existing annual subscription, which can result in multiple subscriptions. +{% include end-info.html %} + +1. [Add the new admin](https://help.expensify.com/articles/expensify-classic/workspaces/Invite-members-and-assign-roles) to the workspace. +2. [Assign the Admin role](https://help.expensify.com/articles/expensify-classic/workspaces/Change-member-workspace-roles) to the new admin. +3. If your company uses company card feeds, Expensify Cards, domain groups, or SAML, invite the new admin to be a [Domain Admin](https://help.expensify.com/articles/expensify-classic/domains/Add-Domain-Members-and-Admins). + +### 2. Share access to company bank account +If you are the only admin with access to the company bank account in Expensify, [share the bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Business-Bank-Accounts-USD#how-to-share-a-verified-bank-account) with the new admin or another workspace admin. + +## Checklist for the new admin +### 1. Take over billing and add payment account +The new admin must [take over ownership and billing](https://help.expensify.com/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account) for the workspace. + +### 2. Reverify the company bank account +1-2 business days after sharing, Expensify will administer 3 test transactions to your bank account. After these transactions (2 withdrawals and 1 deposit) have been processed in your account, visit your Expensify Inbox or Payments page, where you’ll see a prompt to input the transaction amounts. + +### 3. Unshare company bank accounts +Once the previous admin has left the company, the new admin (or any admin with access to the bank account) should [unshare the company bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Business-Bank-Accounts-USD#how-to-remove-access-to-a-verified-bank-account) with the previous admin. + +### 4. Update settlement account assignments +1. Hover over Settings, then click Domains. +2. Click the desired domain name. +3. On the Company Cards tab, click the dropdown under the Imported Cards section to select the desired Expensify Card. +4. To the right of the dropdown, click the Settings tab. +5. If the bank account set as the Expensify Card settlement account matches the company bank account, use the green chat icon to send a message to Concierge or your Account Manager. We will link the settlement account to the bank account once it has been reverified by the new settlement owner. + +{% include info.html %} +The settlement owner must also be a Domain Admin. +{% include end-info.html %} + +### 5. Update default reimburser assignment +1. Hover over Settings, then click Workspaces. +2. Click the desired workspace name. +3. Click the Reimbursement tab. +4. Ensure that the reverified bank account is set as the reimbursement account. +5. Ensure that the previous admin is not set as the Default Reimburser. If they are, select a new reimburser. + +### 6. Reconnect integrations & set technical contact +1. If your workspace is connected to an [accounting integration](https://help.expensify.com/expensify-classic/hubs/connections/) that is tied to the previous admin’s account, reconnect it. +2. [Assign a new Technical Contact] if the email listed is for the previous admin. + +### 7. Remove the previous Admin +1. Once all of the above steps have been completed, you can either downgrade the previous admin’s [role](https://help.expensify.com/articles/expensify-classic/workspaces/Change-member-workspace-roles) to Employee if they are still within the company, or if they have left the company: +[Remove the previous admin](https://help.expensify.com/articles/expensify-classic/workspaces/Remove-Members) from the workspace. +2. Close the member’s company Expensify account. + a. Hover over Settings, then click Domains. + b. Click the desired domain name. + c. Click the Domain Members tab. + d. Select the checkbox to the left of the employee, then click Close Accounts. + e. Click Close to confirm. diff --git a/docs/articles/new-expensify/connections/Netsuite/Configure-Netsuite.md b/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md similarity index 100% rename from docs/articles/new-expensify/connections/Netsuite/Configure-Netsuite.md rename to docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md diff --git a/docs/articles/new-expensify/connections/Netsuite/Connect-to-NetSuite.md b/docs/articles/new-expensify/connections/netsuite/Connect-to-NetSuite.md similarity index 100% rename from docs/articles/new-expensify/connections/Netsuite/Connect-to-NetSuite.md rename to docs/articles/new-expensify/connections/netsuite/Connect-to-NetSuite.md diff --git a/docs/articles/new-expensify/connections/Netsuite/Netsuite-Troubleshooting.md b/docs/articles/new-expensify/connections/netsuite/Netsuite-Troubleshooting.md similarity index 100% rename from docs/articles/new-expensify/connections/Netsuite/Netsuite-Troubleshooting.md rename to docs/articles/new-expensify/connections/netsuite/Netsuite-Troubleshooting.md diff --git a/docs/articles/new-expensify/connections/Quickbooks-Online/Configure-Quickbooks-Online.md b/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md similarity index 100% rename from docs/articles/new-expensify/connections/Quickbooks-Online/Configure-Quickbooks-Online.md rename to docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md diff --git a/docs/articles/new-expensify/connections/Quickbooks-Online/Connect-to-QuickBooks-Online.md b/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online.md similarity index 100% rename from docs/articles/new-expensify/connections/Quickbooks-Online/Connect-to-QuickBooks-Online.md rename to docs/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online.md diff --git a/docs/articles/new-expensify/connections/Quickbooks-Online/Quickbooks-Online-Troubleshooting.md b/docs/articles/new-expensify/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md similarity index 100% rename from docs/articles/new-expensify/connections/Quickbooks-Online/Quickbooks-Online-Troubleshooting.md rename to docs/articles/new-expensify/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md diff --git a/docs/articles/new-expensify/connections/Sage-Intacct/Configure-Sage-Intacct.md b/docs/articles/new-expensify/connections/sage-intacct/Configure-Sage-Intacct.md similarity index 100% rename from docs/articles/new-expensify/connections/Sage-Intacct/Configure-Sage-Intacct.md rename to docs/articles/new-expensify/connections/sage-intacct/Configure-Sage-Intacct.md diff --git a/docs/articles/new-expensify/connections/Sage-Intacct/Connect-to-Sage-Intacct.md b/docs/articles/new-expensify/connections/sage-intacct/Connect-to-Sage-Intacct.md similarity index 100% rename from docs/articles/new-expensify/connections/Sage-Intacct/Connect-to-Sage-Intacct.md rename to docs/articles/new-expensify/connections/sage-intacct/Connect-to-Sage-Intacct.md diff --git a/docs/articles/new-expensify/connections/Sage-Intacct/Sage-Intacct-Troubleshooting.md b/docs/articles/new-expensify/connections/sage-intacct/Sage-Intacct-Troubleshooting.md similarity index 100% rename from docs/articles/new-expensify/connections/Sage-Intacct/Sage-Intacct-Troubleshooting.md rename to docs/articles/new-expensify/connections/sage-intacct/Sage-Intacct-Troubleshooting.md diff --git a/docs/articles/new-expensify/connections/Xero/Configure-Xero.md b/docs/articles/new-expensify/connections/xero/Configure-Xero.md similarity index 100% rename from docs/articles/new-expensify/connections/Xero/Configure-Xero.md rename to docs/articles/new-expensify/connections/xero/Configure-Xero.md diff --git a/docs/articles/new-expensify/connections/Xero/Connect-to-Xero.md b/docs/articles/new-expensify/connections/xero/Connect-to-Xero.md similarity index 100% rename from docs/articles/new-expensify/connections/Xero/Connect-to-Xero.md rename to docs/articles/new-expensify/connections/xero/Connect-to-Xero.md diff --git a/docs/articles/new-expensify/connections/Xero/Xero-Troubleshooting.md b/docs/articles/new-expensify/connections/xero/Xero-Troubleshooting.md similarity index 100% rename from docs/articles/new-expensify/connections/Xero/Xero-Troubleshooting.md rename to docs/articles/new-expensify/connections/xero/Xero-Troubleshooting.md diff --git a/docs/articles/new-expensify/expenses-&-payments/Export-expenses.md b/docs/articles/new-expensify/expenses-&-payments/Export-expenses.md new file mode 100644 index 000000000000..df112259edbb --- /dev/null +++ b/docs/articles/new-expensify/expenses-&-payments/Export-expenses.md @@ -0,0 +1,45 @@ +--- +title: Export Expenses +description: Export expense data to a CSV file +--- +
+ +Expensify allows you to export expense data to a CSV file that you can import into your favorite spreadsheet tool for deeper analysis. + +To export your expense data to a CSV, + + 1. Click the **[Search](https://new.expensify.com/search/all?sortBy=date&sortOrder=desc)** tab in the bottom left menu. + 2. Select the checkbox to the left of the expenses or reports you wish to export. + 3. Click **# selected** at the top-right and select **Download**. + +The CSV download will save locally to your device with the file naming prefix _“Expensify.”_ This file provides the following data for each expense: + - Date + - Merchant + - Description + - From + - To + - Category + - Tag + - Tax + - Amount + - Currency + - Type (i.e. cash, card, distance) + - Receipt URL + +{% include faq-begin.md %} + +**Can I export in a different format, like PDF or XLS?** + +No, currently Expensify supports CSV export only. + +**Can I add columns to the CSV download to capture additional data points?** + +No, the CSV template cannot be customized. + +**Can I select expenses or reports in bulk?** + +Yes, you can select expenses or reports in bulk by selecting the **Select multiple** or **Select all** option. To display these options on the mobile app, simply long press an item. + +{% include faq-end.md %} + +
diff --git a/docs/articles/new-expensify/travel/Approve-travel-expenses.md b/docs/articles/new-expensify/travel/Approve-travel-expenses.md index ae0feb4efaa6..585e930a3dde 100644 --- a/docs/articles/new-expensify/travel/Approve-travel-expenses.md +++ b/docs/articles/new-expensify/travel/Approve-travel-expenses.md @@ -17,6 +17,40 @@ Travel expenses follow the same approval workflow as other expenses. Admins can 3. Click the **Program** tab at the top and select **Policies**. 4. Under General, select approval methods for Flights, Hotels, Cars and Rail. +![Screenshot of Expensify Travel policy approval settings](https://help.expensify.com/assets/images/Travel_Policy.png){:width="100%"} + +# Approve travel + +![Screenshot of Expensify Travel approval email](https://help.expensify.com/assets/images/Travel_Email.png){:width="100%"} + +## Soft approval + +Once an employee has booked a trip, their approver will receive an email notifying them of the booking with a prompt to decline it if needed. + +- To approve the booking, no action is required. +- To decline the booking, click **Decline booking** within 24 hours. Then click **Deny Booking**. + +## Hard approval + +Once an employee has booked a trip, their approver will receive an email notifying them of the booking with a prompt to accept or decline the booking. + +To approve the booking, click **Approve booking**. Then click **Approve**. +To decline the booking, click **Decline booking**. Then click **Deny**. + +# FAQs + +## Are extended approval windows given for trips booked over the weekend or during company holidays? + +No, the approval window will always be 24 hours from when the trip is booked. + +## How does Expensify Travel handle approvals when the assigned approver is out of office? + +It is recommended to have multiple approvers for travel, as there is no delegated approval for out-of-office approvers. + +## Can travelers upload a document when submitting a trip for approval? + +Travelers are unable to add a document when submitting a trip for approval, but the company can add a ‘reason code’ in the Out of Policy rules that the traveler can complete at checkout. The traveler can then add the document to the expense report in Expensify when submitting the report. +
@@ -34,4 +68,38 @@ Travel expenses follow the same approval workflow as other expenses. Admins can 3. Click the **Program** tab at the top and select **Policies**. 4. Under General, select approval methods for Flights, Hotels, Cars and Rail. +![Screenshot of Expensify Travel policy approval settings](https://help.expensify.com/assets/images/Travel_Policy.png){:width="100%"} + +# Approve travel + +![Screenshot of Expensify Travel approval email](https://help.expensify.com/assets/images/Travel_Email.png){:width="100%"} + +## Soft approval + +Once an employee has booked a trip, their approver will receive an email notifying them of the booking with a prompt to decline it if needed. + +- To approve the booking, no action is required. +- To decline the booking, click **Decline booking** within 24 hours. Then click **Deny Booking**. + +## Hard approval + +Once an employee has booked a trip, their approver will receive an email notifying them of the booking with a prompt to accept or decline the booking. + +To approve the booking, click **Approve booking**. Then click **Approve**. +To decline the booking, click **Decline booking**. Then click **Deny**. + +# FAQs + +## Are extended approval windows given for trips booked over the weekend or during company holidays? + +No, the approval window will always be 24 hours from when the trip is booked. + +## How does Expensify Travel handle approvals when the assigned approver is out of office? + +It is recommended to have multiple approvers for travel, as there is no delegated approval for out-of-office approvers. + +## Can travelers upload a document when submitting a trip for approval? + +Travelers are unable to add a document when submitting a trip for approval, but the company can add a ‘reason code’ in the Out of Policy rules that the traveler can complete at checkout. The traveler can then add the document to the expense report in Expensify when submitting the report. +
diff --git a/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md b/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md index 2e17af06773c..7e9a217b34ec 100644 --- a/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md +++ b/docs/articles/new-expensify/travel/Configure-travel-policy-and-preferences.md @@ -4,32 +4,78 @@ description: Set and update travel policies and preferences for your Expensify W ---
-As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees. +As a Workspace Admin, you can set travel policies for all travel booked under a workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees. -# Create a travel policy +# Create or update a travel policy -When using Expensify Travel for the first time, you will need to create a new Travel Policy. +Workspace admins can create different travel policies that provide travel rules for different groups of travelers. When using Expensify Travel for the first time, you will need to create a new Travel Policy. -To create a travel policy from the Expensify web app, +To create or update a travel policy, 1. Click the **Travel** tab. 2. Click **Book or manage travel**. 3. Click the **Program** tab at the top and select **Policies**. 4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy. -5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. -6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. -7. Click the paperclip icon next to each setting to de-couple it from your default policy. -8. Update the desired settings. +5. Use the **Edit members** section to select the group of employees that belong to this policy. + 1. **To select an existing policy:** Select the policy in the left menu. + 2. **To add a new policy:** Click **Add new** under Employee or Non-employee in the left menu. Then under the Edit members section, select the group of employees that belong to this policy. -# Update travel policy preferences +{% include info.html %} +A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. +{% include end-info.html %} -1. Click the **Travel** tab. -2. Click **Book or manage travel**. -3. Click the **Program** tab at the top and select **Policies**. -4. Select the appropriate policy in the left menu. -5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. -6. Click the paperclip icon next to each setting to de-couple it from your default policy. -7. Update the desired policies. +10. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +11. Click the paperclip icon next to each setting to de-couple it from your default policy. +12. Update the desired settings. + +# General + +Determine the currency and [approval type](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses#set-approval-method) for different types of travel bookings. You can also upload a PDF of your company travel policy for employees to access when booking travel. + +![Screenshot of Expensify Travel policy approval settings](https://help.expensify.com/assets/images/Travel_Policy.png){:width="100%"} + +# Flight + +Flight preferences include multiple sections with different settings: + +- **Flights not allowed to be booked:** Restrict specific flight classes from being booked by employees. +- **Cabin class settings:** Set the highest cabin class allowed for booking and whether employees can accept cabin upgrades. +- **Budget:** Set a percentage or fare cap for the median or cheapest fares on flights, and a maximum capped price. Flight fares above this cap will be considered out of policy. You can also add customization to allow for different caps for different flight durations. +- **Lowest logical fare settings:** Set a preference for layovers, number of stops, flight time window, and airport connection changes. Expensify automatically takes these preferences into account when showing the lowest logical travel fare. +- **Add Ons and Preferences:** Select what types of add-ons your employees can book, including: + - Additional baggage + - Early check-in + - Seat preference + - No add-ons allowed +- **Booking windows:** Add a time limit to prevent employees from booking less than a certain number of days in advance to prohibit bookings too close to the flight time. +- **Refundable/changeable tickets:** Allow employees to book fully or partially refundable fares. To allow all options, you can leave this as the default option. +- **Maximum CO2 per kilometer:** If the CO2 per passenger km exceeds this threshold, the flight will be marked as out of policy. You can leave the entry blank if you do not wish to use this policy. +- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy flight booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. + +# Hotel + +- **Restrict booking by keyword:** Keywords entered here will be compared to the hotel rate description. If any of the keywords are found, the hotel rate will be restricted. You also have the option to provide a reason why each keyword is restricted. +- **Hotel rates not allowed to be booked:** Restrict specific hotel rates (such as non-refundable, prepaid, and requires deposit) from being booked by employees. This overrides all other hotel policy rules. You also have the option to add a reason why these options aren’t available. +- **Maximum price:** Set a maximum price per night for bookings. You can also select the Customizations by location option to set a maximum price for each location. +- **Booking window:** Add a time limit to prevent employees from booking less than a certain number of days in advance to prohibit bookings too close to the flight time. +- **Cancellation policy:** Allow your employees to book fully or partially refundable rooms. To allow all options, you can leave this as the default option. +- **Experience:** Set hotel ratings that are in and out of policy. +- **Nightly median rate:** Determine which hotels to consider when calculating the median price. You can set a radius around the search location and include/disclude hotels above or below a certain rating. +- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy hotel booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. + +# Car + +- **Car categories not allowed:** Restrict specific car types from being booked by employees. This overrides any other car policy. +- **Car categories in policy:** Define the types of cars that are in policy. If you do not list a specific car category, it will still be booked as long as it isn’t included in the Car categories not allowed setting. However, the booking will be classed as out of policy. +- **Car engine types not allowed:** Restrict specific engine types from being booked by employees regardless of other policy settings. +- **Maximum price:** Set a daily price cap per car (not including taxes and fees). +- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy car booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. + +# FAQ + +How do travel policy rules interact with Expensify’s [approval flows](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses)? + +Travel policy rules define what can and can’t be booked by your employees while they’re making the booking. Once a booking is placed and the travel itself is [approved](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses), the expense will appear in Expensify. It will then be coded, submitted, pushed through the existing expense approval process as defined by your workspace, and exported to your preferred accounting platform (if applicable).
@@ -39,27 +85,73 @@ As a Workspace Admin, you can set travel policies for all travel booked under yo # Create a travel policy -When using Expensify Travel for the first time, you will need to create a new Travel Policy. +Workspace admins can create different travel policies that provide travel rules for different groups of travelers. When using Expensify Travel for the first time, you will need to create a new Travel Policy. -To create a travel policy from the Expensify web app, +To create or update a travel policy, 1. Click the + icon in the bottom left menu and select **Book travel**. 2. Click **Book or manage travel**. 3. Click the **Program** tab at the top and select **Policies**. 4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy. -5. Use the **Edit members** section to select the group of employees that belong to this policy. A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. -6. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. -7. Click the paperclip icon next to each setting to de-couple it from your default policy. -8. Update the desired settings. +5. Use the **Edit members** section to select the group of employees that belong to this policy. + 1. **To select an existing policy:** Select the policy in the left menu. + 2. **To add a new policy:** Click **Add new** under Employee or Non-employee in the left menu. Then under the Edit members section, select the group of employees that belong to this policy. -# Update travel policy preferences +{% include info.html %} +A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. +{% include end-info.html %} -1. Click the + icon in the bottom left menu and select **Book travel**. -2. Click **Book or manage travel**. -3. Click the **Program** tab at the top and select **Policies**. -4. Select the appropriate policy in the left menu. -5. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. -6. Click the paperclip icon next to each setting to de-couple it from your default policy. -7. Update the desired policies. +10. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. +11. Click the paperclip icon next to each setting to de-couple it from your default policy. +12. Update the desired settings. + +# General + +Determine the currency and [approval type](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses#set-approval-method) for different types of travel bookings. You can also upload a PDF of your company travel policy for employees to access when booking travel. + +![Screenshot of Expensify Travel policy approval settings](https://help.expensify.com/assets/images/Travel_Policy.png){:width="100%"} + +# Flight + +Flight preferences include multiple sections with different settings: + +- **Flights not allowed to be booked:** Restrict specific flight classes from being booked by employees. +- **Cabin class settings:** Set the highest cabin class allowed for booking and whether employees can accept cabin upgrades. +- **Budget:** Set a percentage or fare cap for the median or cheapest fares on flights, and a maximum capped price. Flight fares above this cap will be considered out of policy. You can also add customization to allow for different caps for different flight durations. +- **Lowest logical fare settings:** Set a preference for layovers, number of stops, flight time window, and airport connection changes. Expensify automatically takes these preferences into account when showing the lowest logical travel fare. +- **Add Ons and Preferences:** Select what types of add-ons your employees can book, including: + - Additional baggage + - Early check-in + - Seat preference + - No add-ons allowed +- **Booking windows:** Add a time limit to prevent employees from booking less than a certain number of days in advance to prohibit bookings too close to the flight time. +- **Refundable/changeable tickets:** Allow employees to book fully or partially refundable fares. To allow all options, you can leave this as the default option. +- **Maximum CO2 per kilometer:** If the CO2 per passenger km exceeds this threshold, the flight will be marked as out of policy. You can leave the entry blank if you do not wish to use this policy. +- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy flight booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. + +# Hotel + +- **Restrict booking by keyword:** Keywords entered here will be compared to the hotel rate description. If any of the keywords are found, the hotel rate will be restricted. You also have the option to provide a reason why each keyword is restricted. +- **Hotel rates not allowed to be booked:** Restrict specific hotel rates (such as non-refundable, prepaid, and requires deposit) from being booked by employees. This overrides all other hotel policy rules. You also have the option to add a reason why these options aren’t available. +- **Maximum price:** Set a maximum price per night for bookings. You can also select the Customizations by location option to set a maximum price for each location. +- **Booking window:** Add a time limit to prevent employees from booking less than a certain number of days in advance to prohibit bookings too close to the flight time. +- **Cancellation policy:** Allow your employees to book fully or partially refundable rooms. To allow all options, you can leave this as the default option. +- **Experience:** Set hotel ratings that are in and out of policy. +- **Nightly median rate:** Determine which hotels to consider when calculating the median price. You can set a radius around the search location and include/disclude hotels above or below a certain rating. +- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy hotel booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. + +# Car + +- **Car categories not allowed:** Restrict specific car types from being booked by employees. This overrides any other car policy. +- **Car categories in policy:** Define the types of cars that are in policy. If you do not list a specific car category, it will still be booked as long as it isn’t included in the Car categories not allowed setting. However, the booking will be classed as out of policy. +- **Car engine types not allowed:** Restrict specific engine types from being booked by employees regardless of other policy settings. +- **Maximum price:** Set a daily price cap per car (not including taxes and fees). +- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy car booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. + +# FAQ + +How do travel policy rules interact with Expensify’s [approval flows](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses)? + +Travel policy rules define what can and can’t be booked by your employees while they’re making the booking. Once a booking is placed and the travel itself is [approved](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses), the expense will appear in Expensify. It will then be coded, submitted, pushed through the existing expense approval process as defined by your workspace, and exported to your preferred accounting platform (if applicable). diff --git a/docs/assets/images/ExpensifyHelp_TrackExpense_1.png b/docs/assets/images/ExpensifyHelp_TrackExpense_1.png new file mode 100644 index 000000000000..f4a74c4cada3 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_TrackExpense_1.png differ diff --git a/docs/assets/images/ExpensifyHelp_TrackExpense_2.png b/docs/assets/images/ExpensifyHelp_TrackExpense_2.png new file mode 100644 index 000000000000..4fe25eb0de85 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_TrackExpense_2.png differ diff --git a/docs/assets/images/ExpensifyHelp_TrackExpense_3.png b/docs/assets/images/ExpensifyHelp_TrackExpense_3.png new file mode 100644 index 000000000000..52f5588aa4f3 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_TrackExpense_3.png differ diff --git a/docs/assets/images/Travel_Email.png b/docs/assets/images/Travel_Email.png new file mode 100644 index 000000000000..95de51c18912 Binary files /dev/null and b/docs/assets/images/Travel_Email.png differ diff --git a/docs/assets/images/Travel_Policy.png b/docs/assets/images/Travel_Policy.png new file mode 100644 index 000000000000..6b80f938721c Binary files /dev/null and b/docs/assets/images/Travel_Policy.png differ diff --git a/docs/expensify-classic/hubs/bank-accounts-and-payments/bank-accounts.html b/docs/expensify-classic/hubs/bank-accounts-and-payments/bank-accounts.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/bank-accounts-and-payments/bank-accounts.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/redirects.csv b/docs/redirects.csv index 4b7cc43bd072..0caf75623e6e 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -282,4 +282,9 @@ https://help.expensify.com/articles/new-expensify/connections/Set-up-QuickBooks- https://help.expensify.com/articles/new-expensify/connections/Set-Up-Sage-Intacct-connection,https://help.expensify.com/articles/new-expensify/connections/sage-intacct/Connect-to-Sage-Intacct https://help.expensify.com/articles/new-expensify/connections/Set-Up-Sage-Intacct-connection.html,https://help.expensify.com/articles/new-expensify/connections/sage-intacct/Connect-to-Sage-Intacct https://help.expensify.com/articles/new-expensify/connections/Set-up-Xero-connection,https://help.expensify.com/articles/new-expensify/connections/xero/Connect-to-Xero -https://help.expensify.com/articles/new-expensify/connections/Set-up-Xero-connection.html,https://help.expensify.com/articles/new-expensify/connections/xero/Connect-to-Xero \ No newline at end of file +https://help.expensify.com/articles/new-expensify/connections/Set-up-Xero-connection.html,https://help.expensify.com/articles/new-expensify/connections/xero/Connect-to-Xero +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Business-Bank-Accounts-USD,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Deposit-Accounts-USD,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account.md +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Business-Bank-Accounts-AUD,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Australian-Reimbursements.md +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Deposit-Accounts-AUD,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Add-Personal-Australian-Bank-Account.md +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Global-Reimbursements,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index f224dcb9db92..14a783eb1866 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.14.2 + 9.0.14.5 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 520c82a45f77..68be54c984ff 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 9.0.14.2 + 9.0.14.5 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 77a3afc2e271..d6b3f8763c11 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.0.14 CFBundleVersion - 9.0.14.2 + 9.0.14.5 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 9082f9183c23..d96d25151a5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.14-2", + "version": "9.0.14-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.14-2", + "version": "9.0.14-5", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 89d2a89aeb42..ca88c73a4328 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.14-2", + "version": "9.0.14-5", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/patches/@perf-profiler+android+0.13.0+001+pid-changed.patch b/patches/@perf-profiler+android+0.13.0+001+pid-changed.patch new file mode 100644 index 000000000000..d607b1460d79 --- /dev/null +++ b/patches/@perf-profiler+android+0.13.0+001+pid-changed.patch @@ -0,0 +1,45 @@ +diff --git a/node_modules/@perf-profiler/android/dist/src/commands/platforms/UnixProfiler.js b/node_modules/@perf-profiler/android/dist/src/commands/platforms/UnixProfiler.js +index 657c3b0..c97e363 100644 +--- a/node_modules/@perf-profiler/android/dist/src/commands/platforms/UnixProfiler.js ++++ b/node_modules/@perf-profiler/android/dist/src/commands/platforms/UnixProfiler.js +@@ -113,7 +113,7 @@ class UnixProfiler { + } + pollPerformanceMeasures(bundleId, { onMeasure, onStartMeasuring = () => { + // noop by default +- }, }) { ++ }, onPidChanged = () => {}}) { + let initialTime = null; + let previousTime = null; + let cpuMeasuresAggregator = new CpuMeasureAggregator_1.CpuMeasureAggregator(this.getCpuClockTick()); +@@ -170,6 +170,7 @@ class UnixProfiler { + previousTime = timestamp; + }, () => { + logger_1.Logger.warn("Process id has changed, ignoring measures until now"); ++ onPidChanged(); + reset(); + }); + } +diff --git a/node_modules/@perf-profiler/android/src/commands/platforms/UnixProfiler.ts b/node_modules/@perf-profiler/android/src/commands/platforms/UnixProfiler.ts +index be26fe6..0473f78 100644 +--- a/node_modules/@perf-profiler/android/src/commands/platforms/UnixProfiler.ts ++++ b/node_modules/@perf-profiler/android/src/commands/platforms/UnixProfiler.ts +@@ -105,9 +105,11 @@ export abstract class UnixProfiler implements Profiler { + onStartMeasuring = () => { + // noop by default + }, ++ onPidChanged = () => {}, + }: { + onMeasure: (measure: Measure) => void; + onStartMeasuring?: () => void; ++ onPidChanged?: () => void; + } + ) { + let initialTime: number | null = null; +@@ -187,6 +189,7 @@ export abstract class UnixProfiler implements Profiler { + }, + () => { + Logger.warn("Process id has changed, ignoring measures until now"); ++ onPidChanged(); + reset(); + } + ); diff --git a/patches/@perf-profiler+types+0.8.0+001+pid-changed.patch b/patches/@perf-profiler+types+0.8.0+001+pid-changed.patch new file mode 100644 index 000000000000..bb24b9e880cf --- /dev/null +++ b/patches/@perf-profiler+types+0.8.0+001+pid-changed.patch @@ -0,0 +1,12 @@ +diff --git a/node_modules/@perf-profiler/types/dist/index.d.ts b/node_modules/@perf-profiler/types/dist/index.d.ts +index 0d0f55f..ef7f864 100644 +--- a/node_modules/@perf-profiler/types/dist/index.d.ts ++++ b/node_modules/@perf-profiler/types/dist/index.d.ts +@@ -80,6 +80,7 @@ export interface ScreenRecorder { + export interface ProfilerPollingOptions { + onMeasure: (measure: Measure) => void; + onStartMeasuring?: () => void; ++ onPidChanged?: () => void; + } + export interface Profiler { + pollPerformanceMeasures: (bundleId: string, options: ProfilerPollingOptions) => { diff --git a/patches/react-native+0.73.4+023+iOS-fix-adjustFontSizeToFit-new-architecture.patch b/patches/react-native+0.73.4+022+iOS-fix-adjustFontSizeToFit-new-architecture.patch similarity index 100% rename from patches/react-native+0.73.4+023+iOS-fix-adjustFontSizeToFit-new-architecture.patch rename to patches/react-native+0.73.4+022+iOS-fix-adjustFontSizeToFit-new-architecture.patch diff --git a/patches/react-native+0.73.4+022+textInputClear.patch b/patches/react-native+0.73.4+022+textInputClear.patch deleted file mode 100644 index 1cadce6a0783..000000000000 --- a/patches/react-native+0.73.4+022+textInputClear.patch +++ /dev/null @@ -1,66 +0,0 @@ -diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm -index 7ce04da..123968f 100644 ---- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm -+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm -@@ -452,6 +452,12 @@ - (void)blur - [_backedTextInputView resignFirstResponder]; - } - -+- (void)clear -+{ -+ [self setTextAndSelection:_mostRecentEventCount value:@"" start:0 end:0]; -+ _mostRecentEventCount++; -+} -+ - - (void)setTextAndSelection:(NSInteger)eventCount - value:(NSString *__nullable)value - start:(NSInteger)start -diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h -index fe3376a..6a9a45f 100644 ---- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h -+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h -@@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN - @protocol RCTTextInputViewProtocol - - (void)focus; - - (void)blur; -+- (void)clear; - - (void)setTextAndSelection:(NSInteger)eventCount - value:(NSString *__nullable)value - start:(NSInteger)start -@@ -49,6 +50,19 @@ RCTTextInputHandleCommand(id componentView, const NSSt - return; - } - -+ if ([commandName isEqualToString:@"clear"]) { -+#if RCT_DEBUG -+ if ([args count] != 0) { -+ RCTLogError( -+ @"%@ command %@ received %d arguments, expected %d.", @"TextInput", commandName, (int)[args count], 0); -+ return; -+ } -+#endif -+ -+ [componentView clear]; -+ return; -+ } -+ - if ([commandName isEqualToString:@"setTextAndSelection"]) { - #if RCT_DEBUG - if ([args count] != 4) { -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java -index 8496a7d..e6bcfc4 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java -@@ -331,6 +331,12 @@ public class ReactTextInputManager extends BaseViewManager) => void) + | undefined; + ++ /** ++ * Callback that is called when the text input was cleared using the native clear command. ++ */ ++ onClear?: ++ | ((e: NativeSyntheticEvent) => void) ++ | undefined; ++ + /** + * Callback that is called when the text input's text changes. + */ +diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js +index 481938f..346acaa 100644 +--- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js ++++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js +@@ -1329,6 +1329,11 @@ function InternalTextInput(props: Props): React.Node { + }); + }; + ++ const _onClear = (event: ChangeEvent) => { ++ setMostRecentEventCount(event.nativeEvent.eventCount); ++ props.onClear && props.onClear(event); ++ }; ++ + const _onFocus = (event: FocusEvent) => { + TextInputState.focusInput(inputRef.current); + if (props.onFocus) { +@@ -1462,6 +1467,7 @@ function InternalTextInput(props: Props): React.Node { + nativeID={id ?? props.nativeID} + onBlur={_onBlur} + onKeyPressSync={props.unstable_onKeyPressSync} ++ onClear={_onClear} + onChange={_onChange} + onChangeSync={useOnChangeSync === true ? _onChangeSync : null} + onContentSizeChange={props.onContentSizeChange} +@@ -1516,6 +1522,7 @@ function InternalTextInput(props: Props): React.Node { + nativeID={id ?? props.nativeID} + numberOfLines={props.rows ?? props.numberOfLines} + onBlur={_onBlur} ++ onClear={_onClear} + onChange={_onChange} + onFocus={_onFocus} + /* $FlowFixMe[prop-missing] the types for AndroidTextInput don't match +diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm +index a19b555..4785987 100644 +--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm ++++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm +@@ -62,6 +62,7 @@ @implementation RCTBaseTextInputViewManager { + + RCT_EXPORT_VIEW_PROPERTY(onChange, RCTBubblingEventBlock) + RCT_EXPORT_VIEW_PROPERTY(onKeyPressSync, RCTDirectEventBlock) ++RCT_EXPORT_VIEW_PROPERTY(onClear, RCTDirectEventBlock) + RCT_EXPORT_VIEW_PROPERTY(onChangeSync, RCTDirectEventBlock) + RCT_EXPORT_VIEW_PROPERTY(onSelectionChange, RCTDirectEventBlock) + RCT_EXPORT_VIEW_PROPERTY(onTextInput, RCTDirectEventBlock) +diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +index 7ce04da..70754bf 100644 +--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm ++++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +@@ -452,6 +452,19 @@ - (void)blur + [_backedTextInputView resignFirstResponder]; + } + ++- (void)clear ++{ ++ auto metrics = [self _textInputMetrics]; ++ [self setTextAndSelection:_mostRecentEventCount value:@"" start:0 end:0]; ++ ++ _mostRecentEventCount++; ++ metrics.eventCount = _mostRecentEventCount; ++ ++ // Notify JS that the event counter has changed ++ const auto &textInputEventEmitter = static_cast(*_eventEmitter); ++ textInputEventEmitter.onClear(metrics); ++} ++ + - (void)setTextAndSelection:(NSInteger)eventCount + value:(NSString *__nullable)value + start:(NSInteger)start +diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h +index fe3376a..6889eed 100644 +--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h ++++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h +@@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN + @protocol RCTTextInputViewProtocol + - (void)focus; + - (void)blur; ++- (void)clear; + - (void)setTextAndSelection:(NSInteger)eventCount + value:(NSString *__nullable)value + start:(NSInteger)start +@@ -49,6 +50,19 @@ RCTTextInputHandleCommand(id componentView, const NSSt + return; + } + ++ if ([commandName isEqualToString:@"clear"]) { ++#if RCT_DEBUG ++ if ([args count] != 0) { ++ RCTLogError( ++ @"%@ command %@ received %d arguments, expected %d.", @"TextInput", commandName, (int)[args count], 0); ++ return; ++ } ++#endif ++ ++ [componentView clear]; ++ return; ++ } ++ + if ([commandName isEqualToString:@"setTextAndSelection"]) { + #if RCT_DEBUG + if ([args count] != 4) { +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextClearEvent.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextClearEvent.java +new file mode 100644 +index 0000000..0c142a0 +--- /dev/null ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextClearEvent.java +@@ -0,0 +1,53 @@ ++/* ++ * Copyright (c) Meta Platforms, Inc. and affiliates. ++ * ++ * This source code is licensed under the MIT license found in the ++ * LICENSE file in the root directory of this source tree. ++ */ ++ ++package com.facebook.react.views.textinput; ++ ++import androidx.annotation.Nullable; ++ ++import com.facebook.react.bridge.Arguments; ++import com.facebook.react.bridge.WritableMap; ++import com.facebook.react.uimanager.common.ViewUtil; ++import com.facebook.react.uimanager.events.Event; ++ ++/** ++ * Event emitted by EditText native view when text changes. VisibleForTesting from {@link ++ * TextInputEventsTestCase}. ++ */ ++public class ReactTextClearEvent extends Event { ++ ++ public static final String EVENT_NAME = "topClear"; ++ ++ private String mText; ++ private int mEventCount; ++ ++ @Deprecated ++ public ReactTextClearEvent(int viewId, String text, int eventCount) { ++ this(ViewUtil.NO_SURFACE_ID, viewId, text, eventCount); ++ } ++ ++ public ReactTextClearEvent(int surfaceId, int viewId, String text, int eventCount) { ++ super(surfaceId, viewId); ++ mText = text; ++ mEventCount = eventCount; ++ } ++ ++ @Override ++ public String getEventName() { ++ return EVENT_NAME; ++ } ++ ++ @Nullable ++ @Override ++ protected WritableMap getEventData() { ++ WritableMap eventData = Arguments.createMap(); ++ eventData.putString("text", mText); ++ eventData.putInt("eventCount", mEventCount); ++ eventData.putInt("target", getViewTag()); ++ return eventData; ++ } ++} +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java +index 8496a7d..53e5c49 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java +@@ -8,6 +8,7 @@ + package com.facebook.react.views.textinput; + + import static com.facebook.react.uimanager.UIManagerHelper.getReactContext; ++import static com.facebook.react.uimanager.UIManagerHelper.getSurfaceId; + + import android.content.Context; + import android.content.res.ColorStateList; +@@ -273,6 +274,9 @@ public class ReactTextInputManager extends BaseViewManager { + Log.info('App launched', false, {Platform, CONFIG}); + }, []); + useEffect(() => { setTimeout(() => { BootSplash.getVisibilityStatus().then((status) => { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 4bf7987f9ceb..cecb02c2ca87 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -385,6 +385,9 @@ const ONYXKEYS = { /** Stores the information about the state of issuing a new card */ ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard', + /** Stores the information if mobile selection mode is active */ + MOBILE_SELECTION_MODE: 'mobileSelectionMode', + NVP_PRIVATE_CANCELLATION_DETAILS: 'nvp_private_cancellationDetails', /** Stores the information about currently edited advanced approval workflow */ @@ -853,6 +856,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_TRAVEL_SETTINGS]: OnyxTypes.TravelSettings; [ONYXKEYS.REVIEW_DUPLICATES]: OnyxTypes.ReviewDuplicates; [ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; + [ONYXKEYS.MOBILE_SELECTION_MODE]: OnyxTypes.MobileSelectionMode; [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: string; [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: string; [ONYXKEYS.NVP_BILLING_FUND_ID]: number; diff --git a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx index b9aeceeb3621..1d273e847d26 100644 --- a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx +++ b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx @@ -45,7 +45,7 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow { - if (isDownloading || isOffline) { + if (isDownloading || isOffline || !sourceID) { return; } Download.setDownload(sourceID, true); @@ -63,7 +63,7 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index f0c5e29bc3ba..164da5922b98 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -195,6 +195,8 @@ function AttachmentModal({ const {translate} = useLocalize(); const {isOffline} = useNetwork(); + const isLocalSource = typeof sourceState === 'string' && /^file:|^blob:/.test(sourceState); + useEffect(() => { setFile(originalFileName ? {name: originalFileName} : undefined); }, [originalFileName]); @@ -342,6 +344,7 @@ function AttachmentModal({ updatedFile = new File([updatedFile], cleanName, {type: updatedFile.type}); } const inputSource = URL.createObjectURL(updatedFile); + updatedFile.uri = inputSource; const inputModalType = getModalType(inputSource, updatedFile); setIsModalOpen(true); setSourceState(inputSource); @@ -412,7 +415,7 @@ function AttachmentModal({ }, }); } - if (!isOffline && allowDownload) { + if (!isOffline && allowDownload && !isLocalSource) { menuItems.push({ icon: Expensicons.Download, text: translate('common.download'), @@ -439,7 +442,7 @@ function AttachmentModal({ let shouldShowThreeDotsButton = false; if (!isEmptyObject(report)) { headerTitleNew = translate(isReceiptAttachment ? 'common.receipt' : 'common.attachment'); - shouldShowDownloadButton = allowDownload && isDownloadButtonReadyToBeShown && !shouldShowNotFoundPage && !isReceiptAttachment && !isOffline; + shouldShowDownloadButton = allowDownload && isDownloadButtonReadyToBeShown && !shouldShowNotFoundPage && !isReceiptAttachment && !isOffline && !isLocalSource; shouldShowThreeDotsButton = isReceiptAttachment && isModalOpen && threeDotsMenuItems.length !== 0; } const context = useMemo( diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts index 40438d47ecc7..81ee6d08934b 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts +++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts @@ -37,11 +37,11 @@ function extractAttachments( } uniqueSources.add(source); - const splittedUrl = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE].split('/'); + const fileName = attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || FileUtils.getFileName(`${source}`); attachments.unshift({ source: tryResolveUrlFromApiRoot(attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]), isAuthTokenRequired: !!attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE], - file: {name: splittedUrl[splittedUrl.length - 1]}, + file: {name: fileName}, duration: Number(attribs[CONST.ATTACHMENT_DURATION_ATTRIBUTE]), isReceipt: false, hasBeenFlagged: false, diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index d4ee3e10914a..12802476ed0d 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -79,7 +79,7 @@ function ButtonWithDropdownMenu({ }} onPress={(event) => (!isSplitButton ? setIsMenuVisible(!isMenuVisible) : onPress(event, selectedItem.value))} text={customText ?? selectedItem.text} - isDisabled={isDisabled || !!selectedItem.disabled} + isDisabled={isDisabled || !!selectedItem?.disabled} isLoading={isLoading} shouldRemoveRightBorderRadius style={[styles.flex1, styles.pr0]} diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/index.native.tsx index 9c380eb336c3..68a8c56c4df9 100644 --- a/src/components/Composer/index.native.tsx +++ b/src/components/Composer/index.native.tsx @@ -1,7 +1,7 @@ import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; -import type {TextInput} from 'react-native'; +import React, {useCallback, useMemo, useRef} from 'react'; +import type {NativeSyntheticEvent, TextInput, TextInputChangeEventData} from 'react-native'; import {StyleSheet} from 'react-native'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; @@ -19,8 +19,7 @@ const excludeReportMentionStyle: Array = ['mentionReport']; function Composer( { - shouldClear = false, - onClear = () => {}, + onClear: onClearProp = () => {}, isDisabled = false, maxLines, isComposerFullSize = false, @@ -39,9 +38,9 @@ function Composer( ) { const textInput = useRef(null); const {isFocused, shouldResetFocusRef} = useResetComposerFocus(textInput); - const doesTextContainOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); + const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); const theme = useTheme(); - const markdownStyle = useMarkdownStyle(doesTextContainOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); + const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -64,19 +63,15 @@ function Composer( // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); - useEffect(() => { - if (!shouldClear) { - return; - } - textInput.current?.clear(); - onClear(); - }, [shouldClear, onClear]); + const onClear = useCallback( + ({nativeEvent}: NativeSyntheticEvent) => { + onClearProp(nativeEvent.text); + }, + [onClearProp], + ); const maxHeightStyle = useMemo(() => StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), [StyleUtils, isComposerFullSize, maxLines]); - const composerStyle = useMemo( - () => StyleSheet.flatten([style, doesTextContainOnlyEmojis ? styles.onlyEmojisTextLineHeight : styles.emojisWithTextLineHeight]), - [style, doesTextContainOnlyEmojis, styles], - ); + const composerStyle = useMemo(() => StyleSheet.flatten([style, textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}]), [style, textContainsOnlyEmojis, styles]); return ( ); } diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 4e192e908458..35f805c5ea0f 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -1,7 +1,7 @@ import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; import lodashDebounce from 'lodash/debounce'; import type {BaseSyntheticEvent, ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {flushSync} from 'react-dom'; // eslint-disable-next-line no-restricted-imports import type {DimensionValue, NativeSyntheticEvent, Text as RNText, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle} from 'react-native'; @@ -59,7 +59,6 @@ function Composer( maxLines = -1, onKeyPress = () => {}, style, - shouldClear = false, autoFocus = false, shouldCalculateCaretPosition = false, isDisabled = false, @@ -80,10 +79,10 @@ function Composer( }: ComposerProps, ref: ForwardedRef, ) { - const doesTextContainOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); + const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); const theme = useTheme(); const styles = useThemeStyles(); - const markdownStyle = useMarkdownStyle(doesTextContainOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); + const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); const StyleUtils = useStyleUtils(); const textRef = useRef(null); const textInput = useRef(null); @@ -107,14 +106,6 @@ function Composer( const [prevScroll, setPrevScroll] = useState(); const isReportFlatListScrolling = useRef(false); - useEffect(() => { - if (!shouldClear) { - return; - } - textInput.current?.clear(); - onClear(); - }, [shouldClear, onClear]); - useEffect(() => { if (!!selection && selectionProp.start === selection.start && selectionProp.end === selection.end) { return; @@ -284,9 +275,6 @@ function Composer( useHtmlPaste(textInput, handlePaste, true); useEffect(() => { - if (typeof ref === 'function') { - ref(textInput.current); - } setIsRendered(true); return () => { @@ -298,6 +286,49 @@ function Composer( // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); + const clear = useCallback(() => { + if (!textInput.current) { + return; + } + + const currentText = textInput.current.innerText; + textInput.current.clear(); + + // We need to reset the selection to 0,0 manually after clearing the text input on web + const selectionEvent = { + nativeEvent: { + selection: { + start: 0, + end: 0, + }, + }, + } as NativeSyntheticEvent; + onSelectionChange(selectionEvent); + setSelection({start: 0, end: 0}); + + onClear(currentText); + }, [onClear, onSelectionChange]); + + useImperativeHandle( + ref, + () => { + const textInputRef = textInput.current; + if (!textInputRef) { + throw new Error('textInputRef is not available. This should never happen and indicates a developer error.'); + } + + return { + ...textInputRef, + // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works + clear, + // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly + blur: () => textInputRef.blur(), + focus: () => textInputRef.focus(), + }; + }, + [clear], + ); + const handleKeyPress = useCallback( (e: NativeSyntheticEvent) => { // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed @@ -345,9 +376,10 @@ function Composer( scrollStyleMemo, StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), isComposerFullSize ? ({height: '100%', maxHeight: 'none' as DimensionValue} as TextStyle) : undefined, + textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}, ], - [style, styles.rtlTextRenderForSafari, scrollStyleMemo, StyleUtils, maxLines, isComposerFullSize], + [style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis], ); return ( diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index 9c7a5a215c1c..e6d8a882f3b8 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -11,7 +11,7 @@ type CustomSelectionChangeEvent = NativeSyntheticEvent & { /** identify id in the text input */ id?: string; @@ -27,6 +27,12 @@ type ComposerProps = TextInputProps & { /** The value of the comment box */ value?: string; + /** + * Callback when the input was cleared using the .clear ref method. + * The text parameter will be the value of the text that was cleared. + */ + onClear?: (text: string) => void; + /** Callback method handle when the input is changed */ onChangeText?: (numberOfLines: string) => void; @@ -37,12 +43,6 @@ type ComposerProps = TextInputProps & { // eslint-disable-next-line react/forbid-prop-types style?: StyleProp; - /** If the input should clear, it actually gets intercepted instead of .clear() */ - shouldClear?: boolean; - - /** When the input has cleared whoever owns this input should know about it */ - onClear?: () => void; - /** Whether or not this TextInput is disabled. */ isDisabled?: boolean; diff --git a/src/components/DraggableList/index.android.tsx b/src/components/DraggableList/index.android.tsx index 30bf7c927bd9..cc1a7b1910d4 100644 --- a/src/components/DraggableList/index.android.tsx +++ b/src/components/DraggableList/index.android.tsx @@ -1,23 +1,24 @@ import React from 'react'; import {View} from 'react-native'; -import DraggableFlatList from 'react-native-draggable-flatlist'; -import type {FlatList} from 'react-native-gesture-handler'; +import {NestableDraggableFlatList, NestableScrollContainer} from 'react-native-draggable-flatlist'; +import type {ScrollView} from 'react-native-gesture-handler'; import useThemeStyles from '@hooks/useThemeStyles'; import type {DraggableListProps} from './types'; -function DraggableList({renderClone, shouldUsePortal, ListFooterComponent, ...viewProps}: DraggableListProps, ref: React.ForwardedRef>) { +function DraggableList({renderClone, shouldUsePortal, ListFooterComponent, ...viewProps}: DraggableListProps, ref: React.ForwardedRef) { const styles = useThemeStyles(); return ( - - + {React.isValidElement(ListFooterComponent) && {ListFooterComponent}} - + ); } diff --git a/src/components/ErrorBoundary/index.tsx b/src/components/ErrorBoundary/index.tsx index 890cf5f4e587..6787adeed44d 100644 --- a/src/components/ErrorBoundary/index.tsx +++ b/src/components/ErrorBoundary/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useEffect} from 'react'; import Log from '@libs//Log'; import BaseErrorBoundary from './BaseErrorBoundary'; import type {BaseErrorBoundaryProps, LogError} from './types'; @@ -8,7 +8,26 @@ const logError: LogError = (errorMessage, error, errorInfo) => { Log.alert(`${errorMessage} - ${error.message}`, {errorInfo}, false); }; +const onUnhandledRejection = (event: PromiseRejectionEvent) => { + let rejection: unknown = event.reason; + if (event.reason instanceof Error) { + Log.alert(`Unhandled Promise Rejection: ${event.reason.message}\nStack: ${event.reason.stack}`, {}, false); + return; + } + + if (typeof event.reason === 'object' && event.reason !== null) { + rejection = JSON.stringify(event.reason); + } + Log.alert(`Unhandled Promise Rejection: ${String(rejection)}`, {}, false); +}; + function ErrorBoundary({errorMessage, children}: Omit) { + // Log unhandled promise rejections to the server + useEffect(() => { + window.addEventListener('unhandledrejection', onUnhandledRejection); + return () => window.removeEventListener('unhandledrejection', onUnhandledRejection); + }, []); + return ( & { function VideoRenderer({tnode, key}: VideoRendererProps) { const htmlAttribs = tnode.attributes; - const attrHref = htmlAttribs.href || htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] || ''; + const attrHref = htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] || htmlAttribs.src || htmlAttribs.href || ''; const sourceURL = tryResolveUrlFromApiRoot(attrHref); const fileName = FileUtils.getFileName(`${sourceURL}`); const thumbnailUrl = tryResolveUrlFromApiRoot(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_URL_ATTRIBUTE]); diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 0b9306d1c977..3aacf2ae88c8 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -82,6 +82,7 @@ import ExpensifyLogoNew from '@assets/images/expensify-logo-new.svg'; import ExpensifyWordmark from '@assets/images/expensify-wordmark.svg'; import EyeDisabled from '@assets/images/eye-disabled.svg'; import Eye from '@assets/images/eye.svg'; +import Filters from '@assets/images/filters.svg'; import Flag from '@assets/images/flag.svg'; import FlagLevelOne from '@assets/images/flag_level_01.svg'; import FlagLevelTwo from '@assets/images/flag_level_02.svg'; @@ -373,5 +374,6 @@ export { CheckCircle, CheckmarkCircle, NetSuiteSquare, + Filters, CalendarSolid, }; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index ccbf4b3a5da9..1ef0ca14bf51 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -56,6 +56,7 @@ import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustrat 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 Filters from '@assets/images/simple-illustrations/simple-illustration__filters.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'; @@ -210,4 +211,5 @@ export { FolderWithPapers, VirtualCard, Tire, + Filters, }; diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx index e77f5dba4eda..3045c15c471b 100644 --- a/src/components/InlineCodeBlock/WrappedText.tsx +++ b/src/components/InlineCodeBlock/WrappedText.tsx @@ -37,8 +37,7 @@ function getTextMatrix(text: string): string[][] { * Validates if the text contains any emoji */ function containsEmoji(text: string): boolean { - const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); - return emojisRegex.test(text); + return CONST.REGEX.EMOJIS.test(text); } function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { diff --git a/src/components/PromotedActionsBar.tsx b/src/components/PromotedActionsBar.tsx index abcd2df95e5c..b0309d702f9a 100644 --- a/src/components/PromotedActionsBar.tsx +++ b/src/components/PromotedActionsBar.tsx @@ -29,7 +29,7 @@ type BasePromotedActions = typeof CONST.PROMOTED_ACTIONS.PIN | typeof CONST.PROM type PromotedActionsType = Record PromotedAction> & { message: (params: {reportID?: string; accountID?: number; login?: string}) => PromotedAction; } & { - hold: (params: {isTextHold: boolean; reportAction: ReportAction | undefined}) => PromotedAction; + hold: (params: {isTextHold: boolean; reportAction: ReportAction | undefined; reportID?: string}) => PromotedAction; }; const PromotedActions = { @@ -70,7 +70,7 @@ const PromotedActions = { } }, }), - hold: ({isTextHold, reportAction}) => ({ + hold: ({isTextHold, reportAction, reportID}) => ({ key: CONST.PROMOTED_ACTIONS.HOLD, icon: Expensicons.Stopwatch, text: Localize.translateLocal(`iou.${isTextHold ? 'hold' : 'unhold'}`), @@ -78,14 +78,15 @@ const PromotedActions = { if (!isTextHold) { Navigation.goBack(); } + const targetedReportID = reportID ?? reportAction?.childReportID ?? ''; const topmostCentralPaneRoute = getTopmostCentralPaneRoute(navigationRef.getRootState() as State); if (topmostCentralPaneRoute?.name !== SCREENS.SEARCH.CENTRAL_PANE && isTextHold) { - ReportUtils.changeMoneyRequestHoldStatus(reportAction, ROUTES.REPORT_WITH_ID.getRoute(reportAction?.childReportID ?? '')); + ReportUtils.changeMoneyRequestHoldStatus(reportAction, ROUTES.REPORT_WITH_ID.getRoute(targetedReportID)); return; } - ReportUtils.changeMoneyRequestHoldStatus(reportAction, ROUTES.SEARCH_REPORT.getRoute(reportAction?.childReportID ?? '')); + ReportUtils.changeMoneyRequestHoldStatus(reportAction, ROUTES.SEARCH_REPORT.getRoute(targetedReportID)); }, }), } satisfies PromotedActionsType; @@ -131,4 +132,4 @@ PromotedActionsBar.displayName = 'PromotedActionsBar'; export default PromotedActionsBar; export {PromotedActions}; -export type {PromotedActionsBarProps, PromotedAction}; +export type {PromotedAction, PromotedActionsBarProps}; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index bff62f247eee..be7c1da611ef 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -32,6 +32,7 @@ import * as TransactionUtils from '@libs/TransactionUtils'; import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import variables from '@styles/variables'; import * as IOU from '@userActions/IOU'; +import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -360,6 +361,7 @@ function ReportPreview({ { + Timing.start(CONST.TIMING.SWITCH_REPORT); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(iouReportID)); }} onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} diff --git a/src/components/Search/SearchListWithHeader.tsx b/src/components/Search/SearchListWithHeader.tsx index 172685377f97..aafa8141e99e 100644 --- a/src/components/Search/SearchListWithHeader.tsx +++ b/src/components/Search/SearchListWithHeader.tsx @@ -2,11 +2,8 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useState} from 'react'; import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; -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 SelectionListWithModal from '@components/SelectionListWithModal'; import useLocalize from '@hooks/useLocalize'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as SearchActions from '@libs/actions/Search'; @@ -14,15 +11,14 @@ import * as SearchUtils from '@libs/SearchUtils'; import CONST from '@src/CONST'; import type {SearchDataTypes, SearchReport} from '@src/types/onyx/SearchResults'; import SearchPageHeader from './SearchPageHeader'; -import type {SearchStatus, SelectedTransactionInfo, SelectedTransactions} from './types'; +import type {SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions} from './types'; type SearchListWithHeaderProps = Omit, 'onSelectAll' | 'onCheckboxPress' | 'sections'> & { - status: SearchStatus; + queryJSON: SearchQueryJSON; hash: number; data: TransactionListItemType[] | ReportListItemType[]; searchType: SearchDataTypes; - isMobileSelectionModeActive?: boolean; - setIsMobileSelectionModeActive?: (isMobileSelectionModeActive: boolean) => void; + isCustomQuery: boolean; }; function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] { @@ -43,14 +39,9 @@ function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListIt }; } -function SearchListWithHeader( - {ListItem, onSelectRow, status, hash, data, searchType, isMobileSelectionModeActive, setIsMobileSelectionModeActive, ...props}: SearchListWithHeaderProps, - ref: ForwardedRef, -) { +function SearchListWithHeader({ListItem, onSelectRow, queryJSON, hash, data, searchType, isCustomQuery, ...props}: SearchListWithHeaderProps, ref: ForwardedRef) { const {isSmallScreenWidth} = useWindowDimensions(); const {translate} = useLocalize(); - const [isModalVisible, setIsModalVisible] = useState(false); - const [longPressedItem, setLongPressedItem] = useState(null); const [selectedTransactions, setSelectedTransactions] = useState({}); const [selectedTransactionsToDelete, setSelectedTransactionsToDelete] = useState([]); const [deleteExpensesConfirmModalVisible, setDeleteExpensesConfirmModalVisible] = useState(false); @@ -132,36 +123,6 @@ function SearchListWithHeader( [selectedTransactions], ); - const openBottomModal = (item: TransactionListItemType | ReportListItemType | null) => { - if (!isSmallScreenWidth) { - return; - } - - setLongPressedItem(item); - setIsModalVisible(true); - }; - - const turnOnSelectionMode = useCallback(() => { - setIsMobileSelectionModeActive?.(true); - setIsModalVisible(false); - - if (longPressedItem) { - toggleTransaction(longPressedItem); - } - }, [longPressedItem, setIsMobileSelectionModeActive, toggleTransaction]); - - const closeBottomModal = useCallback(() => { - setIsModalVisible(false); - }, []); - - useEffect(() => { - if (isMobileSelectionModeActive) { - return; - } - - setSelectedTransactions({}); - }, [setSelectedTransactions, isMobileSelectionModeActive]); - const toggleAllTransactions = () => { const areItemsOfReportType = searchType === CONST.SEARCH.DATA_TYPES.REPORT; const flattenedItems = areItemsOfReportType ? (data as ReportListItemType[]).flatMap((item) => item.transactions) : data; @@ -188,26 +149,25 @@ function SearchListWithHeader( setOfflineModalVisible(true)} setDownloadErrorModalOpen={() => setDownloadErrorModalVisible(true)} /> - + // eslint-disable-next-line react/jsx-props-no-spreading {...props} sections={[{data: sortedSelectedData, isDisabled: false}]} ListItem={ListItem} onSelectRow={onSelectRow} - onLongPressRow={openBottomModal} + turnOnSelectionModeOnLongPress + onTurnOnSelectionMode={(item) => item && toggleTransaction(item)} ref={ref} onCheckboxPress={toggleTransaction} onSelectAll={toggleAllTransactions} - isMobileSelectionModeActive={isMobileSelectionModeActive} /> setDownloadErrorModalVisible(false)} /> - - - ); } diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 01f80f5bab56..97ab5383ff9c 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -1,53 +1,123 @@ import React, {useMemo} from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Header from '@components/Header'; +import type HeaderWithBackButtonProps from '@components/HeaderWithBackButton/types'; +import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; +import Text from '@components/Text'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import * as SearchActions from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; +import * as SearchUtils from '@libs/SearchUtils'; import SearchSelectedNarrow from '@pages/Search/SearchSelectedNarrow'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {SearchReport} from '@src/types/onyx/SearchResults'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type IconAsset from '@src/types/utils/IconAsset'; import {useSearchContext} from './SearchContext'; -import type {SearchStatus, SelectedTransactions} from './types'; +import type {SearchQueryJSON, SearchStatus, SelectedTransactions} from './types'; + +type HeaderWrapperProps = Pick & { + subtitleStyles?: StyleProp; +}; + +function HeaderWrapper({icon, title, subtitle, children, subtitleStyles = {}}: HeaderWrapperProps) { + const styles = useThemeStyles(); + + // If the icon is present, the header bar should be taller and use different font. + const isCentralPaneSettings = !!icon; + + const middleContent = useMemo(() => { + return ( +
+ {title} + + } + subtitle={ + + {subtitle} + + } + /> + ); + }, [styles.mutedTextLabel, styles.pre, styles.textLarge, subtitle, subtitleStyles, title]); + + return ( + + + {icon && ( + + )} + + {middleContent} + {children} + + + ); +} type SearchPageHeaderProps = { - status: SearchStatus; + queryJSON: SearchQueryJSON; selectedTransactions?: SelectedTransactions; selectedReports?: Array; clearSelectedItems?: () => void; hash: number; onSelectDeleteOption?: (itemsToDelete: string[]) => void; - isMobileSelectionModeActive?: boolean; - setIsMobileSelectionModeActive?: (isMobileSelectionModeActive: boolean) => void; + isCustomQuery: boolean; setOfflineModalOpen?: () => void; setDownloadErrorModalOpen?: () => void; }; type SearchHeaderOptionValue = DeepValueOf | undefined; +const headerContent: {[key in SearchStatus]: {icon: IconAsset; titleTx: TranslationPaths}} = { + all: {icon: Illustrations.MoneyReceipts, titleTx: 'common.expenses'}, + shared: {icon: Illustrations.SendMoney, titleTx: 'common.shared'}, + drafts: {icon: Illustrations.Pencil, titleTx: 'common.drafts'}, + finished: {icon: Illustrations.CheckmarkCircle, titleTx: 'common.finished'}, +}; + function SearchPageHeader({ - status, + queryJSON, selectedTransactions = {}, hash, clearSelectedItems, onSelectDeleteOption, - isMobileSelectionModeActive, - setIsMobileSelectionModeActive, setOfflineModalOpen, setDownloadErrorModalOpen, selectedReports, + isCustomQuery, }: SearchPageHeaderProps) { const {translate} = useLocalize(); const theme = useTheme(); @@ -56,16 +126,16 @@ function SearchPageHeader({ const {activeWorkspaceID} = useActiveWorkspace(); const {isSmallScreenWidth} = useResponsiveLayout(); const {setSelectedTransactionIDs} = useSearchContext(); - - const headerContent: {[key in SearchStatus]: {icon: IconAsset; title: string}} = { - all: {icon: Illustrations.MoneyReceipts, title: translate('common.expenses')}, - shared: {icon: Illustrations.SendMoney, title: translate('common.shared')}, - drafts: {icon: Illustrations.Pencil, title: translate('common.drafts')}, - finished: {icon: Illustrations.CheckmarkCircle, title: translate('common.finished')}, - }; + const {status} = queryJSON; + const headerSubtitle = isCustomQuery ? SearchUtils.getSearchHeaderTitle(queryJSON) : translate(headerContent[status]?.titleTx); + const headerTitle = isCustomQuery ? translate('search.filtersHeader') : ''; + const headerIcon = isCustomQuery ? Illustrations.Filters : headerContent[status]?.icon; + const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); const selectedTransactionsKeys = Object.keys(selectedTransactions ?? []); + const subtitleStyles = isCustomQuery ? {} : styles.textHeadlineH2; + const headerButtonsOptions = useMemo(() => { if (selectedTransactionsKeys.length === 0) { return []; @@ -106,8 +176,8 @@ function SearchPageHeader({ } clearSelectedItems?.(); - if (isMobileSelectionModeActive) { - setIsMobileSelectionModeActive?.(false); + if (selectionMode?.isEnabled) { + turnOffMobileSelectionMode(); } setSelectedTransactionIDs(selectedTransactionsKeys); Navigation.navigate(ROUTES.TRANSACTION_HOLD_REASON_RHP); @@ -130,8 +200,8 @@ function SearchPageHeader({ } clearSelectedItems?.(); - if (isMobileSelectionModeActive) { - setIsMobileSelectionModeActive?.(false); + if (selectionMode?.isEnabled) { + turnOffMobileSelectionMode(); } SearchActions.unholdMoneyRequestOnSearch(hash, selectedTransactionsKeys); }, @@ -183,9 +253,7 @@ function SearchPageHeader({ translate, onSelectDeleteOption, clearSelectedItems, - isMobileSelectionModeActive, hash, - setIsMobileSelectionModeActive, theme.icon, styles.colorMuted, styles.fontWeightNormal, @@ -196,10 +264,11 @@ function SearchPageHeader({ selectedReports, styles.textWrap, setSelectedTransactionIDs, + selectionMode?.isEnabled, ]); if (isSmallScreenWidth) { - if (isMobileSelectionModeActive) { + if (selectionMode?.isEnabled) { return ( {headerButtonsOptions.length > 0 && ( )} - + ); } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 72fb55e7eb05..da708173f092 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -32,8 +32,7 @@ import type {SearchColumnType, SearchQueryJSON, SearchStatus, SortOrder} from '. type SearchProps = { queryJSON: SearchQueryJSON; policyIDs?: string; - isMobileSelectionModeActive?: boolean; - setIsMobileSelectionModeActive?: (isMobileSelectionModeActive: boolean) => void; + isCustomQuery: boolean; }; const sortableSearchTabs: SearchStatus[] = [CONST.SEARCH.STATUS.ALL]; @@ -42,13 +41,14 @@ const reportItemTransactionHeight = 52; const listItemPadding = 12; // this is equivalent to 'mb3' on every transaction/report list item const searchHeaderHeight = 54; -function Search({queryJSON, policyIDs, isMobileSelectionModeActive, setIsMobileSelectionModeActive}: SearchProps) { +function Search({queryJSON, policyIDs, isCustomQuery}: SearchProps) { const {isOffline} = useNetwork(); const styles = useThemeStyles(); const {isLargeScreenWidth, isSmallScreenWidth} = useWindowDimensions(); const navigation = useNavigation>(); const lastSearchResultsRef = useRef>(); const {setCurrentSearchHash} = useSearchContext(); + const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); const [offset, setOffset] = React.useState(0); const {status, sortBy, sortOrder, hash} = queryJSON; @@ -110,7 +110,8 @@ function Search({queryJSON, policyIDs, isMobileSelectionModeActive, setIsMobileS return ( <> @@ -124,7 +125,8 @@ function Search({queryJSON, policyIDs, isMobileSelectionModeActive, setIsMobileS return ( <> @@ -179,14 +181,15 @@ function Search({queryJSON, policyIDs, isMobileSelectionModeActive, setIsMobileS const shouldShowYear = SearchUtils.shouldShowYear(searchResults?.data); - const canSelectMultiple = isSmallScreenWidth ? isMobileSelectionModeActive : true; + const canSelectMultiple = isSmallScreenWidth ? selectionMode?.isEnabled : true; return ( ( shouldDelayFocus = true, shouldUpdateFocusedIndex = false, onLongPressRow, - isMobileSelectionModeActive, }: BaseSelectionListProps, ref: ForwardedRef, ) { @@ -447,10 +446,9 @@ function BaseSelectionList( showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} onLongPressRow={onLongPressRow} - isMobileSelectionModeActive={isMobileSelectionModeActive} onSelectRow={() => { if (shouldSingleExecuteRowSelect) { - singleExecution(() => selectRow(item))(); + singleExecution(() => selectRow(item, index))(); } else { selectRow(item); } diff --git a/src/components/SelectionList/Search/UserInfoCell.tsx b/src/components/SelectionList/Search/UserInfoCell.tsx index d89220935dc5..2a5a7da51979 100644 --- a/src/components/SelectionList/Search/UserInfoCell.tsx +++ b/src/components/SelectionList/Search/UserInfoCell.tsx @@ -32,7 +32,7 @@ function UserInfoCell({participant, displayName}: UserInfoCellProps) { /> {displayName} diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx index 83bc8df36571..ee1ce4672529 100644 --- a/src/components/SelectionList/TableListItem.tsx +++ b/src/components/SelectionList/TableListItem.tsx @@ -23,6 +23,7 @@ function TableListItem({ onDismissError, rightHandSideComponent, onFocus, + onLongPressRow, shouldSyncFocus, }: TableListItemProps) { const styles = useThemeStyles(); @@ -50,6 +51,7 @@ function TableListItem({ isDisabled={isDisabled} showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} + onLongPressRow={onLongPressRow} onSelectRow={onSelectRow} onDismissError={onDismissError} rightHandSideComponent={rightHandSideComponent} diff --git a/src/components/SelectionList/UserListItem.tsx b/src/components/SelectionList/UserListItem.tsx index 119ebd9fd535..104990cf479c 100644 --- a/src/components/SelectionList/UserListItem.tsx +++ b/src/components/SelectionList/UserListItem.tsx @@ -12,7 +12,6 @@ import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; import CONST from '@src/CONST'; import BaseListItem from './BaseListItem'; import type {ListItem, UserListItemProps} from './types'; @@ -132,7 +131,6 @@ function UserListItem({ {!!item.alternateText && ( diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index df0e39f9f374..782307876e55 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -66,9 +66,6 @@ type CommonListItemProps = { /** Callback to fire when the item is long pressed */ onLongPressRow?: (item: TItem) => void; - - /** Whether Selection Mode is active - used only on small screens */ - isMobileSelectionModeActive?: boolean; } & TRightHandSideComponent; type ListItem = { @@ -492,9 +489,6 @@ type BaseSelectionListProps = Partial & { /** Callback to fire when the item is long pressed */ onLongPressRow?: (item: TItem) => void; - - /** Whether Selection Mode is active - used only on small screens */ - isMobileSelectionModeActive?: boolean; } & TRightHandSideComponent; type SelectionListHandle = { diff --git a/src/components/SelectionListWithModal/index.tsx b/src/components/SelectionListWithModal/index.tsx new file mode 100644 index 000000000000..45b90f17ac46 --- /dev/null +++ b/src/components/SelectionListWithModal/index.tsx @@ -0,0 +1,91 @@ +import React, {forwardRef, useEffect, useState} from 'react'; +import type {ForwardedRef} from 'react'; +import {useOnyx} from 'react-native-onyx'; +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, ListItem, SelectionListHandle} from '@components/SelectionList/types'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type SelectionListWithModalProps = BaseSelectionListProps & { + turnOnSelectionModeOnLongPress?: boolean; + onTurnOnSelectionMode?: (item: TItem | null) => void; +}; + +function SelectionListWithModal( + {turnOnSelectionModeOnLongPress, onTurnOnSelectionMode, onLongPressRow, sections, ...rest}: SelectionListWithModalProps, + ref: ForwardedRef, +) { + const [isModalVisible, setIsModalVisible] = useState(false); + const [longPressedItem, setLongPressedItem] = useState(null); + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useResponsiveLayout(); + const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); + + useEffect(() => { + // We can access 0 index safely as we are not displaying multiple sections in table view + const selectedItems = sections[0].data.filter((item) => item.isSelected); + if (!isSmallScreenWidth) { + if (selectedItems.length === 0) { + turnOffMobileSelectionMode(); + } + return; + } + if (selectedItems.length > 0 && !selectionMode?.isEnabled) { + turnOnMobileSelectionMode(); + } + }, [sections, selectionMode, isSmallScreenWidth]); + + const handleLongPressRow = (item: TItem) => { + if (!turnOnSelectionModeOnLongPress || !isSmallScreenWidth) { + return; + } + setLongPressedItem(item); + setIsModalVisible(true); + + if (onLongPressRow) { + onLongPressRow(item); + } + }; + + const turnOnSelectionMode = () => { + turnOnMobileSelectionMode(); + setIsModalVisible(false); + + if (onTurnOnSelectionMode) { + onTurnOnSelectionMode(longPressedItem); + } + }; + + useEffect(() => turnOffMobileSelectionMode(), []); + + return ( + <> + + setIsModalVisible(false)} + > + + + + ); +} + +export default forwardRef(SelectionListWithModal); diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 7764bdad0a60..685d54d86765 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -386,7 +386,6 @@ function BaseTextInput( // Add disabled color theme when field is not editable. inputProps.disabled && styles.textInputDisabled, styles.pointerEventsAuto, - isMarkdownEnabled ? {lineHeight: variables.lineHeightMarkdownEnabledInput} : null, ]} multiline={isMultiline} maxLength={maxLength} diff --git a/src/components/TextWithTooltip/index.ios.tsx b/src/components/TextWithTooltip/index.ios.tsx deleted file mode 100644 index f7c325b6d14e..000000000000 --- a/src/components/TextWithTooltip/index.ios.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, {useMemo} from 'react'; -import Text from '@components/Text'; -import * as EmojiUtils from '@libs/EmojiUtils'; -import CONST from '@src/CONST'; -import type TextWithTooltipProps from './types'; - -function TextWithTooltip({text, style, emojiFontSize, numberOfLines = 1}: TextWithTooltipProps) { - const processedTextArray = useMemo(() => { - const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); - const doesTextContainEmojis = !!(emojiFontSize && emojisRegex.test(text)); - - if (!doesTextContainEmojis) { - return []; - } - - return EmojiUtils.splitTextWithEmojis(text); - }, [emojiFontSize, text]); - - return ( - - {processedTextArray.length !== 0 ? processedTextArray.map(({text: textItem, isEmoji}) => (isEmoji ? {textItem} : textItem)) : text} - - ); -} - -TextWithTooltip.displayName = 'TextWithTooltip'; - -export default TextWithTooltip; diff --git a/src/components/TextWithTooltip/index.android.tsx b/src/components/TextWithTooltip/index.native.tsx similarity index 100% rename from src/components/TextWithTooltip/index.android.tsx rename to src/components/TextWithTooltip/index.native.tsx diff --git a/src/components/TextWithTooltip/types.ts b/src/components/TextWithTooltip/types.ts index 1df5af02b67a..4705e2b69a68 100644 --- a/src/components/TextWithTooltip/types.ts +++ b/src/components/TextWithTooltip/types.ts @@ -12,9 +12,6 @@ type TextWithTooltipProps = { /** Custom number of lines for text wrapping */ numberOfLines?: number; - - /** Emoji font size */ - emojiFontSize?: number; }; export default TextWithTooltipProps; diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts index b3b2b33ae71a..c7e9bf2c0218 100644 --- a/src/hooks/useMarkdownStyle.ts +++ b/src/hooks/useMarkdownStyle.ts @@ -1,13 +1,16 @@ import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; import {useMemo} from 'react'; +import {containsOnlyEmojis} from '@libs/EmojiUtils'; import FontUtils from '@styles/utils/FontUtils'; import variables from '@styles/variables'; import useTheme from './useTheme'; const defaultEmptyArray: Array = []; -function useMarkdownStyle(doesInputContainOnlyEmojis?: boolean, excludeStyles: Array = defaultEmptyArray): MarkdownStyle { +function useMarkdownStyle(message: string | null = null, excludeStyles: Array = defaultEmptyArray): MarkdownStyle { const theme = useTheme(); + const hasMessageOnlyEmojis = message != null && message.length > 0 && containsOnlyEmojis(message); + const emojiFontSize = hasMessageOnlyEmojis ? variables.fontSizeOnlyEmojis : variables.fontSizeNormal; // this map is used to reset the styles that are not needed - passing undefined value can break the native side const nonStylingDefaultValues: Record = useMemo( @@ -34,7 +37,7 @@ function useMarkdownStyle(doesInputContainOnlyEmojis?: boolean, excludeStyles: A fontSize: variables.fontSizeLarge, }, emoji: { - fontSize: doesInputContainOnlyEmojis ? variables.fontSizeEmojisOnlyComposer : variables.fontSizeEmojisWithinText, + fontSize: emojiFontSize, }, blockquote: { borderColor: theme.border, @@ -86,7 +89,7 @@ function useMarkdownStyle(doesInputContainOnlyEmojis?: boolean, excludeStyles: A } return styling; - }, [theme, doesInputContainOnlyEmojis, excludeStyles, nonStylingDefaultValues]); + }, [theme, emojiFontSize, excludeStyles, nonStylingDefaultValues]); return markdownStyle; } diff --git a/src/languages/en.ts b/src/languages/en.ts index 32b9a9eff2b6..67354e7ea30a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -32,7 +32,7 @@ import type { EnterMagicCodeParams, ExportedToIntegrationParams, FormattedMaxLengthParams, - ForwardedParams, + ForwardedAmountParams, GoBackMessageParams, GoToRoomParams, InstantSummaryParams, @@ -159,6 +159,7 @@ export default { resend: 'Resend', save: 'Save', select: 'Select', + selectMultiple: 'Select multiple', saveChanges: 'Save changes', submit: 'Submit', rotate: 'Rotate', @@ -745,6 +746,7 @@ export default { managerApprovedAmount: ({manager, amount}: ManagerApprovedAmountParams) => `${manager} approved ${amount}`, payerSettled: ({amount}: PayerSettledParams) => `paid ${amount}`, approvedAmount: ({amount}: ApprovedAmountParams) => `approved ${amount}`, + forwardedAmount: ({amount}: ForwardedAmountParams) => `approved ${amount}`, waitingOnBankAccount: ({submitterDisplayName}: WaitingOnBankAccountParams) => `started settling up. Payment is on hold until ${submitterDisplayName} adds a bank account.`, adminCanceledRequest: ({manager, amount}: AdminCanceledRequestParams) => `${manager ? `${manager}: ` : ''}cancelled the ${amount} payment.`, canceledRequest: ({amount, submitterDisplayName}: CanceledRequestParams) => @@ -1242,7 +1244,7 @@ export default { workflowTitle: 'Spend', workflowDescription: 'Configure a workflow from the moment spend occurs, including approval and payment.', delaySubmissionTitle: 'Delay submissions', - delaySubmissionDescription: 'Expenses are shared right away for better spend visibility. Set a slower cadence if needed.', + delaySubmissionDescription: 'Delay expense submissions based on a custom schedule, or keep this option disabled to maintain realtime spend visibility.', submissionFrequency: 'Submission frequency', submissionFrequencyDateOfMonth: 'Date of month', addApprovalsTitle: 'Add approvals', @@ -1250,11 +1252,12 @@ export default { approver: 'Approver', connectBankAccount: 'Connect bank account', addApprovalsDescription: 'Require additional approval before authorizing a payment.', - makeOrTrackPaymentsTitle: 'Payments', - makeOrTrackPaymentsDescription: 'Add an authorized payer for payments made in Expensify, or track payments made elsewhere.', + makeOrTrackPaymentsTitle: 'Make or track payments', + makeOrTrackPaymentsDescription: 'Add an authorized payer for payments made in Expensify, or simply track payments made elsewhere.', editor: { submissionFrequency: 'Choose how long Expensify should wait before sharing error-free spend.', }, + frequencyDescription: 'Choose how often you’d like expenses to submit automatically, or make it manual', frequencies: { weekly: 'Weekly', monthly: 'Monthly', @@ -1293,6 +1296,8 @@ export default { title: 'Authorized payer', genericErrorMessage: 'The authorized payer could not be changed. Please try again.', admins: 'Admins', + payer: 'Payer', + paymentAccount: 'Payment account', }, reportFraudPage: { title: 'Report virtual card fraud', @@ -3434,6 +3439,11 @@ export default { description: `Add GL & Payroll codes to your categories for easy export of expenses to your accounting and payroll systems.`, onlyAvailableOnPlan: 'GL & Payroll codes are only available on the Control plan, starting at ', }, + taxCodes: { + title: 'Tax codes', + description: `Add tax codes to your taxes for easy export of expenses to your accounting and payroll systems.`, + onlyAvailableOnPlan: 'Tax codes are only available on the Control plan, starting at ', + }, pricing: { amount: '$9 ', perActiveMember: 'per active member per month.', @@ -3577,7 +3587,6 @@ export default { screenShareRequest: 'Expensify is inviting you to a screen share', }, search: { - selectMultiple: 'Select multiple', resultsAreLimited: 'Search results are limited.', viewResults: 'View results', searchResults: { @@ -3716,7 +3725,6 @@ export default { nonReimbursableLink: 'View company card expenses.', pending: ({label}: ExportedToIntegrationParams) => `started exporting this report to ${label}...`, }, - forwarded: ({amount, currency}: ForwardedParams) => `approved ${currency}${amount}`, integrationsMessage: (errorMessage: string, label: string) => `failed to export this report to ${label} ("${errorMessage}").`, managerAttachReceipt: `added a receipt`, managerDetachReceipt: `removed a receipt`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 1636512a6fa4..7a92d4d1217d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -31,7 +31,7 @@ import type { EnterMagicCodeParams, ExportedToIntegrationParams, FormattedMaxLengthParams, - ForwardedParams, + ForwardedAmountParams, GoBackMessageParams, GoToRoomParams, InstantSummaryParams, @@ -142,6 +142,7 @@ export default { find: 'Encontrar', searchWithThreeDots: 'Buscar...', select: 'Seleccionar', + selectMultiple: 'Seleccionar varios', next: 'Siguiente', create: 'Crear', previous: 'Anterior', @@ -741,6 +742,7 @@ export default { managerApprovedAmount: ({manager, amount}: ManagerApprovedAmountParams) => `${manager} aprobó ${amount}`, payerSettled: ({amount}: PayerSettledParams) => `pagó ${amount}`, approvedAmount: ({amount}: ApprovedAmountParams) => `aprobó ${amount}`, + forwardedAmount: ({amount}: ForwardedAmountParams) => `aprobó ${amount}`, waitingOnBankAccount: ({submitterDisplayName}: WaitingOnBankAccountParams) => `inició el pago, pero no se procesará hasta que ${submitterDisplayName} añada una cuenta bancaria`, adminCanceledRequest: ({manager, amount}: AdminCanceledRequestParams) => `${manager ? `${manager}: ` : ''}canceló el pago de ${amount}.`, canceledRequest: ({amount, submitterDisplayName}: CanceledRequestParams) => @@ -1251,7 +1253,7 @@ export default { workflowTitle: 'Gasto', workflowDescription: 'Configure un flujo de trabajo desde el momento en que se produce el gasto, incluida la aprobación y el pago', delaySubmissionTitle: 'Retrasar envíos', - delaySubmissionDescription: 'Los gastos se comparten de inmediato para una mejor visibilidad del gasto. Establece una cadencia más lenta si es necesario.', + delaySubmissionDescription: 'Retrasa la presentación de gastos en base a un calendario personalizado, o mantén esta opción desactivada para seguir viendo los gastos en tiempo real.', submissionFrequency: 'Frecuencia de envíos', submissionFrequencyDateOfMonth: 'Fecha del mes', addApprovalsTitle: 'Requerir aprobaciones', @@ -1259,11 +1261,12 @@ export default { approver: 'Aprobador', connectBankAccount: 'Conectar cuenta bancaria', addApprovalsDescription: 'Requiere una aprobación adicional antes de autorizar un pago.', - makeOrTrackPaymentsTitle: 'Pagos', - makeOrTrackPaymentsDescription: 'Añade un pagador autorizado para los pagos realizados en Expensify, o realiza un seguimiento de los pagos realizados en otro lugar.', + makeOrTrackPaymentsTitle: 'Realizar o seguir pagos', + makeOrTrackPaymentsDescription: 'Añade un pagador autorizado para los pagos realizados en Expensify, o simplemente realiza un seguimiento de los pagos realizados en otro lugar.', editor: { submissionFrequency: 'Elige cuánto tiempo Expensify debe esperar antes de compartir los gastos sin errores.', }, + frequencyDescription: 'Elige la frecuencia de presentación automática de gastos, o preséntalos manualmente', frequencies: { weekly: 'Semanal', monthly: 'Mensual', @@ -1302,6 +1305,8 @@ export default { title: 'Pagador autorizado', genericErrorMessage: 'El pagador autorizado no se pudo cambiar. Por favor, inténtalo mas tarde.', admins: 'Administradores', + payer: 'Pagador', + paymentAccount: 'Cuenta de pago', }, reportFraudPage: { title: 'Reportar fraude con la tarjeta virtual', @@ -3490,6 +3495,11 @@ export default { description: `Añada códigos de libro mayor y nómina a sus categorías para exportar fácilmente los gastos a sus sistemas de contabilidad y nómina.`, onlyAvailableOnPlan: 'Los códigos de libro mayor y nómina solo están disponibles en el plan Control, a partir de ', }, + taxCodes: { + title: 'Código de impuesto', + description: `Añada código de impuesto mayor a sus categorías para exportar fácilmente los gastos a sus sistemas de contabilidad y nómina.`, + onlyAvailableOnPlan: 'Los código de impuesto mayor solo están disponibles en el plan Control, a partir de ', + }, note: { upgradeWorkspace: 'Mejore su espacio de trabajo para acceder a esta función, o', learnMore: 'más información', @@ -3634,7 +3644,6 @@ export default { screenShareRequest: 'Expensify te está invitando a compartir la pantalla', }, search: { - selectMultiple: 'Seleccionar varios', resultsAreLimited: 'Los resultados de búsqueda están limitados.', viewResults: 'Ver resultados', searchResults: { @@ -3774,7 +3783,6 @@ export default { nonReimbursableLink: 'Ver los gastos de la tarjeta de empresa.', pending: ({label}: ExportedToIntegrationParams) => `comenzó a exportar este informe a ${label}...`, }, - forwarded: ({amount, currency}: ForwardedParams) => `aprobado ${currency}${amount}`, integrationsMessage: (errorMessage: string, label: string) => `no se pudo exportar este informe a ${label} ("${errorMessage}").`, managerAttachReceipt: `agregó un recibo`, managerDetachReceipt: `quitó un recibo`, diff --git a/src/languages/types.ts b/src/languages/types.ts index c246864b3c03..b2e80ae3e973 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -123,6 +123,8 @@ type PayerPaidAmountParams = {payer?: string; amount: number | string}; type ApprovedAmountParams = {amount: number | string}; +type ForwardedAmountParams = {amount: number | string}; + type ManagerApprovedParams = {manager: string}; type ManagerApprovedAmountParams = {manager: string; amount: number | string}; @@ -313,8 +315,6 @@ type DelegateSubmitParams = {delegateUser: string; originalManager: string}; type ExportedToIntegrationParams = {label: string; markedManually?: boolean; inProgress?: boolean; lastModified?: string}; -type ForwardedParams = {amount: string; currency: string}; - type IntegrationsMessageParams = { label: string; result: { @@ -377,6 +377,7 @@ export type { EnglishTranslation, EnterMagicCodeParams, FormattedMaxLengthParams, + ForwardedAmountParams, GoBackMessageParams, GoToRoomParams, HeldRequestParams, @@ -465,7 +466,6 @@ export type { ChangeTypeParams, ExportedToIntegrationParams, DelegateSubmitParams, - ForwardedParams, IntegrationsMessageParams, MarkedReimbursedParams, MarkReimbursedFromIntegrationParams, diff --git a/src/libs/API/parameters/ApproveMoneyRequestParams.ts b/src/libs/API/parameters/ApproveMoneyRequestParams.ts index 521226aeeff2..f6eb93270428 100644 --- a/src/libs/API/parameters/ApproveMoneyRequestParams.ts +++ b/src/libs/API/parameters/ApproveMoneyRequestParams.ts @@ -12,6 +12,10 @@ type ApproveMoneyRequestParams = { * }> */ optimisticHoldReportExpenseActionIDs?: string; + /** + * Call the optimized version of ApproveMoneyRequest + */ + v2?: boolean; }; export default ApproveMoneyRequestParams; diff --git a/src/libs/ComponentUtils/index.native.ts b/src/libs/ComponentUtils/index.native.ts index 5ad39162e1a0..7a3492c20ded 100644 --- a/src/libs/ComponentUtils/index.native.ts +++ b/src/libs/ComponentUtils/index.native.ts @@ -1,7 +1,20 @@ +import type {Component} from 'react'; +import type {AnimatedRef} from 'react-native-reanimated'; +import {dispatchCommand} from 'react-native-reanimated'; import type {AccessibilityRoleForm, NewPasswordAutocompleteType, PasswordAutocompleteType} from './types'; const PASSWORD_AUTOCOMPLETE_TYPE: PasswordAutocompleteType = 'password'; const NEW_PASSWORD_AUTOCOMPLETE_TYPE: NewPasswordAutocompleteType = 'password-new'; const ACCESSIBILITY_ROLE_FORM: AccessibilityRoleForm = 'none'; -export {PASSWORD_AUTOCOMPLETE_TYPE, ACCESSIBILITY_ROLE_FORM, NEW_PASSWORD_AUTOCOMPLETE_TYPE}; +/** + * Clears a text input on the UI thread using a custom clear command + * that bypasses the event count check. + */ +function forceClearInput(animatedInputRef: AnimatedRef) { + 'worklet'; + + dispatchCommand(animatedInputRef, 'clear'); +} + +export {PASSWORD_AUTOCOMPLETE_TYPE, ACCESSIBILITY_ROLE_FORM, NEW_PASSWORD_AUTOCOMPLETE_TYPE, forceClearInput}; diff --git a/src/libs/ComponentUtils/index.ts b/src/libs/ComponentUtils/index.ts index 38abb98594da..f7c48f87af5a 100644 --- a/src/libs/ComponentUtils/index.ts +++ b/src/libs/ComponentUtils/index.ts @@ -1,3 +1,6 @@ +import type {Component} from 'react'; +import type {TextInput} from 'react-native'; +import type {AnimatedRef} from 'react-native-reanimated'; import type {AccessibilityRoleForm, NewPasswordAutocompleteType, PasswordAutocompleteType} from './types'; /** @@ -7,4 +10,10 @@ const PASSWORD_AUTOCOMPLETE_TYPE: PasswordAutocompleteType = 'current-password'; const NEW_PASSWORD_AUTOCOMPLETE_TYPE: NewPasswordAutocompleteType = 'new-password'; const ACCESSIBILITY_ROLE_FORM: AccessibilityRoleForm = 'form'; -export {PASSWORD_AUTOCOMPLETE_TYPE, ACCESSIBILITY_ROLE_FORM, NEW_PASSWORD_AUTOCOMPLETE_TYPE}; +function forceClearInput(_: AnimatedRef, textInputRef: React.RefObject) { + 'worklet'; + + textInputRef.current?.clear(); +} + +export {PASSWORD_AUTOCOMPLETE_TYPE, ACCESSIBILITY_ROLE_FORM, NEW_PASSWORD_AUTOCOMPLETE_TYPE, forceClearInput}; diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index 8d455ceb7485..22b1ee0bc010 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -148,8 +148,7 @@ function trimEmojiUnicode(emojiCode: string): string { */ function isFirstLetterEmoji(message: string): boolean { const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', ''); - const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); - const match = trimmedMessage.match(emojisRegex); + const match = trimmedMessage.match(CONST.REGEX.EMOJIS); if (!match) { return false; @@ -163,8 +162,7 @@ function isFirstLetterEmoji(message: string): boolean { */ function containsOnlyEmojis(message: string): boolean { const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', ''); - const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); - const match = trimmedMessage.match(emojisRegex); + const match = trimmedMessage.match(CONST.REGEX.EMOJIS); if (!match) { return false; @@ -287,8 +285,7 @@ function extractEmojis(text: string): Emoji[] { } // Parse Emojis including skin tones - Eg: ['👩🏻', '👩🏻', '👩🏼', '👩🏻', '👩🏼', '👩'] - const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); - const parsedEmojis = text.match(emojisRegex); + const parsedEmojis = text.match(CONST.REGEX.EMOJIS); if (!parsedEmojis) { return []; @@ -589,57 +586,7 @@ function getSpacersIndexes(allEmojis: EmojiPickerList): number[] { return spacersIndexes; } -type TextWithEmoji = { - text: string; - isEmoji: boolean; -}; - -function splitTextWithEmojis(text = ''): TextWithEmoji[] { - if (!text) { - return []; - } - - // The regex needs to be cloned because `exec()` is a stateful operation and maintains the state inside - // the regex variable itself, so we must have a independent instance for each function's call. - const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); - - const splitText: TextWithEmoji[] = []; - let regexResult: RegExpExecArray | null; - let lastMatchIndexEnd = 0; - do { - regexResult = emojisRegex.exec(text); - - if (regexResult?.indices) { - const matchIndexStart = regexResult.indices[0][0]; - const matchIndexEnd = regexResult.indices[0][1]; - - if (matchIndexStart > lastMatchIndexEnd) { - splitText.push({ - text: text.slice(lastMatchIndexEnd, matchIndexStart), - isEmoji: false, - }); - } - - splitText.push({ - text: text.slice(matchIndexStart, matchIndexEnd), - isEmoji: true, - }); - - lastMatchIndexEnd = matchIndexEnd; - } - } while (regexResult !== null); - - if (lastMatchIndexEnd < text.length) { - splitText.push({ - text: text.slice(lastMatchIndexEnd, text.length), - isEmoji: false, - }); - } - - return splitText; -} - -export type {HeaderIndice, EmojiPickerList, EmojiSpacer, EmojiPickerListItem, TextWithEmoji}; +export type {HeaderIndice, EmojiPickerList, EmojiSpacer, EmojiPickerListItem}; export { findEmojiByName, @@ -664,5 +611,4 @@ export { hasAccountIDEmojiReacted, getRemovedSkinToneEmoji, getSpacersIndexes, - splitTextWithEmojis, }; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index d9cd2f5ded85..e3ba198f0e30 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -91,7 +91,7 @@ function shouldOpenOnAdminRoom() { function getCentralPaneScreenInitialParams(screenName: CentralPaneName, initialReportID?: string): Partial> { if (screenName === SCREENS.SEARCH.CENTRAL_PANE) { // Generate default query string with buildSearchQueryString without argument. - return {q: buildSearchQueryString()}; + return {q: buildSearchQueryString(), isCustomQuery: false}; } if (screenName === SCREENS.REPORT) { diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx index ebfa3fb1d7c9..0f4c41b9cbfc 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx @@ -11,8 +11,10 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; +import Performance from '@libs/Performance'; import SignInButton from '@pages/home/sidebar/SignInButton'; import * as Session from '@userActions/Session'; +import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -62,7 +64,11 @@ function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true} Navigation.navigate(ROUTES.CHAT_FINDER))} + onPress={Session.checkIfActionIsAllowed(() => { + Timing.start(CONST.TIMING.CHAT_FINDER_RENDER); + Performance.markStart(CONST.TIMING.CHAT_FINDER_RENDER); + Navigation.navigate(ROUTES.CHAT_FINDER); + })} > ['config'] = { exact: true, }, [SCREENS.SETTINGS.WORKSPACES]: ROUTES.SETTINGS_WORKSPACES, - [SCREENS.SEARCH.CENTRAL_PANE]: ROUTES.SEARCH_CENTRAL_PANE.route, + [SCREENS.SEARCH.CENTRAL_PANE]: { + path: ROUTES.SEARCH_CENTRAL_PANE.route, + parse: { + isCustomQuery: (isCustomQuery) => isCustomQuery.toLowerCase() === 'true', + }, + }, [SCREENS.SETTINGS.SAVE_THE_WORLD]: ROUTES.SETTINGS_SAVE_THE_WORLD, [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: ROUTES.SETTINGS_SUBSCRIPTION, diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 2fe9ea55a0ee..a2936e398ddd 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -749,6 +749,8 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails lastMessageTextFromReport = ReportUtils.getIOUSubmittedMessage(reportID); } else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED) { lastMessageTextFromReport = ReportUtils.getIOUApprovedMessage(reportID); + } else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.FORWARDED) { + lastMessageTextFromReport = ReportUtils.getIOUForwardedMessage(reportID); } else if (ReportActionUtils.isActionableAddPaymentCard(lastReportAction)) { lastMessageTextFromReport = ReportActionUtils.getReportActionMessageText(lastReportAction); } else if (lastReportAction?.actionName === 'EXPORTINTEGRATION') { diff --git a/src/libs/PaymentUtils.ts b/src/libs/PaymentUtils.ts index 60dac04a09ac..c18ebd217406 100644 --- a/src/libs/PaymentUtils.ts +++ b/src/libs/PaymentUtils.ts @@ -1,13 +1,15 @@ +import type {ValueOf} from 'type-fest'; import getBankIcon from '@components/Icon/BankIcons'; import type {ThemeStyles} from '@styles/index'; import CONST from '@src/CONST'; import type BankAccount from '@src/types/onyx/BankAccount'; import type Fund from '@src/types/onyx/Fund'; import type PaymentMethod from '@src/types/onyx/PaymentMethod'; +import type {ACHAccount} from '@src/types/onyx/Policy'; import * as Localize from './Localize'; import BankAccountModel from './models/BankAccount'; -type AccountType = BankAccount['accountType'] | Fund['accountType']; +type AccountType = ValueOf | undefined; /** * Check to see if user has either a debit card or personal bank account added that can be used with a wallet. @@ -25,11 +27,14 @@ function hasExpensifyPaymentMethod(fundList: Record, bankAccountLi return validBankAccount || (shouldIncludeDebitCard && validDebitCard); } -function getPaymentMethodDescription(accountType: AccountType, account: BankAccount['accountData'] | Fund['accountData']): string { +function getPaymentMethodDescription(accountType: AccountType, account: BankAccount['accountData'] | Fund['accountData'] | ACHAccount): string { if (account) { if (accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT && 'accountNumber' in account) { return `${Localize.translateLocal('paymentMethodList.accountLastFour')} ${account.accountNumber?.slice(-4)}`; } + if (accountType === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT && 'accountNumber' in account) { + return `${Localize.translateLocal('paymentMethodList.accountLastFour')} ${account.accountNumber?.slice(-4)}`; + } if (accountType === CONST.PAYMENT_METHODS.DEBIT_CARD && 'cardNumber' in account) { return `${Localize.translateLocal('paymentMethodList.cardLastFour')} ${account.cardNumber?.slice(-4)}`; } diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 2f27f0185759..c74ef8075558 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -376,6 +376,10 @@ function isSubmitAndClose(policy: OnyxInputOrEntry): boolean { return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL; } +function isControlOnAdvancedApprovalMode(policy: OnyxInputOrEntry): boolean { + return policy?.type === CONST.POLICY.TYPE.CORPORATE && getApprovalWorkflow(policy) === CONST.POLICY.APPROVAL_MODE.ADVANCED; +} + function extractPolicyIDFromPath(path: string) { return path.match(CONST.REGEX.POLICY_ID_FROM_PATH)?.[1]; } @@ -419,6 +423,9 @@ function isPolicyFeatureEnabled(policy: OnyxEntry, featureName: PolicyFe if (featureName === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED) { return !!policy?.tax?.trackingEnabled; } + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED) { + return policy?.[featureName] ? !!policy?.[featureName] : !isEmptyObject(policy?.connections); + } return !!policy?.[featureName]; } @@ -902,6 +909,7 @@ export { hasPolicyError, hasPolicyErrorFields, hasTaxRateError, + isControlOnAdvancedApprovalMode, isExpensifyTeam, isDeletedPolicyEmployee, isFreeGroupPolicy, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 8e9926648c2c..8155fb7793ba 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1169,7 +1169,6 @@ function isOldDotReportAction(action: ReportAction | OldDotReportAction) { CONST.REPORT.ACTIONS.TYPE.CHANGE_TYPE, CONST.REPORT.ACTIONS.TYPE.DELEGATE_SUBMIT, CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV, - CONST.REPORT.ACTIONS.TYPE.FORWARDED, CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE, CONST.REPORT.ACTIONS.TYPE.MANAGER_ATTACH_RECEIPT, CONST.REPORT.ACTIONS.TYPE.MANAGER_DETACH_RECEIPT, @@ -1290,6 +1289,15 @@ function getMemberChangeMessageFragment(reportAction: OnyxEntry): }; } +function getUpdateRoomDescriptionFragment(reportAction: ReportAction): Message { + const html = getUpdateRoomDescriptionMessage(reportAction); + return { + html: `${html}`, + text: getReportActionMessage(reportAction) ? getReportActionText(reportAction) : '', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }; +} + function getMemberChangeMessagePlainText(reportAction: OnyxEntry): string { const messageElements = getMemberChangeMessageElements(reportAction); return messageElements.map((element) => element.content).join(''); @@ -1608,6 +1616,7 @@ export { getLatestReportActionFromOnyxData, getLinkedTransactionID, getMemberChangeMessageFragment, + getUpdateRoomDescriptionFragment, getMemberChangeMessagePlainText, getReportActionMessageFragments, getMessageOfOldDotReportAction, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 305bde1dc387..8c8629406155 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3770,6 +3770,33 @@ function getParsedComment(text: string, parsingDetails?: ParsingDetails): string : lodashEscape(text); } +function getUploadingAttachmentHtml(file?: FileObject): string { + if (!file || typeof file.uri !== 'string') { + return ''; + } + + const dataAttributes = [ + `${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="${file.uri}"`, + `${CONST.ATTACHMENT_SOURCE_ATTRIBUTE}="${file.uri}"`, + `${CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE}="${file.name}"`, + 'width' in file && `${CONST.ATTACHMENT_THUMBNAIL_WIDTH_ATTRIBUTE}="${file.width}"`, + 'height' in file && `${CONST.ATTACHMENT_THUMBNAIL_HEIGHT_ATTRIBUTE}="${file.height}"`, + ] + .filter((x) => !!x) + .join(' '); + + // file.type is a known mime type like image/png, image/jpeg, video/mp4 etc. + if (file.type?.startsWith('image')) { + return `${file.name}`; + } + if (file.type?.startsWith('video')) { + return ``; + } + + // For all other types, we present a generic download link + return `${file.name}`; +} + function getReportDescriptionText(report: Report): string { if (!report.description) { return ''; @@ -3795,24 +3822,14 @@ function buildOptimisticAddCommentReportAction( reportID?: string, ): OptimisticReportAction { const commentText = getParsedComment(text ?? '', {shouldEscapeText, reportID}); - const isAttachmentOnly = file && !text; - const isTextOnly = text && !file; - - let htmlForNewComment; - let textForNewComment; - if (isAttachmentOnly) { - htmlForNewComment = CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML; - textForNewComment = CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML; - } else if (isTextOnly) { - htmlForNewComment = commentText; - textForNewComment = Parser.htmlToText(htmlForNewComment); - } else { - htmlForNewComment = `${commentText}${CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML}`; - textForNewComment = `${Parser.htmlToText(commentText)}\n${CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML}`; - } + const attachmentHtml = getUploadingAttachmentHtml(file); + + const htmlForNewComment = `${commentText}${commentText && attachmentHtml ? '

' : ''}${attachmentHtml}`; + const textForNewComment = Parser.htmlToText(htmlForNewComment); + const isAttachmentOnly = file && !text; const isAttachmentWithText = !!text && file !== undefined; - const accountID = actorAccountID ?? currentUserAccountID; + const accountID = actorAccountID ?? currentUserAccountID ?? -1; // Remove HTML from text when applying optimistic offline comment return { @@ -3824,12 +3841,12 @@ function buildOptimisticAddCommentReportAction( person: [ { style: 'strong', - text: allPersonalDetails?.[accountID ?? -1]?.displayName ?? currentUserEmail, + text: allPersonalDetails?.[accountID]?.displayName ?? currentUserEmail, type: 'TEXT', }, ], automatic: false, - avatar: allPersonalDetails?.[accountID ?? -1]?.avatar, + avatar: allPersonalDetails?.[accountID]?.avatar, created: DateUtils.getDBTimeWithSkew(Date.now() + createdOffset), message: [ { @@ -4107,6 +4124,10 @@ function getIOUApprovedMessage(reportID: string) { return Localize.translateLocal('iou.approvedAmount', {amount: getFormattedAmount(reportID)}); } +function getIOUForwardedMessage(reportID: string) { + return Localize.translateLocal('iou.forwardedAmount', {amount: getFormattedAmount(reportID)}); +} + /** * @param iouReportID - the report ID of the IOU report the action belongs to * @param type - IOUReportAction type. Can be oneOf(create, decline, cancel, pay, split) @@ -4139,6 +4160,9 @@ function getIOUReportActionMessage(iouReportID: string, type: string, total: num case CONST.REPORT.ACTIONS.TYPE.APPROVED: iouMessage = `approved ${amount}`; break; + case CONST.REPORT.ACTIONS.TYPE.FORWARDED: + iouMessage = getIOUForwardedMessage(iouReportID); + break; case CONST.REPORT.ACTIONS.TYPE.UNAPPROVED: iouMessage = `unapproved ${amount}`; break; @@ -5490,7 +5514,7 @@ function shouldDisplayTransactionThreadViolations( return false; } const {IOUReportID} = ReportActionsUtils.getOriginalMessage(parentReportAction) ?? {}; - if (isSettled(IOUReportID)) { + if (isSettled(IOUReportID) || isReportApproved(IOUReportID?.toString())) { return false; } return doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction); @@ -7418,6 +7442,7 @@ export { getIOUReportActionDisplayMessage, getIOUReportActionMessage, getIOUApprovedMessage, + getIOUForwardedMessage, getIOUSubmittedMessage, getIcons, getIconsForParticipants, diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 4c3229760d71..367f414eb53d 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -8,8 +8,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; import INPUT_IDS from '@src/types/form/SearchAdvancedFiltersForm'; import type * as OnyxTypes from '@src/types/onyx'; -import type {SearchAccountDetails, SearchDataTypes, SearchPersonalDetails, SearchTransaction, SearchTypeToItemMap, SectionsType} from '@src/types/onyx/SearchResults'; import type SearchResults from '@src/types/onyx/SearchResults'; +import type {SearchAccountDetails, SearchDataTypes, SearchPersonalDetails, SearchTransaction, SearchTypeToItemMap, SectionsType} from '@src/types/onyx/SearchResults'; import DateUtils from './DateUtils'; import getTopmostCentralPaneRoute from './Navigation/getTopmostCentralPaneRoute'; import navigationRef from './Navigation/navigationRef'; @@ -33,6 +33,18 @@ const columnNamesToSortingProperty = { [CONST.SEARCH.TABLE_COLUMNS.RECEIPT]: null, }; +// This map contains signs with spaces that match each operator +const operatorToSignMap = { + [CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]: ':' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN]: '<' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO]: '<=' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN]: '>' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO]: '>=' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO]: '!=' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.AND]: ',' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.OR]: ' ' as const, +}; + /** * @private */ @@ -429,11 +441,11 @@ function getFilters(query: SearchQueryString, fields: Array { + // If the previous queryFilter has the same operator (this rule applies only to eq and neq operators) then append the current value + if ((queryFilter.operator === 'eq' && queryFilters[index - 1]?.operator === 'eq') || (queryFilter.operator === 'neq' && queryFilters[index - 1]?.operator === 'neq')) { + filterValueString += `,${queryFilter.value}`; + } else { + filterValueString += ` ${filterName}${operatorToSignMap[queryFilter.operator]}${queryFilter.value}`; + } + }); + + return filterValueString; +} + +function getSearchHeaderTitle(queryJSON: SearchQueryJSON) { + const {inputQuery, type, status} = queryJSON; + const filters = getFilters(inputQuery, Object.values(CONST.SEARCH.SYNTAX_FILTER_KEYS)) ?? {}; + + let title = `type:${type} status:${status}`; + + Object.keys(filters).forEach((key) => { + const queryFilter = filters[key as ValueOf] as QueryFilter[]; + title += buildFilterValueString(key, queryFilter); + }); + + return title; +} + export { + buildQueryStringFromFilters, buildSearchQueryJSON, buildSearchQueryString, getCurrentSearchParams, + getFilters, getListItem, getQueryHash, + getSearchHeaderTitle, + getSearchType, getSections, - getSortedSections, getShouldShowMerchant, - getSearchType, - shouldShowYear, + getSortedSections, isReportListItemType, - isTransactionListItemType, isSearchResultsEmpty, - getFilters, + isTransactionListItemType, normalizeQuery, - buildQueryStringFromFilters, + shouldShowYear, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 9dc0d2abcbda..eb13915c6f47 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -135,7 +135,7 @@ function getOrderedReportIDs( return; } const isSystemChat = ReportUtils.isSystemChat(report); - const shouldOverrideHidden = hasErrorsOtherThanFailedReceipt || isFocused || isSystemChat || report.isPinned; + const shouldOverrideHidden = hasValidDraftComment(report.reportID) || hasErrorsOtherThanFailedReceipt || isFocused || isSystemChat || report.isPinned; if (isHidden && !shouldOverrideHidden) { return; } diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index 0cc4e05f7b29..cd5904e4a82d 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -1,7 +1,6 @@ -import {differenceInSeconds, fromUnixTime, isAfter, isBefore, parse as parseDate} from 'date-fns'; +import {differenceInSeconds, fromUnixTime, isAfter, isBefore} from 'date-fns'; import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {BillingGraceEndPeriod, BillingStatus, Fund, FundList, Policy, StripeCustomerID} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -396,7 +395,7 @@ function hasUserFreeTrialEnded(): boolean { } const currentDate = new Date(); - const lastDayFreeTrialDate = parseDate(lastDayFreeTrial, CONST.DATE.FNS_DATE_TIME_FORMAT_STRING, currentDate); + const lastDayFreeTrialDate = new Date(`${lastDayFreeTrial}Z`); return isAfter(currentDate, lastDayFreeTrialDate); } diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 6fbbd61dd212..b0c99f4a6026 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -41,9 +41,7 @@ function isValidAddress(value: FormValue): boolean { return false; } - const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); - - if (!CONST.REGEX.ANY_VALUE.test(value) || value.match(emojisRegex)) { + if (!CONST.REGEX.ANY_VALUE.test(value) || value.match(CONST.REGEX.EMOJIS)) { return false; } @@ -333,8 +331,7 @@ function isValidRoutingNumber(routingNumber: string): boolean { * Checks that the provided name doesn't contain any emojis */ function isValidCompanyName(name: string) { - const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); - return !name.match(emojisRegex); + return !name.match(CONST.REGEX.EMOJIS); } function isValidReportName(name: string) { diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 5b2b3d617e58..6e718b1bde34 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -335,6 +335,7 @@ function validateBankAccount(bankAccountID: number, validateCode: string, policy key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, value: { isLoading: false, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('bankAccount.error.validationAmounts'), }, }, ], diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 961976960536..acaf37fc8bf4 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -6853,6 +6853,7 @@ function approveMoneyRequest(expenseReport: OnyxEntry, full?: optimisticHoldReportID, optimisticHoldActionID, optimisticHoldReportExpenseActionIDs, + v2: PolicyUtils.isControlOnAdvancedApprovalMode(PolicyUtils.getPolicy(expenseReport?.policyID)), }; API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); @@ -7223,7 +7224,15 @@ function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes. function detachReceipt(transactionID: string) { const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - const newTransaction = transaction ? {...transaction, filename: '', receipt: {}} : null; + const newTransaction = transaction + ? { + ...transaction, + filename: '', + receipt: { + source: '', + }, + } + : null; const optimisticData: OnyxUpdate[] = [ { diff --git a/src/libs/actions/MobileSelectionMode.ts b/src/libs/actions/MobileSelectionMode.ts new file mode 100644 index 000000000000..65a51b834901 --- /dev/null +++ b/src/libs/actions/MobileSelectionMode.ts @@ -0,0 +1,12 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; + +const turnOnMobileSelectionMode = () => { + Onyx.merge(ONYXKEYS.MOBILE_SELECTION_MODE, {isEnabled: true}); +}; + +const turnOffMobileSelectionMode = () => { + Onyx.merge(ONYXKEYS.MOBILE_SELECTION_MODE, {isEnabled: false}); +}; + +export {turnOnMobileSelectionMode, turnOffMobileSelectionMode}; diff --git a/src/libs/isReportMessageAttachment.ts b/src/libs/isReportMessageAttachment.ts index dcb5aa97ee67..229e9c1653f9 100644 --- a/src/libs/isReportMessageAttachment.ts +++ b/src/libs/isReportMessageAttachment.ts @@ -8,7 +8,7 @@ const attachmentRegex = new RegExp(` ${CONST.ATTACHMENT_SOURCE_ATTRIBUTE}="(.*)" * Check whether a report action is Attachment or not. * Ignore messages containing [Attachment] as the main content. Attachments are actions with only text as [Attachment]. * - * @param reportActionMessage report action's message as text, html and translationKey + * @param message report action's message as text, html and translationKey */ export default function isReportMessageAttachment(message: Message | undefined): boolean { if (!message?.text || !message.html) { @@ -19,7 +19,7 @@ export default function isReportMessageAttachment(message: Message | undefined): return message.text === CONST.ATTACHMENT_MESSAGE_TEXT && message.translationKey === CONST.TRANSLATION_KEYS.ATTACHMENT; } - const hasAttachmentHtml = message.html === CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML || attachmentRegex.test(message.html); + const hasAttachmentHtml = attachmentRegex.test(message.html); if (!hasAttachmentHtml) { return false; diff --git a/src/pages/ChatFinderPage/index.tsx b/src/pages/ChatFinderPage/index.tsx index 1ae1a54dcafd..8b6ec126f63b 100644 --- a/src/pages/ChatFinderPage/index.tsx +++ b/src/pages/ChatFinderPage/index.tsx @@ -71,11 +71,6 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa ); useCancelSearchOnModalClose(); - useEffect(() => { - Timing.start(CONST.TIMING.CHAT_FINDER_RENDER); - Performance.markStart(CONST.TIMING.CHAT_FINDER_RENDER); - }, []); - useEffect(() => { Report.searchInServer(debouncedSearchValueInServer.trim()); }, [debouncedSearchValueInServer]); diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index d5f7faa8e0f5..669d9fac7ffa 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -540,7 +540,13 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD } if (isExpenseReport && shouldShowHoldAction) { - result.push(PromotedActions.hold({isTextHold: canHoldUnholdReportAction.canHoldRequest, reportAction: moneyRequestAction})); + result.push( + PromotedActions.hold({ + isTextHold: canHoldUnholdReportAction.canHoldRequest, + reportAction: moneyRequestAction, + reportID: transactionThreadReportID ? report.reportID : moneyRequestAction?.childReportID ?? '-1', + }), + ); } if (report) { @@ -550,7 +556,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD result.push(PromotedActions.share(report)); return result; - }, [report, moneyRequestAction, canJoin, isExpenseReport, shouldShowHoldAction, canHoldUnholdReportAction.canHoldRequest]); + }, [report, moneyRequestAction, canJoin, isExpenseReport, shouldShowHoldAction, canHoldUnholdReportAction.canHoldRequest, transactionThreadReportID]); const nameSectionExpenseIOU = ( diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx index 452664c9d4f7..6e5f8f859be3 100755 --- a/src/pages/ReportParticipantsPage.tsx +++ b/src/pages/ReportParticipantsPage.tsx @@ -2,8 +2,7 @@ import {useIsFocused} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; import type {TextInput} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {useOnyx, withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import Badge from '@components/Badge'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; @@ -14,14 +13,15 @@ import ConfirmModal from '@components/ConfirmModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem, SelectionListHandle} from '@components/SelectionList/types'; +import SelectionListWithModal from '@components/SelectionListWithModal'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import * as Report from '@libs/actions/Report'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; @@ -30,37 +30,30 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetailsList, Session} from '@src/types/onyx'; import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; import withReportOrNotFound from './home/report/withReportOrNotFound'; -type ReportParticipantsPageOnyxProps = { - /** Personal details of all the users */ - personalDetails: OnyxEntry; - - /** Session info for the currently logged in user. */ - session: OnyxEntry; -}; - -type ReportParticipantsPageProps = ReportParticipantsPageOnyxProps & WithReportOrNotFoundProps; - type MemberOption = Omit & {accountID: number}; -function ReportParticipantsPage({report, personalDetails, session}: ReportParticipantsPageProps) { +function ReportParticipantsPage({report}: WithReportOrNotFoundProps) { const [selectedMembers, setSelectedMembers] = useState([]); const [removeMembersConfirmModalVisible, setRemoveMembersConfirmModalVisible] = useState(false); const {translate, formatPhoneNumber} = useLocalize(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const selectionListRef = useRef(null); const textInputRef = useRef(null); - const currentUserAccountID = Number(session?.accountID); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); + const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const currentUserAccountID = Number(session?.accountID); const isCurrentUserAdmin = ReportUtils.isGroupChatAdmin(report, currentUserAccountID); const isGroupChat = useMemo(() => ReportUtils.isGroupChat(report), [report]); const isIOUReport = ReportUtils.isIOUReport(report); const isFocused = useIsFocused(); + const canSelectMultiple = isGroupChat && isCurrentUserAdmin && (isSmallScreenWidth ? selectionMode?.isEnabled : true); useEffect(() => { if (isFocused) { @@ -180,15 +173,19 @@ function ReportParticipantsPage({report, personalDetails, session}: ReportPartic * Toggle user from the selectedMembers list */ const toggleUser = useCallback( - (accountID: number) => { + (user: MemberOption) => { + if (user.accountID === currentUserAccountID) { + return; + } + // Add or remove the user if the checkbox is enabled - if (selectedMembers.includes(accountID)) { - removeUser(accountID); + if (selectedMembers.includes(user.accountID)) { + removeUser(user.accountID); } else { - addUser(accountID); + addUser(user.accountID); } }, - [selectedMembers, addUser, removeUser], + [selectedMembers, addUser, removeUser, currentUserAccountID], ); const headerContent = useMemo(() => { @@ -215,12 +212,12 @@ function ReportParticipantsPage({report, personalDetails, session}: ReportPartic ); - if (isCurrentUserAdmin) { + if (canSelectMultiple) { return header; } return {header}; - }, [styles, translate, isGroupChat, isCurrentUserAdmin, StyleUtils]); + }, [styles, translate, isGroupChat, isCurrentUserAdmin, StyleUtils, canSelectMultiple]); const bulkActionsButtonOptions = useMemo(() => { const options: Array> = [ @@ -264,7 +261,7 @@ function ReportParticipantsPage({report, personalDetails, session}: ReportPartic return ( - {selectedMembers.length > 0 ? ( + {(isSmallScreenWidth ? canSelectMultiple : selectedMembers.length > 0) ? ( shouldAlwaysShowDropdownMenu pressOnEnter @@ -287,7 +284,7 @@ function ReportParticipantsPage({report, personalDetails, session}: ReportPartic )} ); - }, [bulkActionsButtonOptions, inviteUser, shouldUseNarrowLayout, selectedMembers, styles, translate, isGroupChat]); + }, [bulkActionsButtonOptions, inviteUser, isSmallScreenWidth, selectedMembers, styles, translate, isGroupChat, canSelectMultiple, shouldUseNarrowLayout]); /** Opens the member details page */ const openMemberDetails = useCallback( @@ -313,6 +310,9 @@ function ReportParticipantsPage({report, personalDetails, session}: ReportPartic } return translate('common.details'); }, [report, translate, isGroupChat]); + + const selectionModeHeader = selectionMode?.isEnabled && isSmallScreenWidth; + return ( Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)) : undefined} - shouldShowBackButton + title={selectionModeHeader ? translate('common.selectMultiple') : headerTitle} + onBackButtonPress={() => { + if (selectionMode?.isEnabled) { + setSelectedMembers([]); + turnOffMobileSelectionMode(); + return; + } + + if (report) { + Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)); + } + }} guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS} /> {headerButtons} @@ -346,15 +355,17 @@ function ReportParticipantsPage({report, personalDetails, session}: ReportPartic }} /> - item && toggleUser(item)} sections={[{data: participants}]} ListItem={TableListItem} headerContent={headerContent} onSelectRow={openMemberDetails} shouldSingleExecuteRowSelect={!(isGroupChat && isCurrentUserAdmin)} - onCheckboxPress={(item) => toggleUser(item.accountID)} + onCheckboxPress={(item) => toggleUser(item)} onSelectAll={() => toggleAllUsers(participants)} showScrollIndicator textInputRef={textInputRef} @@ -369,13 +380,4 @@ function ReportParticipantsPage({report, personalDetails, session}: ReportPartic ReportParticipantsPage.displayName = 'ReportParticipantsPage'; -export default withReportOrNotFound()( - withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - session: { - key: ONYXKEYS.SESSION, - }, - })(ReportParticipantsPage), -); +export default withReportOrNotFound()(ReportParticipantsPage); diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index c95290dbc006..e2495c02d44f 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -17,10 +17,9 @@ type SearchPageProps = StackScreenProps buildSearchQueryJSON(route.params.q, policyIDs), [route.params.q, policyIDs]); + const queryJSON = useMemo(() => buildSearchQueryJSON(q, policyIDs), [q, policyIDs]); const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: CONST.SEARCH.TAB.EXPENSE.ALL})); @@ -44,6 +43,7 @@ function SearchPage({route}: SearchPageProps) { > {queryJSON && ( diff --git a/src/pages/Search/SearchPageBottomTab.tsx b/src/pages/Search/SearchPageBottomTab.tsx index 35679b158b7f..c8254af295d7 100644 --- a/src/pages/Search/SearchPageBottomTab.tsx +++ b/src/pages/Search/SearchPageBottomTab.tsx @@ -1,4 +1,5 @@ -import React, {useMemo, useState} from 'react'; +import React, {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -7,11 +8,13 @@ import useActiveCentralPaneRoute from '@hooks/useActiveCentralPaneRoute'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import Navigation from '@libs/Navigation/Navigation'; import type {AuthScreensParamList} from '@libs/Navigation/types'; import {buildSearchQueryJSON} from '@libs/SearchUtils'; import TopBar from '@navigation/AppNavigator/createCustomBottomTabNavigator/TopBar'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import SearchStatusMenu from './SearchStatusMenu'; @@ -21,9 +24,9 @@ function SearchPageBottomTab() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const activeCentralPaneRoute = useActiveCentralPaneRoute(); const styles = useThemeStyles(); - const [isMobileSelectionModeActive, setIsMobileSelectionModeActive] = useState(false); + const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); - const {queryJSON, policyIDs} = useMemo(() => { + const {queryJSON, policyIDs, isCustomQuery} = useMemo(() => { if (!activeCentralPaneRoute || activeCentralPaneRoute.name !== SCREENS.SEARCH.CENTRAL_PANE) { return {queryJSON: undefined, policyIDs: undefined}; } @@ -34,6 +37,7 @@ function SearchPageBottomTab() { return { queryJSON: buildSearchQueryJSON(searchParams.q, searchParams.policyIDs), policyIDs: searchParams.policyIDs, + isCustomQuery: searchParams.isCustomQuery, }; }, [activeCentralPaneRoute]); @@ -50,27 +54,29 @@ function SearchPageBottomTab() { onBackButtonPress={handleOnBackButtonPress} shouldShowLink={false} > - {!isMobileSelectionModeActive && queryJSON ? ( + {!selectionMode?.isEnabled && queryJSON ? ( <> - + ) : ( setIsMobileSelectionModeActive(false)} + title={translate('common.selectMultiple')} + onBackButtonPress={turnOffMobileSelectionMode} /> )} {shouldUseNarrowLayout && queryJSON && ( )} diff --git a/src/pages/Search/SearchStatusMenu.tsx b/src/pages/Search/SearchStatusMenu.tsx index e1470bdc4e75..9b8e890bbefd 100644 --- a/src/pages/Search/SearchStatusMenu.tsx +++ b/src/pages/Search/SearchStatusMenu.tsx @@ -1,13 +1,14 @@ import React from 'react'; import {View} from 'react-native'; import MenuItem from '@components/MenuItem'; -import type {SearchStatus} from '@components/Search/types'; +import type {SearchQueryJSON, SearchStatus} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import {normalizeQuery} from '@libs/SearchUtils'; +import * as SearchUtils from '@libs/SearchUtils'; import variables from '@styles/variables'; import * as Expensicons from '@src/components/Icon/Expensicons'; import CONST from '@src/CONST'; @@ -17,17 +18,19 @@ import type IconAsset from '@src/types/utils/IconAsset'; import SearchStatusMenuNarrow from './SearchStatusMenuNarrow'; type SearchStatusMenuProps = { - status: SearchStatus; + queryJSON: SearchQueryJSON; + isCustomQuery: boolean; }; type SearchStatusMenuItem = { title: string; status: SearchStatus; icon: IconAsset; - route: Route; + route?: Route; }; -function SearchStatusMenu({status}: SearchStatusMenuProps) { +function SearchStatusMenu({queryJSON, isCustomQuery}: SearchStatusMenuProps) { + const {status} = queryJSON; const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {singleExecution} = useSingleExecution(); @@ -62,10 +65,13 @@ function SearchStatusMenu({status}: SearchStatusMenuProps) { const activeItemIndex = statusMenuItems.findIndex((item) => item.status === status); if (shouldUseNarrowLayout) { + const title = isCustomQuery ? SearchUtils.getSearchHeaderTitle(queryJSON) : undefined; + return ( ); } diff --git a/src/pages/Search/SearchStatusMenuNarrow.tsx b/src/pages/Search/SearchStatusMenuNarrow.tsx index e80db44ce3dc..1d10dc1dd431 100644 --- a/src/pages/Search/SearchStatusMenuNarrow.tsx +++ b/src/pages/Search/SearchStatusMenuNarrow.tsx @@ -1,4 +1,4 @@ -import React, {useRef, useState} from 'react'; +import React, {useMemo, useRef, useState} from 'react'; import {Animated, View} from 'react-native'; import Icon from '@components/Icon'; import PopoverMenu from '@components/PopoverMenu'; @@ -15,9 +15,10 @@ import type {SearchStatusMenuItem} from './SearchStatusMenu'; type SearchStatusMenuNarrowProps = { statusMenuItems: SearchStatusMenuItem[]; activeItemIndex: number; + title?: string; }; -function SearchStatusMenuNarrow({statusMenuItems, activeItemIndex}: SearchStatusMenuNarrowProps) { +function SearchStatusMenuNarrow({statusMenuItems, activeItemIndex, title}: SearchStatusMenuNarrowProps) { const theme = useTheme(); const styles = useThemeStyles(); const {singleExecution} = useSingleExecution(); @@ -29,34 +30,61 @@ function SearchStatusMenuNarrow({statusMenuItems, activeItemIndex}: SearchStatus const openMenu = () => setIsPopoverVisible(true); const closeMenu = () => setIsPopoverVisible(false); - const popoverMenuItems = statusMenuItems.map((item, index) => ({ - text: item.title, - onSelected: singleExecution(() => Navigation.navigate(item.route)), - icon: item.icon, - iconFill: index === activeItemIndex ? theme.iconSuccessFill : theme.icon, - iconRight: Expensicons.Checkmark, - shouldShowRightIcon: index === activeItemIndex, - success: index === activeItemIndex, - containerStyle: index === activeItemIndex ? [{backgroundColor: theme.border}] : undefined, - })); + const popoverMenuItems = statusMenuItems.map((item, index) => { + const isSelected = title ? false : index === activeItemIndex; + + return { + text: item.title, + onSelected: singleExecution(() => Navigation.navigate(item.route)), + icon: item.icon, + iconFill: isSelected ? theme.iconSuccessFill : theme.icon, + iconRight: Expensicons.Checkmark, + shouldShowRightIcon: isSelected, + success: isSelected, + containerStyle: isSelected ? [{backgroundColor: theme.border}] : undefined, + }; + }); + + if (title) { + popoverMenuItems.push({ + text: title, + onSelected: closeMenu, + icon: Expensicons.Filters, + iconFill: theme.iconSuccessFill, + success: true, + containerStyle: [{backgroundColor: theme.border}], + iconRight: Expensicons.Checkmark, + shouldShowRightIcon: false, + }); + } + + const menuIcon = useMemo(() => (title ? Expensicons.Filters : popoverMenuItems[activeItemIndex]?.icon ?? Expensicons.Receipt), [activeItemIndex, popoverMenuItems, title]); + const menuTitle = useMemo(() => title ?? popoverMenuItems[activeItemIndex]?.text, [activeItemIndex, popoverMenuItems, title]); + const titleViewStyles = title ? {...styles.flex1, ...styles.justifyContentCenter} : {}; return ( - + {({hovered}) => ( - + - {popoverMenuItems[activeItemIndex]?.text} + + {menuTitle} + { - if (!shouldUseNarrowLayout || !isFocused || prevIsFocused || !ReportUtils.isChatThread(report) || report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + if ( + !shouldUseNarrowLayout || + !isFocused || + prevIsFocused || + !ReportUtils.isChatThread(report) || + report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN || + isSingleTransactionView + ) { return; } Report.openReport(report.reportID); @@ -591,7 +598,7 @@ function ReportScreen({ // We don't want to run this useEffect every time `report` is changed // Excluding shouldUseNarrowLayout from the dependency list to prevent re-triggering on screen resize events. // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [prevIsFocused, report.notificationPreference, isFocused]); + }, [prevIsFocused, report.notificationPreference, isFocused, isSingleTransactionView]); useEffect(() => { // We don't want this effect to run on the first render. diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index b0a4d3d59d09..921ec324fced 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -406,6 +406,9 @@ const ContextMenuActions: ContextMenuAction[] = [ } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED) { const displayMessage = ReportUtils.getIOUApprovedMessage(reportID); Clipboard.setString(displayMessage); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.FORWARDED) { + const displayMessage = ReportUtils.getIOUForwardedMessage(reportID); + Clipboard.setString(displayMessage); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) { Clipboard.setString(Localize.translateLocal('iou.heldExpense')); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) { @@ -420,6 +423,8 @@ const ContextMenuActions: ContextMenuAction[] = [ Clipboard.setString(Localize.translateLocal(`violationDismissal.${violationName}.${reason}` as TranslationPaths)); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION) { setClipboardMessage(ReportActionsUtils.getExportIntegrationMessageHTML(reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) { + setClipboardMessage(ReportActionsUtils.getUpdateRoomDescriptionMessage(reportAction)); } else if (content) { setClipboardMessage( content.replace(/()(.*?)(<\/mention-user>)/gi, (match, openTag: string, innerContent: string, closeTag: string): string => { @@ -515,10 +520,9 @@ const ContextMenuActions: ContextMenuAction[] = [ successIcon: Expensicons.Download, shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID, isPinnedChat, isUnreadChat, isOffline): reportAction is ReportAction => { const isAttachment = ReportActionsUtils.isReportActionAttachment(reportAction); - const messageHtml = getActionHtml(reportAction); - return ( - isAttachment && messageHtml !== CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML && !!reportAction?.reportActionID && !ReportActionsUtils.isMessageDeleted(reportAction) && !isOffline - ); + const html = getActionHtml(reportAction); + const isUploading = html.includes(CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE); + return isAttachment && !isUploading && !!reportAction?.reportActionID && !ReportActionsUtils.isMessageDeleted(reportAction) && !isOffline; }, onPress: (closePopover, {reportAction}) => { const html = getActionHtml(reportAction); diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 5b89f09718c5..19efb2c2968e 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -15,8 +15,7 @@ import {DeviceEventEmitter, findNodeHandle, InteractionManager, NativeModules, V import {useFocusedInputHandler} from 'react-native-keyboard-controller'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import {useSharedValue} from 'react-native-reanimated'; -import type {useAnimatedRef} from 'react-native-reanimated'; +import {useAnimatedRef, useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; @@ -31,6 +30,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Browser from '@libs/Browser'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; +import {forceClearInput} from '@libs/ComponentUtils'; import * as ComposerUtils from '@libs/ComposerUtils'; import convertToLTRForComposer from '@libs/convertToLTRForComposer'; import {getDraftComment} from '@libs/DraftCommentUtils'; @@ -63,8 +63,6 @@ type SyncSelection = { value: string; }; -type AnimatedRef = ReturnType; - type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; type ComposerWithSuggestionsOnyxProps = { @@ -95,6 +93,9 @@ type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps & /** Callback to update the value of the composer */ onValueChange: (value: string) => void; + /** Callback when the composer got cleared on the UI thread */ + onCleared?: (text: string) => void; + /** Whether the composer is full size */ isComposerFullSize: boolean; @@ -107,12 +108,6 @@ type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps & /** Function to display a file in a modal */ displayFileInModal: (file: FileObject) => void; - /** Whether the text input should clear */ - textInputShouldClear: boolean; - - /** Function to set the text input should clear */ - setTextInputShouldClear: (shouldClear: boolean) => void; - /** Whether the user is blocked from concierge */ isBlockedFromConcierge: boolean; @@ -146,9 +141,6 @@ type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps & /** The ref to the suggestions */ suggestionsRef: React.RefObject; - /** The ref to the animated input */ - animatedRef: AnimatedRef; - /** The ref to the next modal will open */ isNextModalWillOpenRef: MutableRefObject; @@ -239,8 +231,6 @@ function ComposerWithSuggestions( isMenuVisible, inputPlaceholder, displayFileInModal, - textInputShouldClear, - setTextInputShouldClear, isBlockedFromConcierge, disabled, isFullComposerAvailable, @@ -251,10 +241,10 @@ function ComposerWithSuggestions( measureParentContainer = () => {}, isScrollLikelyLayoutTriggered, raiseIsScrollLikelyLayoutTriggered, + onCleared = () => {}, // Refs suggestionsRef, - animatedRef, isNextModalWillOpenRef, editFocused, @@ -282,7 +272,11 @@ function ComposerWithSuggestions( return draftComment; }); const commentRef = useRef(value); + const lastTextRef = useRef(value); + useEffect(() => { + lastTextRef.current = value; + }, [value]); const {shouldUseNarrowLayout} = useResponsiveLayout(); const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; @@ -309,6 +303,7 @@ function ComposerWithSuggestions( // The ref to check whether the comment saving is in progress const isCommentPendingSaved = useRef(false); + const animatedRef = useAnimatedRef(); /** * Set the TextInput Ref */ @@ -421,6 +416,7 @@ function ComposerWithSuggestions( setIsCommentEmpty(isNewCommentEmpty); } emojisPresentBefore.current = emojis; + setValue(newCommentConverted); if (commentValue !== newComment) { const position = Math.max((selection.end ?? 0) + (newComment.length - commentRef.current.length), cursorPosition ?? 0); @@ -451,31 +447,6 @@ function ComposerWithSuggestions( [findNewlyAddedChars, preferredLocale, preferredSkinTone, reportID, setIsCommentEmpty, suggestionsRef, raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, selection.end], ); - const prepareCommentAndResetComposer = useCallback((): string => { - const trimmedComment = commentRef.current.trim(); - const commentLength = ReportUtils.getCommentLength(trimmedComment, {reportID}); - - // Don't submit empty comments or comments that exceed the character limit - if (!commentLength || commentLength > CONST.MAX_COMMENT_LENGTH) { - return ''; - } - - // Since we're submitting the form here which should clear the composer - // We don't really care about saving the draft the user was typing - // We need to make sure an empty draft gets saved instead - debouncedSaveReportComment.cancel(); - isCommentPendingSaved.current = false; - - setSelection({start: 0, end: 0, positionX: 0, positionY: 0}); - updateComment(''); - setTextInputShouldClear(true); - if (isComposerFullSize) { - Report.setIsComposerFullSize(reportID, false); - } - setIsFullComposerAvailable(false); - return trimmedComment; - }, [updateComment, setTextInputShouldClear, isComposerFullSize, setIsFullComposerAvailable, reportID, debouncedSaveReportComment]); - /** * Callback to add whatever text is chosen into the main input (used f.e as callback for the emoji picker) */ @@ -633,6 +604,16 @@ function ComposerWithSuggestions( textInputRef.current.blur(); }, []); + const clear = useCallback(() => { + 'worklet'; + + forceClearInput(animatedRef, textInputRef); + }, [animatedRef]); + + const getCurrentText = useCallback(() => { + return commentRef.current; + }, []); + useEffect(() => { const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListener(focusComposerOnKeyPress)); const unsubscribeNavigationFocus = navigation.addListener('focus', () => { @@ -692,16 +673,13 @@ function ComposerWithSuggestions( blur, focus, replaceSelectionWithText, - prepareCommentAndResetComposer, isFocused: () => !!textInputRef.current?.isFocused(), + clear, + getCurrentText, }), - [blur, focus, prepareCommentAndResetComposer, replaceSelectionWithText], + [blur, clear, focus, replaceSelectionWithText, getCurrentText], ); - useEffect(() => { - lastTextRef.current = value; - }, [value]); - useEffect(() => { onValueChange(value); }, [onValueChange, value]); @@ -717,11 +695,15 @@ function ComposerWithSuggestions( [composerHeight], ); - const onClear = useCallback(() => { - mobileInputScrollPosition.current = 0; - setTextInputShouldClear(false); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, []); + const onClear = useCallback( + (text: string) => { + mobileInputScrollPosition.current = 0; + // Note: use the value when the clear happened, not the current value which might have changed already + onCleared(text); + updateComment('', true); + }, + [onCleared, updateComment], + ); useEffect(() => { // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data. @@ -784,7 +766,6 @@ function ComposerWithSuggestions( textInputRef.current?.blur(); displayFileInModal(file); }} - shouldClear={textInputShouldClear} onClear={onClear} isDisabled={isBlockedFromConcierge || disabled} isReportActionCompose diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 3a57f057a938..8bba87b8a838 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,10 +1,9 @@ -import type {SyntheticEvent} from 'react'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputFocusEventData, TextInputSelectionChangeEventData} from 'react-native'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import {runOnJS, setNativeProps, useAnimatedRef} from 'react-native-reanimated'; +import {runOnUI, useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; import AttachmentModal from '@components/AttachmentModal'; @@ -49,8 +48,13 @@ type ComposerRef = { blur: () => void; focus: (shouldDelay?: boolean) => void; replaceSelectionWithText: EmojiPickerActions.OnEmojiSelected; - prepareCommentAndResetComposer: () => string; + getCurrentText: () => string; isFocused: () => boolean; + /** + * Calling clear will immediately clear the input on the UI thread (its a worklet). + * Once the composer ahs cleared onCleared will be called with the value that was cleared. + */ + clear: () => void; }; type SuggestionsRef = { @@ -122,7 +126,6 @@ function ReportActionCompose({ const {translate} = useLocalize(); const {isMediumScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); const {isOffline} = useNetwork(); - const animatedRef = useAnimatedRef(); const actionButtonRef = useRef(null); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; @@ -155,10 +158,6 @@ function ReportActionCompose({ debouncedLowerIsScrollLikelyLayoutTriggered(); }, [debouncedLowerIsScrollLikelyLayoutTriggered]); - /** - * Updates the should clear state of the composer - */ - const [textInputShouldClear, setTextInputShouldClear] = useState(false); const [isCommentEmpty, setIsCommentEmpty] = useState(() => { const draftComment = getDraftComment(reportID); return !draftComment || !!draftComment.match(/^(\s)*$/); @@ -177,7 +176,7 @@ function ReportActionCompose({ const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength(); const suggestionsRef = useRef(null); - const composerRef = useRef(null); + const composerRef = useRef(); const reportParticipantIDs = useMemo( () => Object.keys(report?.participants ?? {}) @@ -219,7 +218,7 @@ function ReportActionCompose({ if (composerRef.current === null) { return; } - composerRef.current.focus(true); + composerRef.current?.focus(true); }; const isKeyboardVisibleWhenShowingModalRef = useRef(false); @@ -263,15 +262,16 @@ function ReportActionCompose({ suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); }, []); - const addAttachment = useCallback( - (file: FileObject) => { - playSound(SOUNDS.DONE); - const newComment = composerRef?.current?.prepareCommentAndResetComposer(); - Report.addAttachment(reportID, file, newComment); - setTextInputShouldClear(false); - }, - [reportID], - ); + const attachmentFileRef = useRef(null); + const addAttachment = useCallback((file: FileObject) => { + attachmentFileRef.current = file; + const clear = composerRef.current?.clear; + if (!clear) { + throw new Error('The composerRef.clear function is not set yet. This should never happen, and indicates a developer error.'); + } + + runOnUI(clear)(); + }, []); /** * Event handler to update the state after the attachment preview is closed. @@ -286,18 +286,19 @@ function ReportActionCompose({ * Add a new comment to this chat */ const submitForm = useCallback( - (event?: SyntheticEvent) => { - event?.preventDefault(); + (newComment: string) => { + playSound(SOUNDS.DONE); - const newComment = composerRef.current?.prepareCommentAndResetComposer(); - if (!newComment) { - return; - } + const newCommentTrimmed = newComment.trim(); - playSound(SOUNDS.DONE); - onSubmit(newComment); + if (attachmentFileRef.current) { + Report.addAttachment(reportID, attachmentFileRef.current, newCommentTrimmed); + attachmentFileRef.current = null; + } else { + onSubmit(newCommentTrimmed); + } }, - [onSubmit], + [onSubmit, reportID], ); const onTriggerAttachmentPicker = useCallback(() => { @@ -325,15 +326,6 @@ function ReportActionCompose({ onComposerFocus?.(); }, [onComposerFocus]); - // resets the composer to normal size when - // the send button is pressed. - const resetFullComposerSize = useCallback(() => { - if (isComposerFullSize) { - Report.setIsComposerFullSize(reportID, false); - } - setIsFullComposerAvailable(false); - }, [isComposerFullSize, reportID]); - // We are returning a callback here as we want to incoke the method on unmount only useEffect( () => () => { @@ -356,19 +348,26 @@ function ReportActionCompose({ const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!disabled || hasExceededMaxCommentLength; + // Note: using JS refs is not well supported in reanimated, thus we need to store the function in a shared value + // useSharedValue on web doesn't support functions, so we need to wrap it in an object. + const composerRefShared = useSharedValue<{ + clear: (() => void) | undefined; + }>({clear: undefined}); const handleSendMessage = useCallback(() => { 'worklet'; + const clearComposer = composerRefShared.value.clear; + if (!clearComposer) { + throw new Error('The composerRefShared.clear function is not set yet. This should never happen, and indicates a developer error.'); + } + if (isSendDisabled || !isReportReadyForDisplay) { return; } - // We are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state - runOnJS(setIsCommentEmpty)(true); - runOnJS(resetFullComposerSize)(); - setNativeProps(animatedRef, {text: ''}); // clears native text input on the UI thread - runOnJS(submitForm)(); - }, [isSendDisabled, resetFullComposerSize, submitForm, animatedRef, isReportReadyForDisplay]); + // This will cause onCleared to be triggered where we actually send the message + clearComposer(); + }, [isSendDisabled, isReportReadyForDisplay, composerRefShared]); const emojiShiftVertical = useMemo(() => { const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; @@ -430,8 +429,13 @@ function ReportActionCompose({ actionButtonRef={actionButtonRef} /> { + composerRef.current = ref ?? undefined; + // eslint-disable-next-line react-compiler/react-compiler + composerRefShared.value = { + clear: ref?.clear, + }; + }} suggestionsRef={suggestionsRef} isNextModalWillOpenRef={isNextModalWillOpenRef} isScrollLikelyLayoutTriggered={isScrollLikelyLayoutTriggered} @@ -448,8 +452,6 @@ function ReportActionCompose({ inputPlaceholder={inputPlaceholder} isComposerFullSize={isComposerFullSize} displayFileInModal={displayFileInModal} - textInputShouldClear={textInputShouldClear} - setTextInputShouldClear={setTextInputShouldClear} isBlockedFromConcierge={isBlockedFromConcierge} disabled={!!disabled} isFullComposerAvailable={isFullComposerAvailable} @@ -459,6 +461,7 @@ function ReportActionCompose({ shouldShowComposeInput={shouldShowComposeInput} onFocus={onFocus} onBlur={onBlur} + onCleared={submitForm} measureParentContainer={measureContainer} onValueChange={(value) => { if (value.length === 0 && isComposerFullSize) { diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index 0cb9876b69f8..aa6e51d4b7ba 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -638,6 +638,8 @@ function ReportActionItem({ children = ; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED) { children = ; + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.FORWARDED) { + children = ; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) { children = ; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD_COMMENT) { @@ -647,8 +649,13 @@ function ReportActionItem({ } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION) { children = ; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) { - const message = ReportActionsUtils.getUpdateRoomDescriptionMessage(action); - children = ; + children = ( + + ); } else if (ReportActionsUtils.isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION)) { children = ; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAG) { diff --git a/src/pages/home/report/ReportActionItemFragment.tsx b/src/pages/home/report/ReportActionItemFragment.tsx index 7c0974f74a4b..787904d72b81 100644 --- a/src/pages/home/report/ReportActionItemFragment.tsx +++ b/src/pages/home/report/ReportActionItemFragment.tsx @@ -2,6 +2,7 @@ import React, {memo} from 'react'; import type {StyleProp, TextStyle} from 'react-native'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; +import UserDetailsTooltip from '@components/UserDetailsTooltip'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -14,7 +15,6 @@ import type {Message} from '@src/types/onyx/ReportAction'; import type ReportActionName from '@src/types/onyx/ReportActionName'; import AttachmentCommentFragment from './comment/AttachmentCommentFragment'; import TextCommentFragment from './comment/TextCommentFragment'; -import ReportActionItemMessageHeaderSender from './ReportActionItemMessageHeaderSender'; type ReportActionItemFragmentProps = { /** Users accountID */ @@ -69,6 +69,7 @@ const MUTED_ACTIONS = [ ...Object.values(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG), CONST.REPORT.ACTIONS.TYPE.IOU, CONST.REPORT.ACTIONS.TYPE.APPROVED, + CONST.REPORT.ACTIONS.TYPE.FORWARDED, CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, CONST.REPORT.ACTIONS.TYPE.MOVED, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_JOIN_REQUEST, @@ -159,13 +160,18 @@ function ReportActionItemFragment({ } return ( - + icon={actorIcon} + > + + {fragment?.text} + + ); } case 'LINK': diff --git a/src/pages/home/report/ReportActionItemMessage.tsx b/src/pages/home/report/ReportActionItemMessage.tsx index 4c9ed8bc78ff..d88a6792f7c9 100644 --- a/src/pages/home/report/ReportActionItemMessage.tsx +++ b/src/pages/home/report/ReportActionItemMessage.tsx @@ -60,6 +60,21 @@ function ReportActionItemMessage({action, transaction, displayAsGroup, reportID, ); } + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) { + const fragment = ReportActionsUtils.getUpdateRoomDescriptionFragment(action); + return ( + + + + ); + } + let iouMessage: string | undefined; if (isIOUReport) { const originalMessage = action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? ReportActionsUtils.getOriginalMessage(action) : null; diff --git a/src/pages/home/report/ReportActionItemMessageHeaderSender.tsx b/src/pages/home/report/ReportActionItemMessageHeaderSender.tsx deleted file mode 100644 index e31f8c55947f..000000000000 --- a/src/pages/home/report/ReportActionItemMessageHeaderSender.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, {useMemo} from 'react'; -import Text from '@components/Text'; -import UserDetailsTooltip from '@components/UserDetailsTooltip'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as EmojiUtils from '@libs/EmojiUtils'; -import CONST from '@src/CONST'; -import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; - -type ReportActionItemMessageHeaderSenderProps = { - /** Text to display */ - fragmentText: string; - - /** Users accountID */ - accountID: number; - - /** Should this fragment be contained in a single line? */ - isSingleLine?: boolean; - - /** The accountID of the copilot who took this action on behalf of the user */ - delegateAccountID?: number; - - /** Actor icon */ - actorIcon?: OnyxCommon.Icon; -}; - -function ReportActionItemMessageHeaderSender({fragmentText, accountID, delegateAccountID, actorIcon, isSingleLine}: ReportActionItemMessageHeaderSenderProps) { - const styles = useThemeStyles(); - - const processedTextArray = useMemo(() => { - const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); - const doesTextContainEmojis = emojisRegex.test(fragmentText); - - if (!doesTextContainEmojis) { - return []; - } - - return EmojiUtils.splitTextWithEmojis(fragmentText); - }, [fragmentText]); - - return ( - - - {processedTextArray.length !== 0 ? processedTextArray.map(({text, isEmoji}) => (isEmoji ? {text} : text)) : fragmentText} - - - ); -} - -ReportActionItemMessageHeaderSender.displayName = 'ReportActionItemMessageHeaderSender'; - -export default ReportActionItemMessageHeaderSender; diff --git a/src/pages/home/report/ReportActionItemParentAction.tsx b/src/pages/home/report/ReportActionItemParentAction.tsx index cc07c74e602e..40e18f91c1d4 100644 --- a/src/pages/home/report/ReportActionItemParentAction.tsx +++ b/src/pages/home/report/ReportActionItemParentAction.tsx @@ -9,6 +9,8 @@ import onyxSubscribe from '@libs/onyxSubscribe'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as Report from '@userActions/Report'; +import Timing from '@userActions/Timing'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; @@ -124,6 +126,7 @@ function ReportActionItemParentAction({ // Pop the chat report screen before navigating to the linked report action. Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(ancestor.report.parentReportID ?? '-1', ancestor.reportAction.reportActionID)); } + Timing.start(CONST.TIMING.SWITCH_REPORT); } : undefined } diff --git a/src/pages/home/report/ReportActionItemThread.tsx b/src/pages/home/report/ReportActionItemThread.tsx index c0dbe2a3825d..5d2c6316df4e 100644 --- a/src/pages/home/report/ReportActionItemThread.tsx +++ b/src/pages/home/report/ReportActionItemThread.tsx @@ -6,6 +6,7 @@ import PressableWithSecondaryInteraction from '@components/PressableWithSecondar import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import Timing from '@libs/actions/Timing'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import type {Icon} from '@src/types/onyx/OnyxCommon'; @@ -45,6 +46,7 @@ function ReportActionItemThread({numberOfReplies, icons, mostRecentReply, childR { Report.navigateToAndOpenChildReport(childReportID); + Timing.start(CONST.TIMING.SWITCH_REPORT); }} role={CONST.ROLE.BUTTON} accessibilityLabel={`${numberOfReplies} ${replyText}`} diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index a7d2046a7ef1..12d886cd30f9 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -177,8 +177,7 @@ function ReportActionsList({ const userActiveSince = useRef(null); const lastMessageTime = useRef(null); - const [isVisible, setIsVisible] = useState(Visibility.isVisible()); - const hasCalledReadNewestAction = useRef(false); + const [isVisible, setIsVisible] = useState(false); const isFocused = useIsFocused(); useEffect(() => { @@ -268,9 +267,6 @@ function ReportActionsList({ if (!userActiveSince.current || report.reportID !== prevReportID) { return; } - if (hasCalledReadNewestAction.current) { - return; - } if (ReportUtils.isUnread(report)) { // On desktop, when the notification center is displayed, Visibility.isVisible() will return false. // Currently, there's no programmatic way to dismiss the notification center panel. @@ -278,7 +274,6 @@ function ReportActionsList({ const isFromNotification = route?.params?.referrer === CONST.REFERRER.NOTIFICATION; if ((Visibility.isVisible() || isFromNotification) && scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD) { Report.readNewestAction(report.reportID); - hasCalledReadNewestAction.current = true; if (isFromNotification) { Navigation.setParams({referrer: undefined}); } @@ -529,10 +524,6 @@ function ReportActionsList({ return; } - if (hasCalledReadNewestAction.current) { - return; - } - if (!isVisible || !isFocused) { if (!lastMessageTime.current) { lastMessageTime.current = sortedVisibleReportActions[0]?.created ?? ''; @@ -544,27 +535,24 @@ function ReportActionsList({ // show marker based on report.lastReadTime const newMessageTimeReference = lastMessageTime.current && report.lastReadTime && lastMessageTime.current > report.lastReadTime ? userActiveSince.current : report.lastReadTime; lastMessageTime.current = null; - const areSomeReportActionsUnread = sortedVisibleReportActions.some((reportAction) => { - /** - * The archived reports should not be marked as unread. So we are checking if the report is archived or not. - * If the report is archived, we will mark the report as read. - */ - const isArchivedReport = ReportUtils.isArchivedRoom(report); - const isUnread = isArchivedReport || (newMessageTimeReference && newMessageTimeReference < reportAction.created); - return ( - isUnread && (ReportActionsUtils.isReportPreviewAction(reportAction) ? reportAction.childLastActorAccountID : reportAction.actorAccountID) !== Report.getCurrentUserAccountID() - ); - }); - if (scrollingVerticalOffset.current >= MSG_VISIBLE_THRESHOLD || !areSomeReportActionsUnread) { + if ( + scrollingVerticalOffset.current >= MSG_VISIBLE_THRESHOLD || + !sortedVisibleReportActions.some( + (reportAction) => + newMessageTimeReference && + newMessageTimeReference < reportAction.created && + (ReportActionsUtils.isReportPreviewAction(reportAction) ? reportAction.childLastActorAccountID : reportAction.actorAccountID) !== Report.getCurrentUserAccountID(), + ) + ) { return; } + Report.readNewestAction(report.reportID); userActiveSince.current = DateUtils.getDBTime(); lastReadTimeRef.current = newMessageTimeReference; setCurrentUnreadMarker(null); cacheUnreadMarkers.delete(report.reportID); calculateUnreadMarker(); - hasCalledReadNewestAction.current = true; // This effect logic to `mark as read` will only run when the report focused has new messages and the App visibility // is changed to visible(meaning user switched to app/web, while user was previously using different tab or application). diff --git a/src/pages/home/report/ReportActionsListItemRenderer.tsx b/src/pages/home/report/ReportActionsListItemRenderer.tsx index c57e02f1d0f1..48c578fd743a 100644 --- a/src/pages/home/report/ReportActionsListItemRenderer.tsx +++ b/src/pages/home/report/ReportActionsListItemRenderer.tsx @@ -171,9 +171,13 @@ function ReportActionsListItemRenderer({ shouldDisplayNewMarker={shouldDisplayNewMarker} shouldShowSubscriptAvatar={ ReportUtils.isPolicyExpenseChat(report) && - [CONST.REPORT.ACTIONS.TYPE.IOU, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, CONST.REPORT.ACTIONS.TYPE.SUBMITTED, CONST.REPORT.ACTIONS.TYPE.APPROVED].some( - (type) => type === reportAction.actionName, - ) + [ + CONST.REPORT.ACTIONS.TYPE.IOU, + CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + CONST.REPORT.ACTIONS.TYPE.APPROVED, + CONST.REPORT.ACTIONS.TYPE.FORWARDED, + ].some((type) => type === reportAction.actionName) } isMostRecentIOUReportAction={reportAction.reportActionID === mostRecentIOUReportActionID} index={index} diff --git a/src/pages/home/report/ThreadDivider.tsx b/src/pages/home/report/ThreadDivider.tsx index 11c8d28c93d3..9176cbb59ada 100644 --- a/src/pages/home/report/ThreadDivider.tsx +++ b/src/pages/home/report/ThreadDivider.tsx @@ -12,6 +12,7 @@ import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import type {Ancestor} from '@libs/ReportUtils'; import variables from '@styles/variables'; +import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -47,6 +48,7 @@ function ThreadDivider({ancestor, isLinkDisabled = false}: ThreadDividerProps) { ) : ( { + Timing.start(CONST.TIMING.SWITCH_REPORT); const isVisibleAction = ReportActionsUtils.shouldReportActionBeVisible(ancestor.reportAction, ancestor.reportAction.reportActionID ?? '-1'); // Pop the thread report screen before navigating to the chat report. Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(ancestor.report.parentReportID ?? '-1')); diff --git a/src/pages/home/report/comment/AttachmentCommentFragment.tsx b/src/pages/home/report/comment/AttachmentCommentFragment.tsx index fec5ebe92e54..7d2d81b86e02 100644 --- a/src/pages/home/report/comment/AttachmentCommentFragment.tsx +++ b/src/pages/home/report/comment/AttachmentCommentFragment.tsx @@ -14,7 +14,7 @@ type AttachmentCommentFragmentProps = { function AttachmentCommentFragment({addExtraMargin, html, source, styleAsDeleted}: AttachmentCommentFragmentProps) { const styles = useThemeStyles(); - const isUploading = html === CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML; + const isUploading = html.includes(CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE); const htmlContent = styleAsDeleted && isUploading ? `${html}` : html; return ( diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx index 3474881e3fee..68827de96172 100644 --- a/src/pages/home/report/comment/TextCommentFragment.tsx +++ b/src/pages/home/report/comment/TextCommentFragment.tsx @@ -17,7 +17,6 @@ import type {OriginalMessageSource} from '@src/types/onyx/OriginalMessage'; import type {Message} from '@src/types/onyx/ReportAction'; import RenderCommentHTML from './RenderCommentHTML'; import shouldRenderAsText from './shouldRenderAsText'; -import TextWithEmojiFragment from './TextWithEmojiFragment'; type TextCommentFragmentProps = { /** The reportAction's source */ @@ -45,19 +44,19 @@ type TextCommentFragmentProps = { function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, source, style, displayAsGroup, iouMessage = ''}: TextCommentFragmentProps) { const theme = useTheme(); const styles = useThemeStyles(); - const {html = '', text = ''} = fragment ?? {}; + const {html = '', text} = fragment ?? {}; const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); // If the only difference between fragment.text and fragment.html is
tags and emoji tag // on native, we render it as text, not as html // on other device, only render it as text if the only difference is
tag - const doesTextContainOnlyEmojis = EmojiUtils.containsOnlyEmojis(text ?? ''); - if (!shouldRenderAsText(html, text ?? '') && !(doesTextContainOnlyEmojis && styleAsDeleted)) { - const editedTag = fragment?.isEdited ? `` : ''; + const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text ?? ''); + if (!shouldRenderAsText(html, text ?? '') && !(containsOnlyEmojis && styleAsDeleted)) { + const editedTag = fragment?.isEdited ? `` : ''; const htmlWithDeletedTag = styleAsDeleted ? `${html}` : html; - const htmlContent = doesTextContainOnlyEmojis ? Str.replaceAll(htmlWithDeletedTag, '', '') : htmlWithDeletedTag; + const htmlContent = containsOnlyEmojis ? Str.replaceAll(htmlWithDeletedTag, '', '') : htmlWithDeletedTag; let htmlWithTag = editedTag ? `${htmlContent}${editedTag}` : htmlContent; if (styleAsMuted) { @@ -73,55 +72,40 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so } const message = isEmpty(iouMessage) ? text : iouMessage; - const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); return ( - + - {emojisRegex.test(message ?? '') && !doesTextContainOnlyEmojis ? ( - - ) : ( + + {convertToLTR(message ?? '')} + + {fragment?.isEdited && ( <> - {convertToLTR(message ?? '')} + {' '} + + + {translate('reportActionCompose.edited')} - {!!fragment?.isEdited && ( - <> - - {' '} - - - {translate('reportActionCompose.edited')} - - - )} )} diff --git a/src/pages/home/report/comment/TextWithEmojiFragment.tsx b/src/pages/home/report/comment/TextWithEmojiFragment.tsx deleted file mode 100644 index cc0da8f81fb0..000000000000 --- a/src/pages/home/report/comment/TextWithEmojiFragment.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, {useMemo} from 'react'; -import type {StyleProp, TextStyle} from 'react-native'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as DeviceCapabilities from '@libs/DeviceCapabilities'; -import * as EmojiUtils from '@libs/EmojiUtils'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; - -type TextWithEmojiFragmentProps = { - /** The message to be displayed */ - message: string; - - /** Additional styles to add after local styles. */ - passedStyles?: StyleProp; - - /** Should this message fragment be styled as deleted? */ - styleAsDeleted?: boolean; - - /** Should this message fragment be styled as muted? */ - styleAsMuted?: boolean; - - /** Should "(edited)" suffix be rendered? */ - isEdited?: boolean; - - /** Does message contain only emojis? */ - hasEmojisOnly?: boolean; -}; - -function TextWithEmojiFragment({message, passedStyles, styleAsDeleted, styleAsMuted, isEdited, hasEmojisOnly}: TextWithEmojiFragmentProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const theme = useTheme(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - - const processedTextArray = useMemo(() => EmojiUtils.splitTextWithEmojis(message), [message]); - - return ( - - {processedTextArray.map(({text, isEmoji}) => - isEmoji ? ( - {text} - ) : ( - - {text} - - ), - )} - - {isEdited && ( - <> - - {' '} - - - {translate('reportActionCompose.edited')} - - - )} - - ); -} - -TextWithEmojiFragment.displayName = 'TextWithEmojiFragment'; - -export default TextWithEmojiFragment; diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index b208e63909a8..39e3dc9a8989 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -29,8 +29,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; import * as CurrencyUtils from '@libs/CurrencyUtils'; -import {splitTextWithEmojis} from '@libs/EmojiUtils'; -import type {TextWithEmoji} from '@libs/EmojiUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as UserUtils from '@libs/UserUtils'; @@ -366,16 +364,9 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa const generalMenuItems = useMemo(() => getMenuItemsSection(generalMenuItemsData), [generalMenuItemsData, getMenuItemsSection]); const workspaceMenuItems = useMemo(() => getMenuItemsSection(workspaceMenuItemsData), [workspaceMenuItemsData, getMenuItemsSection]); - const avatarURL = currentUserPersonalDetails?.avatar ?? ''; - const accountID = currentUserPersonalDetails?.accountID ?? '-1'; - - const processedTextArray: TextWithEmoji[] = useMemo(() => { - const doesUsernameContainEmojis = CONST.REGEX.EMOJIS.test(currentUserPersonalDetails?.displayName ?? ''); - if (!doesUsernameContainEmojis) { - return []; - } - return splitTextWithEmojis(currentUserPersonalDetails?.displayName ?? ''); - }, [currentUserPersonalDetails?.displayName]); + const currentUserDetails = currentUserPersonalDetails; + const avatarURL = currentUserDetails?.avatar ?? ''; + const accountID = currentUserDetails?.accountID ?? '-1'; const headerContent = ( @@ -425,7 +416,7 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa Navigation.navigate(ROUTES.PROFILE_AVATAR.getRoute(String(accountID)))} previewSource={UserUtils.getFullSizeAvatar(avatarURL, accountID)} - originalFileName={currentUserPersonalDetails.originalFileName} + originalFileName={currentUserDetails.originalFileName} headerTitle={translate('profilePage.profileAvatar')} - fallbackIcon={currentUserPersonalDetails?.fallbackIcon} + fallbackIcon={currentUserDetails?.fallbackIcon} editIconStyle={styles.smallEditIconAccount} /> - {processedTextArray.length !== 0 ? ( - - {processedTextArray.map(({text, isEmoji}) => (isEmoji ? {text} : text))} - - ) : ( - - {currentUserPersonalDetails.displayName ? currentUserPersonalDetails.displayName : formatPhoneNumber(session?.email ?? '')} - - )} + + {currentUserPersonalDetails.displayName ? currentUserPersonalDetails.displayName : formatPhoneNumber(session?.email ?? '')} + {!!currentUserPersonalDetails.displayName && (
@@ -115,7 +114,6 @@ function DisplayNamePage({isLoadingApp = true, currentUserPersonalDetails}: Disp role={CONST.ROLE.PRESENTATION} defaultValue={currentUserDetails.lastName ?? ''} spellCheck={false} - isMarkdownEnabled /> diff --git a/src/pages/workspace/AccessOrNotFoundWrapper.tsx b/src/pages/workspace/AccessOrNotFoundWrapper.tsx index 79dd93cc46e1..816d2b74dbc6 100644 --- a/src/pages/workspace/AccessOrNotFoundWrapper.tsx +++ b/src/pages/workspace/AccessOrNotFoundWrapper.tsx @@ -3,7 +3,6 @@ import React, {useEffect, useState} from 'react'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import type {FullPageNotFoundViewProps} from '@components/BlockingViews/FullPageNotFoundView'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; @@ -87,17 +86,16 @@ type AccessOrNotFoundWrapperProps = AccessOrNotFoundWrapperOnyxProps & { type PageNotFoundFallbackProps = Pick & {shouldShowFullScreenFallback: boolean; isMoneyRequest: boolean}; function PageNotFoundFallback({policyID, shouldShowFullScreenFallback, fullPageNotFoundViewProps, isMoneyRequest}: PageNotFoundFallbackProps) { - return shouldShowFullScreenFallback ? ( - Navigation.dismissModal()} - shouldForceFullScreen - // eslint-disable-next-line react/jsx-props-no-spreading - {...fullPageNotFoundViewProps} - /> - ) : ( + return ( Navigation.goBack(policyID && !isMoneyRequest ? ROUTES.WORKSPACE_PROFILE.getRoute(policyID) : undefined)} + shouldForceFullScreen={shouldShowFullScreenFallback} + onBackButtonPress={() => { + if (shouldShowFullScreenFallback) { + Navigation.dismissModal(); + return; + } + Navigation.goBack(policyID && !isMoneyRequest ? ROUTES.WORKSPACE_PROFILE.getRoute(policyID) : undefined); + }} // eslint-disable-next-line react/jsx-props-no-spreading {...fullPageNotFoundViewProps} /> diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index bd9bc04aef6a..67d049e3cafe 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -107,7 +107,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc [CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED]: policy?.areCategoriesEnabled, [CONST.POLICY.MORE_FEATURES.ARE_TAGS_ENABLED]: policy?.areTagsEnabled, [CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED]: policy?.tax?.trackingEnabled, - [CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED]: policy?.areConnectionsEnabled, + [CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED]: !!policy?.areConnectionsEnabled || !isEmptyObject(policy?.connections), [CONST.POLICY.MORE_FEATURES.ARE_EXPENSIFY_CARDS_ENABLED]: policy?.areExpensifyCardsEnabled, [CONST.POLICY.MORE_FEATURES.ARE_REPORT_FIELDS_ENABLED]: policy?.areReportFieldsEnabled, }), diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index c1fde46d7a9c..a68b572a06d1 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -5,7 +5,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {TextInput} from 'react-native'; import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import Badge from '@components/Badge'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; @@ -14,9 +14,9 @@ import ConfirmModal from '@components/ConfirmModal'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import MessagesRow from '@components/MessagesRow'; -import SelectionList from '@components/SelectionList'; import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem, SelectionListHandle} from '@components/SelectionList/types'; +import SelectionListWithModal from '@components/SelectionListWithModal'; import Text from '@components/Text'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; @@ -26,6 +26,7 @@ import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; @@ -40,24 +41,14 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {InvitedEmailsToAccountIDs, PersonalDetailsList, PolicyEmployeeList, Session} from '@src/types/onyx'; +import type {PersonalDetailsList, PolicyEmployeeList} from '@src/types/onyx'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; import WorkspacePageWithSections from './WorkspacePageWithSections'; -type WorkspaceMembersPageOnyxProps = { - /** Session info for the currently logged in user. */ - session: OnyxEntry; - - /** An object containing the accountID for every invited user email */ - invitedEmailsToAccountIDsDraft: OnyxEntry; -}; -type WorkspaceMembersPageProps = WithPolicyAndFullscreenLoadingProps & - WithCurrentUserPersonalDetailsProps & - WorkspaceMembersPageOnyxProps & - StackScreenProps; +type WorkspaceMembersPageProps = WithPolicyAndFullscreenLoadingProps & WithCurrentUserPersonalDetailsProps & StackScreenProps; /** * Inverts an object, equivalent of _.invert @@ -69,7 +60,7 @@ function invertObject(object: Record): Record { type MemberOption = Omit & {accountID: number}; -function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft, route, policy, session, currentUserPersonalDetails}: WorkspaceMembersPageProps) { +function WorkspaceMembersPage({personalDetails, route, policy, currentUserPersonalDetails}: WorkspaceMembersPageProps) { const policyMemberEmailsToAccountIDs = useMemo(() => PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList, true), [policy?.employeeList]); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -90,10 +81,16 @@ function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft, () => !isOfflineAndNoMemberDataAvailable && (!OptionsListUtils.isPersonalDetailsReady(personalDetails) || isEmptyObject(policy?.employeeList)), [isOfflineAndNoMemberDataAvailable, personalDetails, policy?.employeeList], ); + + const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`); + const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); + const [session] = useOnyx(ONYXKEYS.SESSION); const selectionListRef = useRef(null); const isFocused = useIsFocused(); const policyID = route.params.policyID; + const canSelectMultiple = isPolicyAdmin && (shouldUseNarrowLayout ? selectionMode?.isEnabled : true); + const confirmModalPrompt = useMemo(() => { const approverAccountID = selectedEmployees.find((selectedEmployee) => Member.isApprover(policy, selectedEmployee)); if (!approverAccountID) { @@ -261,6 +258,10 @@ function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft, */ const toggleUser = useCallback( (accountID: number, pendingAction?: PendingAction) => { + if (accountID === policy?.ownerAccountID && accountID !== session?.accountID) { + return; + } + if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return; } @@ -272,7 +273,7 @@ function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft, addUser(accountID); } }, - [selectedEmployees, addUser, removeUser], + [selectedEmployees, addUser, removeUser, policy?.ownerAccountID, session?.accountID], ); /** Opens the member details page */ @@ -422,18 +423,26 @@ function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft, ); + useEffect(() => { + if (selectionMode?.isEnabled) { + return; + } + + setSelectedEmployees([]); + }, [setSelectedEmployees, selectionMode?.isEnabled]); + const getCustomListHeader = () => { const header = ( - {translate('common.member')} + {translate('common.member')} {translate('common.role')} ); - if (isPolicyAdmin) { + if (canSelectMultiple) { return header; } return {header}; @@ -497,7 +506,7 @@ function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft, } return ( - {selectedEmployees.length > 0 ? ( + {(shouldUseNarrowLayout ? canSelectMultiple : selectedEmployees.length > 0) ? ( shouldAlwaysShowDropdownMenu pressOnEnter @@ -523,17 +532,27 @@ function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft, ); }; + const selectionModeHeader = selectionMode?.isEnabled && shouldUseNarrowLayout; + return ( { + if (selectionMode?.isEnabled) { + setSelectedEmployees([]); + turnOffMobileSelectionMode(); + return; + } + Navigation.goBack(); + }} > {() => ( <> @@ -557,11 +576,13 @@ function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft, }} /> - item && toggleUser(item?.accountID)} shouldUseUserSkeletonView disableKeyboardShortcuts={removeMembersConfirmModalVisible} headerMessage={getHeaderMessage()} @@ -588,15 +609,4 @@ function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft, WorkspaceMembersPage.displayName = 'WorkspaceMembersPage'; -export default withCurrentUserPersonalDetails( - withPolicyAndFullscreenLoading( - withOnyx({ - invitedEmailsToAccountIDsDraft: { - key: ({route}) => `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`, - }, - session: { - key: ONYXKEYS.SESSION, - }, - })(WorkspaceMembersPage), - ), -); +export default withCurrentUserPersonalDetails(withPolicyAndFullscreenLoading(WorkspaceMembersPage)); diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx index 24a5f782a469..e54914fc6817 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx @@ -85,7 +85,8 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); const {canUseReportFieldsFeature, canUseWorkspaceFeeds} = usePermissions(); - const hasAccountingConnection = !!policy?.areConnectionsEnabled && !isEmptyObject(policy?.connections); + const hasAccountingConnection = !isEmptyObject(policy?.connections); + const isAccountingEnabled = !!policy?.areConnectionsEnabled || !isEmptyObject(policy?.connections); const isSyncTaxEnabled = !!policy?.connections?.quickbooksOnline?.config?.syncTax || !!policy?.connections?.xero?.config?.importTaxRates || @@ -225,7 +226,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro icon: Illustrations.Accounting, titleTranslationKey: 'workspace.moreFeatures.connections.title', subtitleTranslationKey: 'workspace.moreFeatures.connections.subtitle', - isActive: !!policy?.areConnectionsEnabled, + isActive: isAccountingEnabled, pendingAction: policy?.pendingFields?.areConnectionsEnabled, action: (isEnabled: boolean) => { if (hasAccountingConnection) { diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx index 277c77c53582..e46e7071333f 100644 --- a/src/pages/workspace/WorkspacePageWithSections.tsx +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -90,6 +90,9 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps & /** Whether the page is loading, example any other API call in progres */ isLoading?: boolean; + + /** Callback to be called when the back button is pressed */ + onBackButtonPress?: () => void; }; function fetchData(policyID: string, skipVBBACal?: boolean) { @@ -122,6 +125,7 @@ function WorkspacePageWithSections({ testID, shouldShowNotFoundPage = false, isLoading: isPageLoading = false, + onBackButtonPress, }: WorkspacePageWithSectionsProps) { const styles = useThemeStyles(); const policyID = route.params?.policyID ?? '-1'; @@ -181,8 +185,8 @@ function WorkspacePageWithSections({ (onBackButtonPress ? onBackButtonPress() : Navigation.goBack(backButtonRoute))} shouldShowBackButton={shouldUseNarrowLayout || shouldShowBackButton} - onBackButtonPress={() => Navigation.goBack(backButtonRoute)} icon={icon ?? undefined} style={styles.headerBarDesktopHeight} > diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 2f56c68acdd9..ec088589ce98 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -171,7 +171,7 @@ function WorkspacesListPage({policies, reimbursementAccount, reports, session}: if (!(isAdmin || isOwner)) { threeDotsMenuItems.push({ - icon: Expensicons.ChatBubbles, + icon: Expensicons.Exit, text: translate('common.leave'), onSelected: Session.checkIfActionIsAllowed(() => Policy.leaveWorkspace(item.policyID ?? '-1')), }); diff --git a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCustomFormIDPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCustomFormIDPage.tsx index d1a950032466..9a4635dd17bb 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCustomFormIDPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCustomFormIDPage.tsx @@ -72,6 +72,7 @@ function NetSuiteCustomFormIDPage({policy}: WithPolicyConnectionsProps) { connectionName={CONST.POLICY.CONNECTIONS.NAME.NETSUITE} shouldIncludeSafeAreaPaddingBottom shouldBeBlocked={!config?.customFormIDOptions?.enabled} + shouldUseScrollView={false} > { diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index 2732a928b6a4..d1d605759d36 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -41,7 +41,6 @@ function CategorySettingsPage({route, policyCategories, navigation}: CategorySet const [deleteCategoryConfirmModalVisible, setDeleteCategoryConfirmModalVisible] = useState(false); const backTo = route.params?.backTo; const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`); - const shouldDisablePayrollCode = !isControlPolicy(policy); const policyCategory = policyCategories?.[route.params.categoryName] ?? Object.values(policyCategories ?? {}).find((category) => category.previousCategoryName === route.params.categoryName); @@ -131,7 +130,7 @@ function CategorySettingsPage({route, policyCategories, navigation}: CategorySet @@ -162,7 +161,6 @@ function CategorySettingsPage({route, policyCategories, navigation}: CategorySet Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.getRoute(route.params.policyID, policyCategory.name)); }} shouldShowRightIcon - disabled={shouldDisablePayrollCode} /> {!isThereAnyAccountingConnection && ( diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index f8b539ca61db..d9179f7317b3 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -13,10 +13,10 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel'; import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem} from '@components/SelectionList/types'; +import SelectionListWithModal from '@components/SelectionListWithModal'; import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; @@ -26,6 +26,7 @@ import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import localeCompare from '@libs/LocaleCompare'; import Navigation from '@libs/Navigation/Navigation'; @@ -61,9 +62,12 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const backTo = route.params?.backTo; const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyId}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyId}`); + const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); const isConnectedToAccounting = Object.keys(policy?.connections ?? {}).length > 0; const currentConnectionName = PolicyUtils.getCurrentConnectionName(policy); + const canSelectMultiple = shouldUseNarrowLayout ? selectionMode?.isEnabled : true; + const fetchCategories = useCallback(() => { Category.openPolicyCategoriesPage(policyId); }, [policyId]); @@ -100,7 +104,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { [policyCategories, selectedCategories, translate], ); - const toggleCategory = (category: PolicyOption) => { + const toggleCategory = useCallback((category: PolicyOption) => { setSelectedCategories((prev) => { if (prev[category.keyForList]) { const {[category.keyForList]: omittedCategory, ...newCategories} = prev; @@ -108,7 +112,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { } return {...prev, [category.keyForList]: true}; }); - }; + }, []); const toggleAllCategories = () => { const availableCategories = categoryList.filter((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); @@ -117,7 +121,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }; const getCustomListHeader = () => ( - + {translate('common.name')} {translate('statusPage.status')} @@ -163,7 +167,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const options: Array>> = []; const isThereAnyAccountingConnection = Object.keys(policy?.connections ?? {}).length !== 0; - if (selectedCategoriesArray.length > 0) { + if (shouldUseNarrowLayout ? canSelectMultiple : selectedCategoriesArray.length > 0) { if (!isThereAnyAccountingConnection) { options.push({ icon: Expensicons.Trashcan, @@ -257,6 +261,14 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const isLoading = !isOffline && policyCategories === undefined; + useEffect(() => { + if (selectionMode?.isEnabled) { + return; + } + + setSelectedCategories({}); + }, [setSelectedCategories, selectionMode?.isEnabled]); + const hasVisibleCategories = categoryList.some((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); const getHeaderText = () => ( @@ -278,6 +290,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { ); + const selectionModeHeader = selectionMode?.isEnabled && shouldUseNarrowLayout; + return ( Navigation.goBack(backTo)} + title={selectionModeHeader ? translate('common.selectMultiple') : translate('workspace.common.categories')} + icon={!selectionModeHeader ? Illustrations.FolderOpen : undefined} + onBackButtonPress={() => { + if (selectionMode?.isEnabled) { + setSelectedCategories({}); + turnOffMobileSelectionMode(); + return; + } + Navigation.goBack(backTo); + }} > {!shouldUseNarrowLayout && getHeaderButtons()} @@ -331,8 +352,10 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { /> )} {hasVisibleCategories && !isLoading && ( - item && toggleCategory(item)} sections={[{data: categoryList, isDisabled: false}]} onCheckboxPress={toggleCategory} onSelectRow={navigateToCategorySettings} diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index c11a7473e3ab..6c0c282d0c24 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -2,8 +2,7 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import type {StackScreenProps} from '@react-navigation/stack'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import type {DropdownOption, WorkspaceDistanceRatesBulkActionType} from '@components/ButtonWithDropdownMenu/types'; import ConfirmModal from '@components/ConfirmModal'; @@ -11,16 +10,17 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel'; import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem} from '@components/SelectionList/types'; +import SelectionListWithModal from '@components/SelectionListWithModal'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; @@ -32,19 +32,17 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; import type {CustomUnit, Rate} from '@src/types/onyx/Policy'; type RateForList = ListItem & {value: string}; -type PolicyDistanceRatesPageOnyxProps = { - /** Policy details */ - policy: OnyxEntry; -}; +type PolicyDistanceRatesPageProps = StackScreenProps; -type PolicyDistanceRatesPageProps = PolicyDistanceRatesPageOnyxProps & StackScreenProps; - -function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps) { +function PolicyDistanceRatesPage({ + route: { + params: {policyID}, + }, +}: PolicyDistanceRatesPageProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); const theme = useTheme(); @@ -52,8 +50,11 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps) const [selectedDistanceRates, setSelectedDistanceRates] = useState([]); const [isWarningModalVisible, setIsWarningModalVisible] = useState(false); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); - const policyID = route.params.policyID; const isFocused = useIsFocused(); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); + + const canSelectMultiple = shouldUseNarrowLayout ? selectionMode?.isEnabled : true; const customUnit: CustomUnit | undefined = useMemo( () => (policy?.customUnits !== undefined ? policy?.customUnits[Object.keys(policy?.customUnits)[0]] : undefined), @@ -191,7 +192,7 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps) }; const getCustomListHeader = () => ( - + {translate('workspace.distanceRates.rate')} {translate('statusPage.status')} @@ -234,7 +235,7 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps) const headerButtons = ( - {selectedDistanceRates.length === 0 ? ( + {(shouldUseNarrowLayout ? !selectionMode?.isEnabled : selectedDistanceRates.length === 0) ? ( <>