diff --git a/.github/scripts/createHelpRedirects.sh b/.github/scripts/createHelpRedirects.sh
index 4ce3d0753f58..1425939ff3ec 100755
--- a/.github/scripts/createHelpRedirects.sh
+++ b/.github/scripts/createHelpRedirects.sh
@@ -27,32 +27,6 @@ function checkCloudflareResult {
declare -a ITEMS_TO_ADD
while read -r line; do
- # Split each line of the file into a source and destination so we can sanity check
- # and compare against the current list.
- read -r -a LINE_PARTS < <(echo "$line" | tr ',' ' ')
- SOURCE_URL=${LINE_PARTS[0]}
- DEST_URL=${LINE_PARTS[1]}
-
- # Make sure the format of the line is as execpted.
- if [[ "${#LINE_PARTS[@]}" -gt 2 ]]; then
- error "Found a line with more than one comma: $line"
- exit 1
- fi
-
- # Basic sanity checking to make sure that the source and destination are in expected
- # subdomains.
- if ! [[ $SOURCE_URL =~ ^https://(community|help)\.expensify\.com ]]; then
- error "Found source URL that is not a communityDot or helpDot URL: $SOURCE_URL"
- exit 1
- fi
-
- if ! [[ $DEST_URL =~ ^https://(help|use|integrations)\.expensify\.com|^https://www\.expensify\.org ]]; then
- error "Found destination URL that is not a supported URL: $DEST_URL"
- exit 1
- fi
-
- info "Source: $SOURCE_URL and destination: $DEST_URL appear to be formatted correctly."
-
ITEMS_TO_ADD+=("$line")
# This line skips the first line in the csv because the first line is a header row.
@@ -83,6 +57,9 @@ done | jq -n '. |= [inputs]')
info "Adding redirects for $PUT_JSON"
+# Dump $PUT_JSON into a file otherwise the curl request below will fail with too many arguments
+echo "$PUT_JSON" > redirects.json
+
# We use PUT here instead of POST so that we replace the entire list in place. This has many benefits:
# 1. We don't have to check if items are already in the list, allowing this script to run faster
# 2. We can support deleting redirects this way by simply removing them from the list
@@ -93,7 +70,7 @@ info "Adding redirects for $PUT_JSON"
PUT_RESULT=$(curl -s --request PUT --url "https://api.cloudflare.com/client/v4/accounts/$ZONE_ID/rules/lists/$LIST_ID/items" \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $CLOUDFLARE_LIST_TOKEN" \
- --data "$PUT_JSON")
+ --data-binary @redirects.json)
checkCloudflareResult "$PUT_RESULT"
OPERATION_ID=$(echo "$PUT_RESULT" | jq -r .result.operation_id)
diff --git a/.github/scripts/verifyRedirect.sh b/.github/scripts/verifyRedirect.sh
index b8942cd5b23d..3d96ba17a799 100755
--- a/.github/scripts/verifyRedirect.sh
+++ b/.github/scripts/verifyRedirect.sh
@@ -3,7 +3,10 @@
# HelpDot - Verifies that redirects.csv does not have any duplicates
# Duplicate sourceURLs break redirection on cloudflare pages
+source scripts/shellUtils.sh
+
declare -r REDIRECTS_FILE="docs/redirects.csv"
+declare -a ITEMS_TO_ADD
declare -r RED='\033[0;31m'
declare -r GREEN='\033[0;32m'
@@ -22,5 +25,44 @@ if [[ DETECT_CYCLE_EXIT_CODE -eq 1 ]]; then
exit 1
fi
+while read -r line; do
+ # Split each line of the file into a source and destination so we can sanity check
+ # and compare against the current list.
+ read -r -a LINE_PARTS < <(echo "$line" | tr ',' ' ')
+ SOURCE_URL=${LINE_PARTS[0]}
+ DEST_URL=${LINE_PARTS[1]}
+
+ # Make sure the format of the line is as expected.
+ if [[ "${#LINE_PARTS[@]}" -gt 2 ]]; then
+ error "Found a line with more than one comma: $line"
+ exit 1
+ fi
+
+ # Basic sanity checking to make sure that the source and destination are in expected
+ # subdomains.
+ if ! [[ $SOURCE_URL =~ ^https://(community|help)\.expensify\.com ]] || [[ $SOURCE_URL =~ \# ]]; then
+ error "Found source URL that is not a communityDot or helpDot URL, or contains a '#': $SOURCE_URL"
+ exit 1
+fi
+
+ if ! [[ $DEST_URL =~ ^https://(help|use|integrations)\.expensify\.com|^https://www\.expensify\.org ]]; then
+ error "Found destination URL that is not a supported URL: $DEST_URL"
+ exit 1
+ fi
+
+ info "Source: $SOURCE_URL and destination: $DEST_URL appear to be formatted correctly."
+
+ ITEMS_TO_ADD+=("$line")
+
+# This line skips the first line in the csv because the first line is a header row.
+done <<< "$(tail +2 $REDIRECTS_FILE)"
+
+# Sanity check that we should actually be running this and we aren't about to delete
+# every single redirect.
+if [[ "${#ITEMS_TO_ADD[@]}" -lt 1 ]]; then
+ error "No items found to add, why are we running?"
+ exit 1
+fi
+
echo -e "${GREEN}The redirects.csv is valid!${NC}"
exit 0
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 6b3e2f51b55d..e407b3324a88 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 1009002300
- versionName "9.0.23-0"
+ versionCode 1009002400
+ versionName "9.0.24-0"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/assets/images/feed.svg b/assets/images/feed.svg
new file mode 100644
index 000000000000..2fd03eeadd00
--- /dev/null
+++ b/assets/images/feed.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/simple-illustrations/simple-illustration__rules.svg b/assets/images/simple-illustrations/simple-illustration__rules.svg
new file mode 100644
index 000000000000..6432f26d9ac6
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__rules.svg
@@ -0,0 +1,10 @@
+
diff --git a/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md b/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md
index 18b8a9ec31f9..d3dcda91ffcc 100644
--- a/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md
+++ b/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md
@@ -2,87 +2,101 @@
title: Configure Quickbooks Desktop
description: Configure Quickbooks Desktop
---
-# How to configure export settings for QuickBooks Desktop
-To Configure Settings, go to **Settings** > **Policies** > **Group** > _[Policy Name]_ > **Connections** and click **Configure**. Click on the Export tab.
+Our new QuickBooks Desktop integration allows you to automate the import and export process with Expensify.
-## Preferred Exporter
-This person is used in QuickBooks Desktop as the export user. They will also receive notifications for errors.
+# Step 1: Configure export settings
+The following steps will determine how data will be exported from Expensify to QuickBooks Desktop.
-## Date
-Choose either the report's submitted date, the report's exported date, or the date of the last expense on the report when exporting reports to QuickBooks Desktop.
+1. In Expensify, hover over **Settings** and click **Workspaces**.
+2. Select the Workspace you want to connect to QuickBooks Desktop.
+3. Click the **Connections** tab.
+4. Click **Export** under the QuickBooks Desktop connection.
+5. Review each of the following export settings:
+- **Preferred Exporter**: This person is used in QuickBooks Desktop as the export user. They will receive notifications for errors, as well as prompts to export reports via the Home page of their Expensify account.
+- **Date**: You can choose either the report’s submitted date, the report’s exported date, or the date of the last expense on the report when exporting reports to QuickBooks Desktop.
+- **Unique reference numbers**: Enable this to allow the use of a unique reference number for each transaction. Disable this to use the same Report ID for all expenses from a certain report.
+- **Reimbursable expenses**: Reimbursable options include:
+ - **Vendor Bill (recommended)**: A single itemized vendor bill for each Expensify report. An A/P account is required to export to a vendor bill.
+ - **Check**: A single itemized check for each Expensify report.
+ - **Journal Entry**: A single itemized journal entry for each Expensify report.
+- **Non-reimbursable expenses**: Non-reimbursable options include:
+ - **Vendor Bill**: Each Expensify report results in a single itemized vendor bill. The bill is associated with the “vendor,” which is the individual responsible for creating or submitting the report in Expensify.
+ - **Credit Card expenses**: Each expense appears as a separate credit card transaction with a post date that matches your credit card statement. If you centrally manage company cards through your domain, you can export expenses from each card to a specific QuickBooks account by clicking Edit Exports next to each user’s card. To display the merchant name in the payee field in QuickBooks Desktop, ensure that a matching Vendor exists in QuickBooks. Expensify searches for an exact match during export. If no match is found, the payee is mapped to a Credit Card Misc. Vendor created by Expensify.
+ - **Debit Card expenses**: Expenses are exported as individual itemized checks for each Expensify report. The check is written to the “vendor,” which is the person who created or submitted the report in Expensify.
-## Use unique reference numbers
-Enable this to allow use of a unique reference number for each transaction. Disable this to use the same Report ID for all expenses from a certain report.
+# Step 2: Configure coding/import settings
-## Reimbursable expenses
-* **Vendor Bill (recommended):** A single itemized vendor bill for each Expensify report. An A/P account is required to export to a vendor bill.
-* **Check:** A single itemized check for each Expensify report.
-* **Journal Entry:** A single itemized journal entry for each Expensify report.
- * When exporting as journal entries to an Accounts Payable, this requires a vendor record, not an employee. The vendor record must have the email address of the report creator/submitter.
- * If the report creator/submitter also has an employee record, you need to remove the email, because Expensify will try to export to the employee record first for journal entries.
+The following steps help you determine how data will be imported from QuickBooks Online to Expensify:
-**Note on negative expenses:** In general, you can export negative expenses successfully to QuickBooks Desktop regardless of which export option you choose. The one thing to keep in mind is that if you have Check selected as your export option, the total of the report can not be negative.
+1. Click Import under the QuickBooks Online connection.
+2. Review each of the following import settings:
+- **Chart of Accounts**: The Chart of Accounts is automatically imported from QuickBooks Desktop as categories. This cannot be amended.
+- **Classes**: Choose whether to import classes, which will be shown in Expensify as tags for expense-level coding.
+- **Customers/Projects**: Choose whether to import customers/projects, which will be shown in Expensify as tags for expense-level coding.
+- **Locations**: Choose whether to import locations, which will be shown in Expensify as tags for expense-level coding.
-**Note on exporting to Employee Records:** If you want to export reports to your users' Employee Records instead of their Vendor Records, you will need to select Check or Journal Entry for your reimbursable export option. There isn't a way to export as a Vendor Bill to an Employee Record. If you are setting up Expensify users as employees, you will need to activate QuickBooks Desktop Payroll to view the Employee Profile tab where submitter's email addresses need to be entered.
+# Step 3: Configure advanced settings
+The following steps help you determine the advanced settings for your connection, like auto-sync and employee invitation settings.
-## Non-reimbursable expenses
-**Credit Card Expenses:**
-* Each expense will appear as a separate credit card transaction.
-* The posting date will match your credit card statement.
-* To display the merchant name in the payee field in QuickBooks Desktop, ensure that a matching Vendor exists in QuickBooks. Expensify searches for an exact match during export. If no match is found, the payee is mapped to a **Credit Card Misc.** Vendor created by Expensify.
-* If you're centrally managing company cards through Domain Control, you can export expenses from each card to a specific QuickBooks account (detailed instructions available).
-
-**Debit Card Expenses:**
-* Expenses export as individual itemized checks for each Expensify report.
-* The check is written to the "vendor," which is the person who created or submitted the report in Expensify.
+1. Click **Advanced** under the QuickBooks Desktop connection.
+2. **Enable or disable Auto-Sync**: If enabled, QuickBooks Desktop automatically communicates changes with Expensify to ensure that the data shared between the two systems is up to date. New report approvals/reimbursements will be synced during the next auto-sync period.
-**Vendor Bill:**
-* Each Expensify report results in a single itemized vendor bill.
-* The bill is associated with the "vendor," which is the individual responsible for creating or submitting the report in Expensify.
+# FAQ
-# How to configure coding for QuickBooks Desktop
-To Configure Settings, go to **Settings** > **Policies** > **Group** > _[Policy Name]_ > **Connections** and click **Configure**. Click on the Coding tab.
+## **How do I manually sync my QuickBooks Desktop if I have Auto-Sync disabled?**
-## Categories
-Expensify's integration with QuickBooks brings in your Chart of Accounts as Categories in Expensify automatically. Here's how to manage them:
-1. After connecting, go to **Settings** > **Policies** > **Group** > _[Policy Name]_ > **Categories** to view the accounts imported from QuickBooks Desktop.
-2. You can use the enable/disable button to choose which Categories your employees can access. Additionally, you can set specific rules for each Category via the blue settings cog.
-3. Expensify offers Auto-Categorization to automatically assign expenses to the appropriate expense categories.
-4. If needed, you can edit the names of the imported Categories to simplify expense coding for your employees. Keep in mind that if you make changes to these accounts in QuickBooks Desktop, the category names in Expensify will update to match them during the next sync.
-5. _**Important:**_ Each expense must have a category selected to export to QuickBooks Desktop. The selected category must be one imported from QuickBooks Desktop; you cannot manually create categories within Expensify policy settings.
+To manually sync your connection:
-## Classes
-Classes can be imported from QuickBooks as either tags (line-item level) or report fields (header level).
+1. In Expensify, hover over **Settings** and select **Workspaces**.
+2. Click the Workspace name that is connected to QuickBooks Desktop.
+3. Click the **Connections** tab on the left.
+4. Click **Sync Now** under QuickBooks Desktop.
-## Customers/Projects
-You can bring in Customers/Projects from QuickBooks into Expensify in two ways: as tags (at the line-item level) or as report fields (at the header level). If you're utilizing Billable Expenses in Expensify, here's what you need to know:
-* Customers/Projects must be enabled if you're using Billable Expenses.
-* Expenses marked as "Billable" need to be tagged with a Customer/Project to successfully export them to QuickBooks.
+{% include info.html %}
+For manual syncing, we recommend completing this process at least once a week and/or after making changes in QuickBooks Desktop that could impact how reports export from Expensify. Changes may include adjustments to your chart of accounts, vendors, employees, customers/jobs, or items. Remember: Both the Web Connector and QuickBooks Desktop need to be running for syncing or exporting to work.
+{% include end-info.html %}
-## Items
-Items can be imported from QuickBooks as categories alongside your expense accounts.
+## **Can I sync Expensify and QuickBooks Desktop (QBD) and use the platforms at the same time?**
-{% include faq-begin.md %}
-## How do I sync my connection?
-1. Ensure that both the Expensify Sync Manager and QuickBooks Desktop are running.
-2. On the Expensify website, navigate to **Settings** > **Policies** > **Group** > _[Policy Name]_ > **Connections** > **QuickBooks Desktop**, and click **Sync now**.
-3. Wait for the syncing process to finish. Typically, this takes about 2-5 minutes, but it might take longer, depending on when you last synced and the size of your QuickBooks company file. The page will refresh automatically once syncing is complete.
+When syncing Expensify to QuickBooks Desktop, we recommend waiting until the sync finishes to access either Expensify and/or QuickBooks Desktop, as performance may vary during this process. You cannot open an instance of QuickBooks Desktop while a program is syncing - this may cause QuickBooks Desktop to behave unexpectedly.
-We recommend syncing at least once a week or whenever you make changes in QuickBooks Desktop that could impact how your reports export from Expensify. Changes could include adjustments to your Chart of Accounts, Vendors, Employees, Customers/Jobs, or Items. Remember, both the Sync Manager and QuickBooks Desktop need to be running for syncing or exporting to work.
+## **What are the different types of accounts that can be imported from Quickbooks Desktop?**
-## How do I export reports?
-The Sync Manager and QuickBooks Desktop both need to be running in order to sync or export.
-* **Exporting an Individual Report:** You can export reports to QuickBooks Desktop one at a time from within an individual report on the Expensify website by clicking the "Export to" button.
-* **Exporting Reports in Bulk:** To export multiple reports at a time, select the reports that you'd like to export from the Reports page on the website and click the "Export to" button near the top of the page.
+Here is the list of accounts from QuickBooks Desktop and how they are pulled into Expensify:
-Once reports have been exported to QuickBooks Desktop successfully, you will see a green QuickBooks icon next to each report on the Reports page. You can check to see when a report was exported in the Comments section of the individual report.
+| QBD account type | How it imports to Expensify |
+| ------------- | ------------- |
+| Accounts payable | Vendor bill or journal entry export options |
+| Accounts receivable | Do not import |
+| Accumulated adjustment | Do not import |
+| Bank | Debit card or check export options |
+| Credit card | Credit card export options |
+| Equity | Do not import |
+| Fixed assets | Categories |
+| Income | Do not import |
+| Long-term liabilities | Do not import |
+| Other assets | Do not import |
+| Other current assets | Categories or journal entry export options |
+| Other current liabilities | Journal Entry export options if the report creator is set up as an Employee within QuickBooks |
+| Other expense | All detail types except Exchange Gain or Loss import as categories; Exchange Gain or Loss does not import |
+| Other income | Do not import |
-## Can I export negative expenses?
-Generally, you can export negative expenses to QuickBooks Desktop successfully, regardless of your option. However, please keep in mind that if you have *Check* selected as your export option, the report's total cannot be negative.
+## **Why are exports showing as “Credit Card Misc.”?**
+
+When exporting as credit or debit card expenses, Expensify checks for an exact vendor match. If none are found, the payee will be mapped to a vendor that Expensify will automatically create and label as Credit Card Misc. or Debit Card Misc.
+
+If you centrally manage your company cards through domains, you can export expenses from each card to a specific account in QuickBooks:
+
+1. In Expensify, hover over Settings and click Domains.
+2. Select the desired domain.
+3. Click the **Company Cards** tab.
+4. Click **Export**.
+
+## **How does multi-currency work with QuickBooks Desktop?**
-## How does multi-currency work with QuickBooks Desktop?
When using QuickBooks Desktop Multi-Currency, there are some limitations to consider based on your export options:
-1. **Vendor Bills and Checks:** The currency of the vendor and the currency of the account must match, but they do not have to be in the home currency.
-2. **Credit Card:** If an expense doesn't match an existing vendor in QuickBooks, it exports to the **Credit Card Misc.** vendor created by Expensify. When exporting a report in a currency other than your home currency, the transaction will be created under the vendor's currency with a 1:1 conversion. For example, a transaction in Expensify for $50 CAD will appear in QuickBooks as $50 USD.
-3. **Journal Entries:** Multi-currency exports will fail because the account currency must match both the vendor currency and the home currency.
+
+- **Vendor Bills and Checks**: The currency of the vendor and the currency of the account must match, but they do not have to be in the home currency.
+- **Credit Card**: If an expense doesn’t match an existing vendor in QuickBooks, it exports to the Credit Card Misc. vendor created by Expensify. When exporting a report in a currency other than your home currency, the transaction will be created under the vendor’s currency with a 1:1 conversion. For example, a transaction in Expensify for $50 CAD will appear in QuickBooks as $50 USD.
+- **Journal Entries**: Multi-currency exports will fail because the account currency must match both the vendor currency and the home currency.
diff --git a/docs/articles/expensify-classic/connections/quickbooks-desktop/Quickbooks-Desktop-Troubleshooting.md b/docs/articles/expensify-classic/connections/quickbooks-desktop/Quickbooks-Desktop-Troubleshooting.md
index 061b01b7a924..09afd2e4e7f2 100644
--- a/docs/articles/expensify-classic/connections/quickbooks-desktop/Quickbooks-Desktop-Troubleshooting.md
+++ b/docs/articles/expensify-classic/connections/quickbooks-desktop/Quickbooks-Desktop-Troubleshooting.md
@@ -3,43 +3,89 @@ title: Quickbooks Desktop Troubleshooting
description: Quickbooks Desktop Troubleshooting
---
-# Sync and export errors
-## Error: No Vendor Found For Email in QuickBooks
-To address this issue, ensure that each submitter's email is saved as the **Main Email** in their Vendor record within QuickBooks Desktop. Here's how to resolve it:
-1. Go to your Vendor section in QuickBooks.
-2. Verify that the email mentioned in the error matches the **Main Email** field in the respective vendor's record. It's important to note that this comparison is case-sensitive, so ensure that capitalization matches as well.
-3. If you prefer to export reports to your users' employee records instead of their vendor records, select either **Check** or **Journal Entry** as your reimbursable export option. If you are setting up Expensify users as employees, activate QuickBooks Desktop Payroll to access the Employee Profile tab where submitter email addresses need to be entered.
-4. Once you've added the correct email to the vendor record, save this change, and then sync your policy before attempting to export the report again.
-
-## Error: Do Not Have Permission to Access Company Data File
-To resolve this error, follow these steps:
-1. Log into QuickBooks Desktop as an Admin in single-user mode.
-2. Go to **Edit** > **Preferences** > **Integrated Applications** > **Company Preferences**.
-3. Select the Expensify Sync Manager and click on **Properties**.
-4. Ensure that **Allow this application to login automatically** is checked, and then click **OK**. Close all windows within QuickBooks.
-5. If you still encounter the error after following the above steps, go to **Edit** > **Preferences** > **Integrated Applications** > **Company Preferences**, and remove the Expensify Sync Manager from the list.
-6. Next, attempt to sync your policy again in Expensify. You'll be prompted to re-authorize the connection in QuickBooks.
-7. Click **Yes, always; allow access even if QuickBooks is not running.**
-8. From the dropdown, select the Admin user, and then click **Continue**. Note that selecting **Admin** here doesn't mean you always have to be logged in as an admin to use the connection; it's just required for setting up the connection.
-9. Click **Done** on the pop-up window and return to Expensify, where your policy should complete the syncing process.
-
-## Error: The Wrong QuickBooks Company is Open.
-This error suggests that the wrong company file is open in QuickBooks Desktop. To resolve this issue, follow these steps:
-1. First, go through the general troubleshooting steps as outlined.
-2. If you can confirm that the incorrect company file is open in QuickBooks, go to QuickBooks and select **File** > **Open or Restore Company** > _[Company Name]_ to open the correct company file. After doing this, try syncing your policy again.
-3. If the correct company file is open, but you're still encountering the error, completely close QuickBooks Desktop, reopen the desired company file and then attempt to sync again.
-4. If the error persists, log into QuickBooks as an admin in single-user mode. Then, go to **Edit** > **Preferences** > **Integrated Applications** > **Company Preferences** and remove the Expensify Sync Manager from the list.
-5. Next, try syncing your policy again in Expensify. You'll be prompted to re-authorize the connection in QuickBooks, allowing you to sync successfully.
-6. If the error continues even after trying the steps above, double-check that the token you see in the Sync Manager matches the token in your connection settings.
-
-## Error: The Expensify Sync Manager Could Not Be Reached.
-To resolve this error, follow these steps:
-*Note: You must be in single-user mode to sync.*
-
-1. Ensure that both the Sync Manager and QuickBooks Desktop are running.
-2. Confirm that the Sync Manager is installed in the correct location. It should be in the same location as your QuickBooks application. If QuickBooks is on your local desktop, the Sync Manager should be there, too. If QuickBooks is on a remote server, install the Sync Manager there.
-Verify that the Sync Manager's status is **Connected**.
-3. If the Sync Manager status is already **Connected**, click **Edit** and then *Save* to refresh the connection. Afterwards, try syncing your policy again.
-4. If the error persists, double-check that the token you see in the Sync Manager matches the token in your connection settings.
-
-{% include faq-end.md %}
+# The Web Connector cannot be reached
+
+Generally, these errors indicate that there is a connection issue, where there’s a breakdown between Expensify and QuickBooks.
+
+## How to resolve
+
+1. Make sure that the Web Connector and QuickBooks Desktop are both running.
+2. Make sure that the Web Connector is installed in the same location as your QuickBooks application. For example, if QuickBooks is installed on your local desktop, the Web Connector should be too. Or if QuickBooks is installed on a remote server, the Web Connector should be installed there as well.
+
+If the error persists:
+
+1. Close the Web Connector completely (you may want to use Task Manager to do this).
+2. Right-click the Web Connector icon on your desktop and select **Run as administrator**.
+3. Sync your Workspace again.
+
+If this doesn’t work, the final troubleshooting steps should be:
+
+1. Quit QuickBooks Desktop, then reopen it.
+2. In Expensify, hover over **Settings** and select **Workspaces**.
+3. Click the workspace name that is connected to QuickBooks Desktop.
+4. Click the **Connections** tab on the left.
+5. Click **QuickBooks Desktop**.
+6. Click **Sync Now**.
+7. If this still doesn’t resolve the issue, use the link to reinstall the Web Connector.
+
+# Connection and/or authentication issue
+
+Generally, these errors indicate that there is a credentials issue.
+
+## How to resolve
+
+1. Make sure QuickBooks Desktop is open with the correct company file. This must be the same company file that you have connected to Expensify.
+2. Make sure the QuickBooks Web Connector is open and the connector is online.
+3. Make sure that there are no dialogue boxes open in QuickBooks that are interfering with attempts to sync or export. To resolve this, close any open windows in QuickBooks Desktop so that you only see a gray screen, then try exporting or syncing again.
+4. Check that you have the correct permissions.
+5. Log in to QuickBooks Desktop as an Admin (in single-user mode).
+6. Go to **Edit** > **Preferences** > **Integrated Applications** > **Company Preferences**.
+7. Select the Web Connector and click **Properties**.
+8. Make sure that the "Allow this application to login automatically" checkbox is selected and click **OK**.
+9. Close all windows in QuickBooks.
+
+If these general troubleshooting steps don’t work, reach out to Concierge and have the following information ready to provide:
+
+1. What version of QuickBooks Desktop do you have (Enterprise 2016, Pro 2014, etc.)?
+2. Is your QuickBooks program installed on your computer or a remote network/drive?
+3. Is your QuickBooks company file installed on your computer or a remote network/drive?
+4. Is your Web Connector installed on your computer or a remote network/drive?
+5. If any of the above are on a remote option, is there a company that runs that remote environment? If so, who (ie: RightNetworks, SwissNet, Cloud9, etc.)?
+
+# Import issue or missing categories and/or tags
+
+Generally, if you are having issues importing data from QuickBooks to Expensify, this indicates that the integration needs to be updated or your version of QuickBooks may not support a specific configuration.
+
+## How to resolve
+
+1. Re-sync the connection between Expensify and QuickBooks Desktop. A fresh sync can often resolve any issues, especially if you have recently updated your chart of accounts or projects, customers, or jobs in QuickBooks Desktop.
+2. Check your configuration in QuickBooks Desktop. Expensify will import the chart of accounts to be utilized either as categories or export account options, while projects, customers, and tags will be imported as tags.
+
+If these general troubleshooting steps don’t work, reach out to Concierge with context on what is specifically missing in Expensify, as well as screenshots from your QuickBooks Desktop setup.
+
+# Export or "can't find category/class/location/account" issue
+
+Generally, when an export error occurs, we’ll share the reason in the Report Comments section at the bottom of the report. This will give you an indication of how to resolve the error.
+
+## How to resolve
+
+1. Re-sync the connection between Expensify and QuickBooks Desktop. A fresh sync can often resolve any issues, especially if you have recently updated your chart of accounts or projects, customers, or jobs in QuickBooks Desktop.
+2. Re-apply coding to expenses and re-export the report. If you’ve recently synced Expensify and QuickBooks or recently made changes to your Workspace category or tags settings, you may need to re-apply coding to expenses.
+3. Make sure that your current version of QuickBooks Desktop supports the selected export option. Different versions of QuickBooks Desktop support different export options and the [version that you own](https://quickbooks.intuit.com/desktop/) may not be compatible with the export type.
+
+If these general troubleshooting steps don’t work, reach out to Concierge with the Report ID, some context on what you’re trying to do, and a screenshot of the Expensify error message.
+
+# “Oops!” error when syncing or exporting
+
+Generally, an “Oops!” error can often be temporary or a false error. Although you will see a message pop up, there may actually not be an actual issue.
+
+## How to resolve
+
+1. Check to see if the sync or export was successful.
+2. If it wasn't, please attempt to sync or export the connection again.
+
+If the problem persists, download the QuickBooks Desktop log file via the Web Connector (click View Logs to download them) and reach out to Concierge for further assistance.
+
+{% include info.html %}
+If you’re using a remote server (e.g. RightNetworks), you may need to contact that support team to request your logs.
+{% include end-info.html %}
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 532bd4ca1752..a1a8346b4789 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -477,7 +477,6 @@ https://community.expensify.com/discussion/5321/how-to-set-up-saml-authenticatio
https://community.expensify.com/discussion/5499/deep-dive-configure-coding-for-sage-intacct,https://help.expensify.com/articles/expensify-classic/connections/sage-intacct/Configure-Sage-Intacct
https://community.expensify.com/discussion/5580/deep-dive-configure-advanced-settings-for-netsuite,https://help.expensify.com/articles/new-expensify/connections/netsuite/Connect-to-NetSuite#step-3-configure-advanced-settings
https://community.expensify.com/discussion/5632/deep-dive-configure-coding-for-netsuite,https://help.expensify.com/articles/new-expensify/connections/netsuite/Connect-to-NetSuite#configure-netsuite-integration
-https://community.expensify.com/discussion/5632/deep-dive-configure-coding-for-netsuite#tax,https://help.expensify.com/articles/new-expensify/connections/netsuite/Connect-to-NetSuite#configure-netsuite-integration
https://community.expensify.com/discussion/5649/deep-dive-configure-advanced-settings-for-quickbooks-online,https://help.expensify.com/articles/expensify-classic/connections/quickbooks-online/Configure-Quickbooks-Online
https://community.expensify.com/discussion/5654/deep-dive-using-expense-rules-to-vendor-match-when-exporting-to-an-accounting-package,https://help.expensify.com/articles/expensify-classic/expenses/Expense-Rules#how-can-i-use-expense-rules-to-vendor-match-when-exporting-to-an-accounting-package
https://community.expensify.com/discussion/5656/deep-dive-configure-coding-for-xero/,https://help.expensify.com/articles/new-expensify/connections/xero/Connect-to-Xero#step-2-configure-import-settings
@@ -490,7 +489,6 @@ https://community.expensify.com/discussion/5864/how-to-add-a-personal-bank-accou
https://community.expensify.com/discussion/5941/how-to-reimburse-overseas-employees-for-us-employers/,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements
https://community.expensify.com/discussion/6203/deep-dive-expensify-card-and-netsuite-auto-reconciliation-how-it-works,https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Reconciliation
https://community.expensify.com/discussion/6698/faq-troubleshooting-bank-and-card-errors,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting
-https://community.expensify.com/discussion/6698/faq-troubleshooting-bank-and-card-errors#account-type-not-supported,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting
https://community.expensify.com/discussion/6827/what-s-happening-to-my-expensify-bill,https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Overview
https://community.expensify.com/discussion/6898/deep-dive-guide-to-billing,https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Overview
https://community.expensify.com/discussion/7231/how-to-export-invoices-to-netsuite,https://help.expensify.com/articles/new-expensify/connections/netsuite/Connect-to-NetSuite#export-invoices-to
diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj
index ac9c1b318aad..bd301e478ada 100644
--- a/ios/NewExpensify.xcodeproj/project.pbxproj
+++ b/ios/NewExpensify.xcodeproj/project.pbxproj
@@ -1015,11 +1015,7 @@
"$(inherited)",
"-DRN_FABRIC_ENABLED",
);
- OTHER_LDFLAGS = (
- "$(inherited)",
- "-Wl",
- "-ld_classic",
- );
+ OTHER_LDFLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
@@ -1822,11 +1818,7 @@
"$(inherited)",
"-DRN_FABRIC_ENABLED",
);
- OTHER_LDFLAGS = (
- "$(inherited)",
- "-Wl",
- "-ld_classic",
- );
+ OTHER_LDFLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
@@ -1894,11 +1886,7 @@
"$(inherited)",
"-DRN_FABRIC_ENABLED",
);
- OTHER_LDFLAGS = (
- "$(inherited)",
- "-Wl",
- "-ld_classic",
- );
+ OTHER_LDFLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "";
PRODUCT_NAME = "";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
@@ -1976,11 +1964,7 @@
"$(inherited)",
"-DRN_FABRIC_ENABLED",
);
- OTHER_LDFLAGS = (
- "$(inherited)",
- "-Wl",
- "-ld_classic",
- );
+ OTHER_LDFLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
@@ -2125,11 +2109,7 @@
"$(inherited)",
"-DRN_FABRIC_ENABLED",
);
- OTHER_LDFLAGS = (
- "$(inherited)",
- "-Wl",
- "-ld_classic",
- );
+ OTHER_LDFLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
@@ -2266,11 +2246,7 @@
"$(inherited)",
"-DRN_FABRIC_ENABLED",
);
- OTHER_LDFLAGS = (
- "$(inherited)",
- "-Wl",
- "-ld_classic",
- );
+ OTHER_LDFLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "";
PRODUCT_NAME = "";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
@@ -2405,11 +2381,7 @@
"$(inherited)",
"-DRN_FABRIC_ENABLED",
);
- OTHER_LDFLAGS = (
- "$(inherited)",
- "-Wl",
- "-ld_classic",
- );
+ OTHER_LDFLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "";
PRODUCT_NAME = "";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index d67a06a0cad7..d601d4a5eb05 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 9.0.23
+ 9.0.24CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.23.0
+ 9.0.24.0FullStoryOrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index d808278550be..2faff3e32557 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 9.0.23
+ 9.0.24CFBundleSignature????CFBundleVersion
- 9.0.23.0
+ 9.0.24.0
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 7fb5a7c657c6..fce2a5696573 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName$(PRODUCT_NAME)CFBundleShortVersionString
- 9.0.23
+ 9.0.24CFBundleVersion
- 9.0.23.0
+ 9.0.24.0NSExtensionNSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 7112eb5e83a7..1a1a21aba7a3 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1891,7 +1891,7 @@ PODS:
- RNGoogleSignin (10.0.1):
- GoogleSignIn (~> 7.0)
- React-Core
- - RNLiveMarkdown (0.1.113):
+ - RNLiveMarkdown (0.1.117):
- glog
- hermes-engine
- RCT-Folly (= 2022.05.16.00)
@@ -1909,9 +1909,9 @@ PODS:
- React-utils
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- - RNLiveMarkdown/common (= 0.1.113)
+ - RNLiveMarkdown/common (= 0.1.117)
- Yoga
- - RNLiveMarkdown/common (0.1.113):
+ - RNLiveMarkdown/common (0.1.117):
- glog
- hermes-engine
- RCT-Folly (= 2022.05.16.00)
@@ -2634,7 +2634,7 @@ SPEC CHECKSUMS:
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: 74b7b3d06d667ba0bbf41da7718f2607ae0dfe8f
RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0
- RNLiveMarkdown: 235376cd828014e8bad6949ea5bb202688fa5bb0
+ RNLiveMarkdown: 54e6a7dfd3e92fdb1d2dab1b64ee8a56d56acd91
RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81
rnmapbox-maps: df8fe93dbd251f25022f4023d31bc04160d4d65c
RNPermissions: d2392b754e67bc14491f5b12588bef2864e783f3
diff --git a/package-lock.json b/package-lock.json
index 338308e8e564..e1ce1dde84dc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,19 +1,19 @@
{
"name": "new.expensify",
- "version": "9.0.23-0",
+ "version": "9.0.24-0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.23-0",
+ "version": "9.0.24-0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@dotlottie/react-player": "^1.6.3",
- "@expensify/react-native-live-markdown": "^0.1.113",
+ "@expensify/react-native-live-markdown": "0.1.117",
"@expo/metro-runtime": "~3.1.1",
"@formatjs/intl-datetimeformat": "^6.10.0",
"@formatjs/intl-listformat": "^7.2.2",
@@ -3952,9 +3952,9 @@
}
},
"node_modules/@expensify/react-native-live-markdown": {
- "version": "0.1.113",
- "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.113.tgz",
- "integrity": "sha512-MeZTqW1Dd2oUAVmedaU6p/TE+mmhWibSkcz8VC10PyCn6HkwO7ZykaSXMjsJHoUyvx9vYaG/4iF9enWXe4+h5w==",
+ "version": "0.1.117",
+ "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.117.tgz",
+ "integrity": "sha512-MMs8U7HRNilTc5PaCODpWL89/+fo61Np1tUBjVaiA4QQw2h5Qta8V5/YexUA4wG29M0N7gcGkxapVhfUoEB0vQ==",
"workspaces": [
"parser",
"example",
diff --git a/package.json b/package.json
index ce2ead203c24..8cca007e5295 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.23-0",
+ "version": "9.0.24-0",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -69,7 +69,7 @@
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@dotlottie/react-player": "^1.6.3",
- "@expensify/react-native-live-markdown": "^0.1.113",
+ "@expensify/react-native-live-markdown": "0.1.117",
"@expo/metro-runtime": "~3.1.1",
"@formatjs/intl-datetimeformat": "^6.10.0",
"@formatjs/intl-listformat": "^7.2.2",
diff --git a/src/CONST.ts b/src/CONST.ts
index b7c330439475..db66b7e16a23 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -86,6 +86,7 @@ const CONST = {
DEFAULT_TABLE_NAME: 'keyvaluepairs',
DEFAULT_ONYX_DUMP_FILE_NAME: 'onyx-state.txt',
DEFAULT_POLICY_ROOM_CHAT_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL],
+ DISABLED_MAX_EXPENSE_VALUE: 10000000000,
// Note: Group and Self-DM excluded as these are not tied to a Workspace
WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT],
@@ -382,6 +383,7 @@ const CONST = {
REPORT_FIELDS_FEATURE: 'reportFieldsFeature',
WORKSPACE_FEEDS: 'workspaceFeeds',
NETSUITE_USA_TAX: 'netsuiteUsaTax',
+ WORKSPACE_RULES: 'workspaceRules',
},
BUTTON_STATES: {
DEFAULT: 'default',
@@ -978,6 +980,7 @@ const CONST = {
OPEN_REPORT_THREAD: 'open_report_thread',
SIDEBAR_LOADED: 'sidebar_loaded',
LOAD_SEARCH_OPTIONS: 'load_search_options',
+ MESSAGE_SENT: 'message_sent',
COLD: 'cold',
WARM: 'warm',
REPORT_ACTION_ITEM_LAYOUT_DEBOUNCE_TIME: 1500,
@@ -2085,6 +2088,7 @@ const CONST = {
ARE_EXPENSIFY_CARDS_ENABLED: 'areExpensifyCardsEnabled',
ARE_INVOICES_ENABLED: 'areInvoicesEnabled',
ARE_TAXES_ENABLED: 'tax',
+ ARE_RULES_ENABLED: 'areRulesEnabled',
},
DEFAULT_CATEGORIES: [
'Advertising',
@@ -2427,6 +2431,7 @@ const CONST = {
WORKSPACE_BANK_ACCOUNT: 'WorkspaceBankAccount',
WORKSPACE_SETTINGS: 'WorkspaceSettings',
WORKSPACE_FEATURES: 'WorkspaceFeatures',
+ WORKSPACE_RULES: 'WorkspaceRules',
},
get EXPENSIFY_EMAILS() {
return [
@@ -4178,7 +4183,7 @@ const CONST = {
VIDEO_PLAYER: {
POPOVER_Y_OFFSET: -30,
- PLAYBACK_SPEEDS: [0.25, 0.5, 1, 1.5, 2],
+ PLAYBACK_SPEEDS: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
HIDE_TIME_TEXT_WIDTH: 250,
MIN_WIDTH: 170,
MIN_HEIGHT: 120,
@@ -5474,6 +5479,14 @@ const CONST = {
description: 'workspace.upgrade.taxCodes.description' as const,
icon: 'Coins',
},
+ rules: {
+ id: 'rules' as const,
+ alias: 'rules',
+ name: 'Rules',
+ title: 'workspace.upgrade.rules.title' as const,
+ description: 'workspace.upgrade.rules.description' as const,
+ icon: 'Rules',
+ },
};
},
REPORT_FIELD_TYPES: {
diff --git a/src/Expensify.tsx b/src/Expensify.tsx
index 620243440384..8a2ef4a2b2f4 100644
--- a/src/Expensify.tsx
+++ b/src/Expensify.tsx
@@ -256,6 +256,7 @@ function Expensify({
{shouldInit && (
<>
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index dd87e5a9996f..73271d85ea49 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -350,7 +350,7 @@ const ROUTES = {
},
ROOM_INVITE: {
route: 'r/:reportID/invite/:role?',
- getRoute: (reportID: string, role?: string) => `r/${reportID}/invite/${role}` as const,
+ getRoute: (reportID: string, role?: string) => `r/${reportID}/invite/${role ?? ''}` as const,
},
MONEY_REQUEST_HOLD_REASON: {
route: ':type/edit/reason/:transactionID?',
@@ -928,6 +928,10 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/expensify-card/settings/frequency',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card/settings/frequency` as const,
},
+ WORKSPACE_RULES: {
+ route: 'settings/workspaces/:policyID/rules',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules` as const,
+ },
WORKSPACE_DISTANCE_RATES: {
route: 'settings/workspaces/:policyID/distance-rates',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index cc4360d7695d..142b2f80a66e 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -441,6 +441,7 @@ const SCREENS = {
DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT: 'Distance_Rate_Tax_Reclaimable_On_Edit',
DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit',
UPGRADE: 'Workspace_Upgrade',
+ RULES: 'Policy_Rules',
},
EDIT_REQUEST: {
diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/index.native.tsx
index b801743732bc..a2fcae901681 100644
--- a/src/components/Composer/index.native.tsx
+++ b/src/components/Composer/index.native.tsx
@@ -1,11 +1,12 @@
import type {MarkdownStyle} from '@expensify/react-native-live-markdown';
import type {ForwardedRef} from 'react';
-import React, {useCallback, useMemo, useRef} from 'react';
+import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import type {NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputPasteEventData} from 'react-native';
import {StyleSheet} from 'react-native';
import type {FileObject} from '@components/AttachmentModal';
import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput';
import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useMarkdownStyle from '@hooks/useMarkdownStyle';
import useResetComposerFocus from '@hooks/useResetComposerFocus';
import useStyleUtils from '@hooks/useStyleUtils';
@@ -46,6 +47,15 @@ function Composer(
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
+ const {inputCallbackRef, inputRef: autoFocusInputRef} = useAutoFocusInput();
+
+ useEffect(() => {
+ if (autoFocus === !!autoFocusInputRef.current) {
+ return;
+ }
+ inputCallbackRef(autoFocus ? textInput.current : null);
+ }, [autoFocus, inputCallbackRef, autoFocusInputRef]);
+
/**
* Set the TextInput Ref
* @param {Element} el
@@ -57,6 +67,10 @@ function Composer(
return;
}
+ if (autoFocus) {
+ inputCallbackRef(el);
+ }
+
// This callback prop is used by the parent component using the constructor to
// get a ref to the inner textInput element e.g. if we do
// this.textInput = el} /> this will not
diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx
index 3db92a2122b3..7a4512ad5aea 100755
--- a/src/components/Composer/index.tsx
+++ b/src/components/Composer/index.tsx
@@ -292,7 +292,7 @@ function Composer(
return;
}
- const currentText = textInput.current.innerText;
+ const currentText = textInput.current.value;
textInput.current.clear();
// We need to reset the selection to 0,0 manually after clearing the text input on web
diff --git a/src/components/DeeplinkWrapper/index.website.tsx b/src/components/DeeplinkWrapper/index.website.tsx
index 649e66ccefa8..b395eb12c5fe 100644
--- a/src/components/DeeplinkWrapper/index.website.tsx
+++ b/src/components/DeeplinkWrapper/index.website.tsx
@@ -5,6 +5,7 @@ import Navigation from '@libs/Navigation/Navigation';
import navigationRef from '@libs/Navigation/navigationRef';
import shouldPreventDeeplinkPrompt from '@libs/Navigation/shouldPreventDeeplinkPrompt';
import * as App from '@userActions/App';
+import * as Link from '@userActions/Link';
import * as Session from '@userActions/Session';
import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
@@ -15,7 +16,7 @@ function isMacOSWeb(): boolean {
return !Browser.isMobile() && typeof navigator === 'object' && typeof navigator.userAgent === 'string' && /Mac/i.test(navigator.userAgent) && !/Electron/i.test(navigator.userAgent);
}
-function promptToOpenInDesktopApp() {
+function promptToOpenInDesktopApp(initialUrl = '') {
// If the current url path is /transition..., meaning it was opened from oldDot, during this transition period:
// 1. The user session may not exist, because sign-in has not been completed yet.
// 2. There may be non-idempotent operations (e.g. create a new workspace), which obviously should not be executed again in the desktop app.
@@ -26,11 +27,11 @@ function promptToOpenInDesktopApp() {
// Match any magic link (/v//<6 digit code>)
const isMagicLink = CONST.REGEX.ROUTES.VALIDATE_LOGIN.test(window.location.pathname);
- App.beginDeepLinkRedirect(!isMagicLink);
+ App.beginDeepLinkRedirect(!isMagicLink, Link.getInternalNewExpensifyPath(initialUrl));
}
}
-function DeeplinkWrapper({children, isAuthenticated, autoAuthState}: DeeplinkWrapperProps) {
+function DeeplinkWrapper({children, isAuthenticated, autoAuthState, initialUrl}: DeeplinkWrapperProps) {
const [currentScreen, setCurrentScreen] = useState();
const [hasShownPrompt, setHasShownPrompt] = useState(false);
const removeListener = useRef<() => void>();
@@ -77,7 +78,7 @@ function DeeplinkWrapper({children, isAuthenticated, autoAuthState}: DeeplinkWra
// Otherwise, we want to wait until the navigation state is set up
// and we know the user is on a screen that supports deeplinks.
if (isAuthenticated) {
- promptToOpenInDesktopApp();
+ promptToOpenInDesktopApp(initialUrl);
setHasShownPrompt(true);
} else {
// Navigation state is not set up yet, we're unsure if we should show the deep link prompt or not
@@ -93,7 +94,7 @@ function DeeplinkWrapper({children, isAuthenticated, autoAuthState}: DeeplinkWra
promptToOpenInDesktopApp();
setHasShownPrompt(true);
}
- }, [currentScreen, hasShownPrompt, isAuthenticated, autoAuthState]);
+ }, [currentScreen, hasShownPrompt, isAuthenticated, autoAuthState, initialUrl]);
return children;
}
diff --git a/src/components/DeeplinkWrapper/types.ts b/src/components/DeeplinkWrapper/types.ts
index db61e5b01c24..23e096d6a093 100644
--- a/src/components/DeeplinkWrapper/types.ts
+++ b/src/components/DeeplinkWrapper/types.ts
@@ -6,6 +6,8 @@ type DeeplinkWrapperProps = ChildrenProps & {
/** The auto authentication status */
autoAuthState?: string;
+
+ initialUrl?: string;
};
export default DeeplinkWrapperProps;
diff --git a/src/components/FeedbackSurvey.tsx b/src/components/FeedbackSurvey.tsx
index a17cca5efae4..3c677a7b0f6d 100644
--- a/src/components/FeedbackSurvey.tsx
+++ b/src/components/FeedbackSurvey.tsx
@@ -43,9 +43,12 @@ type FeedbackSurveyProps = {
/** Indicates whether a loading indicator should be shown */
isLoading?: boolean;
+
+ /** Should the submit button be enabled when offline */
+ enabledWhenOffline?: boolean;
};
-function FeedbackSurvey({title, description, onSubmit, optionRowStyles, footerText, isNoteRequired, isLoading, formID}: FeedbackSurveyProps) {
+function FeedbackSurvey({title, description, onSubmit, optionRowStyles, footerText, isNoteRequired, isLoading, formID, enabledWhenOffline = true}: FeedbackSurveyProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
const [draft, draftResults] = useOnyx(`${formID}Draft`);
@@ -103,7 +106,7 @@ function FeedbackSurvey({title, description, onSubmit, optionRowStyles, footerTe
onSubmit={handleSubmit}
submitButtonText={translate('common.submit')}
isSubmitButtonVisible={false}
- enabledWhenOffline
+ enabledWhenOffline={enabledWhenOffline}
>
{title}
@@ -138,7 +141,7 @@ function FeedbackSurvey({title, description, onSubmit, optionRowStyles, footerTe
onSubmit={handleSubmit}
message={translate('common.error.pleaseCompleteForm')}
buttonText={translate('common.submit')}
- enabledWhenOffline
+ enabledWhenOffline={enabledWhenOffline}
containerStyles={styles.mt3}
isLoading={isLoading}
/>
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index ea33af302670..b1adf360bae6 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 Feed from '@assets/images/feed.svg';
import Filter from '@assets/images/filter.svg';
import Filters from '@assets/images/filters.svg';
import Flag from '@assets/images/flag.svg';
@@ -386,4 +387,5 @@ export {
Filters,
CalendarSolid,
Filter,
+ Feed,
};
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index 9537e7a0a7a7..3b7b2068acd1 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -87,6 +87,7 @@ import ReceiptEnvelope from '@assets/images/simple-illustrations/simple-illustra
import ReceiptLocationMarker from '@assets/images/simple-illustrations/simple-illustration__receipt-location-marker.svg';
import ReceiptWrangler from '@assets/images/simple-illustrations/simple-illustration__receipt-wrangler.svg';
import ReceiptUpload from '@assets/images/simple-illustrations/simple-illustration__receiptupload.svg';
+import Rules from '@assets/images/simple-illustrations/simple-illustration__rules.svg';
import SanFrancisco from '@assets/images/simple-illustrations/simple-illustration__sanfrancisco.svg';
import SendMoney from '@assets/images/simple-illustrations/simple-illustration__sendmoney.svg';
import ShieldYellow from '@assets/images/simple-illustrations/simple-illustration__shield.svg';
@@ -216,4 +217,5 @@ export {
Tire,
BigVault,
Filters,
+ Rules,
};
diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx
index b35b14016235..a734890a1f38 100644
--- a/src/components/LHNOptionsList/LHNOptionsList.tsx
+++ b/src/components/LHNOptionsList/LHNOptionsList.tsx
@@ -188,8 +188,8 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio
);
const extraData = useMemo(
- () => [reportActions, reports, policy, personalDetails, data.length, draftComments, optionMode, preferredLocale],
- [reportActions, reports, policy, personalDetails, data.length, draftComments, optionMode, preferredLocale],
+ () => [reportActions, reports, transactionViolations, policy, personalDetails, data.length, draftComments, optionMode, preferredLocale],
+ [reportActions, reports, transactionViolations, policy, personalDetails, data.length, draftComments, optionMode, preferredLocale],
);
const previousOptionMode = usePrevious(optionMode);
diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx
index 32c1a3852c86..ee3929292cd3 100644
--- a/src/components/MoneyReportHeader.tsx
+++ b/src/components/MoneyReportHeader.tsx
@@ -106,6 +106,9 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea
const connectedIntegration = PolicyUtils.getConnectedIntegration(policy);
const navigateBackToAfterDelete = useRef();
const hasScanningReceipt = ReportUtils.getTransactionsWithReceipts(moneyRequestReport?.reportID).some((t) => TransactionUtils.isReceiptBeingScanned(t));
+ const hasOnlyPendingTransactions = ReportUtils.getTransactionsWithReceipts(moneyRequestReport?.reportID).every(
+ (t) => TransactionUtils.isExpensifyCardTransaction(t) && TransactionUtils.isPending(t),
+ );
const transactionIDs = allTransactions.map((t) => t.transactionID);
const allHavePendingRTERViolation = TransactionUtils.allHavePendingRTERViolation(transactionIDs);
const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(moneyRequestReport.reportID);
@@ -131,7 +134,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea
const shouldDisableSubmitButton = shouldShowSubmitButton && !ReportUtils.isAllowedToSubmitDraftExpenseReport(moneyRequestReport);
const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE;
- const shouldShowStatusBar = allHavePendingRTERViolation || hasOnlyHeldExpenses || hasScanningReceipt || isPayAtEndExpense;
+ const shouldShowStatusBar = allHavePendingRTERViolation || hasOnlyHeldExpenses || hasScanningReceipt || isPayAtEndExpense || hasOnlyPendingTransactions;
const shouldShowNextStep = !ReportUtils.isClosedExpenseReportWithNoExpenses(moneyRequestReport) && isFromPaidPolicy && !!nextStep?.message?.length && !shouldShowStatusBar;
const shouldShowAnyButton =
shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton || shouldShowNextStep || allHavePendingRTERViolation || shouldShowExportIntegrationButton;
@@ -217,6 +220,9 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea
if (allHavePendingRTERViolation) {
return {icon: getStatusIcon(Expensicons.Hourglass), description: translate('iou.pendingMatchWithCreditCardDescription')};
}
+ if (hasOnlyPendingTransactions) {
+ return {icon: getStatusIcon(Expensicons.CreditCardHourglass), description: translate('iou.transactionPendingDescription')};
+ }
if (hasScanningReceipt) {
return {icon: getStatusIcon(Expensicons.ReceiptScan), description: translate('iou.receiptScanInProgressDescription')};
}
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
index 03be18e023a2..1350fd30ca54 100755
--- a/src/components/MoneyRequestConfirmationList.tsx
+++ b/src/components/MoneyRequestConfirmationList.tsx
@@ -233,14 +233,15 @@ function MoneyRequestConfirmationList({
const customUnitRateID = TransactionUtils.getRateID(transaction) ?? '-1';
useEffect(() => {
- if (customUnitRateID || !canUseP2PDistanceRequests) {
+ if ((customUnitRateID && customUnitRateID !== '-1') || !isDistanceRequest) {
return;
}
- if (!customUnitRateID) {
- const rateID = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? defaultMileageRate?.customUnitRateID ?? '';
- IOU.setCustomUnitRateID(transactionID, rateID);
- }
- }, [defaultMileageRate, customUnitRateID, lastSelectedDistanceRates, policy?.id, canUseP2PDistanceRequests, transactionID]);
+
+ const defaultRate = defaultMileageRate?.customUnitRateID ?? '';
+ const lastSelectedRate = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? defaultRate;
+ const rateID = canUseP2PDistanceRequests ? lastSelectedRate : defaultRate;
+ IOU.setCustomUnitRateID(transactionID, rateID);
+ }, [defaultMileageRate, customUnitRateID, lastSelectedDistanceRates, policy?.id, canUseP2PDistanceRequests, transactionID, isDistanceRequest]);
const policyCurrency = policy?.outputCurrency ?? PolicyUtils.getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD;
diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx
index c36fb3cb1570..4a76f33de346 100644
--- a/src/components/ReportActionItem/MoneyRequestView.tsx
+++ b/src/components/ReportActionItem/MoneyRequestView.tsx
@@ -186,7 +186,7 @@ function MoneyRequestView({
// Flags for allowing or disallowing editing an expense
// Used for non-restricted fields such as: description, category, tag, billable, etc...
- const canUserPerformWriteAction = !!ReportUtils.canUserPerformWriteAction(report);
+ const canUserPerformWriteAction = !!ReportUtils.canUserPerformWriteAction(report) && !readonly;
const canEdit = ReportActionsUtils.isMoneyRequestAction(parentReportAction) && ReportUtils.canEditMoneyRequest(parentReportAction, transaction) && canUserPerformWriteAction;
const canEditTaxFields = canEdit && !isDistanceRequest;
@@ -344,8 +344,8 @@ function MoneyRequestView({
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1'))
@@ -370,8 +370,8 @@ function MoneyRequestView({
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1'))}
/>
@@ -428,8 +428,8 @@ function MoneyRequestView({
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, iouType, orderWeight, transaction?.transactionID ?? '', report?.reportID ?? '-1'))
@@ -501,7 +501,7 @@ function MoneyRequestView({
{shouldShowReceiptEmptyState && (
Navigation.navigate(
ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(
@@ -524,8 +524,8 @@ function MoneyRequestView({
titleIcon={Expensicons.Checkmark}
description={amountDescription}
titleStyle={styles.textHeadlineH2}
- interactive={canEditAmount && !readonly}
- shouldShowRightIcon={canEditAmount && !readonly}
+ interactive={canEditAmount}
+ shouldShowRightIcon={canEditAmount}
onPress={() =>
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1'))
}
@@ -538,8 +538,8 @@ function MoneyRequestView({
description={translate('common.description')}
shouldParseTitle
title={updatedTransactionDescription ?? transactionDescription}
- interactive={canEdit && !readonly}
- shouldShowRightIcon={canEdit && !readonly}
+ interactive={canEdit}
+ shouldShowRightIcon={canEdit}
titleStyle={styles.flex1}
onPress={() =>
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1'))
@@ -557,8 +557,8 @@ function MoneyRequestView({
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1'))
@@ -574,8 +574,8 @@ function MoneyRequestView({
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1' ?? '-1'))
@@ -589,8 +589,8 @@ function MoneyRequestView({
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1'))
@@ -616,8 +616,8 @@ function MoneyRequestView({
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1'))
@@ -632,8 +632,8 @@ function MoneyRequestView({
Navigation.navigate(
@@ -671,7 +671,7 @@ function MoneyRequestView({
accessibilityLabel={translate('common.billable')}
isOn={updatedTransaction?.billable ?? !!transactionBillable}
onToggle={saveBillable}
- disabled={!canEdit || readonly}
+ disabled={!canEdit}
/>
)}
diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx
index 694ee05a9ec2..45a06968cefd 100644
--- a/src/components/ReportActionItem/ReportPreview.tsx
+++ b/src/components/ReportActionItem/ReportPreview.tsx
@@ -365,7 +365,7 @@ function ReportPreview({
}
return {
supportText: translate('iou.expenseCount', {
- count: numberOfRequests - numberOfScanningReceipts - numberOfPendingRequests,
+ count: numberOfRequests,
scanningReceipts: numberOfScanningReceipts,
pendingReceipts: numberOfPendingRequests,
}),
diff --git a/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx b/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx
index bb2b2e24aea4..966f49e45a93 100644
--- a/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx
+++ b/src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx
@@ -15,7 +15,7 @@ const Context = React.createContext(null);
function VideoPopoverMenuContextProvider({children}: ChildrenProps) {
const {currentlyPlayingURL} = usePlaybackContext();
const {translate} = useLocalize();
- const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS[2]);
+ const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS[3]);
const {isOffline} = useNetwork();
const isLocalFile = currentlyPlayingURL && CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => currentlyPlayingURL.startsWith(prefix));
const videoPopoverMenuPlayerRef = useRef(null);
@@ -57,7 +57,7 @@ function VideoPopoverMenuContextProvider({children}: ChildrenProps) {
text: translate('videoPlayer.playbackSpeed'),
subMenuItems: CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS.map((speed) => ({
icon: currentPlaybackSpeed === speed ? Expensicons.Checkmark : undefined,
- text: speed.toString(),
+ text: speed === 1 ? translate('videoPlayer.normal') : speed.toString(),
onSelected: () => {
updatePlaybackSpeed(speed);
},
diff --git a/src/hooks/useHtmlPaste/index.ts b/src/hooks/useHtmlPaste/index.ts
index 022d6178877d..6199a36abdca 100644
--- a/src/hooks/useHtmlPaste/index.ts
+++ b/src/hooks/useHtmlPaste/index.ts
@@ -1,6 +1,5 @@
import {useNavigation} from '@react-navigation/native';
import {useCallback, useEffect} from 'react';
-import type {ClipboardEvent as PasteEvent} from 'react';
import Parser from '@libs/Parser';
import type UseHtmlPaste from './types';
@@ -21,10 +20,6 @@ const insertAtCaret = (target: HTMLElement, text: string) => {
range.setEnd(node, node.length);
selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset);
- // Dispatch paste event to make Markdown Input properly set cursor position
- const pasteEvent = new ClipboardEvent('paste', {bubbles: true, cancelable: true});
- (pasteEvent as unknown as PasteEvent).isDefaultPrevented = () => false;
- target.dispatchEvent(pasteEvent);
// Dispatch input event to trigger Markdown Input to parse the new text
target.dispatchEvent(new Event('input', {bubbles: true}));
} else {
@@ -48,11 +43,6 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeLi
insertByCommand(text);
}
- if (!textInputRef.current?.isFocused()) {
- textInputRef.current?.focus();
- return;
- }
-
// Pointer will go out of sight when a large paragraph is pasted on the web. Refocusing the input keeps the cursor in view.
// To avoid the keyboard toggle issue in mWeb if using blur() and focus() functions, we just need to dispatch the event to trigger the onFocus handler
// We need to trigger the bubbled "focusin" event to make sure the onFocus handler is triggered
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 1ad463cb576d..8a8fc815ddb4 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -750,10 +750,17 @@ export default {
yourCompanyWebsiteNote: "If you don't have a website, you can provide your company's LinkedIn or social media profile instead.",
invalidDomainError: 'You have entered an invalid domain. To continue, please enter a valid domain.',
publicDomainError: 'You have entered a public domain. To continue, please enter a private domain.',
- expenseCount: ({count, scanningReceipts = 0, pendingReceipts = 0}: RequestCountParams) =>
- `${count} ${Str.pluralize('expense', 'expenses', count)}${scanningReceipts > 0 ? `, ${scanningReceipts} scanning` : ''}${
- pendingReceipts > 0 ? `, ${pendingReceipts} pending` : ''
- }`,
+ expenseCount: ({count, scanningReceipts = 0, pendingReceipts = 0}: RequestCountParams) => {
+ const expenseText = `${count} ${Str.pluralize('expense', 'expenses', count)}`;
+ const statusText = [];
+ if (scanningReceipts > 0) {
+ statusText.push(`${scanningReceipts} scanning`);
+ }
+ if (pendingReceipts > 0) {
+ statusText.push(`${pendingReceipts} pending`);
+ }
+ return statusText.length > 0 ? `${expenseText} (${statusText.join(', ')})` : expenseText;
+ },
deleteExpense: ({count}: DeleteExpenseTranslationParams = {count: 1}) => `Delete ${Str.pluralize('expense', 'expenses', count)}`,
deleteConfirmation: ({count}: DeleteExpenseTranslationParams = {count: 1}) => `Are you sure that you want to delete ${Str.pluralize('this expense', 'these expenses', count)}?`,
settledExpensify: 'Paid',
@@ -1427,7 +1434,7 @@ export default {
addPaymentMethod: 'Add payment method',
addNewDebitCard: 'Add new debit card',
addNewBankAccount: 'Add new bank account',
- accountLastFour: 'Account ending in',
+ accountLastFour: 'Ending in',
cardLastFour: 'Card ending in',
addFirstPaymentMethod: 'Add a payment method to send and receive payments directly in the app.',
defaultPaymentMethod: 'Default',
@@ -2122,6 +2129,7 @@ export default {
travel: 'Travel',
members: 'Members',
accounting: 'Accounting',
+ rules: 'Rules',
displayedAs: 'Displayed as',
plan: 'Plan',
profile: 'Profile',
@@ -2831,6 +2839,10 @@ export default {
title: 'Spend',
subtitle: 'Enable functionality that helps you scale your team.',
},
+ manageSection: {
+ title: 'Manage',
+ subtitle: 'Add controls that help keep spend within budget.',
+ },
earnSection: {
title: 'Earn',
subtitle: 'Enable optional functionality to streamline your revenue and get paid faster.',
@@ -2898,6 +2910,10 @@ export default {
disconnectText: "To disable accounting, you'll need to disconnect your accounting connection from your workspace.",
manageSettings: 'Manage settings',
},
+ rules: {
+ title: 'Rules',
+ subtitle: 'Configure when receipts are required, flag high spend, and more.',
+ },
},
reportFields: {
addField: 'Add field',
@@ -3543,6 +3559,11 @@ export default {
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 ',
},
+ rules: {
+ title: 'Rules',
+ description: `Rules run in the background and keep your spend under control so you don't have to sweat the small stuff.\n\nRequire expense details like receipts and descriptions, set limits and defaults, and automate approvals and payments – all in one place.`,
+ onlyAvailableOnPlan: 'Rules are only available on the Control plan, starting at ',
+ },
pricing: {
amount: '$9 ',
perActiveMember: 'per active member per month.',
@@ -3574,6 +3595,16 @@ export default {
chatInAdmins: 'Chat in #admins',
addPaymentCard: 'Add payment card',
},
+ rules: {
+ individualExpenseRules: {
+ title: 'Expenses',
+ subtitle: 'Set spend controls and defaults for individual expenses. You can also create rules for',
+ },
+ expenseReportRules: {
+ title: 'Expense reports',
+ subtitle: 'Automate expense report compliance, approvals, and payment.',
+ },
+ },
},
getAssistancePage: {
title: 'Get assistance',
@@ -4179,6 +4210,7 @@ export default {
expand: 'Expand',
mute: 'Mute',
unmute: 'Unmute',
+ normal: 'Normal',
},
exitSurvey: {
header: 'Before you go',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 76c2585d7f04..a51ce1d91bf7 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -743,11 +743,17 @@ export default {
yourCompanyWebsiteNote: 'Si no tiene un sitio web, puede proporcionar el perfil de LinkedIn o de las redes sociales de su empresa.',
invalidDomainError: 'Ha introducido un dominio no válido. Para continuar, introduzca un dominio válido.',
publicDomainError: 'Ha introducido un dominio público. Para continuar, introduzca un dominio privado.',
- expenseCount: ({count, scanningReceipts = 0, pendingReceipts = 0}: RequestCountParams) =>
- `${count} ${Str.pluralize('gasto', 'gastos', count)}${scanningReceipts > 0 ? `, ${scanningReceipts} escaneando` : ''}${
- pendingReceipts > 0 ? `, ${pendingReceipts} pendiente` : ''
- }`,
-
+ expenseCount: ({count, scanningReceipts = 0, pendingReceipts = 0}: RequestCountParams) => {
+ const expenseText = `${count} ${Str.pluralize('gasto', 'gastos', count)}`;
+ const statusText = [];
+ if (scanningReceipts > 0) {
+ statusText.push(`${scanningReceipts} escaneando`);
+ }
+ if (pendingReceipts > 0) {
+ statusText.push(`${pendingReceipts} pendiente`);
+ }
+ return statusText.length > 0 ? `${expenseText} (${statusText.join(', ')})` : expenseText;
+ },
deleteExpense: ({count}: DeleteExpenseTranslationParams = {count: 1}) => `Eliminar ${Str.pluralize('gasto', 'gastos', count)}`,
deleteConfirmation: ({count}: DeleteExpenseTranslationParams = {count: 1}) => `¿Estás seguro de que quieres eliminar ${Str.pluralize('esta solicitud', 'estas solicitudes', count)}?`,
settledExpensify: 'Pagado',
@@ -1436,7 +1442,7 @@ export default {
addPaymentMethod: 'Añadir método de pago',
addNewDebitCard: 'Añadir nueva tarjeta de débito',
addNewBankAccount: 'Añadir nueva cuenta de banco',
- accountLastFour: 'Cuenta terminada en',
+ accountLastFour: 'Terminada en',
cardLastFour: 'Tarjeta terminada en',
addFirstPaymentMethod: 'Añade un método de pago para enviar y recibir pagos directamente desde la aplicación.',
defaultPaymentMethod: 'Predeterminado',
@@ -2153,6 +2159,7 @@ export default {
travel: 'Viajes',
members: 'Miembros',
accounting: 'Contabilidad',
+ rules: 'Reglas',
plan: 'Plan',
profile: 'Perfil',
bankAccount: 'Cuenta bancaria',
@@ -2880,6 +2887,10 @@ export default {
title: 'Gasto',
subtitle: 'Habilita otras funcionalidades que ayudan a aumentar tu equipo.',
},
+ manageSection: {
+ title: 'Gestionar',
+ subtitle: 'Añade controles que ayudan a mantener los gastos dentro del presupuesto.',
+ },
earnSection: {
title: 'Gane',
subtitle: 'Habilita funciones opcionales para agilizar tus ingresos y recibir pagos más rápido.',
@@ -2947,6 +2958,10 @@ export default {
disconnectText: 'Para desactivar la contabilidad, desconecta tu conexión contable del espacio de trabajo.',
manageSettings: 'Gestionar la configuración',
},
+ rules: {
+ title: 'Reglas',
+ subtitle: 'Configura cuándo se exigen los recibos, marca los gastos elevados y mucho más.',
+ },
},
reportFields: {
addField: 'Añadir campo',
@@ -3593,6 +3608,11 @@ export default {
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 ',
},
+ rules: {
+ title: 'Reglas',
+ description: `Las reglas se ejecutan en segundo plano y mantienen tus gastos bajo control para que no tengas que preocuparte por los detalles pequeños.\n\nExige detalles de los gastos, como recibos y descripciones, establece límites y valores predeterminados, y automatiza las aprobaciones y los pagos, todo en un mismo lugar.`,
+ onlyAvailableOnPlan: 'Las reglas están disponibles solo en el plan Control, que comienza en ',
+ },
note: {
upgradeWorkspace: 'Mejore su espacio de trabajo para acceder a esta función, o',
learnMore: 'más información',
@@ -3624,6 +3644,16 @@ export default {
chatInAdmins: 'Chatea en #admins',
addPaymentCard: 'Agregar tarjeta de pago',
},
+ rules: {
+ individualExpenseRules: {
+ title: 'Gastos',
+ subtitle: 'Establece controles y valores predeterminados para gastos individuales. También puedes crear reglas para',
+ },
+ expenseReportRules: {
+ title: 'Informes de gastos',
+ subtitle: 'Automatiza el cumplimiento, la aprobación y el pago de los informes de gastos.',
+ },
+ },
},
getAssistancePage: {
title: 'Obtener ayuda',
@@ -4694,6 +4724,7 @@ export default {
expand: 'Expandir',
mute: 'Silenciar',
unmute: 'Activar sonido',
+ normal: 'Normal',
},
exitSurvey: {
header: 'Antes de irte',
diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts
index 65fd2b6ad015..8f087ebbf080 100644
--- a/src/libs/API/index.ts
+++ b/src/libs/API/index.ts
@@ -9,7 +9,7 @@ import * as Request from '@libs/Request';
import * as PersistedRequests from '@userActions/PersistedRequests';
import CONST from '@src/CONST';
import type OnyxRequest from '@src/types/onyx/Request';
-import type {PaginatedRequest, PaginationConfig} from '@src/types/onyx/Request';
+import type {PaginatedRequest, PaginationConfig, RequestConflictResolver} from '@src/types/onyx/Request';
import type Response from '@src/types/onyx/Response';
import type {ApiCommand, ApiRequestCommandParameters, ApiRequestType, CommandOfType, ReadCommand, SideEffectRequestCommand, WriteCommand} from './types';
@@ -45,7 +45,13 @@ type OnyxData = {
/**
* Prepare the request to be sent. Bind data together with request metadata and apply optimistic Onyx data.
*/
-function prepareRequest(command: TCommand, type: ApiRequestType, params: ApiRequestCommandParameters[TCommand], onyxData: OnyxData = {}): OnyxRequest {
+function prepareRequest(
+ command: TCommand,
+ type: ApiRequestType,
+ params: ApiRequestCommandParameters[TCommand],
+ onyxData: OnyxData = {},
+ conflictResolver: RequestConflictResolver = {},
+): OnyxRequest {
Log.info('[API] Preparing request', false, {command, type});
const {optimisticData, ...onyxDataWithoutOptimisticData} = onyxData;
@@ -71,6 +77,7 @@ function prepareRequest(command: TCommand, type: Ap
command,
data,
...onyxDataWithoutOptimisticData,
+ ...conflictResolver,
};
if (isWriteRequest) {
@@ -116,9 +123,14 @@ function processRequest(request: OnyxRequest, type: ApiRequestType): Promise(command: TCommand, apiCommandParameters: ApiRequestCommandParameters[TCommand], onyxData: OnyxData = {}): void {
+function write(
+ command: TCommand,
+ apiCommandParameters: ApiRequestCommandParameters[TCommand],
+ onyxData: OnyxData = {},
+ conflictResolver: RequestConflictResolver = {},
+): void {
Log.info('[API] Called API write', false, {command, ...apiCommandParameters});
- const request = prepareRequest(command, CONST.API_REQUEST_TYPE.WRITE, apiCommandParameters, onyxData);
+ const request = prepareRequest(command, CONST.API_REQUEST_TYPE.WRITE, apiCommandParameters, onyxData, conflictResolver);
processRequest(request, CONST.API_REQUEST_TYPE.WRITE);
}
diff --git a/src/libs/API/parameters/SendInvoiceParams.ts b/src/libs/API/parameters/SendInvoiceParams.ts
index c95ffce14b2c..e2cac84e0d12 100644
--- a/src/libs/API/parameters/SendInvoiceParams.ts
+++ b/src/libs/API/parameters/SendInvoiceParams.ts
@@ -20,6 +20,9 @@ type SendInvoiceParams = RequireAtLeastOne<
transactionThreadReportID: string;
companyName?: string;
companyWebsite?: string;
+ createdIOUReportActionID: string;
+ createdReportActionIDForThread: string;
+ reportActionID: string;
},
'receiverEmail' | 'receiverInvoiceRoomID'
>;
diff --git a/src/libs/API/parameters/SetPolicyRulesEnabledParams.ts b/src/libs/API/parameters/SetPolicyRulesEnabledParams.ts
new file mode 100644
index 000000000000..c748a98e4119
--- /dev/null
+++ b/src/libs/API/parameters/SetPolicyRulesEnabledParams.ts
@@ -0,0 +1,6 @@
+type SetPolicyRulesEnabledParams = {
+ policyID: string;
+ enabled: boolean;
+};
+
+export default SetPolicyRulesEnabledParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index c95355dada31..a72220c3d943 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -272,6 +272,7 @@ export type {default as ExportSearchItemsToCSVParams} from './ExportSearchItemsT
export type {default as UpdateExpensifyCardLimitParams} from './UpdateExpensifyCardLimitParams';
export type {CreateWorkspaceApprovalParams, UpdateWorkspaceApprovalParams, RemoveWorkspaceApprovalParams} from './WorkspaceApprovalParams';
export type {default as StartIssueNewCardFlowParams} from './StartIssueNewCardFlowParams';
+export type {default as SetPolicyRulesEnabledParams} from './SetPolicyRulesEnabledParams';
export type {default as ConfigureExpensifyCardsForPolicyParams} from './ConfigureExpensifyCardsForPolicyParams';
export type {default as CreateExpensifyCardParams} from './CreateExpensifyCardParams';
export type {default as UpdateExpensifyCardTitleParams} from './UpdateExpensifyCardTitleParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index cc3504ad89ab..de63ed032afe 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -200,6 +200,7 @@ const WRITE_COMMANDS = {
ENABLE_POLICY_REPORT_FIELDS: 'EnablePolicyReportFields',
ENABLE_POLICY_EXPENSIFY_CARDS: 'EnablePolicyExpensifyCards',
ENABLE_POLICY_INVOICING: 'EnablePolicyInvoicing',
+ SET_POLICY_RULES_ENABLED: 'SetPolicyRulesEnabled',
SET_POLICY_TAXES_CURRENCY_DEFAULT: 'SetPolicyCurrencyDefaultTax',
SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT: 'SetPolicyForeignCurrencyDefaultTax',
SET_POLICY_CUSTOM_TAX_NAME: 'SetPolicyCustomTaxName',
@@ -530,6 +531,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.ENABLE_POLICY_REPORT_FIELDS]: Parameters.EnablePolicyReportFieldsParams;
[WRITE_COMMANDS.ENABLE_POLICY_EXPENSIFY_CARDS]: Parameters.EnablePolicyExpensifyCardsParams;
[WRITE_COMMANDS.ENABLE_POLICY_INVOICING]: Parameters.EnablePolicyInvoicingParams;
+ [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams;
[WRITE_COMMANDS.JOIN_POLICY_VIA_INVITE_LINK]: Parameters.JoinPolicyInviteLinkParams;
[WRITE_COMMANDS.ACCEPT_JOIN_REQUEST]: Parameters.AcceptJoinRequestParams;
[WRITE_COMMANDS.DECLINE_JOIN_REQUEST]: Parameters.DeclineJoinRequestParams;
diff --git a/src/libs/Browser/index.website.ts b/src/libs/Browser/index.website.ts
index b89190dc7f78..2007f2c6cbc0 100644
--- a/src/libs/Browser/index.website.ts
+++ b/src/libs/Browser/index.website.ts
@@ -79,12 +79,12 @@ const isSafari: IsSafari = () => getBrowser() === 'safari' || isMobileSafari();
/**
* The session information needs to be passed to the Desktop app, and the only way to do that is by using query params. There is no other way to transfer the data.
*/
-const openRouteInDesktopApp: OpenRouteInDesktopApp = (shortLivedAuthToken = '', email = '') => {
+const openRouteInDesktopApp: OpenRouteInDesktopApp = (shortLivedAuthToken = '', email = '', initialRoute = '') => {
const params = new URLSearchParams();
// If the user is opening the desktop app through a third party signin flow, we need to manually add the exitTo param
// so that the desktop app redirects to the correct home route after signin is complete.
const openingFromDesktopRedirect = window.location.pathname === `/${ROUTES.DESKTOP_SIGN_IN_REDIRECT}`;
- params.set('exitTo', `${openingFromDesktopRedirect ? '/r' : window.location.pathname}${window.location.search}${window.location.hash}`);
+ params.set('exitTo', `${openingFromDesktopRedirect ? '/r' : initialRoute || window.location.pathname}${window.location.search}${window.location.hash}`);
if (email && shortLivedAuthToken) {
params.set('email', email);
params.set('shortLivedAuthToken', shortLivedAuthToken);
diff --git a/src/libs/Browser/types.ts b/src/libs/Browser/types.ts
index cb242d3729aa..ff0de91e7b78 100644
--- a/src/libs/Browser/types.ts
+++ b/src/libs/Browser/types.ts
@@ -12,6 +12,6 @@ type IsChromeIOS = () => boolean;
type IsSafari = () => boolean;
-type OpenRouteInDesktopApp = (shortLivedAuthToken?: string, email?: string) => void;
+type OpenRouteInDesktopApp = (shortLivedAuthToken?: string, email?: string, initialRoute?: string) => void;
export type {GetBrowser, IsMobile, IsMobileSafari, IsMobileChrome, IsMobileWebKit, IsSafari, IsChromeIOS, OpenRouteInDesktopApp};
diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts
index fa98cf32ee39..2b2aad59d58d 100644
--- a/src/libs/DistanceRequestUtils.ts
+++ b/src/libs/DistanceRequestUtils.ts
@@ -248,7 +248,7 @@ function convertToDistanceInMeters(distance: number, unit: Unit): number {
/**
* Returns custom unit rate ID for the distance transaction
*/
-function getCustomUnitRateID(reportID: string) {
+function getCustomUnitRateID(reportID: string, shouldUseDefault?: boolean) {
const allReports = ReportConnection.getAllReports();
const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`];
@@ -259,7 +259,7 @@ function getCustomUnitRateID(reportID: string) {
const distanceUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
const lastSelectedDistanceRateID = lastSelectedDistanceRates?.[policy?.id ?? '-1'] ?? '-1';
const lastSelectedDistanceRate = distanceUnit?.rates[lastSelectedDistanceRateID] ?? {};
- if (lastSelectedDistanceRate.enabled && lastSelectedDistanceRateID) {
+ if (lastSelectedDistanceRate.enabled && lastSelectedDistanceRateID && !shouldUseDefault) {
customUnitRateID = lastSelectedDistanceRateID;
} else {
customUnitRateID = getDefaultMileageRate(policy)?.customUnitRateID ?? '-1';
diff --git a/src/libs/E2E/tests/reportTypingTest.e2e.ts b/src/libs/E2E/tests/reportTypingTest.e2e.ts
index 9624d7ab992b..efe1c380dfd0 100644
--- a/src/libs/E2E/tests/reportTypingTest.e2e.ts
+++ b/src/libs/E2E/tests/reportTypingTest.e2e.ts
@@ -1,13 +1,16 @@
import type {NativeConfig} from 'react-native-config';
import Config from 'react-native-config';
+import {runOnUI} from 'react-native-reanimated';
import E2ELogin from '@libs/E2E/actions/e2eLogin';
import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded';
import waitForKeyboard from '@libs/E2E/actions/waitForKeyboard';
import E2EClient from '@libs/E2E/client';
import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow';
+import getPromiseWithResolve from '@libs/E2E/utils/getPromiseWithResolve';
import Navigation from '@libs/Navigation/Navigation';
import Performance from '@libs/Performance';
import {getRerenderCount, resetRerenderCount} from '@pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e';
+import {onSubmitAction} from '@pages/home/report/ReportActionCompose/ReportActionCompose';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import * as NativeCommands from '../../../../tests/e2e/nativeCommands/NativeCommandsAction';
@@ -17,6 +20,7 @@ const test = (config: NativeConfig) => {
console.debug('[E2E] Logging in for typing');
const reportID = getConfigValueOrThrow('reportID', config);
+ const message = getConfigValueOrThrow('message', config);
E2ELogin().then((neededLogin) => {
if (neededLogin) {
@@ -28,7 +32,26 @@ const test = (config: NativeConfig) => {
console.debug('[E2E] Logged in, getting typing metrics and submitting them…');
+ const [renderTimesPromise, renderTimesResolve] = getPromiseWithResolve();
+ const [messageSentPromise, messageSentResolve] = getPromiseWithResolve();
+
+ Promise.all([renderTimesPromise, messageSentPromise]).then(() => {
+ console.debug(`[E2E] Submitting!`);
+
+ E2EClient.submitTestDone();
+ });
+
Performance.subscribeToMeasurements((entry) => {
+ if (entry.name === CONST.TIMING.MESSAGE_SENT) {
+ E2EClient.submitTestResults({
+ branch: Config.E2E_BRANCH,
+ name: 'Message sent',
+ metric: entry.duration,
+ unit: 'ms',
+ }).then(messageSentResolve);
+ return;
+ }
+
if (entry.name !== CONST.TIMING.SIDEBAR_LOADED) {
return;
}
@@ -46,18 +69,26 @@ const test = (config: NativeConfig) => {
return Promise.resolve();
})
.then(() => E2EClient.sendNativeCommand(NativeCommands.makeTypeTextCommand('A')))
- .then(() => {
- setTimeout(() => {
- const rerenderCount = getRerenderCount();
+ .then(
+ () =>
+ new Promise((resolve) => {
+ setTimeout(() => {
+ const rerenderCount = getRerenderCount();
- E2EClient.submitTestResults({
- branch: Config.E2E_BRANCH,
- name: 'Composer typing rerender count',
- metric: rerenderCount,
- unit: 'renders',
- }).then(E2EClient.submitTestDone);
- }, 3000);
- })
+ E2EClient.submitTestResults({
+ branch: Config.E2E_BRANCH,
+ name: 'Composer typing rerender count',
+ metric: rerenderCount,
+ unit: 'renders',
+ })
+ .then(renderTimesResolve)
+ .then(resolve);
+ }, 3000);
+ }),
+ )
+ .then(() => E2EClient.sendNativeCommand(NativeCommands.makeBackspaceCommand()))
+ .then(() => E2EClient.sendNativeCommand(NativeCommands.makeTypeTextCommand(message)))
+ .then(() => runOnUI(onSubmitAction)())
.catch((error) => {
console.error('[E2E] Error while test', error);
E2EClient.submitTestDone();
diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts
index 986fd165d2d7..d1bf8fcd8c8c 100644
--- a/src/libs/IOUUtils.ts
+++ b/src/libs/IOUUtils.ts
@@ -141,10 +141,11 @@ function insertTagIntoTransactionTagsString(transactionTags: string, tag: string
const tagArray = TransactionUtils.getTagArrayFromName(transactionTags);
tagArray[tagIndex] = tag;
- return tagArray
- .map((tagItem) => tagItem.trim())
- .filter((tagItem) => !!tagItem)
- .join(CONST.COLON);
+ while (tagArray.length > 0 && !tagArray[tagArray.length - 1]) {
+ tagArray.pop();
+ }
+
+ return tagArray.map((tagItem) => tagItem.trim()).join(CONST.COLON);
}
function isMovingTransactionFromTrackExpense(action?: IOUAction) {
diff --git a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx
index 748d92b49a1c..077f42d32ec5 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx
@@ -33,6 +33,7 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = {
[SCREENS.WORKSPACE.REPORT_FIELDS]: () => require('../../../../pages/workspace/reportFields/WorkspaceReportFieldsPage').default,
[SCREENS.WORKSPACE.EXPENSIFY_CARD]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardPage').default,
[SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default,
+ [SCREENS.WORKSPACE.RULES]: () => require('../../../../pages/workspace/rules/PolicyRulesPage').default,
} satisfies Screens;
function FullScreenNavigator() {
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 6b4d7eca95c1..bb9d92c7a5a3 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -1099,6 +1099,9 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.DISTANCE_RATES]: {
path: ROUTES.WORKSPACE_DISTANCE_RATES.route,
},
+ [SCREENS.WORKSPACE.RULES]: {
+ path: ROUTES.WORKSPACE_RULES.route,
+ },
},
},
},
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index b689f36d8a35..c85f0972d84a 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -1194,6 +1194,9 @@ type FullScreenNavigatorParamList = {
[SCREENS.WORKSPACE.EXPENSIFY_CARD]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.RULES]: {
+ policyID: string;
+ };
};
type OnboardingModalNavigatorParamList = {
diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts
index 5646cb8956dd..9fd65602eca2 100644
--- a/src/libs/Network/SequentialQueue.ts
+++ b/src/libs/Network/SequentialQueue.ts
@@ -24,7 +24,8 @@ let isReadyPromise = new Promise((resolve) => {
resolveIsReadyPromise?.();
let isSequentialQueueRunning = false;
-let currentRequest: Promise | null = null;
+let currentRequest: OnyxRequest | null = null;
+let currentRequestPromise: Promise | null = null;
let isQueuePaused = false;
/**
@@ -81,8 +82,9 @@ function process(): Promise {
const requestToProcess = persistedRequests[0];
+ currentRequest = requestToProcess;
// Set the current request to a promise awaiting its processing so that getCurrentRequest can be used to take some action after the current request has processed.
- currentRequest = Request.processWithMiddleware(requestToProcess, true)
+ currentRequestPromise = Request.processWithMiddleware(requestToProcess, true)
.then((response) => {
// A response might indicate that the queue should be paused. This happens when a gap in onyx updates is detected between the client and the server and
// that gap needs resolved before the queue can continue.
@@ -112,7 +114,7 @@ function process(): Promise {
});
});
- return currentRequest;
+ return currentRequestPromise;
}
function flush() {
@@ -158,6 +160,7 @@ function flush() {
resolveIsReadyPromise?.();
}
currentRequest = null;
+ currentRequestPromise = null;
flushOnyxUpdatesQueue();
});
},
@@ -191,9 +194,22 @@ function isPaused(): boolean {
// Flush the queue when the connection resumes
NetworkStore.onReconnection(flush);
-function push(request: OnyxRequest) {
- // Add request to Persisted Requests so that it can be retried if it fails
- PersistedRequests.save(request);
+function push(newRequest: OnyxRequest) {
+ const requests = PersistedRequests.getAll().filter((persistedRequest) => persistedRequest !== currentRequest);
+
+ const {checkAndFixConflictingRequest} = newRequest;
+ if (checkAndFixConflictingRequest) {
+ const {conflictAction} = checkAndFixConflictingRequest(requests, newRequest);
+
+ if (conflictAction.type === 'save') {
+ PersistedRequests.save(newRequest);
+ } else {
+ PersistedRequests.update(conflictAction.index, newRequest);
+ }
+ } else {
+ // Add request to Persisted Requests so that it can be retried if it fails
+ PersistedRequests.save(newRequest);
+ }
// If we are offline we don't need to trigger the queue to empty as it will happen when we come back online
if (NetworkStore.isOffline()) {
@@ -210,10 +226,10 @@ function push(request: OnyxRequest) {
}
function getCurrentRequest(): Promise {
- if (currentRequest === null) {
+ if (currentRequestPromise === null) {
return Promise.resolve();
}
- return currentRequest;
+ return currentRequestPromise;
}
/**
diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts
index 15c15e113c8c..0a6756034f7d 100644
--- a/src/libs/Permissions.ts
+++ b/src/libs/Permissions.ts
@@ -36,6 +36,10 @@ function canUseNetSuiteUSATax(betas: OnyxEntry): boolean {
return !!betas?.includes(CONST.BETAS.NETSUITE_USA_TAX) || canUseAllBetas(betas);
}
+function canUseWorkspaceRules(betas: OnyxEntry): boolean {
+ return !!betas?.includes(CONST.BETAS.WORKSPACE_RULES) || canUseAllBetas(betas);
+}
+
/**
* Link previews are temporarily disabled.
*/
@@ -52,4 +56,5 @@ export default {
canUseSpotnanaTravel,
canUseWorkspaceFeeds,
canUseNetSuiteUSATax,
+ canUseWorkspaceRules,
};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index cda35311122a..38b73ffc2057 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -5813,7 +5813,11 @@ function doesTransactionThreadHaveViolations(
if (report?.stateNum !== CONST.REPORT.STATE_NUM.OPEN && report?.stateNum !== CONST.REPORT.STATE_NUM.SUBMITTED) {
return false;
}
- return TransactionUtils.hasViolation(IOUTransactionID, transactionViolations) || TransactionUtils.hasWarningTypeViolation(IOUTransactionID, transactionViolations);
+ return (
+ TransactionUtils.hasViolation(IOUTransactionID, transactionViolations) ||
+ TransactionUtils.hasWarningTypeViolation(IOUTransactionID, transactionViolations) ||
+ TransactionUtils.hasModifiedAmountOrDateViolation(IOUTransactionID, transactionViolations)
+ );
}
/**
diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts
index b2b14be5cece..e5bd5d9b0753 100644
--- a/src/libs/TransactionUtils/index.ts
+++ b/src/libs/TransactionUtils/index.ts
@@ -733,6 +733,15 @@ function hasWarningTypeViolation(transactionID: string, transactionViolations: O
return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.WARNING);
}
+/**
+ * Checks if any violations for the provided transaction are of modifiedAmount or modifiedDate
+ */
+function hasModifiedAmountOrDateViolation(transactionID: string, transactionViolations: OnyxCollection): boolean {
+ return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some(
+ (violation: TransactionViolation) => violation.name === CONST.VIOLATIONS.MODIFIED_AMOUNT || violation.name === CONST.VIOLATIONS.MODIFIED_DATE,
+ );
+}
+
/**
* Calculates tax amount from the given expense amount and tax percentage
*/
@@ -1106,6 +1115,7 @@ export {
hasViolation,
hasNoticeTypeViolation,
hasWarningTypeViolation,
+ hasModifiedAmountOrDateViolation,
isCustomUnitRateIDForP2P,
getRateID,
getTransaction,
diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts
index 7be767d73f48..7f7fc95ae5d4 100644
--- a/src/libs/actions/App.ts
+++ b/src/libs/actions/App.ts
@@ -274,7 +274,27 @@ function reconnectApp(updateIDFrom: OnyxEntry = 0) {
params.updateIDFrom = updateIDFrom;
}
- API.write(WRITE_COMMANDS.RECONNECT_APP, params, getOnyxDataForOpenOrReconnect());
+ API.write(WRITE_COMMANDS.RECONNECT_APP, params, getOnyxDataForOpenOrReconnect(), {
+ checkAndFixConflictingRequest: (persistedRequests, newRequest) => {
+ const index = persistedRequests.findIndex((request) => request.command === WRITE_COMMANDS.RECONNECT_APP);
+ if (index === -1) {
+ return {
+ request: newRequest,
+ conflictAction: {
+ type: 'save',
+ },
+ };
+ }
+
+ return {
+ request: newRequest,
+ conflictAction: {
+ type: 'update',
+ index,
+ },
+ };
+ },
+ });
});
}
@@ -449,7 +469,7 @@ function redirectThirdPartyDesktopSignIn() {
/**
* @param shouldAuthenticateWithCurrentAccount Optional, indicates whether default authentication method (shortLivedAuthToken) should be used
*/
-function beginDeepLinkRedirect(shouldAuthenticateWithCurrentAccount = true) {
+function beginDeepLinkRedirect(shouldAuthenticateWithCurrentAccount = true, initialRoute?: string) {
// There's no support for anonymous users on desktop
if (Session.isAnonymousUser()) {
return;
@@ -475,7 +495,7 @@ function beginDeepLinkRedirect(shouldAuthenticateWithCurrentAccount = true) {
return;
}
- Browser.openRouteInDesktopApp(response.shortLivedAuthToken, currentUserEmail);
+ Browser.openRouteInDesktopApp(response.shortLivedAuthToken, currentUserEmail, initialRoute);
});
}
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index bbb6e3c48fcd..5dab6176847d 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -111,6 +111,9 @@ type SendInvoiceInformation = {
reportPreviewReportActionID: string;
transactionID: string;
transactionThreadReportID: string;
+ createdIOUReportActionID: string;
+ createdReportActionIDForThread: string;
+ reportActionID: string;
onyxData: OnyxData;
};
@@ -1944,6 +1947,9 @@ function getSendInvoiceInformation(
);
return {
+ createdIOUReportActionID: optimisticCreatedActionForIOUReport.reportActionID,
+ createdReportActionIDForThread: optimisticCreatedActionForTransactionThread?.reportActionID ?? '-1',
+ reportActionID: iouAction.reportActionID,
senderWorkspaceID,
receiver,
invoiceRoom: chatReport,
@@ -3582,10 +3588,25 @@ function sendInvoice(
companyName?: string,
companyWebsite?: string,
) {
- const {senderWorkspaceID, receiver, invoiceRoom, createdChatReportActionID, invoiceReportID, reportPreviewReportActionID, transactionID, transactionThreadReportID, onyxData} =
- getSendInvoiceInformation(transaction, currentUserAccountID, invoiceChatReport, receiptFile, policy, policyTagList, policyCategories, companyName, companyWebsite);
+ const {
+ senderWorkspaceID,
+ receiver,
+ invoiceRoom,
+ createdChatReportActionID,
+ invoiceReportID,
+ reportPreviewReportActionID,
+ transactionID,
+ transactionThreadReportID,
+ createdIOUReportActionID,
+ createdReportActionIDForThread,
+ reportActionID,
+ onyxData,
+ } = getSendInvoiceInformation(transaction, currentUserAccountID, invoiceChatReport, receiptFile, policy, policyTagList, policyCategories, companyName, companyWebsite);
const parameters: SendInvoiceParams = {
+ createdIOUReportActionID,
+ createdReportActionIDForThread,
+ reportActionID,
senderWorkspaceID,
accountID: currentUserAccountID,
amount: transaction?.amount ?? 0,
diff --git a/src/libs/actions/PersistedRequests.ts b/src/libs/actions/PersistedRequests.ts
index 310ae2ea3e0a..18d66ee9ccb7 100644
--- a/src/libs/actions/PersistedRequests.ts
+++ b/src/libs/actions/PersistedRequests.ts
@@ -25,21 +25,9 @@ function getLength(): number {
}
function save(requestToPersist: Request) {
- if (keepLastInstance.includes(requestToPersist.command)) {
- // Find the index of an existing request with the same command
- const index = persistedRequests.findIndex((request) => request.command === requestToPersist.command);
+ // If the command is not in the keepLastInstance array, add the new request as usual
+ persistedRequests = [...persistedRequests, requestToPersist];
- if (index !== -1) {
- // If found, update the existing request with the new one
- persistedRequests[index] = requestToPersist;
- } else {
- // If not found, add the new request
- persistedRequests.push(requestToPersist);
- }
- } else {
- // If the command is not in the keepLastInstance array, add the new request as usual
- persistedRequests = [...persistedRequests, requestToPersist];
- }
Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests).then(() => {
Log.info(`[SequentialQueue] '${requestToPersist.command}' command queued. Queue length is ${getLength()}`);
});
diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts
index d0913a2b0ac1..19585a5e69c5 100644
--- a/src/libs/actions/Policy/Policy.ts
+++ b/src/libs/actions/Policy/Policy.ts
@@ -42,6 +42,7 @@ import type {
UpdateWorkspaceGeneralSettingsParams,
UpgradeToCorporateParams,
} from '@libs/API/parameters';
+import type SetPolicyRulesEnabledParams from '@libs/API/parameters/SetPolicyRulesEnabledParams';
import type UpdatePolicyAddressParams from '@libs/API/parameters/UpdatePolicyAddressParams';
import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import DateUtils from '@libs/DateUtils';
@@ -1038,6 +1039,9 @@ function updateGeneralSettings(policyID: string, name: string, currencyValue?: s
const customUnitID = distanceUnit?.customUnitID;
const currency = currencyValue ?? policy?.outputCurrency ?? CONST.CURRENCY.USD;
+ const currencyPendingAction = currency !== policy?.outputCurrency ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : undefined;
+ const namePendingAction = name !== policy?.name ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : undefined;
+
const currentRates = distanceUnit?.rates ?? {};
const optimisticRates: Record = {};
const finallyRates: Record = {};
@@ -1073,12 +1077,14 @@ function updateGeneralSettings(policyID: string, name: string, currencyValue?: s
pendingFields: {
...policy.pendingFields,
- generalSettings: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ ...(namePendingAction !== undefined && {name: namePendingAction}),
+ ...(currencyPendingAction !== undefined && {outputCurrency: currencyPendingAction}),
},
// Clear errorFields in case the user didn't dismiss the general settings error
errorFields: {
- generalSettings: null,
+ name: null,
+ outputCurrency: null,
},
name,
outputCurrency: currency,
@@ -1099,7 +1105,8 @@ function updateGeneralSettings(policyID: string, name: string, currencyValue?: s
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
pendingFields: {
- generalSettings: null,
+ name: null,
+ outputCurrency: null,
},
...(customUnitID && {
customUnits: {
@@ -1113,14 +1120,20 @@ function updateGeneralSettings(policyID: string, name: string, currencyValue?: s
},
];
+ const errorFields: Policy['errorFields'] = {
+ name: namePendingAction && ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'),
+ };
+
+ if (!errorFields.name && currencyPendingAction) {
+ errorFields.outputCurrency = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage');
+ }
+
const failureData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
- errorFields: {
- generalSettings: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'),
- },
+ errorFields,
...(customUnitID && {
customUnits: {
[customUnitID]: {
@@ -1280,12 +1293,29 @@ function updateAddress(policyID: string, newAddress: CompanyAddress) {
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
address: newAddress,
+ pendingFields: {
+ address: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ },
+ },
+ ];
+
+ const finallyData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ address: newAddress,
+ pendingFields: {
+ address: null,
+ },
},
},
];
API.write(WRITE_COMMANDS.UPDATE_POLICY_ADDRESS, parameters, {
optimisticData,
+ finallyData,
});
}
@@ -1597,7 +1627,9 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName
autoReporting: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
approvalMode: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
reimbursementChoice: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
- generalSettings: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ name: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ outputCurrency: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ address: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
description: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
},
},
@@ -1674,6 +1706,9 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName
autoReporting: null,
approvalMode: null,
reimbursementChoice: null,
+ name: null,
+ outputCurrency: null,
+ address: null,
},
},
},
@@ -2940,6 +2975,68 @@ function enablePolicyWorkflows(policyID: string, enabled: boolean) {
}
}
+const DISABLED_MAX_EXPENSE_VALUES: Pick = {
+ maxExpenseAmountNoReceipt: CONST.DISABLED_MAX_EXPENSE_VALUE,
+ maxExpenseAmount: CONST.DISABLED_MAX_EXPENSE_VALUE,
+ maxExpenseAge: CONST.DISABLED_MAX_EXPENSE_VALUE,
+};
+
+function enablePolicyRules(policyID: string, enabled: boolean, disableRedirect = false) {
+ const policy = getPolicy(policyID);
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ areRulesEnabled: enabled,
+ ...(!enabled ? DISABLED_MAX_EXPENSE_VALUES : {}),
+ pendingFields: {
+ areRulesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ pendingFields: {
+ areRulesEnabled: null,
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ areRulesEnabled: !enabled,
+ ...(!enabled
+ ? {
+ maxExpenseAmountNoReceipt: policy?.maxExpenseAmountNoReceipt,
+ maxExpenseAmount: policy?.maxExpenseAmount,
+ maxExpenseAge: policy?.maxExpenseAge,
+ }
+ : {}),
+ pendingFields: {
+ areRulesEnabled: null,
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters: SetPolicyRulesEnabledParams = {policyID, enabled};
+ API.write(WRITE_COMMANDS.SET_POLICY_RULES_ENABLED, parameters, onyxData);
+
+ if (enabled && getIsNarrowLayout() && !disableRedirect) {
+ navigateWhenEnableFeature(policyID);
+ }
+}
+
function enableDistanceRequestTax(policyID: string, customUnitName: string, customUnitID: string, attributes: Attributes) {
const policy = getPolicy(policyID);
const onyxData: OnyxData = {
@@ -3367,6 +3464,7 @@ export {
getAdminPoliciesConnectedToNetSuite,
getAdminPoliciesConnectedToSageIntacct,
hasInvoicingDetails,
+ enablePolicyRules,
};
export type {NewCustomUnit};
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index c06e6087d336..b687abd61bb9 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -1001,6 +1001,7 @@ function handleExitToNavigation(exitTo: Route | HybridAppRoute) {
waitForUserSignIn().then(() => {
Navigation.waitForProtectedRoutes().then(() => {
const url = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : (exitTo as Route);
+ Navigation.goBack();
Navigation.navigate(url);
});
});
diff --git a/src/pages/ReimbursementAccount/EnableBankAccount/EnableBankAccount.tsx b/src/pages/ReimbursementAccount/EnableBankAccount/EnableBankAccount.tsx
index 8687ff89ba62..599a7a1cf6f1 100644
--- a/src/pages/ReimbursementAccount/EnableBankAccount/EnableBankAccount.tsx
+++ b/src/pages/ReimbursementAccount/EnableBankAccount/EnableBankAccount.tsx
@@ -42,7 +42,7 @@ function EnableBankAccount({reimbursementAccount, user, onBackButtonPress}: Enab
const achData = reimbursementAccount?.achData ?? {};
const {icon, iconSize} = getBankIcon({bankName: achData.bankName, styles});
const isUsingExpensifyCard = user?.isUsingExpensifyCard;
- const formattedBankAccountNumber = achData.accountNumber ? `${translate('paymentMethodList.accountLastFour')} ${achData.accountNumber.slice(-4)}` : '';
+ const formattedBankAccountNumber = achData.accountNumber ? `${translate('bankAccount.accountEnding')} ${achData.accountNumber.slice(-4)}` : '';
const bankAccountOwnerName = achData.addressName;
const errors = reimbursementAccount?.errors ?? {};
const pendingAction = reimbursementAccount?.pendingAction;
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
index 175d1b2d56ac..db7e482a0457 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
@@ -640,7 +640,6 @@ const ContextMenuActions: ContextMenuAction[] = [
];
const restrictedReadOnlyActions: TranslationPaths[] = [
- 'common.download',
'reportActionContextMenu.replyInThread',
'reportActionContextMenu.editAction',
'reportActionContextMenu.joinThread',
diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
index 005824fa949f..da7b4f72bfb6 100644
--- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
+++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
@@ -1,4 +1,5 @@
import {useNavigation} from '@react-navigation/native';
+import noop from 'lodash/noop';
import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputFocusEventData, TextInputSelectionChangeEventData} from 'react-native';
import {View} from 'react-native';
@@ -31,6 +32,7 @@ import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import {getDraftComment} from '@libs/DraftCommentUtils';
import getModalState from '@libs/getModalState';
+import Performance from '@libs/Performance';
import * as ReportUtils from '@libs/ReportUtils';
import playSound, {SOUNDS} from '@libs/Sound';
import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside';
@@ -116,6 +118,9 @@ const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc();
+// eslint-disable-next-line import/no-mutable-exports
+let onSubmitAction = noop;
+
function ReportActionCompose({
blockedFromConcierge,
currentUserPersonalDetails,
@@ -309,6 +314,7 @@ function ReportActionCompose({
Report.addAttachment(reportID, attachmentFileRef.current, newCommentTrimmed);
attachmentFileRef.current = null;
} else {
+ Performance.markStart(CONST.TIMING.MESSAGE_SENT, {message: newCommentTrimmed});
onSubmit(newCommentTrimmed);
}
},
@@ -390,6 +396,9 @@ function ReportActionCompose({
clearComposer();
}, [isSendDisabled, isReportReadyForDisplay, composerRefShared]);
+ // eslint-disable-next-line react-compiler/react-compiler
+ onSubmitAction = handleSendMessage;
+
const emojiShiftVertical = useMemo(() => {
const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom;
const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight;
@@ -594,5 +603,5 @@ export default withCurrentUserPersonalDetails(
},
})(memo(ReportActionCompose)),
);
-
+export {onSubmitAction};
export type {SuggestionsRef, ComposerRef};
diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx
index 68827de96172..9d1a4d9ad4b7 100644
--- a/src/pages/home/report/comment/TextCommentFragment.tsx
+++ b/src/pages/home/report/comment/TextCommentFragment.tsx
@@ -1,6 +1,6 @@
import {Str} from 'expensify-common';
import {isEmpty} from 'lodash';
-import React, {memo} from 'react';
+import React, {memo, useEffect} from 'react';
import type {StyleProp, TextStyle} from 'react-native';
import Text from '@components/Text';
import ZeroWidthView from '@components/ZeroWidthView';
@@ -11,6 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import convertToLTR from '@libs/convertToLTR';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import * as EmojiUtils from '@libs/EmojiUtils';
+import Performance from '@libs/Performance';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type {OriginalMessageSource} from '@src/types/onyx/OriginalMessage';
@@ -48,6 +49,10 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so
const {translate} = useLocalize();
const {shouldUseNarrowLayout} = useResponsiveLayout();
+ useEffect(() => {
+ Performance.markEnd(CONST.TIMING.MESSAGE_SENT, {message: text});
+ }, [text]);
+
// 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
diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
index 7fbc8d260f8a..552ad4d54e39 100644
--- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx
+++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
@@ -4,6 +4,7 @@ import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import FormHelpMessage from '@components/FormHelpMessage';
import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
import useThemeStyles from '@hooks/useThemeStyles';
import {READ_COMMANDS} from '@libs/API/types';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
@@ -45,6 +46,7 @@ function IOURequestStepParticipants({
const {translate} = useLocalize();
const styles = useThemeStyles();
const isFocused = useIsFocused();
+ const {canUseP2PDistanceRequests} = usePermissions(iouType);
// We need to set selectedReportID if user has navigated back from confirmation page and navigates to confirmation page with already selected participant
const selectedReportID = useRef(participants?.length === 1 ? participants[0]?.reportID ?? reportID : reportID);
@@ -93,7 +95,7 @@ function IOURequestStepParticipants({
HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_REPORTS);
const firstParticipantReportID = val[0]?.reportID ?? '';
- const rateID = DistanceRequestUtils.getCustomUnitRateID(firstParticipantReportID);
+ const rateID = DistanceRequestUtils.getCustomUnitRateID(firstParticipantReportID, !canUseP2PDistanceRequests);
const isInvoice = iouType === CONST.IOU.TYPE.INVOICE && ReportUtils.isInvoiceRoomWithID(firstParticipantReportID);
numberOfParticipants.current = val.length;
@@ -110,7 +112,7 @@ function IOURequestStepParticipants({
// When a participant is selected, the reportID needs to be saved because that's the reportID that will be used in the confirmation step.
selectedReportID.current = firstParticipantReportID || reportID;
},
- [iouType, reportID, transactionID],
+ [iouType, reportID, transactionID, canUseP2PDistanceRequests],
);
const goToNextStep = useCallback(() => {
diff --git a/src/pages/settings/Subscription/RequestEarlyCancellationPage/index.tsx b/src/pages/settings/Subscription/RequestEarlyCancellationPage/index.tsx
index ab148f8d8edc..d74e126ff94e 100644
--- a/src/pages/settings/Subscription/RequestEarlyCancellationPage/index.tsx
+++ b/src/pages/settings/Subscription/RequestEarlyCancellationPage/index.tsx
@@ -107,6 +107,7 @@ function RequestEarlyCancellationPage() {
footerText={{acknowledgementText}}
isNoteRequired
isLoading={isLoading}
+ enabledWhenOffline={false}
/>
),
[acknowledgementText, isLoading, styles.flex1, styles.mb2, styles.mt4, translate],
diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx
index 115a24691838..76ef67bdb0f0 100644
--- a/src/pages/workspace/WorkspaceInitialPage.tsx
+++ b/src/pages/workspace/WorkspaceInitialPage.tsx
@@ -16,6 +16,7 @@ import ScrollView from '@components/ScrollView';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
+import usePermissions from '@hooks/usePermissions';
import usePrevious from '@hooks/usePrevious';
import useSingleExecution from '@hooks/useSingleExecution';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -64,7 +65,8 @@ type WorkspaceMenuItem = {
| typeof SCREENS.WORKSPACE.PROFILE
| typeof SCREENS.WORKSPACE.MEMBERS
| typeof SCREENS.WORKSPACE.EXPENSIFY_CARD
- | typeof SCREENS.WORKSPACE.REPORT_FIELDS;
+ | typeof SCREENS.WORKSPACE.REPORT_FIELDS
+ | typeof SCREENS.WORKSPACE.RULES;
};
type WorkspaceInitialPageOnyxProps = {
@@ -99,6 +101,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc
const activeRoute = useNavigationState(getTopmostRouteName);
const {translate} = useLocalize();
const {isOffline} = useNetwork();
+ const {canUseWorkspaceRules} = usePermissions();
const wasRendered = useRef(false);
const prevPendingFields = usePrevious(policy?.pendingFields);
@@ -112,6 +115,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc
[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,
+ [CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED]: policy?.areRulesEnabled,
}),
[policy],
) as PolicyFeatureStates;
@@ -162,7 +166,11 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc
const hasMembersError = PolicyUtils.hasEmployeeListError(policy);
const hasPolicyCategoryError = PolicyUtils.hasPolicyCategoriesError(policyCategories);
- const hasGeneralSettingsError = !isEmptyObject(policy?.errorFields?.generalSettings ?? {}) || !isEmptyObject(policy?.errorFields?.avatarURL ?? {});
+ const hasGeneralSettingsError =
+ !isEmptyObject(policy?.errorFields?.name ?? {}) ||
+ !isEmptyObject(policy?.errorFields?.avatarURL ?? {}) ||
+ !isEmptyObject(policy?.errorFields?.ouputCurrency ?? {}) ||
+ !isEmptyObject(policy?.errorFields?.address ?? {});
const {login} = useCurrentUserPersonalDetails();
const shouldShowProtectedItems = PolicyUtils.isPolicyAdmin(policy, login);
const isPaidGroupPolicy = PolicyUtils.isPaidGroupPolicy(policy);
@@ -306,6 +314,15 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc
});
}
+ if (featureStates?.[CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED] && canUseWorkspaceRules) {
+ protectedCollectPolicyMenuItems.push({
+ translationKey: 'workspace.common.rules',
+ icon: Expensicons.Feed,
+ action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_RULES.getRoute(policyID)))),
+ routeName: SCREENS.WORKSPACE.RULES,
+ });
+ }
+
protectedCollectPolicyMenuItems.push({
translationKey: 'workspace.common.moreFeatures',
icon: Expensicons.Gear,
diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx
index 47af86b53315..d33a83c4363c 100644
--- a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx
+++ b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx
@@ -61,7 +61,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {translate} = useLocalize();
- const {canUseWorkspaceFeeds} = usePermissions();
+ const {canUseWorkspaceFeeds, canUseWorkspaceRules} = usePermissions();
const hasAccountingConnection = !isEmptyObject(policy?.connections);
const isAccountingEnabled = !!policy?.areConnectionsEnabled || !isEmptyObject(policy?.connections);
const isSyncTaxEnabled =
@@ -90,19 +90,6 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro
DistanceRate.enablePolicyDistanceRates(policyID, isEnabled);
},
},
- {
- icon: Illustrations.Workflows,
- titleTranslationKey: 'workspace.moreFeatures.workflows.title',
- subtitleTranslationKey: 'workspace.moreFeatures.workflows.subtitle',
- isActive: policy?.areWorkflowsEnabled ?? false,
- pendingAction: policy?.pendingFields?.areWorkflowsEnabled,
- action: (isEnabled: boolean) => {
- if (!policyID) {
- return;
- }
- Policy.enablePolicyWorkflows(policyID, isEnabled);
- },
- },
];
// TODO remove this when feature will be fully done, and move spend item inside spendItems array
@@ -126,6 +113,44 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro
});
}
+ const manageItems: Item[] = [
+ {
+ icon: Illustrations.Workflows,
+ titleTranslationKey: 'workspace.moreFeatures.workflows.title',
+ subtitleTranslationKey: 'workspace.moreFeatures.workflows.subtitle',
+ isActive: policy?.areWorkflowsEnabled ?? false,
+ pendingAction: policy?.pendingFields?.areWorkflowsEnabled,
+ action: (isEnabled: boolean) => {
+ if (!policyID) {
+ return;
+ }
+ Policy.enablePolicyWorkflows(policyID, isEnabled);
+ },
+ },
+ ];
+
+ // TODO remove this when feature will be fully done, and move manage item inside manageItems array
+ if (canUseWorkspaceRules) {
+ manageItems.splice(1, 0, {
+ icon: Illustrations.Rules,
+ titleTranslationKey: 'workspace.moreFeatures.rules.title',
+ subtitleTranslationKey: 'workspace.moreFeatures.rules.subtitle',
+ isActive: policy?.areRulesEnabled ?? false,
+ pendingAction: policy?.pendingFields?.areRulesEnabled,
+ action: (isEnabled: boolean) => {
+ if (!policyID) {
+ return;
+ }
+
+ if (isEnabled && !isControlPolicy(policy)) {
+ Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.alias, ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)));
+ return;
+ }
+ Policy.enablePolicyRules(policyID, isEnabled);
+ },
+ });
+ }
+
const earnItems: Item[] = [
{
icon: Illustrations.InvoiceBlue,
@@ -262,6 +287,11 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro
subtitleTranslationKey: 'workspace.moreFeatures.spendSection.subtitle',
items: spendItems,
},
+ {
+ titleTranslationKey: 'workspace.moreFeatures.manageSection.title',
+ subtitleTranslationKey: 'workspace.moreFeatures.manageSection.subtitle',
+ items: manageItems,
+ },
{
titleTranslationKey: 'workspace.moreFeatures.earnSection.title',
subtitleTranslationKey: 'workspace.moreFeatures.earnSection.subtitle',
diff --git a/src/pages/workspace/WorkspaceProfilePage.tsx b/src/pages/workspace/WorkspaceProfilePage.tsx
index 025cc9587bf6..8cd5c7c1127b 100644
--- a/src/pages/workspace/WorkspaceProfilePage.tsx
+++ b/src/pages/workspace/WorkspaceProfilePage.tsx
@@ -36,8 +36,8 @@ import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
-import withPolicy from './withPolicy';
import type {WithPolicyProps} from './withPolicy';
+import withPolicy from './withPolicy';
import WorkspacePageWithSections from './WorkspacePageWithSections';
type WorkspaceProfilePageOnyxProps = {
@@ -196,7 +196,7 @@ function WorkspaceProfilePage({policyDraft, policy: policyProp, currencyList = {
disabledStyle={styles.cursorDefault}
errorRowStyles={styles.mt3}
/>
-
+
)}
Policy.clearPolicyErrorField(policy?.id ?? '-1', CONST.POLICY.COLLECTION_KEYS.GENERAL_SETTINGS)}
errorRowStyles={[styles.mt2]}
@@ -249,7 +249,7 @@ function WorkspaceProfilePage({policyDraft, policy: policyProp, currencyList = {
{canUseSpotnanaTravel && shouldShowAddress && (
-
+ item && toggleValue(item)}
sections={listValuesSections}
onCheckboxPress={toggleValue}
diff --git a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx
index d4cc5a65e272..872946905974 100644
--- a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx
+++ b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx
@@ -57,7 +57,7 @@ function WorkspaceReportFieldsPage({
params: {policyID},
},
}: WorkspaceReportFieldsPageProps) {
- const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
const styles = useThemeStyles();
const theme = useTheme();
const {translate} = useLocalize();
@@ -78,7 +78,7 @@ function WorkspaceReportFieldsPage({
const isConnectedToAccounting = Object.keys(policy?.connections ?? {}).length > 0;
const currentConnectionName = PolicyUtils.getCurrentConnectionName(policy);
- const canSelectMultiple = !hasAccountingConnections && shouldUseNarrowLayout ? selectionMode?.isEnabled : true;
+ const canSelectMultiple = !hasAccountingConnections && (isSmallScreenWidth ? selectionMode?.isEnabled : true);
const fetchReportFields = useCallback(() => {
ReportField.openPolicyReportFieldsPage(policyID);
@@ -297,7 +297,7 @@ function WorkspaceReportFieldsPage({
{!shouldShowEmptyState && !isLoading && (
item && updateSelectedReportFields(item)}
sections={reportFieldsSections}
onCheckboxPress={updateSelectedReportFields}
diff --git a/src/pages/workspace/rules/PolicyRulesPage.tsx b/src/pages/workspace/rules/PolicyRulesPage.tsx
new file mode 100644
index 000000000000..ec7cdffb8df5
--- /dev/null
+++ b/src/pages/workspace/rules/PolicyRulesPage.tsx
@@ -0,0 +1,106 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React from 'react';
+import {View} from 'react-native';
+import Section from '@components/Section';
+import Text from '@components/Text';
+import TextLink from '@components/TextLink';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import usePolicy from '@hooks/usePolicy';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import type {FullScreenNavigatorParamList} from '@libs/Navigation/types';
+import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections';
+import * as Illustrations from '@src/components/Icon/Illustrations';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type PolicyRulesPageProps = StackScreenProps;
+
+function PolicyRulesPage({route}: PolicyRulesPageProps) {
+ const {translate} = useLocalize();
+ const {policyID} = route.params;
+ const policy = usePolicy(policyID);
+ const styles = useThemeStyles();
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {canUseWorkspaceRules} = usePermissions();
+
+ const handleOnPressCategoriesLink = () => {
+ if (policy?.areCategoriesEnabled) {
+ Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID));
+ return;
+ }
+
+ Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID));
+ };
+
+ const handleOnPressTagsLink = () => {
+ if (policy?.areTagsEnabled) {
+ Navigation.navigate(ROUTES.WORKSPACE_TAGS.getRoute(policyID));
+ return;
+ }
+
+ Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID));
+ };
+
+ return (
+
+
+
+ (
+
+ {translate('workspace.rules.individualExpenseRules.subtitle')}{' '}
+
+ {translate('workspace.common.categories').toLowerCase()}
+ {' '}
+ {translate('common.and')}{' '}
+
+ {translate('workspace.common.tags').toLowerCase()}
+
+ .
+
+ )}
+ subtitle={translate('workspace.rules.individualExpenseRules.subtitle')}
+ titleStyles={styles.accountSettingsSectionTitle}
+ />
+
+
+
+
+ );
+}
+
+PolicyRulesPage.displayName = 'PolicyRulesPage';
+
+export default PolicyRulesPage;
diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx
index 0837ac164600..bb4bdff27097 100644
--- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx
+++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx
@@ -47,6 +47,9 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) {
case CONST.UPGRADE_FEATURE_INTRO_MAPPING.reportFields.id:
Policy.enablePolicyReportFields(policyID, true, true);
return Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID));
+ case CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.id:
+ Policy.enablePolicyRules(policyID, true, true);
+ return Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID));
default:
return route.params.backTo ? Navigation.navigate(route.params.backTo) : Navigation.goBack();
}
diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx
index dbd7a7cb3a58..91f069ac2224 100644
--- a/src/pages/workspace/withPolicy.tsx
+++ b/src/pages/workspace/withPolicy.tsx
@@ -45,6 +45,7 @@ type PolicyRoute = RouteProp<
| typeof SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE
| typeof SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS
| typeof SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION
+ | typeof SCREENS.WORKSPACE.RULES
>;
function getPolicyIDFromRoute(route: PolicyRoute): string {
diff --git a/src/styles/index.ts b/src/styles/index.ts
index fe65e48bc4a1..9f93c799abb5 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -3777,7 +3777,7 @@ const styles = (theme: ThemeColors) =>
paymentMethod: {
paddingHorizontal: 20,
- height: variables.optionRowHeight,
+ minHeight: variables.optionRowHeight,
},
chatFooterBanner: {
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index 8f48205d8749..9bac5f2e4de4 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -1510,6 +1510,9 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback<
/** Whether the workflows feature is enabled */
areWorkflowsEnabled?: boolean;
+ /** Whether the reules feature is enabled */
+ areRulesEnabled?: boolean;
+
/** Whether the Report Fields feature is enabled */
areReportFieldsEnabled?: boolean;
@@ -1555,7 +1558,7 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback<
/** Workspace account ID configured for Expensify Card */
workspaceAccountID?: number;
} & Partial,
- 'generalSettings' | 'addWorkspaceRoom' | 'employeeList' | keyof ACHAccount | keyof Attributes
+ 'addWorkspaceRoom' | 'employeeList' | keyof ACHAccount | keyof Attributes
>;
/** Stages of policy connection sync */
diff --git a/src/types/onyx/Request.ts b/src/types/onyx/Request.ts
index 1f3f788a8418..0b583422f738 100644
--- a/src/types/onyx/Request.ts
+++ b/src/types/onyx/Request.ts
@@ -56,7 +56,58 @@ type RequestData = {
};
/** Model of requests sent to the API */
-type Request = RequestData & OnyxData;
+type Request = RequestData & OnyxData & RequestConflictResolver;
+
+/**
+ * Model of a conflict request that has to be updated, in the request queue.
+ */
+type ConflictRequestUpdate = {
+ /**
+ * The action to take in case of a conflict.
+ */
+ type: 'update';
+
+ /**
+ * The index of the request in the queue to update.
+ */
+ index: number;
+};
+
+/**
+ * Model of a conflict request that has to be saved, in the request queue.
+ */
+type ConflictRequestSave = {
+ /**
+ * The action to take in case of a conflict.
+ */
+ type: 'save';
+};
+
+/**
+ * An object that has the request and the action to take in case of a conflict.
+ */
+type ConflictActionData = {
+ /**
+ * The request that is conflicting with the new request.
+ */
+ request: Request;
+
+ /**
+ * The action to take in case of a conflict.
+ */
+ conflictAction: ConflictRequestUpdate | ConflictRequestSave;
+};
+
+/**
+ * An object that describes how a new write request can identify any queued requests that may conflict with or be undone by the new request,
+ * and how to resolve those conflicts.
+ */
+type RequestConflictResolver = {
+ /**
+ * A function that checks if a new request conflicts with any existing requests in the queue.
+ */
+ checkAndFixConflictingRequest?: (persistedRequest: Request[], request: Request) => ConflictActionData;
+};
/**
* An object used to describe how a request can be paginated.
@@ -85,4 +136,4 @@ type PaginatedRequest = Request &
};
export default Request;
-export type {OnyxData, RequestType, PaginationConfig, PaginatedRequest};
+export type {OnyxData, RequestType, PaginationConfig, PaginatedRequest, RequestConflictResolver};
diff --git a/tests/e2e/config.ts b/tests/e2e/config.ts
index 8963e07c31c8..bdb24bee0bc5 100644
--- a/tests/e2e/config.ts
+++ b/tests/e2e/config.ts
@@ -83,6 +83,7 @@ export default {
},
// Crowded Policy (Do Not Delete) Report, has a input bar available:
reportID: '8268282951170052',
+ message: `Measure_performance#${Math.floor(Math.random() * 1000000)}`,
},
[TEST_NAMES.ChatOpening]: {
name: TEST_NAMES.ChatOpening,
diff --git a/tests/e2e/nativeCommands/adbBackspace.ts b/tests/e2e/nativeCommands/adbBackspace.ts
index 2891d1daf0e9..112a80bdb37e 100644
--- a/tests/e2e/nativeCommands/adbBackspace.ts
+++ b/tests/e2e/nativeCommands/adbBackspace.ts
@@ -1,10 +1,9 @@
import execAsync from '../utils/execAsync';
import * as Logger from '../utils/logger';
-const adbBackspace = () => {
+const adbBackspace = (): Promise => {
Logger.log(`🔙 Pressing backspace`);
- execAsync(`adb shell input keyevent KEYCODE_DEL`);
- return true;
+ return execAsync(`adb shell input keyevent KEYCODE_DEL`).then(() => true);
};
export default adbBackspace;
diff --git a/tests/e2e/nativeCommands/adbTypeText.ts b/tests/e2e/nativeCommands/adbTypeText.ts
index 72fefbd25d26..deea16b198c8 100644
--- a/tests/e2e/nativeCommands/adbTypeText.ts
+++ b/tests/e2e/nativeCommands/adbTypeText.ts
@@ -3,8 +3,7 @@ import * as Logger from '../utils/logger';
const adbTypeText = (text: string) => {
Logger.log(`📝 Typing text: ${text}`);
- execAsync(`adb shell input text "${text}"`);
- return true;
+ return execAsync(`adb shell input text "${text}"`).then(() => true);
};
export default adbTypeText;
diff --git a/tests/e2e/nativeCommands/index.ts b/tests/e2e/nativeCommands/index.ts
index 31af618c8ec1..310aa2ab3c22 100644
--- a/tests/e2e/nativeCommands/index.ts
+++ b/tests/e2e/nativeCommands/index.ts
@@ -4,7 +4,7 @@ import adbTypeText from './adbTypeText';
// eslint-disable-next-line rulesdir/prefer-import-module-contents
import {NativeCommandsAction} from './NativeCommandsAction';
-const executeFromPayload = (actionName?: string, payload?: NativeCommandPayload): boolean => {
+const executeFromPayload = (actionName?: string, payload?: NativeCommandPayload): Promise => {
switch (actionName) {
case NativeCommandsAction.scroll:
throw new Error('Not implemented yet');
diff --git a/tests/e2e/server/index.ts b/tests/e2e/server/index.ts
index 494b8b4644e1..512492908049 100644
--- a/tests/e2e/server/index.ts
+++ b/tests/e2e/server/index.ts
@@ -169,8 +169,8 @@ const createServerInstance = (): ServerInstance => {
case Routes.testNativeCommand: {
getPostJSONRequestData(req, res)
- ?.then((data) => {
- const status = nativeCommands.executeFromPayload(data?.actionName, data?.payload);
+ ?.then((data) => nativeCommands.executeFromPayload(data?.actionName, data?.payload))
+ .then((status) => {
if (status) {
res.end('ok');
return;
diff --git a/tests/perf-test/ReportActionCompose.perf-test.tsx b/tests/perf-test/ReportActionCompose.perf-test.tsx
index e3aaccd1f050..f8f3ce499218 100644
--- a/tests/perf-test/ReportActionCompose.perf-test.tsx
+++ b/tests/perf-test/ReportActionCompose.perf-test.tsx
@@ -1,5 +1,5 @@
import {fireEvent, screen} from '@testing-library/react-native';
-import type {ComponentType} from 'react';
+import type {ComponentType, EffectCallback} from 'react';
import React from 'react';
import Onyx from 'react-native-onyx';
import type Animated from 'react-native-reanimated';
@@ -36,6 +36,7 @@ jest.mock('@react-navigation/native', () => {
}),
useIsFocused: () => true,
useNavigationState: () => {},
+ useFocusEffect: (cb: EffectCallback) => cb(),
};
});
diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts
index b8640e4ecdf1..04a5b3babd5e 100644
--- a/tests/unit/IOUUtilsTest.ts
+++ b/tests/unit/IOUUtilsTest.ts
@@ -114,6 +114,24 @@ describe('IOUUtils', () => {
expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 100, 'BHD')).toBe(33);
});
});
+
+ describe('insertTagIntoTransactionTagsString', () => {
+ test('Inserting a tag into tag string should update the tag', () => {
+ expect(IOUUtils.insertTagIntoTransactionTagsString(':NY:Texas', 'California', 2)).toBe(':NY:California');
+ });
+
+ test('Inserting a tag into an index with no tags should update the tag', () => {
+ expect(IOUUtils.insertTagIntoTransactionTagsString('::California', 'NY', 1)).toBe(':NY:California');
+ });
+
+ test('Inserting a tag with colon in name into tag string should keep the colon in tag', () => {
+ expect(IOUUtils.insertTagIntoTransactionTagsString('East:NY:California', 'City \\: \\:', 1)).toBe('East:City \\: \\::California');
+ });
+
+ test('Remove a tag from tagString', () => {
+ expect(IOUUtils.insertTagIntoTransactionTagsString('East:City \\: \\::California', '', 1)).toBe('East::California');
+ });
+ });
});
describe('isValidMoneyRequestType', () => {